mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 10:51:36 -04:00
* 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>
332 lines
11 KiB
Python
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}
|