Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)

## [Unreleased]

## [0.6.2] - 2026-04-07

### Fixed
- **Manual reconnect and introduced-peer connect now prefer better public paths more consistently** — the user-triggered reconnect and introduced-peer dial routes now try discovered and public or tunnel endpoints before stale remembered LAN addresses, which makes operator-initiated recovery behave more like the improved background reconnect path.
- **Expected reconnect and probe churn is less noisy in routine logs** — per-attempt outbound connect lines, connect timeouts, missing peer IDs from pre-handshake probes, and broker requests that simply were not routed immediately are now logged more quietly so real connection failures stand out better during catchup and internet-peer debugging.

## [0.6.1] - 2026-04-07

### Fixed
- **Expected websocket handshake churn is quieter in logs** — early disconnects that happen before a peer finishes the websocket opening handshake no longer spray a scary library-level stack trace into server logs when the underlying condition is just a dropped or non-Canopy client connection.
- **Reconnect now prefers learned public or tunnel endpoints over stale private addresses** — once Canopy has a reusable public callback path for a peer, stored reconnect attempts stop burning retries on older `192.168.*` endpoints before trying the internet-reachable address.
- **Introduced-peer brokering is more truthful and useful for internet peers** — broker-only connect flows now require an actually connected broker candidate instead of treating historical introducers as live paths, and broker requests reuse the same advertised public or tunnel endpoints learned by the handshake path.

## [0.6.0] - 2026-04-06

### Changed
Expand Down
2 changes: 1 addition & 1 deletion canopy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
License: Apache 2.0
"""

__version__ = "0.6.0"
__version__ = "0.6.2"
__protocol_version__ = 1
__author__ = "Canopy Contributors"
__license__ = "Apache-2.0"
Expand Down
240 changes: 189 additions & 51 deletions canopy/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3107,7 +3107,18 @@ def import_p2p_invite():

if not connected:
result['status'] = 'imported_not_connected'
result['message'] = 'Peer registered but could not connect to any endpoint. Make sure the peer is online and reachable.'
if not list(result.get('endpoints') or []):
result['diagnostic_code'] = 'invite_has_no_usable_endpoints'
result['message'] = (
'Peer registered, but the invite did not contain any usable direct endpoints. '
'Ask the remote peer to regenerate the invite with a public or tunnel endpoint.'
)
else:
result['diagnostic_code'] = 'direct_connect_failed'
result['message'] = (
'Peer registered but could not connect to any advertised endpoint. '
'The peer may be offline, the endpoint may be stale, or the connection may require broker/relay help.'
)
_record_connection_event(
p2p_manager,
invite.peer_id,
Expand Down Expand Up @@ -3178,25 +3189,78 @@ def connect_introduced_peer():
if not intro:
return jsonify({'error': 'Peer not found in introduced list'}), 404

endpoints = intro.get('endpoints', [])
if not endpoints:
return jsonify({'error': 'No endpoints available for this peer'}), 400

ev_loop = p2p_manager._event_loop
if not ev_loop or ev_loop.is_closed():
return jsonify({'error': 'P2P event loop unavailable'}), 500

from ..network.invite import parse_invite_endpoint
from ..network.invite import canonicalize_invite_endpoint, parse_invite_endpoint
import ipaddress

def _endpoint_priority(endpoint: str) -> tuple[int, str]:
parsed = parse_invite_endpoint(endpoint)
if not parsed:
return (99, endpoint)
host, _, scheme = parsed
text = str(host or '').strip().lower()
if not text:
return (99, endpoint)
if text == 'localhost' or text.startswith('127.'):
return (3, endpoint)
try:
ip = ipaddress.ip_address(text)
except ValueError:
return (0 if scheme == 'wss' else 1, endpoint)
if ip.is_loopback or ip.is_unspecified:
return (3, endpoint)
if ip.is_private or ip.is_link_local:
return (2, endpoint)
return (0 if scheme == 'wss' else 1, endpoint)

direct_attempt_count = 0
discovered_endpoints: list[str] = []
non_discovered_endpoints: list[str] = []
seen_endpoints: set[str] = set()
for group in (
intro.get('endpoints', []),
getattr(getattr(p2p_manager, 'identity_manager', None), 'peer_endpoints', {}).get(peer_id, []),
):
if not isinstance(group, list):
continue
for endpoint in group:
canon = canonicalize_invite_endpoint(endpoint)
if not canon or canon in seen_endpoints:
continue
seen_endpoints.add(canon)
non_discovered_endpoints.append(canon)
get_discovered = getattr(p2p_manager, '_get_discovered_peer_endpoints', None)
if callable(get_discovered):
try:
for endpoint in list(get_discovered(peer_id) or []):
canon = canonicalize_invite_endpoint(endpoint)
if not canon or canon in seen_endpoints:
continue
seen_endpoints.add(canon)
discovered_endpoints.append(canon)
except Exception:
pass
endpoints = discovered_endpoints + sorted(non_discovered_endpoints, key=_endpoint_priority)

broker_candidates = []
get_broker_candidates = getattr(p2p_manager, 'get_introduced_peer_broker_candidates', None)
if callable(get_broker_candidates):
try:
broker_candidates = list(get_broker_candidates(peer_id) or [])
except Exception:
broker_candidates = []

if force_broker:
_record_connection_event(
p2p_manager,
peer_id,
status='forced_failover',
detail='Direct connect skipped by caller; testing broker/relay path',
)
else:
elif endpoints:
for ep in endpoints:
direct_attempt_count += 1
try:
Expand Down Expand Up @@ -3240,53 +3304,53 @@ def connect_introduced_peer():
except Exception as ce:
logger.warning(f"Connect to introduced {ep} failed: {ce}")
continue
else:
_record_connection_event(
p2p_manager,
peer_id,
status='broker_only',
detail='No direct endpoints announced; trying broker path',
)

# Direct connection failed — try connection brokering.
# Prefer connected introducers, then other connected peers as fallback.
attempted_brokers: list[str] = []
if p2p_manager.relay_policy != 'off':
broker_candidates: list[str] = []
seen_brokers: set[str] = set()

connected_peers: list[str] = []
try:
connected_peers = list(p2p_manager.get_connected_peers() or [])
except Exception:
connected_peers = []
connected_set = set(connected_peers)
if not broker_candidates:
seen_brokers: set[str] = set()
connected_peers: list[str] = []
try:
connected_peers = list(p2p_manager.get_connected_peers() or [])
except Exception:
connected_peers = []
connected_set = set(connected_peers)

local_peer_id = ''
try:
local_peer_id = p2p_manager.get_peer_id() or ''
except Exception:
local_peer_id = ''

introducers: list[str] = []
introduced_via = intro.get('introduced_via', [])
if isinstance(introduced_via, list):
for pid in introduced_via:
if isinstance(pid, str) and pid:
introducers.append(pid)
introduced_by = intro.get('introduced_by')
if isinstance(introduced_by, str) and introduced_by:
introducers.append(introduced_by)

connected_introducers = [pid for pid in introducers if pid in connected_set]
disconnected_introducers = [pid for pid in introducers if pid not in connected_set]

for pid in connected_introducers:
if pid not in seen_brokers:
seen_brokers.add(pid)
broker_candidates.append(pid)

for pid in connected_peers:
if not pid or pid == peer_id or pid == local_peer_id or pid in seen_brokers:
continue
seen_brokers.add(pid)
broker_candidates.append(pid)

for pid in disconnected_introducers:
if pid not in seen_brokers:
try:
local_peer_id = p2p_manager.get_peer_id() or ''
except Exception:
local_peer_id = ''

introducers: list[str] = []
introduced_via = intro.get('introduced_via', [])
if isinstance(introduced_via, list):
for pid in introduced_via:
if isinstance(pid, str) and pid:
introducers.append(pid)
introduced_by = intro.get('introduced_by')
if isinstance(introduced_by, str) and introduced_by:
introducers.append(introduced_by)

connected_introducers = [pid for pid in introducers if pid in connected_set]

for pid in connected_introducers:
if pid not in seen_brokers:
seen_brokers.add(pid)
broker_candidates.append(pid)

for pid in connected_peers:
if not pid or pid == peer_id or pid == local_peer_id or pid in seen_brokers:
continue
seen_brokers.add(pid)
broker_candidates.append(pid)

Expand Down Expand Up @@ -3315,9 +3379,13 @@ def connect_introduced_peer():
'via_peer': broker_peer,
'attempted_brokers': attempted_brokers,
'forced_failover': force_broker,
'direct_attempted': not force_broker,
'direct_attempted': bool(endpoints) and not force_broker,
'direct_attempt_count': direct_attempt_count,
'diagnostic_code': 'introduced_peer_broker_connect',
'message': (
'No direct endpoints were announced; broker request sent. '
'The introducer or relay peer will attempt to connect the target back.'
if not endpoints else
'Direct connection failed; broker request sent. '
'The target peer will attempt to connect back. '
'If both peers remain unreachable, use a broker with Full Relay enabled.'
Expand All @@ -3330,7 +3398,26 @@ def connect_introduced_peer():
status='failed',
detail='Introduced peer connection failed',
)
guidance = 'Could not connect to any endpoint'
if not endpoints:
guidance = (
'This introduced peer does not currently advertise any direct endpoints. '
'Keep the introducer online, retry through a broker, or import a fresh raw invite from the target peer.'
)
if attempted_brokers:
guidance += f" ({len(attempted_brokers)} broker{'s' if len(attempted_brokers) != 1 else ''} tried)"
return jsonify({
'status': 'failed',
'error': 'No endpoints available for this introduced peer record',
'diagnostic_code': 'introduced_peer_has_no_direct_endpoints',
'message': guidance,
'attempted_brokers': attempted_brokers,
'relay_policy': getattr(p2p_manager, 'relay_policy', 'broker_only'),
'forced_failover': force_broker,
'direct_attempted': False,
'direct_attempt_count': 0,
}), 502

guidance = 'Could not connect to any advertised endpoint'
if attempted_brokers:
guidance += f" and no broker succeeded ({len(attempted_brokers)} attempted)"
if p2p_manager.relay_policy != 'full_relay':
Expand All @@ -3341,10 +3428,11 @@ def connect_introduced_peer():
return jsonify({
'status': 'failed',
'message': guidance,
'diagnostic_code': 'introduced_peer_direct_connect_failed',
'attempted_brokers': attempted_brokers,
'relay_policy': getattr(p2p_manager, 'relay_policy', 'broker_only'),
'forced_failover': force_broker,
'direct_attempted': not force_broker,
'direct_attempted': bool(endpoints) and not force_broker,
'direct_attempt_count': direct_attempt_count,
}), 502

Expand Down Expand Up @@ -3386,7 +3474,57 @@ def reconnect_known_peer():
})

im = p2p_manager.identity_manager
endpoints = im.peer_endpoints.get(peer_id, [])
endpoints: list[str] = []
seen_endpoints: set[str] = set()
if not endpoints:
stored_endpoints = list(im.peer_endpoints.get(peer_id, []) or [])
discovered_endpoints: list[str] = []
get_discovered = getattr(p2p_manager, '_get_discovered_peer_endpoints', None)
if callable(get_discovered):
try:
discovered_endpoints = list(get_discovered(peer_id) or [])
except Exception:
discovered_endpoints = []

from ..network.invite import canonicalize_invite_endpoint, parse_invite_endpoint
import ipaddress

def _endpoint_priority(endpoint: str) -> tuple[int, str]:
parsed = parse_invite_endpoint(endpoint)
if not parsed:
return (99, endpoint)
host, _, scheme = parsed
text = str(host or '').strip().lower()
if not text:
return (99, endpoint)
if text == 'localhost' or text.startswith('127.'):
return (3, endpoint)
try:
ip = ipaddress.ip_address(text)
except ValueError:
return (0 if scheme == 'wss' else 1, endpoint)
if ip.is_loopback or ip.is_unspecified:
return (3, endpoint)
if ip.is_private or ip.is_link_local:
return (2, endpoint)
return (0 if scheme == 'wss' else 1, endpoint)

for endpoint in discovered_endpoints:
canon = canonicalize_invite_endpoint(endpoint)
if not canon or canon in seen_endpoints:
continue
seen_endpoints.add(canon)
endpoints.append(canon)

stored_canonical = []
for endpoint in stored_endpoints:
canon = canonicalize_invite_endpoint(endpoint)
if not canon or canon in seen_endpoints:
continue
seen_endpoints.add(canon)
stored_canonical.append(canon)
endpoints.extend(sorted(stored_canonical, key=_endpoint_priority))

if not endpoints:
return jsonify({'error': 'No known endpoints for this peer'}), 400

Expand Down
Loading
Loading