Serve a zone per network, and make a network in one command

Twice now a report has read "dns not serving" and been taken for a broken
resolver. It was accurate both times: the agent had been started without
`--dns`. That is the flag's fault, not the reader's — a resolver that
disappears because one word was not retyped is worse than none, since the
names simply stop working. So the setting belongs to the device now: `--dns`
turns it on and it stays on, `tsunagi dns off` turns it off, and
`tsunagi dns on` turns it on for an agent that is already running, without
restarting it. `tsunagi dns` says what it is doing, or what it will do at
the next start when nothing is running.

One agent has one identity and as many networks as it likes, so one DNS
service serves them all: each network is a zone named after it, and joining
or leaving one changes what resolves with no restart. A question carries a
name and not the network it belongs to, so the suffix decides and nothing is
shared between zones — a member of one network is not a name in another.
`--dns-zone` is gone with that: there is no single zone to name any more, and
a network name may contain dots, so `--network lab.internal` is how you get
`music.lab.internal`.

It listens on loopback only, where it always could have. Binding the overlay
address put the zones in front of the whole mesh, and with several networks
on one agent that would have answered one network's questions about
another's names.

That made a gap plain: a second network on an agent had no addresses at all,
because the configured range belongs to whichever network took it first, so
its members had nothing to allocate from and no names to answer with. A
second network now uses the range **derived from its own network id** —
every member derives the same one from something they all already have, so
it is an agreement rather than a local invention. It is held back for a
moment first, because a network that already exists has a range of its own
and a joiner should adopt it rather than argue; that wait is what keeps
"the first member settles it" true.

And a network needs no ceremony to start. `tsunagi up --network lab` with no
secret resolves the obvious way: the one network of that name this device
already has, or — when there is none — a fresh random secret, printed in
full with the single line to send the others. That is the ad-hoc case, one
person makes a network and passes the command round, and it was previously
two steps with a flag people could not find. The secret is printed only when
the agent invented it, because then there is nowhere else to read it from;
one that was supplied is not echoed. Two networks of one name and no secret
is the one case with no answer, and it says so rather than choosing.

Releasing now also stops this agent claiming again. The periodic check would
otherwise publish a fresh claim in the moment between the goodbye and the
teardown, turning a release into a hello nobody asked for.

Exercised with the real binary: a zone per network as a second one is joined
into a running agent, the resolver switched on and off while it runs, and an
ad-hoc network printing its secret and the line to share.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tsunagi
2026-09-21 23:03:42 +01:00
co-authored by Claude Opus 5
parent 44a799faee
commit 0f97d60854
13 changed files with 1330 additions and 295 deletions
+56 -14
View File
@@ -127,18 +127,21 @@ ping 100.65.243.53
`tx` and `rx` in the status should start moving.
IPv6 works out of the box: each member's address is derived from the network
id and collides with essentially nothing.
**IPv4 addresses are allocated and then remembered.** The default range is
**Addresses are allocated and then remembered.** The default range is
`10.13.37.0/24`; the first member to join settles it and later members adopt
what they find, so `--ipv4-range` only matters for whoever starts the network:
```bash
tsunagi up --network lab --secret "$SECRET" --ipv4-range 10.44.0.0/16
tsunagi up --network lab --secret "$SECRET" --ipv4-range none # IPv6 only
tsunagi up --network lab --secret "$SECRET" --ipv4-range none # no data plane
```
One agent has one interface, so two of its networks cannot both use that
range. The second takes the range **derived from its own network id**: not
picked locally — every member derives the same one from something they all
already have — so it is an agreement rather than a guess, and a device in
several networks gets an address in each.
An address is claimed with a record signed by that member's persistent device
key, stored, and merged between every replica. A member that disappears for a
month comes back to the same address, because the claim outlived the session.
@@ -187,24 +190,35 @@ session with no agreed protocol.
## Names
`--dns` serves a local DNS zone for the network's members, so they can be
reached by name instead of by address:
`--dns` serves a local DNS zone for **every network this device is in**,
each named after the network, so members can be reached by name instead of
by address:
```bash
tsunagi up --network lab --secret "$SECRET" --dns
dig @10.13.37.69 -p 5354 music.lab
dig @127.0.0.1 -p 5354 music.lab
```
It is remembered with the device rather than with the command line: once on
it stays on across restarts, and `tsunagi dns off` is what turns it off. A
resolver that quietly disappears because a flag was not retyped is worse
than none, because the names simply stop working. `tsunagi dns on` also
turns it on for an agent that is already running, without restarting it.
The zone of a network is its name — `--dns-zone` is gone, because with
several networks there is no single zone to name. A network name may contain
dots, so `--network lab.internal` is how you get `music.lab.internal`.
Names come from signed state, which is the point: **a member that is
switched off still resolves**, because its claim outlived the session.
The answers are IPv4 addresses, because that is what the overlay is. The
*questions* are taken over both families, on UDP and TCP: the server listens
on the overlay address and on `127.0.0.1`, and on `[::1]` for IPv6, so a
resolver reaches it over whichever it uses. All of those are published to
the system resolver together. A listening address is disposable — unlike an
address a member holds in signed state — so serving one family over the
overlay and the other over loopback costs nothing.
*questions* are taken on `127.0.0.1` and `[::1]`, over UDP and TCP, so a
resolver reaches it over whichever family it uses; both are published to the
system resolver together. Loopback and nothing else: the zones are a view
for the host running the agent, and binding an overlay address would put
them in front of the whole mesh — an agent in two networks would then answer
one network's questions about the other's names.
The zone is the network name unless `--dns-zone` says otherwise. It is
yours to choose, so a name that shadows a real public domain is reported and
@@ -309,12 +323,40 @@ tsunagi id key rotate replace that key
tsunagi network the networks this device belongs to
tsunagi network join -n lab -s tsn1… join one; adds it to a running agent
tsunagi network join -n lab join one whose secret this device already has
tsunagi network leave <id> give up the address and name, then forget it
tsunagi network secret the secret of each joined network
tsunagi network secret <id> just that one, for copying
tsunagi network secret generate a fresh secret for a network that does not exist yet
tsunagi dns whether the local resolver is serving, and what
tsunagi dns on start it, now and after every restart
tsunagi dns off stop it, now and after every restart
```
**A network without a secret makes one.** `tsunagi up --network lab` with no
`--secret` resolves in the obvious way: if this device is already in exactly
one network called `lab`, that one — so the name alone resumes what you have;
if it is in none, a fresh random secret, printed in full along with the one
line to send the others:
```
tsunagi is up
network lab (k2on43wadb…)
secret tsn1u7c…
No --peer was given, so this agent waits to be contacted. Run this on the
other machine:
tsunagi up --network lab --secret tsn1u7c… --peer 91e83a6e2b7a…
```
That is the ad-hoc case: one person makes a network and sends the command
round. The secret is printed *only* when the agent invented it — there is
nowhere else to read it from — and never when it was supplied, because then
it is already yours. Two networks of one name and no secret is the one case
with no answer, and it says so instead of choosing.
A secret is printed by `network secret` and nowhere else — not by `id`, not
by `status`, not in a log, a `Debug` rendering or anything sent to a peer.
Asking for it is deliberate, because these reports get pasted into chats.