wrolpi/controller/main.py
lrnselfreliance d701846254
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
Hotspot captive portal: welcome page with the WROLPi's address (#709)
* Hotspot captive portal: welcome page with the WROLPi's address

Devices that join the hotspot probe a fixed plain-HTTP URL to check for
Internet access.  NetworkManager's shared-mode dnsmasq now resolves those
probe hostnames (and `wrolpi`) to the hotspot address via an include the
Controller writes when the hotspot starts, the probes land on the Controller
(port 80), and are redirected to /portal: "This is your WROLPi, its address is
10.42.0.1, open WROLPi / open the Controller".

Only the probe hostnames are hijacked so the hotspot keeps forwarding
everything else when the WROLPi has an uplink.  The include is corrected and
the connection re-activated once if NetworkManager picked a non-default
subnet.  Writing the include is best-effort; the hotspot starts regardless.

`hotspot.captive_portal` (controller.yaml, default true) toggles the probe
redirects and the include; like the other hotspot settings it applies on the
next hotspot start, so saving while the hotspot is up reports
restart_required and both Controller UIs say so.  The status endpoint gains
the hotspot address and portal URL, shown in both UIs.  repair.sh only
creates the include directory so a disabled setting survives upgrades.
Native only: Docker has no hotspot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* captive portal: address review

* React: re-read the hotspot address whenever the HotspotToggle starts or
  stops the hotspot, matching the fallback UI's refresh-on-status.
* A WPA3 start that reverts to WPA2 leaves the hotspot up, so it now gets
  the captive portal address correction too.
* The corrective `nmcli connection up` logs a warning on a non-zero exit;
  dnsmasq would otherwise keep the old address silently.
* HotspotStatusResponse.captive_portal defaults to true like everywhere else.
* Trim design narrative from the module docstring and React comments.
* The include-before-activate test now observes the include at the moment
  of the `nmcli ... hotspot` call instead of inferring from call order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* captive portal: never forward any record type for probe hosts

`address=` only synthesizes A/AAAA answers.  iOS also asks for the HTTPS
record type; dnsmasq forwarded that upstream, Apple's answer aliased to
captive.g.aaplimg.com, and the phone fetched the genuine "Success" from
Apple instead of the portal.  Each probe host now also gets `local=`, so
dnsmasq answers every type itself, and the CDN alias is a probe host too.

Observed with dnsmasq query logging on a hotspot with an eth0 uplink.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* captive portal: let acknowledged clients finish so the OS offers "Done"

A phone's sign-in sheet only shows "Done" once the OS re-probes and gets
the genuine success reply.  The Controller now remembers each client (by
hotspot IP) that has loaded the portal page and answers its later probes
with the reply each OS expects (Apple "Success" HTML, Android 204, Windows
"Microsoft Connect Test", ...).  The set is cleared when the hotspot
starts so a new session shows the page again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* captive portal: acknowledge on Continue, not on view

iOS fetches the portal page in the background before opening the sheet,
then re-probes after the sheet's own navigation.  Acknowledging on view
made that re-probe return "Success" as the sheet's content.  Phones only
re-check the network (and offer "Done") after a full-page navigation, so
the welcome page now has a Continue button leading to /portal/continue,
which is the only place a client is acknowledged.  The Continue page says
to tap Done and links to WROLPi and the Controller.

Also hijack clients3.google.com (older Android probe host).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* help: bump wrolpi-help to the captive portal and Speed Test docs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* captive portal: comments describe the Continue design; clear acks on stop

Three comments still described acknowledging on view, which c72a7f7b
removed.  Acknowledged clients are now also cleared when the hotspot
stops, so a lease reused in a later session sees the portal page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 20:10:02 -06:00

332 lines
11 KiB
Python

"""
WROLPi Controller - FastAPI Application
The Controller provides:
- System status monitoring (CPU, memory, drives, network)
- Service management (start/stop/restart systemd services)
- Disk mounting and management
- Admin operations (hotspot, throttle, shutdown, reboot)
- Simple emergency UI when main app is down
"""
import asyncio
import logging
from contextlib import asynccontextmanager
from pathlib import Path
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(name)s %(levelname)s %(message)s')
logger = logging.getLogger(__name__)
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.templating import Jinja2Templates
from controller import __version__
from fastapi.middleware.cors import CORSMiddleware
from controller.api.admin import router as admin_router
from controller.api.captive_portal import router as captive_portal_router
from controller.api.disks import router as disks_router
from controller.api.onboarding import router as onboarding_router
from controller.api.ready import router as ready_router
from controller.api.schemas import HealthResponse
from controller.api.scripts import router as scripts_router
from controller.api.services import router as services_router
from controller.api.status import router as status_router
from controller.lib.config import (
get_ca_certificate_path,
get_config_value,
is_ca_certificate_available,
is_docker_mode,
is_primary_drive_mounted,
reload_config_from_drive,
save_config,
update_config,
)
from controller.lib.scripts import get_script_status
from controller.lib.docker_services import (
can_manage_containers,
get_all_containers_status,
)
from controller.lib.status import (
get_cpu_status,
get_iostat_status,
get_load_status,
get_memory_status,
get_primary_drive_status,
get_uptime_status,
)
from controller.lib.status_worker import status_worker_loop
from controller.lib.systemd import get_all_services_status
# Templates directory
TEMPLATES_DIR = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Application lifespan handler.
Runs on startup and shutdown.
"""
# Startup
logger.info("WROLPi Controller v%s starting...", __version__)
logger.info("Docker mode: %s", is_docker_mode())
# When running under Docker dev, status.py will have already remapped
# psutil.PROCFS_PATH to /host/proc (and the sysfs paths) so we report
# real host metrics instead of the container's tiny namespace.
if is_docker_mode():
import psutil
procfs_path = getattr(psutil, "PROCFS_PATH", "N/A (not supported on this platform)")
logger.info("Using host procfs for stats: %s", procfs_path)
# Try to load config from drive if mounted
if is_primary_drive_mounted():
if reload_config_from_drive():
logger.info("Loaded configuration from drive")
else:
logger.info("Using default configuration (no controller.yaml found)")
# Apply timezone from wrolpi.yaml to the system.
from controller.lib.admin import apply_timezone_from_config, migrate_hotspot_settings_from_wrolpi_config
apply_timezone_from_config()
# Copy legacy hotspot settings from wrolpi.yaml into controller.yaml.
migrate_hotspot_settings_from_wrolpi_config()
# Apply Samba config if enabled.
from controller.lib.samba import apply_samba_from_config
apply_samba_from_config()
else:
# Drive may be mounted but controller.yaml may not exist yet (e.g., the system
# was set up before the Controller was introduced). Create the marker file.
from controller.lib.config import get_media_directory, save_config
media_dir = get_media_directory()
if media_dir.is_mount():
logger.info("Primary drive is mounted at %s but controller.yaml is missing, creating it", media_dir)
config_dir = media_dir / "config"
config_dir.mkdir(parents=True, exist_ok=True)
try:
save_config()
reload_config_from_drive()
except RuntimeError as e:
logger.warning("Failed to create controller.yaml: %s", e)
from controller.lib.admin import apply_timezone_from_config, migrate_hotspot_settings_from_wrolpi_config
apply_timezone_from_config()
migrate_hotspot_settings_from_wrolpi_config()
from controller.lib.samba import apply_samba_from_config
apply_samba_from_config()
else:
logger.info("Primary drive not mounted - using default configuration")
# Initialize cached status
app.state.cached_status = {}
# Start status worker background task
status_task = asyncio.create_task(status_worker_loop(app))
yield
# Shutdown
logger.info("WROLPi Controller shutting down...")
status_task.cancel()
try:
await status_task
except asyncio.CancelledError:
pass
# Create FastAPI app
app = FastAPI(
title="WROLPi Controller",
description="System management service for WROLPi",
version=__version__,
lifespan=lifespan,
)
# CORS middleware - matches Caddy's permissive policy for offline/local use.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(admin_router)
app.include_router(captive_portal_router)
app.include_router(disks_router)
app.include_router(onboarding_router)
app.include_router(ready_router)
app.include_router(scripts_router)
app.include_router(services_router)
app.include_router(status_router)
def get_host(request: Request) -> str:
"""Get the host for links (use request host, fallback to localhost)."""
host = request.headers.get("host", "localhost")
# Strip port if present
if ":" in host:
host = host.split(":")[0]
return host
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
"""Render the main dashboard UI with real status data."""
# Try to use cached status first
cached = getattr(request.app.state, "cached_status", {})
if cached:
cpu = cached.get("cpu_stats") or {}
memory = cached.get("memory_stats") or {}
load = cached.get("load_stats") or {}
iostat = cached.get("iostat_stats") or {}
uptime = cached.get("uptime_stats") or {}
drives = cached.get("drives_stats") or []
primary_drive = None
for drive in drives:
if drive.get("mount") == "/media/wrolpi":
primary_drive = drive
break
else:
# Fallback to direct calls if cache not yet populated
cpu = get_cpu_status()
memory = get_memory_status()
load = get_load_status()
iostat = get_iostat_status()
uptime = get_uptime_status()
primary_drive = get_primary_drive_status()
# Format storage data
storage = {}
if primary_drive:
free_bytes = primary_drive["size"] - primary_drive["used"]
storage = {
"percent": primary_drive["percent"],
"free_gb": round(free_bytes / (1024 ** 3), 1),
}
# Format uptime
uptime_seconds = uptime.get("uptime_seconds", 0)
if uptime_seconds:
days = uptime_seconds // 86400
hours = (uptime_seconds % 86400) // 3600
uptime_formatted = f"{days}d {hours}h" if days > 0 else f"{hours}h {(uptime_seconds % 3600) // 60}m"
else:
uptime_formatted = "--"
# Get service status
if is_docker_mode():
if can_manage_containers():
services = get_all_containers_status()
else:
services = []
else:
services = get_all_services_status()
# Sort services so problems surface first, then alphabetically. The
# template partitions on each service's "group" (core vs optional).
_status_order = {"failed": 0, "unknown": 1, "stopped": 2, "running": 3}
services = sorted(
services,
key=lambda s: (_status_order.get(s.get("status"), 1), s["name"]),
)
# Root CA download is only useful after the media drive is mounted (so an
# existing CA on the drive is visible) and repair has finished generating
# or refreshing certificates. While repair is running the CA may not exist
# yet or the leaf/Caddyfile may still be mid-update.
script_status = get_script_status()
repair_running = (
script_status.get("running") and script_status.get("script_name") == "repair"
)
cert_download_ready = is_ca_certificate_available() and not repair_running
context = {
"version": __version__,
"docker_mode": is_docker_mode(),
"drive_mounted": is_primary_drive_mounted(),
"hide_cert_banner": get_config_value("hide_cert_banner", False),
"cert_download_ready": cert_download_ready,
"host": get_host(request),
# Real status data
"cpu": {
"percent": cpu["percent"],
"temperature_c": cpu["temperature"], # Field renamed
"fan_rpm": cpu.get("fan_rpm"),
},
"memory": {
"percent": round((memory["used"] / memory["total"]) * 100, 1) if memory["total"] > 0 else 0,
"used_gb": round(memory["used"] / (1024 ** 3), 1),
},
"load": {
"load_1min": load["minute_1"], # Field renamed
},
"storage": storage,
"iostat": {
"percent_iowait": iostat.get("percent_iowait"),
},
"uptime": {
"formatted": uptime_formatted,
"seconds": uptime_seconds,
},
# Real service status
"services": services,
}
return templates.TemplateResponse(request, "index.html", context)
@app.get("/api/health", response_model=HealthResponse)
async def health_check() -> HealthResponse:
"""
Health check endpoint.
Returns basic status for monitoring and load balancer checks.
"""
return HealthResponse(
status="healthy",
version=__version__,
docker_mode=is_docker_mode(),
drive_mounted=is_primary_drive_mounted(),
)
@app.get("/ca.crt")
async def download_ca_certificate():
"""Serve the WROLPi Root CA certificate for browser trust setup.
Only available after the media drive is mounted so an existing CA on the
drive can be served (and never a transient pre-onboarding path).
"""
from fastapi.responses import JSONResponse
if not is_docker_mode() and not is_primary_drive_mounted():
return JSONResponse(
status_code=404,
content={"error": "CA certificate not available until the media drive is mounted"},
)
ca_path = get_ca_certificate_path()
if not ca_path.is_file():
return JSONResponse(status_code=404, content={"error": "CA certificate not found"})
return FileResponse(
path=ca_path,
media_type="application/x-x509-ca-cert",
filename="wrolpi-ca.crt",
headers={"Content-Disposition": 'attachment; filename="wrolpi-ca.crt"'},
)
@app.post("/api/hide-cert-banner")
async def hide_cert_banner():
"""Permanently hide the HTTPS certificate setup banner."""
update_config("hide_cert_banner", True)
save_config()
return {"ok": True}