mirror of
https://github.com/torlando-tech/columba
synced 2026-08-12 18:07:15 -04:00
When Columba starts without WiFi (or with only cellular), AutoInterface scans for network interfaces once during __init__ and never again. If WiFi connects later, the new wlan0 interface is invisible — no multicast discovery sockets are created, no peers are found. Two bugs fixed: 1. NetworkChangeManager only fired on network *switches* (lastNetworkId != null), not on first connection. Changed condition to also fire when lastNetworkId is null, with the existing binder.isInitialized() guard preventing premature invocation during startup. 2. No mechanism existed to tell AutoInterface about new interfaces. Added auto_interface_manager.py which surgically hot-adds only NEW interfaces to the existing AutoInterface — creating multicast/unicast discovery sockets, joining the multicast group, starting discovery threads, and creating UDP data servers. This avoids tearing down existing peer connections on already-adopted interfaces. The hot-add runs before the LXMF announce on network change, so the announce goes out on the newly added interface. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
220 lines
8 KiB
Python
220 lines
8 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"
|
|
|
|
|
|
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})")
|
|
|
|
# Register in AutoInterface state
|
|
auto_iface.link_local_addresses.append(link_local_addr)
|
|
auto_iface.adopted_interfaces[ifname] = link_local_addr
|
|
auto_iface.multicast_echoes[ifname] = time.time()
|
|
|
|
if_index = auto_iface.interface_name_to_index(ifname)
|
|
if_struct = struct.pack("I", if_index)
|
|
|
|
# --- 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 ---
|
|
socketserver.UDPServer.address_family = socket.AF_INET6
|
|
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 = socketserver.UDPServer(
|
|
addr_info[0][4],
|
|
auto_iface.handler_factory(auto_iface.process_incoming)
|
|
)
|
|
auto_iface.interface_servers[ifname] = udp_server
|
|
thread = threading.Thread(target=udp_server.serve_forever)
|
|
thread.daemon = True
|
|
thread.start()
|