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