Skip to content

Add -4/-6 address-family preference - #68

Merged
l1a merged 1 commit into
mainfrom
feature/prefer-address-family
Aug 16, 2026
Merged

l1a merged 1 commit into
mainfrom
feature/prefer-address-family

Conversation

@l1a

@l1a l1a commented Aug 16, 2026

Copy link
Copy Markdown
Owner

What

etr and etrs both gain -4/--prefer-ipv4 and -6/--prefer-ipv6, plus an
address_family = "ipv4" | "ipv6" | "auto" config key in [client] and [server].

Until now the IP family was whatever the resolver returned first, and there was no way to
influence it — awkward on a dual-stack host where one family is slow, filtered, or simply
the one you are trying to test.

Why "prefer" rather than "force"

ssh -4/-6 forbid the other family. These order it: if the host has no address of the
requested family, or none the kernel can route to, the other family is used and the
fallback is reported at -v. Nothing here can turn a working connection into a failure.

That has one non-obvious consequence. The client cannot simply hand -6 to ssh, because
ssh would hard-fail on an IPv4-only host while etr itself would have fallen back — one
session, two contracts. So the client resolves the target first and passes ssh the flag
only when the host really has such an address; a name that does not resolve locally
(an ssh_config Host alias) gets no flag, leaving ssh's own resolution untouched.

Scope

Every place an address is chosen:

Where Resolved by How the preference gets there
SSH bootstrap client ssh -4/-6, gated on availability
QUIC session client ordered candidates + routing probe
-R targets client AddrPref threaded into run_session
-L targets server new ETRPREFER:<4|6> bootstrap line

Forwarded TCP needed forward::connect_tcp_preferred to be included at all:
TcpStream::connect("host:port") walks every resolved address but in resolver order, so
the flag would have been honoured for QUIC and UDP and silently ignored for TCP.

ETRPREFER: uses the same forward-compatible mechanism as ETRCMD:/ETRX11: — an old
server ignores it, an old client never sends it. PROTOCOL.md §2 now documents it, and
ETRX11: which it had never listed, plus the compatibility rule that makes both safe.

Behaviour without a flag

AddrPref::Auto means what this call site did before, not one global default — the two
call sites genuinely differed (the QUIC path took the resolver's first answer;
resolve_udp_target has preferred IPv6 since v0.4.x, with a regression test asserting it).
Each caller supplies its own fallback, so an unflagged run behaves as in v0.7.9.

One deliberate exception: the QUIC peer address is now picked with the same no-packet
routing probe the UDP path always used, so a host whose AAAA comes back first, on a machine
with no IPv6 route, falls through to the A record instead of failing the connect. If
nothing is routable the first candidate is returned anyway, so the user still gets a real
connection error rather than "could not resolve".

etrs -4 also flips the default bind to 0.0.0.0, because a single wildcard bind cannot
express "prefer IPv4". An explicit -b always wins, and a client's preference
deliberately does not touch the server's bind — [::] already serves IPv4.

Tests

112 → 145. Bootstrap-line handling and the bind default were extracted into
parse_bootstrap_line and effective_bind_ip so the tests call the shipped code: the
existing ETRCMD/ETRX11 tests re-implemented the parse loop inside the test, which would
pass just as happily over a broken real loop.

New just e2e-family-local — a CLI test can only prove the flags parse; this asserts they
change the family actually connected over.

Test plan

just check && cargo test          # 145 tests
just e2e-family-local             # Parts 1–2 pass; Part 3 skips unless ssh 127.0.0.1 works
just e2e-local && just e2e-forward-local && just e2e-reverse-local
just e2e-cmd-local && just e2e-env-local && just e2e-udp-concurrent

Measured on Fedora 44 (all of the above green):

Check Result
unflagged etr -vv localhost [::1] — so -4 is the discriminating case here
etr -4 / etr -6 127.0.0.1 / [::1]
[client] address_family = "ipv4" via XDG_CONFIG_HOME 127.0.0.1
etr -4/-6 -L …/udp → server log UDP forward → 127.0.0.1:… / [::1]:…

Reviewer-runnable equivalent of the last row:

etr -vv -4 -L 15353:localhost:15354/udp localhost 'sleep 12' &
python3 -c "import socket;socket.socket(socket.AF_INET,socket.SOCK_DGRAM).sendto(b'x',('127.0.0.1',15353))"
grep 'UDP forward' ~/.local/state/etr/etrs.log      # 127.0.0.1 with -4, [::1] with -6

Docs

Man pages (both), README (new "IPv4 / IPv6" section), PROTOCOL.md §2, NOTES.md, and
five wiki pages pushed before this PR per AGENTS.md §4.11 (854ec79..4693aa6).

Found but not fixed

man/etrs.1.md's BUGS section still claims the reconnect window "is not configurable" — it
has been since v0.4.6, and --reconnect-timeout is missing from that page's OPTIONS.
Recorded in NOTES.md; it needs its own PR rather than being widened into this one.

🤖 Generated with Claude Code

Until now the IP family was whatever the resolver returned first, with no way
to influence it. `etr` and `etrs` both gain -4/--prefer-ipv4 and
-6/--prefer-ipv6, plus an `address_family` config key in each section.

They are a preference, not a restriction, which is the whole design: if the
host has no address of the requested family, or none the kernel can route to,
the other family is used and the fallback is reported at -v. That is why the
client cannot simply forward the flag to ssh, whose -4/-6 forbid the other
family outright -- it resolves the target first and passes ssh the flag only
when such an address exists, leaving unresolvable ssh_config aliases alone.

The preference covers every place an address is chosen: the SSH bootstrap, the
QUIC session, -R targets (resolved on the client) and -L targets (resolved on
the server, reached via a new ETRPREFER: bootstrap line that older servers
ignore). Forwarded TCP needed connect_tcp_preferred to be included at all --
TcpStream::connect walks every address but in resolver order.

AddrPref::Auto means "what this call site did before", not one global default:
the QUIC path took the resolver's first answer while resolve_udp_target has
preferred IPv6 since v0.4.x, so each caller supplies its own fallback and an
unflagged run is unchanged. One deliberate exception: the QUIC peer address now
uses the same no-packet routing probe the UDP path always had, so an AAAA-first
host on a machine without an IPv6 route falls through to the A record instead
of failing the connect.

etrs -4 also flips the default bind to 0.0.0.0, since a single wildcard bind
cannot express "prefer IPv4"; an explicit -b still wins, and a client's
preference deliberately does not touch the server's bind because [::] already
serves IPv4.

Bootstrap-line handling and the bind default moved into parse_bootstrap_line
and effective_bind_ip so the tests exercise the shipped code -- the existing
ETRCMD/ETRX11 tests re-implemented the parse loop inside the test.

Tests 112 -> 145, new `just e2e-family-local` asserting the flags change the
family actually connected over. Version 0.7.9 -> 0.8.0.

Assisted-By: Claude Opus 5
@l1a
l1a merged commit a145537 into main Aug 16, 2026
22 checks passed
@l1a
l1a deleted the feature/prefer-address-family branch August 16, 2026 04:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant