Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1,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'],
|
||||
)
|
||||
Reference in new issue
Block a user