- 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>
183 lines
5.7 KiB
Python
183 lines
5.7 KiB
Python
"""Atlus — main FastAPI application entry point."""
|
|
|
|
import asyncio
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pydantic import BaseModel
|
|
|
|
from backend.auth import authenticate_user, create_token, logout
|
|
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(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
log = logging.getLogger("atlus")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifespan — start/stop background tasks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
log.info("Atlus starting on %s:%d", HOST, PORT)
|
|
|
|
# Autostart configured GUI apps (always-on desktop session)
|
|
cfg = load_config()
|
|
gui_apps = cfg.get("gui_apps", [])
|
|
if gui_apps:
|
|
try:
|
|
await display_manager.autostart_apps(gui_apps)
|
|
except Exception as e:
|
|
log.warning("Autostart failed: %s", e)
|
|
|
|
# Start stats broadcaster
|
|
broadcaster = asyncio.create_task(stats.stats_broadcaster())
|
|
|
|
# Start periodic session cleanup (every hour, remove sessions idle > 24h)
|
|
async def _session_cleanup_loop():
|
|
while True:
|
|
await asyncio.sleep(3600)
|
|
await session_manager.cleanup_stale(max_idle_hours=24)
|
|
|
|
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:
|
|
pass
|
|
try:
|
|
await cleanup_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
try:
|
|
await netwatch_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
# Kill all PTYs on shutdown
|
|
session_manager.shutdown_all()
|
|
# Stop all virtual displays and GUI apps
|
|
await display_manager.shutdown_all()
|
|
log.info("Atlus shutdown complete")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# App
|
|
# ---------------------------------------------------------------------------
|
|
|
|
app = FastAPI(
|
|
title="Atlus",
|
|
description="Web-based desktop environment for headless SBCs",
|
|
version="0.1.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth endpoints (not behind auth — they *create* auth)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class LoginRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
|
|
|
|
@app.post("/api/auth/login")
|
|
async def login(req: LoginRequest):
|
|
if not authenticate_user(req.username, req.password):
|
|
raise HTTPException(401, "Invalid credentials")
|
|
token, jti = create_token(req.username)
|
|
return {"token": token, "username": req.username}
|
|
|
|
|
|
@app.post("/api/auth/logout")
|
|
async def logout_endpoint(request: Request):
|
|
auth = request.headers.get("Authorization", "")
|
|
if auth.startswith("Bearer "):
|
|
logout(auth[7:])
|
|
return {"ok": True}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# API routers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
app.include_router(stats.router)
|
|
app.include_router(terminal.router)
|
|
app.include_router(files.router)
|
|
app.include_router(services.router)
|
|
app.include_router(processes.router)
|
|
app.include_router(settings.router)
|
|
app.include_router(network.router)
|
|
app.include_router(packages.router)
|
|
app.include_router(updates.router)
|
|
app.include_router(session.router)
|
|
app.include_router(display.router)
|
|
app.include_router(asi_bridge.router)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Frontend — served as static files, SPA-style routing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Mount static assets (CSS, JS, images)
|
|
app.mount("/css", StaticFiles(directory=str(FRONTEND_DIR / "css")), name="css")
|
|
app.mount("/js", StaticFiles(directory=str(FRONTEND_DIR / "js")), name="js")
|
|
app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIR / "assets")), name="assets")
|
|
|
|
|
|
@app.get("/")
|
|
async def serve_login():
|
|
return FileResponse(str(FRONTEND_DIR / "index.html"))
|
|
|
|
|
|
@app.get("/desktop")
|
|
async def serve_desktop():
|
|
return FileResponse(str(FRONTEND_DIR / "desktop.html"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Error handler
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@app.exception_handler(Exception)
|
|
async def global_exception_handler(request: Request, exc: Exception):
|
|
log.exception("Unhandled error: %s", exc)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"detail": "Internal server error"},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(
|
|
"backend.main:app",
|
|
host=HOST,
|
|
port=PORT,
|
|
reload=False,
|
|
log_level="info",
|
|
)
|