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.lispis the verb.src/net/resolve.lispis its first half, reachable from the verb and nowhere else.src/net/policy.lispdecides whether a principal may dial a destination.src/net/address-range.lispsays which special-purpose range an address lies in, so the verb can decide whether a connection may land there.src/net/conditions.lispis the failure vocabulary every outbound path speaks.src/net/exemptions.lispenumerates what is still permitted outside the seam, and why.src/net/tripwire.lisprefuses 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:
- Nothing resolves without connecting.
- 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.
- 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-challengename, 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_ALLOWis read only at the first boot; after that, change the key withvalis 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-refusedonce, 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
Foofromfooanda/bfroma.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-forwardorzone-notifyis 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.