valis / Understanding the system

The outbound seam: one door out of valis

Every connection valis originates to the world leaves through one verb. This document says what that seam is, why it exists, what its capability model guarantees, what it deliberately does not promise, and what the Linux underneath it decides on its own.

Read ARCHITECTURE.org for where the subsystems sit and CONDITION-HANDLING.org for the failure convention this seam is written to. This file is about how a valis node reaches anything that is not itself.

Why there is a seam at all

valis is designed to run arbitrary computations for its operator, which can include code that she did not self-author. Client code hosted inside an instance, reaching the world through the instance they are attached to, are a core feature of what this is for. That is what makes one managed egress a hard requirement. A subsystem this project wrote choosing its own socket implementation is untidy, and it can be found by reading the tree. Hosted client code reaching the network however it likes cannot be found that way at all, and between those two situations the only thing standing is an authority that has to be presented before a connection exists.

The defect that made the shape concrete was in valis's own code, and it is worth telling in full, because a reader debugging something similar at three in the morning will recognize it.

valis notifies its DNS secondaries the moment a zone is revised. Writing that path, a Fade opened a socket in the file, turned the recipient names into addresses with a library that was already loaded and close to hand, and wrote handling for whatever came back. Every one of those was a locally reasonable decision, the library was a respectable one, and the file passed its tests. Nothing available at the inflection point said otherwise.

On a live node it could resolve nothing at all. The node runs inside a network namespace; the resolver that library carries reads its own nameserver list and opens its own query socket, and inside that namespace it had no route to a nameserver. The failure surfaced as a complaint about polling a file descriptor, so nothing in it said the word "name".

Reading that as a bad choice of library gets it wrong, and so does resolving to be more careful next time. Under a deadline the nearest thing wins, and it should: the developer who adds the next outbound path will reach for whatever is closest, and so will code this project never saw. The design's job is not to forbid the shortcut but to leave it nothing to offer, so that reaching for what is nearest arrives at the thing that was going to be correct anyway and no one has to remember a rule. A subsystem that decides for itself how to reach the network is a second door, and a capability model with several ways out is decorative. So there is exactly one:

  • src/net/dial.lisp is the verb.
  • src/net/resolve.lisp is its first half, reachable from the verb and nowhere else.
  • src/net/policy.lisp decides whether a principal may dial a destination.
  • src/net/address-range.lisp says which special-purpose range an address lies in, so the verb can decide whether a connection may land there.
  • src/net/conditions.lisp is the failure vocabulary every outbound path speaks.
  • src/net/exemptions.lisp enumerates what is still permitted outside the seam, and why.
  • src/net/tripwire.lisp refuses a connection that skipped the seam anyway.

One verb, and the verb that does not exist

The verb is dial. There is no resolve. Not deprecated, not discouraged: absent, and the absence is asserted by a test over the package's exported symbols.

The reason is the sharpest single finding behind this design. Resolving in one place and connecting in another hides the query and publishes the destination. Picture the case that makes it concrete: a client in a dangerous place asks a valis node in another jurisdiction to look a name up, then opens the connection itself, from where it is standing. The lookup is now private and the connection is not. That is strictly worse than offering no service at all, because it looks like protection while removing none of the exposure, and a capability that can be held but is unsafe to use alone will eventually be used alone.

Resolution therefore happens only as the opening move of a dial that has already been permitted. Three absences hold the verb to it, and each is checkable rather than conventional:

  1. Nothing resolves without connecting.
  2. Nothing connects without a decision. The grant is the first required, positional argument, and the permission is evaluated inside the verb, so there is no arity that reaches a socket unchecked. A check applied by a wrapper above the primitive would leave a second way around it, taken by whichever future caller forgets the wrapper exists.
  3. No exported function answers an address.

A dial answers an integer file descriptor, the peer that answered, and its address family. A caller sharing this kernel takes the descriptor and talks to the peer itself, so valis leaves the data path and nothing costs per byte. A caller reached across the internet cannot be handed a descriptor at all, because a descriptor means something only inside the kernel that issued it; that case is admitted by the same decision and the same walk, with the stream re-framed onto the sealed transport rather than handed back. It is designed and not built.

Dialling is a capability, and grant width is the boundary

A dial requires a grant. A confined agent module and a person's browsing proxy are the same mechanism at different widths: one implementation, one audit surface, one place to look.

Because a destination that may not be reached is never looked up, a narrow grant closes name-based exfiltration as a side effect rather than as a countermeasure. Hostile code cannot smuggle a secret inside a hostname it is not already permitted to reach, because the query never happens.

The honest limit, stated so it is not later assumed away: that property belongs to narrow grants alone. A broad grant is by construction a general egress channel. That is the correct shape for a person browsing the web, and it means a compromised process holding one is exfiltrating freely. The property is not weakened by a wide grant; it does not apply. Grant width should track what a principal is doing now, not everything it might one day need.

The posture is default deny throughout. No grant, an unrecognized grant, and a destination the grant does not name are all refusals, and a refusal is signalled rather than returned, so it cannot be read past by a caller that ignores a value. A refusal by the grant also happens before any name is looked up, for the reason above: a caller told no must not first have made this node emit a query naming where it wanted to go. The one judgement that cannot be made before the lookup is where the answer lands, and that is the next section.

Where a dial may go, and who chose it

A grant says which destination a principal may name. It cannot say where that name leads. A name resolves to whatever its zone says, and the zone belongs to whoever publishes it. An exchanger in somebody's mail record, a host a module's publisher wrote into its grant, a nameserver found while walking somebody else's zones: each can point this node at its own loopback, at the private network it sits on, or at a cloud metadata service, as easily as at the public Internet. So the dial judges every address a destination resolves to by the range it lies in, each time it dials, before a socket exists for it.

Who chose the destination decides what it may reach

Every grant carries a mark saying who chose its destination.

  • You, the owner. Node code marks a grant as your choice only when it reads the destination from your own configuration: the certificate authority's directory host, the host your dependency source pointer names, the secondaries this node sends NOTIFY to, its own egress source address, which it dials to prove it holds that address before a notification leaves from it, and the primary exchanger a backup exchanger forwards mail to. Such a grant reaches any address except the floor below, so a secondary you run on your own private network is reached because you put it there. Nothing a module holds can add a host to that list: a module holding the zone-write grant can publish only a TXT record at an _acme-challenge name, so it cannot add a name to the set of secondaries.
  • Somebody else. Every other grant is marked as a third party's choice: every grant a vouched module is admitted with, broad or narrowed to one host, and the delegation check below. Such a grant reaches no special-purpose range unless you have allowed that range for the grant's client.

A grant is a third party's unless node code deliberately marks it as yours, so a grant built without thought carries the stricter mark. Every place that marks a grant as yours is listed in a test, so adding one means changing that test, where a reviewer sees it.

I drew the line at who chose, not at the range alone. A rule keyed only on ranges either refuses your own private secondaries or lets a stranger's zone steer this node into them. Keyed on the chooser, a destination node code reads from your configuration is yours to point wherever you like, and a destination somebody else handed in reaches only the public Internet until you say otherwise.

The primary a backup exchanger forwards mail to is one you configure, and the mail adapter module is the one that dials it. So the node, not the module, builds the grant for that forward: marked as your choice, issued to the client secondary-forward, narrowed to the primary you named and held to port 25, and handed to the adapter with the message. The adapter dials the forward through that grant rather than its own, which is marked as a third party's choice. A primary you run on your own private network is reached with no allowance, and the adapter can point the grant at no other host and no other port. The grant lasts for one forward: it is revoked when the adapter returns, and retiring the adapter's module takes it back sooner.

A primary at a floor address is refused, and so is a primary written as one of the words the dial reserves for a set of destinations, such as dns-secondaries. Either way the message stays queued for you to correct the primary. A refusal your adapter lets through is logged: the node's log (journalctl -u valis.service) carries a warning naming the entry and the refusal, once when the entry is first held and again if the reason changes.

Your choice is the name, not the address. A primary written as a host name reaches whatever its zone answers, loopback included, and a primary of 127.0.0.1 forwards to this node's own port 25 if it listens there, so the mail comes straight back. Name a primary that is not this node.

The floor

Five ranges are never dialled, whoever chose the destination, and no allowance you configure opens them:

Range What it is
0.0.0.0/32 the unspecified IPv4 address, which the kernel reads as this host
::/128 the unspecified IPv6 address, read the same way
224.0.0.0/4 IPv4 multicast
ff00::/8 IPv6 multicast
255.255.255.255/32 IPv4 limited broadcast

None of these names a single peer. A destination that resolves into one has a mistake in it, and the refusal says so: dial-address-in-floor, a kind of dial-address-not-permitted. A path still exempt from the dial check is the one way a connection reaches the floor: it is let through and logged, as the section on exempt paths describes.

The special-purpose ranges

The ranges below are refused for a third party's choice unless you allow it, and this table, with the floor above, is the whole of what the dial checks. It is drawn from the IANA special-purpose registries, with the multicast ranges and the IPv6 transition prefixes 6to4 and Teredo added. A registry entry that is a globally reachable service and lies outside every row, such as 192.31.196.0/24 and 192.175.48.0/24 for AS112, 192.52.193.0/24 for AMT, or 2620:4f:8000::/48, is dialled like any public address. Registry entries that lie inside a row, such as the anycast addresses in 192.0.0.0/24 and 2001::/23, are refused with it:

Range What it is RFC
0.0.0.0/8 this network RFC 791
10.0.0.0/8 private use RFC 1918
100.64.0.0/10 shared address space, carrier-grade NAT RFC 6598
127.0.0.0/8 loopback RFC 1122
169.254.0.0/16 link local, where cloud metadata services answer RFC 3927
172.16.0.0/12 private use RFC 1918
192.0.0.0/24 IETF protocol assignments RFC 6890
192.0.2.0/24 documentation RFC 5737
192.88.99.0/24 6to4 relay anycast RFC 7526
192.168.0.0/16 private use RFC 1918
198.18.0.0/15 benchmarking RFC 2544
198.51.100.0/24 documentation RFC 5737
203.0.113.0/24 documentation RFC 5737
240.0.0.0/4 reserved RFC 1112
::1/128 loopback RFC 4291
64:ff9b:1::/48 local-use IPv4/IPv6 translation RFC 8215
100::/64 discard only RFC 6666
100:0:0:1::/64 dummy prefix RFC 9780
2001::/23 IETF protocol assignments RFC 2928
2001::/32 Teredo RFC 4380
2001:2::/48 benchmarking RFC 5180
2001:10::/28 deprecated ORCHID RFC 4843
2001:db8::/32 documentation RFC 3849
2002::/16 6to4 RFC 3056
3fff::/20 documentation RFC 9637
5f00::/16 SRv6 SIDs RFC 9602
fc00::/7 unique local RFC 4193
fe80::/10 link local RFC 4291

An IPv6 address carrying an IPv4 one is judged by the IPv4 address it carries: the mapped form under ::ffff:0:0/96, the deprecated compatible form under ::/96 (apart from :: and ::1, which are IPv6's own), and the well-known NAT64 prefix 64:ff9b::/96. Without that, ::ffff:127.0.0.1 and ::7f00:1 would pass as ordinary IPv6 addresses while naming loopback. None of these three prefixes is refused as such: an address in one is refused or dialled according to the IPv4 address it carries. The well-known NAT64 prefix is unwrapped rather than refused whole because it is how an IPv6-only node behind DNS64 reaches IPv4 at all; the local-use prefix exists to reach private IPv4 networks, so it has a row of its own.

What a dial does with a refused address

The dial judges an address by the octets a socket would connect to, after the name is looked up and before a socket exists for it, so a name that answers differently on a second lookup cannot slip past the judgement. The dial passes over a refused address and tries the next: a destination with one public address and one private one connects to the public one, and nothing is ever sent toward the private one.

When no address connects, you get one condition, chosen by how much it says about the destination. A failure on the wire, such as a refusal or a timeout, comes first, because a peer that was tried says more than one that was not. A range refusal comes next, a floor one ahead of any other. A family this node has no route to, or will not open a socket for, comes last. A range refusal is dial-address-not-permitted, or dial-address-in-floor, and names the address, its range, and the grant's chooser and client, so you can tell a destination pointing somewhere this node will not go from one that does not exist. It is not a kind of dial-not-permitted, because that condition promises no name was looked up.

Each address passed over also signals dial-address-passed-over, a notice rather than an error, so a caller handling dial-error around a dial does not end the walk at the first refused address while a permitted one remains. An observer that wants to stop at the first refusal takes the abandon-dial restart. There is no restart that permits an address: only you widen what a dial reaches, through your configuration.

I made the node judge every connection as it is made and hold no judgement between dials, because what a name resolves to can change under it, and an allowance you narrow should apply to the next connection with nothing to expire. A connection already open is never interrupted, and a reconnect is judged again.

Allowing a range: net.dial-allow

You widen what a third party's choice may reach with the node key net.dial-allow, seeded from VALIS_NET_DIAL_ALLOW at the node's first boot. Its value is a list of client=range members separated by commas, one range to a member, with a client repeated for several ranges:

delegation-probe=192.168.140.0/22,example-client=10.0.0.0/8,example-client=fd00:1234:5678::/48
  • A range is written address/length from its first address. A malformed range, or one with a bit set past its prefix length, is refused.
  • A client is named in lower-case ASCII letters, digits, dots and hyphens, starting with a letter or a digit.
  • A range lying wholly inside the floor is refused, because nothing reaches the floor. A wider range covering it, such as 0.0.0.0/0, is accepted and allows only what lies outside the floor.
  • One refused member refuses the whole value, and nothing changes.
  • An allowance applies only to a grant marked as a third party's choice. A grant marked as yours needs none.
  • A running node reads the key as it makes each such connection, so a change applies to the next one. VALIS_NET_DIAL_ALLOW is read only at the first boot; after that, change the key with valis config.
  • A value stored under looser rules than the node now applies allows nothing at all, not the members that still pass, and the node logs config.allowance-refused once, naming the key and why. Set it again under the current rules to restore it. An allowance only ever widens, so I made a value the node no longer accepts allow nothing, rather than the half that still passes.

valis config set replaces the whole value. To add a member, read what is there and carry it over, joined by commas:

valis config get net.dial-allow
valis config set net.dial-allow 'delegation-probe=192.168.140.0/22,example-client=100.64.0.0/10'

valis config reset net.dial-allow returns the key to unset, which allows no client any range. On a deployed node, run these the way Changing a setting describes.

How a module gets a client name

A grant handed to a vouched module is issued to that module under its ASDF system name, exactly as you write it in the allowance: example-client=10.0.0.0/8 lets the module admitted as example-client reach that network, and no other module.

  • The allowance follows the system name, not the publisher. Whichever vouched module is admitted under that name carries it: a replacement, or another publisher's module admitted under the same name, inherits it. Look at the allowance again when you admit a module under a name that already has one.
  • A name an allowance cannot spell gets nothing. A module whose system name has an upper-case letter, a slash, or any other character outside the client-name rule is issued to no client, so no allowance reaches it and its dials keep to public addresses. I chose not to have the node rewrite a name to make it fit: ASDF tells Foo from foo and a/b from a.b, so any rewriting could hand one module's allowance to another.
  • The node's own client names are taken. A module named acme, delegation-probe, dist, secondary-forward or zone-notify is issued to no client, so an allowance you wrote for the node's own work never reaches a module that happens to share its name.

A grant held to its ports

A grant can be issued for particular ports, and then a dial to any other port is refused before anything is looked up, as dial-not-permitted with the cause :outside-grant-ports. The delegation check's grant is the one issued this way, held to port 53. A module's grant names no ports.

The delegation check and --allow-range

valis zone delegation walks the parent zones' nameservers down from the root, so every nameserver it asks was named by somebody else. Its grant is a third party's, issued to the client delegation-probe and held to port 53. A nameserver whose address lies in a special-purpose range is passed over unless delegation-probe is allowed that range.

The check reads the allowance from net.dial-allow: from the node's configuration on its host, or over the owner session when --keyfile, --endpoint, VALIS_KEYFILE or VALIS_MGMT_ENDPOINT names a node. --allow-range supplies one for a single run instead, in the same client=range form:

valis zone delegation --origin example.org --allow-range delegation-probe=192.168.140.0/22

The check holds --allow-range to the key's rules before it asks anything. When a member is refused, the run stops with exit status 2 and an error naming --allow-range, the member and why. Exit status 2 is also what the check returns when it could not establish anything, so a script should read the message, not only the status:

valis: zone delegation refused: --allow-range member "delegation-probe=192.168.140.1/22": a range is written from its first address, with no bit set past its prefix length

An allowance read from the node is different: when the key's rules refuse it, it allows nothing rather than stopping the run, as it does on the node itself, and the nameservers you meant to allow show up as passed over.

Mail to an exchanger in a refused range

A recipient domain's exchangers are named in its MX records, so they are a third party's choice. valis ships no mail adapter of its own: the module you install as the adapter dials the exchangers, and the node decides what becomes of the message from what the adapter's delivery reports. When that delivery signals dial-address-not-permitted, or its floor subclass, the node records the message as bounced, not deferred. I made that permanent because the domain points its mail somewhere this node will not go, and no retry changes that while the records and your allowance stay as they are. Widening the allowance afterwards does not recover a message already bounced.

What the node cannot see is which exchangers the adapter tried, so the rest is a contract the adapter keeps. It tries every exchanger and never stops at the first refusal; it signals the range refusal only when every exchanger was refused for its range and nothing reached the wire; and a failure on the wire at any exchanger makes the outcome transient, so the message stays queued for a retry. An adapter that lets the first exchanger's refusal escape bounces mail a later exchanger could have taken. The contract is written out in full in the docstring of relay-disposition in src/mail/relay.lisp.

A backup exchanger's forward is the one delivery that arrives with a grant of its own. The adapter receives it as the keyword argument :dial-capability and dials the forward through it, not through its own grant. If you write an adapter, name :dial-capability in your deliver function's lambda list. One whose lambda list rejects the keyword signals on every forward, so the node holds the message for a later attempt and logs why. One that takes &rest or &allow-other-keys without naming it accepts the keyword silently and dials the forward under its own grant, where a primary on a private network is refused. Keep the grant past the call and it refuses. The contract is in the docstring of mail-adapter-spec in src/plugin/mail-adapter.lisp.

The bounce is a record on the queue. No delivery status notification goes back by mail, the same as for a domain with a null MX, so a refused exchanger never turns into mail sent to whoever a message claims came from. Mail on this path is mail you submitted, so the node tells you instead. It logs mail.relay-bounced at WARN once for the entry, naming the entry, the recipient's domain and the reason, and the entry's leaf under /mail/queue carries the same reason on a status: line after the disposition. The reason here is exchangers-refused. You hear of the other bounces of your mail the same way: null-mx for a domain that accepts no mail, domain-unresolvable for a domain that does not exist or names no exchanger, and exchanger-rejected when an exchanger refused the message permanently. The log line names the domain and never the address you wrote to.

Paths not yet through the dial are logged, not refused

The paths still exempt from the dial check, listed in the exemption registry described below, are not refused by the range rule. Each exemption entry says who chooses where its path connects, and a running node judges every connection such a path opens by the same rule the dial applies. When the dial would have refused it, the node logs it at WARN, once for each caller, address and range, naming the caller, the address, the range and the chooser, and lets it through. Every other connection from an exempt path is logged at DEBUG. An address given as a name is logged unclassified, because the check never looks a name up. After 1024 distinct WARN lines, further ones go to DEBUG until the node restarts.

Every exempt path that connects today goes where you configured it, so on an ordinary node this log stays quiet unless one of them reaches the floor.

Common setups, and what to allow

Each row names the client that needs the range, and the commands below set it for that setup alone. example-client stands for the system name of whatever module you admitted to do the peering. When the key already holds members, carry them over in the same command, joined by commas.

Setup Client Range
Peering with nodes on your LAN the peering module your LAN, here 192.168.1.0/24
Peering over a Tailscale tailnet the peering module 100.64.0.0/10 and fd7a:115c:a1e0::/48
Peering over a ULA overlay, such as a WireGuard mesh the peering module your overlay's prefix, here fd00:1234:5678::/48
Several nodes sharing one loopback, as development images do the peering module 127.0.0.0/8 and ::1/128
The proving ground's simulated internet delegation-probe 192.168.140.0/22

The command for each, in the same order:

valis config set net.dial-allow example-client=192.168.1.0/24
valis config set net.dial-allow example-client=100.64.0.0/10,example-client=fd7a:115c:a1e0::/48
valis config set net.dial-allow example-client=fd00:1234:5678::/48
valis config set net.dial-allow example-client=127.0.0.0/8,example-client=::1/128
valis config set net.dial-allow delegation-probe=192.168.140.0/22

Two of these allow more than they seem to. 100.64.0.0/10 is also the range carrier-grade NAT assigns addresses from, so if your provider puts you behind it, the allowance reaches the provider's side as well; allow your peers' own addresses, one /32 each, when that matters. And allowing loopback to a module lets it reach every service listening on that loopback, not only the other nodes.

A proving ground node seeds the last row from SITE_NET_DIAL_ALLOW at its first boot. A node already running keeps what it has; give it the command above.

Two caches, and why they are two

The outbound path holds two caches. They are two objects, in two files, emptied two different ways, and merging them would be a security defect rather than a tidy-up.

Cache Holds Emptied by
name cache (src/net/resolve.lisp) a name and the addresses it resolved to the answer's own lifetime running out
decision cache (src/net/policy.lisp) may this principal dial this destination the owner withdrawing the grant

The decision cache has no expiry of its own: no lifetime, no timestamp, no thread sweeping it. Give it one, even as a safety measure, and you have put a clock between the owner's withdrawal and its effect. Share it with the name cache and a withdrawn grant keeps dialling until some name's time to live happens to run out: a delay nobody chose, of a length set by whoever publishes that name, on the one path where an owner's decision is supposed to take effect at once. A decision is held only for a grant carrying its own hash, because the hash is what a withdrawal names; a grant without one is evaluated afresh every time.

A membership scope resolved at the node, such as "the secondaries configured for this zone", is never cached at all. Membership follows operator configuration, which changes with no grant being withdrawn, so a held decision would go on permitting a secondary the operator had already removed.

Where an address lands is held in neither. The range judgement is made afresh at every connection, against the allowance as it stands at that moment, for the same reason: an owner who narrows an allowance should not wait for anything to expire.

Failure is typed, and each type asks the operator for something different

The longest-lived defect in the connection-server model this seam follows is error propagation: many branches, one collapsed error string, and because the code sits in a library every caller inherits the loss. valis has condition classes, so it spends them here.

Condition What it means What an operator does
dial-not-permitted the capability check refused, including a dial to a port the grant was not issued for widen the grant deliberately, or accept the refusal
dial-address-not-permitted every address the destination resolved to lies in a range this grant may not reach allow the range for the grant's client if you mean to, or fix the destination
dial-address-in-floor the destination resolved into the floor fix the destination; nothing allows the floor
dial-name-not-found the name has no record anywhere fix the destination
dial-no-address-family this node's namespace holds no address of the family asked for fix the namespace, not the destination
dial-resolver-unreachable the configured nameserver could not be reached fix resolver configuration or routing
dial-connection-refused the peer answered and declined the destination is reachable and not listening
dial-host-unreachable the network reported no path routing or the far network
dial-timed-out the deadline elapsed with nothing back a blackhole, not a refusal
dial-source-address-unbound the required egress source could not be taken this node does not hold that address
dial-outside-the-seam code opened a connection without going through the seam migrate that call site

Every one of these carries the cause as a condition or an errno, never as a rendered string, because a string cannot be dispatched on and discriminating is the whole point.

The pair that matters most is dial-name-not-found against dial-no-address-family. The operating system reports both identically. They mean opposite things, and an operator who reads a confinement failure as "no such name" stops looking at the place the fault actually is. The next section is about why.

The Linux underneath, which decides more than the code does

Four facts about the machine are invisible from any line of valis source and expensive to work out from the outside.

A name lookup is not DNS, and the switch decides whether one is possible

getaddrinfo speaks no protocol of its own. It dispatches through the hosts: line of /etc/nsswitch.conf to loadable name service switch modules, and which modules are consulted is configuration the process neither chooses nor can read out of its own source.

A serving valis runs inside a network namespace, and that is where this stops being trivia. Such a process can hold working sockets, a correct routing table and a reachable nameserver and still resolve nothing at all, because the hosts: line it inherited, names a module that cannot answer from where it is standing. Every layer above reports that as though the name did not exist.

The rule that follows, for anyone adding an outbound path: a library that implements DNS itself, rather than asking the system, opts out of the one lever an operator holds over a confined node. It does so silently, and only on a deployed host.

Two resolvers live in this image and they do not behave alike

SBCL's own socket layer hands a lookup to glibc getaddrinfo, so it goes through the switch and obeys the confined view. iolib, which this seam uses for its sockets and never for names, carries a DNS client of its own: it reads /etc/resolv.conf itself, opens its own datagram socket, and sends the query. Inside a namespace whose nameserver that client cannot reach, it fails on the query socket rather than in the resolver, so what surfaces is a condition about polling a descriptor.

That failure is also defined outside its own library's condition hierarchy, so it is neither a resolver condition nor a socket condition. It escapes the library's retry path and it escapes a lookup that was asked not to signal. The general form outlives this one library: a condition class outside a hierarchy escapes every handler written for that hierarchy, and a handler broad enough to catch it anyway is one that cannot discriminate.

None of this is a preference between libraries. It is measured behaviour on a confined node, and it is the defect that produced this seam.

A namespace can be given its own view of that configuration, and the bind fails silently

The namespace launcher, ip netns exec, bind-mounts /etc/netns/<name>/* over the matching files in /etc for the process it starts. That is the supported way to hand a confined process a different resolv.conf or a different nsswitch.conf, and it works under a hardened service unit with a strict read-only system, a private temporary directory and a full capability bounding set, with both files binding.

The pitfall is silent. A bind that fails prints one line to standard error and still exits zero. The unit reports success while the process runs on the host's configuration instead of the one written for it. Anything depending on that view must assert the bind took effect and must never infer it from an exit status.

This is measured rather than inferred from a manual page. It was run under a unit carrying exactly those restrictions on two systems four systemd releases apart: the deployment platform, Debian 13 with systemd 257 and iproute2 6.15, and a development workstation running systemd 261 and iproute2 7.1. On both, the file placed under /etc/netns was what the confined process read, and standard error, read on its own, carried the unit's ordinary reporting and no failed bind. A file whose contents cannot be read back leaves only the second of those checks, which is why it is worth capturing separately.

A missing name and a missing address family are reported identically

Under AI_ADDRCONFIG, which is the ordinary default, the system declines to look up a family the local machine holds no configured address of, and it declines before any query leaves. The failure is EAI_NONAME, rendered "Name or service not known": exactly what a name that exists nowhere produces, and exactly how an NXDOMAIN from a nameserver arrives.

Since the answer cannot separate them, the seam asks the kernel a different question instead of asking the network again. It opens a datagram socket of the family and fixes its peer at an address reserved for documentation: TEST-NET-1 for version four (192.0.2.1) and the documentation prefix for version six (2001:db8::1). Fixing a datagram socket's peer transmits nothing. It performs a route lookup and, where one succeeds, binds a local source address that can be read back. So the question actually asked is whether this namespace holds a routable address of that family, the answer costs one socket and no packets, and the reserved destination means even a misreading of that code cannot put traffic on a wire toward a real host.

A unix socket crosses a network namespace, and that is why it is out of scope

A network namespace partitions interfaces, routes and ports. It does not partition the mount namespace, so a pathname AF_UNIX socket is reachable from inside one by path exactly as it is from outside.

That is how a node reaches its own operator-state database over a unix socket with no TCP listener anywhere and no password on any wire. It is also why AF_LOCAL connections sit outside this seam's remit: they never leave the host, so there is no destination to decide about and no name to look up. The refusing check beneath the socket libraries makes the same exclusion for the same reason.

The same crossing is a way out that nothing here audits, and it is open

The paragraph above is true and it is not the whole picture. A reader who stops there takes away that the crossing is a convenience. It is also the one path out of a confined namespace that this seam never sees, and on a stock systemd host it is open by default.

On such a host the hosts: line consults nss-resolve, which does not send a query. It asks systemd-resolved over the pathname socket /run/systemd/resolve/io.systemd.Resolve, and that socket is mode srw-rw-rw-. Any user id inside the namespace can drive host-side resolution through it, for any name, and the namespace's own egress policy is never consulted because nothing the policy governs is used.

Measured on a development workstation on 2026-07-27, and it is blunter than the description. A namespace holding no configured interfaces at all resolves a public name successfully:

unshare -Urn getent hosts example.com   -> 2606:4700:10::ac42:93f3, rc=0

The control isolates the cause. Same namespace, plus a private mount namespace and a tmpfs laid over the socket directory:

unshare -Urnm sh -c 'mount -t tmpfs none /run/systemd/resolve; getent hosts example.com'
                                        -> no output, rc=2

Why this belongs in a document about the outbound seam: the confinement argument for hosting code the operator did not write is that such code has nowhere to send anything, because every destination has to be presented to one verb first. A query name is attacker-chosen text that reaches the network. Low bandwidth is not no bandwidth, and exfiltration over query names is old, well understood work.

Two further points a reader should not have to rediscover. This is not specific to the seam's own libraries: it is glibc's switch, so it holds for anything in the namespace that resolves names the ordinary way, whatever language it is written in. And it should not be read as a defect awaiting an upstream fix. systemd-resolved.service(8) places the stub on loopback addresses in a single namespace and describes no per-namespace operation anywhere, so a host resolver reached from several namespaces at once is a configuration outside what the service sets out to do. Closing this is our decision to make, on our side of the boundary.

The disposition today is open, and stated rather than mitigated. What would close it is visible in the control above: masking the socket directory inside the unit's mount namespace. The cost is that the confined process then resolves nothing at all, so the name path it is supposed to use has to be put somewhere before that mask goes on, which makes this a decision about the namespace's resolver rather than a flag to set.

Holding the line mechanically

An intention is not a boundary. Three mechanisms make the single door a property of the tree rather than a habit.

The whole-tree scan

Every Lisp source under src/ is read with its comments and string contents removed, and a source naming a verb that dials or resolves fails unless the exemption registry names it. The sweep walks the tree and the registry is the set of exceptions taken out of it, never the other way round. A scan over a written-down list of files checks only the files somebody thought of, and a subsystem added tomorrow goes unchecked while saying nothing about it.

The count of files read is asserted to clear a floor, because a walker that found nothing would satisfy every other assertion and read as a clean run.

Prose may name any of these verbs freely. The writing that explains why a file must not call something is exactly the writing that names it, so a sweep punishing prose would be weakened until it checked nothing.

The refusing check at run time

A source sweep is blind to two things: a library valis merely loads, and a call reached through a symbol rather than written as one. Both are real. So a running node installs a check beneath the socket libraries' own connect verbs. Its decision, cheapest first: the seam's own connection proceeds; a connection in the local address family proceeds; otherwise the stack is walked for the innermost frame outside the socket libraries, and a name on the escape list proceeds, is counted, and has where it connected logged against the range rule (see Where a dial may go). Anything else, including a caller that could not be named, is refused.

The check holds no way of turning a name into an address, and a test reads the file as code to keep it that way. A resolving check would rebuild the very verb this seam does not have, one layer below where it was forbidden.

The node installs the check when it starts and removes it when it stops, and a test compares both definitions by identity across a start and stop cycle.

The exemption registry, and what it costs when an entry is wrong

src/net/exemptions.lisp is one list with two fields: a repo-relative path for the source sweep, and a package-qualified function name for the run-time check. They are one subject seen from two angles, and kept as two lists they drift.

Entries are marked :by-design or :migration-debt. The debt entries exist in order to be removed, and four things hold the list open, because intending to empty it is what failed the last time this was tried:

  • every entry carries a full sentence saying why, not a tag;
  • a test compares the whole list against a written-down expected value, so growth fails until someone edits the expectation on purpose;
  • a runtime report leads with the count of what remains unmigrated, so it can be read off a running node rather than inferred by reading code;
  • every entry naming a caller counts the connections it was let through on, so an entry nothing uses is visibly deletable and one used constantly is visibly urgent. A zero is printed rather than withheld.

What it costs when an entry is wrong is worth being blunt about, because it has already happened twice here. The check reads the site a connection was opened from. Two entries named the valis-side function that asks for a connection rather than the one that opens it, not because of any malice on my part but because the knowledge of the function was in my mind and my internal flow state reached for it logically within the procedure I was encoding, just one frame deeper and in a sibling system. I didn't even notice doing this when it happened. A name the check can never match is decorative, which is the one thing an exemption list must not be. Both were corrected once the running node was measured in QA, rather than read.

The failure direction of a wrong entry matters and is deliberate: an unmatched or unnameable caller is refused, never permitted. A wrong entry therefore breaks a path loudly instead of opening one quietly. The exception is an entry naming something too broad. Widening an entry to name a third-party HTTP client, for instance, would permit every caller that client has, which is a wildcard by another name.

Two entries carry a subtlety a reader should not have to rediscover

  • One debt entry is knowingly unmatchable. That path reaches an HTTP client by symbol at run time, and the client opens the connection itself, so the check names the client rather than the site that called it. The path is refused. The entry keeps the site the migration has to reach rather than being widened to the client.
  • Two permanent entries name sites the check never reaches, because they are in the local address family and the local-family rule short-circuits before the stack walk. They are not wrong; they are simply never consulted. If either ever moves off AF_LOCAL, its name should be measured rather than assumed.

Do not add a loopback exception to the local-family rule. It would let any library in the image reach any local service, including a proxy that forwards off-host . Where a local destination genuinely needs permitting, the check belongs in that subsystem: the operator-state pool classifies the database host the operator configured and refuses one it would have to leave this node's own network to reach.

What this does and does not provide

valis is first a way for an operator to seize control of their own data. Resistance to a well-resourced adversary is a real secondary capability, not the headline. Where the two conflict, sovereignty wins by default. This section is written for the reader who needs the second thing and is in real danger, because that reader cannot afford an optimistic document.

What the seam does provide.

  • One place where an outbound connection is decided, so the reach of a valis node can be enumerated rather than guessed at.
  • A refusal that is signalled and typed, so a confinement fault and a wrong destination do not look the same.
  • Name-based exfiltration closed for narrow grants, because a name that may not be reached is never looked up.
  • A destination somebody else chose cannot steer this node into its own loopback, the private networks it sits on, or a metadata service unless you allowed that range for that client, and no dial reaches the floor at all. A path still exempt from the dial check that reaches the floor is let through and logged.
  • Resolution that goes through the system, so an operator who configures a namespace actually governs what the node inside it can resolve.

What it does not provide, plainly.

  • A wide grant is a general egress channel. Nothing about the seam changes that, and it matters more rather than less as hosted client code becomes the point: what a hosted client may reach is exactly the width it was granted.
  • Putting resolution inside a tunnel removes the content leak and sharpens the pattern. Volume, timing, burst shape and message boundaries stay visible to whoever watches the operator's own network. A page load has a recognisable shape whether or not the names in it were readable.
  • No transport property survives a compromised endpoint or physical coercion. A machine that is seized and carried to a laboratory yields what it holds.
  • Traffic-analysis resistance against a state-level observer is not claimed. It is a mature adversarial discipline in which home-rolled obfuscation has generally lost. The posture is to interoperate with what has been studied rather than to reinvent it.
  • An allowance is a whole range. Allowing a client a range lets it reach every host in that range, not only the ones you had in mind.
  • The range rule judges the address a connection lands on, not what is behind it. A public address that forwards inward, such as a proxy or a port forward on your router, is reached like any other public address.
  • Paths still exempt from the dial check are logged by the range rule, not refused.
  • Nothing here defends against a hostile process already holding a wide grant. The seam bounds where code may go; it does not decide whether that code is trustworthy.

Honest documentation is necessary and not sufficient

A person under stress reaches for whatever works now, and a warning read weeks earlier does not survive that state. So the design position is that a convenient path and a safe path must not look equivalent at the moment of use, and that a node should be able to state which mode it is running in rather than relying on anyone to remember what they chose.

What a node can state about itself today is what still reaches the network outside the seam. valis/src/net/exemptions:report-outbound-exemptions prints, on a running node: the count of sites still unmigrated, each one with what it is blocked on and how many connections it has been let through on since the counts were cleared, then the permanently permitted set with the same counts, then the one tracked tree of Lisp sources the sweep deliberately skips and why. It reads the escape counts off the live node, so it reports what has actually happened rather than what the source implies.

What does not exist yet, said plainly rather than implied: there is no client-facing indicator telling the person at the other end of a session which mode that session is running in, and no affordance making a convenience path visibly different from a hardened one at the moment of use. Both are design requirements of this seam and neither is built. Until they are, a reader in real danger should assume nothing about a session's protection from the fact that it worked.

Where to look in the code

File What it holds
src/net/dial.lisp the verb, the address walk and the range judgement in it, the descriptor handback, and the commentary on what the machine underneath decides
src/net/resolve.lisp the lookup, the name cache, and the namespace probe
src/net/policy.lisp the grant, who chose its destination, its client and ports, the decision, the decision cache, and named scopes
src/net/address-range.lisp the special-purpose ranges, the floor among them, and how an address carrying an IPv4 one is judged
src/config/node.lisp the net.dial-allow key
src/net/conditions.lisp the failure vocabulary and what each branch means
src/net/http.lisp an HTTP request whose socket comes from the seam, used by the certificate path
src/net/exemptions.lisp the registry and the runtime report
src/net/tripwire.lisp the refusing check installed by a running node
src/edge/zone-notify.lisp the path the defect was found on, and the account of it

The cross-repo obligations this seam still needs from the sister repositories are specified in SEAM-CONTRACTS.org.