roll back authentik login. removed and restore working state.

This commit is contained in:
2025-11-19 11:42:06 +01:00
parent e8eb2e954c
commit cb428a885d
23 changed files with 730 additions and 2512 deletions
-332
View File
@@ -1,332 +0,0 @@
# Core-API Refactoring Plan
**Date:** 2025-11-14
**Goal:** Restructure Core-API into controller-based architecture and add Infrastructure Management API
## Current Structure
```
src/
├── api/
│ └── v1/
│ ├── chat.py # AI chat completions
│ ├── models.py # Model listing
│ ├── conversations.py # Conversation memory
│ └── schemas.py # Pydantic schemas
├── web_scraper/
│ ├── router.py # Webscraper endpoints
│ ├── service.py
│ └── schemas.py
├── models/
│ ├── ollama_client.py # Ollama HTTP client
│ └── embeddings.py
├── memory/ # Memory tier system
├── config.py # Global settings
└── main.py # FastAPI app
```
## Target Structure
```
src/
├── controllers/ # NEW: Controller-based routing
│ ├── __init__.py
│ ├── base.py # Base controller class
│ ├── ai_controller.py # AI Orchestrator (chat, models, conversations)
│ ├── tools_controller.py # Utility tools (webscraper, etc.)
│ ├── health_controller.py # Health & monitoring
│ └── infrastructure_controller.py # Infrastructure automation
├── clients/ # NEW: External API clients
│ ├── __init__.py
│ ├── portainer_client.py # Portainer API
│ ├── npm_client.py # Nginx Proxy Manager API
│ └── kuma_client.py # Uptime Kuma Socket.IO API
├── api/v1/ # Keep existing for backward compat
├── web_scraper/ # Keep as-is for now
├── models/ # Keep as-is
├── memory/ # Keep as-is
├── config.py # Enhanced with infrastructure settings
└── main.py # Updated routing
```
## Implementation Phases
### Phase 1: Infrastructure Setup ✅ COMPLETE
- [x] Research API authentication methods
- [x] Add infrastructure settings to config.py
- [x] Create credentials.py for sensitive data (gitignored)
- [x] Create credentials.example.py as template
- [x] Update .gitignore to exclude credentials.py
- [x] Update config.py to import from credentials module
- [x] Create /controllers directory structure
- [x] Create /clients directory structure
- [x] Create base controller class
### Phase 2: API Clients ✅ COMPLETE (Portainer & NPM)
- [x] Implement Portainer API client (access token auth)
- [x] Implement NPM API client (JWT with refresh)
- [x] Add token storage/refresh mechanisms
- [ ] Implement Uptime Kuma Socket.IO client (DEFERRED - WebSocket complexity)
### Phase 3: Infrastructure Controller ✅ COMPLETE
- [x] GET /infrastructure/health - Check connectivity ✅ TESTED
- [x] GET /infrastructure/services - List all services ✅ TESTED
- [x] GET /infrastructure/services/{name} - Get service details ✅ TESTED
- [x] GET /infrastructure/ports - List allocated ports ✅ IMPLEMENTED & TESTED
- [x] GET /infrastructure/domains - List configured domains ✅ TESTED
- [x] Integrate with main.py routing ✅ TESTED
- [x] Fix Pydantic validation issues (status field type conversion)
- [x] POST /infrastructure/services - Deploy new service ✅ TESTED
- [x] PUT /infrastructure/services/{name} - Update service ✅ TESTED
- [x] DELETE /infrastructure/services/{name} - Remove service ✅ TESTED
- [x] POST /infrastructure/proxy - Create NPM proxy host with optional SSL ✅ IMPLEMENTED
- [ ] POST /infrastructure/monitoring/add - Auto-add Kuma monitor (DEFERRED - Socket.IO complexity)
### Phase 4: Refactor Existing Controllers ✅ COMPLETE
- [x] Move AI endpoints to ai_controller.py ✅ COMPLETE
- [x] Move webscraper to tools_controller.py ✅ COMPLETE
- [x] Move health check to health_controller.py ✅ COMPLETE
- [x] Update main.py imports and routing ✅ COMPLETE
- [x] Test all refactored endpoints ✅ ALL WORKING
### Phase 5: Testing & Documentation ✅ COMPLETE
- [x] Test all refactored endpoints ✅ ALL WORKING
- [x] Update API documentation (OpenAPI spec auto-generated and validated)
- [~] Create CLI wrapper scripts (SKIPPED - LLMs consume OpenAPI spec directly)
- [~] Remove old shell scripts (DEFERRED - not blocking)
### Phase 6: Infrastructure Improvements 📋 FUTURE
- [ ] Consolidate Docker network topology into single `docker-dataplane` network
- Currently each stack has its own network (172.22.0.x, 172.25.0.x, 172.20.0.x, etc.)
- Error-prone and unnecessarily complex
- Single shared network simplifies inter-service communication
- Reduces subnet conflicts and improves service discovery
- Update all compose files to use: `networks: [docker-dataplane]`
- Create network once: `docker network create docker-dataplane`
---
## Progress Notes (2025-11-14)
### Session 1: Foundation & Read Endpoints
**Completed:**
- Created controller and client architecture
- Implemented Portainer client with full CRUD operations for stacks
- Implemented NPM client with JWT refresh and proxy/certificate management
- Built infrastructure controller with 5 read/list endpoints
- Added infrastructure settings to config.py
**Files Created:**
- `src/controllers/__init__.py`
- `src/controllers/base.py`
- `src/controllers/infrastructure_controller.py`
- `src/clients/__init__.py`
- `src/clients/portainer_client.py`
- `src/clients/npm_client.py`
- `REFACTORING_PLAN.md` (this file)
### Session 2: Credentials & Testing (2025-11-14 Evening)
**Completed:**
- Created credentials management system (credentials.py gitignored, credentials.example.py committed)
- Updated config.py to import from credentials module with fallback
- Generated Portainer API token programmatically via API
- Integrated infrastructure controller into main.py
- Fixed Pydantic validation bug (status field int→str conversion)
- Tested all read endpoints with live Portainer/NPM infrastructure
- Verified 8 stacks detected, domains with SSL status working
**Test Results:**
- ✅ GET /infrastructure/health - Portainer connected, NPM accessible
- ✅ GET /infrastructure/services - Returns 8 active stacks
- ✅ GET /infrastructure/services/{name} - Service lookup working
- ✅ GET /infrastructure/domains - Returns proxy hosts with SSL status
- ✅ NPM health check fixed (now accepts 2xx/3xx status codes and follows redirects)
### Session 3: Write Endpoints (2025-11-14 Evening)
**Completed:**
- Created request/response models for write operations (DeployServiceRequest, UpdateServiceRequest, CreateProxyRequest, OperationResult)
- Implemented POST /infrastructure/services - Deploy new service from compose YAML
- Implemented PUT /infrastructure/services/{name} - Update existing service configuration
- Implemented DELETE /infrastructure/services/{name} - Remove service and stack
- Implemented POST /infrastructure/proxy - Create NPM proxy host with optional SSL certificate
- Updated main.py API description with write endpoints
- Tested all service management endpoints (POST/PUT/DELETE) with live Portainer instance
**Test Results:**
- ✅ POST /infrastructure/services - Created test-nginx stack (ID: 30)
- ✅ PUT /infrastructure/services/test-nginx - Updated compose with environment variable
- ✅ DELETE /infrastructure/services/test-nginx - Removed stack successfully
- ✅ POST /infrastructure/proxy - Implemented (not tested to avoid production interference)
**Next Steps:**
1. ~~Refactor existing AI/tools/health endpoints into separate controllers (Phase 4)~~ ✅ DONE (2025-11-14)
2. ~~Fix NPM health check to handle redirects~~ ✅ DONE (2025-11-14)
3. ~~Implement port allocation detection logic~~ ✅ DONE (2025-11-14)
4. ~~Create CLI wrappers for common operations~~ ⊘ SKIPPED (LLMs use OpenAPI)
5. (OPTIONAL) Consolidate Docker networks into `docker-dataplane` (Phase 6)
### Session 4: NPM Health Check & Port Detection (2025-11-14 Afternoon)
**Completed:**
- Fixed NPM health check to handle redirects properly
- Updated `npm_client.py` to accept 2xx/3xx status codes as healthy
- Enabled explicit redirect following in httpx client
- Verified fix with live NPM instance (now shows 9 proxy hosts)
- Implemented comprehensive port detection in `GET /infrastructure/ports` endpoint
- Added `get_containers()` and `get_container()` methods to PortainerClient
- Enhanced PortInfo model with internal/external hostname and IP fields
- Implemented domain mapping from NPM proxy hosts to services
- Added deduplication logic for port entries (Docker returns duplicates per bind address)
**Port Detection Features:**
- Scans all running containers across all Portainer endpoints
- Extracts internal port, host port, and protocol for each container
- Maps container names to service names via Docker Compose labels
- Retrieves internal Docker hostnames and IP addresses per network
- Cross-references NPM proxy hosts to identify external domains
- Returns 22 unique port mappings with complete metadata
**Technical Details:**
*NPM Health Check:*
- Issue: NPM's `/api` endpoint returns 302 redirect, old code only accepted 200
- Solution: Accept `200 <= status_code < 400` as healthy response
- Result: NPM health check now returns `true` and proxy hosts are enumerated correctly
*Port Detection:*
- Queries Portainer Docker API for container list and port mappings
- Extracts NetworkSettings for internal IPs and hostnames
- Builds port→domain map from NPM proxy hosts configuration
- Matches services to external domains using multiple strategies:
- By container name + port
- By internal IP + port
- By host address + host port (localhost, 127.0.0.1, server IP)
- Deduplicates based on (port, container_name, protocol) tuple
- Example output: Nextcloud port 80 → internal IP 172.25.0.3 → external domain cloud.schweitz.net
### Session 5: Controller Architecture Refactoring (2025-11-14 Evening)
**Completed:**
- Created `ai_controller.py` consolidating chat, models, and conversations endpoints
- Created `tools_controller.py` for web scraper functionality
- Created `health_controller.py` for service health and info endpoints
- Updated `main.py` to use new controller-based architecture
- Removed legacy router imports and inline endpoint definitions
- Tested all refactored endpoints - 16 endpoints working correctly
**Architecture Changes:**
- All endpoints now follow consistent controller pattern inheriting from `BaseController`
- Controllers use `create_router()` method for FastAPI router configuration
- Clean separation of concerns:
- `ai_controller.py` - AI orchestration and conversation memory (7 endpoints)
- `tools_controller.py` - Utility tools like web scraper (1 endpoint)
- `health_controller.py` - Service status and info (2 endpoints)
- `infrastructure_controller.py` - Infrastructure management (6 endpoints)
- Simplified `main.py` from 220 lines to 152 lines
- Backward compatible - all existing endpoints work identically
**Test Results:**
- ✅ GET / - Service information
- ✅ GET /health - Health check with Ollama status
- ✅ GET /v1/models - Model listing
- ✅ POST /v1/chat/completions - Chat completions
- ✅ GET /v1/conversations/{id} - Conversation history
- ✅ GET /infrastructure/health - Infrastructure health
- ✅ POST /web-scraper/scrape - Web scraping
- ✅ OpenAPI spec generation - 16 endpoints documented
## API Authentication Strategy
### Portainer
- **Method:** Access Token (X-API-Key header)
- **Setup:** Manual creation in UI, store in config/env
- **Duration:** Long-lived
- **Storage:** Environment variable `PORTAINER_API_KEY`
### Nginx Proxy Manager
- **Method:** JWT Bearer Token
- **Setup:** Login via `/api/tokens` with credentials
- **Duration:** ~24 hours
- **Strategy:** Auto-refresh with stored credentials
- **Storage:** `NPM_EMAIL` and `NPM_PASSWORD` in env
### Uptime Kuma
- **Method:** Socket.IO WebSocket
- **Setup:** Login via Socket.IO `login` event
- **Duration:** Session-based
- **Strategy:** Maintain persistent connection or re-auth per request
- **Storage:** `KUMA_USERNAME` and `KUMA_PASSWORD` in env
## Configuration Changes
### Credentials Management Strategy
**Use `credentials.py` for sensitive data** (added to `.gitignore`):
- Keeps secrets out of version control
- Easy terminal-based management with editor
- Python format for type safety and autocomplete
- Separate from config for security isolation
**Implementation:**
1. Create `src/credentials.py` with credentials (gitignored)
2. Create `src/credentials.example.py` as template (committed)
3. Update `config.py` to import from credentials module
4. Add `credentials.py` to `.gitignore`
**Example `src/credentials.py`:**
```python
"""
Infrastructure credentials (GITIGNORED)
Copy from credentials.example.py and fill in real values
"""
# Portainer
PORTAINER_URL = "http://localhost:8001"
PORTAINER_API_KEY = "ptr_your_actual_token_here"
# Nginx Proxy Manager
NPM_URL = "http://localhost:81"
NPM_EMAIL = "jpmschweitzer@gmail.com"
NPM_PASSWORD = "your_actual_password"
# Uptime Kuma
KUMA_URL = "http://localhost:3001"
KUMA_USERNAME = "admin"
KUMA_PASSWORD = "your_actual_password"
```
**Updated `config.py` to use credentials:**
```python
from src.credentials import (
PORTAINER_URL, PORTAINER_API_KEY,
NPM_URL, NPM_EMAIL, NPM_PASSWORD,
KUMA_URL, KUMA_USERNAME, KUMA_PASSWORD
)
class Settings(BaseSettings):
# Infrastructure Management (from credentials.py)
portainer_url: str = PORTAINER_URL
portainer_api_key: str = PORTAINER_API_KEY
npm_url: str = NPM_URL
npm_email: str = NPM_EMAIL
npm_password: str = NPM_PASSWORD
kuma_url: str = KUMA_URL
kuma_username: str = KUMA_USERNAME
kuma_password: str = KUMA_PASSWORD
```
## Benefits
1. **Cleaner Code:** Separation of concerns, easier to maintain
2. **Automation:** Programmatic service deployment and configuration
3. **Elimination of Shell Scripts:** Replace ad-hoc scripts with proper API
4. **Service Discovery:** Auto-detect running services and configurations
5. **Self-Managing Homelab:** Foundation for autonomous infrastructure
## Migration Notes
- Existing `/v1/` endpoints remain unchanged for backward compatibility
- Web scraper endpoints stay at `/web-scraper/` initially
- Old shell scripts in `/stacks/` will be replaced with CLI wrappers
-152
View File
@@ -1,152 +0,0 @@
#!/usr/bin/env python3
"""
Create Authentik Outpost for NPM Forward Authentication
Creates a dedicated outpost and retrieves its token for deployment.
"""
import asyncio
import sys
sys.path.insert(0, '/home/jpmschweitzer/Projects/portainer-core/services/core-api')
from src.clients.authentik_client import get_authentik_client
async def create_npm_outpost():
"""Create outpost for NPM forward auth and get token"""
authentik = get_authentik_client()
try:
print("=" * 60)
print("Creating NPM Forward Auth Outpost")
print("=" * 60)
# Check if provider exists
print("\n1. Checking for proxy provider...")
provider = await authentik.get_provider_by_name_proxy("npm-forward-auth-provider")
if not provider:
print("❌ Proxy provider not found. Run setup_authentik_forward_auth.py first.")
return False
provider_id = provider["pk"]
print(f"✓ Found provider (ID: {provider_id})")
# Check if outpost already exists
print("\n2. Checking for existing NPM outpost...")
try:
existing = await authentik.get_outpost_by_name("npm-forward-auth-outpost")
if existing:
print(f"✓ Outpost already exists (ID: {existing['pk']})")
outpost_id = existing["pk"]
# Update it to ensure provider is assigned
print("\n3. Updating outpost configuration...")
await authentik.update_outpost(
outpost_id=outpost_id,
providers=[provider_id]
)
print("✓ Outpost updated with provider")
except Exception:
# Outpost doesn't exist, create it
print("⊘ Outpost doesn't exist, creating new one...")
print("\n3. Creating NPM outpost...")
outpost = await authentik.create_outpost(
name="npm-forward-auth-outpost",
type="proxy",
providers=[provider_id],
config={
"authentik_host": "http://192.168.86.149:9000",
"authentik_host_insecure": False,
"log_level": "info",
"docker_labels": None,
"docker_network": None,
"docker_map_ports": True,
"container_image": None,
"kubernetes_replicas": 1,
"kubernetes_namespace": "default"
}
)
outpost_id = outpost["pk"]
print(f"✓ Created outpost (ID: {outpost_id})")
# Try to get the service connection token
print("\n4. Retrieving outpost token...")
print("\nNote: Authentik creates service accounts for outposts automatically.")
print("The token format is: ak-outpost-<outpost_uuid>-api")
# Get outpost details to find its service account
outpost_details = await authentik._request("GET", f"outposts/instances/{outpost_id}/")
print(f"\nOutpost Details:")
print(f" Name: {outpost_details.get('name')}")
print(f" ID: {outpost_details.get('pk')}")
print(f" Type: {outpost_details.get('type')}")
print(f" Providers: {outpost_details.get('providers')}")
# The outpost service connection details
if 'service_connection' in outpost_details:
print(f" Service Connection: {outpost_details.get('service_connection')}")
# List tokens to find the one for this outpost
print("\n5. Looking for outpost service token...")
tokens = await authentik.list_tokens()
outpost_uuid = outpost_details.get('pk')
token_identifier = f"ak-outpost-{outpost_uuid}-api"
matching_token = None
for token in tokens:
if token.get('identifier') == token_identifier:
matching_token = token
break
if matching_token:
print(f"✓ Found token: {matching_token.get('identifier')}")
print(f"\n{'=' * 60}")
print("IMPORTANT: Token Key Required")
print("=" * 60)
print("\nAuthentik does not expose token keys via API after creation.")
print("\nTo get the token key:")
print("1. Go to: https://auth.schweitz.net/if/admin/#/core/tokens")
print(f"2. Find token: {token_identifier}")
print("3. Click 'View Token Key' or regenerate the token")
print("4. Copy the token key")
print("\nAlternatively, you can:")
print("1. Delete the existing outpost via UI")
print("2. Create a new outpost via UI")
print("3. Copy the token key when it's displayed")
print("\n" + "=" * 60)
else:
print("\n⚠ No automatic token found.")
print("You may need to manually create a token for the outpost.")
print("\nManual token creation:")
print("1. Go to: https://auth.schweitz.net/if/admin/#/core/tokens")
print("2. Click 'Create'")
print(f"3. Identifier: npm-outpost-token")
print("4. User: Select the outpost service account")
print("5. Intent: API")
print("6. Copy the token key when displayed")
print("\n" + "=" * 60)
print("Next Steps")
print("=" * 60)
print("\n1. Obtain the outpost token key (see above)")
print("2. Create/update .env.authentik-shared file:")
print(" AUTHENTIK_OUTPOST_TOKEN=<your_token_key>")
print("3. Deploy the stack:")
print(" docker-compose -f stacks/authentik-shared.yml --env-file .env.authentik-shared up -d")
print("4. Update NPM hosts to point to port 9001")
return True
except Exception as e:
print(f"\n❌ Error: {e}")
import traceback
traceback.print_exc()
return False
if __name__ == "__main__":
success = asyncio.run(create_npm_outpost())
sys.exit(0 if success else 1)
@@ -1,118 +0,0 @@
#!/usr/bin/env python3
"""
Setup Authentik Forward Authentication for NPM
This script creates a proxy provider and outpost for forward authentication
across all NPM-managed domains.
"""
import asyncio
import sys
sys.path.insert(0, '/home/jpmschweitzer/Projects/portainer-core/services/core-api')
from src.clients.authentik_client import get_authentik_client
async def setup_forward_auth():
"""Create proxy provider, application, and outpost for forward auth"""
client = get_authentik_client()
try:
# Check if Authentik is accessible
print("Checking Authentik connectivity...")
if not await client.health_check():
print("❌ Authentik is not accessible")
return False
print("✓ Authentik is accessible")
# Check if proxy provider already exists
print("\nChecking for existing proxy provider...")
existing_provider = await client.get_provider_by_name_proxy("npm-forward-auth-provider")
if existing_provider:
print(f"✓ Proxy provider already exists (ID: {existing_provider.get('pk')})")
provider = existing_provider
else:
# Create Proxy provider for forward auth
print("\nCreating Proxy provider for forward authentication...")
provider = await client.create_proxy_provider(
name="npm-forward-auth-provider",
external_host="https://auth.schweitz.net",
mode="forward_single",
token_validity=480 # 8 hours
)
print(f"✓ Created proxy provider (ID: {provider.get('pk')})")
# Display provider details
print("\n" + "="*60)
print("Proxy Provider Details:")
print("="*60)
print(f"Provider ID: {provider.get('pk')}")
print(f"Mode: {provider.get('mode')}")
print(f"External Host: {provider.get('external_host')}")
print(f"Token Validity: {provider.get('access_token_validity')}")
print(f"Session Duration: {provider.get('session_duration')}")
print("="*60)
# Check if application already exists
print("\nChecking for existing application...")
existing_app = await client.get_application_by_slug("npm-forward-auth")
if existing_app:
print(f"✓ Application already exists (slug: {existing_app.get('slug')})")
app = existing_app
else:
# Create application
print("\nCreating application...")
app = await client.create_application(
name="NPM Forward Auth",
slug="npm-forward-auth",
provider_pk=provider.get("pk"),
launch_url="https://auth.schweitz.net"
)
print(f"✓ Created application (slug: {app.get('slug')})")
# Check if outpost already exists
print("\nChecking for existing outpost...")
existing_outpost = await client.get_outpost_by_name("npm-forward-auth-outpost")
if existing_outpost:
print(f"✓ Outpost already exists (ID: {existing_outpost.get('pk')})")
outpost = existing_outpost
else:
# Create outpost
print("\nCreating outpost...")
outpost = await client.create_outpost(
name="npm-forward-auth-outpost",
type="proxy",
providers=[provider.get("pk")],
config={
"authentik_host": "https://auth.schweitz.net",
"authentik_host_insecure": False,
"log_level": "info"
}
)
print(f"✓ Created outpost (ID: {outpost.get('pk')})")
print("\n" + "="*60)
print("Setup Complete!")
print("="*60)
print("\nNext steps:")
print("1. Deploy the Authentik outpost container")
print("2. Configure NPM proxy hosts with forward auth")
print("3. Test the SSO flow")
print("\nOutpost deployment command will be generated...")
return True
except Exception as e:
print(f"\n❌ Error: {e}")
import traceback
traceback.print_exc()
return False
finally:
await client.close()
if __name__ == "__main__":
success = asyncio.run(setup_forward_auth())
sys.exit(0 if success else 1)
+102 -1
View File
@@ -4,7 +4,7 @@ OIDC Authentication Module
Provides OAuth2/OIDC token validation for FastAPI using Authentik as IdP.
Implements bearer token authentication with JWT verification.
"""
from fastapi import Depends, HTTPException, Security
from fastapi import Depends, HTTPException, Security, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
import httpx
@@ -233,3 +233,104 @@ async def get_optional_user(
except HTTPException:
# Invalid token - return None instead of raising
return None
async def get_forward_auth_user(
request: Request
) -> Optional[Dict]:
"""
Authentik Forward Auth authentication for external access via NPM
This dependency allows:
- External access through api.schweitz.net (with Authentik forward auth headers) - REQUIRES authentication
- Internal direct access (no forward auth headers) - ALLOWED without authentication
When accessing through NPM with Authentik forward auth enabled, NPM adds headers like:
- X-authentik-username
- X-authentik-email
- X-authentik-groups
- X-authentik-name
- X-authentik-uid
Args:
request: FastAPI request object containing headers
Returns:
User info dict if authenticated via forward auth headers
None if accessed internally (no forward auth headers)
Raises:
HTTPException 401: If forward auth headers present but invalid/incomplete
"""
# Check for Authentik forward auth headers
username = request.headers.get("x-authentik-username")
email = request.headers.get("x-authentik-email")
groups = request.headers.get("x-authentik-groups")
name = request.headers.get("x-authentik-name")
uid = request.headers.get("x-authentik-uid")
# If NO forward auth headers present, this is internal access - allow it
if not username and not email:
logger.debug("No forward auth headers - allowing internal access")
return None
# Forward auth headers present (external access via api.schweitz.net)
# Validate authentication
if not username or not email:
logger.warning("Incomplete forward auth headers detected")
raise HTTPException(
status_code=401,
detail="Authentication required - incomplete forward auth headers"
)
# Parse groups (comma-separated string to list)
groups_list = [g.strip() for g in groups.split(",")] if groups else []
user_info = {
"username": username,
"email": email,
"name": name or username,
"groups": groups_list,
"uid": uid,
"auth_method": "forward_auth"
}
logger.info(f"Authenticated via forward auth: {email} (groups: {groups_list})")
return user_info
async def get_forward_auth_admin(
user: Optional[Dict] = Depends(get_forward_auth_user)
) -> Dict:
"""
Require admin access for external requests, allow all internal requests
Use this dependency for endpoints that require admin access when accessed
externally through api.schweitz.net, but allow unrestricted internal access.
Args:
user: User info from get_forward_auth_user
Returns:
User info dict if user is admin or if accessed internally
Raises:
HTTPException 403: If external user is not in admin/authentik Admins group
"""
# Internal access (no forward auth headers) - allow all
if user is None:
logger.debug("Internal access - allowing without admin check")
return {"email": "internal", "groups": ["admin"], "auth_method": "internal"}
# External access - check admin group membership
groups = user.get("groups", [])
if "admin" not in groups and "authentik Admins" not in groups:
user_email = user.get("email", "unknown")
logger.warning(f"User {user_email} attempted admin access (groups: {groups})")
raise HTTPException(
status_code=403,
detail="Admin access required"
)
return user
+128 -8
View File
@@ -2,9 +2,12 @@
Uptime Kuma Socket.IO Client
Provides interface to Uptime Kuma via Socket.IO for monitor management.
Also provides metrics API access for real-time status data.
"""
import socketio
import asyncio
import httpx
import re
from typing import Optional, Dict, List, Any
from src.logging_config import get_logger
from src.config import get_settings
@@ -125,31 +128,76 @@ class KumaClient:
async def get_monitors(self) -> List[Dict[str, Any]]:
"""
List all monitors
List all monitors with uptime data
Returns:
List of monitor configurations
List of monitor configurations with uptime_24h field
"""
await self._ensure_connected()
try:
# Get monitor list
response = await self.sio.call('getMonitorList', timeout=self.timeout)
# Storage for monitor list and uptime data received via events
monitor_list_data = {}
uptime_list_data = {}
monitor_event_received = asyncio.Event()
uptime_event_received = asyncio.Event()
if response and isinstance(response, dict):
# Uptime Kuma returns monitors as a dict with monitor IDs as keys
# Register event handler for monitorList
@self.sio.event
async def monitorList(data):
nonlocal monitor_list_data
monitor_list_data = data
monitor_event_received.set()
# Register event handler for uptimeList (24h uptime percentages)
@self.sio.event
async def uptimeList(monitor_id, uptime_data):
nonlocal uptime_list_data
# uptime_data is typically a dict with time periods: {"24": 99.5, "720": 98.2, ...}
uptime_list_data[str(monitor_id)] = uptime_data
# Don't set event here as we'll get multiple calls
# Request monitor list - this triggers the server to send monitorList event
response = await self.sio.call('getMonitorList', timeout=self.timeout)
logger.info(f"getMonitorList call response: {response}")
# Wait for the monitorList event (with timeout)
try:
await asyncio.wait_for(monitor_event_received.wait(), timeout=5.0)
logger.info(f"Received monitorList event with {len(monitor_list_data)} items")
# Give time for uptimeList events to arrive
await asyncio.sleep(0.5)
logger.info(f"Received uptime data for {len(uptime_list_data)} monitors")
except asyncio.TimeoutError:
logger.warning("Timeout waiting for monitorList event")
# Process the monitor list data
if monitor_list_data and isinstance(monitor_list_data, dict):
monitors = []
for monitor_id, monitor_data in response.items():
for monitor_id, monitor_data in monitor_list_data.items():
if isinstance(monitor_data, dict):
monitor_data['id'] = int(monitor_id)
# Add uptime data if available
uptime_info = uptime_list_data.get(str(monitor_id), {})
if isinstance(uptime_info, dict):
# Uptime Kuma provides 24h uptime as key "24"
monitor_data['uptime_24h'] = float(uptime_info.get('24', 0))
else:
monitor_data['uptime_24h'] = 0.0
monitors.append(monitor_data)
self._monitors_cache[int(monitor_id)] = monitor_data
logger.info(f"Found {len(monitors)} monitors total")
return monitors
logger.warning(f"No valid monitor data received")
return []
except Exception as e:
logger.error(f"Failed to get monitors: {e}")
logger.error(f"Failed to get monitors: {e}", exc_info=True)
raise
async def get_monitor(self, monitor_id: int) -> Dict[str, Any]:
@@ -419,6 +467,78 @@ class KumaClient:
await self.delete_monitor(monitor["id"])
return True
async def get_metrics_status(self) -> Dict[str, Dict[str, Any]]:
"""
Get monitor status from Prometheus metrics endpoint
This is simpler and more reliable than Socket.IO for getting current status.
Returns real-time UP/DOWN status but not historical uptime percentages.
Returns:
Dict mapping monitor names to status info:
{
"Portainer": {
"status": 1, # 1=UP, 0=DOWN, 2=PENDING, 3=MAINTENANCE
"response_time": 5, # ms
"monitor_type": "http",
"url": "http://192.168.86.149:8001"
},
...
}
"""
try:
# Use API key authentication
api_key = settings.kuma_api_key
if not api_key:
logger.warning("Kuma API key not configured")
return {}
# Fetch metrics with HTTP Basic Auth (empty username, API key as password)
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{self.base_url}/metrics",
auth=("", api_key)
)
response.raise_for_status()
metrics_text = response.text
# Parse Prometheus format metrics
# Format: metric_name{label1="value1",label2="value2"} value
monitor_data = {}
# Parse monitor_status lines
status_pattern = r'monitor_status\{monitor_name="([^"]+)",.*?\} (\d+)'
for match in re.finditer(status_pattern, metrics_text):
monitor_name = match.group(1)
status = int(match.group(2))
if monitor_name not in monitor_data:
monitor_data[monitor_name] = {}
monitor_data[monitor_name]['status'] = status
# Parse monitor_response_time lines
response_pattern = r'monitor_response_time\{monitor_name="([^"]+)",monitor_type="([^"]+)",monitor_url="([^"]+)",.*?\} ([\d.]+)'
for match in re.finditer(response_pattern, metrics_text):
monitor_name = match.group(1)
monitor_type = match.group(2)
monitor_url = match.group(3)
response_time = float(match.group(4))
if monitor_name not in monitor_data:
monitor_data[monitor_name] = {}
monitor_data[monitor_name].update({
'response_time': response_time,
'monitor_type': monitor_type,
'url': monitor_url
})
logger.info(f"Fetched metrics for {len(monitor_data)} monitors")
return monitor_data
except Exception as e:
logger.error(f"Failed to fetch metrics: {e}")
return {}
async def __aenter__(self):
"""Async context manager entry"""
await self._ensure_connected()
+4 -2
View File
@@ -9,7 +9,7 @@ try:
from src.credentials import (
PORTAINER_URL, PORTAINER_API_KEY,
NPM_URL, NPM_EMAIL, NPM_PASSWORD,
KUMA_URL, KUMA_USERNAME, KUMA_PASSWORD
KUMA_URL, KUMA_USERNAME, KUMA_PASSWORD, KUMA_API_KEY
)
except ImportError:
# Fallback to empty strings if credentials.py doesn't exist
@@ -22,6 +22,7 @@ except ImportError:
KUMA_URL = "http://localhost:3001"
KUMA_USERNAME = ""
KUMA_PASSWORD = ""
KUMA_API_KEY = ""
class Settings(BaseSettings):
@@ -43,7 +44,7 @@ class Settings(BaseSettings):
cors_headers: list[str] = ["*"]
# Logging
log_level: str = "INFO"
log_level: str = "DEBUG"
# Ollama Configuration (for AI orchestration)
ollama_base_url: str = "http://ollama:11434"
@@ -88,6 +89,7 @@ class Settings(BaseSettings):
kuma_url: str = KUMA_URL
kuma_username: str = KUMA_USERNAME
kuma_password: str = KUMA_PASSWORD
kuma_api_key: str = KUMA_API_KEY
# OIDC Authentication (Authentik)
oidc_enabled: bool = False # Set to True to require authentication
@@ -14,7 +14,7 @@ from src.clients.npm_client import get_npm_client
from src.clients.kuma_client import get_kuma_client
from src.logging_config import get_logger
from src import service_groups
from src.auth.oidc import get_admin_user
from src.auth.oidc import get_admin_user, get_forward_auth_admin
logger = get_logger(__name__)
@@ -751,11 +751,11 @@ class InfrastructureController(BaseController):
"/services/{name}/stop",
response_model=OperationResult,
summary="Stop a service or service group",
description="Stop a service or service group by pausing monitors and stopping containers. Requires admin authentication."
description="Stop a service or service group by pausing monitors and stopping containers. Requires admin authentication when accessed externally via api.schweitz.net."
)
async def stop_service(
name: str,
user: Dict = Depends(get_admin_user)
user: Dict = Depends(get_forward_auth_admin)
):
"""
Stop a service or service group
@@ -866,11 +866,11 @@ class InfrastructureController(BaseController):
"/services/{name}/start",
response_model=OperationResult,
summary="Start a service or service group",
description="Start a service or service group by starting containers and resuming monitors. Requires admin authentication."
description="Start a service or service group by starting containers and resuming monitors. Requires admin authentication when accessed externally via api.schweitz.net."
)
async def start_service(
name: str,
user: Dict = Depends(get_admin_user)
user: Dict = Depends(get_forward_auth_admin)
):
"""
Start a service or service group
@@ -971,6 +971,118 @@ class InfrastructureController(BaseController):
# ===== Monitoring Endpoints =====
@router.get(
"/widget-data",
summary="Get combined data for service control widget",
response_model=Dict[str, Any]
)
async def get_widget_data():
"""
Get combined service and monitor data for the widget
Returns all data needed by service-control widget in a single call:
- Service list with status and container counts
- Monitor list with uptime percentages
- Service groups and always-on list
This endpoint is designed for browser-based widgets to avoid
multiple API calls and cross-origin issues.
"""
try:
portainer = get_portainer_client()
kuma = get_kuma_client()
npm = get_npm_client()
# Fetch services (same logic as /services endpoint)
stacks = await portainer.get_stacks()
proxy_hosts = await npm.get_proxy_hosts()
# Build domain mapping
domain_map = {}
for proxy in proxy_hosts:
for domain in proxy.get("domain_names", []):
forward_host = proxy.get("forward_host", "")
domain_map[domain] = forward_host
services = []
for stack in stacks:
stack_name = stack.get("Name", "")
endpoint_id = stack.get("EndpointId")
domains = [
domain for domain, host in domain_map.items()
if stack_name in host or host in stack_name
]
# Get container status
containers_running = 0
containers_total = 0
try:
all_containers = await portainer.get_containers(endpoint_id, all_containers=True)
for container in all_containers:
labels = container.get("Labels", {})
container_stack = labels.get("com.docker.compose.project", "")
if container_stack.lower() == stack_name.lower():
containers_total += 1
if container.get("State", "") == "running":
containers_running += 1
except Exception as e:
logger.warning(f"Failed to get container status for {stack_name}: {e}")
services.append({
"name": stack_name,
"stack_id": stack.get("Id"),
"status": "active" if stack.get("Status") == 1 else "inactive",
"endpoint_id": endpoint_id,
"domains": domains,
"running": containers_running > 0,
"containers_running": containers_running,
"containers_total": containers_total
})
# Fetch monitors with real-time status from metrics endpoint
monitors_list = []
try:
# Get real-time status from Prometheus metrics
metrics_data = await kuma.get_metrics_status()
for monitor_name, monitor_info in metrics_data.items():
# Status: 1=UP, 0=DOWN, 2=PENDING, 3=MAINTENANCE
status = monitor_info.get('status', 0)
# Convert status to simple up/down for widget
# Treat UP (1) as 100%, anything else as 0%
status_percentage = 100.0 if status == 1 else 0.0
monitors_list.append({
"id": None, # Not available from metrics
"name": monitor_name,
"uptime_24h": status_percentage, # Current status as percentage
"active": True, # Assume active if in metrics
"status": status, # 1=UP, 0=DOWN, 2=PENDING, 3=MAINTENANCE
"response_time": monitor_info.get('response_time', 0)
})
logger.info(f"Fetched status for {len(monitors_list)} monitors from metrics")
except Exception as e:
logger.warning(f"Failed to fetch monitors: {e}")
# Continue without monitor data rather than failing
return {
"success": True,
"services": services,
"monitors": monitors_list,
"service_groups": {
"groups": service_groups.list_service_groups(),
"always_on": list(service_groups.ALWAYS_ON_SERVICES),
"stoppable": service_groups.list_stoppable_services()
}
}
except Exception as e:
logger.error(f"Failed to fetch widget data: {e}")
raise HTTPException(status_code=500, detail=f"Failed to fetch widget data: {str(e)}")
@router.get(
"/monitors",
summary="List all monitors",
+3 -2
View File
@@ -16,6 +16,9 @@ ALWAYS_ON_SERVICES: Set[str] = {
"watchtower",
"netdata",
"maintenance",
"postgres-shared",
"redis-shared",
"authentik",
}
# Service groups - services that should be started/stopped together
@@ -25,8 +28,6 @@ SERVICE_GROUPS: Dict[str, List[str]] = {
],
"nextcloud": [
"nextcloud",
"nextcloud-db",
"nextcloud-redis",
],
"gitea": [
"gitea",
@@ -15,106 +15,163 @@
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: transparent;
color: #e0e0e0;
padding: 10px;
padding: 15px;
}
.container {
max-width: 1200px;
margin: 0 auto;
max-width: 100%;
}
h2 {
.section {
margin-bottom: 30px;
}
.section-header {
color: #fff;
margin-bottom: 15px;
font-size: 20px;
font-weight: 500;
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
}
.service-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
.service-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.service-row {
background: rgba(40, 40, 40, 0.95);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
transition: all 0.2s ease;
gap: 15px;
}
.service-card {
background: rgba(40, 40, 40, 0.95);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 15px;
transition: all 0.3s ease;
.service-row:hover {
border-color: rgba(66, 153, 225, 0.4);
background: rgba(45, 45, 45, 0.95);
}
.service-card:hover {
border-color: rgba(66, 153, 225, 0.5);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.service-header {
.service-left {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
gap: 15px;
flex: 1;
min-width: 0;
}
.service-name {
font-size: 16px;
font-size: 15px;
font-weight: 600;
color: #fff;
text-transform: capitalize;
min-width: 300px;
}
.status-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
.service-status {
display: flex;
align-items: center;
gap: 8px;
min-width: 120px;
}
.status-running {
background: rgba(72, 187, 120, 0.2);
color: #48bb78;
border: 1px solid rgba(72, 187, 120, 0.4);
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-stopped {
background: rgba(245, 101, 101, 0.2);
color: #f56565;
border: 1px solid rgba(245, 101, 101, 0.4);
.status-indicator.running {
background: #48bb78;
box-shadow: 0 0 8px rgba(72, 187, 120, 0.6);
}
.status-loading {
background: rgba(237, 137, 54, 0.2);
color: #ed8936;
border: 1px solid rgba(237, 137, 54, 0.4);
.status-indicator.stopped {
background: #f56565;
box-shadow: 0 0 8px rgba(245, 101, 101, 0.6);
}
.service-info {
.status-text {
font-size: 13px;
color: #a0a0a0;
margin-bottom: 12px;
}
.service-actions {
.uptime-status {
display: flex;
align-items: center;
gap: 8px;
min-width: 150px;
padding: 4px 10px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
text-decoration: none;
color: inherit;
}
.uptime-status:hover {
background: rgba(0, 0, 0, 0.4);
}
.uptime-percentage {
font-size: 13px;
font-weight: 600;
}
.uptime-percentage.excellent {
color: #48bb78;
}
.uptime-percentage.good {
color: #68d391;
}
.uptime-percentage.warning {
color: #ed8936;
}
.uptime-percentage.critical {
color: #f56565;
}
.uptime-percentage.unknown {
color: #718096;
}
.uptime-icon {
font-size: 11px;
color: #a0a0a0;
}
.service-right {
display: flex;
align-items: center;
gap: 8px;
}
.btn {
flex: 1;
padding: 8px 12px;
padding: 6px 16px;
border: none;
border-radius: 6px;
font-size: 13px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
min-width: 70px;
}
.btn:disabled {
opacity: 0.5;
opacity: 0.3;
cursor: not-allowed;
}
@@ -126,6 +183,7 @@
.btn-start:hover:not(:disabled) {
background: linear-gradient(135deg, #38a169 0%, #2f855a 100%);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(72, 187, 120, 0.3);
}
.btn-stop {
@@ -136,21 +194,12 @@
.btn-stop:hover:not(:disabled) {
background: linear-gradient(135deg, #e53e3e 0%, #c53030 100%);
transform: translateY(-1px);
}
.btn-restart {
background: linear-gradient(135deg, #4299e1 0%, #3182ce 100%);
color: white;
}
.btn-restart:hover:not(:disabled) {
background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(245, 101, 101, 0.3);
}
.loading {
text-align: center;
padding: 40px;
padding: 30px;
color: #a0a0a0;
}
@@ -164,14 +213,14 @@
}
.always-on-badge {
display: inline-block;
padding: 2px 8px;
background: rgba(66, 153, 225, 0.2);
font-size: 10px;
color: #4299e1;
border: 1px solid rgba(66, 153, 225, 0.4);
border-radius: 10px;
font-size: 11px;
background: rgba(66, 153, 225, 0.15);
padding: 2px 6px;
border-radius: 3px;
margin-left: 8px;
text-transform: uppercase;
font-weight: 600;
}
@keyframes spin {
@@ -180,45 +229,102 @@
.spinner {
display: inline-block;
width: 14px;
height: 14px;
width: 12px;
height: 12px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
margin-right: 6px;
}
/* Responsive design */
@media (max-width: 768px) {
.service-row {
flex-wrap: wrap;
}
.service-name {
min-width: 100px;
}
.uptime-status {
min-width: 100px;
}
.service-right {
width: 100%;
justify-content: flex-end;
}
}
</style>
</head>
<body>
<div class="container">
<h2>🎛️ On-Demand Services</h2>
<div id="error-container"></div>
<div id="service-container" class="loading">Loading services...</div>
<div class="section">
<div class="section-header">🎛️ Stoppable Services</div>
<div id="stoppable-container" class="loading">Loading services...</div>
</div>
<div class="section">
<div class="section-header">🔒 Always-On Infrastructure</div>
<div id="always-on-container" class="loading">Loading infrastructure...</div>
</div>
</div>
<script>
// Auto-detect API base from current domain (works with NPM proxy)
const API_BASE = window.location.origin;
// Use relative URL to work in any context (iframe, direct access, etc.)
const API_BASE = '';
const KUMA_BASE = window.location.protocol + '//' + window.location.hostname + ':3001';
let services = [];
let alwaysOnServices = [];
let monitors = {};
async function fetchServices() {
async function fetchData() {
try {
const response = await fetch(`${API_BASE}/infrastructure/services`);
if (!response.ok) throw new Error('Failed to fetch services');
services = await response.json();
// Single API call to get all data
const response = await fetch(`${API_BASE}/infrastructure/widget-data`);
const groupsResponse = await fetch(`${API_BASE}/infrastructure/service-groups`);
if (groupsResponse.ok) {
const groupsData = await groupsResponse.json();
alwaysOnServices = groupsData.always_on || [];
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
throw new Error('API returned unsuccessful response');
}
// Update services
services = data.services || [];
// Update always-on services list
if (data.service_groups && data.service_groups.always_on) {
alwaysOnServices = data.service_groups.always_on;
}
// Build monitors map
const monitorsMap = {};
if (data.monitors) {
data.monitors.forEach(monitor => {
const name = monitor.name.toLowerCase().replace(/[^a-z0-9]/g, '-');
monitorsMap[name] = {
id: monitor.id,
uptime_24h: monitor.uptime_24h || 0,
active: monitor.active !== false
};
});
}
monitors = monitorsMap;
renderServices();
document.getElementById('error-container').innerHTML = '';
} catch (error) {
console.error('Error fetching services:', error);
console.error('Error fetching data:', error);
document.getElementById('error-container').innerHTML =
`<div class="error">❌ Failed to connect to API: ${error.message}</div>`;
}
@@ -228,80 +334,118 @@
return alwaysOnServices.includes(serviceName.toLowerCase());
}
function getServiceStatus(service) {
if (service.containers_running > 0) {
function getUptimeInfo(serviceName) {
const monitorKey = serviceName.toLowerCase().replace(/[^a-z0-9]/g, '-');
const monitor = monitors[monitorKey];
if (!monitor) {
return {
class: 'status-running',
text: `Running (${service.containers_running}/${service.containers_total})`
};
} else if (service.containers_total > 0) {
return {
class: 'status-stopped',
text: 'Stopped'
};
} else {
return {
class: 'status-stopped',
text: 'No containers'
percentage: 0,
class: 'unknown',
text: 'No monitor',
id: null
};
}
const uptime = monitor.uptime_24h;
let className = 'unknown';
if (uptime >= 99.5) className = 'excellent';
else if (uptime >= 95) className = 'good';
else if (uptime >= 90) className = 'warning';
else if (uptime > 0) className = 'critical';
return {
percentage: uptime,
class: className,
text: uptime > 0 ? `${uptime.toFixed(1)}% ↑` : 'Down',
id: monitor.id
};
}
function renderServiceRow(service) {
const isRunning = service.containers_running > 0;
const alwaysOn = isAlwaysOn(service.name);
const uptime = getUptimeInfo(service.name);
const kumaLink = uptime.id ?
`${KUMA_BASE}/dashboard/${uptime.id}` :
KUMA_BASE;
return `
<div class="service-row" data-service="${service.name}">
<div class="service-left">
<div class="service-name">
${service.name}
${alwaysOn ? '<span class="always-on-badge">Protected</span>' : ''}
</div>
<div class="service-status">
<div class="status-indicator ${isRunning ? 'running' : 'stopped'}"></div>
<span class="status-text">
${isRunning ? `Running (${service.containers_running}/${service.containers_total})` : 'Stopped'}
</span>
</div>
<a href="${kumaLink}" target="_blank" class="uptime-status" title="View in Uptime Kuma">
<span class="uptime-icon">📊</span>
<span class="uptime-percentage ${uptime.class}">${uptime.text}</span>
</a>
</div>
<div class="service-right">
<button class="btn btn-start"
onclick="controlService('${service.name}', 'start')"
${isRunning || alwaysOn ? 'disabled' : ''}>
Start
</button>
<button class="btn btn-stop"
onclick="controlService('${service.name}', 'stop')"
${!isRunning || alwaysOn ? 'disabled' : ''}>
Stop
</button>
</div>
</div>
`;
}
function renderServices() {
const container = document.getElementById('service-container');
const stoppableContainer = document.getElementById('stoppable-container');
const alwaysOnContainer = document.getElementById('always-on-container');
// Filter to only show stoppable services
// Stoppable services
const stoppableServices = services.filter(s => !isAlwaysOn(s.name));
if (stoppableServices.length === 0) {
container.innerHTML = '<div class="loading">No stoppable services found</div>';
return;
stoppableContainer.innerHTML = '<div class="loading">No stoppable services found</div>';
} else {
stoppableContainer.className = 'service-list';
stoppableContainer.innerHTML = stoppableServices
.sort((a, b) => a.name.localeCompare(b.name))
.map(service => renderServiceRow(service))
.join('');
}
container.className = 'service-grid';
container.innerHTML = stoppableServices.map(service => {
const status = getServiceStatus(service);
const isRunning = service.containers_running > 0;
const alwaysOn = isAlwaysOn(service.name);
// Always-on services
const alwaysOnServicesList = services.filter(s => isAlwaysOn(s.name));
return `
<div class="service-card" data-service="${service.name}">
<div class="service-header">
<span class="service-name">
${service.name}
${alwaysOn ? '<span class="always-on-badge">ALWAYS ON</span>' : ''}
</span>
<span class="status-badge ${status.class}">${status.text}</span>
</div>
<div class="service-info">
Stack ID: ${service.stack_id || 'N/A'}
</div>
<div class="service-actions">
<button class="btn btn-start"
onclick="controlService('${service.name}', 'start')"
${isRunning || alwaysOn ? 'disabled' : ''}>
Start
</button>
<button class="btn btn-stop"
onclick="controlService('${service.name}', 'stop')"
${!isRunning || alwaysOn ? 'disabled' : ''}>
Stop
</button>
</div>
</div>
`;
}).join('');
if (alwaysOnServicesList.length === 0) {
alwaysOnContainer.innerHTML = '<div class="loading">No infrastructure services found</div>';
} else {
alwaysOnContainer.className = 'service-list';
alwaysOnContainer.innerHTML = alwaysOnServicesList
.sort((a, b) => a.name.localeCompare(b.name))
.map(service => renderServiceRow(service))
.join('');
}
}
async function controlService(serviceName, action) {
const card = document.querySelector(`[data-service="${serviceName}"]`);
const buttons = card.querySelectorAll('button');
const row = document.querySelector(`[data-service="${serviceName}"]`);
const buttons = row.querySelectorAll('button');
// Disable all buttons and show loading
buttons.forEach(btn => {
btn.disabled = true;
if (btn.textContent.toLowerCase().includes(action)) {
btn.innerHTML = `<span class="spinner"></span>${action.toUpperCase()}...`;
btn.innerHTML = `<span class="spinner"></span>${action}`;
}
});
@@ -318,26 +462,26 @@
console.log(`${action} ${serviceName}:`, result);
// Wait a bit for containers to start/stop
// Wait for containers to start/stop
await new Promise(resolve => setTimeout(resolve, 2000));
// Refresh service list
await fetchServices();
await fetchData();
} catch (error) {
console.error(`Error ${action}ing ${serviceName}:`, error);
alert(`Failed to ${action} ${serviceName}: ${error.message}`);
// Re-enable buttons on error
await fetchServices();
await fetchData();
}
}
// Auto-refresh every 10 seconds
setInterval(fetchServices, 10000);
setInterval(fetchData, 10000);
// Initial load
fetchServices();
fetchData();
</script>
</body>
</html>
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env python3
"""
Test what headers are being sent to Organizr
"""
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from src.clients.npm_client import get_npm_client
async def main():
npm = get_npm_client()
# Find home.schweitz.net
hosts = await npm.get_proxy_hosts()
for host in hosts:
if 'home.schweitz.net' in host.get('domain_names', []):
print(f"Found: {', '.join(host.get('domain_names', []))}")
print(f"Forward to: {host.get('forward_scheme')}://{host.get('forward_host')}:{host.get('forward_port')}")
print()
config = host.get('advanced_config', '')
print("Checking for Authentik headers in nginx config:")
print("=" * 60)
headers_to_check = [
'X-authentik-username',
'X-authentik-email',
'X-authentik-groups',
'X-authentik-name',
'X-authentik-uid'
]
for header in headers_to_check:
if f'proxy_set_header {header}' in config:
print(f"✓ {header} is configured")
else:
print(f"✗ {header} is NOT configured")
print()
print("Full advanced config:")
print("=" * 60)
print(config)
break
if __name__ == "__main__":
asyncio.run(main())
@@ -1,115 +0,0 @@
#!/usr/bin/env python3
"""
Update all NPM proxy hosts to use port 9001 for Authentik forward auth
"""
import asyncio
import httpx
import os
NPM_URL = os.getenv("NPM_URL", "http://192.168.86.149:81")
NPM_EMAIL = os.getenv("NPM_EMAIL", "admin@example.com")
NPM_PASSWORD = os.getenv("NPM_PASSWORD", "changeme")
async def get_npm_token():
"""Get NPM authentication token"""
async with httpx.AsyncClient() as client:
response = await client.post(
f"{NPM_URL}/api/tokens",
json={"identity": NPM_EMAIL, "secret": NPM_PASSWORD}
)
response.raise_for_status()
return response.json()["token"]
async def get_proxy_hosts(token):
"""Get all proxy hosts"""
headers = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient() as client:
response = await client.get(
f"{NPM_URL}/api/nginx/proxy-hosts",
headers=headers
)
response.raise_for_status()
return response.json()
async def update_proxy_host(token, host_id, config):
"""Update a proxy host"""
headers = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient() as client:
response = await client.put(
f"{NPM_URL}/api/nginx/proxy-hosts/{host_id}",
headers=headers,
json=config
)
response.raise_for_status()
return response.json()
async def main():
print("Updating NPM proxy hosts to use port 9001...\n")
# Get token
token = await get_npm_token()
# Get all proxy hosts
hosts = await get_proxy_hosts(token)
updated = []
skipped = []
for host in hosts:
host_id = host.get("id")
domain_names = host.get("domain_names", [])
domain_str = ", ".join(domain_names)
advanced_config = host.get("advanced_config", "")
# Skip if no authentik config
if "authentik" not in advanced_config.lower():
continue
# Skip if already port 9001
if ":9001" in advanced_config:
print(f"⊘ {domain_str} - Already using port 9001")
skipped.append(domain_str)
continue
# Update 9000 to 9001
if ":9000" in advanced_config:
print(f"⟳ {domain_str} - Updating to port 9001...", end=" ")
new_config = advanced_config.replace(":9000", ":9001")
# Clean config
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_host = {k: v for k, v in host.items() if k not in readonly_fields}
clean_host["advanced_config"] = new_config
if "locations" not in clean_host or clean_host["locations"] is None:
clean_host["locations"] = []
try:
await update_proxy_host(token, host_id, clean_host)
print("✓")
updated.append(domain_str)
except Exception as e:
print(f"✗ Error: {e}")
print(f"\n{'='*60}")
print(f"Updated: {len(updated)} hosts")
print(f"Skipped: {len(skipped)} hosts")
if updated:
print("\nUpdated hosts:")
for d in updated:
print(f" • {d}")
if __name__ == "__main__":
asyncio.run(main())