196 lines
16 KiB
Python
196 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
|
|
def _text(raw: dict[str, Any]) -> str:
|
|
labels = raw.get('labels') if isinstance(raw.get('labels'), dict) else {}
|
|
label_text = ' '.join(f'{key} {value}' for key, value in labels.items())
|
|
ports = ' '.join(str(port) for port in raw.get('ports', []) if port is not None)
|
|
text = f"{raw.get('name', '')} {raw.get('image', '')} {label_text} {ports}".lower()
|
|
# Preserve the source while also matching humanized names such as "Gpu Hot"
|
|
# against the same conservative patterns used for container IDs and images.
|
|
return ' '.join((text, re.sub(r'[\s_]+', '-', text), re.sub(r'[\s-]+', '_', text)))
|
|
|
|
|
|
def _match(text: str, *patterns: str) -> bool:
|
|
return any(re.search(pattern, text) for pattern in patterns)
|
|
|
|
|
|
def _base(raw: dict[str, Any]) -> dict[str, Any]:
|
|
name = str(raw.get('name') or raw.get('container_id') or 'unknown').strip('/') or 'unknown'
|
|
safe_id = re.sub(r'[^a-z0-9_]+', '-', name.lower()).strip('-') or 'unknown'
|
|
return {
|
|
'suggested_app_id': safe_id.replace('-', '_'),
|
|
'display_name': name.replace('-', ' ').replace('_', ' ').title(),
|
|
'app_type': 'unknown',
|
|
'category': 'unknown',
|
|
'criticality': 'low',
|
|
'connector': 'none',
|
|
'policy_profile': 'generic_unknown',
|
|
'protected': False,
|
|
'healthcheck_suggestion': None,
|
|
'voice_suggestion': 'disabled',
|
|
'confidence': 0.2,
|
|
'reasons': ['No known pattern matched.'],
|
|
'import_status': 'unmapped',
|
|
}
|
|
|
|
|
|
def classify_container(raw: dict[str, Any]) -> dict[str, Any]:
|
|
text = _text(raw)
|
|
result = _base(raw)
|
|
|
|
def apply(
|
|
*,
|
|
app_type: str,
|
|
category: str,
|
|
criticality: str,
|
|
connector: str = 'none',
|
|
policy_profile: str | None = None,
|
|
subcategory: str | None = None,
|
|
protected: bool = False,
|
|
privacy_sensitive: bool = False,
|
|
confidence: float = 0.85,
|
|
reason: str,
|
|
default_port: int | None = None,
|
|
) -> dict[str, Any]:
|
|
result.update(
|
|
{
|
|
'suggested_app_id': app_type,
|
|
'display_name': app_type.replace('_', ' ').title(),
|
|
'app_type': app_type,
|
|
'category': category,
|
|
'subcategory': subcategory,
|
|
'criticality': criticality,
|
|
'connector': connector,
|
|
'policy_profile': policy_profile or category,
|
|
'protected': protected,
|
|
'privacy_sensitive': privacy_sensitive,
|
|
'healthcheck_suggestion': {'port': default_port} if default_port else {'type': 'http'},
|
|
'voice_suggestion': 'blocked' if protected else 'disabled',
|
|
'confidence': confidence,
|
|
'reasons': [reason],
|
|
'import_status': 'discovered',
|
|
}
|
|
)
|
|
return result
|
|
|
|
if _match(text, r'\b(binhex-)?sonarr\b', r'arch-sonarr'):
|
|
return apply(app_type='sonarr', category='media', criticality='medium', connector='sonarr', policy_profile='media_app', reason='Matched Sonarr name/image pattern.')
|
|
if _match(text, r'\b(binhex-)?radarr\b', r'arch-radarr'):
|
|
return apply(app_type='radarr', category='media', criticality='medium', connector='radarr', policy_profile='media_app', reason='Matched Radarr name/image pattern.')
|
|
if _match(text, r'\bprowlarr\b'):
|
|
return apply(app_type='prowlarr', category='media', criticality='medium', connector='prowlarr', policy_profile='media_indexer', reason='Matched Prowlarr pattern.', default_port=9696)
|
|
if _match(text, r'\b(binhex-)?deluge(vpn)?\b', r'arch-delugevpn', r'linuxserver/deluge'):
|
|
return apply(app_type='deluge', category='downloads', subcategory='torrent_client', criticality='medium', connector='deluge', policy_profile='download_client', reason='Matched Deluge download client pattern.', default_port=8112)
|
|
if _match(text, r'\b(binhex-)?plex(pass)?\b', r'arch-plexpass', r'linuxserver/plex', r'plexinc/pms-docker'):
|
|
return apply(app_type='plex', category='media', subcategory='media_server', criticality='medium', connector='plex', policy_profile='media_server', reason='Matched Plex media server pattern.', default_port=32400)
|
|
if _match(text, r'\bjellyfin\b'):
|
|
return apply(app_type='jellyfin', category='media', criticality='medium', connector='none', policy_profile='media_app', reason='Matched Jellyfin media server pattern; no AppOps Jellyfin connector is registered.')
|
|
if _match(text, r'\b(binhex-)?tautulli\b', r'linuxserver/tautulli', r'tautulli/tautulli'):
|
|
return apply(app_type='tautulli', category='media', subcategory='plex_analytics', criticality='medium', connector='tautulli', policy_profile='media_analytics', privacy_sensitive=True, reason='Matched Tautulli Plex analytics pattern.', default_port=8181)
|
|
if _match(text, r'\b(qbittorrent|deluge|sabnzbd|nzbget)\b'):
|
|
app_type = next((name for name in ['qbittorrent', 'deluge', 'sabnzbd', 'nzbget'] if name in text), 'downloads')
|
|
return apply(app_type=app_type, category='downloads', criticality='medium', policy_profile='download_client', reason='Matched download client pattern.')
|
|
if _match(text, r'\bauthentik\b', r'\bauthentik-(server|worker)\b', r'ghcr\.io/goauthentik/(server|proxy)', r'beryju/authentik', r'\bgoauthentik\b'):
|
|
return apply(
|
|
app_type='authentik',
|
|
category='auth',
|
|
subcategory='identity_provider',
|
|
criticality='critical',
|
|
connector='authentik',
|
|
protected=True,
|
|
policy_profile='auth_provider',
|
|
reason='Matched Authentik identity provider pattern.',
|
|
default_port=9000,
|
|
)
|
|
if _match(text, r'\b(postgres|postgresql|mariadb|mysql)\b'):
|
|
app_type = next((name for name in ['postgres', 'postgresql', 'mariadb', 'mysql'] if name in text), 'database')
|
|
return apply(app_type=app_type, category='database', criticality='critical', protected=True, policy_profile='database', reason='Matched database pattern.')
|
|
if _match(text, r'\bredis\b'):
|
|
return apply(app_type='redis', category='database', criticality='high', protected=True, policy_profile='database', reason='Matched Redis pattern.')
|
|
if _match(text, r'\b(nginxproxymanager|nginx-proxy-manager|jc21/nginx-proxy-manager|npm)\b'):
|
|
return apply(
|
|
app_type='nginx_proxy_manager',
|
|
category='network',
|
|
subcategory='reverse_proxy',
|
|
criticality='critical',
|
|
connector='nginx_proxy_manager',
|
|
protected=True,
|
|
policy_profile='reverse_proxy',
|
|
reason='Matched Nginx Proxy Manager reverse proxy pattern.',
|
|
default_port=81,
|
|
)
|
|
if _match(text, r'\bportainer\b'):
|
|
return apply(app_type='portainer', category='infrastructure', criticality='critical', protected=True, policy_profile='protected_service', reason='Matched Portainer pattern.')
|
|
if _match(text, r'\b(homeassistant|home-assistant|home_assistant)\b'):
|
|
return apply(app_type='home_assistant', category='home_automation', criticality='high', protected=True, policy_profile='protected_service', reason='Matched Home Assistant pattern.')
|
|
if _match(text, r'\bgitea\b'):
|
|
return apply(app_type='gitea', category='dev', criticality='medium', policy_profile='dev_tool', reason='Matched Gitea pattern.')
|
|
if _match(text, r'\bwidefrog\b'):
|
|
return apply(app_type='widefrog', category='media', criticality='medium', connector='widefrog', policy_profile='media_custom_app', reason='Matched Widefrog pattern.', default_port=1207)
|
|
if _match(text, r'\b(adguard|adguardhome|adguard-home)\b'):
|
|
return apply(app_type='adguard_home', category='network', criticality='high', protected=True, policy_profile='network_service', reason='Matched AdGuard network service pattern.')
|
|
if _match(text, r'\b(vaultwarden|bitwarden)\b'):
|
|
return apply(app_type='vaultwarden', category='auth', criticality='critical', protected=True, privacy_sensitive=True, policy_profile='auth_provider', reason='Matched credential vault pattern.')
|
|
if _match(text, r'\b(adminer|phpmyadmin)\b'):
|
|
app_type = 'adminer' if 'adminer' in text else 'phpmyadmin'
|
|
return apply(app_type=app_type, category='database', criticality='high', protected=True, policy_profile='database_admin', reason='Matched database administration pattern.')
|
|
if _match(text, r'\b(audiobookshelf|audiodeck|bazarr|seerr|komga|lidarr|mylar3?|immich|peertube|romm|tdarr|grimmory|listenarr|readmeabook)\b'):
|
|
app_type = next((name for name in ['audiobookshelf', 'audiodeck', 'bazarr', 'seerr', 'komga', 'lidarr', 'mylar3', 'immich', 'peertube', 'romm', 'tdarr', 'grimmory', 'listenarr', 'readmeabook'] if name.replace('_', '-') in text or name in text), 'media_app')
|
|
protected = app_type == 'immich'
|
|
return apply(app_type=app_type, category='media', criticality='high' if protected else 'medium', protected=protected, privacy_sensitive=protected, policy_profile='media_app', reason='Matched media library or companion app pattern.')
|
|
if _match(text, r'\b(paperless-ngx|paperless_ngx|stirlingpdf|stirling-pdf|leafwiki|infrabinder-local|infrabinder_local)\b'):
|
|
app_type = next((name for name in ['paperless_ngx', 'stirlingpdf', 'leafwiki', 'infrabinder_local'] if name.replace('_', '-') in text or name in text), 'document_service')
|
|
protected = app_type in {'paperless_ngx', 'infrabinder_local'}
|
|
return apply(app_type=app_type, category='productivity', subcategory='documents', criticality='high' if protected else 'medium', protected=protected, privacy_sensitive=True, policy_profile='document_service', reason='Matched document or knowledge-management service pattern.')
|
|
if _match(text, r'\b(jdownloader2?|metube|slskd|unpackerr)\b'):
|
|
app_type = next((name for name in ['jdownloader2', 'metube', 'slskd', 'unpackerr'] if name in text), 'download_tool')
|
|
return apply(app_type=app_type, category='downloads', criticality='medium', policy_profile='download_client', reason='Matched download workflow pattern.')
|
|
if _match(text, r'\b(ollama|anythingllm|litellm|chromadb|hivekeep|odysseus-aio|odysseus_aio)\b'):
|
|
app_type = next((name for name in ['ollama', 'anythingllm', 'litellm', 'chromadb', 'hivekeep', 'odysseus_aio'] if name.replace('_', '-') in text or name in text), 'ai_service')
|
|
category = 'database' if app_type == 'chromadb' else 'infrastructure'
|
|
protected = app_type in {'chromadb', 'hivekeep', 'odysseus_aio'}
|
|
return apply(app_type=app_type, category=category, criticality='high' if protected else 'medium', protected=protected, privacy_sensitive=protected, policy_profile='database' if app_type == 'chromadb' else 'ai_service', reason='Matched local AI platform pattern.')
|
|
if _match(text, r'\b(searxng|flaresolverr|postfix-relay|postfix_relay)\b'):
|
|
app_type = next((name for name in ['searxng', 'flaresolverr', 'postfix_relay'] if name.replace('_', '-') in text or name in text), 'network_service')
|
|
return apply(app_type=app_type, category='network', criticality='medium', protected=app_type == 'postfix_relay', policy_profile='network_service', reason='Matched network support service pattern.')
|
|
if _match(text, r'\b(unraid-appops-gateway|unraid_appops_gateway|watchtower)\b'):
|
|
app_type = 'unraid_appops_gateway' if 'appops' in text else 'watchtower'
|
|
return apply(app_type=app_type, category='infrastructure', criticality='critical' if app_type == 'unraid_appops_gateway' else 'high', protected=True, policy_profile='protected_service', reason='Matched infrastructure control-plane service pattern.')
|
|
if _match(text, r'\b(organizrv?2?|organizr|unraid-progressive-web-app|unraid_progressive_web_app|ntfy)\b'):
|
|
app_type = next((name for name in ['organizrv2', 'unraid_progressive_web_app', 'ntfy'] if name.replace('_', '-') in text or name in text), 'infrastructure_service')
|
|
return apply(app_type=app_type, category='infrastructure', criticality='medium', policy_profile='infrastructure_service', reason='Matched self-hosted infrastructure support service pattern.')
|
|
if _match(text, r'\b(retroarch|rickroll)\b'):
|
|
app_type = 'retroarch' if 'retroarch' in text else 'rickroll'
|
|
return apply(app_type=app_type, category='media', criticality='low', policy_profile='media_app', reason='Matched media or entertainment service pattern.')
|
|
if _match(text, r'\bdockdeck\b', r'dockdeck:local'):
|
|
return apply(app_type='dockdeck', category='infrastructure', subcategory='service_portal', criticality='medium', privacy_sensitive=True, policy_profile='internal_service', reason='Matched the local DockDeck self-hosted start dashboard pattern.', default_port=1218)
|
|
if _match(text, r'\blaunchpad\b', r'launchpad:local'):
|
|
return apply(app_type='launchpad', category='infrastructure', subcategory='service_portal', criticality='medium', policy_profile='internal_service', reason='Matched the local Launchpad service portal pattern.')
|
|
if _match(text, r'\bmedia-codec-dashboard\b', r'\bmedia_codec_dashboard\b'):
|
|
return apply(app_type='media_codec_dashboard', category='media', subcategory='media_observability', criticality='medium', privacy_sensitive=True, policy_profile='media_app', reason='Matched the local media codec observability dashboard pattern.')
|
|
if _match(text, r'\bgeointel\b'):
|
|
return apply(app_type='geointel', category='ai', subcategory='geospatial_workbench', criticality='medium', protected=True, privacy_sensitive=True, policy_profile='ai_service', reason='Matched the local GeoIntel GeoAI workbench pattern.')
|
|
if _match(text, r'\bopscenter-it-tools\b', r'\bopscenter_it_tools\b'):
|
|
return apply(app_type='opscenter_it_tools', category='dev', subcategory='operations_tooling', criticality='high', protected=True, privacy_sensitive=True, policy_profile='dev_tool', reason='Matched the local OpsCenter diagnostics workbench pattern.')
|
|
if _match(text, r'\bprojectbrain\b'):
|
|
return apply(app_type='projectbrain', category='productivity', subcategory='project_operations', criticality='high', protected=True, privacy_sensitive=True, policy_profile='document_service', reason='Matched the local ProjectBrain project operating system pattern.')
|
|
if _match(text, r'\bpsforge\b'):
|
|
return apply(app_type='psforge', category='productivity', subcategory='project_planning', criticality='medium', protected=True, privacy_sensitive=True, policy_profile='document_service', reason='Matched the local PSForge planning and documentation workbench pattern.')
|
|
if _match(text, r'\bquizmonitor\b', r'\bquiz-monitor\b'):
|
|
return apply(app_type='quizmonitor', category='ai', subcategory='knowledge_assistant', criticality='high', protected=True, privacy_sensitive=True, policy_profile='ai_service', reason='Matched the local QuizMonitor AI knowledge platform pattern.')
|
|
if _match(text, r'\bspoortracker\b'):
|
|
return apply(app_type='spoortracker', category='monitoring', subcategory='transport_monitoring', criticality='low', policy_profile='monitoring', reason='Matched the local SpoorTracker live transport monitoring pattern.')
|
|
if _match(text, r'\bcertscope\b'):
|
|
return apply(app_type='certscope', category='monitoring', subcategory='domain_inspection', criticality='low', policy_profile='monitoring', reason='Matched the local CertScope domain posture inspector pattern.')
|
|
if _match(text, r'\bps4-http\b', r'\bps4_http\b'):
|
|
return apply(app_type='ps4_http', category='infrastructure', subcategory='http_service', criticality='low', policy_profile='infrastructure_service', reason='Matched the local PS4 HTTP support service pattern.')
|
|
if _match(text, r'\b(zabbix|grafana|uptime-kuma|glances|netdata|netronome|unraid-stats|unraid_stats|system-monitor|system_monitor|gpu-hot|gpu_hot)\b'):
|
|
app_type = next((name for name in ['zabbix', 'grafana', 'uptime-kuma', 'glances', 'netdata', 'netronome', 'unraid_stats', 'system_monitor', 'gpu_hot'] if name.replace('_', '-') in text or name in text), 'monitoring')
|
|
return apply(app_type=app_type.replace('-', '_'), category='monitoring', criticality='medium', policy_profile='monitoring', reason='Matched monitoring pattern.')
|
|
|
|
return result
|