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:
parent
8d48e3eeb8
commit
14f5ce7cf1
4 changed files with 255 additions and 2 deletions
|
|
@ -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()
|
||||
|
|
|
|||
204
backend/netwatch.py
Normal file
204
backend/netwatch.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue