roll back authentik login. removed and restore working state.

This commit is contained in:
2025-11-19 11:42:06 +01:00
parent e8eb2e954c
commit cb428a885d
23 changed files with 730 additions and 2512 deletions
+102 -1
View File
@@ -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
+128 -8
View File
@@ -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()
+4 -2
View File
@@ -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",
+3 -2
View File
@@ -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",