Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1 @@
|
||||
# API submodule exports
|
||||
@@ -0,0 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..db.session import get_session
|
||||
from ..services.setup_service import apply_stored_connector_settings
|
||||
|
||||
|
||||
def load_connector_runtime(session: Session = Depends(get_session)) -> None:
|
||||
apply_stored_connector_settings(session)
|
||||
@@ -0,0 +1,510 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..db.session import get_session
|
||||
from ..db.models import App, AppSetting, utc_now
|
||||
from ..services.audit_logger import log_event
|
||||
from ..services.container_restart_service import execute_container_restart
|
||||
from ..services.policy_engine import evaluate_policy
|
||||
from ..services.action_registry import (
|
||||
ACTIONS,
|
||||
ACTION_RISK,
|
||||
action_blast_radius,
|
||||
action_capability,
|
||||
action_category,
|
||||
action_default_target,
|
||||
action_label,
|
||||
action_operator_guidance,
|
||||
action_safeguards,
|
||||
)
|
||||
from ..services.approval_service import create_approval
|
||||
from ..services.action_execution import action_execution_profile, execute_governed_action
|
||||
from ..services.preflight_service import build_preflight
|
||||
|
||||
router = APIRouter(prefix='/api/actions')
|
||||
|
||||
|
||||
def _idempotency_context(session: Session, key: str | None, payload: dict) -> tuple[AppSetting | None, str | None]:
|
||||
if not key:
|
||||
return None, None
|
||||
normalized = key.strip()
|
||||
if not normalized or len(normalized) > 128:
|
||||
raise HTTPException(status_code=400, detail='Idempotency-Key must contain 1 to 128 characters.')
|
||||
request_hash = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||
setting_key = f'action-idempotency:{hashlib.sha256(normalized.encode()).hexdigest()}'
|
||||
existing = session.get(AppSetting, setting_key)
|
||||
if existing:
|
||||
if existing.value_json.get('requestHash') != request_hash:
|
||||
raise HTTPException(status_code=409, detail='Idempotency-Key was already used for a different request.')
|
||||
return existing, request_hash
|
||||
return AppSetting(key=setting_key, is_secret=False), request_hash
|
||||
|
||||
|
||||
def _idempotent_response(session: Session, row: AppSetting | None, request_hash: str | None, response: dict) -> dict:
|
||||
if row is None or request_hash is None:
|
||||
return response
|
||||
if row.value_json.get('response') is not None:
|
||||
return {**row.value_json['response'], 'idempotentReplay': True}
|
||||
row.value_json = {'requestHash': request_hash, 'response': response}
|
||||
row.updated_at = utc_now()
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
class ActionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
input: str | None = Field(default=None, max_length=2000)
|
||||
action: str | None = Field(default=None, max_length=160)
|
||||
intent: str | None = Field(default=None, max_length=160)
|
||||
target: str | None = Field(default=None, max_length=240)
|
||||
source: str = Field(default='manual', pattern='^(manual|voice|automation|system)$')
|
||||
risk: str | None = Field(default=None, pattern='^(low|medium|high|critical)$')
|
||||
policy: str | None = Field(default=None, pattern='^(allowed|approval_required|blocked)$')
|
||||
reason: str | None = Field(default=None, max_length=1000)
|
||||
plan: list[str] | dict | None = None
|
||||
postChecks: list[str] = Field(default_factory=list, max_length=30)
|
||||
|
||||
|
||||
def _request_payload(payload: ActionRequest) -> dict:
|
||||
return payload.model_dump(exclude_none=True)
|
||||
|
||||
|
||||
def _extract_plan(payload: dict) -> dict:
|
||||
plan = payload.get('plan')
|
||||
if isinstance(plan, dict):
|
||||
return plan
|
||||
|
||||
if isinstance(payload.get('intent'), str):
|
||||
return {
|
||||
'intent': payload.get('intent'),
|
||||
'target': payload.get('target'),
|
||||
'source': payload.get('source', 'manual'),
|
||||
'risk': payload.get('risk', 'low'),
|
||||
'plan': payload.get('steps', []) or payload.get('plan', []),
|
||||
'postChecks': payload.get('postChecks', []),
|
||||
'policy': payload.get('policy'),
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def _build_heuristic_plan(payload: dict, session: Session) -> dict | None:
|
||||
input_text = str(payload.get('input', '')).lower()
|
||||
source = str(payload.get('source', 'manual')).strip() or 'manual'
|
||||
|
||||
apps = session.exec(select(App.id)).all()
|
||||
available_apps = [app_id for app_id in apps]
|
||||
|
||||
def has_app(name: str) -> bool:
|
||||
return name in available_apps
|
||||
|
||||
if ('restart' in input_text or 'herstart' in input_text):
|
||||
if has_app('widefrog') and 'widefrog' in input_text:
|
||||
return {
|
||||
'intent': 'container.restart',
|
||||
'target': 'widefrog',
|
||||
'source': source,
|
||||
'risk': 'medium',
|
||||
'policy': 'allowed',
|
||||
'reason': 'Media app restart is low-medium risk.',
|
||||
'plan': ['Verify container status', 'Restart widefrog container', 'Health check'],
|
||||
'postChecks': ['HTTP 200 at port 1207', 'Container running'],
|
||||
}
|
||||
if has_app('authentik') and 'authentik' in input_text:
|
||||
return {
|
||||
'intent': 'container.restart',
|
||||
'target': 'authentik',
|
||||
'source': source,
|
||||
'risk': 'high',
|
||||
'policy': 'approval_required',
|
||||
'reason': 'Auth service is critical and requires explicit approval.',
|
||||
'plan': ['Check current sessions', 'Stop if maintenance mode', 'Restart container'],
|
||||
'postChecks': ['SSO endpoint responds', 'Login flow returns'],
|
||||
}
|
||||
|
||||
health_terms = {'health', 'check', 'controleer'}
|
||||
if any(term in input_text for term in health_terms) and ('unraid' in input_text or 'array' in input_text):
|
||||
return {
|
||||
'intent': 'unraid.health_summary',
|
||||
'target': 'unraid',
|
||||
'source': source,
|
||||
'risk': 'low',
|
||||
'policy': 'allowed',
|
||||
'reason': 'Read-only health summary.',
|
||||
'plan': ['Collect array status', 'Collect disk warnings', 'Collect cache usage'],
|
||||
'postChecks': ['Sanity of returned summary'],
|
||||
}
|
||||
|
||||
if 'volume' in input_text and 'delete' in input_text:
|
||||
return {
|
||||
'intent': 'volume.delete',
|
||||
'target': 'volume',
|
||||
'source': source,
|
||||
'risk': 'critical',
|
||||
'policy': 'blocked',
|
||||
'reason': 'Destructive action is blocked by default.',
|
||||
'plan': [],
|
||||
'postChecks': [],
|
||||
}
|
||||
|
||||
aliases = {
|
||||
'scan plex': 'plex.scan_all_libraries',
|
||||
'scan bibliotheek': 'plex.scan_all_libraries',
|
||||
'pauzeer deluge': 'deluge.pause_all',
|
||||
'pause deluge': 'deluge.pause_all',
|
||||
'hervat deluge': 'deluge.resume_all',
|
||||
'resume deluge': 'deluge.resume_all',
|
||||
'controleer media pipeline': 'media_pipeline.check',
|
||||
'check media pipeline': 'media_pipeline.check',
|
||||
'diagnose media pipeline': 'media_pipeline.diagnose',
|
||||
'controleer veiligheid': 'safety.summary',
|
||||
'safety summary': 'safety.summary',
|
||||
'notificatie overzicht': 'notifications.summary',
|
||||
'notification summary': 'notifications.summary',
|
||||
'setup status': 'setup.status',
|
||||
}
|
||||
matched_action = next((action for phrase, action in aliases.items() if phrase in input_text and action in ACTIONS), None)
|
||||
if not matched_action:
|
||||
matched_action = next((action for action in ACTIONS if action.lower() in input_text), None)
|
||||
if not matched_action:
|
||||
input_terms = set(input_text.replace('.', ' ').replace('_', ' ').split())
|
||||
candidates = []
|
||||
for action in ACTIONS:
|
||||
label_terms = set(action_label(action).lower().replace('/', ' ').split())
|
||||
overlap = len(input_terms & label_terms)
|
||||
if overlap >= 2:
|
||||
candidates.append((overlap, action))
|
||||
if candidates:
|
||||
matched_action = max(candidates)[1]
|
||||
|
||||
if matched_action:
|
||||
target = action_default_target(matched_action)
|
||||
matched_app = next((app_id for app_id in available_apps if app_id.lower() in input_text), None)
|
||||
if matched_app and matched_action.startswith('container.'):
|
||||
target = matched_app
|
||||
policy = evaluate_policy(session, action=matched_action, target=target, source=source)
|
||||
capability = action_capability(matched_action)
|
||||
return {
|
||||
'intent': matched_action,
|
||||
'target': target,
|
||||
'source': source,
|
||||
'risk': str(policy.get('risk') or ACTION_RISK.get(matched_action, 'medium')),
|
||||
'policy': policy['decision'],
|
||||
'reason': policy['reason'],
|
||||
'plan': [
|
||||
f'Validate current state of {target}',
|
||||
('Collect read-only result' if capability == 'read_only' else f'Run {action_label(matched_action)} through its guarded executor'),
|
||||
],
|
||||
'postChecks': [f'Confirm {target} state and record the result in Audit'],
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _registry_item(action: str, session: Session) -> dict:
|
||||
target = action_default_target(action)
|
||||
policy = evaluate_policy(session, action=action, target=target, source='manual')
|
||||
decision = str(policy.get('decision', 'blocked'))
|
||||
risk = str(policy.get('risk') or ACTION_RISK.get(action, 'medium'))
|
||||
capability = action_capability(action)
|
||||
execution = action_execution_profile(action, decision, capability)
|
||||
return {
|
||||
'id': action,
|
||||
'label': action_label(action),
|
||||
'category': action_category(action),
|
||||
'target': target,
|
||||
'risk': risk,
|
||||
'capability': capability,
|
||||
'policyDecision': decision,
|
||||
'policyReason': policy.get('reason') or 'Policy preview.',
|
||||
**execution,
|
||||
}
|
||||
|
||||
|
||||
@router.get('/registry')
|
||||
def action_registry(session: Session = Depends(get_session)):
|
||||
items = []
|
||||
summary = {
|
||||
'total': 0,
|
||||
'allowed': 0,
|
||||
'approvalRequired': 0,
|
||||
'blocked': 0,
|
||||
'readOnly': 0,
|
||||
'operator': 0,
|
||||
'destructive': 0,
|
||||
'highRisk': 0,
|
||||
'commandExecutable': 0,
|
||||
'approvalExecutable': 0,
|
||||
'dedicatedSurface': 0,
|
||||
'governanceOnly': 0,
|
||||
}
|
||||
|
||||
for action in sorted(ACTIONS):
|
||||
item = _registry_item(action, session)
|
||||
decision = item['policyDecision']
|
||||
risk = item['risk']
|
||||
capability = item['capability']
|
||||
|
||||
summary['total'] += 1
|
||||
if decision == 'allowed':
|
||||
summary['allowed'] += 1
|
||||
elif decision == 'approval_required':
|
||||
summary['approvalRequired'] += 1
|
||||
elif decision == 'blocked':
|
||||
summary['blocked'] += 1
|
||||
if capability == 'read_only':
|
||||
summary['readOnly'] += 1
|
||||
elif capability == 'destructive':
|
||||
summary['destructive'] += 1
|
||||
else:
|
||||
summary['operator'] += 1
|
||||
if risk in {'high', 'critical'}:
|
||||
summary['highRisk'] += 1
|
||||
execution_mode = item['executionMode']
|
||||
if execution_mode == 'command_center':
|
||||
summary['commandExecutable'] += 1
|
||||
elif execution_mode == 'approval_then_execute':
|
||||
summary['approvalExecutable'] += 1
|
||||
elif execution_mode == 'dedicated_surface':
|
||||
summary['dedicatedSurface'] += 1
|
||||
elif execution_mode == 'governance_only':
|
||||
summary['governanceOnly'] += 1
|
||||
|
||||
items.append(item)
|
||||
|
||||
return {'summary': summary, 'items': items}
|
||||
|
||||
|
||||
@router.get('/registry/{action_id}')
|
||||
def action_registry_detail(action_id: str, session: Session = Depends(get_session)):
|
||||
if action_id not in ACTIONS:
|
||||
raise HTTPException(status_code=404, detail='Unknown action id.')
|
||||
|
||||
item = _registry_item(action_id, session)
|
||||
return {
|
||||
**item,
|
||||
'blastRadius': action_blast_radius(action_id, item['capability']),
|
||||
'safeguards': action_safeguards(action_id, item['policyDecision'], item['capability']),
|
||||
'operatorGuidance': action_operator_guidance(action_id, item['policyDecision'], item['capability']),
|
||||
'relatedSurfaces': [
|
||||
'/command-center',
|
||||
'/policies',
|
||||
'/audit',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post('/plan')
|
||||
def action_plan(payload: ActionRequest, session: Session = Depends(get_session)):
|
||||
payload = _request_payload(payload)
|
||||
source = str(payload.get('source', 'manual')).strip() or 'manual'
|
||||
plan = _extract_plan(payload)
|
||||
|
||||
if not plan:
|
||||
plan = _build_heuristic_plan(payload, session)
|
||||
|
||||
if not plan:
|
||||
return {'decision': 'blocked', 'reason': 'No actionable intent detected.'}
|
||||
|
||||
return {
|
||||
'intent': plan['intent'],
|
||||
'target': plan['target'],
|
||||
'source': source,
|
||||
'risk': plan['risk'],
|
||||
'policy': plan['policy'],
|
||||
'reason': plan['reason'],
|
||||
'plan': plan['plan'],
|
||||
'postChecks': plan['postChecks'],
|
||||
}
|
||||
|
||||
|
||||
@router.post('/preflight')
|
||||
def preflight(payload: ActionRequest, session: Session = Depends(get_session)):
|
||||
payload = _request_payload(payload)
|
||||
action = str(payload.get('action', '')).strip()
|
||||
target = str(payload.get('target', '')).strip()
|
||||
source = str(payload.get('source', 'manual')).strip() or 'manual'
|
||||
|
||||
if not action or not target:
|
||||
if plan := _build_heuristic_plan(payload, session):
|
||||
action = plan['intent']
|
||||
target = plan['target']
|
||||
source = plan['source']
|
||||
else:
|
||||
action = payload.get('action', '')
|
||||
target = payload.get('target', '')
|
||||
|
||||
policy = evaluate_policy(session, action=action, target=target, source=source)
|
||||
risk = policy.get('risk', 'medium')
|
||||
return build_preflight(action, target, policy['decision'], risk)
|
||||
|
||||
|
||||
@router.post('/execute')
|
||||
def execute(payload: ActionRequest, session: Session = Depends(get_session), idempotency_key: str | None = Header(default=None, alias='Idempotency-Key')):
|
||||
payload = _request_payload(payload)
|
||||
idempotency_row, request_hash = _idempotency_context(session, idempotency_key, payload)
|
||||
if idempotency_row is not None and idempotency_row.value_json.get('response') is not None:
|
||||
return _idempotent_response(session, idempotency_row, request_hash, {})
|
||||
plan = _extract_plan(payload)
|
||||
if not plan:
|
||||
return _idempotent_response(session, idempotency_row, request_hash, {'success': False, 'message': 'No valid action plan provided.'})
|
||||
|
||||
action = str(plan.get('intent', '')).strip()
|
||||
target = str(plan.get('target', '')).strip()
|
||||
source = str(plan.get('source', 'manual')).strip() or 'manual'
|
||||
steps = plan.get('plan', []) if isinstance(plan.get('plan', []), list) else []
|
||||
|
||||
if action == 'deployment.target.promote_workmesh':
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk='high',
|
||||
policy_decision='blocked',
|
||||
reason='WorkMesh promotion requires its exact-revision deployment preflight endpoint.',
|
||||
result='blocked',
|
||||
execution_plan={'plan': steps, 'executor': '/api/deployments/workmesh/promotions/preflight'},
|
||||
)
|
||||
return _idempotent_response(session, idempotency_row, request_hash, {
|
||||
'success': False,
|
||||
'decision': 'blocked',
|
||||
'status': 'failed',
|
||||
'policy_decision': 'blocked',
|
||||
'result': 'preflight_required',
|
||||
'message': 'Use the fixed WorkMesh revision preflight and approval workflow.',
|
||||
'auditId': audit.id,
|
||||
})
|
||||
|
||||
decision = evaluate_policy(session, action=action, target=target, source=source)
|
||||
risk = str(decision.get('risk', 'medium')).strip() or 'medium'
|
||||
|
||||
# Always audit every request.
|
||||
if decision['decision'] == 'blocked':
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk,
|
||||
policy_decision=decision['decision'],
|
||||
reason=decision['reason'],
|
||||
result='blocked',
|
||||
execution_plan={'plan': steps, 'policyReason': decision['reason']},
|
||||
)
|
||||
return _idempotent_response(session, idempotency_row, request_hash, {
|
||||
'success': False,
|
||||
'decision': 'blocked',
|
||||
'status': 'failed',
|
||||
'policy_decision': decision['decision'],
|
||||
'result': 'blocked',
|
||||
'message': decision['reason'],
|
||||
'auditId': audit.id,
|
||||
})
|
||||
|
||||
if decision['decision'] == 'approval_required':
|
||||
approval = create_approval(
|
||||
session,
|
||||
action=action,
|
||||
target=target,
|
||||
source=source,
|
||||
risk=risk,
|
||||
reason=decision['reason'],
|
||||
requester=source,
|
||||
)
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk,
|
||||
policy_decision=decision['decision'],
|
||||
reason=decision['reason'],
|
||||
result='approval_requested',
|
||||
execution_plan={'plan': steps, 'approvalId': approval.id},
|
||||
redacted_metadata={'approval_id': approval.id},
|
||||
)
|
||||
return _idempotent_response(session, idempotency_row, request_hash, {
|
||||
'success': False,
|
||||
'decision': 'approval_required',
|
||||
'status': 'failed',
|
||||
'policy_decision': decision['decision'],
|
||||
'result': 'approval_required',
|
||||
'message': 'Action requires approval before execution.',
|
||||
'approvalId': approval.id,
|
||||
'auditId': audit.id,
|
||||
})
|
||||
|
||||
if action in {
|
||||
'deployment.targets.sync',
|
||||
'deployment.target.deploy_workmesh',
|
||||
'deployment.target.register_portfolio',
|
||||
'deployment.target.register_privatemuse',
|
||||
}:
|
||||
response = execute_governed_action(
|
||||
session,
|
||||
action=action,
|
||||
target=target,
|
||||
source=source,
|
||||
risk=risk,
|
||||
reason=str(decision.get('reason') or 'Execute the fixed deployment target operation.'),
|
||||
original_policy_decision=str(decision['decision']),
|
||||
)
|
||||
return _idempotent_response(session, idempotency_row, request_hash, response)
|
||||
if action == 'container.restart':
|
||||
response = execute_container_restart(
|
||||
session=session,
|
||||
action=action,
|
||||
target=target,
|
||||
source=source,
|
||||
risk=risk,
|
||||
steps=steps if isinstance(steps, list) else None,
|
||||
)
|
||||
return _idempotent_response(session, idempotency_row, request_hash, response)
|
||||
|
||||
# Fail closed for allowed actions without a real executor. This avoids
|
||||
# presenting read-only checks or future actions as successfully executed.
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk,
|
||||
policy_decision=decision['decision'],
|
||||
reason='No live executor is registered for this action.',
|
||||
result='unsupported',
|
||||
execution_plan={'plan': steps, 'executor': None},
|
||||
)
|
||||
|
||||
return _idempotent_response(session, idempotency_row, request_hash, {
|
||||
'success': False,
|
||||
'decision': 'allowed',
|
||||
'status': 'failed',
|
||||
'policy_decision': decision['decision'],
|
||||
'result': 'unsupported',
|
||||
'message': 'No live executor is registered for this action.',
|
||||
'auditId': audit.id,
|
||||
})
|
||||
|
||||
|
||||
@router.post('/container/{app_id}/restart')
|
||||
def restart_container(app_id: str, session: Session = Depends(get_session)):
|
||||
return execute_container_restart(
|
||||
session=session,
|
||||
action='container.restart',
|
||||
target=app_id,
|
||||
source='manual',
|
||||
risk='medium',
|
||||
steps=['Resolve container', 'Restart container', 'Post-check status'],
|
||||
)
|
||||
@@ -0,0 +1,210 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..core.redaction import redact_message
|
||||
from ..db.session import get_session
|
||||
from ..services import ai_security_reviewer, knowledge_service
|
||||
|
||||
router = APIRouter(prefix='/api/ai')
|
||||
|
||||
|
||||
_AI_TOOLS = [
|
||||
{
|
||||
'id': 'read_container_status',
|
||||
'name': 'read_container_status',
|
||||
'description': 'Leest containerstatus via de Portainer read facade.',
|
||||
'type': 'read',
|
||||
'permissionLevel': 1,
|
||||
'requiresApproval': False,
|
||||
'rateLimit': '60/min',
|
||||
'tool': 'read_container_status',
|
||||
'enabled': True,
|
||||
'allowedByDefault': True,
|
||||
'scope': 'read',
|
||||
},
|
||||
{
|
||||
'id': 'read_unraid_health',
|
||||
'name': 'read_unraid_health',
|
||||
'description': 'Leest Unraid array, cache, parity en docker health zonder mutaties.',
|
||||
'type': 'read',
|
||||
'permissionLevel': 1,
|
||||
'requiresApproval': False,
|
||||
'rateLimit': '30/min',
|
||||
'tool': 'read_unraid_health',
|
||||
'enabled': True,
|
||||
'allowedByDefault': True,
|
||||
'scope': 'read',
|
||||
},
|
||||
{
|
||||
'id': 'read_audit',
|
||||
'name': 'read_audit',
|
||||
'description': 'Leest geredigeerde audit events voor analyse en incidentcontext.',
|
||||
'type': 'read',
|
||||
'permissionLevel': 1,
|
||||
'requiresApproval': False,
|
||||
'rateLimit': '30/min',
|
||||
'tool': 'read_audit',
|
||||
'enabled': True,
|
||||
'allowedByDefault': True,
|
||||
'scope': 'read',
|
||||
},
|
||||
{
|
||||
'id': 'query_knowledge',
|
||||
'name': 'query_knowledge',
|
||||
'description': 'Zoekt lokale documentatie en actuele, geredigeerde AppOps-context met bronverwijzingen.',
|
||||
'type': 'read',
|
||||
'permissionLevel': 1,
|
||||
'requiresApproval': False,
|
||||
'rateLimit': '30/min',
|
||||
'tool': 'query_knowledge',
|
||||
'enabled': True,
|
||||
'allowedByDefault': True,
|
||||
'scope': 'read',
|
||||
},
|
||||
{
|
||||
'id': 'draft_playbook_run',
|
||||
'name': 'draft_playbook_run',
|
||||
'description': 'Maakt alleen een voorstel voor playbook-uitvoering; uitvoering loopt via policy en approval.',
|
||||
'type': 'mutate',
|
||||
'permissionLevel': 3,
|
||||
'requiresApproval': True,
|
||||
'rateLimit': '5/min',
|
||||
'tool': 'draft_playbook_run',
|
||||
'enabled': True,
|
||||
'allowedByDefault': False,
|
||||
'scope': 'mutate',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _redact_payload(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return redact_message(value)
|
||||
if isinstance(value, list):
|
||||
return [_redact_payload(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _redact_payload(item) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
@router.get('/status')
|
||||
def status(session: Session = Depends(get_session)):
|
||||
summary = ai_security_reviewer.get_summary(session)
|
||||
current_risks = int(summary.get('criticalFindings') or 0) + int(summary.get('highFindings') or 0) + int(summary.get('mediumFindings') or 0)
|
||||
recommended_actions = int(summary.get('recommendationsCount') or 0)
|
||||
data_completeness = summary.get('dataCompleteness') if isinstance(summary.get('dataCompleteness'), dict) else {}
|
||||
missing_sources = data_completeness.get('missingSources') or []
|
||||
if missing_sources:
|
||||
storage_forecast = f"Coverage partial; ontbrekende bronnen: {', '.join(str(item) for item in missing_sources[:4])}."
|
||||
else:
|
||||
storage_forecast = 'Geen storage forecast beschikbaar in de advisory AI status.'
|
||||
|
||||
return {
|
||||
'dailySummary': redact_message(
|
||||
f"AI advisor draait read-only. Security status: {summary.get('status', 'unknown')}; "
|
||||
f"{current_risks} open risico(s), {recommended_actions} aanbeveling(en)."
|
||||
),
|
||||
'currentRisks': current_risks,
|
||||
'recommendedActions': recommended_actions,
|
||||
'storageForecast': redact_message(storage_forecast),
|
||||
'securityScore': int(summary.get('score') or 0),
|
||||
'autopilotStatus': 'off',
|
||||
}
|
||||
|
||||
@router.post('/chat')
|
||||
def chat(payload: dict, session: Session = Depends(get_session)):
|
||||
content = redact_message(str(payload.get('content') or ''))
|
||||
try:
|
||||
grounded = knowledge_service.query(session, content, limit=5)
|
||||
except ValueError:
|
||||
grounded = {
|
||||
'answer': 'Stel een concrete vraag over AppOps, policies, apps, audit of platformwerking.',
|
||||
'citations': [],
|
||||
'confidence': 'none',
|
||||
}
|
||||
citations = grounded.get('citations') or []
|
||||
evidence = '; '.join(
|
||||
f"[{item.get('rank')}] {item.get('title')} — {item.get('uri')}"
|
||||
for item in citations[:5]
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
return {
|
||||
'id': f"chat-{uuid.uuid4().hex[:12]}",
|
||||
'role': 'assistant',
|
||||
'content': grounded['answer'],
|
||||
'timestamp': datetime.now(UTC).isoformat(),
|
||||
'metadata': {
|
||||
'evidence': evidence or 'Geen relevante bron gevonden; er is niet gegokt.',
|
||||
'risk': 'low',
|
||||
'policyResult': 'allowed',
|
||||
'nextStep': 'RAG blijft read-only. Uitvoering loopt uitsluitend via Command Center, policy en approvals.',
|
||||
'citations': citations,
|
||||
'confidence': grounded.get('confidence', 'none'),
|
||||
},
|
||||
}
|
||||
|
||||
@router.get('/recommendations')
|
||||
def recommendations(session: Session = Depends(get_session)):
|
||||
results: list[dict[str, Any]] = []
|
||||
for recommendation in ai_security_reviewer.get_recommendations(session):
|
||||
evidence = recommendation.get('evidence') or []
|
||||
evidence_text = '; '.join(
|
||||
redact_message(f"{item.get('source', 'source')}:{item.get('label', 'evidence')}={item.get('value', item.get('observed', ''))}")
|
||||
for item in evidence[:3]
|
||||
if isinstance(item, dict)
|
||||
) or redact_message(str(recommendation.get('description') or 'Security reviewer recommendation.'))
|
||||
status = recommendation.get('status') or 'open'
|
||||
if status == 'accepted':
|
||||
status = 'applied'
|
||||
confidence = recommendation.get('confidence') or 'medium'
|
||||
confidence_score = {'low': 0.45, 'medium': 0.7, 'high': 0.9}.get(str(confidence), 0.7)
|
||||
severity = str(recommendation.get('severity') or 'medium')
|
||||
if severity == 'info':
|
||||
severity = 'low'
|
||||
results.append({
|
||||
'id': recommendation.get('id'),
|
||||
'title': redact_message(str(recommendation.get('title') or 'AI recommendation')),
|
||||
'severity': severity,
|
||||
'confidence': confidence_score,
|
||||
'evidence': evidence_text,
|
||||
'affectedApps': [str(item) for item in recommendation.get('affectedResources', [])],
|
||||
'suggestedAction': redact_message(str(recommendation.get('suggestedAction') or 'Review in AI Security Reviewer')),
|
||||
'status': status,
|
||||
})
|
||||
return results
|
||||
|
||||
@router.post('/analyze/incident/{incident_id}')
|
||||
def analyze_incident(incident_id: str):
|
||||
return {'incident': incident_id, 'analysis': 'No blocking issues.'}
|
||||
|
||||
@router.post('/summarize/logs')
|
||||
def summarize_logs(payload: dict):
|
||||
return {'summary': 'No critical events.'}
|
||||
|
||||
@router.get('/tools')
|
||||
def tools():
|
||||
return _AI_TOOLS
|
||||
|
||||
|
||||
@router.get('/history')
|
||||
def history():
|
||||
return []
|
||||
|
||||
@router.get('/memory')
|
||||
def memory():
|
||||
return []
|
||||
|
||||
@router.post('/memory')
|
||||
def create_memory(payload: dict):
|
||||
return {'success': True, 'memory': _redact_payload(payload)}
|
||||
|
||||
@router.patch('/memory/{memory_id}')
|
||||
def update_memory(memory_id: str, payload: dict):
|
||||
return {'success': True, 'id': memory_id, 'memory': _redact_payload(payload)}
|
||||
|
||||
@router.delete('/memory/{memory_id}')
|
||||
def delete_memory(memory_id: str):
|
||||
return {'success': True, 'id': memory_id}
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..db.session import get_session
|
||||
from ..services import ai_remediation_planner
|
||||
from .connector_runtime import load_connector_runtime
|
||||
|
||||
router = APIRouter(prefix='/api/ai/remediation', dependencies=[Depends(load_connector_runtime)])
|
||||
|
||||
|
||||
@router.get('/status')
|
||||
def status(session: Session = Depends(get_session)):
|
||||
plans = ai_remediation_planner.get_plans(session).plans
|
||||
return {
|
||||
'enabled': True,
|
||||
'mode': 'deterministic_rule_based',
|
||||
'externalAiRequired': False,
|
||||
'canExecuteActions': False,
|
||||
'totalPlans': len(plans),
|
||||
'openPlans': sum(1 for plan in plans if plan.status == 'draft'),
|
||||
'reviewedPlans': sum(1 for plan in plans if plan.status == 'reviewed'),
|
||||
'dismissedPlans': sum(1 for plan in plans if plan.status == 'dismissed'),
|
||||
}
|
||||
|
||||
|
||||
@router.get('/plans')
|
||||
def plans(session: Session = Depends(get_session)):
|
||||
return ai_remediation_planner.get_plans(session).model_dump()
|
||||
|
||||
|
||||
@router.get('/plans/{plan_id}')
|
||||
def plan_detail(plan_id: str, session: Session = Depends(get_session)):
|
||||
plan = ai_remediation_planner.get_plan(session, plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail='plan_not_found')
|
||||
return plan.model_dump()
|
||||
|
||||
|
||||
@router.post('/plans/generate')
|
||||
def generate(session: Session = Depends(get_session)):
|
||||
return ai_remediation_planner.generate_plans(session, audit_run=True).model_dump()
|
||||
|
||||
|
||||
@router.post('/plans/from-finding/{finding_id}')
|
||||
def from_finding(finding_id: str, session: Session = Depends(get_session)):
|
||||
return ai_remediation_planner.generate_from_finding(session, finding_id).model_dump()
|
||||
|
||||
|
||||
@router.post('/plans/from-recommendation/{recommendation_id}')
|
||||
def from_recommendation(recommendation_id: str, session: Session = Depends(get_session)):
|
||||
return ai_remediation_planner.generate_from_recommendation(session, recommendation_id).model_dump()
|
||||
|
||||
|
||||
@router.post('/plans/{plan_id}/review')
|
||||
def review(plan_id: str, session: Session = Depends(get_session)):
|
||||
if not ai_remediation_planner.get_plan(session, plan_id):
|
||||
raise HTTPException(status_code=404, detail='plan_not_found')
|
||||
return ai_remediation_planner.review_plan(session, plan_id)
|
||||
|
||||
|
||||
@router.post('/plans/{plan_id}/dismiss')
|
||||
def dismiss(plan_id: str, session: Session = Depends(get_session)):
|
||||
if not ai_remediation_planner.get_plan(session, plan_id):
|
||||
raise HTTPException(status_code=404, detail='plan_not_found')
|
||||
return ai_remediation_planner.dismiss_plan(session, plan_id)
|
||||
|
||||
|
||||
@router.post('/plans/{plan_id}/create-approval-proposal')
|
||||
def create_approval_proposal(plan_id: str, session: Session = Depends(get_session)):
|
||||
proposal = ai_remediation_planner.create_approval_proposal(session, plan_id)
|
||||
if not proposal:
|
||||
raise HTTPException(status_code=404, detail='plan_not_found')
|
||||
return proposal.model_dump()
|
||||
|
||||
|
||||
@router.post('/plans/{plan_id}/dry-run')
|
||||
def dry_run(plan_id: str, session: Session = Depends(get_session)):
|
||||
result = ai_remediation_planner.dry_run(session, plan_id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail='plan_not_found')
|
||||
return result.model_dump()
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..db.session import get_session
|
||||
from ..schemas.ai_security import SecurityReviewScope
|
||||
from ..services import ai_security_reviewer
|
||||
from .connector_runtime import load_connector_runtime
|
||||
|
||||
router = APIRouter(prefix='/api/ai/security', dependencies=[Depends(load_connector_runtime)])
|
||||
|
||||
|
||||
@router.get('/status')
|
||||
def status(session: Session = Depends(get_session)):
|
||||
summary = ai_security_reviewer.get_summary(session)
|
||||
return {
|
||||
'enabled': True,
|
||||
'mode': 'deterministic_rule_based',
|
||||
'externalAiRequired': False,
|
||||
'canExecuteActions': False,
|
||||
'status': summary['status'],
|
||||
'score': summary['score'],
|
||||
'generatedAt': summary['generatedAt'],
|
||||
}
|
||||
|
||||
|
||||
@router.get('/summary')
|
||||
def summary(session: Session = Depends(get_session)):
|
||||
return ai_security_reviewer.get_summary(session)
|
||||
|
||||
|
||||
@router.get('/findings')
|
||||
def findings(scope: SecurityReviewScope = 'all', session: Session = Depends(get_session)):
|
||||
return ai_security_reviewer.get_findings(session, scope)
|
||||
|
||||
|
||||
@router.get('/findings/{finding_id}')
|
||||
def finding_detail(finding_id: str, session: Session = Depends(get_session)):
|
||||
finding = ai_security_reviewer.get_finding(session, finding_id)
|
||||
if not finding:
|
||||
raise HTTPException(status_code=404, detail='finding_not_found')
|
||||
return finding
|
||||
|
||||
|
||||
@router.get('/recommendations')
|
||||
def recommendations(session: Session = Depends(get_session)):
|
||||
return ai_security_reviewer.get_recommendations(session)
|
||||
|
||||
|
||||
@router.post('/review')
|
||||
def review(session: Session = Depends(get_session)):
|
||||
return ai_security_reviewer.generate_review(session, audit_run=True).model_dump()
|
||||
|
||||
|
||||
@router.post('/review/scope/{scope}')
|
||||
def review_scope(scope: SecurityReviewScope, session: Session = Depends(get_session)):
|
||||
return ai_security_reviewer.generate_review(session, scope=scope, audit_run=True).model_dump()
|
||||
|
||||
|
||||
@router.post('/findings/{finding_id}/acknowledge')
|
||||
def acknowledge_finding(finding_id: str, session: Session = Depends(get_session)):
|
||||
return ai_security_reviewer.set_finding_status(session, finding_id, 'acknowledged')
|
||||
|
||||
|
||||
@router.post('/findings/{finding_id}/dismiss')
|
||||
def dismiss_finding(finding_id: str, session: Session = Depends(get_session)):
|
||||
return ai_security_reviewer.set_finding_status(session, finding_id, 'dismissed')
|
||||
|
||||
|
||||
@router.post('/recommendations/{recommendation_id}/snooze')
|
||||
def snooze_recommendation(recommendation_id: str, session: Session = Depends(get_session)):
|
||||
return ai_security_reviewer.set_recommendation_status(session, recommendation_id, 'snoozed')
|
||||
|
||||
|
||||
@router.post('/recommendations/{recommendation_id}/dismiss')
|
||||
def dismiss_recommendation(recommendation_id: str, session: Session = Depends(get_session)):
|
||||
return ai_security_reviewer.set_recommendation_status(session, recommendation_id, 'dismissed')
|
||||
|
||||
|
||||
@router.post('/recommendations/{recommendation_id}/accept')
|
||||
def accept_recommendation(recommendation_id: str, session: Session = Depends(get_session)):
|
||||
return ai_security_reviewer.set_recommendation_status(session, recommendation_id, 'accepted')
|
||||
@@ -0,0 +1,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from ..db.session import get_session
|
||||
from ..services.audit_logger import log_event
|
||||
from ..services.action_execution import approval_execution_supported, execute_approved_action
|
||||
from ..services.approval_service import (
|
||||
ApprovalStateError,
|
||||
approval_fingerprint,
|
||||
create_approval,
|
||||
dismiss_expired_approvals,
|
||||
get_approval,
|
||||
list_approvals,
|
||||
mark_approval_executed,
|
||||
update_approval_status,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix='/api/approvals')
|
||||
|
||||
|
||||
class ApprovalCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
action: str = Field(min_length=1, max_length=160)
|
||||
target: str = Field(min_length=1, max_length=240)
|
||||
source: str = Field(default='manual', pattern='^(manual|voice|automation|system)$')
|
||||
risk: str = Field(default='medium', pattern='^(low|medium|high|critical)$')
|
||||
reason: str | None = Field(default=None, max_length=1000)
|
||||
requester: str = Field(default='manual', max_length=120)
|
||||
plan: list[str] = Field(default_factory=list, max_length=30)
|
||||
|
||||
|
||||
class ApprovalDecisionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
status: str = Field(min_length=1, max_length=32)
|
||||
|
||||
|
||||
@router.get('')
|
||||
def list_all(include_dismissed: bool = Query(default=False), session=Depends(get_session)):
|
||||
return [
|
||||
{
|
||||
**approval.model_dump(),
|
||||
'executionSupported': approval_execution_supported(approval.action),
|
||||
'executionReason': None if approval_execution_supported(approval.action) else 'Geen rollback-veilige executor geregistreerd.',
|
||||
}
|
||||
for approval in list_approvals(session, include_dismissed=include_dismissed)
|
||||
]
|
||||
|
||||
|
||||
@router.post('/cleanup')
|
||||
def cleanup_expired(session=Depends(get_session)):
|
||||
dismissed = dismiss_expired_approvals(session)
|
||||
audit = log_event(
|
||||
session,
|
||||
source='manual',
|
||||
identity='operator',
|
||||
action_id='approval.cleanup',
|
||||
target='approval-queue',
|
||||
target_type='approval',
|
||||
risk='low',
|
||||
policy_decision='allowed',
|
||||
reason=f'{len(dismissed)} expired approval(s) archived; active approvals were retained.',
|
||||
result='success',
|
||||
execution_plan={'steps': ['expire overdue pending approvals', 'archive expired approvals', 'retain approval and audit records']},
|
||||
redacted_metadata={'dismissedCount': len(dismissed)},
|
||||
)
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'{len(dismissed)} verlopen approval(s) opgeruimd.',
|
||||
'dismissedCount': len(dismissed),
|
||||
'auditId': audit.id,
|
||||
}
|
||||
|
||||
|
||||
@router.post('')
|
||||
def create(payload: ApprovalCreateRequest, session=Depends(get_session)):
|
||||
approval = create_approval(
|
||||
session,
|
||||
action=payload.action,
|
||||
target=payload.target,
|
||||
source=payload.source,
|
||||
risk=payload.risk,
|
||||
reason=payload.reason,
|
||||
requester=payload.requester,
|
||||
context={'plan': payload.plan},
|
||||
)
|
||||
return {'success': True, 'message': 'Approval created', 'id': approval.id}
|
||||
|
||||
|
||||
@router.patch('/{approval_id}')
|
||||
def update(approval_id: str, payload: ApprovalDecisionRequest, session=Depends(get_session)):
|
||||
status = payload.status
|
||||
try:
|
||||
updated = update_approval_status(session, approval_id, status)
|
||||
except ApprovalStateError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if not updated:
|
||||
return {'success': False, 'message': 'Approval not found', 'id': approval_id}
|
||||
|
||||
audit = log_event(
|
||||
session,
|
||||
source='manual',
|
||||
identity='operator',
|
||||
action_id=f'approval.{status}',
|
||||
target=updated.target,
|
||||
target_type='approval',
|
||||
risk=updated.risk,
|
||||
policy_decision='approval_required',
|
||||
reason=updated.reason or f'Approval {approval_id} marked {status}.',
|
||||
result='success',
|
||||
execution_plan={'steps': ['load approval', f'mark {status}', 'write audit']},
|
||||
redacted_metadata={'approvalId': updated.id, 'approvalStatus': updated.status, 'requestedAction': updated.action},
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'id': updated.id,
|
||||
'status': updated.status,
|
||||
'auditId': audit.id,
|
||||
'message': f'Approval {updated.status}',
|
||||
}
|
||||
|
||||
|
||||
@router.post('/{approval_id}/execute')
|
||||
def execute_approved(approval_id: str, session=Depends(get_session)):
|
||||
approval = get_approval(session, approval_id)
|
||||
if not approval:
|
||||
raise HTTPException(status_code=404, detail='Approval not found.')
|
||||
if approval.status != 'approved':
|
||||
raise HTTPException(status_code=409, detail='Only an approved request can be executed.')
|
||||
expected = approval_fingerprint(approval.action, approval.target, approval.source, approval.risk, approval.request_context_json)
|
||||
if not approval.action_fingerprint or approval.action_fingerprint != expected:
|
||||
raise HTTPException(status_code=409, detail='Approval action context changed after review.')
|
||||
|
||||
result = execute_approved_action(session, approval)
|
||||
if not result.get('success'):
|
||||
return result
|
||||
|
||||
mark_approval_executed(
|
||||
session,
|
||||
approval,
|
||||
job_id=str(result['jobId']),
|
||||
audit_event_id=str(result['auditId']),
|
||||
)
|
||||
return {
|
||||
**result,
|
||||
'approvalId': approval.id,
|
||||
'approvalStatus': 'executed',
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..db.models import App, AppContainerMapping, DiscoveredContainer
|
||||
from ..db.session import get_session
|
||||
from ..core.config import settings
|
||||
from ..services import safety_center_service
|
||||
from ..services.app_registry import category_label, list_active_apps
|
||||
from ..services.audit_logger import log_event
|
||||
from ..services.inventory_discovery import serialize_inventory_app, sync_app_connector_statuses
|
||||
|
||||
router = APIRouter(prefix='/api/apps')
|
||||
|
||||
|
||||
def _published_port(values: list | None) -> int | None:
|
||||
for value in values or []:
|
||||
match = re.match(r'^(\d+):\d+', str(value))
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def _credential_free_url(raw: str) -> str | None:
|
||||
raw = str(raw or '').strip()
|
||||
if not raw:
|
||||
return None
|
||||
parsed = urlsplit(raw if '://' in raw else f'http://{raw}')
|
||||
try:
|
||||
hostname = parsed.hostname
|
||||
parsed_port = parsed.port
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.scheme not in {'http', 'https'} or not hostname:
|
||||
return None
|
||||
host = f'[{hostname}]' if ':' in hostname else hostname
|
||||
port = f':{parsed_port}' if parsed_port else ''
|
||||
path = parsed.path.rstrip('/')
|
||||
return f'{parsed.scheme}://{host}{port}{path}'
|
||||
|
||||
|
||||
def _base_runtime_url() -> str | None:
|
||||
safe = _credential_free_url(str(settings.apis.get('UNRAID_URL') or settings.apis.get('PORTAINER_URL') or ''))
|
||||
if not safe:
|
||||
return None
|
||||
parsed = urlsplit(safe)
|
||||
return f'{parsed.scheme}://{parsed.netloc}'
|
||||
|
||||
|
||||
def _runtime_hints_by_app(session: Session) -> dict[str, dict]:
|
||||
from ..services.inventory_discovery import _latest_by_container
|
||||
|
||||
configured_urls = {
|
||||
'sonarr': 'SONARR_URL',
|
||||
'radarr': 'RADARR_URL',
|
||||
'prowlarr': 'PROWLARR_URL',
|
||||
'deluge': 'DELUGE_URL',
|
||||
'plex': 'PLEX_URL',
|
||||
'tautulli': 'TAUTULLI_URL',
|
||||
'nginx_proxy_manager': 'NPM_URL',
|
||||
'authentik': 'AUTHENTIK_URL',
|
||||
'home_assistant': 'HOME_ASSISTANT_URL',
|
||||
'widefrog': 'WIDEFROG_URL',
|
||||
'portainer': 'PORTAINER_URL',
|
||||
'unraid': 'UNRAID_URL',
|
||||
}
|
||||
hints: dict[str, dict] = {}
|
||||
for app_id, env_name in configured_urls.items():
|
||||
value = _credential_free_url(str(settings.apis.get(env_name) or ''))
|
||||
if value:
|
||||
hints[app_id] = {'url': value}
|
||||
|
||||
base_url = _base_runtime_url()
|
||||
latest = _latest_by_container(session)
|
||||
mappings = session.exec(select(AppContainerMapping).order_by(AppContainerMapping.is_primary.desc())).all()
|
||||
for mapping in mappings:
|
||||
if mapping.app_id in hints or not base_url:
|
||||
continue
|
||||
item = latest.get(mapping.container_id)
|
||||
port = _published_port(item.ports_json if item else None)
|
||||
if port:
|
||||
hints[mapping.app_id] = {'url': base_url, 'port': port}
|
||||
return hints
|
||||
|
||||
|
||||
def _serialize_app(app: App, hints: dict | None = None) -> dict:
|
||||
return {
|
||||
'id': app.id,
|
||||
'name': app.name,
|
||||
'category': app.category,
|
||||
'status': app.status,
|
||||
'criticality': app.criticality,
|
||||
'connectorStatus': app.connector_status,
|
||||
'containerStatus': app.container_status,
|
||||
'voiceEnabled': app.voice_enabled,
|
||||
'lastAction': app.last_action,
|
||||
'protected': app.protected,
|
||||
'discoveryStatus': app.discovery_status,
|
||||
'discoverySource': app.discovery_source,
|
||||
'needsReview': app.needs_review,
|
||||
**(hints or {}),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_discovered_app(item: DiscoveredContainer) -> dict:
|
||||
return serialize_inventory_app(item)
|
||||
|
||||
|
||||
@router.get('')
|
||||
def list_apps(include_discovered: bool = False, session: Session = Depends(get_session)):
|
||||
sync_app_connector_statuses(session)
|
||||
runtime_hints = _runtime_hints_by_app(session)
|
||||
apps = [_serialize_app(app, runtime_hints.get(app.id)) for app in list_active_apps(session)]
|
||||
|
||||
if include_discovered:
|
||||
managed_container_ids = {
|
||||
mapping.container_id for mapping in session.exec(select(AppContainerMapping)).all()
|
||||
}
|
||||
latest_by_container: dict[str, DiscoveredContainer] = {}
|
||||
for item in session.exec(select(DiscoveredContainer)).all():
|
||||
latest_by_container[item.container_id] = item
|
||||
for item in latest_by_container.values():
|
||||
if item.container_id in managed_container_ids or item.import_status == 'managed':
|
||||
continue
|
||||
if item.import_status in {'discovered', 'unmapped', 'changed', 'ignored'}:
|
||||
apps.append(_serialize_discovered_app(item))
|
||||
|
||||
return apps
|
||||
|
||||
|
||||
@router.get('/{app_id}')
|
||||
def get_app(app_id: str, session: Session = Depends(get_session)):
|
||||
sync_app_connector_statuses(session)
|
||||
app = session.get(App, app_id)
|
||||
if app:
|
||||
return _serialize_app(app, _runtime_hints_by_app(session).get(app.id))
|
||||
|
||||
if app_id.startswith('discovered-'):
|
||||
discovered_id = app_id.replace('discovered-', '', 1)
|
||||
item = session.get(DiscoveredContainer, discovered_id)
|
||||
if item:
|
||||
return _serialize_discovered_app(item)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.patch('/{app_id}/metadata')
|
||||
def update_app_metadata(app_id: str, payload: dict, session: Session = Depends(get_session)):
|
||||
app = session.get(App, app_id)
|
||||
if not app:
|
||||
return {'success': False, 'status': 'not_found', 'message': 'app_not_found'}
|
||||
|
||||
name = str(payload.get('name') or '').strip()[:80]
|
||||
category = re.sub(r'[^a-zA-Z0-9_\- ]', '', str(payload.get('category') or '')).strip()[:40]
|
||||
criticality = str(payload.get('criticality') or '').strip().lower()
|
||||
if not name or not category or criticality not in {'low', 'medium', 'high', 'critical'}:
|
||||
return {'success': False, 'status': 'validation_error', 'message': 'invalid_app_metadata'}
|
||||
|
||||
before = {'name': app.name, 'category': app.category, 'criticality': app.criticality}
|
||||
app.name = name
|
||||
app.category = category_label(category)
|
||||
app.criticality = criticality
|
||||
session.add(app)
|
||||
session.commit()
|
||||
session.refresh(app)
|
||||
safety_center_service.invalidate_cache()
|
||||
log_event(
|
||||
session,
|
||||
source='manual',
|
||||
identity='operator',
|
||||
action_id='app.metadata.update',
|
||||
target=app.id,
|
||||
target_type='app',
|
||||
risk='low',
|
||||
policy_decision='allowed',
|
||||
reason='Operator updated AppOps registry metadata only; no container or external service was mutated.',
|
||||
result='success',
|
||||
before_state=before,
|
||||
after_state={'name': app.name, 'category': app.category, 'criticality': app.criticality},
|
||||
redacted_metadata={'fields': ['name', 'category', 'criticality']},
|
||||
)
|
||||
return {'success': True, 'status': 'success', 'message': 'app_metadata_updated', 'app': _serialize_app(app, _runtime_hints_by_app(session).get(app.id))}
|
||||
|
||||
|
||||
@router.patch('/{app_id}/container-mapping')
|
||||
def update_container_mapping(app_id: str, payload: dict, session: Session = Depends(get_session)):
|
||||
container_id = str(payload.get('containerId') or '')
|
||||
container_name = str(payload.get('containerName') or container_id)
|
||||
if not session.get(App, app_id):
|
||||
return {'success': False, 'message': 'app_not_found'}
|
||||
if not container_id:
|
||||
return {'success': False, 'message': 'container_id_required'}
|
||||
|
||||
mapping = session.exec(select(AppContainerMapping).where(AppContainerMapping.container_id == container_id)).first()
|
||||
if not mapping:
|
||||
mapping = AppContainerMapping(id=f'map-{container_id}', app_id=app_id, container_id=container_id, container_name=container_name)
|
||||
else:
|
||||
mapping.app_id = app_id
|
||||
mapping.container_name = container_name
|
||||
session.add(mapping)
|
||||
session.commit()
|
||||
safety_center_service.invalidate_cache()
|
||||
return {'success': True, 'message': 'mapping_updated', 'mappingId': mapping.id}
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from ..db.session import get_session
|
||||
from ..services.audit_logger import list_events
|
||||
|
||||
router = APIRouter(prefix='/api/audit')
|
||||
|
||||
|
||||
@router.get('')
|
||||
def list_audit(
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
session=Depends(get_session),
|
||||
):
|
||||
return list_events(session, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.post('/export')
|
||||
def export_audit(payload: dict):
|
||||
return {
|
||||
'success': True,
|
||||
'message': f"Exporting audit in {str(payload.get('format', 'json')).upper()} format",
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ..connectors.authentik import AuthentikConnector
|
||||
from .connector_runtime import load_connector_runtime
|
||||
|
||||
router = APIRouter(prefix='/api/apps/authentik', dependencies=[Depends(load_connector_runtime)])
|
||||
|
||||
|
||||
def _shape(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = payload.get('data')
|
||||
if not isinstance(data, dict):
|
||||
data = {key: value for key, value in payload.items() if key not in {'success', 'status', 'message'}}
|
||||
return {
|
||||
**payload,
|
||||
'success': bool(payload.get('success')),
|
||||
'status': str(payload.get('status', 'ok')),
|
||||
'message': str(payload.get('message', 'ok')),
|
||||
'data': data,
|
||||
}
|
||||
|
||||
|
||||
def _connector() -> AuthentikConnector:
|
||||
return AuthentikConnector()
|
||||
|
||||
|
||||
@router.get('/health')
|
||||
def health():
|
||||
return _shape(_connector().health())
|
||||
|
||||
|
||||
@router.get('/system')
|
||||
def system_status():
|
||||
return _shape(_connector().system_status())
|
||||
|
||||
|
||||
@router.get('/applications')
|
||||
def applications():
|
||||
return _shape(_connector().applications())
|
||||
|
||||
|
||||
@router.get('/providers')
|
||||
def providers():
|
||||
return _shape(_connector().providers())
|
||||
|
||||
|
||||
@router.get('/providers/proxy')
|
||||
def proxy_providers():
|
||||
return _shape(_connector().proxy_providers())
|
||||
|
||||
|
||||
@router.get('/providers/oauth2')
|
||||
def oauth2_providers():
|
||||
return _shape(_connector().oauth2_providers())
|
||||
|
||||
|
||||
@router.get('/outposts')
|
||||
def outposts():
|
||||
return _shape(_connector().outposts())
|
||||
|
||||
|
||||
@router.get('/outposts/health')
|
||||
def outpost_health():
|
||||
return _shape(_connector().outpost_health())
|
||||
|
||||
|
||||
@router.get('/flows')
|
||||
def flows_summary():
|
||||
return _shape(_connector().flows_summary())
|
||||
|
||||
|
||||
@router.get('/policies')
|
||||
def policies_summary():
|
||||
return _shape(_connector().policies_summary())
|
||||
|
||||
|
||||
@router.get('/events')
|
||||
def events(limit: int = 25):
|
||||
return _shape(_connector().events(limit))
|
||||
|
||||
|
||||
@router.get('/auth-coverage')
|
||||
def auth_coverage():
|
||||
return _shape(_connector().auth_coverage())
|
||||
|
||||
|
||||
@router.get('/security-warnings')
|
||||
def security_warnings():
|
||||
return _shape(_connector().security_warnings())
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session, select
|
||||
|
||||
# Kept imported here for compatibility with tests and callers that patch the
|
||||
# route module's Portainer connector class.
|
||||
from ..connectors.portainer import PortainerConnector
|
||||
from ..core.redaction import redact_message
|
||||
from ..db.models import Connector
|
||||
from ..db.session import get_session
|
||||
from ..services.connector_health import refresh_connector_statuses, test_and_persist_connector
|
||||
from ..services.inventory_discovery import (
|
||||
bind_connector_to_discovered,
|
||||
list_connector_bindings,
|
||||
list_connector_candidates,
|
||||
unbind_connector_from_app,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix='/api/connectors')
|
||||
|
||||
|
||||
class ConnectorBindRequest(BaseModel):
|
||||
discoveredContainerId: str
|
||||
|
||||
|
||||
class ConnectorUnbindRequest(BaseModel):
|
||||
appId: str
|
||||
|
||||
|
||||
def _serialize_connector(connector: Connector) -> dict:
|
||||
return {
|
||||
'id': connector.id,
|
||||
'name': connector.name,
|
||||
'status': connector.status,
|
||||
'lastTest': connector.last_test,
|
||||
'capabilities': connector.capabilities,
|
||||
'type': connector.type,
|
||||
'endpoint': None,
|
||||
'secretRef': connector.secret_ref,
|
||||
}
|
||||
|
||||
|
||||
def _required_env_vars(connector_id: str) -> list[str]:
|
||||
mapping = {
|
||||
'portainer': ['PORTAINER_URL', 'PORTAINER_API_KEY', 'PORTAINER_ENDPOINT_ID'],
|
||||
'sonarr': ['SONARR_URL', 'SONARR_API_KEY'],
|
||||
'radarr': ['RADARR_URL', 'RADARR_API_KEY'],
|
||||
'prowlarr': ['PROWLARR_URL', 'PROWLARR_API_KEY'],
|
||||
'deluge': ['DELUGE_URL', 'DELUGE_PASSWORD'],
|
||||
'plex': ['PLEX_URL', 'PLEX_TOKEN'],
|
||||
'tautulli': ['TAUTULLI_URL', 'TAUTULLI_API_KEY'],
|
||||
'nginx_proxy_manager': ['NPM_URL', 'NPM_IDENTITY', 'NPM_SECRET'],
|
||||
'authentik': ['AUTHENTIK_URL', 'AUTHENTIK_TOKEN'],
|
||||
'widefrog': ['WIDEFROG_URL'],
|
||||
'unraid': ['UNRAID_URL', 'UNRAID_API_KEY'],
|
||||
'home_assistant': ['HOME_ASSISTANT_URL', 'HOME_ASSISTANT_TOKEN'],
|
||||
}
|
||||
return mapping.get(connector_id, [])
|
||||
|
||||
|
||||
@router.get('')
|
||||
def list_connectors(refresh: bool = False, session: Session = Depends(get_session)):
|
||||
if refresh:
|
||||
refresh_connector_statuses(session)
|
||||
connectors = session.exec(select(Connector).order_by(Connector.id)).all()
|
||||
return [_serialize_connector(connector) for connector in connectors]
|
||||
|
||||
|
||||
@router.get('/{connector_id}')
|
||||
def connector_status(connector_id: str, session: Session = Depends(get_session)):
|
||||
connector = session.get(Connector, connector_id)
|
||||
if not connector:
|
||||
return {'success': False, 'message': 'connector_not_found', 'id': connector_id}
|
||||
return _serialize_connector(connector)
|
||||
|
||||
|
||||
@router.get('/{connector_id}/candidates')
|
||||
def connector_candidates(connector_id: str, session: Session = Depends(get_session)):
|
||||
connector = session.get(Connector, connector_id)
|
||||
if not connector:
|
||||
return {'success': False, 'message': 'connector_not_found', 'id': connector_id}
|
||||
return list_connector_candidates(session, connector_id)
|
||||
|
||||
|
||||
@router.get('/{connector_id}/bindings')
|
||||
def connector_bindings(connector_id: str, session: Session = Depends(get_session)):
|
||||
connector = session.get(Connector, connector_id)
|
||||
if not connector:
|
||||
return {'success': False, 'message': 'connector_not_found', 'id': connector_id}
|
||||
return list_connector_bindings(session, connector_id)
|
||||
|
||||
|
||||
@router.post('/{connector_id}/bind')
|
||||
def bind_connector(connector_id: str, payload: ConnectorBindRequest, session: Session = Depends(get_session)):
|
||||
connector = session.get(Connector, connector_id)
|
||||
if not connector:
|
||||
return {'success': False, 'message': 'connector_not_found', 'id': connector_id}
|
||||
return bind_connector_to_discovered(session, connector_id, payload.discoveredContainerId)
|
||||
|
||||
|
||||
@router.post('/{connector_id}/unbind')
|
||||
def unbind_connector(connector_id: str, payload: ConnectorUnbindRequest, session: Session = Depends(get_session)):
|
||||
connector = session.get(Connector, connector_id)
|
||||
if not connector:
|
||||
return {'success': False, 'message': 'connector_not_found', 'id': connector_id}
|
||||
return unbind_connector_from_app(session, connector_id, payload.appId)
|
||||
|
||||
|
||||
@router.post('/{connector_id}/test')
|
||||
def test_connector(connector_id: str, session: Session = Depends(get_session)):
|
||||
connector = session.get(Connector, connector_id)
|
||||
if not connector:
|
||||
return {'success': False, 'status': 'disconnected', 'message': f'Connector {connector_id} not found'}
|
||||
|
||||
result = test_and_persist_connector(session, connector_id)
|
||||
|
||||
status = str(result.get('status', 'disconnected'))
|
||||
reason = result.get('reason')
|
||||
success = status == 'connected'
|
||||
payload: dict[str, object] = {
|
||||
'success': success,
|
||||
'status': status,
|
||||
'id': connector_id,
|
||||
'message': redact_message(str(reason)) if reason else ('Connector test succeeded.' if success else 'Connector test failed.'),
|
||||
'missing_config': status == 'missing_config',
|
||||
'required_env_vars': _required_env_vars(connector_id),
|
||||
'secretRef': connector.secret_ref,
|
||||
}
|
||||
if reason:
|
||||
payload['reason'] = redact_message(str(reason))
|
||||
return payload
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..connectors.portainer import PortainerConnector
|
||||
from ..core.redaction import redact_message
|
||||
from ..db.session import get_session
|
||||
from ..services.setup_service import apply_stored_connector_settings
|
||||
|
||||
router = APIRouter(prefix='/api/containers')
|
||||
|
||||
|
||||
def _raise_connector_error(response: dict):
|
||||
status = str(response.get('status', 'error'))
|
||||
reason = redact_message(str(response.get('reason', 'connection_failed')))
|
||||
status_code = 503 if status == 'missing_config' else 502
|
||||
raise HTTPException(
|
||||
status_code=status_code,
|
||||
detail={
|
||||
'status': status,
|
||||
'reason': reason,
|
||||
'connector': 'portainer',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get('')
|
||||
def list_containers(
|
||||
include_stats: bool = Query(False),
|
||||
stats_limit: int = Query(200, ge=1, le=500),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
apply_stored_connector_settings(session)
|
||||
response = PortainerConnector().list_containers(include_stats=include_stats, stats_limit=stats_limit)
|
||||
if response.get('status') == 'ok':
|
||||
return response.get('containers', [])
|
||||
|
||||
_raise_connector_error(response)
|
||||
|
||||
|
||||
@router.get('/{container_name}')
|
||||
def get_container(container_name: str, session: Session = Depends(get_session)):
|
||||
apply_stored_connector_settings(session)
|
||||
response = PortainerConnector().get_container_status(container_name)
|
||||
status = response.get('status')
|
||||
if status == 'ok':
|
||||
return response.get('container')
|
||||
|
||||
if status == 'not_found':
|
||||
return None
|
||||
|
||||
_raise_connector_error(response)
|
||||
|
||||
|
||||
@router.get('/{container_name}/logs')
|
||||
def get_container_logs(container_name: str, session: Session = Depends(get_session)):
|
||||
apply_stored_connector_settings(session)
|
||||
response = PortainerConnector().get_container_logs(container_name)
|
||||
return {
|
||||
'container': response.get('container', container_name),
|
||||
'status': response.get('status', 'unknown'),
|
||||
'reason': redact_message(str(response.get('reason', ''))) if response.get('reason') else None,
|
||||
'logs': [
|
||||
redact_message(str(line))
|
||||
for line in response.get('logs', [])
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..connectors.deluge import DelugeConnector
|
||||
from ..db.session import get_session
|
||||
from ..services.approval_service import create_approval
|
||||
from ..services.audit_logger import log_event
|
||||
from ..services.job_runner import complete_job, create_job, set_job_progress, start_job
|
||||
from ..services.policy_engine import evaluate_policy
|
||||
from .connector_runtime import load_connector_runtime
|
||||
|
||||
router = APIRouter(prefix='/api/apps/deluge', dependencies=[Depends(load_connector_runtime)])
|
||||
|
||||
|
||||
_PAUSE_STEPS = [
|
||||
'Validate torrent id',
|
||||
'Evaluate policy',
|
||||
'Check Deluge health/session',
|
||||
'Pause torrent',
|
||||
'Fetch torrent status',
|
||||
'Complete audit/job',
|
||||
]
|
||||
|
||||
_RESUME_STEPS = [
|
||||
'Validate torrent id',
|
||||
'Evaluate policy',
|
||||
'Check Deluge health/session',
|
||||
'Resume torrent',
|
||||
'Fetch torrent status',
|
||||
'Complete audit/job',
|
||||
]
|
||||
|
||||
_BULK_STEPS = [
|
||||
'Validate action scope',
|
||||
'Evaluate policy',
|
||||
'Require operator approval',
|
||||
'Call Deluge bulk operation',
|
||||
'Complete audit/job',
|
||||
]
|
||||
|
||||
|
||||
@router.get('/health')
|
||||
def health():
|
||||
return DelugeConnector().health()
|
||||
|
||||
|
||||
@router.get('/session')
|
||||
def session_status():
|
||||
return DelugeConnector().session_status()
|
||||
|
||||
|
||||
@router.get('/torrents')
|
||||
def torrents():
|
||||
return DelugeConnector().torrent_status()
|
||||
|
||||
|
||||
@router.get('/active')
|
||||
def active():
|
||||
return DelugeConnector().active_downloads()
|
||||
|
||||
|
||||
@router.get('/stalled')
|
||||
def stalled():
|
||||
return DelugeConnector().stalled_downloads()
|
||||
|
||||
|
||||
@router.get('/paused')
|
||||
def paused():
|
||||
return DelugeConnector().paused_downloads()
|
||||
|
||||
|
||||
@router.get('/speeds')
|
||||
def speeds():
|
||||
return DelugeConnector().speed_status()
|
||||
|
||||
|
||||
@router.post('/torrents/{torrent_id}/pause')
|
||||
def pause_torrent(torrent_id: str, payload: dict | None = None, session: Session = Depends(get_session)):
|
||||
return _execute_deluge_action(
|
||||
session=session,
|
||||
payload=payload or {},
|
||||
action='deluge.pause_torrent',
|
||||
steps=_PAUSE_STEPS,
|
||||
connector_call=lambda connector: connector.pause_torrent(torrent_id),
|
||||
default_success_message='torrent_paused',
|
||||
metadata={'torrentId': torrent_id},
|
||||
)
|
||||
|
||||
|
||||
@router.post('/torrents/{torrent_id}/resume')
|
||||
def resume_torrent(torrent_id: str, payload: dict | None = None, session: Session = Depends(get_session)):
|
||||
return _execute_deluge_action(
|
||||
session=session,
|
||||
payload=payload or {},
|
||||
action='deluge.resume_torrent',
|
||||
steps=_RESUME_STEPS,
|
||||
connector_call=lambda connector: connector.resume_torrent(torrent_id),
|
||||
default_success_message='torrent_resumed',
|
||||
metadata={'torrentId': torrent_id},
|
||||
)
|
||||
|
||||
|
||||
@router.post('/pause-all')
|
||||
def pause_all(payload: dict | None = None, session: Session = Depends(get_session)):
|
||||
return _execute_deluge_action(
|
||||
session=session,
|
||||
payload=payload or {},
|
||||
action='deluge.pause_all',
|
||||
steps=_BULK_STEPS,
|
||||
connector_call=lambda connector: connector.pause_all(),
|
||||
default_success_message='all_torrents_paused',
|
||||
metadata={'scope': 'all'},
|
||||
)
|
||||
|
||||
|
||||
@router.post('/resume-all')
|
||||
def resume_all(payload: dict | None = None, session: Session = Depends(get_session)):
|
||||
return _execute_deluge_action(
|
||||
session=session,
|
||||
payload=payload or {},
|
||||
action='deluge.resume_all',
|
||||
steps=_BULK_STEPS,
|
||||
connector_call=lambda connector: connector.resume_all(),
|
||||
default_success_message='all_torrents_resumed',
|
||||
metadata={'scope': 'all'},
|
||||
)
|
||||
|
||||
|
||||
def _execute_deluge_action(
|
||||
*,
|
||||
session: Session,
|
||||
payload: dict[str, Any],
|
||||
action: str,
|
||||
steps: list[str],
|
||||
connector_call: Callable[[DelugeConnector], dict[str, Any]],
|
||||
default_success_message: str,
|
||||
metadata: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
target = 'deluge'
|
||||
source = str(payload.get('source', 'manual')).strip() or 'manual'
|
||||
policy = evaluate_policy(session=session, action=action, target=target, source=source)
|
||||
risk = str(policy.get('risk', 'medium'))
|
||||
|
||||
if policy['decision'] == 'blocked':
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk,
|
||||
policy_decision='blocked',
|
||||
reason=policy['reason'],
|
||||
result='blocked',
|
||||
execution_plan={'plan': steps, 'policyReason': policy['reason']},
|
||||
redacted_metadata=metadata,
|
||||
)
|
||||
return {'success': False, 'decision': 'blocked', 'status': 'failed', 'policy_decision': 'blocked', 'result': 'blocked', 'message': policy['reason'], 'auditId': audit.id}
|
||||
|
||||
if policy['decision'] == 'approval_required':
|
||||
approval = create_approval(session, action=action, target=target, source=source, risk=risk, reason=policy['reason'], requester=source)
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk,
|
||||
policy_decision='approval_required',
|
||||
reason=policy['reason'],
|
||||
result='approval_requested',
|
||||
execution_plan={'plan': steps, 'approvalId': approval.id},
|
||||
redacted_metadata={**metadata, 'approval_id': approval.id},
|
||||
)
|
||||
return {'success': False, 'decision': 'approval_required', 'status': 'failed', 'policy_decision': 'approval_required', 'result': 'approval_required', 'message': 'Action requires approval before execution.', 'approvalId': approval.id, 'auditId': audit.id}
|
||||
|
||||
job = create_job(session, action_id=action, target=target, source=source, risk=risk, policy_decision='allowed', steps=steps)
|
||||
start_job(session, job)
|
||||
set_job_progress(session, job, 35, 'Policy allowed; calling Deluge connector.')
|
||||
|
||||
connector_result = connector_call(DelugeConnector())
|
||||
success = bool(connector_result.get('success'))
|
||||
message = str(connector_result.get('message', default_success_message if success else 'deluge_failed'))
|
||||
completed = complete_job(session, job, status='succeeded' if success else 'failed', result_summary=message)
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk,
|
||||
policy_decision='allowed',
|
||||
reason=policy['reason'],
|
||||
result='success' if success else 'failed',
|
||||
execution_plan={'plan': steps, 'jobId': completed.id},
|
||||
after_state={'status': connector_result.get('status'), 'message': message},
|
||||
redacted_metadata={**metadata, 'connector': 'deluge'},
|
||||
)
|
||||
|
||||
if not success:
|
||||
return {'success': False, 'decision': 'failed', 'status': 'failed', 'policy_decision': 'allowed', 'result': 'failed', 'message': message, 'jobId': completed.id, 'auditId': audit.id, 'data': connector_result.get('data')}
|
||||
|
||||
return {'success': True, 'decision': 'allowed', 'status': 'success', 'policy_decision': 'allowed', 'result': 'success', 'message': message, 'jobId': completed.id, 'auditId': audit.id, 'data': connector_result.get('data', {})}
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Operator API for Deployment Control Plane V1.
|
||||
|
||||
All mutations accept a typed plan and an idempotency key. There is no endpoint
|
||||
for command execution, Compose upload, arbitrary host paths or raw secrets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..core.config import settings
|
||||
from ..core.security import require_operator_request
|
||||
from ..db.session import get_session
|
||||
from ..schemas.deployment import DeploymentPlanV1
|
||||
from ..services.deployment_control_plane import (
|
||||
DeploymentControlError,
|
||||
DeploymentControlPlane,
|
||||
)
|
||||
from ..services.deployment_broker import DeploymentBrokerClient
|
||||
|
||||
|
||||
def _require_deployment_operator(request: Request) -> str:
|
||||
if not settings.operator_auth_required:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Deployment Control Plane requires the configured operator authentication boundary.",
|
||||
)
|
||||
return require_operator_request(request)
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/deployments",
|
||||
tags=["deployments"],
|
||||
dependencies=[Depends(_require_deployment_operator)],
|
||||
)
|
||||
|
||||
|
||||
class DeploymentRequestV1(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
plan: DeploymentPlanV1
|
||||
operation: Literal["preflight", "apply", "rollback"] = "preflight"
|
||||
|
||||
|
||||
class WorkMeshPromotionPreflightRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
revision: str = Field(pattern=r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
DeploymentSession = Annotated[Session, Depends(get_session)]
|
||||
DeploymentIdempotencyKey = Annotated[str | None, Header(alias="Idempotency-Key")]
|
||||
DeploymentLimit = Annotated[int, Query(ge=1, le=250)]
|
||||
|
||||
|
||||
def _idempotency_header(value: str | None) -> str:
|
||||
if not value:
|
||||
raise HTTPException(status_code=400, detail="Idempotency-Key is required for deployment operations.")
|
||||
return value.strip()
|
||||
|
||||
|
||||
@router.post("")
|
||||
def request_deployment(
|
||||
payload: DeploymentRequestV1,
|
||||
session: DeploymentSession,
|
||||
idempotency_key: DeploymentIdempotencyKey = None,
|
||||
):
|
||||
try:
|
||||
return DeploymentControlPlane().execute(
|
||||
session,
|
||||
plan=payload.plan,
|
||||
idempotency_key=_idempotency_header(idempotency_key),
|
||||
operation=payload.operation,
|
||||
)
|
||||
except DeploymentControlError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/workmesh/promotions/preflight")
|
||||
def preflight_workmesh_promotion(
|
||||
payload: WorkMeshPromotionPreflightRequest,
|
||||
session: DeploymentSession,
|
||||
idempotency_key: DeploymentIdempotencyKey = None,
|
||||
):
|
||||
from ..services.workmesh_promotion import (
|
||||
WorkMeshPromotionError,
|
||||
WorkMeshPromotionService,
|
||||
)
|
||||
|
||||
try:
|
||||
return WorkMeshPromotionService().preflight(
|
||||
session,
|
||||
revision=payload.revision,
|
||||
idempotency_key=_idempotency_header(idempotency_key),
|
||||
)
|
||||
except WorkMeshPromotionError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/workmesh/promotions/{promotion_id}")
|
||||
def get_workmesh_promotion(promotion_id: str, session: DeploymentSession):
|
||||
from ..services.workmesh_promotion import WorkMeshPromotionService
|
||||
|
||||
result = WorkMeshPromotionService().refresh(session, promotion_id)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="WorkMesh promotion not found.")
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/workmesh/promotions")
|
||||
def list_workmesh_promotions(
|
||||
session: DeploymentSession,
|
||||
limit: DeploymentLimit = 20,
|
||||
):
|
||||
from ..services.workmesh_promotion import WorkMeshPromotionService
|
||||
|
||||
return {"items": WorkMeshPromotionService.list_promotions(session, limit=limit)}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_deployments(session: DeploymentSession, limit: DeploymentLimit = 100):
|
||||
return {"items": DeploymentControlPlane().list_jobs(session, limit=limit)}
|
||||
|
||||
|
||||
@router.get("/delivery-status")
|
||||
def get_delivery_status():
|
||||
"""Expose the controller's bounded, secret-free runnerless delivery view."""
|
||||
return DeploymentBrokerClient().delivery_status()
|
||||
|
||||
|
||||
@router.get("/{job_id}")
|
||||
def get_deployment(job_id: str, session: DeploymentSession):
|
||||
try:
|
||||
return DeploymentControlPlane().refresh(session, job_id)
|
||||
except DeploymentControlError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Operator API for the bounded Enrollment & Provisioning Plane v1."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..core.config import settings
|
||||
from ..core.security import require_operator_request
|
||||
from ..db.session import get_session
|
||||
from ..schemas.enrollment import EnrollmentPlanV1, EnrollmentTrustConfigV1
|
||||
from ..services.enrollment_plane import EnrollmentError, EnrollmentPlane
|
||||
from ..services.repopulse_adoption_journal import (
|
||||
RepoPulseAdoptionJournalError,
|
||||
get_repopulse_adoption_status,
|
||||
)
|
||||
|
||||
|
||||
def _require_operator(request: Request) -> str:
|
||||
if not settings.operator_auth_required:
|
||||
raise HTTPException(status_code=503, detail="Enrollment requires the configured operator authentication boundary.")
|
||||
return require_operator_request(request)
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/enrollments",
|
||||
tags=["enrollments"],
|
||||
dependencies=[Depends(_require_operator)],
|
||||
)
|
||||
|
||||
|
||||
class EnrollmentRequestV1(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
plan: EnrollmentPlanV1
|
||||
operation: Literal["preflight", "enroll"] = "preflight"
|
||||
|
||||
|
||||
EnrollmentSession = Annotated[Session, Depends(get_session)]
|
||||
EnrollmentIdempotencyKey = Annotated[str | None, Header(alias="Idempotency-Key")]
|
||||
EnrollmentLimit = Annotated[int, Query(ge=1, le=250)]
|
||||
|
||||
|
||||
def _idempotency(value: str | None) -> str:
|
||||
if not value:
|
||||
raise HTTPException(status_code=400, detail="Idempotency-Key is required for enrollment mutations.")
|
||||
return value.strip()
|
||||
|
||||
|
||||
@router.get("/capabilities")
|
||||
def enrollment_capabilities(session: EnrollmentSession):
|
||||
return EnrollmentPlane().capabilities(session)
|
||||
|
||||
|
||||
@router.get("/trust")
|
||||
def enrollment_trust_status(session: EnrollmentSession):
|
||||
return EnrollmentPlane.trust_status(session)
|
||||
|
||||
|
||||
@router.put("/trust")
|
||||
def update_enrollment_trust(payload: EnrollmentTrustConfigV1, session: EnrollmentSession):
|
||||
return EnrollmentPlane.set_trust_config(session, payload)
|
||||
|
||||
|
||||
@router.post("")
|
||||
def request_enrollment(
|
||||
payload: EnrollmentRequestV1,
|
||||
session: EnrollmentSession,
|
||||
idempotency_key: EnrollmentIdempotencyKey = None,
|
||||
):
|
||||
plane = EnrollmentPlane()
|
||||
try:
|
||||
if payload.operation == "preflight":
|
||||
return plane.preflight(session, payload.plan)
|
||||
return plane.enroll(session, payload.plan, idempotency_key=_idempotency(idempotency_key))
|
||||
except EnrollmentError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_enrollments(session: EnrollmentSession, limit: EnrollmentLimit = 100):
|
||||
return {"items": EnrollmentPlane().list_enrollments(session, limit=limit)}
|
||||
|
||||
|
||||
@router.get("/repopulse/adoption")
|
||||
def repopulse_adoption_status(session: EnrollmentSession):
|
||||
"""Read the sole fixed migration journal through the operator boundary."""
|
||||
try:
|
||||
return get_repopulse_adoption_status(session)
|
||||
except RepoPulseAdoptionJournalError as exc:
|
||||
raise HTTPException(status_code=503, detail=exc.code) from None
|
||||
|
||||
|
||||
@router.get("/{enrollment_id}")
|
||||
def get_enrollment(enrollment_id: str, session: EnrollmentSession):
|
||||
try:
|
||||
return EnrollmentPlane().get_enrollment(session, enrollment_id)
|
||||
except EnrollmentError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
@@ -0,0 +1,37 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..connectors.gitea_actions import GiteaActionsConnector
|
||||
from ..db.session import get_session
|
||||
from ..services.gitea_project_auto_registration import GiteaProjectAutoRegistration
|
||||
from ..services.runner_fleet import RunnerFleetService
|
||||
|
||||
router = APIRouter(prefix="/api/gitea/actions", tags=["gitea-actions"])
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def gitea_actions_status():
|
||||
"""Return bounded runner and queue state without exposing the admin credential."""
|
||||
|
||||
return GiteaActionsConnector().snapshot()
|
||||
|
||||
|
||||
@router.get("/fleet")
|
||||
def gitea_actions_fleet():
|
||||
"""Return the joined, secret-free CI runner fleet projection."""
|
||||
|
||||
return RunnerFleetService().snapshot()
|
||||
|
||||
|
||||
@router.get("/projects")
|
||||
def gitea_project_catalog(session: Session = Depends(get_session)):
|
||||
"""Return the persisted owned-project registration catalog."""
|
||||
|
||||
return GiteaProjectAutoRegistration.catalog(session)
|
||||
|
||||
|
||||
@router.post("/projects/reconcile")
|
||||
def reconcile_gitea_project_catalog(session: Session = Depends(get_session)):
|
||||
"""Refresh all owned Gitea projects and safely activate proven deploy targets."""
|
||||
|
||||
return GiteaProjectAutoRegistration().sync(session, force=True)
|
||||
@@ -0,0 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get('/api/health')
|
||||
def health():
|
||||
return {'status': 'ok'}
|
||||
@@ -0,0 +1,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..core.config import settings
|
||||
from ..core.security import n8n_auth, operator_auth
|
||||
from ..db.session import get_session
|
||||
from ..mcp_server import runtime_status as mcp_runtime_status
|
||||
from ..services import knowledge_service, n8n_integration
|
||||
|
||||
router = APIRouter(prefix="/api/integrations")
|
||||
n8n_router = APIRouter(prefix="/api/integrations/n8n", dependencies=[Depends(n8n_auth)])
|
||||
|
||||
|
||||
class PreflightRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
action: str = Field(min_length=1, max_length=160)
|
||||
target: str = Field(min_length=1, max_length=240)
|
||||
|
||||
|
||||
class ApprovalRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
action: str = Field(min_length=1, max_length=160)
|
||||
target: str = Field(min_length=1, max_length=240)
|
||||
reason: str = Field(min_length=3, max_length=1000)
|
||||
plan: list[str] = Field(default_factory=list, max_length=30)
|
||||
|
||||
|
||||
class KnowledgeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
question: str = Field(min_length=2, max_length=500)
|
||||
limit: int = Field(default=6, ge=1, le=10)
|
||||
|
||||
|
||||
class HeartbeatRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
workflowId: str = Field(min_length=1, max_length=120)
|
||||
workflowVersion: str = Field(default="1", max_length=40)
|
||||
branch: str = Field(default="unknown", max_length=40)
|
||||
processedEvents: int = Field(default=0, ge=0, le=100_000)
|
||||
backlogRemaining: bool = False
|
||||
confidence: str = Field(default="", max_length=20)
|
||||
|
||||
|
||||
@router.get("/status", dependencies=[Depends(operator_auth)])
|
||||
def status(session: Session = Depends(get_session)):
|
||||
rag = knowledge_service.index_status(session)
|
||||
mcp_runtime = mcp_runtime_status()
|
||||
n8n_runtime = n8n_integration.heartbeat_status(session)
|
||||
return {
|
||||
"mcp": {
|
||||
"enabled": settings.mcp_enabled,
|
||||
"authenticated": bool(settings.mcp_token),
|
||||
"transport": "streamable_http",
|
||||
"endpoint": "/mcp",
|
||||
"scope": "governed_operations",
|
||||
"canRequestApproval": True,
|
||||
"canExecuteApproved": True,
|
||||
"requiresHumanApproval": True,
|
||||
"maxConcurrentExecutions": 1,
|
||||
**mcp_runtime,
|
||||
},
|
||||
"rag": rag,
|
||||
"n8n": {
|
||||
"enabled": settings.n8n_enabled,
|
||||
"authenticated": bool(settings.n8n_token),
|
||||
"endpoint": "/api/integrations/n8n",
|
||||
"canExecute": False,
|
||||
"capabilities": [
|
||||
"event_feed",
|
||||
"knowledge_query",
|
||||
"preflight",
|
||||
"create_approval",
|
||||
"read_approval",
|
||||
"read_job",
|
||||
],
|
||||
**n8n_runtime,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@n8n_router.post("/heartbeat")
|
||||
def heartbeat(payload: HeartbeatRequest, session: Session = Depends(get_session)):
|
||||
return n8n_integration.record_heartbeat(session, payload.model_dump())
|
||||
|
||||
|
||||
@n8n_router.get("/events")
|
||||
def events(
|
||||
cursor: str | None = Query(default=None, max_length=600),
|
||||
limit: int = Query(default=100, ge=1, le=250),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
return n8n_integration.event_feed(session, cursor=cursor, limit=limit)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@n8n_router.post("/knowledge/query")
|
||||
def knowledge_query(payload: KnowledgeRequest, session: Session = Depends(get_session)):
|
||||
try:
|
||||
return knowledge_service.query(session, payload.question, limit=payload.limit)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@n8n_router.post("/preflight")
|
||||
def preflight(payload: PreflightRequest, session: Session = Depends(get_session)):
|
||||
return n8n_integration.preflight(session, payload.action, payload.target)
|
||||
|
||||
|
||||
@n8n_router.post("/approvals")
|
||||
def create_approval(
|
||||
payload: ApprovalRequest,
|
||||
idempotency_key: str = Header(
|
||||
alias="Idempotency-Key", min_length=16, max_length=128
|
||||
),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
return n8n_integration.request_approval(
|
||||
session,
|
||||
action=payload.action,
|
||||
target=payload.target,
|
||||
reason=payload.reason,
|
||||
plan=payload.plan,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@n8n_router.get("/approvals/{approval_id}")
|
||||
def approval(approval_id: str, session: Session = Depends(get_session)):
|
||||
result = n8n_integration.approval_status(session, approval_id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="approval_not_found")
|
||||
return result
|
||||
|
||||
|
||||
@n8n_router.get("/jobs/{job_id}")
|
||||
def job(job_id: str, session: Session = Depends(get_session)):
|
||||
result = n8n_integration.job_status(session, job_id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="job_not_found")
|
||||
return result
|
||||
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..db.session import get_session
|
||||
from ..services.inventory_discovery import (
|
||||
calculate_drift,
|
||||
acknowledge_drift,
|
||||
complete_app_review,
|
||||
create_drift_incident,
|
||||
get_run_detail,
|
||||
ignore_discovered_container,
|
||||
import_discovered_container,
|
||||
list_discovered,
|
||||
list_inventory_apps,
|
||||
list_inventory_containers,
|
||||
list_mappings,
|
||||
list_runs,
|
||||
list_suggestions,
|
||||
list_unknown,
|
||||
list_unmapped,
|
||||
map_discovered_to_existing_app,
|
||||
prune_historical_discovery_rows,
|
||||
run_discovery,
|
||||
sync_app_connector_statuses,
|
||||
update_discovered_review_metadata,
|
||||
update_inventory_baseline,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix='/api/inventory')
|
||||
|
||||
|
||||
@router.post('/discover')
|
||||
def discover(session: Session = Depends(get_session)):
|
||||
return run_discovery(session=session)
|
||||
|
||||
|
||||
@router.get('/runs')
|
||||
def runs(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
return list_runs(session, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get('/runs/{run_id}')
|
||||
def run_detail(run_id: str, session: Session = Depends(get_session)):
|
||||
result = get_run_detail(session, run_id)
|
||||
if not result:
|
||||
return {'success': False, 'message': 'discovery_run_not_found', 'id': run_id}
|
||||
return result
|
||||
|
||||
|
||||
@router.get('/discovered-containers')
|
||||
def discovered_containers(session: Session = Depends(get_session)):
|
||||
return list_discovered(session)
|
||||
|
||||
|
||||
@router.get('/containers')
|
||||
def inventory_containers(include_ignored: bool = True, session: Session = Depends(get_session)):
|
||||
return list_inventory_containers(session, include_ignored=include_ignored)
|
||||
|
||||
|
||||
@router.get('/apps')
|
||||
def inventory_apps(include_unmanaged: bool = True, include_ignored: bool = False, session: Session = Depends(get_session)):
|
||||
return list_inventory_apps(session, include_unmanaged=include_unmanaged, include_ignored=include_ignored)
|
||||
|
||||
|
||||
@router.get('/suggestions')
|
||||
def suggestions(session: Session = Depends(get_session)):
|
||||
return list_suggestions(session)
|
||||
|
||||
|
||||
@router.get('/unmapped')
|
||||
def unmapped(include_ignored: bool = False, session: Session = Depends(get_session)):
|
||||
return list_unmapped(session, include_ignored=include_ignored)
|
||||
|
||||
|
||||
@router.get('/unknown')
|
||||
def unknown(include_ignored: bool = False, session: Session = Depends(get_session)):
|
||||
return list_unknown(session, include_ignored=include_ignored)
|
||||
|
||||
|
||||
@router.get('/mappings')
|
||||
def mappings(session: Session = Depends(get_session)):
|
||||
return list_mappings(session)
|
||||
|
||||
|
||||
@router.get('/drift')
|
||||
def drift(session: Session = Depends(get_session)):
|
||||
return calculate_drift(session)
|
||||
|
||||
|
||||
@router.post('/drift/{drift_id}/acknowledge')
|
||||
def acknowledge_inventory_drift(drift_id: str, session: Session = Depends(get_session)):
|
||||
return acknowledge_drift(session, drift_id)
|
||||
|
||||
|
||||
@router.post('/drift/{drift_id}/incident')
|
||||
def create_inventory_drift_incident(drift_id: str, session: Session = Depends(get_session)):
|
||||
return create_drift_incident(session, drift_id)
|
||||
|
||||
|
||||
@router.post('/import')
|
||||
def import_container(payload: dict, session: Session = Depends(get_session)):
|
||||
discovered_id = str(payload.get('discoveredContainerId') or payload.get('id') or '')
|
||||
return import_discovered_container(session, discovered_id)
|
||||
|
||||
|
||||
@router.post('/ignore')
|
||||
def ignore_container(payload: dict, session: Session = Depends(get_session)):
|
||||
discovered_id = str(payload.get('discoveredContainerId') or payload.get('id') or '')
|
||||
return ignore_discovered_container(session, discovered_id)
|
||||
|
||||
|
||||
@router.post('/review/update')
|
||||
def update_review_metadata(payload: dict, session: Session = Depends(get_session)):
|
||||
discovered_id = str(payload.get('discoveredContainerId') or payload.get('id') or '')
|
||||
return update_discovered_review_metadata(session, discovered_id, payload)
|
||||
|
||||
|
||||
@router.post('/map-existing')
|
||||
def map_existing(payload: dict, session: Session = Depends(get_session)):
|
||||
discovered_id = str(payload.get('discoveredContainerId') or payload.get('id') or '')
|
||||
app_id = str(payload.get('appId') or '')
|
||||
return map_discovered_to_existing_app(session, discovered_id, app_id)
|
||||
|
||||
|
||||
@router.post('/review/complete')
|
||||
def complete_review(payload: dict | None = None, session: Session = Depends(get_session)):
|
||||
app_ids = payload.get('appIds') if isinstance(payload, dict) else None
|
||||
if not isinstance(app_ids, list):
|
||||
app_ids = None
|
||||
return complete_app_review(session, app_ids=[str(item) for item in app_ids] if app_ids else None)
|
||||
|
||||
|
||||
@router.post('/maintenance/sync-status')
|
||||
def sync_status(session: Session = Depends(get_session)):
|
||||
return sync_app_connector_statuses(session)
|
||||
|
||||
|
||||
@router.post('/maintenance/prune-discovery-history')
|
||||
def prune_discovery_history(session: Session = Depends(get_session)):
|
||||
return prune_historical_discovery_rows(session)
|
||||
|
||||
|
||||
@router.post('/baseline/update')
|
||||
def update_baseline(session: Session = Depends(get_session)):
|
||||
return update_inventory_baseline(session)
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ..db.session import get_session
|
||||
from ..services.audit_logger import log_event
|
||||
from ..services.job_runner import cancel_job, get_job, list_jobs
|
||||
|
||||
router = APIRouter(prefix='/api/jobs')
|
||||
|
||||
|
||||
@router.get('')
|
||||
def list_all(session=Depends(get_session)):
|
||||
return list_jobs(session)
|
||||
|
||||
|
||||
@router.get('/{job_id}')
|
||||
def get_single(job_id: str, session=Depends(get_session)):
|
||||
job = get_job(session, job_id)
|
||||
if not job:
|
||||
return {'success': False, 'message': f'Job {job_id} not found'}
|
||||
return job
|
||||
|
||||
|
||||
@router.post('/{job_id}/cancel')
|
||||
def cancel(job_id: str, session=Depends(get_session)):
|
||||
job = get_job(session, job_id)
|
||||
if not job:
|
||||
return {'success': False, 'message': 'Job not found', 'id': job_id}
|
||||
|
||||
from ..services.privatemuse_deployment_recovery import (
|
||||
privatemuse_recovery_job_cancellation_reason,
|
||||
)
|
||||
|
||||
cancellation_reason = privatemuse_recovery_job_cancellation_reason(session, job)
|
||||
if not cancellation_reason:
|
||||
from ..services.workmesh_first_deployment import (
|
||||
workmesh_first_deploy_job_cancellation_reason,
|
||||
)
|
||||
|
||||
cancellation_reason = workmesh_first_deploy_job_cancellation_reason(session, job)
|
||||
if cancellation_reason:
|
||||
return {
|
||||
'success': False,
|
||||
'message': cancellation_reason,
|
||||
'id': job.id,
|
||||
'status': job.status,
|
||||
}
|
||||
|
||||
if job.status in {'succeeded', 'failed', 'cancelled'}:
|
||||
return {'success': False, 'message': f'Job already {job.status}', 'id': job.id}
|
||||
|
||||
previous_status = job.status
|
||||
updated = cancel_job(session, job)
|
||||
audit = log_event(
|
||||
session,
|
||||
source='manual',
|
||||
identity='operator',
|
||||
action_id='job.cancel',
|
||||
target=updated.id,
|
||||
target_type='job',
|
||||
risk='low',
|
||||
policy_decision='allowed',
|
||||
reason='Operator cancelled an AppOps job record.',
|
||||
result='success',
|
||||
before_state={'status': previous_status},
|
||||
after_state={'status': updated.status},
|
||||
redacted_metadata={'jobId': updated.id, 'actionId': updated.action_id},
|
||||
)
|
||||
return {'success': True, 'id': updated.id, 'status': updated.status, 'auditId': audit.id}
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..core.security import operator_auth
|
||||
from ..db.session import get_session
|
||||
from ..services import knowledge_service
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/ai/rag", dependencies=[Depends(operator_auth)])
|
||||
|
||||
|
||||
class RagQueryRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
question: str = Field(min_length=2, max_length=500)
|
||||
limit: int = Field(default=6, ge=1, le=10)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def status(session: Session = Depends(get_session)):
|
||||
return knowledge_service.index_status(session)
|
||||
|
||||
|
||||
@router.post("/reindex")
|
||||
def reindex(session: Session = Depends(get_session)):
|
||||
return knowledge_service.rebuild_index(session)
|
||||
|
||||
|
||||
@router.post("/query")
|
||||
def query(payload: RagQueryRequest, session: Session = Depends(get_session)):
|
||||
try:
|
||||
return knowledge_service.query(session, payload.question, limit=payload.limit)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/evaluate")
|
||||
def evaluate(session: Session = Depends(get_session)):
|
||||
return knowledge_service.evaluate_retrieval(session)
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..db.session import get_session
|
||||
from ..services import media_pipeline_service
|
||||
from .connector_runtime import load_connector_runtime
|
||||
|
||||
router = APIRouter(prefix='/api/media-pipeline', dependencies=[Depends(load_connector_runtime)])
|
||||
|
||||
|
||||
@router.get('/summary')
|
||||
def summary(refresh: bool = False, session: Session = Depends(get_session)):
|
||||
return media_pipeline_service.summary(session, force_refresh=refresh)
|
||||
|
||||
|
||||
@router.get('/health')
|
||||
def health(refresh: bool = False, session: Session = Depends(get_session)):
|
||||
return media_pipeline_service.summary(session, force_refresh=refresh)
|
||||
|
||||
|
||||
@router.get('/flow')
|
||||
def flow(session: Session = Depends(get_session)):
|
||||
return media_pipeline_service.flow(session)
|
||||
|
||||
|
||||
@router.get('/issues')
|
||||
def issues(session: Session = Depends(get_session)):
|
||||
return media_pipeline_service.issues(session)
|
||||
|
||||
|
||||
@router.get('/downloads')
|
||||
def downloads(session: Session = Depends(get_session)):
|
||||
return media_pipeline_service.downloads(session)
|
||||
|
||||
|
||||
@router.get('/library')
|
||||
def library(session: Session = Depends(get_session)):
|
||||
return media_pipeline_service.library(session)
|
||||
|
||||
|
||||
@router.get('/activity')
|
||||
def activity():
|
||||
return media_pipeline_service.activity()
|
||||
|
||||
|
||||
@router.get('/recommendations')
|
||||
def recommendations(session: Session = Depends(get_session)):
|
||||
return media_pipeline_service.recommendations(session)
|
||||
|
||||
|
||||
@router.post('/check')
|
||||
def check(session: Session = Depends(get_session)):
|
||||
return media_pipeline_service.check(session)
|
||||
|
||||
|
||||
@router.post('/diagnose')
|
||||
def diagnose(payload: dict | None = None, session: Session = Depends(get_session)):
|
||||
return media_pipeline_service.diagnose(session, payload or {})
|
||||
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
from .connector_runtime import load_connector_runtime
|
||||
|
||||
router = APIRouter(prefix='/api/apps/nginx-proxy-manager', dependencies=[Depends(load_connector_runtime)])
|
||||
alias_router = APIRouter(prefix='/api/apps/npm', dependencies=[Depends(load_connector_runtime)])
|
||||
|
||||
|
||||
def _shape(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = payload.get('data')
|
||||
if not isinstance(data, dict):
|
||||
data = {key: value for key, value in payload.items() if key not in {'success', 'status', 'message'}}
|
||||
return {
|
||||
**payload,
|
||||
'success': bool(payload.get('success')),
|
||||
'status': str(payload.get('status', 'ok')),
|
||||
'message': str(payload.get('message', 'ok')),
|
||||
'data': data,
|
||||
}
|
||||
|
||||
|
||||
def _connector() -> NginxProxyManagerConnector:
|
||||
return NginxProxyManagerConnector()
|
||||
|
||||
|
||||
@router.get('/health')
|
||||
def health():
|
||||
return _shape(_connector().health())
|
||||
|
||||
|
||||
@router.get('/proxy-hosts')
|
||||
def proxy_hosts():
|
||||
return _shape(_connector().proxy_hosts())
|
||||
|
||||
|
||||
@router.get('/certificates')
|
||||
def certificates():
|
||||
return _shape(_connector().certificates())
|
||||
|
||||
|
||||
@router.get('/certificates/expiring')
|
||||
def expiring_certificates(days: int = 30):
|
||||
return _shape(_connector().expiring_certificates(days))
|
||||
|
||||
|
||||
@router.get('/access-lists')
|
||||
def access_lists():
|
||||
return _shape(_connector().access_lists())
|
||||
|
||||
|
||||
@router.get('/exposure-summary')
|
||||
def exposure_summary():
|
||||
return _shape(_connector().exposure_summary())
|
||||
|
||||
|
||||
@router.get('/host-reachability')
|
||||
def host_reachability():
|
||||
return _shape(_connector().host_reachability())
|
||||
|
||||
|
||||
@router.get('/security-warnings')
|
||||
def security_warnings():
|
||||
return _shape(_connector().security_warnings())
|
||||
|
||||
|
||||
@alias_router.get('/health')
|
||||
def alias_health():
|
||||
return health()
|
||||
|
||||
|
||||
@alias_router.get('/proxy-hosts')
|
||||
def alias_proxy_hosts():
|
||||
return proxy_hosts()
|
||||
|
||||
|
||||
@alias_router.get('/certificates')
|
||||
def alias_certificates():
|
||||
return certificates()
|
||||
|
||||
|
||||
@alias_router.get('/certificates/expiring')
|
||||
def alias_expiring_certificates(days: int = 30):
|
||||
return expiring_certificates(days)
|
||||
|
||||
|
||||
@alias_router.get('/access-lists')
|
||||
def alias_access_lists():
|
||||
return access_lists()
|
||||
|
||||
|
||||
@alias_router.get('/exposure-summary')
|
||||
def alias_exposure_summary():
|
||||
return exposure_summary()
|
||||
|
||||
|
||||
@alias_router.get('/host-reachability')
|
||||
def alias_host_reachability():
|
||||
return host_reachability()
|
||||
|
||||
|
||||
@alias_router.get('/security-warnings')
|
||||
def alias_security_warnings():
|
||||
return security_warnings()
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..db.session import get_session
|
||||
from ..services import notification_service
|
||||
|
||||
router = APIRouter(prefix='/api/notifications')
|
||||
|
||||
|
||||
@router.get('/rules')
|
||||
def rules(session: Session = Depends(get_session)):
|
||||
return notification_service.list_rules(session)
|
||||
|
||||
|
||||
@router.post('/rules')
|
||||
def create_rule(payload: dict, session: Session = Depends(get_session)):
|
||||
return notification_service.create_rule(session, payload)
|
||||
|
||||
|
||||
@router.patch('/rules/{rule_id}')
|
||||
def update_rule(rule_id: str, payload: dict, session: Session = Depends(get_session)):
|
||||
result = notification_service.update_rule(session, rule_id, payload)
|
||||
if result.get('status') == 'not_found':
|
||||
raise HTTPException(status_code=404, detail='rule_not_found')
|
||||
return result
|
||||
|
||||
|
||||
@router.delete('/rules/{rule_id}')
|
||||
def delete_rule(rule_id: str, session: Session = Depends(get_session)):
|
||||
result = notification_service.delete_rule(session, rule_id)
|
||||
if result.get('status') == 'not_found':
|
||||
raise HTTPException(status_code=404, detail='rule_not_found')
|
||||
return result
|
||||
|
||||
|
||||
@router.get('/events')
|
||||
def events(
|
||||
limit: int = Query(default=250, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
include_dismissed: bool = Query(default=True),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
return notification_service.list_events(
|
||||
session,
|
||||
include_dismissed=include_dismissed,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/events/unread')
|
||||
def unread_events(
|
||||
limit: int = Query(default=250, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
return notification_service.unread_events(session, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.post('/events/{event_id}/dismiss')
|
||||
def dismiss_event(event_id: str, session: Session = Depends(get_session)):
|
||||
result = notification_service.dismiss_event(session, event_id)
|
||||
if result.get('status') == 'not_found':
|
||||
raise HTTPException(status_code=404, detail='event_not_found')
|
||||
return result
|
||||
|
||||
|
||||
@router.post('/events/{event_id}/mark-read')
|
||||
def mark_read(event_id: str, session: Session = Depends(get_session)):
|
||||
result = notification_service.mark_read(session, event_id)
|
||||
if result.get('status') == 'not_found':
|
||||
raise HTTPException(status_code=404, detail='event_not_found')
|
||||
return result
|
||||
|
||||
|
||||
@router.post('/events/mark-all-read')
|
||||
def mark_all_read(session: Session = Depends(get_session)):
|
||||
return notification_service.mark_all_read(session)
|
||||
|
||||
|
||||
@router.get('/channels')
|
||||
def channels(session: Session = Depends(get_session)):
|
||||
return notification_service.list_channels(session)
|
||||
|
||||
|
||||
@router.post('/channels')
|
||||
def create_channel(payload: dict, session: Session = Depends(get_session)):
|
||||
return notification_service.create_channel(session, payload)
|
||||
|
||||
|
||||
@router.patch('/channels/{channel_id}')
|
||||
def update_channel(channel_id: str, payload: dict, session: Session = Depends(get_session)):
|
||||
result = notification_service.update_channel(session, channel_id, payload)
|
||||
if result.get('status') == 'not_found':
|
||||
raise HTTPException(status_code=404, detail='channel_not_found')
|
||||
return result
|
||||
|
||||
|
||||
@router.post('/channels/{channel_id}/test')
|
||||
def test_channel(channel_id: str, session: Session = Depends(get_session)):
|
||||
result = notification_service.test_channel(session, channel_id)
|
||||
if result.get('status') == 'not_found':
|
||||
raise HTTPException(status_code=404, detail='channel_not_found')
|
||||
return result
|
||||
|
||||
|
||||
@router.post('/channels/{channel_id}/test-connection')
|
||||
def test_channel_connection(channel_id: str, session: Session = Depends(get_session)):
|
||||
result = notification_service.test_channel_connection(session, channel_id)
|
||||
if result.get('status') == 'not_found':
|
||||
raise HTTPException(status_code=404, detail='channel_not_found')
|
||||
return result
|
||||
|
||||
|
||||
@router.post('/evaluate')
|
||||
def evaluate(session: Session = Depends(get_session)):
|
||||
return notification_service.evaluate_source(session, 'all')
|
||||
|
||||
|
||||
@router.post('/evaluate/safety')
|
||||
def evaluate_safety(session: Session = Depends(get_session)):
|
||||
return notification_service.evaluate_source(session, 'safety')
|
||||
|
||||
|
||||
@router.post('/evaluate/media')
|
||||
def evaluate_media(session: Session = Depends(get_session)):
|
||||
return notification_service.evaluate_source(session, 'media')
|
||||
|
||||
|
||||
@router.post('/evaluate/connectors')
|
||||
def evaluate_connectors(session: Session = Depends(get_session)):
|
||||
return notification_service.evaluate_source(session, 'connectors')
|
||||
|
||||
|
||||
@router.get('/summary')
|
||||
def summary(session: Session = Depends(get_session)):
|
||||
return notification_service.summary(session)
|
||||
@@ -0,0 +1,633 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..connectors.authentik import AuthentikConnector
|
||||
from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
from ..connectors.portainer import PortainerConnector
|
||||
from ..connectors.unraid import UnraidConnector
|
||||
from ..core.config import settings
|
||||
from ..core.redaction import redact_message
|
||||
from ..core.time import utc_now
|
||||
from ..db.models import AppSetting, AuditEvent, Connector, JobRun, Policy, Secret
|
||||
from ..db.session import get_session
|
||||
from ..services.incident_service import list_incidents as list_live_incidents, update_incident_status as update_live_incident_status
|
||||
from ..services.app_registry import list_active_apps
|
||||
from ..services.setup_service import apply_stored_connector_settings
|
||||
|
||||
router = APIRouter(prefix='/api')
|
||||
|
||||
NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION = 1
|
||||
NETWORK_EXPOSURE_CACHE_KEY = 'network_exposure:cache'
|
||||
NETWORK_EXPOSURE_CACHE_TTL_SECONDS = 60
|
||||
NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS = 300
|
||||
STATISTICS_HISTORY_MAX_POINTS = 48
|
||||
_STATISTICS_HISTORY: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
@router.get('/incidents')
|
||||
def list_incidents(session: Session = Depends(get_session)):
|
||||
return list_live_incidents(session)
|
||||
|
||||
|
||||
@router.post('/incidents/{incident_id}/status')
|
||||
def update_incident_status(incident_id: str, payload: dict | None = None, session: Session = Depends(get_session)):
|
||||
status = str(payload.get('status') or 'acknowledged') if isinstance(payload, dict) else 'acknowledged'
|
||||
return update_live_incident_status(session, incident_id, status)
|
||||
|
||||
|
||||
@router.get('/operations-map')
|
||||
def operations_map(session: Session = Depends(get_session)):
|
||||
apps = {app.id: app for app in list_active_apps(session, order_by='id')}
|
||||
connectors = session.exec(select(Connector).order_by(Connector.id)).all()
|
||||
policies = session.exec(select(Policy).order_by(Policy.id)).all()
|
||||
secrets_count = len(session.exec(select(Secret.id)).all())
|
||||
generated_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
|
||||
|
||||
nodes: set[str] = {'Connector Registry', 'Policy Engine', 'Secret Store'}
|
||||
edges: set[tuple[str, str]] = {('Secret Store', 'Connector Registry')}
|
||||
|
||||
for connector in connectors:
|
||||
app = apps.get(connector.id)
|
||||
if app is None:
|
||||
continue
|
||||
nodes.add(app.name)
|
||||
edges.add(('Connector Registry', app.name))
|
||||
if connector.secret_ref:
|
||||
edges.add(('Secret Store', app.name))
|
||||
|
||||
for policy in policies:
|
||||
if policy.target == '*':
|
||||
continue
|
||||
app = apps.get(policy.target)
|
||||
if app is None:
|
||||
continue
|
||||
nodes.add(app.name)
|
||||
edges.add(('Policy Engine', app.name))
|
||||
|
||||
return {
|
||||
'generatedAt': generated_at,
|
||||
'sources': {
|
||||
'apps': {'status': 'ok', 'count': len(apps)},
|
||||
'connectors': {'status': 'ok', 'count': len(connectors)},
|
||||
'policies': {'status': 'ok', 'count': len(policies)},
|
||||
'secrets': {'status': 'metadata_only', 'count': secrets_count},
|
||||
},
|
||||
'nodes': sorted(nodes),
|
||||
'edges': [
|
||||
{'from': source, 'to': target}
|
||||
for source, target in sorted(edges)
|
||||
if source in nodes and target in nodes
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _nullable_int(value: Any) -> int | None:
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int | float):
|
||||
return int(round(float(value)))
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip().strip('%')
|
||||
if not stripped:
|
||||
return None
|
||||
try:
|
||||
return int(round(float(stripped)))
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _record_statistics_sample(generated_at: str, cpu_usage: int | None, ram_usage: int | None) -> list[dict[str, Any]]:
|
||||
if cpu_usage is None and ram_usage is None:
|
||||
return list(_STATISTICS_HISTORY)
|
||||
|
||||
_STATISTICS_HISTORY.append({'time': generated_at, 'cpu': cpu_usage, 'ram': ram_usage})
|
||||
if len(_STATISTICS_HISTORY) > STATISTICS_HISTORY_MAX_POINTS:
|
||||
del _STATISTICS_HISTORY[:-STATISTICS_HISTORY_MAX_POINTS]
|
||||
return list(_STATISTICS_HISTORY)
|
||||
|
||||
|
||||
@router.get('/statistics/summary')
|
||||
def statistics_summary(session: Session = Depends(get_session)):
|
||||
apply_stored_connector_settings(session)
|
||||
generated_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
|
||||
metrics = UnraidConnector().get_system_metrics()
|
||||
cpu_usage = _nullable_int(metrics.get('cpuUsage'))
|
||||
ram_usage = _nullable_int(metrics.get('ramUsage'))
|
||||
samples = _record_statistics_sample(generated_at, cpu_usage, ram_usage)
|
||||
source_warnings = []
|
||||
if metrics.get('status') != 'ok':
|
||||
source_warnings.append({
|
||||
'source': 'unraid',
|
||||
'surface': 'metrics',
|
||||
'status': metrics.get('status', 'unknown'),
|
||||
'reason': redact_message(str(metrics.get('reason', 'metrics_unavailable')))[:240],
|
||||
})
|
||||
|
||||
audit_events = session.exec(select(AuditEvent).order_by(AuditEvent.timestamp.desc())).all()
|
||||
jobs = session.exec(select(JobRun).order_by(JobRun.started_at.desc())).all()
|
||||
restarts: dict[str, int] = {}
|
||||
durations_ms: list[int] = []
|
||||
for job in jobs:
|
||||
if job.action_id == 'container.restart':
|
||||
restarts[job.target] = restarts.get(job.target, 0) + 1
|
||||
if job.status == 'succeeded' and job.duration_ms is not None:
|
||||
durations_ms.append(int(job.duration_ms))
|
||||
|
||||
blocked_actions = sum(
|
||||
1
|
||||
for event in audit_events
|
||||
if event.policy_decision == 'blocked' or event.result == 'blocked'
|
||||
)
|
||||
voice_action_count = sum(1 for event in audit_events if event.source == 'voice' or event.identity == 'voice')
|
||||
mttr_hours = None
|
||||
if durations_ms:
|
||||
mttr_hours = round((sum(durations_ms) / len(durations_ms)) / 1000 / 60 / 60, 2)
|
||||
|
||||
return {
|
||||
'generatedAt': generated_at,
|
||||
'sources': {'unraid': metrics.get('status', 'unknown'), 'audit': 'ok', 'jobs': 'ok'},
|
||||
'sourceWarnings': source_warnings,
|
||||
'cpuHistory': [{'time': sample['time'], 'value': sample.get('cpu')} for sample in samples],
|
||||
'ramHistory': [{'time': sample['time'], 'value': sample.get('ram')} for sample in samples],
|
||||
'restartsByApp': [
|
||||
{'appId': app_id, 'count': count}
|
||||
for app_id, count in sorted(restarts.items(), key=lambda item: (-item[1], item[0]))
|
||||
],
|
||||
'blockedActions': blocked_actions,
|
||||
'voiceActionCount': voice_action_count,
|
||||
'mttrHours': mttr_hours,
|
||||
}
|
||||
|
||||
|
||||
@router.get('/storage/summary')
|
||||
def storage_summary(session: Session = Depends(get_session)):
|
||||
apply_stored_connector_settings(session)
|
||||
unraid = UnraidConnector()
|
||||
portainer = PortainerConnector()
|
||||
cache_usage = 0
|
||||
storage_pools: list[dict] = []
|
||||
shares: list[dict] = []
|
||||
disks: list[dict] = []
|
||||
app_data_usage: list[dict] = []
|
||||
source_warnings: list[dict] = []
|
||||
|
||||
cache_result = unraid.get_cache_status()
|
||||
if cache_result.get('status') == 'ok':
|
||||
cache_usage = int(cache_result.get('cacheUsage', 0) or 0)
|
||||
storage_pools = [
|
||||
{
|
||||
'name': str(pool.get('name', 'cache')),
|
||||
'status': str(pool.get('status', 'unknown')),
|
||||
'healthy': pool.get('healthy'),
|
||||
'type': pool.get('type'),
|
||||
'usedGiB': pool.get('usedGiB'),
|
||||
'sizeGiB': pool.get('sizeGiB'),
|
||||
'freeGiB': pool.get('freeGiB'),
|
||||
'usagePercent': pool.get('usagePercent'),
|
||||
}
|
||||
for pool in cache_result.get('pools', [])
|
||||
if isinstance(pool, dict)
|
||||
]
|
||||
elif cache_result.get('status') not in {None, 'ok'}:
|
||||
source_warnings.append({
|
||||
'source': 'unraid',
|
||||
'surface': 'cache',
|
||||
'status': cache_result.get('status', 'error'),
|
||||
'reason': redact_message(str(cache_result.get('reason', 'cache_unavailable')))[:240],
|
||||
})
|
||||
|
||||
share_result = unraid.get_share_summary()
|
||||
if share_result.get('status') == 'ok':
|
||||
shares = [
|
||||
{
|
||||
'name': str(share.get('name', 'unknown')),
|
||||
'usedGiB': float(share.get('usedGiB', 0) or 0),
|
||||
'sizeGiB': float(share.get('sizeGiB', 0) or 0),
|
||||
'critical': bool(share.get('critical', False)),
|
||||
}
|
||||
for share in share_result.get('shares', [])
|
||||
if isinstance(share, dict)
|
||||
]
|
||||
elif share_result.get('status') not in {None, 'ok'}:
|
||||
source_warnings.append({
|
||||
'source': 'unraid',
|
||||
'surface': 'shares',
|
||||
'status': share_result.get('status', 'error'),
|
||||
'reason': redact_message(str(share_result.get('reason', 'shares_unavailable')))[:240],
|
||||
})
|
||||
|
||||
disk_result = unraid.get_disk_warnings()
|
||||
if disk_result.get('status') == 'ok':
|
||||
disks = [
|
||||
{
|
||||
'name': str(disk.get('name', 'unknown')),
|
||||
'status': str(disk.get('status', 'unknown')),
|
||||
'healthy': disk.get('healthy'),
|
||||
'type': disk.get('type'),
|
||||
'usedGiB': disk.get('usedGiB'),
|
||||
'sizeGiB': disk.get('sizeGiB'),
|
||||
'freeGiB': disk.get('freeGiB'),
|
||||
'temperature': disk.get('temperature'),
|
||||
}
|
||||
for disk in disk_result.get('disks', [])
|
||||
if isinstance(disk, dict)
|
||||
]
|
||||
elif disk_result.get('status') not in {None, 'ok'}:
|
||||
source_warnings.append({
|
||||
'source': 'unraid',
|
||||
'surface': 'disks',
|
||||
'status': disk_result.get('status', 'error'),
|
||||
'reason': redact_message(str(disk_result.get('reason', 'disks_unavailable')))[:240],
|
||||
})
|
||||
|
||||
share_names = {
|
||||
str(share.get('name', '')).strip('/').lower(): share
|
||||
for share in shares
|
||||
if str(share.get('name', '')).strip()
|
||||
}
|
||||
|
||||
containers_result = portainer.list_containers()
|
||||
if containers_result.get('status') == 'ok':
|
||||
seen: set[str] = set()
|
||||
for container in containers_result.get('containers', []):
|
||||
if not isinstance(container, dict):
|
||||
continue
|
||||
app_id = str(container.get('appId') or container.get('name') or '').strip()
|
||||
if not app_id:
|
||||
continue
|
||||
container_name = str(container.get('name') or app_id).strip()
|
||||
mounts = container.get('mounts', [])
|
||||
if not isinstance(mounts, list) or not mounts:
|
||||
key = f'{app_id}:{container_name}:container'
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
app_data_usage.append({
|
||||
'appId': app_id,
|
||||
'container': container_name,
|
||||
'usedGiB': None,
|
||||
'volume': container_name,
|
||||
'source': None,
|
||||
'destination': None,
|
||||
'type': 'container',
|
||||
'share': None,
|
||||
'measurement': 'not_available',
|
||||
})
|
||||
continue
|
||||
|
||||
if isinstance(mounts, list):
|
||||
for mount in mounts:
|
||||
if not isinstance(mount, dict):
|
||||
continue
|
||||
source = str(mount.get('source') or '').strip()
|
||||
destination = str(mount.get('destination') or '').strip()
|
||||
mount_type = str(mount.get('type') or '').strip() or 'unknown'
|
||||
if not source and not destination:
|
||||
continue
|
||||
key = f'{app_id}:{container_name}:{source}:{destination}'
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
parts = [part for part in source.replace('\\', '/').split('/') if part]
|
||||
share_name = parts[2] if len(parts) >= 3 and parts[0] == 'mnt' and parts[1] in {'user', 'cache'} else None
|
||||
share = share_names.get(str(share_name or '').lower())
|
||||
app_data_usage.append({
|
||||
'appId': app_id,
|
||||
'container': container_name,
|
||||
'usedGiB': None,
|
||||
'volume': f'{source or mount_type} -> {destination or "container"}',
|
||||
'source': source or None,
|
||||
'destination': destination or None,
|
||||
'type': mount_type,
|
||||
'share': share_name,
|
||||
'shareUsagePercent': round((float(share.get('usedGiB', 0) or 0) / float(share.get('sizeGiB', 0) or 1)) * 100) if share and float(share.get('sizeGiB', 0) or 0) > 0 else None,
|
||||
'measurement': 'mount_discovered',
|
||||
})
|
||||
elif containers_result.get('status') not in {None, 'ok'}:
|
||||
source_warnings.append({
|
||||
'source': 'portainer',
|
||||
'surface': 'containers',
|
||||
'status': containers_result.get('status', 'error'),
|
||||
'reason': redact_message(str(containers_result.get('reason', 'containers_unavailable')))[:240],
|
||||
})
|
||||
|
||||
total_size = sum(float(share.get('sizeGiB', 0) or 0) for share in shares)
|
||||
total_used = sum(float(share.get('usedGiB', 0) or 0) for share in shares)
|
||||
array_usage = round((total_used / total_size) * 100) if total_size > 0 else 0
|
||||
|
||||
return {
|
||||
'cacheUsage': cache_usage,
|
||||
'arrayUsage': array_usage,
|
||||
'disks': disks,
|
||||
'storagePools': storage_pools,
|
||||
'shares': shares,
|
||||
'appDataUsage': app_data_usage,
|
||||
'orphanedVolumes': [],
|
||||
'forecastDays': None,
|
||||
'sources': {
|
||||
'unraid': share_result.get('status', cache_result.get('status', 'unknown')),
|
||||
'unraidDisks': disk_result.get('status', 'unknown'),
|
||||
'portainer': containers_result.get('status', 'unknown'),
|
||||
},
|
||||
'sourceWarnings': source_warnings,
|
||||
}
|
||||
|
||||
|
||||
def _parse_utc(value: str) -> datetime:
|
||||
return datetime.fromisoformat(value.replace('Z', '+00:00'))
|
||||
|
||||
|
||||
def _network_cache_ttl_seconds() -> int:
|
||||
raw = os.getenv('NETWORK_EXPOSURE_CACHE_TTL_SECONDS', str(NETWORK_EXPOSURE_CACHE_TTL_SECONDS))
|
||||
try:
|
||||
return max(1, int(raw))
|
||||
except (TypeError, ValueError):
|
||||
return NETWORK_EXPOSURE_CACHE_TTL_SECONDS
|
||||
|
||||
|
||||
def _network_cache_max_stale_seconds() -> int:
|
||||
raw = os.getenv('NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS', str(NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS))
|
||||
try:
|
||||
return max(0, int(raw))
|
||||
except (TypeError, ValueError):
|
||||
return NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS
|
||||
|
||||
|
||||
def _redact(value):
|
||||
if isinstance(value, str):
|
||||
return redact_message(value)
|
||||
if isinstance(value, list):
|
||||
return [_redact(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(key): _redact(item)
|
||||
for key, item in value.items()
|
||||
if 'secret' not in str(key).lower() and 'token' not in str(key).lower() and 'password' not in str(key).lower()
|
||||
}
|
||||
return value
|
||||
|
||||
|
||||
def _network_exposure_signature(session: Session) -> str:
|
||||
connectors = [
|
||||
{
|
||||
'id': item.id,
|
||||
'status': item.status,
|
||||
'lastTest': item.last_test,
|
||||
'secretRef': item.secret_ref,
|
||||
'capabilities': item.capabilities,
|
||||
}
|
||||
for item in session.exec(select(Connector)).all()
|
||||
if item.id in {'nginx_proxy_manager', 'authentik'}
|
||||
]
|
||||
secret_rows = [
|
||||
{
|
||||
'id': item.id,
|
||||
'name': item.name,
|
||||
'updatedAt': item.updated_at.isoformat() if item.updated_at else None,
|
||||
'usedBy': item.used_by_json,
|
||||
}
|
||||
for item in session.exec(select(Secret)).all()
|
||||
if any(connector_id in item.used_by_json for connector_id in ('nginx_proxy_manager', 'authentik'))
|
||||
]
|
||||
signature_payload = {
|
||||
'schemaVersion': NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION,
|
||||
'connectors': sorted(connectors, key=lambda item: item['id']),
|
||||
'secrets': sorted(secret_rows, key=lambda item: item['id']),
|
||||
}
|
||||
serialized = json.dumps(signature_payload, sort_keys=True, default=str, separators=(',', ':'))
|
||||
return hashlib.sha256(serialized.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _persistent_network_exposure(session: Session, signature: str) -> list[dict] | None:
|
||||
row = session.get(AppSetting, NETWORK_EXPOSURE_CACHE_KEY)
|
||||
if not row or not isinstance(row.value_json, dict):
|
||||
return None
|
||||
value = row.value_json
|
||||
if value.get('schemaVersion') != NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION or value.get('signature') != signature:
|
||||
return None
|
||||
payload = value.get('payload')
|
||||
expires_at_raw = value.get('expiresAt')
|
||||
if not isinstance(payload, list) or not isinstance(expires_at_raw, str):
|
||||
return None
|
||||
expires_at = _parse_utc(expires_at_raw)
|
||||
now_dt = datetime.now(UTC)
|
||||
stale_until = expires_at.timestamp() + _network_cache_max_stale_seconds()
|
||||
if now_dt >= expires_at and now_dt.timestamp() >= stale_until:
|
||||
return None
|
||||
return [item for item in payload if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _store_persistent_network_exposure(session: Session, signature: str, payload: list[dict]) -> None:
|
||||
ttl_seconds = _network_cache_ttl_seconds()
|
||||
expires_at = datetime.fromtimestamp(datetime.now(UTC).replace(microsecond=0).timestamp() + ttl_seconds, UTC).isoformat().replace('+00:00', 'Z')
|
||||
row = session.get(AppSetting, NETWORK_EXPOSURE_CACHE_KEY)
|
||||
if not row:
|
||||
row = AppSetting(key=NETWORK_EXPOSURE_CACHE_KEY, is_secret=False)
|
||||
row.value_json = {
|
||||
'schemaVersion': NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION,
|
||||
'signature': signature,
|
||||
'payload': _redact(payload),
|
||||
'generatedAt': datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z'),
|
||||
'expiresAt': expires_at,
|
||||
}
|
||||
row.updated_at = utc_now()
|
||||
session.add(row)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _build_network_exposure() -> list[dict]:
|
||||
with ThreadPoolExecutor(max_workers=3) as executor:
|
||||
hosts_future = executor.submit(lambda: NginxProxyManagerConnector().proxy_hosts())
|
||||
certs_future = executor.submit(lambda: NginxProxyManagerConnector().certificates())
|
||||
coverage_future = executor.submit(_auth_coverage_by_host)
|
||||
hosts_payload = hosts_future.result()
|
||||
certs_payload = certs_future.result()
|
||||
coverage_by_host = coverage_future.result()
|
||||
|
||||
if not hosts_payload.get('success'):
|
||||
return []
|
||||
|
||||
certs = certs_payload.get('items', []) if certs_payload.get('success') else []
|
||||
cert_by_id = {item.get('id'): item for item in certs if isinstance(item, dict)}
|
||||
exposures = []
|
||||
for host in hosts_payload.get('items', []):
|
||||
if not isinstance(host, dict):
|
||||
continue
|
||||
domains = host.get('domainNames') if isinstance(host.get('domainNames'), list) else []
|
||||
label = ', '.join(str(item) for item in domains if str(item).strip()) or f"proxy-host-{host.get('id', 'unknown')}"
|
||||
certificate = cert_by_id.get(host.get('certificateId')) if host.get('certificateId') else None
|
||||
has_certificate = bool(host.get('certificateId'))
|
||||
certificate_expired = bool(certificate and certificate.get('isExpired'))
|
||||
certificate_expiring = bool(certificate and certificate.get('isExpiringSoon'))
|
||||
coverage = coverage_by_host.get(str(host.get('id')))
|
||||
auth_status = str((coverage or {}).get('coverageStatus') or 'unknown')
|
||||
auth_protected = auth_status in {'protected', 'likely_protected'}
|
||||
npm_protected = bool(host.get('accessListId'))
|
||||
protected = npm_protected or auth_protected
|
||||
protection_source = 'authentik' if auth_protected else ('npm_access_list' if npm_protected else 'none')
|
||||
enabled = bool(host.get('enabled'))
|
||||
ssl_forced = bool(host.get('sslForced'))
|
||||
risk_reasons: list[str] = []
|
||||
if enabled and not protected:
|
||||
risk_reasons.append('No NPM access list or Authentik match')
|
||||
if enabled and not has_certificate:
|
||||
risk_reasons.append('No certificate assigned')
|
||||
if certificate_expired:
|
||||
risk_reasons.append('Certificate expired')
|
||||
elif certificate_expiring:
|
||||
risk_reasons.append('Certificate expiring soon')
|
||||
if enabled and has_certificate and not ssl_forced:
|
||||
risk_reasons.append('SSL is not forced')
|
||||
|
||||
if not enabled:
|
||||
risk = 'low'
|
||||
elif certificate_expired:
|
||||
risk = 'critical'
|
||||
elif not protected or not has_certificate:
|
||||
risk = 'high'
|
||||
elif not ssl_forced or certificate_expiring:
|
||||
risk = 'medium'
|
||||
else:
|
||||
risk = 'low'
|
||||
|
||||
exposures.append({
|
||||
'source': 'reverse_proxy',
|
||||
'host': label,
|
||||
'port': int(host.get('forwardPort') or 0),
|
||||
'protocol': str(host.get('forwardScheme') or 'http'),
|
||||
'protected': protected,
|
||||
'protectionSource': protection_source,
|
||||
'authCoverageStatus': auth_status,
|
||||
'authentikMatches': (coverage or {}).get('authentikMatches') or [],
|
||||
'appId': str(host.get('forwardHost') or ''),
|
||||
'risk': risk,
|
||||
'riskReasons': risk_reasons,
|
||||
'externalChangeRequired': bool(risk_reasons),
|
||||
})
|
||||
return exposures
|
||||
|
||||
|
||||
@router.get('/network/exposure')
|
||||
def network_exposure(refresh: bool = False, session: Session = Depends(get_session)):
|
||||
apply_stored_connector_settings(session, {'nginx_proxy_manager', 'authentik'})
|
||||
signature = _network_exposure_signature(session)
|
||||
if not refresh:
|
||||
persisted = _persistent_network_exposure(session, signature)
|
||||
if persisted is not None:
|
||||
return persisted
|
||||
exposures = _build_network_exposure()
|
||||
_store_persistent_network_exposure(session, signature, exposures)
|
||||
return exposures
|
||||
|
||||
|
||||
def _auth_coverage_by_host() -> dict[str, dict]:
|
||||
try:
|
||||
payload = AuthentikConnector().auth_coverage()
|
||||
except Exception:
|
||||
return {}
|
||||
if not payload.get('success'):
|
||||
return {}
|
||||
result: dict[str, dict] = {}
|
||||
for item in payload.get('publicHosts', []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
host_id = item.get('hostId')
|
||||
if host_id is None:
|
||||
continue
|
||||
result[str(host_id)] = item
|
||||
return result
|
||||
|
||||
|
||||
@router.get('/healthchecks')
|
||||
def healthchecks(session: Session = Depends(get_session)):
|
||||
checked_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
|
||||
connectors = {connector.id: connector for connector in session.exec(select(Connector)).all()}
|
||||
|
||||
return [
|
||||
{'service': 'API Gateway', 'status': 'pass', 'checkedAt': checked_at, 'details': 'DB en route registratie OK'},
|
||||
_connector_healthcheck('Portainer', connectors.get('portainer'), checked_at),
|
||||
_connector_healthcheck('Unraid', connectors.get('unraid'), checked_at),
|
||||
_voice_bridge_healthcheck(connectors.get('home_assistant'), checked_at),
|
||||
]
|
||||
|
||||
|
||||
def _connector_healthcheck(service: str, connector: Connector | None, checked_at: str) -> dict:
|
||||
if connector is None:
|
||||
return {
|
||||
'service': service,
|
||||
'status': 'warn',
|
||||
'checkedAt': checked_at,
|
||||
'details': 'Niet aanwezig in connector registry.',
|
||||
}
|
||||
|
||||
status = str(connector.status or 'unknown').lower()
|
||||
if status == 'connected':
|
||||
return {
|
||||
'service': service,
|
||||
'status': 'pass',
|
||||
'checkedAt': checked_at,
|
||||
'details': 'Connector registry meldt verbonden.',
|
||||
}
|
||||
if status in {'missing_config', 'disconnected', 'unknown'}:
|
||||
return {
|
||||
'service': service,
|
||||
'status': 'warn',
|
||||
'checkedAt': checked_at,
|
||||
'details': f'Connector registry meldt {status}.',
|
||||
}
|
||||
return {
|
||||
'service': service,
|
||||
'status': 'fail',
|
||||
'checkedAt': checked_at,
|
||||
'details': f'Connector registry meldt {status}.',
|
||||
}
|
||||
|
||||
|
||||
def _voice_bridge_healthcheck(home_assistant: Connector | None, checked_at: str) -> dict:
|
||||
if not settings.voice_token:
|
||||
return {
|
||||
'service': 'Voice Bridge',
|
||||
'status': 'warn',
|
||||
'checkedAt': checked_at,
|
||||
'details': 'Voice Bridge is niet geactiveerd.',
|
||||
}
|
||||
|
||||
if home_assistant is None:
|
||||
return {
|
||||
'service': 'Voice Bridge',
|
||||
'status': 'warn',
|
||||
'checkedAt': checked_at,
|
||||
'details': 'Home Assistant connector ontbreekt.',
|
||||
}
|
||||
|
||||
status = str(home_assistant.status or 'unknown').lower()
|
||||
if status == 'connected':
|
||||
return {
|
||||
'service': 'Voice Bridge',
|
||||
'status': 'pass',
|
||||
'checkedAt': checked_at,
|
||||
'details': 'Home Assistant connector beschikbaar voor voice exports.',
|
||||
}
|
||||
if status in {'missing_config', 'disconnected', 'unknown'}:
|
||||
return {
|
||||
'service': 'Voice Bridge',
|
||||
'status': 'warn',
|
||||
'checkedAt': checked_at,
|
||||
'details': f'Home Assistant connector meldt {status}.',
|
||||
}
|
||||
return {
|
||||
'service': 'Voice Bridge',
|
||||
'status': 'fail',
|
||||
'checkedAt': checked_at,
|
||||
'details': f'Home Assistant connector meldt {status}.',
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..db.models import Connector, Policy
|
||||
from ..db.session import get_session
|
||||
|
||||
router = APIRouter(prefix='/api')
|
||||
|
||||
|
||||
def _connector_names(connectors: list[Connector], ids: set[str]) -> list[str]:
|
||||
return [connector.name for connector in connectors if connector.id in ids]
|
||||
|
||||
|
||||
def _policy_risk(session: Session, action: str, target: str, fallback: str = 'medium') -> str:
|
||||
policy = session.exec(select(Policy).where(Policy.action == action, Policy.target == target)).first()
|
||||
if policy and policy.risk in {'low', 'medium', 'high', 'critical'}:
|
||||
return policy.risk
|
||||
return fallback
|
||||
|
||||
|
||||
def _connector_labels(connectors: list[Connector], ids: set[str]) -> list[str]:
|
||||
labels = _connector_names(connectors, ids)
|
||||
known = {connector.id for connector in connectors}
|
||||
for connector_id in sorted(ids - known):
|
||||
labels.append(connector_id.replace('_', ' ').title())
|
||||
return labels
|
||||
|
||||
|
||||
def _success_rate(connectors: dict[str, Connector], ids: set[str], fallback: int = 80) -> int:
|
||||
if not ids:
|
||||
return fallback
|
||||
present = [connectors.get(connector_id) for connector_id in ids]
|
||||
connected = sum(1 for connector in present if connector and connector.status == 'connected')
|
||||
return round((connected / len(ids)) * 100)
|
||||
|
||||
|
||||
@router.get('/playbooks')
|
||||
def list_playbooks(session: Session = Depends(get_session)):
|
||||
connectors = session.exec(select(Connector).order_by(Connector.id)).all()
|
||||
degraded = [
|
||||
connector
|
||||
for connector in connectors
|
||||
if connector.status in {'missing_config', 'disconnected', 'error', 'auth_failed', 'unreachable'}
|
||||
]
|
||||
connector_by_id = {connector.id: connector for connector in connectors}
|
||||
|
||||
playbooks: list[dict] = []
|
||||
playbooks.append({
|
||||
'id': 'storage-pressure-review',
|
||||
'name': 'Storage Pressure Review',
|
||||
'description': 'Brengt array-, cache-, disk-, share- en container-mountdruk samen zonder storage-mutaties te starten.',
|
||||
'risk': 'medium',
|
||||
'allowedSources': ['manual', 'automation'],
|
||||
'requiredConnectors': _connector_labels(connectors, {'unraid', 'portainer'}),
|
||||
'steps': [
|
||||
'Open Storage & Volumes and review array/cache pressure',
|
||||
'Inspect disks and critical shares above threshold',
|
||||
'Compare Portainer mount hints with Unraid share usage',
|
||||
'Open Safety or Remediation Planner for non-executing follow-up notes',
|
||||
'Keep mover, prune and storage mutations outside this playbook',
|
||||
],
|
||||
'successRate': _success_rate(connector_by_id, {'unraid', 'portainer'}),
|
||||
})
|
||||
|
||||
playbooks.append({
|
||||
'id': 'exposure-auth-coverage-review',
|
||||
'name': 'Exposure & Auth Coverage Review',
|
||||
'description': 'Controleert NPM public hosts, SSL-force, certificaten en Authentik coverage als één securityroute.',
|
||||
'risk': 'high',
|
||||
'allowedSources': ['manual', 'automation'],
|
||||
'requiredConnectors': _connector_labels(connectors, {'nginx_proxy_manager', 'authentik'}),
|
||||
'steps': [
|
||||
'Open Exposure Center and sort high-risk public hosts first',
|
||||
'Check SSL-force, certificate status and access-list evidence',
|
||||
'Open matching Authentik coverage details for unprotected hosts',
|
||||
'Create or review a remediation draft before touching external systems',
|
||||
'Re-run Safety refresh after operator-side NPM/Auth changes',
|
||||
],
|
||||
'successRate': _success_rate(connector_by_id, {'nginx_proxy_manager', 'authentik'}),
|
||||
})
|
||||
|
||||
playbooks.append({
|
||||
'id': 'media-stalled-download-review',
|
||||
'name': 'Media Pipeline Stalled Download Review',
|
||||
'description': 'Volgt Sonarr/Radarr wanted items, Prowlarr health en Deluge stalled downloads zonder data te verwijderen.',
|
||||
'risk': 'medium',
|
||||
'allowedSources': ['manual', 'automation'],
|
||||
'requiredConnectors': _connector_labels(connectors, {'sonarr', 'radarr', 'prowlarr', 'deluge', 'plex', 'tautulli', 'widefrog'}),
|
||||
'steps': [
|
||||
'Open Media Pipeline summary and review stalled/wanted counters',
|
||||
'Check Prowlarr indexers and Sonarr/Radarr queue status',
|
||||
'Inspect Deluge stalled downloads and pause/resume policy guidance',
|
||||
'Review Plex/Tautulli activity before starting scans',
|
||||
'Use approval-gated actions only for non-destructive connector operations',
|
||||
],
|
||||
'successRate': _success_rate(connector_by_id, {'sonarr', 'radarr', 'prowlarr', 'deluge', 'plex', 'tautulli', 'widefrog'}),
|
||||
})
|
||||
|
||||
playbooks.append({
|
||||
'id': 'unhealthy-container-triage',
|
||||
'name': 'Unhealthy Container Triage',
|
||||
'description': 'Richt de operator op ongezonde of ongemapte containers met logs, Portainer deep-link en policy-aware restartpad.',
|
||||
'risk': 'medium',
|
||||
'allowedSources': ['manual'],
|
||||
'requiredConnectors': _connector_labels(connectors, {'portainer'}),
|
||||
'steps': [
|
||||
'Open Containers with attention filter',
|
||||
'Inspect health, import status, app mapping and redacted logs',
|
||||
'Open Portainer deep-link only when live context is available',
|
||||
'Request approval before restarting protected apps',
|
||||
'Verify the container and app detail after any approved action',
|
||||
],
|
||||
'successRate': _success_rate(connector_by_id, {'portainer'}),
|
||||
})
|
||||
|
||||
playbooks.append({
|
||||
'id': 'app-inventory-normalization',
|
||||
'name': 'App Inventory Normalization',
|
||||
'description': 'Werkt Unknown, needs-review en no-connector apps systematisch weg zodat Apps minder ruis tonen.',
|
||||
'risk': 'low',
|
||||
'allowedSources': ['manual', 'automation'],
|
||||
'requiredConnectors': _connector_labels(connectors, {'portainer'}),
|
||||
'steps': [
|
||||
'Open Apps with attention filter',
|
||||
'Review Unknown and needs-review app cards first',
|
||||
'Map discovered containers to canonical apps where the match is clear',
|
||||
'Bind connector candidates only after connector config has tested connected',
|
||||
'Re-run inventory discovery and compare drift after review',
|
||||
],
|
||||
'successRate': _success_rate(connector_by_id, {'portainer'}),
|
||||
})
|
||||
|
||||
playbooks.append({
|
||||
'id': 'backup-readiness-check',
|
||||
'name': 'Backup Readiness Check',
|
||||
'description': 'Controleert SQLite backup posture, restore-notes en release hygiene zonder restore uit te voeren.',
|
||||
'risk': 'low',
|
||||
'allowedSources': ['manual', 'automation'],
|
||||
'requiredConnectors': ['AppOps DB'],
|
||||
'steps': [
|
||||
'Open Settings backups section',
|
||||
'Review latest backup metadata and integrity status',
|
||||
'Confirm restore remains manual stop-container flow',
|
||||
'Open Audit for recent backup events',
|
||||
'Update release notes after verified production changes',
|
||||
],
|
||||
'successRate': 100,
|
||||
})
|
||||
|
||||
playbooks.append({
|
||||
'id': 'notification-delivery-review',
|
||||
'name': 'Notification Delivery Review',
|
||||
'description': 'Controleert dashboard- en Home Assistant notificatiebereik zonder automatisch testberichten te verzenden.',
|
||||
'risk': 'low',
|
||||
'allowedSources': ['manual', 'automation'],
|
||||
'requiredConnectors': _connector_labels(connectors, {'home_assistant'}),
|
||||
'steps': [
|
||||
'Open Notifications and review active rules, channels and unread events',
|
||||
'Validate Home Assistant readiness only when the operator enabled the channel',
|
||||
'Run explicit evaluation for safety/media events when stale notifications appear',
|
||||
'Keep external delivery opt-in and audited',
|
||||
],
|
||||
'successRate': _success_rate(connector_by_id, {'home_assistant'}, fallback=70),
|
||||
})
|
||||
|
||||
if degraded:
|
||||
degraded_names = [connector.name for connector in degraded]
|
||||
playbooks.append({
|
||||
'id': 'connector-readiness-review',
|
||||
'name': 'Connector Readiness Review',
|
||||
'description': 'Controleert welke connectors configuratie, binding of health-herstel nodig hebben zonder wijzigingen uit te voeren.',
|
||||
'risk': 'medium',
|
||||
'allowedSources': ['manual', 'automation'],
|
||||
'requiredConnectors': degraded_names,
|
||||
'steps': [
|
||||
f"Review {len(degraded)} degraded connector(s): {', '.join(degraded_names[:8])}",
|
||||
'Open connector setup for missing_config entries',
|
||||
'Run read-only connector tests',
|
||||
'Review policy and audit status before any follow-up action',
|
||||
],
|
||||
'successRate': max(0, 100 - (len(degraded) * 10)),
|
||||
})
|
||||
|
||||
if {'portainer', 'widefrog'} <= set(connector_by_id):
|
||||
widefrog_ready = connector_by_id['widefrog'].status == 'connected'
|
||||
portainer_ready = connector_by_id['portainer'].status == 'connected'
|
||||
playbooks.append({
|
||||
'id': 'fix-widefrog',
|
||||
'name': 'Fix Widefrog',
|
||||
'description': 'Bereidt een veilige Widefrog herstelprocedure voor met read-only checks en policy-gated vervolgstappen.',
|
||||
'risk': _policy_risk(session, 'widefrog.retry_failed', 'widefrog', 'medium'),
|
||||
'allowedSources': ['manual', 'voice'],
|
||||
'requiredConnectors': _connector_names(connectors, {'portainer', 'widefrog'}),
|
||||
'steps': [
|
||||
'Read Portainer container status',
|
||||
'Read Widefrog health, queue and failed jobs',
|
||||
'Inspect recent redacted logs when available',
|
||||
'Draft follow-up action only when policy allows it',
|
||||
'Require approval for retry or restart when policy demands it',
|
||||
'Write audit result after an approved execution path',
|
||||
],
|
||||
'successRate': 90 if widefrog_ready and portainer_ready else 50,
|
||||
})
|
||||
|
||||
if 'unraid' in connector_by_id:
|
||||
unraid_ready = connector_by_id['unraid'].status == 'connected'
|
||||
playbooks.append({
|
||||
'id': 'unraid-health-scan',
|
||||
'name': 'Unraid Health Scan',
|
||||
'description': 'Leest array, disk, cache en docker-status uit zonder muterende acties te starten.',
|
||||
'risk': 'low',
|
||||
'allowedSources': ['manual', 'automation'],
|
||||
'requiredConnectors': _connector_names(connectors, {'unraid'}),
|
||||
'steps': [
|
||||
'Read array status',
|
||||
'Read parity status',
|
||||
'Check disk warning count',
|
||||
'Check cache usage',
|
||||
'Check docker service status',
|
||||
'Write read-only audit summary',
|
||||
],
|
||||
'successRate': 100 if unraid_ready else 60,
|
||||
})
|
||||
|
||||
return playbooks
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..connectors.plex import PlexConnector
|
||||
from ..db.session import get_session
|
||||
from .connector_runtime import load_connector_runtime
|
||||
from ..services.approval_service import create_approval
|
||||
from ..services.audit_logger import log_event
|
||||
from ..services.job_runner import complete_job, create_job, set_job_progress, start_job
|
||||
from ..services.policy_engine import evaluate_policy
|
||||
|
||||
router = APIRouter(prefix='/api/apps/plex', dependencies=[Depends(load_connector_runtime)])
|
||||
|
||||
|
||||
_SCAN_LIBRARY_STEPS = [
|
||||
'Validate library id',
|
||||
'Evaluate policy',
|
||||
'Check Plex health',
|
||||
'Fetch library status',
|
||||
'Trigger scan library',
|
||||
'Verify accepted or return status',
|
||||
'Complete audit/job',
|
||||
]
|
||||
|
||||
_SCAN_ALL_STEPS = [
|
||||
'Evaluate policy',
|
||||
'Check Plex health',
|
||||
'Fetch libraries',
|
||||
'Trigger scan for each library',
|
||||
'Complete audit/job',
|
||||
]
|
||||
|
||||
|
||||
@router.get('/health')
|
||||
def health():
|
||||
return PlexConnector().health()
|
||||
|
||||
|
||||
@router.get('/server')
|
||||
def server():
|
||||
return PlexConnector().server_status()
|
||||
|
||||
|
||||
@router.get('/libraries')
|
||||
def libraries():
|
||||
return PlexConnector().library_status()
|
||||
|
||||
|
||||
@router.get('/sessions')
|
||||
def sessions():
|
||||
return PlexConnector().active_sessions()
|
||||
|
||||
|
||||
@router.get('/recent')
|
||||
def recent():
|
||||
return PlexConnector().recent_media()
|
||||
|
||||
|
||||
@router.get('/streams')
|
||||
def streams():
|
||||
return PlexConnector().stream_summary()
|
||||
|
||||
|
||||
@router.post('/libraries/{library_id}/scan')
|
||||
def scan_library(library_id: str, payload: dict | None = None, session: Session = Depends(get_session)):
|
||||
return _execute_plex_action(
|
||||
session=session,
|
||||
payload=payload or {},
|
||||
action='plex.scan_library',
|
||||
steps=_SCAN_LIBRARY_STEPS,
|
||||
connector_call=lambda connector: connector.scan_library(library_id),
|
||||
default_success_message='scan_started',
|
||||
metadata={'libraryId': library_id},
|
||||
)
|
||||
|
||||
|
||||
@router.post('/libraries/scan-all')
|
||||
def scan_all_libraries(payload: dict | None = None, session: Session = Depends(get_session)):
|
||||
return _execute_plex_action(
|
||||
session=session,
|
||||
payload=payload or {},
|
||||
action='plex.scan_all_libraries',
|
||||
steps=_SCAN_ALL_STEPS,
|
||||
connector_call=lambda connector: connector.scan_all_libraries(),
|
||||
default_success_message='scan_all_started',
|
||||
metadata={'scope': 'all'},
|
||||
)
|
||||
|
||||
|
||||
def _execute_plex_action(
|
||||
*,
|
||||
session: Session,
|
||||
payload: dict[str, Any],
|
||||
action: str,
|
||||
steps: list[str],
|
||||
connector_call: Callable[[PlexConnector], dict[str, Any]],
|
||||
default_success_message: str,
|
||||
metadata: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
target = 'plex'
|
||||
source = str(payload.get('source', 'manual')).strip() or 'manual'
|
||||
policy = evaluate_policy(session=session, action=action, target=target, source=source)
|
||||
risk = str(policy.get('risk', 'medium'))
|
||||
|
||||
if policy['decision'] == 'blocked':
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk,
|
||||
policy_decision='blocked',
|
||||
reason=policy['reason'],
|
||||
result='blocked',
|
||||
execution_plan={'plan': steps, 'policyReason': policy['reason']},
|
||||
redacted_metadata=metadata,
|
||||
)
|
||||
return {'success': False, 'decision': 'blocked', 'status': 'failed', 'policy_decision': 'blocked', 'result': 'blocked', 'message': policy['reason'], 'auditId': audit.id}
|
||||
|
||||
if policy['decision'] == 'approval_required':
|
||||
approval = create_approval(session, action=action, target=target, source=source, risk=risk, reason=policy['reason'], requester=source)
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk,
|
||||
policy_decision='approval_required',
|
||||
reason=policy['reason'],
|
||||
result='approval_requested',
|
||||
execution_plan={'plan': steps, 'approvalId': approval.id},
|
||||
redacted_metadata={**metadata, 'approval_id': approval.id},
|
||||
)
|
||||
return {'success': False, 'decision': 'approval_required', 'status': 'failed', 'policy_decision': 'approval_required', 'result': 'approval_required', 'message': 'Action requires approval before execution.', 'approvalId': approval.id, 'auditId': audit.id}
|
||||
|
||||
job = create_job(session, action_id=action, target=target, source=source, risk=risk, policy_decision='allowed', steps=steps)
|
||||
start_job(session, job)
|
||||
set_job_progress(session, job, 35, 'Policy allowed; calling Plex connector.')
|
||||
|
||||
connector_result = connector_call(PlexConnector())
|
||||
success = bool(connector_result.get('success'))
|
||||
message = str(connector_result.get('message', default_success_message if success else 'plex_failed'))
|
||||
completed = complete_job(session, job, status='succeeded' if success else 'failed', result_summary=message)
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk,
|
||||
policy_decision='allowed',
|
||||
reason=policy['reason'],
|
||||
result='success' if success else 'failed',
|
||||
execution_plan={'plan': steps, 'jobId': completed.id},
|
||||
after_state={'status': connector_result.get('status'), 'message': message},
|
||||
redacted_metadata={**metadata, 'connector': 'plex'},
|
||||
)
|
||||
|
||||
if not success:
|
||||
return {'success': False, 'decision': 'failed', 'status': 'failed', 'policy_decision': 'allowed', 'result': 'failed', 'message': message, 'jobId': completed.id, 'auditId': audit.id, 'data': connector_result.get('data')}
|
||||
|
||||
return {'success': True, 'decision': 'allowed', 'status': 'success', 'policy_decision': 'allowed', 'result': 'success', 'message': message, 'jobId': completed.id, 'auditId': audit.id, 'data': connector_result.get('data', {})}
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..db.session import get_session
|
||||
from ..db.models import Policy
|
||||
from ..services.policy_engine import evaluate_policy
|
||||
from ..services.action_registry import action_capability
|
||||
from ..services.audit_logger import log_event
|
||||
|
||||
router = APIRouter(prefix='/api/policies')
|
||||
DECISIONS = {'allowed', 'approval_required', 'blocked'}
|
||||
RISKS = {'low', 'medium', 'high', 'critical'}
|
||||
|
||||
|
||||
class PolicyUpdate(BaseModel):
|
||||
voice: str
|
||||
manual: str
|
||||
risk: str
|
||||
reason: str
|
||||
|
||||
|
||||
@router.get('')
|
||||
def list_policies(session: Session = Depends(get_session)):
|
||||
return session.exec(select(Policy).order_by(Policy.action, Policy.target)).all()
|
||||
|
||||
|
||||
@router.patch('/{policy_id}')
|
||||
def update_policy(policy_id: str, payload: PolicyUpdate, session: Session = Depends(get_session)):
|
||||
policy = session.get(Policy, policy_id)
|
||||
if not policy:
|
||||
raise HTTPException(status_code=404, detail='Policy not found.')
|
||||
if payload.voice not in DECISIONS or payload.manual not in DECISIONS or payload.risk not in RISKS:
|
||||
raise HTTPException(status_code=422, detail='Invalid policy decision or risk value.')
|
||||
if not payload.reason.strip():
|
||||
raise HTTPException(status_code=422, detail='A policy reason is required.')
|
||||
|
||||
capability = action_capability(policy.action)
|
||||
if capability == 'destructive' and (payload.voice != 'blocked' or payload.manual != 'blocked'):
|
||||
raise HTTPException(status_code=409, detail='Destructive actions remain blocked by the platform guardrail.')
|
||||
|
||||
before = {'voice': policy.voice, 'manual': policy.manual, 'risk': policy.risk, 'reason': policy.reason}
|
||||
policy.voice = payload.voice
|
||||
policy.manual = payload.manual
|
||||
policy.risk = 'critical' if capability == 'destructive' else payload.risk
|
||||
policy.reason = payload.reason.strip()
|
||||
session.add(policy)
|
||||
session.commit()
|
||||
session.refresh(policy)
|
||||
after = {'voice': policy.voice, 'manual': policy.manual, 'risk': policy.risk, 'reason': policy.reason}
|
||||
log_event(
|
||||
session,
|
||||
source='manual',
|
||||
identity='operator',
|
||||
action_id='policy.update',
|
||||
target=policy.id,
|
||||
target_type='policy',
|
||||
risk='medium',
|
||||
policy_decision='allowed',
|
||||
reason='Operator updated policy decisions within enforced platform guardrails.',
|
||||
result='success',
|
||||
before_state=before,
|
||||
after_state=after,
|
||||
redacted_metadata={'protectedGuardrail': capability == 'destructive', 'policyAction': policy.action},
|
||||
)
|
||||
return {
|
||||
'id': policy.id,
|
||||
'action': policy.action,
|
||||
'target': policy.target,
|
||||
'voice': policy.voice,
|
||||
'manual': policy.manual,
|
||||
'risk': policy.risk,
|
||||
'reason': policy.reason,
|
||||
}
|
||||
|
||||
|
||||
@router.post('/evaluate')
|
||||
def evaluate(payload: dict, session: Session = Depends(get_session)):
|
||||
action = str(payload.get('action', '')).strip()
|
||||
target = str(payload.get('target', '')).strip()
|
||||
source = str(payload.get('source', 'manual')).strip() or 'manual'
|
||||
result = evaluate_policy(session, action=action, target=target, source=source)
|
||||
|
||||
return {
|
||||
'decision': result['decision'],
|
||||
'reason': result['reason'],
|
||||
'risk': result['risk'],
|
||||
'policyId': result['policy_id'],
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..db.session import get_session
|
||||
from ..services.audit_logger import log_event
|
||||
from ..services.policy_engine import evaluate_policy
|
||||
from ..services.privatemuse_artifact_installer import (
|
||||
ArtifactInstallError,
|
||||
install,
|
||||
validate_request,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/privatemuse/artifacts")
|
||||
|
||||
|
||||
class ArtifactInstallRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
artifact_type: str = Field(pattern="^(lora|checkpoint|unet|vae|clip|gguf)$")
|
||||
source_url: str = Field(min_length=9, max_length=2048)
|
||||
sha256: str = Field(pattern="^[0-9a-f]{64}$")
|
||||
filename: str = Field(min_length=13, max_length=192)
|
||||
source_id: str = Field(min_length=1, max_length=240)
|
||||
license: str = Field(min_length=1, max_length=240)
|
||||
reason: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
|
||||
@router.post("/preflight")
|
||||
def preflight(
|
||||
payload: ArtifactInstallRequest,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
action = "privatemuse.comfyui.install_artifact"
|
||||
target = "privatemuse/comfyui"
|
||||
decision = evaluate_policy(
|
||||
session,
|
||||
action=action,
|
||||
target=target,
|
||||
source="manual",
|
||||
)
|
||||
try:
|
||||
normalized = validate_request(payload.model_dump())
|
||||
except ArtifactInstallError as exc:
|
||||
raise HTTPException(422, str(exc)) from exc
|
||||
return {
|
||||
"ready": decision["decision"] != "blocked",
|
||||
"policy": decision,
|
||||
"artifact": {
|
||||
"artifact_type": normalized["artifact_type"],
|
||||
"filename": normalized["filename"],
|
||||
"sha256": normalized["sha256"],
|
||||
"source_id": normalized["source_id"],
|
||||
"source_host": normalized["source_url"].split("/")[2],
|
||||
"license": normalized["license"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/install")
|
||||
def install_artifact(
|
||||
payload: ArtifactInstallRequest,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
action = "privatemuse.comfyui.install_artifact"
|
||||
target = "privatemuse/comfyui"
|
||||
decision = evaluate_policy(
|
||||
session,
|
||||
action=action,
|
||||
target=target,
|
||||
source="manual",
|
||||
)
|
||||
if decision["decision"] != "allowed":
|
||||
raise HTTPException(
|
||||
409,
|
||||
"Artifact installation requires an explicitly allowed policy/approved execution context.",
|
||||
)
|
||||
model_root = Path(
|
||||
os.environ.get(
|
||||
"APPOPS_PRIVATEMUSE_COMFYUI_MODEL_ROOT",
|
||||
"/mnt/privatemuse-comfyui-models",
|
||||
)
|
||||
)
|
||||
receipt_root = Path(
|
||||
os.environ.get(
|
||||
"APPOPS_PRIVATEMUSE_ARTIFACT_RECEIPT_ROOT",
|
||||
"/data/privatemuse-artifact-receipts",
|
||||
)
|
||||
)
|
||||
try:
|
||||
result = install(
|
||||
payload.model_dump(),
|
||||
model_root=model_root,
|
||||
receipt_root=receipt_root,
|
||||
)
|
||||
except ArtifactInstallError as exc:
|
||||
log_event(
|
||||
session,
|
||||
source="action",
|
||||
identity="manual",
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk="high",
|
||||
policy_decision="allowed",
|
||||
reason=payload.reason,
|
||||
result="failed",
|
||||
redacted_metadata={
|
||||
"filename": payload.filename,
|
||||
"sha256": payload.sha256,
|
||||
"source_id": payload.source_id,
|
||||
},
|
||||
)
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
audit = log_event(
|
||||
session,
|
||||
source="action",
|
||||
identity="manual",
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk="high",
|
||||
policy_decision="allowed",
|
||||
reason=payload.reason,
|
||||
result="success",
|
||||
after_state={
|
||||
"status": result["status"],
|
||||
"sha256": result["sha256"],
|
||||
"bytes": result["bytes"],
|
||||
},
|
||||
redacted_metadata={
|
||||
"filename": payload.filename,
|
||||
"source_id": payload.source_id,
|
||||
"license": payload.license,
|
||||
},
|
||||
)
|
||||
return {**result, "auditId": audit.id}
|
||||
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..connectors.prowlarr import ProwlarrConnector
|
||||
from ..db.session import get_session
|
||||
from ..services.approval_service import create_approval
|
||||
from ..services.audit_logger import log_event
|
||||
from ..services.policy_engine import evaluate_policy
|
||||
from .connector_runtime import load_connector_runtime
|
||||
|
||||
router = APIRouter(prefix='/api/apps/prowlarr', dependencies=[Depends(load_connector_runtime)])
|
||||
|
||||
|
||||
@router.get('/health')
|
||||
def health():
|
||||
return ProwlarrConnector().health()
|
||||
|
||||
|
||||
@router.get('/indexers')
|
||||
def indexers():
|
||||
return ProwlarrConnector().indexer_status()
|
||||
|
||||
|
||||
@router.get('/applications')
|
||||
def applications():
|
||||
return ProwlarrConnector().application_status()
|
||||
|
||||
|
||||
@router.get('/indexer-health')
|
||||
def indexer_health():
|
||||
return ProwlarrConnector().indexer_health()
|
||||
|
||||
|
||||
@router.get('/sync-status')
|
||||
def sync_status():
|
||||
return ProwlarrConnector().sync_status()
|
||||
|
||||
|
||||
@router.get('/search-test')
|
||||
def search_test(q: str = '', source: str = 'manual', session: Session = Depends(get_session)):
|
||||
query = q.strip()
|
||||
if not query:
|
||||
return {'success': False, 'status': 'error', 'message': 'query_required', 'total': 0, 'items': []}
|
||||
|
||||
identity = source.strip() or 'manual'
|
||||
policy = evaluate_policy(session=session, action='prowlarr.search_test', target='prowlarr', source=identity)
|
||||
risk = str(policy.get('risk', 'medium'))
|
||||
|
||||
if policy['decision'] == 'blocked':
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=identity,
|
||||
action_id='prowlarr.search_test',
|
||||
target='prowlarr',
|
||||
risk=risk,
|
||||
policy_decision='blocked',
|
||||
reason=policy['reason'],
|
||||
result='blocked',
|
||||
redacted_metadata={'query': query, 'downloadAllowed': False},
|
||||
)
|
||||
return {'success': False, 'decision': 'blocked', 'status': 'failed', 'policy_decision': 'blocked', 'result': 'blocked', 'message': policy['reason'], 'auditId': audit.id}
|
||||
|
||||
if policy['decision'] == 'approval_required':
|
||||
approval = create_approval(session, action='prowlarr.search_test', target='prowlarr', source=identity, risk=risk, reason=policy['reason'], requester=identity)
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=identity,
|
||||
action_id='prowlarr.search_test',
|
||||
target='prowlarr',
|
||||
risk=risk,
|
||||
policy_decision='approval_required',
|
||||
reason=policy['reason'],
|
||||
result='approval_requested',
|
||||
redacted_metadata={'query': query, 'approval_id': approval.id, 'downloadAllowed': False},
|
||||
)
|
||||
return {'success': False, 'decision': 'approval_required', 'status': 'failed', 'policy_decision': 'approval_required', 'result': 'approval_required', 'message': 'Action requires approval before execution.', 'approvalId': approval.id, 'auditId': audit.id}
|
||||
|
||||
result = ProwlarrConnector().search_test(query)
|
||||
success = bool(result.get('success'))
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=identity,
|
||||
action_id='prowlarr.search_test',
|
||||
target='prowlarr',
|
||||
risk=risk,
|
||||
policy_decision='allowed',
|
||||
reason=policy['reason'],
|
||||
result='success' if success else 'failed',
|
||||
redacted_metadata={'query': query, 'resultCount': int(result.get('total', 0)), 'downloadAllowed': False},
|
||||
)
|
||||
result['auditId'] = audit.id
|
||||
result['downloadAllowed'] = False
|
||||
return result
|
||||
@@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..connectors.radarr import RadarrConnector
|
||||
from ..db.session import get_session
|
||||
from ..services.approval_service import create_approval
|
||||
from ..services.audit_logger import log_event
|
||||
from ..services.job_runner import complete_job, create_job, set_job_progress, start_job
|
||||
from ..services.policy_engine import evaluate_policy
|
||||
from .connector_runtime import load_connector_runtime
|
||||
|
||||
router = APIRouter(prefix='/api/apps/radarr', dependencies=[Depends(load_connector_runtime)])
|
||||
|
||||
|
||||
_ADD_MOVIE_STEPS = [
|
||||
'Validate payload',
|
||||
'Evaluate policy',
|
||||
'Resolve root folder / quality profile defaults',
|
||||
'Add movie in Radarr',
|
||||
'Optional search movie',
|
||||
'Verify movie exists',
|
||||
'Complete audit/job',
|
||||
]
|
||||
|
||||
|
||||
@router.get('/health')
|
||||
def health():
|
||||
return RadarrConnector().health()
|
||||
|
||||
|
||||
@router.get('/queue')
|
||||
def queue():
|
||||
return RadarrConnector().queue_status()
|
||||
|
||||
|
||||
@router.get('/wanted')
|
||||
def wanted():
|
||||
return RadarrConnector().wanted_missing()
|
||||
|
||||
|
||||
@router.get('/search')
|
||||
def search(q: str = ''):
|
||||
query = q.strip()
|
||||
if not query:
|
||||
return {'success': False, 'status': 'error', 'message': 'query_required', 'items': []}
|
||||
return RadarrConnector().search_movie(query)
|
||||
|
||||
|
||||
@router.post('/movie')
|
||||
def add_movie(payload: dict, session: Session = Depends(get_session)):
|
||||
action = 'radarr.add_movie'
|
||||
target = 'radarr'
|
||||
source = str(payload.get('source', 'manual')).strip() or 'manual'
|
||||
policy = evaluate_policy(session=session, action=action, target=target, source=source)
|
||||
risk = str(policy.get('risk', 'low'))
|
||||
|
||||
if policy['decision'] == 'blocked':
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk,
|
||||
policy_decision='blocked',
|
||||
reason=policy['reason'],
|
||||
result='blocked',
|
||||
execution_plan={'plan': _ADD_MOVIE_STEPS, 'policyReason': policy['reason']},
|
||||
)
|
||||
return {
|
||||
'success': False,
|
||||
'decision': 'blocked',
|
||||
'status': 'failed',
|
||||
'policy_decision': 'blocked',
|
||||
'result': 'blocked',
|
||||
'message': policy['reason'],
|
||||
'auditId': audit.id,
|
||||
}
|
||||
|
||||
if policy['decision'] == 'approval_required':
|
||||
approval = create_approval(
|
||||
session,
|
||||
action=action,
|
||||
target=target,
|
||||
source=source,
|
||||
risk=risk,
|
||||
reason=policy['reason'],
|
||||
requester=source,
|
||||
)
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk,
|
||||
policy_decision='approval_required',
|
||||
reason=policy['reason'],
|
||||
result='approval_requested',
|
||||
execution_plan={'plan': _ADD_MOVIE_STEPS, 'approvalId': approval.id},
|
||||
redacted_metadata={'approval_id': approval.id},
|
||||
)
|
||||
return {
|
||||
'success': False,
|
||||
'decision': 'approval_required',
|
||||
'status': 'failed',
|
||||
'policy_decision': 'approval_required',
|
||||
'result': 'approval_required',
|
||||
'message': 'Action requires approval before execution.',
|
||||
'approvalId': approval.id,
|
||||
'auditId': audit.id,
|
||||
}
|
||||
|
||||
job = create_job(
|
||||
session,
|
||||
action_id=action,
|
||||
target=target,
|
||||
source=source,
|
||||
risk=risk,
|
||||
policy_decision='allowed',
|
||||
steps=_ADD_MOVIE_STEPS,
|
||||
)
|
||||
start_job(session, job)
|
||||
set_job_progress(session, job, 35, 'Payload validated and policy allowed.')
|
||||
|
||||
connector_result = RadarrConnector().add_movie(payload)
|
||||
success = bool(connector_result.get('success'))
|
||||
result_status = 'succeeded' if success else 'failed'
|
||||
message = str(connector_result.get('message', 'movie_added' if success else 'radarr_failed'))
|
||||
|
||||
completed = complete_job(
|
||||
session,
|
||||
job,
|
||||
status=result_status,
|
||||
result_summary=message,
|
||||
)
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk,
|
||||
policy_decision='allowed',
|
||||
reason=policy['reason'],
|
||||
result='success' if success else 'failed',
|
||||
execution_plan={'plan': _ADD_MOVIE_STEPS, 'jobId': completed.id},
|
||||
after_state={'status': connector_result.get('status'), 'message': message},
|
||||
redacted_metadata={'connector': 'radarr'},
|
||||
)
|
||||
|
||||
if not success:
|
||||
return {
|
||||
'success': False,
|
||||
'decision': 'failed',
|
||||
'status': 'failed',
|
||||
'policy_decision': 'allowed',
|
||||
'result': 'failed',
|
||||
'message': message,
|
||||
'jobId': completed.id,
|
||||
'auditId': audit.id,
|
||||
'data': connector_result.get('data'),
|
||||
}
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'decision': 'allowed',
|
||||
'status': 'success',
|
||||
'policy_decision': 'allowed',
|
||||
'result': 'success',
|
||||
'message': message,
|
||||
'jobId': completed.id,
|
||||
'auditId': audit.id,
|
||||
'data': connector_result.get('data', {}),
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel
|
||||
from fastapi.responses import FileResponse, PlainTextResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..core.security import (
|
||||
OPERATOR_SESSION_COOKIE,
|
||||
issue_operator_session,
|
||||
operator_session_status,
|
||||
require_operator_request,
|
||||
revoke_operator_session,
|
||||
)
|
||||
from ..db.session import get_session
|
||||
from ..services.audit_logger import log_event
|
||||
from ..services.deployment_target_registry import DeploymentTargetRegistry
|
||||
from ..services.maintenance_scheduler import maintenance_status
|
||||
from ..services.request_metrics import (
|
||||
request_metrics_openmetrics,
|
||||
request_metrics_snapshot,
|
||||
)
|
||||
from ..services.runtime_service import (
|
||||
checkpoint_sqlite,
|
||||
create_sqlite_backup,
|
||||
deep_health,
|
||||
export_audit_summary,
|
||||
export_config,
|
||||
export_inventory,
|
||||
export_policies,
|
||||
get_sqlite_backup_path,
|
||||
list_sqlite_backups,
|
||||
operator_auth_policy,
|
||||
runtime_info,
|
||||
sqlite_maintenance_status,
|
||||
verify_sqlite_backup,
|
||||
update_operator_auth_policy,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix='/api')
|
||||
|
||||
|
||||
class OperatorAuthPolicyRequest(BaseModel):
|
||||
required: bool
|
||||
|
||||
|
||||
class DeploymentTargetRegistrationRequest(BaseModel):
|
||||
repository: str
|
||||
revision: str
|
||||
|
||||
|
||||
@router.get('/runtime/info')
|
||||
def get_runtime_info(session: Session = Depends(get_session)):
|
||||
return runtime_info(session)
|
||||
|
||||
|
||||
@router.get('/operator/session')
|
||||
def get_operator_session(request: Request):
|
||||
return operator_session_status(request)
|
||||
|
||||
|
||||
@router.get('/runtime/operator-auth')
|
||||
def get_operator_auth_policy():
|
||||
return operator_auth_policy()
|
||||
|
||||
|
||||
@router.patch('/runtime/operator-auth')
|
||||
def patch_operator_auth_policy(payload: OperatorAuthPolicyRequest, session: Session = Depends(get_session)):
|
||||
return update_operator_auth_policy(session, payload.required)
|
||||
|
||||
|
||||
@router.post('/admin/deployment-targets/single-container')
|
||||
def admin_register_single_container_deployment_target(
|
||||
payload: DeploymentTargetRegistrationRequest,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Register one bounded legacy Actions deployment target.
|
||||
|
||||
This is deliberately not a generic Docker exec or host-file API. AppOps
|
||||
derives the exact-source path, Compose filename/project and container name
|
||||
from a canonical Jens repository identifier. The controller-scoped bridge
|
||||
validates the immutable revision and Compose file directly against the bare
|
||||
Gitea repository before it updates the deployment registry. Webhook secrets
|
||||
and modern runnerless webhook onboarding are explicitly out of scope here.
|
||||
"""
|
||||
require_operator_request(request)
|
||||
result = DeploymentTargetRegistry().register_single_container(
|
||||
payload.repository,
|
||||
payload.revision,
|
||||
)
|
||||
status = str(result.get('status') or 'error')
|
||||
succeeded = status == 'registered'
|
||||
audit = log_event(
|
||||
session,
|
||||
source='operator',
|
||||
identity='operator',
|
||||
action_id='deployment.target.register',
|
||||
target=str(payload.repository).strip().lower(),
|
||||
target_type='deployment-target',
|
||||
risk='high',
|
||||
policy_decision='allowed',
|
||||
reason='Operator requested bounded single-container deployment target onboarding.',
|
||||
result='success' if succeeded else 'failed',
|
||||
execution_plan={
|
||||
'steps': [
|
||||
'validate canonical Jens repository and immutable revision',
|
||||
'derive controller-owned exact-source and Compose topology',
|
||||
'verify the exact revision is on main and contains docker-compose.yml',
|
||||
'atomically update deployment controller projects.json',
|
||||
'leave the registry unchanged on any source or validation failure',
|
||||
]
|
||||
},
|
||||
after_state={
|
||||
'registered': succeeded,
|
||||
'repository': str(result.get('repository') or str(payload.repository).strip().lower()),
|
||||
'revision': str(result.get('revision') or payload.revision),
|
||||
'slug': str(result.get('slug') or ''),
|
||||
},
|
||||
redacted_metadata={
|
||||
'scope': 'legacy-actions-single-container',
|
||||
'webhookSecretsCreated': False,
|
||||
},
|
||||
)
|
||||
if not succeeded:
|
||||
code = 400 if status == 'invalid' else 409 if status == 'conflict' else 502
|
||||
raise HTTPException(
|
||||
status_code=code,
|
||||
detail={
|
||||
'status': status,
|
||||
'reason': str(result.get('reason') or 'deployment target registration failed'),
|
||||
'auditId': audit.id,
|
||||
},
|
||||
)
|
||||
return {
|
||||
'success': True,
|
||||
'status': status,
|
||||
'repository': result.get('repository'),
|
||||
'revision': result.get('revision'),
|
||||
'slug': result.get('slug'),
|
||||
'composeProject': result.get('compose_project'),
|
||||
'container': result.get('container'),
|
||||
'auditId': audit.id,
|
||||
}
|
||||
|
||||
|
||||
def _session_cookie_secure(request: Request) -> bool:
|
||||
forwarded_proto = request.headers.get('X-Forwarded-Proto', '').split(',', 1)[0].strip().lower()
|
||||
return request.url.scheme == 'https' or forwarded_proto == 'https'
|
||||
|
||||
|
||||
@router.post('/operator/session')
|
||||
def create_operator_session(request: Request, response: Response):
|
||||
session_id = issue_operator_session(request)
|
||||
response.set_cookie(
|
||||
OPERATOR_SESSION_COOKIE,
|
||||
session_id,
|
||||
httponly=True,
|
||||
secure=_session_cookie_secure(request),
|
||||
samesite='strict',
|
||||
path='/',
|
||||
)
|
||||
return {'authorized': True, 'mode': 'session', 'identity': None}
|
||||
|
||||
|
||||
@router.delete('/operator/session')
|
||||
def delete_operator_session(request: Request, response: Response):
|
||||
require_operator_request(request)
|
||||
revoke_operator_session(request)
|
||||
response.delete_cookie(
|
||||
OPERATOR_SESSION_COOKIE,
|
||||
httponly=True,
|
||||
secure=_session_cookie_secure(request),
|
||||
samesite='strict',
|
||||
path='/',
|
||||
)
|
||||
return {'authorized': False, 'mode': 'locked', 'identity': None}
|
||||
|
||||
|
||||
@router.get('/health/deep')
|
||||
def get_deep_health(session: Session = Depends(get_session)):
|
||||
return deep_health(session)
|
||||
|
||||
|
||||
@router.get('/admin/observability/requests')
|
||||
def admin_request_metrics():
|
||||
return request_metrics_snapshot()
|
||||
|
||||
|
||||
@router.get('/admin/observability/metrics', response_class=PlainTextResponse)
|
||||
def admin_openmetrics():
|
||||
return PlainTextResponse(request_metrics_openmetrics(), media_type='text/plain; version=0.0.4')
|
||||
|
||||
|
||||
@router.get('/admin/maintenance/status')
|
||||
def admin_maintenance_status():
|
||||
return maintenance_status()
|
||||
|
||||
|
||||
@router.get('/admin/export/config')
|
||||
def admin_export_config(session: Session = Depends(get_session)):
|
||||
return export_config(session)
|
||||
|
||||
|
||||
@router.get('/admin/export/policies')
|
||||
def admin_export_policies(session: Session = Depends(get_session)):
|
||||
return export_policies(session)
|
||||
|
||||
|
||||
@router.get('/admin/export/inventory')
|
||||
def admin_export_inventory(session: Session = Depends(get_session)):
|
||||
return export_inventory(session)
|
||||
|
||||
|
||||
@router.get('/admin/export/audit-summary')
|
||||
def admin_export_audit_summary(session: Session = Depends(get_session)):
|
||||
return export_audit_summary(session)
|
||||
|
||||
|
||||
@router.get('/admin/backups/sqlite')
|
||||
def admin_list_sqlite_backups():
|
||||
return list_sqlite_backups()
|
||||
|
||||
|
||||
@router.get('/admin/database/status')
|
||||
def admin_database_status():
|
||||
return sqlite_maintenance_status()
|
||||
|
||||
|
||||
@router.post('/admin/database/checkpoint')
|
||||
def admin_database_checkpoint(session: Session = Depends(get_session)):
|
||||
return checkpoint_sqlite(session)
|
||||
|
||||
|
||||
@router.post('/admin/backups/sqlite')
|
||||
def admin_create_sqlite_backup(session: Session = Depends(get_session)):
|
||||
return create_sqlite_backup(session)
|
||||
|
||||
|
||||
@router.post('/admin/backups/sqlite/{name}/verify')
|
||||
def admin_verify_sqlite_backup(name: str, session: Session = Depends(get_session)):
|
||||
result = verify_sqlite_backup(session, name)
|
||||
if result.get('status') == 'not_found':
|
||||
raise HTTPException(status_code=404, detail='SQLite backup not found.')
|
||||
return result
|
||||
|
||||
|
||||
@router.get('/admin/backups/sqlite/{name}/download')
|
||||
def admin_download_sqlite_backup(name: str):
|
||||
path = get_sqlite_backup_path(name)
|
||||
if not path:
|
||||
raise HTTPException(status_code=404, detail='SQLite backup not found.')
|
||||
return FileResponse(
|
||||
path=path,
|
||||
filename=path.name,
|
||||
media_type='application/vnd.sqlite3',
|
||||
headers={'Cache-Control': 'no-store'},
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..db.session import get_session
|
||||
from ..services import safety_center_service
|
||||
|
||||
router = APIRouter(prefix='/api/safety')
|
||||
|
||||
|
||||
@router.get('/dashboard')
|
||||
def dashboard(refresh: bool = False, session: Session = Depends(get_session)):
|
||||
return safety_center_service.get_dashboard(session, force_refresh=refresh)
|
||||
|
||||
|
||||
@router.get('/summary')
|
||||
def summary(session: Session = Depends(get_session)):
|
||||
return safety_center_service.get_summary(session)
|
||||
|
||||
|
||||
@router.get('/sections')
|
||||
def sections(session: Session = Depends(get_session)):
|
||||
return safety_center_service.get_sections(session)
|
||||
|
||||
|
||||
@router.get('/findings')
|
||||
def findings(session: Session = Depends(get_session)):
|
||||
return safety_center_service.get_findings(session)
|
||||
|
||||
|
||||
@router.get('/recommendations')
|
||||
def recommendations(session: Session = Depends(get_session)):
|
||||
return safety_center_service.get_recommendations(session)
|
||||
|
||||
|
||||
@router.get('/score-breakdown')
|
||||
def score_breakdown(session: Session = Depends(get_session)):
|
||||
return safety_center_service.get_score_breakdown(session)
|
||||
|
||||
|
||||
@router.post('/refresh')
|
||||
def refresh(session: Session = Depends(get_session)):
|
||||
return safety_center_service.refresh(session)
|
||||
|
||||
|
||||
@router.post('/findings/{finding_id}/acknowledge')
|
||||
def acknowledge_finding(finding_id: str, session: Session = Depends(get_session)):
|
||||
return safety_center_service.acknowledge_finding(session, finding_id)
|
||||
|
||||
|
||||
@router.post('/findings/{finding_id}/dismiss')
|
||||
def dismiss_finding(finding_id: str, session: Session = Depends(get_session)):
|
||||
return safety_center_service.dismiss_finding(session, finding_id)
|
||||
|
||||
|
||||
@router.post('/recommendations/{recommendation_id}/dismiss')
|
||||
def dismiss_recommendation(recommendation_id: str, session: Session = Depends(get_session)):
|
||||
return safety_center_service.dismiss_recommendation(session, recommendation_id)
|
||||
|
||||
|
||||
@router.post('/recommendations/{recommendation_id}/create-remediation-plan')
|
||||
def create_remediation_plan(recommendation_id: str, session: Session = Depends(get_session)):
|
||||
result = safety_center_service.create_remediation_plan(session, recommendation_id)
|
||||
if result.get('status') == 'not_found':
|
||||
raise HTTPException(status_code=404, detail='recommendation_not_found')
|
||||
return result
|
||||
@@ -0,0 +1,233 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..connectors.deluge import DelugeConnector
|
||||
from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
from ..connectors.authentik import AuthentikConnector
|
||||
from ..connectors.plex import PlexConnector
|
||||
from ..connectors.prowlarr import ProwlarrConnector
|
||||
from ..connectors.radarr import RadarrConnector
|
||||
from ..connectors.sonarr import SonarrConnector
|
||||
from ..connectors.tautulli import TautulliConnector
|
||||
from ..connectors.widefrog import WidefrogConnector
|
||||
from ..core.redaction import redact_message
|
||||
from ..db.session import get_session
|
||||
from ..services.setup_service import (
|
||||
complete_setup,
|
||||
get_setup_status,
|
||||
list_connector_settings,
|
||||
mark_setup_complete,
|
||||
mark_voice_configured,
|
||||
reconcile_setup,
|
||||
save_connector_settings,
|
||||
serialize_connector_settings,
|
||||
test_portainer_setup,
|
||||
test_unraid_setup,
|
||||
)
|
||||
from ..services.secret_store import list_secret_status, secret_storage_status
|
||||
from ..core.network_security import UnsafeConnectorUrl
|
||||
|
||||
router = APIRouter(prefix='/api')
|
||||
|
||||
|
||||
class ConnectorSettingsRequest(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
url: str | None = Field(default=None, max_length=2048)
|
||||
endpointId: str | None = Field(default=None, max_length=80)
|
||||
verifyTls: bool | None = None
|
||||
apiKey: str | None = Field(default=None, max_length=8192)
|
||||
token: str | None = Field(default=None, max_length=8192)
|
||||
password: str | None = Field(default=None, max_length=8192)
|
||||
secret: str | None = Field(default=None, max_length=8192)
|
||||
identity: str | None = Field(default=None, max_length=320)
|
||||
outpostUrl: str | None = Field(default=None, max_length=2048)
|
||||
expectEmbeddedOutpost: bool | None = None
|
||||
|
||||
|
||||
def _connector_payload(payload: ConnectorSettingsRequest) -> dict:
|
||||
return payload.model_dump(exclude_none=True)
|
||||
|
||||
|
||||
def _save_connector_settings(session: Session, connector_id: str, payload: dict):
|
||||
try:
|
||||
return save_connector_settings(session, connector_id, payload)
|
||||
except UnsafeConnectorUrl as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _authentik_setup_result(result: dict, settings: dict) -> dict:
|
||||
status = str(result.get('status', 'error'))
|
||||
message = str(result.get('reason') or status)
|
||||
required_fields: list[str] = []
|
||||
if status == 'unreachable' and 'HTTP 404' in message:
|
||||
status = 'url_error'
|
||||
required_fields = ['AUTHENTIK_URL']
|
||||
message = 'Authentik API gaf HTTP 404 terug. Controleer dat de Authentik URL de root/base URL is, niet een app-, flow- of outpost-pad.'
|
||||
if status == 'auth_failed':
|
||||
required_fields = ['AUTHENTIK_TOKEN']
|
||||
message = 'Authentik heeft de token geweigerd: token ongeldig of verlopen. Gebruik de waarde van een Authentik API Token, niet de tokennaam, identifier of een App password, en sla die opnieuw op.'
|
||||
return {
|
||||
'success': status == 'connected',
|
||||
'status': status,
|
||||
'connected': status == 'connected',
|
||||
'message': message,
|
||||
'requiredFields': required_fields,
|
||||
'settings': settings,
|
||||
}
|
||||
|
||||
|
||||
def _normalized_url(settings: dict) -> str:
|
||||
safe_config = settings.get('safeConfig') if isinstance(settings.get('safeConfig'), dict) else {}
|
||||
return str(safe_config.get('url') or '').strip().rstrip('/').lower()
|
||||
|
||||
|
||||
def _connector_setup_result(connector_id: str, result: dict, settings: dict, session: Session) -> dict:
|
||||
status = str(result.get('status', 'error'))
|
||||
message = str(result.get('reason') or status)
|
||||
required_fields = list(settings.get('missing') or [])
|
||||
if not required_fields and status in {'error', 'unreachable', 'auth_failed'}:
|
||||
required_fields = list(_CONNECTOR_REQUIRED_FIELDS.get(connector_id, []))
|
||||
|
||||
if connector_id == 'prowlarr' and status in {'error', 'unreachable', 'auth_failed'} and message == 'unauthorized':
|
||||
prowlarr_url = _normalized_url(settings)
|
||||
for sibling_id in ('radarr', 'sonarr'):
|
||||
sibling = serialize_connector_settings(session, sibling_id)
|
||||
if prowlarr_url and prowlarr_url == _normalized_url(sibling):
|
||||
label = 'Radarr' if sibling_id == 'radarr' else 'Sonarr'
|
||||
return {
|
||||
'success': False,
|
||||
'status': 'url_mismatch',
|
||||
'connected': False,
|
||||
'message': f'Prowlarr geeft unauthorized terug en gebruikt dezelfde URL als {label}. Controleer PROWLARR_URL en PROWLARR_API_KEY; Prowlarr draait meestal op poort 9696.',
|
||||
'requiredFields': ['PROWLARR_URL', 'PROWLARR_API_KEY'],
|
||||
'settings': settings,
|
||||
}
|
||||
|
||||
return {
|
||||
'success': status == 'connected',
|
||||
'status': status,
|
||||
'connected': status == 'connected',
|
||||
'message': redact_message(message),
|
||||
'requiredFields': required_fields,
|
||||
'settings': settings,
|
||||
}
|
||||
|
||||
|
||||
_CONNECTOR_TESTS = {
|
||||
'sonarr': SonarrConnector,
|
||||
'radarr': RadarrConnector,
|
||||
'prowlarr': ProwlarrConnector,
|
||||
'deluge': DelugeConnector,
|
||||
'plex': PlexConnector,
|
||||
'tautulli': TautulliConnector,
|
||||
'widefrog': WidefrogConnector,
|
||||
}
|
||||
|
||||
|
||||
_CONNECTOR_REQUIRED_FIELDS = {
|
||||
'sonarr': ['SONARR_URL', 'SONARR_API_KEY'],
|
||||
'radarr': ['RADARR_URL', 'RADARR_API_KEY'],
|
||||
'prowlarr': ['PROWLARR_URL', 'PROWLARR_API_KEY'],
|
||||
'deluge': ['DELUGE_URL', 'DELUGE_PASSWORD'],
|
||||
'plex': ['PLEX_URL', 'PLEX_TOKEN'],
|
||||
'tautulli': ['TAUTULLI_URL', 'TAUTULLI_API_KEY'],
|
||||
'widefrog': ['WIDEFROG_URL'],
|
||||
}
|
||||
|
||||
|
||||
@router.get('/setup/status')
|
||||
def setup_status(session: Session = Depends(get_session)):
|
||||
return get_setup_status(session)
|
||||
|
||||
|
||||
@router.post('/setup/complete')
|
||||
def setup_complete(session: Session = Depends(get_session)):
|
||||
return complete_setup(session)
|
||||
|
||||
|
||||
@router.post('/setup/reconcile')
|
||||
def setup_reconcile(session: Session = Depends(get_session)):
|
||||
return reconcile_setup(session)
|
||||
|
||||
|
||||
@router.post('/setup/mark-complete')
|
||||
def setup_mark_complete(payload: dict, session: Session = Depends(get_session)):
|
||||
return mark_setup_complete(session, payload)
|
||||
|
||||
|
||||
@router.post('/setup/voice/mark-configured')
|
||||
def setup_voice_configured(session: Session = Depends(get_session)):
|
||||
return mark_voice_configured(session)
|
||||
|
||||
|
||||
@router.post('/setup/test/portainer')
|
||||
def setup_test_portainer(session: Session = Depends(get_session)):
|
||||
return test_portainer_setup(session)
|
||||
|
||||
|
||||
@router.post('/setup/test/unraid')
|
||||
def setup_test_unraid(session: Session = Depends(get_session)):
|
||||
return test_unraid_setup(session)
|
||||
|
||||
|
||||
@router.post('/setup/test/connector/{connector_id}')
|
||||
def setup_test_connector(connector_id: str, session: Session = Depends(get_session)):
|
||||
if connector_id == 'portainer':
|
||||
return test_portainer_setup(session)
|
||||
if connector_id == 'unraid':
|
||||
return test_unraid_setup(session)
|
||||
if connector_id == 'nginx_proxy_manager':
|
||||
settings = serialize_connector_settings(session, connector_id)
|
||||
if settings['missing']:
|
||||
return {'success': False, 'status': 'missing_config', 'connected': False, 'requiredFields': settings['missing'], 'message': 'missing_config', 'settings': settings}
|
||||
result = NginxProxyManagerConnector().test_connection()
|
||||
status = str(result.get('status', 'error'))
|
||||
return {'success': status == 'connected', 'status': status, 'connected': status == 'connected', 'message': str(result.get('reason') or status), 'settings': settings}
|
||||
if connector_id == 'authentik':
|
||||
settings = serialize_connector_settings(session, connector_id)
|
||||
if settings['missing']:
|
||||
return {'success': False, 'status': 'missing_config', 'connected': False, 'requiredFields': settings['missing'], 'message': 'missing_config', 'settings': settings}
|
||||
result = AuthentikConnector().test_connection()
|
||||
return _authentik_setup_result(result, settings)
|
||||
connector_cls = _CONNECTOR_TESTS.get(connector_id)
|
||||
if connector_cls:
|
||||
settings = serialize_connector_settings(session, connector_id)
|
||||
if settings['missing']:
|
||||
return {'success': False, 'status': 'missing_config', 'connected': False, 'requiredFields': settings['missing'], 'message': 'missing_config', 'settings': settings}
|
||||
result = connector_cls().test_connection()
|
||||
return _connector_setup_result(connector_id, result, settings, session)
|
||||
return {
|
||||
'success': False,
|
||||
'status': 'unsupported',
|
||||
'connected': False,
|
||||
'message': f'setup_test_not_implemented_for_{connector_id}',
|
||||
'settings': serialize_connector_settings(session, connector_id),
|
||||
}
|
||||
|
||||
|
||||
@router.get('/settings/connectors')
|
||||
def settings_connectors(session: Session = Depends(get_session)):
|
||||
return list_connector_settings(session)
|
||||
|
||||
|
||||
@router.get('/settings/secrets')
|
||||
def settings_secrets(session: Session = Depends(get_session)):
|
||||
return {'storage': secret_storage_status(), 'secrets': list_secret_status(session)}
|
||||
|
||||
|
||||
@router.patch('/settings/connectors/portainer')
|
||||
def update_portainer_settings(payload: ConnectorSettingsRequest, session: Session = Depends(get_session)):
|
||||
return _save_connector_settings(session, 'portainer', _connector_payload(payload))
|
||||
|
||||
|
||||
@router.patch('/settings/connectors/unraid')
|
||||
def update_unraid_settings(payload: ConnectorSettingsRequest, session: Session = Depends(get_session)):
|
||||
return _save_connector_settings(session, 'unraid', _connector_payload(payload))
|
||||
|
||||
|
||||
@router.patch('/settings/connectors/{connector_id}')
|
||||
def update_connector_settings(connector_id: str, payload: ConnectorSettingsRequest, session: Session = Depends(get_session)):
|
||||
return _save_connector_settings(session, connector_id, _connector_payload(payload))
|
||||
@@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..connectors.sonarr import SonarrConnector
|
||||
from ..db.session import get_session
|
||||
from ..services.approval_service import create_approval
|
||||
from ..services.audit_logger import log_event
|
||||
from ..services.job_runner import complete_job, create_job, set_job_progress, start_job
|
||||
from ..services.policy_engine import evaluate_policy
|
||||
from .connector_runtime import load_connector_runtime
|
||||
|
||||
router = APIRouter(prefix='/api/apps/sonarr', dependencies=[Depends(load_connector_runtime)])
|
||||
|
||||
|
||||
_ADD_SERIES_STEPS = [
|
||||
'Validate payload',
|
||||
'Evaluate policy',
|
||||
'Resolve root folder / quality profile defaults',
|
||||
'Add series in Sonarr',
|
||||
'Optional search missing episodes',
|
||||
'Verify series exists',
|
||||
'Complete audit/job',
|
||||
]
|
||||
|
||||
|
||||
@router.get('/health')
|
||||
def health():
|
||||
return SonarrConnector().health()
|
||||
|
||||
|
||||
@router.get('/queue')
|
||||
def queue():
|
||||
return SonarrConnector().queue_status()
|
||||
|
||||
|
||||
@router.get('/wanted')
|
||||
def wanted():
|
||||
return SonarrConnector().wanted_missing()
|
||||
|
||||
|
||||
@router.get('/search')
|
||||
def search(q: str = ''):
|
||||
query = q.strip()
|
||||
if not query:
|
||||
return {'success': False, 'status': 'error', 'message': 'query_required', 'items': []}
|
||||
return SonarrConnector().search_series(query)
|
||||
|
||||
|
||||
@router.post('/series')
|
||||
def add_series(payload: dict, session: Session = Depends(get_session)):
|
||||
action = 'sonarr.add_series'
|
||||
target = 'sonarr'
|
||||
source = str(payload.get('source', 'manual')).strip() or 'manual'
|
||||
policy = evaluate_policy(session=session, action=action, target=target, source=source)
|
||||
risk = str(policy.get('risk', 'low'))
|
||||
|
||||
if policy['decision'] == 'blocked':
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk,
|
||||
policy_decision='blocked',
|
||||
reason=policy['reason'],
|
||||
result='blocked',
|
||||
execution_plan={'plan': _ADD_SERIES_STEPS, 'policyReason': policy['reason']},
|
||||
)
|
||||
return {
|
||||
'success': False,
|
||||
'decision': 'blocked',
|
||||
'status': 'failed',
|
||||
'policy_decision': 'blocked',
|
||||
'result': 'blocked',
|
||||
'message': policy['reason'],
|
||||
'auditId': audit.id,
|
||||
}
|
||||
|
||||
if policy['decision'] == 'approval_required':
|
||||
approval = create_approval(
|
||||
session,
|
||||
action=action,
|
||||
target=target,
|
||||
source=source,
|
||||
risk=risk,
|
||||
reason=policy['reason'],
|
||||
requester=source,
|
||||
)
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk,
|
||||
policy_decision='approval_required',
|
||||
reason=policy['reason'],
|
||||
result='approval_requested',
|
||||
execution_plan={'plan': _ADD_SERIES_STEPS, 'approvalId': approval.id},
|
||||
redacted_metadata={'approval_id': approval.id},
|
||||
)
|
||||
return {
|
||||
'success': False,
|
||||
'decision': 'approval_required',
|
||||
'status': 'failed',
|
||||
'policy_decision': 'approval_required',
|
||||
'result': 'approval_required',
|
||||
'message': 'Action requires approval before execution.',
|
||||
'approvalId': approval.id,
|
||||
'auditId': audit.id,
|
||||
}
|
||||
|
||||
job = create_job(
|
||||
session,
|
||||
action_id=action,
|
||||
target=target,
|
||||
source=source,
|
||||
risk=risk,
|
||||
policy_decision='allowed',
|
||||
steps=_ADD_SERIES_STEPS,
|
||||
)
|
||||
start_job(session, job)
|
||||
set_job_progress(session, job, 35, 'Payload validated and policy allowed.')
|
||||
|
||||
connector_result = SonarrConnector().add_series(payload)
|
||||
success = bool(connector_result.get('success'))
|
||||
result_status = 'succeeded' if success else 'failed'
|
||||
message = str(connector_result.get('message', 'series_added' if success else 'sonarr_failed'))
|
||||
|
||||
completed = complete_job(
|
||||
session,
|
||||
job,
|
||||
status=result_status,
|
||||
result_summary=message,
|
||||
)
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk,
|
||||
policy_decision='allowed',
|
||||
reason=policy['reason'],
|
||||
result='success' if success else 'failed',
|
||||
execution_plan={'plan': _ADD_SERIES_STEPS, 'jobId': completed.id},
|
||||
after_state={'status': connector_result.get('status'), 'message': message},
|
||||
redacted_metadata={'connector': 'sonarr'},
|
||||
)
|
||||
|
||||
if not success:
|
||||
return {
|
||||
'success': False,
|
||||
'decision': 'failed',
|
||||
'status': 'failed',
|
||||
'policy_decision': 'allowed',
|
||||
'result': 'failed',
|
||||
'message': message,
|
||||
'jobId': completed.id,
|
||||
'auditId': audit.id,
|
||||
'data': connector_result.get('data'),
|
||||
}
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'decision': 'allowed',
|
||||
'status': 'success',
|
||||
'policy_decision': 'allowed',
|
||||
'result': 'success',
|
||||
'message': message,
|
||||
'jobId': completed.id,
|
||||
'auditId': audit.id,
|
||||
'data': connector_result.get('data', {}),
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from ..connectors.portainer import PortainerConnector
|
||||
from ..core.redaction import redact_message
|
||||
|
||||
router = APIRouter(prefix='/api/stacks')
|
||||
|
||||
|
||||
def _raise_connector_error(response: dict):
|
||||
status = str(response.get('status', 'error'))
|
||||
reason = redact_message(str(response.get('reason', 'connection_failed')))
|
||||
status_code = 503 if status == 'missing_config' else 502
|
||||
raise HTTPException(
|
||||
status_code=status_code,
|
||||
detail={
|
||||
'status': status,
|
||||
'reason': reason,
|
||||
'connector': 'portainer',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get('')
|
||||
def list_stacks():
|
||||
response = PortainerConnector().list_stacks()
|
||||
if response.get('status') == 'ok':
|
||||
return response.get('stacks', [])
|
||||
_raise_connector_error(response)
|
||||
|
||||
|
||||
@router.get('/{stack_id}')
|
||||
def get_stack(stack_id: str):
|
||||
response = PortainerConnector().get_stack_status(stack_id)
|
||||
if response.get('status') == 'ok':
|
||||
return response.get('stack')
|
||||
if response.get('status') == 'not_found':
|
||||
return None
|
||||
_raise_connector_error(response)
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..connectors.unraid import UnraidConnector
|
||||
from ..core.redaction import redact_message
|
||||
from ..db.models import AuditEvent, Connector
|
||||
from ..db.session import get_session
|
||||
from ..services.incident_service import count_open_incidents
|
||||
from ..services.setup_service import apply_stored_connector_settings, list_connector_settings
|
||||
|
||||
router = APIRouter(prefix='/api/system')
|
||||
|
||||
|
||||
def _gateway_health(unraid_status: str, portainer_status: str, open_incidents: int) -> str:
|
||||
core_statuses = {unraid_status, portainer_status}
|
||||
if core_statuses & {'missing_config', 'error', 'disconnected'}:
|
||||
return 'critical'
|
||||
if open_incidents > 0:
|
||||
return 'warning'
|
||||
return 'healthy'
|
||||
|
||||
|
||||
def _latest_action_label(session: Session) -> str | None:
|
||||
event = session.exec(select(AuditEvent).order_by(AuditEvent.timestamp.desc())).first()
|
||||
if event is None:
|
||||
return None
|
||||
action = str(event.action_id or '').strip()
|
||||
target = str(event.target or '').strip()
|
||||
if not action:
|
||||
return None
|
||||
label = f'{action} -> {target}' if target else action
|
||||
return redact_message(label)[:120]
|
||||
|
||||
|
||||
@router.get('/summary')
|
||||
def system_summary(session: Session = Depends(get_session)):
|
||||
apply_stored_connector_settings(session)
|
||||
connectors = session.exec(select(Connector)).all()
|
||||
status_map = {connector.id: connector.status for connector in connectors}
|
||||
config_map = {item["connectorId"]: item for item in list_connector_settings(session)}
|
||||
|
||||
portainer_config = config_map.get('portainer', {})
|
||||
portainer_status = 'missing_config' if portainer_config.get('missing') else status_map.get('portainer', 'disconnected')
|
||||
if portainer_status not in {'connected', 'missing_config', 'error', 'disconnected'}:
|
||||
portainer_status = 'disconnected'
|
||||
|
||||
unraid_config = config_map.get('unraid', {})
|
||||
unraid_status = 'missing_config' if unraid_config.get('missing') else status_map.get('unraid', 'disconnected')
|
||||
if unraid_status not in {'connected', 'missing_config', 'error', 'disconnected'}:
|
||||
unraid_status = 'disconnected'
|
||||
|
||||
unraid = UnraidConnector()
|
||||
server_info = unraid.get_server_info() if unraid.is_configured() else {'status': 'missing_config'}
|
||||
metrics = unraid.get_system_metrics() if unraid.is_configured() else {'status': 'missing_config'}
|
||||
cache = unraid.get_cache_status() if unraid.is_configured() else {'status': 'missing_config'}
|
||||
server = server_info.get('info', {}) if server_info.get('status') == 'ok' else {}
|
||||
cpu_usage = metrics.get('cpuUsage') if isinstance(metrics.get('cpuUsage'), int | float) else 0
|
||||
ram_usage = metrics.get('ramUsage') if isinstance(metrics.get('ramUsage'), int | float) else 0
|
||||
cache_usage = cache.get('cacheUsage') if cache.get('status') == 'ok' and isinstance(cache.get('cacheUsage'), int | float) else 0
|
||||
open_incidents = count_open_incidents(session)
|
||||
last_action = _latest_action_label(session)
|
||||
|
||||
return {
|
||||
'gatewayHealth': _gateway_health(unraid_status, portainer_status, open_incidents),
|
||||
'unraidStatus': unraid_status,
|
||||
'portainerStatus': portainer_status,
|
||||
'maintenanceMode': False,
|
||||
'openIncidents': open_incidents,
|
||||
'lastAction': last_action,
|
||||
'uptime': str(server.get('uptime') or 'unknown'),
|
||||
'cpuUsage': int(cpu_usage),
|
||||
'ramUsage': int(ram_usage),
|
||||
'cacheUsage': int(cache_usage),
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..connectors.tautulli import TautulliConnector
|
||||
from ..db.session import get_session
|
||||
from ..services.audit_logger import log_event
|
||||
from ..services.policy_engine import evaluate_policy
|
||||
from .connector_runtime import load_connector_runtime
|
||||
|
||||
router = APIRouter(prefix='/api/apps/tautulli', dependencies=[Depends(load_connector_runtime)])
|
||||
|
||||
|
||||
def _read(
|
||||
session: Session,
|
||||
*,
|
||||
action: str,
|
||||
source: str,
|
||||
risk_default: str,
|
||||
call: Callable[[TautulliConnector], dict[str, Any]],
|
||||
audit_read: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
identity = source.strip() or 'manual'
|
||||
policy = evaluate_policy(session=session, action=action, target='tautulli', source=identity)
|
||||
risk = str(policy.get('risk', risk_default))
|
||||
if policy['decision'] == 'blocked':
|
||||
audit = log_event(
|
||||
session,
|
||||
source='read',
|
||||
identity=identity,
|
||||
action_id=action,
|
||||
target='tautulli',
|
||||
risk=risk,
|
||||
policy_decision='blocked',
|
||||
reason=policy['reason'],
|
||||
result='blocked',
|
||||
redacted_metadata={'privacySensitive': audit_read},
|
||||
)
|
||||
return {'success': False, 'decision': 'blocked', 'status': 'failed', 'message': policy['reason'], 'auditId': audit.id}
|
||||
if policy['decision'] == 'approval_required':
|
||||
audit = log_event(
|
||||
session,
|
||||
source='read',
|
||||
identity=identity,
|
||||
action_id=action,
|
||||
target='tautulli',
|
||||
risk=risk,
|
||||
policy_decision='approval_required',
|
||||
reason=policy['reason'],
|
||||
result='approval_required',
|
||||
redacted_metadata={'privacySensitive': audit_read},
|
||||
)
|
||||
return {'success': False, 'decision': 'approval_required', 'status': 'failed', 'message': 'Action requires approval before execution.', 'auditId': audit.id}
|
||||
result = call(TautulliConnector())
|
||||
if audit_read:
|
||||
log_event(
|
||||
session,
|
||||
source='read',
|
||||
identity=identity,
|
||||
action_id=action,
|
||||
target='tautulli',
|
||||
risk=risk,
|
||||
policy_decision='allowed',
|
||||
reason=policy['reason'],
|
||||
result='success' if result.get('success') else 'failed',
|
||||
redacted_metadata={'privacySensitive': True, 'status': result.get('status')},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get('/health')
|
||||
def health():
|
||||
return TautulliConnector().health()
|
||||
|
||||
|
||||
@router.get('/status')
|
||||
def status():
|
||||
return TautulliConnector().status()
|
||||
|
||||
|
||||
@router.get('/activity')
|
||||
def activity():
|
||||
return TautulliConnector().activity()
|
||||
|
||||
|
||||
@router.get('/history')
|
||||
def history(limit: int = 25, source: str = 'manual', session: Session = Depends(get_session)):
|
||||
return _read(session, action='tautulli.history', source=source, risk_default='medium', call=lambda connector: connector.history(limit), audit_read=True)
|
||||
|
||||
|
||||
@router.get('/users')
|
||||
def users(source: str = 'manual', session: Session = Depends(get_session)):
|
||||
return _read(session, action='tautulli.users', source=source, risk_default='medium', call=lambda connector: connector.users(), audit_read=True)
|
||||
|
||||
|
||||
@router.get('/libraries')
|
||||
def libraries():
|
||||
return TautulliConnector().libraries()
|
||||
|
||||
|
||||
@router.get('/recently-added')
|
||||
def recently_added():
|
||||
return TautulliConnector().recently_added()
|
||||
|
||||
|
||||
@router.get('/watch-stats')
|
||||
def watch_stats(source: str = 'manual', session: Session = Depends(get_session)):
|
||||
return _read(session, action='tautulli.watch_stats', source=source, risk_default='medium', call=lambda connector: connector.watch_stats(), audit_read=True)
|
||||
|
||||
|
||||
@router.get('/streams')
|
||||
def streams():
|
||||
return TautulliConnector().stream_summary()
|
||||
|
||||
|
||||
@router.get('/plex-info')
|
||||
def plex_info():
|
||||
return TautulliConnector().plex_info()
|
||||
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..connectors.unraid import UnraidConnector
|
||||
from ..db.session import get_session
|
||||
from ..services.unraid_vm_service import request_vm_action, vm_capabilities
|
||||
from .connector_runtime import load_connector_runtime
|
||||
|
||||
router = APIRouter(prefix='/api/unraid', dependencies=[Depends(load_connector_runtime)])
|
||||
|
||||
|
||||
def _default_summary():
|
||||
return {
|
||||
'arrayStatus': 'stopped',
|
||||
'parityStatus': 'invalid',
|
||||
'cacheUsage': 0,
|
||||
'diskWarnings': 0,
|
||||
'uptime': 'unknown',
|
||||
'dockerService': False,
|
||||
'cpuUsage': 0,
|
||||
'ramUsage': 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get('/summary')
|
||||
def summary():
|
||||
connector = UnraidConnector()
|
||||
data = _default_summary()
|
||||
if not connector.is_configured():
|
||||
return data
|
||||
|
||||
server_info = connector.get_server_info()
|
||||
if server_info.get('status') == 'ok':
|
||||
info = server_info.get('info', {})
|
||||
data['uptime'] = str(info.get('uptime', 'unknown'))
|
||||
|
||||
metrics = connector.get_system_metrics()
|
||||
if metrics.get('status') in {'ok', 'partial'}:
|
||||
if isinstance(metrics.get('cpuUsage'), int | float):
|
||||
data['cpuUsage'] = int(metrics.get('cpuUsage', 0))
|
||||
if isinstance(metrics.get('ramUsage'), int | float):
|
||||
data['ramUsage'] = int(metrics.get('ramUsage', 0))
|
||||
|
||||
array_status = connector.get_array_status()
|
||||
if array_status.get('status') == 'ok':
|
||||
data['arrayStatus'] = str(array_status.get('arrayStatus', 'stopped'))
|
||||
|
||||
disk_summary = connector.get_disk_warnings()
|
||||
if disk_summary.get('status') == 'ok':
|
||||
data['diskWarnings'] = int(disk_summary.get('diskWarnings', 0))
|
||||
|
||||
cache_summary = connector.get_cache_status()
|
||||
if cache_summary.get('status') == 'ok':
|
||||
data['cacheUsage'] = int(cache_summary.get('cacheUsage', 0))
|
||||
|
||||
parity_summary = connector.get_parity_status()
|
||||
if parity_summary.get('status') == 'ok':
|
||||
data['parityStatus'] = str(parity_summary.get('parityStatus', 'invalid'))
|
||||
|
||||
docker_summary = connector.get_docker_service_status()
|
||||
if docker_summary.get('status') == 'ok':
|
||||
data['dockerService'] = bool(docker_summary.get('running', False))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@router.get('/array')
|
||||
def array():
|
||||
result = UnraidConnector().get_array_status()
|
||||
if result.get('status') == 'ok':
|
||||
return {'status': result.get('arrayStatus', 'stopped')}
|
||||
if result.get('status') == 'missing_config':
|
||||
return {'status': 'missing_config', 'reason': result.get('reason', 'missing_config')}
|
||||
return {'status': 'disconnected', 'reason': result.get('reason', 'connection_failed')}
|
||||
|
||||
|
||||
@router.get('/disks')
|
||||
def disks():
|
||||
result = UnraidConnector().get_disk_warnings()
|
||||
if result.get('status') == 'ok':
|
||||
return {'disks': result.get('disks', []), 'diskWarnings': result.get('diskWarnings', 0)}
|
||||
if result.get('status') == 'missing_config':
|
||||
return {'disks': [], 'diskWarnings': 0, 'status': 'missing_config', 'reason': result.get('reason', 'missing_config')}
|
||||
return {'disks': [], 'diskWarnings': 0, 'status': 'disconnected', 'reason': result.get('reason', 'connection_failed')}
|
||||
|
||||
|
||||
@router.get('/cache')
|
||||
def cache():
|
||||
result = UnraidConnector().get_cache_status()
|
||||
if result.get('status') == 'ok':
|
||||
return {'cacheUsage': result.get('cacheUsage', 0), 'pools': result.get('pools', [])}
|
||||
if result.get('status') == 'missing_config':
|
||||
return {'cacheUsage': 0, 'pools': [], 'status': 'missing_config', 'reason': result.get('reason', 'missing_config')}
|
||||
return {'cacheUsage': 0, 'pools': [], 'status': 'disconnected', 'reason': result.get('reason', 'connection_failed')}
|
||||
|
||||
|
||||
@router.get('/parity')
|
||||
def parity():
|
||||
result = UnraidConnector().get_parity_status()
|
||||
if result.get('status') == 'ok':
|
||||
return {
|
||||
'status': result.get('parityStatus', 'invalid'),
|
||||
'checkStatus': result.get('checkStatus', 'idle'),
|
||||
'running': bool(result.get('running', False)),
|
||||
'paused': bool(result.get('paused', False)),
|
||||
'correcting': bool(result.get('correcting', False)),
|
||||
'progress': result.get('progress'),
|
||||
'speed': result.get('speed'),
|
||||
'errors': int(result.get('errors', 0)),
|
||||
}
|
||||
if result.get('status') == 'missing_config':
|
||||
return {'status': 'missing_config', 'reason': result.get('reason', 'missing_config')}
|
||||
return {'status': 'disconnected', 'reason': result.get('reason', 'connection_failed')}
|
||||
|
||||
|
||||
@router.get('/docker')
|
||||
def docker():
|
||||
result = UnraidConnector().get_docker_service_status()
|
||||
if result.get('status') == 'ok':
|
||||
return {
|
||||
'status': result.get('serviceStatus', 'stopped'),
|
||||
'running': result.get('running', False),
|
||||
}
|
||||
if result.get('status') == 'missing_config':
|
||||
return {'status': 'missing_config', 'running': False, 'reason': result.get('reason', 'missing_config')}
|
||||
return {'status': 'disconnected', 'running': False, 'reason': result.get('reason', 'connection_failed')}
|
||||
|
||||
|
||||
@router.get('/vms')
|
||||
def vms():
|
||||
result = UnraidConnector().get_vm_summary()
|
||||
if result.get('status') == 'ok':
|
||||
return {'vms': result.get('vms', [])}
|
||||
if result.get('status') == 'missing_config':
|
||||
return {'vms': [], 'status': 'missing_config', 'reason': result.get('reason', 'missing_config')}
|
||||
if result.get('status') in {'unavailable', 'api_unavailable'}:
|
||||
return {'vms': [], 'status': result.get('status'), 'reason': result.get('reason', 'VM API unavailable')}
|
||||
return {'vms': [], 'status': 'disconnected', 'reason': result.get('reason', 'connection_failed')}
|
||||
|
||||
|
||||
@router.get('/vms/capabilities')
|
||||
def vms_capabilities(session: Session = Depends(get_session)):
|
||||
return vm_capabilities(session)
|
||||
|
||||
|
||||
@router.post('/vms/{vm_id}/actions/{action}')
|
||||
def run_vm_action(vm_id: str, action: str, payload: dict, session: Session = Depends(get_session)):
|
||||
return request_vm_action(
|
||||
session,
|
||||
vm_id=vm_id,
|
||||
action=action,
|
||||
source=str(payload.get('source') or 'manual'),
|
||||
confirmed=payload.get('confirm') is True,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/shares')
|
||||
def shares():
|
||||
result = UnraidConnector().get_share_summary()
|
||||
if result.get('status') == 'ok':
|
||||
return {'shares': result.get('shares', [])}
|
||||
if result.get('status') == 'missing_config':
|
||||
return {'shares': [], 'status': 'missing_config', 'reason': result.get('reason', 'missing_config')}
|
||||
return {'shares': [], 'status': 'disconnected', 'reason': result.get('reason', 'connection_failed')}
|
||||
@@ -0,0 +1,388 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..connectors.authentik import AuthentikConnector
|
||||
from ..connectors.deluge import DelugeConnector
|
||||
from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector
|
||||
from ..connectors.plex import PlexConnector
|
||||
from ..connectors.prowlarr import ProwlarrConnector
|
||||
from ..connectors.radarr import RadarrConnector
|
||||
from ..connectors.sonarr import SonarrConnector
|
||||
from ..connectors.tautulli import TautulliConnector
|
||||
from ..connectors.unraid import UnraidConnector
|
||||
from ..connectors.widefrog import WidefrogConnector
|
||||
from ..core.redaction import redact_message
|
||||
from ..db.session import get_session
|
||||
from ..services.approval_service import create_approval
|
||||
from ..services.audit_logger import log_event
|
||||
from ..services.container_restart_service import execute_container_restart
|
||||
from ..services.policy_engine import evaluate_policy
|
||||
from ..services import media_pipeline_service, notification_service, safety_center_service
|
||||
from ..services.setup_service import apply_stored_connector_settings, get_setup_status
|
||||
from ..services import voice_catalog
|
||||
|
||||
router = APIRouter(prefix='/api/voice')
|
||||
|
||||
|
||||
def _handle_voice_action(
|
||||
session: Session,
|
||||
action: str,
|
||||
target: str,
|
||||
risk: str,
|
||||
steps: list[str] | None = None,
|
||||
reader: Callable[[], Any] | None = None,
|
||||
):
|
||||
policy = evaluate_policy(session=session, action=action, target=target, source='voice')
|
||||
decision = policy['decision']
|
||||
risk_level = policy.get('risk', risk)
|
||||
normalized_steps = steps or []
|
||||
|
||||
if decision == 'blocked':
|
||||
audit = log_event(
|
||||
session,
|
||||
source='voice',
|
||||
identity='voice',
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk_level,
|
||||
policy_decision=decision,
|
||||
reason=policy['reason'],
|
||||
result='blocked',
|
||||
execution_plan={'plan': normalized_steps, 'policyReason': policy['reason']},
|
||||
redacted_metadata={'policyId': policy.get('policy_id')},
|
||||
)
|
||||
return {
|
||||
'success': False,
|
||||
'decision': 'blocked',
|
||||
'status': 'failed',
|
||||
'policy_decision': decision,
|
||||
'result': 'blocked',
|
||||
'message': policy['reason'],
|
||||
'auditId': audit.id,
|
||||
}
|
||||
|
||||
if decision == 'approval_required':
|
||||
approval = create_approval(
|
||||
session,
|
||||
action=action,
|
||||
target=target,
|
||||
source='voice',
|
||||
risk=risk_level,
|
||||
reason=policy['reason'],
|
||||
requester='voice',
|
||||
)
|
||||
audit = log_event(
|
||||
session,
|
||||
source='voice',
|
||||
identity='voice',
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk_level,
|
||||
policy_decision=decision,
|
||||
reason=policy['reason'],
|
||||
result='approval_requested',
|
||||
execution_plan={'plan': normalized_steps, 'approvalId': approval.id},
|
||||
redacted_metadata={'approval_id': approval.id},
|
||||
)
|
||||
return {
|
||||
'success': False,
|
||||
'decision': 'approval_required',
|
||||
'status': 'failed',
|
||||
'policy_decision': decision,
|
||||
'result': 'approval_requested',
|
||||
'message': 'Action requires approval before execution.',
|
||||
'approvalId': approval.id,
|
||||
'auditId': audit.id,
|
||||
}
|
||||
|
||||
if decision == 'allowed':
|
||||
try:
|
||||
data = reader() if reader else None
|
||||
except Exception as exc: # noqa: BLE001 - voice must return a governed, redacted failure.
|
||||
message = redact_message(str(exc))
|
||||
audit = log_event(
|
||||
session, source='voice', identity='voice', action_id=action, target=target,
|
||||
risk=risk_level, policy_decision=decision, reason=policy['reason'], result='failed',
|
||||
execution_plan={'plan': normalized_steps}, redacted_metadata={'error': message[:200]},
|
||||
)
|
||||
return {
|
||||
'success': False, 'decision': decision, 'status': 'failed',
|
||||
'policy_decision': decision, 'result': 'failed', 'message': message, 'auditId': audit.id,
|
||||
}
|
||||
audit = log_event(
|
||||
session,
|
||||
source='voice',
|
||||
identity='voice',
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk_level,
|
||||
policy_decision=decision,
|
||||
reason=policy['reason'],
|
||||
result='success',
|
||||
execution_plan={'plan': normalized_steps, 'policyReason': policy['reason']},
|
||||
)
|
||||
response = {
|
||||
'success': True,
|
||||
'decision': 'allowed',
|
||||
'status': 'success',
|
||||
'policy_decision': decision,
|
||||
'result': 'success',
|
||||
'message': f'Voice action {action} allowed for {target}.',
|
||||
'auditId': audit.id,
|
||||
}
|
||||
if data is not None:
|
||||
response['data'] = data
|
||||
return response
|
||||
|
||||
return {
|
||||
'success': False,
|
||||
'decision': 'blocked',
|
||||
'status': 'failed',
|
||||
'policy_decision': 'blocked',
|
||||
'result': 'blocked',
|
||||
'message': 'Action policy outcome unsupported.',
|
||||
}
|
||||
|
||||
|
||||
@router.get('/commands')
|
||||
def voice_commands(session: Session = Depends(get_session)):
|
||||
return voice_catalog.list_commands(session)
|
||||
|
||||
|
||||
@router.get('/home-assistant/export')
|
||||
def voice_export(request: Request, session: Session = Depends(get_session)):
|
||||
apply_stored_connector_settings(session)
|
||||
return voice_catalog.home_assistant_export(session, str(request.base_url).rstrip('/'))
|
||||
|
||||
|
||||
@router.get('/home-assistant/export/rest-commands')
|
||||
def voice_export_rest_commands(request: Request, session: Session = Depends(get_session)):
|
||||
return {'yaml': voice_catalog.rest_commands_yaml(voice_catalog.list_commands(session), str(request.base_url).rstrip('/'))}
|
||||
|
||||
|
||||
@router.get('/home-assistant/export/scripts')
|
||||
def voice_export_scripts(session: Session = Depends(get_session)):
|
||||
return {'yaml': voice_catalog.scripts_yaml(voice_catalog.list_commands(session))}
|
||||
|
||||
|
||||
@router.get('/home-assistant/export/dashboard')
|
||||
def voice_export_dashboard(session: Session = Depends(get_session)):
|
||||
return {'yaml': voice_catalog.dashboard_yaml(voice_catalog.list_commands(session))}
|
||||
|
||||
|
||||
@router.post('/test-intent')
|
||||
def test_intent(payload: dict | None = None, session: Session = Depends(get_session)):
|
||||
text = str((payload or {}).get('input') or (payload or {}).get('phrase') or '').strip()
|
||||
return voice_catalog.test_intent(session, text)
|
||||
|
||||
|
||||
@router.post('/commands/{command_id}/dry-run')
|
||||
def dry_run_command(command_id: str, session: Session = Depends(get_session)):
|
||||
return voice_catalog.dry_run(session, command_id)
|
||||
|
||||
|
||||
@router.post('/commands/{command_id}/request-approval')
|
||||
def request_command_approval(command_id: str, session: Session = Depends(get_session)):
|
||||
return voice_catalog.request_approval(session, command_id)
|
||||
|
||||
|
||||
@router.post('/actions/container/{app_id}/restart')
|
||||
def restart_container(app_id: str, session: Session = Depends(get_session)):
|
||||
return execute_container_restart(
|
||||
session=session,
|
||||
action='container.restart',
|
||||
target=app_id,
|
||||
source='voice',
|
||||
risk='medium',
|
||||
steps=['Resolve container', 'Restart container', 'Voice post-check'],
|
||||
)
|
||||
|
||||
|
||||
@router.post('/actions/unraid/health')
|
||||
def unraid_health(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(
|
||||
session=session,
|
||||
action='unraid.health_summary',
|
||||
target='unraid',
|
||||
risk='low',
|
||||
steps=['Read unraid health summary'],
|
||||
reader=lambda: {
|
||||
'array': UnraidConnector().get_array_status(),
|
||||
'metrics': UnraidConnector().get_system_metrics(),
|
||||
'docker': UnraidConnector().get_docker_service_status(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post('/actions/setup/status')
|
||||
def setup_status(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(
|
||||
session=session,
|
||||
action='setup.status',
|
||||
target='appops',
|
||||
risk='low',
|
||||
steps=['Read AppOps setup status'],
|
||||
reader=lambda: get_setup_status(session),
|
||||
)
|
||||
|
||||
|
||||
@router.post('/actions/safety/summary')
|
||||
def safety_summary(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(
|
||||
session=session,
|
||||
action='safety.summary',
|
||||
target='safety',
|
||||
risk='low',
|
||||
steps=['Read Safety Center summary'],
|
||||
reader=lambda: safety_center_service.get_summary(session),
|
||||
)
|
||||
|
||||
|
||||
@router.post('/actions/media-pipeline/check')
|
||||
def media_pipeline_check(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(
|
||||
session=session,
|
||||
action='media_pipeline.check',
|
||||
target='media-pipeline',
|
||||
risk='low',
|
||||
steps=['Run read-only media pipeline check'],
|
||||
reader=lambda: media_pipeline_service.summary(session),
|
||||
)
|
||||
|
||||
|
||||
@router.post('/actions/notifications/summary')
|
||||
def notifications_summary(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(
|
||||
session=session,
|
||||
action='notifications.summary',
|
||||
target='notifications',
|
||||
risk='low',
|
||||
steps=['Read notification summary'],
|
||||
reader=lambda: notification_service.summary(session),
|
||||
)
|
||||
|
||||
|
||||
@router.post('/actions/notifications/evaluate/safety')
|
||||
def evaluate_safety_notifications(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(
|
||||
session=session,
|
||||
action='notifications.evaluate_safety',
|
||||
target='notifications',
|
||||
risk='medium',
|
||||
steps=['Evaluate safety notification rules'],
|
||||
)
|
||||
|
||||
|
||||
@router.post('/actions/apps/widefrog/health')
|
||||
def widefrog_health(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(
|
||||
session=session,
|
||||
action='widefrog.health',
|
||||
target='widefrog',
|
||||
risk='low',
|
||||
steps=['Read Widefrog health'],
|
||||
reader=lambda: WidefrogConnector().health(),
|
||||
)
|
||||
|
||||
|
||||
@router.post('/actions/apps/deluge/status')
|
||||
def deluge_status(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(
|
||||
session=session,
|
||||
action='deluge.session_status',
|
||||
target='deluge',
|
||||
risk='low',
|
||||
steps=['Read Deluge session status'],
|
||||
reader=lambda: DelugeConnector().session_status(),
|
||||
)
|
||||
|
||||
|
||||
@router.post('/actions/apps/plex/activity')
|
||||
def plex_activity(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(
|
||||
session=session,
|
||||
action='plex.active_sessions',
|
||||
target='plex',
|
||||
risk='low',
|
||||
steps=['Read Plex active sessions'],
|
||||
reader=lambda: PlexConnector().active_sessions(),
|
||||
)
|
||||
|
||||
|
||||
@router.post('/actions/unraid/storage')
|
||||
def unraid_storage(session: Session = Depends(get_session)):
|
||||
connector = UnraidConnector()
|
||||
return _handle_voice_action(
|
||||
session, 'unraid.health_summary', 'unraid', 'low', ['Read storage health'],
|
||||
reader=lambda: {'disks': connector.get_disk_warnings(), 'cache': connector.get_cache_status(), 'parity': connector.get_parity_status()},
|
||||
)
|
||||
|
||||
|
||||
@router.post('/actions/unraid/vms')
|
||||
def unraid_vms(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(session, 'unraid.health_summary', 'unraid', 'low', ['Read VM inventory'], reader=lambda: UnraidConnector().get_vm_summary())
|
||||
|
||||
|
||||
@router.post('/actions/apps/sonarr/health')
|
||||
def sonarr_health(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(session, 'sonarr.health', 'sonarr', 'low', ['Read Sonarr health'], reader=lambda: SonarrConnector().health())
|
||||
|
||||
|
||||
@router.post('/actions/apps/sonarr/queue')
|
||||
def sonarr_queue(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(session, 'sonarr.queue_status', 'sonarr', 'low', ['Read Sonarr queue'], reader=lambda: SonarrConnector().queue_status())
|
||||
|
||||
|
||||
@router.post('/actions/apps/radarr/health')
|
||||
def radarr_health(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(session, 'radarr.health', 'radarr', 'low', ['Read Radarr health'], reader=lambda: RadarrConnector().health())
|
||||
|
||||
|
||||
@router.post('/actions/apps/radarr/queue')
|
||||
def radarr_queue(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(session, 'radarr.queue_status', 'radarr', 'low', ['Read Radarr queue'], reader=lambda: RadarrConnector().queue_status())
|
||||
|
||||
|
||||
@router.post('/actions/apps/prowlarr/indexers')
|
||||
def prowlarr_indexers(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(session, 'prowlarr.indexer_health', 'prowlarr', 'low', ['Read Prowlarr indexer health'], reader=lambda: ProwlarrConnector().indexer_health())
|
||||
|
||||
|
||||
@router.post('/actions/apps/deluge/stalled')
|
||||
def deluge_stalled(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(session, 'deluge.stalled_downloads', 'deluge', 'low', ['Read stalled downloads'], reader=lambda: DelugeConnector().stalled_downloads())
|
||||
|
||||
|
||||
@router.post('/actions/apps/plex/recent')
|
||||
def plex_recent(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(session, 'plex.recent_media', 'plex', 'low', ['Read recent Plex media'], reader=lambda: PlexConnector().recent_media())
|
||||
|
||||
|
||||
@router.post('/actions/apps/tautulli/activity')
|
||||
def tautulli_activity(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(session, 'tautulli.activity', 'tautulli', 'low', ['Read Tautulli activity'], reader=lambda: TautulliConnector().activity())
|
||||
|
||||
|
||||
@router.post('/actions/security/exposure')
|
||||
def exposure_summary(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(session, 'npm.exposure_summary', 'nginx_proxy_manager', 'low', ['Read public exposure'], reader=lambda: NginxProxyManagerConnector().exposure_summary())
|
||||
|
||||
|
||||
@router.post('/actions/security/authentik')
|
||||
def authentik_health(session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(session, 'authentik.health', 'authentik', 'low', ['Read Authentik health'], reader=lambda: AuthentikConnector().health())
|
||||
|
||||
|
||||
@router.post('/actions/playbooks/{playbook_id}/run')
|
||||
def run_playbook(playbook_id: str, session: Session = Depends(get_session)):
|
||||
return _handle_voice_action(
|
||||
session=session,
|
||||
action='playbook.run',
|
||||
target=playbook_id,
|
||||
risk='medium',
|
||||
steps=['Run playbook'],
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..db.session import get_session
|
||||
from ..services import voice_catalog
|
||||
from ..services.home_assistant_voice_readiness import build_readiness
|
||||
from ..services.setup_service import apply_stored_connector_settings
|
||||
|
||||
|
||||
router = APIRouter(prefix='/api/operator/voice')
|
||||
|
||||
|
||||
@router.get('/commands')
|
||||
def commands(session: Session = Depends(get_session)):
|
||||
return voice_catalog.list_commands(session)
|
||||
|
||||
|
||||
@router.get('/home-assistant/export')
|
||||
def home_assistant_export(request: Request, session: Session = Depends(get_session)):
|
||||
apply_stored_connector_settings(session)
|
||||
return voice_catalog.home_assistant_export(session, str(request.base_url).rstrip('/'))
|
||||
|
||||
|
||||
@router.get('/home-assistant/readiness')
|
||||
def home_assistant_readiness(session: Session = Depends(get_session)):
|
||||
apply_stored_connector_settings(session)
|
||||
return build_readiness(session)
|
||||
|
||||
|
||||
@router.post('/test-intent')
|
||||
def test_intent(payload: dict | None = None, session: Session = Depends(get_session)):
|
||||
text = str((payload or {}).get('input') or (payload or {}).get('phrase') or '').strip()
|
||||
return voice_catalog.test_intent(session, text)
|
||||
|
||||
|
||||
@router.post('/commands/{command_id}/dry-run')
|
||||
def dry_run(command_id: str, session: Session = Depends(get_session)):
|
||||
return voice_catalog.dry_run(session, command_id)
|
||||
@@ -0,0 +1,160 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from ..connectors.widefrog import WidefrogConnector
|
||||
from ..db.session import get_session
|
||||
from ..services.approval_service import create_approval
|
||||
from ..services.audit_logger import log_event
|
||||
from ..services.job_runner import complete_job, create_job, set_job_progress, start_job
|
||||
from ..services.policy_engine import evaluate_policy
|
||||
from .connector_runtime import load_connector_runtime
|
||||
|
||||
router = APIRouter(prefix='/api/apps/widefrog', dependencies=[Depends(load_connector_runtime)])
|
||||
|
||||
|
||||
_RETRY_STEPS = [
|
||||
'Validate connector config',
|
||||
'Evaluate policy',
|
||||
'Fetch failed jobs summary',
|
||||
'Call retry endpoint',
|
||||
'Fetch queue/activity summary',
|
||||
'Complete audit/job',
|
||||
]
|
||||
|
||||
_SCAN_STEPS = [
|
||||
'Validate connector config',
|
||||
'Evaluate policy',
|
||||
'Check app health',
|
||||
'Call scan endpoint',
|
||||
'Verify accepted or status',
|
||||
'Complete audit/job',
|
||||
]
|
||||
|
||||
|
||||
@router.get('/health')
|
||||
def health():
|
||||
return WidefrogConnector().health()
|
||||
|
||||
|
||||
@router.get('/queue')
|
||||
def queue():
|
||||
return WidefrogConnector().queue_status()
|
||||
|
||||
|
||||
@router.get('/failed')
|
||||
def failed():
|
||||
return WidefrogConnector().failed_jobs()
|
||||
|
||||
|
||||
@router.get('/activity')
|
||||
def activity():
|
||||
return WidefrogConnector().activity()
|
||||
|
||||
|
||||
@router.get('/download-status')
|
||||
def download_status():
|
||||
return WidefrogConnector().download_status()
|
||||
|
||||
|
||||
@router.post('/retry-failed')
|
||||
def retry_failed(payload: dict | None = None, session: Session = Depends(get_session)):
|
||||
return _execute_widefrog_action(
|
||||
session=session,
|
||||
payload=payload or {},
|
||||
action='widefrog.retry_failed',
|
||||
steps=_RETRY_STEPS,
|
||||
connector_call=lambda connector, body: connector.retry_failed(body),
|
||||
default_success_message='retry_started',
|
||||
)
|
||||
|
||||
|
||||
@router.post('/scan-library')
|
||||
def scan_library(payload: dict | None = None, session: Session = Depends(get_session)):
|
||||
return _execute_widefrog_action(
|
||||
session=session,
|
||||
payload=payload or {},
|
||||
action='widefrog.scan_library',
|
||||
steps=_SCAN_STEPS,
|
||||
connector_call=lambda connector, body: connector.scan_library(body),
|
||||
default_success_message='scan_started',
|
||||
)
|
||||
|
||||
|
||||
def _execute_widefrog_action(
|
||||
*,
|
||||
session: Session,
|
||||
payload: dict[str, Any],
|
||||
action: str,
|
||||
steps: list[str],
|
||||
connector_call: Callable[[WidefrogConnector, dict[str, Any]], dict[str, Any]],
|
||||
default_success_message: str,
|
||||
) -> dict[str, Any]:
|
||||
target = 'widefrog'
|
||||
source = str(payload.get('source', 'manual')).strip() or 'manual'
|
||||
policy = evaluate_policy(session=session, action=action, target=target, source=source)
|
||||
risk = str(policy.get('risk', 'medium'))
|
||||
|
||||
if policy['decision'] == 'blocked':
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk,
|
||||
policy_decision='blocked',
|
||||
reason=policy['reason'],
|
||||
result='blocked',
|
||||
execution_plan={'plan': steps, 'policyReason': policy['reason']},
|
||||
)
|
||||
return {'success': False, 'decision': 'blocked', 'status': 'failed', 'policy_decision': 'blocked', 'result': 'blocked', 'message': policy['reason'], 'auditId': audit.id}
|
||||
|
||||
if policy['decision'] == 'approval_required':
|
||||
approval = create_approval(session, action=action, target=target, source=source, risk=risk, reason=policy['reason'], requester=source)
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk,
|
||||
policy_decision='approval_required',
|
||||
reason=policy['reason'],
|
||||
result='approval_requested',
|
||||
execution_plan={'plan': steps, 'approvalId': approval.id},
|
||||
redacted_metadata={'approval_id': approval.id},
|
||||
)
|
||||
return {'success': False, 'decision': 'approval_required', 'status': 'failed', 'policy_decision': 'approval_required', 'result': 'approval_required', 'message': 'Action requires approval before execution.', 'approvalId': approval.id, 'auditId': audit.id}
|
||||
|
||||
job = create_job(session, action_id=action, target=target, source=source, risk=risk, policy_decision='allowed', steps=steps)
|
||||
start_job(session, job)
|
||||
set_job_progress(session, job, 35, 'Policy allowed; calling Widefrog connector.')
|
||||
|
||||
connector_result = connector_call(WidefrogConnector(), payload)
|
||||
success = bool(connector_result.get('success'))
|
||||
message = str(connector_result.get('message', default_success_message if success else 'widefrog_failed'))
|
||||
completed = complete_job(session, job, status='succeeded' if success else 'failed', result_summary=message)
|
||||
audit = log_event(
|
||||
session,
|
||||
source='action',
|
||||
identity=source,
|
||||
action_id=action,
|
||||
target=target,
|
||||
risk=risk,
|
||||
policy_decision='allowed',
|
||||
reason=policy['reason'],
|
||||
result='success' if success else 'failed',
|
||||
execution_plan={'plan': steps, 'jobId': completed.id},
|
||||
after_state={'status': connector_result.get('status'), 'message': message},
|
||||
redacted_metadata={'connector': 'widefrog'},
|
||||
)
|
||||
|
||||
if not success:
|
||||
return {'success': False, 'decision': 'failed', 'status': 'failed', 'policy_decision': 'allowed', 'result': 'failed', 'message': message, 'jobId': completed.id, 'auditId': audit.id, 'data': connector_result.get('data')}
|
||||
|
||||
return {'success': True, 'decision': 'allowed', 'status': 'success', 'policy_decision': 'allowed', 'result': 'success', 'message': message, 'jobId': completed.id, 'auditId': audit.id, 'data': connector_result.get('data', {})}
|
||||
Reference in new issue
Block a user