Add ethernet link watchdog and prevent disabling last active interface

- Ethernet watchdog: background task polls /sys/class/net every 5s, detects
  cable plug-in on disabled interfaces and auto-enables with DHCP
- interface_down endpoint now refuses to disable the only active interface
  (returns 409), preventing accidental lockout
- Frontend shows the error message instead of silently failing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
roberts 2026-03-15 00:12:00 -05:00
parent 8d48e3eeb8
commit 14f5ce7cf1
4 changed files with 255 additions and 2 deletions

View file

@ -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.routers import stats, terminal, files, services, processes, settings, network, packages, updates, session, display
from backend.sessions import manager as session_manager from backend.sessions import manager as session_manager
from backend.display import display_manager from backend.display import display_manager
from backend.netwatch import ethernet_watchdog
from backend.routers.plugins import asi_bridge from backend.routers.plugins import asi_bridge
logging.basicConfig( logging.basicConfig(
@ -51,10 +52,14 @@ async def lifespan(app: FastAPI):
cleanup_task = asyncio.create_task(_session_cleanup_loop()) 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 yield
broadcaster.cancel() broadcaster.cancel()
cleanup_task.cancel() cleanup_task.cancel()
netwatch_task.cancel()
try: try:
await broadcaster await broadcaster
except asyncio.CancelledError: except asyncio.CancelledError:
@ -63,6 +68,10 @@ async def lifespan(app: FastAPI):
await cleanup_task await cleanup_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
try:
await netwatch_task
except asyncio.CancelledError:
pass
# Kill all PTYs on shutdown # Kill all PTYs on shutdown
session_manager.shutdown_all() session_manager.shutdown_all()

204
backend/netwatch.py Normal file
View file

@ -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)

View file

@ -1037,9 +1037,42 @@ async def interface_up(name: str, _user: str = Depends(get_current_user)):
@router.post("/interfaces/{name}/down") @router.post("/interfaces/{name}/down")
async def interface_down(name: str, _user: str = Depends(get_current_user)): 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) _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") rc, _, stderr = await _run_cmd(_IP_BIN, "link", "set", name, "down")
if rc != 0: if rc != 0:
raise HTTPException(500, f"Failed to bring down {name}: {stderr.strip()}") raise HTTPException(500, f"Failed to bring down {name}: {stderr.strip()}")
return {"success": True} 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

View file

@ -440,7 +440,14 @@
btn.disabled = true; btn.disabled = true;
btn.textContent = isUp ? 'Disabling…' : 'Enabling…'; btn.textContent = isUp ? 'Disabling…' : 'Enabling…';
try { 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 */ } } catch (e) { /* may lose connection */ }
setTimeout(() => _renderNetworkReadonly(), 2000); setTimeout(() => _renderNetworkReadonly(), 2000);
}); });