diff --git a/.github/workflows/test-offline-qualified.yml b/.github/workflows/test-offline-qualified.yml index a562c6b3..bc092ae9 100644 --- a/.github/workflows/test-offline-qualified.yml +++ b/.github/workflows/test-offline-qualified.yml @@ -13,6 +13,8 @@ on: - 'AppImage/scripts/*.py' - 'AppImage/scripts/tests/test_health_persistence_recurrence.py' - 'AppImage/scripts/tests/test_native_app_discovery.py' + - 'AppImage/scripts/tests/test_docker_app_discovery.py' + - 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json' - 'AppImage/app/**' - 'AppImage/components/**' - 'AppImage/hooks/**' @@ -34,6 +36,8 @@ on: - 'AppImage/scripts/*.py' - 'AppImage/scripts/tests/test_health_persistence_recurrence.py' - 'AppImage/scripts/tests/test_native_app_discovery.py' + - 'AppImage/scripts/tests/test_docker_app_discovery.py' + - 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json' - 'AppImage/app/**' - 'AppImage/components/**' - 'AppImage/hooks/**' diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index 129e270e..932c91eb 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -1748,7 +1748,14 @@ "nativeScanPartial": "Die Anwendungssuche ist unvollständig. Prüfen Sie die gefundenen Hinweise und versuchen Sie es erneut.", "nativeVersionUnknown": "Installierte Version unbekannt", "nativeEvidenceStale": "Die Hinweise sind möglicherweise veraltet oder unvollständig.", - "nativeRegisterOnly": "Bestätigen Sie die Registrierung im Editor. Eine bekannte Version wird als manueller Wert gespeichert; es wird kein Updater eingerichtet." + "nativeRegisterOnly": "Bestätigen Sie die Registrierung im Editor. Eine bekannte Version wird als manueller Wert gespeichert; es wird kein Updater eingerichtet.", + "dockerScanFailed": "Die Docker-Suche konnte nicht abgeschlossen werden. Frühere Ergebnisse sind möglicherweise veraltet; versuchen Sie es erneut.", + "dockerScanPartial": "Die Docker-Suche ist unvollständig. Einige Container konnten nicht geprüft werden.", + "dockerEvidenceContainer": "Docker-Container", + "dockerEvidenceImage": "Image", + "dockerEvidencePort": "Veröffentlichter TCP-Port", + "dockerEvidenceProject": "Compose-Projekt", + "dockerEndpointReview": "Container mit veröffentlichten TCP-Ports. Bestätigen Sie Namen und Webadresse vor dem Speichern. Die Registrierung richtet keine Updates ein." }, "statusFilter": { "ariaLabel": "Virtuelle Maschinen und Container filtern", diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index 102b62df..a9cb8e80 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -1734,6 +1734,13 @@ "nativeVersionUnknown": "Versión instalada desconocida", "nativeEvidenceStale": "Lo detectado puede estar desactualizado o incompleto.", "nativeRegisterOnly": "Confirma el registro en el editor. Una versión conocida se guarda como valor manual; no se configura ningún actualizador.", + "dockerScanFailed": "La búsqueda en Docker no pudo terminar. Los resultados anteriores pueden estar desactualizados; vuelve a intentarlo.", + "dockerScanPartial": "La búsqueda en Docker está incompleta. No se pudieron revisar algunos contenedores.", + "dockerEvidenceContainer": "Contenedor Docker", + "dockerEvidenceImage": "Imagen", + "dockerEvidencePort": "Puerto TCP publicado", + "dockerEvidenceProject": "Proyecto Compose", + "dockerEndpointReview": "Contenedor con puertos TCP publicados. Confirma el nombre y la dirección web antes de guardar. El registro no configura actualizaciones.", "noNewApplicationsDetected": "No se han detectado aplicaciones nuevas.", "oneNewApplicationDetected": "Se ha detectado una aplicación nueva.", "newApplicationsDetected": "Se han detectado {count} aplicaciones nuevas.", diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index de2f0de3..39d7802e 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -1748,7 +1748,14 @@ "nativeScanPartial": "La recherche d'applications est incomplète. Vérifiez les éléments trouvés et réessayez.", "nativeVersionUnknown": "Version installée inconnue", "nativeEvidenceStale": "Les éléments trouvés peuvent être obsolètes ou incomplets.", - "nativeRegisterOnly": "Confirmez l'enregistrement dans l'éditeur. Une version connue est enregistrée comme valeur manuelle ; aucun outil de mise à jour n'est configuré." + "nativeRegisterOnly": "Confirmez l'enregistrement dans l'éditeur. Une version connue est enregistrée comme valeur manuelle ; aucun outil de mise à jour n'est configuré.", + "dockerScanFailed": "La recherche Docker n'a pas pu se terminer. Les résultats précédents peuvent être obsolètes ; réessayez.", + "dockerScanPartial": "La recherche Docker est incomplète. Certains conteneurs n'ont pas pu être vérifiés.", + "dockerEvidenceContainer": "Conteneur Docker", + "dockerEvidenceImage": "Image", + "dockerEvidencePort": "Port TCP publié", + "dockerEvidenceProject": "Projet Compose", + "dockerEndpointReview": "Conteneur avec des ports TCP publiés. Confirmez le nom et l'adresse web avant d'enregistrer. L'enregistrement ne configure pas les mises à jour." }, "statusFilter": { "ariaLabel": "Filtrer les machines virtuelles et les conteneurs", diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index 7c13401b..b264b434 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -1748,7 +1748,14 @@ "nativeScanPartial": "La ricerca delle applicazioni è incompleta. Controlla gli elementi trovati e riprova.", "nativeVersionUnknown": "Versione installata sconosciuta", "nativeEvidenceStale": "Gli elementi trovati potrebbero non essere aggiornati o essere incompleti.", - "nativeRegisterOnly": "Conferma la registrazione nell'editor. Una versione nota viene salvata come valore manuale; non viene configurato alcun aggiornamento automatico." + "nativeRegisterOnly": "Conferma la registrazione nell'editor. Una versione nota viene salvata come valore manuale; non viene configurato alcun aggiornamento automatico.", + "dockerScanFailed": "La ricerca Docker non è stata completata. I risultati precedenti potrebbero non essere aggiornati; riprova.", + "dockerScanPartial": "La ricerca Docker è incompleta. Non è stato possibile controllare alcuni container.", + "dockerEvidenceContainer": "Container Docker", + "dockerEvidenceImage": "Immagine", + "dockerEvidencePort": "Porta TCP pubblicata", + "dockerEvidenceProject": "Progetto Compose", + "dockerEndpointReview": "Container con porte TCP pubblicate. Conferma il nome e l'indirizzo web prima di salvare. La registrazione non configura gli aggiornamenti." }, "statusFilter": { "ariaLabel": "Filtra macchine virtuali e container", diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index 6eef895c..0d1307c2 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -1748,7 +1748,14 @@ "nativeScanPartial": "A pesquisa de aplicativos está incompleta. Revise os itens encontrados e tente novamente.", "nativeVersionUnknown": "Versão instalada desconhecida", "nativeEvidenceStale": "Os itens encontrados podem estar desatualizados ou incompletos.", - "nativeRegisterOnly": "Confirme o registro no editor. Uma versão conhecida é salva como valor manual; nenhum atualizador é configurado." + "nativeRegisterOnly": "Confirme o registro no editor. Uma versão conhecida é salva como valor manual; nenhum atualizador é configurado.", + "dockerScanFailed": "A pesquisa no Docker não pôde ser concluída. Os resultados anteriores podem estar desatualizados; tente novamente.", + "dockerScanPartial": "A pesquisa no Docker está incompleta. Não foi possível verificar alguns contentores.", + "dockerEvidenceContainer": "Contentor Docker", + "dockerEvidenceImage": "Imagem", + "dockerEvidencePort": "Porta TCP publicada", + "dockerEvidenceProject": "Projeto Compose", + "dockerEndpointReview": "Contentor com portas TCP publicadas. Confirme o nome e o endereço web antes de guardar. O registo não configura atualizações." }, "statusFilter": { "ariaLabel": "Filtrar máquinas virtuais e contêineres", diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index f622bbb9..ec2ad5f9 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -1748,7 +1748,14 @@ "nativeScanPartial": "Programsökningen är ofullständig. Granska det som hittades och försök igen.", "nativeVersionUnknown": "Installerad version okänd", "nativeEvidenceStale": "Det som hittades kan vara inaktuellt eller ofullständigt.", - "nativeRegisterOnly": "Bekräfta registreringen i redigeraren. En känd version sparas som ett manuellt värde; ingen uppdatering konfigureras." + "nativeRegisterOnly": "Bekräfta registreringen i redigeraren. En känd version sparas som ett manuellt värde; ingen uppdatering konfigureras.", + "dockerScanFailed": "Docker-sökningen kunde inte slutföras. Tidigare resultat kan vara inaktuella; försök igen.", + "dockerScanPartial": "Docker-sökningen är ofullständig. Vissa containrar kunde inte kontrolleras.", + "dockerEvidenceContainer": "Docker-container", + "dockerEvidenceImage": "Avbild", + "dockerEvidencePort": "Publicerad TCP-port", + "dockerEvidenceProject": "Compose-projekt", + "dockerEndpointReview": "Container med publicerade TCP-portar. Bekräfta namnet och webbadressen innan du sparar. Registreringen konfigurerar inga uppdateringar." }, "statusFilter": { "ariaLabel": "Filtrera virtuella maskiner och containrar", diff --git a/AppImage/scripts/docker_app_discovery.py b/AppImage/scripts/docker_app_discovery.py index d619ebc3..209067f8 100644 --- a/AppImage/scripts/docker_app_discovery.py +++ b/AppImage/scripts/docker_app_discovery.py @@ -116,18 +116,19 @@ def public_ports(record): if not isinstance(value, dict): raise ValueError('invalid binding') address = value.get('HostIp') or '0.0.0.0' + raw = value.get('HostPort') + # An unassigned ("") or ranged host port is not a single address. + if not isinstance(raw, str) or not raw.isdecimal(): + continue try: ip = ipaddress.ip_address(address) - if ip.is_loopback or getattr(ip, 'ipv4_mapped', None) and ip.ipv4_mapped.is_loopback: - continue - raw = value.get('HostPort') - if not isinstance(raw, str) or not raw.isdecimal(): - raise ValueError('invalid port') - outer = int(raw) except (ValueError, TypeError): - raise ValueError('invalid binding') from None + continue + if ip.is_loopback or getattr(ip, 'ipv4_mapped', None) and ip.ipv4_mapped.is_loopback: + continue + outer = int(raw) if not 1 <= outer <= 65535: - raise ValueError('invalid port') + continue result[outer] = {'host_port': outer, 'container_port': inner, 'scheme': 'https' if inner in {443, 4443, 8443, 9443} or outer in {443, 4443, 8443, 9443} else 'http'} return sorted(result.values(), key=lambda item: item['host_port']) @@ -167,6 +168,9 @@ def _candidate(vmid, record): 'container_name': record['name'], 'name': name, 'installed_version': record['version'], 'evidence': evidence, 'default_ports': [port['host_port'] for port in record['endpoints'] if port['container_port'] in WEB_PORTS], + 'port_details': [{'port': port['host_port'], 'scheme': port['scheme'], 'path': None, + 'description': '', 'logo_url': ''} + for port in record['endpoints'] if port['container_port'] in WEB_PORTS] or None, 'tracking_suggestion': None} @@ -272,7 +276,9 @@ def cached_web_links(vmid, lookup): """Reuse a Find snapshot in existing web-link suggestions without another scan.""" with _mutex: value = copy.deepcopy(_cache.get(int(vmid))) - if not value or value.get('_retired') or time.time() - value['_attempt_at'] >= SNAPSHOT_SECONDS: + # Only a complete search lists every container; otherwise probe as before. + if (not value or value.get('_retired') or value.get('status') != 'complete' + or time.time() - value['_attempt_at'] >= SNAPSHOT_SECONDS): return None result = [] for record in value.get('_containers', []): diff --git a/AppImage/scripts/tests/test_docker_app_discovery.py b/AppImage/scripts/tests/test_docker_app_discovery.py index eff1390e..a0d5750d 100644 --- a/AppImage/scripts/tests/test_docker_app_discovery.py +++ b/AppImage/scripts/tests/test_docker_app_discovery.py @@ -100,6 +100,21 @@ class Scan(unittest.TestCase): self.assertEqual(item['default_ports'],[8080]) self.assertIn({'kind':'tcp_listener','value':'21115'},item['evidence']) + def test_unassigned_or_ranged_host_ports_are_skipped_not_invalid(self): + app = record(running=False, bindings={'8080/tcp': [{'HostIp': '', 'HostPort': ''}], + '9000/tcp': [{'HostIp': '', 'HostPort': '9000-9010'}], + '443/tcp': [{'HostIp': '', 'HostPort': '8444'}]}) + result = self.scan(records=[app]) + self.assertEqual(result['status'], 'complete') + endpoints = result['_containers'][0]['endpoints'] + self.assertEqual([e['host_port'] for e in endpoints], [8444]) + + def test_https_container_port_prefills_its_scheme(self): + app = record(bindings={'443/tcp': [{'HostIp': '0.0.0.0', 'HostPort': '8444'}]}) + item, = self.scan(records=[app])['candidates'] + self.assertEqual(item['default_ports'], [8444]) + self.assertEqual([(d['port'], d['scheme']) for d in item['port_details']], [(8444, 'https')]) + def test_empty_engine_is_complete_and_missing_docker_never_calls_docker(self): self.assertEqual(self.scan(ids=[])['status'], 'complete') self.calls.clear() @@ -202,6 +217,20 @@ class Cache(unittest.TestCase): self.assertEqual(lxc_apps._probe_docker_web_links(101)[0]['host_port'], 8080) execute.assert_not_called() + def test_incomplete_search_never_replaces_the_web_link_probe(self): + lookup = Mock(return_value={'slug': None, 'name': 'web', 'logo_url': None}) + partial = {**copy.deepcopy(self.value), 'status': 'partial', 'issues': ['inspect_failed']} + with patch.object(docker, 'scan', return_value=partial), patch.object(docker.time, 'time', return_value=1000): + docker.discover(101) + with patch.object(docker.time, 'time', return_value=1001): + self.assertIsNone(docker.cached_web_links(101, lookup)) + docker._cache.clear() + with patch.object(docker, 'scan', side_effect=native.ProbeError('timeout')), \ + patch.object(docker.time, 'time', return_value=1000): + docker.discover(101) + with patch.object(docker.time, 'time', return_value=1001): + self.assertIsNone(docker.cached_web_links(101, lookup)) + def test_shared_links_keep_existing_name_then_port_order(self): value = copy.deepcopy(self.value) value['_containers'][0]['name'] = 'z-last'