diff --git a/backend/main.py b/backend/main.py index 2d2ce77..d5712ef 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,7 @@ from backend.config import FRONTEND_DIR, HOST, PORT, load_config from backend.routers import stats, terminal, files, services, processes, settings, network, packages, updates, session, display from backend.sessions import manager as session_manager from backend.display import display_manager +from backend.netwatch import ethernet_watchdog from backend.routers.plugins import asi_bridge logging.basicConfig( @@ -51,10 +52,14 @@ async def lifespan(app: FastAPI): cleanup_task = asyncio.create_task(_session_cleanup_loop()) + # Start ethernet link watchdog (auto-enable interfaces when cable plugged in) + netwatch_task = asyncio.create_task(ethernet_watchdog()) + yield broadcaster.cancel() cleanup_task.cancel() + netwatch_task.cancel() try: await broadcaster except asyncio.CancelledError: @@ -63,6 +68,10 @@ async def lifespan(app: FastAPI): await cleanup_task except asyncio.CancelledError: pass + try: + await netwatch_task + except asyncio.CancelledError: + pass # Kill all PTYs on shutdown session_manager.shutdown_all() diff --git a/backend/netwatch.py b/backend/netwatch.py new file mode 100644 index 0000000..eb5c33e --- /dev/null +++ b/backend/netwatch.py @@ -0,0 +1,204 @@ +"""Ethernet link watchdog — auto-enable interfaces when cable is plugged in. + +Monitors /sys/class/net/*/carrier for ethernet interfaces. +When a cable is detected on a DOWN interface, brings it up with DHCP. +This prevents lockout when the only active interface is disabled. +""" + +import asyncio +import logging +import os +import shutil +from pathlib import Path + +log = logging.getLogger("atlus.netwatch") + +_POLL_INTERVAL = 5 # seconds between checks + +_SEARCH_PATHS = ("/usr/bin", "/usr/local/bin", "/bin", "/usr/sbin", "/sbin") + + +def _find_bin(name: str) -> str | None: + found = shutil.which(name) + if found: + return found + for d in _SEARCH_PATHS: + p = os.path.join(d, name) + if os.path.isfile(p) and os.access(p, os.X_OK): + return p + return None + + +def _safe_env() -> dict: + env = {**os.environ, "LC_ALL": "C"} + path = env.get("PATH", "") + for p in _SEARCH_PATHS: + if p not in path: + path = p + ":" + path + env["PATH"] = path + return env + + +def _is_ethernet(iface: str) -> bool: + """Check if an interface is a physical ethernet (not wireless, not virtual).""" + sys_path = Path(f"/sys/class/net/{iface}") + if not sys_path.exists(): + return False + # Skip wireless + if (sys_path / "wireless").exists(): + return False + # Skip loopback + if iface == "lo": + return False + # Skip virtual interfaces (bridges, veth, docker, etc.) + device_path = sys_path / "device" + if not device_path.exists(): + return False + # Has a device backing it — likely physical + return True + + +def _has_carrier(iface: str) -> bool: + """Check if a cable is plugged in (carrier detected).""" + try: + carrier = Path(f"/sys/class/net/{iface}/carrier").read_text().strip() + return carrier == "1" + except Exception: + return False + + +def _is_up(iface: str) -> bool: + """Check if interface is operationally up.""" + try: + flags = Path(f"/sys/class/net/{iface}/flags").read_text().strip() + # IFF_UP = 0x1 + return (int(flags, 16) & 0x1) != 0 + except Exception: + return False + + +def _has_ip(iface: str) -> bool: + """Check if interface has any IPv4 address assigned.""" + try: + addrs = Path(f"/proc/net/fib_trie").read_text() + # Quick check: look for the interface in the routing table + # More reliable: check /sys/class/net/{iface}/ + import subprocess + result = subprocess.run( + ["ip", "-4", "addr", "show", iface], + capture_output=True, text=True, timeout=5 + ) + return "inet " in result.stdout + except Exception: + return False + + +async def _run(cmd: list[str], timeout: float = 15) -> tuple[int, str, str]: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=_safe_env(), + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return 1, "", "timeout" + return proc.returncode, stdout.decode(), stderr.decode() + + +async def _enable_with_dhcp(iface: str): + """Bring interface up and request DHCP lease.""" + ip_bin = _find_bin("ip") or "ip" + + log.info("Ethernet watchdog: bringing up %s with DHCP", iface) + + # Bring link up + await _run([ip_bin, "link", "set", iface, "up"]) + await asyncio.sleep(1) + + # Try ifup first (respects /etc/network/interfaces.d/) + ifup = _find_bin("ifup") + if ifup: + rc, _, stderr = await _run([ifup, iface], timeout=20) + if rc == 0: + log.info("Ethernet watchdog: %s enabled via ifup", iface) + return + + # Fallback: DHCP client directly + dhclient = _find_bin("dhclient") + udhcpc = _find_bin("udhcpc") + + if dhclient: + # Release any stale lease first + await _run([dhclient, "-r", iface], timeout=5) + rc, _, stderr = await _run([dhclient, iface], timeout=20) + if rc == 0: + log.info("Ethernet watchdog: %s enabled via dhclient", iface) + return + log.warning("dhclient failed for %s: %s", iface, stderr.strip()) + elif udhcpc: + rc, _, stderr = await _run([udhcpc, "-i", iface, "-n", "-q"], timeout=20) + if rc == 0: + log.info("Ethernet watchdog: %s enabled via udhcpc", iface) + return + log.warning("udhcpc failed for %s: %s", iface, stderr.strip()) + else: + log.warning("No DHCP client found — %s brought up without address", iface) + + +async def ethernet_watchdog(): + """Background task: monitor ethernet interfaces for cable plug-in events. + + When a cable is plugged into a DOWN ethernet interface: + 1. Bring the interface up + 2. Request a DHCP lease + + This prevents lockout when the user accidentally disables their only interface. + """ + log.info("Ethernet link watchdog started") + + # Track which interfaces we've already auto-enabled to avoid repeated attempts + auto_enabled = set() + + while True: + try: + net_dir = Path("/sys/class/net") + if not net_dir.exists(): + await asyncio.sleep(_POLL_INTERVAL) + continue + + for iface_path in net_dir.iterdir(): + iface = iface_path.name + + if not _is_ethernet(iface): + continue + + has_cable = _has_carrier(iface) + is_up = _is_up(iface) + + if has_cable and not is_up: + # Cable plugged in but interface is down — enable it + if iface not in auto_enabled: + log.info("Ethernet watchdog: cable detected on disabled %s", iface) + await _enable_with_dhcp(iface) + auto_enabled.add(iface) + elif has_cable and is_up and not _has_ip(iface): + # Interface is up but has no IP — try DHCP + if iface not in auto_enabled: + log.info("Ethernet watchdog: %s up but no IP, requesting DHCP", iface) + await _enable_with_dhcp(iface) + auto_enabled.add(iface) + elif not has_cable: + # Cable removed — clear from auto_enabled so we re-enable on next plug + auto_enabled.discard(iface) + elif has_cable and is_up and _has_ip(iface): + # All good — clear flag in case it was auto-enabled + auto_enabled.discard(iface) + + except Exception: + log.exception("Ethernet watchdog error") + + await asyncio.sleep(_POLL_INTERVAL) diff --git a/backend/routers/network.py b/backend/routers/network.py index 0562fd1..95a8ac9 100644 --- a/backend/routers/network.py +++ b/backend/routers/network.py @@ -1037,9 +1037,42 @@ async def interface_up(name: str, _user: str = Depends(get_current_user)): @router.post("/interfaces/{name}/down") async def interface_down(name: str, _user: str = Depends(get_current_user)): - """Bring an interface down.""" + """Bring an interface down. Refuses if it's the only active interface.""" _validate_iface_name(name) + + # Safety: don't disable the last active interface + active = await _get_active_interfaces() + if name in active and len(active) <= 1: + raise HTTPException( + 409, + "Cannot disable the only active network interface. " + "Enable another interface first to avoid losing connectivity." + ) + rc, _, stderr = await _run_cmd(_IP_BIN, "link", "set", name, "down") if rc != 0: raise HTTPException(500, f"Failed to bring down {name}: {stderr.strip()}") return {"success": True} + + +async def _get_active_interfaces() -> set[str]: + """Return set of interface names that are UP and have an IP address.""" + active = set() + rc, stdout, _ = await _run_cmd(_IP_BIN, "addr", "show") + if rc != 0: + return active + current_iface = None + current_up = False + for line in stdout.splitlines(): + if not line.startswith(" "): + # Interface line: "2: eth0: <...UP...> ..." + parts = line.split(":") + if len(parts) >= 2: + current_iface = parts[1].strip().split("@")[0] + current_up = "UP" in line and current_iface != "lo" + else: + current_iface = None + current_up = False + elif current_iface and current_up and "inet " in line: + active.add(current_iface) + return active diff --git a/frontend/js/apps/settings.js b/frontend/js/apps/settings.js index 34a27ba..debd2c1 100644 --- a/frontend/js/apps/settings.js +++ b/frontend/js/apps/settings.js @@ -440,7 +440,14 @@ btn.disabled = true; btn.textContent = isUp ? 'Disabling…' : 'Enabling…'; try { - await Atlus.apiFetch(`/api/network/interfaces/${name}/${action}`, { method: 'POST' }); + const res = await Atlus.apiFetch(`/api/network/interfaces/${name}/${action}`, { method: 'POST' }); + if (res && !res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.detail || 'Failed to change interface state'); + btn.disabled = false; + btn.textContent = isUp ? 'Disable' : 'Enable'; + return; + } } catch (e) { /* may lose connection */ } setTimeout(() => _renderNetworkReadonly(), 2000); });