columba/python/auto_interface_manager.py
torlando-tech edd6781c85 Clean up sockets on partial failure in _add_interface
Wrap socket/thread/server setup in try/except so that already-created
sockets are closed if a later step fails, preventing EADDRINUSE on retry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:31:51 -05:00

241 lines
8.7 KiB
Python

"""
AutoInterface Hot-Add Manager for Columba
Handles dynamically adding new network interfaces to an existing RNS AutoInterface
at runtime. This solves the problem where AutoInterface only scans for network
interfaces once during __init__ — if WiFi connects after startup, the new interface
is invisible to peer discovery until this module adds it.
The hot-add approach is surgical: it adds only NEW interfaces to the existing
AutoInterface without tearing down existing sockets, threads, or peer connections.
"""
import json
import socket
import socketserver
import struct
import threading
import time
from typing import Dict, Optional
from logging_utils import log_debug, log_error, log_info
# Tag for logging
TAG = "AutoInterfaceManager"
class _IPv6UDPServer(socketserver.UDPServer):
"""UDPServer subclass bound to IPv6, avoiding global class mutation."""
address_family = socket.AF_INET6
def hot_add_interfaces() -> str:
"""
Scan for network interfaces not yet adopted by the AutoInterface and add them.
Creates multicast discovery sockets, unicast discovery sockets, discovery
threads, and data port UDP servers for each new interface — mirroring the
setup that AutoInterface.__init__ and final_init perform at startup.
This is idempotent: if no new interfaces are found, it returns immediately.
Returns:
JSON string: {"success": true/false, "action": "...", "count": N}
"""
try:
import RNS
from RNS.Interfaces.AutoInterface import AutoInterface as AutoInterfaceClass
from RNS.Interfaces import netinfo
except ImportError:
return json.dumps({"success": False, "error": "RNS not available"})
# Find the existing AutoInterface in Transport
auto_iface = _find_auto_interface(RNS)
if auto_iface is None:
return json.dumps({"success": False, "error": "no AutoInterface in Transport"})
# Scan for interfaces not yet adopted
new_adopted = _scan_new_interfaces(auto_iface, AutoInterfaceClass, netinfo)
if not new_adopted:
log_debug(TAG, "hot_add_interfaces",
f"No new interfaces (adopted: {list(auto_iface.adopted_interfaces.keys())})")
return json.dumps({"success": True, "action": "no_new_interfaces"})
# Add each new interface
added_count = 0
for ifname, link_local_addr in new_adopted.items():
try:
_add_interface(auto_iface, AutoInterfaceClass, ifname, link_local_addr)
added_count += 1
RNS.log(
f"{auto_iface} Hot-added interface {ifname} "
f"with address {link_local_addr}",
RNS.LOG_NOTICE
)
except Exception as e:
log_error(TAG, "hot_add_interfaces", f"Failed to hot-add {ifname}: {e}")
RNS.log(
f"Could not hot-add interface {ifname} to {auto_iface}: {e}",
RNS.LOG_ERROR
)
# Ensure AutoInterface is marked as active
if added_count > 0:
if not auto_iface.receives:
auto_iface.receives = True
if not auto_iface.OUT:
auto_iface.OUT = True
auto_iface.carrier_changed = True
log_info(TAG, "hot_add_interfaces", f"Hot-added {added_count} interface(s)")
return json.dumps({
"success": True,
"action": "added_interfaces",
"count": added_count
})
def _find_auto_interface(rns_module) -> Optional[object]:
"""Find the first AutoInterface instance in RNS Transport."""
from RNS.Interfaces.AutoInterface import AutoInterface as AutoInterfaceClass
for iface in rns_module.Transport.interfaces:
if isinstance(iface, AutoInterfaceClass):
return iface
return None
def _scan_new_interfaces(auto_iface, auto_cls, netinfo) -> Dict[str, str]:
"""
Scan for network interfaces with link-local IPv6 that aren't yet adopted.
Returns:
Dict mapping interface name -> link-local address for new interfaces.
"""
new_adopted = {}
for ifname in auto_iface.list_interfaces():
if ifname in auto_iface.adopted_interfaces:
continue
if ifname in auto_cls.ANDROID_IGNORE_IFS:
continue
if ifname in auto_iface.ignored_interfaces:
continue
if ifname in auto_cls.ALL_IGNORE_IFS:
continue
if len(auto_iface.allowed_interfaces) > 0 and ifname not in auto_iface.allowed_interfaces:
continue
addresses = auto_iface.list_addresses(ifname)
if netinfo.AF_INET6 in addresses:
for address in addresses[netinfo.AF_INET6]:
if "addr" in address and address["addr"].startswith("fe80:"):
link_local_addr = auto_iface.descope_linklocal(address["addr"])
new_adopted[ifname] = link_local_addr
break
return new_adopted
def _add_interface(auto_iface, auto_cls, ifname: str, link_local_addr: str):
"""
Add a single network interface to the AutoInterface.
Sets up:
1. Unicast discovery socket (for reverse peering)
2. Multicast discovery socket (joins the multicast group)
3. Discovery + announce threads
4. UDP data server for incoming packets
"""
log_info(TAG, "_add_interface", f"Hot-adding {ifname} ({link_local_addr})")
if_index = auto_iface.interface_name_to_index(ifname)
if_struct = struct.pack("I", if_index)
uds = None
ds = None
udp_server = None
try:
# --- Unicast discovery socket ---
uds = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
uds.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if hasattr(socket, "SO_REUSEPORT"):
uds.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
addr_info = socket.getaddrinfo(
link_local_addr + "%" + ifname,
auto_iface.unicast_discovery_port,
socket.AF_INET6, socket.SOCK_DGRAM
)
uds.bind(addr_info[0][4])
# --- Multicast discovery socket ---
mcast_addr = auto_iface.mcast_discovery_address
ds = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
ds.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if hasattr(socket, "SO_REUSEPORT"):
ds.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
ds.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, if_struct)
# Join multicast group
mcast_group = socket.inet_pton(socket.AF_INET6, mcast_addr) + if_struct
ds.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, mcast_group)
# Bind multicast socket
if auto_iface.discovery_scope == auto_cls.SCOPE_LINK:
addr_info = socket.getaddrinfo(
mcast_addr + "%" + ifname,
auto_iface.discovery_port,
socket.AF_INET6, socket.SOCK_DGRAM
)
else:
addr_info = socket.getaddrinfo(
mcast_addr,
auto_iface.discovery_port,
socket.AF_INET6, socket.SOCK_DGRAM
)
ds.bind(addr_info[0][4])
# --- Start discovery threads ---
# Factory functions capture loop variables properly (avoids late-binding closure bug)
def _make_discovery_loop(sock, name):
def loop():
auto_iface.discovery_handler(sock, name)
return loop
def _make_unicast_loop(sock, name):
def loop():
auto_iface.discovery_handler(sock, name, announce=False)
return loop
threading.Thread(target=_make_discovery_loop(ds, ifname), daemon=True).start()
threading.Thread(target=_make_unicast_loop(uds, ifname), daemon=True).start()
# --- Create UDP data server ---
local_addr = link_local_addr + "%" + str(if_index)
addr_info = socket.getaddrinfo(
local_addr, auto_iface.data_port,
socket.AF_INET6, socket.SOCK_DGRAM
)
udp_server = _IPv6UDPServer(
addr_info[0][4],
auto_iface.handler_factory(auto_iface.process_incoming)
)
thread = threading.Thread(target=udp_server.serve_forever)
thread.daemon = True
thread.start()
# Register in AutoInterface state ONLY after successful setup
auto_iface.link_local_addresses.append(link_local_addr)
auto_iface.adopted_interfaces[ifname] = link_local_addr
auto_iface.multicast_echoes[ifname] = time.time()
auto_iface.interface_servers[ifname] = udp_server
except Exception:
for s in (uds, ds):
if s:
try:
s.close()
except OSError:
pass
if udp_server:
try:
udp_server.server_close()
except OSError:
pass
raise