roll back authentik login. removed and restore working state.
This commit is contained in:
@@ -4,7 +4,7 @@ OIDC Authentication Module
|
||||
Provides OAuth2/OIDC token validation for FastAPI using Authentik as IdP.
|
||||
Implements bearer token authentication with JWT verification.
|
||||
"""
|
||||
from fastapi import Depends, HTTPException, Security
|
||||
from fastapi import Depends, HTTPException, Security, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import jwt, JWTError
|
||||
import httpx
|
||||
@@ -233,3 +233,104 @@ async def get_optional_user(
|
||||
except HTTPException:
|
||||
# Invalid token - return None instead of raising
|
||||
return None
|
||||
|
||||
|
||||
async def get_forward_auth_user(
|
||||
request: Request
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
Authentik Forward Auth authentication for external access via NPM
|
||||
|
||||
This dependency allows:
|
||||
- External access through api.schweitz.net (with Authentik forward auth headers) - REQUIRES authentication
|
||||
- Internal direct access (no forward auth headers) - ALLOWED without authentication
|
||||
|
||||
When accessing through NPM with Authentik forward auth enabled, NPM adds headers like:
|
||||
- X-authentik-username
|
||||
- X-authentik-email
|
||||
- X-authentik-groups
|
||||
- X-authentik-name
|
||||
- X-authentik-uid
|
||||
|
||||
Args:
|
||||
request: FastAPI request object containing headers
|
||||
|
||||
Returns:
|
||||
User info dict if authenticated via forward auth headers
|
||||
None if accessed internally (no forward auth headers)
|
||||
|
||||
Raises:
|
||||
HTTPException 401: If forward auth headers present but invalid/incomplete
|
||||
"""
|
||||
# Check for Authentik forward auth headers
|
||||
username = request.headers.get("x-authentik-username")
|
||||
email = request.headers.get("x-authentik-email")
|
||||
groups = request.headers.get("x-authentik-groups")
|
||||
name = request.headers.get("x-authentik-name")
|
||||
uid = request.headers.get("x-authentik-uid")
|
||||
|
||||
# If NO forward auth headers present, this is internal access - allow it
|
||||
if not username and not email:
|
||||
logger.debug("No forward auth headers - allowing internal access")
|
||||
return None
|
||||
|
||||
# Forward auth headers present (external access via api.schweitz.net)
|
||||
# Validate authentication
|
||||
if not username or not email:
|
||||
logger.warning("Incomplete forward auth headers detected")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentication required - incomplete forward auth headers"
|
||||
)
|
||||
|
||||
# Parse groups (comma-separated string to list)
|
||||
groups_list = [g.strip() for g in groups.split(",")] if groups else []
|
||||
|
||||
user_info = {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"name": name or username,
|
||||
"groups": groups_list,
|
||||
"uid": uid,
|
||||
"auth_method": "forward_auth"
|
||||
}
|
||||
|
||||
logger.info(f"Authenticated via forward auth: {email} (groups: {groups_list})")
|
||||
return user_info
|
||||
|
||||
|
||||
async def get_forward_auth_admin(
|
||||
user: Optional[Dict] = Depends(get_forward_auth_user)
|
||||
) -> Dict:
|
||||
"""
|
||||
Require admin access for external requests, allow all internal requests
|
||||
|
||||
Use this dependency for endpoints that require admin access when accessed
|
||||
externally through api.schweitz.net, but allow unrestricted internal access.
|
||||
|
||||
Args:
|
||||
user: User info from get_forward_auth_user
|
||||
|
||||
Returns:
|
||||
User info dict if user is admin or if accessed internally
|
||||
|
||||
Raises:
|
||||
HTTPException 403: If external user is not in admin/authentik Admins group
|
||||
"""
|
||||
# Internal access (no forward auth headers) - allow all
|
||||
if user is None:
|
||||
logger.debug("Internal access - allowing without admin check")
|
||||
return {"email": "internal", "groups": ["admin"], "auth_method": "internal"}
|
||||
|
||||
# External access - check admin group membership
|
||||
groups = user.get("groups", [])
|
||||
|
||||
if "admin" not in groups and "authentik Admins" not in groups:
|
||||
user_email = user.get("email", "unknown")
|
||||
logger.warning(f"User {user_email} attempted admin access (groups: {groups})")
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Admin access required"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
Uptime Kuma Socket.IO Client
|
||||
|
||||
Provides interface to Uptime Kuma via Socket.IO for monitor management.
|
||||
Also provides metrics API access for real-time status data.
|
||||
"""
|
||||
import socketio
|
||||
import asyncio
|
||||
import httpx
|
||||
import re
|
||||
from typing import Optional, Dict, List, Any
|
||||
from src.logging_config import get_logger
|
||||
from src.config import get_settings
|
||||
@@ -125,31 +128,76 @@ class KumaClient:
|
||||
|
||||
async def get_monitors(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all monitors
|
||||
List all monitors with uptime data
|
||||
|
||||
Returns:
|
||||
List of monitor configurations
|
||||
List of monitor configurations with uptime_24h field
|
||||
"""
|
||||
await self._ensure_connected()
|
||||
|
||||
try:
|
||||
# Get monitor list
|
||||
response = await self.sio.call('getMonitorList', timeout=self.timeout)
|
||||
# Storage for monitor list and uptime data received via events
|
||||
monitor_list_data = {}
|
||||
uptime_list_data = {}
|
||||
monitor_event_received = asyncio.Event()
|
||||
uptime_event_received = asyncio.Event()
|
||||
|
||||
if response and isinstance(response, dict):
|
||||
# Uptime Kuma returns monitors as a dict with monitor IDs as keys
|
||||
# Register event handler for monitorList
|
||||
@self.sio.event
|
||||
async def monitorList(data):
|
||||
nonlocal monitor_list_data
|
||||
monitor_list_data = data
|
||||
monitor_event_received.set()
|
||||
|
||||
# Register event handler for uptimeList (24h uptime percentages)
|
||||
@self.sio.event
|
||||
async def uptimeList(monitor_id, uptime_data):
|
||||
nonlocal uptime_list_data
|
||||
# uptime_data is typically a dict with time periods: {"24": 99.5, "720": 98.2, ...}
|
||||
uptime_list_data[str(monitor_id)] = uptime_data
|
||||
# Don't set event here as we'll get multiple calls
|
||||
|
||||
# Request monitor list - this triggers the server to send monitorList event
|
||||
response = await self.sio.call('getMonitorList', timeout=self.timeout)
|
||||
logger.info(f"getMonitorList call response: {response}")
|
||||
|
||||
# Wait for the monitorList event (with timeout)
|
||||
try:
|
||||
await asyncio.wait_for(monitor_event_received.wait(), timeout=5.0)
|
||||
logger.info(f"Received monitorList event with {len(monitor_list_data)} items")
|
||||
|
||||
# Give time for uptimeList events to arrive
|
||||
await asyncio.sleep(0.5)
|
||||
logger.info(f"Received uptime data for {len(uptime_list_data)} monitors")
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Timeout waiting for monitorList event")
|
||||
|
||||
# Process the monitor list data
|
||||
if monitor_list_data and isinstance(monitor_list_data, dict):
|
||||
monitors = []
|
||||
for monitor_id, monitor_data in response.items():
|
||||
for monitor_id, monitor_data in monitor_list_data.items():
|
||||
if isinstance(monitor_data, dict):
|
||||
monitor_data['id'] = int(monitor_id)
|
||||
|
||||
# Add uptime data if available
|
||||
uptime_info = uptime_list_data.get(str(monitor_id), {})
|
||||
if isinstance(uptime_info, dict):
|
||||
# Uptime Kuma provides 24h uptime as key "24"
|
||||
monitor_data['uptime_24h'] = float(uptime_info.get('24', 0))
|
||||
else:
|
||||
monitor_data['uptime_24h'] = 0.0
|
||||
|
||||
monitors.append(monitor_data)
|
||||
self._monitors_cache[int(monitor_id)] = monitor_data
|
||||
|
||||
logger.info(f"Found {len(monitors)} monitors total")
|
||||
return monitors
|
||||
|
||||
logger.warning(f"No valid monitor data received")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get monitors: {e}")
|
||||
logger.error(f"Failed to get monitors: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def get_monitor(self, monitor_id: int) -> Dict[str, Any]:
|
||||
@@ -419,6 +467,78 @@ class KumaClient:
|
||||
await self.delete_monitor(monitor["id"])
|
||||
return True
|
||||
|
||||
async def get_metrics_status(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Get monitor status from Prometheus metrics endpoint
|
||||
|
||||
This is simpler and more reliable than Socket.IO for getting current status.
|
||||
Returns real-time UP/DOWN status but not historical uptime percentages.
|
||||
|
||||
Returns:
|
||||
Dict mapping monitor names to status info:
|
||||
{
|
||||
"Portainer": {
|
||||
"status": 1, # 1=UP, 0=DOWN, 2=PENDING, 3=MAINTENANCE
|
||||
"response_time": 5, # ms
|
||||
"monitor_type": "http",
|
||||
"url": "http://192.168.86.149:8001"
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Use API key authentication
|
||||
api_key = settings.kuma_api_key
|
||||
if not api_key:
|
||||
logger.warning("Kuma API key not configured")
|
||||
return {}
|
||||
|
||||
# Fetch metrics with HTTP Basic Auth (empty username, API key as password)
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/metrics",
|
||||
auth=("", api_key)
|
||||
)
|
||||
response.raise_for_status()
|
||||
metrics_text = response.text
|
||||
|
||||
# Parse Prometheus format metrics
|
||||
# Format: metric_name{label1="value1",label2="value2"} value
|
||||
monitor_data = {}
|
||||
|
||||
# Parse monitor_status lines
|
||||
status_pattern = r'monitor_status\{monitor_name="([^"]+)",.*?\} (\d+)'
|
||||
for match in re.finditer(status_pattern, metrics_text):
|
||||
monitor_name = match.group(1)
|
||||
status = int(match.group(2))
|
||||
|
||||
if monitor_name not in monitor_data:
|
||||
monitor_data[monitor_name] = {}
|
||||
monitor_data[monitor_name]['status'] = status
|
||||
|
||||
# Parse monitor_response_time lines
|
||||
response_pattern = r'monitor_response_time\{monitor_name="([^"]+)",monitor_type="([^"]+)",monitor_url="([^"]+)",.*?\} ([\d.]+)'
|
||||
for match in re.finditer(response_pattern, metrics_text):
|
||||
monitor_name = match.group(1)
|
||||
monitor_type = match.group(2)
|
||||
monitor_url = match.group(3)
|
||||
response_time = float(match.group(4))
|
||||
|
||||
if monitor_name not in monitor_data:
|
||||
monitor_data[monitor_name] = {}
|
||||
monitor_data[monitor_name].update({
|
||||
'response_time': response_time,
|
||||
'monitor_type': monitor_type,
|
||||
'url': monitor_url
|
||||
})
|
||||
|
||||
logger.info(f"Fetched metrics for {len(monitor_data)} monitors")
|
||||
return monitor_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch metrics: {e}")
|
||||
return {}
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry"""
|
||||
await self._ensure_connected()
|
||||
|
||||
@@ -9,7 +9,7 @@ try:
|
||||
from src.credentials import (
|
||||
PORTAINER_URL, PORTAINER_API_KEY,
|
||||
NPM_URL, NPM_EMAIL, NPM_PASSWORD,
|
||||
KUMA_URL, KUMA_USERNAME, KUMA_PASSWORD
|
||||
KUMA_URL, KUMA_USERNAME, KUMA_PASSWORD, KUMA_API_KEY
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback to empty strings if credentials.py doesn't exist
|
||||
@@ -22,6 +22,7 @@ except ImportError:
|
||||
KUMA_URL = "http://localhost:3001"
|
||||
KUMA_USERNAME = ""
|
||||
KUMA_PASSWORD = ""
|
||||
KUMA_API_KEY = ""
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -43,7 +44,7 @@ class Settings(BaseSettings):
|
||||
cors_headers: list[str] = ["*"]
|
||||
|
||||
# Logging
|
||||
log_level: str = "INFO"
|
||||
log_level: str = "DEBUG"
|
||||
|
||||
# Ollama Configuration (for AI orchestration)
|
||||
ollama_base_url: str = "http://ollama:11434"
|
||||
@@ -88,6 +89,7 @@ class Settings(BaseSettings):
|
||||
kuma_url: str = KUMA_URL
|
||||
kuma_username: str = KUMA_USERNAME
|
||||
kuma_password: str = KUMA_PASSWORD
|
||||
kuma_api_key: str = KUMA_API_KEY
|
||||
|
||||
# OIDC Authentication (Authentik)
|
||||
oidc_enabled: bool = False # Set to True to require authentication
|
||||
|
||||
@@ -14,7 +14,7 @@ from src.clients.npm_client import get_npm_client
|
||||
from src.clients.kuma_client import get_kuma_client
|
||||
from src.logging_config import get_logger
|
||||
from src import service_groups
|
||||
from src.auth.oidc import get_admin_user
|
||||
from src.auth.oidc import get_admin_user, get_forward_auth_admin
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -751,11 +751,11 @@ class InfrastructureController(BaseController):
|
||||
"/services/{name}/stop",
|
||||
response_model=OperationResult,
|
||||
summary="Stop a service or service group",
|
||||
description="Stop a service or service group by pausing monitors and stopping containers. Requires admin authentication."
|
||||
description="Stop a service or service group by pausing monitors and stopping containers. Requires admin authentication when accessed externally via api.schweitz.net."
|
||||
)
|
||||
async def stop_service(
|
||||
name: str,
|
||||
user: Dict = Depends(get_admin_user)
|
||||
user: Dict = Depends(get_forward_auth_admin)
|
||||
):
|
||||
"""
|
||||
Stop a service or service group
|
||||
@@ -866,11 +866,11 @@ class InfrastructureController(BaseController):
|
||||
"/services/{name}/start",
|
||||
response_model=OperationResult,
|
||||
summary="Start a service or service group",
|
||||
description="Start a service or service group by starting containers and resuming monitors. Requires admin authentication."
|
||||
description="Start a service or service group by starting containers and resuming monitors. Requires admin authentication when accessed externally via api.schweitz.net."
|
||||
)
|
||||
async def start_service(
|
||||
name: str,
|
||||
user: Dict = Depends(get_admin_user)
|
||||
user: Dict = Depends(get_forward_auth_admin)
|
||||
):
|
||||
"""
|
||||
Start a service or service group
|
||||
@@ -971,6 +971,118 @@ class InfrastructureController(BaseController):
|
||||
|
||||
# ===== Monitoring Endpoints =====
|
||||
|
||||
@router.get(
|
||||
"/widget-data",
|
||||
summary="Get combined data for service control widget",
|
||||
response_model=Dict[str, Any]
|
||||
)
|
||||
async def get_widget_data():
|
||||
"""
|
||||
Get combined service and monitor data for the widget
|
||||
|
||||
Returns all data needed by service-control widget in a single call:
|
||||
- Service list with status and container counts
|
||||
- Monitor list with uptime percentages
|
||||
- Service groups and always-on list
|
||||
|
||||
This endpoint is designed for browser-based widgets to avoid
|
||||
multiple API calls and cross-origin issues.
|
||||
"""
|
||||
try:
|
||||
portainer = get_portainer_client()
|
||||
kuma = get_kuma_client()
|
||||
npm = get_npm_client()
|
||||
|
||||
# Fetch services (same logic as /services endpoint)
|
||||
stacks = await portainer.get_stacks()
|
||||
proxy_hosts = await npm.get_proxy_hosts()
|
||||
|
||||
# Build domain mapping
|
||||
domain_map = {}
|
||||
for proxy in proxy_hosts:
|
||||
for domain in proxy.get("domain_names", []):
|
||||
forward_host = proxy.get("forward_host", "")
|
||||
domain_map[domain] = forward_host
|
||||
|
||||
services = []
|
||||
for stack in stacks:
|
||||
stack_name = stack.get("Name", "")
|
||||
endpoint_id = stack.get("EndpointId")
|
||||
domains = [
|
||||
domain for domain, host in domain_map.items()
|
||||
if stack_name in host or host in stack_name
|
||||
]
|
||||
|
||||
# Get container status
|
||||
containers_running = 0
|
||||
containers_total = 0
|
||||
try:
|
||||
all_containers = await portainer.get_containers(endpoint_id, all_containers=True)
|
||||
for container in all_containers:
|
||||
labels = container.get("Labels", {})
|
||||
container_stack = labels.get("com.docker.compose.project", "")
|
||||
|
||||
if container_stack.lower() == stack_name.lower():
|
||||
containers_total += 1
|
||||
if container.get("State", "") == "running":
|
||||
containers_running += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get container status for {stack_name}: {e}")
|
||||
|
||||
services.append({
|
||||
"name": stack_name,
|
||||
"stack_id": stack.get("Id"),
|
||||
"status": "active" if stack.get("Status") == 1 else "inactive",
|
||||
"endpoint_id": endpoint_id,
|
||||
"domains": domains,
|
||||
"running": containers_running > 0,
|
||||
"containers_running": containers_running,
|
||||
"containers_total": containers_total
|
||||
})
|
||||
|
||||
# Fetch monitors with real-time status from metrics endpoint
|
||||
monitors_list = []
|
||||
try:
|
||||
# Get real-time status from Prometheus metrics
|
||||
metrics_data = await kuma.get_metrics_status()
|
||||
|
||||
for monitor_name, monitor_info in metrics_data.items():
|
||||
# Status: 1=UP, 0=DOWN, 2=PENDING, 3=MAINTENANCE
|
||||
status = monitor_info.get('status', 0)
|
||||
|
||||
# Convert status to simple up/down for widget
|
||||
# Treat UP (1) as 100%, anything else as 0%
|
||||
status_percentage = 100.0 if status == 1 else 0.0
|
||||
|
||||
monitors_list.append({
|
||||
"id": None, # Not available from metrics
|
||||
"name": monitor_name,
|
||||
"uptime_24h": status_percentage, # Current status as percentage
|
||||
"active": True, # Assume active if in metrics
|
||||
"status": status, # 1=UP, 0=DOWN, 2=PENDING, 3=MAINTENANCE
|
||||
"response_time": monitor_info.get('response_time', 0)
|
||||
})
|
||||
|
||||
logger.info(f"Fetched status for {len(monitors_list)} monitors from metrics")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch monitors: {e}")
|
||||
# Continue without monitor data rather than failing
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"services": services,
|
||||
"monitors": monitors_list,
|
||||
"service_groups": {
|
||||
"groups": service_groups.list_service_groups(),
|
||||
"always_on": list(service_groups.ALWAYS_ON_SERVICES),
|
||||
"stoppable": service_groups.list_stoppable_services()
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch widget data: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch widget data: {str(e)}")
|
||||
|
||||
@router.get(
|
||||
"/monitors",
|
||||
summary="List all monitors",
|
||||
|
||||
@@ -16,6 +16,9 @@ ALWAYS_ON_SERVICES: Set[str] = {
|
||||
"watchtower",
|
||||
"netdata",
|
||||
"maintenance",
|
||||
"postgres-shared",
|
||||
"redis-shared",
|
||||
"authentik",
|
||||
}
|
||||
|
||||
# Service groups - services that should be started/stopped together
|
||||
@@ -25,8 +28,6 @@ SERVICE_GROUPS: Dict[str, List[str]] = {
|
||||
],
|
||||
"nextcloud": [
|
||||
"nextcloud",
|
||||
"nextcloud-db",
|
||||
"nextcloud-redis",
|
||||
],
|
||||
"gitea": [
|
||||
"gitea",
|
||||
|
||||
Reference in New Issue
Block a user