Skip to content

Commit 5b9ea0c

Browse files
Ryanmello07claude
andcommitted
fix(daemon): tear the tunnel down when it amplifies traffic instead of carrying it
THE OWNER'S MACHINE SENT 3.38 Tb. A live tunnel ran for ~40 minutes at up to 1.34 Gbps OUT with 0 Kbps IN (322 Kb received in total) until he noticed, killed urnetworkd and took his internet back by hand. That is not a leak or a misroute — it is an amplifying loop. THE CAUSE IS IN THE LOG, one line above "tunnel up": [tun] egress split verified: unmarked -> urnet0, daemon (mark 0x55524e57) -> enp129s0 [tun] egress mark applied to 2 packet(s), 160 byte(s) TWO packets matched the daemon's own egress-exclusion rule in forty minutes. So R4 self-exclusion was not in force: the daemon's SDK traffic fell into the tun, the IoLoop read it back out, sent it again, and it was captured again. Each pass multiplies. The routing probe passed because it tests the ROUTING DECISION for a marked vs unmarked packet; nothing tested whether the daemon's actual sockets ever get marked, and they did not. Root cause of the match failure is still open and is the next piece of work. WHAT THIS COMMIT ADDS IS THE THING THAT SHOULD HAVE EXISTED FIRST: a guard that makes this class of bug survivable. On the reaper tick the daemon samples the tun's own kernel counters, and after three consecutive strikes of ">64 MiB out and <1 MiB back" it stops the tunnel, publishes kCodeTunnelStorm and says plainly that the connection was looping rather than carrying. Deliberately dumb and slow to fire: a genuinely upload-heavy session still draws ACKs, which move rx_bytes, so a backup or a large upload cannot trip it. Counters that cannot be read are treated as no evidence, never as a reason to tear a tunnel down. A VPN that can saturate a user's uplink for forty minutes without noticing is missing a safety valve, independently of whichever bug does the saturating. Verified: both builds clean, 86/86, jammy 2/2, tarball rebuilt. The guard has NOT been exercised against a real storm — doing that deliberately on the owner's machine is not a test I am willing to run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011zv2X6ZPH8h6uBudVCGvG7
1 parent bb87adb commit 5b9ea0c

3 files changed

Lines changed: 81 additions & 0 deletions

File tree

app/src/ControlProtocol.hpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,12 @@ inline constexpr const char* kCodeTunPermissionDenied = "tun_permission_denied";
696696
inline constexpr const char* kCodeTunBusy = "tun_busy";
697697
// open()/TUNSETIFF failed for some other reason (message carries strerror).
698698
inline constexpr const char* kCodeTunOpenFailed = "tun_open_failed";
699+
// The tunnel was torn down because it was AMPLIFYING: the tun's transmit
700+
// counter ran away while nothing came back, which is what an egress-exclusion
701+
// failure looks like from the outside (the daemon's own packets get captured
702+
// into the tunnel, re-sent, re-captured). Measured once on a real machine at
703+
// 1.34 Gbps out / 0 in, 3.38 Tb sent before a human noticed and killed it.
704+
inline constexpr const char* kCodeTunnelStorm = "tunnel_storm";
699705
// The device handed back an address/mtu/prefix that is not usable.
700706
inline constexpr const char* kCodeTunConfigInvalid = "tun_config_invalid";
701707
// `ip`, `nft` or `resolvectl` is not installed.

app/src/daemon/TunnelHost.cpp

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -957,10 +957,78 @@ void TunnelHost::MaintainFilterLocked() {
957957
ToString(filter_.state()));
958958
}
959959

960+
namespace {
961+
962+
// Read one of the tun's kernel byte counters. Absent/unreadable -> 0, which the
963+
// guard treats as "no evidence", never as a reason to tear a tunnel down.
964+
uint64_t ReadIfaceCounter(const std::string& iface, const char* which) {
965+
const std::string path = "/sys/class/net/" + iface + "/statistics/" + which;
966+
std::ifstream in(path);
967+
uint64_t v = 0;
968+
if (in >> v) return v;
969+
return 0;
970+
}
971+
972+
} // namespace
973+
974+
// A tunnel that transmits megabytes while receiving essentially nothing is not
975+
// carrying traffic — it is AMPLIFYING it. That is what an egress-exclusion
976+
// failure looks like from outside the SDK: the daemon's own packets fall into
977+
// the tun, are read back out, re-sent, and captured again. Measured once on a
978+
// real machine: 1.34 Gbps out, 0 in, 3.38 Tb sent before a human killed it.
979+
//
980+
// The guard is deliberately dumb and slow to fire — three consecutive strikes,
981+
// each needing a large TX delta AND a negligible RX delta — so a genuinely
982+
// upload-heavy session (a backup, a big send) cannot trip it: real uploads still
983+
// draw ACKs, which move rx_bytes.
984+
bool TunnelHost::CheckTunnelStormLocked() {
985+
if (!tunnel_) { stormStrikes_ = 0; stormLastTx_ = stormLastRx_ = 0; return false; }
986+
const std::string iface = tunnel_->name();
987+
if (iface.empty()) return false;
988+
989+
const uint64_t tx = ReadIfaceCounter(iface, "tx_bytes");
990+
const uint64_t rx = ReadIfaceCounter(iface, "rx_bytes");
991+
if (tx == 0 && rx == 0) return false; // counters unreadable: no evidence
992+
993+
const uint64_t dtx = tx > stormLastTx_ ? tx - stormLastTx_ : 0;
994+
const uint64_t drx = rx > stormLastRx_ ? rx - stormLastRx_ : 0;
995+
stormLastTx_ = tx;
996+
stormLastRx_ = rx;
997+
998+
// Per tick: >64 MiB out with <1 MiB back. At the reaper's cadence that is an
999+
// order of magnitude above any plausible real session.
1000+
constexpr uint64_t kStormTxDelta = 64ull * 1024 * 1024;
1001+
constexpr uint64_t kStormRxCeiling = 1ull * 1024 * 1024;
1002+
if (dtx >= kStormTxDelta && drx < kStormRxCeiling) {
1003+
++stormStrikes_;
1004+
std::fprintf(stderr,
1005+
"[tunnel] runaway: %llu MiB out and %llu KiB back since the last tick "
1006+
"(strike %d of 3)\n",
1007+
static_cast<unsigned long long>(dtx / (1024 * 1024)),
1008+
static_cast<unsigned long long>(drx / 1024), stormStrikes_);
1009+
} else {
1010+
stormStrikes_ = 0;
1011+
}
1012+
if (stormStrikes_ < 3) return false;
1013+
1014+
std::fprintf(stderr,
1015+
"[tunnel] STOPPING: the tunnel is amplifying traffic rather than carrying it. "
1016+
"This is what a failed egress exclusion looks like -- the daemon's own packets "
1017+
"are being captured into the tunnel. Tearing it down to protect the network.\n");
1018+
PublishError(
1019+
"The connection was stopped because it was sending traffic in a loop instead of "
1020+
"carrying it. This is a bug; please report it.",
1021+
ctl::kCodeTunnelStorm);
1022+
stormStrikes_ = 0;
1023+
StopInternalLocked("tunnel_storm");
1024+
return true;
1025+
}
1026+
9601027
void TunnelHost::Reap() {
9611028
{
9621029
std::scoped_lock lock(opMutex_);
9631030
ReapRetiredLoopsLocked();
1031+
if (CheckTunnelStormLocked()) return; // the tunnel is gone; nothing else to reap
9641032
}
9651033
if (busy_.load()) return; // a bring-up owns the session AND the filter
9661034

app/src/daemon/TunnelHost.hpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,13 @@ class TunnelHost {
167167
// Requires opMutex_.
168168
void StopInternalLocked(const std::string& reason);
169169
void ReapRetiredLoopsLocked();
170+
// RUNAWAY GUARD. Samples the tun's own byte counters and tears the tunnel
171+
// down if it is transmitting hard while receiving nothing — the signature of
172+
// a capture loop. Returns true when it stopped the tunnel.
173+
bool CheckTunnelStormLocked();
174+
uint64_t stormLastTx_ = 0;
175+
uint64_t stormLastRx_ = 0;
176+
int stormStrikes_ = 0;
170177

171178
// ---- the nftables floor: ONE decision site --------------------------------
172179
//

0 commit comments

Comments
 (0)