security rework and memory optimilizations.

This commit is contained in:
2025-11-17 08:48:00 +01:00
parent 72a653f494
commit e8eb2e954c
55 changed files with 8301 additions and 245 deletions
+5
View File
@@ -0,0 +1,5 @@
"""
Authentication module for core-api
Provides OIDC/OAuth2 authentication via Authentik
"""
+235
View File
@@ -0,0 +1,235 @@
"""
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.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
import httpx
from functools import lru_cache
from typing import Dict, Optional
from src.logging_config import get_logger
logger = get_logger(__name__)
security = HTTPBearer(auto_error=False)
class OIDCConfig:
"""OIDC configuration from environment"""
def __init__(self):
# These will be set from environment variables in config.py
self.enabled = False
self.issuer = ""
self.audience = ""
self.jwks_uri = ""
def configure(self, enabled: bool, issuer: str, audience: str):
"""Configure OIDC settings"""
self.enabled = enabled
self.issuer = issuer
self.audience = audience
self.jwks_uri = f"{issuer.rstrip('/')}/jwks/"
logger.info(f"OIDC configured: enabled={enabled}, issuer={issuer}")
# Global OIDC config instance
oidc_config = OIDCConfig()
@lru_cache(maxsize=1)
def get_jwks() -> Dict:
"""
Fetch JSON Web Key Set (JWKS) from Authentik
Cached to avoid repeated requests. Cache is cleared on server restart.
Returns:
JWKS dictionary containing public keys for token verification
Raises:
HTTPException: If JWKS fetch fails
"""
if not oidc_config.enabled:
return {}
try:
logger.debug(f"Fetching JWKS from {oidc_config.jwks_uri}")
response = httpx.get(oidc_config.jwks_uri, timeout=10.0)
response.raise_for_status()
jwks = response.json()
logger.info(f"JWKS fetched successfully ({len(jwks.get('keys', []))} keys)")
return jwks
except Exception as e:
logger.error(f"Failed to fetch JWKS: {e}")
raise HTTPException(
status_code=503,
detail="Authentication service unavailable"
)
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Security(security)
) -> Optional[Dict]:
"""
Validate OIDC token from Authorization: Bearer header
Extracts and validates JWT token from request header. Verifies:
- Token signature using JWKS
- Token expiration
- Issuer matches Authentik
- Audience matches core-api
Args:
credentials: HTTP Bearer token from Authorization header
Returns:
User claims dictionary containing email, name, groups, etc.
Returns None if OIDC is disabled (allows unauthenticated access)
Raises:
HTTPException 401: If token is invalid, expired, or missing when OIDC enabled
"""
# If OIDC is disabled, allow all requests (no authentication)
if not oidc_config.enabled:
logger.debug("OIDC disabled - allowing unauthenticated access")
return None
# OIDC enabled - token required
if not credentials:
logger.warning("Authentication required but no token provided")
raise HTTPException(
status_code=401,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials
try:
# Decode token header to get key ID
unverified_header = jwt.get_unverified_header(token)
kid = unverified_header.get("kid")
if not kid:
raise HTTPException(status_code=401, detail="Invalid token format")
# Find matching key in JWKS
jwks = get_jwks()
rsa_key = None
for key in jwks.get("keys", []):
if key.get("kid") == kid:
rsa_key = key
break
if not rsa_key:
logger.warning(f"No matching key found for kid: {kid}")
raise HTTPException(status_code=401, detail="Invalid token key")
# Verify and decode token
payload = jwt.decode(
token,
rsa_key,
algorithms=["RS256"],
audience=oidc_config.audience,
issuer=oidc_config.issuer,
)
user_email = payload.get("email", "unknown")
logger.info(f"Authenticated user: {user_email}")
return payload
except jwt.ExpiredSignatureError:
logger.warning("Token expired")
raise HTTPException(
status_code=401,
detail="Token expired",
headers={"WWW-Authenticate": "Bearer"},
)
except jwt.JWTClaimsError as e:
logger.warning(f"Invalid token claims: {e}")
raise HTTPException(
status_code=401,
detail="Invalid token claims",
headers={"WWW-Authenticate": "Bearer"},
)
except JWTError as e:
logger.error(f"JWT validation error: {e}")
raise HTTPException(
status_code=401,
detail="Invalid authentication token",
headers={"WWW-Authenticate": "Bearer"},
)
except Exception as e:
logger.error(f"Unexpected authentication error: {e}")
raise HTTPException(
status_code=500,
detail="Authentication error",
)
async def get_admin_user(
user: Optional[Dict] = Depends(get_current_user)
) -> Dict:
"""
Require admin group membership
Use this dependency for endpoints that require admin access.
Checks if user is member of 'admin' group in Authentik.
Args:
user: User claims from get_current_user
Returns:
User claims dictionary if user is admin
Raises:
HTTPException 403: If user is not in admin group
HTTPException 401: If OIDC enabled but user not authenticated
"""
# If OIDC disabled, allow all (backward compatibility)
if not oidc_config.enabled or user is None:
logger.debug("OIDC disabled - allowing admin access")
return {"email": "unauthenticated", "groups": ["admin"]}
# 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
async def get_optional_user(
credentials: Optional[HTTPAuthorizationCredentials] = Security(security)
) -> Optional[Dict]:
"""
Optional authentication - allows both authenticated and unauthenticated access
Use for endpoints that should be accessible to everyone but can provide
enhanced functionality for authenticated users.
Args:
credentials: HTTP Bearer token from Authorization header
Returns:
User claims if valid token provided, None otherwise
"""
if not credentials or not oidc_config.enabled:
return None
try:
return await get_current_user(credentials)
except HTTPException:
# Invalid token - return None instead of raising
return None
@@ -0,0 +1,302 @@
"""
Authentik API Client
Provides methods for interacting with Authentik Identity Provider API.
Used for managing applications, providers, and authentication flows.
"""
import httpx
from typing import Dict, List, Any, Optional
from functools import lru_cache
from src.logging_config import get_logger
logger = get_logger(__name__)
class AuthentikClient:
"""Client for Authentik API operations"""
def __init__(self, base_url: str, api_token: str):
"""
Initialize Authentik client
Args:
base_url: Authentik base URL (e.g., http://authentik-server:9000)
api_token: API token for authentication
"""
self.base_url = base_url.rstrip('/')
self.api_token = api_token
self.client = httpx.AsyncClient(timeout=30.0)
async def _request(self, method: str, endpoint: str, **kwargs) -> Dict:
"""Make authenticated API request using token auth"""
headers = kwargs.pop("headers", {})
headers["Authorization"] = f"Bearer {self.api_token}"
response = await self.client.request(
method,
f"{self.base_url}/api/v3/{endpoint.lstrip('/')}",
headers=headers,
**kwargs
)
if not response.is_success:
logger.error(f"API request failed: {response.status_code}")
logger.error(f"Response body: {response.text}")
response.raise_for_status()
return response.json()
async def health_check(self) -> bool:
"""Check if Authentik is accessible"""
try:
response = await self.client.get(f"{self.base_url}/-/health/live/")
return response.status_code == 200
except Exception as e:
logger.error(f"Authentik health check failed: {e}")
return False
async def create_oauth2_provider(
self,
name: str,
client_id: str,
redirect_uris: List[str],
authorization_flow_slug: str = "default-provider-authorization-implicit-consent",
signing_key: Optional[str] = None
) -> Dict:
"""
Create an OAuth2/OIDC provider
Args:
name: Provider name
client_id: OAuth2 client ID
redirect_uris: List of allowed redirect URIs
authorization_flow_slug: Authorization flow slug (will be resolved to UUID)
signing_key: Signing key UUID (defaults to auto-selected)
Returns:
Created provider data including client_secret
"""
# Get authorization flow UUID from slug
flows = await self.list_flows()
auth_flow_uuid = None
invalidation_flow_uuid = None
for flow in flows:
if flow.get("slug") == authorization_flow_slug:
auth_flow_uuid = flow.get("pk")
if flow.get("slug") == "default-provider-invalidation-flow":
invalidation_flow_uuid = flow.get("pk")
if not auth_flow_uuid:
raise ValueError(f"Authorization flow '{authorization_flow_slug}' not found")
if not invalidation_flow_uuid:
raise ValueError("Invalidation flow not found")
# Get signing key if not provided
if not signing_key:
keys = await self._request("GET", "crypto/certificatekeypairs/")
# Find the self-signed cert
for key in keys.get("results", []):
if "authentik" in key.get("name", "").lower():
signing_key = key.get("pk")
break
if not signing_key and keys.get("results"):
signing_key = keys["results"][0]["pk"]
# Format redirect URIs as objects with matching_mode
formatted_redirect_uris = [
{"url": uri, "matching_mode": "strict"}
for uri in redirect_uris
]
provider_data = {
"name": name,
"authorization_flow": auth_flow_uuid,
"invalidation_flow": invalidation_flow_uuid,
"client_type": "confidential",
"client_id": client_id,
"redirect_uris": formatted_redirect_uris,
"signing_key": signing_key,
"sub_mode": "hashed_user_id",
"include_claims_in_id_token": True,
"issuer_mode": "per_provider",
"access_token_validity": "minutes=60",
"refresh_token_validity": "days=30",
"property_mappings": [] # Will use default mappings
}
result = await self._request("POST", "providers/oauth2/", json=provider_data)
logger.info(f"Created OAuth2 provider: {name} (ID: {result.get('pk')})")
return result
async def create_application(
self,
name: str,
slug: str,
provider_pk: int,
launch_url: Optional[str] = None,
icon_url: Optional[str] = None
) -> Dict:
"""
Create an application
Args:
name: Application display name
slug: Application slug (URL-safe identifier)
provider_pk: Primary key of the provider to use
launch_url: Optional launch URL
icon_url: Optional icon URL
Returns:
Created application data
"""
app_data = {
"name": name,
"slug": slug,
"provider": provider_pk,
"meta_launch_url": launch_url or "",
"meta_icon": icon_url or "",
"policy_engine_mode": "any",
"open_in_new_tab": False
}
result = await self._request("POST", "core/applications/", json=app_data)
logger.info(f"Created application: {name} (slug: {slug})")
return result
async def get_provider_by_name(self, name: str) -> Optional[Dict]:
"""Get OAuth2 provider by name"""
providers = await self._request("GET", "providers/oauth2/", params={"name": name})
results = providers.get("results", [])
return results[0] if results else None
async def get_application_by_slug(self, slug: str) -> Optional[Dict]:
"""Get application by slug"""
apps = await self._request("GET", "core/applications/", params={"slug": slug})
results = apps.get("results", [])
return results[0] if results else None
async def list_flows(self) -> List[Dict]:
"""List all authentication flows"""
result = await self._request("GET", "flows/instances/")
return result.get("results", [])
async def create_proxy_provider(
self,
name: str,
external_host: str,
authorization_flow_slug: str = "default-provider-authorization-implicit-consent",
mode: str = "forward_single",
token_validity: int = 480 # 8 hours in minutes
) -> Dict:
"""
Create a Proxy Provider for forward authentication
Args:
name: Provider name
external_host: External URL (e.g., https://auth.schweitz.net)
authorization_flow_slug: Authorization flow slug
mode: Proxy mode (forward_single for forward auth)
token_validity: Token validity in minutes (default: 480 = 8 hours)
Returns:
Created provider data
"""
# Get authorization flow UUID from slug
flows = await self.list_flows()
auth_flow_uuid = None
invalidation_flow_uuid = None
for flow in flows:
if flow.get("slug") == authorization_flow_slug:
auth_flow_uuid = flow.get("pk")
if flow.get("slug") == "default-provider-invalidation-flow":
invalidation_flow_uuid = flow.get("pk")
if not auth_flow_uuid:
raise ValueError(f"Authorization flow '{authorization_flow_slug}' not found")
if not invalidation_flow_uuid:
raise ValueError("Invalidation flow not found")
provider_data = {
"name": name,
"authorization_flow": auth_flow_uuid,
"invalidation_flow": invalidation_flow_uuid,
"mode": mode,
"external_host": external_host,
"access_token_validity": f"minutes={token_validity}",
"refresh_token_validity": f"minutes={token_validity}",
"session_duration": f"seconds={token_validity * 60}",
"cookie_domain": "", # Will use the domain of each proxied site
"property_mappings": []
}
result = await self._request("POST", "providers/proxy/", json=provider_data)
logger.info(f"Created Proxy provider: {name} (ID: {result.get('pk')})")
return result
async def get_provider_by_name_proxy(self, name: str) -> Optional[Dict]:
"""Get Proxy provider by name"""
providers = await self._request("GET", "providers/proxy/", params={"name": name})
results = providers.get("results", [])
return results[0] if results else None
async def create_outpost(
self,
name: str,
type: str,
providers: List[int],
config: Optional[Dict] = None
) -> Dict:
"""
Create an Authentik Outpost
Args:
name: Outpost name
type: Outpost type (e.g., "proxy")
providers: List of provider PKs
config: Optional configuration overrides
Returns:
Created outpost data
"""
outpost_data = {
"name": name,
"type": type,
"providers": providers,
"config": config or {},
"service_connection": None # Will use local Docker
}
result = await self._request("POST", "outposts/instances/", json=outpost_data)
logger.info(f"Created outpost: {name} (ID: {result.get('pk')})")
return result
async def get_outpost_by_name(self, name: str) -> Optional[Dict]:
"""Get outpost by name"""
outposts = await self._request("GET", "outposts/instances/", params={"name": name})
results = outposts.get("results", [])
return results[0] if results else None
async def close(self):
"""Close HTTP client"""
await self.client.aclose()
@lru_cache()
def get_authentik_client() -> AuthentikClient:
"""Get cached Authentik client instance"""
# Import credentials from gitignored module
try:
from src.credentials import AUTHENTIK_URL, AUTHENTIK_CORE_API_TOKEN
except ImportError:
# Fallback to environment variables if credentials.py doesn't exist
import os
AUTHENTIK_URL = os.getenv("AUTHENTIK_URL", "http://authentik-server:9000")
AUTHENTIK_CORE_API_TOKEN = os.getenv("AUTHENTIK_API_TOKEN", "")
return AuthentikClient(
base_url=AUTHENTIK_URL,
api_token=AUTHENTIK_CORE_API_TOKEN
)
@@ -0,0 +1,441 @@
"""
Uptime Kuma Socket.IO Client
Provides interface to Uptime Kuma via Socket.IO for monitor management.
"""
import socketio
import asyncio
from typing import Optional, Dict, List, Any
from src.logging_config import get_logger
from src.config import get_settings
logger = get_logger(__name__)
settings = get_settings()
class KumaClient:
"""
Socket.IO client for Uptime Kuma
Uses Socket.IO for real-time communication with Uptime Kuma.
"""
def __init__(
self,
base_url: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
timeout: int = 30
):
"""
Initialize Kuma client
Args:
base_url: Kuma base URL (default from settings)
username: Kuma username (default from settings)
password: Kuma password (default from settings)
timeout: Request timeout in seconds
"""
self.base_url = (base_url or settings.kuma_url).rstrip("/")
self.username = username or settings.kuma_username
self.password = password or settings.kuma_password
self.timeout = timeout
self.sio = socketio.AsyncClient(
reconnection=True,
reconnection_attempts=3,
reconnection_delay=1,
)
self._connected = False
self._authenticated = False
self._monitors_cache: Dict[int, Dict[str, Any]] = {}
if not self.username or not self.password:
logger.warning("Uptime Kuma credentials not configured")
async def _ensure_connected(self):
"""Ensure we have an active connection and authentication"""
if not self._connected:
await self.connect()
if not self._authenticated:
await self.login()
async def connect(self):
"""Connect to Uptime Kuma Socket.IO server"""
if self._connected:
return
try:
await self.sio.connect(self.base_url, transports=['websocket'])
self._connected = True
logger.info(f"Connected to Uptime Kuma at {self.base_url}")
except Exception as e:
logger.error(f"Failed to connect to Uptime Kuma: {e}")
raise
async def disconnect(self):
"""Disconnect from Uptime Kuma"""
if self._connected:
await self.sio.disconnect()
self._connected = False
self._authenticated = False
logger.info("Disconnected from Uptime Kuma")
async def login(self):
"""Authenticate with Uptime Kuma"""
if not self._connected:
await self.connect()
try:
# Uptime Kuma login event
login_response = await self.sio.call(
'login',
{
'username': self.username,
'password': self.password,
'token': None
},
timeout=self.timeout
)
if login_response and login_response.get('ok'):
self._authenticated = True
logger.info("Successfully authenticated with Uptime Kuma")
else:
error_msg = login_response.get('msg', 'Unknown error') if login_response else 'No response'
raise Exception(f"Login failed: {error_msg}")
except Exception as e:
logger.error(f"Failed to authenticate with Uptime Kuma: {e}")
raise
async def health_check(self) -> bool:
"""
Check if Uptime Kuma is accessible
Returns:
True if accessible, False otherwise
"""
try:
await self._ensure_connected()
return self._authenticated
except Exception as e:
logger.error(f"Uptime Kuma health check failed: {e}")
return False
async def get_monitors(self) -> List[Dict[str, Any]]:
"""
List all monitors
Returns:
List of monitor configurations
"""
await self._ensure_connected()
try:
# Get monitor list
response = await self.sio.call('getMonitorList', timeout=self.timeout)
if response and isinstance(response, dict):
# Uptime Kuma returns monitors as a dict with monitor IDs as keys
monitors = []
for monitor_id, monitor_data in response.items():
if isinstance(monitor_data, dict):
monitor_data['id'] = int(monitor_id)
monitors.append(monitor_data)
self._monitors_cache[int(monitor_id)] = monitor_data
return monitors
return []
except Exception as e:
logger.error(f"Failed to get monitors: {e}")
raise
async def get_monitor(self, monitor_id: int) -> Dict[str, Any]:
"""
Get details of a specific monitor
Args:
monitor_id: Monitor identifier
Returns:
Monitor configuration details
"""
await self._ensure_connected()
try:
response = await self.sio.call('getMonitor', monitor_id, timeout=self.timeout)
if response:
self._monitors_cache[monitor_id] = response
return response
raise Exception(f"Monitor {monitor_id} not found")
except Exception as e:
logger.error(f"Failed to get monitor {monitor_id}: {e}")
raise
async def find_monitor_by_name(self, name: str) -> Optional[Dict[str, Any]]:
"""
Find a monitor by its name (case-insensitive)
Args:
name: Monitor name to search for
Returns:
Monitor object if found, None otherwise
"""
monitors = await self.get_monitors()
name_lower = name.lower()
for monitor in monitors:
if monitor.get("name", "").lower() == name_lower:
return monitor
return None
async def find_monitors_by_tag(self, tag: str) -> List[Dict[str, Any]]:
"""
Find all monitors with a specific tag
Args:
tag: Tag name to search for
Returns:
List of monitors with the tag
"""
monitors = await self.get_monitors()
tagged_monitors = []
for monitor in monitors:
monitor_tags = monitor.get("tags", [])
if any(t.get("name", "").lower() == tag.lower() for t in monitor_tags):
tagged_monitors.append(monitor)
return tagged_monitors
async def pause_monitor(self, monitor_id: int) -> bool:
"""
Pause a monitor (disable monitoring)
Args:
monitor_id: Monitor identifier
Returns:
True if successful
"""
await self._ensure_connected()
try:
# Uptime Kuma pause event
response = await self.sio.call('pauseMonitor', monitor_id, timeout=self.timeout)
if response and response.get('ok'):
logger.info(f"Paused monitor {monitor_id}")
return True
error_msg = response.get('msg', 'Unknown error') if response else 'No response'
raise Exception(f"Failed to pause monitor: {error_msg}")
except Exception as e:
logger.error(f"Failed to pause monitor {monitor_id}: {e}")
raise
async def resume_monitor(self, monitor_id: int) -> bool:
"""
Resume a monitor (enable monitoring)
Args:
monitor_id: Monitor identifier
Returns:
True if successful
"""
await self._ensure_connected()
try:
# Uptime Kuma resume event
response = await self.sio.call('resumeMonitor', monitor_id, timeout=self.timeout)
if response and response.get('ok'):
logger.info(f"Resumed monitor {monitor_id}")
return True
error_msg = response.get('msg', 'Unknown error') if response else 'No response'
raise Exception(f"Failed to resume monitor: {error_msg}")
except Exception as e:
logger.error(f"Failed to resume monitor {monitor_id}: {e}")
raise
async def pause_monitor_by_name(self, name: str) -> bool:
"""
Pause a monitor by its name
Args:
name: Monitor name
Returns:
True if successful, False if monitor not found
"""
monitor = await self.find_monitor_by_name(name)
if not monitor:
logger.warning(f"Monitor '{name}' not found")
return False
await self.pause_monitor(monitor["id"])
return True
async def resume_monitor_by_name(self, name: str) -> bool:
"""
Resume a monitor by its name
Args:
name: Monitor name
Returns:
True if successful, False if monitor not found
"""
monitor = await self.find_monitor_by_name(name)
if not monitor:
logger.warning(f"Monitor '{name}' not found")
return False
await self.resume_monitor(monitor["id"])
return True
async def add_monitor(self, monitor_config: Dict[str, Any]) -> Dict[str, Any]:
"""
Create a new monitor
Args:
monitor_config: Monitor configuration dict
Returns:
Created monitor details including ID
"""
await self._ensure_connected()
try:
# Uptime Kuma add monitor event
response = await self.sio.call('add', monitor_config, timeout=self.timeout)
if response and response.get('ok'):
monitor_id = response.get('monitorID')
logger.info(f"Created monitor '{monitor_config.get('name')}' with ID {monitor_id}")
# Get full monitor details
monitor = await self.get_monitor(monitor_id)
return monitor
error_msg = response.get('msg', 'Unknown error') if response else 'No response'
raise Exception(f"Failed to create monitor: {error_msg}")
except Exception as e:
logger.error(f"Failed to create monitor '{monitor_config.get('name')}': {e}")
raise
async def update_monitor(self, monitor_id: int, monitor_config: Dict[str, Any]) -> Dict[str, Any]:
"""
Update an existing monitor
Args:
monitor_id: Monitor identifier
monitor_config: Updated monitor configuration
Returns:
Updated monitor details
"""
await self._ensure_connected()
try:
# Ensure ID is in the config
monitor_config['id'] = monitor_id
# Uptime Kuma edit monitor event
response = await self.sio.call('editMonitor', monitor_config, timeout=self.timeout)
if response and response.get('ok'):
logger.info(f"Updated monitor {monitor_id}")
# Get updated monitor details
monitor = await self.get_monitor(monitor_id)
return monitor
error_msg = response.get('msg', 'Unknown error') if response else 'No response'
raise Exception(f"Failed to update monitor: {error_msg}")
except Exception as e:
logger.error(f"Failed to update monitor {monitor_id}: {e}")
raise
async def delete_monitor(self, monitor_id: int) -> bool:
"""
Delete a monitor
Args:
monitor_id: Monitor identifier
Returns:
True if successful
"""
await self._ensure_connected()
try:
# Uptime Kuma delete monitor event
response = await self.sio.call('deleteMonitor', monitor_id, timeout=self.timeout)
if response and response.get('ok'):
logger.info(f"Deleted monitor {monitor_id}")
# Remove from cache
self._monitors_cache.pop(monitor_id, None)
return True
error_msg = response.get('msg', 'Unknown error') if response else 'No response'
raise Exception(f"Failed to delete monitor: {error_msg}")
except Exception as e:
logger.error(f"Failed to delete monitor {monitor_id}: {e}")
raise
async def delete_monitor_by_name(self, name: str) -> bool:
"""
Delete a monitor by its name
Args:
name: Monitor name
Returns:
True if successful, False if monitor not found
"""
monitor = await self.find_monitor_by_name(name)
if not monitor:
logger.warning(f"Monitor '{name}' not found")
return False
await self.delete_monitor(monitor["id"])
return True
async def __aenter__(self):
"""Async context manager entry"""
await self._ensure_connected()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit"""
await self.disconnect()
# Singleton instance
_kuma_client: Optional[KumaClient] = None
def get_kuma_client() -> KumaClient:
"""Get singleton Kuma client instance"""
global _kuma_client
if _kuma_client is None:
_kuma_client = KumaClient()
return _kuma_client
+114 -2
View File
@@ -99,9 +99,11 @@ class NPMClient:
True if accessible, False otherwise
"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
response = await client.get(f"{self.base_url}/api")
return response.status_code == 200
# Accept any successful response (2xx) or redirect (3xx) as healthy
# A redirect indicates the service is up and responding
return 200 <= response.status_code < 400
except Exception as e:
logger.error(f"NPM health check failed: {e}")
return False
@@ -207,6 +209,116 @@ class NPMClient:
response.raise_for_status()
return response.json()
async def update_proxy_host(
self,
proxy_id: int,
config: Dict[str, Any]
) -> Dict[str, Any]:
"""
Update an existing proxy host configuration
Args:
proxy_id: Proxy host ID to update
config: Full proxy host configuration (get from get_proxy_host, modify, then update)
Returns:
Updated proxy host details
"""
await self._ensure_token()
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.put(
f"{self.base_url}/api/nginx/proxy-hosts/{proxy_id}",
headers=self._get_headers(),
json=config
)
if not response.is_success:
logger.error(f"Update failed: {response.status_code}")
logger.error(f"Response: {response.text}")
response.raise_for_status()
return response.json()
async def enable_authentik_forward_auth(
self,
proxy_id: int,
authentik_url: str = "http://authentik-server:9000"
) -> Dict[str, Any]:
"""
Enable Authentik forward authentication on a proxy host
Args:
proxy_id: Proxy host ID to update
authentik_url: Authentik server URL (default: http://authentik-server:9000)
Returns:
Updated proxy host details
"""
# Get current config
proxy_host = await self.get_proxy_host(proxy_id)
# Authentik forward auth configuration
auth_config = f"""# Authentik Forward Authentication
# Send authentication requests to Authentik
auth_request /outpost.goauthentik.io/auth/nginx;
# Preserve authentication cookies
auth_request_set $auth_cookie $upstream_http_set_cookie;
add_header Set-Cookie $auth_cookie;
# Get user information from Authentik
auth_request_set $authentik_username $upstream_http_x_authentik_username;
auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
auth_request_set $authentik_email $upstream_http_x_authentik_email;
auth_request_set $authentik_name $upstream_http_x_authentik_name;
auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
# Pass user info to backend
proxy_set_header X-authentik-username $authentik_username;
proxy_set_header X-authentik-groups $authentik_groups;
proxy_set_header X-authentik-email $authentik_email;
proxy_set_header X-authentik-name $authentik_name;
proxy_set_header X-authentik-uid $authentik_uid;
# On authentication failure, redirect to Authentik login
error_page 401 = @authentik_proxy_signin;
location @authentik_proxy_signin {{
internal;
add_header Set-Cookie $auth_cookie;
return 302 /outpost.goauthentik.io/start?rd=$scheme://$http_host$request_uri;
}}
# Authentik authentication endpoint
location /outpost.goauthentik.io {{
proxy_pass {authentik_url}/outpost.goauthentik.io;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Host $host;
}}
"""
# Update the advanced config
proxy_host["advanced_config"] = auth_config
# Remove read-only fields that NPM doesn't accept in updates
readonly_fields = [
"id", "created_on", "modified_on", "owner", "owner_user_id",
"certificate", "use_default_location", "ipv6", "meta", "nginx_online",
"nginx_err", "access_list", "certificate_id"
]
clean_config = {k: v for k, v in proxy_host.items() if k not in readonly_fields}
# Ensure locations is an array (required field)
if "locations" not in clean_config or clean_config["locations"] is None:
clean_config["locations"] = []
# Update the proxy host
return await self.update_proxy_host(proxy_id, clean_config)
async def get_certificates(self) -> List[Dict[str, Any]]:
"""
List all SSL certificates
@@ -208,6 +208,87 @@ class PortainerClient:
response.raise_for_status()
return True
async def get_containers(self, endpoint_id: int, all_containers: bool = True) -> List[Dict[str, Any]]:
"""
List containers on a specific endpoint
Args:
endpoint_id: Portainer endpoint identifier
all_containers: Include stopped containers (default: True)
Returns:
List of container details
"""
params = {"all": 1 if all_containers else 0}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/json",
headers=self._get_headers(),
params=params
)
response.raise_for_status()
return response.json()
async def get_container(self, endpoint_id: int, container_id: str) -> Dict[str, Any]:
"""
Get detailed information about a specific container
Args:
endpoint_id: Portainer endpoint identifier
container_id: Container ID or name
Returns:
Container details including network and port information
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/json",
headers=self._get_headers()
)
response.raise_for_status()
return response.json()
async def stop_container(self, endpoint_id: int, container_id: str) -> bool:
"""
Stop a container
Args:
endpoint_id: Portainer endpoint identifier
container_id: Container ID or name
Returns:
True if successful
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/stop",
headers=self._get_headers()
)
response.raise_for_status()
logger.info(f"Stopped container {container_id}")
return True
async def start_container(self, endpoint_id: int, container_id: str) -> bool:
"""
Start a container
Args:
endpoint_id: Portainer endpoint identifier
container_id: Container ID or name
Returns:
True if successful
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/start",
headers=self._get_headers()
)
response.raise_for_status()
logger.info(f"Started container {container_id}")
return True
# Singleton instance
_portainer_client: Optional[PortainerClient] = None
+5
View File
@@ -89,6 +89,11 @@ class Settings(BaseSettings):
kuma_username: str = KUMA_USERNAME
kuma_password: str = KUMA_PASSWORD
# OIDC Authentication (Authentik)
oidc_enabled: bool = False # Set to True to require authentication
oidc_issuer: str = "https://auth.schweitz.net/application/o/core-api/"
oidc_audience: str = "core-api"
@property
def model_aliases(self) -> dict:
"""Computed property for model aliases"""
@@ -0,0 +1,671 @@
"""
AI Controller
Provides AI orchestration endpoints including:
- OpenAI-compatible chat completions
- Model listing
- Conversation memory management
"""
import time
import logging
import uuid
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
from typing import AsyncIterator, List, Optional
from pydantic import BaseModel, Field
from src.controllers.base import BaseController
from src.api.v1.schemas import (
ChatCompletionRequest,
ChatCompletionResponse,
ChatCompletionChoice,
ChatMessageResponse,
UsageInfo,
ChatCompletionStreamResponse,
ChatCompletionStreamChoice,
DeltaMessage,
ModelsListResponse,
ModelInfo,
)
from src.models.ollama_client import get_ollama_client
from src.memory import get_memory_manager, MessageRole as MemoryMessageRole, TokenUsage
from src.config import get_settings
logger = logging.getLogger(__name__)
# Request/Response Models for Conversations
class SearchRequest(BaseModel):
"""Request model for semantic search"""
query: str = Field(..., description="Search query")
limit: int = Field(5, ge=1, le=50, description="Maximum number of results")
class ConversationTurnResponse(BaseModel):
"""Response model for a conversation turn"""
turn_number: int
role: str
content: str
timestamp: str
tokens_prompt: Optional[int] = None
tokens_completion: Optional[int] = None
tokens_total: Optional[int] = None
metadata: dict = Field(default_factory=dict)
class ConversationHistoryResponse(BaseModel):
"""Response model for conversation history"""
conversation_id: str
turn_count: int
total_tokens: int
turns: List[ConversationTurnResponse]
class SearchResultResponse(BaseModel):
"""Response model for a single search result"""
conversation_id: str
turn_number: int
role: str
content: str
timestamp: str
score: float
class SearchResponse(BaseModel):
"""Response model for search results"""
query: str
results: List[SearchResultResponse]
count: int
class ConversationStatsResponse(BaseModel):
"""Response model for conversation statistics"""
conversation_id: str
buffer_turns: int
buffer_tokens: int
qdrant_turns: int
qdrant_tokens: int
exists_in_buffer: bool
exists_in_qdrant: bool
class DeleteResponse(BaseModel):
"""Response model for delete operation"""
conversation_id: str
deleted: bool
message: str
# Helper functions
def build_prompt_from_messages(messages: list) -> str:
"""
Convert message list to a prompt string.
"""
prompt_parts = []
for msg in messages:
role = msg.role.value if hasattr(msg.role, 'value') else msg.role
content = msg.content
if role == "system":
prompt_parts.append(f"System: {content}")
elif role == "user":
prompt_parts.append(f"User: {content}")
elif role == "assistant":
prompt_parts.append(f"Assistant: {content}")
prompt_parts.append("Assistant:")
return "\n\n".join(prompt_parts)
async def stream_chat_completion(
request_id: str,
model: str,
prompt: str,
temperature: float,
max_tokens: int | None
) -> AsyncIterator[str]:
"""
Stream chat completion in OpenAI SSE format.
Yields:
Server-Sent Events formatted strings
"""
created = int(time.time())
ollama_client = get_ollama_client()
# First chunk with role
first_chunk = ChatCompletionStreamResponse(
id=request_id,
created=created,
model=model,
choices=[
ChatCompletionStreamChoice(
index=0,
delta=DeltaMessage(role="assistant"),
finish_reason=None
)
]
)
yield f"data: {first_chunk.model_dump_json()}\n\n"
# Stream tokens
try:
async for token in ollama_client.generate_streaming(
model=model,
prompt=prompt,
temperature=temperature,
max_tokens=max_tokens
):
chunk = ChatCompletionStreamResponse(
id=request_id,
created=created,
model=model,
choices=[
ChatCompletionStreamChoice(
index=0,
delta=DeltaMessage(content=token),
finish_reason=None
)
]
)
yield f"data: {chunk.model_dump_json()}\n\n"
except Exception as e:
logger.error(f"Streaming error: {e}")
# Send error in OpenAI format
import json
error_chunk = {
"error": {
"message": str(e),
"type": "server_error"
}
}
yield f"data: {json.dumps(error_chunk)}\n\n"
return
# Final chunk
final_chunk = ChatCompletionStreamResponse(
id=request_id,
created=created,
model=model,
choices=[
ChatCompletionStreamChoice(
index=0,
delta=DeltaMessage(),
finish_reason="stop"
)
]
)
yield f"data: {final_chunk.model_dump_json()}\n\n"
yield "data: [DONE]\n\n"
async def store_conversation_turn(
conversation_id: str,
role: str,
content: str,
tokens: dict = None
):
"""
Store a conversation turn in memory
Args:
conversation_id: Unique conversation identifier
role: Message role (user, assistant, system)
content: Message content
tokens: Optional token usage dict
"""
try:
memory_manager = get_memory_manager()
# Convert role string to MemoryMessageRole
if role == "user":
memory_role = MemoryMessageRole.USER
elif role == "assistant":
memory_role = MemoryMessageRole.ASSISTANT
elif role == "system":
memory_role = MemoryMessageRole.SYSTEM
else:
memory_role = MemoryMessageRole.USER # Default fallback
# Create TokenUsage if provided
token_usage = None
if tokens:
token_usage = TokenUsage(
prompt=tokens.get("prompt", 0),
completion=tokens.get("completion", 0),
total=tokens.get("total", 0)
)
# Store in memory
await memory_manager.add_turn(
conversation_id=conversation_id,
role=memory_role,
content=content,
tokens=token_usage
)
logger.debug(f"Stored {role} turn in memory for conversation {conversation_id}")
except Exception as e:
# Log error but don't fail the request
logger.error(f"Failed to store turn in memory: {e}")
class AIController(BaseController):
"""
Controller for AI orchestration operations
Provides endpoints for:
- OpenAI-compatible chat completions (streaming and non-streaming)
- Model listing
- Conversation memory management
"""
def __init__(self):
super().__init__(prefix="/v1", tags=["AI"])
def create_router(self) -> APIRouter:
"""Create and configure the router"""
router = APIRouter()
settings = get_settings()
# Chat Completions Endpoint
@router.post(
"/v1/chat/completions",
tags=["AI"]
)
async def chat_completions(request: ChatCompletionRequest):
"""
OpenAI-compatible chat completions endpoint.
Supports both streaming and non-streaming.
Automatically stores conversations in memory system if enabled.
"""
request_id = f"chatcmpl-{int(time.time() * 1000)}"
# Generate or use provided conversation_id
conversation_id = request.conversation_id or f"conv_{uuid.uuid4().hex[:16]}"
logger.info(
f"Chat request: id={request_id}, model={request.model}, "
f"messages={len(request.messages)}, stream={request.stream}, "
f"conversation_id={conversation_id}, store_in_memory={request.store_in_memory}"
)
# Store user messages in memory (if enabled)
if request.store_in_memory:
for msg in request.messages:
role = msg.role.value if hasattr(msg.role, 'value') else msg.role
if role == "user": # Store latest user message
await store_conversation_turn(
conversation_id=conversation_id,
role=role,
content=msg.content
)
# Build prompt from messages
prompt = build_prompt_from_messages(request.messages)
# Streaming response
if request.stream:
return StreamingResponse(
stream_chat_completion(
request_id=request_id,
model=request.model,
prompt=prompt,
temperature=request.temperature,
max_tokens=request.max_tokens
),
media_type="text/event-stream"
)
# Non-streaming response
try:
ollama_client = get_ollama_client()
result = await ollama_client.generate_non_streaming(
model=request.model,
prompt=prompt,
temperature=request.temperature,
max_tokens=request.max_tokens
)
assistant_content = result["response"]
# Store assistant response in memory (if enabled)
if request.store_in_memory:
await store_conversation_turn(
conversation_id=conversation_id,
role="assistant",
content=assistant_content,
tokens=result["tokens"]
)
response = ChatCompletionResponse(
id=request_id,
created=int(time.time()),
model=request.model,
choices=[
ChatCompletionChoice(
index=0,
message=ChatMessageResponse(
role="assistant",
content=assistant_content
),
finish_reason="stop"
)
],
usage=UsageInfo(
prompt_tokens=result["tokens"]["prompt"],
completion_tokens=result["tokens"]["completion"],
total_tokens=result["tokens"]["total"]
)
)
logger.info(
f"Chat response: id={request_id}, "
f"tokens={result['tokens']['total']}, "
f"conversation_id={conversation_id}"
)
return response
except Exception as e:
logger.error(f"Chat completion error: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to generate completion: {str(e)}"
)
# Models Endpoint
@router.get(
"/v1/models",
tags=["AI"]
)
async def list_models():
"""List available models in OpenAI format."""
models = []
# Add OpenAI-style aliases
for alias in settings.model_aliases.keys():
models.append(ModelInfo(id=alias, owned_by="tatlock"))
# Add actual local models
for model_list in [
settings.get_lightweight_models(),
settings.get_heavy_models(),
settings.get_code_models()
]:
for model in model_list:
# Avoid duplicates
if model not in [m.id for m in models]:
models.append(ModelInfo(id=model, owned_by="tatlock"))
return ModelsListResponse(data=models)
# Conversation Endpoints
@router.get(
"/v1/conversations/{conversation_id}",
response_model=ConversationHistoryResponse,
tags=["Conversations"],
summary="Get conversation history",
description="Retrieve complete conversation history including all turns"
)
async def get_conversation(
conversation_id: str,
include_buffer: bool = Query(
True,
description="Include recent turns from buffer that haven't been consolidated yet"
)
):
"""
Get complete conversation history
Args:
conversation_id: Unique conversation identifier
include_buffer: Include recent buffer turns not yet consolidated
Returns:
Complete conversation history with all turns
"""
manager = get_memory_manager()
# Get full history
turns = await manager.get_full_history(conversation_id, include_buffer=include_buffer)
if not turns:
raise HTTPException(
status_code=404,
detail=f"Conversation {conversation_id} not found"
)
# Convert to response format
turn_responses = []
total_tokens = 0
for turn in turns:
turn_response = ConversationTurnResponse(
turn_number=turn.turn_number,
role=turn.role.value if isinstance(turn.role, MemoryMessageRole) else turn.role,
content=turn.content,
timestamp=turn.timestamp.isoformat(),
metadata=turn.metadata
)
if turn.tokens:
turn_response.tokens_prompt = turn.tokens.prompt
turn_response.tokens_completion = turn.tokens.completion
turn_response.tokens_total = turn.tokens.total
total_tokens += turn.tokens.total
turn_responses.append(turn_response)
return ConversationHistoryResponse(
conversation_id=conversation_id,
turn_count=len(turns),
total_tokens=total_tokens,
turns=turn_responses
)
@router.get(
"/v1/conversations/{conversation_id}/stats",
response_model=ConversationStatsResponse,
tags=["Conversations"],
summary="Get conversation statistics",
description="Get detailed statistics about a conversation across all storage tiers"
)
async def get_conversation_stats(conversation_id: str):
"""
Get conversation statistics
Args:
conversation_id: Unique conversation identifier
Returns:
Statistics including turn counts and token usage across tiers
"""
manager = get_memory_manager()
stats = await manager.get_conversation_stats(conversation_id)
return ConversationStatsResponse(**stats)
@router.post(
"/v1/conversations/{conversation_id}/search",
response_model=SearchResponse,
tags=["Conversations"],
summary="Search conversation semantically",
description="Search for relevant turns within a conversation using semantic similarity"
)
async def search_conversation(
conversation_id: str,
search_request: SearchRequest
):
"""
Semantic search within a conversation
Args:
conversation_id: Unique conversation identifier
search_request: Search query and parameters
Returns:
Relevant conversation turns ranked by semantic similarity
"""
manager = get_memory_manager()
# Perform semantic search
results = await manager.search_conversations(
query=search_request.query,
conversation_id=conversation_id,
limit=search_request.limit
)
# Convert to response format
search_results = [
SearchResultResponse(
conversation_id=result["conversation_id"],
turn_number=result["turn_number"],
role=result["role"],
content=result["content"],
timestamp=result["timestamp"],
score=result["score"]
)
for result in results
]
return SearchResponse(
query=search_request.query,
results=search_results,
count=len(search_results)
)
@router.post(
"/v1/conversations/search",
response_model=SearchResponse,
tags=["Conversations"],
summary="Search all conversations",
description="Search across all conversations using semantic similarity"
)
async def search_all_conversations(search_request: SearchRequest):
"""
Semantic search across all conversations
Args:
search_request: Search query and parameters
Returns:
Relevant turns from any conversation ranked by semantic similarity
"""
manager = get_memory_manager()
# Perform semantic search across all conversations
results = await manager.search_conversations(
query=search_request.query,
conversation_id=None, # Search all conversations
limit=search_request.limit
)
# Convert to response format
search_results = [
SearchResultResponse(
conversation_id=result["conversation_id"],
turn_number=result["turn_number"],
role=result["role"],
content=result["content"],
timestamp=result["timestamp"],
score=result["score"]
)
for result in results
]
return SearchResponse(
query=search_request.query,
results=search_results,
count=len(search_results)
)
@router.delete(
"/v1/conversations/{conversation_id}",
response_model=DeleteResponse,
tags=["Conversations"],
summary="Delete conversation",
description="Delete a conversation from all storage tiers"
)
async def delete_conversation(
conversation_id: str,
clear_buffer: bool = Query(True, description="Clear from buffer (Tier 1)"),
clear_qdrant: bool = Query(True, description="Clear from Qdrant (Tier 2/3)")
):
"""
Delete a conversation
Args:
conversation_id: Unique conversation identifier
clear_buffer: Clear from Tier 1 buffer
clear_qdrant: Clear from Tier 2/3 Qdrant
Returns:
Deletion confirmation
"""
manager = get_memory_manager()
try:
await manager.clear_conversation(
conversation_id,
clear_buffer=clear_buffer,
clear_qdrant=clear_qdrant
)
return DeleteResponse(
conversation_id=conversation_id,
deleted=True,
message=f"Conversation {conversation_id} deleted successfully"
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Error deleting conversation: {str(e)}"
)
@router.post(
"/v1/conversations/{conversation_id}/consolidate",
tags=["Conversations"],
summary="Consolidate conversation",
description="Manually trigger consolidation from buffer to persistent storage"
)
async def consolidate_conversation(conversation_id: str):
"""
Manually consolidate a conversation
Moves all buffer turns to Qdrant persistent storage.
Args:
conversation_id: Unique conversation identifier
Returns:
Number of turns consolidated
"""
manager = get_memory_manager()
try:
count = await manager.consolidate(conversation_id)
return {
"conversation_id": conversation_id,
"consolidated_turns": count,
"message": f"Successfully consolidated {count} turns to persistent storage"
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Error consolidating conversation: {str(e)}"
)
return router
# Create controller instance
ai_controller = AIController()
@@ -0,0 +1,89 @@
"""
Health Controller
Provides service health and information endpoints
"""
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from src.controllers.base import BaseController
from src.config import get_settings
from src.logging_config import get_logger
from src.models.ollama_client import get_ollama_client
logger = get_logger(__name__)
class HealthController(BaseController):
"""
Controller for service health and information
Provides endpoints for:
- Service information and status
- Health checks
"""
def __init__(self):
super().__init__(prefix="", tags=["Health"])
def create_router(self) -> APIRouter:
"""Create and configure the router"""
router = APIRouter(tags=self.tags)
settings = get_settings()
@router.get(
"/",
summary="Service information",
response_class=JSONResponse
)
async def root():
"""
Get service information and health status
Returns basic information about the API service and available endpoints.
"""
logger.debug("Root endpoint accessed")
return {
"service": settings.app_name,
"version": settings.app_version,
"status": "healthy",
"documentation": {
"swagger_ui": "/docs",
"redoc": "/redoc",
"openapi_spec": "/openapi.json"
},
"endpoints": {
"chat_completions": "/v1/chat/completions",
"models": "/v1/models",
"conversations": "/v1/conversations",
"web_scraper": "/web-scraper/scrape",
"infrastructure": "/infrastructure",
"health": "/health"
}
}
@router.get(
"/health",
summary="Health check",
response_class=JSONResponse
)
async def health_check():
"""
Simple health check endpoint for container orchestration
Returns a 200 OK status when the service is running properly.
Used by Docker, Kubernetes, and load balancers.
"""
ollama_client = get_ollama_client()
ollama_healthy = await ollama_client.health_check()
return {
"status": "healthy",
"ollama_connected": ollama_healthy
}
return router
# Create controller instance
health_controller = HealthController()
@@ -4,14 +4,17 @@ Infrastructure Management Controller
Provides API endpoints for automated infrastructure management,
including service deployment, configuration, and monitoring setup.
"""
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Depends
from typing import List, Dict, Any, Optional, Union
from pydantic import BaseModel, field_validator
from src.controllers.base import BaseController
from src.clients.portainer_client import get_portainer_client
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
logger = get_logger(__name__)
@@ -25,6 +28,9 @@ class ServiceInfo(BaseModel):
endpoint_id: Optional[int]
ports: List[int] = []
domains: List[str] = []
running: bool = False # True if at least one container is running
containers_running: int = 0 # Number of running containers
containers_total: int = 0 # Total number of containers
@field_validator('status', mode='before')
@classmethod
@@ -40,7 +46,12 @@ class PortInfo(BaseModel):
"""Information about an allocated port"""
port: int
service: str
container_name: Optional[str] = None
protocol: str = "tcp"
internal_hostname: Optional[str] = None
internal_ip: Optional[str] = None
external_domains: List[str] = []
host_port: Optional[int] = None # Port exposed on host, if different from internal
description: str = ""
@@ -158,17 +169,19 @@ class InfrastructureController(BaseController):
@router.get(
"/services",
response_model=List[ServiceInfo],
summary="List all deployed services"
summary="List all deployed services (Docker Compose stacks)",
description="List all services deployed via Portainer stacks. Each 'service' represents a Docker Compose stack."
)
async def list_services():
"""
List all deployed services from Portainer stacks
List all deployed Docker Compose stacks from Portainer
Returns comprehensive service information including:
- Stack/service name
- Status
Returns comprehensive stack/service information including:
- Stack name (the service name)
- Status (active/inactive)
- Exposed ports
- Configured domains
- Configured domains (from NPM reverse proxy)
- Running container count
"""
portainer = get_portainer_client()
npm = get_npm_client()
@@ -189,18 +202,38 @@ class InfrastructureController(BaseController):
for stack in stacks:
# Find domains for this stack
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 for this stack
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}")
service_info = ServiceInfo(
name=stack_name,
stack_id=stack.get("Id"),
status=stack.get("Status", "unknown"),
endpoint_id=stack.get("EndpointId"),
endpoint_id=endpoint_id,
ports=[], # TODO: Extract from stack file
domains=domains
domains=domains,
running=containers_running > 0,
containers_running=containers_running,
containers_total=containers_total
)
services.append(service_info)
@@ -263,12 +296,133 @@ class InfrastructureController(BaseController):
"""
List all currently allocated ports
Scans services and proxy configurations to build
a comprehensive port allocation map.
Scans all running containers to extract:
- Internal and external port mappings
- Container internal hostnames and IPs
- External domain names (from NPM proxy configuration)
"""
# TODO: Implement port scanning from containers and proxy configs
# For now, return a placeholder
return []
portainer = get_portainer_client()
npm = get_npm_client()
try:
# Get all endpoints (Docker environments)
endpoints = await portainer.get_endpoints()
# Get proxy hosts for external domain mapping
proxy_hosts = await npm.get_proxy_hosts()
# Build mapping of forward_host:forward_port -> domains
port_domain_map = {}
for proxy in proxy_hosts:
forward_host = proxy.get("forward_host", "")
forward_port = proxy.get("forward_port", 0)
domains = proxy.get("domain_names", [])
key = f"{forward_host}:{forward_port}"
if key not in port_domain_map:
port_domain_map[key] = []
port_domain_map[key].extend(domains)
ports = []
# Scan containers on each endpoint
for endpoint in endpoints:
endpoint_id = endpoint.get("Id")
try:
containers = await portainer.get_containers(endpoint_id, all_containers=False)
for container in containers:
container_name = container.get("Names", ["unknown"])[0].lstrip("/")
state = container.get("State", "")
# Skip non-running containers
if state != "running":
continue
# Extract network information
networks = container.get("NetworkSettings", {}).get("Networks", {})
internal_hostname = container_name
internal_ip = None
# Get first network IP
for network_name, network_info in networks.items():
if network_info.get("IPAddress"):
internal_ip = network_info.get("IPAddress")
break
# Extract port mappings
port_mappings = container.get("Ports", [])
for port_mapping in port_mappings:
internal_port = port_mapping.get("PrivatePort")
host_port = port_mapping.get("PublicPort")
protocol = port_mapping.get("Type", "tcp")
if not internal_port:
continue
# Find external domains for this port
external_domains = []
# Try matching by container name and port
key_by_name = f"{container_name}:{internal_port}"
if key_by_name in port_domain_map:
external_domains.extend(port_domain_map[key_by_name])
# Try matching by internal IP and port
if internal_ip:
key_by_ip = f"{internal_ip}:{internal_port}"
if key_by_ip in port_domain_map:
external_domains.extend(port_domain_map[key_by_ip])
# Try matching by localhost and host port
if host_port:
for localhost_variant in ["localhost", "127.0.0.1", "192.168.86.149"]:
key_by_host = f"{localhost_variant}:{host_port}"
if key_by_host in port_domain_map:
external_domains.extend(port_domain_map[key_by_host])
# Deduplicate external domains
external_domains = list(set(external_domains))
# Get service name from stack label or container name
labels = container.get("Labels", {})
service_name = labels.get("com.docker.compose.service", container_name)
port_info = PortInfo(
port=internal_port,
service=service_name,
container_name=container_name,
protocol=protocol,
internal_hostname=internal_hostname,
internal_ip=internal_ip,
external_domains=external_domains,
host_port=host_port,
description=f"{container_name} on {endpoint.get('Name', 'unknown')}"
)
ports.append(port_info)
except Exception as e:
logger.error(f"Failed to scan containers on endpoint {endpoint_id}: {e}")
continue
# Deduplicate ports based on (port, container_name, protocol)
seen = set()
unique_ports = []
for port_info in ports:
key = (port_info.port, port_info.container_name, port_info.protocol)
if key not in seen:
seen.add(key)
unique_ports.append(port_info)
# Sort by port number
unique_ports.sort(key=lambda p: p.port)
return unique_ports
except Exception as e:
logger.error(f"Failed to list ports: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/domains",
@@ -311,18 +465,32 @@ class InfrastructureController(BaseController):
@router.post(
"/services",
response_model=OperationResult,
summary="Deploy a new service",
summary="Deploy a new Docker Compose stack",
description="Deploy a new service by creating a Docker Compose stack in Portainer. Provide the stack name and compose file content. Requires admin authentication.",
status_code=201
)
async def deploy_service(request: DeployServiceRequest):
async def deploy_service(
request: DeployServiceRequest,
user: Dict = Depends(get_admin_user)
):
"""
Deploy a new service via Portainer stack
Deploy a new Docker Compose stack via Portainer
Creates a new Portainer stack from the provided Docker Compose content.
This is equivalent to deploying a stack through the Portainer UI.
Args:
request: Service deployment configuration
request: Stack deployment configuration containing:
- name: Stack name (must be unique)
- compose_content: Full docker-compose.yml content as string
- endpoint_id: Portainer endpoint ID (default: 3 for local)
Returns:
Operation result with stack details
Operation result with created stack details including stack ID
Raises:
409: If a stack with the same name already exists
500: If deployment fails
"""
portainer = get_portainer_client()
@@ -360,11 +528,19 @@ class InfrastructureController(BaseController):
@router.put(
"/services/{name}",
response_model=OperationResult,
summary="Update an existing service"
summary="Update an existing Docker Compose stack",
description="Update a deployed stack's Docker Compose configuration. This redeploys the stack with the new configuration. Requires admin authentication."
)
async def update_service(name: str, request: UpdateServiceRequest):
async def update_service(
name: str,
request: UpdateServiceRequest,
user: Dict = Depends(get_admin_user)
):
"""
Update an existing service's compose configuration
Update an existing Docker Compose stack's configuration
Updates the stack's compose file and redeploys it. This is equivalent
to updating a stack through the Portainer UI.
Args:
name: Service/stack name
@@ -415,9 +591,13 @@ class InfrastructureController(BaseController):
@router.delete(
"/services/{name}",
response_model=OperationResult,
summary="Delete a service"
summary="Delete a service",
description="Delete a service and remove its stack. Requires admin authentication."
)
async def delete_service(name: str):
async def delete_service(
name: str,
user: Dict = Depends(get_admin_user)
):
"""
Delete a service and remove its stack
@@ -463,13 +643,40 @@ class InfrastructureController(BaseController):
logger.error(f"Failed to delete service '{name}': {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/proxy/{proxy_id}",
summary="Get proxy host details"
)
async def get_proxy_host(proxy_id: int):
"""
Get detailed configuration of a specific proxy host
Args:
proxy_id: NPM proxy host ID
Returns:
Complete proxy host configuration including locations
"""
npm = get_npm_client()
try:
proxy_host = await npm.get_proxy_host(proxy_id)
return proxy_host
except Exception as e:
logger.error(f"Failed to get proxy host {proxy_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/proxy",
response_model=OperationResult,
summary="Create a new proxy host",
description="Create a new Nginx Proxy Manager proxy host with optional SSL certificate. Requires admin authentication.",
status_code=201
)
async def create_proxy(request: CreateProxyRequest):
async def create_proxy(
request: CreateProxyRequest,
user: Dict = Depends(get_admin_user)
):
"""
Create a new Nginx Proxy Manager proxy host
@@ -523,6 +730,410 @@ class InfrastructureController(BaseController):
logger.error(f"Failed to create proxy host: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Service Control Endpoints
@router.get(
"/service-groups",
summary="List service groups"
)
async def list_service_groups():
"""
List all defined service groups
Returns service groups with their member services and status.
"""
return {
"groups": service_groups.list_service_groups(),
"always_on": list(service_groups.ALWAYS_ON_SERVICES),
"stoppable": service_groups.list_stoppable_services()
}
@router.post(
"/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."
)
async def stop_service(
name: str,
user: Dict = Depends(get_admin_user)
):
"""
Stop a service or service group
This will:
1. Validate service can be stopped (not always-on)
2. Pause Uptime Kuma monitors for all services in group
3. Stop the Portainer stack(s)
Args:
name: Service or group name
Returns:
Operation result with details
"""
portainer = get_portainer_client()
kuma = get_kuma_client()
try:
# Get all services in the group
services = service_groups.get_service_group(name)
# Validate none are always-on
is_valid, error_msg = service_groups.validate_stop_request(services)
if not is_valid:
raise HTTPException(status_code=403, detail=error_msg)
results = {
"stopped_services": [],
"paused_monitors": [],
"errors": []
}
# Stop each service
for service_name in services:
try:
# 1. Pause Uptime Kuma monitor
try:
monitor_paused = await kuma.pause_monitor_by_name(service_name)
if monitor_paused:
results["paused_monitors"].append(service_name)
logger.info(f"Paused Kuma monitor for {service_name}")
except Exception as e:
logger.warning(f"Failed to pause Kuma monitor for {service_name}: {e}")
results["errors"].append(f"Kuma pause failed for {service_name}: {str(e)}")
# 2. Stop Portainer stack
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == service_name.lower()),
None
)
if stack:
stack_id = stack.get("Id")
endpoint_id = stack.get("EndpointId")
# Stop stack by deleting it (Portainer doesn't have a "stop" operation)
# Note: This is destructive. For a gentler approach, we'd need to use docker compose stop
# Let's use docker API instead
logger.info(f"Stopping containers for stack: {service_name}")
# Get containers for this stack
containers = await portainer.get_containers(endpoint_id, all_containers=False)
stopped_containers = []
for container in containers:
labels = container.get("Labels", {})
container_stack = labels.get("com.docker.compose.project", "")
if container_stack.lower() == service_name.lower():
container_id = container.get("Id")
# Stop container via Portainer Docker API
await portainer.stop_container(endpoint_id, container_id)
stopped_containers.append(container.get("Names", ["unknown"])[0])
results["stopped_services"].append({
"service": service_name,
"stack_id": stack_id,
"containers": stopped_containers
})
logger.info(f"Stopped service: {service_name}")
else:
results["errors"].append(f"Stack not found: {service_name}")
except Exception as e:
logger.error(f"Failed to stop service {service_name}: {e}")
results["errors"].append(f"{service_name}: {str(e)}")
success = len(results["stopped_services"]) > 0
message = f"Stopped {len(results['stopped_services'])} service(s)"
if results["errors"]:
message += f" with {len(results['errors'])} error(s)"
return OperationResult(
success=success,
message=message,
details=results
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to stop service group '{name}': {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/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."
)
async def start_service(
name: str,
user: Dict = Depends(get_admin_user)
):
"""
Start a service or service group
This will:
1. Start the Portainer stack(s)
2. Resume Uptime Kuma monitors for all services in group
Args:
name: Service or group name
Returns:
Operation result with details
"""
portainer = get_portainer_client()
kuma = get_kuma_client()
try:
# Get all services in the group
services = service_groups.get_service_group(name)
results = {
"started_services": [],
"resumed_monitors": [],
"errors": []
}
# Start each service
for service_name in services:
try:
# 1. Start Portainer stack (start containers)
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == service_name.lower()),
None
)
if stack:
stack_id = stack.get("Id")
endpoint_id = stack.get("EndpointId")
logger.info(f"Starting containers for stack: {service_name}")
# Get containers for this stack
containers = await portainer.get_containers(endpoint_id, all_containers=True)
started_containers = []
for container in containers:
labels = container.get("Labels", {})
container_stack = labels.get("com.docker.compose.project", "")
if container_stack.lower() == service_name.lower():
container_id = container.get("Id")
# Start container via Portainer Docker API
await portainer.start_container(endpoint_id, container_id)
started_containers.append(container.get("Names", ["unknown"])[0])
results["started_services"].append({
"service": service_name,
"stack_id": stack_id,
"containers": started_containers
})
logger.info(f"Started service: {service_name}")
# 2. Resume Uptime Kuma monitor
try:
monitor_resumed = await kuma.resume_monitor_by_name(service_name)
if monitor_resumed:
results["resumed_monitors"].append(service_name)
logger.info(f"Resumed Kuma monitor for {service_name}")
except Exception as e:
logger.warning(f"Failed to resume Kuma monitor for {service_name}: {e}")
results["errors"].append(f"Kuma resume failed for {service_name}: {str(e)}")
else:
results["errors"].append(f"Stack not found: {service_name}")
except Exception as e:
logger.error(f"Failed to start service {service_name}: {e}")
results["errors"].append(f"{service_name}: {str(e)}")
success = len(results["started_services"]) > 0
message = f"Started {len(results['started_services'])} service(s)"
if results["errors"]:
message += f" with {len(results['errors'])} error(s)"
return OperationResult(
success=success,
message=message,
details=results
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to start service group '{name}': {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== Monitoring Endpoints =====
@router.get(
"/monitors",
summary="List all monitors",
response_model=Dict[str, Any]
)
async def list_monitors():
"""
List all Uptime Kuma monitors
Returns:
List of monitors with their configurations
"""
try:
kuma = get_kuma_client()
monitors = await kuma.get_monitors()
return {
"success": True,
"monitors": monitors,
"total": len(monitors)
}
except Exception as e:
logger.error(f"Failed to list monitors: {e}")
raise HTTPException(status_code=500, detail=f"Failed to list monitors: {str(e)}")
@router.post(
"/monitors",
summary="Create a new monitor",
description="Create a new Uptime Kuma monitor. Requires admin authentication.",
response_model=Dict[str, Any]
)
async def create_monitor(
monitor_config: Dict[str, Any],
user: Dict = Depends(get_admin_user)
):
"""
Create a new Uptime Kuma monitor
Args:
monitor_config: Monitor configuration (name, type, hostname, port, etc.)
Returns:
Created monitor details including ID
"""
try:
kuma = get_kuma_client()
created_monitor = await kuma.add_monitor(monitor_config)
return {
"success": True,
"message": f"Monitor '{monitor_config.get('name')}' created successfully",
"monitor": created_monitor
}
except Exception as e:
logger.error(f"Failed to create monitor: {e}")
raise HTTPException(status_code=500, detail=f"Failed to create monitor: {str(e)}")
@router.get(
"/monitors/{monitor_id}",
summary="Get monitor details",
response_model=Dict[str, Any]
)
async def get_monitor(monitor_id: int):
"""
Get details of a specific monitor
Args:
monitor_id: Monitor identifier
Returns:
Monitor configuration and status
"""
try:
kuma = get_kuma_client()
monitor = await kuma.get_monitor(monitor_id)
return {
"success": True,
"monitor": monitor
}
except Exception as e:
logger.error(f"Failed to get monitor {monitor_id}: {e}")
raise HTTPException(status_code=404, detail=f"Monitor {monitor_id} not found: {str(e)}")
@router.put(
"/monitors/{monitor_id}",
summary="Update a monitor",
description="Update an existing Uptime Kuma monitor. Requires admin authentication.",
response_model=Dict[str, Any]
)
async def update_monitor(
monitor_id: int,
updates: Dict[str, Any],
user: Dict = Depends(get_admin_user)
):
"""
Update an existing monitor
Args:
monitor_id: Monitor identifier
updates: Fields to update
Returns:
Updated monitor details
"""
try:
kuma = get_kuma_client()
# Get existing monitor
existing = await kuma.get_monitor(monitor_id)
# Merge updates
monitor_config = existing.copy()
monitor_config.update(updates)
# Update monitor
updated_monitor = await kuma.update_monitor(monitor_id, monitor_config)
return {
"success": True,
"message": f"Monitor {monitor_id} updated successfully",
"monitor": updated_monitor
}
except Exception as e:
logger.error(f"Failed to update monitor {monitor_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to update monitor: {str(e)}")
@router.delete(
"/monitors/{monitor_id}",
summary="Delete a monitor",
description="Delete an Uptime Kuma monitor. Requires admin authentication.",
response_model=Dict[str, Any]
)
async def delete_monitor(
monitor_id: int,
user: Dict = Depends(get_admin_user)
):
"""
Delete a monitor
Args:
monitor_id: Monitor identifier
Returns:
Success confirmation
"""
try:
kuma = get_kuma_client()
await kuma.delete_monitor(monitor_id)
return {
"success": True,
"message": f"Monitor {monitor_id} deleted successfully"
}
except Exception as e:
logger.error(f"Failed to delete monitor {monitor_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to delete monitor: {str(e)}")
return router
@@ -0,0 +1,116 @@
"""
Static Files Controller
Serves static files for widgets and other frontend assets.
"""
from fastapi import APIRouter
from fastapi.responses import FileResponse, HTMLResponse
from pathlib import Path
import os
from src.controllers.base import BaseController
from src.logging_config import get_logger
logger = get_logger(__name__)
class StaticController(BaseController):
"""
Controller for serving static files
Provides endpoints for:
- Organizr widgets
- Other static assets
"""
def __init__(self):
super().__init__(prefix="/static", tags=["Static"])
self.static_dir = Path(__file__).parent.parent.parent / "static"
def create_router(self) -> APIRouter:
"""Create and configure the router"""
router = APIRouter(prefix=self.prefix, tags=self.tags)
@router.get(
"/widgets/{filename}",
response_class=HTMLResponse,
summary="Get widget file"
)
async def get_widget(filename: str):
"""
Serve widget HTML files
Args:
filename: Widget filename (e.g., service-control.html)
Returns:
HTML file content
"""
widget_path = self.static_dir / "widgets" / filename
if not widget_path.exists():
return HTMLResponse(
content=f"<h1>404 - Widget not found</h1><p>{filename}</p>",
status_code=404
)
if not widget_path.is_file():
return HTMLResponse(
content=f"<h1>400 - Not a file</h1>",
status_code=400
)
# Security: Ensure the path is within the static directory
try:
widget_path.resolve().relative_to(self.static_dir.resolve())
except ValueError:
return HTMLResponse(
content=f"<h1>403 - Forbidden</h1>",
status_code=403
)
logger.info(f"Serving widget: {filename}")
return FileResponse(
widget_path,
media_type="text/html",
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
)
@router.get(
"/widgets",
summary="List available widgets"
)
async def list_widgets():
"""
List all available widget files
Returns:
List of widget filenames
"""
widgets_dir = self.static_dir / "widgets"
if not widgets_dir.exists():
return {"widgets": [], "message": "Widgets directory not found"}
widgets = []
for file in widgets_dir.glob("*.html"):
widgets.append({
"name": file.name,
"url": f"/static/widgets/{file.name}",
"size": file.stat().st_size
})
return {
"widgets": widgets,
"count": len(widgets)
}
return router
# Create controller instance
static_controller = StaticController()
@@ -0,0 +1,98 @@
"""
Tools Controller
Provides utility tool endpoints including:
- Web scraping and content extraction
"""
from fastapi import APIRouter, HTTPException, status
from src.controllers.base import BaseController
from src.logging_config import get_logger
from src.web_scraper.schemas import WebScraperRequest, WebScraperResponse
from src.web_scraper.service import WebScraperService
from src.web_scraper.exceptions import FetchError, ScrapingError
logger = get_logger(__name__)
class ToolsController(BaseController):
"""
Controller for utility tools
Provides endpoints for:
- Web scraping and content extraction
"""
def __init__(self):
super().__init__(prefix="/web-scraper", tags=["Tools"])
# Initialize service (could be dependency injected for testing)
self.scraper_service = WebScraperService()
def create_router(self) -> APIRouter:
"""Create and configure the router"""
router = APIRouter(prefix=self.prefix, tags=self.tags)
@router.post(
"/scrape",
response_model=WebScraperResponse,
status_code=status.HTTP_200_OK,
summary="Scrape website content",
description="""
Scrape and extract main content from a website.
Uses trafilatura for intelligent content extraction (articles, blog posts, documentation),
with BeautifulSoup as fallback. Perfect for feeding webpage content to LLMs.
**Features:**
- Intelligent main content extraction
- Removes navigation, ads, footers
- Optional link extraction
- Configurable content length limits
**Rate Limiting:** None (internal network use only)
"""
)
async def scrape_website(request: WebScraperRequest) -> WebScraperResponse:
"""
Scrape a website and extract its main content
Args:
request: Scraping request with URL and options
Returns:
Extracted content with metadata
Raises:
HTTPException: 400 for fetch errors, 500 for processing errors
"""
try:
logger.info(f"Received scrape request for: {request.url}")
result = await self.scraper_service.scrape_url(request)
return result
except FetchError as e:
logger.warning(f"Fetch failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to fetch URL: {str(e)}"
)
except ScrapingError as e:
logger.error(f"Scraping failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to extract content: {str(e)}"
)
except Exception as e:
logger.error(f"Unexpected error: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred"
)
return router
# Create controller instance
tools_controller = ToolsController()
+27 -69
View File
@@ -8,12 +8,13 @@ from contextlib import asynccontextmanager
from src.config import get_settings
from src.logging_config import setup_logging, get_logger
from src.web_scraper import router as web_scraper_router
from src.api.v1.chat import router as chat_router
from src.api.v1.models import router as models_router
from src.api.v1.conversations import router as conversations_router
from src.models.ollama_client import get_ollama_client, close_ollama_client
from src.controllers.infrastructure_controller import infrastructure_controller
from src.controllers.ai_controller import ai_controller
from src.controllers.tools_controller import tools_controller
from src.controllers.health_controller import health_controller
from src.controllers.static_controller import static_controller
from src.security import initialize_oidc
# Initialize settings
settings = get_settings()
@@ -47,6 +48,9 @@ async def lifespan(app: FastAPI):
else:
logger.warning("✗ Ollama connection failed - AI features may not work")
# Initialize security (OIDC authentication)
initialize_oidc(settings)
yield
# Shutdown
@@ -88,7 +92,7 @@ app = FastAPI(
- `GET /infrastructure/ports` - List allocated ports
- `GET /infrastructure/domains` - List configured domains
**Write Endpoints:**
**Write Endpoints (Admin Only):**
- `POST /infrastructure/services` - Deploy new service from compose YAML
- `PUT /infrastructure/services/{name}` - Update existing service
- `DELETE /infrastructure/services/{name}` - Remove service and stack
@@ -100,6 +104,13 @@ app = FastAPI(
Intelligent web scraping with main content extraction.
Perfect for extracting articles, documentation, and blog posts for LLM consumption.
## Authentication
When OIDC authentication is enabled (oidc_enabled=true in config):
- Infrastructure write endpoints require authentication
- Use OAuth2/OIDC bearer token from Authentik
- Admin group membership required for infrastructure operations
## Integration
This API is designed to integrate with:
@@ -118,7 +129,11 @@ app = FastAPI(
redoc_url="/redoc",
openapi_url="/openapi.json",
lifespan=lifespan,
debug=settings.debug
debug=settings.debug,
swagger_ui_init_oauth={
"clientId": settings.oidc_audience,
"usePkceWithAuthorizationCodeGrant": True,
} if settings.oidc_enabled else None
)
# Add CORS middleware
@@ -131,69 +146,12 @@ app.add_middleware(
)
# Root endpoint
@app.get(
"/",
tags=["Health"],
summary="Service information",
response_class=JSONResponse
)
async def root():
"""
Get service information and health status
Returns basic information about the API service and available endpoints.
"""
logger.debug("Root endpoint accessed")
return {
"service": settings.app_name,
"version": settings.app_version,
"status": "healthy",
"documentation": {
"swagger_ui": "/docs",
"redoc": "/redoc",
"openapi_spec": "/openapi.json"
},
"endpoints": {
"chat_completions": "/v1/chat/completions",
"models": "/v1/models",
"conversations": "/v1/conversations",
"web_scraper": "/web-scraper/scrape",
"infrastructure": "/infrastructure",
"health": "/health"
}
}
# Health check endpoint
@app.get(
"/health",
tags=["Health"],
summary="Health check",
response_class=JSONResponse
)
async def health_check():
"""
Simple health check endpoint for container orchestration
Returns a 200 OK status when the service is running properly.
Used by Docker, Kubernetes, and load balancers.
"""
ollama_client = get_ollama_client()
ollama_healthy = await ollama_client.health_check()
return {
"status": "healthy",
"ollama_connected": ollama_healthy
}
# Include routers
app.include_router(chat_router) # /v1/chat/completions
app.include_router(models_router) # /v1/models
app.include_router(conversations_router) # /v1/conversations
app.include_router(web_scraper_router) # /web-scraper/scrape
app.include_router(infrastructure_controller.create_router()) # /infrastructure/*
# Include controller routers
app.include_router(health_controller.router) # / and /health
app.include_router(ai_controller.router) # /v1/chat, /v1/models, /v1/conversations
app.include_router(tools_controller.router) # /web-scraper/scrape
app.include_router(infrastructure_controller.router) # /infrastructure/*
app.include_router(static_controller.router) # /static/*
# Global exception handler
+32
View File
@@ -0,0 +1,32 @@
"""
Security initialization module
Handles OIDC configuration and authentication setup
"""
from src.config import Settings
from src.auth.oidc import oidc_config
from src.logging_config import get_logger
logger = get_logger(__name__)
def initialize_oidc(settings: Settings) -> None:
"""
Initialize OIDC authentication configuration
Configures the global oidc_config instance with settings from environment.
If OIDC is enabled, logs the issuer URL for verification.
Args:
settings: Application settings containing OIDC configuration
"""
oidc_config.configure(
enabled=settings.oidc_enabled,
issuer=settings.oidc_issuer,
audience=settings.oidc_audience
)
if settings.oidc_enabled:
logger.info(f"✓ OIDC authentication enabled (issuer: {settings.oidc_issuer})")
else:
logger.info("○ OIDC authentication disabled - API is publicly accessible")
+135
View File
@@ -0,0 +1,135 @@
"""
Service Groups and Safety Configuration
Defines service groups, dependencies, and always-on infrastructure services.
"""
from typing import List, Dict, Set
# Always-on infrastructure services (CANNOT be stopped via API)
ALWAYS_ON_SERVICES: Set[str] = {
"portainer",
"nginx-proxy-manager",
"core-api",
"uptime-kuma",
"organizr",
"headscale",
"watchtower",
"netdata",
"maintenance",
}
# Service groups - services that should be started/stopped together
SERVICE_GROUPS: Dict[str, List[str]] = {
"jellyfin": [
"jellyfin",
],
"nextcloud": [
"nextcloud",
"nextcloud-db",
"nextcloud-redis",
],
"gitea": [
"gitea",
"gitea-db",
],
"ai-stack": [
"open-webui",
"ollama",
"qdrant",
],
"samba": [
"samba",
],
}
# Reverse mapping: service name -> group name
SERVICE_TO_GROUP: Dict[str, str] = {}
for group, services in SERVICE_GROUPS.items():
for service in services:
SERVICE_TO_GROUP[service] = group
def is_always_on(service_name: str) -> bool:
"""
Check if a service is marked as always-on (infrastructure)
Args:
service_name: Name of the service
Returns:
True if service cannot be stopped, False otherwise
"""
return service_name.lower() in ALWAYS_ON_SERVICES
def get_service_group(service_name: str) -> List[str]:
"""
Get all services in the same group as the given service
Args:
service_name: Name of the service
Returns:
List of service names in the group (including the service itself)
Returns [service_name] if not part of a group
"""
group = SERVICE_TO_GROUP.get(service_name.lower())
if group:
return SERVICE_GROUPS[group].copy()
return [service_name]
def get_group_name(service_name: str) -> str:
"""
Get the group name for a service
Args:
service_name: Name of the service
Returns:
Group name or the service name if not in a group
"""
return SERVICE_TO_GROUP.get(service_name.lower(), service_name)
def list_service_groups() -> Dict[str, List[str]]:
"""
Get all defined service groups
Returns:
Dictionary of group names to service lists
"""
return SERVICE_GROUPS.copy()
def list_stoppable_services() -> List[str]:
"""
Get list of all services that can be stopped
Returns:
List of service names that are not always-on
"""
stoppable = []
for services in SERVICE_GROUPS.values():
stoppable.extend(services)
# Remove any always-on services (shouldn't be in groups, but safety check)
return [s for s in stoppable if not is_always_on(s)]
def validate_stop_request(service_names: List[str]) -> tuple[bool, str]:
"""
Validate that a list of services can be stopped
Args:
service_names: List of service names to check
Returns:
Tuple of (is_valid, error_message)
error_message is empty string if valid
"""
for service in service_names:
if is_always_on(service):
return False, f"Cannot stop always-on service: {service}"
return True, ""