From 284924e86debbae1b33175eae0ceebc5e51f73b4 Mon Sep 17 00:00:00 2001 From: Christoph Gasser Date: Mon, 27 Apr 2026 14:36:02 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20release=20=E2=80=94=20Salus=20by=20St?= =?UTF-8?q?ranto=20v1.6.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FastAPI/Jinja2 web app for viewing and rebooting TP-Link Omada APs across all sites. Authentik OIDC auth, SQLite audit log, Docker deploy. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 30 ++++ .gitignore | 6 + CLAUDE.md | 73 +++++++++ Dockerfile | 26 ++++ README.md | 192 +++++++++++++++++++++++ app/__init__.py | 0 app/auth.py | 118 ++++++++++++++ app/database.py | 36 +++++ app/main.py | 308 +++++++++++++++++++++++++++++++++++++ app/omada.py | 214 ++++++++++++++++++++++++++ app/static/.gitkeep | 0 app/templates/audit.html | 132 ++++++++++++++++ app/templates/base.html | 103 +++++++++++++ app/templates/index.html | 321 +++++++++++++++++++++++++++++++++++++++ app/templates/login.html | 38 +++++ docker-compose.yml | 40 +++++ requirements.txt | 9 ++ 17 files changed, 1646 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/auth.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/omada.py create mode 100644 app/static/.gitkeep create mode 100644 app/templates/audit.html create mode 100644 app/templates/base.html create mode 100644 app/templates/index.html create mode 100644 app/templates/login.html create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..51731f3 --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# Copy this file to .env and fill in your values +# .env is never committed (see .gitignore) + +# --- Dev mode (set to true to skip Authentik login entirely) --- +AUTH_DISABLED=false + +# --- Omada Controller --- +OMADA_BASE_URL=https://sdn.qwe.stranto.com:8043/ +OMADA_USERNAME=api-user +OMADA_PASSWORD=secret +OMADA_VERIFY_SSL=false +# OpenAPI client credentials — required for reboot on Omada Controller v5.9+/v6+ +# Create in Omada Controller: Settings → OpenAPI → Add Role → Add Client +OMADA_CLIENT_ID= +OMADA_CLIENT_SECRET= + +# --- Authentik OIDC --- +AUTHENTIK_ISSUER=https://auth.stranto.com/application/o/qwe-salus/ +AUTHENTIK_CLIENT_ID= +AUTHENTIK_CLIENT_SECRET= +# For local dev, use localhost; for production use the public URL +AUTHENTIK_REDIRECT_URI=http://localhost:8080/auth/callback + +# --- Session --- +# Generate with: python -c "import secrets; print(secrets.token_hex(32))" +SESSION_SECRET_KEY=changeme_replace_with_random_string + +# --- Database --- +# Defaults to /data/audit.db (Docker). Override for local dev: +DB_PATH=./audit.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e0a088c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +*.db +__pycache__/ +*.py[cod] +.venv/ +venv/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f1072c1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,73 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +```bash +# Local setup +python -m venv .venv +source .venv/Scripts/activate # Windows Git Bash +pip install -r requirements.txt +cp .env.example .env # fill in values + +# Run locally (pick any free port; earlier dev sessions may occupy 8080–8094) +uvicorn app.main:app --reload --port 8080 + +# Skip Authentik login entirely during local dev +AUTH_DISABLED=true uvicorn app.main:app --reload --port 8080 + +# Docker +docker build -t omada-ap-manager:latest . +docker compose up +``` + +There are no tests and no linter configured. + +## Architecture + +### Request flow + +``` +Browser → FastAPI (main.py) + ├── auth.py – Authentik OIDC + session management + ├── omada.py – Omada Controller API client (singleton) + └── database.py – SQLAlchemy / SQLite audit log +``` + +Templates extend `base.html` (nav, footer, toast system). TailwindCSS is loaded from CDN — no build step. + +### Critical: module-level `os.getenv()` ordering + +`app/main.py` calls `load_dotenv()` **before** any `from app.*` imports. `omada.py` and `auth.py` read their config via `os.getenv()` at module load time, so `.env` must be loaded first. Moving `load_dotenv()` below those imports silently breaks all env var config. + +### OmadaClient (`app/omada.py`) + +A module-level singleton (`omada_client`). Key behaviour: + +- **Two separate `httpx.AsyncClient` instances**: `_get_client()` carries the web session cookie (`TPOMADA_SESSIONID`); `_get_openapi_client()` is cookie-free. Mixing them causes 401s on the OpenAPI token endpoint. +- **Auth flow**: `_fetch_omadac_id()` → `_login()` (username/password → CSRF token + session cookie) → `_fetch_sites()`. `_ensure_ready()` gates all public methods. +- **Sites are fetched from** `GET /api/v2/users/current` → `result.privilege.sites[]`, not `/api/v2/sites` (which requires root admin and returns empty for regular admins). +- **Devices list**: `GET /{omadacId}/api/v2/sites/{siteKey}/devices` filtered by `type == "ap"`. There is no `/aps` endpoint in v6.x. +- **Reboot**: `POST /{omadacId}/api/v2/sites/{siteKey}/cmd/devices/{mac}/reboot` via the web session client. Response `errorCode: -39009` with `"Rebooting... Please wait."` is the success response — treat it as non-error alongside `0`. OpenAPI client credentials (`OMADA_CLIENT_ID`/`OMADA_CLIENT_SECRET`) are no longer used for reboot. +- **Session expiry** is handled in `_request_with_retry`: error codes `-1006`, `-1003`, `-30109` trigger automatic re-login. +- **Uptime safety check**: `reboot_ap()` re-fetches live uptime before issuing the command; it raises if `uptimeLong < 300` seconds (5 minutes). The UI also disables the reboot button client-side for the same condition. + +### Authentication (`app/auth.py`) + +Authentik OIDC Authorization Code Flow. The JWT `id_token` is decoded **without signature verification** (Authentik is a trusted internal IdP). User dict `{username, email, name, sub}` is stored in the Starlette signed-cookie session. + +`AUTH_DISABLED=true` injects a hardcoded `DEV_USER` on `/auth/login` — no Authentik required. + +All POST API endpoints (`/api/reboot`, `/api/reboot-bulk`) validate a CSRF token stored in the session and echoed in the JSON request body. + +### Database (`app/database.py`) + +Single table `reboot_log` written on every reboot attempt (success and error). `DB_PATH` defaults to `/data/audit.db` (Docker volume mount). Override to `./audit.db` for local dev. + +### Deployment + +- Docker exposes port `8080`; `docker-compose.yml` maps it to host port `8098`. +- Uvicorn runs with `--proxy-headers --forwarded-allow-ips=*` to trust `X-Forwarded-Proto` from Nginx Proxy Manager. +- The `/health` endpoint is used by the Docker healthcheck. +- `/debug/sites` is a diagnostic endpoint that lists all accessible Omada sites — useful for verifying credentials without touching the UI. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e2c02a3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Create non-root user +RUN addgroup --system --gid 1001 appgroup && \ + adduser --system --uid 1001 --ingroup appgroup --no-create-home appuser + +# Install dependencies first for layer caching +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application source +COPY app/ ./app/ + +# Data directory (SQLite DB lives here, mounted as a volume) +RUN mkdir -p /data && chown appuser:appgroup /data + +USER appuser + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')" || exit 1 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers", "--forwarded-allow-ips=*"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..107fcc7 --- /dev/null +++ b/README.md @@ -0,0 +1,192 @@ +# Omada AP Manager + +A web-based tool for viewing and rebooting TP-Link Omada access points. +Secured via Authentik OIDC, with a full audit log exported as CSV. + +--- + +## Features + +- Live AP list fetched from Omada Controller (status, uptime, model, IP, MAC) +- Single and bulk reboot with confirmation modal +- Full audit log (who rebooted which AP, when, and whether it succeeded) +- CSV export of the entire audit log +- Authentik OIDC login (Authorization Code Flow) +- Deployable as a Docker container via Portainer + +--- + +## Prerequisites + +| Component | Version | +|-----------|---------| +| Omada Controller | 5.x or later | +| Authentik | Any recent self-hosted version | +| Docker / Portainer | Docker 20+ | +| Nginx Proxy Manager | Any recent version | + +--- + +## 1. Authentik: Create an OAuth2 Provider + Application + +### 1.1 Create an OAuth2 Provider + +1. In Authentik, go to **Admin → Providers → Create**. +2. Choose **OAuth2/OpenID Provider**. +3. Fill in: + - **Name**: `omada-ap-manager` + - **Authorization flow**: your default authorization flow + - **Client type**: `Confidential` + - **Client ID**: auto-generated (copy it) + - **Client Secret**: auto-generated (copy it) + - **Redirect URIs**: `https://salus.qwe.stranto.com/auth/callback` + - **Scopes**: `openid`, `profile`, `email` + - **Subject mode**: `Based on the User's username` (or `hashed user ID`) +4. Save. + +### 1.2 Create an Application + +1. Go to **Admin → Applications → Create**. +2. Fill in: + - **Name**: `Omada AP Manager` + - **Slug**: `qwe-salus` + - **Provider**: select the provider created above +3. Save. + +### 1.3 Note the Issuer URL + +The issuer URL follows the pattern: +``` +https:///application/o// +``` +Example: `https://auth.stranto.com/application/o/qwe-salus/` + +--- + +## 2. Omada Controller: Create an API User + +1. In Omada Controller, go to **Settings → Administrators**. +2. Create a **local admin** account (not linked to OIDC): + - Username: `api-user` + - Password: a strong, unique password + - Role: **Viewer** is enough for listing APs; needs **Administrator** role to send reboot commands. +3. Note the username and password — these go into `OMADA_USERNAME` / `OMADA_PASSWORD`. + +**Required permissions for the API user:** +- Read access to site and AP data +- Execute device commands (for reboot) + +> If self-signed certs are used, set `OMADA_VERIFY_SSL=false`. + +--- + +## 3. Deploy via Portainer (Stack) + +### 3.1 Build the image first (on the Docker host) + +```bash +git clone omada-ap-manager +cd omada-ap-manager +docker build -t omada-ap-manager:latest . +``` + +Or add a `build: .` section in the stack (Portainer will build it if the source is available). + +### 3.2 Create a Portainer Stack + +1. In Portainer, go to **Stacks → Add stack**. +2. Paste the contents of `docker-compose.yml`. +3. Fill in all environment variable values (see table below). +4. Click **Deploy the stack**. + +### Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `OMADA_BASE_URL` | Base URL of the Omada Controller | `https://192.168.1.1:8043` | +| `OMADA_USERNAME` | Local Omada admin username | `api-user` | +| `OMADA_PASSWORD` | Local Omada admin password | `s3cr3t` | +| `OMADA_SITE_NAME` | Site name in Omada | `Default` | +| `OMADA_VERIFY_SSL` | Set to `false` for self-signed certs | `false` | +| `AUTHENTIK_ISSUER` | OIDC issuer URL from Authentik | `https://auth.example.com/application/o/slug/` | +| `AUTHENTIK_CLIENT_ID` | OAuth2 client ID from Authentik | `abc123...` | +| `AUTHENTIK_CLIENT_SECRET` | OAuth2 client secret from Authentik | `xyz789...` | +| `AUTHENTIK_REDIRECT_URI` | Must match what Authentik has configured | `https://salus.example.com/auth/callback` | +| `SESSION_SECRET_KEY` | Random secret for cookie signing — **change this!** | `openssl rand -hex 32` | +| `DB_PATH` | Path to the SQLite database inside the container | `/data/audit.db` | + +### Generating a SESSION_SECRET_KEY + +```bash +openssl rand -hex 32 +``` + +--- + +## 4. Nginx Proxy Manager Setup + +1. Add a **Proxy Host**: + - **Domain Names**: `salus.qwe.stranto.com` + - **Forward Hostname / IP**: the Docker host IP (or container name if on the same network) + - **Forward Port**: `8098` + - Enable **Websockets Support** (optional but harmless) +2. Under **SSL**, issue/select a Let's Encrypt certificate. +3. **Important**: The app uses `--proxy-headers` in uvicorn so it correctly handles `X-Forwarded-For` and `X-Forwarded-Proto` from NPM. + +--- + +## 5. Accessing the Audit Log CSV Export + +Once logged in, navigate to **Audit Log** (top navigation) and click **Export CSV**. + +The file is named `audit_log_YYYY-MM-DD.csv` and contains: + +| Column | Description | +|--------|-------------| +| Timestamp (UTC) | ISO 8601 timestamp | +| Username | OIDC `preferred_username` | +| Email | OIDC `email` claim | +| AP Name | Name as shown in Omada | +| MAC | AP MAC address | +| IP | AP IP address | +| Result | `success` or `error` | +| Error | Error message if result is `error` | + +--- + +## Local Development + +```bash +# Create a .env file +cp .env.example .env # edit with your values + +pip install -r requirements.txt + +# SQLite DB will be written to /data/audit.db by default +# override for local dev: +export DB_PATH=./audit.db + +uvicorn app.main:app --reload --port 8080 +``` + +--- + +## Project Structure + +``` +omada-ap-manager/ +├── app/ +│ ├── main.py # FastAPI routes +│ ├── auth.py # Authentik OIDC logic +│ ├── omada.py # Omada Controller API client +│ ├── database.py # SQLAlchemy models + DB init +│ ├── templates/ +│ │ ├── base.html # Nav, toast system +│ │ ├── login.html # Login landing page +│ │ ├── index.html # AP list + reboot UI +│ │ └── audit.html # Audit log + CSV export +│ └── static/ +├── Dockerfile +├── docker-compose.yml +└── requirements.txt +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..cfce4bc --- /dev/null +++ b/app/auth.py @@ -0,0 +1,118 @@ +import json +import base64 +import secrets +import os +import logging +from typing import Optional +from urllib.parse import urlencode + +import httpx +from fastapi import Request, HTTPException +from fastapi.responses import RedirectResponse + +logger = logging.getLogger(__name__) + +AUTHENTIK_ISSUER = os.getenv("AUTHENTIK_ISSUER", "").rstrip("/") +AUTHENTIK_CLIENT_ID = os.getenv("AUTHENTIK_CLIENT_ID", "") +AUTHENTIK_CLIENT_SECRET = os.getenv("AUTHENTIK_CLIENT_SECRET", "") +AUTHENTIK_REDIRECT_URI = os.getenv("AUTHENTIK_REDIRECT_URI", "http://localhost:8080/auth/callback") +SESSION_SECRET_KEY = os.getenv("SESSION_SECRET_KEY", "changeme_please_set_in_env") + +_oidc_config: Optional[dict] = None + + +async def get_oidc_config() -> dict: + global _oidc_config + if _oidc_config is None: + url = f"{AUTHENTIK_ISSUER}/.well-known/openid-configuration" + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get(url) + resp.raise_for_status() + _oidc_config = resp.json() + logger.info("OIDC config loaded from %s", url) + return _oidc_config + + +async def build_login_url(request: Request) -> str: + config = await get_oidc_config() + state = secrets.token_urlsafe(16) + nonce = secrets.token_urlsafe(16) + request.session["oauth_state"] = state + request.session["oauth_nonce"] = nonce + params = { + "response_type": "code", + "client_id": AUTHENTIK_CLIENT_ID, + "redirect_uri": AUTHENTIK_REDIRECT_URI, + "scope": "openid profile email", + "state": state, + "nonce": nonce, + } + return f"{config['authorization_endpoint']}?{urlencode(params)}" + + +def _decode_jwt_payload(token: str) -> dict: + """Decode JWT payload without signature verification (Authentik is trusted).""" + try: + payload_b64 = token.split(".")[1] + padding = "=" * (4 - len(payload_b64) % 4) + return json.loads(base64.urlsafe_b64decode(payload_b64 + padding)) + except Exception: + return {} + + +async def exchange_code(request: Request) -> dict: + """Handle OIDC callback: exchange code for tokens, extract user info.""" + config = await get_oidc_config() + + code = request.query_params.get("code") + state = request.query_params.get("state") + error = request.query_params.get("error") + + if error: + raise HTTPException(status_code=400, detail=f"OAuth error: {error}") + if not code: + raise HTTPException(status_code=400, detail="Missing authorization code") + if state != request.session.get("oauth_state"): + raise HTTPException(status_code=400, detail="OAuth state mismatch") + + async with httpx.AsyncClient(timeout=15.0) as client: + token_resp = await client.post( + config["token_endpoint"], + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": AUTHENTIK_REDIRECT_URI, + "client_id": AUTHENTIK_CLIENT_ID, + "client_secret": AUTHENTIK_CLIENT_SECRET, + }, + ) + token_resp.raise_for_status() + tokens = token_resp.json() + + claims = _decode_jwt_payload(tokens.get("id_token", "")) + + user = { + "username": claims.get("preferred_username") or claims.get("sub", "unknown"), + "email": claims.get("email", ""), + "name": claims.get("name", ""), + "sub": claims.get("sub", ""), + } + request.session["user"] = user + request.session.pop("oauth_state", None) + request.session.pop("oauth_nonce", None) + logger.info("User logged in: %s", user["username"]) + return user + + +def get_current_user(request: Request) -> Optional[dict]: + return request.session.get("user") + + +def require_auth(request: Request) -> dict: + user = get_current_user(request) + if not user: + raise HTTPException( + status_code=302, + headers={"Location": "/auth/login"}, + ) + return user diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..480295b --- /dev/null +++ b/app/database.py @@ -0,0 +1,36 @@ +import os +import datetime +from sqlalchemy import create_engine, Column, Integer, String, DateTime +from sqlalchemy.orm import declarative_base, sessionmaker + +DATABASE_URL = f"sqlite:///{os.getenv('DB_PATH', '/data/audit.db')}" + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +class RebootLog(Base): + __tablename__ = "reboot_log" + + id = Column(Integer, primary_key=True, index=True) + timestamp = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) + username = Column(String, nullable=False) + user_email = Column(String, nullable=False) + ap_name = Column(String, nullable=False) + ap_mac = Column(String, nullable=False) + ap_ip = Column(String, nullable=True) + result = Column(String, nullable=False) # "success" | "error" + error_message = Column(String, nullable=True) + + +def init_db(): + Base.metadata.create_all(bind=engine) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..a5af512 --- /dev/null +++ b/app/main.py @@ -0,0 +1,308 @@ +import csv +import io +import datetime +import secrets +import logging +import os +from typing import Optional + +# Load .env before any module-level os.getenv() calls in auth/omada/database +from dotenv import load_dotenv +load_dotenv() + +from fastapi import FastAPI, Request, Depends, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse +from fastapi.templating import Jinja2Templates +from fastapi.staticfiles import StaticFiles +from starlette.middleware.sessions import SessionMiddleware +from sqlalchemy.orm import Session + +from app.database import init_db, get_db, RebootLog +from app.auth import ( + SESSION_SECRET_KEY, + build_login_url, + exchange_code, + get_current_user, +) +from app.omada import omada_client + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +logger = logging.getLogger(__name__) + +app = FastAPI(title="Salus by Stranto", docs_url=None, redoc_url=None) + +app.add_middleware( + SessionMiddleware, + secret_key=SESSION_SECRET_KEY, + max_age=86400, + same_site="lax", + https_only=False, +) + +BASE_DIR = os.path.dirname(__file__) +templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates")) +app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static") + + +@app.on_event("startup") +async def startup(): + init_db() + logger.info("Database initialized") + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +@app.get("/debug/sites") +async def debug_sites(): + """Temporary: lists all accessible Omada sites.""" + try: + sites = await omada_client.get_all_sites() + return {"sites": [{"name": k, "key": v} for k, v in sites.items()]} + except Exception as exc: + return {"error": str(exc)} + + +# --------------------------------------------------------------------------- +# Auth helpers +# --------------------------------------------------------------------------- + +def _redirect_login(): + return RedirectResponse("/auth/login", status_code=302) + + +def _get_user_or_redirect(request: Request): + """Return user dict or a RedirectResponse. Callers must check the type.""" + return get_current_user(request) + + +def _csrf_token(request: Request) -> str: + if "csrf_token" not in request.session: + request.session["csrf_token"] = secrets.token_hex(32) + return request.session["csrf_token"] + + +def _verify_csrf(request: Request, token: str): + expected = request.session.get("csrf_token") + if not expected or not secrets.compare_digest(expected, token): + raise HTTPException(status_code=403, detail="CSRF token invalid") + + +AUTH_DISABLED = os.getenv("AUTH_DISABLED", "false").strip().lower() == "true" + +DEV_USER = {"username": "dev-user", "email": "dev@localhost", "name": "Dev User", "sub": "dev"} + + +# --------------------------------------------------------------------------- +# Auth routes +# --------------------------------------------------------------------------- + +@app.get("/auth/login", response_class=HTMLResponse) +async def login(request: Request): + if AUTH_DISABLED: + request.session["user"] = DEV_USER + return RedirectResponse("/", status_code=302) + if get_current_user(request): + return RedirectResponse("/", status_code=302) + return templates.TemplateResponse("login.html", {"request": request}) + + +@app.get("/auth/login/start") +async def login_start(request: Request): + if AUTH_DISABLED: + request.session["user"] = DEV_USER + return RedirectResponse("/", status_code=302) + url = await build_login_url(request) + return RedirectResponse(url, status_code=302) + + +@app.get("/auth/callback") +async def callback(request: Request): + await exchange_code(request) + return RedirectResponse("/", status_code=302) + + +@app.get("/auth/logout") +async def logout(request: Request): + request.session.clear() + return RedirectResponse("/auth/login", status_code=302) + + +# --------------------------------------------------------------------------- +# Main pages +# --------------------------------------------------------------------------- + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + user = get_current_user(request) + if not user: + return _redirect_login() + + error: Optional[str] = None + aps: list = [] + try: + aps = await omada_client.get_aps() + except Exception as exc: + logger.error("Failed to fetch APs: %s", exc) + error = str(exc) + + csrf = _csrf_token(request) + return templates.TemplateResponse("index.html", { + "request": request, + "user": user, + "aps": aps, + "error": error, + "csrf_token": csrf, + }) + + +@app.get("/audit", response_class=HTMLResponse) +async def audit_page( + request: Request, + db: Session = Depends(get_db), + username: str = "", + ap_name: str = "", +): + user = get_current_user(request) + if not user: + return _redirect_login() + + query = db.query(RebootLog).order_by(RebootLog.timestamp.desc()) + if username.strip(): + query = query.filter(RebootLog.username.icontains(username.strip())) + if ap_name.strip(): + query = query.filter(RebootLog.ap_name.icontains(ap_name.strip())) + logs = query.all() + + return templates.TemplateResponse("audit.html", { + "request": request, + "user": user, + "logs": logs, + "filter_username": username, + "filter_ap": ap_name, + }) + + +@app.get("/audit/export") +async def export_csv(request: Request, db: Session = Depends(get_db)): + user = get_current_user(request) + if not user: + return _redirect_login() + + logs = db.query(RebootLog).order_by(RebootLog.timestamp.desc()).all() + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(["Timestamp (UTC)", "Username", "Email", "AP Name", "MAC", "IP", "Result", "Error"]) + for log in logs: + writer.writerow([ + log.timestamp.isoformat(timespec="seconds"), + log.username, + log.user_email, + log.ap_name, + log.ap_mac, + log.ap_ip or "", + log.result, + log.error_message or "", + ]) + + filename = f"audit_log_{datetime.date.today().isoformat()}.csv" + return StreamingResponse( + io.BytesIO(buf.getvalue().encode("utf-8-sig")), + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +# --------------------------------------------------------------------------- +# API endpoints +# --------------------------------------------------------------------------- + +@app.post("/api/reboot") +async def api_reboot(request: Request, db: Session = Depends(get_db)): + user = get_current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + + body = await request.json() + _verify_csrf(request, body.get("csrf_token", "")) + + mac: str = body.get("mac", "").strip() + ap_name: str = body.get("name", "") + ap_ip: str = body.get("ip", "") + site_key: str = body.get("site_key", "") + + if not mac: + raise HTTPException(status_code=400, detail="MAC address required") + + result = "success" + error_msg: Optional[str] = None + try: + await omada_client.reboot_ap(mac, site_key) + logger.info("Reboot sent for AP %s (%s) by %s", ap_name, mac, user["username"]) + except Exception as exc: + result = "error" + error_msg = str(exc) + logger.error("Reboot failed for AP %s (%s): %s", ap_name, mac, exc) + + db.add(RebootLog( + timestamp=datetime.datetime.utcnow(), + username=user["username"], + user_email=user["email"], + ap_name=ap_name, + ap_mac=mac, + ap_ip=ap_ip, + result=result, + error_message=error_msg, + )) + db.commit() + + if result == "error": + raise HTTPException(status_code=502, detail=error_msg) + return {"status": "ok", "mac": mac} + + +@app.post("/api/reboot-bulk") +async def api_reboot_bulk(request: Request, db: Session = Depends(get_db)): + user = get_current_user(request) + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + + body = await request.json() + _verify_csrf(request, body.get("csrf_token", "")) + + aps: list = body.get("aps", []) + if not aps: + raise HTTPException(status_code=400, detail="No APs specified") + + results = [] + for ap in aps: + mac = ap.get("mac", "").strip() + ap_name = ap.get("name", "") + ap_ip = ap.get("ip", "") + site_key = ap.get("site_key", "") + result = "success" + error_msg = None + try: + await omada_client.reboot_ap(mac, site_key) + logger.info("Bulk reboot sent for AP %s (%s) by %s", ap_name, mac, user["username"]) + except Exception as exc: + result = "error" + error_msg = str(exc) + logger.error("Bulk reboot failed for AP %s (%s): %s", ap_name, mac, exc) + + db.add(RebootLog( + timestamp=datetime.datetime.utcnow(), + username=user["username"], + user_email=user["email"], + ap_name=ap_name, + ap_mac=mac, + ap_ip=ap_ip, + result=result, + error_message=error_msg, + )) + results.append({"mac": mac, "name": ap_name, "result": result, "error": error_msg}) + + db.commit() + return {"results": results} diff --git a/app/omada.py b/app/omada.py new file mode 100644 index 0000000..05ea806 --- /dev/null +++ b/app/omada.py @@ -0,0 +1,214 @@ +import os +import time +import logging +from typing import Optional + +import httpx + +logger = logging.getLogger(__name__) + +OMADA_BASE_URL = os.getenv("OMADA_BASE_URL", "https://192.168.1.1:8043").rstrip("/") +OMADA_USERNAME = os.getenv("OMADA_USERNAME", "") +OMADA_PASSWORD = os.getenv("OMADA_PASSWORD", "") +OMADA_VERIFY_SSL = os.getenv("OMADA_VERIFY_SSL", "true").strip().lower() != "false" +OMADA_CLIENT_ID = os.getenv("OMADA_CLIENT_ID", "") +OMADA_CLIENT_SECRET = os.getenv("OMADA_CLIENT_SECRET", "") + + +class OmadaClient: + def __init__(self): + self._client: Optional[httpx.AsyncClient] = None + self._omadac_id: Optional[str] = None + self._token: Optional[str] = None + # {site_name: site_key} + self._sites: Optional[dict[str, str]] = None + # OpenAPI OAuth2 + self._openapi_client: Optional[httpx.AsyncClient] = None + self._openapi_token: Optional[str] = None + self._openapi_token_expires: float = 0.0 + + def _get_client(self) -> httpx.AsyncClient: + """Session client — carries web-login cookies for the v2 API.""" + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient( + verify=OMADA_VERIFY_SSL, + timeout=30.0, + follow_redirects=True, + ) + return self._client + + def _get_openapi_client(self) -> httpx.AsyncClient: + """Cookie-free client for OpenAPI calls — session cookies must not bleed in.""" + if self._openapi_client is None or self._openapi_client.is_closed: + self._openapi_client = httpx.AsyncClient( + verify=OMADA_VERIFY_SSL, + timeout=30.0, + follow_redirects=True, + cookies={}, + ) + return self._openapi_client + + def _auth_headers(self) -> dict: + return {"Csrf-Token": self._token} if self._token else {} + + async def _fetch_omadac_id(self) -> str: + resp = await self._get_client().get(f"{OMADA_BASE_URL}/api/info") + resp.raise_for_status() + data = resp.json() + if data.get("errorCode", -1) != 0: + raise RuntimeError(f"Omada /api/info error: {data.get('msg')}") + self._omadac_id = data["result"]["omadacId"] + logger.info("Omada controller ID: %s", self._omadac_id) + return self._omadac_id + + async def _login(self) -> str: + if not self._omadac_id: + await self._fetch_omadac_id() + resp = await self._get_client().post( + f"{OMADA_BASE_URL}/{self._omadac_id}/api/v2/login", + json={"username": OMADA_USERNAME, "password": OMADA_PASSWORD}, + ) + resp.raise_for_status() + data = resp.json() + if data.get("errorCode", -1) != 0: + raise RuntimeError(f"Omada login failed: {data.get('msg')}") + self._token = data["result"]["token"] + logger.info("Omada web-API login successful") + return self._token + + async def _fetch_sites(self) -> dict[str, str]: + """Return {site_name: site_key} for all sites accessible to this user.""" + resp = await self._get_client().get( + f"{OMADA_BASE_URL}/{self._omadac_id}/api/v2/users/current", + headers=self._auth_headers(), + ) + resp.raise_for_status() + data = resp.json() + if data.get("errorCode", -1) != 0: + raise RuntimeError(f"Omada users/current error: {data.get('msg')}") + raw = data.get("result", {}).get("privilege", {}).get("sites", []) + self._sites = {s["name"]: s["key"] for s in raw} + logger.info("Loaded %d Omada sites", len(self._sites)) + return self._sites + + async def _ensure_ready(self): + if not self._token: + await self._login() + if self._sites is None: + await self._fetch_sites() + + async def _request_with_retry(self, method: str, url: str, **kwargs): + client = self._get_client() + resp = await client.request(method, url, headers=self._auth_headers(), **kwargs) + resp.raise_for_status() + data = resp.json() + if data.get("errorCode", 0) in (-1006, -1003, -30109): + self._token = None + self._sites = None + await self._login() + resp = await client.request(method, url, headers=self._auth_headers(), **kwargs) + resp.raise_for_status() + data = resp.json() + if data.get("errorCode", 0) != 0: + raise RuntimeError(f"Omada API error {data.get('errorCode')}: {data.get('msg')}") + return data + + # ── OpenAPI OAuth2 (commands) ────────────────────────────────────────────── + + async def _get_openapi_token(self) -> str: + if self._openapi_token and time.time() < self._openapi_token_expires - 60: + return self._openapi_token + if not self._omadac_id: + await self._fetch_omadac_id() + resp = await self._get_openapi_client().post( + f"{OMADA_BASE_URL}/openapi/authorize/token", + data={ + "grantType": "client_credentials", + "clientId": OMADA_CLIENT_ID, + "clientSecret": OMADA_CLIENT_SECRET, + }, + ) + resp.raise_for_status() + data = resp.json() + if data.get("errorCode", -1) != 0: + raise RuntimeError(f"Omada OpenAPI token error: {data.get('msg')}") + self._openapi_token = data["result"]["accessToken"] + self._openapi_token_expires = time.time() + data["result"].get("expiresIn", 3600) + logger.info("Omada OpenAPI token acquired") + return self._openapi_token + + async def _openapi_post(self, path: str, body: dict) -> dict: + token = await self._get_openapi_token() + resp = await self._get_openapi_client().post( + f"{OMADA_BASE_URL}/openapi/v1/{self._omadac_id}/{path}", + headers={"Authorization": f"AccessToken={token}"}, + json=body, + ) + resp.raise_for_status() + data = resp.json() + if data.get("errorCode", 0) != 0: + raise RuntimeError(f"Omada OpenAPI error {data.get('errorCode')}: {data.get('msg')}") + return data + + # ── Public methods ───────────────────────────────────────────────────────── + + async def get_all_sites(self) -> dict[str, str]: + await self._ensure_ready() + return self._sites + + async def get_aps(self) -> list[dict]: + """Fetch APs from every accessible site and return a combined list.""" + await self._ensure_ready() + all_aps = [] + for site_name, site_key in self._sites.items(): + try: + data = await self._request_with_retry( + "GET", + f"{OMADA_BASE_URL}/{self._omadac_id}/api/v2/sites/{site_key}/devices", + ) + devices = data.get("result") or [] + for d in devices: + if d.get("type") == "ap": + d["_site_name"] = site_name + d["_site_key"] = site_key + all_aps.append(d) + except Exception as exc: + logger.warning("Failed to fetch devices for site '%s': %s", site_name, exc) + logger.info("Fetched %d APs across %d sites", len(all_aps), len(self._sites)) + return all_aps + + async def _get_ap_uptime(self, mac: str, site_key: str) -> int: + """Return current uptimeLong (seconds) for a specific AP.""" + data = await self._request_with_retry( + "GET", + f"{OMADA_BASE_URL}/{self._omadac_id}/api/v2/sites/{site_key}/devices", + ) + for device in (data.get("result") or []): + if device.get("mac") == mac and device.get("type") == "ap": + return int(device.get("uptimeLong", 0)) + raise RuntimeError(f"AP {mac} not found in site {site_key}") + + async def reboot_ap(self, mac: str, site_key: str, min_uptime: int = 300) -> dict: + await self._ensure_ready() + uptime = await self._get_ap_uptime(mac, site_key) + if uptime < min_uptime: + mins = uptime // 60 + secs = uptime % 60 + raise RuntimeError( + f"AP has only been online for {mins}m {secs}s. " + f"A minimum uptime of {min_uptime // 60} minutes is required before rebooting." + ) + resp = await self._get_client().post( + f"{OMADA_BASE_URL}/{self._omadac_id}/api/v2/sites/{site_key}/cmd/devices/{mac}/reboot", + headers=self._auth_headers(), + json={}, + ) + resp.raise_for_status() + data = resp.json() + # -39009 = "Rebooting... Please wait." — Omada's success code for this command + if data.get("errorCode", 0) not in (0, -39009): + raise RuntimeError(f"Omada API error {data.get('errorCode')}: {data.get('msg')}") + return data + + +omada_client = OmadaClient() diff --git a/app/static/.gitkeep b/app/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/templates/audit.html b/app/templates/audit.html new file mode 100644 index 0000000..186ada4 --- /dev/null +++ b/app/templates/audit.html @@ -0,0 +1,132 @@ +{% extends "base.html" %} + +{% block title %}Audit Log – Salus by Stranto{% endblock %} + +{% block content %} +
+ + +
+
+

Audit Log

+

All reboot actions · {{ logs|length }} record{{ 's' if logs|length != 1 }}

+
+ + + + + Export CSV + +
+ + +
+
+ + + + +
+
+ + + + +
+
+ + {% if filter_username or filter_ap %} + + Clear + + {% endif %} +
+
+ + {% if logs %} + +
+ + + + + + + + + + + + + + {% for log in logs %} + + + + + + + + + + {% endfor %} + +
Timestamp (UTC)UserAP NameMACIPResultDetails
+ {{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }} + +
{{ log.username }}
+
{{ log.user_email }}
+
{{ log.ap_name }}{{ log.ap_mac }}{{ log.ap_ip or '—' }} + {% if log.result == 'success' %} + + + + + Success + + {% else %} + + + + + Error + + {% endif %} + + {{ log.error_message or '—' }} +
+
+ + {% else %} +
+ + + +

No log entries found

+ {% if filter_username or filter_ap %} +

Try adjusting your filters.

+ {% else %} +

Reboot actions will appear here.

+ {% endif %} +
+ {% endif %} + +
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..417be55 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,103 @@ + + + + + + {% block title %}Salus by Stranto{% endblock %} + + + + + + + + + + +
+ {% block content %}{% endblock %} +
+ + + + + +
+ + + + {% block scripts %}{% endblock %} + + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..e448d42 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,321 @@ +{% extends "base.html" %} + +{% block title %}Access Points – Salus by Stranto{% endblock %} + +{% block content %} +
+ + +
+
+

Access Points

+

Live from Omada Controller · {{ aps|length }} of {{ aps|length }} device{{ 's' if aps|length != 1 }} across all sites

+
+
+
+ + + + +
+ + +
+
+ + {% if error %} +
+ + + +
+

Failed to connect to Omada Controller

+

{{ error }}

+
+
+ {% endif %} + + {% if aps %} + +
+ + + + + + + + + + + + + + + + {% for ap in aps %} + {% set online = ap.get('statusCategory', 0) == 1 %} + {% set mac = ap.get('mac', '') %} + {% set name = ap.get('name', 'Unknown') %} + {% set ip = ap.get('ip', '') %} + {% set site_name = ap.get('_site_name', '') %} + {% set site_key = ap.get('_site_key', '') %} + {% set uptime_secs = ap.get('uptimeLong', 0) | int %} + {% set reboot_allowed = online and uptime_secs >= 300 %} + + + + + + + + + + + + {% endfor %} + +
+ + NameSiteIP AddressMAC AddressModelStatusUptimeAction
+ + {{ name }}{{ site_name }}{{ ip or '—' }}{{ mac or '—' }}{{ ap.get('model', '—') }} + {% if online %} + + + Online + + {% else %} + + + Offline + + {% endif %} + + {% set up = ap.get('uptimeLong', 0) | int %} + {% if up %} + {{ up // 86400 }}d {{ (up % 86400) // 3600 }}h {{ (up % 3600) // 60 }}m + {% else %}—{% endif %} + + +
+
+ + {% elif not error %} +
+ + + +

No access points found

+

Check your Omada site name configuration.

+
+ {% endif %} + +
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..c41d5fd --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,38 @@ + + + + + + Login – Salus by Stranto + + + +
+
+
+
+ + + +
+

Salus by Stranto

+

Sign in to manage your access points

+
+ + + + + + Sign in with Authentik + + +

+ Access is restricted to authorized users only. +

+
+
+ + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6f25e0e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +version: "3.8" + +services: + omada-ap-manager: + image: omada-ap-manager:latest + build: . + container_name: omada-ap-manager + ports: + - "8098:8080" + volumes: + - omada_data:/data + environment: + # Omada Controller + - OMADA_BASE_URL=https://sdn.qwe.stranto.com:8043/ + - OMADA_USERNAME=api-user + - OMADA_PASSWORD=secret + - OMADA_SITE_NAME=Default + - OMADA_VERIFY_SSL=false + - OMADA_CLIENT_ID= + - OMADA_CLIENT_SECRET= + + # Authentik OIDC + - AUTHENTIK_ISSUER=https://auth.stranto.com/application/o/qwe-salus/ + - AUTHENTIK_CLIENT_ID= + - AUTHENTIK_CLIENT_SECRET= + - AUTHENTIK_REDIRECT_URI=https://salus.qwe.stranto.com/auth/callback + + # Session + - SESSION_SECRET_KEY=changeme_replace_with_random_64_char_string + + # DB path (inside the container, matches the volume mount) + - DB_PATH=/data/audit.db + + restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=false" + +volumes: + omada_data: + driver: local diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..67a8155 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +jinja2==3.1.4 +python-multipart==0.0.12 +httpx==0.27.2 +sqlalchemy==2.0.36 +authlib==1.3.2 +itsdangerous==2.2.0 +python-dotenv==1.0.1