geiserx_tailscale 0.56.2

A work-in-progress pure-Rust Tailscale implementation (fork of tailscale/tailscale-rs)
Documentation
{
  "schema": "parity-audit/v1",
  "auditedPrs": [
    {
      "url": "https://github.com/GeiserX/tailscale-rs/pull/436",
      "verdict": "faithful",
      "notes": "ts_packetfilter::permits_unlocked_nodes matches packetFilterPermitsUnlockedNodes clause for clause: the union is built from the (already route-clamped) AllowedIPs, the comparison is prefix overlap rather than containment, Match.Srcs alone participates so a node-capability source is ignored, and the len(m.Dsts) != 0 test is kept. Go's numUnlocked == 0 early return and the Rust empty-union early return reach the same verdict, including for an unlocked peer with no AllowedIPs. The deny-all-but-retain shape matches Go's packetFilter = nil (which keeps cn.PacketFilter() and declines to install it), and the check runs over the whole ruleset map including the DEFAULT_RULESET_NAME entry that apply_update writes the base filter into, so the main ACL is in scope. The only upstream side effect not reproduced is the invalidPacketFilterWarnable health signal, and this tree has no health-warnable surface at all to carry it, so there is nothing to diverge from; not filed as a finding. Go's tryHandleIncrementally-order and knob timing are not implicated here."
    },
    {
      "url": "https://github.com/GeiserX/tailscale-rs/pull/438",
      "verdict": "gaps",
      "notes": "The collapse itself is right: 10000 as the constant, a strict > comparison, single-IP-in-CGNAT as the collapsible class (Go's classify), and the /10 replacing the /32s it covers. The enable-wins tie-break the commit body records as reasoned rather than transcribed does match upstream (controlknobs.UpdateFromNodeAttributes checks OneCGNATEnable first, then OneCGNATDisable in an else-if), so that recorded QUESTION can be closed. Two divergences in how the tri-state is turned into a threshold: one-cgnat?v=false is modelled as an unreachable threshold where upstream keeps the 10000 ceiling, and the no-attribute case ignores upstream's platform/interface gate entirely. See findings."
    },
    {
      "url": "https://github.com/GeiserX/tailscale-rs/pull/442",
      "verdict": "gaps",
      "notes": "The attribute name and the refusal's shape are both right. disable-delta-updates is the literal nodecap string, and the commit's central claim — that the attribute neither rejects the response nor drops the patches, it declines the incremental path so the full path handles the same response — is exactly Go's structure: a false return from tryHandleIncrementally falls through to ms.netmap() plus UpdateFullNetmap. The same-response timing claim also checks out: UpdateFromNodeAttributes runs at map.go:268, before the tryHandleIncrementally call at map.go:284, so an attribute control grants does take effect on the response that granted it. But the port is narrower than the gate it copies (it covers only PeersChangedPatch), and the full arm drops upstream's tailnet-lock re-filter. See findings."
    }
  ],
  "gate": {
    "ran": true,
    "pass": false,
    "detail": "Scoped gate on main @ 98881e3 with CARGO_BUILD_JOBS=4 / RUST_TEST_THREADS=4. Green: clippy --workspace --exclude ts_cli_util --lib --all-features -D warnings; clippy over bins/tests/benches/examples with -D warnings -A missing_docs; cargo build --workspace --exclude ts_cli_util --all-targets --all-features (Finished dev profile in 14m 21s, zero errors). nextest -p geiserx_ts_control -p geiserx_ts_runtime --all-features ran 942 tests: 939 passed, 1 failed, 2 never returned. The failure is ts_runtime::direct::tests::probe_stun_servers_once_sends_binding_request (opens a real UDP socket to a STUN server; this uid's egress is dropped) and the two that sit are ts_runtime::serve::tests::serve_path_proxy_replays_consumed_head_to_backend and proxy_with_prefix_writes_prefix_before_bidi_copy (loopback SYN dropped rather than refused, so they hang instead of failing); nextest was killed once only those two remained. All three are documented sandbox-environment failures on this box rather than tree failures, so pass is recorded false on the literal outcome while no code-attributable failure was observed. Not run: cargo deny check all (standing red on any branch here, on banned aws-lc-rs/aws-lc-sys and an unsatisfiable licence requirement) and anything needing ts_cli_util under --all-features (its tracy feature does not compile against the locked tracy-client-sys 0.28.0)."
  },
  "findings": [
    {
      "prUrl": "https://github.com/GeiserX/tailscale-rs/pull/438",
      "title": "one-cgnat?v=false disables the CGNAT collapse outright where upstream keeps the 10000 ceiling",
      "detail": "Upstream carries the decision into the route manager as routemanager.TailnetConfig.OneCGNAT, a plain bool, and RouteManager.cgnatThreshold() returns 1 when it is set and the cgnatThreshold constant (10_000) otherwise. So the disabling attribute only declines the forced collapse; the ceiling still applies, and wantCoarse := len(rm.cgnatPfxs) > rm.cgnatThreshold() still installs the single 100.64.0.0/10 once more than 10000 distinct CGNAT peer routes exist. The merged cgnat_threshold maps Some(false) to usize::MAX (commented 'unreachable so the fold never fires'), so with one-cgnat?v=false set the collapse can never happen at any peer count: a tailnet above the threshold programs one /32 per peer here where a Go client programs a single /10. The merged test host_routes_collapse_above_the_threshold_unless_control_forbids_it pins the divergent half, and the commit body states the inverted rule as fact ('?v=false forces /32-per-peer even above the threshold, and neither attribute leaves the threshold to decide'). This is the unbounded host route table the change set out to bound, still reachable on control's say-so.",
      "upstream": "net/routemanager/routemanager.go @ 023255e8a27ec9f6a21d24e3eda21c052ff72af3",
      "severity": "major"
    },
    {
      "prUrl": "https://github.com/GeiserX/tailscale-rs/pull/438",
      "title": "the no-attribute case skips upstream's platform and CGNAT-interface gate",
      "detail": "With neither one-cgnat attribute present, upstream does not fall through to the 10000 threshold. shouldUseOneCGNATRoute consults the knob first (an explicit true or false is terminal), then returns true for versionOS == \"plan9\", then for versionOS == \"macOS\" or \"android\" probes netmon.HasCGNATInterface() and returns true when no other interface uses the CGNAT range — returning false if that probe errors — and only otherwise false. A true result becomes TailnetConfig.OneCGNAT, i.e. a threshold of 1, so the /10 replaces the per-peer /32s from the second CGNAT route onward. The merged Node::one_cgnat() returns None whenever the attribute is absent and cgnat_threshold(None) is unconditionally CGNAT_THRESHOLD, with no platform or interface input anywhere on the path; the commit body does not mention the gate. On macOS — a platform this workspace builds and tests on — this tree therefore keeps one /32 per peer at every peer count below 10000 where a Go client installs the single /10 and stops reconfiguring the host route set per peer.",
      "upstream": "ipn/ipnlocal/local.go @ 023255e8a27ec9f6a21d24e3eda21c052ff72af3",
      "severity": "major"
    },
    {
      "prUrl": "https://github.com/GeiserX/tailscale-rs/pull/442",
      "title": "the escape hatch diverts only PeersChangedPatch; PeersChanged/PeersRemoved deltas still ride the incremental path",
      "detail": "Upstream's gate is the first statement of tryHandleIncrementally, and a false return there is not scoped to patches: handleNonKeepAliveMapResponse falls through to ms.netmap() and netmapUpdater.UpdateFullNetmap(nm) for the entire response, whatever it carried, which is what the attribute's own documentation asks for ('treat all netmap changes as \"full\" ones as tailscaled did in 1.48.x and earlier'). In the merged tree the check lives in PeerTracker::apply_peer_patch_set, which the netmap handler reaches only from inside `if !msg.peer_patches.is_empty()`. PeerTracker::apply_peer_update — which applies PeerUpdate::Delta, built in ts_control::tokio::map_stream from MapResponse.PeersChanged and PeersRemoved, this tree's other delta mechanism — runs unconditionally just above it and is untouched by the attribute. So a response carrying only PeersChanged/PeersRemoved never takes the full arm even with the attribute set, and one carrying both applies the delta incrementally before the patches are rebuilt as a full update. Control cannot actually take this node off the delta netmap path, which is the escape hatch's entire purpose.",
      "upstream": "control/controlclient/map.go @ 023255e8a27ec9f6a21d24e3eda21c052ff72af3",
      "severity": "major"
    },
    {
      "prUrl": "https://github.com/GeiserX/tailscale-rs/pull/442",
      "title": "the full arm omits the tailnet-lock re-filter upstream's full netmap path runs",
      "detail": "Upstream reaches the full netmap path through tkaFilterNetmapLocked(st.NetMap) (guarded only by envknob.TKASkipSignatureCheck) immediately before setNetMapLocked, so declining the delta path also re-verifies every peer's node-key signature against the lock; the delta path gets the narrower tkaFilterDeltaMutsLocked instead, and upstream's comment says that exists precisely to match 'the full-netmap behavior of tkaFilterNetmapLocked'. rebuild_netmap_with_patches re-installs every retained peer through upsert_from_control without re-running the lock gate, so the only trust check on this arm is the per-patched-node one inside apply_peer_patches. A peer that was admitted earlier and whose signature has since stopped verifying is evicted by upstream on this path and retained here. The commit's DECISIONS section discloses the omission and gives a defensible reason (this tree rewrites an expiry-flagged peer's node key via ts_keys::node_public_with_bad_old_prefix, so a whole-db re-filter would evict it and lose the diagnostic), which is why this is filed as minor rather than as a security regression — but it is still a behaviour divergence on the arm the change introduced, and the reason argues for a narrower gate rather than for none.",
      "upstream": "ipn/ipnlocal/local.go @ 023255e8a27ec9f6a21d24e3eda21c052ff72af3",
      "severity": "minor"
    }
  ]
}