Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:35:57 +02:00
commit 1dfbca08a1
528 files changed
+131054

No files matched your search

+1
View File
@@ -0,0 +1 @@
# API submodule exports
+11
View File
@@ -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)
+510
View File
@@ -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'],
)
+210
View File
@@ -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}
+83
View File
@@ -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()
+83
View File
@@ -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')
+150
View File
@@ -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',
}
+207
View File
@@ -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}
+25
View File
@@ -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",
}
+92
View File
@@ -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())
+133
View File
@@ -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
+68
View File
@@ -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', [])
],
}
+208
View File
@@ -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', {})}
+140
View File
@@ -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
+102
View File
@@ -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
+37
View File
@@ -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)
+7
View File
@@ -0,0 +1,7 @@
from fastapi import APIRouter
router = APIRouter()
@router.get('/api/health')
def health():
return {'status': 'ok'}
+150
View File
@@ -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
+151
View File
@@ -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)
+70
View File
@@ -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}
+41
View File
@@ -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)
+60
View File
@@ -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()
+139
View File
@@ -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)
+633
View File
@@ -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}.',
}
+227
View File
@@ -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
+169
View File
@@ -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', {})}
+91
View File
@@ -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}
+98
View File
@@ -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
+177
View File
@@ -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', {}),
}
+258
View File
@@ -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'},
)
+67
View File
@@ -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
+233
View File
@@ -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))
+177
View File
@@ -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', {}),
}
+40
View File
@@ -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)
+76
View File
@@ -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),
}
+122
View File
@@ -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()
+166
View File
@@ -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')}
+388
View File
@@ -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'],
)
+40
View File
@@ -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)
+160
View File
@@ -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', {})}