Skip to content

fix(ip): fail tnet ip -6 on a node with no IPv6 instead of exiting 0 - #394

Merged
GeiserX merged 2 commits into
mainfrom
factory/tsd-aud319-c581-r1-s1n0/fix-ip-family-filter-exit-status
Sep 7, 2026
Merged

fix(ip): fail tnet ip -6 on a node with no IPv6 instead of exiting 0#394
GeiserX merged 2 commits into
mainfrom
factory/tsd-aud319-c581-r1-s1n0/fix-ip-family-filter-exit-status

Conversation

@GeiserX

@GeiserX GeiserX commented Sep 7, 2026

Copy link
Copy Markdown
Owner

tnet ip -4 and tnet ip -6 asked for an address family the target does
not have, printed (no matching tailnet address) on stdout, and exited 0.
Upstream does the opposite: runIP's match loop sets a match flag, and
if !match returns errors.New("no Tailscale IPv4 address") for -4 and
"no Tailscale IPv6 address" for -6. Those are error returns, not
outln calls, and that is the point of them — the CLI turns an error into
a line on stderr and a non-zero exit, so a script can write
if tailscale ip -6 host; then .... Ours told such a script that an
IPv4-only node has an IPv6 address, on stdout, ready to be captured into a
variable.

Ported from cmd/tailscale/cli/ip.go at upstream v1.102.3,
53a0d659afa51835dd7a9283873cca44261454f8 (the same file and ref the
surrounding tnet ip code cites).

WHAT CHANGED

ip_no_match_error(sel) is Go's if !match tail on its own: -4 and -6
each name the family that came up empty, and neither flag yields None
because with both families wanted an empty answer means the target had no
address at all. format_ip_filtered and format_service_ips now return
Result<String, &'static str> and consult it before falling back to the
placeholder; run_ip prints an Ok and bail!s an Err, which reaches
the user as stderr text and exit 1.

One helper rather than two copies because Go has one: runIP resolves this
node, a peer, or a Service into a single ips slice and runs one match loop
over it. tnet ip -4 <IPv6-only-service-VIP> is the same question with the
same answer, so it takes the same path. Splitting it would have left two
arms of one Go loop disagreeing about what an unsatisfiable -4 means.

The placeholder is not gone. It still stands in for the unflagged empty
answer, which is a node holding no address at all.

WHAT IS STILL DIFFERENT (not fixed here, and not part of this change)

Go checks len(ips) == 0 BEFORE the match loop and returns
no current Tailscale IPs; state: %v. That message is built from
BackendState, which the ip wire response does not carry, so tnet ip
with no family flag on a node with no address still prints the placeholder
and exits 0 where Go exits 1. Fixing it means either putting the backend
state on that response or making tnet ip ask for status instead — a
protocol change, and a separate piece of work. A consequence worth naming:
with -4 or -6 set, that same no-address state now takes the new error
path, so it exits non-zero with the family message rather than Go's
state-bearing one. Right stream and right exit status, different words.

VERIFICATION

Four unit tests in src/bin/tnet.rs drive the production functions:
ip_family_filter_that_selects_nothing_is_an_error_not_a_line (the field
case, -6 on an IPv4-only node, and its -4 mirror), and the family
assertions inside format_ip_filtered_selects_family_and_first,
ip_prints_the_whole_matched_node_whichever_address_named_it (an
IPv6-only peer asked for -4) and
service_ips_honor_the_family_and_first_filters (the Service arm).
Stubbing ip_no_match_error back to None — the old behaviour — fails 5
of them; restored, cargo test --bin tnet is 302 passed, 0 failed.

Full gate, clean: cargo fmt --all --check, cargo clippy --all-targets -- -D warnings, cargo test --all-targets (all suites pass),
cargo build --release --bins.

STALE ELSEWHERE

Nothing in the parity ledger is edited here. What this makes stale: any
note describing tnet ip's family filter as answering with a placeholder,
and any claim that the #319 re-derivation of format_ip_filtered leaves
the !match branch diverging — it no longer does, except for the
len(ips) == 0 case described above.

READ FIRST

ip_no_match_error and the two out.is_empty() branches that call it.

DECISIONS

  • The error text is Go's, verbatim and unprefixed (no Tailscale IPv6 address), not re-spelled with tnet. The tnet ip refusal that already
    exists is re-spelled (tnet ip -1, -4, and -6 are mutually exclusive)
    because it names flags of this binary; this one names a fact about the
    tailnet and reads identically either way, so it stays as upstream wrote
    it and greps the same.
  • bail! rather than eprintln! + exit(1), which the peer-lookup miss a
    few lines up uses. Both give stderr and exit 1; bail! is what the ip
    usage refusal in the same function already does, and it keeps the
    formatter's error a value the caller decides about rather than a process
    exit buried in a formatter.
  • No end-to-end exit-status test. Reaching the branch through the built
    binary needs a daemon reporting one family and not the other, which the
    in-tree harness cannot produce without a real tailnet. The unit tests
    call the production functions and the caller is one match.

QUESTIONS

  • Should no current Tailscale IPs; state: <state> be ported too, by
    putting the backend state on the ip response? It is the last piece of
    runIP's tail that still exits 0 where Go exits 1. I would do it, but as
    its own change: it touches the wire DTO, which this one deliberately does
    not.

bead tsd-aud319 · session tsd-aud319-c581-r1-s1n0 · base refs/heads/main

Summary by CodeRabbit

  • Bug Fixes
    • The tnet ip command now exits with an error when an explicitly requested IPv4 or IPv6 address is unavailable.
    • Clear error messages identify whether no Tailscale IPv4 or IPv6 address was found.
    • Unfiltered requests with no matching address continue to display the existing placeholder.

`tnet ip -4` and `tnet ip -6` asked for an address family the target does
not have, printed `(no matching tailnet address)` on stdout, and exited 0.
Upstream does the opposite: `runIP`'s match loop sets a `match` flag, and
`if !match` returns `errors.New("no Tailscale IPv4 address")` for `-4` and
`"no Tailscale IPv6 address"` for `-6`. Those are error returns, not
`outln` calls, and that is the point of them — the CLI turns an error into
a line on stderr and a non-zero exit, so a script can write
`if tailscale ip -6 host; then ...`. Ours told such a script that an
IPv4-only node has an IPv6 address, on stdout, ready to be captured into a
variable.

Ported from `cmd/tailscale/cli/ip.go` at upstream v1.102.3,
`53a0d659afa51835dd7a9283873cca44261454f8` (the same file and ref the
surrounding `tnet ip` code cites).

WHAT CHANGED

`ip_no_match_error(sel)` is Go's `if !match` tail on its own: `-4` and `-6`
each name the family that came up empty, and neither flag yields `None`
because with both families wanted an empty answer means the target had no
address at all. `format_ip_filtered` and `format_service_ips` now return
`Result<String, &'static str>` and consult it before falling back to the
placeholder; `run_ip` prints an `Ok` and `bail!`s an `Err`, which reaches
the user as stderr text and exit 1.

One helper rather than two copies because Go has one: `runIP` resolves this
node, a peer, or a Service into a single `ips` slice and runs one match loop
over it. `tnet ip -4 <IPv6-only-service-VIP>` is the same question with the
same answer, so it takes the same path. Splitting it would have left two
arms of one Go loop disagreeing about what an unsatisfiable `-4` means.

The placeholder is not gone. It still stands in for the unflagged empty
answer, which is a node holding no address at all.

WHAT IS STILL DIFFERENT (not fixed here, and not part of this change)

Go checks `len(ips) == 0` BEFORE the match loop and returns
`no current Tailscale IPs; state: %v`. That message is built from
`BackendState`, which the `ip` wire response does not carry, so `tnet ip`
with no family flag on a node with no address still prints the placeholder
and exits 0 where Go exits 1. Fixing it means either putting the backend
state on that response or making `tnet ip` ask for `status` instead — a
protocol change, and a separate piece of work. A consequence worth naming:
with `-4` or `-6` set, that same no-address state now takes the new error
path, so it exits non-zero with the family message rather than Go's
state-bearing one. Right stream and right exit status, different words.

VERIFICATION

Four unit tests in `src/bin/tnet.rs` drive the production functions:
`ip_family_filter_that_selects_nothing_is_an_error_not_a_line` (the field
case, `-6` on an IPv4-only node, and its `-4` mirror), and the family
assertions inside `format_ip_filtered_selects_family_and_first`,
`ip_prints_the_whole_matched_node_whichever_address_named_it` (an
IPv6-only peer asked for `-4`) and
`service_ips_honor_the_family_and_first_filters` (the Service arm).
Stubbing `ip_no_match_error` back to `None` — the old behaviour — fails 5
of them; restored, `cargo test --bin tnet` is 302 passed, 0 failed.

Full gate, clean: `cargo fmt --all --check`, `cargo clippy --all-targets --
-D warnings`, `cargo test --all-targets` (all suites pass),
`cargo build --release --bins`.

STALE ELSEWHERE

Nothing in the parity ledger is edited here. What this makes stale: any
note describing `tnet ip`'s family filter as answering with a placeholder,
and any claim that the `#319` re-derivation of `format_ip_filtered` leaves
the `!match` branch diverging — it no longer does, except for the
`len(ips) == 0` case described above.

READ FIRST

`ip_no_match_error` and the two `out.is_empty()` branches that call it.

DECISIONS

- The error text is Go's, verbatim and unprefixed (`no Tailscale IPv6
  address`), not re-spelled with `tnet`. The `tnet ip` refusal that already
  exists is re-spelled (`tnet ip -1, -4, and -6 are mutually exclusive`)
  because it names flags of this binary; this one names a fact about the
  tailnet and reads identically either way, so it stays as upstream wrote
  it and greps the same.
- `bail!` rather than `eprintln!` + `exit(1)`, which the peer-lookup miss a
  few lines up uses. Both give stderr and exit 1; `bail!` is what the `ip`
  usage refusal in the same function already does, and it keeps the
  formatter's error a value the caller decides about rather than a process
  exit buried in a formatter.
- No end-to-end exit-status test. Reaching the branch through the built
  binary needs a daemon reporting one family and not the other, which the
  in-tree harness cannot produce without a real tailnet. The unit tests
  call the production functions and the caller is one `match`.

QUESTIONS

- Should `no current Tailscale IPs; state: <state>` be ported too, by
  putting the backend state on the `ip` response? It is the last piece of
  `runIP`'s tail that still exits 0 where Go exits 1. I would do it, but as
  its own change: it touches the wire DTO, which this one deliberately does
  not.
@GeiserX GeiserX added factory graph-opened The factory was seen to open this pull request. labels Sep 7, 2026
@coderabbitai

coderabbitai Bot commented Sep 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: bf38e54f-98c8-4944-b50d-c73653446270

📥 Commits

Reviewing files that changed from the base of the PR and between db857c5 and 98f182f.

📒 Files selected for processing (1)
  • src/bin/tnet.rs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The IP formatting functions now return errors for unsatisfied -4 or -6 selections. The tnet ip command propagates these errors and exits non-zero. Unfiltered empty results still print the existing placeholder.

Changes

IP family selection errors

Layer / File(s) Summary
Formatting error contract
src/bin/tnet.rs
format_ip_filtered and format_service_ips return family-specific errors when filtering selects no address. Unfiltered empty results retain the placeholder. Tests cover node, peer, self-node, and service cases.
Command error propagation
src/bin/tnet.rs
The tnet ip command prints successful output and propagates formatting errors through anyhow::bail!.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 1e98a

The command now reports an unavailable requested IP family on stderr with a non-zero exit while preserving the unfiltered empty-result placeholder. Covered node, peer, and Service cases support merge readiness.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary behavior change: tnet ip -6 now fails when no IPv6 address is available. It is specific and directly related to the pull request.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch factory/tsd-aud319-c581-r1-s1n0/fix-ip-family-filter-exit-status

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@GeiserX GeiserX added the graph-rebased The settle lane was seen to update this branch onto moved main (phase B). label Sep 7, 2026
@GeiserX
GeiserX merged commit ec4a7cb into main Sep 7, 2026
6 checks passed
@GeiserX
GeiserX deleted the factory/tsd-aud319-c581-r1-s1n0/fix-ip-family-filter-exit-status branch September 7, 2026 12:00
@GeiserX GeiserX added graph-merged The settle lane was seen to squash-merge this pull request. graph-done FINAL: the bead was seen to close. Stamped on every pull request of that session. labels Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

factory graph-done FINAL: the bead was seen to close. Stamped on every pull request of that session. graph-merged The settle lane was seen to squash-merge this pull request. graph-opened The factory was seen to open this pull request. graph-rebased The settle lane was seen to update this branch onto moved main (phase B).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant