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.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
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")
|
@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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue