# netd

> The network manager as built: how it reads the interface layer and profiles, reconciles links, addresses and routes, runs its DHCPv4, router-discovery and DHCPv6 clients, identifies networks, and publishes readiness and DNS facts.

---

# 1.1 Overview

_Peios / Peios technical reference / netd / Introduction_

> netd is the Peios network manager — a single-threaded reconciler that executes PNP's interface layer, runs its own DHCPv4, router-discovery and DHCPv6 clients, and publishes readiness and DNS facts. What is unusual about it.

**netd** is the Peios network manager. It is the executor of the
interface layer of PNP, the Peios Network Policy: it reads which
interfaces join which networks, and how they stand there, from
`Machine\System\Network`, and makes the kernel match. It runs as a
SYSTEM service started at boot, and it is the only program on a Peios
machine that configures links, addresses and routes.

Three decisions shape everything else in this manual.

**It is a reconciler, not a command interpreter.** netd keeps no list of
things it has done. On every input — a kernel link or address event, a
registry change, a DHCP or router-discovery packet, a timer, a control
request — it re-derives what each interface should look like and applies
the difference from what the kernel reports. Nothing happens imperatively
in a handler. Two consequences follow directly: a manual change to an
interface netd manages lasts until the next pass, and a restarted netd
derives its desired state afresh. While its first INIT-REBOOT is pending,
it preserves the requested IPv4 address already installed and applicable
netd-owned routes (§2.1). Static configuration is desired from the first
pass and stays.

**It owns addresses wholly and routes only by mark.** On an interface
netd has joined, every IPv4 address and every non-link-local IPv6 address
is netd's: anything else is removed. Routes are netd's only when they
carry routing protocol 200, the number netd stamps on every route it
adds, so a route another program adds for itself survives. The kernel's
own IPv6 link-local address is never touched.

**It is the only router-advertisement listener.** netd switches the
kernel's own RA processing off on every interface before it does
anything else, and acts on advertisements itself, so exactly one place
decides what an advertisement means. The kernel still makes link-local
addresses and answers neighbour solicitations; everything an
advertisement could configure — addresses, the default route, DNS, the
MTU — is netd's decision, filtered through the interface's profile.

netd builds and judges the interface layer with `pnp-core`, the same
engine the kernel's NTFE uses for the packet layers, so a rule means the
same thing in every layer. The kernel never reads `Rules\Interface`;
netd never reads the packet layers. Each executor validates and refuses
its own layers on its own.

## 1.1.1 Where netd sits

```text
   registry (Machine\System\Network)          peinit
        │ Rules\Interface, Profiles,            ▲ READY=1, LEVEL=<level>
        │ Hostname, Duid, ControlSecurity       │
        ▼                                       │
   ┌─────────────────────── netd ──────────────────────┐
   │ interface layer (pnp-core) → profile per interface │
   │ DHCPv4 · router discovery/SLAAC · DHCPv6           │──▶ rtnetlink (links,
   │ reconciler · network identification                │     addresses, routes)
   └────────────────────────────────────────────────────┘
        │ Interfaces\<id>\Status, Networks\<id>,        │ /run/netd/control.sock
        │ Readiness                                     ▼
        ▼                                         net, resolvd (subscribe)
   registry (written back)
```

What netd writes back to the registry is read by others: the kernel
reads each interface's `Status Network` to give the packet layers their
`Network.*` facts (PKM §6.5), services and scripts watch `Readiness`,
and resolvd subscribes to netd's DNS snapshots over the control socket
(PSPU §6.9).

## 1.1.2 What netd does not do

- **Answer names.** netd reports what each interface contributes to name
  resolution; resolvd routes and answers queries.
- **Filter packets.** The packet layers are NTFE's. netd's own DHCP and
  router-discovery traffic passes them only because the shipped baseline
  policy permits it.
- **Decide who can bind a port.** That is the kernel's port reservation
  table.
- **Associate with wireless networks.** A supplicant brings a wireless
  link up; netd then treats it as any other interface.
- **Hold leases across a reboot.** Nothing about a lease is persisted; a
  rebooted machine asks for the address its network last gave it (§5.7).

---

# 1.2 What this manual covers

_Peios / Peios technical reference / netd / Introduction_

> The scope of the netd manual, the documents it leans on for the policy vocabulary, the resolver channel and the kernel's side of the network context, and the version it describes.

This manual describes netd as built: the `netd` workspace's `netd`
daemon, its `dhcp4`, `dhcp6` and `ndp` state-machine crates, the
`libnetd` wire crate, and the `net` command. It describes version 0.1.7.

## 1.2.1 What is covered elsewhere

| Subject | Where |
|---|---|
| The interface layer's vocabulary — facts, verdicts, the collation laws — and every profile value, as an operator writes them | The network policy reference (`~peios/networking/network-policy-reference`) |
| The rule engine itself: ingestion, conditions, collation, refusal | Kernel TRM chapter 6 (PKM §6.4, §6.5); netd links the same `pnp-core` crate |
| The kernel's side of the network context — how `Status Network` becomes `Network.*` facts in the packet layers | PKM §6.5 |
| The netd → resolver channel's contract | PSPU §6.9 |
| What a resolver does with the snapshots | PSPU book 6 and the resolvd manual |
| The query-channel framing the control socket uses | PSPU book 3 |
| How peinit holds a service until a readiness level is published | The peinit manual |
| Every registry value netd reads or writes, one entry each | `regman 'Machine\System\Network'` on a Peios machine |

Where this manual and the network policy reference both mention a
profile value, the reference owns the meaning and this manual owns what
netd does with it: how the value is parsed, when it takes effect, and
how it combines with what a network offers.

## 1.2.2 Conventions

Section numbers in this manual are written `§n.m`. A reference into
another book is qualified with the book's short name: `PKM §6.5` is the
Kernel TRM, `PSPU §6.9` the userspace protocols book.

Times are given as netd's monotonic clock sees them. Every timer netd
runs — DHCP retransmission, lease boundaries, advertisement lifetimes,
solicitation backoff — is measured on `CLOCK_MONOTONIC`, so setting the
wall clock moves none of them.

---

# 1.3 Terminology

_Peios / Peios technical reference / netd / Introduction_

> The terms this manual uses for netd's own concepts — interface, joined, profile, generation, pass, desired state, network, scope and level.

Terms from the policy language — *rule*, *fact*, *verdict*, *backstop*,
*priority*, *exception* — mean what the network policy reference says.
The terms below are netd's own.

| Term | Meaning |
|---|---|
| **interface** | A kernel network link netd has seen, keyed by its kernel index while it exists and named in the registry by its interface id. |
| **interface id** | A UUID-shaped digest of an interface's bus path and MAC (§4.1). Stable across boots and kernel renames. |
| **verdict** | What the interface layer said about an interface: `JOIN(profile)`, `IGNORE` or `DOWN`. |
| **joined** (managed) | An interface whose verdict is `JOIN`. Loopback is never joined. Only joined interfaces get clients, contribute readiness, and appear in DNS snapshots. |
| **profile** | A resolved key under `Profiles\`, inheritance applied: how a joined interface stands on its network. |
| **generation** | One build of the interface layer and the profiles from one reading of the registry. netd holds the last good generation; a refused one changes nothing. |
| **pass** | One run of netd's converge sequence (§2.2): identify networks, judge links, start or stop clients, reconcile, publish. |
| **desired state** | What one interface should look like: up or down, its MTU, its addresses and its routes (§4.2). |
| **reconcile** | Diffing desired state against the kernel's and applying the difference (§4.3). |
| **network** | What is on the other side of a joined interface's carrier, identified from what it has offered (§7.1) and recorded under `Networks\<id>`. |
| **offer** | Everything a network has said: a DHCPv4 lease, router advertisements, a stateless DHCPv6 reply. |
| **level** | How far an interface has got towards being usable: `absent`, `link`, `addressed` or `routed` (§8.2). |
| **scope** | What one interface contributes to name resolution, as sent to resolvd (§8.3). |

---

# 2.1 Startup

_Peios / Peios technical reference / netd / The Daemon_

> The service definition netd ships, and the order netd does things in when it starts — which failures are fatal and which only degrade it — up to the first pass and READY=1.

## 2.1.1 The service definition

The netd package ships its service definition as a registry seed,
`netd-service.reg`, applied by an image that names it. The definition:

| Value | Setting | Why |
|---|---|---|
| `ImagePath` | `/usr/sbin/netd` | |
| `Triggers` | `boot` | |
| `Identity` | `SYSTEM` | rtnetlink changes, the packet socket and a bind to udp/68 each need a privilege only SYSTEM holds |
| `Provides` | `network` | the role a dependent names: `Requires = ["network:routed"]` |
| `Readiness` | `0` (Notify) | readiness is `READY=1` on the notify socket |
| `RuntimeDirectories` | `netd` | peinit creates `/run/netd` for the control socket |
| `RestartPolicy` | `2` (Always) | a stopped network manager is a machine whose leases silently expire |
| `ErrorControl` | `0` | |

A second seed, `netd-default-profile.reg`, ships the baseline policy: the
profile `default` and the rule `wired` (§3.1). It is separate so an image
can run netd without agreeing that every wired interface joins whatever
it is plugged into.

## 2.1.2 The startup sequence [*startup.sequence]

netd does these steps in this order:

1. **Opens rtnetlink**: one socket for requests and dumps, and a
   second subscribed to the link, IPv4 and IPv6 address, and IPv4 and
   IPv6 route multicast groups. Failure is fatal.
2. **Opens the control socket** at `/run/netd/control.sock`, removing a
   stale one first (§9.1). Failure is fatal.
3. **Opens the udp/68 absorber** (§5.6). Failure is a warning: netd runs
   without it, and a DHCP server's unicast reply can draw an ICMP
   port-unreachable from the kernel.
4. **Switches the kernel's RA processing off** on every interface that
   exists, and in `all` and `default` (§6.1). This happens before any
   link is brought up.
5. **Reads the configuration** (§2.3) and builds a generation from it
   (§3.2). A generation that fails to build at startup is logged as
   refused, and netd runs with an empty policy, so the backstop ignores
   every interface until a good generation is written.
6. **Arms the registry watch** on `Machine\System\Network`. Failure is a
   warning, and the configuration is then read once, never again.
7. **Dumps the kernel's state** (links, addresses and routes, both
   families). Failure is fatal.
8. **Runs the first pass** (§2.2): every interface is judged, every
   static address applied, every client that is due started.
9. **Sends `READY=1`** on the notify socket.

Readiness is reported after the first pass, not after any lease arrives.
A lease is a network event, not a startup one. A service that needs an
address waits on a readiness level (§8.2), not on netd being ready.
[*startup.ready-after-first-pass-not-a-lease]

A tie between two rules is not checked at startup: the startup build
refuses a generation only for the build errors of §3.2. A tie present at
startup is judged per interface as a conflict (§3.3), so the interfaces
it covers are ignored with a warning rather than the generation being
refused. [*startup.tie-at-startup-is-a-conflict-not-a-refusal]

If `Machine\System\Network` does not exist when netd starts, the
configuration is empty and the watch cannot be armed, so a key created
later is never read; a restart of netd picks it up.
[*startup.missing-root-is-read-once]

## 2.1.3 Restart [*startup.restart-reacquires-the-lease]

netd re-derives everything from the registry and the kernel. Its DHCP
clients start again, and a client remembers nothing across a restart:
each one begins with an INIT-REBOOT request for the address its network
last leased (§5.7), and holds no lease until that request is answered.

During an interface's first DHCP start in this process, netd preserves
its requested IPv4 address at the prefix already installed in the kernel.
It also preserves its netd-marked IPv4 routes while `Route.Offered` allows
them; an explicit `Route.Gateway` takes precedence and the current profile's
metric applies. This provisional state lasts only while INIT-REBOOT is
pending. An unchanged ACK replaces it with the confirmed lease without
deleting the address or route, so IPv4 readiness stays in place.

A NAK or two unanswered requests end revalidation and discard the
provisional state as the client enters fresh discovery (§5.2). Carrier
loss or a changed profile clears it too. Later client restarts in the same
process do not adopt kernel state again. A requested address that is not
already installed is never added merely because it was remembered.

This is not a restored lease: no lease duration, expiry, DNS servers or
other offered information is inferred from the installed address. Status
still reports no lease until the ACK. DHCP-provided DNS information and
IPv6 router-discovery state must be reacquired; this does not promise that
every aspect of a daemon restart is invisible. IPv6 addresses and routes
from advertisements remain desired only after discovery hears the routers
again (§6.1). Static addresses are desired from the first pass and stay.

---

# 2.2 The loop

_Peios / Peios technical reference / netd / The Daemon_

> netd's single thread and single poll — its inputs, how each one leads to a full pass or a reconcile, and the converge sequence that repeats until the kernel stops changing.

netd is one thread around one `poll(2)`. [*loop.one-thread-one-poll]
Every input is a descriptor in the poll set or a timer deadline, and every
handler records what it learnt and lets the end of the iteration act on
it. Because there is one thread, a handler that blocks holds everything
up: no DHCP timer fires and no kernel event is read until it returns.

## 2.2.1 Inputs

| Input | Descriptor | What it leads to |
|---|---|---|
| Kernel events | the rtnetlink multicast socket | a fresh dump, then a full pass |
| Registry changes | the watch on `Machine\System\Network` | a reload (§2.3), then a full pass |
| udp/68 absorber | the absorber socket | drained and discarded |
| DHCPv4 replies | one packet socket per running client | the client's actions; a reconcile if the network changed |
| Router advertisements | one ICMPv6 socket per interface doing router discovery | the engine's actions; a full pass if anything changed |
| DHCPv6 replies | one UDP socket per running DHCPv6 client | the client's actions; a reconcile if the DNS facts changed |
| Control requests | the listener and accepted client sockets | the request (§9.2); `reconcile` runs a full pass |
| Time | the earliest deadline of any client | every client's timers, each acted on as above |

The poll timeout is the earliest deadline any DHCPv4, router-discovery
or DHCPv6 client holds, or a pending control request or reply holds
(§9.1); with none, netd waits indefinitely. A deadline
already acted on is not held again: once an address is deprecated, its
preferred lifetime is behind it, and its next deadline is the end of its
valid lifetime. So netd sleeps while addresses sit deprecated, which on
an IPv6 network with temporary addresses is most of the time.

After a kernel event netd does not fold the event into its model. It
dumps the whole state again and always runs a full pass, because a dump
taken after an apply can already show a state whose consequences have not
been decided yet. [*loop.kernel-event-redumps-and-converges]

## 2.2.2 A full pass [*loop.full-pass-sequence]

A full pass runs these steps, in this order:

1. identify networks on joined interfaces with carrier (§7.1);
2. bring the interface table in line with the kernel's links and judge
   every non-loopback interface (§3.3), stopping the clients of an
   interface that lost carrier or whose outcome changed;
3. start DHCPv4 clients where due (§5.1);
4. start or stop router discovery and DHCPv6 (§6.1, §6.3);
5. reconcile every interface (§4.3) and dump the kernel's state again.

It then repeats steps 1 to 5 while the dump differs from the one before
the reconcile. [*loop.converge-repeats-until-stable]
The steps run at most four times in one pass, the first run included,
even if the dump is still changing after the fourth.

Applying changes alters what the next decisions see: bringing a link up
gives it carrier, carrier starts a DHCP client, and a lease adds an
address. One pass would leave that chain waiting for a later event that,
on a virtual NIC, has already arrived.

After the repeats, the pass applies the hostname (§8.4) and publishes:
the machine level to peinit and the registry, every joined interface's
`Status`, and a DNS snapshot to subscribers if it changed (§8.2, §8.3).

## 2.2.3 Reconcile without a full pass

A DHCPv4 lease arriving, a lease being lost, link-local fallback, and a
DHCPv6 answer each change only what an interface should carry, so they
lead to a reconcile of every interface, the hostname, and a publish,
without re-judging anything. If the same iteration also needs a full
pass, only the full pass runs.

A DHCPv4 lease also marks its interface for re-judgement, because the
network on the other side might now be identifiable (§7.1). Any interface
so marked makes the iteration run a full pass.
[*loop.lease-marks-for-rejudgement]

---

# 2.3 Reading the registry

_Peios / Peios technical reference / netd / The Daemon_

> What netd reads from Machine\System\Network, how values are lowered before anything interprets them, the depth limit, and what a registry change causes.

netd reads its configuration from `Machine\System\Network`, whole, every
time it reads it.

## 2.3.1 What is read [*config.what-is-read]

| Value or key | Used for |
|---|---|
| `Hostname` (`REG_SZ`) | the machine's name (§8.4). An empty string counts as unset. |
| `ControlSecurity` (`REG_BINARY`) | the control object's descriptor (§9.1). An empty value counts as unset. |
| `Duid` (`REG_SZ`) | the machine's DHCP unique identifier, as hex (§5.7). A value that is not hex is ignored with a warning, and replaced when netd next decides the DUID (§5.7). |
| `Rules\Interface\` and everything under it | the interface layer (§3.2) |
| `Profiles\` and everything under it | the profiles (§3.1) |

Nothing else under the key is configuration to netd. `Interfaces\` and
`Networks\` are read only for the values netd shares with the operator
(§5.7, §7.2), at the moment it needs them.

A missing `Machine\System\Network` means no configuration: no hostname,
no rules (so the backstop ignores every interface), no profiles.

## 2.3.2 Lowering [*config.lowering]

Every value in the two trees is lowered to one of four shapes before
anything interprets it:

| Registry type | Lowered to |
|---|---|
| `REG_DWORD` (4 bytes) | an integer |
| `REG_QWORD` (8 bytes) | an integer |
| `REG_SZ`, `REG_EXPAND_SZ` | a string, cut at the first NUL; *other* if not UTF-8 |
| `REG_MULTI_SZ` | a list of strings, empty items dropped, invalid UTF-8 items dropped |
| anything else, or a DWORD or QWORD of the wrong length | *other* |

The rule builder refuses an *other* by name (§3.2), and the profile
parser refuses it as a value of the wrong shape (§3.1), so an unusable
value is never silently dropped. The default (unnamed) value of a key is
skipped in both trees.

Values and subkeys are sorted by name, so two readings of an unchanged
tree compare equal.

A tree is read at most 16 levels deep. Below that the read stops with a
warning, and whatever lies there is not part of the configuration.
Nothing else bounds the depth of a rule tree or a profile tree (§3.2).
[*config.tree-read-sixteen-deep]

## 2.3.3 A registry change [*config.registry-change]

The watch is armed on the whole `Machine\System\Network` subtree. On any
event, netd reads the whole configuration again and compares it with
what it holds:

- If anything differs, the control object is rebuilt from
  `ControlSecurity`. If the rules or the profiles differ, a new
  generation is built (§3.2), and taken or refused.
- Whatever differs, a full pass follows.

netd's own writes to `Interfaces\` and `Networks\` come back through the
same watch. They cause a reload that finds nothing changed, and a pass
that plans nothing.

A failed read of the watch's events re-arms the watch.

There is no reload command: a write is noticed within the time it takes
the watch to deliver and a pass to run.

---

# 3.1 Profiles

_Peios / Peios technical reference / netd / The Interface Layer_

> How netd resolves the profile tree — inheritance, Enabled, the accepted shapes of every value, what refuses the generation, and what a bare profile does.

The profile vocabulary — what each value means to an operator — is the
network policy reference's (`~peios/networking/network-policy-reference#profiles`).
This article is what netd does with it.

## 3.1.1 Resolution

Every key under `Profiles\` is a profile, at every depth, named by its
path below `Profiles` with `/` between levels. The `Profiles` key's own
values are ignored. [*profile.every-key-is-a-profile]

Profiles are looked up case-insensitively: netd keys them by the
lower-cased path and keeps the path as written for display and for
`Status Profile`. [*profile.lookup-is-case-insensitive]

A key whose name is empty or contains `/` or `\` refuses the generation.

### 3.1.1.1 Inheritance [*profile.inheritance]

A profile's effective values are its ancestors' values with its own laid
over them, per value name. A value a profile names replaces the
inherited one whole — a list replaces a list, never appends to it — and
a value it does not name is inherited. Value names compare
case-insensitively, so `address.offered` overrides `Address.Offered`.

Each profile is parsed from its own effective values. A malformed value
in a parent therefore refuses the generation at the parent, before any
child is reached.

### 3.1.1.2 Enabled [*profile.enabled]

`Enabled` is a profile's switch, not a value: it is never inherited as
one. A profile is enabled when its own `Enabled` and every ancestor's say
so; absent means enabled. A child cannot re-enable itself under a
disabled parent. A disabled profile is still resolved and parsed — a
malformed value in it still refuses the generation — but a rule that
names it abstains (§3.2).

`Enabled` accepts the boolean shapes below; anything else refuses the
generation.

## 3.1.2 Accepted shapes [*profile.value-shapes]

Every value is parsed from its lowered form (§2.3):

| Shape | Accepted |
|---|---|
| boolean | an integer (non-zero is true), or a string `on`, `yes`, `true`, `1`, `off`, `no`, `false`, `0` in any case |
| number | a non-negative integer that fits 32 bits, or a string of one, surrounding space trimmed |
| list | a multi-string (empty items dropped), or a single string as a one-item list; an empty string is the empty list |
| string | a string, or an integer written as its decimal text |

A value of any other shape — a list where a boolean belongs, a binary
value — refuses the generation with the message `profile <path>: <name>
has the wrong shape`, where `<name>` is the value's name in lower case.

## 3.1.3 Values

| Value | Shape | What netd does with it |
|---|---|---|
| `Address.Offered` | boolean | runs a DHCPv4 client while IPv4 is in the families, and router discovery while IPv6 is |
| `Address.Families` | list | items `ipv4` and `ipv6`, in any case. The list replaces the default of both, so an empty list means neither family. Any other item refuses the generation. |
| `Address.Static` | list | each item `address/prefix`, either family, prefix at most 32 or 128. An item that does not parse refuses the generation. |
| `Address.LinkLocal` | boolean | link-local fallback (§5.5) |
| `Address.Temporary` | boolean | temporary IPv6 addresses (§6.2) |
| `Address.OnExpiry` | string | `Drop` or `Keep`, in any case. Anything else refuses the generation. |
| `Route.Offered` | boolean | takes the lease's gateway and classless routes and the routers' default route (§4.2) |
| `Route.Gateway` | list | each item an address of either family. The last IPv4 item is the IPv4 gateway, the last IPv6 item the IPv6 gateway. An item that does not parse refuses the generation. |
| `Route.Metric` | number | the metric of every route netd adds for the interface, and its place in DNS scope order |
| `Mtu.Offered` | boolean | takes the lease's MTU, else the routers' |
| `Mtu.Value` | number | the MTU to set. Below 68 refuses the generation. |
| `Hostname.Announce` | boolean | sends the machine's `Hostname` in DHCP option 12 |
| `Hostname.Offered` | boolean | adopts a lease's hostname when `Hostname` is unset (§8.4) |
| `Dns.Offered` | boolean | adds the network's DNS servers and search domains after the profile's own (§8.3) |
| `Dns.Servers` | list | each item an address of either family. An item that does not parse refuses the generation. |
| `Dns.Domains` | list | taken as written; nothing validates a domain |
| `Dns.Default` | boolean | present sets the scope's default-route flag; absent follows the interface's level (§8.3) |
| `Dns.Exclusive` | boolean | sets the scope's exclusive flag |

[*profile.values-as-tabled]

A value name not in this table (case-insensitively) refuses the
generation with `profile <path>: unknown value <name>`, the name as
written. [*profile.unknown-name-refuses]

After parsing, any `Address.Static` entry outside the profile's families
is dropped without an error: the family switch wins.
[*profile.statics-outside-families-dropped]

## 3.1.4 A bare profile [*profile.bare-profile-does-nothing]

Every compiled default is false, empty or absent, apart from the
families (both) and `OnExpiry` (`Drop`). A profile that sets nothing
therefore brings its interface up and does nothing else: no clients, no
addresses, no routes, no DNS. The friendly behaviour of the shipped
`default` profile is four values in its seed: `Address.Offered`,
`Address.LinkLocal`, `Route.Offered` and `Dns.Offered`, each `1`.

---

# 3.2 Building a generation

_Peios / Peios technical reference / netd / The Interface Layer_

> How netd turns Rules\Interface and Profiles\ into a generation with pnp-core — the JOIN of a disabled profile, what refuses a generation, lints, and what a refusal leaves in force.

A generation is built whenever the rules or the profiles have changed
(§2.3), and once at startup.

## 3.2.1 The order of the build

1. **Profiles** are resolved (§3.1). Any error refuses the generation.
2. If `Rules\Interface` does not exist, the generation has no forest.
   Every interface meets the backstop, `IGNORE`, and the profiles are
   kept for nothing to name. [*generation.no-rules-means-backstop]
3. Each subkey of `Rules\Interface` is a root rule. Its tree is lowered
   to `pnp-core`'s input and built as the `Interface` layer with
   `build_forest` — the same ingestion, condition parsing and lints the
   kernel applies to the packet layers (PKM §6.5). Every build error
   refuses the generation.
4. Every profile a `JOIN` names is looked up. A `JOIN` naming no profile
   refuses the generation with `JOIN(<path>) names no profile`.
   [*generation.dangling-join-refuses]

## 3.2.2 Lowering a rule

Integers and strings pass through. A value of any other lowered shape
(§2.3) refuses the generation with `rule <path>: value <name> has an
unsupported type`.

A list is passed through item by item, except in `Actions`. There, an
item that is a `JOIN` naming a profile that exists and is **disabled** is
replaced by `NULL` before the build, so the rule abstains and the
forest's own collation answers without it: its parent, another tree, or
the backstop. A `JOIN` of a disabled profile is not a fault.
[*generation.join-of-disabled-profile-abstains]

A `JOIN` target is recognised loosely. Whitespace anywhere is ignored,
`JOIN(` matches in any case, `\` in the path is read as `/`, and the
path is compared case-insensitively. So `join ( Office\London )` names
the profile `office/london`. [*generation.join-target-recognised-loosely]

## 3.2.3 What refuses a generation

Everything `pnp-core` refuses at the interface layer refuses it here, with
netd's wording:

| Cause | Message |
|---|---|
| An unknown fact in a condition | `rule <path>: unknown fact <key>` |
| A malformed operator | `rule <path>: bad operator in <key>` |
| A malformed pattern | `rule <path>: bad pattern in <key>` |
| `Actions` not a list | `rule <path>: Actions is not a list` |
| A malformed action | `rule <path>: bad action (…)` |
| An action the interface layer does not speak (`PASS`, `DROP`, `TAG`, …) | `rule <path>: an action the interface layer does not speak` |
| A key that does not exist at the interface layer | `rule <path>: <key> does not exist at the interface layer` |
| `Present` on a fact never present at this layer | `rule <path>: <key> on a fact that never exists at this layer` |
| `Priority` not an integer | `rule <path>: Priority is not an integer` |
| `Enabled` not 0 or 1 | `rule <path>: Enabled is not 0 or 1` |
| A malformed rule name | `rule <path>: bad name` |
| A `JOIN` naming no profile | `JOIN(<path>) names no profile` |
| A malformed profile | the profile parser's message (§3.1) |

The depth of a rule tree refuses nothing. It is bounded only by netd's
registry read, which takes rules to 16 levels below `Rules\Interface` and
ignores anything deeper (§2.3).

[*generation.refusal-causes]

A condition on a fact that exists in the vocabulary but can never be
present at the interface layer, such as `DstPort`, is not refused. It is
a **lint**: logged as a warning, `rule <path>: <key> can never hold at
the interface layer`, and the generation is taken.
[*generation.never-at-layer-is-a-lint]

## 3.2.4 A tie refuses at runtime

A generation built while netd is running is judged against every
non-loopback interface before it is taken. If any interface meets a
**conflict** (two rules tied on priority naming different profiles,
§3.3), the generation is refused with `rules <a> vs <b> tie on interface
<name>`. [*generation.runtime-tie-refuses]

## 3.2.5 Taken or refused

A taken generation replaces the old one whole, clears any recorded
refusal, and is logged with its counts of rule trees and profiles. The
pass that follows judges every interface against it.

A refused generation changes nothing. The last good generation stays in
force, netd logs `interface layer refused: <why>; the last good
generation stands` at error level, and records the reason, which
`status` reports as `refusal` and `net status` prints as a `policy
REFUSED` line. [*generation.refusal-keeps-last-good-and-is-reported]
The refusal stays recorded until a generation builds.

The interface layer's refusal is netd's alone. The kernel reads the
packet layers from the same registry state and refuses or takes them on
its own (PKM §6.5): a dangling `JOIN` never stalls the firewall, and a
bad `Flow` rule never stops an interface joining.

---

# 3.3 Judging an interface

_Peios / Peios technical reference / netd / The Interface Layer_

> The facts netd gives the interface layer, when an interface is judged, how a verdict and its attribution are read off the evaluation, conflicts, and what a change of verdict does.

## 3.3.1 The facts [*judgment.facts]

An interface is judged on a snapshot carrying only interface and network
facts:

| Fact | Value |
|---|---|
| `Interface` | the kernel's name for the link |
| `Interface.Kind` | `wired`, `wireless`, `loopback` or `other` (§4.1) |
| `Interface.Id` | the interface id (§4.1) |
| `Interface.Mac` | the hardware address, when the link has a 6-byte one |
| `Interface.Path` | the bus path; absent when sysfs gives none |
| `Interface.Driver` | the driver; absent when sysfs gives none |
| `Network.Id` | the id of the network identified on the link (§7.1); absent until one is |
| `Network.Name` | the record's `Name`; absent when unset or empty |
| `Network.Trust` | the record's `Trust`; absent when unset or empty |
| `Network.Kind` | the interface's kind, once a network is identified; absent before |

A fact the interface lacks is absent, which makes a condition on it
false, never an error.

## 3.3.2 When an interface is judged [*judgment.when]

Every non-loopback interface is judged on every full pass (§2.2).
Loopback is never judged and never managed: it has no verdict, no record
under `Interfaces\`, and netd never changes it.
[*judgment.loopback-never-judged]

## 3.3.3 The verdict [*judgment.verdict]

`pnp-core` evaluates the forest and netd reads the result:

- `JOIN(i)`: the profile the forest's `i`th `JOIN` names, looked up
  case-insensitively. netd stands the interface in that profile.
- `DOWN`: the interface is kept administratively down.
- anything else, including the backstop: `IGNORE`.

The attribution is the evaluation's: the path of the rule that spoke, or
`backstop` when none did.

## 3.3.4 Conflicts [*judgment.conflict-ignores-and-names-both]

When the evaluation reports a conflict — the highest-priority candidates
tie and name different profiles — netd does not choose. The interface's
outcome is `IGNORE`, its attribution is the tied rules' paths, sorted and
joined with ` vs `, and its warning is `rules <a> vs <b> tie; the
interface is ignored`. The first time an interface meets a conflict, netd
logs a warning.

A conflict reaches an interface in two ways. A generation taken at
startup is not checked for ties (§2.1), and an interface that appears
after a generation was taken is judged against it with no check. A
generation built at runtime that ties on an interface already present is
refused instead (§3.2).

## 3.3.5 A change of verdict [*judgment.change-restarts-clients]

The outcome compares the verdict **and the profile's resolved values**.
When either differs from the interface's previous outcome, netd logs the
new verdict and its rule, and stops the interface's clients:

- the DHCPv4 client sends a RELEASE if it holds a lease (§5.4) and is
  discarded, along with its lease and any link-local address;
- router discovery and DHCPv6 are discarded, and their addresses go at
  the next reconcile.

The pass then starts whatever clients the new outcome wants. Editing any
value of the profile an interface stands in is a change of outcome, so
it restarts the interface's clients against the new profile, rather than
trusting a lease obtained under the old one.

## 3.3.6 What each verdict does

| Verdict | Clients | Desired state (§4.2) |
|---|---|---|
| `JOIN(profile)` | started as the profile asks | up, with the profile's addresses and routes |
| `DOWN` | none | down, carrying no addresses and no netd routes |
| `IGNORE` | none | none: netd plans nothing for the interface |

[*judgment.verdict-effects]

An `IGNORE`d interface is left exactly as it is. If it was joined before,
the addresses and routes netd gave it stay where they are, because
nothing desires their removal. [*judgment.ignore-leaves-what-was-there]

---

# 4.1 Identity and carrier

_Peios / Peios technical reference / netd / Interfaces_

> How netd classifies a link, derives its stable interface id, bus path and driver, and what appearing, disappearing and losing carrier do.

## 4.1.1 Kind [*ifid.kind]

netd classifies each kernel link once per dump:

| Link | `Interface.Kind` |
|---|---|
| the loopback flag set | `loopback` |
| link type Ethernet, with `/sys/class/net/<name>/wireless` or `/sys/class/net/<name>/phy80211` present | `wireless` |
| link type Ethernet otherwise | `wired` |
| any other link type | `other` |

A bridge, a veth or a TAP device has link type Ethernet and is `wired`.

## 4.1.2 Bus path and driver [*ifid.path-and-driver]

The **bus path** comes from sysfs. netd resolves
`/sys/class/net/<name>/device` and walks up from it to the nearest
ancestor whose name is a PCI address (`dddd:bb:dd.f`), and names it
`pci-<address>`. A device with no PCI ancestor is named by the device's
own directory name. A link with no `device` (a virtual device) has no bus
path. A virtio NIC sits one level below its PCI function, so it is named
by the function.

The **driver** is the name the `device/driver` link points at
(`virtio_net`, for example), and empty when there is none.

Both are read once, when netd first sees the link.

## 4.1.3 The interface id [*ifid.derivation]

The interface id is a SHA-1 digest of the bytes `peios-netd-ifid|`, then
the bus path if there is one, then `|`, then the six MAC bytes — or, for
a link with no 6-byte hardware address, its name. The first 16 bytes of
the digest are stamped as a version 5, RFC 4122 variant UUID and written
in lower-case hyphenated form.

So the same card in the same slot has the same id on every boot, however
the kernel names it, and a renamed link keeps its id. A virtual device has
no bus path, so its id follows its MAC alone.
[*ifid.stable-across-renames-and-boots]

The id is the key of the interface's record under `Interfaces\` (§8.1),
the `Interface.Id` fact, the key of the RFC 7217 address derivation
(§6.2), and the basis of its DHCP IAID (§5.7).

## 4.1.4 Appearing and disappearing [*ifid.appear-disappear]

When netd first sees a link, it reads the link's identity, logs
`interface <name> (<path>) is <id>`, and, unless it is the loopback,
writes `accept_ra = 0` for it (§6.1).

When a link disappears from the kernel's dump, netd drops it and
everything it held: its clients, lease, network and judgement. Nothing is
sent on the wire. Its record under `Interfaces\` stays.

## 4.1.5 Carrier [*ifid.carrier-loss]

netd counts an interface as having carrier when it is administratively up
**and** the kernel reports `IFF_LOWER_UP`. When an interface that had
carrier loses it, netd logs `interface <name>: carrier lost` and:

- stops its DHCPv4 client: a RELEASE is sent if a lease was held, and the
  lease and any link-local address are forgotten (§5.4);
- stops router discovery and DHCPv6;
- forgets the network identified on it (§7.1), so `Status Network` is
  cleared and the `Network.*` facts go absent, for netd's own judgement
  and the kernel's packet layers alike.

The next reconcile then removes the leased and autoconfigured addresses
and the routes that depended on them, because nothing desires them any
more. Static addresses stay. [*ifid.carrier-loss-removes-offered-config]

When carrier returns, the pass starts the clients again from scratch
(§5.1, §6.1), and the DHCPv4 client asks first for the address the
network last gave (§5.7).

---

# 4.2 Desired state

_Peios / Peios technical reference / netd / Interfaces_

> What a joined interface should carry — link, MTU, addresses, routes and their metric — computed from its profile and what its network has offered; and what DOWN and IGNORE desire.

On every reconcile netd computes, for each interface, what it should look
like. That **desired state** is a link state, an MTU, a set of addresses
and a set of routes. It is derived from the interface's verdict, its
profile, and its clients' current state. During the first INIT-REBOOT it
also includes the provisional IPv4 kernel state described in §2.1; netd
keeps no general record of what it applied.

## 4.2.1 By verdict [*desired.by-verdict]

- Loopback, or an `IGNORE`d interface: there is no desired state, and
  netd plans nothing for the interface.
- `DOWN`: the link down, with no addresses and no routes.
- `JOIN`: the link up, and everything below.

## 4.2.2 The metric [*desired.metric]

Every route netd adds for an interface carries the interface's metric:
the profile's `Route.Metric` when it is set, otherwise 600 for a
`wireless` interface and 100 for anything else.

## 4.2.3 The MTU [*desired.mtu]

The profile's `Mtu.Value` when set. Otherwise, with `Mtu.Offered`, the
lease's MTU (DHCP option 26), else the routers' advertised MTU (§6.1).
Otherwise no MTU is desired and the link's MTU is left as it is.

## 4.2.4 IPv4 addresses [*desired.ipv4-addresses]

- every IPv4 entry of `Address.Static`, at its prefix;
- the lease's address at the lease's prefix (§5.3), when a lease is
  held;
- while the first INIT-REBOOT is pending, the requested address at its
  already-installed prefix (§2.1);
- the link-local address (§5.5) at prefix 16, only when the client has
  fallen back to one **and** no lease is held **and** the profile has no
  static address of either family.

The order in which they are listed, or written in `Address.Static`, has
no effect. netd adds the addresses an interface lacks in ascending
address order (§4.3), and the kernel makes an address primary only when
the interface holds no other address in the same subnet, so the first
one added in a subnet is its primary. Of two statics in one subnet written
`10.0.0.9/24, 10.0.0.3/24`, both added in one pass, `10.0.0.3` is the
primary.

While a lease that carried a broadcast address (option 28) is held, that
broadcast is the one netd sets on every IPv4 address it adds to the
interface: the lease's own address, and a static added in that time,
whatever its subnet (§4.3).

## 4.2.5 IPv4 routes [*desired.ipv4-routes]

- **The default route** goes via `Route.Gateway`'s IPv4 entry when it is
  set. Otherwise, with `Route.Offered`, it goes via the lease's gateway:
  the first router of option 3, or, when the lease carried classless
  routes, the gateway of the classless `0.0.0.0/0` route. With neither,
  there is no IPv4 default route.
- **Classless static routes** (option 121) with a prefix above zero are
  added, each via its gateway, only with `Route.Offered`.

During the first INIT-REBOOT, existing netd-marked IPv4 routes accompany
the provisional address when `Route.Offered` is enabled. The profile's
explicit gateway and current metric still take precedence (§2.1).

`Route.Gateway` is used as written; netd does not check that the gateway
is on a subnet the interface has. A gateway the kernel cannot reach makes
the route's add fail on every reconcile, logged each time (§11).

## 4.2.6 IPv6 addresses [*desired.ipv6-addresses]

1. every IPv6 entry of `Address.Static`, at its prefix, preferred, with
   a prefix route;
2. every address router discovery currently wants (§6.2): stable-privacy
   and temporary addresses, each deprecated or not, each with or without
   a prefix route according to the prefix's on-link flag.

## 4.2.7 IPv6 default route [*desired.ipv6-default-route]

Via `Route.Gateway`'s IPv6 entry when it is set. Otherwise, with
`Route.Offered`, via the default router router discovery has chosen
(§6.1). With neither, there is no IPv6 default route.

## 4.2.8 Not desired

netd never desires the kernel's own IPv6 link-local address, the
kernel's prefix routes, or any route without netd's protocol mark. Those
are outside what reconciliation compares (§4.3).

---

# 4.3 Reconciliation

_Peios / Peios technical reference / netd / Interfaces_

> How netd diffs an interface's desired state against the kernel's and applies the difference — what counts as netd's, the order of operations, how each is phrased to rtnetlink, and which failures are benign.

## 4.3.1 What netd owns [*reconcile.ownership]

On an interface with a desired state:

- **Every IPv4 address**, including a 169.254 address, and **every IPv6
  address except a link-local one** is netd's. An address netd does not
  desire is removed, whoever added it.
  [*reconcile.foreign-address-removed]
- **A route is netd's only if it carries routing protocol 200**
  (`RTPROT_NETD`). netd stamps it on every route it adds, and removes no
  route without it. [*reconcile.foreign-route-kept]
- **The kernel's IPv6 link-local addresses** (`fe80::/10`) are never
  compared, added or removed. Every up interface has one, and neighbour
  discovery needs it.

Only routes in the main table, of unicast type and with an output
interface, are read from the kernel at all.

## 4.3.2 The plan [*reconcile.plan-order]

For each interface with a desired state, netd plans these operations, in
this order:

1. **Link up**, if it is desired up and is down.
2. **MTU**, if one is desired, differs from the link's, and is at least
   68.
3. **Address deletions**: every address netd owns whose address and
   prefix length together match no desired address.
4. **Address additions**: every desired address the kernel does not hold
   with the same flags, in ascending address order, IPv4 before IPv6.
5. **Route deletions**: every protocol-200 route on the interface that is
   not desired.
6. **Route additions**: every desired route the kernel does not hold.
7. **Link down**, if it is desired down and is up — last, after its
   addresses and routes have gone.

Addresses compare by address and prefix length, plus the two flags netd
sets: deprecated and no-prefix-route. A tentative flag (duplicate address
detection still running) is erased before comparing. A change of flags
alone is therefore an addition of the same address, which replaces it
(below), never a delete and re-add that would reset standing connections.
[*reconcile.flag-change-is-a-replace]

An IPv4 address's broadcast address is not compared. An address keeps
the broadcast it was added with: a lease whose broadcast changes, or a
lease that arrives after a static was added, changes nothing on an
address the interface already holds.

Routes compare on everything: destination, prefix length, gateway, metric
and protocol. A change of metric or gateway is a deletion and an
addition. For IPv6 routes, configured metric zero uses the kernel default
of 1024: netd sends and compares that effective value, so an unchanged
route converges. IPv4 metric zero and all nonzero metrics are unchanged.
The configured `Route.Metric` still controls DNS scope ordering.

An interface whose desired state the kernel already matches gets an empty
plan, and nothing is sent. [*reconcile.converged-plan-is-empty]

## 4.3.3 Applying [*reconcile.apply]

Operations are applied one at a time, each as an rtnetlink request with
an acknowledgement:

| Operation | Request |
|---|---|
| link up or down | `RTM_SETLINK` changing only `IFF_UP` |
| MTU | `RTM_SETLINK` with `IFLA_MTU` |
| address add | `RTM_NEWADDR` with `NLM_F_CREATE` and `NLM_F_REPLACE` |
| address delete | `RTM_DELADDR` |
| IPv4 route add | `RTM_NEWROUTE` with `NLM_F_CREATE` and `NLM_F_APPEND`, main table, protocol 200 |
| IPv6 route add | `RTM_NEWROUTE` with `NLM_F_CREATE` and `NLM_F_EXCL`, main table, protocol 200 |
| route delete | `RTM_DELROUTE` |

IPv4 route additions append a distinct gateway/interface at the configured
metric. Two joined interfaces may therefore both hold default routes at the
same metric; adding one preserves the other, and withdrawing one removes
only its own route. Exact duplicates remain `EEXIST`. Equal-metric route
selection follows the kernel's routing behavior; set distinct metrics when
one interface should have a defined preference.
[*reconcile.equal-metric-ipv4-routes]

How addresses are phrased:

- An **IPv4** address carries `IFA_LOCAL` and `IFA_ADDRESS` set to
  itself, link scope for 169.254/16 and universe scope otherwise. Below
  a /31 it also carries a broadcast address: the broadcast of the lease
  the interface holds, if that lease gave one, otherwise the address's
  own subnet's all-ones address. The lease's broadcast goes on every
  IPv4 address added while the lease is held, statics included.
- An **IPv6** address carries universe scope and, when its prefix is not
  on-link, `IFA_F_NOPREFIXROUTE`. Its lifetimes are netd's to manage, so
  the kernel is told a valid lifetime of forever and a preferred lifetime
  of forever, or of zero when the address is deprecated. Source address
  selection then stops choosing a deprecated address, and connections
  already using it keep it. [*reconcile.ipv6-lifetimes-forever-or-deprecated]

A route with a gateway has universe scope, and one without has link
scope. A route carries a metric only when it is above zero.

A failed operation is logged as `reconcile: <operation> failed: <error>`
and the rest of the plan carries on, so one bad route never stops an
address landing. `EEXIST` on an add and `ENOENT` or `ESRCH` on a delete
mean the kernel got there first, and are not logged.
[*reconcile.failure-carries-on]

After every interface's plan is applied, netd dumps the kernel's state
again, so readiness and the status reply describe what actually landed.

---

# 5.1 The client

_Peios / Peios technical reference / netd / Dhcpv4_

> When netd runs a DHCPv4 client on an interface, what it is configured with, its states, and exactly what each message it sends carries.

netd's DHCPv4 client is the `dhcp4` crate: the RFC 2131 state machine as a
pure, clock-injected type, driven by netd's loop and sockets.

## 5.1.1 Starting and stopping [*dhcp4-client.start-stop]

On every full pass, for each joined interface:

- the interface **wants** a client when its profile has `Address.Offered`
  and IPv4 in its families;
- it **can** run one when it is up with carrier.

A running client is stopped (§5.4) in the step of the pass that brings
the interface table in line with the kernel's links, before clients are
started (§2.2):

- when the link loses carrier or goes down, logged as `interface <name>:
  carrier lost`;
- when the interface's outcome changes, logged as the new verdict
  (§3.3). Any edit to the profile the interface stands in is a change of
  outcome, so it stops the client even when the edited profile still
  wants one, and the same pass starts a new one.

Only a change of outcome changes what an interface wants, so a client its
interface no longer wants is always stopped the second way. A link that
leaves the kernel takes its client with it (§5.4).

An interface that wants and can run a client but has none starts one,
logging `interface <name>: dhcp starting`, provided:

- the link has a 6-byte hardware address — otherwise no client is started,
  silently;
- its packet socket opens (§5.6) — otherwise netd logs `no packet socket`
  and tries again on the next pass.

A new client starts with no lease. It is configured with:

| Setting | Value |
|---|---|
| `chaddr` | the interface's MAC |
| client identifier (option 61) | the interface's `ClientId` (§5.7) |
| hostname (option 12) | the machine's `Hostname`, only when the profile has `Hostname.Announce` and `Hostname` is set |
| randomness | 8 bytes of `/dev/urandom` XORed with the interface index, seeding transaction ids and jitter |
| a previous address | the `RequestedAddress` of the interface's network, if one is known; otherwise that of the network named by its `Status LastNetwork` (§5.7) |

With a previous address the client begins in **Rebooting** (RFC 2131
§3.2, INIT-REBOOT). Without one it begins in **Selecting**.
[*dhcp4-client.previous-address-starts-in-rebooting]

## 5.1.2 States

| State | Entered | Leaves on |
|---|---|---|
| Selecting | start without a previous address; a NAK while requesting or rebooting; a request that went stale; a reboot nobody confirmed; a lost lease | an acceptable OFFER |
| Requesting | an OFFER in Selecting | an ACK (Bound), a NAK (Selecting), four unanswered transmissions (Selecting) |
| Rebooting | start with a previous address | an ACK (Bound), a NAK (Selecting), two unanswered transmissions (Selecting) |
| Bound | an ACK | T1 (Renewing) |
| Renewing | T1; or an operator `renew` while bound, renewing or rebinding | an ACK (Bound), a NAK (lease lost, Selecting), T2 (Rebinding) |
| Rebinding | T2 | an ACK (Bound), a NAK or the lease's end (lease lost, Selecting) |

The status reply shows a client's state only inside its `lease`, which is
present only while the client holds a lease (§9.2), and `net status`
prints it on the `lease` line. So the state shown is always `bound`,
`renewing` or `rebinding`. A client in Selecting, Requesting or Rebooting
holds no lease, and neither shows a state for it. The `init` state exists
only between a stop and the client being discarded.

[*dhcp4-client.states]

A new transaction id is drawn on entering Selecting, Rebooting, Renewing
and Rebinding. Requesting keeps the id of the Selecting exchange it came
from. [*dhcp4-client.transaction-ids]

## 5.1.3 What every message carries [*dhcp4-client.common-fields]

Every message is a BOOTREQUEST with hardware type 1 and length 6, the
current transaction id, `chaddr`, and `secs` set to the whole seconds
since the client started, capped at 65535. Every message except a RELEASE
carries, in this order:

| Option | Content |
|---|---|
| 53 | the message type |
| 61 | the client identifier |
| 57 | maximum message size 1500 |
| 55 | the parameter request list: 1, 3, 6, 12, 15, 26, 28, 42, 51, 58, 59, 119, 121 |
| 12 | the hostname, when configured |

The encoded message ends with option 255 and is padded with zeros to 300
bytes. An option longer than 255 bytes would be cut to 255.

## 5.1.4 The messages [*dhcp4-client.messages]

| Message | When | Broadcast flag | `ciaddr` | Option 50 | Option 54 | Sent |
|---|---|---|---|---|---|---|
| DISCOVER | Selecting | set | 0 | the address of a lease that expired, until a NAK or a reboot timeout forgets it | — | broadcast |
| REQUEST | Requesting | set | 0 | the offered address | the offer's | broadcast |
| REQUEST | Rebooting | set | 0 | the previous address | — | broadcast |
| REQUEST | Renewing | clear | the lease address | — | — | unicast to the server |
| REQUEST | Rebinding | clear | the lease address | — | — | broadcast |
| RELEASE | a stop while holding a lease | clear | the lease address | — | the server | unicast to the server |

A RELEASE carries only options 53, 54 and 61.

"Broadcast" and "unicast" are the paths of §5.6: a broadcast leaves the
packet socket from `0.0.0.0` to `255.255.255.255`, rebinding included; a
unicast leaves an ordinary UDP socket bound to the lease address.

---

# 5.2 Timers

_Peios / Peios technical reference / netd / Dhcpv4_

> The DHCPv4 client's retransmission schedule in each state, when "nobody answered" is reported, and the lease boundaries T1, T2 and expiry.

Every timer is on netd's monotonic clock.

## 5.2.1 Retransmission while acquiring [*dhcp4-timers.backoff]

In Selecting, Requesting and Rebooting each transmission schedules the
next one after the current backoff plus a jitter drawn uniformly from −1 s
to +1 s, then doubles the backoff, up to 64 s. The backoff starts at 4 s
on entering each of the three states. So transmissions follow at roughly
4, 8, 16, 32, 64, 64, … seconds.

| State | Transmissions before it gives up | Then |
|---|---|---|
| Selecting | never gives up | — |
| Requesting | four REQUESTs (about 0, 4, 12 and 28 s) | at the next timer, about 60 s, Selecting with a new DISCOVER |
| Rebooting | two REQUESTs (about 0 and 4 s) | at the next timer, about 12 s, Selecting with a new DISCOVER and no option 50 |

[*dhcp4-timers.give-up-schedule]

## 5.2.2 "Nobody answered" [*dhcp4-timers.no-offer-once-per-client]

On the third retransmission of a DISCOVER — the fourth DISCOVER, about
28 s after the first — the client reports that no offer came. netd then
records the interface warning `asked for an address; nobody answered` and,
when the profile wants it, falls back to a link-local address (§5.5). The
client keeps discovering. The warning survives full reconciliation passes,
including the pass triggered by adding the link-local fallback. It clears
when a lease binds or that DHCP client stops (for example, on carrier loss
or a profile change). A rule-conflict warning takes display precedence;
rejudging an unchanged client does not erase its discovery warning.

In [source `23af1f6`](https://github.com/peios/netd/blob/23af1f6b84f3764d9327eb009ced56b2a16b7aa8/dhcp4/src/client.rs),
the report is made at most once between client starts: the flag is reset
only when a client is started. A client that reported it, then got a lease,
then lost it, does not report it again, so it does not fall back to a
link-local address a second time.

The [proposed source correction](https://github.com/peios/netd/blob/13d593fdf64b58b08edf901b6f5edb8535f6fe75/dhcp4/src/client.rs)
resets the flag after a valid ACK produces a lease. If that lease is later
lost, unanswered discovery can report again at the same fourth-DISCOVER
threshold, allowing the profile's link-local fallback to be requested
again. An uninterrupted unanswered acquisition still reports only once:
a malformed ACK, a NAK while Requesting, or a Requesting timeout does not
rearm it. This describes the proposed source behavior, not a released
package or an installed image; check the installed build before relying
on repeat fallback.

## 5.2.3 Lease boundaries [*dhcp4-timers.lease-boundaries]

A lease's T1, T2 and length are measured from the moment its ACK
arrived, and are fixed when the lease is read (§5.3).

- At **T1** a Bound client enters Renewing and sends a unicast REQUEST.
- At **T2** a Renewing client enters Rebinding and sends a broadcast
  REQUEST.
- At the **lease's end** a Rebinding client loses the lease (§5.4) and
  enters Selecting.

A boundary is acted on before any retransmission due at the same
moment.

## 5.2.4 Retransmission while renewing and rebinding [*dhcp4-timers.renewal-retransmit]

Each REQUEST in Renewing or Rebinding schedules the next for half the time
remaining to the state's next boundary (T2 while renewing, the lease's
end while rebinding), but never sooner than 60 s from now. With a short
lease the 60 s floor means a state's boundary usually arrives before its
second REQUEST: a 20 s lease renews once at 10 s and rebinds once at
17 s, then lapses at 20 s.

An ACK in either state starts the lease's clock again from that moment.

---

# 5.3 Replies and leases

_Peios / Peios technical reference / netd / Dhcpv4_

> Which DHCPv4 replies the client accepts, the server-identity lock, what makes an OFFER or an ACK acceptable, and how an ACK is read into a lease — prefix, routes, T1 and T2, and the options kept.

## 5.3.1 Which replies are looked at [*dhcp4-lease.reply-filter]

A received message is ignored unless all of these hold:

- it is a BOOTREPLY;
- its transaction id is the client's current one;
- its `chaddr` is the interface's MAC;
- it carries a message type (option 53) the client knows.

## 5.3.2 The server lock [*dhcp4-lease.server-lock]

Once a server has been chosen — the server of the lease held, or else the
server identifier of the offer being requested — an ACK or NAK is
accepted only if its option 54 names that server. A stranger who guesses
the transaction id cannot take a lease away or substitute one.

In Rebooting, with no lease and no offer, no server has been chosen, so
an ACK or NAK from any server is accepted.

## 5.3.3 What each state accepts [*dhcp4-lease.state-table]

| State | Message | Result |
|---|---|---|
| Selecting | OFFER with a non-zero `yiaddr` and a server identifier | taken: Requesting. The first acceptable OFFER wins; there is no comparison between offers. |
| Selecting | OFFER without either | ignored |
| Requesting, Rebooting, Renewing, Rebinding | ACK that reads as a lease (below) | Bound, with the lease |
| Requesting, Rebooting, Renewing, Rebinding | ACK that does not read as a lease | ignored; retransmission continues |
| Requesting, Rebooting | NAK | Selecting; the previous address and the offer are forgotten |
| Renewing, Rebinding | NAK | the lease is lost (§5.4); Selecting |
| any other combination | | ignored |

An ACK in Renewing or Rebinding that grants a different address replaces
the lease. The next reconcile removes the old address and adds the new.

## 5.3.4 Reading an ACK [*dhcp4-lease.ack-requirements]

An ACK is a lease only when it has:

- a non-zero `yiaddr`;
- a server identifier (option 54);
- a lease time (option 51) of exactly 4 bytes, and at least 4 seconds.

A lease time under 4 s is refused: it cannot hold T1 < T2 < lease, and a
server asking for it wants the client to spin.

### 5.3.4.1 The prefix [*dhcp4-lease.prefix]

From the subnet mask (option 1) when it is a non-zero run of contiguous
ones. Otherwise, absent or malformed, from the address's class: /8 for
a first octet up to 127, /16 up to 191, /24 above.

### 5.3.4.2 Routes [*dhcp4-lease.routes]

Classless static routes (option 121, RFC 3442) are read as a sequence of
prefix length, the significant destination octets, and a 4-byte gateway.
In [source `23af1f6`](https://github.com/peios/netd/blob/23af1f6b84f3764d9327eb009ced56b2a16b7aa8/dhcp4/src/packet.rs),
a prefix length above 32, or an entry running past the end of the
option, makes the whole ACK fail to read as a lease: it is ignored like
any other unusable ACK.

The [proposed source correction](https://github.com/peios/netd/blob/98f46343f6cdff62b1d9fcb886baf383c3943ac5/dhcp4/src/packet.rs) instead
discards the entire malformed optional route list, including any valid
entries before the malformed one, and keeps an otherwise-valid lease.
The existing router-option (3) fallback then applies; without that
option, the lease has no routes or gateway. This recovery does not relax
whole-message or option framing, the mandatory lease requirements above,
or the client and server-identity checks. It describes the proposed
source policy, not a released package or installed image, and does not
assert an RFC requirement to recover malformed route lists.

When the decoded classless route list is nonempty, the router option (3)
is ignored. The
lease's gateway is the first router of option 3, or, when classless
routes are present, the gateway of the classless route with prefix 0.

### 5.3.4.3 T1 and T2 [*dhcp4-lease.t1-t2]

- **T1** is option 58 when it is above 0 and below the lease time minus
  2. Otherwise it is half the lease time.
- **T2** is option 59 when it is above T1 and below the lease time.
  Otherwise it is seven-eighths of the lease time, rounded down, raised to
  at least T1 + 1 and capped at the lease time minus 1.

So T1 < T2 < the lease time always holds, whatever the server sent.

### 5.3.4.4 Other options kept [*dhcp4-lease.options-kept]

| Option | Kept as |
|---|---|
| 6 | DNS servers, every 4-byte entry |
| 15 | the domain name: the bytes up to the first NUL; dropped if not UTF-8 |
| 119 | the domain search list, RFC 1035 names with compression pointers followed, at most 16 jumps per name |
| 12 | the hostname offered: the bytes up to the first NUL; dropped if not UTF-8 |
| 26 | the MTU, when it is at least 68 |
| 28 | the broadcast address |
| 42 | NTP servers, every 4-byte entry |

Where an option appears twice, its first occurrence is used.
Nothing else in the ACK is kept.

How each of these is used — and whether, by the profile — is in §4.2
(addresses, routes, MTU), §8.3 (DNS and NTP) and §8.4 (the hostname).

## 5.3.5 The record of a lease

A lease is logged as `interface <name>: lease <address>/<prefix> from
<server> for <seconds>s`. Binding clears the interface's link-local
address and its warning, and marks the interface for re-judgement,
because the network might now be identifiable (§7.1).
[*dhcp4-lease.bound-clears-link-local-and-warning]

---

# 5.4 Losing a lease

_Peios / Peios technical reference / netd / Dhcpv4_

> How a DHCPv4 lease ends — expiry, a NAK, or the client being stopped — what Address.OnExpiry decides, and when a RELEASE is sent.

## 5.4.1 Lost by the network [*dhcp4-loss.lost]

A lease is **lost** when a Rebinding client reaches the lease's end
(§5.2), or when a NAK arrives while renewing or rebinding (§5.3). The
client forgets the lease and enters Selecting, and netd acts on the
interface's `Address.OnExpiry`:

- **`Drop`** (the default): netd logs `interface <name>: lease lost` and
  forgets the lease. The next reconcile removes the address and the routes
  that came with it. [*dhcp4-loss.drop-removes-the-address]
- **`Keep`**: netd logs `interface <name>: lease lost; keeping the address
  (Address.OnExpiry = Keep)` at warning level and goes on desiring the
  lost lease — its address, gateway and routes — while the client looks
  for a new one. The kept lease is replaced the moment a new one binds.
  [*dhcp4-loss.keep-holds-the-lease]

With `Keep`, the status reply's `lease` describes the client, not the
kept lease: it disappears with the client's lease, while the address
stays on the interface.

`Keep` lasts while the client lasts. A stop (below) forgets the kept lease
as it would any other.

## 5.4.2 Stopped by netd [*dhcp4-loss.stop-releases]

netd stops a client when:

- the interface's outcome changes: another verdict, another profile, or
  any value of its profile edited (§3.3);
- the interface loses carrier (§4.1);
- the profile no longer wants a client (§5.1).

A client in Bound, Renewing or Rebinding sends a RELEASE to its server
first: unicast from the lease address (§5.1, §5.6). After a carrier loss
that RELEASE goes out on a link with no carrier, so the server never sees
it. The client is then discarded, and the interface's lease and
link-local address are forgotten, so the next reconcile removes both
addresses.

A link that disappears from the kernel takes its client with it and
sends nothing.

## 5.4.3 The operator's renew [*dhcp4-loss.operator-renew]

The control request `renew` (§9.2), and so `net renew <interface>`, moves
a client in Bound, Renewing or Rebinding into Renewing at once: a new
transaction id and a unicast REQUEST to the lease's server. The lease is
not released first, and its address stays on the interface throughout.
A client that holds no lease is left as it is, and the request still
answers `ok`.

---

# 5.5 Link-local fallback

_Peios / Peios technical reference / netd / Dhcpv4_

> The 169.254/16 address netd self-assigns when DHCPv4 discovery goes unanswered — when, which address, and when it goes.

With `Address.LinkLocal` in the interface's profile, an interface whose
DHCPv4 discovery goes unanswered gets an IPv4 link-local address
(RFC 3927), so two machines on a cable with no server can still reach
each other.

## 5.5.1 When [*linklocal.when]

At the client's "nobody answered" report (§5.2), about 28 s after
discovery began, netd logs `interface <name>: no DHCP offer; link-local
<address>` and the interface desires the address. Discovery carries on
throughout.

The address is desired at prefix 16 only while no lease is held and the
profile has no `Address.Static` entry of either family (§4.2). A profile
with a static address therefore never shows one, even after the report.
[*linklocal.not-beside-a-lease-or-static]

## 5.5.2 Which address [*linklocal.address-derivation]

A pure function of the interface, so it is the same every time:

- the seed is the last four bytes of the MAC, read as a big-endian
  32-bit number, or, for a link with no MAC, the interface index times
  2654435761, wrapping at 32 bits;
- the host part is 256 plus the seed modulo 64768;
- the address is `169.254.<host / 256>.<host % 256>`.

That lands in 169.254.1.0 to 169.254.254.255, the range RFC 3927 allows
for self-assignment.

netd does not probe for a conflicting holder or announce the address
(RFC 3927 §2.2 and §2.4). Two machines whose MACs share their last four
bytes choose the same address.

## 5.5.3 When it goes [*linklocal.dropped-on-lease-or-stop]

The link-local address is forgotten when a lease binds, and when the
client is stopped (§5.4). The next reconcile removes it, because netd owns
every IPv4 address on a joined interface (§4.3).

---

# 5.6 Sockets

_Peios / Peios technical reference / netd / Dhcpv4_

> The sockets behind netd's DHCPv4 client — the per-interface packet socket and its filter, the broadcast path from 0.0.0.0, the unicast path from the lease address, and the udp/68 absorber.

Before an interface has an address, nothing but a packet socket can send
from `0.0.0.0`. So every DHCPv4 broadcast leaves through `AF_PACKET`, with
an IPv4 and UDP header netd builds itself, and every reply is read from
the same socket.

## 5.6.1 The packet socket [*dhcp4-sockets.packet-socket]

One per running client: `AF_PACKET`, `SOCK_DGRAM`, protocol `ETH_P_IP`,
bound to the interface. In the proposed corrected source, a classic BPF filter
passes only an IPv4 UDP datagram that is not a fragment and is addressed
to port 68. It refuses both a non-zero fragment offset and the
more-fragments flag; the don't-fragment flag is allowed. Everything read
is checked again for an IPv4 header and UDP port 68, then decoded as a
DHCP message.

In versions 0.1.7 (described by this manual) and 0.1.8, the filter tests
only the fragment offset: a later fragment is refused but a first
fragment passes and is decoded as if it were the whole datagram. The
proposed source correction above is not present in those releases.

The socket sees every frame on the interface that passes the filter, so
it receives both broadcast replies and replies unicast to the lease
address.

## 5.6.2 The broadcast path [*dhcp4-sockets.broadcast-frame]

DISCOVER, REQUEST in Requesting and Rebooting, and REQUEST in Rebinding go
out on the packet socket to Ethernet `ff:ff:ff:ff:ff:ff`, as one IPv4
datagram:

| Field | Value |
|---|---|
| source | `0.0.0.0` |
| destination | `255.255.255.255` |
| TOS | `0x10` |
| identification | 0 |
| flags | don't-fragment |
| TTL | 64 |
| UDP ports | 68 → 67 |
| UDP checksum | 0 (none, which IPv4 permits) |

## 5.6.3 The unicast path [*dhcp4-sockets.unicast-renewal]

REQUEST in Renewing, and RELEASE, go out on a transient `AF_INET` UDP
socket with `SO_REUSEADDR` and `SO_BINDTODEVICE` set to the interface,
bound to the lease address on port 68, sent to the server on port 67, and
closed. The kernel resolves the server's link-layer address. The reply
is read from the packet socket.

Binding port 68 meets the kernel's port reservation for ports 1 to 1023,
which the shipped seed grants to SYSTEM, netd's identity.

## 5.6.4 The absorber [*dhcp4-sockets.absorber]

At startup netd binds one more UDP socket, to `0.0.0.0:68` with
`SO_REUSEADDR`, and discards whatever arrives on it. Its only purpose is
that a server's unicast reply finds a bound port, so the kernel does not
answer it with ICMP port-unreachable. The packet sockets see the same
datagrams. If it cannot be bound, netd runs without it (§2.1).

---

# 5.7 Identity and memory

_Peios / Peios technical reference / netd / Dhcpv4_

> How netd names the machine and each interface to a DHCP server — the DUID and the per-interface client identifier — and how it remembers which address a network gave it.

A DHCP server recognises a client by its client identifier, and a
reservation on the server matches on it. netd keeps the identifier stable
across boots and NIC swaps in the same slot, and remembers per network
what it was given.

## 5.7.1 The DUID [*dhcp4-memory.duid]

The machine's DHCP unique identifier, shared by DHCPv4 and DHCPv6
(RFC 4361), is decided once per netd process, the first time a client
needs it:

1. the registry's `Machine\System\Network Duid`, as hex, when it is set
   and parses;
2. otherwise the file `/var/state/netd/duid`, when it holds at least 4
   bytes;
3. otherwise a new DUID-LL: `00 03 00 01` followed by the MAC of the
   interface whose client is starting. It is written to
   `/var/state/netd/duid`; a failure to write is logged and changes
   nothing else.

When the registry's `Duid` is absent, empty or not hex, the result of 2
or 3 is written to it, as colon-separated lower-case hex, replacing a
value that was not hex. [*dhcp4-memory.duid-written-back]

Hex in `Duid` (and `ClientId`) can be written with `:`, `-` or spaces
between digits, or none. A value with any other character, an odd number
of digits, or no digits is not hex.

## 5.7.2 The client identifier [*dhcp4-memory.client-id]

Per interface, `Interfaces\<id> ClientId`:

- when the value is set and is non-empty hex, it is sent as written;
- when it is absent, the identifier is generated and written back:
  `ff`, then a 4-byte IAID, then the DUID. The IAID is the interface id's
  text folded into 4 bytes by XOR, byte *i* of the text into byte
  *i* mod 4;
- when it is set but not hex, netd logs `<id>: ClientId "<value>" is
  not hex; regenerating`, where `<id>` is the interface id, generates
  one, and overwrites the value.

`ClientId` is a value both netd and the operator write. Writing it is
how an operator makes netd present the identifier a server reservation
expects. It is read when a client starts, so it takes effect at the next
start (a carrier loss, a profile edit, a restart of netd).

## 5.7.3 The address a network gave [*dhcp4-memory.requested-address]

While an interface holds a lease on an identified network (§7.1), netd
writes the lease address to that network's `Networks\<id>
RequestedAddress` whenever it differs from what is there.

When a client starts, it takes as its previous address (§5.1):

1. the `RequestedAddress` of the network currently identified on the
   interface, when there is one; otherwise
2. the `RequestedAddress` of the network the interface's `Status
   LastNetwork` names (§8.1).

With one, the client opens with an INIT-REBOOT REQUEST for it. A server
that agrees answers with an ACK and the machine keeps the address in one
round trip. One that refuses answers with a NAK and the client
discovers afresh. Silence for two transmissions does the same (§5.2).

`RequestedAddress` is also the operator's: writing it is a soft
reservation, an address the client asks for first and the server can
refuse. [*dhcp4-memory.requested-address-is-a-soft-reservation]

Nothing about a lease itself — its timers, its server, its options — is
persisted. A machine that reboots asks for its address again.

---

# 6.1 Router discovery

_Peios / Peios technical reference / netd / Ipv6_

> How netd takes router advertisements away from the kernel, when it solicits and how often, which advertisements it accepts, and how it chooses a default router.

## 6.1.1 The kernel's RA handling is off [*rdisc.kernel-accept-ra-off]

netd writes `0` to `/proc/sys/net/ipv6/conf/<name>/accept_ra` for `all`,
for `default`, and for every interface listed there, at startup before
any link is brought up (§2.1). It writes it again for each non-loopback
interface the first time it sees it. A missing file — a kernel without
IPv6 — is a warning only for `all`.

The kernel still creates link-local addresses and answers neighbour
solicitations. That is the protocol, not policy. Everything an
advertisement could configure is netd's to decide.

## 6.1.2 When router discovery runs [*rdisc.start-stop]

On every full pass, for each joined interface:

- it **wants** router discovery when its profile has `Address.Offered`
  and IPv6 in its families;
- it **can** run it when it is up with carrier and the kernel's
  link-local address on it has finished duplicate address detection. A
  tentative address cannot source a solicitation.

Running discovery that is no longer wanted or possible is stopped (`ipv6
stopping`): the engine and any DHCPv6 client are discarded, and the next
reconcile removes their addresses and route. Nothing is sent on the
wire, because neither protocol has a goodbye. An interface that wants and
can run it starts it (`soliciting routers`), provided its ICMPv6 socket
opens — otherwise netd logs `no icmpv6 socket` and tries again on the
next pass.

## 6.1.3 The socket [*rdisc.socket]

One raw ICMPv6 socket per interface, bound to it with `SO_BINDTODEVICE`,
filtered to receive only type 134 (router advertisement), sending with a
multicast hop limit of 255, and asking for each received packet's hop
limit. The kernel computes and checks ICMPv6 checksums.

## 6.1.4 Solicitation [*rdisc.solicitation-schedule]

A router solicitation is type 133, code 0, with a source link-layer
address option carrying the interface's MAC, sent to `ff02::2` with hop
limit 255.

The first is sent as soon as discovery starts. The interval is 4 s for
the next two, then doubles before each later one, up to 3600 s. Each
interval carries a jitter of ±10%. So solicitations go at about 0, 4, 8,
16, 32, 64, … s.

Soliciting stops when an advertisement arrives with a router lifetime
above zero. It begins again, from the first interval, as soon as every
default router has expired. [*rdisc.solicit-again-when-routers-gone]

## 6.1.5 Which advertisements count [*rdisc.acceptance]

An advertisement is dropped unless it arrived with hop limit 255 and from
a link-local source ([RFC 4861 §6.1.2](https://datatracker.ietf.org/doc/html/rfc4861#section-6.1.2)). A lower hop limit has crossed a
router, and a non-link-local source is not a router on this link.
[*rdisc.hop-limit-and-source-checks]

It is then decoded. The whole advertisement is refused when:

- it is shorter than 16 bytes, or its type is not 134 or its code not 0;
- an option has length zero, or runs past the end of the message.

[*rdisc.malformed-advertisement-refused]

Within an accepted advertisement, an option that does not parse is
skipped and the rest stand:

| Option | Accepted when | Limits |
|---|---|---|
| Prefix information (3) | 32 bytes long; prefix length ≤ 128; the prefix neither multicast nor link-local | the first 16 per advertisement |
| MTU (5) | 8 bytes long | |
| RDNSS (25) | at least 24 bytes, and a whole number of addresses | 16 servers per option; unspecified, multicast and loopback addresses dropped |
| DNSSL (31) | at least 16 bytes | 16 domains per option; names uncompressed, lower-cased, labels of printable ASCII, 253 characters at most; a malformed name ends the list |

[*rdisc.option-acceptance]

Any other option is ignored. So are the advertisement's hop limit,
reachable time and retransmission timer.

## 6.1.6 What an advertisement does [*rdisc.effects]

- **Router lifetime above zero**: the source becomes, or stays, a default
  router until that many seconds from now. **Zero**: the source is
  removed as a default router. Its prefixes, servers and domains stay.
- **M or O flag**: stateless DHCPv6 is wanted (§6.3). Once set it stays
  set until discovery restarts; a later advertisement without the flags
  does not withdraw it. [*rdisc.m-or-o-latches-dhcpv6]
- **MTU option**: remembered as the link's advertised MTU. It is used only
  with `Mtu.Offered`, only when no lease gave one, and only when it is at
  least 68 (§4.2, §4.3).
- **Prefixes**: §6.2. **RDNSS and DNSSL**: §6.3.

Whenever what the interface should carry changes — an address, its
deprecation, the default router, the servers, the domains, the MTU, the
DHCPv6 flag — netd runs a full pass. That includes a change caused by time
alone: a lifetime running out is a change.

## 6.1.7 The default router [*rdisc.default-router-is-lowest-address]

Of the routers whose lifetime has not run out, netd uses the one with the
numerically lowest link-local address, so the choice is stable while the
set is. Router preference (RFC 4191) is not read.

---

# 6.2 Addresses

_Peios / Peios technical reference / netd / Ipv6_

> Stateless address autoconfiguration as netd does it — which prefixes form addresses, the stable-privacy derivation and its secret, lifetimes and the two-hour rule, deprecation, and temporary addresses.

## 6.2.1 Which prefixes form addresses [*slaac.prefix-acceptance]

A prefix information option forms addresses only when its A flag is set,
its prefix length is exactly 64, and its preferred lifetime does not
exceed its valid lifetime. Any other prefix option is ignored whole. A
prefix with only the L flag gets no address, and no on-link route,
because the kernel's RA handling is off.

A prefix seen for the first time is taken only with a valid lifetime
above zero.

## 6.2.2 The stable address [*slaac.stable-privacy-derivation]

Each accepted prefix gets one stable address (RFC 7217): the prefix's
first 64 bits, followed by the first 8 bytes of a SHA-1 digest of:

1. the bytes `peios-ndp-stable-iid|`;
2. the machine's 32-byte secret;
3. the prefix's first 8 bytes;
4. the interface id's text;
5. a one-byte counter, starting at 0.

When the resulting interface identifier is reserved (RFC 5453: all zeros,
`0200:5eff:fe00::/40`, or `fdff:ffff:ffff:ff80` to
`fdff:ffff:ffff:ffff`), the counter is incremented and the digest taken
again, up to 8 times.

So the same machine on the same network has the same address on every
boot, a different network sees an unrelated address, and no MAC address
ever appears in it. The address is a function of the interface id, so a
kernel rename does not change it either.
[*slaac.same-machine-same-network-same-address]

### 6.2.2.1 The secret [*slaac.secret]

The 32-byte secret is read from `/var/state/netd/secret` the first time an
interface starts router discovery. When the file does not hold exactly
32 bytes, a new secret is drawn from `/dev/urandom` and written there; a
failure to write is logged and the new secret used for the rest of the
process's life. Losing the secret renumbers the machine, and does
nothing else.

## 6.2.3 Lifetimes [*slaac.lifetimes]

A lifetime of `0xffffffff` is forever. Others count from the moment the
advertisement arrived.

When an advertisement updates a prefix already held:

- the on-link flag and the preferred lifetime are taken as advertised;
- the valid lifetime uses the two-hour rule from
  [RFC 4862 §5.5.3(e)](https://www.rfc-editor.org/rfc/rfc4862.html#section-5.5.3),
  with the infinite-lifetime exception described below:

| Advertised valid lifetime | Remaining valid lifetime | Result |
|---|---|---|
| above two hours, or above what remains | any finite | the advertised lifetime |
| two hours or less, not above what remains | two hours or less | unchanged |
| two hours or less, not above what remains | above two hours | two hours from now |
| above two hours | forever | the advertised lifetime |
| two hours or less | forever | forever |

[*slaac.two-hour-rule]

The table records the 0.1.7 behaviour this manual describes; it is also
present in [netd 0.1.8 source](https://github.com/peios/netd/blob/23af1f6b84f3764d9327eb009ced56b2a16b7aa8/ndp/src/engine.rs#L220-L237).
The final row is a known deviation: infinity is above two hours, so
[RFC 4862 §5.5.3(e)(3)](https://www.rfc-editor.org/rfc/rfc4862.html#section-5.5.3)
requires **two hours from the
new advertisement**, including when the advertised lifetime is zero.
A source build containing the proposed source correction uses that deadline;
it still accepts advertised lifetimes above two hours, including
infinity, and takes the preferred lifetime as advertised. This source
correction does not establish that an installed package or image has
changed.

When a prefix's preferred lifetime runs out, its addresses are
**deprecated**: kept on the interface for standing connections, with a
preferred lifetime of zero so that nothing new chooses them (§4.3). When
its valid lifetime runs out, the prefix and its addresses are removed.
[*slaac.deprecate-then-remove]

## 6.2.4 Flags on the address

An address in a prefix advertised **on-link** (L set) is added normally,
so the kernel adds the prefix route. One advertised off-link is added
with `IFA_F_NOPREFIXROUTE`, so the kernel adds no prefix route for it.
[*slaac.off-link-gets-no-prefix-route]

## 6.2.5 Temporary addresses [*slaac.temporary-addresses]

With `Address.Temporary` in the profile, each prefix also carries
temporary addresses (RFC 8981):

- one is formed with the prefix when the prefix arrives with a preferred
  lifetime above zero;
- its interface identifier is random, avoiding the reserved identifiers,
  the stable address and the prefix's other temporaries;
- its preferred lifetime is one day minus a random 0 to 599 s, so a rack
  of machines does not regenerate together, and its valid lifetime is two
  days. Each is cut to the prefix's own lifetimes.

When the newest temporary is no longer preferred while the prefix still
is, a new one is formed. The old one stays, deprecated, until its valid
lifetime ends. A prefix keeps at most four temporaries, dropping the
oldest beyond that.

A temporary is deprecated when its own preferred lifetime or its
prefix's has run out, and removed when its own valid lifetime or its
prefix's has.

`Address.Temporary` is read when router discovery starts. Changing it is a
profile edit, which restarts discovery (§3.3).

---

# 6.3 DNS and DHCPv6

_Peios / Peios technical reference / netd / Ipv6_

> The DNS servers and search domains routers advertise, and the stateless DHCPv6 information-request client netd runs when routers ask for it — when it starts, what it sends, what reply it accepts, and when it asks again.

## 6.3.1 RDNSS and DNSSL [*v6dns.rdnss-dnssl-lifetimes]

Each server of an RDNSS option, and each domain of a DNSSL option, is held
for the option's lifetime from the moment the advertisement arrived.
`0xffffffff` is forever, and a lifetime of zero removes that server or
domain at once. A server or domain advertised again has its lifetime
replaced.

## 6.3.2 Stateless DHCPv6

Peios gets IPv6 addresses by SLAAC alone. DHCPv6 is used for
configuration only: an INFORMATION-REQUEST (RFC 8415 §18.2.6) for DNS
servers and search domains. The stateful address exchange is not
implemented, and an advertisement's M flag is treated as O.

### 6.3.2.1 When it runs [*dhcp6.start-stop]

A DHCPv6 client starts on an interface once its router-discovery engine
has seen an advertisement with M or O (§6.1), provided the interface has
a non-tentative link-local address and a MAC, and its socket opens. netd
logs `interface <name>: dhcpv6 information request`. It stops with router
discovery.

### 6.3.2.2 The socket

UDP, bound with `SO_BINDTODEVICE` to the interface and to its link-local
address on port 546, so two interfaces' clients never share a port.
Messages go to `ff02::1:2` port 547.

### 6.3.2.3 The request [*dhcp6.information-request]

An INFORMATION-REQUEST (type 11) with a random 3-byte transaction id and
these options:

| Option | Content |
|---|---|
| 1, client identifier | the machine's DUID (§5.7) |
| 8, elapsed time | hundredths of a second since this exchange began, capped at 65535 |
| 6, option request | 23 (DNS servers), 24 (domain list), 32 (information refresh time) |

The first is sent when the exchange begins. Retransmissions follow after
1 s, doubling up to 3600 s, each with a jitter of ±10%, and never stop
until a reply is accepted. [*dhcp6.retransmit-schedule]

### 6.3.2.4 The reply [*dhcp6.reply-acceptance]

A datagram is accepted only while an exchange is running, and only when:

- it is a REPLY (type 7) with the exchange's transaction id;
- it carries a client identifier equal to the machine's DUID;
- it carries a non-empty server identifier.

Anything else is ignored, and retransmission continues. From an accepted
reply, option 23 gives the DNS servers — when its length is a whole
number of addresses, up to 16, with unspecified, multicast and loopback
addresses dropped — and option 24 the search domains, read as a DNSSL's
are (§6.1).

### 6.3.2.5 Refresh [*dhcp6.refresh-time]

After a reply, the client asks again after the reply's information
refresh time (option 32), or one day when there is none, but never sooner
than 600 s. Each refresh is a new exchange with a new transaction id.

An answer that differs from the last logs `dhcpv6 answered` and leads to a
reconcile and a publish. An identical one changes nothing.

## 6.3.3 Where these go

Advertised and DHCPv6 servers and domains reach name resolution only
through the profile's `Dns.Offered`, including link-local servers with
the interface supplied by their DNS scope (§8.3). They are always part of what a network has shown
for its identity and its record (§7).

---

# 7.1 Identification

_Peios / Peios technical reference / netd / Networks_

> How netd decides which network is on the other side of an interface — the signals, the identity derived from them, when identification runs, and what a newly identified network does.

netd has to say "this is the same network as last time" with nothing on
the wire that says so. The identity is a guess from what the network has
offered, recorded with every signal it was made from, so that a later
proof can replace the guess without changing the record's key.

## 7.1.1 Signals [*netid.signals]

What a joined interface's network has shown, at the moment of the pass:

| Signal | From |
|---|---|
| kind | the interface's `Interface.Kind` |
| server | the DHCPv4 lease's server identifier |
| subnet | the lease address masked to the lease prefix |
| gateway | the lease's gateway (§5.3) |
| router | the default router router discovery chose (§6.1) |
| prefixes | the subnet, then every non-deprecated autoconfigured address's prefix, each once |
| DNS servers | the lease's, then the routers' RDNSS servers, link-local ones included |

## 7.1.2 The identity [*netid.derivation]

- With a lease (a server and a subnet), the basis is
  `dhcp:<server>|<subnet>/<prefix>`.
- Otherwise, with a default router and at least one autoconfigured
  prefix, the basis is `ra:<router>|<prefix>/<length>`, using the first
  prefix.
- Otherwise there is no identity yet: nothing has been offered, or the
  interface is static only.

The network id is a SHA-1 digest of the bytes `peios-netd-network|`, the
kind, `|`, and the basis, stamped and written as a version 5 UUID exactly
as an interface id is (§4.1).

So two leases from the same server in the same subnet are the same
network, whatever the address. A different server, a different subnet,
or a different kind of interface is a different network. An IPv4 lease
always wins over the IPv6 basis, so a dual-stack network is identified by
its DHCPv4 server. [*netid.same-server-and-subnet-same-network]

## 7.1.3 When [*netid.when]

Identification is the first step of every full pass (§2.2). It runs only
on a joined interface that is up with carrier, and only when the signals
yield an identity.

For each identity it creates or refreshes the network's record (§7.2) and
reads back what the operator wrote on it. While a lease is held, it
writes the lease address to the record's `RequestedAddress` (§5.7).

## 7.1.4 A newly identified network [*netid.new-network-rejudges]

When the identity differs from the network the interface already had —
including when it had none — netd logs `interface <name>: network
<name or id>`, with ` (trust <trust>)` when the record has one. It then
marks the interface for re-judgement, so the same pass judges it with the
`Network.*` facts present (§3.3). A rule conditioned on `Network.Name`
or `Network.Trust` takes effect then. If it moves the interface to
another profile, the interface's clients restart under that profile
(§3.3).

The network an interface stands on is kept while it has carrier, even
when a profile switch restarts the clients that identified it. A switch
that its own identification triggered would otherwise forget the network
and switch back. Losing carrier forgets it (§4.1).
[*netid.sticky-while-carrier]

The interface's `Status Network` names the identified network (§8.1). The
kernel reads it to give the packet layers their `Network.*` facts
(PKM §6.5), so it never names a network the link is not on.

---

# 7.2 Records

_Peios / Peios technical reference / netd / Networks_

> The Networks\<id> record — what netd writes under its Status, what the operator writes on the record, and how the two are read.

Every network a joined interface has been identified on gets a record,
`Machine\System\Network\Networks\<id>`. Records are never deleted by
netd. [*netrec.never-deleted]

## 7.2.1 What netd writes [*netrec.status-values]

On every identification (§7.1), netd creates the record if it is missing
and writes these values under its `Status` subkey, each only when it
differs from what is there:

| Value | Type | Content |
|---|---|---|
| `Kind` | `REG_SZ` | the interface kind it was seen on |
| `Server` | `REG_SZ` | the DHCPv4 server; removed when there is none |
| `Gateway` | `REG_SZ` | the lease's gateway; removed when there is none |
| `Router` | `REG_SZ` | the IPv6 default router; removed when there is none |
| `Prefixes` | `REG_MULTI_SZ` | the subnet and the autoconfigured prefixes, CIDR form |
| `DnsServers` | `REG_MULTI_SZ` | the DNS servers offered, either family |
| `LastSeen` | `REG_SZ` | the wall-clock time of the identification, in Unix seconds |
| `LastInterface` | `REG_SZ` | the kernel name of the interface it was seen on |

`LastSeen` is rewritten on every pass that identifies the network, so a
record's `Status` changes whenever netd runs a pass on a link that is on
it.

`Status` is created with the descriptor `O:SYG:SYD:P(A;;KA;;;SY)(A;;KR;;;WD)`:
SYSTEM has full control and Everyone has read access. It is protected, so the
hive root's inheritable grants do not reach it, and a hand edit is
refused rather than silently reverted at the next pass. The descriptor is
set only when netd creates the key; a `Status` key that already exists
keeps whatever descriptor it has. [*netrec.status-descriptor]

## 7.2.2 What the operator writes [*netrec.operator-values]

On the record itself, not under `Status`:

| Value | Read as | Meaning |
|---|---|---|
| `Name` | `REG_SZ`, empty is unset | the `Network.Name` fact |
| `Trust` | `REG_SZ`, empty is unset | the `Network.Trust` fact |
| `RequestedAddress` | `REG_SZ`, an IPv4 address | the address the next DHCPv4 client asks for first (§5.7); netd writes it too |

`Name` and `Trust` are read at every identification, so an edit reaches
the interface layer on the pass that follows the registry write (§2.3).
They are opaque: netd compares nothing about them and gives them no
vocabulary. [*netrec.name-and-trust-are-read-each-pass]

The kernel reads `Name` and `Trust` itself, from the record that an
interface's `Status Network` names (PKM §6.5).

---

# 8.1 The inventory

_Peios / Peios technical reference / netd / What Netd Publishes_

> What netd writes under Interfaces\<id>\Status for every interface it has seen, when each value is set or removed, the descriptor on Status, and the values netd shares with the operator.

## 8.1.1 The record [*inventory.record]

Every non-loopback interface netd has seen has a key
`Machine\System\Network\Interfaces\<id>`, named by its interface id
(§4.1). netd creates it on the first pass that sees the interface. The
key is the operator's, apart from `ClientId`, a value both write (§5.7).
netd's findings are under its `Status` subkey.

Records are never deleted. The record of a card that has been removed
stays, with what netd last wrote.

## 8.1.2 Status [*inventory.status-values]

| Value | Content | Present |
|---|---|---|
| `Name` | the kernel's name for the link | always |
| `Kind` | `wired`, `wireless` or `other` | always |
| `Mac` | the hardware address, lower-case, colon-separated | when the link has one |
| `Path` | the bus path | when there is one |
| `Driver` | the driver | when there is one |
| `Verdict` | `JOIN`, `IGNORE` or `DOWN` | when a rule spoke; removed when the backstop answered or rules tied |
| `Rule` | the path of the rule that spoke, or the tied rules as `<a> vs <b>` | when a rule spoke or rules tied; removed when the backstop answered |
| `Profile` | the profile path as written | `JOIN` only |
| `Readiness` | the interface's level (§8.2) | `JOIN` only |
| `Network` | the id of the network identified on the link now | while one is identified; removed when the carrier goes |
| `LastNetwork` | the id of the network the link last stood on | from the first identification; never removed |

[*inventory.value-presence]

Every value is `REG_SZ`. A value is written only when it differs from
what is there, and removed only when it is there, so a pass that changes
nothing writes nothing to the registry. [*inventory.writes-only-on-change]

`Status` is refreshed on every full pass for every non-loopback interface,
and again at each publish (§2.2) for joined ones, so `Readiness` follows
a reconcile that moved it without any link event.

`Status Network` is read by the kernel to give the packet layers their
`Network.*` facts (PKM §6.5). It names the network only while netd has
identified one on the link (§7.1).

## 8.1.3 The descriptor [*inventory.status-descriptor]

`Status` is created with the descriptor
`O:SYG:SYD:P(A;;KA;;;SY)(A;;KR;;;WD)`: SYSTEM has full control, Everyone
has read access, and the DACL is protected, so the hive root's inheritable grant
to Administrators does not reach it. Only netd, as SYSTEM, can write
there. An administrator's hand edit is refused at the write, not
reverted at the next pass. The descriptor is set only when netd creates
the key.

## 8.1.4 The machine's values

On `Machine\System\Network` itself netd writes:

- `Readiness`, the machine level (§8.2);
- `Duid`, when it was absent and netd generated or read one (§5.7).

---

# 8.2 Readiness

_Peios / Peios technical reference / netd / What Netd Publishes_

> The four readiness levels, how netd computes one for each interface and for the machine, and how the machine level reaches peinit and the registry.

"The network is up" means different things to different services, so
netd reports a level, not a flag. Levels are ordered:
`absent` < `link` < `addressed` < `routed`.

## 8.2.1 An interface's level [*readiness.interface-level]

From the kernel's state after the last reconcile:

| Level | When |
|---|---|
| `absent` | the interface is not up, or has no carrier |
| `link` | up with carrier, and no usable address |
| `addressed` | a usable address, and no default route |
| `routed` | a usable address, and a default route on the interface |

A **usable** address is any IPv4 address other than a loopback one —
a 169.254 link-local address counts, because it is the outcome of address
acquisition — and any IPv6 address that is neither loopback nor
link-local. Every up interface has an IPv6 link-local address, so
counting one would make `addressed` mean nothing. Deprecated and
tentative addresses count. [*readiness.usable-address]

A **default route** is any route with prefix length 0, of either family,
in the main table, out of the interface. It need not be one of netd's.
[*readiness.any-default-route-counts]

## 8.2.2 The machine's level [*readiness.machine-level]

The highest level among joined interfaces, or `absent` when none is
joined. An `IGNORE`d or `DOWN` interface contributes nothing, whatever
state it is in.

## 8.2.3 Publishing it [*readiness.publish-on-change]

At every publish (§2.2) netd computes the machine level. When it differs
from the last one published — and at the first publish after startup,
which precedes `READY=1` — netd:

1. logs `machine readiness is <level>`;
2. sends the complete readiness set below in one datagram on the notify
   socket named by `NOTIFY_SOCKET`;
3. writes `Readiness` on `Machine\System\Network`.

The notify set is determined by the highest machine level:

| Machine level | `LEVEL` values, in wire order |
|---|---|
| `absent` | `absent` |
| `link` | `link` |
| `addressed` | `link`, `addressed` |
| `routed` | `link`, `addressed`, `routed` |

Each value has its own `LEVEL=` line in the same datagram. The highest
level comes last, so an older manager that retains only the last value
continues to see the highest level. Upgrade peinit before or alongside
netd to make every member available to dependents.

The set is sent only on a change. The notify channel is lossy by design
and unacknowledged (PSPU §4.16), and a level is a statement of a current
condition, so a lost one is corrected by the next change.

Each joined interface's own level is written as `Status Readiness`
(§8.1).

## 8.2.4 What peinit makes of it [*readiness.peinit-matches-the-published-level]

peinit records the set netd last sent against the sending service, and
resolves the `network` role to that service. A dependency on
`network:<level>` waits until the service is active and its published set
contains the requested level (peinit TRM §7.5).

A DHCPv4 lease that installs an address and default route in the same
pass takes the highest machine level directly from `link` to `routed`.
The notify datagram contains `link`, `addressed` and `routed`, so both
`network:addressed` and `network:routed` dependents can start. peinit needs
no knowledge of network level ordering; netd publishes every condition
that holds. Losing the link replaces this set with `absent`, preventing
new starts on any of the three previous conditions. Dependents already
running keep running.

The registry, log and control status continue to report the highest
machine level. `net wait <level>` polls over the control socket and
succeeds when that level is at least the one named (§10.1).

---

# 8.3 The resolver channel

_Peios / Peios technical reference / netd / What Netd Publishes_

> netd's side of the network-manager channel of PSPU §6.9 — the subscribe request, the snapshot and every scope field, how DNS facts are merged from profile and offer, and when snapshots are sent.

The channel's contract — that a resolver subscribes, and receives whole
snapshots, never deltas — is PSPU §6.9. This article is netd's half of
it.

## 8.3.1 Subscribing [*snapshot.subscribe]

A `subscribe` request on the control socket (§9.2), which needs the query
right, is answered with a snapshot at once, and the connection is kept:
switched to non-blocking and added to the subscriber list. It is the one
request that does not end its connection.

The first snapshot is the last one published, or, before any has been,
one computed on the spot.

## 8.3.2 When snapshots are sent [*snapshot.sent-on-change]

At every publish (§2.2) netd computes the snapshot and compares it with
the last one sent. If it differs, it is sent to every subscriber. If it
is equal, nothing is sent.

A subscriber that cannot take the whole frame at once — closed, or so far
behind that its socket buffer is full — is dropped. A resolver reconnects
and receives a fresh snapshot. [*snapshot.slow-subscriber-dropped]

## 8.3.3 The snapshot [*snapshot.contents]

A reply map with `ok` = true, `kind` = `snapshot`, `hostname` — the name
netd last set (§8.4), empty if none — and `scopes`: one scope per joined
interface whose level is above `absent`, ordered by the interface's
metric, then its kernel index. [*snapshot.scope-order-and-membership]

Each scope:

| Field | Content |
|---|---|
| `ifid` | the interface id |
| `name` | the kernel name |
| `servers` | DNS servers, as text, in the order to try them (below) |
| `domains` | search domains, likewise |
| `ntp` | the lease's NTP servers (option 42) |
| `addresses` | every address the kernel holds on the interface, `address/prefix`, link-local ones included |
| `default_route` | the profile's `Dns.Default` when set; otherwise whether the interface's level is `routed` |
| `exclusive` | the profile's `Dns.Exclusive` |
| `metric` | the interface's metric (§4.2) |
| `level` | the interface's level (§8.2) |

[*snapshot.scope-fields]

## 8.3.4 Merging DNS facts [*snapshot.dns-merge]

Servers, in this order:

1. the profile's `Dns.Servers`, always;
2. with `Dns.Offered`: the lease's servers (option 6);
3. with `Dns.Offered`: the routers' RDNSS servers, including link-local IPv6;
4. with `Dns.Offered`: the DHCPv6 reply's servers, including link-local IPv6.

Domains, in this order:

1. the profile's `Dns.Domains`, always;
2. with `Dns.Offered`: the lease's domain search list (option 119) if it
   is not empty, otherwise its domain name (option 15);
3. with `Dns.Offered`: the routers' DNSSL domains;
4. with `Dns.Offered`: the DHCPv6 reply's domains.

Each list removes duplicates across the entire list, retaining the first
occurrence and the source order above. This includes repeated values with
other values between them.
[*snapshot.first-occurrence-deduplication]

The containing scope supplies the interface for link-local IPv6 servers.
resolvd uses its index on UDP and TCP upstream sockets; the address text
does not need a zone suffix. Deploy this publication change with a resolver
that supports interface-scoped upstreams (resolvd TRM §4.7).

NTP servers are reported whatever `Dns.Offered` says. Whether to believe
a lease's time servers is timed's decision, made against its own
registry value. A snapshot describes what the network said.
[*snapshot.ntp-regardless-of-dns-offered]

The same merge feeds the `dns` and `search` fields of the status reply
(§9.2), for every non-loopback interface, joined or not.

---

# 8.4 The hostname

_Peios / Peios technical reference / netd / What Netd Publishes_

> Where netd's hostname comes from — the registry or a lease — when it calls sethostname, and what it does not do.

## 8.4.1 Which name [*hostname.source]

1. `Machine\System\Network Hostname`, when it is set and not empty;
2. otherwise the hostname option (12) of a lease. netd takes the first
   joined interface, in kernel index order, whose profile has
   `Hostname.Offered` and whose lease carries a hostname;
3. otherwise none.

The selected name is checked by `libnetd::hostname`, the same rule used
by System Settings and first-boot setup: after trimming outer whitespace,
it must be one nonempty label of at most 63 ASCII letters, digits or
hyphens, with no leading or trailing hyphen, and must not be `localhost`
(case-insensitive). This applies to registry and lease names alike.
[*hostname.passed-as-written]

An invalid selected name leaves the kernel's name and netd's last applied
name unchanged. It is logged as `hostname <quoted name> refused: <quoted
reason>`, with control characters escaped, once while the same rejected
candidate remains selected. A different candidate, a valid name, or no
name clears that suppression. A nonempty but invalid registry name still
takes precedence: it does not silently fall through to a lease. Likewise,
the first selected lease name is checked without searching later leases
for a replacement.

A valid name that the kernel nevertheless refuses is logged as
`sethostname(<name>): <error>` and tried again at the next pass.

## 8.4.2 When [*hostname.applied-after-each-pass]

After every full pass and every reconcile-only iteration (§2.2), netd
calls `sethostname(2)` if the name differs from the last one **it** set,
and logs `hostname is <name>`. It does not compare against the kernel's
current hostname. A change made by anything else is not reverted until
netd's own name changes.

When there is no name — the registry value removed and no lease offering
one — netd does nothing: the kernel keeps whatever hostname it had.
[*hostname.never-unset]

The status reply and the resolver snapshot report the name netd last
set, empty if it has set none.

## 8.4.3 What is recorded

Each `sethostname(2)` that changes the kernel's name writes a
`netd.hostname.changed` event, which is `essential`: the emission policy
cannot switch it off. It carries `config.name` (always `Hostname`, whether
the name came from the registry or from a lease), `config.text` (the name
now in force) and `config.text-previous` (the kernel's name, read just
before the call; the kernel's own `(none)` on the first change after boot).
A call that leaves the kernel's name as it was — netd setting a name the
kernel already holds, as after a restart of netd or a change made behind
its back — records nothing, and neither does a name the kernel refuses.
[*hostname.change-recorded]

The record's `subject.token.sid` is the principal that acted (PGSS §6.4).
When the change is made during the pass a `reconcile` request (§9.2) asked
for, that is the requester's user SID; netd refuses a request whose user
it cannot read. A change netd makes on its own authority — after a
registry change, a lease, or at startup — names netd's own user, SYSTEM;
who wrote the registry value is LCS's to record.
[*hostname.requester-recorded]

## 8.4.4 Telling the network

With `Hostname.Announce` in an interface's profile, a newly started DHCPv4
client sends the validated, trimmed registry `Hostname` in option 12
(§5.1). An invalid registry name is omitted. An existing client's
announcement configuration is unchanged until it restarts. A name
adopted from a lease is never announced.

---

# 9.1 The socket and its object

_Peios / Peios technical reference / netd / The Control Socket_

> netd's control socket — its path and descriptors, the control object and its default descriptor, the access rights and their generic mapping, and how each connection is checked.

## 9.1.1 The socket [*control.socket-path-and-descriptor]

netd listens on `/run/netd/control.sock`, a `SOCK_STREAM` Unix socket. At
startup it:

1. creates `/run/netd` if it is missing, sets its mode to `0755`, and
   gives it the descriptor below;
2. removes a socket file left by a previous run, logging `removed a stale
   /run/netd/control.sock`;
3. binds, sets the socket file's mode to `0666`, and gives it the same
   descriptor.

The descriptor on both: owner and group SYSTEM, and a DACL allowing SYSTEM
`GENERIC_ALL` and Everyone `GENERIC_READ | GENERIC_WRITE |
GENERIC_EXECUTE`. peinit creates `/run` SYSTEM-only and inheritable, so
without it nobody else could reach the socket. Everyone can connect; the
control object decides what each connection can do. A failure to set it is logged
as an error and netd carries on.

## 9.1.2 The control object [*control.object-default-descriptor]

Every request is checked against the **control object**, a security
descriptor netd holds in memory. It is
`Machine\System\Network ControlSecurity`, a self-relative descriptor in a
`REG_BINARY` value, when that is set and valid. When it is not valid,
netd logs `ControlSecurity is not a valid descriptor (…); using the
default`.

The compiled default: owner and group SYSTEM, and a DACL of:

| Trustee | Rights |
|---|---|
| SYSTEM | `NETWORK_ALL_ACCESS` |
| Administrators | `NETWORK_ALL_ACCESS` |
| Everyone | `NETWORK_QUERY`, `READ_CONTROL` |

The object is rebuilt whenever the configuration changes (§2.3), so a
written `ControlSecurity` applies to the next connection.
[*control.object-follows-control-security]

## 9.1.3 Rights [*control.rights-and-mapping]

| Right | Value |
|---|---|
| `NETWORK_QUERY` | `0x00000001` |
| `NETWORK_CONTROL` | `0x00000002` |
| `NETWORK_ALL_ACCESS` | `0x000F0003`: both, plus the standard rights |

The generic mapping:

| Generic right | Maps to |
|---|---|
| read | `NETWORK_QUERY`, `READ_CONTROL` |
| write | `NETWORK_CONTROL`, `READ_CONTROL` |
| execute | `NETWORK_QUERY` |
| all | `NETWORK_ALL_ACCESS` |

## 9.1.4 The check [*control.access-check-on-peer-token]

For each request, netd opens the connecting peer's token through KACS and
runs a real access check of the request's right (§9.2) against the
control object. It never looks at `SO_PEERCRED`, so a deny-only group or
a filtered token is judged as the kernel would judge it anywhere else. A
peer whose token cannot be opened is denied, and so is one whose token's
user cannot be read, since a change made for it is recorded under that
user (§8.4).

A denied request is answered `{ok: false, error: "access denied"}`, and
nothing else happens.

## 9.1.5 Nonblocking client I/O [*control.serial-and-blocking]

The control socket is served on netd's one thread (§2.2). Accepted
connections are nonblocking and polled alongside network and registry
inputs. An idle client, partial request, or client that does not read its
reply does not hold the loop in a socket read or write.

At most 64 requests or replies are pending at once. While full, netd
stops polling the listener until a slot becomes free; connections still
waiting in the kernel backlog have not been accepted. Each iteration
accepts at most 16 connections and reads or writes at most 4 096 bytes
per ready pending connection. The 65 536-byte message ceiling is checked
from the header before reading a request payload (§9.2).

A request must complete within two seconds of acceptance. A normal reply
must be written within two seconds of being queued. Partial progress does
not extend either deadline. Expiry closes the connection without a
timeout reply. These deadlines participate in the same poll timeout as
network-client timers. Completed subscriptions leave this pending-client
set and use the existing nonblocking snapshot publisher (§8.3).

---

# 9.2 Requests

_Peios / Peios technical reference / netd / The Control Socket_

> The control socket's framing and limits, its four requests and the rights they need, the errors it answers with, and every field of the status reply.

## 9.2.1 Framing [*control-req.framing]

The control socket is a PSPU observability query channel: one
length-prefixed MessagePack map in each direction, one request and one
reply per connection, except `subscribe`.

- A frame is a 4-byte little-endian payload length, then the payload.
- A length above 65536 is refused before any of the payload is read, with
  `{ok: false, error: "message of <n> bytes exceeds the ceiling"}`.
- A request is a map with a string `query`. Unknown keys are ignored, and
  a key that appears twice refuses the request.
- A reply is a map with a boolean `ok`, and either the result's fields or
  a string `error`.

An oversized, incomplete-at-EOF, or undecodable request is answered with an error reply
and the connection ends:

| Problem | `error` |
|---|---|
| no `query` | `missing field query` |
| an unknown `query` | `unknown query "<query>"` |
| a duplicate key | `duplicate field <key>` |
| `renew` without `interface` | `missing field interface` |
| malformed MessagePack | `malformed message: …` |

[*control-req.decode-errors]

Expired client deadlines and failed socket I/O close the connection without
a reply (§9.1). A normal reply that exceeds the message ceiling is replaced
with an error reporting its size.

## 9.2.2 The requests [*control-req.requests]

| `query` | Right | Does | Reply |
|---|---|---|---|
| `status` | `NETWORK_QUERY` | nothing | the status (below) |
| `subscribe` | `NETWORK_QUERY` | keeps the connection as a subscriber (§8.3) | a snapshot now, and another on each change |
| `reconcile` | `NETWORK_CONTROL` | a full pass (§2.2) | `{ok: true}` |
| `renew`, with `interface` | `NETWORK_CONTROL` | the client renews (§5.4) | `{ok: true}`, or an error |

`renew` finds its interface by kernel name or by interface id. It answers
`no interface <name>` when there is none, and `<name> is not using DHCP`
when the interface has no DHCPv4 client. [*control-req.renew-errors]

## 9.2.3 The status reply [*control-req.status-fields]

| Field | Content |
|---|---|
| `hostname` | the name netd last set, empty if none (§8.4) |
| `level` | the machine level (§8.2) |
| `refusal` | why the newest generation was refused, or nil (§3.2) |
| `interfaces` | one map per non-loopback interface netd has seen, joined or not, in kernel index order |

Each interface:

| Field | Content |
|---|---|
| `ifid`, `name`, `index` | the interface id, kernel name and index |
| `mac`, `path`, `driver` | identity (§4.1); empty strings when absent |
| `verdict` | `JOIN`, `IGNORE` or `DOWN`; nil when the backstop answered or rules tied |
| `rule` | the rule that spoke, `backstop`, or the tied rules `<a> vs <b>` |
| `profile` | the profile path, `JOIN` only |
| `network`, `network_name`, `network_trust` | the network identified now (§7) |
| `warning` | discovery unanswered (§5.2), or a tie (§3.3); nil otherwise |
| `up`, `carrier` | the link's flags |
| `level` | the interface's level (§8.2), computed for every interface |
| `addresses` | every address the kernel holds, `address/prefix`, link-local ones included |
| `gateway`, `gateway6` | the gateway of the interface's first IPv4 and IPv6 default route, any owner's |
| `dns`, `search` | the merged DNS facts (§8.3); empty for an interface not joined |
| `lease` | while the client holds a lease: `server`, `expires_in` (whole seconds), `state` (§5.1), `duration` (the lease's length), and `renew_at` and `rebind_at` (T1 and T2), the last three in whole seconds after the lease was bound; nil otherwise. A client reading a netd that predates the last three finds them absent. |

[*control-req.status-interface-fields]

---

# 10.1 Commands

_Peios / Peios technical reference / netd / The Net Command_

> The net command as built — each subcommand, what it reads, how net wait and net policy wait decide, and its exit statuses.

`net` is netd's operator command. It talks to netd over the control
socket, and reads the registry directly for `rules` and `profiles`,
because those are configuration that netd merely executes. Changing the
network is a registry write; `net` shows state and pokes the daemon. The
task-oriented guide is `~peios/networking/the-net-command`.

## 10.1.1 Subcommands [*net.subcommands]

| Command | Does |
|---|---|
| `net status` | prints the status reply (§9.2) |
| `net renew <interface>` | sends `renew` |
| `net reconcile` | sends `reconcile` |
| `net rules` | prints `Rules\Interface` from the registry: one line per rule, indented by depth, with its priority, `(disabled)` for `Enabled = 0`, its conditions, and its actions (`NULL` when it has none) |
| `net profiles`, `net profile list` | prints `Profiles\` from the registry: one line per profile, indented by depth, with the values it sets itself (`(inherits only)` when none) and `(disabled)` |
| `net wait <link\|addressed\|routed> [seconds]` | waits for the machine level |
| `net policy` | prints the kernel engine's status: enforcing or not, the generation, unwalked changes, a refused last walk, the context count |
| `net policy wait [seconds]` | waits for the packet policy to be in force |
| `net version`, `net --version` | prints `net <version>` |

`rules` and `profiles` fail when their key does not exist. Neither
needs netd running.

## 10.1.2 `net status` output [*net.status-output]

A `hostname` line (`(unset)` when empty) and a `readiness` line, then
`policy     REFUSED: <why> (the last good generation stands)` when there
is a refusal. Then a block per interface: `<name>  [<id>]`, then `verdict`
(`<VERDICT>(<profile>) by <rule>`, or `(none) by <rule>`), `state`, and
`readiness` for a joined interface. Then, where present, `hardware`,
`network` (`<name> [<id>] trust <trust>`), each `address`, `gateway`,
`gateway6`, `dns`, `search`, `lease` (`<state> from <server>, <n>s
left`) and `warning`.

## 10.1.3 `net wait` [*net.wait]

It asks for `status` every 500 ms and succeeds as soon as the machine
level is at least the one named. An error reaching netd is printed and
the wait goes on. The timeout is the second argument, or 60 s when absent
or not a number. When it lapses, `net` prints `net: timed out waiting for
<level>` and fails.

`absent` is accepted as a level, and is met at once.

## 10.1.4 `net policy wait` [*net.policy-wait]

It reads `/dev/peios-ntfe`'s status (ABI 5) once and takes the number of
policy changes the engine has noted as its target. It then polls every
5 ms until the engine's count of changes walked reaches the target. If
the last walk was refused, it fails, naming the errno and the generation
that stands; otherwise it succeeds. The timeout is the argument, or 10 s.
The counters are the kernel's (PKM §6.5). A device that answers with
another ABI fails at once.

## 10.1.5 Exit status [*net.exit-status]

| Code | Meaning |
|---|---|
| 0 | success |
| 1 | netd unreachable, an error reply, a timeout, a refused policy, or a registry key that cannot be opened |
| 2 | usage: an unknown subcommand, a wrong argument count, an unknown level, or a non-numeric `policy wait` timeout |

---

# 11.1 Failure modes

_Peios / Peios technical reference / netd / Failure Modes_

> What goes wrong with netd, what each failure looks like from outside, where the evidence is, and how to get back to a known state.

## 11.1.1 Where the evidence is

- **`net status`**: the policy refusal, each interface's verdict and the
  rule that gave it, its warning, its addresses and gateways, and its
  lease's state.
- **netd's log.** Informational lines go to standard output; warnings and
  errors go to standard error. peinit forwards both to eventd
  (`evctl 'LOGS FROM netd SINCE 1h ago TAKE 40'`). Each line is also
  mirrored to `/dev/kmsg` at `KERN_INFO`, `KERN_WARNING` or `KERN_ERR`, so it
  reaches the kernel log and, at a permissive console log level, the
  console. If the mirror cannot be opened, netd says so once on standard
  error and tries again on the next line. Each mirrored line opens and
  closes its own `/dev/kmsg` descriptor, so boot and reconciliation
  bursts do not lose lines to the kernel's per-open rate limit (normally
  ten lines in five seconds). All three severities are retained. The
  mirror remains best-effort if opening or writing the device fails;
  stdout/stderr delivery is independent. [*failure.log-mirrored-to-kmsg]
- **The registry**: `Interfaces\<id>\Status`, `Networks\<id>\Status`,
  `Readiness`.

## 11.1.2 No address on a joined interface

| What `net status` shows | Likely cause |
|---|---|
| `warning    asked for an address; nobody answered`, a 169.254 address or none | no DHCPv4 server answered. Either there is none, or NTFE refused the exchange: the shipped baseline's DHCP rules were deleted or overridden. netd cannot see the verdict stream. |
| no `lease` line, no warning | the profile does not offer the address (`Address.Offered`, `Address.Families`), the interface has no MAC, or the packet socket would not open (logged). Or a server is answering but its ACK does not read as a lease: no server identifier, a lease time under 4 s, or a malformed classless route option (§5.3). The client then cycles through Selecting and Requesting, neither of which is shown (§5.1). |
| no IPv6 address | no advertisement arrived, or none was accepted: hop limit under 255, a non-link-local source, a prefix that is not an autonomous /64 (§6.1, §6.2). Also check `Address.Families`. |

## 11.1.3 The wrong interfaces are configured

| What `net status` shows | Meaning |
|---|---|
| `policy     REFUSED: …` | the newest generation was refused (§3.2). Everything shown is still the last good generation. Fix the named rule or profile; the next write is read at once. |
| `verdict    (none) by backstop` | no rule speaks for the interface. It is left as the kernel left it. |
| `verdict    (none) by <a> vs <b>`, warning `rules … tie` | two rules tied on priority and named different profiles (§3.3). The interface is ignored until one outranks the other. |
| an interface that was joined is now `IGNORE`d, with its old addresses still on it | by design: `IGNORE` touches nothing, removal included (§3.3) |

## 11.1.4 Changes that do not stick

- **An address added by hand disappears.** netd owns every address on a
  joined interface, and the next pass removes it (§4.3). Add it to
  `Address.Static` instead.
- **A route added by hand stays,** unless it carries protocol 200.
- **A pinned gateway never appears.** `Route.Gateway` is not checked
  against the interface's subnets. When the kernel cannot reach it, every
  reconcile logs `reconcile: AddRoute(…) failed`, and the interface stays
  `addressed`.
- **The hostname does not change back** when `Hostname` is removed. netd
  never unsets a name (§8.4).

## 11.1.5 Losing what was working

- **A cable pull drops everything offered** — the lease, autoconfigured
  addresses, routes, the network identity — and stops the clients
  (§4.1). When the carrier returns, the DHCPv4 client asks for the old
  address first, so a cooperative server restores it in one exchange.
- **A lease that cannot be renewed drops its address** at the lease's
  end, unless the profile says `Address.OnExpiry = Keep` (§5.4). After
  that, the link-local fallback does not happen again in the same
  client's life (§5.2).
- **Editing a profile restarts its interfaces' clients** (§3.3), so a
  DHCPv4 lease is released and asked for again.

## 11.1.6 netd is slow or unresponsive

- **Everything pauses for seconds at a time.** A program that connects to
  the control socket and does not send its request holds netd's single
  loop for up to 2 s per connection (§9.1). Look for a misbehaving client
  of `/run/netd/control.sock`.
- **`net: cannot reach netd at /run/netd/control.sock`.** netd is not
  running, or `/run/netd` lost its descriptor. The service restarts
  netd always; `evctl` shows why it stopped.

## 11.1.7 Configuration not read

- **netd ignores every registry change.** The watch could not be armed at
  startup (`registry watch unavailable … configuration is read once`), or
  `Machine\System\Network` did not exist then (§2.1). Restart netd.

## 11.1.8 Getting back to a known state

1. Undo the last registry write. A refused generation names what to undo,
   and the next good write is taken at once.
2. `net reconcile` runs a full pass without waiting for an event.
3. `net renew <interface>` renews a lease without releasing it.
4. Restarting netd re-derives everything from the registry and the
   kernel, and asks for the remembered IPv4 address with INIT-REBOOT (§2.1).
   The requested address already installed and applicable netd-owned routes
   stay while that request is pending. A NAK or unanswered revalidation
   discards this provisional state and starts fresh discovery; an unchanged
   ACK keeps IPv4 connectivity in place. Offered DNS and IPv6 information
   still have to be reacquired.

---

# Appendix A Constants

_Peios / Peios technical reference / netd_

> Every fixed number, path and identifier netd uses, in one table, with the section that describes it.

## A.1 Paths

| Path | Use |
|---|---|
| `Machine\System\Network` | configuration root, readiness, DUID (§2.3, §8) |
| `/run/netd/control.sock` | control socket (§9.1) |
| `/run/netd` | runtime directory, created by peinit (§2.1) |
| `/var/state/netd/duid` | the DUID, when the registry has none (§5.7) |
| `/var/state/netd/secret` | the RFC 7217 secret (§6.2) |
| `/usr/sbin/netd` | the daemon |
| `/proc/sys/net/ipv6/conf/<name>/accept_ra` | set to 0 (§6.1) |

## A.2 Numbers

| Constant | Value | § |
|---|---|---|
| Routing protocol stamped on netd's routes | 200 | §4.3 |
| Default route metric | 100; 600 for `wireless` | §4.2 |
| Minimum MTU applied | 68 | §4.3 |
| Converge repetitions per pass | at most 4 | §2.2 |
| Registry tree depth read | 16 | §2.3 |
| Control message ceiling | 65536 bytes | §9.2 |
| Control read and write timeout | 2 s | §9.1 |
| DHCPv4 first backoff, ceiling | 4 s, 64 s, ±1 s jitter | §5.2 |
| DISCOVER retransmissions before "nobody answered" | 3 | §5.2 |
| REQUESTs in Requesting before rediscovery | 4 | §5.2 |
| REQUESTs in Rebooting before rediscovery | 2 | §5.2 |
| Renewing and rebinding retransmission floor | 60 s | §5.2 |
| Shortest lease accepted | 4 s | §5.3 |
| DHCPv4 maximum message size advertised | 1500 | §5.1 |
| DHCPv4 message padding | to 300 bytes | §5.1 |
| Domain-search compression jumps per name | 16 | §5.3 |
| Link-local range | 169.254.1.0–169.254.254.255 | §5.5 |
| Router solicitation interval, ceiling | 4 s, 3600 s, ±10% jitter | §6.1 |
| Solicitations before backoff | 3 | §6.1 |
| Prefixes, RDNSS servers, DNSSL domains per advertisement option | 16 | §6.1 |
| SLAAC prefix length | 64 | §6.2 |
| Two-hour rule | 7200 s | §6.2 |
| Temporary address preferred lifetime | 86400 s minus 0–599 s | §6.2 |
| Temporary address valid lifetime | 172800 s | §6.2 |
| Temporary addresses per prefix | 4 | §6.2 |
| Stable address counter attempts | 8 | §6.2 |
| DHCPv6 first retransmission, ceiling | 1 s, 3600 s, ±10% jitter | §6.3 |
| DHCPv6 refresh default, floor | 86400 s, 600 s | §6.3 |
| DHCPv6 servers and domains kept | 16 each | §6.3 |

## A.3 Identifiers

| Identifier | Value | § |
|---|---|---|
| Interface id digest prefix | `peios-netd-ifid\|` | §4.1 |
| Network id digest prefix | `peios-netd-network\|` | §7.1 |
| Stable address digest prefix | `peios-ndp-stable-iid\|` | §6.2 |
| DUID-LL header | `00 03 00 01` | §5.7 |
| `Status` descriptor | `O:SYG:SYD:P(A;;KA;;;SY)(A;;KR;;;WD)` | §8.1 |
| `NETWORK_QUERY`, `NETWORK_CONTROL`, `NETWORK_ALL_ACCESS` | `0x1`, `0x2`, `0x000F0003` | §9.1 |
