mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-05 11:08:25 +00:00
Merge branch 'maint' into master
This commit is contained in:
@@ -138,9 +138,11 @@ jobs:
|
||||
bash namcap-gate.sh PKGBUILD
|
||||
echo "::endgroup::"
|
||||
echo "::group::makepkg build"
|
||||
# --nocheck: skip the PKGBUILD check() (cargo test --lib); the test
|
||||
# suite is already covered by ci.yml. This job validates packaging.
|
||||
makepkg -s --noconfirm --nocheck
|
||||
# No --nocheck. makepkg runs check() by default, so every AUR user
|
||||
# runs it and this job must too. ci.yml runs the tests, but not this
|
||||
# way: frozen and offline against what prepare fetched, with the
|
||||
# Arch toolchain, in this container.
|
||||
makepkg -s --noconfirm
|
||||
echo "::endgroup::"
|
||||
echo "::group::namcap built package"
|
||||
bash namcap-gate.sh ./*.pkg.tar.*
|
||||
|
||||
@@ -359,6 +359,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
is still the oldest Debian-family distribution, which is no longer the oldest
|
||||
distribution outright.
|
||||
|
||||
#### Identity and config
|
||||
|
||||
- An ephemeral node no longer writes `fips.key`. It wrote the private key of
|
||||
an identity it discards at every restart to that file, overwriting any key
|
||||
already there, including an operator's key when `persistent: true` had been
|
||||
forgotten. It now writes only `fips.pub`, so the running npub stays visible,
|
||||
and holds the private key in memory only. A `fips.key` found at an ephemeral
|
||||
start is moved to `fips.key.unused` with a warning, so an ephemeral node logs
|
||||
that warning once, on its first start after the upgrade; if that name is
|
||||
taken or the rename fails, the file is left in place and a warning says so.
|
||||
For a stable identity, set `node.identity.persistent: true` and restart: the
|
||||
first persistent start generates and saves a key, so the npub changes once,
|
||||
at that restart, and is stable from then on. Starting once in ephemeral mode
|
||||
and then pinning the key it wrote no longer works.
|
||||
|
||||
#### Gateway
|
||||
|
||||
- The gateway's default DNS listen address is now `[::1]:5365`; it was
|
||||
`[::1]:5353`, the mDNS port, which the daemon's LAN rendezvous, Avahi and
|
||||
systemd-resolved can hold. On OpenWrt the init script now points dnsmasq at
|
||||
whatever port `gateway.dns.listen` sets, and an upgrade rewrites the
|
||||
previously shipped `listen: "[::1]:5353"` line. On other hosts, a resolver
|
||||
you configured by hand to forward `.fips` to `[::1]:5353` must now forward
|
||||
to `[::1]:5365`, or set `gateway.dns.listen: "[::1]:5353"` to keep the old
|
||||
port. The gateway warns at startup when it is configured on 5353.
|
||||
|
||||
#### Packaging (Debian)
|
||||
|
||||
- An upgrade of the `.deb` now reapplies the firewall ruleset in place. Until
|
||||
@@ -394,6 +420,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
maintainer rather than flagged by an advisory. What it buys is that a fresh
|
||||
checkout can resolve the lockfile without reaching for a yanked version.
|
||||
|
||||
#### Documentation (native API)
|
||||
|
||||
- The native API documentation now says that on Linux an empty datagram sent
|
||||
immediately before a close may read as end of file, and is then not
|
||||
delivered. Linux carries the flow on `SOCK_SEQPACKET`, where a zero-length
|
||||
datagram that is the last message before a close cannot be told apart from
|
||||
the close; macOS and FreeBSD carry it on `SOCK_DGRAM` and are not affected.
|
||||
The `Received::Datagram` rustdoc, which said an empty datagram is never a
|
||||
close, now says where the exception applies.
|
||||
|
||||
### Removed
|
||||
|
||||
- **Source-breaking for consumers of the library crate**: `ActivePeer` no
|
||||
@@ -615,6 +651,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
resends when they show a loss, and resends once after 30 seconds when the
|
||||
reports cannot confirm it. Resends over one peer connection are limited to
|
||||
six a minute, and to one a minute while losses persist.
|
||||
- A spanning-tree announce lost on the link is now resent. A node counted a
|
||||
tree announce as delivered once the transport accepted it, so after a
|
||||
dropped datagram or a link outage shorter than the dead timeout the peer kept
|
||||
our old tree position until the periodic re-broadcast, up to a minute later,
|
||||
and a node with only one peer had no periodic re-broadcast at all. Meanwhile
|
||||
the peer could leave destinations out of discovery or route toward them by
|
||||
stale coordinates. The node now confirms each tree announce from the link's
|
||||
receiver reports, as it does for bloom filter announces, resends it when
|
||||
they show a loss, and resends it once after 30 seconds when they cannot
|
||||
confirm it, under the same limits.
|
||||
|
||||
#### Session setup
|
||||
|
||||
@@ -788,6 +834,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
warning says which limit refused it. A name that already has a mapping is
|
||||
answered before either limit is consulted, so names in use keep resolving
|
||||
when the pool is full. The limits are compiled in, not configured.
|
||||
- `fips-gateway` exits when its DNS listener cannot bind, or stops while the
|
||||
gateway runs, instead of staying up with `.fips` resolution dead, so systemd
|
||||
or procd restarts it or reports it failed. This applies to a gateway used
|
||||
only for port forwards too. An "address in use" error names the service
|
||||
likely to hold the port. On an OpenWrt access point with the gateway
|
||||
enabled, the gateway had lost its port to the daemon's own mDNS responder
|
||||
and `.fips` names stopped resolving with nothing reported.
|
||||
|
||||
#### Nostr and NAT traversal
|
||||
|
||||
@@ -944,6 +997,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
directory is owned by another account. Rerun the installer after moving files
|
||||
into the directory. A foreground run from an unelevated prompt can no longer
|
||||
read the files there.
|
||||
- The Windows service installer now creates empty `peers.allow` and
|
||||
`peers.deny` files in `C:\ProgramData\fips`. While either was missing there,
|
||||
the service read that file from `\etc\fips` on the system drive, where any
|
||||
local user can create files, so a planted list was enforced. An empty file
|
||||
allows every peer; to clear a list, empty its file rather than deleting it.
|
||||
The installer stops when either file exists under `\etc\fips` and not in
|
||||
`C:\ProgramData\fips`, so an upgrader's list is neither enforced from the
|
||||
old location nor dropped unreviewed: review it, move it into
|
||||
`C:\ProgramData\fips` or delete it, and run the installer again. Windows
|
||||
upgraders should rerun `install-service.ps1`.
|
||||
|
||||
## [0.5.1] - 2026-09-06
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ There is no `fipsctl gateway` subcommand; clients (including
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌───────────┐ │
|
||||
│ │ DNS proxy │ │ Virtual │ │
|
||||
│ │ ([::1]:5353) │─▶│ IP pool │ │
|
||||
│ │ ([::1]:5365) │─▶│ IP pool │ │
|
||||
│ │ .fips only │ │ (state │ │
|
||||
│ └──────┬───────┘ │ machine) │ │
|
||||
│ │ └─────┬─────┘ │
|
||||
@@ -182,8 +182,10 @@ involving the DNS proxy or the pool.
|
||||
### DNS Resolution Flow
|
||||
|
||||
1. A LAN client sends a DNS query to the gateway's listener (default
|
||||
`[::1]:5353`, configurable via `gateway.dns.listen`). The default
|
||||
is loopback-only on an unprivileged port: the canonical deployment
|
||||
`[::1]:5365`, configurable via `gateway.dns.listen`). The default
|
||||
is not 5353, the mDNS port, which the daemon's LAN rendezvous and
|
||||
other mDNS responders hold. It is loopback-only on an unprivileged
|
||||
port: the canonical deployment
|
||||
has another resolver on the host (dnsmasq, systemd-resolved, BIND)
|
||||
holding port 53 and forwarding `.fips` queries to the gateway over
|
||||
loopback. Operators on a host without a pre-existing resolver on
|
||||
|
||||
@@ -207,6 +207,19 @@ branch, and each only re-announces to its peers.
|
||||
- **Convergence time**: A tree of depth D reconverges in roughly D × 0.5s
|
||||
to D × 1.0s
|
||||
|
||||
The transport accepting a TreeAnnounce is not delivery. Each announce stays
|
||||
outstanding until the link's receiver reports confirm it, by the same rule
|
||||
as a FilterAnnounce (see "Update Triggers" in
|
||||
[fips-bloom-filters.md](fips-bloom-filters.md)). It is resent when the
|
||||
reports show a loss, and once after 30 s when they cannot confirm it, with
|
||||
the same budgets and backoff: per declaration sequence per session, one
|
||||
unchecked resend and three on loss, spaced by a per-peer backoff of 1, 2,
|
||||
4 ... up to 60 s. A resend carries the current declaration, so a peer that
|
||||
missed an older one gets the newer position, and one that already holds it
|
||||
ignores it as not fresher. The periodic re-evaluation under Stability
|
||||
Mechanisms, which re-broadcasts an unchanged declaration, stays as the
|
||||
backstop on a node with two or more peers.
|
||||
|
||||
### Transitive Trust (v1)
|
||||
|
||||
In the v1 protocol, only the sender's outer signature on the TreeAnnounce
|
||||
|
||||
@@ -143,7 +143,7 @@ virtual IPs, which is the gateway's hard cap regardless of CIDR
|
||||
width.
|
||||
|
||||
This minimum config is enough to start the gateway. The `dns.*` block
|
||||
is optional and defaults to `listen: "[::1]:5353"` and
|
||||
is optional and defaults to `listen: "[::1]:5365"` and
|
||||
`upstream: "[::1]:5354"`. The full block — including `dns.*`,
|
||||
`pool_grace_period`, `conntrack.*`, and `port_forwards[]` — is
|
||||
documented in
|
||||
@@ -196,7 +196,7 @@ Constraints:
|
||||
```yaml
|
||||
gateway:
|
||||
dns:
|
||||
listen: "[::1]:5353"
|
||||
listen: "[::1]:5365"
|
||||
upstream: "[::1]:5354"
|
||||
ttl: 60
|
||||
```
|
||||
@@ -204,15 +204,16 @@ gateway:
|
||||
Common cases:
|
||||
|
||||
- **Another resolver on the host (the canonical case):** the default
|
||||
`listen: "[::1]:5353"` is loopback-only on an unprivileged port,
|
||||
`listen: "[::1]:5365"` is loopback-only on an unprivileged port,
|
||||
so it never conflicts with dnsmasq, systemd-resolved, or BIND
|
||||
holding 53. Configure the existing resolver to forward `.fips`
|
||||
queries to `[::1]:5353` and you are done — this is what the
|
||||
OpenWrt ipk does automatically.
|
||||
queries to `[::1]:5365` and you are done — this is what the
|
||||
OpenWrt ipk does automatically. On OpenWrt the init script reads
|
||||
`gateway.dns.listen` and points dnsmasq at whatever port it sets.
|
||||
- **No other resolver on the host:** set `listen: "[::]:53"`
|
||||
explicitly and LAN clients can query the gateway directly.
|
||||
- **systemd-resolved is on port 53:** the default already side-steps
|
||||
this — leave the listen address at `[::1]:5353` and configure the
|
||||
this — leave the listen address at `[::1]:5365` and configure the
|
||||
stub or a small forwarder to delegate `.fips` to the gateway. If
|
||||
you would rather have the gateway on 53 directly, disable the
|
||||
systemd stub listener (`DNSStubListener=no` in
|
||||
|
||||
@@ -35,19 +35,12 @@ nodes, and tests where you actively want a fresh identity per run.
|
||||
|
||||
The Debian/Ubuntu `.deb` and the Arch `fips` AUR package both ship a
|
||||
default `/etc/fips/fips.yaml` with `node.identity.persistent` left as
|
||||
the upstream default (false), so the daemon writes a fresh keypair to
|
||||
`/etc/fips/fips.{key,pub}` on every start until you set
|
||||
`persistent: true`. To pin the current keypair:
|
||||
the upstream default (false). In that mode the daemon generates a
|
||||
fresh keypair on every start, holds the private key only in memory,
|
||||
and writes only `/etc/fips/fips.pub`. To give the node a stable
|
||||
identity:
|
||||
|
||||
1. Install the package and start the daemon once so it generates
|
||||
`fips.key` / `fips.pub`:
|
||||
|
||||
```sh
|
||||
sudo systemctl start fips
|
||||
sudo systemctl status fips # confirm it came up
|
||||
```
|
||||
|
||||
2. Edit `/etc/fips/fips.yaml` and set:
|
||||
1. Install the package, then edit `/etc/fips/fips.yaml` and set:
|
||||
|
||||
```yaml
|
||||
node:
|
||||
@@ -55,22 +48,31 @@ the upstream default (false), so the daemon writes a fresh keypair to
|
||||
persistent: true
|
||||
```
|
||||
|
||||
3. Restart the daemon and verify the identity is reused:
|
||||
2. Start or restart the daemon. On this first persistent start it
|
||||
generates a keypair and saves it to `/etc/fips/fips.key`:
|
||||
|
||||
```sh
|
||||
sudo systemctl restart fips
|
||||
sudo systemctl status fips # confirm it came up
|
||||
```
|
||||
|
||||
3. Verify the identity:
|
||||
|
||||
```sh
|
||||
fipsctl show status | grep -E '"npub"|"node_addr"'
|
||||
cat /etc/fips/fips.pub
|
||||
```
|
||||
|
||||
The npub printed by `fipsctl show status` should match
|
||||
`/etc/fips/fips.pub` and remain stable across subsequent restarts.
|
||||
`/etc/fips/fips.pub`. If the daemon was already running
|
||||
ephemeral, the npub changes once at this restart and is stable
|
||||
from then on.
|
||||
|
||||
The package's `postinst` script does **not** generate the keypair —
|
||||
the daemon does, on first start. This means the keypair is only
|
||||
present after the first successful daemon start. If the daemon never
|
||||
came up cleanly (config error, permission problem), the key files
|
||||
will be missing.
|
||||
The package's `postinst` script does **not** generate the keypair.
|
||||
The first successful daemon start with `persistent: true` does, so
|
||||
`fips.key` is only present after that start. If the daemon never came
|
||||
up cleanly (config error, permission problem), the key file will be
|
||||
missing.
|
||||
|
||||
### macOS note
|
||||
|
||||
|
||||
@@ -81,28 +81,62 @@ for the full flag list.
|
||||
|
||||
### Port conflict on the DNS listen port
|
||||
|
||||
Symptom: gateway fails to start with "address already in use" on
|
||||
the configured `gateway.dns.listen` address.
|
||||
Symptom: the gateway exits at startup, before it creates the address
|
||||
pool or the NAT table, and the log carries an error such as this one,
|
||||
wrapped here for reading:
|
||||
|
||||
The default `[::1]:5353` is loopback-only on an unprivileged port and
|
||||
should not collide with any standard resolver. If you have overridden
|
||||
`dns.listen` to bind port 53 (or a LAN-side address) and another DNS
|
||||
server (systemd-resolved, dnsmasq, BIND) is already bound there,
|
||||
identify it:
|
||||
|
||||
```sh
|
||||
sudo ss -tulnp | grep ':53'
|
||||
```text
|
||||
cannot bind the gateway DNS listener on [::]:53: Address already in
|
||||
use (os error 98); another DNS server holds port 53: dnsmasq,
|
||||
systemd-resolved's stub listener, unbound or BIND; find the holder
|
||||
with `ss -ulpn 'sport = :53'` or `netstat -ulnp`, or set
|
||||
gateway.dns.listen to a free port and point the resolver that
|
||||
forwards .fips at it
|
||||
```
|
||||
|
||||
Under systemd the unit restarts every five seconds and fails the same
|
||||
way each time; under procd on OpenWrt the service stops respawning
|
||||
after five failures within an hour. The gateway also exits, after
|
||||
removing its NAT table and routes, when the DNS resolver stops while
|
||||
the gateway is running; that log line reads "Gateway DNS resolver
|
||||
stopped; exiting so the service manager restarts the gateway".
|
||||
|
||||
The error names the service most likely to hold the port:
|
||||
|
||||
- **53**: another DNS server, such as dnsmasq, systemd-resolved's stub
|
||||
listener, unbound or BIND.
|
||||
- **5353**: mDNS. The fips daemon's LAN rendezvous
|
||||
(`node.rendezvous.lan`), avahi-daemon or systemd-resolved's
|
||||
MulticastDNS.
|
||||
- **5354**: the fips daemon's own DNS responder. `gateway.dns.listen`
|
||||
must not be the daemon's DNS port.
|
||||
- **5355**: LLMNR, held by systemd-resolved unless `LLMNR=no`.
|
||||
- **5365**, the default: another fips-gateway already running.
|
||||
- **Any other port**: another process.
|
||||
|
||||
Find the actual holder, replacing the port with your own:
|
||||
|
||||
```sh
|
||||
sudo ss -ulpn 'sport = :53'
|
||||
# OpenWrt ships netstat but not ss:
|
||||
netstat -ulnp
|
||||
```
|
||||
|
||||
The default listen address, `[::1]:5365`, is loopback-only on an
|
||||
unprivileged port. Releases before 0.5.2 defaulted to `[::1]:5353`,
|
||||
the mDNS port; a config that still sets it explicitly keeps it, and
|
||||
the gateway warns at startup. On OpenWrt, an upgrade rewrites the
|
||||
previously shipped `listen: "[::1]:5353"` line to the new default.
|
||||
Two options:
|
||||
|
||||
- **Stay on the loopback default.** Drop the override and let the
|
||||
gateway use `[::1]:5353`. Configure the existing resolver to
|
||||
forward `.fips` queries to it (the canonical OpenWrt deployment
|
||||
works this way out of the box).
|
||||
- **Move the gateway.** Set `gateway.dns.listen` to a free port and
|
||||
point the resolver that forwards `.fips` at the same port. With the
|
||||
loopback default, configure the existing resolver to forward `.fips`
|
||||
queries to `[::1]:5365` (the canonical OpenWrt deployment works this
|
||||
way out of the box).
|
||||
|
||||
- **Relocate the conflicting resolver.** Move it to a different port
|
||||
(or disable it if not needed) and let the gateway bind 53.
|
||||
(or disable it if not needed) and let the gateway bind the port.
|
||||
Practical for systemd-resolved (set `DNSStubListener=no` in
|
||||
`/etc/systemd/resolved.conf`); rarely worth it for production
|
||||
resolvers.
|
||||
@@ -211,7 +245,7 @@ not running or not enabled. Check that the daemon config has
|
||||
**Step 2.** Verify the gateway is listening on its DNS port:
|
||||
|
||||
```sh
|
||||
sudo ss -tulnp | grep -E ':(53|5353)\b'
|
||||
sudo ss -tulnp | grep -E ':(53|5365)\b'
|
||||
```
|
||||
|
||||
If nothing is listening on the configured `dns.listen` address, the
|
||||
|
||||
@@ -286,10 +286,11 @@ port from one dropped because a client was not reading fast enough.
|
||||
For the response shape, see
|
||||
[../reference/control-socket.md](../reference/control-socket.md#read-only-queries).
|
||||
|
||||
Reach for this when datagrams go missing. **Four places lose data with
|
||||
Reach for this when datagrams go missing. **Five places lose data with
|
||||
nothing reported to your program**: a full per-flow queue, a listener that
|
||||
does not accept fast enough, an outbound datagram sent before a session
|
||||
exists, and an outbound datagram after the transport MTU has fallen.
|
||||
exists, an outbound datagram after the transport MTU has fallen, and, on
|
||||
Linux, an empty datagram sent just before a close.
|
||||
[../reference/native-api.md](../reference/native-api.md#where-data-disappears)
|
||||
describes each and what bounds it.
|
||||
|
||||
|
||||
@@ -85,13 +85,15 @@ that costs a real payload if you get it wrong. A client that sends an empty
|
||||
datagram, then a message, then closes, leaves both queued, and a reader that
|
||||
trusts `POLLHUP` alone discards the message.
|
||||
|
||||
**One case has no answer, and you should design around it rather than solve
|
||||
it.** A zero-length datagram that is the last message before a close is
|
||||
indistinguishable from the close: reading it drains the queue, and `FIONREAD`
|
||||
then reports zero because a zero-length message contributes no bytes. If your
|
||||
protocol gives a zero-length payload a meaning, do not send it as a zero-length
|
||||
socket message. Carry a one-byte discriminator, and keep the zero-byte read for
|
||||
end of file alone.
|
||||
**One case has no answer on Linux, where the pair is `SOCK_SEQPACKET`, and you
|
||||
should design around it rather than solve it.** A zero-length datagram that is
|
||||
the last message before a close is indistinguishable from the close: reading it
|
||||
drains the queue, and `FIONREAD` then reports zero because a zero-length message
|
||||
contributes no bytes. If your protocol gives a zero-length payload a meaning, do
|
||||
not send it as a zero-length socket message. Carry a one-byte discriminator, and
|
||||
keep the zero-byte read for end of file alone. On macOS and FreeBSD the pair is
|
||||
`SOCK_DGRAM`: the empty datagram reads as zero bytes, and the close is reported
|
||||
by the read after it.
|
||||
|
||||
Both directions of the mistake are real. Reading an empty datagram as a close
|
||||
lets a peer tear down a live flow by sending nothing, and presents as a
|
||||
|
||||
@@ -73,7 +73,7 @@ Linux host) and
|
||||
| Code | Meaning |
|
||||
| ---- | ------- |
|
||||
| `0` | Clean shutdown after `SIGINT` / `SIGTERM`. |
|
||||
| `1` | Non-Linux platform, configuration load failure, missing or invalid `gateway:` block, NAT/network setup failure, or control-socket bind failure. The reason is printed to stderr or the log before exit. |
|
||||
| `1` | Non-Linux platform, configuration load failure, missing or invalid `gateway:` block, the DNS listener could not bind or stopped while running, or NAT/network setup failure. The reason is printed to stderr or the log before exit. A control-socket bind failure is logged as a warning and the gateway continues without the socket. |
|
||||
|
||||
## Environment
|
||||
|
||||
|
||||
@@ -76,16 +76,18 @@ first, then `/usr/local/etc/fips`, so the packaged file wins over a
|
||||
leftover `/etc/fips` copy from an earlier install. Windows likewise
|
||||
probes `\etc\fips` on the current drive, then `C:\ProgramData\fips`.
|
||||
|
||||
Adjacent to the highest-priority config file the daemon reads (or
|
||||
writes, on first start) the identity files:
|
||||
Adjacent to the highest-priority config file the daemon keeps the
|
||||
identity files:
|
||||
|
||||
| File | Mode | Purpose |
|
||||
| ---- | ---- | ------- |
|
||||
| `fips.key` | `0600` | Bech32 nsec for the persistent identity (Unix; on Windows the file takes its directory's ACL, which `install-service.ps1` restricts to SYSTEM and Administrators). |
|
||||
| `fips.pub` | `0644` | Bech32 npub corresponding to `fips.key`. |
|
||||
| `fips.key` | `0600` | Bech32 nsec for the persistent identity, written only in persistent mode (Unix; on Windows the file takes its directory's ACL, which `install-service.ps1` restricts to SYSTEM and Administrators). |
|
||||
| `fips.pub` | `0644` | Bech32 npub of the running identity, written on every start. In persistent mode it corresponds to `fips.key`. |
|
||||
|
||||
When `node.identity.persistent` is `false` (the default), a fresh
|
||||
keypair is written to these files on every start.
|
||||
keypair is generated on every start and only `fips.pub` is written.
|
||||
A `fips.key` found there is moved aside to `fips.key.unused` and a
|
||||
warning is logged.
|
||||
|
||||
On Windows the service writes its log to `C:\ProgramData\fips\fips.log`,
|
||||
rolled at 10 MiB with four old files kept; a foreground run logs to the
|
||||
|
||||
@@ -108,8 +108,10 @@ Identity resolution follows a three-tier priority:
|
||||
3. **Ephemeral** — when `persistent: false` (default) and no `nsec`, generates a fresh
|
||||
keypair on each start
|
||||
|
||||
Key files (`fips.key` with mode 0600, `fips.pub` with mode 0644) are written adjacent
|
||||
to the highest-priority config file for operator visibility, even in ephemeral mode.
|
||||
`fips.pub` (mode 0644) is written adjacent to the highest-priority config file
|
||||
on every start. `fips.key` (mode 0600) is written only in persistent mode. In
|
||||
ephemeral mode a `fips.key` found at startup is moved aside to
|
||||
`fips.key.unused` with a warning.
|
||||
|
||||
### General
|
||||
|
||||
@@ -1073,7 +1075,7 @@ Non-`.fips` queries are answered with `REFUSED`.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `gateway.dns.listen` | string | `"[::1]:5353"` | DNS listen address. The default binds IPv6 loopback on an unprivileged port, matching the canonical deployment where another resolver on the host (dnsmasq, systemd-resolved, BIND) holds port 53 and forwards `.fips` queries to the gateway over loopback. Bind on the LAN-side IP (e.g., `"192.168.1.1:53"`) or wildcard (`"[::]:53"`) only on hosts with no other resolver on 53 and where LAN clients query the gateway directly. See [../how-to/troubleshoot-gateway.md](../how-to/troubleshoot-gateway.md). |
|
||||
| `gateway.dns.listen` | string | `"[::1]:5365"` | DNS listen address. The default binds IPv6 loopback on an unprivileged port, not 5353, the mDNS port, matching the canonical deployment where another resolver on the host (dnsmasq, systemd-resolved, BIND) holds port 53 and forwards `.fips` queries to the gateway over loopback. Bind on the LAN-side IP (e.g., `"192.168.1.1:53"`) or wildcard (`"[::]:53"`) only on hosts with no other resolver on 53 and where LAN clients query the gateway directly. See [../how-to/troubleshoot-gateway.md](../how-to/troubleshoot-gateway.md). |
|
||||
| `gateway.dns.upstream` | string | `"[::1]:5354"` | Upstream FIPS daemon resolver. **Must match the daemon's `dns.bind_addr` and `dns.port`.** Defaults match the daemon defaults (`::1:5354`). A v4 upstream (`"127.0.0.1:5354"`) cannot reach a daemon bound on `[::1]:5354` — Linux IPv6 sockets bound to explicit `::1` do not accept v4-mapped traffic. If you change the daemon's `dns.bind_addr`, update this field accordingly. |
|
||||
| `gateway.dns.ttl` | u32 | `60` | TTL in seconds on AAAA responses returned to LAN clients. Smaller values let the gateway recycle pool addresses faster; larger values reduce LAN-side query traffic. |
|
||||
|
||||
@@ -1120,7 +1122,7 @@ gateway:
|
||||
pool: "fd01::/112"
|
||||
lan_interface: "enp3s0"
|
||||
dns:
|
||||
listen: "[::1]:5353"
|
||||
listen: "[::1]:5365"
|
||||
upstream: "[::1]:5354"
|
||||
ttl: 60
|
||||
pool_grace_period: 60
|
||||
|
||||
@@ -260,13 +260,15 @@ because it latches while messages are still queued: a read that trusted it
|
||||
would report the close early and discard whatever was waiting. The `EPIPE` that
|
||||
`recv` returns means the daemon went away, never that a peer finished.
|
||||
|
||||
**One case is reported as `EPIPE` although it is a datagram.** A zero-length
|
||||
datagram that is the last message before a close is indistinguishable from the
|
||||
close, because reading it drains the queue and `FIONREAD` then reports zero: a
|
||||
zero-length message contributes no bytes. Do not give a zero-length payload a
|
||||
meaning of its own on this API. Carry a one-byte discriminator instead.
|
||||
Separating the two needs a payload that is never zero bytes on the wire, which
|
||||
is a protocol change rather than a receive-path one.
|
||||
**On Linux, one case is reported as `EPIPE` although it is a datagram.** A
|
||||
zero-length datagram that is the last message before a close is
|
||||
indistinguishable from the close, because reading it drains the queue and
|
||||
`FIONREAD` then reports zero: a zero-length message contributes no bytes. macOS
|
||||
and FreeBSD carry the flow on `SOCK_DGRAM`, where the datagram is delivered as
|
||||
`Ok(0)` and the close follows it. Do not give a zero-length payload a meaning
|
||||
of its own on this API. Carry a one-byte discriminator instead. Separating the
|
||||
two needs a payload that is never zero bytes on the wire, which is a protocol
|
||||
change rather than a receive-path one.
|
||||
|
||||
**`peer_addr()`** and **`local_addr()`** return `FipsAddr`, not
|
||||
`io::Result<FipsAddr>`, unlike their `TcpStream` counterparts. These are field
|
||||
@@ -438,7 +440,7 @@ There is no acknowledgement, no retransmission, no ordering guarantee and no
|
||||
flow control between the two ends. A program that needs confirmation gets it
|
||||
from the peer, in the payload.
|
||||
|
||||
Four places lose data with nothing reported to the client.
|
||||
Five places lose data with nothing reported to the client.
|
||||
|
||||
**A full per-flow queue.** Inbound datagrams beyond `pending_per_flow` are
|
||||
dropped with a trace log and no client-visible signal. A program that stops
|
||||
@@ -467,6 +469,14 @@ snapshot taken at setup. The daemon re-checks each outbound datagram against
|
||||
the node's current limit and drops it silently if the transport MTU has since
|
||||
fallen.
|
||||
|
||||
**An empty datagram sent just before a close, on Linux.** A program that sends
|
||||
a zero-length datagram and then drops its `FipsStream` may have the daemon read
|
||||
that datagram as the close: the daemon frees the flow and the empty datagram
|
||||
never reaches the peer. In the other direction, an empty datagram from the peer
|
||||
that the daemon delivers immediately before its own half of the flow closes,
|
||||
as when the daemon stops, reaches `recv` as `EPIPE` rather than `Ok(0)`. macOS
|
||||
and FreeBSD are not affected. See `recv` above.
|
||||
|
||||
### The drop causes, and what they mean
|
||||
|
||||
An inbound datagram can be refused for eight reasons, which render as seven
|
||||
|
||||
@@ -132,7 +132,7 @@ gateway:
|
||||
pool: "fd01::/112" # virtual IP range (up to 65535 addresses)
|
||||
lan_interface: "br-lan" # LAN-facing interface for proxy NDP
|
||||
dns:
|
||||
listen: "[::1]:5353" # gateway DNS bind (IPv6 loopback only)
|
||||
# listen: "[::1]:5365" # the default; the init script points dnsmasq at this port
|
||||
upstream: "[::1]:5354" # FIPS daemon DNS resolver (matches daemon default)
|
||||
ttl: 60 # DNS TTL and mapping lifetime (seconds)
|
||||
pool_grace_period: 60 # seconds after last session before reclaiming
|
||||
@@ -147,9 +147,10 @@ Three things to notice:
|
||||
- `lan_interface: "br-lan"` — the OpenWrt LAN bridge. The gateway
|
||||
installs proxy-NDP entries on this interface so LAN clients can
|
||||
ARP-equivalent for pool addresses.
|
||||
- `dns.listen: "[::1]:5353"` — the gateway's DNS bind, pinned to
|
||||
IPv6 loopback only. dnsmasq, which owns LAN port 53, forwards
|
||||
`.fips` queries to it. The init script wires up that forwarding;
|
||||
- `dns.listen`, commented out — the gateway's DNS bind, left at its
|
||||
default `[::1]:5365`, IPv6 loopback only. dnsmasq, which owns LAN
|
||||
port 53, forwards `.fips` queries to it. The init script reads
|
||||
`gateway.dns.listen` and points dnsmasq at whatever port it sets;
|
||||
you don't bind to a LAN address yourself.
|
||||
|
||||
For the full reference, see
|
||||
@@ -172,7 +173,7 @@ Behind that single command, the init script
|
||||
`/etc/sysctl.d/fips-gateway.conf`.
|
||||
2. **Reconfigures dnsmasq via UCI** so `.fips` queries arriving at
|
||||
the LAN's port 53 are forwarded to the gateway's loopback
|
||||
listener on port 5353 instead of going straight to the daemon's
|
||||
listener on port 5365 instead of going straight to the daemon's
|
||||
resolver on port 5354. (Dnsmasq still owns 53; the gateway sits
|
||||
in front of the daemon for `.fips` only.)
|
||||
3. **Adds a global-scope IPv6 prefix** to `br-lan`. Without a
|
||||
@@ -242,7 +243,7 @@ Expectations:
|
||||
|
||||
> **What just happened end to end.** Your client asked dnsmasq for
|
||||
> `test-us01.fips`. Dnsmasq forwarded the query to the gateway's
|
||||
> loopback listener on port 5353. The gateway forwarded the query on
|
||||
> loopback listener on port 5365. The gateway forwarded the query on
|
||||
> to the daemon's resolver on port 5354. The daemon answered with
|
||||
> `test-us01`'s mesh address (`fd97:...`). The gateway allocated a
|
||||
> virtual IP from `fd01::/112`, installed nftables DNAT/SNAT/
|
||||
|
||||
@@ -133,7 +133,7 @@ transports:
|
||||
# pool: "fd01::/112"
|
||||
# lan_interface: "eth0"
|
||||
# dns:
|
||||
# listen: "[::1]:5353"
|
||||
# listen: "[::1]:5365"
|
||||
# # upstream must match the daemon's dns.bind_addr above. The
|
||||
# # default "[::1]:5354" matches the daemon's default. If you set
|
||||
# # the daemon to bind on a wildcard ("::") or specific address,
|
||||
|
||||
@@ -70,8 +70,8 @@ operator-editable runtime state:
|
||||
| Path | Purpose | Writable | Seeded from |
|
||||
|---|---|---|---|
|
||||
| `/var/lib/fips/fips.yaml` | Main config | yes | `services.fips.configFile` (first run only) |
|
||||
| `/var/lib/fips/fips.key` | Node identity (private) | yes | generated by fips on first start |
|
||||
| `/var/lib/fips/fips.pub` | Node identity (public) | yes | generated by fips on first start |
|
||||
| `/var/lib/fips/fips.key` | Node identity (private) | yes | generated by fips on first start with `persistent: true` |
|
||||
| `/var/lib/fips/fips.pub` | Node identity (public) | yes | written by fips on every start |
|
||||
| `/etc/fips/hosts` | Static hostname → npub map | yes | shipped `hosts` (first run only) |
|
||||
| `/etc/fips/peers.allow` | Peer allowlist (ACL) | yes | operator-created |
|
||||
| `/etc/fips/peers.deny` | Peer denylist (ACL) | yes | operator-created |
|
||||
|
||||
@@ -168,15 +168,15 @@ transports:
|
||||
|
||||
# No BLE transport: OpenWrt builds target musl, which has no BlueZ backend.
|
||||
|
||||
# Outbound LAN gateway. dnsmasq forwards .fips queries to listen=[::1]:5353
|
||||
# while it runs (configured by the fips-gateway init script). Requires IPv6
|
||||
# forwarding enabled.
|
||||
# Outbound LAN gateway. While it runs, the fips-gateway init script points
|
||||
# dnsmasq's .fips forwarding at the port dns.listen sets (default [::1]:5365).
|
||||
# Requires IPv6 forwarding enabled.
|
||||
gateway:
|
||||
enabled: true
|
||||
pool: "fd01::/112"
|
||||
lan_interface: "br-lan"
|
||||
dns:
|
||||
listen: "[::1]:5353"
|
||||
# listen: "[::1]:5365" # the default; the init script points dnsmasq at this port
|
||||
upstream: "[::1]:5354"
|
||||
ttl: 60
|
||||
pool_grace_period: 60
|
||||
|
||||
@@ -15,8 +15,10 @@ STOP=09
|
||||
PROG=/usr/bin/fips-gateway
|
||||
CONFIG=/etc/fips/fips.yaml
|
||||
|
||||
# Port the gateway DNS listens on (must match dns.listen in fips.yaml).
|
||||
GW_DNS_PORT=5353
|
||||
# Port the gateway DNS listens on when gateway.dns.listen is not set. Must
|
||||
# match DEFAULT_DNS_LISTEN in the gateway's source; the scenario harness checks
|
||||
# the two agree. The port actually used comes from gateway_dns_port.
|
||||
GW_DNS_DEFAULT=5365
|
||||
# Port the FIPS daemon DNS listens on.
|
||||
DAEMON_DNS_PORT=5354
|
||||
|
||||
@@ -40,11 +42,11 @@ start_service() {
|
||||
# Load conntrack module for /proc/net/nf_conntrack.
|
||||
modprobe nf_conntrack 2>/dev/null || true
|
||||
|
||||
# Redirect dnsmasq .fips forwarding from daemon (5354) to gateway (5353)
|
||||
# so LAN clients get virtual IPs instead of raw mesh addresses.
|
||||
# Done early and synchronously so dnsmasq is ready before the gateway
|
||||
# starts accepting DNS queries.
|
||||
dnsmasq_swap_fips_upstream "$GW_DNS_PORT"
|
||||
# Redirect dnsmasq .fips forwarding from the daemon (5354) to the port the
|
||||
# gateway listens on, so LAN clients get virtual IPs instead of raw mesh
|
||||
# addresses. Done early and synchronously so dnsmasq is ready before the
|
||||
# gateway starts accepting DNS queries.
|
||||
dnsmasq_swap_fips_upstream "$(gateway_dns_port)"
|
||||
sleep 1
|
||||
|
||||
# Add a global-scope IPv6 prefix to br-lan so Android/Chrome clients
|
||||
@@ -87,6 +89,35 @@ gateway_config_enabled() {
|
||||
awk '/^gateway:/{found=1; next} found && /^[^ ]/{found=0} found && /enabled:/{gsub(/.*enabled:[[:space:]]*/, ""); gsub(/["'"'"']/, ""); print; exit}' "$CONFIG"
|
||||
}
|
||||
|
||||
# Print the port the gateway's DNS listener will bind: the digits after the
|
||||
# last ":" of the "listen:" value inside the top-level "gateway:" block of
|
||||
# fips.yaml, or $GW_DNS_DEFAULT when there is no such line or its value does
|
||||
# not end in a port. Commented lines are skipped, and "listen_port:" (a port
|
||||
# forward key) does not match.
|
||||
#
|
||||
# Block-style YAML only: a flow-style "dns: {listen: ...}" reads as the
|
||||
# default. The dnsmasq entry this port feeds is always ::1#<port>, so a
|
||||
# gateway listening only on 127.0.0.1 is still not reachable through it.
|
||||
gateway_dns_port() {
|
||||
local port
|
||||
port="$(awk '
|
||||
/^[A-Za-z_]/ { top = $1 }
|
||||
top != "gateway:" { next }
|
||||
/^[[:space:]]*#/ { next }
|
||||
/^[[:space:]]+listen:/ {
|
||||
v = $0
|
||||
sub(/^[[:space:]]+listen:[[:space:]]*/, "", v)
|
||||
sub(/[[:space:]]+#.*$/, "", v)
|
||||
gsub(/["'"'"']/, "", v)
|
||||
sub(/[[:space:]]+$/, "", v)
|
||||
n = split(v, part, ":")
|
||||
if (n > 1 && part[n] ~ /^[0-9]+$/) print part[n]
|
||||
exit
|
||||
}
|
||||
' "$CONFIG" 2>/dev/null)"
|
||||
echo "${port:-$GW_DNS_DEFAULT}"
|
||||
}
|
||||
|
||||
# Extract the gateway pool CIDR from fips.yaml.
|
||||
# Looks for "pool:" indented under the top-level "gateway:" block.
|
||||
gateway_pool_cidr() {
|
||||
@@ -178,13 +209,20 @@ gateway_remove_global_prefix() {
|
||||
# $1 = target port number
|
||||
dnsmasq_swap_fips_upstream() {
|
||||
local port="$1"
|
||||
local server
|
||||
|
||||
# Remove both possible entries, then add the correct one.
|
||||
uci -q del_list dhcp.@dnsmasq[0].server="/fips/127.0.0.1#${DAEMON_DNS_PORT}" 2>/dev/null
|
||||
uci -q del_list dhcp.@dnsmasq[0].server="/fips/127.0.0.1#${GW_DNS_PORT}" 2>/dev/null
|
||||
# Also handle IPv6 loopback variants.
|
||||
uci -q del_list dhcp.@dnsmasq[0].server="/fips/::1#${DAEMON_DNS_PORT}" 2>/dev/null
|
||||
uci -q del_list dhcp.@dnsmasq[0].server="/fips/::1#${GW_DNS_PORT}" 2>/dev/null
|
||||
# Remove every loopback .fips forward, then add the one for $port. That
|
||||
# covers the daemon's port, this gateway's, and a stale entry for any other
|
||||
# local port, such as the old default 5353 or a changed gateway.dns.listen.
|
||||
# A .fips forward to another host and servers for other domains are kept.
|
||||
# uci prints a list on one line separated by spaces.
|
||||
for server in $(uci -q get 'dhcp.@dnsmasq[0].server' 2>/dev/null); do
|
||||
case "$server" in
|
||||
"/fips/::1#"* | "/fips/127.0.0.1#"*)
|
||||
uci -q del_list dhcp.@dnsmasq[0].server="$server" 2>/dev/null
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
uci add_list dhcp.@dnsmasq[0].server="/fips/::1#${port}"
|
||||
uci commit dhcp
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
#!/bin/sh
|
||||
# FIPS first-boot setup — runs once after package installation.
|
||||
# Configures the firewall and kernel modules for FIPS operation.
|
||||
# This script is executed by /etc/rc.d/S19sysctl on first boot and
|
||||
# then deleted by the UCI defaults mechanism.
|
||||
# The UCI defaults mechanism runs it once and deletes it when it ends with
|
||||
# status 0.
|
||||
#
|
||||
# It is executed by the package's postinst, but sourced, not executed, by
|
||||
# OpenWrt's default_postinst for a package built from the SDK feed and by the
|
||||
# first-boot uci-defaults run after a sysupgrade. Nothing here may exit early,
|
||||
# change directory or set shell options, and it must end with status 0: a
|
||||
# non-zero status leaves the script in place to run again on every boot.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Kernel modules
|
||||
@@ -63,9 +69,13 @@ uci commit firewall
|
||||
# dnsmasq init script builds its config from UCI and loads no directory under
|
||||
# /etc. The daemon's DNS responder binds ::1. The 127.0.0.1 del_list removes
|
||||
# the entry older packages added. While fips-gateway runs, its init script
|
||||
# points this entry at the gateway's DNS port instead.
|
||||
# points this entry at the gateway's DNS port instead. The two del_lists after
|
||||
# the first remove the gateway's entry for its old default port, left behind
|
||||
# by a gateway that stopped without its init script's stop running.
|
||||
|
||||
uci -q del_list dhcp.@dnsmasq[0].server="/fips/127.0.0.1#5354" 2>/dev/null || true
|
||||
uci -q del_list dhcp.@dnsmasq[0].server="/fips/::1#5353" 2>/dev/null || true
|
||||
uci -q del_list dhcp.@dnsmasq[0].server="/fips/127.0.0.1#5353" 2>/dev/null || true
|
||||
uci -q del_list dhcp.@dnsmasq[0].server="/fips/::1#5354" 2>/dev/null || true
|
||||
uci add_list dhcp.@dnsmasq[0].server="/fips/::1#5354"
|
||||
uci -q del_list dhcp.@dnsmasq[0].rebind_domain="fips" 2>/dev/null || true
|
||||
@@ -86,4 +96,40 @@ grep -qxF 'nf_conntrack' /etc/modules.d/nf-conntrack 2>/dev/null || \
|
||||
# proxy NDP entries are actually added.
|
||||
sysctl -p /etc/sysctl.d/fips-gateway.conf 2>/dev/null || true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Gateway DNS listen port
|
||||
# ---------------------------------------------------------------------------
|
||||
# Every release up to 0.5.1 shipped the gateway's DNS listener on 5353, the
|
||||
# mDNS port, which the daemon's LAN rendezvous can hold. fips.yaml is a
|
||||
# conffile and fips-ap-setup edits it, so an upgrade keeps the old line.
|
||||
# Rewrite exactly that shipped line, four-space indent and nothing after the
|
||||
# closing quote, inside the top-level gateway block, to the line a fresh
|
||||
# install ships. Any other value is left as configured; the gateway warns at
|
||||
# startup when it is still on the mDNS port.
|
||||
#
|
||||
# Runs last, and cannot fail the script: see the note at the top.
|
||||
fips_migrate_gateway_dns_listen() {
|
||||
local cfg=/etc/fips/fips.yaml
|
||||
local old=' listen: "[::1]:5353"'
|
||||
local new=' # listen: "[::1]:5365" # the default; the init script points dnsmasq at this port'
|
||||
local msg='fips: moved gateway.dns.listen off the mDNS port 5353 to the default [::1]:5365'
|
||||
|
||||
if [ -f "$cfg" ] && grep -qxF "$old" "$cfg" 2>/dev/null; then
|
||||
if awk -v old="$old" -v new="$new" '
|
||||
/^[A-Za-z_]/ { top = $1 }
|
||||
top == "gateway:" && $0 == old { print new; changed = 1; next }
|
||||
{ print }
|
||||
END { exit changed ? 0 : 1 }
|
||||
' "$cfg" > "$cfg.tmp" 2>/dev/null &&
|
||||
chmod 600 "$cfg.tmp" 2>/dev/null &&
|
||||
mv -f "$cfg.tmp" "$cfg" 2>/dev/null; then
|
||||
logger -t fips "$msg" 2>/dev/null || true
|
||||
echo "$msg"
|
||||
else
|
||||
rm -f "$cfg.tmp" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
fips_migrate_gateway_dns_listen
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -17,8 +17,8 @@ sudo ./install.sh
|
||||
| fipstop (TUI) | /usr/local/bin/fipstop |
|
||||
| fips-gateway (LAN bridge) | /usr/local/bin/fips-gateway |
|
||||
| Configuration | /etc/fips/fips.yaml |
|
||||
| Identity key | /etc/fips/fips.key (auto-generated) |
|
||||
| Public key | /etc/fips/fips.pub (auto-generated) |
|
||||
| Identity key | /etc/fips/fips.key (generated on first start with `persistent: true`) |
|
||||
| Public key | /etc/fips/fips.pub (written on every start) |
|
||||
| Hosts file | /etc/fips/hosts |
|
||||
| Firewall baseline | /etc/fips/fips.nft |
|
||||
| Firewall drop-in directory | /etc/fips/fips.d/ |
|
||||
|
||||
@@ -101,9 +101,20 @@ Control Socket:
|
||||
|
||||
Configuration:
|
||||
The service reads C:\ProgramData\fips\fips.yaml, where
|
||||
install-service.ps1 puts it, and keeps fips.key, hosts,
|
||||
peers.allow and peers.deny beside it. Edit fips.yaml there
|
||||
before starting the service.
|
||||
install-service.ps1 puts it, and keeps hosts, peers.allow,
|
||||
peers.deny and, with node.identity.persistent: true, fips.key
|
||||
beside it. Edit fips.yaml there before starting the service.
|
||||
|
||||
install-service.ps1 creates empty peers.allow and peers.deny
|
||||
there, which allow every peer until you add entries. To clear
|
||||
a list, empty the file; do not delete it. While either file
|
||||
is missing from C:\ProgramData\fips, the service still reads
|
||||
that file from \etc\fips on the system drive, where earlier
|
||||
releases kept it and any local user can create it. The
|
||||
installer stops if it finds a file there with none in
|
||||
C:\ProgramData\fips: review that file, move it into
|
||||
C:\ProgramData\fips or delete it, and run install-service.ps1
|
||||
again.
|
||||
|
||||
install-service.ps1 restricts C:\ProgramData\fips to SYSTEM
|
||||
and Administrators before writing into it. Reading or editing
|
||||
|
||||
@@ -130,6 +130,31 @@ foreach ($item in @(Get-ChildItem -LiteralPath $ConfigDir -Force)) {
|
||||
|
||||
Write-Host " Restricted $ConfigDir to SYSTEM and Administrators"
|
||||
|
||||
# Releases before this one read peers.allow and peers.deny from \etc\fips on
|
||||
# the system drive, where any local user can create files, and the service
|
||||
# still reads a file there when it is missing from the config directory.
|
||||
# Stop rather than enforce, or silently drop, a list nobody has reviewed.
|
||||
$legacyAclDir = "$env:SystemDrive\etc\fips"
|
||||
foreach ($name in @("peers.allow", "peers.deny")) {
|
||||
$legacy = Join-Path $legacyAclDir $name
|
||||
$current = "$ConfigDir\$name"
|
||||
if ((Test-Path -LiteralPath $legacy) -and -not (Test-Path -LiteralPath $current)) {
|
||||
Write-Error "$legacy exists and $current does not, so the service would enforce the old file. Earlier releases read it, and any local user can write there. Review it, then move it to $current or delete it, and run install-service.ps1 again."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Empty peer ACL files allow every peer. Having them here means the service
|
||||
# never falls back to the \etc\fips copies. Empty them to clear a list; do not
|
||||
# delete them.
|
||||
foreach ($name in @("peers.allow", "peers.deny")) {
|
||||
$aclFile = "$ConfigDir\$name"
|
||||
if (-not (Test-Path -LiteralPath $aclFile)) {
|
||||
New-Item -ItemType File -Path $aclFile | Out-Null
|
||||
Write-Host " Created empty $aclFile (allows every peer until you add entries)"
|
||||
}
|
||||
}
|
||||
|
||||
# Copy binaries
|
||||
$Binaries = @("fips.exe", "fipsctl.exe", "fipstop.exe")
|
||||
foreach ($bin in $Binaries) {
|
||||
|
||||
+62
-24
@@ -251,8 +251,9 @@ async fn main() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Check DNS upstream reachability (proves the FIPS daemon is running)
|
||||
{
|
||||
// Check DNS upstream reachability (proves the FIPS daemon is running).
|
||||
// The resolver later forwards to the address this probe reached.
|
||||
let upstream_addr = {
|
||||
let upstream = gw_config.dns.upstream();
|
||||
info!(upstream = %upstream, "Checking DNS upstream reachability");
|
||||
|
||||
@@ -363,6 +364,29 @@ async fn main() {
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
upstream_addr
|
||||
};
|
||||
|
||||
// --- Bind the DNS listener ---
|
||||
//
|
||||
// Before the pool, NAT table and routes exist, so a port that is already
|
||||
// taken ends the gateway with nothing to tear down, and a service manager
|
||||
// restarting it does not churn nftables.
|
||||
if gw_config.dns.is_mdns() {
|
||||
warn!(
|
||||
"gateway.dns.listen uses port 5353, the mDNS port; an mDNS responder (the fips daemon's LAN rendezvous, avahi) will conflict with it; the default is now [::1]:5365"
|
||||
);
|
||||
}
|
||||
let dns_socket = match dns::bind_listener(gw_config.dns.listen()).await {
|
||||
Ok(socket) => socket,
|
||||
Err(e) => {
|
||||
error!("{e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
match dns_socket.local_addr() {
|
||||
Ok(addr) => info!(addr = %addr, "Gateway DNS resolver listening"),
|
||||
Err(_) => info!(addr = %gw_config.dns.listen(), "Gateway DNS resolver listening"),
|
||||
}
|
||||
|
||||
// --- Initialize components ---
|
||||
@@ -421,27 +445,16 @@ async fn main() {
|
||||
|
||||
// --- Start DNS resolver task ---
|
||||
|
||||
let dns_pool = Arc::clone(&ip_pool);
|
||||
let dns_event_tx = event_tx.clone();
|
||||
let dns_shutdown = shutdown_rx.clone();
|
||||
let dns_listen = gw_config.dns.listen().to_string();
|
||||
let dns_upstream = gw_config.dns.upstream().to_string();
|
||||
let dns_ttl = gw_config.dns.ttl();
|
||||
|
||||
let dns_task = tokio::spawn(async move {
|
||||
if let Err(e) = dns::run_dns_resolver(
|
||||
&dns_listen,
|
||||
&dns_upstream,
|
||||
dns_ttl,
|
||||
dns_pool,
|
||||
dns_event_tx,
|
||||
dns_shutdown,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!(error = %e, "DNS resolver error");
|
||||
}
|
||||
});
|
||||
// Held in an Option because the main loop may see it complete, and a
|
||||
// completed JoinHandle panics if it is polled again.
|
||||
let mut dns_task = Some(tokio::spawn(dns::serve(
|
||||
dns_socket,
|
||||
upstream_addr,
|
||||
gw_config.dns.ttl(),
|
||||
Arc::clone(&ip_pool),
|
||||
event_tx.clone(),
|
||||
shutdown_rx.clone(),
|
||||
)));
|
||||
|
||||
// --- Snapshot channel for control socket ---
|
||||
|
||||
@@ -531,6 +544,7 @@ async fn main() {
|
||||
|
||||
info!("fips-gateway running");
|
||||
|
||||
let mut exit_code = 0;
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(event) = event_rx.recv() => {
|
||||
@@ -559,6 +573,25 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The resolver ends only on shutdown, which has not been
|
||||
// signalled while this loop runs, so any completion here means
|
||||
// .fips resolution has stopped. Exit non-zero so systemd or procd
|
||||
// restarts the gateway or shows it failed.
|
||||
result = async { dns_task.as_mut().expect("guarded by the precondition").await },
|
||||
if dns_task.is_some() => {
|
||||
dns_task = None;
|
||||
let cause = match result {
|
||||
Ok(Ok(())) => "the resolver returned without an error".to_string(),
|
||||
Ok(Err(e)) => e.to_string(),
|
||||
Err(e) => e.to_string(),
|
||||
};
|
||||
error!(
|
||||
cause = %cause,
|
||||
"Gateway DNS resolver stopped; exiting so the service manager restarts the gateway"
|
||||
);
|
||||
exit_code = 1;
|
||||
break;
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
info!("Received SIGINT, shutting down");
|
||||
break;
|
||||
@@ -582,7 +615,9 @@ async fn main() {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
let _ = dns_task.await;
|
||||
if let Some(task) = dns_task {
|
||||
let _ = task.await;
|
||||
}
|
||||
let _ = tick_task.await;
|
||||
|
||||
// Log final pool status
|
||||
@@ -606,4 +641,7 @@ async fn main() {
|
||||
}
|
||||
|
||||
info!("fips-gateway shutdown complete");
|
||||
if exit_code != 0 {
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -484,7 +484,8 @@ mod service {
|
||||
"Configuration: the service reads {}",
|
||||
dir.join("fips.yaml").display()
|
||||
);
|
||||
println!(" keep fips.key, hosts, peers.allow and peers.deny beside it.");
|
||||
println!(" keep hosts, peers.allow and peers.deny beside it, and fips.key");
|
||||
println!(" too when node.identity.persistent is true.");
|
||||
println!(
|
||||
"Logs: the service writes {}",
|
||||
dir.join("fips.log").display()
|
||||
|
||||
+6
-2
@@ -593,8 +593,12 @@ fn main() {
|
||||
eprintln!("{npub}");
|
||||
eprintln!("Key files written to: {}/", dir.display());
|
||||
eprintln!();
|
||||
eprintln!("NOTE: Set 'node.identity.persistent: true' in fips.yaml");
|
||||
eprintln!(" or these keys will be overwritten on next daemon start.");
|
||||
eprintln!(
|
||||
"NOTE: Set 'node.identity.persistent: true' in fips.yaml before the next daemon start."
|
||||
);
|
||||
eprintln!(
|
||||
" Without it the daemon does not use this key: it moves fips.key aside to fips.key.unused."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+57
-5
@@ -9,7 +9,10 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Default gateway DNS listen address.
|
||||
///
|
||||
/// Loopback-only on the unprivileged port 5353. The canonical
|
||||
/// Loopback-only on the unprivileged port 5365, which IANA leaves
|
||||
/// unassigned and no common resolver uses. It is not 5353, the mDNS
|
||||
/// port, which the daemon's LAN rendezvous, avahi-daemon and
|
||||
/// systemd-resolved can hold. The canonical
|
||||
/// gateway deployment is a host already serving DHCP/DNS to a LAN
|
||||
/// segment (e.g., an OpenWrt AP), where port 53 is taken by the
|
||||
/// existing resolver and `.fips` queries are forwarded to the
|
||||
@@ -21,7 +24,7 @@ use serde::{Deserialize, Serialize};
|
||||
/// explicit `::1` do not accept v4-mapped traffic. Forwarders that
|
||||
/// reach the gateway over IPv4 loopback (`127.0.0.1`) need to be
|
||||
/// pointed at an explicit IPv4 listen address instead.
|
||||
const DEFAULT_DNS_LISTEN: &str = "[::1]:5353";
|
||||
const DEFAULT_DNS_LISTEN: &str = "[::1]:5365";
|
||||
|
||||
/// Default upstream DNS resolver (FIPS daemon).
|
||||
///
|
||||
@@ -131,7 +134,7 @@ pub struct PortForward {
|
||||
/// Gateway DNS resolver configuration (`gateway.dns.*`).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct GatewayDnsConfig {
|
||||
/// Listen address and port (default: `[::1]:5353`).
|
||||
/// Listen address and port (default: `[::1]:5365`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub listen: Option<String>,
|
||||
|
||||
@@ -146,7 +149,7 @@ pub struct GatewayDnsConfig {
|
||||
}
|
||||
|
||||
impl GatewayDnsConfig {
|
||||
/// Get the listen address (default: `[::1]:5353`).
|
||||
/// Get the listen address (default: `[::1]:5365`).
|
||||
pub fn listen(&self) -> &str {
|
||||
self.listen.as_deref().unwrap_or(DEFAULT_DNS_LISTEN)
|
||||
}
|
||||
@@ -160,6 +163,18 @@ impl GatewayDnsConfig {
|
||||
pub fn ttl(&self) -> u32 {
|
||||
self.ttl.unwrap_or(DEFAULT_DNS_TTL)
|
||||
}
|
||||
|
||||
/// The port of a listen address: the digits after its last `:`, or
|
||||
/// `None` when they do not form a port. Works on a hostname form too.
|
||||
pub(crate) fn port_of(listen: &str) -> Option<u16> {
|
||||
listen.rsplit_once(':')?.1.parse().ok()
|
||||
}
|
||||
|
||||
/// Whether the listen address is on the mDNS port, which an mDNS
|
||||
/// responder can take from the gateway at any time.
|
||||
pub fn is_mdns(&self) -> bool {
|
||||
Self::port_of(self.listen()) == Some(5353)
|
||||
}
|
||||
}
|
||||
|
||||
/// Conntrack timeout overrides (`gateway.conntrack.*`).
|
||||
@@ -218,7 +233,7 @@ lan_interface: "eth0"
|
||||
assert!(!config.enabled);
|
||||
assert_eq!(config.pool, "fd01::/112");
|
||||
assert_eq!(config.lan_interface, "eth0");
|
||||
assert_eq!(config.dns.listen(), "[::1]:5353");
|
||||
assert_eq!(config.dns.listen(), "[::1]:5365");
|
||||
assert_eq!(config.dns.upstream(), "[::1]:5354");
|
||||
assert_eq!(config.dns.ttl(), 60);
|
||||
assert_eq!(config.grace_period(), 60);
|
||||
@@ -226,6 +241,43 @@ lan_interface: "eth0"
|
||||
assert_eq!(config.conntrack.udp_timeout(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_listen_on_5353_is_flagged_as_mdns() {
|
||||
for listen in [
|
||||
"[::1]:5353",
|
||||
"[::]:5353",
|
||||
"127.0.0.1:5353",
|
||||
"localhost:5353",
|
||||
] {
|
||||
let dns = GatewayDnsConfig {
|
||||
listen: Some(listen.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(dns.is_mdns(), "{listen} must be flagged as the mDNS port");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_and_other_ports_are_not_flagged_as_mdns() {
|
||||
assert!(!GatewayDnsConfig::default().is_mdns());
|
||||
for listen in [
|
||||
"[::1]:5365",
|
||||
"[::]:53",
|
||||
"192.168.1.1:53",
|
||||
"[::]:5355",
|
||||
"localhost",
|
||||
] {
|
||||
let dns = GatewayDnsConfig {
|
||||
listen: Some(listen.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
!dns.is_mdns(),
|
||||
"{listen} must not be flagged as the mDNS port"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gateway_config_custom() {
|
||||
let yaml = r#"
|
||||
|
||||
+265
-30
@@ -596,13 +596,60 @@ pub fn write_pub_file(path: &Path, npub: &str) -> Result<(), ConfigError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move a key file found at an ephemeral start to `<name>.unused` beside it,
|
||||
/// so it is neither used nor overwritten and stays recoverable.
|
||||
///
|
||||
/// Returns the new path when the file was moved. Does nothing when no file is
|
||||
/// at `key_path`; a dangling symlink counts as a file and is moved as a link.
|
||||
/// An existing file at the aside path is never replaced: the key is left in
|
||||
/// place and a warning says so, as it does when the rename fails. Neither case
|
||||
/// stops the start.
|
||||
fn retire_key(key_path: &Path) -> Option<PathBuf> {
|
||||
// symlink_metadata rather than exists: a dangling symlink at the key path
|
||||
// reports exists() == false but is still a file the operator put there.
|
||||
key_path.symlink_metadata().ok()?;
|
||||
|
||||
let mut name = key_path.file_name()?.to_os_string();
|
||||
name.push(".unused");
|
||||
let aside = key_path.with_file_name(name);
|
||||
|
||||
let failure = match aside.symlink_metadata() {
|
||||
Ok(_) => "a file already exists at the aside path".to_string(),
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => e.to_string(),
|
||||
Err(_) => match std::fs::rename(key_path, &aside) {
|
||||
Ok(()) => {
|
||||
tracing::warn!(
|
||||
path = %key_path.display(),
|
||||
moved_to = %aside.display(),
|
||||
config_key = "node.identity.persistent",
|
||||
"An identity key file was found in ephemeral mode and moved aside, not used; \
|
||||
set node.identity.persistent: true and move it back to use it"
|
||||
);
|
||||
return Some(aside);
|
||||
}
|
||||
Err(e) => e.to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
tracing::warn!(
|
||||
path = %key_path.display(),
|
||||
aside = %aside.display(),
|
||||
error = %failure,
|
||||
config_key = "node.identity.persistent",
|
||||
"An identity key file was found in ephemeral mode and could not be moved aside; \
|
||||
it is not used; set node.identity.persistent: true to use it, or remove it"
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve identity from config and key file.
|
||||
///
|
||||
/// Behavior depends on `node.identity.persistent`:
|
||||
///
|
||||
/// - **`persistent: false`** (default): generate a fresh ephemeral keypair
|
||||
/// every start. Key files are written for operator visibility but overwritten
|
||||
/// on each restart.
|
||||
/// every start. Only `fips.pub` is written, so the running npub is visible;
|
||||
/// the private key is never written. A `fips.key` already at the path is
|
||||
/// moved aside to `fips.key.unused` with a warning, not used or overwritten.
|
||||
///
|
||||
/// - **`persistent: true`**: use three-tier resolution:
|
||||
/// 1. Explicit nsec in config — highest priority
|
||||
@@ -738,8 +785,8 @@ pub fn resolve_identity(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Ephemeral mode (default): fresh keypair every start, write key files
|
||||
// for operator visibility
|
||||
// Ephemeral mode (default): a fresh keypair every start, held only in
|
||||
// memory. Only the public key file is written.
|
||||
let identity = Identity::generate();
|
||||
// `keypair()` and `secret_key()` each hand back a whole private key
|
||||
// rather than a handle, so both temporaries are bound and erased.
|
||||
@@ -754,25 +801,8 @@ pub fn resolve_identity(
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
// symlink_metadata rather than exists: a dangling symlink at the key
|
||||
// path reports exists() == false but is still an existing file the
|
||||
// write is about to act on.
|
||||
if key_path.symlink_metadata().is_ok() {
|
||||
tracing::warn!(
|
||||
path = %key_path.display(),
|
||||
config_key = "node.identity.persistent",
|
||||
"An existing key file at this path is being replaced by a fresh ephemeral \
|
||||
identity; set node.identity.persistent: true to keep the existing identity"
|
||||
);
|
||||
}
|
||||
retire_key(&key_path);
|
||||
|
||||
if let Err(e) = write_key_file(&key_path, &nsec) {
|
||||
tracing::warn!(
|
||||
path = %key_path.display(),
|
||||
error = %e,
|
||||
"Failed to write the ephemeral key file"
|
||||
);
|
||||
}
|
||||
if let Err(e) = write_pub_file(&pub_path, &npub) {
|
||||
tracing::warn!(
|
||||
path = %pub_path.display(),
|
||||
@@ -2103,29 +2133,64 @@ node:
|
||||
assert_eq!(fs::read_to_string(&victim).unwrap(), "victim contents\n");
|
||||
}
|
||||
|
||||
/// The names in `dir`, sorted, so a test can assert on the whole directory.
|
||||
fn dir_names(dir: &Path) -> Vec<String> {
|
||||
let mut names: Vec<String> = fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// The npub a resolved identity runs as.
|
||||
fn resolved_npub(resolved: &ResolvedIdentity) -> String {
|
||||
crate::Identity::from_secret_str(&resolved.nsec)
|
||||
.unwrap()
|
||||
.npub()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ephemeral_over_existing_key_warns() {
|
||||
fn ephemeral_start_moves_an_existing_key_aside_intact() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("fips.yaml");
|
||||
let key_path = temp_dir.path().join("fips.key");
|
||||
let aside_path = temp_dir.path().join("fips.key.unused");
|
||||
|
||||
fs::write(&config_path, "node:\n identity: {}\n").unwrap();
|
||||
let identity = crate::Identity::generate();
|
||||
let existing = crate::encode_nsec(&identity.keypair().secret_key());
|
||||
write_key_file(&key_path, &existing).unwrap();
|
||||
let planted = fs::read(&key_path).unwrap();
|
||||
|
||||
let config = Config::load_file(&config_path).unwrap();
|
||||
let (resolved, logs) =
|
||||
capture_logs(|| resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap());
|
||||
|
||||
assert_ne!(resolved.nsec, existing);
|
||||
assert!(
|
||||
key_path.symlink_metadata().is_err(),
|
||||
"the key file must be moved away from the path a persistent start reads"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&aside_path).unwrap(),
|
||||
planted,
|
||||
"the key set aside must hold the planted bytes exactly"
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
assert_eq!(fs::metadata(&aside_path).unwrap().mode() & 0o777, 0o600);
|
||||
}
|
||||
let warnings = logs.warnings();
|
||||
assert!(
|
||||
warnings
|
||||
.iter()
|
||||
.any(|w| w.contains(&key_path.display().to_string())
|
||||
&& w.contains("node.identity.persistent")),
|
||||
"expected a warning naming the key path and the config key, got {warnings:?}"
|
||||
&& w.contains(&aside_path.display().to_string())
|
||||
&& w.contains("node.identity.persistent")
|
||||
&& w.contains("and moved aside, not used")),
|
||||
"expected the moved-aside warning naming both paths and the config key, got {warnings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2135,6 +2200,7 @@ node:
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("fips.yaml");
|
||||
let key_path = temp_dir.path().join("fips.key");
|
||||
let aside_path = temp_dir.path().join("fips.key.unused");
|
||||
let target = temp_dir.path().join("absent-target");
|
||||
|
||||
fs::write(&config_path, "node:\n identity: {}\n").unwrap();
|
||||
@@ -2148,8 +2214,21 @@ node:
|
||||
assert!(
|
||||
warnings
|
||||
.iter()
|
||||
.any(|w| w.contains(&key_path.display().to_string())),
|
||||
"expected a warning naming the key path, got {warnings:?}"
|
||||
.any(|w| w.contains(&key_path.display().to_string())
|
||||
&& w.contains("and moved aside, not used")),
|
||||
"expected the moved-aside warning naming the key path, got {warnings:?}"
|
||||
);
|
||||
assert!(
|
||||
key_path.symlink_metadata().is_err(),
|
||||
"the dangling symlink must be moved away from the key path"
|
||||
);
|
||||
assert!(
|
||||
aside_path
|
||||
.symlink_metadata()
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink(),
|
||||
"the symlink itself must be what was moved aside, not a file written through it"
|
||||
);
|
||||
assert!(
|
||||
!target.exists(),
|
||||
@@ -2157,6 +2236,155 @@ node:
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ephemeral_start_leaves_a_key_in_place_when_the_aside_name_is_taken() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("fips.yaml");
|
||||
let key_path = temp_dir.path().join("fips.key");
|
||||
let aside_path = temp_dir.path().join("fips.key.unused");
|
||||
|
||||
fs::write(&config_path, "node:\n identity: {}\n").unwrap();
|
||||
let current = crate::encode_nsec(&crate::Identity::generate().keypair().secret_key());
|
||||
let earlier = crate::encode_nsec(&crate::Identity::generate().keypair().secret_key());
|
||||
write_key_file(&key_path, ¤t).unwrap();
|
||||
write_key_file(&aside_path, &earlier).unwrap();
|
||||
let key_bytes = fs::read(&key_path).unwrap();
|
||||
let aside_bytes = fs::read(&aside_path).unwrap();
|
||||
|
||||
let config = Config::load_file(&config_path).unwrap();
|
||||
let (resolved, logs) =
|
||||
capture_logs(|| resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap());
|
||||
|
||||
assert!(matches!(resolved.source, IdentitySource::Ephemeral));
|
||||
assert_ne!(resolved.nsec, current);
|
||||
assert_eq!(
|
||||
fs::read(&key_path).unwrap(),
|
||||
key_bytes,
|
||||
"the key must be left in place, not overwritten"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&aside_path).unwrap(),
|
||||
aside_bytes,
|
||||
"the file already at the aside path must not be replaced"
|
||||
);
|
||||
let warnings = logs.warnings();
|
||||
assert!(
|
||||
warnings
|
||||
.iter()
|
||||
.any(|w| w.contains(&key_path.display().to_string())
|
||||
&& w.contains(&aside_path.display().to_string())
|
||||
&& w.contains("node.identity.persistent")
|
||||
&& w.contains("could not be moved aside")),
|
||||
"expected the could-not-move warning naming both paths and the config key, got {warnings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ephemeral_start_proceeds_when_the_key_cannot_be_moved() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
/// Puts the directory's mode back when dropped, so a failing
|
||||
/// assertion does not leave a read-only directory behind that
|
||||
/// `TempDir` cannot remove.
|
||||
struct RestoreMode<'a>(&'a Path);
|
||||
impl Drop for RestoreMode<'_> {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::set_permissions(self.0, fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
}
|
||||
|
||||
// Coverage gap: root bypasses directory permissions, so the rename
|
||||
// succeeds and this branch goes unexercised when the suite runs as
|
||||
// root. The aside-name-taken test still covers the same warning.
|
||||
if unsafe { libc::geteuid() } == 0 {
|
||||
eprintln!("skipped: running as root, which a read-only directory does not stop");
|
||||
return;
|
||||
}
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("fips.yaml");
|
||||
let key_path = temp_dir.path().join("fips.key");
|
||||
|
||||
fs::write(&config_path, "node:\n identity: {}\n").unwrap();
|
||||
let existing = crate::encode_nsec(&crate::Identity::generate().keypair().secret_key());
|
||||
write_key_file(&key_path, &existing).unwrap();
|
||||
let planted = fs::read(&key_path).unwrap();
|
||||
let config = Config::load_file(&config_path).unwrap();
|
||||
|
||||
fs::set_permissions(temp_dir.path(), fs::Permissions::from_mode(0o555)).unwrap();
|
||||
let _restore = RestoreMode(temp_dir.path());
|
||||
|
||||
let (resolved, logs) =
|
||||
capture_logs(|| resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap());
|
||||
|
||||
assert!(matches!(resolved.source, IdentitySource::Ephemeral));
|
||||
assert_ne!(resolved.nsec, existing);
|
||||
assert_eq!(
|
||||
fs::read(&key_path).unwrap(),
|
||||
planted,
|
||||
"a key that cannot be moved must be left as it was, not overwritten"
|
||||
);
|
||||
let warnings = logs.warnings();
|
||||
assert!(
|
||||
warnings
|
||||
.iter()
|
||||
.any(|w| w.contains(&key_path.display().to_string())
|
||||
&& w.contains("could not be moved aside")
|
||||
&& w.contains("os error")),
|
||||
"expected a warning naming the key path and the rename error, got {warnings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ephemeral_restarts_never_leave_a_private_key_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("fips.yaml");
|
||||
let pub_path = temp_dir.path().join("fips.pub");
|
||||
|
||||
fs::write(&config_path, "node:\n identity: {}\n").unwrap();
|
||||
let config = Config::load_file(&config_path).unwrap();
|
||||
|
||||
for start in 1..=3 {
|
||||
let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
|
||||
assert_eq!(
|
||||
dir_names(temp_dir.path()),
|
||||
["fips.pub", "fips.yaml"],
|
||||
"after start {start} the directory must hold only the config and the public key"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(&pub_path).unwrap().trim(),
|
||||
resolved_npub(&resolved),
|
||||
"after start {start} fips.pub must name the running identity"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistent_start_ignores_a_key_set_aside() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("fips.yaml");
|
||||
let key_path = temp_dir.path().join("fips.key");
|
||||
let aside_path = temp_dir.path().join("fips.key.unused");
|
||||
|
||||
fs::write(&config_path, "node:\n identity:\n persistent: true\n").unwrap();
|
||||
let earlier = crate::encode_nsec(&crate::Identity::generate().keypair().secret_key());
|
||||
write_key_file(&aside_path, &earlier).unwrap();
|
||||
let aside_bytes = fs::read(&aside_path).unwrap();
|
||||
|
||||
let config = Config::load_file(&config_path).unwrap();
|
||||
let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
|
||||
|
||||
assert!(matches!(resolved.source, IdentitySource::Generated(_)));
|
||||
assert_ne!(resolved.nsec, earlier);
|
||||
assert_eq!(read_key_file(&key_path).unwrap(), resolved.nsec);
|
||||
assert_eq!(
|
||||
fs::read(&aside_path).unwrap(),
|
||||
aside_bytes,
|
||||
"a persistent start must leave the key set aside untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_persistent_permissive_key_warns() {
|
||||
@@ -2288,11 +2516,18 @@ node:
|
||||
let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
|
||||
assert!(matches!(resolved.source, IdentitySource::Ephemeral));
|
||||
|
||||
// Key files should still be written for operator visibility
|
||||
// Only the public key is written: an ephemeral private key lives in
|
||||
// memory and nowhere else.
|
||||
let key_path = temp_dir.path().join("fips.key");
|
||||
let pub_path = temp_dir.path().join("fips.pub");
|
||||
assert!(key_path.exists());
|
||||
assert!(pub_path.exists());
|
||||
assert_eq!(
|
||||
key_path.symlink_metadata().unwrap_err().kind(),
|
||||
std::io::ErrorKind::NotFound
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(&pub_path).unwrap().trim(),
|
||||
resolved_npub(&resolved)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+151
-4
@@ -17,6 +17,7 @@ use tracing::{debug, info, trace, warn};
|
||||
|
||||
use super::pool::{PoolEvent, VirtualIpPool};
|
||||
use crate::NodeAddr;
|
||||
use crate::config::GatewayDnsConfig;
|
||||
|
||||
/// Timeout for upstream DNS queries.
|
||||
const UPSTREAM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
@@ -156,25 +157,108 @@ fn build_aaaa_response(query: &Packet, virtual_ip: Ipv6Addr, ttl: u32) -> Option
|
||||
response.build_bytes_vec_compressed().ok()
|
||||
}
|
||||
|
||||
/// The gateway DNS listener could not be bound.
|
||||
///
|
||||
/// The message names the listen address and, when the port is already in
|
||||
/// use, the service most likely to hold it and how to find the holder.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("cannot bind the gateway DNS listener on {listen}: {source}{}", in_use_hint(.listen, .source))]
|
||||
pub struct ListenError {
|
||||
listen: String,
|
||||
source: std::io::Error,
|
||||
}
|
||||
|
||||
impl ListenError {
|
||||
/// The kind of the underlying bind error.
|
||||
pub fn kind(&self) -> std::io::ErrorKind {
|
||||
self.source.kind()
|
||||
}
|
||||
}
|
||||
|
||||
/// The suffix `ListenError`'s message carries for an address-in-use error:
|
||||
/// the likely holder of the port, and how to find the actual one.
|
||||
fn in_use_hint(listen: &str, source: &std::io::Error) -> String {
|
||||
if source.kind() != std::io::ErrorKind::AddrInUse {
|
||||
return String::new();
|
||||
}
|
||||
let (holder, port) = match GatewayDnsConfig::port_of(listen) {
|
||||
Some(port) => (holder_hint(port), port.to_string()),
|
||||
None => (holder_hint(0), "<port>".to_string()),
|
||||
};
|
||||
format!(
|
||||
"; {holder}; find the holder with `ss -ulpn 'sport = :{port}'` or `netstat -ulnp`, \
|
||||
or set gateway.dns.listen to a free port and point the resolver that forwards .fips at it"
|
||||
)
|
||||
}
|
||||
|
||||
/// The service most likely to hold a DNS listen port that is already in use.
|
||||
pub(crate) fn holder_hint(port: u16) -> &'static str {
|
||||
match port {
|
||||
53 => {
|
||||
"another DNS server holds port 53: dnsmasq, systemd-resolved's stub listener, unbound or BIND"
|
||||
}
|
||||
5353 => {
|
||||
"port 5353 is mDNS: the fips daemon's LAN rendezvous (node.rendezvous.lan), \
|
||||
avahi-daemon or systemd-resolved's MulticastDNS may hold it"
|
||||
}
|
||||
5354 => {
|
||||
"the fips daemon's own DNS responder listens on 5354 by default; \
|
||||
gateway.dns.listen must not be the daemon's DNS port"
|
||||
}
|
||||
5355 => "port 5355 is LLMNR, held by systemd-resolved unless LLMNR=no",
|
||||
5365 => "another fips-gateway may already be running",
|
||||
_ => "another process holds it",
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind the gateway DNS listener.
|
||||
///
|
||||
/// Called before the gateway creates anything it would have to tear down, so
|
||||
/// a port that is already taken stops the gateway before it starts.
|
||||
pub async fn bind_listener(listen: &str) -> Result<UdpSocket, ListenError> {
|
||||
UdpSocket::bind(listen).await.map_err(|source| ListenError {
|
||||
listen: listen.to_string(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the gateway DNS resolver.
|
||||
///
|
||||
/// Listens for DNS queries, forwards `.fips` queries to the upstream
|
||||
/// daemon resolver, allocates virtual IPs, and returns them to clients.
|
||||
/// Binds `listen_addr`, then serves as [`serve`] does. The gateway binary
|
||||
/// binds and serves separately so that a bind failure stops it at startup.
|
||||
pub async fn run_dns_resolver(
|
||||
listen_addr: &str,
|
||||
upstream_addr: &str,
|
||||
ttl: u32,
|
||||
pool: std::sync::Arc<tokio::sync::Mutex<VirtualIpPool>>,
|
||||
event_tx: tokio::sync::mpsc::Sender<PoolEvent>,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
shutdown: watch::Receiver<bool>,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let socket = UdpSocket::bind(listen_addr).await?;
|
||||
let socket = bind_listener(listen_addr)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::new(e.kind(), e))?;
|
||||
info!(addr = %listen_addr, "Gateway DNS resolver listening");
|
||||
|
||||
let upstream: SocketAddr = upstream_addr
|
||||
.parse()
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
|
||||
|
||||
serve(socket, upstream, ttl, pool, event_tx, shutdown).await
|
||||
}
|
||||
|
||||
/// Serve DNS queries on a bound listener until shutdown.
|
||||
///
|
||||
/// Forwards `.fips` queries to the upstream daemon resolver, allocates
|
||||
/// virtual IPs, and returns them to clients. Returns `Ok` on shutdown and
|
||||
/// `Err` when receiving from the listener fails.
|
||||
pub async fn serve(
|
||||
socket: UdpSocket,
|
||||
upstream: SocketAddr,
|
||||
ttl: u32,
|
||||
pool: std::sync::Arc<tokio::sync::Mutex<VirtualIpPool>>,
|
||||
event_tx: tokio::sync::mpsc::Sender<PoolEvent>,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let mut buf = vec![0u8; MAX_DNS_SIZE];
|
||||
|
||||
loop {
|
||||
@@ -807,6 +891,69 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_in_use_hint_names_the_mdns_responders_for_5353() {
|
||||
let hint = holder_hint(5353);
|
||||
assert!(hint.contains("mDNS"), "{hint}");
|
||||
assert!(hint.contains("node.rendezvous.lan"), "{hint}");
|
||||
assert!(hint.contains("avahi-daemon"), "{hint}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_in_use_hint_names_llmnr_for_5355() {
|
||||
let hint = holder_hint(5355);
|
||||
assert!(hint.contains("LLMNR"), "{hint}");
|
||||
assert!(!hint.contains("mDNS"), "{hint}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_in_use_hint_names_the_daemon_for_5354() {
|
||||
let hint = holder_hint(5354);
|
||||
assert!(hint.contains("fips daemon's own DNS responder"), "{hint}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_in_use_hint_names_a_dns_server_for_53() {
|
||||
let hint = holder_hint(53);
|
||||
assert!(hint.contains("another DNS server"), "{hint}");
|
||||
assert!(hint.contains("dnsmasq"), "{hint}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_in_use_hint_names_another_gateway_for_the_default_port() {
|
||||
let hint = holder_hint(5365);
|
||||
assert!(hint.contains("another fips-gateway"), "{hint}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_in_use_hint_names_another_process_for_an_unknown_port() {
|
||||
assert_eq!(holder_hint(40000), "another process holds it");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binding_a_held_port_fails_with_addr_in_use_and_names_the_port_ss_and_netstat() {
|
||||
let holder = UdpSocket::bind("[::1]:0").await.unwrap();
|
||||
let port = holder.local_addr().unwrap().port();
|
||||
let listen = format!("[::1]:{port}");
|
||||
|
||||
let err = bind_listener(&listen)
|
||||
.await
|
||||
.expect_err("binding a held port must fail");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::AddrInUse);
|
||||
let message = err.to_string();
|
||||
assert!(message.contains(&listen), "{message}");
|
||||
assert!(message.contains(&format!("sport = :{port}")), "{message}");
|
||||
assert!(message.contains("ss -ulpn"), "{message}");
|
||||
assert!(message.contains("netstat -ulnp"), "{message}");
|
||||
assert!(message.contains(holder_hint(port)), "{message}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binding_a_free_port_returns_a_bound_socket() {
|
||||
let socket = bind_listener("[::1]:0").await.expect("bind a free port");
|
||||
assert_ne!(socket.local_addr().unwrap().port(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_fips_name() {
|
||||
// Build a simple AAAA query for test.fips
|
||||
|
||||
@@ -488,10 +488,12 @@ impl FipsStream {
|
||||
/// [`bytes_queued`](super::seqpacket::bytes_queued) reports nothing behind
|
||||
/// it. The daemon half applies the identical rule, on the same pair.
|
||||
///
|
||||
/// One case survives both checks: a zero-length datagram that is the last
|
||||
/// message before the close is indistinguishable from the close, because
|
||||
/// reading it drains the queue and a zero-length message contributes no
|
||||
/// bytes. Do not give a zero-length payload a meaning of its own.
|
||||
/// One case survives both checks on Linux, where the pair is
|
||||
/// `SOCK_SEQPACKET`: a zero-length datagram that is the last message before
|
||||
/// the close is indistinguishable from the close, because reading it drains
|
||||
/// the queue and a zero-length message contributes no bytes, so it is
|
||||
/// reported as `EPIPE`. On macOS and FreeBSD it is delivered as `Ok(0)` and
|
||||
/// the close follows. Do not give a zero-length payload a meaning of its own.
|
||||
///
|
||||
/// A datagram longer than `buf` is truncated and the remainder discarded,
|
||||
/// which is `SOCK_SEQPACKET` behaviour. Size `buf` at
|
||||
|
||||
+55
-8
@@ -46,7 +46,11 @@ use tokio::io::unix::AsyncFd;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Received {
|
||||
/// A datagram of this many bytes. Zero is a legitimate value: a client may
|
||||
/// send an empty datagram, and that is not the same as closing.
|
||||
/// send an empty datagram, and while it keeps its half open that is not the
|
||||
/// same as closing. On Linux, where the pair is `SOCK_SEQPACKET`, an empty
|
||||
/// datagram that is the last thing a client sends before it closes reads as
|
||||
/// [`Received::Eof`] instead; `recv_once` says why that one case cannot be
|
||||
/// told apart.
|
||||
Datagram(usize),
|
||||
/// The peer closed its half of the pair.
|
||||
Eof,
|
||||
@@ -308,14 +312,18 @@ impl Seqpacket {
|
||||
/// message behind it is never taken.
|
||||
///
|
||||
/// This matters because reading an empty datagram as a close would let a client
|
||||
/// tear down its own flow by sending nothing, and the defect would present as a
|
||||
/// spurious disconnect.
|
||||
/// tear down its own flow by sending nothing while it is still connected, and the
|
||||
/// defect would present as a spurious disconnect.
|
||||
///
|
||||
/// **One case survives and cannot be fixed here.** A zero-length datagram that
|
||||
/// is the last message before a close is indistinguishable from the close:
|
||||
/// reading it drains the queue, and every observation then matches a bare end of
|
||||
/// file. Separating those needs a payload that is never zero bytes on the wire,
|
||||
/// which is a protocol change rather than a receive-path one.
|
||||
/// **One case survives on `SOCK_SEQPACKET`, which is Linux, and cannot be fixed
|
||||
/// here.** A zero-length datagram that is the last message before a close is
|
||||
/// indistinguishable from the close: reading it drains the queue, and every
|
||||
/// observation then matches a bare end of file. The flow ends early and the empty
|
||||
/// datagram is never delivered. Separating those needs a payload that is never
|
||||
/// zero bytes on the wire, which is a protocol change rather than a receive-path
|
||||
/// one. On `SOCK_DGRAM`, which macOS and FreeBSD use, the empty datagram is
|
||||
/// delivered and the close is reported by the read after it; the tests below
|
||||
/// pin both behaviours.
|
||||
///
|
||||
/// **`ECONNRESET` is treated as end of file too**, for the platform whose
|
||||
/// datagram sockets report a close that way rather than through `POLLHUP`. Both
|
||||
@@ -671,6 +679,45 @@ mod tests {
|
||||
assert_eq!(recv_bounded(&daemon, &mut buf).await, Received::Eof);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_trailing_empty_datagram_before_a_close_reads_as_the_close_only_on_seqpacket() {
|
||||
// The case recv_once documents as unfixable, pinned per socket type so
|
||||
// the documentation cannot drift from what the kernel does. On
|
||||
// SOCK_SEQPACKET the empty datagram is the last thing queued when the
|
||||
// client closes, so reading it drains the queue with POLLHUP latched
|
||||
// and it is indistinguishable from the close. On SOCK_DGRAM it is
|
||||
// delivered and the close is reported by the read after it. The branch
|
||||
// is on SOCK_TYPE rather than on the OS, because the socket type is the
|
||||
// property the limitation depends on.
|
||||
let (daemon, theirs) = pair().unwrap();
|
||||
let daemon = Seqpacket::new(daemon).unwrap();
|
||||
let theirs = client(theirs);
|
||||
|
||||
// SAFETY: the descriptor is open and owned by `theirs`.
|
||||
let sent = unsafe { libc::send(theirs.as_raw_fd(), std::ptr::null(), 0, 0) };
|
||||
assert_eq!(sent, 0, "{}", io::Error::last_os_error());
|
||||
drop(theirs);
|
||||
|
||||
let mut buf = [0u8; 64];
|
||||
if SOCK_TYPE == libc::SOCK_SEQPACKET {
|
||||
assert_eq!(
|
||||
recv_bounded(&daemon, &mut buf).await,
|
||||
Received::Eof,
|
||||
"a trailing empty datagram on SOCK_SEQPACKET was delivered rather than \
|
||||
read as the close; the documented limitation no longer holds on this \
|
||||
kernel, so re-measure it and correct the docs that describe it"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
recv_bounded(&daemon, &mut buf).await,
|
||||
Received::Datagram(0),
|
||||
"a trailing empty datagram on SOCK_DGRAM was not delivered; the docs \
|
||||
say only SOCK_SEQPACKET platforms lose it"
|
||||
);
|
||||
assert_eq!(recv_bounded(&daemon, &mut buf).await, Received::Eof);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_halves_of_a_pair_are_close_on_exec() {
|
||||
// Asserted rather than assumed because the platforms disagree on how it
|
||||
|
||||
+4
-3
@@ -6,7 +6,7 @@
|
||||
use crate::NodeAddr;
|
||||
use crate::proto::bloom::BloomFilter;
|
||||
use crate::proto::bloom::FilterAnnounce;
|
||||
use crate::proto::bloom::{LinkEvidence, RrCounters};
|
||||
use crate::proto::mmp::delivery::{LinkEvidence, RrCounters};
|
||||
|
||||
use super::reject::BloomReject;
|
||||
use super::{Node, NodeError};
|
||||
@@ -288,8 +288,9 @@ impl Node {
|
||||
|
||||
/// Read what `peer_addr`'s link shows about delivery of our frames: the
|
||||
/// session's identity and next send counter, and the last ReceiverReport
|
||||
/// accepted on it. `None` when the peer has no session.
|
||||
fn link_evidence(&self, peer_addr: &NodeAddr) -> Option<LinkEvidence> {
|
||||
/// accepted on it. `None` when the peer has no session. Both the filter
|
||||
/// and the tree announce resends read it.
|
||||
pub(super) fn link_evidence(&self, peer_addr: &NodeAddr) -> Option<LinkEvidence> {
|
||||
let peer = self.peers.get(peer_addr)?;
|
||||
let session = peer.noise_session()?;
|
||||
let mut epoch = [0u8; 8];
|
||||
|
||||
+605
-79
@@ -1013,17 +1013,24 @@ fn zero_intervals(node: &mut Node) {
|
||||
}
|
||||
}
|
||||
|
||||
/// One MMP exchange between nodes `a` and `b`: `a` reports, everyone
|
||||
/// processes, `b` reports, everyone processes. No other node is asked to
|
||||
/// report.
|
||||
async fn mmp_between(nodes: &mut [TestNode], a: usize, b: usize) {
|
||||
zero_intervals(&mut nodes[a].node);
|
||||
zero_intervals(&mut nodes[b].node);
|
||||
nodes[a].node.check_mmp_reports().await;
|
||||
process_available_packets(nodes).await;
|
||||
zero_intervals(&mut nodes[a].node);
|
||||
zero_intervals(&mut nodes[b].node);
|
||||
nodes[b].node.check_mmp_reports().await;
|
||||
process_available_packets(nodes).await;
|
||||
}
|
||||
|
||||
/// One MMP exchange between M and P: M reports, P processes, P reports, M
|
||||
/// processes. C is never asked to report.
|
||||
async fn mmp_round(nodes: &mut [TestNode]) {
|
||||
zero_intervals(&mut nodes[M].node);
|
||||
zero_intervals(&mut nodes[P].node);
|
||||
nodes[M].node.check_mmp_reports().await;
|
||||
process_available_packets(nodes).await;
|
||||
zero_intervals(&mut nodes[M].node);
|
||||
zero_intervals(&mut nodes[P].node);
|
||||
nodes[P].node.check_mmp_reports().await;
|
||||
process_available_packets(nodes).await;
|
||||
mmp_between(nodes, M, P).await;
|
||||
}
|
||||
|
||||
/// Process packets on every node until a pass handles none, at most 50 passes.
|
||||
@@ -1050,15 +1057,22 @@ async fn drop_queued(tn: &mut TestNode) -> usize {
|
||||
dropped
|
||||
}
|
||||
|
||||
/// ReceiverReports M has seen from P, including stale and duplicate ones.
|
||||
fn reports_seen(fx: &FlipFixture) -> u64 {
|
||||
fx.nodes[M]
|
||||
/// ReceiverReports node `a` has seen from node `b`, including stale and
|
||||
/// duplicate ones.
|
||||
fn seen_by(nodes: &[TestNode], a: usize, b: usize) -> u64 {
|
||||
let remote = *nodes[b].node.node_addr();
|
||||
nodes[a]
|
||||
.node
|
||||
.get_peer(&fx.p)
|
||||
.get_peer(&remote)
|
||||
.and_then(|peer| peer.mmp())
|
||||
.map_or(0, |mmp| mmp.metrics.reports_seen())
|
||||
}
|
||||
|
||||
/// ReceiverReports M has seen from P, including stale and duplicate ones.
|
||||
fn reports_seen(fx: &FlipFixture) -> u64 {
|
||||
seen_by(&fx.nodes, M, P)
|
||||
}
|
||||
|
||||
/// Whether the filter P stores for M contains the marker.
|
||||
fn holds_marker(fx: &FlipFixture) -> bool {
|
||||
fx.nodes[P]
|
||||
@@ -1068,40 +1082,52 @@ fn holds_marker(fx: &FlipFixture) -> bool {
|
||||
.is_some_and(|filter| filter.contains(&marker()))
|
||||
}
|
||||
|
||||
/// Parent-switch counts at M and P before a run of MMP rounds.
|
||||
/// Parent-switch counts at the two ends of a link before a run of MMP
|
||||
/// rounds.
|
||||
///
|
||||
/// A first RTT sample can re-evaluate the parent, and a switch marks every
|
||||
/// peer, which would pass a resend test for a reason unrelated to the resend.
|
||||
struct SwitchGuard {
|
||||
m: u64,
|
||||
p: u64,
|
||||
a: u64,
|
||||
b: u64,
|
||||
}
|
||||
|
||||
/// Snapshot the parent-switch counters of nodes `a` and `b`.
|
||||
fn guard_of(nodes: &[TestNode], a: usize, b: usize) -> SwitchGuard {
|
||||
SwitchGuard {
|
||||
a: nodes[a].node.metrics().tree.parent_switches.get(),
|
||||
b: nodes[b].node.metrics().tree.parent_switches.get(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert neither node `a` nor node `b` switched parent since `guard`, and
|
||||
/// `a`'s parent is still `parent`.
|
||||
fn assert_steady(nodes: &[TestNode], a: usize, b: usize, guard: &SwitchGuard, parent: &NodeAddr) {
|
||||
assert_eq!(
|
||||
nodes[a].node.metrics().tree.parent_switches.get(),
|
||||
guard.a,
|
||||
"setup: node {a} must not switch parent during the MMP rounds"
|
||||
);
|
||||
assert_eq!(
|
||||
nodes[b].node.metrics().tree.parent_switches.get(),
|
||||
guard.b,
|
||||
"setup: node {b} must not switch parent during the MMP rounds"
|
||||
);
|
||||
assert_eq!(
|
||||
nodes[a].node.tree_state().my_declaration().parent_id(),
|
||||
parent,
|
||||
"setup: node {a}'s parent must not change"
|
||||
);
|
||||
}
|
||||
|
||||
/// Snapshot M's and P's parent-switch counters.
|
||||
fn switch_guard(fx: &FlipFixture) -> SwitchGuard {
|
||||
SwitchGuard {
|
||||
m: fx.nodes[M].node.metrics().tree.parent_switches.get(),
|
||||
p: fx.nodes[P].node.metrics().tree.parent_switches.get(),
|
||||
}
|
||||
guard_of(&fx.nodes, M, P)
|
||||
}
|
||||
|
||||
/// Assert neither M nor P switched parent since `guard`, and M's parent is P.
|
||||
fn assert_unswitched(fx: &FlipFixture, guard: &SwitchGuard) {
|
||||
assert_eq!(
|
||||
fx.nodes[M].node.metrics().tree.parent_switches.get(),
|
||||
guard.m,
|
||||
"setup: M must not switch parent during the MMP rounds"
|
||||
);
|
||||
assert_eq!(
|
||||
fx.nodes[P].node.metrics().tree.parent_switches.get(),
|
||||
guard.p,
|
||||
"setup: P must not switch parent during the MMP rounds"
|
||||
);
|
||||
assert_eq!(
|
||||
fx.nodes[M].node.tree_state().my_declaration().parent_id(),
|
||||
&fx.p,
|
||||
"setup: M's parent must still be P"
|
||||
);
|
||||
assert_steady(&fx.nodes, M, P, guard, &fx.p);
|
||||
}
|
||||
|
||||
/// FilterAnnounces M has sent.
|
||||
@@ -1109,16 +1135,25 @@ fn sent_count(fx: &FlipFixture) -> u64 {
|
||||
fx.nodes[M].node.metrics().bloom.sent.get()
|
||||
}
|
||||
|
||||
/// Drain the fixture, then run MMP rounds until M has seen a report from P.
|
||||
async fn start_reports(fx: &mut FlipFixture) {
|
||||
drain_quiet(&mut fx.nodes).await;
|
||||
/// Drain `nodes`, then run MMP exchanges between nodes `a` and `b` until `a`
|
||||
/// has seen a report from `b`.
|
||||
async fn await_report(nodes: &mut [TestNode], a: usize, b: usize) {
|
||||
drain_quiet(nodes).await;
|
||||
for _ in 0..10 {
|
||||
if reports_seen(fx) >= 1 {
|
||||
if seen_by(nodes, a, b) >= 1 {
|
||||
break;
|
||||
}
|
||||
mmp_round(&mut fx.nodes).await;
|
||||
mmp_between(nodes, a, b).await;
|
||||
}
|
||||
assert!(reports_seen(fx) >= 1, "setup: P must report to M");
|
||||
assert!(
|
||||
seen_by(nodes, a, b) >= 1,
|
||||
"setup: node {b} must report to node {a}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Drain the fixture, then run MMP rounds until M has seen a report from P.
|
||||
async fn start_reports(fx: &mut FlipFixture) {
|
||||
await_report(&mut fx.nodes, M, P).await;
|
||||
}
|
||||
|
||||
/// Deliver C's filter carrying the marker and send M's announce of it to P.
|
||||
@@ -1159,19 +1194,26 @@ async fn lose_marker(fx: &mut FlipFixture) -> u64 {
|
||||
sent
|
||||
}
|
||||
|
||||
/// The first eight bytes of the handshake hash of M's current session with P.
|
||||
fn link_epoch(fx: &FlipFixture) -> [u8; 8] {
|
||||
let hash = fx.nodes[M]
|
||||
/// The first eight bytes of the handshake hash of node `a`'s current session
|
||||
/// with node `b`.
|
||||
fn epoch_of(nodes: &[TestNode], a: usize, b: usize) -> [u8; 8] {
|
||||
let remote = *nodes[b].node.node_addr();
|
||||
let hash = nodes[a]
|
||||
.node
|
||||
.get_peer(&fx.p)
|
||||
.get_peer(&remote)
|
||||
.and_then(|peer| peer.noise_session())
|
||||
.expect("M has a session with P")
|
||||
.expect("setup: the link has a session")
|
||||
.handshake_hash();
|
||||
let mut epoch = [0u8; 8];
|
||||
epoch.copy_from_slice(&hash[..8]);
|
||||
epoch
|
||||
}
|
||||
|
||||
/// The first eight bytes of the handshake hash of M's current session with P.
|
||||
fn link_epoch(fx: &FlipFixture) -> [u8; 8] {
|
||||
epoch_of(&fx.nodes, M, P)
|
||||
}
|
||||
|
||||
/// A FilterAnnounce lost in transit is resent once a receiver report shows
|
||||
/// the loss, so the peer ends up holding the filter.
|
||||
#[tokio::test]
|
||||
@@ -1221,52 +1263,76 @@ async fn test_bloom_unchanged_filter_with_newer_sequence_marks_no_peer() {
|
||||
cleanup_nodes(&mut fx.nodes).await;
|
||||
}
|
||||
|
||||
/// Make M rekey on its next check: one message on a session is enough, time
|
||||
/// never triggers it, and both ends of M's links are aged past the
|
||||
/// responder's rekey-acceptance gate so both rekeys are ordinary ones.
|
||||
fn arm_rekey(fx: &mut FlipFixture) {
|
||||
fx.nodes[M].node.replace_context(|ctx| {
|
||||
/// Make node `a` rekey on its next check: one message on a session is
|
||||
/// enough, time never triggers it, and both ends of every link of `a` are
|
||||
/// aged past the responder's rekey-acceptance gate so every rekey is an
|
||||
/// ordinary one.
|
||||
fn arm_rekeys(nodes: &mut [TestNode], a: usize) {
|
||||
nodes[a].node.replace_context(|ctx| {
|
||||
let mut cfg = (*ctx.config).clone();
|
||||
cfg.node.rekey.enabled = true;
|
||||
cfg.node.rekey.after_messages = 1;
|
||||
cfg.node.rekey.after_secs = u64::MAX;
|
||||
ctx.config = std::sync::Arc::new(cfg);
|
||||
});
|
||||
let (m, p, c) = (fx.m, fx.p, fx.c);
|
||||
let local = *nodes[a].node.node_addr();
|
||||
let remotes: Vec<NodeAddr> = nodes[a].node.peers.keys().copied().collect();
|
||||
let age = Duration::from_secs(31);
|
||||
for (i, remote) in [(M, p), (P, m), (M, c), (C, m)] {
|
||||
fx.nodes[i]
|
||||
.node
|
||||
.get_peer_mut(&remote)
|
||||
.expect("setup: link peer present")
|
||||
.test_backdate_session_established(age);
|
||||
for remote in remotes {
|
||||
let b = nodes
|
||||
.iter()
|
||||
.position(|tn| *tn.node.node_addr() == remote)
|
||||
.expect("setup: every peer is a test node");
|
||||
for (i, addr) in [(a, remote), (b, local)] {
|
||||
nodes[i]
|
||||
.node
|
||||
.get_peer_mut(&addr)
|
||||
.expect("setup: link peer present")
|
||||
.test_backdate_session_established(age);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Make M rekey on its next check, with both ends of both of M's links aged
|
||||
/// past the responder's rekey-acceptance gate.
|
||||
fn arm_rekey(fx: &mut FlipFixture) {
|
||||
arm_rekeys(&mut fx.nodes, M);
|
||||
}
|
||||
|
||||
/// Drive the real rekey handshake until node `a`'s session with node `b` is
|
||||
/// cut over.
|
||||
async fn cutover(nodes: &mut [TestNode], a: usize, b: usize) {
|
||||
let before = epoch_of(nodes, a, b);
|
||||
for _ in 0..6 {
|
||||
nodes[a].node.check_rekey().await;
|
||||
nodes[b].node.check_rekey().await;
|
||||
for _ in 0..3 {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
process_available_packets(nodes).await;
|
||||
}
|
||||
if epoch_of(nodes, a, b) != before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_ne!(
|
||||
epoch_of(nodes, a, b),
|
||||
before,
|
||||
"setup: node {a}'s link to node {b} must rekey"
|
||||
);
|
||||
let (addr_a, addr_b) = (*nodes[a].node.node_addr(), *nodes[b].node.node_addr());
|
||||
assert!(
|
||||
!nodes[a].node.get_peer(&addr_b).unwrap().rekey_in_progress(),
|
||||
"setup: node {a}'s rekey with node {b} must be complete"
|
||||
);
|
||||
assert!(
|
||||
!nodes[b].node.get_peer(&addr_a).unwrap().rekey_in_progress(),
|
||||
"setup: node {b}'s rekey with node {a} must be complete"
|
||||
);
|
||||
}
|
||||
|
||||
/// Drive the real rekey handshake until M's session with P is cut over.
|
||||
async fn rekey_cutover(fx: &mut FlipFixture) {
|
||||
let before = link_epoch(fx);
|
||||
for _ in 0..6 {
|
||||
fx.nodes[M].node.check_rekey().await;
|
||||
fx.nodes[P].node.check_rekey().await;
|
||||
for _ in 0..3 {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
process_available_packets(&mut fx.nodes).await;
|
||||
}
|
||||
if link_epoch(fx) != before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_ne!(link_epoch(fx), before, "setup: M's link to P must rekey");
|
||||
let (m, p) = (fx.m, fx.p);
|
||||
assert!(
|
||||
!fx.nodes[M].node.get_peer(&p).unwrap().rekey_in_progress(),
|
||||
"setup: M's rekey with P must be complete"
|
||||
);
|
||||
assert!(
|
||||
!fx.nodes[P].node.get_peer(&m).unwrap().rekey_in_progress(),
|
||||
"setup: P's rekey with M must be complete"
|
||||
);
|
||||
cutover(&mut fx.nodes, M, P).await;
|
||||
}
|
||||
|
||||
/// An announce lost just before a link rekey is resent on the new session.
|
||||
@@ -1574,3 +1640,463 @@ async fn test_bloom_reports_from_the_previous_session_do_not_trigger_resends() {
|
||||
);
|
||||
cleanup_nodes(&mut fx.nodes).await;
|
||||
}
|
||||
|
||||
// ===== Resend of a tree announce the peer did not receive =====
|
||||
//
|
||||
// Tree announces are confirmed and resent by the same receiver-report check
|
||||
// as filter announces, so their node tests share this file's MMP, rekey and
|
||||
// loss helpers. They run on real converged state only: every role is read
|
||||
// from the converged tree, and nothing in any node's tree state is forged.
|
||||
// Final assertions read the sequence the receiver stores for the sender,
|
||||
// never what the sender believes it sent.
|
||||
|
||||
/// A converged line of loopback nodes with a sender S that is not root and
|
||||
/// its parent R.
|
||||
///
|
||||
/// In a line every neighbour of S other than its parent is its child, whose
|
||||
/// ancestry contains S, so S has no alternative parent and cannot switch.
|
||||
struct TreeLine {
|
||||
nodes: Vec<TestNode>,
|
||||
/// Index of the sender.
|
||||
s: usize,
|
||||
/// Index of the sender's parent.
|
||||
r: usize,
|
||||
}
|
||||
|
||||
/// Index of the node whose address is `addr`.
|
||||
fn index_of(nodes: &[TestNode], addr: &NodeAddr) -> usize {
|
||||
nodes
|
||||
.iter()
|
||||
.position(|tn| tn.node.node_addr() == addr)
|
||||
.expect("setup: address belongs to a test node")
|
||||
}
|
||||
|
||||
/// Converge a line of `n` nodes (2 or 4) and pick S and R from the result.
|
||||
///
|
||||
/// With 4 nodes, S is the first of indices 1 and 2 that is not root; with 2,
|
||||
/// S is the node that is not root. R is S's parent. Every node's per-peer
|
||||
/// tree announce rate limit is set to 0 and whatever convergence left
|
||||
/// pending is flushed, so a resend is never held back by the rate limit.
|
||||
async fn tree_line(n: usize) -> TreeLine {
|
||||
let edges: Vec<(usize, usize)> = (1..n).map(|i| (i - 1, i)).collect();
|
||||
let mut nodes = run_tree_test(n, &edges, false).await;
|
||||
|
||||
let root = (0..n)
|
||||
.find(|&i| nodes[i].node.tree_state().is_root())
|
||||
.expect("setup: the line must have a root");
|
||||
let candidates: &[usize] = if n == 2 { &[0, 1] } else { &[1, 2] };
|
||||
let s = *candidates
|
||||
.iter()
|
||||
.find(|&&i| !nodes[i].node.tree_state().is_root())
|
||||
.expect("setup: one candidate sender is not root");
|
||||
let s_addr = *nodes[s].node.node_addr();
|
||||
let r = index_of(
|
||||
&nodes,
|
||||
nodes[s].node.tree_state().my_declaration().parent_id(),
|
||||
);
|
||||
let neighbours: Vec<usize> = [s.checked_sub(1), Some(s + 1)]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|&i| i < n)
|
||||
.collect();
|
||||
eprintln!(
|
||||
"tree_line({n}): root index {root}, S {s}, R {r}, shape: {}",
|
||||
if r == root {
|
||||
"R is root"
|
||||
} else {
|
||||
"R is not root"
|
||||
}
|
||||
);
|
||||
|
||||
assert!(
|
||||
!nodes[s].node.tree_state().is_root(),
|
||||
"setup: S is not root"
|
||||
);
|
||||
assert_eq!(
|
||||
nodes[s].node.peers.len(),
|
||||
neighbours.len(),
|
||||
"setup: S has exactly its line neighbours as peers"
|
||||
);
|
||||
assert!(neighbours.contains(&r), "setup: R is S's neighbour");
|
||||
for &c in neighbours.iter().filter(|&&i| i != r) {
|
||||
assert_eq!(
|
||||
nodes[c].node.tree_state().my_declaration().parent_id(),
|
||||
&s_addr,
|
||||
"setup: S's other neighbour declares S as its parent"
|
||||
);
|
||||
}
|
||||
|
||||
for tn in nodes.iter_mut() {
|
||||
for peer in tn.node.peers.values_mut() {
|
||||
peer.set_tree_announce_min_interval_ms(0);
|
||||
}
|
||||
tn.node.send_pending_tree_announces().await;
|
||||
}
|
||||
drain_quiet(&mut nodes).await;
|
||||
for (i, tn) in nodes.iter().enumerate() {
|
||||
for peer in tn.node.peers.values() {
|
||||
assert!(
|
||||
!peer.has_pending_tree_announce(),
|
||||
"setup: node {i} must have no pending tree announce"
|
||||
);
|
||||
}
|
||||
}
|
||||
TreeLine { nodes, s, r }
|
||||
}
|
||||
|
||||
/// Turn off the periodic parent re-evaluation on `node`, so its periodic
|
||||
/// re-broadcast cannot be what delivers a lost announce.
|
||||
fn no_reeval(node: &mut Node) {
|
||||
node.replace_context(|ctx| {
|
||||
let mut cfg = (*ctx.config).clone();
|
||||
cfg.node.tree.reeval_interval_secs = 0;
|
||||
ctx.config = std::sync::Arc::new(cfg);
|
||||
});
|
||||
}
|
||||
|
||||
/// Hold off S's fallback resend. S's announces to a child that never
|
||||
/// reports stay outstanding, and on a loaded host a test running past the
|
||||
/// fallback would add an unchecked resend to the child and break the exact
|
||||
/// send counts.
|
||||
fn hold_fallback(line: &mut TreeLine) {
|
||||
line.nodes[line.s]
|
||||
.node
|
||||
.tree_state_mut()
|
||||
.set_fallback(u64::MAX);
|
||||
}
|
||||
|
||||
/// Whether S's announce to R still awaits confirmation.
|
||||
fn parent_outstanding(line: &TreeLine) -> bool {
|
||||
let parent = *line.nodes[line.r].node.node_addr();
|
||||
line.nodes[line.s]
|
||||
.node
|
||||
.tree_state()
|
||||
.announce_outstanding(&parent)
|
||||
}
|
||||
|
||||
/// TreeAnnounces node `i` has sent.
|
||||
fn tree_sent(nodes: &[TestNode], i: usize) -> u64 {
|
||||
nodes[i].node.metrics().tree.sent.get()
|
||||
}
|
||||
|
||||
/// The declaration sequence node `r` stores for node `s`.
|
||||
fn held_seq(nodes: &[TestNode], r: usize, s: usize) -> Option<u64> {
|
||||
let sender = *nodes[s].node.node_addr();
|
||||
nodes[r]
|
||||
.node
|
||||
.tree_state()
|
||||
.peer_declaration(&sender)
|
||||
.map(|decl| decl.sequence())
|
||||
}
|
||||
|
||||
/// Give S a new declaration sequence with the same parent, signed, as a
|
||||
/// position change would. Returns the new sequence.
|
||||
fn bump(line: &mut TreeLine) -> u64 {
|
||||
let (s, r) = (line.s, line.r);
|
||||
let parent = *line.nodes[r].node.node_addr();
|
||||
let identity = line.nodes[s].node.identity().clone();
|
||||
let ts = line.nodes[s].node.tree_state_mut();
|
||||
let seq = ts.my_declaration().sequence() + 1;
|
||||
let timestamp = ts.my_declaration().timestamp() + 1;
|
||||
ts.set_parent(parent, seq, timestamp, crate::time::mono_ms());
|
||||
ts.recompute_coords();
|
||||
sign_declaration(ts.my_declaration_mut(), &identity).unwrap();
|
||||
|
||||
let ts = line.nodes[s].node.tree_state();
|
||||
assert!(!ts.is_root(), "setup: S is still not root after the bump");
|
||||
assert_eq!(
|
||||
ts.my_declaration().parent_id(),
|
||||
&parent,
|
||||
"setup: S's parent is still R after the bump"
|
||||
);
|
||||
assert!(
|
||||
held_seq(&line.nodes, r, s).is_some_and(|held| seq > held),
|
||||
"setup: the new sequence is fresher than the one R holds for S"
|
||||
);
|
||||
seq
|
||||
}
|
||||
|
||||
/// Send S's current announce to R and check exactly one was sent. Returns
|
||||
/// S's sent count after the send.
|
||||
async fn send_up(line: &mut TreeLine) -> u64 {
|
||||
let (s, r) = (line.s, line.r);
|
||||
let parent = *line.nodes[r].node.node_addr();
|
||||
let before = tree_sent(&line.nodes, s);
|
||||
line.nodes[s]
|
||||
.node
|
||||
.send_tree_announce_to_peer(&parent)
|
||||
.await
|
||||
.expect("setup: the send must succeed");
|
||||
let after = tree_sent(&line.nodes, s);
|
||||
assert_eq!(after, before + 1, "setup: S must send exactly one announce");
|
||||
after
|
||||
}
|
||||
|
||||
/// Lose the announce just sent to R, and check the loss took.
|
||||
async fn lose_up(line: &mut TreeLine, old: Option<u64>) {
|
||||
let (s, r) = (line.s, line.r);
|
||||
assert_eq!(
|
||||
drop_queued(&mut line.nodes[r]).await,
|
||||
1,
|
||||
"setup: exactly the one announce frame must be lost"
|
||||
);
|
||||
assert_eq!(
|
||||
held_seq(&line.nodes, r, s),
|
||||
old,
|
||||
"control: R must still hold S's old sequence"
|
||||
);
|
||||
}
|
||||
|
||||
/// Bump S's declaration, send it to R and lose it on the way. Returns the new
|
||||
/// sequence and S's sent count after the send.
|
||||
async fn lose_bump(line: &mut TreeLine) -> (u64, u64) {
|
||||
let old = held_seq(&line.nodes, line.r, line.s);
|
||||
let seq = bump(line);
|
||||
let sent = send_up(line).await;
|
||||
lose_up(line, old).await;
|
||||
(seq, sent)
|
||||
}
|
||||
|
||||
/// `rounds` rounds of an MMP exchange between S and R followed by S's tree
|
||||
/// tick, with checks that a report arrived and nothing switched parent.
|
||||
async fn tree_rounds(line: &mut TreeLine, rounds: usize) {
|
||||
let (s, r) = (line.s, line.r);
|
||||
let parent = *line.nodes[r].node.node_addr();
|
||||
let seen = seen_by(&line.nodes, s, r);
|
||||
let guard = guard_of(&line.nodes, s, r);
|
||||
for _ in 0..rounds {
|
||||
mmp_between(&mut line.nodes, s, r).await;
|
||||
line.nodes[s].node.check_tree_state().await;
|
||||
process_available_packets(&mut line.nodes).await;
|
||||
}
|
||||
assert!(
|
||||
seen_by(&line.nodes, s, r) > seen,
|
||||
"setup: a receiver report must arrive after the send"
|
||||
);
|
||||
assert_steady(&line.nodes, s, r, &guard, &parent);
|
||||
}
|
||||
|
||||
/// Whether S has a tree announce pending for R.
|
||||
fn parent_pending(line: &TreeLine) -> bool {
|
||||
let parent = *line.nodes[line.r].node.node_addr();
|
||||
line.nodes[line.s]
|
||||
.node
|
||||
.get_peer(&parent)
|
||||
.expect("setup: R is S's peer")
|
||||
.has_pending_tree_announce()
|
||||
}
|
||||
|
||||
/// A TreeAnnounce lost in transit is resent once a receiver report shows the
|
||||
/// loss, so the parent ends up holding the new declaration.
|
||||
#[tokio::test]
|
||||
async fn test_tree_announce_lost_in_transit_reaches_the_peer_after_a_receiver_report() {
|
||||
let mut line = tree_line(4).await;
|
||||
let (s, r) = (line.s, line.r);
|
||||
await_report(&mut line.nodes, s, r).await;
|
||||
no_reeval(&mut line.nodes[s].node);
|
||||
hold_fallback(&mut line);
|
||||
|
||||
let (seq, sent) = lose_bump(&mut line).await;
|
||||
tree_rounds(&mut line, 3).await;
|
||||
|
||||
assert_eq!(
|
||||
tree_sent(&line.nodes, s),
|
||||
sent + 1,
|
||||
"S must resend to R exactly once after the loss"
|
||||
);
|
||||
assert!(
|
||||
!parent_pending(&line),
|
||||
"the rate limit must not be holding the resend"
|
||||
);
|
||||
assert_eq!(
|
||||
held_seq(&line.nodes, r, s),
|
||||
Some(seq),
|
||||
"R must hold the declaration whose announce was lost"
|
||||
);
|
||||
cleanup_nodes(&mut line.nodes).await;
|
||||
}
|
||||
|
||||
/// A node with a single peer has no periodic re-broadcast, so without a
|
||||
/// resend a lost announce is never recovered.
|
||||
#[tokio::test]
|
||||
async fn test_tree_announce_lost_by_a_node_with_one_peer_reaches_the_peer_after_a_receiver_report()
|
||||
{
|
||||
let mut line = tree_line(2).await;
|
||||
let (s, r) = (line.s, line.r);
|
||||
await_report(&mut line.nodes, s, r).await;
|
||||
|
||||
let (seq, _) = lose_bump(&mut line).await;
|
||||
tree_rounds(&mut line, 3).await;
|
||||
|
||||
assert_eq!(
|
||||
held_seq(&line.nodes, r, s),
|
||||
Some(seq),
|
||||
"R must hold the declaration whose announce was lost"
|
||||
);
|
||||
cleanup_nodes(&mut line.nodes).await;
|
||||
}
|
||||
|
||||
/// An announce lost just before a link rekey is resent on the new session.
|
||||
#[tokio::test]
|
||||
async fn test_tree_announce_lost_before_a_link_rekey_reaches_the_peer_after_the_cutover() {
|
||||
let mut line = tree_line(4).await;
|
||||
let (s, r) = (line.s, line.r);
|
||||
await_report(&mut line.nodes, s, r).await;
|
||||
no_reeval(&mut line.nodes[s].node);
|
||||
hold_fallback(&mut line);
|
||||
|
||||
let (seq, sent) = lose_bump(&mut line).await;
|
||||
arm_rekeys(&mut line.nodes, s);
|
||||
cutover(&mut line.nodes, s, r).await;
|
||||
tree_rounds(&mut line, 5).await;
|
||||
|
||||
assert_eq!(
|
||||
tree_sent(&line.nodes, s),
|
||||
sent + 1,
|
||||
"S must resend to R exactly once after the loss"
|
||||
);
|
||||
assert_eq!(
|
||||
held_seq(&line.nodes, r, s),
|
||||
Some(seq),
|
||||
"R must hold the declaration whose announce was lost before the rekey"
|
||||
);
|
||||
assert!(
|
||||
!parent_outstanding(&line),
|
||||
"S's resend on the new session must be confirmed"
|
||||
);
|
||||
cleanup_nodes(&mut line.nodes).await;
|
||||
}
|
||||
|
||||
/// An announce that arrives is confirmed from the receiver reports and never
|
||||
/// resent.
|
||||
#[tokio::test]
|
||||
async fn test_tree_delivered_announce_is_confirmed_without_a_resend() {
|
||||
let mut line = tree_line(4).await;
|
||||
let (s, r) = (line.s, line.r);
|
||||
await_report(&mut line.nodes, s, r).await;
|
||||
no_reeval(&mut line.nodes[s].node);
|
||||
hold_fallback(&mut line);
|
||||
|
||||
let seq = bump(&mut line);
|
||||
let sent = send_up(&mut line).await;
|
||||
assert!(
|
||||
parent_outstanding(&line),
|
||||
"control: the tracker must hold the announce straight after the send"
|
||||
);
|
||||
process_available_packets(&mut line.nodes).await;
|
||||
assert_eq!(
|
||||
held_seq(&line.nodes, r, s),
|
||||
Some(seq),
|
||||
"control: R must hold the announce"
|
||||
);
|
||||
tree_rounds(&mut line, 3).await;
|
||||
|
||||
assert_eq!(
|
||||
tree_sent(&line.nodes, s),
|
||||
sent,
|
||||
"S must not resend a delivered announce"
|
||||
);
|
||||
assert!(!parent_pending(&line), "R must not be marked");
|
||||
assert!(
|
||||
!parent_outstanding(&line),
|
||||
"the delivered announce must be confirmed"
|
||||
);
|
||||
cleanup_nodes(&mut line.nodes).await;
|
||||
}
|
||||
|
||||
/// On a converged mesh with clean links, every tree announce is confirmed
|
||||
/// from the receiver reports and none is resent. The convergence announces
|
||||
/// go out before any report, so this checks the zero baseline on real
|
||||
/// counters.
|
||||
#[tokio::test]
|
||||
async fn test_tree_clean_links_confirm_every_announce_without_a_resend() {
|
||||
let mut nodes = run_tree_test(3, &[(0, 1), (1, 2)], false).await;
|
||||
for tn in nodes.iter_mut() {
|
||||
for peer in tn.node.peers.values_mut() {
|
||||
peer.set_tree_announce_min_interval_ms(0);
|
||||
}
|
||||
tn.node.send_pending_tree_announces().await;
|
||||
no_reeval(&mut tn.node);
|
||||
}
|
||||
drain_quiet(&mut nodes).await;
|
||||
for (i, tn) in nodes.iter().enumerate() {
|
||||
for peer in tn.node.peers.values() {
|
||||
assert!(
|
||||
!peer.has_pending_tree_announce(),
|
||||
"setup: node {i} must have no pending tree announce"
|
||||
);
|
||||
}
|
||||
}
|
||||
let snapshot = |nodes: &[TestNode]| -> Vec<(u64, u64, NodeAddr)> {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|tn| {
|
||||
(
|
||||
tn.node.metrics().tree.sent.get(),
|
||||
tn.node.metrics().tree.parent_switches.get(),
|
||||
*tn.node.tree_state().my_declaration().parent_id(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let before = snapshot(&nodes);
|
||||
assert!(
|
||||
(0..nodes.len()).all(|i| tree_sent(&nodes, i) > 0),
|
||||
"setup: every node must have sent tree announces while converging"
|
||||
);
|
||||
|
||||
for _ in 0..5 {
|
||||
for i in 0..nodes.len() {
|
||||
zero_intervals(&mut nodes[i].node);
|
||||
nodes[i].node.check_mmp_reports().await;
|
||||
process_available_packets(&mut nodes).await;
|
||||
}
|
||||
for tn in nodes.iter_mut() {
|
||||
tn.node.check_tree_state().await;
|
||||
}
|
||||
process_available_packets(&mut nodes).await;
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
snapshot(&nodes),
|
||||
before,
|
||||
"no node may send a tree announce, switch parent or change parent"
|
||||
);
|
||||
for (i, tn) in nodes.iter().enumerate() {
|
||||
for peer in tn.node.peers.keys() {
|
||||
assert!(
|
||||
!tn.node.tree_state().announce_outstanding(peer),
|
||||
"node {i} must have confirmed its tree announce to every peer"
|
||||
);
|
||||
}
|
||||
}
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
|
||||
/// With no receiver report at all, a lost tree announce is still resent once
|
||||
/// the fallback interval passes.
|
||||
#[tokio::test]
|
||||
async fn test_tree_lost_announce_is_resent_after_the_fallback_when_no_receiver_report_arrives() {
|
||||
let mut line = tree_line(4).await;
|
||||
let (s, r) = (line.s, line.r);
|
||||
drain_quiet(&mut line.nodes).await;
|
||||
no_reeval(&mut line.nodes[s].node);
|
||||
line.nodes[s].node.tree_state_mut().set_fallback(0);
|
||||
let seen = seen_by(&line.nodes, s, r);
|
||||
|
||||
let (seq, _) = lose_bump(&mut line).await;
|
||||
line.nodes[s].node.check_tree_state().await;
|
||||
process_available_packets(&mut line.nodes).await;
|
||||
|
||||
assert_eq!(
|
||||
seen_by(&line.nodes, s, r),
|
||||
seen,
|
||||
"setup: R must send no report"
|
||||
);
|
||||
assert_eq!(
|
||||
held_seq(&line.nodes, r, s),
|
||||
Some(seq),
|
||||
"R must hold the declaration once the fallback resends it"
|
||||
);
|
||||
cleanup_nodes(&mut line.nodes).await;
|
||||
}
|
||||
|
||||
+61
-3
@@ -139,7 +139,26 @@ impl Node {
|
||||
peer.record_tree_announce_sent(now_ms);
|
||||
}
|
||||
|
||||
trace!(peer = %self.peer_display_name(peer_addr), "Sent TreeAnnounce");
|
||||
// Keep the announce outstanding until the receiver reports confirm
|
||||
// it. Read after the send: anything else taking a counter in between
|
||||
// only makes the recorded counter higher, which delays confirmation
|
||||
// rather than confirming a frame that was never covered.
|
||||
let seq = announce.declaration.sequence();
|
||||
if let Some(link) = self.link_evidence(peer_addr) {
|
||||
self.tree_state.record_announce(
|
||||
*peer_addr,
|
||||
seq,
|
||||
link.next_counter.saturating_sub(1),
|
||||
&link,
|
||||
crate::time::mono_ms(),
|
||||
);
|
||||
}
|
||||
|
||||
trace!(
|
||||
peer = %self.peer_display_name(peer_addr),
|
||||
seq = seq,
|
||||
"Sent TreeAnnounce"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -584,13 +603,52 @@ impl Node {
|
||||
|
||||
/// Periodic tree maintenance, called from the tick handler.
|
||||
///
|
||||
/// Sends pending rate-limited announces and checks for periodic
|
||||
/// parent re-evaluation based on current MMP link costs.
|
||||
/// Marks peers whose last announce was not confirmed delivered, sends
|
||||
/// pending rate-limited announces (so a resend goes out in the same tick
|
||||
/// through the ordinary send path), and checks for periodic parent
|
||||
/// re-evaluation based on current MMP link costs.
|
||||
pub(super) async fn check_tree_state(&mut self) {
|
||||
self.tree_resend();
|
||||
self.send_pending_tree_announces().await;
|
||||
self.check_periodic_parent_reeval().await;
|
||||
}
|
||||
|
||||
/// Mark for resend every peer whose outstanding TreeAnnounce the receiver
|
||||
/// reports show was lost, or could not confirm in time.
|
||||
///
|
||||
/// The resend is an ordinary announce built from the current declaration,
|
||||
/// so if our position moved on since the lost one, the peer gets the
|
||||
/// newer position.
|
||||
fn tree_resend(&mut self) {
|
||||
let now_ms = crate::time::mono_ms();
|
||||
let waiting: Vec<NodeAddr> = self
|
||||
.peers
|
||||
.keys()
|
||||
.filter(|addr| self.tree_state.announce_outstanding(addr))
|
||||
.copied()
|
||||
.collect();
|
||||
for addr in waiting {
|
||||
let Some(link) = self.link_evidence(&addr) else {
|
||||
continue;
|
||||
};
|
||||
let counter = self.tree_state.outstanding_counter(&addr);
|
||||
let seq = self.tree_state.outstanding_seq(&addr);
|
||||
let Some(reason) = self.tree_state.check_announce(&addr, &link, now_ms) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(peer) = self.peers.get_mut(&addr) {
|
||||
peer.mark_tree_announce_pending();
|
||||
}
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&addr),
|
||||
reason = ?reason,
|
||||
counter = ?counter,
|
||||
seq = ?seq,
|
||||
"Resending unconfirmed TreeAnnounce"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Periodic parent re-evaluation based on current MMP link costs.
|
||||
///
|
||||
/// Self-paces using `last_parent_reeval` and the configured
|
||||
|
||||
+208
-16
@@ -475,6 +475,54 @@ fn ps_lines(ps1: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Asserts that line `i` of install-service.ps1's code lines is an `if` whose
|
||||
/// body is `Write-Error` then `exit 1`, so the condition it tests stops the
|
||||
/// install rather than only reporting it.
|
||||
fn refuses_at(lines: &[String], i: usize, what: &str) {
|
||||
let cond = &lines[i];
|
||||
assert!(
|
||||
cond.starts_with("if (") && cond.ends_with('{'),
|
||||
"install-service.ps1: the {what} is not an if statement: {cond}"
|
||||
);
|
||||
let body = lines.get(i + 1..i + 3).unwrap_or_default();
|
||||
assert!(
|
||||
body.len() == 2 && body[0].starts_with("Write-Error ") && body[1] == "exit 1",
|
||||
"install-service.ps1: the {what} is not followed by Write-Error then exit 1: \
|
||||
{cond}\n then: {body:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the index of the line that closes the block opened on line
|
||||
/// `start`, found by brace depth. Braces inside single- or double-quoted
|
||||
/// strings are not counted.
|
||||
fn block_end(lines: &[String], start: usize) -> usize {
|
||||
let mut depth = 0i64;
|
||||
for (i, line) in lines.iter().enumerate().skip(start) {
|
||||
let mut quote = None;
|
||||
for c in line.chars() {
|
||||
match (quote, c) {
|
||||
(None, '"' | '\'') => quote = Some(c),
|
||||
(Some(q), _) if c == q => quote = None,
|
||||
(None, '{') => depth += 1,
|
||||
(None, '}') => depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if depth <= 0 {
|
||||
assert!(
|
||||
i > start,
|
||||
"install-service.ps1: no block opens at code line {start}: {}",
|
||||
lines[start]
|
||||
);
|
||||
return i;
|
||||
}
|
||||
}
|
||||
panic!(
|
||||
"install-service.ps1: the block at code line {start} never closes: {}",
|
||||
lines[start]
|
||||
)
|
||||
}
|
||||
|
||||
/// Guards the order in which install-service.ps1 secures `C:\ProgramData\fips`.
|
||||
///
|
||||
/// The directory inherits `C:\ProgramData`'s access, under which any local
|
||||
@@ -664,19 +712,6 @@ fn windows_installer_restricts_config_dir_before_any_path_inside_it() {
|
||||
#[test]
|
||||
fn windows_installer_refusal_conditions_stop_the_install() {
|
||||
let lines = ps_lines(&repo_file("packaging/windows/install-service.ps1"));
|
||||
let refuses_at = |i: usize, what: &str| {
|
||||
let cond = &lines[i];
|
||||
assert!(
|
||||
cond.starts_with("if (") && cond.ends_with('{'),
|
||||
"install-service.ps1: the {what} is not an if statement: {cond}"
|
||||
);
|
||||
let body = lines.get(i + 1..i + 3).unwrap_or_default();
|
||||
assert!(
|
||||
body.len() == 2 && body[0].starts_with("Write-Error ") && body[1] == "exit 1",
|
||||
"install-service.ps1: the {what} is not followed by Write-Error then exit 1: \
|
||||
{cond}\n then: {body:?}"
|
||||
);
|
||||
};
|
||||
let find = |what: &str, pred: &dyn Fn(&str) -> bool| -> Vec<usize> {
|
||||
let found: Vec<usize> = lines
|
||||
.iter()
|
||||
@@ -708,7 +743,7 @@ fn windows_installer_refusal_conditions_stop_the_install() {
|
||||
for i in find("owner refusal", &|l| {
|
||||
l == "if ($trustedOwners -notcontains $ownerSid) {"
|
||||
}) {
|
||||
refuses_at(i, "owner refusal");
|
||||
refuses_at(&lines, i, "owner refusal");
|
||||
}
|
||||
|
||||
let dir_links = find("refusal of $ConfigDir as a link", &|l| {
|
||||
@@ -722,7 +757,7 @@ fn windows_installer_refusal_conditions_stop_the_install() {
|
||||
dir_links.len()
|
||||
);
|
||||
for i in dir_links {
|
||||
refuses_at(i, "refusal of $ConfigDir as a link");
|
||||
refuses_at(&lines, i, "refusal of $ConfigDir as a link");
|
||||
}
|
||||
|
||||
for i in find("refusal of a link or folder entry", &|l| {
|
||||
@@ -737,7 +772,7 @@ fn windows_installer_refusal_conditions_stop_the_install() {
|
||||
"install-service.ps1: an entry must be refused if it is a link or a folder, \
|
||||
either one: {cond}"
|
||||
);
|
||||
refuses_at(i, "refusal of a link or folder entry");
|
||||
refuses_at(&lines, i, "refusal of a link or folder entry");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -806,6 +841,163 @@ fn windows_installer_icacls_calls_act_on_links_and_check_exit_codes() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Guards the peer ACL files install-service.ps1 creates, and its refusal of
|
||||
/// legacy ones.
|
||||
///
|
||||
/// Earlier releases read `peers.allow` and `peers.deny` from `\etc\fips` on
|
||||
/// the system drive, where any local user can create files, and the service
|
||||
/// still reads a file there when it is missing from `C:\ProgramData\fips`. So
|
||||
/// for each of the two files the installer must stop when the legacy file
|
||||
/// exists and the current one does not, which is exactly when the service
|
||||
/// would enforce the legacy file, and otherwise create the current file empty
|
||||
/// if it is missing, without truncating one that exists. Every refusal is
|
||||
/// decided before any file is created, and all of it happens after the config
|
||||
/// directory is secured and before the binaries are copied or the service is
|
||||
/// registered, so a refusal leaves an existing install's binaries, config and
|
||||
/// service as they were. Each check is bounded by its loop's closing brace,
|
||||
/// since a statement moved out of its loop runs for the last file only.
|
||||
#[test]
|
||||
fn windows_installer_creates_empty_peer_acl_files_and_refuses_legacy_ones() {
|
||||
let lines = ps_lines(&repo_file("packaging/windows/install-service.ps1"));
|
||||
let all = |pred: &dyn Fn(&str) -> bool| -> Vec<usize> {
|
||||
lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| pred(l))
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
};
|
||||
let first = |what: &str, pred: &dyn Fn(&str) -> bool| -> usize {
|
||||
all(pred)
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or_else(|| panic!("install-service.ps1: no line {what}"))
|
||||
};
|
||||
|
||||
let legacy_dir = first("assigning $legacyAclDir", &|l| {
|
||||
l.starts_with("$legacyAclDir = ")
|
||||
});
|
||||
assert_eq!(
|
||||
lines[legacy_dir], r#"$legacyAclDir = "$env:SystemDrive\etc\fips""#,
|
||||
"install-service.ps1: the legacy peer ACL directory is not \\etc\\fips on the \
|
||||
system drive"
|
||||
);
|
||||
|
||||
let loop_line = r#"foreach ($name in @("peers.allow", "peers.deny")) {"#;
|
||||
let loops = all(&|l| {
|
||||
l.starts_with("foreach") && (l.contains("peers.allow") || l.contains("peers.deny"))
|
||||
});
|
||||
assert_eq!(
|
||||
loops.len(),
|
||||
2,
|
||||
"install-service.ps1: expected two loops over the peer ACL files, the refusal \
|
||||
and the creation, found {}",
|
||||
loops.len()
|
||||
);
|
||||
for &i in &loops {
|
||||
assert_eq!(
|
||||
lines[i], loop_line,
|
||||
"install-service.ps1: a peer ACL loop does not cover both files"
|
||||
);
|
||||
}
|
||||
let (refusal, creation) = (loops[0], loops[1]);
|
||||
let (refusal_end, creation_end) = (block_end(&lines, refusal), block_end(&lines, creation));
|
||||
let refusal_body = refusal + 1..refusal_end;
|
||||
let creation_body = creation + 1..creation_end;
|
||||
|
||||
for text in [
|
||||
"$legacy = Join-Path $legacyAclDir $name",
|
||||
r#"$current = "$ConfigDir\$name""#,
|
||||
] {
|
||||
assert!(
|
||||
lines[refusal_body.clone()].iter().any(|l| l == text),
|
||||
"install-service.ps1: the refusal loop has no line {text}"
|
||||
);
|
||||
}
|
||||
let check = refusal_body
|
||||
.clone()
|
||||
.find(|&i| lines[i].starts_with("if (") && lines[i].contains("$legacy"))
|
||||
.unwrap_or_else(|| {
|
||||
panic!("install-service.ps1: the refusal loop does not test the legacy file")
|
||||
});
|
||||
assert_eq!(
|
||||
lines[check],
|
||||
"if ((Test-Path -LiteralPath $legacy) -and -not (Test-Path -LiteralPath $current)) {",
|
||||
"install-service.ps1: the refusal must hold only when the legacy file exists and \
|
||||
the current one does not"
|
||||
);
|
||||
refuses_at(&lines, check, "refusal of a legacy peer ACL file");
|
||||
assert!(
|
||||
block_end(&lines, check) < refusal_end,
|
||||
"install-service.ps1: the refusal of a legacy peer ACL file does not close inside \
|
||||
the refusal loop"
|
||||
);
|
||||
|
||||
let aclfile_line = r#"$aclFile = "$ConfigDir\$name""#;
|
||||
let guard_line = "if (-not (Test-Path -LiteralPath $aclFile)) {";
|
||||
let create = creation_body
|
||||
.clone()
|
||||
.find(|&i| lines[i].contains("New-Item") && lines[i].contains("-ItemType File"))
|
||||
.unwrap_or_else(|| panic!("install-service.ps1: the creation loop does not create a file"));
|
||||
let new_item = &lines[create];
|
||||
assert!(
|
||||
new_item.contains("$aclFile") && !new_item.to_ascii_lowercase().contains("-force"),
|
||||
"install-service.ps1: the peer ACL file must be created at $aclFile without -Force, \
|
||||
which would truncate an existing list: {new_item}"
|
||||
);
|
||||
assert!(
|
||||
lines[creation + 1..create]
|
||||
.iter()
|
||||
.any(|l| l == aclfile_line),
|
||||
"install-service.ps1: the creation loop does not set $aclFile to the file in $ConfigDir"
|
||||
);
|
||||
let guard = create - 1;
|
||||
assert!(
|
||||
lines[guard] == guard_line && block_end(&lines, guard) < creation_end,
|
||||
"install-service.ps1: the peer ACL file is not created only when it is missing"
|
||||
);
|
||||
for l in &lines[creation_body] {
|
||||
assert!(
|
||||
[aclfile_line, guard_line, new_item.as_str(), "}"].contains(&l.as_str())
|
||||
|| l.starts_with("Write-Host "),
|
||||
"install-service.ps1: the creation loop does more than create a missing file: {l}"
|
||||
);
|
||||
}
|
||||
|
||||
let is_check = |l: &str| l == "& $refuseEntries";
|
||||
let order = [
|
||||
("check after the reset", all(&is_check).get(2).copied()),
|
||||
("$legacyAclDir", Some(legacy_dir)),
|
||||
("refusal loop", Some(refusal)),
|
||||
("refusal of a legacy file", Some(check)),
|
||||
("end of the refusal loop", Some(refusal_end)),
|
||||
("creation loop", Some(creation)),
|
||||
("creation of a peer ACL file", Some(create)),
|
||||
("end of the creation loop", Some(creation_end)),
|
||||
(
|
||||
"binary copy",
|
||||
all(&|l| l.contains("$Binaries")).first().copied(),
|
||||
),
|
||||
(
|
||||
"service registration",
|
||||
all(&|l| l.contains("--install-service")).first().copied(),
|
||||
),
|
||||
];
|
||||
for pair in order.windows(2) {
|
||||
let [(a, ia), (b, ib)] = pair else {
|
||||
unreachable!("windows(2) yields pairs")
|
||||
};
|
||||
let (ia, ib) = (
|
||||
ia.unwrap_or_else(|| panic!("install-service.ps1: no {a}")),
|
||||
ib.unwrap_or_else(|| panic!("install-service.ps1: no {b}")),
|
||||
);
|
||||
assert!(
|
||||
ia < ib,
|
||||
"install-service.ps1: {a} (code line {ia}) must come before {b} (code line {ib})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const COMMON_CONFIG: &str = "packaging/common/fips.yaml";
|
||||
const OPENWRT_CONFIG: &str = "packaging/openwrt-ipk/files/etc/fips/fips.yaml";
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ mod tests;
|
||||
|
||||
pub use core::BloomFilter;
|
||||
pub use limits::{DEFAULT_FILTER_SIZE_BITS, DEFAULT_HASH_COUNT, V1_SIZE_CLASS};
|
||||
pub use state::{BloomState, LinkEvidence, RrCounters};
|
||||
pub use state::BloomState;
|
||||
pub use wire::FilterAnnounce;
|
||||
|
||||
/// Errors related to Bloom filter operations.
|
||||
|
||||
+16
-314
@@ -4,225 +4,7 @@ use alloc::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use super::BloomFilter;
|
||||
use crate::NodeAddr;
|
||||
|
||||
/// How long an announce the receiver reports cannot check waits before its
|
||||
/// one unchecked resend, in milliseconds. Equal to the default link dead
|
||||
/// timeout, so an outage that did not remove the peer has ended by then.
|
||||
pub const FALLBACK_MS: u64 = 30_000;
|
||||
|
||||
/// The largest gap the per-peer resend backoff imposes, in milliseconds.
|
||||
pub const MAXGAP_MS: u64 = 60_000;
|
||||
|
||||
/// A run of resends with no resend for this long, in milliseconds, resets the
|
||||
/// backoff. It must exceed [`MAXGAP_MS`], or a sustained trigger resending at
|
||||
/// the largest gap would reset its own backoff every time.
|
||||
pub const QUIET_MS: u64 = 120_000;
|
||||
|
||||
/// Unchecked resends (`Unverified`, `SessionChanged` or `Timeout`) allowed per
|
||||
/// announce lineage per session.
|
||||
pub const UNVERIFIED_BUDGET: u8 = 1;
|
||||
|
||||
/// Resends on reported loss allowed per announce lineage per session.
|
||||
pub const LOSS_BUDGET: u8 = 3;
|
||||
|
||||
/// Highest backoff level. `gap` at this level is already capped at
|
||||
/// [`MAXGAP_MS`], so a higher level would add nothing; the cap keeps the
|
||||
/// shift in range.
|
||||
const MAX_LEVEL: u8 = 7;
|
||||
|
||||
/// The cumulative counters of one ReceiverReport the peer sent about our
|
||||
/// frames on a link.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RrCounters {
|
||||
/// Highest link counter the peer had received from us.
|
||||
pub highest: u64,
|
||||
/// Link frames from us the peer had counted, cumulative.
|
||||
pub received: u64,
|
||||
/// Of those, frames that arrived below the highest counter, cumulative.
|
||||
pub reordered: u32,
|
||||
}
|
||||
|
||||
/// What the shell reads from one peer's link at one moment, for deciding
|
||||
/// whether an announce reached that peer.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LinkEvidence {
|
||||
/// Identity of the current link session (from its handshake hash). The
|
||||
/// send counter restarts at 0 in every session, so counters are compared
|
||||
/// only within one epoch.
|
||||
pub epoch: u64,
|
||||
/// The next send counter the current session will use.
|
||||
pub next_counter: u64,
|
||||
/// The last ReceiverReport accepted in the current session, if any.
|
||||
pub rr: Option<RrCounters>,
|
||||
}
|
||||
|
||||
impl LinkEvidence {
|
||||
/// The report, when it can describe the current session.
|
||||
///
|
||||
/// The peer cannot have received a counter this session has not used yet,
|
||||
/// so a report whose highest counter is at or above `next_counter`
|
||||
/// describes another session. That happens briefly around a rekey, when a
|
||||
/// report or frame of the old session is counted against the new one, and
|
||||
/// such a report is no evidence either way.
|
||||
pub fn usable_rr(&self) -> Option<RrCounters> {
|
||||
self.rr.filter(|rr| rr.highest < self.next_counter)
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an announce is being resent, for the shell's log line.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ResendReason {
|
||||
/// A report covering the announce shows fewer frames arrived since its
|
||||
/// base than were sent.
|
||||
Loss,
|
||||
/// The first usable report already covers an announce it cannot check.
|
||||
Unverified,
|
||||
/// The announce was sent on an earlier session and cannot be checked.
|
||||
SessionChanged,
|
||||
/// No usable report checked the announce within the fallback interval.
|
||||
Timeout,
|
||||
}
|
||||
|
||||
/// What an announce's delivery is measured from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Base {
|
||||
/// The last usable report before the announce was sent, and whether it
|
||||
/// has no holes: every counter up to its highest had arrived.
|
||||
Counted(RrCounters, bool),
|
||||
/// Nothing received yet in the peer's first session: every counter from 0
|
||||
/// must arrive.
|
||||
Zero,
|
||||
/// No base: a later report can become one if it does not yet cover the
|
||||
/// announce.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// The one announce to a peer still awaiting confirmation.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct SentAnnounce {
|
||||
/// Link counter the announce was sent with.
|
||||
counter: u64,
|
||||
/// Session the counter belongs to (or, once orphaned, the session that
|
||||
/// orphaned it).
|
||||
epoch: u64,
|
||||
/// What delivery is measured from.
|
||||
base: Base,
|
||||
/// Sent on an earlier session, so it can never be checked.
|
||||
orphan: bool,
|
||||
/// When it was sent or orphaned, for the fallback.
|
||||
at_ms: u64,
|
||||
}
|
||||
|
||||
/// Per-peer delivery tracking for filter announces.
|
||||
#[derive(Clone, Debug)]
|
||||
struct AckState {
|
||||
/// Session in which this entry was created; only there does a missing
|
||||
/// report mean the peer has received nothing yet.
|
||||
first_epoch: u64,
|
||||
/// The outstanding announce, if one is unconfirmed.
|
||||
sent: Option<SentAnnounce>,
|
||||
/// Backoff level: the number of resends in the current run, capped.
|
||||
level: u8,
|
||||
/// When the last resend was triggered.
|
||||
resent_ms: Option<u64>,
|
||||
/// Session the budgets were last refilled for.
|
||||
budget_epoch: u64,
|
||||
/// Unchecked resends left for the current lineage in this session.
|
||||
unverified_left: u8,
|
||||
/// Loss resends left for the current lineage in this session.
|
||||
loss_left: u8,
|
||||
}
|
||||
|
||||
impl AckState {
|
||||
/// A fresh entry for a peer first sent to in session `epoch`.
|
||||
fn new(epoch: u64) -> Self {
|
||||
Self {
|
||||
first_epoch: epoch,
|
||||
sent: None,
|
||||
level: 0,
|
||||
resent_ms: None,
|
||||
budget_epoch: epoch,
|
||||
unverified_left: UNVERIFIED_BUDGET,
|
||||
loss_left: LOSS_BUDGET,
|
||||
}
|
||||
}
|
||||
|
||||
/// Refill both budgets for session `epoch`.
|
||||
fn refill(&mut self, epoch: u64) {
|
||||
self.budget_epoch = epoch;
|
||||
self.unverified_left = UNVERIFIED_BUDGET;
|
||||
self.loss_left = LOSS_BUDGET;
|
||||
}
|
||||
|
||||
/// Whether report `rr`, taken in session `epoch`, shows no holes: every
|
||||
/// counter up to its highest had arrived. Only in the peer's first session
|
||||
/// does its cumulative count start at 0, so a later session's report is
|
||||
/// never known to be whole.
|
||||
fn whole(&self, epoch: u64, rr: RrCounters) -> bool {
|
||||
epoch == self.first_epoch && rr.highest.checked_add(1) == Some(rr.received)
|
||||
}
|
||||
|
||||
/// Whether the backoff allows a resend at `now_ms`, resetting the level
|
||||
/// after a quiet period.
|
||||
fn backoff_allows(&mut self, now_ms: u64) -> bool {
|
||||
let Some(last) = self.resent_ms else {
|
||||
return true;
|
||||
};
|
||||
if now_ms >= last.saturating_add(QUIET_MS) {
|
||||
self.level = 0;
|
||||
}
|
||||
now_ms >= last.saturating_add(gap(self.level))
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimum time after a resend before the next one, at backoff `level`.
|
||||
fn gap(level: u8) -> u64 {
|
||||
match level {
|
||||
0 => 0,
|
||||
n => (1000u64 << (n.min(MAX_LEVEL) - 1)).min(MAXGAP_MS),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every counter in the report's range since `base` arrived.
|
||||
///
|
||||
/// Within one receiver epoch every frame counted between two reports is a
|
||||
/// distinct counter at or below `h1`. Those in `(h0, h1]` number at most all
|
||||
/// receipts, `got`, and at least the non-reorder receipts, `sure`: a frame
|
||||
/// that arrived after a higher counter is a reorder whether its counter lies
|
||||
/// inside the window or at or below `h0`.
|
||||
///
|
||||
/// - `got` below the span is a loss: fewer frames arrived than the window
|
||||
/// holds.
|
||||
/// - `sure` equal to the span is delivery.
|
||||
/// - `got` equal to the span is delivery when the base has no holes, since
|
||||
/// then no counter at or below `h0` is left to arrive late.
|
||||
///
|
||||
/// Anything else is ambiguous and proves nothing, as is an inconsistent pair
|
||||
/// (a counter went backwards), which means the two reports straddle a
|
||||
/// receiver reset or another session's frame. Frames reserved but never
|
||||
/// sent and frames dropped before counting only lower the counts, so a lost
|
||||
/// frame is confirmed only if the peer overcounts.
|
||||
fn delivered(base: Base, rr: RrCounters) -> Option<bool> {
|
||||
let (r0, o0, span, complete) = match base {
|
||||
Base::Counted(b, complete) => (
|
||||
b.received,
|
||||
b.reordered,
|
||||
rr.highest.checked_sub(b.highest)?,
|
||||
complete,
|
||||
),
|
||||
Base::Zero => (0, 0, rr.highest.checked_add(1)?, true),
|
||||
Base::Unknown => return None,
|
||||
};
|
||||
let got = rr.received.checked_sub(r0)?;
|
||||
let sure = got.checked_sub(u64::from(rr.reordered.checked_sub(o0)?))?;
|
||||
if got < span {
|
||||
Some(false)
|
||||
} else if sure == span || (complete && got == span) {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
use crate::proto::mmp::delivery::{Acks, LinkEvidence, ResendReason};
|
||||
|
||||
/// State for managing Bloom filter announcements.
|
||||
///
|
||||
@@ -245,10 +27,8 @@ pub struct BloomState {
|
||||
sequence: u64,
|
||||
/// Last outgoing filter sent to each peer (for change detection).
|
||||
last_sent_filters: BTreeMap<NodeAddr, BloomFilter>,
|
||||
/// How long an unchecked announce waits for its fallback resend (ms).
|
||||
fallback_ms: u64,
|
||||
/// Per-peer delivery tracking for sent announces.
|
||||
acks: BTreeMap<NodeAddr, AckState>,
|
||||
acks: Acks,
|
||||
}
|
||||
|
||||
impl BloomState {
|
||||
@@ -263,8 +43,7 @@ impl BloomState {
|
||||
pending_updates: BTreeSet::new(),
|
||||
sequence: 0,
|
||||
last_sent_filters: BTreeMap::new(),
|
||||
fallback_ms: FALLBACK_MS,
|
||||
acks: BTreeMap::new(),
|
||||
acks: Acks::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,9 +86,10 @@ impl BloomState {
|
||||
}
|
||||
|
||||
/// Set how long an announce the receiver reports cannot check waits
|
||||
/// before its fallback resend. Defaults to [`FALLBACK_MS`].
|
||||
/// before its fallback resend. Defaults to
|
||||
/// [`FALLBACK_MS`](crate::proto::mmp::delivery::FALLBACK_MS).
|
||||
pub fn set_fallback(&mut self, ms: u64) {
|
||||
self.fallback_ms = ms;
|
||||
self.acks.set_fallback(ms);
|
||||
}
|
||||
|
||||
/// Add a leaf dependent that we'll include in our filter.
|
||||
@@ -414,28 +194,8 @@ impl BloomState {
|
||||
link: &LinkEvidence,
|
||||
now_ms: u64,
|
||||
) {
|
||||
let new_lineage = self.last_sent_filters.get(&peer) != Some(filter);
|
||||
let ack = self
|
||||
.acks
|
||||
.entry(peer)
|
||||
.or_insert_with(|| AckState::new(link.epoch));
|
||||
if new_lineage || link.epoch != ack.budget_epoch {
|
||||
ack.refill(link.epoch);
|
||||
}
|
||||
// With no report yet, the zero baseline holds only in the peer's first
|
||||
// session: a later session's cumulative count includes earlier ones.
|
||||
let base = match link.usable_rr() {
|
||||
Some(rr) if rr.highest < counter => Base::Counted(rr, ack.whole(link.epoch, rr)),
|
||||
_ if link.rr.is_none() && link.epoch == ack.first_epoch => Base::Zero,
|
||||
_ => Base::Unknown,
|
||||
};
|
||||
ack.sent = Some(SentAnnounce {
|
||||
counter,
|
||||
epoch: link.epoch,
|
||||
base,
|
||||
orphan: false,
|
||||
at_ms: now_ms,
|
||||
});
|
||||
let fresh = self.last_sent_filters.get(&peer) != Some(filter);
|
||||
self.acks.record(peer, fresh, counter, link, now_ms);
|
||||
}
|
||||
|
||||
/// Decide whether the outstanding announce to `peer` must be resent.
|
||||
@@ -449,9 +209,11 @@ impl BloomState {
|
||||
/// triggering a resend. A report from another session, or a pair of
|
||||
/// reports that is inconsistent or cannot tell a late frame from before
|
||||
/// the base from one inside the window, is no evidence. Each announce lineage
|
||||
/// gets [`UNVERIFIED_BUDGET`] unchecked and [`LOSS_BUDGET`] loss resends
|
||||
/// per session, and a per-peer backoff spaces all resends by 1, 2, 4 ...
|
||||
/// up to 60 s until [`QUIET_MS`] passes with none.
|
||||
/// gets [`UNVERIFIED_BUDGET`](crate::proto::mmp::delivery::UNVERIFIED_BUDGET)
|
||||
/// unchecked and [`LOSS_BUDGET`](crate::proto::mmp::delivery::LOSS_BUDGET)
|
||||
/// loss resends per session, and a per-peer backoff spaces all resends by
|
||||
/// 1, 2, 4 ... up to 60 s until
|
||||
/// [`QUIET_MS`](crate::proto::mmp::delivery::QUIET_MS) passes with none.
|
||||
///
|
||||
/// On `Some`, the peer has been marked for an update; the ordinary send
|
||||
/// path delivers the resend.
|
||||
@@ -461,69 +223,9 @@ impl BloomState {
|
||||
link: &LinkEvidence,
|
||||
now_ms: u64,
|
||||
) -> Option<ResendReason> {
|
||||
let fallback_ms = self.fallback_ms;
|
||||
let ack = self.acks.get_mut(peer)?;
|
||||
let mut sent = ack.sent?;
|
||||
|
||||
if link.epoch != ack.budget_epoch {
|
||||
ack.refill(link.epoch);
|
||||
}
|
||||
if sent.epoch != link.epoch {
|
||||
sent.orphan = true;
|
||||
sent.epoch = link.epoch;
|
||||
sent.base = Base::Unknown;
|
||||
sent.at_ms = now_ms;
|
||||
}
|
||||
ack.sent = Some(sent);
|
||||
|
||||
let rr = link.usable_rr();
|
||||
let due = now_ms >= sent.at_ms.saturating_add(fallback_ms);
|
||||
let candidate = match (sent.base, rr) {
|
||||
(Base::Unknown, Some(rr)) => {
|
||||
if !sent.orphan && rr.highest < sent.counter {
|
||||
sent.base = Base::Counted(rr, ack.whole(link.epoch, rr));
|
||||
ack.sent = Some(sent);
|
||||
return None;
|
||||
}
|
||||
Some(if sent.orphan {
|
||||
ResendReason::SessionChanged
|
||||
} else {
|
||||
ResendReason::Unverified
|
||||
})
|
||||
}
|
||||
(Base::Unknown, None) => due.then_some(ResendReason::Timeout),
|
||||
(base, rr) => {
|
||||
let covering = rr.filter(|rr| rr.highest >= sent.counter);
|
||||
let loss = match covering.and_then(|rr| delivered(base, rr)) {
|
||||
Some(true) => {
|
||||
ack.sent = None;
|
||||
return None;
|
||||
}
|
||||
Some(false) if ack.loss_left > 0 => Some(ResendReason::Loss),
|
||||
_ => None,
|
||||
};
|
||||
loss.or(due.then_some(ResendReason::Timeout))
|
||||
}
|
||||
}?;
|
||||
|
||||
let loss = candidate == ResendReason::Loss;
|
||||
let left = if loss {
|
||||
ack.loss_left
|
||||
} else {
|
||||
ack.unverified_left
|
||||
};
|
||||
if left == 0 || !ack.backoff_allows(now_ms) {
|
||||
return None;
|
||||
}
|
||||
if loss {
|
||||
ack.loss_left -= 1;
|
||||
} else {
|
||||
ack.unverified_left -= 1;
|
||||
}
|
||||
ack.level = (ack.level + 1).min(MAX_LEVEL);
|
||||
ack.resent_ms = Some(now_ms);
|
||||
let reason = self.acks.check(peer, link, now_ms)?;
|
||||
self.mark_update_needed(*peer);
|
||||
Some(candidate)
|
||||
Some(reason)
|
||||
}
|
||||
|
||||
/// Whether an announce to `peer` is still awaiting confirmation.
|
||||
@@ -533,7 +235,7 @@ impl BloomState {
|
||||
|
||||
/// The link counter of the announce to `peer` awaiting confirmation.
|
||||
pub fn outstanding_counter(&self, peer: &NodeAddr) -> Option<u64> {
|
||||
self.acks.get(peer)?.sent.map(|sent| sent.counter)
|
||||
self.acks.outstanding(peer)
|
||||
}
|
||||
|
||||
/// Mark only peers whose outgoing filter has actually changed.
|
||||
|
||||
@@ -322,7 +322,7 @@ fn test_bloom_state_mark_changed_peers_excludes_source() {
|
||||
// straight after the send.
|
||||
|
||||
use crate::NodeAddr;
|
||||
use crate::proto::bloom::state::{
|
||||
use crate::proto::mmp::delivery::{
|
||||
FALLBACK_MS, LOSS_BUDGET, LinkEvidence, QUIET_MS, ResendReason, RrCounters, UNVERIFIED_BUDGET,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
//! Delivery of announces, inferred from the link's receiver reports.
|
||||
//!
|
||||
//! The transport accepting a frame is not delivery: a datagram can still be
|
||||
//! lost. An announce is therefore held as outstanding until the peer's
|
||||
//! ReceiverReports show that every link counter since a base, up to and
|
||||
//! including the announce's own, arrived. The reports carry cumulative
|
||||
//! counts, so the evidence has an upper and a lower bound:
|
||||
//!
|
||||
//! - loss is concluded only when the upper bound, all receipts since the
|
||||
//! base, falls short of the span of counters;
|
||||
//! - delivery is concluded only when the lower bound, the receipts that were
|
||||
//! not reorders, equals the span, or when all receipts equal the span on a
|
||||
//! base known to have no holes;
|
||||
//! - the gap between the two bounds is no evidence either way.
|
||||
//!
|
||||
//! A report whose highest counter is at or above the session's next send
|
||||
//! counter describes another session and is no evidence. An announce the
|
||||
//! reports cannot check gets one unchecked resend; loss and unchecked
|
||||
//! resends are bounded per announce lineage per session and spaced by a
|
||||
//! per-peer backoff.
|
||||
//!
|
||||
//! Sans-IO: time is injected as `u64` milliseconds and the shell supplies
|
||||
//! the link counters, so nothing here reads a clock or touches a socket.
|
||||
|
||||
use alloc::collections::BTreeMap;
|
||||
|
||||
use crate::NodeAddr;
|
||||
|
||||
/// How long an announce the receiver reports cannot check waits before its
|
||||
/// one unchecked resend, in milliseconds. Equal to the default link dead
|
||||
/// timeout, so an outage that did not remove the peer has ended by then.
|
||||
pub const FALLBACK_MS: u64 = 30_000;
|
||||
|
||||
/// The largest gap the per-peer resend backoff imposes, in milliseconds.
|
||||
pub const MAXGAP_MS: u64 = 60_000;
|
||||
|
||||
/// A run of resends with no resend for this long, in milliseconds, resets the
|
||||
/// backoff. It must exceed [`MAXGAP_MS`], or a sustained trigger resending at
|
||||
/// the largest gap would reset its own backoff every time.
|
||||
pub const QUIET_MS: u64 = 120_000;
|
||||
|
||||
/// Unchecked resends (`Unverified`, `SessionChanged` or `Timeout`) allowed per
|
||||
/// announce lineage per session.
|
||||
pub const UNVERIFIED_BUDGET: u8 = 1;
|
||||
|
||||
/// Resends on reported loss allowed per announce lineage per session.
|
||||
pub const LOSS_BUDGET: u8 = 3;
|
||||
|
||||
/// Highest backoff level. `gap` at this level is already capped at
|
||||
/// [`MAXGAP_MS`], so a higher level would add nothing; the cap keeps the
|
||||
/// shift in range.
|
||||
const MAX_LEVEL: u8 = 7;
|
||||
|
||||
/// The cumulative counters of one ReceiverReport the peer sent about our
|
||||
/// frames on a link.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RrCounters {
|
||||
/// Highest link counter the peer had received from us.
|
||||
pub highest: u64,
|
||||
/// Link frames from us the peer had counted, cumulative.
|
||||
pub received: u64,
|
||||
/// Of those, frames that arrived below the highest counter, cumulative.
|
||||
pub reordered: u32,
|
||||
}
|
||||
|
||||
/// What the shell reads from one peer's link at one moment, for deciding
|
||||
/// whether an announce reached that peer.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LinkEvidence {
|
||||
/// Identity of the current link session (from its handshake hash). The
|
||||
/// send counter restarts at 0 in every session, so counters are compared
|
||||
/// only within one epoch.
|
||||
pub epoch: u64,
|
||||
/// The next send counter the current session will use.
|
||||
pub next_counter: u64,
|
||||
/// The last ReceiverReport accepted in the current session, if any.
|
||||
pub rr: Option<RrCounters>,
|
||||
}
|
||||
|
||||
impl LinkEvidence {
|
||||
/// The report, when it can describe the current session.
|
||||
///
|
||||
/// The peer cannot have received a counter this session has not used yet,
|
||||
/// so a report whose highest counter is at or above `next_counter`
|
||||
/// describes another session. That happens briefly around a rekey, when a
|
||||
/// report or frame of the old session is counted against the new one, and
|
||||
/// such a report is no evidence either way.
|
||||
pub fn usable_rr(&self) -> Option<RrCounters> {
|
||||
self.rr.filter(|rr| rr.highest < self.next_counter)
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an announce is being resent, for the shell's log line.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ResendReason {
|
||||
/// A report covering the announce shows fewer frames arrived since its
|
||||
/// base than were sent.
|
||||
Loss,
|
||||
/// The first usable report already covers an announce it cannot check.
|
||||
Unverified,
|
||||
/// The announce was sent on an earlier session and cannot be checked.
|
||||
SessionChanged,
|
||||
/// No usable report checked the announce within the fallback interval.
|
||||
Timeout,
|
||||
}
|
||||
|
||||
/// What an announce's delivery is measured from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Base {
|
||||
/// The last usable report before the announce was sent, and whether it
|
||||
/// has no holes: every counter up to its highest had arrived.
|
||||
Counted(RrCounters, bool),
|
||||
/// Nothing received yet in the peer's first session: every counter from 0
|
||||
/// must arrive.
|
||||
Zero,
|
||||
/// No base: a later report can become one if it does not yet cover the
|
||||
/// announce.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// The one announce to a peer still awaiting confirmation.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct SentAnnounce {
|
||||
/// Link counter the announce was sent with.
|
||||
counter: u64,
|
||||
/// Session the counter belongs to (or, once orphaned, the session that
|
||||
/// orphaned it).
|
||||
epoch: u64,
|
||||
/// What delivery is measured from.
|
||||
base: Base,
|
||||
/// Sent on an earlier session, so it can never be checked.
|
||||
orphan: bool,
|
||||
/// When it was sent or orphaned, for the fallback.
|
||||
at_ms: u64,
|
||||
}
|
||||
|
||||
/// Per-peer delivery tracking for sent announces.
|
||||
#[derive(Clone, Debug)]
|
||||
struct AckState {
|
||||
/// Session in which this entry was created; only there does a missing
|
||||
/// report mean the peer has received nothing yet.
|
||||
first_epoch: u64,
|
||||
/// The outstanding announce, if one is unconfirmed.
|
||||
sent: Option<SentAnnounce>,
|
||||
/// Backoff level: the number of resends in the current run, capped.
|
||||
level: u8,
|
||||
/// When the last resend was triggered.
|
||||
resent_ms: Option<u64>,
|
||||
/// Session the budgets were last refilled for.
|
||||
budget_epoch: u64,
|
||||
/// Unchecked resends left for the current lineage in this session.
|
||||
unverified_left: u8,
|
||||
/// Loss resends left for the current lineage in this session.
|
||||
loss_left: u8,
|
||||
}
|
||||
|
||||
impl AckState {
|
||||
/// A fresh entry for a peer first sent to in session `epoch`.
|
||||
fn new(epoch: u64) -> Self {
|
||||
Self {
|
||||
first_epoch: epoch,
|
||||
sent: None,
|
||||
level: 0,
|
||||
resent_ms: None,
|
||||
budget_epoch: epoch,
|
||||
unverified_left: UNVERIFIED_BUDGET,
|
||||
loss_left: LOSS_BUDGET,
|
||||
}
|
||||
}
|
||||
|
||||
/// Refill both budgets for session `epoch`.
|
||||
fn refill(&mut self, epoch: u64) {
|
||||
self.budget_epoch = epoch;
|
||||
self.unverified_left = UNVERIFIED_BUDGET;
|
||||
self.loss_left = LOSS_BUDGET;
|
||||
}
|
||||
|
||||
/// Whether report `rr`, taken in session `epoch`, shows no holes: every
|
||||
/// counter up to its highest had arrived. Only in the peer's first session
|
||||
/// does its cumulative count start at 0, so a later session's report is
|
||||
/// never known to be whole.
|
||||
fn whole(&self, epoch: u64, rr: RrCounters) -> bool {
|
||||
epoch == self.first_epoch && rr.highest.checked_add(1) == Some(rr.received)
|
||||
}
|
||||
|
||||
/// Whether the backoff allows a resend at `now_ms`, resetting the level
|
||||
/// after a quiet period.
|
||||
fn backoff_allows(&mut self, now_ms: u64) -> bool {
|
||||
let Some(last) = self.resent_ms else {
|
||||
return true;
|
||||
};
|
||||
if now_ms >= last.saturating_add(QUIET_MS) {
|
||||
self.level = 0;
|
||||
}
|
||||
now_ms >= last.saturating_add(gap(self.level))
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimum time after a resend before the next one, at backoff `level`.
|
||||
fn gap(level: u8) -> u64 {
|
||||
match level {
|
||||
0 => 0,
|
||||
n => (1000u64 << (n.min(MAX_LEVEL) - 1)).min(MAXGAP_MS),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every counter in the report's range since `base` arrived.
|
||||
///
|
||||
/// Within one receiver epoch every frame counted between two reports is a
|
||||
/// distinct counter at or below `h1`. Those in `(h0, h1]` number at most all
|
||||
/// receipts, `got`, and at least the non-reorder receipts, `sure`: a frame
|
||||
/// that arrived after a higher counter is a reorder whether its counter lies
|
||||
/// inside the window or at or below `h0`.
|
||||
///
|
||||
/// - `got` below the span is a loss: fewer frames arrived than the window
|
||||
/// holds.
|
||||
/// - `sure` equal to the span is delivery.
|
||||
/// - `got` equal to the span is delivery when the base has no holes, since
|
||||
/// then no counter at or below `h0` is left to arrive late.
|
||||
///
|
||||
/// Anything else is ambiguous and proves nothing, as is an inconsistent pair
|
||||
/// (a counter went backwards), which means the two reports straddle a
|
||||
/// receiver reset or another session's frame. Frames reserved but never
|
||||
/// sent and frames dropped before counting only lower the counts, so a lost
|
||||
/// frame is confirmed only if the peer overcounts.
|
||||
fn delivered(base: Base, rr: RrCounters) -> Option<bool> {
|
||||
let (r0, o0, span, complete) = match base {
|
||||
Base::Counted(b, complete) => (
|
||||
b.received,
|
||||
b.reordered,
|
||||
rr.highest.checked_sub(b.highest)?,
|
||||
complete,
|
||||
),
|
||||
Base::Zero => (0, 0, rr.highest.checked_add(1)?, true),
|
||||
Base::Unknown => return None,
|
||||
};
|
||||
let got = rr.received.checked_sub(r0)?;
|
||||
let sure = got.checked_sub(u64::from(rr.reordered.checked_sub(o0)?))?;
|
||||
if got < span {
|
||||
Some(false)
|
||||
} else if sure == span || (complete && got == span) {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Delivery tracking for one kind of announce, per peer.
|
||||
///
|
||||
/// Each kind of announce keeps its own instance, so the budgets and the
|
||||
/// backoff of one kind never hold back the other.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Acks {
|
||||
/// How long an unchecked announce waits for its fallback resend (ms).
|
||||
fallback_ms: u64,
|
||||
/// Per-peer delivery tracking for sent announces.
|
||||
peers: BTreeMap<NodeAddr, AckState>,
|
||||
}
|
||||
|
||||
impl Default for Acks {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Acks {
|
||||
/// Tracking with no peer and the default fallback, [`FALLBACK_MS`].
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
fallback_ms: FALLBACK_MS,
|
||||
peers: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set how long an announce the receiver reports cannot check waits
|
||||
/// before its fallback resend.
|
||||
pub fn set_fallback(&mut self, ms: u64) {
|
||||
self.fallback_ms = ms;
|
||||
}
|
||||
|
||||
/// Record an announce the transport accepted for `peer`, sent with link
|
||||
/// counter `counter`, so it stays outstanding until the peer's receiver
|
||||
/// reports show it arrived.
|
||||
///
|
||||
/// `fresh` says the announce starts a new lineage (new content), which
|
||||
/// refills its resend budgets. The budgets also refill when the session
|
||||
/// changes, and never otherwise, so a resend of the same content spends
|
||||
/// from its lineage's budget.
|
||||
pub fn record(
|
||||
&mut self,
|
||||
peer: NodeAddr,
|
||||
fresh: bool,
|
||||
counter: u64,
|
||||
link: &LinkEvidence,
|
||||
now_ms: u64,
|
||||
) {
|
||||
let ack = self
|
||||
.peers
|
||||
.entry(peer)
|
||||
.or_insert_with(|| AckState::new(link.epoch));
|
||||
if fresh || link.epoch != ack.budget_epoch {
|
||||
ack.refill(link.epoch);
|
||||
}
|
||||
// With no report yet, the zero baseline holds only in the peer's first
|
||||
// session: a later session's cumulative count includes earlier ones.
|
||||
let base = match link.usable_rr() {
|
||||
Some(rr) if rr.highest < counter => Base::Counted(rr, ack.whole(link.epoch, rr)),
|
||||
_ if link.rr.is_none() && link.epoch == ack.first_epoch => Base::Zero,
|
||||
_ => Base::Unknown,
|
||||
};
|
||||
ack.sent = Some(SentAnnounce {
|
||||
counter,
|
||||
epoch: link.epoch,
|
||||
base,
|
||||
orphan: false,
|
||||
at_ms: now_ms,
|
||||
});
|
||||
}
|
||||
|
||||
/// Decide whether the outstanding announce to `peer` must be resent.
|
||||
///
|
||||
/// Confirms the announce when a usable report covering its counter shows
|
||||
/// every counter since its base arrived. Resends on a covering report that
|
||||
/// shows a loss; once, as soon as a usable report arrives, for an announce
|
||||
/// the reports cannot check; and once after the fallback interval when no
|
||||
/// usable report checks it. A usable report that does not yet cover an
|
||||
/// announce sent in the current session becomes its base instead of
|
||||
/// triggering a resend. A report from another session, or a pair of
|
||||
/// reports that is inconsistent or cannot tell a late frame from before
|
||||
/// the base from one inside the window, is no evidence. Each announce lineage
|
||||
/// gets [`UNVERIFIED_BUDGET`] unchecked and [`LOSS_BUDGET`] loss resends
|
||||
/// per session, and a per-peer backoff spaces all resends by 1, 2, 4 ...
|
||||
/// up to 60 s until [`QUIET_MS`] passes with none.
|
||||
///
|
||||
/// On `Some`, the caller must arrange the resend; nothing is marked here.
|
||||
pub fn check(
|
||||
&mut self,
|
||||
peer: &NodeAddr,
|
||||
link: &LinkEvidence,
|
||||
now_ms: u64,
|
||||
) -> Option<ResendReason> {
|
||||
let fallback_ms = self.fallback_ms;
|
||||
let ack = self.peers.get_mut(peer)?;
|
||||
let mut sent = ack.sent?;
|
||||
|
||||
if link.epoch != ack.budget_epoch {
|
||||
ack.refill(link.epoch);
|
||||
}
|
||||
if sent.epoch != link.epoch {
|
||||
sent.orphan = true;
|
||||
sent.epoch = link.epoch;
|
||||
sent.base = Base::Unknown;
|
||||
sent.at_ms = now_ms;
|
||||
}
|
||||
ack.sent = Some(sent);
|
||||
|
||||
let rr = link.usable_rr();
|
||||
let due = now_ms >= sent.at_ms.saturating_add(fallback_ms);
|
||||
let candidate = match (sent.base, rr) {
|
||||
(Base::Unknown, Some(rr)) => {
|
||||
if !sent.orphan && rr.highest < sent.counter {
|
||||
sent.base = Base::Counted(rr, ack.whole(link.epoch, rr));
|
||||
ack.sent = Some(sent);
|
||||
return None;
|
||||
}
|
||||
Some(if sent.orphan {
|
||||
ResendReason::SessionChanged
|
||||
} else {
|
||||
ResendReason::Unverified
|
||||
})
|
||||
}
|
||||
(Base::Unknown, None) => due.then_some(ResendReason::Timeout),
|
||||
(base, rr) => {
|
||||
let covering = rr.filter(|rr| rr.highest >= sent.counter);
|
||||
let loss = match covering.and_then(|rr| delivered(base, rr)) {
|
||||
Some(true) => {
|
||||
ack.sent = None;
|
||||
return None;
|
||||
}
|
||||
Some(false) if ack.loss_left > 0 => Some(ResendReason::Loss),
|
||||
_ => None,
|
||||
};
|
||||
loss.or(due.then_some(ResendReason::Timeout))
|
||||
}
|
||||
}?;
|
||||
|
||||
let loss = candidate == ResendReason::Loss;
|
||||
let left = if loss {
|
||||
ack.loss_left
|
||||
} else {
|
||||
ack.unverified_left
|
||||
};
|
||||
if left == 0 || !ack.backoff_allows(now_ms) {
|
||||
return None;
|
||||
}
|
||||
if loss {
|
||||
ack.loss_left -= 1;
|
||||
} else {
|
||||
ack.unverified_left -= 1;
|
||||
}
|
||||
ack.level = (ack.level + 1).min(MAX_LEVEL);
|
||||
ack.resent_ms = Some(now_ms);
|
||||
Some(candidate)
|
||||
}
|
||||
|
||||
/// The link counter of the announce to `peer` awaiting confirmation.
|
||||
pub fn outstanding(&self, peer: &NodeAddr) -> Option<u64> {
|
||||
self.peers.get(peer)?.sent.map(|sent| sent.counter)
|
||||
}
|
||||
|
||||
/// Forget everything about `peer`, which was removed.
|
||||
pub fn remove(&mut self, peer: &NodeAddr) {
|
||||
self.peers.remove(peer);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
mod algorithms;
|
||||
mod core;
|
||||
pub(crate) mod delivery;
|
||||
mod limits;
|
||||
mod metrics;
|
||||
mod path_mtu;
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::core::ParentEval;
|
||||
use super::limits::FlapDampener;
|
||||
use super::{CoordEntry, ParentDeclaration, TreeCoordinate};
|
||||
use crate::NodeAddr;
|
||||
use crate::proto::mmp::delivery::{Acks, LinkEvidence, ResendReason};
|
||||
|
||||
/// What a parent-loss recovery did: whether the tree state changed, and
|
||||
/// whether the recovery switch was the one that armed a dampening episode.
|
||||
@@ -40,6 +41,10 @@ pub struct TreeState {
|
||||
peer_declarations: BTreeMap<NodeAddr, ParentDeclaration>,
|
||||
/// Each peer's full ancestry to root.
|
||||
peer_ancestry: BTreeMap<NodeAddr, TreeCoordinate>,
|
||||
/// Per-peer delivery tracking for sent TreeAnnounces.
|
||||
acks: Acks,
|
||||
/// The declaration sequence last recorded as sent to each peer.
|
||||
announced: BTreeMap<NodeAddr, u64>,
|
||||
/// Hysteresis factor for cost-based parent re-selection (0.0-1.0).
|
||||
parent_hysteresis: f64,
|
||||
/// Flap-dampening / hold-down state machine.
|
||||
@@ -64,6 +69,8 @@ impl TreeState {
|
||||
root: my_node_addr,
|
||||
peer_declarations: BTreeMap::new(),
|
||||
peer_ancestry: BTreeMap::new(),
|
||||
acks: Acks::new(),
|
||||
announced: BTreeMap::new(),
|
||||
parent_hysteresis: 0.0,
|
||||
flap: FlapDampener::new(),
|
||||
}
|
||||
@@ -149,6 +156,69 @@ impl TreeState {
|
||||
pub fn remove_peer(&mut self, peer_id: &NodeAddr) {
|
||||
self.peer_declarations.remove(peer_id);
|
||||
self.peer_ancestry.remove(peer_id);
|
||||
self.acks.remove(peer_id);
|
||||
self.announced.remove(peer_id);
|
||||
}
|
||||
|
||||
/// Record a TreeAnnounce the transport accepted for `peer`, carrying our
|
||||
/// declaration sequence `seq` and sent with link counter `counter`, so it
|
||||
/// stays outstanding until the peer's receiver reports show it arrived.
|
||||
///
|
||||
/// The transport accepting a frame is not delivery. A lost announce would
|
||||
/// leave the peer on our old tree position until the next announce, which
|
||||
/// on a node with one peer may never come. The announce's content changes
|
||||
/// only with the declaration sequence, so a sequence other than the one
|
||||
/// last recorded for `peer` starts a new lineage with fresh resend
|
||||
/// budgets, and a resend or periodic re-broadcast of the same declaration
|
||||
/// spends from its lineage's budget.
|
||||
pub fn record_announce(
|
||||
&mut self,
|
||||
peer: NodeAddr,
|
||||
seq: u64,
|
||||
counter: u64,
|
||||
link: &LinkEvidence,
|
||||
now_ms: u64,
|
||||
) {
|
||||
let fresh = self.announced.insert(peer, seq) != Some(seq);
|
||||
self.acks.record(peer, fresh, counter, link, now_ms);
|
||||
}
|
||||
|
||||
/// Decide whether the outstanding TreeAnnounce to `peer` must be resent,
|
||||
/// by the shared receiver-report rule ([`Acks::check`]).
|
||||
///
|
||||
/// Marks nothing: on `Some`, the caller marks the peer's announce pending
|
||||
/// so the ordinary send path delivers the current declaration.
|
||||
pub fn check_announce(
|
||||
&mut self,
|
||||
peer: &NodeAddr,
|
||||
link: &LinkEvidence,
|
||||
now_ms: u64,
|
||||
) -> Option<ResendReason> {
|
||||
self.acks.check(peer, link, now_ms)
|
||||
}
|
||||
|
||||
/// Whether a TreeAnnounce to `peer` is still awaiting confirmation.
|
||||
pub fn announce_outstanding(&self, peer: &NodeAddr) -> bool {
|
||||
self.outstanding_counter(peer).is_some()
|
||||
}
|
||||
|
||||
/// The link counter of the TreeAnnounce to `peer` awaiting confirmation.
|
||||
pub fn outstanding_counter(&self, peer: &NodeAddr) -> Option<u64> {
|
||||
self.acks.outstanding(peer)
|
||||
}
|
||||
|
||||
/// The declaration sequence of the TreeAnnounce to `peer` awaiting
|
||||
/// confirmation.
|
||||
pub fn outstanding_seq(&self, peer: &NodeAddr) -> Option<u64> {
|
||||
self.outstanding_counter(peer)?;
|
||||
self.announced.get(peer).copied()
|
||||
}
|
||||
|
||||
/// Set how long a TreeAnnounce the receiver reports cannot check waits
|
||||
/// before its fallback resend. Defaults to
|
||||
/// [`FALLBACK_MS`](crate::proto::mmp::delivery::FALLBACK_MS).
|
||||
pub fn set_fallback(&mut self, ms: u64) {
|
||||
self.acks.set_fallback(ms);
|
||||
}
|
||||
|
||||
/// Update this node's parent selection.
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Delivery tracking for sent tree announces.
|
||||
//!
|
||||
//! Synthetic milliseconds, counters and receiver reports, no I/O. A report is
|
||||
//! written `(highest, received, reordered)`. Unless a test says otherwise, a
|
||||
//! send is recorded with `next_counter = counter + 1`, as the shell reads it
|
||||
//! straight after the send. The shared rule itself (bases, sessions, budgets,
|
||||
//! backoff, rekey artifacts) is covered by the filter announce tests; these
|
||||
//! cover what the tree adds: the declaration sequence as the lineage, and
|
||||
//! removal with the peer.
|
||||
|
||||
use super::util::make_node_addr;
|
||||
use crate::NodeAddr;
|
||||
use crate::proto::mmp::delivery::{FALLBACK_MS, LinkEvidence, ResendReason, RrCounters};
|
||||
use crate::proto::stp::TreeState;
|
||||
|
||||
/// First session.
|
||||
const E1: u64 = 0x0e01;
|
||||
/// Second session.
|
||||
const E2: u64 = 0x0e02;
|
||||
|
||||
/// A report's cumulative counters.
|
||||
fn rr(highest: u64, received: u64, reordered: u32) -> Option<RrCounters> {
|
||||
Some(RrCounters {
|
||||
highest,
|
||||
received,
|
||||
reordered,
|
||||
})
|
||||
}
|
||||
|
||||
/// Link evidence for session `epoch`.
|
||||
fn link(epoch: u64, next_counter: u64, rr: Option<RrCounters>) -> LinkEvidence {
|
||||
LinkEvidence {
|
||||
epoch,
|
||||
next_counter,
|
||||
rr,
|
||||
}
|
||||
}
|
||||
|
||||
/// One peer's tree announces, driven as the shell drives them.
|
||||
struct Track {
|
||||
state: TreeState,
|
||||
peer: NodeAddr,
|
||||
counter: u64,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
/// A tracker with nothing sent yet.
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
state: TreeState::new(make_node_addr(0), 1000),
|
||||
peer: make_node_addr(1),
|
||||
counter: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a send of declaration sequence `seq` at `counter`.
|
||||
fn send(&mut self, seq: u64, counter: u64, link: LinkEvidence, now_ms: u64) {
|
||||
self.state
|
||||
.record_announce(self.peer, seq, counter, &link, now_ms);
|
||||
self.counter = counter;
|
||||
}
|
||||
|
||||
/// One tick of the tracker.
|
||||
fn check(&mut self, link: LinkEvidence, now_ms: u64) -> Option<ResendReason> {
|
||||
self.state.check_announce(&self.peer, &link, now_ms)
|
||||
}
|
||||
|
||||
/// Whether the announce is still unconfirmed.
|
||||
fn outstanding(&self) -> bool {
|
||||
self.state.announce_outstanding(&self.peer)
|
||||
}
|
||||
|
||||
/// Check every 1,000 ms from `from_ms` to `to_ms` inclusive, recording
|
||||
/// each resend as a send of `seq`, as the shell resends the current
|
||||
/// declaration. `model` gives the evidence at a time, from the
|
||||
/// outstanding counter: for a check with `false`, and for recording a
|
||||
/// resend with `true`, where the resend takes the counter
|
||||
/// `next_counter - 1` of that evidence. Returns the resends.
|
||||
fn hold(
|
||||
&mut self,
|
||||
seq: u64,
|
||||
from_ms: u64,
|
||||
to_ms: u64,
|
||||
model: impl Fn(u64, bool) -> LinkEvidence,
|
||||
) -> Vec<(u64, ResendReason)> {
|
||||
let mut resends = Vec::new();
|
||||
let mut now = from_ms;
|
||||
while now <= to_ms {
|
||||
if let Some(reason) = self.check(model(self.counter, false), now) {
|
||||
resends.push((now, reason));
|
||||
let ev = model(self.counter, true);
|
||||
self.send(seq, ev.next_counter - 1, ev, now);
|
||||
}
|
||||
now += 1_000;
|
||||
}
|
||||
resends
|
||||
}
|
||||
}
|
||||
|
||||
/// Loss on every check: each send is based on a report just below it, and
|
||||
/// each check sees a report two counters on with one frame missing.
|
||||
fn lossy(counter: u64, recording: bool) -> LinkEvidence {
|
||||
if recording {
|
||||
let n = counter + 1;
|
||||
link(E1, n + 1, rr(n - 1, n, 0))
|
||||
} else {
|
||||
link(E1, counter + 2, rr(counter + 1, counter + 1, 0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Send sequence 5 into a lossy link and spend its three loss resends.
|
||||
///
|
||||
/// The resends are checked but not recorded, so spending the budget does not
|
||||
/// depend on the lineage decision; the one record each test then makes is
|
||||
/// the only lineage decision it observes. The window ends before the
|
||||
/// backoff would allow a fourth resend (7 s), so the budget limit itself is
|
||||
/// observed only by the test's final assertion.
|
||||
fn spend_budget() -> Track {
|
||||
let mut t = Track::new();
|
||||
t.send(5, 12, lossy(11, true), 0);
|
||||
let mut resends = Vec::new();
|
||||
for now in (0..=6_000).step_by(1_000) {
|
||||
if let Some(reason) = t.check(lossy(12, false), now) {
|
||||
resends.push((now, reason));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
resends,
|
||||
vec![
|
||||
(0, ResendReason::Loss),
|
||||
(1_000, ResendReason::Loss),
|
||||
(3_000, ResendReason::Loss),
|
||||
],
|
||||
"setup: the lineage spends its three loss resends"
|
||||
);
|
||||
assert!(
|
||||
t.outstanding(),
|
||||
"setup: the lost announce is still outstanding"
|
||||
);
|
||||
t
|
||||
}
|
||||
|
||||
/// A new declaration sequence starts a new lineage, whose loss budget is
|
||||
/// full again.
|
||||
#[test]
|
||||
fn test_tree_ack_new_sequence_starts_a_new_lineage() {
|
||||
let mut t = spend_budget();
|
||||
let c = t.counter;
|
||||
t.send(6, c + 1, lossy(c, true), 10_000);
|
||||
assert_eq!(
|
||||
t.check(lossy(t.counter, false), 11_000),
|
||||
Some(ResendReason::Loss),
|
||||
"a new sequence must refill the loss budget"
|
||||
);
|
||||
}
|
||||
|
||||
/// The periodic re-broadcast sends the same sequence again, so it stays in
|
||||
/// the lineage and does not refill the loss budget; the one unchecked
|
||||
/// resend still comes, 30 s after the re-broadcast.
|
||||
#[test]
|
||||
fn test_tree_ack_periodic_resend_of_the_same_sequence_keeps_the_budget() {
|
||||
let mut t = spend_budget();
|
||||
let c = t.counter;
|
||||
t.send(5, c + 1, lossy(c, true), 10_000);
|
||||
let resends = t.hold(5, 11_000, 120_000, lossy);
|
||||
assert_eq!(
|
||||
resends,
|
||||
vec![(10_000 + FALLBACK_MS, ResendReason::Timeout)],
|
||||
"no fourth loss resend, and exactly one unchecked resend"
|
||||
);
|
||||
}
|
||||
|
||||
/// Removing the peer forgets its announce, so the next send starts a fresh
|
||||
/// entry whose first session measures from counter zero.
|
||||
#[test]
|
||||
fn test_tree_ack_removed_peer_starts_fresh() {
|
||||
// Control: without the removal, an announce in a later session with no
|
||||
// report has no base, and a first report already covering it cannot
|
||||
// check it.
|
||||
let mut t = Track::new();
|
||||
t.send(5, 12, link(E1, 13, rr(9, 10, 0)), 0);
|
||||
t.send(5, 3, link(E2, 4, None), 1_000);
|
||||
assert_eq!(
|
||||
t.check(link(E2, 4, rr(3, 4, 0)), 2_000),
|
||||
Some(ResendReason::Unverified),
|
||||
"control: the kept entry cannot check the announce"
|
||||
);
|
||||
|
||||
let mut t = Track::new();
|
||||
t.send(5, 12, link(E1, 13, rr(9, 10, 0)), 0);
|
||||
t.state.remove_peer(&t.peer);
|
||||
assert!(!t.outstanding(), "removal must forget the announce");
|
||||
t.send(5, 3, link(E2, 4, None), 1_000);
|
||||
assert_eq!(t.check(link(E2, 4, rr(3, 4, 0)), 2_000), None);
|
||||
assert!(!t.outstanding(), "the new entry must measure from zero");
|
||||
}
|
||||
|
||||
/// In the peer's first session with no report before the send, frames that
|
||||
/// arrive out of order are still every counter from 0, so the announce
|
||||
/// confirms. This is the traced shape of a tree announce and a filter
|
||||
/// announce sent in the same tick arriving swapped.
|
||||
#[test]
|
||||
fn test_tree_ack_in_window_reorder_on_the_zero_base_confirms() {
|
||||
let mut t = Track::new();
|
||||
t.send(5, 3, link(E1, 4, None), 0);
|
||||
// 0..=4 all arrived, two of them after a higher counter.
|
||||
assert_eq!(t.check(link(E1, 5, rr(4, 5, 2)), 1_000), None);
|
||||
assert!(
|
||||
!t.outstanding(),
|
||||
"every counter arrived, so it must confirm"
|
||||
);
|
||||
}
|
||||
|
||||
/// A covering report whose receipts since the base fall short of the span is
|
||||
/// a loss, and the announce stays outstanding.
|
||||
#[test]
|
||||
fn test_tree_ack_short_upper_bound_is_a_loss() {
|
||||
let mut t = Track::new();
|
||||
// Base (9, 10, 0): all of 0..=9 counted.
|
||||
t.send(5, 12, link(E1, 13, rr(9, 10, 0)), 0);
|
||||
// Four of 10..=14 arrived.
|
||||
assert_eq!(
|
||||
t.check(link(E1, 15, rr(14, 14, 0)), 1_000),
|
||||
Some(ResendReason::Loss)
|
||||
);
|
||||
assert!(t.outstanding(), "a lost announce stays outstanding");
|
||||
}
|
||||
|
||||
/// With no report ever, the announce gets its one unchecked resend exactly
|
||||
/// at the default fallback, and none after.
|
||||
#[test]
|
||||
fn test_tree_ack_fallback_resends_once_per_lineage() {
|
||||
let mut t = Track::new();
|
||||
t.send(5, 12, link(E1, 13, None), 0);
|
||||
assert_eq!(t.check(link(E1, 13, None), FALLBACK_MS - 1), None);
|
||||
assert!(t.outstanding());
|
||||
let quiet = |c: u64, recording: bool| link(E1, c + if recording { 2 } else { 1 }, None);
|
||||
let resends = t.hold(5, FALLBACK_MS, 120_000, quiet);
|
||||
assert_eq!(resends, vec![(FALLBACK_MS, ResendReason::Timeout)]);
|
||||
assert_eq!(FALLBACK_MS, 30_000);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! STP primitive unit tests. Shared helpers live in `util`.
|
||||
|
||||
mod acks;
|
||||
mod coordinate;
|
||||
mod limits;
|
||||
mod state;
|
||||
|
||||
@@ -82,6 +82,14 @@ traffic:
|
||||
# happened, not a loss ratio, so a busy host slows it without failing
|
||||
# it. It runs at teardown, after flapped links are restored.
|
||||
#
|
||||
# Recovery: a tree or bloom announce lost to a flap is resent from the
|
||||
# link's receiver reports a few seconds after the link returns, or within
|
||||
# about 30 s when those reports cannot confirm it; only a per-peer backoff
|
||||
# built up by earlier resends on a lossy link can hold a resend longer, up
|
||||
# to 60 s after the previous one. The 60 s window therefore no longer sits
|
||||
# at the ordinary recovery bound, and a red here is investigated, not
|
||||
# expected.
|
||||
#
|
||||
# Sizes: payload 0 is the smallest ICMPv6 echo and 1200 is near the
|
||||
# 1280-byte TUN MTU. Neither can reach the receiver's padding trim on a
|
||||
# veth link. Measured on a live run of this scenario (2026-09-19): an
|
||||
|
||||
@@ -132,7 +132,9 @@ def status_tested(name: str, all_text: str, defining_file: Path) -> list[str]:
|
||||
(rf"^\s*{re.escape(name)}\s*\|\|", "<fn> ||"),
|
||||
(rf"^\s*{re.escape(name)}\s+[^\n|&]*&&", "<fn> &&"),
|
||||
(rf"^\s*{re.escape(name)}\s*&&", "<fn> &&"),
|
||||
(rf"\bif\s+\S*\s*{re.escape(name)}\b.*;\s*then", "if ... <fn> ; then"),
|
||||
# The lookbehind keeps `\S*` from ending inside a longer name, so
|
||||
# `if text=$(read_gateway_log ...); then` is not read as a call of `log`.
|
||||
(rf"\bif\s+\S*\s*(?<![A-Za-z0-9_]){re.escape(name)}\b.*;\s*then", "if ... <fn> ; then"),
|
||||
# Command substitution. Found 2026-07-23 while fixing a function this
|
||||
# check reported clean: `total=$(count_log_pattern "$p") || { ... }`
|
||||
# consumes the status, but the line begins with the variable, so none
|
||||
|
||||
@@ -304,6 +304,42 @@ EOF
|
||||
return
|
||||
}
|
||||
|
||||
# The installed gateway, on the default config, serves .fips on its default
|
||||
# listen address. Each check needs the gateway running: a gateway that exited
|
||||
# fails the first one rather than letting the others pass on nothing.
|
||||
# Args: <name> <npub>, the daemon's npub to resolve through the gateway.
|
||||
check_gateway_default_listener() {
|
||||
local name="$1" npub="$2"
|
||||
local journal="" _i
|
||||
for _i in $(seq 1 5); do
|
||||
journal=$(docker exec "$name" journalctl -u fips-gateway.service --no-pager 2>/dev/null) || journal=""
|
||||
printf '%s\n' "$journal" | grep -q "fips-gateway running" && break
|
||||
sleep 1
|
||||
done
|
||||
if ! printf '%s\n' "$journal" | grep -q "fips-gateway running"; then
|
||||
fail "the installed fips-gateway did not reach 'fips-gateway running', so its default listener was not observed"
|
||||
echo " --- fips-gateway journal ---"
|
||||
printf '%s\n' "$journal" | tail -15
|
||||
return
|
||||
fi
|
||||
|
||||
local sockets
|
||||
sockets=$(docker exec "$name" ss -Hulnp 'sport = :5365' 2>/dev/null) || sockets=""
|
||||
if printf '%s\n' "$sockets" | grep -F '[::1]:5365' | grep -q 'fips-gateway'; then
|
||||
pass "fips-gateway listens on its default [::1]:5365"
|
||||
else
|
||||
fail "no fips-gateway socket on [::1]:5365: '$sockets'"
|
||||
fi
|
||||
|
||||
local answer
|
||||
answer=$(docker exec "$name" dig +short +tries=1 +time=3 @::1 -p 5365 AAAA "${npub}.fips" 2>&1)
|
||||
if printf '%s\n' "$answer" | grep -qE '^fd01::[0-9a-f]{1,4}$'; then
|
||||
pass "the gateway answers ${npub}.fips on [::1]:5365 from its fd01::/112 pool"
|
||||
else
|
||||
fail "the gateway did not answer ${npub}.fips on [::1]:5365 from its pool: '$answer'"
|
||||
fi
|
||||
}
|
||||
|
||||
# Purge the package with the DNS routing file planted and fips-dns stopped, and
|
||||
# check that postrm removes the file and restarts systemd-resolved.
|
||||
#
|
||||
@@ -709,6 +745,8 @@ DOCKERFILE
|
||||
docker exec "$name" journalctl -u fips-gateway.service --no-pager 2>&1 | tail -15
|
||||
fi
|
||||
|
||||
check_gateway_default_listener "$name" "$npub"
|
||||
|
||||
check_purge_clears_dns "$name" "$expected_backend"
|
||||
|
||||
cleanup_container "$name"
|
||||
|
||||
@@ -701,6 +701,114 @@ DOCKERFILE
|
||||
# prepare_binaries) and are copied into each per-distro runtime image.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Print a fips-gateway log from the container with terminal colour codes
|
||||
# removed, so structured fields can be matched as plain "key=value" text.
|
||||
# Fails when the log cannot be read.
|
||||
read_gateway_log() {
|
||||
local name="$1" log="$2"
|
||||
local text
|
||||
text=$(docker exec "$name" cat "$log" 2>/dev/null) || return 1
|
||||
printf '%s\n' "$text" | sed 's/\x1b\[[0-9;]*m//g'
|
||||
return 0
|
||||
}
|
||||
|
||||
# The gateway on its default config binds [::1]:5365 and gets past the
|
||||
# bind to the NAT step, logging one of the two NAT lines whichever way NAT
|
||||
# goes in this container. Those are the lines the held-port check requires
|
||||
# to be absent, so this shows the gateway still emits them in that text.
|
||||
check_gateway_default_bind() {
|
||||
local name="$1"
|
||||
local log=/var/log/fips-gateway.log
|
||||
local text="" read_ok=0 listening=0 nat_step=0 _i
|
||||
for _i in $(seq 1 5); do
|
||||
if text=$(read_gateway_log "$name" "$log"); then
|
||||
read_ok=1
|
||||
if printf '%s\n' "$text" | grep -q 'Gateway DNS resolver listening.*addr=\[::1\]:5365'; then
|
||||
listening=1
|
||||
fi
|
||||
if printf '%s\n' "$text" | grep -qE 'Created nftables table|Failed to create nftables table'; then
|
||||
nat_step=1
|
||||
break
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$read_ok" = "0" ]; then
|
||||
fail "could not read $log, so the gateway's default bind was not observed"
|
||||
return
|
||||
fi
|
||||
if [ "$listening" = "1" ]; then
|
||||
pass "fips-gateway listens on its default [::1]:5365"
|
||||
else
|
||||
fail "fips-gateway did not log listening on [::1]:5365"
|
||||
fi
|
||||
if [ "$nat_step" = "1" ]; then
|
||||
pass "fips-gateway gets past the DNS bind to the NAT step"
|
||||
else
|
||||
fail "fips-gateway logged neither NAT-step line after the DNS bind"
|
||||
fi
|
||||
if [ "$listening" = "0" ] || [ "$nat_step" = "0" ]; then
|
||||
echo " --- $log ---"
|
||||
printf '%s\n' "$text" | tail -20
|
||||
fi
|
||||
}
|
||||
|
||||
# The gateway exits at the DNS bind when its listen port is held. The
|
||||
# daemon in this container holds [::1]:5354, so a gateway configured to
|
||||
# listen there must exit non-zero with the hint naming the daemon, before
|
||||
# it creates the address pool or the NAT table. Any build that gets past
|
||||
# the bind logs one of the pool or NAT lines below, whichever way NAT goes
|
||||
# in this container, so their absence shows the exit came first.
|
||||
check_gateway_exits_on_held_port() {
|
||||
local name="$1"
|
||||
local log=/var/log/fips-gateway-held.log
|
||||
local fail_before=$FAIL
|
||||
docker exec "$name" bash -c 'cat > /tmp/gateway-held.yaml <<EOF
|
||||
node:
|
||||
identity:
|
||||
persistent: true
|
||||
gateway:
|
||||
enabled: true
|
||||
pool: "fd01::/112"
|
||||
lan_interface: "eth0"
|
||||
dns:
|
||||
listen: "[::1]:5354"
|
||||
EOF'
|
||||
docker exec "$name" bash -c "timeout 30 /usr/bin/fips-gateway --config /tmp/gateway-held.yaml >$log 2>&1; echo \"EXIT=\$?\" >>$log"
|
||||
|
||||
local text
|
||||
if ! text=$(read_gateway_log "$name" "$log"); then
|
||||
fail "could not read $log, so the held-port exit was not observed"
|
||||
return
|
||||
fi
|
||||
local rc
|
||||
rc=$(printf '%s\n' "$text" | sed -n 's/^EXIT=//p' | tail -n 1)
|
||||
if [ -z "$rc" ]; then
|
||||
fail "the held-port gateway run left no exit status in $log"
|
||||
elif [ "$rc" = "0" ] || [ "$rc" = "124" ]; then
|
||||
fail "fips-gateway on a held DNS port exited $rc (expected non-zero, not the timeout)"
|
||||
else
|
||||
pass "fips-gateway on a held DNS port exits $rc"
|
||||
fi
|
||||
if printf '%s\n' "$text" | grep -qF "the fips daemon's own DNS responder listens on 5354"; then
|
||||
pass "the bind error names the daemon as the likely holder of 5354"
|
||||
else
|
||||
fail "the bind error does not carry the 5354 hint"
|
||||
fi
|
||||
local line
|
||||
for line in "Failed to create virtual IP pool" "Failed to create nftables table" "Created nftables table"; do
|
||||
if printf '%s\n' "$text" | grep -qF "$line"; then
|
||||
fail "fips-gateway reached a step after the DNS bind: '$line'"
|
||||
else
|
||||
pass "fips-gateway stopped before '$line'"
|
||||
fi
|
||||
done
|
||||
if [ "$FAIL" -gt "$fail_before" ]; then
|
||||
echo " --- $log ---"
|
||||
printf '%s\n' "$text" | tail -20
|
||||
fi
|
||||
}
|
||||
|
||||
# Args: <distro_label> <docker_base_image> <apt_packages>
|
||||
# distro_label: short tag for container/image names (e.g. "debian12")
|
||||
# docker_base_image: e.g. "debian:12", "ubuntu:26.04"
|
||||
@@ -913,11 +1021,14 @@ EOF'
|
||||
echo " --- fips-gateway log ---"
|
||||
docker exec "$name" tail -20 /var/log/fips-gateway.log 2>&1 || true
|
||||
fi
|
||||
# Stop the gateway (it will likely have failed past the upstream
|
||||
# check on something unrelated in this minimal container — we only
|
||||
# care that the upstream reachability step succeeded).
|
||||
check_gateway_default_bind "$name"
|
||||
|
||||
# Stop the gateway (it may have failed after the DNS bind on something
|
||||
# unrelated in this minimal container).
|
||||
docker exec "$name" pkill -f fips-gateway 2>/dev/null || true
|
||||
|
||||
check_gateway_exits_on_held_port "$name"
|
||||
|
||||
# Teardown via the script: backend config file must be removed
|
||||
# (path varies by backend selected above).
|
||||
local teardown_path
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# FIPS Node Configuration
|
||||
|
||||
node:
|
||||
identity:
|
||||
# By default, a new ephemeral keypair is generated on each start.
|
||||
# Uncomment persistent to keep the same identity across restarts;
|
||||
# on first start a keypair is saved to fips.key/fips.pub next to
|
||||
# this config file (mode 0600/0644).
|
||||
# persistent: true
|
||||
#
|
||||
# Or set an explicit key (overrides persistent):
|
||||
# nsec: "nsec1..."
|
||||
# Mesh-lookup protocol (node.lookup.*): the overlay coordinate-lookup engine
|
||||
# (mesh address -> coordinates). Defaults shown; uncomment to override.
|
||||
# lookup:
|
||||
# ttl: 64
|
||||
# attempt_timeouts_secs: [1, 2, 4, 8]
|
||||
# recent_expiry_secs: 10
|
||||
# backoff_base_secs: 0
|
||||
# backoff_max_secs: 0
|
||||
# forward_min_interval_secs: 2
|
||||
rendezvous:
|
||||
# Optional Nostr-mediated overlay endpoint rendezvous.
|
||||
# nostr:
|
||||
# enabled: true
|
||||
# policy: configured_only # disabled | configured_only | open
|
||||
# open_discovery_max_pending: 64 # caps queued open-rendezvous retries
|
||||
# app: "fips-overlay-v1"
|
||||
# advertise: true
|
||||
# advert_relays:
|
||||
# - "wss://relay.damus.io"
|
||||
# - "wss://nos.lol"
|
||||
# - "wss://offchain.pub"
|
||||
# dm_relays:
|
||||
# - "wss://relay.damus.io"
|
||||
# - "wss://nos.lol"
|
||||
# - "wss://offchain.pub"
|
||||
# # Optional override. If omitted, FIPS uses the built-in STUN list.
|
||||
# # Built-in relay/STUN defaults are best-effort and should be
|
||||
# # overridden by operators for production use.
|
||||
# stun_servers:
|
||||
# - "stun:stun.l.google.com:19302"
|
||||
# - "stun:stun.cloudflare.com:3478"
|
||||
# - "stun:global.stun.twilio.com:3478"
|
||||
|
||||
# mDNS/DNS-SD peer rendezvous on the local link. Ships commented (the
|
||||
# daemon default is off); 'fips-ap-setup' uncomments it when creating
|
||||
# the access SSID — phone FIPS apps cannot see raw-Ethernet beacons,
|
||||
# so mDNS is how they find this router's daemon. Daemon-wide switch,
|
||||
# left enabled on 'fips-ap-setup remove'.
|
||||
# lan:
|
||||
# enabled: true
|
||||
|
||||
tun:
|
||||
enabled: true
|
||||
name: fips0
|
||||
mtu: 1280
|
||||
|
||||
dns:
|
||||
enabled: true
|
||||
# bind_addr defaults to "::1" (IPv6 loopback). The shipped
|
||||
# fips-dns-setup script configures systemd-resolved with a global
|
||||
# /etc/systemd/resolved.conf.d/fips.conf drop-in pointing at
|
||||
# [::1]:5354.
|
||||
#
|
||||
# Set "::" to expose the responder to mesh peers as well (e.g. for
|
||||
# gateway hosts that resolve .fips on behalf of LAN clients). The
|
||||
# mesh-interface filter in src/upper/dns.rs will still defend
|
||||
# /etc/fips/hosts aliases from cross-mesh enumeration.
|
||||
# bind_addr: "::1"
|
||||
port: 5354
|
||||
|
||||
transports:
|
||||
udp:
|
||||
# Dual-stack wildcard, not "0.0.0.0": access-SSID clients (phones) learn
|
||||
# this node's addresses from the mDNS advert and prefer the IPv6
|
||||
# link-local — a v4-only bind silently drops their Noise msg1.
|
||||
# OpenWrt is Linux (bindv6only=0), so "[::]" accepts v4 too.
|
||||
bind_addr: "[::]:2121"
|
||||
# advertise_on_nostr: true
|
||||
# public: false # false => advertise udp:nat; true => advertise bound host:port
|
||||
# accept_connections: true # default; refuse inbound msg1 when false
|
||||
# outbound_only: false # true => bind ephemeral, no listener on a
|
||||
# # known port. Forces advertise_on_nostr=false
|
||||
# # and accept_connections=false. Pure-client
|
||||
# # posture; bind_addr is ignored.
|
||||
|
||||
tcp:
|
||||
# Accepts inbound connections. No static outbound peers.
|
||||
bind_addr: "0.0.0.0:8443"
|
||||
# advertise_on_nostr: true
|
||||
|
||||
# Ethernet transport — physical port names, NOT bridge names.
|
||||
# Run 'ip link show' on the router to identify port names.
|
||||
ethernet:
|
||||
wan:
|
||||
interface: "eth0"
|
||||
listen: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
wwan:
|
||||
interface: "phy0-sta0"
|
||||
listen: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
lan:
|
||||
interface: "br-lan"
|
||||
listen: true
|
||||
announce: true
|
||||
auto_connect: true
|
||||
accept_connections: true
|
||||
|
||||
# 802.11s mesh backhaul between FIPS routers. These entries ship
|
||||
# commented out so a stock install that never creates fips-mesh*
|
||||
# logs no per-boot "interface missing" bind warning. Running
|
||||
# 'fips-mesh-setup <radio>' creates the interface AND uncomments the
|
||||
# matching block here (once per radio; radio0 -> fips-mesh0, radio1 ->
|
||||
# fips-mesh1); 'fips-mesh-setup remove' re-comments it. Restart fips
|
||||
# after — a transport whose interface is missing at startup is skipped,
|
||||
# not retried. Dual-band routers can mesh on both bands at once —
|
||||
# failover, not multipath: FIPS keeps one active link per peer, the
|
||||
# other band stands by. The mesh runs OPEN (no SAE) with 802.11s
|
||||
# forwarding off: FIPS's Noise handshake is the encryption and
|
||||
# authentication, and FIPS is the routing layer. See
|
||||
# docs/how-to/set-up-80211s-mesh-backhaul.md.
|
||||
# mesh0:
|
||||
# interface: "fips-mesh0"
|
||||
# listen: true
|
||||
# announce: true
|
||||
# auto_connect: true
|
||||
# accept_connections: true
|
||||
# mesh1:
|
||||
# interface: "fips-mesh1"
|
||||
# listen: true
|
||||
# announce: true
|
||||
# auto_connect: true
|
||||
# accept_connections: true
|
||||
|
||||
# Open "!FIPS" access SSID for phones and laptops running FIPS. These
|
||||
# entries ship commented out so a stock install that never creates
|
||||
# fips-ap* logs no per-boot "interface missing" bind warning. Running
|
||||
# 'fips-ap-setup <radio>' creates the interface AND uncomments the
|
||||
# matching block here (once per radio; radio0 -> fips-ap0, radio1 ->
|
||||
# fips-ap1); 'fips-ap-setup remove' re-comments it. Restart fips after
|
||||
# — a transport whose interface is missing at startup is skipped, not
|
||||
# retried. The SSID is OPEN and isolated on purpose: FIPS's Noise
|
||||
# handshake is the only security layer, and associated clients reach
|
||||
# nothing but the FIPS handshake surface. See
|
||||
# docs/how-to/set-up-open-access-ssid.md.
|
||||
# ap0:
|
||||
# interface: "fips-ap0"
|
||||
# listen: true
|
||||
# announce: true
|
||||
# auto_connect: true
|
||||
# accept_connections: true
|
||||
# ap1:
|
||||
# interface: "fips-ap1"
|
||||
# listen: true
|
||||
# announce: true
|
||||
# auto_connect: true
|
||||
# accept_connections: true
|
||||
|
||||
# No BLE transport: OpenWrt builds target musl, which has no BlueZ backend.
|
||||
|
||||
# Outbound LAN gateway. dnsmasq forwards .fips queries to listen=[::1]:5353
|
||||
# while it runs (configured by the fips-gateway init script). Requires IPv6
|
||||
# forwarding enabled.
|
||||
gateway:
|
||||
enabled: true
|
||||
pool: "fd01::/112"
|
||||
lan_interface: "br-lan"
|
||||
dns:
|
||||
listen: "[::1]:5353"
|
||||
upstream: "[::1]:5354"
|
||||
ttl: 60
|
||||
pool_grace_period: 60
|
||||
|
||||
peers: []
|
||||
# Static peers for bootstrapping (UDP or TCP):
|
||||
# - npub: "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98"
|
||||
# alias: "gateway"
|
||||
# via_nostr: true
|
||||
# addresses:
|
||||
# - transport: udp
|
||||
# addr: "test-us01.fips.network:2121" # IP or hostname (e.g., "peer.example.com:2121")
|
||||
# - transport: udp
|
||||
# addr: "nat" # Use node.rendezvous.nostr for Nostr/STUN hole punching
|
||||
# connect_policy: auto_connect
|
||||
@@ -28,9 +28,22 @@ RELEASED_PRERM="$REPO/testing/openwrt/fixtures/released-prerm"
|
||||
INIT_GATEWAY="$REPO/packaging/openwrt-ipk/files/etc/init.d/fips-gateway"
|
||||
APK_SCRIPTS="${APK_SCRIPTS:-}"
|
||||
SHIPPED_YAML="$REPO/packaging/openwrt-ipk/files/etc/fips/fips.yaml"
|
||||
# The fips.yaml every release up to 0.5.1 shipped, from before the gateway's
|
||||
# default DNS port moved.
|
||||
RELEASED_YAML="$REPO/testing/openwrt/fixtures/released-fips.yaml"
|
||||
GATEWAY_RS="$REPO/src/config/gateway.rs"
|
||||
SETUP_SCRIPT="$REPO/packaging/openwrt-ipk/files/etc/uci-defaults/90-fips-setup"
|
||||
LEGACY_LISTEN=' listen: "[::1]:5353"'
|
||||
SHIPPED_LISTEN=' # listen: "[::1]:5365" # the default; the init script points dnsmasq at this port'
|
||||
MIGRATED_MSG='fips: moved gateway.dns.listen off the mDNS port 5353 to the default [::1]:5365'
|
||||
|
||||
WORK=/tmp/fips-openwrt-scenarios
|
||||
UPGRADE_MARKER=/tmp/fips-prerm-upgrade
|
||||
# The uci stub's state and the directory holding the executable stubs. Fixed
|
||||
# paths, because the .apk scripts run with only PATH in their environment.
|
||||
UCI_DIR=/tmp/fips-openwrt-uci
|
||||
STUB_BIN=/tmp/fips-openwrt-bin
|
||||
DNSMASQ_OPT='dhcp.@dnsmasq[0].server'
|
||||
|
||||
FAILURES=0
|
||||
CASES=0
|
||||
@@ -124,6 +137,27 @@ assert_not_called() {
|
||||
return 0
|
||||
}
|
||||
|
||||
assert_none_called_with_prefix() {
|
||||
# assert_none_called_with_prefix <prefix> <what it means>
|
||||
# Fails if any recorded call begins with <prefix>, whatever follows it.
|
||||
if [ ! -r "$CALLS" ]; then
|
||||
bad "$2 — the call log $CALLS cannot be read"
|
||||
return 0
|
||||
fi
|
||||
matched=""
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
"$1"*) matched="$matched$line;" ;;
|
||||
esac
|
||||
done < "$CALLS"
|
||||
if [ -n "$matched" ]; then
|
||||
bad "$2 — called: $matched"
|
||||
else
|
||||
ok "$2"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
assert_file_is() {
|
||||
# assert_file_is <file> <expected contents> <what it means>
|
||||
got="$(cat "$1" 2>/dev/null)"
|
||||
@@ -182,6 +216,76 @@ assert_order() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Install an executable uci that keeps each option as a file of values, one per
|
||||
# line, under $UCI_DIR. Like the real uci, "get" prints a list on one line
|
||||
# separated by single spaces and fails for an option with no values, and
|
||||
# del_list removes every copy of the value. Other commands are only logged.
|
||||
install_uci_stub() {
|
||||
rm -rf "$UCI_DIR"
|
||||
mkdir -p "$UCI_DIR" "$STUB_BIN"
|
||||
{
|
||||
echo '#!/bin/sh'
|
||||
echo "dir=$UCI_DIR"
|
||||
cat <<'STUB'
|
||||
[ "${1:-}" = "-q" ] && shift
|
||||
cmd="${1:-}"
|
||||
[ $# -gt 0 ] && shift
|
||||
echo "uci $cmd $*" >> "$dir/log"
|
||||
file_of() {
|
||||
printf '%s/%s' "$dir" "$(printf '%s' "$1" | sed 's/[^A-Za-z0-9._-]/_/g')"
|
||||
}
|
||||
case "$cmd" in
|
||||
get)
|
||||
f="$(file_of "$1")"
|
||||
[ -s "$f" ] || exit 1
|
||||
tr '\n' ' ' < "$f" | sed 's/ $//'
|
||||
echo
|
||||
;;
|
||||
add_list)
|
||||
echo "${1#*=}" >> "$(file_of "${1%%=*}")"
|
||||
;;
|
||||
del_list)
|
||||
f="$(file_of "${1%%=*}")"
|
||||
if [ -f "$f" ]; then
|
||||
grep -vxF -- "${1#*=}" "$f" > "$f.new"
|
||||
mv "$f.new" "$f"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
STUB
|
||||
} > "$STUB_BIN/uci"
|
||||
chmod 0755 "$STUB_BIN/uci"
|
||||
|
||||
cat > /etc/init.d/dnsmasq <<'STUB'
|
||||
#!/bin/sh
|
||||
echo "dnsmasq $1" >> "$CALLS"
|
||||
STUB
|
||||
chmod 0755 /etc/init.d/dnsmasq
|
||||
return 0
|
||||
}
|
||||
|
||||
uci_seed() {
|
||||
# uci_seed <option> <value>...
|
||||
opt="$1"
|
||||
shift
|
||||
for value in "$@"; do
|
||||
"$STUB_BIN/uci" add_list "$opt=$value"
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
uci_sorted() {
|
||||
# uci_sorted <option>: the option's values, sorted, on one line.
|
||||
"$STUB_BIN/uci" -q get "$1" | tr ' ' '\n' | sort | tr '\n' ' '
|
||||
return 0
|
||||
}
|
||||
|
||||
sorted_words() {
|
||||
printf '%s\n' "$@" | sort | tr '\n' ' '
|
||||
return 0
|
||||
}
|
||||
|
||||
run_apk_script() {
|
||||
# run_apk_script <phase> <args...>
|
||||
# Runs one captured .apk script the way apk-tools v3 does: executed
|
||||
@@ -357,9 +461,21 @@ scenario_start_service_guard() {
|
||||
reset_state
|
||||
CONFIG="$SHIPPED_YAML"
|
||||
start_service >/dev/null 2>&1
|
||||
assert_called "dnsmasq_swap 5353" "an enabled gateway still redirects dnsmasq"
|
||||
assert_called "dnsmasq_swap 5365" "an enabled gateway redirects dnsmasq to the default port"
|
||||
assert_called "procd_open_instance" "an enabled gateway still starts the daemon"
|
||||
|
||||
reset_state
|
||||
CONFIG="$WORK/explicit-5353.yaml"
|
||||
cat > "$CONFIG" <<'YAML'
|
||||
gateway:
|
||||
enabled: true
|
||||
pool: "fd01::/112"
|
||||
dns:
|
||||
listen: "[::1]:5353"
|
||||
YAML
|
||||
start_service >/dev/null 2>&1
|
||||
assert_called "dnsmasq_swap 5353" "dnsmasq follows an explicit gateway.dns.listen"
|
||||
|
||||
reset_state
|
||||
CONFIG="$WORK/disabled.yaml"
|
||||
cat > "$CONFIG" <<'YAML'
|
||||
@@ -368,7 +484,7 @@ gateway:
|
||||
pool: "fd01::/112"
|
||||
YAML
|
||||
start_service >/dev/null 2>&1
|
||||
assert_not_called "dnsmasq_swap 5353" "a disabled gateway does not redirect dnsmasq"
|
||||
assert_none_called_with_prefix "dnsmasq_swap " "a disabled gateway does not redirect dnsmasq"
|
||||
assert_not_called "add_global_prefix" "a disabled gateway does not add the LAN prefix"
|
||||
assert_not_called "add_ra_route" "a disabled gateway does not advertise the pool route"
|
||||
assert_not_called "procd_open_instance" "a disabled gateway does not start the daemon"
|
||||
@@ -434,6 +550,289 @@ scenario_apk_upgrade_disabled() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── 11. gateway_dns_port reads the port the gateway will bind ───────────────
|
||||
scenario_dns_port_reader() {
|
||||
note "scenario 11: gateway_dns_port"
|
||||
reset_state
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
. "$INIT_GATEWAY"
|
||||
|
||||
CONFIG="$SHIPPED_YAML"
|
||||
assert_equals "$(gateway_dns_port)" "5365" "the shipped fips.yaml reads as the default port"
|
||||
|
||||
CONFIG="$RELEASED_YAML"
|
||||
assert_equals "$(gateway_dns_port)" "5353" "the previously shipped fips.yaml reads as 5353"
|
||||
|
||||
CONFIG="$WORK/v4.yaml"
|
||||
cat > "$CONFIG" <<'YAML'
|
||||
gateway:
|
||||
enabled: true
|
||||
dns:
|
||||
listen: '127.0.0.1:5400'
|
||||
YAML
|
||||
assert_equals "$(gateway_dns_port)" "5400" "a single-quoted IPv4 listen address reads as its port"
|
||||
|
||||
CONFIG="$WORK/port-forward.yaml"
|
||||
cat > "$CONFIG" <<'YAML'
|
||||
gateway:
|
||||
enabled: true
|
||||
pool: "fd01::/112"
|
||||
port_forwards:
|
||||
- listen_port: 8080
|
||||
proto: tcp
|
||||
target: "[fd00::1]:80"
|
||||
YAML
|
||||
assert_equals "$(gateway_dns_port)" "5365" "a port forward's listen_port is not the DNS listen port"
|
||||
|
||||
CONFIG="$WORK/transport-listen.yaml"
|
||||
cat > "$CONFIG" <<'YAML'
|
||||
transports:
|
||||
ethernet:
|
||||
lan:
|
||||
interface: "br-lan"
|
||||
listen: true
|
||||
gateway:
|
||||
enabled: true
|
||||
pool: "fd01::/112"
|
||||
YAML
|
||||
assert_equals "$(gateway_dns_port)" "5365" "a listen key outside the gateway block is ignored"
|
||||
|
||||
CONFIG="$WORK/commented.yaml"
|
||||
cat > "$CONFIG" <<'YAML'
|
||||
gateway:
|
||||
enabled: true
|
||||
dns:
|
||||
# listen: "[::1]:5400"
|
||||
upstream: "[::1]:5354"
|
||||
YAML
|
||||
assert_equals "$(gateway_dns_port)" "5365" "a commented listen line is ignored"
|
||||
|
||||
CONFIG="$WORK/no-gateway.yaml"
|
||||
cat > "$CONFIG" <<'YAML'
|
||||
dns:
|
||||
enabled: true
|
||||
|
||||
peers: []
|
||||
YAML
|
||||
assert_equals "$(gateway_dns_port)" "5365" "a config with no gateway block reads as the default port"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── 12. The init script's default port matches the gateway's ───────────────
|
||||
scenario_default_port_parity() {
|
||||
note "scenario 12: GW_DNS_DEFAULT matches DEFAULT_DNS_LISTEN"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
. "$INIT_GATEWAY"
|
||||
|
||||
rust_listen="$(sed -n 's/^const DEFAULT_DNS_LISTEN: &str = "\(.*\)";$/\1/p' "$GATEWAY_RS" 2>/dev/null)"
|
||||
rust_port="${rust_listen##*:}"
|
||||
case "$rust_port" in
|
||||
'' | *[!0-9]*)
|
||||
bad "could not read a port from DEFAULT_DNS_LISTEN in $GATEWAY_RS (got '$rust_listen')"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
assert_equals "${GW_DNS_DEFAULT:-}" "$rust_port" "the init script's GW_DNS_DEFAULT is the gateway's default port"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── 13. The swap clears every loopback .fips forward ────────────────────────
|
||||
# The real dnsmasq_swap_fips_upstream runs against the uci stub.
|
||||
scenario_swap_cleanup() {
|
||||
note "scenario 13: dnsmasq_swap_fips_upstream"
|
||||
reset_state
|
||||
install_uci_stub
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
. "$INIT_GATEWAY"
|
||||
|
||||
saved_path="$PATH"
|
||||
PATH="$STUB_BIN:$PATH"
|
||||
|
||||
uci_seed "$DNSMASQ_OPT" "/fips/::1#5353" "/fips/127.0.0.1#5400" "/fips/192.168.1.5#53" "/lan/192.168.1.2"
|
||||
lines="$(uci -q get "$DNSMASQ_OPT" | wc -l | tr -d ' ')"
|
||||
assert_equals "$lines" "1" "the uci stub prints a list on one line, as uci does"
|
||||
|
||||
dnsmasq_swap_fips_upstream 5365 >/dev/null 2>&1
|
||||
assert_equals "$(uci_sorted "$DNSMASQ_OPT")" \
|
||||
"$(sorted_words /lan/192.168.1.2 /fips/192.168.1.5#53 /fips/::1#5365)" \
|
||||
"the swap to 5365 clears every loopback .fips forward and keeps the others"
|
||||
|
||||
dnsmasq_swap_fips_upstream 5354 >/dev/null 2>&1
|
||||
assert_equals "$(uci_sorted "$DNSMASQ_OPT")" \
|
||||
"$(sorted_words /lan/192.168.1.2 /fips/192.168.1.5#53 /fips/::1#5354)" \
|
||||
"the swap back to 5354 leaves only the daemon's loopback forward"
|
||||
assert_called "dnsmasq restart" "the swap restarts dnsmasq"
|
||||
|
||||
PATH="$saved_path"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── 14. 90-fips-setup migrates the previously shipped listen line ──────────
|
||||
# The real 90-fips-setup runs, executed by the ipk postinst and the apk
|
||||
# post-upgrade script, and sourced in a subshell as OpenWrt's default_postinst
|
||||
# and first-boot uci-defaults run do. uci, logger, modprobe and sysctl are
|
||||
# stubs on PATH, and in /usr/sbin for the apk script, whose PATH is fixed.
|
||||
|
||||
install_setup_stubs() {
|
||||
install_uci_stub
|
||||
for cmd in logger modprobe sysctl; do
|
||||
printf '#!/bin/sh\necho "%s $*" >> %s/stub-calls\n' "$cmd" "$UCI_DIR" > "$STUB_BIN/$cmd"
|
||||
chmod 0755 "$STUB_BIN/$cmd"
|
||||
done
|
||||
mkdir -p /usr/sbin /etc/modules.d /etc/fips
|
||||
# rm first: cp onto a busybox applet link would overwrite busybox itself.
|
||||
for cmd in uci logger modprobe sysctl; do
|
||||
rm -f "/usr/sbin/$cmd"
|
||||
cp "$STUB_BIN/$cmd" "/usr/sbin/$cmd"
|
||||
done
|
||||
cp "$SETUP_SCRIPT" /etc/uci-defaults/90-fips-setup
|
||||
chmod 0755 /etc/uci-defaults/90-fips-setup
|
||||
return 0
|
||||
}
|
||||
|
||||
remove_setup_stubs() {
|
||||
for cmd in uci logger modprobe sysctl; do
|
||||
rm -f "/usr/sbin/$cmd"
|
||||
done
|
||||
rm -rf "$STUB_BIN" /etc/fips
|
||||
return 0
|
||||
}
|
||||
|
||||
install_config() {
|
||||
# install_config <file>: installed mode 0644, so a mode check can fail.
|
||||
cp "$1" /etc/fips/fips.yaml
|
||||
chmod 0644 /etc/fips/fips.yaml
|
||||
return 0
|
||||
}
|
||||
|
||||
assert_migrated() {
|
||||
# assert_migrated <how the script ran>
|
||||
CONFIG=/etc/fips/fips.yaml
|
||||
assert_equals "$(gateway_dns_port)" "5365" "$1: the init script reads the default port"
|
||||
changes="$(diff -U0 "$RELEASED_YAML" /etc/fips/fips.yaml 2>/dev/null | grep -v '^---' | grep -v '^+++' | grep '^[-+]')"
|
||||
assert_equals "$changes" "$(printf '%s\n%s' "-$LEGACY_LISTEN" "+$SHIPPED_LISTEN")" \
|
||||
"$1: only the listen line changed, to the shipped form"
|
||||
assert_equals "$(stat -c %a /etc/fips/fips.yaml 2>/dev/null)" "600" "$1: the file is mode 0600"
|
||||
return 0
|
||||
}
|
||||
|
||||
run_setup() {
|
||||
# run_setup <output file>: executes the installed 90-fips-setup.
|
||||
sh /etc/uci-defaults/90-fips-setup > "$1" 2>&1
|
||||
return $?
|
||||
}
|
||||
|
||||
scenario_listen_migration() {
|
||||
note "scenario 14: 90-fips-setup migrates the shipped gateway listen line"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
. "$INIT_GATEWAY"
|
||||
saved_path="$PATH"
|
||||
PATH="$STUB_BIN:$PATH"
|
||||
|
||||
# 1. Executed by the ipk postinst on an upgrade.
|
||||
reset_state
|
||||
install_setup_stubs
|
||||
install_config "$RELEASED_YAML"
|
||||
touch "$UPGRADE_MARKER"
|
||||
PKG_UPGRADE=1 sh "$POSTINST" configure > "$WORK/postinst.out" 2>&1
|
||||
assert_migrated "ipk upgrade"
|
||||
if grep -qxF "$MIGRATED_MSG" "$WORK/postinst.out"; then
|
||||
ok "ipk upgrade: the migration is reported on stdout"
|
||||
else
|
||||
bad "ipk upgrade: no migration message in: $(tr '\n' ';' < "$WORK/postinst.out")"
|
||||
fi
|
||||
assert_absent /etc/uci-defaults/90-fips-setup "ipk upgrade: the postinst removed 90-fips-setup, so it exited 0"
|
||||
|
||||
# 2. Executed by the apk post-upgrade script.
|
||||
reset_state
|
||||
install_setup_stubs
|
||||
install_config "$RELEASED_YAML"
|
||||
touch "$UPGRADE_MARKER"
|
||||
if run_apk_script post-upgrade 0.6.0-r1 0.5.2-r1; then
|
||||
assert_migrated "apk upgrade"
|
||||
assert_absent /etc/uci-defaults/90-fips-setup "apk upgrade: post-upgrade removed 90-fips-setup, so it exited 0"
|
||||
fi
|
||||
|
||||
# 3. Sourced in a subshell, without the functions earlier scenarios define.
|
||||
reset_state
|
||||
install_setup_stubs
|
||||
install_config "$RELEASED_YAML"
|
||||
(
|
||||
unset -f logger modprobe sysctl sleep 2>/dev/null
|
||||
cd /etc/uci-defaults && . ./90-fips-setup
|
||||
) > "$WORK/sourced.out" 2>&1
|
||||
assert_equals "$?" "0" "sourced: the subshell exits 0"
|
||||
assert_migrated "sourced"
|
||||
|
||||
# 4. Anything but the exact shipped line is left byte-identical.
|
||||
for variant in other-port indent comment no-gateway; do
|
||||
reset_state
|
||||
install_setup_stubs
|
||||
input="$WORK/$variant.yaml"
|
||||
case "$variant" in
|
||||
other-port) sed 's|^ listen: "\[::1\]:5353"$| listen: "[::1]:5400"|' "$RELEASED_YAML" > "$input" ;;
|
||||
indent) sed 's|^ listen: "\[::1\]:5353"$| listen: "[::1]:5353"|' "$RELEASED_YAML" > "$input" ;;
|
||||
comment) sed 's|^ listen: "\[::1\]:5353"$| listen: "[::1]:5353" # kept|' "$RELEASED_YAML" > "$input" ;;
|
||||
no-gateway) printf 'node:\n identity:\n persistent: true\n\npeers: []\n' > "$input" ;;
|
||||
esac
|
||||
if [ "$variant" != "no-gateway" ] && cmp -s "$input" "$RELEASED_YAML"; then
|
||||
bad "negative case $variant: the fixture edit changed nothing, so the case would prove nothing"
|
||||
continue
|
||||
fi
|
||||
install_config "$input"
|
||||
run_setup "$WORK/$variant.out"
|
||||
if cmp -s "$input" /etc/fips/fips.yaml; then
|
||||
ok "negative case $variant: the file is left byte-identical"
|
||||
else
|
||||
bad "negative case $variant: the file was changed"
|
||||
fi
|
||||
done
|
||||
|
||||
# 5. No config file.
|
||||
reset_state
|
||||
install_setup_stubs
|
||||
rm -f /etc/fips/fips.yaml
|
||||
run_setup "$WORK/no-config.out"
|
||||
assert_equals "$?" "0" "no config: the script exits 0"
|
||||
assert_absent /etc/fips/fips.yaml "no config: no file is created"
|
||||
if grep -qF "$MIGRATED_MSG" "$WORK/no-config.out"; then
|
||||
bad "no config: a migration was reported"
|
||||
else
|
||||
ok "no config: no migration is reported"
|
||||
fi
|
||||
|
||||
# 6. A second run changes nothing.
|
||||
reset_state
|
||||
install_setup_stubs
|
||||
install_config "$RELEASED_YAML"
|
||||
run_setup "$WORK/first.out"
|
||||
cp /etc/fips/fips.yaml "$WORK/migrated.yaml"
|
||||
run_setup "$WORK/second.out"
|
||||
if cmp -s "$WORK/migrated.yaml" /etc/fips/fips.yaml; then
|
||||
ok "a second run leaves the migrated file byte-identical"
|
||||
else
|
||||
bad "a second run changed the migrated file"
|
||||
fi
|
||||
|
||||
# 7. Stale loopback gateway entries for 5353 are removed; others are kept.
|
||||
reset_state
|
||||
install_setup_stubs
|
||||
install_config "$SHIPPED_YAML"
|
||||
uci_seed "$DNSMASQ_OPT" "/fips/::1#5353" "/fips/127.0.0.1#5353" "/fips/192.168.1.5#53"
|
||||
run_setup "$WORK/entries.out"
|
||||
assert_equals "$(uci_sorted "$DNSMASQ_OPT")" \
|
||||
"$(sorted_words /fips/192.168.1.5#53 /fips/::1#5354)" \
|
||||
"the legacy 5353 entries are removed and the daemon's entry is added"
|
||||
|
||||
PATH="$saved_path"
|
||||
remove_setup_stubs
|
||||
return 0
|
||||
}
|
||||
|
||||
echo "OpenWrt maintainer-script scenarios (shell: $(readlink -f /proc/$$/exe 2>/dev/null || echo sh))"
|
||||
echo " postinst: $POSTINST"
|
||||
echo " prerm: $PRERM"
|
||||
@@ -449,6 +848,10 @@ scenario_start_service_guard
|
||||
scenario_apk_fresh_install
|
||||
scenario_apk_upgrade_enabled
|
||||
scenario_apk_upgrade_disabled
|
||||
scenario_dns_port_reader
|
||||
scenario_default_port_parity
|
||||
scenario_swap_cleanup
|
||||
scenario_listen_migration
|
||||
|
||||
echo ""
|
||||
if [ "$FAILURES" -eq 0 ]; then
|
||||
|
||||
Reference in New Issue
Block a user