OverviewFilesCommitsIssuesDocsReleasesPeers

hyperswarm-peer / src / holepuncher.cpp

484 lines · cpp ref:
#include "holepuncher.h"

#include "debug_log.h"
#include <cstdlib>

// Single-byte holepunch probe payload (matches JS).
static const uint8_t HOLEPUNCH_PROBE[] = { 0x00 };

Holepuncher::Holepuncher()
	: _socket(nullptr), _local_firewall(0), _stage(HP_IDLE),
	  _punching(false), _connected(false), _started(false),
	  _start_time(0), _last_probe_time(0), _probe_round(0),
	  _birthday_count(0), _birthday_active(false),
	  _birthday_probe_index(0), _birthday_iteration(0),
	  _birthday_low_ttl_round(true), _winning_socket(nullptr),
	  _random_iteration(0) {
	memset(&_remote, 0, sizeof(_remote));
	memset(_birthday_sockets, 0, sizeof(_birthday_sockets));
}

Holepuncher::~Holepuncher() {
	if (_birthday_active) {
		_cleanup_birthday_sockets();
	}
	if (_winning_socket) {
		udx_socket_recv_stop(_winning_socket);
		udx_socket_close(_winning_socket);
		_winning_socket = nullptr;
	}
}

void Holepuncher::init(udx_socket_t *socket, uint32_t local_firewall) {
	_socket = socket;
	_local_firewall = local_firewall;
}

void Holepuncher::update_remote(const RemotePeerInfo &info) {
	_remote = info;
}

void Holepuncher::start() {
	if (_started) return;
	_started = true;
	_punching = true;
	_connected = false;
	_probe_round = 0;
	_last_probe_time = 0;
	_random_iteration = 0;
	_birthday_iteration = 0;
	_birthday_probe_index = 0;
	_birthday_low_ttl_round = true;

	// If both firewalls are already known, decide strategy immediately.
	if (_local_firewall != FIREWALL_UNKNOWN && _remote.firewall != FIREWALL_UNKNOWN) {
		_decide_strategy();
	} else {
		_stage = HP_ANALYZE;
	}
}

void Holepuncher::stop() {
	bool was_active = _punching || _stage == HP_ANALYZE;
	if (!was_active) return;
	_punching = false;
	_stage = HP_FAILED;
	if (_birthday_active) _cleanup_birthday_sockets();
	if (!_connected && on_abort) {
		on_abort();
	}
}

void Holepuncher::reset() {
	// Full state reset for slot reuse. stop() only clears _punching/_stage,
	// leaving _started/_connected stale — which causes tick() to immediately
	// report is_connected()=true on reused slots.
	if (_birthday_active) _cleanup_birthday_sockets();
	if (_winning_socket) {
		udx_socket_recv_stop(_winning_socket);
		udx_socket_close(_winning_socket);
		_winning_socket = nullptr;
	}
	_socket = nullptr;
	_local_firewall = FIREWALL_UNKNOWN;
	_stage = HP_IDLE;
	_punching = false;
	_connected = false;
	_started = false;
	_start_time = 0;
	_last_probe_time = 0;
	_probe_round = 0;
	_birthday_count = 0;
	_birthday_active = false;
	_birthday_probe_index = 0;
	_birthday_iteration = 0;
	_birthday_low_ttl_round = true;
	_random_iteration = 0;
	memset(&_remote, 0, sizeof(_remote));
	on_connect = nullptr;
	on_abort = nullptr;
}

// ---- Strategy decision ----
// Matches JS holepuncher.js _punch() method decision tree.

void Holepuncher::_decide_strategy() {
	uint32_t local = _local_firewall;
	uint32_t remote = _remote.firewall;

	if (local >= FIREWALL_RANDOM && remote >= FIREWALL_RANDOM) {
		// Both random — impossible to hole-punch.
		_stage = HP_FAILED;
		_punching = false;
		if (on_abort) on_abort();
		return;
	}

	if (local <= FIREWALL_CONSISTENT && remote <= FIREWALL_CONSISTENT) {
		// Both open/consistent — direct probing.
		_stage = HP_PUNCH_CONSISTENT;
	} else if (local <= FIREWALL_CONSISTENT && remote >= FIREWALL_RANDOM) {
		// Local consistent, remote random — guess random ports.
		_stage = HP_PUNCH_RANDOM;
	} else {
		// Local random, remote consistent — birthday attack.
		_stage = HP_PUNCH_BIRTHDAY;
		_start_birthday_attack();
	}
}

// ---- Tick ----

bool Holepuncher::tick(uint64_t now_ms) {
	if (!_started) return false;
	if (_connected) return false;

	if (_start_time == 0) _start_time = now_ms;

	switch (_stage) {
	case HP_ANALYZE:
		if (_local_firewall != FIREWALL_UNKNOWN && _remote.firewall != FIREWALL_UNKNOWN) {
			_decide_strategy();
		} else if (now_ms - _start_time > HP_ANALYZE_TIMEOUT_MS) {
			// Timeout — fall back to consistent probing.
			_stage = HP_PUNCH_CONSISTENT;
		}
		break;

	case HP_PUNCH_CONSISTENT:
		_consistent_probe(now_ms);
		break;

	case HP_PUNCH_RANDOM:
		_random_probe_tick(now_ms);
		break;

	case HP_PUNCH_BIRTHDAY:
		_birthday_probe_tick(now_ms);
		break;

	case HP_CONNECTED:
	case HP_FAILED:
	case HP_IDLE:
		return false;
	}

	return _punching;
}

// ---- Consistent probing (both OPEN/CONSISTENT) ----
// Matches JS _consistentProbe(): 10 rounds × 1s = ~10 seconds.

void Holepuncher::_consistent_probe(uint64_t now_ms) {
	if (now_ms - _last_probe_time < (uint64_t)HP_CONSISTENT_PROBE_INTERVAL_MS) return;
	_last_probe_time = now_ms;

	if (_probe_round >= HP_CONSISTENT_PROBE_MAX_ROUNDS) {
		_punching = false;
		_stage = HP_FAILED;
		if (on_abort) on_abort();
		return;
	}

	// Send probes to all known remote addresses.
	for (int i = 0; i < _remote.address_count; i++) {
		// Prioritize verified address; probe unverified every 4th round.
		bool is_verified = _remote.has_verified &&
			memcmp(_remote.addresses[i].ip, _remote.verified_address.ip, 4) == 0 &&
			_remote.addresses[i].port == _remote.verified_address.port;
		if (!is_verified && (_probe_round & 3) != 0) continue;

		send_probe(_remote.addresses[i], HP_TTL_NORMAL);
	}

	if (_remote.has_verified) {
		send_probe(_remote.verified_address, HP_TTL_NORMAL);
	}

	_probe_round++;
}

// ---- Random port probing (local CONSISTENT → remote RANDOM) ----
// Matches JS _randomProbes(): 1750 iterations × 20ms = ~35 seconds.
// Sends probes to random ports on the remote's known IP.

void Holepuncher::_random_probe_tick(uint64_t now_ms) {
	if (now_ms - _last_probe_time < (uint64_t)HP_RANDOM_PROBE_INTERVAL_MS) return;
	_last_probe_time = now_ms;

	if (_random_iteration >= HP_RANDOM_PROBE_MAX_ITERATIONS) {
		_punching = false;
		_stage = HP_FAILED;
		if (on_abort) on_abort();
		return;
	}

	// Generate random port in [1000, 65535] (matches JS randomPort()).
	const IPv4Address &target = _remote_target();
	IPv4Address random_addr;
	memcpy(random_addr.ip, target.ip, 4);
	random_addr.port = static_cast<uint16_t>(1000 + (rand() % 64536));

	send_probe(random_addr, HP_TTL_NORMAL);
	_random_iteration++;
}

// ---- Birthday attack probing (local RANDOM → remote CONSISTENT) ----
// Cycles through 256 birthday sockets at 20ms intervals.
// First full cycle uses low TTL (5), subsequent cycles use normal TTL (64).
// Matches JS _openBirthdaySockets() + _keepAliveRandomNat().

void Holepuncher::_birthday_probe_tick(uint64_t now_ms) {
	if (!_birthday_active || _birthday_count == 0) return;
	if (now_ms - _last_probe_time < (uint64_t)HP_RANDOM_PROBE_INTERVAL_MS) return;
	_last_probe_time = now_ms;

	if (_birthday_iteration >= HP_RANDOM_PROBE_MAX_ITERATIONS) {
		_punching = false;
		_stage = HP_FAILED;
		_cleanup_birthday_sockets();
		if (on_abort) on_abort();
		return;
	}

	// Cycle through birthday sockets.
	if (_birthday_probe_index >= _birthday_count) {
		_birthday_probe_index = 0;
		_birthday_low_ttl_round = false; // After first full round, use normal TTL.
	}

	udx_socket_t *sock = _birthday_sockets[_birthday_probe_index];
	if (sock) {
		int ttl = _birthday_low_ttl_round ? HP_TTL_LOW : HP_TTL_NORMAL;
		_send_probe_from(sock, _remote_target(), ttl);
	}

	_birthday_probe_index++;
	_birthday_iteration++;
}

// ---- Birthday socket allocation ----
// Creates 256 sockets bound to random ports, starts recv on each,
// and sends an initial low-TTL probe from each.

void Holepuncher::_start_birthday_attack() {
	if (!_socket || _birthday_active) return;

	udx_t *udx = _socket->udx;
	if (!udx) return;

	const IPv4Address &target = _remote_target();
	if (target.port == 0) return;

	struct sockaddr_in bind_addr;
	memset(&bind_addr, 0, sizeof(bind_addr));
	bind_addr.sin_family = AF_INET;
	bind_addr.sin_port = 0; // OS assigns random port.
	bind_addr.sin_addr.s_addr = INADDR_ANY;

	_birthday_count = 0;

	for (int i = 0; i < HP_BIRTHDAY_SOCKET_COUNT; i++) {
		udx_socket_t *sock = new udx_socket_t();
		memset(sock, 0, sizeof(udx_socket_t));

		int r = udx_socket_init(udx, sock, _on_birthday_close);
		if (r != 0) {
			delete sock;
			break;
		}

		r = udx_socket_bind(sock, (struct sockaddr *)&bind_addr, 0);
		if (r != 0) {
			udx_socket_close(sock);
			break;
		}

		// ⛔ `sock->data`, NOT `sock->uv_udp.data`.
		//
		// `uv_udp.data` belongs to libudx: udx_socket_init sets it to the
		// udx_socket_t itself (udx.c:1960) and on_udx_socket_handle_close casts
		// it back and writes through it (`socket->nrefs--`). Storing a
		// Holepuncher* there made that callback decrement a field inside THIS
		// object — silent heap corruption. Nulling it in cleanup to "prevent a
		// callback crash" only traded that for a NULL deref, which is the SEGV
		// test_phase6 has been dying on. `udx_socket_t::data` (udx.h:200) is the
		// user slot; libudx never reads it.
		sock->data = this;
		udx_socket_recv_start(sock, _on_birthday_recv);

		_birthday_sockets[_birthday_count++] = sock;

		// Send initial low-TTL probe from this socket.
		_send_probe_from(sock, target, HP_TTL_LOW);
	}

	_birthday_active = (_birthday_count > 0);
	_birthday_probe_index = 0;
	_birthday_iteration = 0;
	_birthday_low_ttl_round = true;

	DHT_LOG("[Holepuncher] Birthday attack: opened %d sockets\n", _birthday_count);
}

// ---- Birthday socket cleanup ----
// Closes all birthday sockets except the winner.

void Holepuncher::_cleanup_birthday_sockets() {
	for (int i = 0; i < _birthday_count; i++) {
		if (_birthday_sockets[i] && _birthday_sockets[i] != _winning_socket) {
			// Detach us from the socket so a recv still in flight bails out.
			// The socket's own libudx back-pointer is left alone — it is what
			// the close callback needs to run.
			_birthday_sockets[i]->data = nullptr;
			udx_socket_recv_stop(_birthday_sockets[i]);
			udx_socket_close(_birthday_sockets[i]);
		}
		_birthday_sockets[i] = nullptr;
	}
	_birthday_count = 0;
	_birthday_active = false;
}

// ---- Birthday socket callbacks ----

void Holepuncher::_on_birthday_recv(udx_socket_t *socket, ssize_t read_len,
									const uv_buf_t * /* buf */, const struct sockaddr *from) {
	if (!socket->data) return; // Holepuncher gone.
	if (read_len != 1) return; // Not a 1-byte holepunch probe.

	Holepuncher *self = (Holepuncher *)socket->data;

	const struct sockaddr_in *sin = (const struct sockaddr_in *)from;
	IPv4Address addr;
	memcpy(addr.ip, &sin->sin_addr.s_addr, 4);
	addr.port = ntohs(sin->sin_port);

	self->_on_birthday_probe(socket, addr);
}

void Holepuncher::_on_birthday_close(udx_socket_t *socket) {
	delete socket;
}

void Holepuncher::_on_birthday_probe(udx_socket_t *winner, const IPv4Address &from) {
	if (_connected) return;

	// Validate source IP matches the remote peer.
	if (!_is_remote_ip(from)) {
		DHT_LOG("[Holepuncher] IGNORING birthday probe from non-remote IP %d.%d.%d.%d:%d\n",
				from.ip[0], from.ip[1], from.ip[2], from.ip[3], from.port);
		return;
	}

	_winning_socket = winner;
	_connected = true;
	_punching = false;
	_stage = HP_CONNECTED;

	// Close all other birthday sockets, keeping the winner.
	_cleanup_birthday_sockets();

	if (on_connect) on_connect(from);
}

// ---- Send probe ----

void Holepuncher::send_probe(const IPv4Address &to, int ttl) {
	_send_probe_from(_socket, to, ttl);
}

void Holepuncher::_send_probe_from(udx_socket_t *sock, const IPv4Address &to, int ttl) {
	if (!sock) return;

	struct sockaddr_in addr;
	memset(&addr, 0, sizeof(addr));
	addr.sin_family = AF_INET;
	addr.sin_port = htons(to.port);
	memcpy(&addr.sin_addr.s_addr, to.ip, 4);

	uv_buf_t buf = uv_buf_init((char *)HOLEPUNCH_PROBE, 1);
	udx_socket_send_t *req = (udx_socket_send_t *)malloc(sizeof(udx_socket_send_t));
	udx_socket_send_ttl(req, sock, &buf, 1,
						(const struct sockaddr *)&addr, ttl,
						[](udx_socket_send_t *r, int /* status */) { free(r); });
}

// ---- Probe received (main socket or external dispatch) ----

void Holepuncher::on_probe_received(const IPv4Address &from) {
	if (_connected) return;

	if (!_punching) {
		// Not actively punching — echo back (server behavior).
		send_probe(from, HP_TTL_NORMAL);
		return;
	}

	// Validate source IP matches the remote peer we're punching to.
	// Without this, probes from local processes (e.g. keet daemon)
	// would be accepted as successful punches.
	if (!_is_remote_ip(from)) {
		DHT_LOG("[Holepuncher] IGNORING probe from non-remote IP %d.%d.%d.%d:%d\n",
				from.ip[0], from.ip[1], from.ip[2], from.ip[3], from.port);
		return;
	}

	// Connection established!
	_connected = true;
	_punching = false;
	_stage = HP_CONNECTED;

	if (_birthday_active) {
		_cleanup_birthday_sockets();
	}

	if (on_connect) {
		on_connect(from);
	}
}

// ---- Remote target address ----

const IPv4Address &Holepuncher::_remote_target() const {
	if (_remote.has_verified) return _remote.verified_address;
	if (_remote.address_count > 0) return _remote.addresses[0];
	static IPv4Address empty = {};
	return empty;
}

// ---- Source IP validation ----
// Only accept probes from IPs matching the remote peer's known addresses.
// Port is NOT checked — NAT may assign different ports.

bool Holepuncher::_is_remote_ip(const IPv4Address &from) const {
	for (int i = 0; i < _remote.address_count; i++) {
		if (memcmp(from.ip, _remote.addresses[i].ip, 4) == 0) return true;
	}
	if (_remote.has_verified && memcmp(from.ip, _remote.verified_address.ip, 4) == 0) {
		return true;
	}
	return false;
}

// ---- Take winning socket (ownership transfer) ----

udx_socket_t *Holepuncher::take_winning_socket() {
	udx_socket_t *s = _winning_socket;
	_winning_socket = nullptr;
	if (s) {
		// Stop the birthday recv handler — UDX stream will handle data now.
		// Also prevents use-after-free if packets arrive after puncher cleanup.
		s->data = nullptr;
		udx_socket_recv_stop(s);
		// Remove from birthday array so cleanup() won't accidentally close it.
		for (int i = 0; i < _birthday_count; i++) {
			if (_birthday_sockets[i] == s) {
				_birthday_sockets[i] = nullptr;
				break;
			}
		}
	}
	return s;
}