Initial release — Salus by Stranto v1.6.1.0

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 14:36:02 +02:00
commit 284924e86d
17 changed files with 1646 additions and 0 deletions

30
.env.example Normal file
View File

@@ -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

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.env
*.db
__pycache__/
*.py[cod]
.venv/
venv/

73
CLAUDE.md Normal file
View File

@@ -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 80808094)
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.

26
Dockerfile Normal file
View File

@@ -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=*"]

192
README.md Normal file
View File

@@ -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://<your-authentik-domain>/application/o/<application-slug>/
```
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 <this-repo> 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
```

0
app/__init__.py Normal file
View File

118
app/auth.py Normal file
View File

@@ -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

36
app/database.py Normal file
View File

@@ -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()

308
app/main.py Normal file
View File

@@ -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}

214
app/omada.py Normal file
View File

@@ -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()

0
app/static/.gitkeep Normal file
View File

132
app/templates/audit.html Normal file
View File

@@ -0,0 +1,132 @@
{% extends "base.html" %}
{% block title %}Audit Log Salus by Stranto{% endblock %}
{% block content %}
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900">Audit Log</h1>
<p class="text-sm text-gray-500 mt-0.5">All reboot actions · {{ logs|length }} record{{ 's' if logs|length != 1 }}</p>
</div>
<a href="/audit/export"
class="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-500 border border-blue-500 text-white transition-colors self-start sm:self-auto">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
Export CSV
</a>
</div>
<!-- Filters -->
<form method="get" class="flex flex-col sm:flex-row gap-3">
<div class="relative flex-1">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
<input type="text" name="username" value="{{ filter_username }}"
placeholder="Filter by username…"
class="w-full pl-9 pr-3 py-2 text-sm rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
</div>
<div class="relative flex-1">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.14 0"/>
</svg>
<input type="text" name="ap_name" value="{{ filter_ap }}"
placeholder="Filter by AP name…"
class="w-full pl-9 pr-3 py-2 text-sm rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
</div>
<div class="flex gap-2">
<button type="submit"
class="px-4 py-2 text-sm rounded-lg bg-white hover:bg-gray-50 border border-gray-300 text-gray-700 transition-colors">
Filter
</button>
{% if filter_username or filter_ap %}
<a href="/audit"
class="px-4 py-2 text-sm rounded-lg bg-gray-50 hover:bg-gray-100 border border-gray-200 text-gray-500 transition-colors">
Clear
</a>
{% endif %}
</div>
</form>
{% if logs %}
<!-- Table -->
<div class="overflow-x-auto rounded-xl border border-gray-200 shadow-sm">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Timestamp (UTC)</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">User</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">AP Name</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">MAC</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">IP</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Result</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Details</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{% for log in logs %}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-4 py-3 text-gray-500 font-mono text-xs whitespace-nowrap">
{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}
</td>
<td class="px-4 py-3">
<div class="text-gray-900 text-sm font-medium">{{ log.username }}</div>
<div class="text-gray-400 text-xs">{{ log.user_email }}</div>
</td>
<td class="px-4 py-3 text-gray-900 font-medium">{{ log.ap_name }}</td>
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ log.ap_mac }}</td>
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ log.ap_ip or '—' }}</td>
<td class="px-4 py-3">
{% if log.result == 'success' %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 border border-green-200">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/>
</svg>
Success
</span>
{% else %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-50 text-red-700 border border-red-200">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
</svg>
Error
</span>
{% endif %}
</td>
<td class="px-4 py-3 text-gray-400 text-xs max-w-xs truncate" title="{{ log.error_message or '' }}">
{{ log.error_message or '—' }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="flex flex-col items-center justify-center py-20 text-gray-400">
<svg class="w-12 h-12 mb-4 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<p class="text-lg font-medium">No log entries found</p>
{% if filter_username or filter_ap %}
<p class="text-sm mt-1">Try adjusting your filters.</p>
{% else %}
<p class="text-sm mt-1">Reboot actions will appear here.</p>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}

103
app/templates/base.html Normal file
View File

@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-white">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Salus by Stranto{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: { 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8' }
}
}
}
}
</script>
<style>
[x-cloak] { display: none !important; }
.toast { animation: slide-in 0.3s ease-out; }
@keyframes slide-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
</style>
</head>
<body class="h-full text-gray-900 flex flex-col">
<!-- Nav -->
<nav class="bg-white border-b border-gray-200 shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<div class="flex items-center gap-8">
<a href="/" class="flex items-center gap-2 font-bold text-lg text-gray-900 hover:text-blue-600 transition-colors">
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v6m-3-3h6m6 0a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Salus <span class="font-normal text-gray-400 text-sm">by Stranto</span>
</a>
<div class="hidden sm:flex gap-1">
<a href="/"
class="px-3 py-2 rounded-md text-sm font-medium transition-colors
{% if request.url.path == '/' %}bg-gray-100 text-gray-900{% else %}text-gray-600 hover:text-gray-900 hover:bg-gray-100{% endif %}">
Access Points
</a>
<a href="/audit"
class="px-3 py-2 rounded-md text-sm font-medium transition-colors
{% if request.url.path == '/audit' %}bg-gray-100 text-gray-900{% else %}text-gray-600 hover:text-gray-900 hover:bg-gray-100{% endif %}">
Audit Log
</a>
</div>
</div>
{% if user %}
<div class="flex items-center gap-3">
<span class="hidden sm:block text-sm text-gray-500">
<span class="text-gray-900 font-medium">{{ user.username }}</span>
</span>
<a href="/auth/logout"
class="px-3 py-1.5 text-sm rounded-md bg-gray-100 text-gray-600 hover:bg-red-50 hover:text-red-700 border border-gray-200 transition-colors">
Logout
</a>
</div>
{% endif %}
</div>
</div>
</nav>
<!-- Page content -->
<main class="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8">
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="border-t border-gray-200 bg-white mt-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between text-xs text-gray-400">
<span>Version 1.6.1.0</span>
<a href="https://www.stranto.com/" target="_blank" rel="noopener noreferrer"
class="hover:text-blue-500 transition-colors">stranto.com</a>
</div>
</footer>
<!-- Toast container -->
<div id="toast-container" class="fixed bottom-4 right-4 flex flex-col gap-2 z-50 pointer-events-none"></div>
<script>
function showToast(msg, type = 'success') {
const container = document.getElementById('toast-container');
const colors = type === 'success'
? 'bg-green-50 border-green-200 text-green-800'
: 'bg-red-50 border-red-200 text-red-800';
const el = document.createElement('div');
el.className = `toast pointer-events-auto px-4 py-3 rounded-lg border shadow text-sm max-w-xs ${colors}`;
el.textContent = msg;
container.appendChild(el);
setTimeout(() => { el.remove(); }, 4000);
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

321
app/templates/index.html Normal file
View File

@@ -0,0 +1,321 @@
{% extends "base.html" %}
{% block title %}Access Points Salus by Stranto{% endblock %}
{% block content %}
<div class="space-y-6">
<!-- Header row -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900">Access Points</h1>
<p class="text-sm text-gray-500 mt-0.5">Live from Omada Controller · <span id="visible-count">{{ aps|length }}</span> of {{ aps|length }} device{{ 's' if aps|length != 1 }} across all sites</p>
</div>
<div class="flex items-center gap-2">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
</svg>
<input id="filter-name" type="text" placeholder="Filter by name or site…"
class="pl-9 pr-3 py-2 text-sm rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent w-52" />
</div>
<button id="btn-refresh"
class="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-white hover:bg-gray-50 border border-gray-300 text-gray-600 transition-colors">
<svg id="refresh-icon" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Refresh
</button>
<button id="btn-reboot-selected" disabled
class="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-red-600 hover:bg-red-500 disabled:opacity-40 disabled:cursor-not-allowed border border-red-500 text-white transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Reboot selected (<span id="sel-count">0</span>)
</button>
</div>
</div>
{% if error %}
<div class="flex items-start gap-3 p-4 rounded-xl bg-red-50 border border-red-200 text-red-700 text-sm">
<svg class="w-5 h-5 flex-shrink-0 mt-0.5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<div>
<p class="font-semibold">Failed to connect to Omada Controller</p>
<p class="mt-0.5 text-red-600">{{ error }}</p>
</div>
</div>
{% endif %}
{% if aps %}
<!-- Table -->
<div class="overflow-x-auto rounded-xl border border-gray-200 shadow-sm">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left w-10">
<input type="checkbox" id="chk-all"
class="rounded border-gray-300 bg-white text-blue-500 focus:ring-blue-500 cursor-pointer" />
</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Name</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Site</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">IP Address</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">MAC Address</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Model</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Status</th>
<th class="px-4 py-3 text-left font-semibold text-gray-500 uppercase tracking-wide text-xs">Uptime</th>
<th class="px-4 py-3 text-right font-semibold text-gray-500 uppercase tracking-wide text-xs">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white" id="ap-table-body">
{% 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 %}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-4 py-3">
<input type="checkbox" class="ap-checkbox rounded border-gray-300 bg-white text-blue-500 focus:ring-blue-500 cursor-pointer
{% if not reboot_allowed %}opacity-40 cursor-not-allowed{% endif %}"
data-mac="{{ mac }}" data-name="{{ name }}" data-ip="{{ ip }}" data-site-key="{{ site_key }}"
{% if not reboot_allowed %}disabled
title="{% if not online %}AP is offline{% else %}Uptime too low — minimum 5 minutes required{% endif %}"
{% endif %} />
</td>
<td class="px-4 py-3 font-medium text-gray-900">{{ name }}</td>
<td class="px-4 py-3 text-gray-500 text-xs">{{ site_name }}</td>
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ ip or '—' }}</td>
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ mac or '—' }}</td>
<td class="px-4 py-3 text-gray-500">{{ ap.get('model', '—') }}</td>
<td class="px-4 py-3">
{% if online %}
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 border border-green-200">
<span class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
Online
</span>
{% else %}
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500 border border-gray-200">
<span class="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
Offline
</span>
{% endif %}
</td>
<td class="px-4 py-3 text-gray-500 text-xs">
{% set up = ap.get('uptimeLong', 0) | int %}
{% if up %}
{{ up // 86400 }}d {{ (up % 86400) // 3600 }}h {{ (up % 3600) // 60 }}m
{% else %}—{% endif %}
</td>
<td class="px-4 py-3 text-right">
<button
class="btn-reboot px-3 py-1.5 text-xs rounded-lg font-medium transition-colors
{% if reboot_allowed %}bg-red-600 hover:bg-red-500 border border-red-500 text-white cursor-pointer
{% else %}bg-gray-100 border border-gray-200 text-gray-400 cursor-not-allowed opacity-50{% endif %}"
data-mac="{{ mac }}" data-name="{{ name }}" data-ip="{{ ip }}" data-site-key="{{ site_key }}"
{% if not reboot_allowed %}disabled
title="{% if not online %}AP is offline{% else %}Uptime too low ({{ uptime_secs // 60 }}m {{ uptime_secs % 60 }}s) — minimum 5 minutes required{% endif %}"
{% endif %}>
Reboot
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% elif not error %}
<div class="flex flex-col items-center justify-center py-20 text-gray-400">
<svg class="w-12 h-12 mb-4 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.14 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"/>
</svg>
<p class="text-lg font-medium">No access points found</p>
<p class="text-sm mt-1">Check your Omada site name configuration.</p>
</div>
{% endif %}
</div>
<!-- Confirmation Modal -->
<div id="modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" id="modal-backdrop"></div>
<div class="relative bg-white border border-gray-200 rounded-2xl shadow-xl w-full max-w-md p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
</svg>
</div>
<h2 class="text-lg font-semibold text-gray-900">Confirm Reboot</h2>
</div>
<p class="text-gray-600 text-sm mb-6" id="modal-message">Are you sure?</p>
<div class="flex gap-3 justify-end">
<button id="modal-cancel"
class="px-4 py-2 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-200 text-gray-700 transition-colors">
Cancel
</button>
<button id="modal-confirm"
class="px-4 py-2 text-sm rounded-lg bg-red-600 hover:bg-red-500 border border-red-500 text-white font-semibold transition-colors">
Reboot
</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const CSRF_TOKEN = {{ csrf_token | tojson }};
// ── Name / site filter ────────────────────────────────────────────────────
const filterInput = document.getElementById('filter-name');
const visibleCount = document.getElementById('visible-count');
const allRows = [...document.querySelectorAll('#ap-table-body tr')];
filterInput.addEventListener('input', () => {
const q = filterInput.value.toLowerCase().trim();
let shown = 0;
allRows.forEach(row => {
const name = (row.querySelector('td:nth-child(2)')?.textContent || '').toLowerCase();
const site = (row.querySelector('td:nth-child(3)')?.textContent || '').toLowerCase();
const match = !q || name.includes(q) || site.includes(q);
row.style.display = match ? '' : 'none';
if (match) shown++;
});
visibleCount.textContent = shown;
updateBulkButton();
});
// ── Refresh ────────────────────────────────────────────────────────────────
document.getElementById('btn-refresh').addEventListener('click', () => {
const icon = document.getElementById('refresh-icon');
icon.classList.add('animate-spin');
window.location.reload();
});
// ── Checkbox logic ─────────────────────────────────────────────────────────
const chkAll = document.getElementById('chk-all');
const btnRebootSel = document.getElementById('btn-reboot-selected');
const selCount = document.getElementById('sel-count');
function updateBulkButton() {
const checked = [...document.querySelectorAll('.ap-checkbox:checked')]
.filter(cb => cb.closest('tr').style.display !== 'none');
selCount.textContent = checked.length;
btnRebootSel.disabled = checked.length === 0;
}
chkAll?.addEventListener('change', () => {
document.querySelectorAll('.ap-checkbox:not(:disabled)').forEach(cb => {
cb.checked = chkAll.checked;
});
updateBulkButton();
});
document.querySelectorAll('.ap-checkbox').forEach(cb => {
cb.addEventListener('change', updateBulkButton);
});
// ── Modal ──────────────────────────────────────────────────────────────────
const modal = document.getElementById('modal');
const modalMsg = document.getElementById('modal-message');
const modalConfirm = document.getElementById('modal-confirm');
let pendingAction = null;
function showModal(message, onConfirm) {
modalMsg.textContent = message;
pendingAction = onConfirm;
modal.classList.remove('hidden');
}
function hideModal() {
modal.classList.add('hidden');
pendingAction = null;
}
document.getElementById('modal-backdrop').addEventListener('click', hideModal);
document.getElementById('modal-cancel').addEventListener('click', hideModal);
modalConfirm.addEventListener('click', () => {
if (pendingAction) pendingAction();
hideModal();
});
// ── Single reboot ──────────────────────────────────────────────────────────
document.querySelectorAll('.btn-reboot').forEach(btn => {
btn.addEventListener('click', () => {
const mac = btn.dataset.mac;
const name = btn.dataset.name;
const ip = btn.dataset.ip;
const site_key = btn.dataset.siteKey;
showModal(`Reboot AP "${name}"?\nThis will disconnect all clients connected to this AP.`, async () => {
btn.disabled = true;
btn.textContent = 'Rebooting…';
try {
const res = await fetch('/api/reboot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mac, name, ip, site_key, csrf_token: CSRF_TOKEN }),
});
if (res.ok) {
showToast(`Reboot command sent to "${name}"`, 'success');
} else {
const err = await res.json();
showToast(`Error: ${err.detail || 'Unknown error'}`, 'error');
btn.disabled = false;
btn.textContent = 'Reboot';
}
} catch (e) {
showToast('Network error', 'error');
btn.disabled = false;
btn.textContent = 'Reboot';
}
});
});
});
// ── Bulk reboot ────────────────────────────────────────────────────────────
btnRebootSel?.addEventListener('click', () => {
const checked = [...document.querySelectorAll('.ap-checkbox:checked')];
const aps = checked.map(cb => ({ mac: cb.dataset.mac, name: cb.dataset.name, ip: cb.dataset.ip, site_key: cb.dataset.siteKey }));
const names = aps.map(a => a.name).join(', ');
showModal(`Reboot ${aps.length} AP${aps.length > 1 ? 's' : ''}?\n${names}`, async () => {
btnRebootSel.disabled = true;
btnRebootSel.querySelector('span').textContent = '0';
try {
const res = await fetch('/api/reboot-bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aps, csrf_token: CSRF_TOKEN }),
});
const data = await res.json();
const errors = (data.results || []).filter(r => r.result === 'error');
if (errors.length === 0) {
showToast(`Reboot sent to ${aps.length} AP${aps.length > 1 ? 's' : ''}`, 'success');
} else {
showToast(`${errors.length} AP(s) failed to reboot`, 'error');
}
document.querySelectorAll('.ap-checkbox').forEach(cb => { cb.checked = false; });
if (chkAll) chkAll.checked = false;
updateBulkButton();
} catch (e) {
showToast('Network error', 'error');
updateBulkButton();
}
});
});
</script>
{% endblock %}

38
app/templates/login.html Normal file
View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-gray-50">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Login Salus by Stranto</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="h-full flex items-center justify-center text-gray-900">
<div class="w-full max-w-sm">
<div class="bg-white border border-gray-200 rounded-2xl shadow-lg p-8 flex flex-col items-center gap-6">
<div class="flex flex-col items-center gap-2">
<div class="w-14 h-14 rounded-full bg-blue-600 flex items-center justify-center shadow">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v6m-3-3h6m6 0a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h1 class="text-xl font-bold text-gray-900">Salus <span class="font-normal text-gray-400 text-base">by Stranto</span></h1>
<p class="text-sm text-gray-500">Sign in to manage your access points</p>
</div>
<a href="/auth/login/start"
class="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-semibold transition-colors shadow">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 3H19a2 2 0 012 2v14a2 2 0 01-2 2h-4M10 17l5-5-5-5M15 12H3"/>
</svg>
Sign in with Authentik
</a>
<p class="text-xs text-gray-400 text-center">
Access is restricted to authorized users only.
</p>
</div>
</div>
</body>
</html>

40
docker-compose.yml Normal file
View File

@@ -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

9
requirements.txt Normal file
View File

@@ -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