feat: finish the docker plugin (B15)

This commit is contained in:
LukeGus committed 2026-09-24 11:58:20 -05:00
1 parent 0a3f05d99d
commit 993a91c899
167 files changed
+26299 -5136

No files matched your search

+17
View File
@@ -3,3 +3,20 @@
## 1.0.0
Bundled with Termix 2.9.0.
- Runs entirely through the plugin SDK: no imports from Termix core.
- Connects through Termix's one SSH pipeline, so TOTP, Warpgate, jump hosts
and proxies behave the same as in the terminal. A wrong TOTP code asks
again instead of ending the connect.
- Each host's Docker switch and container runtime are this plugin's host
settings now, moved over on upgrade. The host editor shows them in the
Plugins tab.
- The API lives at `/plugin-api/docker/` and the console at
`/plugin-ws/docker/console`. Disabling the plugin closes open consoles and
enabling it again works without a restart.
- New `docker.use` permission, given to the admin and user roles.
- Provides `docker.containers` (list containers, start, stop and the rest)
and `docker.events` (container state changes) for other plugins, which
Automations uses for its Docker step and trigger.
- The Docker homepage widget comes from this plugin.
- Downloaded logs are the plain log text.
+12
View File
@@ -4,6 +4,18 @@ Container management over SSH: list, inspect, start, stop and view logs for Dock
Bundled with Termix and enabled by default.
## Services
- `docker.containers` 1.0.0: `listContainers(hostId, { all? })` and
`action(hostId, container, "start" | "stop" | "restart" | "pause" |
"unpause" | "remove")`, run over a pooled SSH connection as the caller.
- `docker.events` 1.0.0: `subscribe(hostId, listener)` calls the listener
with `{ hostId, container, event }` when a container exits, starts,
restarts or turns unhealthy, polled about once a minute as the caller.
Subscribing the same listener again is a no-op.
Both need `docker.use`.
## Layout
manifest.json id, capabilities and what this plugin contributes
+22 -11
View File
@@ -100,26 +100,37 @@
"stats": "Stats",
"consoleTab": "Console",
"startContainerToAccess": "Start the container to access the console",
"noHostSelected": "No host selected"
"noHostSelected": "No host selected",
"failedToFetchLogs": "Failed to fetch logs: {{error}}",
"failedToDownloadLogs": "Failed to download logs: {{error}}"
},
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Docker Integration",
"enableDockerMonitor": "Enable Docker",
"enableDockerMonitorDesc": "Monitor and manage containers on this host via Docker",
"containerRuntime": "Container Runtime",
"containerRuntimeDesc": "Choose the CLI used for container management on this host",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Docker",
"feature_docker_desc": "See containers, read logs and watch stats on any host running Docker."
},
"terminal": {
"retry": "Retry"
},
"permissions": {
"use": {
"title": "Use Docker",
"description": "Open the Docker manager and container consoles on hosts with Docker enabled."
}
},
"settings": {
"host": {
"enable": {
"label": "Enable Docker"
},
"containerRuntime": {
"label": "Container Runtime",
"description": "Choose the CLI used for container management on this host",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Docker-integrasie",
"enableDockerMonitor": "Aktiveer Docker",
"enableDockerMonitorDesc": "Monitor en bestuur houers op hierdie gasheer via Docker",
"containerRuntime": "Houerlooptyd",
"containerRuntimeDesc": "Kies die CLI wat vir houerbestuur op hierdie gasheer gebruik word",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Docker",
"feature_docker_desc": "Sien houers, lees logs en kyk na statistieke op enige gasheer wat Docker gebruik."
},
"settings": {
"host": {
"enable": {
"label": "Aktiveer Docker"
},
"containerRuntime": {
"label": "Houerlooptyd",
"description": "Kies die CLI wat vir houerbestuur op hierdie gasheer gebruik word",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "تكامل Docker",
"enableDockerMonitor": "تمكين Docker",
"enableDockerMonitorDesc": "مراقبة وإدارة الحاويات على هذا المضيف عبر Docker",
"containerRuntime": "بيئة تشغيل الحاويات",
"containerRuntimeDesc": "اختر واجهة سطر الأوامر المستخدمة لإدارة الحاويات على هذا المضيف",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "عامل ميناء",
"feature_docker_desc": "اطلع على الحاويات، واقرأ السجلات، وراقب الإحصائيات على أي مضيف يعمل بنظام Docker."
},
"settings": {
"host": {
"enable": {
"label": "تمكين Docker"
},
"containerRuntime": {
"label": "بيئة تشغيل الحاويات",
"description": "اختر واجهة سطر الأوامر المستخدمة لإدارة الحاويات على هذا المضيف",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Докер"
},
"hosts": {
"tabDocker": "Докер",
"dockerIntegration": "Интеграция на Docker",
"enableDockerMonitor": "Активиране на Докер",
"enableDockerMonitorDesc": "Наблюдавайте и управлявайте контейнери на този хост чрез Docker",
"containerRuntime": "Изпълнение на контейнера",
"containerRuntimeDesc": "Изберете CLI, използван за управление на контейнери на този хост",
"containerRuntimeDocker": "Докер",
"containerRuntimePodman": "Подман"
},
"onboarding": {
"feature_docker": "Докер",
"feature_docker_desc": "Вижте контейнери, прочетете лог файлове и наблюдавайте статистики на всеки хост, на който работи Docker."
},
"settings": {
"host": {
"enable": {
"label": "Активиране на Докер"
},
"containerRuntime": {
"label": "Изпълнение на контейнера",
"description": "Изберете CLI, използван за управление на контейнери на този хост",
"docker": "Докер",
"podman": "Подман"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "ডকার"
},
"hosts": {
"tabDocker": "ডকার",
"dockerIntegration": "ডকার ইন্টিগ্রেশন",
"enableDockerMonitor": "ডকার সক্রিয় করুন",
"enableDockerMonitorDesc": "ডকারের মাধ্যমে এই হোস্টে কন্টেইনারগুলি নিরীক্ষণ ও পরিচালনা করুন।",
"containerRuntime": "কন্টেইনার রানটাইম",
"containerRuntimeDesc": "এই হোস্টে কন্টেইনার ব্যবস্থাপনার জন্য ব্যবহৃত CLI নির্বাচন করুন",
"containerRuntimeDocker": "ডকার",
"containerRuntimePodman": "পডম্যান"
},
"onboarding": {
"feature_docker": "ডকার",
"feature_docker_desc": "ডকার চালিত যেকোনো হোস্টে কন্টেইনার দেখুন, লগ পড়ুন এবং পরিসংখ্যান পর্যবেক্ষণ করুন।"
},
"settings": {
"host": {
"enable": {
"label": "ডকার সক্রিয় করুন"
},
"containerRuntime": {
"label": "কন্টেইনার রানটাইম",
"description": "এই হোস্টে কন্টেইনার ব্যবস্থাপনার জন্য ব্যবহৃত CLI নির্বাচন করুন",
"docker": "ডকার",
"podman": "পডম্যান"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Integració de Docker",
"enableDockerMonitor": "Activa Docker",
"enableDockerMonitorDesc": "Supervisar i gestionar els contenidors d'aquest amfitrió mitjançant Docker",
"containerRuntime": "Temps d'execució del contenidor",
"containerRuntimeDesc": "Trieu la CLI utilitzada per a la gestió de contenidors en aquest amfitrió",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Docker",
"feature_docker_desc": "Veure contenidors, llegir registres i observar estadístiques de qualsevol host que executi Docker."
},
"settings": {
"host": {
"enable": {
"label": "Activa Docker"
},
"containerRuntime": {
"label": "Temps d'execució del contenidor",
"description": "Trieu la CLI utilitzada per a la gestió de contenidors en aquest amfitrió",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Přístavní dělník"
},
"hosts": {
"tabDocker": "Přístavní dělník",
"dockerIntegration": "Integrace Dockeru",
"enableDockerMonitor": "Povolit Docker",
"enableDockerMonitorDesc": "Monitorování a správa kontejnerů na tomto hostiteli pomocí Dockeru",
"containerRuntime": "Běh kontejneru",
"containerRuntimeDesc": "Vyberte rozhraní CLI používané pro správu kontejnerů na tomto hostiteli",
"containerRuntimeDocker": "Přístavní dělník",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Přístavní dělník",
"feature_docker_desc": "Zobrazujte kontejnery, čtěte protokoly a sledujte statistiky na jakémkoli hostiteli s Dockerem."
},
"settings": {
"host": {
"enable": {
"label": "Povolit Docker"
},
"containerRuntime": {
"label": "Běh kontejneru",
"description": "Vyberte rozhraní CLI používané pro správu kontejnerů na tomto hostiteli",
"docker": "Přístavní dělník",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Docker-integration",
"enableDockerMonitor": "Aktivér Docker",
"enableDockerMonitorDesc": "Overvåg og administrer containere på denne vært via Docker",
"containerRuntime": "Containerkørselstid",
"containerRuntimeDesc": "Vælg den CLI, der bruges til containeradministration på denne vært",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Docker",
"feature_docker_desc": "Se containere, læs logfiler og se statistikker på enhver vært, der kører Docker."
},
"settings": {
"host": {
"enable": {
"label": "Aktivér Docker"
},
"containerRuntime": {
"label": "Containerkørselstid",
"description": "Vælg den CLI, der bruges til containeradministration på denne vært",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Docker-Integration",
"enableDockerMonitor": "Docker aktivieren",
"enableDockerMonitorDesc": "Container auf diesem Host über Docker überwachen und verwalten",
"containerRuntime": "Container-Runtime",
"containerRuntimeDesc": "Wählen Sie die CLI für die Container-Verwaltung auf diesem Host",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Docker",
"feature_docker_desc": "Container anzeigen, Protokolle lesen und Statistiken auf jedem Host, auf dem Docker ausgeführt wird, einsehen."
},
"settings": {
"host": {
"enable": {
"label": "Docker aktivieren"
},
"containerRuntime": {
"label": "Container-Runtime",
"description": "Wählen Sie die CLI für die Container-Verwaltung auf diesem Host",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Λιμενεργάτης"
},
"hosts": {
"tabDocker": "Λιμενεργάτης",
"dockerIntegration": "Ενσωμάτωση Docker",
"enableDockerMonitor": "Ενεργοποίηση Docker",
"enableDockerMonitorDesc": "Παρακολούθηση και διαχείριση κοντέινερ σε αυτόν τον κεντρικό υπολογιστή μέσω του Docker",
"containerRuntime": "Χρόνος εκτέλεσης κοντέινερ",
"containerRuntimeDesc": "Επιλέξτε το CLI που χρησιμοποιείται για τη διαχείριση κοντέινερ σε αυτόν τον κεντρικό υπολογιστή",
"containerRuntimeDocker": "Λιμενεργάτης",
"containerRuntimePodman": "Πόντμαν"
},
"onboarding": {
"feature_docker": "Λιμενεργάτης",
"feature_docker_desc": "Δείτε κοντέινερ, διαβάστε αρχεία καταγραφής και παρακολουθήστε στατιστικά στοιχεία σε οποιονδήποτε κεντρικό υπολογιστή που εκτελεί Docker."
},
"settings": {
"host": {
"enable": {
"label": "Ενεργοποίηση Docker"
},
"containerRuntime": {
"label": "Χρόνος εκτέλεσης κοντέινερ",
"description": "Επιλέξτε το CLI που χρησιμοποιείται για τη διαχείριση κοντέινερ σε αυτόν τον κεντρικό υπολογιστή",
"docker": "Λιμενεργάτης",
"podman": "Πόντμαν"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Integración con Docker",
"enableDockerMonitor": "Activar Docker",
"enableDockerMonitorDesc": "Supervisar y gestionar contenedores en este host mediante Docker.",
"containerRuntime": "Runtime de contenedores",
"containerRuntimeDesc": "Elegir la CLI utilizada para la gestión de contenedores en este host.",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Estibador",
"feature_docker_desc": "Vea los contenedores, lea los registros y observe las estadísticas en cualquier host que ejecute Docker."
},
"settings": {
"host": {
"enable": {
"label": "Activar Docker"
},
"containerRuntime": {
"label": "Runtime de contenedores",
"description": "Elegir la CLI utilizada para la gestión de contenedores en este host.",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Satamatyöläinen"
},
"hosts": {
"tabDocker": "Satamatyöläinen",
"dockerIntegration": "Docker-integraatio",
"enableDockerMonitor": "Ota Docker käyttöön",
"enableDockerMonitorDesc": "Valvo ja hallinnoi tämän isännän säilöjä Dockerin kautta",
"containerRuntime": "Säilön suoritusaika",
"containerRuntimeDesc": "Valitse CLI, jota käytetään säilön hallintaan tässä isännässä",
"containerRuntimeDocker": "Satamatyöläinen",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Satamatyöläinen",
"feature_docker_desc": "Katso säilöjä, lue lokeja ja seuraa tilastoja millä tahansa Dockeria käyttävällä isännällä."
},
"settings": {
"host": {
"enable": {
"label": "Ota Docker käyttöön"
},
"containerRuntime": {
"label": "Säilön suoritusaika",
"description": "Valitse CLI, jota käytetään säilön hallintaan tässä isännässä",
"docker": "Satamatyöläinen",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Intégration Docker",
"enableDockerMonitor": "Activer Docker",
"enableDockerMonitorDesc": "Surveiller et gérer les conteneurs sur cet hôte via Docker",
"containerRuntime": "Moteur d'exécution de conteneurs",
"containerRuntimeDesc": "Choisissez le CLI utilisé pour la gestion des conteneurs sur cet hôte",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Docker",
"feature_docker_desc": "Consultez les conteneurs, lisez les journaux et surveillez les statistiques sur n'importe quel hôte exécutant Docker."
},
"settings": {
"host": {
"enable": {
"label": "Activer Docker"
},
"containerRuntime": {
"label": "Moteur d'exécution de conteneurs",
"description": "Choisissez le CLI utilisé pour la gestion des conteneurs sur cet hôte",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "דוקר"
},
"hosts": {
"tabDocker": "דוקר",
"dockerIntegration": "אינטגרציה עם Docker",
"enableDockerMonitor": "הפעל את Docker",
"enableDockerMonitorDesc": "ניטור וניהול של מכולות במארח זה באמצעות Docker",
"containerRuntime": "זמן ריצה של המכולה",
"containerRuntimeDesc": "בחר את ממשק שורת הפקודה (CLI) המשמש לניהול מכולות במארח זה",
"containerRuntimeDocker": "דוקר",
"containerRuntimePodman": "פודמן"
},
"onboarding": {
"feature_docker": "דוקר",
"feature_docker_desc": "ראה קונטיינרים, קרא יומני רישום וצפה בסטטיסטיקות על כל מארח שמפעיל את Docker."
},
"settings": {
"host": {
"enable": {
"label": "הפעל את Docker"
},
"containerRuntime": {
"label": "זמן ריצה של המכולה",
"description": "בחר את ממשק שורת הפקודה (CLI) המשמש לניהול מכולות במארח זה",
"docker": "דוקר",
"podman": "פודמן"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Docker एकीकरण",
"enableDockerMonitor": "Docker सक्षम करें",
"enableDockerMonitorDesc": "इस होस्ट पर Docker के माध्यम से कंटेनरों की निगरानी और प्रबंधन करें",
"containerRuntime": "कंटेनर रनटाइम",
"containerRuntimeDesc": "इस होस्ट पर कंटेनर प्रबंधन के लिए उपयोग किए जाने वाले CLI को चुनें",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "डाक में काम करनेवाला मज़दूर",
"feature_docker_desc": "डॉकर चलाने वाले किसी भी होस्ट पर कंटेनर देखें, लॉग पढ़ें और आंकड़े देखें।"
},
"settings": {
"host": {
"enable": {
"label": "Docker सक्षम करें"
},
"containerRuntime": {
"label": "कंटेनर रनटाइम",
"description": "इस होस्ट पर कंटेनर प्रबंधन के लिए उपयोग किए जाने वाले CLI को चुनें",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Dokkmunkás"
},
"hosts": {
"tabDocker": "Dokkmunkás",
"dockerIntegration": "Docker-integráció",
"enableDockerMonitor": "Docker engedélyezése",
"enableDockerMonitorDesc": "Dockeren keresztül figyelheti és kezelheti a konténereket ezen a gazdagépen",
"containerRuntime": "Konténer futásideje",
"containerRuntimeDesc": "Válassza ki a gazdagépen a konténerkezeléshez használt parancssori felületet",
"containerRuntimeDocker": "Dokkmunkás",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Dokkmunkás",
"feature_docker_desc": "Tekintsd meg a konténereket, olvasd el a naplókat és figyeld a statisztikákat bármelyik Dockert futtató gazdagépen."
},
"settings": {
"host": {
"enable": {
"label": "Docker engedélyezése"
},
"containerRuntime": {
"label": "Konténer futásideje",
"description": "Válassza ki a gazdagépen a konténerkezeléshez használt parancssori felületet",
"docker": "Dokkmunkás",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Buruh pelabuhan"
},
"hosts": {
"tabDocker": "Buruh pelabuhan",
"dockerIntegration": "Integrasi Docker",
"enableDockerMonitor": "Aktifkan Docker",
"enableDockerMonitorDesc": "Pantau dan kelola kontainer di host ini melalui Docker.",
"containerRuntime": "Lingkungan Eksekusi Kontainer",
"containerRuntimeDesc": "Pilih CLI yang digunakan untuk manajemen kontainer pada host ini.",
"containerRuntimeDocker": "Buruh pelabuhan",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Buruh pelabuhan",
"feature_docker_desc": "Lihat kontainer, baca log, dan pantau statistik pada host mana pun yang menjalankan Docker."
},
"settings": {
"host": {
"enable": {
"label": "Aktifkan Docker"
},
"containerRuntime": {
"label": "Lingkungan Eksekusi Kontainer",
"description": "Pilih CLI yang digunakan untuk manajemen kontainer pada host ini.",
"docker": "Buruh pelabuhan",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Integrazione Docker",
"enableDockerMonitor": "Abilita Docker",
"enableDockerMonitorDesc": "Monitora e gestisci i container su questo host tramite Docker",
"containerRuntime": "Runtime container",
"containerRuntimeDesc": "Scegli la CLI utilizzata per la gestione dei container su questo host",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Docker",
"feature_docker_desc": "Visualizza i container, leggi i log e controlla le statistiche su qualsiasi host che esegue Docker."
},
"settings": {
"host": {
"enable": {
"label": "Abilita Docker"
},
"containerRuntime": {
"label": "Runtime container",
"description": "Scegli la CLI utilizzata per la gestione dei container su questo host",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Docker統合",
"enableDockerMonitor": "Docker有効化",
"enableDockerMonitorDesc": "Docker経由でこのホスト上のコンテナを監視・管理します。",
"containerRuntime": "コンテナランタイム",
"containerRuntimeDesc": "このホストのコンテナ管理に使用するCLIを選択します。",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "ドッカー",
"feature_docker_desc": "Dockerを実行している任意のホスト上で、コンテナの表示、ログの読み取り、統計情報の監視が可能です。"
},
"settings": {
"host": {
"enable": {
"label": "Docker有効化"
},
"containerRuntime": {
"label": "コンテナランタイム",
"description": "このホストのコンテナ管理に使用するCLIを選択します。",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Docker 통합",
"enableDockerMonitor": "Docker 활성화",
"enableDockerMonitorDesc": "Docker를 통해 이 호스트의 컨테이너를 모니터링하고 관리합니다.",
"containerRuntime": "컨테이너 런타임",
"containerRuntimeDesc": "이 호스트에서 컨테이너 관리에 사용할 CLI를 선택합니다.",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "도커",
"feature_docker_desc": "Docker가 실행 중인 모든 호스트에서 컨테이너를 확인하고, 로그를 읽고, 통계를 볼 수 있습니다."
},
"settings": {
"host": {
"enable": {
"label": "Docker 활성화"
},
"containerRuntime": {
"label": "컨테이너 런타임",
"description": "이 호스트에서 컨테이너 관리에 사용할 CLI를 선택합니다.",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Docker-integratie",
"enableDockerMonitor": "Docker inschakelen",
"enableDockerMonitorDesc": "Containers op deze host bewaken en beheren via Docker.",
"containerRuntime": "Container Runtime",
"containerRuntimeDesc": "Kies de CLI die op deze host wordt gebruikt voor containerbeheer.",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Docker",
"feature_docker_desc": "Bekijk containers, lees logboeken en bekijk statistieken op elke host waarop Docker draait."
},
"settings": {
"host": {
"enable": {
"label": "Docker inschakelen"
},
"containerRuntime": {
"label": "Container Runtime",
"description": "Kies de CLI die op deze host wordt gebruikt voor containerbeheer.",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Dokker"
},
"hosts": {
"tabDocker": "Dokker",
"dockerIntegration": "Docker-integrasjon",
"enableDockerMonitor": "Aktiver Docker",
"enableDockerMonitorDesc": "Overvåk og administrer containere på denne verten via Docker",
"containerRuntime": "Containerkjøringstid",
"containerRuntimeDesc": "Velg CRI-et som brukes for containeradministrasjon på denne verten",
"containerRuntimeDocker": "Dokker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Dokker",
"feature_docker_desc": "Se containere, les logger og se statistikk på alle verter som kjører Docker."
},
"settings": {
"host": {
"enable": {
"label": "Aktiver Docker"
},
"containerRuntime": {
"label": "Containerkjøringstid",
"description": "Velg CRI-et som brukes for containeradministrasjon på denne verten",
"docker": "Dokker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Integracja z Dockerem",
"enableDockerMonitor": "Włącz Docker",
"enableDockerMonitorDesc": "Monitoruj i zarządzaj kontenerami na tym hoście za pomocą Dockera",
"containerRuntime": "Środowisko wykonawcze kontenera",
"containerRuntimeDesc": "Wybierz CLI używane do zarządzania kontenerami na tym hoście",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Docker",
"feature_docker_desc": "Przeglądaj kontenery, czytaj logi i śledź statystyki na dowolnym hoście, na którym działa Docker."
},
"settings": {
"host": {
"enable": {
"label": "Włącz Docker"
},
"containerRuntime": {
"label": "Środowisko wykonawcze kontenera",
"description": "Wybierz CLI używane do zarządzania kontenerami na tym hoście",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Integração do Docker",
"enableDockerMonitor": "Habilitar Docker",
"enableDockerMonitorDesc": "Monitore e gerencie contêineres neste host via Docker.",
"containerRuntime": "Tempo de execução do contêiner",
"containerRuntimeDesc": "Escolha a CLI usada para gerenciamento de contêineres neste host.",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Docker",
"feature_docker_desc": "Visualize contêineres, leia registros e acompanhe as estatísticas em qualquer host que execute o Docker."
},
"settings": {
"host": {
"enable": {
"label": "Habilitar Docker"
},
"containerRuntime": {
"label": "Tempo de execução do contêiner",
"description": "Escolha a CLI usada para gerenciamento de contêineres neste host.",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Integração com Docker",
"enableDockerMonitor": "Ativar Docker",
"enableDockerMonitorDesc": "Monitorizar e gerir contentores neste host via Docker",
"containerRuntime": "Runtime de Contentores",
"containerRuntimeDesc": "Escolha a CLI usada para a gestão de contentores neste host",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Docker",
"feature_docker_desc": "Visualize contentores, leia registos e acompanhe as estatísticas em qualquer host que execute o Docker."
},
"settings": {
"host": {
"enable": {
"label": "Ativar Docker"
},
"containerRuntime": {
"label": "Runtime de Contentores",
"description": "Escolha a CLI usada para a gestão de contentores neste host",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docher"
},
"hosts": {
"tabDocker": "Docher",
"dockerIntegration": "Integrare Docker",
"enableDockerMonitor": "Activează Docker",
"enableDockerMonitorDesc": "Monitorizați și gestionați containerele de pe această gazdă prin Docker",
"containerRuntime": "Runtime de container",
"containerRuntimeDesc": "Alegeți interfața CLI utilizată pentru gestionarea containerelor pe această gazdă",
"containerRuntimeDocker": "Docher",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Docher",
"feature_docker_desc": "Vizualizați containere, citiți jurnale și urmăriți statistici pe orice gazdă care rulează Docker."
},
"settings": {
"host": {
"enable": {
"label": "Activează Docker"
},
"containerRuntime": {
"label": "Runtime de container",
"description": "Alegeți interfața CLI utilizată pentru gestionarea containerelor pe această gazdă",
"docker": "Docher",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Интеграция Docker",
"enableDockerMonitor": "Включить Docker",
"enableDockerMonitorDesc": "Отслеживать и управлять контейнерами на этом хосте через Docker",
"containerRuntime": "Среда выполнения контейнеров",
"containerRuntimeDesc": "Выберите CLI, используемый для управления контейнерами на этом хосте",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Docker",
"feature_docker_desc": "Просматривайте контейнеры, читайте журналы и отслеживайте статистику на любом хосте, где запущен Docker."
},
"settings": {
"host": {
"enable": {
"label": "Включить Docker"
},
"containerRuntime": {
"label": "Среда выполнения контейнеров",
"description": "Выберите CLI, используемый для управления контейнерами на этом хосте",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Докер"
},
"hosts": {
"tabDocker": "Докер",
"dockerIntegration": "Интеграција Докера",
"enableDockerMonitor": "Омогући Докер",
"enableDockerMonitorDesc": "Пратите и управљајте контејнерима на овом хосту путем Докера",
"containerRuntime": "Време извршавања контејнера",
"containerRuntimeDesc": "Изаберите CLI који се користи за управљање контејнерима на овом хосту",
"containerRuntimeDocker": "Докер",
"containerRuntimePodman": "Подман"
},
"onboarding": {
"feature_docker": "Докер",
"feature_docker_desc": "Погледајте контејнере, читајте логове и пратите статистику на било ком хосту који користи Docker."
},
"settings": {
"host": {
"enable": {
"label": "Омогући Докер"
},
"containerRuntime": {
"label": "Време извршавања контејнера",
"description": "Изаберите CLI који се користи за управљање контејнерима на овом хосту",
"docker": "Докер",
"podman": "Подман"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Hamnarbetare"
},
"hosts": {
"tabDocker": "Hamnarbetare",
"dockerIntegration": "Docker-integration",
"enableDockerMonitor": "Aktivera Docker",
"enableDockerMonitorDesc": "Övervaka och hantera containrar på den här värden via Docker",
"containerRuntime": "Containerkörningstid",
"containerRuntimeDesc": "Välj det CRI som används för containerhantering på den här värden",
"containerRuntimeDocker": "Hamnarbetare",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Hamnarbetare",
"feature_docker_desc": "Se containrar, läs loggar och titta på statistik för alla värdar som kör Docker."
},
"settings": {
"host": {
"enable": {
"label": "Aktivera Docker"
},
"containerRuntime": {
"label": "Containerkörningstid",
"description": "Välj det CRI som används för containerhantering på den här värden",
"docker": "Hamnarbetare",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "ด็อกเกอร์"
},
"hosts": {
"tabDocker": "ด็อกเกอร์",
"dockerIntegration": "การผสานรวม Docker",
"enableDockerMonitor": "เปิดใช้งาน Docker",
"enableDockerMonitorDesc": "ตรวจสอบและจัดการคอนเทนเนอร์บนโฮสต์นี้ผ่าน Docker",
"containerRuntime": "รันไทม์คอนเทนเนอร์",
"containerRuntimeDesc": "เลือก CLI ที่ใช้สำหรับการจัดการคอนเทนเนอร์บนโฮสต์นี้",
"containerRuntimeDocker": "ด็อกเกอร์",
"containerRuntimePodman": "พอดแมน"
},
"onboarding": {
"feature_docker": "ด็อกเกอร์",
"feature_docker_desc": "ดูคอนเทนเนอร์ อ่านบันทึก และตรวจสอบสถิติบนโฮสต์ใดก็ได้ที่ใช้งาน Docker"
},
"settings": {
"host": {
"enable": {
"label": "เปิดใช้งาน Docker"
},
"containerRuntime": {
"label": "รันไทม์คอนเทนเนอร์",
"description": "เลือก CLI ที่ใช้สำหรับการจัดการคอนเทนเนอร์บนโฮสต์นี้",
"docker": "ด็อกเกอร์",
"podman": "พอดแมน"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Docker Entegrasyonu",
"enableDockerMonitor": "Docker'ı Etkinleştir",
"enableDockerMonitorDesc": "Docker aracılığıyla bu ana bilgisayardaki kapsayıcıları izle ve yönet",
"containerRuntime": "Kapsayıcı Çalışma Zamanı",
"containerRuntimeDesc": "Bu ana bilgisayarda kapsayıcı yönetimi için kullanılan CLI'yi seçin",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Liman işçisi",
"feature_docker_desc": "Docker çalıştıran herhangi bir sunucudaki konteynerleri görüntüleyin, günlükleri okuyun ve istatistikleri izleyin."
},
"settings": {
"host": {
"enable": {
"label": "Docker'ı Etkinleştir"
},
"containerRuntime": {
"label": "Kapsayıcı Çalışma Zamanı",
"description": "Bu ana bilgisayarda kapsayıcı yönetimi için kullanılan CLI'yi seçin",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Докер"
},
"hosts": {
"tabDocker": "Докер",
"dockerIntegration": "Інтеграція Docker",
"enableDockerMonitor": "Увімкнути Докер",
"enableDockerMonitorDesc": "Моніторинг та керування контейнерами на цьому хості через Docker",
"containerRuntime": "Середовище виконання контейнера",
"containerRuntimeDesc": "Виберіть інтерфейс командного рядка, який використовується для керування контейнерами на цьому хості",
"containerRuntimeDocker": "Докер",
"containerRuntimePodman": "Подман"
},
"onboarding": {
"feature_docker": "Докер",
"feature_docker_desc": "Переглядайте контейнери, читайте журнали та переглядайте статистику на будь-якому хості, на якому працює Docker."
},
"settings": {
"host": {
"enable": {
"label": "Увімкнути Докер"
},
"containerRuntime": {
"label": "Середовище виконання контейнера",
"description": "Виберіть інтерфейс командного рядка, який використовується для керування контейнерами на цьому хості",
"docker": "Докер",
"podman": "Подман"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Tích hợp Docker",
"enableDockerMonitor": "Bật Docker",
"enableDockerMonitorDesc": "Giám sát và quản lý container trên máy chủ này qua Docker",
"containerRuntime": "Môi trường chạy container",
"containerRuntimeDesc": "Chọn CLI dùng để quản lý container trên máy chủ này",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Docker",
"feature_docker_desc": "Xem các container, đọc nhật ký và theo dõi số liệu thống kê trên bất kỳ máy chủ nào đang chạy Docker."
},
"settings": {
"host": {
"enable": {
"label": "Bật Docker"
},
"containerRuntime": {
"label": "Môi trường chạy container",
"description": "Chọn CLI dùng để quản lý container trên máy chủ này",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Docker 集成",
"enableDockerMonitor": "启用 Docker",
"enableDockerMonitorDesc": "通过 Docker 监控和管理此主机上的容器",
"containerRuntime": "容器运行时",
"containerRuntimeDesc": "选择此主机上用于容器管理的 CLI",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Docker",
"feature_docker_desc": "在任何运行 Docker 的主机上查看容器、读取日志及监控运行状态。"
},
"settings": {
"host": {
"enable": {
"label": "启用 Docker"
},
"containerRuntime": {
"label": "容器运行时",
"description": "选择此主机上用于容器管理的 CLI",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": {
"docker": "Docker"
},
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Docker 整合",
"enableDockerMonitor": "啟用 Docker",
"enableDockerMonitorDesc": "透過 Docker 監控及管理此主機上的容器",
"containerRuntime": "容器執行環境",
"containerRuntimeDesc": "選擇用於此主機容器管理的 CLI",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": {
"feature_docker": "Docker",
"feature_docker_desc": "查看執行 Docker 的任何主機上的容器、讀取日誌和查看統計資料。"
},
"settings": {
"host": {
"enable": {
"label": "啟用 Docker"
},
"containerRuntime": {
"label": "容器執行環境",
"description": "選擇用於此主機容器管理的 CLI",
"docker": "Docker",
"podman": "Podman"
}
}
}
}
+45 -5
View File
@@ -19,9 +19,20 @@
"credentials:use",
"ssh:connect",
"network:serve",
"audit:read",
"ui:surface"
],
"provides": [
{
"service": "docker.containers",
"version": "1.0.0",
"permission": "docker.use"
},
{
"service": "docker.events",
"version": "1.0.0",
"permission": "docker.use"
}
],
"contributes": {
"tabs": [
{
@@ -31,10 +42,39 @@
"openFrom": ["host-context-menu", "palette"]
}
],
"hostCapability": {
"key": "enableDocker",
"labelKey": "hosts.dockerIntegration",
"editorTab": "docker"
"permissions": [
{
"name": "use",
"titleKey": "permissions.use.title",
"descriptionKey": "permissions.use.description",
"defaultRoles": ["admin", "user"]
}
],
"settings": {
"host": {
"enableKey": "enableDocker",
"enableLabelKey": "settings.host.enable.label",
"fields": [
{
"key": "containerRuntime",
"type": "select",
"labelKey": "settings.host.containerRuntime.label",
"descriptionKey": "settings.host.containerRuntime.description",
"requires": "enableDocker",
"default": "docker",
"options": [
{
"value": "docker",
"labelKey": "settings.host.containerRuntime.docker"
},
{
"value": "podman",
"labelKey": "settings.host.containerRuntime.podman"
}
]
}
]
}
}
},
"locales": "locales",
+404
View File
@@ -0,0 +1,404 @@
/**
* The Docker panel's interactive connect.
*
* Core's pipeline does the handshake; this only bridges its prompt channel to
* HTTP. A connect that needs a person (a TOTP code, a Warpgate sign-in, a
* password the host does not store) answers the request that is waiting with
* what to ask, and the next request (connect-totp, connect-warpgate) carries
* the answer back and waits for whatever comes after it: the session, another
* prompt, or an error.
*/
import type { Client } from "ssh2";
import type {
PluginContext,
PluginSshHost,
PluginSshPromptRequest,
} from "@termix/plugin-sdk/backend";
import type { ContainerRuntime } from "./container-runtime.js";
import type { DockerSessions } from "./sessions.js";
import {
connectionLog,
getErrorMessage,
type ConnectionLogLine,
type DockerLogger,
} from "./helpers.js";
/** What one HTTP request answers with. */
export interface ConnectStep {
status: number;
body: Record<string, unknown>;
}
const TOTP_WAIT_MS = 3 * 60 * 1000;
const WARPGATE_WAIT_MS = 5 * 60 * 1000;
const STEP_WAIT_MS = 90 * 1000;
// Covers the longest a person may take on a prompt, plus the handshake.
const CONNECT_TIMEOUT_MS = WARPGATE_WAIT_MS + 30 * 1000;
const PASSWORD_PROMPT = /password/i;
/** Hands each step to whichever request is waiting for one. */
export class PendingConnect {
constructor(
readonly userId: string,
readonly hostId: number,
) {}
expiresAt = Date.now() + TOTP_WAIT_MS;
/** What the open prompt expects, or null when nothing is being asked. */
awaiting: "totp" | "warpgate" | null = null;
abandoned = false;
private waiter: ((step: ConnectStep) => void) | null = null;
private queued: ConnectStep | null = null;
private answer: ((value: string | null) => void) | null = null;
emit(step: ConnectStep): void {
if (this.waiter) {
const waiter = this.waiter;
this.waiter = null;
waiter(step);
} else {
this.queued = step;
}
}
next(timeoutMs = STEP_WAIT_MS): Promise<ConnectStep> {
if (this.queued) {
const step = this.queued;
this.queued = null;
return Promise.resolve(step);
}
return new Promise((resolve) => {
const timer = setTimeout(() => {
if (this.waiter !== settle) return;
this.waiter = null;
this.cancel();
resolve({
status: 408,
body: { error: "Connection timed out. Please reconnect." },
});
}, timeoutMs);
const settle = (step: ConnectStep) => {
clearTimeout(timer);
resolve(step);
};
this.waiter = settle;
});
}
ask(kind: "totp" | "warpgate", waitMs: number): Promise<string | null> {
this.awaiting = kind;
this.expiresAt = Date.now() + waitMs;
return new Promise((resolve) => {
this.answer = resolve;
});
}
respond(value: string | null): void {
const answer = this.answer;
this.answer = null;
this.awaiting = null;
answer?.(value);
}
/** Nobody will read the result: give up the prompt and drop the client. */
cancel(): void {
this.abandoned = true;
this.respond(null);
}
}
function classifyConnectError(
message: string,
logs: ConnectionLogLine[],
): { hostKeyChanged: boolean; authFailed: boolean } {
if (message.includes("ENOTFOUND") || message.includes("getaddrinfo")) {
logs.push(
connectionLog("error", "dns", `DNS resolution failed: ${message}`),
);
} else if (
message.includes("ECONNREFUSED") ||
message.includes("ETIMEDOUT")
) {
logs.push(
connectionLog("error", "tcp", `TCP connection failed: ${message}`),
);
} else if (message.includes("verification failed")) {
logs.push(
connectionLog(
"error",
"handshake",
"SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.",
),
);
return { hostKeyChanged: true, authFailed: false };
} else if (
message.includes("handshake") ||
message.includes("key exchange")
) {
logs.push(
connectionLog("error", "handshake", `SSH handshake failed: ${message}`),
);
} else if (/authentication/i.test(message)) {
logs.push(
connectionLog("error", "auth", `Authentication failed: ${message}`),
);
return { hostKeyChanged: false, authFailed: true };
} else {
logs.push(
connectionLog("error", "error", `SSH connection failed: ${message}`),
);
}
return { hostKeyChanged: false, authFailed: false };
}
export interface StartConnectInput {
sessionId: string;
userId: string;
host: PluginSshHost;
runtime: ContainerRuntime;
logs: ConnectionLogLine[];
}
/**
* Starts a connect and returns the first step for the request that asked.
* The pending entry lives in `sessions` until the connect settles, so the
* answer routes can find it.
*/
export async function startConnect(
ctx: PluginContext,
sessions: DockerSessions,
log: DockerLogger,
input: StartConnectInput,
): Promise<ConnectStep> {
const { sessionId, userId, host, runtime, logs } = input;
const hostId = host.id;
const authType = (host.authType as string) || "none";
const credentialless = !ctx.ssh.requiresSecret(authType);
const pending = new PendingConnect(userId, hostId);
sessions.setPending(sessionId, pending);
const parkTotp = (prompt: string, extra: Record<string, unknown>) => {
logs.push(connectionLog("info", "docker_auth", "Verification required"));
const answer = pending.ask("totp", TOTP_WAIT_MS);
pending.emit({
status: 200,
body: {
requires_totp: true,
sessionId,
prompt,
connectionLogs: logs,
...extra,
},
});
return answer;
};
const ask = async (
request: PluginSshPromptRequest,
): Promise<string | null> => {
if (pending.abandoned) return null;
switch (request.kind) {
case "totp":
return parkTotp(request.prompt, request.retry ? { retry: true } : {});
case "warpgate": {
logs.push(
connectionLog(
"info",
"docker_auth",
"Warpgate authentication required",
),
);
const answer = pending.ask("warpgate", WARPGATE_WAIT_MS);
pending.emit({
status: 200,
body: {
requires_warpgate: true,
sessionId,
url: request.url,
securityKey: request.securityKey,
connectionLogs: logs,
},
});
return answer;
}
case "input": {
const isPassword = PASSWORD_PROMPT.test(request.prompt);
if (isPassword && credentialless) {
pending.abandoned = true;
pending.emit({
status: 200,
body: { status: "auth_required", reason: "no_keyboard" },
});
return null;
}
if (!isPassword && !request.isPush) return "";
return parkTotp(request.prompt, isPassword ? { isPassword: true } : {});
}
}
};
logs.push(connectionLog("info", "dns", `Resolving DNS for ${host.ip}`));
logs.push(
connectionLog("info", "tcp", `Connecting to ${host.ip}:${host.port}`),
);
logs.push(connectionLog("info", "handshake", "Initiating SSH handshake"));
ctx.ssh
.connect<Client>(host, {
purpose: "docker",
timeoutMs: CONNECT_TIMEOUT_MS,
prompt: { ask },
})
.then(
(connection) => {
sessions.clearPending(sessionId);
ctx.hosts.status.reportLogin(hostId, { ok: true });
if (pending.abandoned) {
connection.dispose();
return;
}
const session = {
id: sessionId,
client: connection.client,
dispose: connection.dispose,
hostId,
userId,
runtime,
isWindows: false,
lastActive: Date.now(),
activeOperations: 0,
};
sessions.add(session);
connection.client.exec("ver", (err, stream) => {
if (err || !stream) return;
let output = "";
stream.on("data", (d: Buffer) => {
output += d.toString();
});
stream.on("close", () => {
if (output.toLowerCase().includes("windows")) {
session.isWindows = true;
}
});
stream.stderr.on("data", () => {});
});
void ctx.audit.record({
action: "docker_connect",
resourceType: "host",
resourceId: String(hostId),
resourceName: (host.name as string) || undefined,
success: true,
});
logs.push(
connectionLog(
"success",
"connected",
"SSH connection established successfully",
),
);
pending.emit({
status: 200,
body: { success: true, status: "success", connectionLogs: logs },
});
},
(error: unknown) => {
sessions.clearPending(sessionId);
const message = getErrorMessage(error, "SSH connection failed");
if (pending.abandoned) return;
const outcome = (error as { outcome?: Record<string, unknown> })
.outcome;
if (outcome?.status === "interaction-required") {
logs.push(connectionLog("error", "docker_auth", message));
pending.emit({
status: 401,
body: {
error: message,
requiresAuthInteraction: outcome.interaction,
...(typeof outcome.flag === "string"
? { [outcome.flag]: true }
: {}),
connectionLogs: logs,
},
});
return;
}
if (outcome?.status === "error") {
logs.push(connectionLog("error", "docker_auth", message));
pending.emit({
status: 400,
body: { error: message, connectionLogs: logs },
});
return;
}
const { hostKeyChanged, authFailed } = classifyConnectError(
message,
logs,
);
if (hostKeyChanged || authFailed) {
ctx.hosts.status.reportLogin(hostId, { ok: false, hostKeyChanged });
}
log.error("Docker SSH connection failed", {
hostId,
userId,
error: message,
});
if (credentialless && authFailed) {
pending.emit({
status: 200,
body: {
status: "auth_required",
reason: "no_keyboard",
connectionLogs: logs,
},
});
return;
}
pending.emit({
status: 500,
body: { success: false, message, connectionLogs: logs },
});
},
);
return pending.next();
}
/** Carries a person's answer to a parked connect and waits for what's next. */
export async function answerConnect(
sessions: DockerSessions,
sessionId: string,
userId: string,
kind: "totp" | "warpgate",
value: string,
): Promise<ConnectStep> {
const pending = sessions.getPending(sessionId);
if (!(pending instanceof PendingConnect) || pending.userId !== userId) {
return {
status: 404,
body: { error: "Authentication session expired. Please reconnect." },
};
}
if (pending.awaiting !== kind) {
return {
status: 400,
body: {
error:
kind === "warpgate"
? "Session is not a Warpgate session"
: "Session is not waiting for a code",
},
};
}
pending.respond(value);
return pending.next();
}
+297 -606
View File
@@ -1,652 +1,343 @@
// Core imports below point at TypeScript source so tsc can type-check them.
// The plugin build rewrites the prefix to the compiled output path; see
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import { StringDecoder } from "string_decoder";
import type { Client as SSHClient } from "ssh2";
import type { PluginSshHost } from "@termix/plugin-sdk/backend";
import { connectSsh } from "./ssh.js";
import { WebSocketServer, WebSocket } from "ws";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { systemLogger } from "../../../../src/backend/utils/logger.js";
import type { SSHHost } from "../../../../src/types/index.js";
import { AsyncResource } from "node:async_hooks";
import { StringDecoder } from "node:string_decoder";
import type { Client, ClientChannel } from "ssh2";
import { WebSocket, type RawData } from "ws";
import type {
PluginContext,
PluginWebSocketConnection,
} from "@termix/plugin-sdk/backend";
import {
containerCommand,
getContainerRuntimeConfig,
type ContainerRuntime,
} from "./container-runtime.js";
import { readDockerHostSettings } from "./host-settings.js";
import {
hostAddressMismatch,
CONTAINER_ID_RE,
HOST_ADDRESS_MISMATCH_MESSAGE,
HOST_NOT_ON_THIS_SERVER_MESSAGE,
} from "../../../../src/backend/hosts/host-identity.js";
import { extractWebSocketToken } from "../../../../src/backend/utils/ws-auth.js";
import {
asObject,
asString,
MAX_WS_MESSAGE_BYTES,
getErrorMessage,
hostAddressMismatch,
parseWsMessage,
toTerminalDimension,
} from "../../../../src/backend/utils/ws-message.js";
type DockerLogger,
} from "./helpers.js";
const sshLogger = systemLogger;
const SHELLS = ["bash", "sh", "ash", "zsh"];
const PING_MS = 30_000;
interface SSHSession {
client: SSHClient;
stream: import("ssh2").ClientChannel | null;
isConnected: boolean;
containerId?: string;
shell?: string;
hostId?: number;
containerRuntime?: ContainerRuntime;
interface ConsoleSession {
client: Client;
dispose: () => void;
stream: ClientChannel | null;
hostId: number;
containerId: string;
runtime: ContainerRuntime;
}
const activeSessions = new Map<string, SSHSession>();
/**
* Created by startConsoleServer(), not at module load.
*
* Importing this file used to bind a port as a side effect, which meant the
* plugin could not be disabled and re-enabled: the module stays in the ESM
* cache, so the second activate() got a server that had already been closed.
* There is no port now -- core routes /plugin-ws/docker/console here -- but
* the same lifecycle applies to the server object itself.
*/
let wss: WebSocketServer | null = null;
function handleConnection(ws: WebSocket, req: import("http").IncomingMessage) {
void onConsoleConnection(ws, req);
}
/** Builds the console WebSocket server. Call from the plugin's activate(). */
export function startConsoleServer(): Promise<void> {
if (wss) return Promise.resolve();
const server = new WebSocketServer({
noServer: true,
maxPayload: MAX_WS_MESSAGE_BYTES,
});
// One bad socket must not take the server down.
server.on("error", (error) => {
sshLogger.error("Docker console WebSocket server error", error, {
operation: "wss_error",
});
});
server.on("connection", handleConnection);
wss = server;
return Promise.resolve();
}
/**
* Hands one upgrade to the console server.
*
* Registered as a public ctx.ws route because onConsoleConnection does its own
* per-message authentication, which is where the container and host checks
* live too.
*/
export function handleConsoleUpgrade(
request: import("http").IncomingMessage,
socket: import("stream").Duplex,
head: Buffer,
): void {
const server = wss;
if (!server) {
socket.destroy();
return;
}
server.handleUpgrade(request, socket, head, (ws) => {
server.emit("connection", ws, request);
});
}
async function detectShell(
session: SSHSession,
function shellExists(
client: Client,
runtime: ContainerRuntime,
containerId: string,
): Promise<string> {
const shells = ["bash", "sh", "ash"];
for (const shell of shells) {
try {
await new Promise<void>((resolve, reject) => {
session.client.exec(
containerCommand(
session.containerRuntime,
`exec ${containerId} which ${shell}`,
),
(err, stream) => {
if (err) return reject(err);
let output = "";
stream.on("data", (data: Buffer) => {
output += data.toString();
});
stream.on("close", (code: number) => {
if (code === 0 && output.trim()) {
resolve();
} else {
reject(new Error(`Shell ${shell} not found`));
}
});
stream.stderr.on("data", () => {});
stream.stderr.on("error", () => {});
stream.on("error", (streamErr) => {
reject(streamErr);
});
},
shell: string,
): Promise<boolean> {
return new Promise((resolve) => {
client.exec(
containerCommand(runtime, `exec ${containerId} which ${shell}`),
(err, stream) => {
if (err) return resolve(false);
let output = "";
stream.on("data", (data: Buffer) => {
output += data.toString();
});
stream.on("close", (code: number) =>
resolve(code === 0 && output.trim().length > 0),
);
});
stream.stderr.on("data", () => {});
stream.stderr.on("error", () => {});
stream.on("error", () => resolve(false));
},
);
});
}
return shell;
} catch {
continue;
}
async function pickShell(
client: Client,
runtime: ContainerRuntime,
containerId: string,
requested: string | undefined,
): Promise<string> {
if (requested && (await shellExists(client, runtime, containerId, requested)))
return requested;
for (const shell of ["bash", "sh", "ash"]) {
if (await shellExists(client, runtime, containerId, shell)) return shell;
}
return "sh";
}
async function onConsoleConnection(
ws: WebSocket,
req: import("http").IncomingMessage,
) {
const token = extractWebSocketToken(req);
if (!token) {
ws.close(1008, "Authentication required");
return;
}
const authManagerInstance = AuthManager.getInstance();
let payload;
try {
payload = await authManagerInstance.verifyJWTToken(token);
} catch (error) {
sshLogger.warn("Docker console JWT verification failed", {
operation: "docker_console_auth_error",
error: getErrorMessage(error),
});
ws.close(1008, "Authentication required");
return;
}
if (!payload?.userId || payload.pendingTOTP) {
ws.close(1008, "Authentication required");
return;
}
const userId = payload.userId;
const sessionId = `docker-console-${Date.now()}-${Math.random()}`;
sshLogger.info("Docker console WebSocket connected", {
operation: "docker_console_connect",
sessionId,
userId,
/**
* The container console at /plugin-ws/docker/console.
*
* Public with optionalAuth: core still resolves the caller's token, and the
* handler refuses a socket without a user or without docker.use. Every live
* console is closed when the plugin deactivates.
*/
export function registerConsole(ctx: PluginContext, log: DockerLogger): void {
const live = new Set<() => void>();
ctx.disposables.add(() => {
for (const end of [...live]) end();
live.clear();
});
let sshSession: SSHSession | null = null;
const wsPingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.ping();
const onConnection = async (connection: PluginWebSocketConnection) => {
const ws = connection.socket as WebSocket;
const userId = connection.userId;
if (!userId || !(await ctx.rbac.hasFor(userId, "use"))) {
ws.close(1008, "Authentication required");
return;
}
}, 30000);
const cleanup = () => {
clearInterval(wsPingInterval);
if (!sshSession) return;
sshSession.stream?.end();
sshSession.client.end();
activeSessions.delete(sessionId);
sshSession = null;
};
let session: ConsoleSession | null = null;
const send = (message: Record<string, unknown>) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message));
};
ws.on("message", async (data) => {
try {
const message = parseWsMessage(data);
const endSession = () => {
if (!session) return;
const current = session;
session = null;
current.stream?.end();
current.dispose();
};
switch (message.type) {
case "connect": {
const connectData = asObject(message.data);
const hostConfig = asObject(
connectData.hostConfig,
) as unknown as SSHHost;
const containerId = asString(connectData.containerId);
const shell = asString(connectData.shell) || undefined;
const cols = toTerminalDimension(connectData.cols) || 80;
const rows = toTerminalDimension(connectData.rows) || 24;
const pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.ping();
}, PING_MS);
const hostId = hostConfig?.id;
const cleanup = () => {
clearInterval(pingTimer);
endSession();
live.delete(shutdown);
};
const shutdown = () => {
cleanup();
try {
ws.close(1001, "Docker plugin disabled");
} catch {
// already closed
}
};
live.add(shutdown);
if (!hostId || !containerId) {
ws.send(
JSON.stringify({
type: "error",
message: "Host configuration and container ID are required",
}),
);
return;
}
const connect = async (data: unknown) => {
if (!connection.isDataUnlocked()) {
send({
type: "error",
message: "Session expired - please log in again",
});
return;
}
const payload = asObject(data);
const hostConfig = asObject(payload.hostConfig);
const hostId = Number(hostConfig.id);
const syncId =
typeof hostConfig.syncId === "string" ? hostConfig.syncId : null;
const containerId = asString(payload.containerId);
const shell = asString(payload.shell) || undefined;
const cols = toTerminalDimension(payload.cols) || 80;
const rows = toTerminalDimension(payload.rows) || 24;
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(containerId)) {
ws.send(
JSON.stringify({
type: "error",
message: "Invalid container ID",
}),
);
return;
}
if (!hostId || !containerId) {
send({
type: "error",
message: "Host configuration and container ID are required",
});
return;
}
if (!CONTAINER_ID_RE.test(containerId)) {
send({ type: "error", message: "Invalid container ID" });
return;
}
if (shell && !SHELLS.includes(shell)) {
send({ type: "error", message: "Invalid shell" });
return;
}
const allowedShells = ["bash", "sh", "ash", "zsh"];
if (shell && !allowedShells.includes(shell)) {
ws.send(
JSON.stringify({
type: "error",
message: "Invalid shell",
}),
);
return;
}
endSession();
if (!hostConfig.enableDocker) {
ws.send(
JSON.stringify({
type: "error",
message: "Docker is not enabled on this host",
}),
);
return;
}
try {
// Resolve host with credentials server-side
const { resolveHostById, resolveHostBySyncId } =
await import("../../../../src/backend/hosts/host-resolver.js");
// syncId names the host on both sides of a sync pair; the numeric
// id only names it in the database the client is displaying.
const hostSyncId = hostConfig?.syncId;
const resolvedHost = hostSyncId
? await resolveHostBySyncId(hostSyncId, userId)
: await resolveHostById(hostId, userId);
if (!resolvedHost) {
ws.send(
JSON.stringify({
type: "error",
message: hostSyncId
? HOST_NOT_ON_THIS_SERVER_MESSAGE
: "Host not found",
}),
);
return;
}
// The connection below dials resolvedHost.ip outright, so if this
// server has a different machine under the id the client sent, the
// console opens on that machine's Docker daemon instead.
if (
!hostSyncId &&
hostAddressMismatch(hostConfig?.ip, resolvedHost.ip)
) {
sshLogger.error(
"Refusing Docker console: host id resolves to a different address here",
undefined,
{
operation: "docker_console_host_id_mismatch",
hostId,
userId,
clientIp: hostConfig?.ip,
resolvedIp: resolvedHost.ip,
},
);
ws.send(
JSON.stringify({
type: "error",
message: HOST_ADDRESS_MISMATCH_MESSAGE,
}),
);
return;
}
if (!resolvedHost.enableDocker) {
ws.send(
JSON.stringify({
type: "error",
message:
"Docker is not enabled for this host. Enable it in Host Settings.",
}),
);
return;
}
// The console socket is public and authenticates itself, so the
// acting user is carried on the host rather than the request.
const { client } = await connectSsh(
{
...(resolvedHost as unknown as PluginSshHost),
userId,
} as PluginSshHost,
{ purpose: "docker-console", timeoutMs: 65000 },
);
const { runtime: containerRuntime } = getContainerRuntimeConfig(
resolvedHost.dockerConfig,
);
sshSession = {
client,
stream: null,
isConnected: true,
containerId,
hostId: resolvedHost.id,
containerRuntime,
};
activeSessions.set(sessionId, sshSession);
let shellToUse = shell || "bash";
if (shell) {
try {
await new Promise<void>((resolve, reject) => {
client.exec(
containerCommand(
containerRuntime,
`exec ${containerId} which ${shell}`,
),
(err, stream) => {
if (err) return reject(err);
let output = "";
stream.on("data", (data: Buffer) => {
output += data.toString();
});
stream.on("close", (code: number) => {
if (code === 0 && output.trim()) {
resolve();
} else {
reject(new Error(`Shell ${shell} not available`));
}
});
stream.stderr.on("data", () => {});
stream.stderr.on("error", () => {});
stream.on("error", (streamErr) => {
reject(streamErr);
});
},
);
});
} catch {
sshLogger.warn(
`Requested shell ${shell} not found, detecting available shell`,
{
operation: "shell_validation",
sessionId,
containerId,
requestedShell: shell,
},
);
shellToUse = await detectShell(sshSession, containerId);
}
} else {
shellToUse = await detectShell(sshSession, containerId);
}
sshSession.shell = shellToUse;
const execCommand = containerCommand(
containerRuntime,
`exec -it ${containerId} /bin/${shellToUse}`,
);
sshLogger.info("Attaching to Docker container", {
operation: "docker_attach",
sessionId,
userId,
hostId: resolvedHost.id,
containerId,
});
client.exec(
execCommand,
{
pty: {
term: "xterm-256color",
cols,
rows,
},
},
(err, stream) => {
if (err) {
sshLogger.error("Failed to create docker exec", err, {
operation: "docker_exec",
sessionId,
containerId,
});
ws.send(
JSON.stringify({
type: "error",
message: `Failed to start console: ${err.message}`,
}),
);
return;
}
sshSession!.stream = stream;
sshLogger.success("Docker container attached", {
operation: "docker_attach_success",
sessionId,
userId,
hostId: resolvedHost.id,
containerId,
});
// Buffers incomplete multi-byte UTF-8 sequences across chunk
// boundaries so box-drawing/special characters don't get
// corrupted when a character is split across TCP packets.
const decoder = new StringDecoder("utf-8");
stream.on("data", (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
const text = decoder.write(data);
if (!text) return;
ws.send(
JSON.stringify({
type: "output",
data: text,
}),
);
}
});
stream.stderr.on("data", () => {});
stream.stderr.on("error", () => {});
stream.on("error", (streamErr) => {
sshLogger.error("Docker console stream error", streamErr, {
operation: "docker_console_stream_error",
sessionId,
containerId,
});
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "error",
message: `Console error: ${streamErr.message}`,
}),
);
}
});
stream.on("close", () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "disconnected",
message: "Console session ended",
}),
);
}
if (sshSession) {
sshSession.client.end();
activeSessions.delete(sessionId);
}
});
ws.send(
JSON.stringify({
type: "connected",
data: {
shell: shellToUse,
requestedShell: shell,
shellChanged: shell && shell !== shellToUse,
},
}),
);
},
);
} catch (error) {
sshLogger.error("Failed to connect to container", error, {
operation: "console_connect",
sessionId,
containerId,
});
ws.send(
JSON.stringify({
type: "error",
message: getErrorMessage(
error,
"Failed to connect to container",
),
}),
);
}
break;
try {
// syncId names the host on both sides of a sync pair; the numeric
// id only names it in the database the client is displaying.
const host = await ctx.ssh.resolveHost(hostId, { syncId });
if (!host) {
send({
type: "error",
message: syncId
? HOST_NOT_ON_THIS_SERVER_MESSAGE
: "Host not found",
});
return;
}
if (!syncId && hostAddressMismatch(hostConfig.ip, host.ip)) {
log.error("Refusing Docker console: host id resolves elsewhere", {
hostId,
userId,
});
send({ type: "error", message: HOST_ADDRESS_MISMATCH_MESSAGE });
return;
}
case "input": {
if (sshSession && sshSession.stream) {
const input = asString(message.data);
if (input) sshSession.stream.write(input);
}
break;
const settings = await readDockerHostSettings(ctx, host.id);
if (!settings.enabled) {
send({
type: "error",
message:
"Docker is not enabled for this host. Enable it in Host Settings.",
});
return;
}
case "resize": {
if (sshSession && sshSession.stream) {
const dimensions = asObject(message.data);
const cols = toTerminalDimension(dimensions.cols);
const rows = toTerminalDimension(dimensions.rows);
const { client, dispose } = await ctx.ssh.connect<Client>(host, {
purpose: "docker-console",
timeoutMs: 65_000,
});
if (ws.readyState !== WebSocket.OPEN) {
dispose();
return;
}
const current: ConsoleSession = {
client,
dispose,
stream: null,
hostId: host.id,
containerId,
runtime: settings.runtime,
};
session = current;
const shellToUse = await pickShell(
client,
settings.runtime,
containerId,
shell,
);
if (session !== current) return;
client.exec(
containerCommand(
settings.runtime,
`exec -it ${containerId} /bin/${shellToUse}`,
),
{ pty: { term: "xterm-256color", cols, rows } },
(err, stream) => {
if (err) {
send({
type: "error",
message: `Failed to start console: ${err.message}`,
});
return;
}
if (session !== current) {
stream.end();
return;
}
current.stream = stream;
// Buffers split multi-byte UTF-8 sequences across chunks.
const decoder = new StringDecoder("utf-8");
stream.on("data", (chunk: Buffer) => {
const text = decoder.write(chunk);
if (text) send({ type: "output", data: text });
});
stream.stderr.on("data", () => {});
stream.stderr.on("error", () => {});
stream.on("error", (streamErr: Error) => {
send({
type: "error",
message: `Console error: ${streamErr.message}`,
});
});
stream.on("close", () => {
send({ type: "disconnected", message: "Console session ended" });
if (session === current) endSession();
});
send({
type: "connected",
data: {
shell: shellToUse,
requestedShell: shell,
shellChanged: !!shell && shell !== shellToUse,
},
});
},
);
} catch (error) {
log.error("Failed to connect to container", error, {
hostId,
containerId,
});
send({
type: "error",
message: getErrorMessage(error, "Failed to connect to container"),
});
}
};
ws.on(
"message",
AsyncResource.bind(async (raw: RawData) => {
let type: string;
let data: unknown;
try {
({ type, data } = parseWsMessage(raw));
} catch (error) {
send({ type: "error", message: getErrorMessage(error) });
return;
}
switch (type) {
case "connect":
await connect(data);
break;
case "input": {
const input = asString(data);
if (input) session?.stream?.write(input);
break;
}
case "resize": {
const size = asObject(data);
const cols = toTerminalDimension(size.cols);
const rows = toTerminalDimension(size.rows);
if (cols && rows)
sshSession.stream.setWindow(rows, cols, rows, cols);
session?.stream?.setWindow(rows, cols, rows, cols);
break;
}
break;
}
case "disconnect": {
if (sshSession) {
if (sshSession.stream) {
sshSession.stream.end();
}
sshSession.client.end();
activeSessions.delete(sessionId);
ws.send(
JSON.stringify({
case "disconnect":
if (session) {
endSession();
send({
type: "disconnected",
message: "Disconnected from container",
}),
);
}
break;
});
}
break;
case "ping":
send({ type: "pong" });
break;
default:
log.warn("Unknown docker console message", { type });
}
}),
);
case "ping": {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "pong" }));
}
break;
}
default:
sshLogger.warn("Unknown message type", {
operation: "ws_message",
type: message.type,
});
}
} catch (error) {
sshLogger.error("WebSocket message error", error, {
operation: "ws_message",
sessionId,
});
ws.send(
JSON.stringify({
type: "error",
message: getErrorMessage(error, "An error occurred"),
}),
);
}
});
ws.on("close", () => {
sshLogger.info("Docker console disconnected", {
operation: "docker_console_disconnect",
sessionId,
userId,
hostId: sshSession?.hostId,
containerId: sshSession?.containerId,
ws.on("close", cleanup);
ws.on("error", (error: Error) => {
log.error("Docker console socket error", error);
cleanup();
});
cleanup();
});
};
ws.on("error", (error) => {
sshLogger.error("WebSocket error", error, {
operation: "ws_error",
sessionId,
});
cleanup();
});
}
/**
* Closes every live console session and the WebSocket server. Called from the
* plugin's deactivate().
*
* There is deliberately no process signal handler here. Core owns shutdown:
* gracefulShutdown in src/backend/starter.ts calls shutdownPlugins(), which
* runs every plugin's deactivate. A plugin calling process.exit() itself
* skipped the rest of that sequence, including other plugins and the
* database flush.
*/
export function closeConsoleServer(): Promise<void> {
activeSessions.forEach((session) => {
if (session.stream) {
session.stream.end();
}
session.client.end();
});
activeSessions.clear();
const server = wss;
wss = null;
if (!server) return Promise.resolve();
server.off("connection", handleConnection);
for (const client of server.clients) {
try {
client.close(1001, "Docker plugin disabled");
} catch {
// Already gone.
}
}
return new Promise((resolve) => server.close(() => resolve()));
ctx.ws.route("/console", onConnection, { public: true, optionalAuth: true });
}
File diff suppressed because it is too large. Load diff
@@ -4,33 +4,6 @@ export function normalizeContainerRuntime(value: unknown): ContainerRuntime {
return value === "podman" ? "podman" : "docker";
}
export function getContainerRuntimeConfig(raw: unknown): {
runtime: ContainerRuntime;
} {
if (!raw) {
return { runtime: "docker" };
}
let config: unknown = raw;
if (typeof raw === "string") {
try {
config = JSON.parse(raw) as Record<string, unknown>;
} catch {
return { runtime: "docker" };
}
}
if (!config || typeof config !== "object") {
return { runtime: "docker" };
}
return {
runtime: normalizeContainerRuntime(
(config as Record<string, unknown>).runtime,
),
};
}
/**
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
* so PATH additions from installers like Homebrew or OrbStack are missing.
@@ -0,0 +1,84 @@
/**
* Container state snapshots and the events two of them imply, for the
* docker.events service.
*
* Events come from diffing successive `ps -a` snapshots, so the poll interval
* is the resolution: a container that stops and starts between two polls is
* not reported. That is the tradeoff for not holding a `docker events` stream
* open against every watched host.
*/
export interface ContainerState {
/** Docker's own state word: running, exited, restarting, ... */
state: string;
/** Health from the status text, when the image declares a healthcheck. */
unhealthy: boolean;
}
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
export const PS_STATE_FORMAT = `'{"name":"{{.Names}}","state":"{{.State}}","status":"{{.Status}}"}'`;
/** Parses `ps -a --format PS_STATE_FORMAT` output, one JSON object per line. */
export function parseContainerStates(
output: string,
): Map<string, ContainerState> {
const states = new Map<string, ContainerState>();
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed) as {
name?: string;
state?: string;
status?: string;
};
if (!parsed.name) continue;
states.set(parsed.name, {
state: (parsed.state ?? "").toLowerCase(),
unhealthy: /\(unhealthy\)/i.test(parsed.status ?? ""),
});
} catch {
// A partial line is not worth failing the whole poll over.
}
}
return states;
}
/**
* The events a pair of snapshots implies. A container missing from the
* previous snapshot counts as newly seen rather than started, so the first
* poll after a restart does not replay every running container.
*/
export function diffContainerStates(
previous: Map<string, ContainerState>,
current: Map<string, ContainerState>,
): Array<{ container: string; event: DockerEventName }> {
const events: Array<{ container: string; event: DockerEventName }> = [];
for (const [name, now] of current) {
const before = previous.get(name);
if (!before) continue;
if (before.state !== now.state) {
if (now.state === "exited")
events.push({ container: name, event: "exited" });
else if (now.state === "running")
events.push({ container: name, event: "started" });
else if (now.state === "restarting")
events.push({ container: name, event: "restarting" });
}
// Health is independent of state: a container can go unhealthy while it
// stays up, which is exactly the case worth alerting on.
if (!before.unhealthy && now.unhealthy) {
events.push({ container: name, event: "unhealthy" });
}
}
return events;
}
+154
View File
@@ -0,0 +1,154 @@
import type { RawData } from "ws";
import type { PluginLogger } from "@termix/plugin-sdk/backend";
export function getErrorMessage(
error: unknown,
fallback = "Unknown error",
): string {
return error instanceof Error ? error.message : fallback;
}
export type Meta = Record<string, unknown>;
export interface DockerLogger {
info: (message: string, meta?: Meta) => void;
warn: (message: string, meta?: Meta) => void;
/** An error, or structured context, or both. */
error: (message: string, errorOrMeta?: unknown, meta?: Meta) => void;
}
function withMeta(message: string, meta?: Meta): string {
if (!meta || Object.keys(meta).length === 0) return message;
try {
return `${message} ${JSON.stringify(meta)}`;
} catch {
return message;
}
}
/** ctx.log with the structured context the old core logger carried. */
export function createLogger(log: PluginLogger): DockerLogger {
return {
info: (message, meta) => log.info(withMeta(message, meta)),
warn: (message, meta) => log.warn(withMeta(message, meta)),
error: (message, errorOrMeta, meta) => {
if (errorOrMeta instanceof Error) {
log.error(withMeta(message, meta), errorOrMeta);
return;
}
log.error(
withMeta(message, {
...((errorOrMeta as Meta | undefined) ?? {}),
...meta,
}),
);
},
};
}
export type LogLevel = "info" | "success" | "warning" | "error";
/** One line of the connection log the panel shows while connecting. */
export interface ConnectionLogLine {
type: LogLevel;
stage: string;
message: string;
}
export function connectionLog(
type: LogLevel,
stage: string,
message: string,
): ConnectionLogLine {
return { type, stage, message };
}
export const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
export const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/;
// Cap on a single decoded text frame. Control messages are tiny, and console
// input is bounded by what a user can type or paste.
export const MAX_WS_MESSAGE_BYTES = 1024 * 1024;
function rawByteLength(raw: RawData): number {
if (Buffer.isBuffer(raw)) return raw.length;
if (Array.isArray(raw))
return raw.reduce((sum, part) => sum + part.length, 0);
if (raw instanceof ArrayBuffer) return raw.byteLength;
return 0;
}
/** Parses a frame into `{ type, data }`, throwing on anything else. */
export function parseWsMessage(raw: RawData): { type: string; data: unknown } {
if (rawByteLength(raw) > MAX_WS_MESSAGE_BYTES) {
throw new Error("Message too large");
}
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString());
} catch {
throw new Error("Invalid JSON");
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Message must be a JSON object");
}
const { type, data } = parsed as { type?: unknown; data?: unknown };
if (typeof type !== "string") {
throw new Error("Message type must be a string");
}
return { type, data };
}
export function asObject(data: unknown): Record<string, unknown> {
return data !== null && typeof data === "object" && !Array.isArray(data)
? (data as Record<string, unknown>)
: {};
}
export function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
/** A sane integer width or height, or 0 when the value is unusable. */
export function toTerminalDimension(value: unknown): number {
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) return 0;
const rounded = Math.floor(n);
if (rounded < 1) return 0;
return Math.min(rounded, 10000);
}
function normalizeHostAddress(value: unknown): string {
if (typeof value !== "string") return "";
return value
.replace(/^\[|\]$/g, "")
.trim()
.toLowerCase();
}
/**
* Whether a numeric host id resolved to a different machine than the client
* meant. Ids from the desktop app and a sync server drift apart, and the
* resolved row supplies the credentials, so a mismatch must be refused.
*/
export function hostAddressMismatch(
clientAddress: unknown,
resolvedAddress: unknown,
): boolean {
const resolved = normalizeHostAddress(resolvedAddress);
if (!resolved) return false;
return resolved !== normalizeHostAddress(clientAddress);
}
export const HOST_ADDRESS_MISMATCH_MESSAGE =
"Host mismatch: this server resolved the selected host to a different machine, so the connection was refused. " +
"The host ids on this device and on the sync server have drifted apart. " +
'Set the connection origin to "This device" for this host, or re-run a full sync, then try again.';
export const HOST_NOT_ON_THIS_SERVER_MESSAGE =
"This host does not exist on the sync server, so the connection was refused. " +
'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.';
export function newSessionId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
@@ -0,0 +1,70 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
normalizeContainerRuntime,
type ContainerRuntime,
} from "./container-runtime.js";
export const PLUGIN_ID = "docker";
export interface DockerHostSettings {
enabled: boolean;
runtime: ContainerRuntime;
}
export async function readDockerHostSettings(
ctx: PluginContext,
hostId: number,
): Promise<DockerHostSettings> {
const values = await ctx.settings.getAll("host", hostId);
return {
enabled: values.enableDocker === true,
runtime: normalizeContainerRuntime(values.containerRuntime),
};
}
function asObject(value: unknown): Record<string, unknown> | undefined {
let parsed = value;
if (typeof value === "string") {
try {
parsed = JSON.parse(value);
} catch {
return undefined;
}
}
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined;
}
function asBool(value: unknown): boolean | undefined {
if (typeof value === "boolean") return value;
if (value === 1 || value === "1" || value === "true") return true;
if (value === 0 || value === "0" || value === "false") return false;
return undefined;
}
/**
* The docker host settings in an imported or created host row: the nested
* `pluginSettings.docker` of a Termix export first, then the flat
* `enableDocker` and `dockerConfig.runtime` hosts had before 2.9.0 (older
* exports, proxmox guests marked as Docker hosts).
*/
export function normalizeImportedHost(
raw: Record<string, unknown>,
): Record<string, unknown> | null {
const nested = asObject(asObject(raw.pluginSettings)?.[PLUGIN_ID]) ?? {};
const out: Record<string, unknown> = {};
const enabled = asBool(
nested.enableDocker !== undefined ? nested.enableDocker : raw.enableDocker,
);
if (enabled !== undefined) out.enableDocker = enabled;
const runtime =
nested.containerRuntime ?? asObject(raw.dockerConfig)?.runtime;
if (runtime === "docker" || runtime === "podman") {
out.containerRuntime = runtime;
}
return Object.keys(out).length > 0 ? out : null;
}
+22 -71
View File
@@ -1,82 +1,33 @@
import express, { type Router } from "express";
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { registerDockerContainerRoutes } from "./container-routes.js";
import {
sshSessions,
pendingTOTPSessions,
executeDockerCommand,
} from "./session-manager.js";
import {
DOCKER_TIMESTAMP_RE,
getRequestUserId,
registerDockerSshRoutes,
} from "./routes.js";
import {
closeConsoleServer,
handleConsoleUpgrade,
startConsoleServer,
} from "./console.js";
import { createLogger } from "./helpers.js";
import { createDockerSessions } from "./sessions.js";
import { registerRoutes } from "./routes.js";
import { registerConsole } from "./console.js";
import { registerServices } from "./services.js";
import { normalizeImportedHost } from "./host-settings.js";
let authManagerInstance: ReturnType<typeof AuthManager.getInstance> | null =
null;
export type {
DockerContainerAction,
DockerContainerSummary,
DockerEvent,
DockerEventsV1,
DockerServiceV1,
} from "./services.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
// An express app rather than the router directly, so the existing
// register*Routes helpers keep the Application they expect. Core mounts it
// at /plugin-api/docker and runs compression, CORS, cookies, auth and body
// parsing in front of it.
const app = express();
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
const log = createLogger(ctx.log);
const sessions = createDockerSessions(ctx.schedule, log);
ctx.disposables.add(() => sessions.closeAll());
registerDockerSshRoutes(app);
registerDockerContainerRoutes(app, {
sshSessions,
pendingTOTPSessions,
getRequestUserId,
executeDockerCommand,
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
});
ctx.http.router<Router>({ bodyLimit: "100mb" }).use(app);
authManagerInstance = AuthManager.getInstance();
try {
await authManagerInstance.initialize();
} catch (err) {
logger.error("Failed to initialize Docker backend", err, {
operation: "startup",
});
}
await startConsoleServer();
// Public because the console authenticates each session over the socket
// itself, where the container and host checks also live.
ctx.ws.upgrade(
"/console",
(request, socket, head) =>
handleConsoleUpgrade(
request as Parameters<typeof handleConsoleUpgrade>[0],
socket as Parameters<typeof handleConsoleUpgrade>[1],
head as Buffer,
),
{ public: true },
);
registerRoutes(ctx.http.router<Router>(), { ctx, sessions, log });
registerConsole(ctx, log);
registerServices(ctx, log);
ctx.registry.provide("docker.hostImportNormalizer", normalizeImportedHost);
ctx.log.info(
"Docker mounted at /plugin-api/docker and /plugin-ws/docker/console",
);
}
export async function deactivate() {
await closeConsoleServer();
authManagerInstance = null;
}
export async function deactivate() {}
File diff suppressed because it is too large. Load diff
+256
View File
@@ -0,0 +1,256 @@
import type { Client } from "ssh2";
import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend";
import { containerCommand } from "./container-runtime.js";
import {
PS_STATE_FORMAT,
diffContainerStates,
parseContainerStates,
type ContainerState,
type DockerEventName,
} from "./container-state.js";
import { readDockerHostSettings } from "./host-settings.js";
import { execOnClient } from "./sessions.js";
import { listFormat, parseContainerList } from "./routes.js";
import {
CONTAINER_ID_RE,
getErrorMessage,
type DockerLogger,
} from "./helpers.js";
export const POOL = "docker";
const TICK_MS = 15_000;
const POLL_INTERVAL_MS = 60_000;
const HOST_CACHE_MS = 5 * 60_000;
export interface DockerContainerSummary {
id: string;
name: string;
image: string;
status: string;
state: string;
ports: string;
created: string;
}
export type DockerContainerAction =
"start" | "stop" | "restart" | "pause" | "unpause" | "remove";
const ACTIONS: DockerContainerAction[] = [
"start",
"stop",
"restart",
"pause",
"unpause",
"remove",
];
/** The "docker.containers" service, version 1. Runs as the caller's user. */
export interface DockerServiceV1 {
listContainers: (
hostId: number,
options?: { all?: boolean },
) => Promise<DockerContainerSummary[]>;
action: (
hostId: number,
container: string,
action: DockerContainerAction,
) => Promise<void>;
}
export interface DockerEvent {
hostId: number;
container: string;
event: DockerEventName;
}
/** The "docker.events" service, version 1. Runs as the caller's user. */
export interface DockerEventsV1 {
/**
* Calls `listener` for container state changes on a host, polled about
* once a minute as the subscribing user. Subscribing the same listener
* again is a no-op, so a consumer can re-subscribe on a timer and pick up
* a restarted docker plugin. Resolves to an unsubscribe function.
*/
subscribe: (
hostId: number,
listener: (event: DockerEvent) => void,
) => Promise<() => void>;
}
function actorOf(ctx: PluginContext): string {
const userId = ctx.currentActor();
if (!userId) throw new Error("The docker service needs a calling user");
return userId;
}
export function registerServices(ctx: PluginContext, log: DockerLogger): void {
const runOnHost = async <T>(
hostId: number,
command: (runtime: "docker" | "podman") => string,
handle: (output: string) => T,
): Promise<T> => {
const { runtime } = await readDockerHostSettings(ctx, hostId);
const output = await ctx.ssh.withConnection<string, Client>(
hostId,
{ pool: POOL, purpose: "docker" },
(client) => execOnClient(client, command(runtime)),
);
return handle(output);
};
ctx.services.provide<DockerServiceV1>("docker.containers", {
listContainers: async (hostId, options) => {
actorOf(ctx);
const all = options?.all !== false;
return runOnHost(
hostId,
(runtime) =>
containerCommand(
runtime,
`ps ${all ? "-a " : ""}--format ${listFormat(false)}`,
),
(output) =>
parseContainerList(output) as unknown as DockerContainerSummary[],
);
},
action: async (hostId, container, action) => {
actorOf(ctx);
if (!CONTAINER_ID_RE.test(container)) {
throw new Error("Invalid container name");
}
if (!ACTIONS.includes(action)) {
throw new Error(`Unknown container action: ${action}`);
}
const args =
action === "remove" ? `rm ${container}` : `${action} ${container}`;
await runOnHost(
hostId,
(runtime) => containerCommand(runtime, args),
() => undefined,
);
},
});
interface Watch {
userId: string;
hostId: number;
listeners: Set<(event: DockerEvent) => void>;
snapshot?: Map<string, ContainerState>;
host?: { value: PluginSshHost; at: number };
lastPolledAt: number;
failing: boolean;
}
const watches = new Map<string, Watch>();
ctx.disposables.add(() => watches.clear());
const hostFor = async (watch: Watch): Promise<PluginSshHost | null> => {
if (watch.host && Date.now() - watch.host.at < HOST_CACHE_MS) {
return watch.host.value;
}
const host = await ctx.asUser(watch.userId, () =>
ctx.ssh.resolveHost(watch.hostId),
);
watch.host = host ? { value: host, at: Date.now() } : undefined;
return host;
};
const pollOne = async (watch: Watch) => {
try {
const host = await hostFor(watch);
if (!host) throw new Error("Host not found");
const { runtime } = await readDockerHostSettings(ctx, watch.hostId);
const output = await ctx.ssh.withConnection<string, Client>(
host,
{ pool: POOL, purpose: "docker" },
(client) =>
execOnClient(
client,
containerCommand(runtime, `ps -a --format ${PS_STATE_FORMAT}`),
),
);
const current = parseContainerStates(output);
const previous = watch.snapshot;
watch.snapshot = current;
watch.failing = false;
if (!previous) return;
for (const { container, event } of diffContainerStates(
previous,
current,
)) {
for (const listener of [...watch.listeners]) {
try {
listener({ hostId: watch.hostId, container, event });
} catch (error) {
log.warn("Docker event listener failed", {
hostId: watch.hostId,
error: getErrorMessage(error),
});
}
}
}
} catch (error) {
// Docker missing, the host down or the owner's data locked. A later
// success is a first observation again rather than a diff.
watch.snapshot = undefined;
watch.host = undefined;
if (!watch.failing) {
log.warn("Docker event poll failed", {
hostId: watch.hostId,
error: getErrorMessage(error),
});
}
watch.failing = true;
}
};
ctx.schedule.every(TICK_MS, async () => {
const now = Date.now();
for (const watch of [...watches.values()]) {
if (now - watch.lastPolledAt < POLL_INTERVAL_MS) continue;
watch.lastPolledAt = now;
await pollOne(watch);
}
});
ctx.events.on("host.updated", (payload) => {
const { hostId } = payload as { hostId?: number };
if (!hostId) return;
ctx.ssh.dropPooled(POOL, hostId);
for (const watch of watches.values()) {
if (watch.hostId === hostId) watch.host = undefined;
}
});
ctx.events.on("host.deleted", (payload) => {
const { hostId } = payload as { hostId?: number };
if (!hostId) return;
for (const [key, watch] of watches) {
if (watch.hostId === hostId) watches.delete(key);
}
});
ctx.services.provide<DockerEventsV1>("docker.events", {
subscribe: async (hostId, listener) => {
const userId = actorOf(ctx);
const key = `${userId}:${hostId}`;
let watch = watches.get(key);
if (!watch) {
watch = {
userId,
hostId,
listeners: new Set(),
lastPolledAt: 0,
failing: false,
};
watches.set(key, watch);
}
watch.listeners.add(listener);
const current = watch;
return () => {
current.listeners.delete(listener);
if (current.listeners.size === 0 && watches.get(key) === current) {
watches.delete(key);
}
};
},
});
}
@@ -1,174 +0,0 @@
// Core imports below point at TypeScript source so tsc can type-check them.
// The plugin build rewrites the prefix to the compiled output path; see
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import { Client as SSHClient } from "ssh2";
import { logger } from "../../../../src/backend/utils/logger.js";
import type { ContainerRuntime } from "./container-runtime.js";
const sshLogger = logger;
export interface SSHSession {
client: SSHClient;
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
activeOperations: number;
hostId?: number;
userId?: string;
isWindows?: boolean;
containerRuntime?: ContainerRuntime;
}
export interface PendingTOTPSession {
client: SSHClient;
finish: (responses: string[]) => void;
config: Record<string, unknown>;
createdAt: number;
sessionId: string;
hostId?: number;
ip?: string;
port?: number;
username?: string;
userId?: string;
prompts?: Array<{ prompt: string; echo: boolean }>;
totpPromptIndex?: number;
resolvedPassword?: string;
totpAttempts: number;
isWarpgate?: boolean;
containerRuntime?: ContainerRuntime;
}
export const sshSessions: Record<string, SSHSession> = {};
export const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
const SESSION_IDLE_TIMEOUT = 60 * 60 * 1000;
setInterval(() => {
const now = Date.now();
Object.keys(pendingTOTPSessions).forEach((sessionId) => {
const session = pendingTOTPSessions[sessionId];
if (now - session.createdAt > 180000) {
try {
session.client.end();
} catch {
// expected
}
delete pendingTOTPSessions[sessionId];
}
});
}, 60000);
export function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
if (session.activeOperations > 0) {
sshLogger.warn(
`Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`,
{
operation: "cleanup_deferred",
sessionId,
activeOperations: session.activeOperations,
},
);
scheduleSessionCleanup(sessionId);
return;
}
try {
session.client.end();
} catch {
// expected
}
clearTimeout(session.timeout);
delete sshSessions[sessionId];
}
}
export function scheduleSessionCleanup(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
if (session.timeout) clearTimeout(session.timeout);
session.timeout = setTimeout(() => {
cleanupSession(sessionId);
}, SESSION_IDLE_TIMEOUT);
}
}
export async function executeDockerCommand(
session: SSHSession,
command: string,
sessionId?: string,
userId?: string,
hostId?: number,
): Promise<string> {
const startTime = Date.now();
sshLogger.info("Executing Docker command", {
operation: "docker_command_exec",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
});
return new Promise((resolve, reject) => {
session.client.exec(command, (err, stream) => {
if (err) {
sshLogger.error("Docker command execution error", err, {
operation: "execute_docker_command",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
});
return reject(err);
}
let stdout = "";
let stderr = "";
stream.on("close", (code: number) => {
if (code !== 0) {
sshLogger.error("Docker command failed", undefined, {
operation: "execute_docker_command",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
exitCode: code,
stderr,
});
reject(new Error(stderr || `Command exited with code ${code}`));
} else {
sshLogger.success("Docker command completed", {
operation: "docker_command_success",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
duration: Date.now() - startTime,
});
resolve(stdout);
}
});
stream.on("data", (data: Buffer) => {
stdout += data.toString();
});
stream.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
stream.on("error", (streamErr: Error) => {
sshLogger.error("Docker command stream error", streamErr, {
operation: "execute_docker_command",
sessionId,
userId,
hostId,
command: command.split(" ")[1],
});
reject(streamErr);
});
});
});
}
+183
View File
@@ -0,0 +1,183 @@
import type { Client } from "ssh2";
import type { PluginSchedule } from "@termix/plugin-sdk/backend";
import type { ContainerRuntime } from "./container-runtime.js";
import type { DockerLogger } from "./helpers.js";
const SESSION_IDLE_MS = 60 * 60 * 1000;
const PENDING_SWEEP_MS = 60 * 1000;
/** A live SSH connection behind the Docker panel, owned by one user. */
export interface DockerSession {
id: string;
client: Client;
dispose: () => void;
hostId: number;
userId: string;
runtime: ContainerRuntime;
isWindows: boolean;
lastActive: number;
activeOperations: number;
cancelIdle?: () => void;
}
/** A connect still waiting on a person (TOTP, Warpgate). */
export interface PendingEntry {
userId: string;
hostId: number;
expiresAt: number;
/** Gives up: answers the open prompt with nothing. */
cancel: () => void;
}
export interface DockerSessions {
add: (session: DockerSession) => void;
/** The session when `userId` owns it. */
get: (id: string, userId: string) => DockerSession | undefined;
close: (id: string) => void;
touch: (session: DockerSession) => void;
/** Runs `fn` with the session marked busy, so idle cleanup waits. */
run: <T>(session: DockerSession, fn: () => Promise<T>) => Promise<T>;
exec: (session: DockerSession, command: string) => Promise<string>;
setPending: (id: string, entry: PendingEntry) => void;
getPending: (id: string) => PendingEntry | undefined;
clearPending: (id: string) => void;
closeAll: () => void;
}
/** Runs one command and resolves stdout, rejecting on a non-zero exit. */
export function execOnClient(client: Client, command: string): Promise<string> {
return new Promise((resolve, reject) => {
client.exec(command, (err, stream) => {
if (err) return reject(err);
let stdout = "";
let stderr = "";
stream.on("close", (code: number) => {
if (code !== 0 && code !== null && code !== undefined) {
reject(new Error(stderr || `Command exited with code ${code}`));
} else {
resolve(stdout);
}
});
stream.on("data", (data: Buffer) => {
stdout += data.toString();
});
stream.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
stream.on("error", (streamErr: Error) => reject(streamErr));
});
});
}
export function createDockerSessions(
schedule: PluginSchedule,
log: DockerLogger,
): DockerSessions {
const sessions = new Map<string, DockerSession>();
const pending = new Map<string, PendingEntry>();
const end = (session: DockerSession) => {
session.cancelIdle?.();
sessions.delete(session.id);
session.dispose();
};
const scheduleIdle = (session: DockerSession) => {
session.cancelIdle?.();
session.cancelIdle = schedule.after(SESSION_IDLE_MS, () => {
if (sessions.get(session.id) !== session) return;
if (session.activeOperations > 0) {
scheduleIdle(session);
return;
}
end(session);
});
};
schedule.every(PENDING_SWEEP_MS, () => {
const now = Date.now();
for (const [id, entry] of pending) {
if (now < entry.expiresAt) continue;
pending.delete(id);
entry.cancel();
}
});
return {
add: (session) => {
const existing = sessions.get(session.id);
if (existing) end(existing);
sessions.set(session.id, session);
session.client.once("close", () => {
if (sessions.get(session.id) === session) {
session.cancelIdle?.();
sessions.delete(session.id);
}
});
scheduleIdle(session);
},
get: (id, userId) => {
const session = sessions.get(id);
return session && session.userId === userId ? session : undefined;
},
close: (id) => {
const session = sessions.get(id);
if (session) end(session);
const entry = pending.get(id);
if (entry) {
pending.delete(id);
entry.cancel();
}
},
touch: (session) => {
session.lastActive = Date.now();
scheduleIdle(session);
},
run: async (session, fn) => {
session.lastActive = Date.now();
session.activeOperations++;
try {
return await fn();
} finally {
session.activeOperations--;
}
},
exec: async (session, command) => {
const started = Date.now();
try {
return await execOnClient(session.client, command);
} catch (error) {
log.warn("Docker command failed", {
hostId: session.hostId,
command: command.split(" ")[2],
durationMs: Date.now() - started,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
},
setPending: (id, entry) => {
const existing = pending.get(id);
if (existing) existing.cancel();
pending.set(id, entry);
},
getPending: (id) => pending.get(id),
clearPending: (id) => {
pending.delete(id);
},
closeAll: () => {
for (const session of [...sessions.values()]) end(session);
for (const entry of pending.values()) entry.cancel();
pending.clear();
},
};
}
-36
View File
@@ -1,36 +0,0 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
+5 -3
View File
@@ -1,8 +1,8 @@
import { ConnectionScreen, FullScreenAppWrapper } from "@termix/plugin-sdk/ui";
import React from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { DockerManager } from "./DockerManager.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import { toDockerHost } from "./types";
interface DockerAppProps {
hostId?: string;
@@ -37,7 +37,9 @@ const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
return (
<DockerManager
hostConfig={hostConfig}
host={toDockerHost(
hostConfig as unknown as Record<string, unknown>,
)}
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
isVisible={true}
isTopbarOpen={false}
+258 -433
View File
@@ -1,10 +1,5 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React from "react";
import { Separator } from "@/components/separator.tsx";
import { Alert, AlertDescription } from "@/components/alert.tsx";
import { Button } from "@/components/button.tsx";
import { Card } from "@/components/card.tsx";
import { Input } from "@/components/input.tsx";
import {
AlertCircle,
Box,
@@ -14,44 +9,46 @@ import {
RefreshCw,
Search,
} from "lucide-react";
import {
useTranslation,
useConnectionRetry,
useHost,
useTranslation,
} from "@termix/plugin-sdk/frontend";
import type { SSHHost, DockerContainer, DockerValidation } from "@/types";
import { logActivity, getSSHHosts } from "@/main-axios";
import {
connectDockerSession,
disconnectDockerSession,
listDockerContainers,
validateDockerAvailability,
keepaliveDockerSession,
verifyDockerTOTP,
verifyDockerWarpgate,
} from "./docker-api";
Alert,
AlertDescription,
Button,
Card,
ConnectionLogProvider,
ConnectionScreen,
Input,
SSHAuthDialog,
Select2,
Separator,
TOTPDialog,
WarpgateDialog,
logActivity,
useAdaptivePolling,
useAreaPreferences,
useConnectionLog,
useTabsSafe,
useUiPreferencesContext,
} from "@termix/plugin-sdk/ui";
import { DockerApiError, useDockerApi, type ConnectResult } from "./docker-api";
import {
dockerEnabled,
hostTitle,
toDockerHost,
type DockerContainer,
type DockerHost,
type DockerValidation,
} from "./types";
import { ContainerList } from "./components/ContainerList.tsx";
import { ContainerTable } from "./components/ContainerTable.tsx";
import { ContainerDetail } from "./components/ContainerDetail.tsx";
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx";
import { useTabsSafe } from "@/shell/TabContext.tsx";
import {
useAreaPreferences,
useUiPreferencesContext,
} from "@/contexts/UiPreferencesContext";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
import type { LogEntry } from "@/types/connection-log.ts";
import { Select2 } from "@/components/select2";
interface DockerManagerProps {
hostConfig?: SSHHost;
host?: DockerHost;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
@@ -59,15 +56,21 @@ interface DockerManagerProps {
onClose?: () => void;
}
type ConnectionLogInput = Omit<LogEntry, "id" | "timestamp">;
interface PromptState {
kind: "totp" | "warpgate";
sessionId: string;
prompt?: string;
isPassword?: boolean;
url?: string;
securityKey?: string;
}
interface DockerConnectionError {
message?: string;
connectionLogs?: ConnectionLogInput[];
function newSessionId(): string {
return `docker-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
function DockerManagerInner({
hostConfig,
host,
title,
isVisible = true,
isTopbarOpen = true,
@@ -75,11 +78,24 @@ function DockerManagerInner({
onClose,
}: DockerManagerProps): React.ReactElement {
const { t } = useTranslation();
const docker = useDockerApi();
const { addLog, setLogs, clearLogs } = useConnectionLog();
const { currentTab, removeTab } = useTabsSafe();
const dockerPrefs = useAreaPreferences("docker");
const uiPrefsCtx = useUiPreferencesContext();
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
// The shell's host list keeps the host current after an edit; a
// standalone window has no shell, so it keeps the host it was given.
const liveRecord = useHost(host?.id);
const currentHost = React.useMemo(
() =>
liveRecord
? toDockerHost(liveRecord as unknown as Record<string, unknown>)
: host,
[liveRecord, host],
);
const enabled = dockerEnabled(currentHost);
const [sessionId, setSessionId] = React.useState<string | null>(null);
const [containers, setContainers] = React.useState<DockerContainer[]>([]);
const containersSignatureRef = React.useRef("");
@@ -105,23 +121,14 @@ function DockerManagerInner({
React.useState<DockerValidation | null>(null);
const [isValidating, setIsValidating] = React.useState(false);
// Initial view follows the interface preset; switching it afterwards is a
// per-session choice, same as before.
// per-session choice.
const [viewMode, setViewMode] = React.useState<"list" | "detail">(
dockerPrefs.viewMode,
);
const [isLoadingContainers, setIsLoadingContainers] = React.useState(false);
const [hasLoadedContainersOnce, setHasLoadedContainersOnce] =
React.useState(false);
const [totpRequired, setTotpRequired] = React.useState(false);
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
const [warpgateRequired, setWarpgateRequired] = React.useState(false);
const [warpgateSessionId, setWarpgateSessionId] = React.useState<
string | null
>(null);
const [warpgateUrl, setWarpgateUrl] = React.useState<string>("");
const [warpgateSecurityKey, setWarpgateSecurityKey] =
React.useState<string>("");
const [prompt, setPrompt] = React.useState<PromptState | null>(null);
const [showAuthDialog, setShowAuthDialog] = React.useState(false);
const [authReason, setAuthReason] = React.useState<
"no_keyboard" | "auth_failed" | "timeout"
@@ -131,214 +138,168 @@ function DockerManagerInner({
const [retryCount, setRetryCount] = React.useState(0);
const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false);
const logDockerActivity = async () => {
if (
!currentHostConfig?.id ||
activityLoggedRef.current ||
activityLoggingRef.current
) {
return;
}
activityLoggingRef.current = true;
const logDockerActivity = () => {
if (!currentHost?.id || activityLoggedRef.current) return;
activityLoggedRef.current = true;
try {
const hostName =
currentHostConfig.name ||
`${currentHostConfig.username}@${currentHostConfig.ip}`;
await logActivity("docker", currentHostConfig.id, hostName);
} catch (err) {
console.warn("Failed to log docker activity:", err);
logActivity("docker", currentHost.id, hostTitle(currentHost)).catch(() => {
activityLoggedRef.current = false;
} finally {
activityLoggingRef.current = false;
}
});
};
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setCurrentHostConfig(hostConfig);
setContainers([]);
setSelectedContainer(null);
setSessionId(null);
setDockerValidation(null);
setViewMode("list");
setContainers([]);
setSelectedContainer(null);
setSessionId(null);
setDockerValidation(null);
setViewMode("list");
}, [host?.id]);
const retryRef = React.useRef<ReturnType<typeof useConnectionRetry> | null>(
null,
);
const validate = async (sid: string, clearAfter: boolean) => {
setSessionId(sid);
setIsValidating(true);
try {
const validation = await docker.validate(sid);
setDockerValidation(validation);
if (!validation.available) {
addLog({
type: "error",
stage: "validation",
message: validation.error || t("docker.error"),
details: validation.code
? `Error code: ${validation.code}`
: undefined,
});
retryRef.current?.markFailed();
} else {
logDockerActivity();
if (clearAfter) setTimeout(() => clearLogs(), 1000);
retryRef.current?.markConnected();
}
} finally {
setIsValidating(false);
}
}, [hostConfig?.id]);
};
React.useEffect(() => {
const fetchLatestHostConfig = async () => {
if (hostConfig?.id) {
try {
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
// Silently handle error
}
/** Acts on one step of a connect, whichever request it answered. */
const applyConnectResult = async (
sid: string,
result: ConnectResult,
clearAfter: boolean,
) => {
if (result.connectionLogs) setLogs(result.connectionLogs);
if (result.requires_warpgate) {
setPrompt({
kind: "warpgate",
sessionId: sid,
url: result.url || "",
securityKey: result.securityKey || "",
});
return;
}
if (result.requires_totp) {
if (result.retry) {
addLog({
type: "error",
stage: "auth",
message: t("docker.totpVerificationFailed"),
});
}
};
setPrompt({
kind: "totp",
sessionId: sid,
prompt: result.prompt || t("docker.verificationCodePrompt"),
isPassword: result.isPassword,
});
return;
}
if (result.status === "auth_required") {
setAuthReason(
result.reason === "no_keyboard" ? "no_keyboard" : "auth_failed",
);
setShowAuthDialog(true);
return;
}
setPrompt(null);
await validate(sid, clearAfter);
};
fetchLatestHostConfig();
const reportFailure = (error: unknown, fallback: string) => {
if (error instanceof DockerApiError && error.connectionLogs?.length) {
setLogs(error.connectionLogs);
} else {
addLog({
type: "error",
stage: "connection",
message: error instanceof Error ? error.message : fallback,
});
}
retryRef.current?.markFailed();
};
const handleHostsChanged = async () => {
if (hostConfig?.id) {
try {
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
// Silently handle error
}
}
};
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
return () =>
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
}, [hostConfig?.id]);
const connect = async (credentials?: {
userProvidedPassword?: string;
userProvidedSshKey?: string;
userProvidedKeyPassword?: string;
}) => {
if (!currentHost?.id) return;
const sid = newSessionId();
setIsConnecting(true);
try {
const result = await docker.connect(sid, currentHost.id, credentials);
await applyConnectResult(sid, result, !credentials);
} catch (error) {
reportFailure(error, t("docker.connectionFailed"));
} finally {
setIsConnecting(false);
}
};
const initializingRef = React.useRef(false);
React.useEffect(() => {
const initSession = async () => {
if (!currentHostConfig?.id || !currentHostConfig.enableDocker) {
return;
}
if (initializingRef.current) return;
initializingRef.current = true;
if (sessionId) {
initializingRef.current = false;
return;
}
setIsConnecting(true);
clearLogs();
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
const result = await connectDockerSession(sid, currentHostConfig.id, {
useSocks5: currentHostConfig.useSocks5,
socks5Host: currentHostConfig.socks5Host,
socks5Port: currentHostConfig.socks5Port,
socks5Username: currentHostConfig.socks5Username,
socks5Password: currentHostConfig.socks5Password,
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
});
if (result?.requires_warpgate) {
setWarpgateRequired(true);
setWarpgateSessionId(sid);
setWarpgateUrl(result.url || "");
setWarpgateSecurityKey(result.securityKey || "");
setIsConnecting(false);
return;
}
if (result?.requires_totp) {
setTotpRequired(true);
setTotpSessionId(sid);
setTotpPrompt(result.prompt || t("docker.verificationCodePrompt"));
setIsConnecting(false);
return;
}
if (result?.status === "auth_required") {
setShowAuthDialog(true);
setAuthReason(
result.reason === "no_keyboard" ? "no_keyboard" : "auth_failed",
);
setIsConnecting(false);
return;
}
setSessionId(sid);
setIsValidating(true);
const validation = await validateDockerAvailability(sid);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
addLog({
type: "error",
stage: "validation",
message: validation.error || t("docker.error"),
details: validation.code
? `Error code: ${validation.code}`
: undefined,
});
dockerConnectRetryRef.current.markFailed();
} else {
logDockerActivity();
setTimeout(() => clearLogs(), 1000);
dockerConnectRetryRef.current.markConnected();
}
} catch (error) {
const dockerError = error as DockerConnectionError;
setIsConnecting(false);
setIsValidating(false);
if (Array.isArray(dockerError.connectionLogs)) {
setLogs(dockerError.connectionLogs);
} else {
addLog({
type: "error",
stage: "connection",
message: dockerError.message || t("docker.connectionFailed"),
});
}
dockerConnectRetryRef.current.markFailed();
} finally {
setIsConnecting(false);
}
};
initSession();
if (!currentHost?.id || !enabled) return;
if (initializingRef.current || sessionId) return;
initializingRef.current = true;
clearLogs();
void connect();
return () => {
initializingRef.current = false;
if (sessionId) {
disconnectDockerSession(sessionId).catch(() => {
// Silently handle disconnect errors
});
}
};
}, [currentHostConfig?.id, currentHostConfig?.enableDocker, retryCount]);
}, [currentHost?.id, enabled, retryCount]);
React.useEffect(() => {
if (!sessionId) return;
return () => {
docker.disconnect(sessionId).catch(() => {});
};
}, [sessionId]);
React.useEffect(() => {
if (!sessionId || !isVisible) return;
const keepalive = setInterval(
() => {
keepaliveDockerSession(sessionId).catch(() => {
// Silently handle keepalive errors
});
docker.keepalive(sessionId).catch(() => {});
},
10 * 60 * 1000,
);
return () => clearInterval(keepalive);
}, [sessionId, isVisible]);
const refreshContainers = React.useCallback(async () => {
if (!sessionId) return;
try {
const data = await listDockerContainers(sessionId, true);
setContainers(data);
setContainers(await docker.listContainers(sessionId, true));
} catch {
// Silently handle polling errors
// the next poll tries again
}
}, [sessionId]);
}, [sessionId, docker]);
React.useEffect(() => {
setHasLoadedContainersOnce(false);
@@ -350,7 +311,7 @@ function DockerManagerInner({
setIsLoadingContainers((loading) =>
hasLoadedContainersOnce ? loading : true,
);
const data = await listDockerContainers(sessionId, true);
const data = await docker.listContainers(sessionId, true);
const signature = JSON.stringify(data);
const changed = signature !== containersSignatureRef.current;
containersSignatureRef.current = signature;
@@ -378,124 +339,48 @@ function DockerManagerInner({
setSelectedContainer(null);
}, []);
const handleTotpSubmit = async (code: string) => {
if (!totpSessionId || !code) return;
try {
setIsConnecting(true);
const result = await verifyDockerTOTP(totpSessionId, code);
if (result?.status === "success") {
setTotpRequired(false);
setTotpPrompt("");
setSessionId(totpSessionId);
setTotpSessionId(null);
setIsValidating(true);
const validation = await validateDockerAvailability(totpSessionId);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
addLog({
type: "error",
stage: "validation",
message: validation.error || t("docker.error"),
details: validation.code
? `Error code: ${validation.code}`
: undefined,
});
dockerConnectRetryRef.current.markFailed();
} else {
logDockerActivity();
dockerConnectRetryRef.current.markConnected();
}
}
} catch (error) {
console.error("TOTP verification failed:", error);
addLog({
type: "error",
stage: "auth",
message: t("docker.totpVerificationFailed"),
});
dockerConnectRetryRef.current.markFailed();
} finally {
setIsConnecting(false);
}
const closeTab = () => {
if (currentTab !== null) removeTab(currentTab);
};
const handleTotpCancel = () => {
setTotpRequired(false);
setTotpSessionId(null);
setTotpPrompt("");
setIsConnecting(false);
if (currentTab !== null) {
removeTab(currentTab);
const handleTotpSubmit = async (code: string) => {
if (!prompt || prompt.kind !== "totp" || !code) return;
const sid = prompt.sessionId;
setPrompt(null);
setIsConnecting(true);
try {
await applyConnectResult(sid, await docker.answerTotp(sid, code), false);
} catch (error) {
reportFailure(error, t("docker.totpVerificationFailed"));
} finally {
setIsConnecting(false);
}
};
const handleWarpgateContinue = async () => {
if (!warpgateSessionId) return;
if (!prompt || prompt.kind !== "warpgate") return;
const sid = prompt.sessionId;
setPrompt(null);
setIsConnecting(true);
try {
setIsConnecting(true);
const result = await verifyDockerWarpgate(warpgateSessionId);
if (result?.status === "success") {
setWarpgateRequired(false);
setWarpgateUrl("");
setWarpgateSecurityKey("");
setSessionId(warpgateSessionId);
setWarpgateSessionId(null);
setIsValidating(true);
const validation = await validateDockerAvailability(warpgateSessionId);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
addLog({
type: "error",
stage: "validation",
message: validation.error || t("docker.error"),
details: validation.code
? `Error code: ${validation.code}`
: undefined,
});
dockerConnectRetryRef.current.markFailed();
} else {
logDockerActivity();
dockerConnectRetryRef.current.markConnected();
}
}
await applyConnectResult(sid, await docker.continueWarpgate(sid), false);
} catch (error) {
console.error("Warpgate verification failed:", error);
addLog({
type: "error",
stage: "auth",
message: t("docker.warpgateVerificationFailed"),
});
dockerConnectRetryRef.current.markFailed();
reportFailure(error, t("docker.warpgateVerificationFailed"));
} finally {
setIsConnecting(false);
}
};
const handleWarpgateCancel = () => {
setWarpgateRequired(false);
setWarpgateSessionId(null);
setWarpgateUrl("");
setWarpgateSecurityKey("");
const handlePromptCancel = () => {
const sid = prompt?.sessionId;
setPrompt(null);
setIsConnecting(false);
if (currentTab !== null) {
removeTab(currentTab);
}
if (sid) docker.disconnect(sid).catch(() => {});
closeTab();
};
const handleWarpgateOpenUrl = () => {
if (warpgateUrl) {
window.open(warpgateUrl, "_blank", "noopener,noreferrer");
}
if (prompt?.url) window.open(prompt.url, "_blank", "noopener,noreferrer");
};
const handleAuthSubmit = async (credentials: {
@@ -503,75 +388,12 @@ function DockerManagerInner({
sshKey?: string;
keyPassword?: string;
}) => {
if (!currentHostConfig?.id) return;
setShowAuthDialog(false);
setIsConnecting(true);
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
const result = await connectDockerSession(sid, currentHostConfig.id, {
userProvidedPassword: credentials.password,
userProvidedSshKey: credentials.sshKey,
userProvidedKeyPassword: credentials.keyPassword,
useSocks5: currentHostConfig.useSocks5,
socks5Host: currentHostConfig.socks5Host,
socks5Port: currentHostConfig.socks5Port,
socks5Username: currentHostConfig.socks5Username,
socks5Password: currentHostConfig.socks5Password,
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
});
if (result?.requires_warpgate) {
setWarpgateRequired(true);
setWarpgateSessionId(sid);
setWarpgateUrl(result.url || "");
setWarpgateSecurityKey(result.securityKey || "N/A");
setIsConnecting(false);
return;
}
if (result?.requires_totp) {
setTotpRequired(true);
setTotpSessionId(sid);
setTotpPrompt(result.prompt || t("docker.verificationCodePrompt"));
setIsConnecting(false);
return;
}
if (result?.status === "auth_required") {
setShowAuthDialog(true);
setAuthReason("auth_failed");
setIsConnecting(false);
return;
}
setSessionId(sid);
setIsValidating(true);
const validation = await validateDockerAvailability(sid);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
dockerConnectRetryRef.current.markFailed();
} else {
logDockerActivity();
dockerConnectRetryRef.current.markConnected();
}
} catch (error) {
setIsConnecting(false);
setIsValidating(false);
addLog({
type: "error",
stage: "connection",
message: error?.message || t("docker.connectionFailed"),
});
dockerConnectRetryRef.current.markFailed();
} finally {
setIsConnecting(false);
}
await connect({
userProvidedPassword: credentials.password,
userProvidedSshKey: credentials.sshKey,
userProvidedKeyPassword: credentials.keyPassword,
});
};
const handleAuthCancel = () => {
@@ -592,15 +414,10 @@ function DockerManagerInner({
connect: () => {
handleRetry();
},
enabled:
!!currentHostConfig?.enableDocker &&
!totpRequired &&
!warpgateRequired &&
!showAuthDialog,
enabled: enabled && !prompt && !showAuthDialog,
autoStart: false,
});
const dockerConnectRetryRef = React.useRef(dockerConnectRetry);
dockerConnectRetryRef.current = dockerConnectRetry;
retryRef.current = dockerConnectRetry;
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = 8;
@@ -619,9 +436,9 @@ function DockerManagerInner({
const containerClass = embedded
? "h-full w-full text-foreground overflow-hidden bg-transparent"
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
: "bg-canvas text-foreground border-2 border-edge overflow-hidden";
if (!currentHostConfig?.enableDocker) {
if (!enabled) {
return (
<div style={wrapperStyle} className={`${containerClass} relative`}>
<div className="h-full w-full flex flex-col flex-1 min-h-0 overflow-hidden">
@@ -649,6 +466,40 @@ function DockerManagerInner({
);
}
const dialogs = (
<>
<TOTPDialog
isOpen={prompt?.kind === "totp"}
prompt={prompt?.prompt ?? ""}
mode={prompt?.isPassword ? "password" : "totp"}
onSubmit={handleTotpSubmit}
onCancel={handlePromptCancel}
/>
<WarpgateDialog
isOpen={prompt?.kind === "warpgate"}
url={prompt?.url ?? ""}
securityKey={prompt?.securityKey ?? ""}
onContinue={handleWarpgateContinue}
onCancel={handlePromptCancel}
onOpenUrl={handleWarpgateOpenUrl}
/>
{currentHost && (
<SSHAuthDialog
isOpen={showAuthDialog}
reason={authReason}
onSubmit={handleAuthSubmit}
onCancel={handleAuthCancel}
hostInfo={{
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username ?? "",
name: currentHost.name,
}}
/>
)}
</>
);
if (isConnecting || isValidating) {
return (
<div style={wrapperStyle} className={`${containerClass} relative`}>
@@ -662,6 +513,7 @@ function DockerManagerInner({
nextRetryInMs={dockerConnectRetry.nextRetryInMs}
onManualRetry={dockerConnectRetry.retryNow}
/>
{dialogs}
</div>
);
}
@@ -689,12 +541,12 @@ function DockerManagerInner({
{viewMode === "detail" &&
sessionId &&
selectedContainer &&
currentHostConfig ? (
currentHost ? (
<ContainerDetail
sessionId={sessionId}
containerId={selectedContainer}
containers={containers}
hostConfig={currentHostConfig}
hostConfig={currentHost}
onBack={handleBack}
initialTab={detailInitialTab}
/>
@@ -833,34 +685,7 @@ function DockerManagerInner({
</div>
)}
</div>
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
/>
<WarpgateDialog
isOpen={warpgateRequired}
url={warpgateUrl}
securityKey={warpgateSecurityKey}
onContinue={handleWarpgateContinue}
onCancel={handleWarpgateCancel}
onOpenUrl={handleWarpgateOpenUrl}
/>
{currentHostConfig && (
<SSHAuthDialog
isOpen={showAuthDialog}
reason={authReason}
onSubmit={handleAuthSubmit}
onCancel={handleAuthCancel}
hostInfo={{
ip: currentHostConfig.ip,
port: currentHostConfig.port,
username: currentHostConfig.username,
name: currentHostConfig.name,
}}
/>
)}
{dialogs}
<ConnectionScreen
status={dockerConnectRetry.status}
message={t("docker.connecting")}
+15 -50
View File
@@ -1,44 +1,24 @@
import { useState, useEffect } from "react";
import { Box } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { useHost, useTranslation } from "@termix/plugin-sdk/frontend";
import { WidgetTitle } from "@termix/plugin-sdk/ui";
import { DockerWidgetEditForm } from "./DockerWidgetEditForm";
import type {
DockerWidgetConfig,
WidgetComponentProps,
WidgetTypeDefinition,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import type { SSHHostWithStatus } from "@/main-axios";
import { DockerManager } from "./DockerManager";
import type { SSHHost } from "@/types/index";
import { WidgetTitle } from "@/features/homepage/widgets/WidgetTitle";
import {
GRID_SIZE,
type DockerWidgetConfig,
type WidgetComponentProps,
type WidgetDefinition,
} from "./homepage";
import { dockerEnabled, hostTitle, toDockerHost } from "./types";
function DockerWidget({
widget,
config,
}: WidgetComponentProps<DockerWidgetConfig>) {
const { t } = useTranslation();
const [host, setHost] = useState<SSHHostWithStatus | null>(null);
const [loading, setLoading] = useState(true);
const record = useHost(config.hostId || undefined);
useEffect(() => {
if (!config.hostId) {
setLoading(false);
return;
}
getSSHHosts()
.then((hosts) => {
const found = hosts.find(
(h) => h.id === config.hostId && h.enableDocker,
);
setHost(found ?? null);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [config.hostId]);
if (!config.hostId) {
if (!config.hostId || !record || !dockerEnabled(record)) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
<Box size={20} />
@@ -47,22 +27,7 @@ function DockerWidget({
);
}
if (loading) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
{t("homepage.loading")}
</div>
);
}
if (!host) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
{t("homepage.widgetNoHostSelected")}
</div>
);
}
const host = toDockerHost(record as unknown as Record<string, unknown>);
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Box size={11} />} />
@@ -71,8 +36,8 @@ function DockerWidget({
onMouseDown={(e) => e.stopPropagation()}
>
<DockerManager
hostConfig={host as unknown as SSHHost}
title={host.name || host.ip}
host={host}
title={hostTitle(host)}
isVisible={true}
isTopbarOpen={false}
embedded={true}
@@ -83,7 +48,7 @@ function DockerWidget({
}
/** Registered by the plugin while it runs. */
export const dockerWidget: WidgetTypeDefinition<DockerWidgetConfig> = {
export const dockerWidget: WidgetDefinition<DockerWidgetConfig> = {
id: "docker_widget",
name: "Docker Manager",
description: "Embedded Docker container manager for a configured host",
@@ -1,18 +1,37 @@
import type {
DockerWidgetConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { SingleHostEditForm } from "@/features/homepage/dialogs/SingleHostEditForm";
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
import { Select2 } from "@termix/plugin-sdk/ui";
import type { DockerWidgetConfig, WidgetEditFormProps } from "./homepage";
import { dockerEnabled } from "./types";
export function DockerWidgetEditForm({
config,
onChange,
}: WidgetEditFormProps<DockerWidgetConfig>) {
const { t } = useTranslation();
const { hosts } = useHosts();
const options = hosts.filter(
(host) => host.enableSsh !== false && dockerEnabled(host),
);
return (
<SingleHostEditForm
hostId={config.hostId}
onChange={(hostId) => onChange({ ...config, hostId })}
filter={(h) => !!(h.enableSsh && h.enableDocker)}
/>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.host")}
</label>
<Select2
value={config.hostId || ""}
onChange={(e) =>
onChange({ ...config, hostId: Number(e.target.value) })
}
className="h-8 text-xs border border-border bg-background px-2"
>
<option value="">{t("homepage.selectHost")}</option>
{options.map((host) => (
<option key={host.id} value={host.id}>
{host.name || host.ip}
</option>
))}
</Select2>
</div>
);
}
@@ -1,68 +0,0 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Box } from "lucide-react";
import { Select2 } from "@/components/select2";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type { HostEditorForm } from "@/sidebar/HostEditorData";
type SetHostField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
export function HostDockerTab({
form,
setField,
}: {
form: HostEditorForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
const dockerConfig = form.dockerConfig ?? { runtime: "docker" as const };
const runtime =
dockerConfig.runtime === "podman"
? ("podman" as const)
: ("docker" as const);
return (
<SectionCard
title={t("hosts.dockerIntegration")}
icon={<Box className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableDockerMonitor")}
description={t("hosts.enableDockerMonitorDesc")}
>
<FakeSwitch
checked={form.enableDocker}
onChange={(v) => setField("enableDocker", v)}
/>
</SettingRow>
{form.enableDocker && (
<SettingRow
label={t("hosts.containerRuntime")}
description={t("hosts.containerRuntimeDesc")}
>
<Select2
value={runtime}
onChange={(e) =>
setField("dockerConfig", {
...dockerConfig,
runtime: e.target.value as "docker" | "podman",
})
}
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
>
<option value="docker">
{t("hosts.containerRuntimeDocker")}
</option>
<option value="podman">
{t("hosts.containerRuntimePodman")}
</option>
</Select2>
</SettingRow>
)}
</div>
</SectionCard>
);
}
@@ -1,39 +1,42 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage } from "../error-message";
import {
Button,
Card,
CardContent,
ConnectionLogProvider,
ConnectionScreen,
DEFAULT_TERMINAL_CONFIG,
RobustClipboardProvider,
Select2,
TERMINAL_FONTS,
copyToClipboard,
ensureTerminalFontsLoaded,
isElectron,
pluginWsUrl,
readFromClipboard,
resolveConnectionOrigin,
resolveTermixThemeColors,
useAppTheme as useTheme,
useConnectionLog,
} from "@termix/plugin-sdk/ui";
import type { DockerHost } from "../types";
import React from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { RobustClipboardProvider } from "@/lib/clipboard-provider";
import { copyToClipboard, readFromClipboard } from "@/lib/clipboard";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { Button } from "@/components/button.tsx";
import { Select2 } from "@/components/select2";
import { Card, CardContent } from "@/components/card.tsx";
import { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
import { pluginWsUrl } from "@/lib/plugin-transport";
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
import { toast } from "sonner";
import type { SSHHost } from "@/types";
import { isElectron } from "@/main-axios.ts";
import {
useTranslation,
useConnectionRetry,
} from "@termix/plugin-sdk/frontend";
import { resolveTermixThemeColors } from "@/lib/terminal-look/terminal-theme";
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
import { ensureTerminalFontsLoaded } from "@/lib/terminal-look/terminal-global-styles";
import { useTheme } from "@/components/theme-provider";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
interface ConsoleTerminalProps {
containerId: string;
containerName: string;
containerState: string;
hostConfig: SSHHost;
hostConfig: DockerHost;
}
export function ConsoleTerminal(
@@ -58,7 +61,12 @@ function ConsoleTerminalInner({
const { addLog, clearLogs } = useConnectionLog();
const terminalConfig = React.useMemo(
() => ({ ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig }),
() => ({
...DEFAULT_TERMINAL_CONFIG,
...(hostConfig.terminalConfig as Partial<
typeof DEFAULT_TERMINAL_CONFIG
> | null),
}),
[hostConfig.terminalConfig],
);
@@ -321,7 +329,11 @@ function ConsoleTerminalInner({
JSON.stringify({
type: "connect",
data: {
hostConfig,
hostConfig: {
id: hostConfig.id,
syncId: hostConfig.syncId ?? null,
ip: hostConfig.ip,
},
containerId,
shell: selectedShell,
cols,
@@ -1,7 +1,7 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage } from "../error-message";
import { Button, Card, useConfirmation } from "@termix/plugin-sdk/ui";
import type { DockerContainer } from "../types";
import React from "react";
import { Card } from "@/components/card.tsx";
import { Button } from "@/components/button.tsx";
import {
Box,
Play,
@@ -14,16 +14,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { DockerContainer } from "@/types";
import {
startDockerContainer,
stopDockerContainer,
restartDockerContainer,
pauseDockerContainer,
unpauseDockerContainer,
removeDockerContainer,
} from "../docker-api";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { useDockerApi } from "../docker-api";
interface ContainerCardProps {
container: DockerContainer;
@@ -61,6 +52,7 @@ export function ContainerCard({
}: ContainerCardProps): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const docker = useDockerApi();
const [isStarting, setIsStarting] = React.useState(false);
const [isStopping, setIsStopping] = React.useState(false);
const [isRestarting, setIsRestarting] = React.useState(false);
@@ -82,7 +74,7 @@ export function ContainerCard({
e.stopPropagation();
setIsStarting(true);
try {
await startDockerContainer(sessionId, container.id);
await docker.containerAction(sessionId, container.id, "start");
toast.success(t("docker.containerStarted", { name: containerName }));
onRefresh?.();
} catch (err) {
@@ -100,7 +92,7 @@ export function ContainerCard({
e.stopPropagation();
setIsStopping(true);
try {
await stopDockerContainer(sessionId, container.id);
await docker.containerAction(sessionId, container.id, "stop");
toast.success(t("docker.containerStopped", { name: containerName }));
onRefresh?.();
} catch (err) {
@@ -118,7 +110,7 @@ export function ContainerCard({
e.stopPropagation();
setIsRestarting(true);
try {
await restartDockerContainer(sessionId, container.id);
await docker.containerAction(sessionId, container.id, "restart");
toast.success(t("docker.containerRestarted", { name: containerName }));
onRefresh?.();
} catch (err) {
@@ -137,10 +129,10 @@ export function ContainerCard({
setIsPausing(true);
try {
if (container.state === "paused") {
await unpauseDockerContainer(sessionId, container.id);
await docker.containerAction(sessionId, container.id, "unpause");
toast.success(t("docker.containerUnpaused", { name: containerName }));
} else {
await pauseDockerContainer(sessionId, container.id);
await docker.containerAction(sessionId, container.id, "pause");
toast.success(t("docker.containerPaused", { name: containerName }));
}
onRefresh?.();
@@ -166,7 +158,7 @@ export function ContainerCard({
async () => {
setIsRemoving(true);
try {
await removeDockerContainer(
await docker.removeContainer(
sessionId,
container.id,
container.state === "running",
@@ -1,7 +1,6 @@
import { Button, Card, Separator } from "@termix/plugin-sdk/ui";
import type { DockerContainer, DockerHost } from "../types";
import React from "react";
import { Button } from "@/components/button.tsx";
import { Card } from "@/components/card.tsx";
import { Separator } from "@/components/separator.tsx";
import {
Activity,
ArrowLeft,
@@ -11,7 +10,6 @@ import {
Terminal,
} from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { DockerContainer, SSHHost } from "@/types";
import { LogViewer } from "./LogViewer.tsx";
import { ContainerStats } from "./ContainerStats.tsx";
import { ConsoleTerminal } from "./ConsoleTerminal.tsx";
@@ -23,7 +21,7 @@ interface ContainerDetailProps {
sessionId: string;
containerId: string;
containers: DockerContainer[];
hostConfig: SSHHost;
hostConfig: DockerHost;
onBack: () => void;
initialTab?: DetailTab;
}
@@ -1,7 +1,7 @@
import type { DockerContainer } from "../types";
import React from "react";
import { Box } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { DockerContainer } from "@/types";
import { ContainerCard } from "./ContainerCard.tsx";
interface ContainerListProps {
@@ -1,4 +1,6 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage } from "../error-message";
import { SectionCard, useAdaptivePolling } from "@termix/plugin-sdk/ui";
import type { DockerStats } from "../types";
import React from "react";
import {
Activity,
@@ -9,12 +11,9 @@ import {
Network,
RefreshCw,
} from "lucide-react";
import type { DockerStats } from "@/types";
import { getContainerStats } from "../docker-api";
import { useDockerApi } from "../docker-api";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { SectionCard } from "@/components/section-card";
import { DockerBadge } from "./ContainerCard.tsx";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
interface ContainerStatsProps {
sessionId: string;
@@ -30,6 +29,7 @@ export function ContainerStats({
containerState,
}: ContainerStatsProps): React.ReactElement {
const { t } = useTranslation();
const docker = useDockerApi();
const [stats, setStats] = React.useState<DockerStats | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
@@ -41,7 +41,7 @@ export function ContainerStats({
setIsLoading(true);
setError(null);
try {
const data = await getContainerStats(sessionId, containerId);
const data = await docker.stats(sessionId, containerId);
const previous = statsRef.current;
const changed =
!previous ||
@@ -58,7 +58,7 @@ export function ContainerStats({
} finally {
setIsLoading(false);
}
}, [sessionId, containerId, containerState, t]);
}, [sessionId, containerId, containerState, t, docker]);
useAdaptivePolling(
fetchStats,
@@ -246,7 +246,7 @@ export function ContainerStats({
</span>
<DockerBadge
state={
containerState as import("@/types").DockerContainer["state"]
containerState as import("../types").DockerContainer["state"]
}
/>
</div>
@@ -1,11 +1,11 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage } from "../error-message";
import { Button } from "@termix/plugin-sdk/ui";
import type { DockerContainer } from "../types";
import React from "react";
import { Box, List, Play, RefreshCw, Square, Terminal } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Button } from "@/components/button.tsx";
import type { DockerContainer } from "@/types";
import { startDockerContainer, stopDockerContainer } from "../docker-api";
import { useDockerApi } from "../docker-api";
import { DockerBadge } from "./ContainerCard.tsx";
type DetailTab = "logs" | "stats" | "console";
@@ -30,6 +30,7 @@ export function ContainerTable({
statusFilter = "all",
}: ContainerTableProps): React.ReactElement {
const { t } = useTranslation();
const docker = useDockerApi();
const [pendingId, setPendingId] = React.useState<string | null>(null);
const filtered = React.useMemo(() => {
@@ -56,10 +57,10 @@ export function ContainerTable({
setPendingId(container.id);
try {
if (container.state === "running") {
await stopDockerContainer(sessionId, container.id);
await docker.containerAction(sessionId, container.id, "stop");
toast.success(t("docker.containerStopped", { name: containerName }));
} else {
await startDockerContainer(sessionId, container.id);
await docker.containerAction(sessionId, container.id, "start");
toast.success(t("docker.containerStarted", { name: containerName }));
}
onRefresh?.();
@@ -1,15 +1,17 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage } from "../error-message";
import {
Button,
Input,
Select2,
Separator,
useAdaptivePolling,
} from "@termix/plugin-sdk/ui";
import type { DockerLogOptions } from "../types";
import React from "react";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { Separator } from "@/components/separator.tsx";
import { Download, RefreshCw, Search, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { DockerLogOptions } from "@/types";
import { getContainerLogs, downloadContainerLogs } from "../docker-api";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
import { Select2 } from "@/components/select2";
import { useDockerApi } from "../docker-api";
interface LogViewerProps {
sessionId: string;
@@ -60,6 +62,7 @@ export function LogViewer({
containerName,
}: LogViewerProps): React.ReactElement {
const { t } = useTranslation();
const docker = useDockerApi();
const [rawLogs, setRawLogs] = React.useState<string[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [isDownloading, setIsDownloading] = React.useState(false);
@@ -78,7 +81,7 @@ export function LogViewer({
tail: tailLines === "all" ? undefined : parseInt(tailLines, 10),
timestamps: showTimestamps,
};
const data = await getContainerLogs(sessionId, containerId, options);
const data = await docker.logs(sessionId, containerId, options);
const next = data.logs.split("\n").filter(Boolean);
const changed =
next.length !== rawLogsRef.current.length ||
@@ -89,11 +92,13 @@ export function LogViewer({
}
return changed;
} catch (error) {
toast.error(`Failed to fetch logs: ${getErrorMessage(error)}`);
toast.error(
t("docker.failedToFetchLogs", { error: getErrorMessage(error) }),
);
} finally {
setIsLoading(false);
}
}, [sessionId, containerId, tailLines, showTimestamps]);
}, [sessionId, containerId, tailLines, showTimestamps, docker, t]);
React.useEffect(() => {
fetchLogs();
@@ -120,9 +125,10 @@ export function LogViewer({
const handleDownload = async () => {
setIsDownloading(true);
try {
const blob = await downloadContainerLogs(sessionId, containerId, {
const data = await docker.logs(sessionId, containerId, {
timestamps: showTimestamps,
});
const blob = new Blob([data.logs], { type: "text/plain" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@@ -133,7 +139,9 @@ export function LogViewer({
document.body.removeChild(a);
toast.success(t("docker.logsDownloaded"));
} catch (error) {
toast.error(`Failed to download logs: ${getErrorMessage(error)}`);
toast.error(
t("docker.failedToDownloadLogs", { error: getErrorMessage(error) }),
);
} finally {
setIsDownloading(false);
}
+139 -314
View File
@@ -1,345 +1,170 @@
import axios from "axios";
import { dockerApi, handleApiError } from "@/main-axios";
import { useMemo } from "react";
import {
usePluginApi,
type PluginApiClient,
} from "@termix/plugin-sdk/frontend";
import type { ConnectionStage } from "@termix/plugin-sdk/ui";
import type {
DockerContainer,
DockerLogOptions,
DockerStats,
DockerValidation,
} from "@/types/index";
} from "./types";
type ApiConnectionLog = {
export interface ApiConnectionLog {
type: "info" | "success" | "warning" | "error";
stage: string;
stage: ConnectionStage;
message: string;
details?: Record<string, unknown>;
};
details?: string;
}
type ConnectErrorResponse = {
error?: string;
message?: string;
connectionLogs?: ApiConnectionLog[];
requires_totp?: boolean;
requires_warpgate?: boolean;
sessionId?: string;
prompt?: string;
url?: string;
securityKey?: string;
/** One step of a connect: connected, a prompt to answer, or auth needed. */
export interface ConnectResult {
success?: boolean;
status?: string;
reason?: string;
};
export async function connectDockerSession(
sessionId: string,
hostId: number,
config?: {
userProvidedPassword?: string;
userProvidedSshKey?: string;
userProvidedKeyPassword?: string;
forceKeyboardInteractive?: boolean;
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: unknown;
},
): Promise<{
success?: boolean;
message?: string;
requires_totp?: boolean;
prompt?: string;
isPassword?: boolean;
status?: string;
reason?: string;
connectionLogs?: ApiConnectionLog[];
retry?: boolean;
requires_warpgate?: boolean;
url?: string;
url?: string | null;
securityKey?: string;
}> {
try {
const response = await dockerApi.post("/ssh/connect", {
sessionId,
hostId,
...config,
});
return response.data;
} catch (error: unknown) {
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.status === "auth_required"
) {
return error.response.data;
connectionLogs?: ApiConnectionLog[];
}
/** An API failure carrying the server's message and connection logs. */
export class DockerApiError extends Error {
constructor(
message: string,
readonly status?: number,
readonly connectionLogs?: ApiConnectionLog[],
) {
super(message);
this.name = "DockerApiError";
}
}
function apiError(error: unknown, action: string): DockerApiError {
const response = (
error as {
response?: {
status?: number;
data?: {
error?: string;
message?: string;
connectionLogs?: ApiConnectionLog[];
};
};
}
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.requires_totp
) {
return error.response.data;
}
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.requires_warpgate
) {
return error.response.data;
}
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.connectionLogs
) {
const data = error.response.data;
const errorWithLogs = new Error(
data.error || data.message || error.message,
);
Object.assign(errorWithLogs, {
connectionLogs: data.connectionLogs,
});
throw errorWithLogs;
}
throw handleApiError(error, "connect to Docker SSH session");
}
)?.response;
const data = response?.data;
const message =
data?.error ||
data?.message ||
(error instanceof Error ? error.message : `Failed to ${action}`);
return new DockerApiError(message, response?.status, data?.connectionLogs);
}
export async function verifyDockerTOTP(
sessionId: string,
totpCode: string,
): Promise<{ status: string; message: string }> {
async function call<T>(
action: string,
request: () => Promise<{ data: T }>,
): Promise<T> {
try {
const response = await dockerApi.post("/ssh/connect-totp", {
sessionId,
totpCode,
});
return response.data;
return (await request()).data;
} catch (error) {
throw handleApiError(error, "verify Docker TOTP");
throw apiError(error, action);
}
}
export async function verifyDockerWarpgate(
sessionId: string,
): Promise<{ status: string; message: string }> {
try {
const response = await dockerApi.post("/ssh/connect-warpgate", {
sessionId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "verify Docker Warpgate");
}
}
export type ContainerAction =
"start" | "stop" | "restart" | "pause" | "unpause";
export async function disconnectDockerSession(
sessionId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post("/ssh/disconnect", {
sessionId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "disconnect from Docker SSH session");
}
}
export async function keepaliveDockerSession(
sessionId: string,
): Promise<{ success: boolean }> {
try {
const response = await dockerApi.post("/ssh/keepalive", {
sessionId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "keepalive Docker SSH session");
}
}
export async function getDockerSessionStatus(
sessionId: string,
): Promise<{ success: boolean; connected: boolean }> {
try {
const response = await dockerApi.get("/ssh/status", {
params: { sessionId },
});
return response.data;
} catch (error) {
throw handleApiError(error, "get Docker session status");
}
}
export async function validateDockerAvailability(
sessionId: string,
): Promise<DockerValidation> {
try {
const response = await dockerApi.get(`/validate/${sessionId}`);
return response.data;
} catch (error) {
throw handleApiError(error, "validate Docker availability");
}
}
export async function listDockerContainers(
sessionId: string,
all: boolean = true,
): Promise<DockerContainer[]> {
try {
const response = await dockerApi.get(`/containers/${sessionId}`, {
params: { all },
});
return response.data;
} catch (error) {
throw handleApiError(error, "list Docker containers");
}
}
export async function getDockerContainerDetails(
sessionId: string,
containerId: string,
): Promise<DockerContainer> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "get Docker container details");
}
}
export async function startDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/start`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "start Docker container");
}
}
export async function stopDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/stop`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "stop Docker container");
}
}
export async function restartDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/restart`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "restart Docker container");
}
}
export async function pauseDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/pause`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "pause Docker container");
}
}
export async function unpauseDockerContainer(
sessionId: string,
containerId: string,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.post(
`/containers/${sessionId}/${containerId}/unpause`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "unpause Docker container");
}
}
export async function removeDockerContainer(
sessionId: string,
containerId: string,
force: boolean = false,
): Promise<{ success: boolean; message: string }> {
try {
const response = await dockerApi.delete(
`/containers/${sessionId}/${containerId}/remove`,
{
params: { force },
export function createDockerApi(api: PluginApiClient) {
return {
connect: (
sessionId: string,
hostId: number,
credentials?: {
userProvidedPassword?: string;
userProvidedSshKey?: string;
userProvidedKeyPassword?: string;
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "remove Docker container");
}
) =>
call<ConnectResult>("connect to Docker", () =>
api.post("/ssh/connect", { sessionId, hostId, ...credentials }),
),
answerTotp: (sessionId: string, totpCode: string) =>
call<ConnectResult>("verify the code", () =>
api.post("/ssh/connect-totp", { sessionId, totpCode }),
),
continueWarpgate: (sessionId: string) =>
call<ConnectResult>("complete Warpgate authentication", () =>
api.post("/ssh/connect-warpgate", { sessionId }),
),
disconnect: (sessionId: string) =>
call("disconnect from Docker", () =>
api.post("/ssh/disconnect", { sessionId }),
),
keepalive: (sessionId: string) =>
call("keep the Docker session alive", () =>
api.post("/ssh/keepalive", { sessionId }),
),
validate: (sessionId: string) =>
call<DockerValidation>("validate Docker", () =>
api.get(`/validate/${sessionId}`),
),
listContainers: (sessionId: string, all = true) =>
call<DockerContainer[]>("list containers", () =>
api.get(`/containers/${sessionId}`, { params: { all } }),
),
containerAction: (
sessionId: string,
containerId: string,
action: ContainerAction,
) =>
call<{ success: boolean; message: string }>(
`${action} the container`,
() => api.post(`/containers/${sessionId}/${containerId}/${action}`),
),
removeContainer: (sessionId: string, containerId: string, force = false) =>
call<{ success: boolean; message: string }>("remove the container", () =>
api.delete(`/containers/${sessionId}/${containerId}/remove`, {
params: { force },
}),
),
logs: (
sessionId: string,
containerId: string,
options?: DockerLogOptions,
) =>
call<{ logs: string }>("load container logs", () =>
api.get(`/containers/${sessionId}/${containerId}/logs`, {
params: options,
}),
),
stats: (sessionId: string, containerId: string) =>
call<DockerStats>("load container stats", () =>
api.get(`/containers/${sessionId}/${containerId}/stats`),
),
};
}
export async function getContainerLogs(
sessionId: string,
containerId: string,
options?: DockerLogOptions,
): Promise<{ logs: string }> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}/logs`,
{
params: options,
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "get container logs");
}
}
export type DockerApi = ReturnType<typeof createDockerApi>;
export async function downloadContainerLogs(
sessionId: string,
containerId: string,
options?: DockerLogOptions,
): Promise<Blob> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}/logs`,
{
params: { ...options, download: true },
responseType: "blob",
},
);
return response.data;
} catch (error) {
throw handleApiError(error, "download container logs");
}
}
export async function getContainerStats(
sessionId: string,
containerId: string,
): Promise<DockerStats> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}/stats`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "get container stats");
}
/** The Docker API on this plugin's own client. */
export function useDockerApi(): DockerApi {
const api = usePluginApi();
return useMemo(() => createDockerApi(api), [api]);
}
@@ -0,0 +1,6 @@
export function getErrorMessage(
error: unknown,
fallback = "Unknown error",
): string {
return error instanceof Error ? error.message : fallback;
}
+24
View File
@@ -0,0 +1,24 @@
import type { HomepageWidgetContribution } from "@termix/plugin-sdk/frontend";
/** The homepage's grid step, in pixels. */
export const GRID_SIZE = 30;
export interface DockerWidgetConfig {
hostId: number;
}
/** What the homepage hands a widget; only the fields this plugin reads. */
export interface WidgetComponentProps<C> {
widget: { title?: string | null };
config: C;
}
export interface WidgetEditFormProps<C> {
config: C;
onChange: (config: C) => void;
}
export type WidgetDefinition<C> = Omit<
HomepageWidgetContribution<C>,
"defaultConfig"
> & { defaultConfig: C };
+11 -24
View File
@@ -2,21 +2,20 @@ import type { ComponentType } from "react";
import { Box } from "lucide-react";
import type {
HomepageWidgetContribution,
HostEditorSectionProps,
StandaloneViewProps,
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import type { SSHHost } from "@/types";
import { DockerManager } from "./DockerManager";
import DockerApp from "./DockerApp";
import { HostDockerTab } from "./HostDockerTab";
import { dockerWidget } from "./DockerWidget";
import { dockerEnabled, toDockerHost } from "./types";
function DockerTab({ sshHost, label, isVisible }: TabProps) {
function DockerTab({ host, sshHost, label, isVisible }: TabProps) {
const record = (host ?? sshHost) as Record<string, unknown> | undefined;
return (
<DockerManager
hostConfig={sshHost as unknown as SSHHost}
host={record ? toDockerHost(record) : undefined}
title={label}
isVisible={isVisible}
isTopbarOpen={false}
@@ -29,16 +28,13 @@ function DockerStandalone({ hostId }: StandaloneViewProps) {
return <DockerApp hostId={hostId} />;
}
function DockerHostSection({ form, setField }: HostEditorSectionProps) {
return (
<HostDockerTab
form={form}
setField={setField as Parameters<typeof HostDockerTab>[0]["setField"]}
/>
);
}
export function activate(app: TermixApp): void {
// Host actions filter synchronously, so the permission is read once here.
let canUse = true;
void app.hasPermission("use").then((allowed) => {
canUse = allowed;
});
app.registerTab("docker", DockerTab, {
icon: Box,
titleKey: "nav.docker",
@@ -58,16 +54,7 @@ export function activate(app: TermixApp): void {
order: 30,
tabType: "docker",
copyUrlView: "docker",
when: (host) => !!host.enableSsh && !!host.enableDocker,
});
app.registerHostEditorSection({
id: "docker",
group: "ssh",
titleKey: "hosts.tabDocker",
icon: Box,
order: 30,
component: DockerHostSection,
when: (host) => canUse && !!host.enableSsh && dockerEnabled(host),
});
app.registerHomepageWidget(
+86
View File
@@ -0,0 +1,86 @@
export interface DockerContainer {
id: string;
name: string;
image: string;
status: string;
state:
| "created"
| "running"
| "paused"
| "restarting"
| "removing"
| "exited"
| "dead";
ports: string;
created: string;
command?: string;
labels?: Record<string, string>;
networks?: string[];
mounts?: string[];
}
export interface DockerStats {
cpu: string;
memoryUsed: string;
memoryLimit: string;
memoryPercent: string;
netInput: string;
netOutput: string;
blockRead: string;
blockWrite: string;
pids?: string;
}
export interface DockerLogOptions {
tail?: number;
timestamps?: boolean;
since?: string;
until?: string;
follow?: boolean;
}
export interface DockerValidation {
available: boolean;
version?: string;
runtime?: "docker" | "podman";
error?: string;
code?: string;
}
/** What the Docker views read from a host. */
export interface DockerHost {
id: number;
name?: string;
ip: string;
port: number;
username?: string;
syncId?: string | null;
connectionOrigin?: "local" | "remote" | null;
terminalConfig?: Record<string, unknown> | null;
pluginSettings?: Record<string, Record<string, unknown> | undefined>;
}
/** Any host record the shell or an API hands over, as a DockerHost. */
export function toDockerHost(record: Record<string, unknown>): DockerHost {
return {
...(record as unknown as DockerHost),
id: Number(record.id),
};
}
function dockerSettings(
host: object | null | undefined,
): Record<string, unknown> {
const all = (host as { pluginSettings?: unknown } | null | undefined)
?.pluginSettings as
Record<string, Record<string, unknown> | undefined> | undefined;
return all?.docker ?? {};
}
export function dockerEnabled(host: object | null | undefined): boolean {
return dockerSettings(host).enableDocker === true;
}
export function hostTitle(host: DockerHost): string {
return host.name || `${host.username ?? ""}@${host.ip}`;
}
@@ -0,0 +1,170 @@
import { EventEmitter } from "node:events";
import express from "express";
import { afterEach, describe, expect, it } from "vitest";
import {
createMockCtx,
type MockPluginContext,
} from "@termix/plugin-sdk/testing";
import type { PluginWebSocketConnection } from "@termix/plugin-sdk/backend";
import { activate } from "../../src/backend/index.js";
import { manifest, sshHost } from "./server";
import { FakeClient, FakeStream } from "./fake-ssh";
/** A ws WebSocket as far as the console uses one. */
class FakeSocket extends EventEmitter {
readyState = 1;
sent: Array<Record<string, unknown>> = [];
closedWith: [number, string] | null = null;
send(data: string) {
this.sent.push(JSON.parse(data));
}
ping() {}
close(code: number, reason: string) {
if (this.readyState !== 1) return;
this.readyState = 3;
this.closedWith = [code, reason];
this.emit("close");
}
message(payload: unknown) {
this.emit("message", Buffer.from(JSON.stringify(payload)));
}
}
function consoleClient() {
const shells: FakeStream[] = [];
const client = new FakeClient([
[/which bash/, { stdout: "/bin/bash\n" }],
[/exec -it abc123 \/bin\/bash/, (stream) => shells.push(stream)],
]);
return { client, shells };
}
async function activated(client: FakeClient): Promise<MockPluginContext> {
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
router: () => express.Router(),
permissions: ["docker.use"],
sshHosts: [sshHost(7)],
sshClient: client,
});
await mock.ctx.settings.setHost(7, "enableDocker", true);
// Like the runtime, disposing a connection ends its client.
const connect = mock.ctx.ssh.connect;
mock.ctx.ssh.connect = (async (...args: Parameters<typeof connect>) => ({
...(await connect(...args)),
dispose: () => client.end(),
})) as typeof connect;
await activate(mock.ctx);
return mock;
}
async function openConsole(mock: MockPluginContext, userId = "user-1") {
const route = mock.wsRoutes.find((r) => r.path === "/console");
expect(route?.options).toEqual({ public: true, optionalAuth: true });
const socket = new FakeSocket();
mock.setActor(userId || undefined);
await route!.handler!({
userId,
request: {},
socket,
clientIp: "127.0.0.1",
requestOrigin: "http://localhost",
isDataUnlocked: () => true,
} as PluginWebSocketConnection);
return socket;
}
async function until(check: () => boolean) {
for (let i = 0; i < 50 && !check(); i++) {
await new Promise((resolve) => setImmediate(resolve));
}
expect(check()).toBe(true);
}
let disposals: Array<() => void | Promise<void>> = [];
afterEach(async () => {
for (const dispose of disposals.reverse()) await dispose();
disposals = [];
});
describe("docker console", () => {
it("refuses a socket without a user", async () => {
const mock = await activated(consoleClient().client);
disposals.push(...mock.disposals);
const socket = await openConsole(mock, "");
expect(socket.closedWith?.[0]).toBe(1008);
});
it("closes an open console on disable, and the next enable works", async () => {
const first = consoleClient();
const mock = await activated(first.client);
const socket = await openConsole(mock);
socket.message({
type: "connect",
data: {
hostConfig: { id: 7, ip: "10.0.0.7" },
containerId: "abc123",
shell: "bash",
cols: 100,
rows: 30,
},
});
await until(() => socket.sent.some((m) => m.type === "connected"));
socket.message({ type: "input", data: "ls\n" });
await until(() => first.shells[0]?.written.includes("ls\n") ?? false);
// Disable: the runtime empties the disposable bag.
for (const dispose of [...mock.disposals].reverse()) await dispose();
expect(first.shells[0].ended).toBe(true);
expect(first.client.ended).toBe(true);
expect(socket.closedWith?.[0]).toBe(1001);
// Enable again: a fresh activate on the same module serves a new console.
const second = consoleClient();
const again = await activated(second.client);
disposals.push(...again.disposals);
const next = await openConsole(again);
next.message({
type: "connect",
data: { hostConfig: { id: 7, ip: "10.0.0.7" }, containerId: "abc123" },
});
await until(() => next.sent.some((m) => m.type === "connected"));
second.shells[0].emit("data", Buffer.from("hello"));
await until(() =>
next.sent.some((m) => m.type === "output" && m.data === "hello"),
);
});
it("refuses a host id that resolves to another machine", async () => {
const mock = await activated(consoleClient().client);
disposals.push(...mock.disposals);
const socket = await openConsole(mock);
socket.message({
type: "connect",
data: { hostConfig: { id: 7, ip: "10.9.9.9" }, containerId: "abc123" },
});
await until(() => socket.sent.some((m) => m.type === "error"));
expect(String(socket.sent[0].message)).toMatch(/Host mismatch/);
});
it("refuses a host with Docker switched off", async () => {
const mock = await activated(consoleClient().client);
disposals.push(...mock.disposals);
await mock.ctx.settings.setHost(7, "enableDocker", false);
const socket = await openConsole(mock);
socket.message({
type: "connect",
data: { hostConfig: { id: 7, ip: "10.0.0.7" }, containerId: "abc123" },
});
await until(() => socket.sent.some((m) => m.type === "error"));
expect(String(socket.sent[0].message)).toMatch(/not enabled/);
});
});
+84
View File
@@ -0,0 +1,84 @@
import { EventEmitter } from "node:events";
/** An ssh2 exec channel: stdout on "data", stderr on `stderr`, then "close". */
export class FakeStream extends EventEmitter {
stderr = new EventEmitter();
written: string[] = [];
ended = false;
window: [number, number] | null = null;
write(data: string) {
this.written.push(data);
return true;
}
end() {
this.ended = true;
}
setWindow(rows: number, cols: number) {
this.window = [rows, cols];
}
/** Emits output and exits, on the next tick like a real channel. */
finish(stdout: string, code = 0, stderr = "") {
setImmediate(() => {
if (stdout) this.emit("data", Buffer.from(stdout));
if (stderr) this.stderr.emit("data", Buffer.from(stderr));
this.emit("close", code);
});
}
}
export type ExecReply =
| { stdout?: string; stderr?: string; code?: number }
| ((stream: FakeStream) => void);
/**
* An ssh2 Client that answers exec by the first matching pattern. A reply
* may be a function for a long-lived channel such as a console.
*/
export class FakeClient extends EventEmitter {
commands: string[] = [];
streams: FakeStream[] = [];
ended = false;
constructor(private replies: Array<[RegExp, ExecReply]> = []) {
super();
}
reply(pattern: RegExp, reply: ExecReply) {
this.replies.unshift([pattern, reply]);
}
exec(
command: string,
optionsOrCallback: unknown,
maybeCallback?: (err: Error | undefined, stream: FakeStream) => void,
) {
const callback = (
typeof optionsOrCallback === "function"
? optionsOrCallback
: maybeCallback
) as (err: Error | undefined, stream: FakeStream) => void;
this.commands.push(command);
const stream = new FakeStream();
this.streams.push(stream);
const match = this.replies.find(([pattern]) => pattern.test(command));
callback(undefined, stream);
if (!match) {
stream.finish("");
return this;
}
const reply = match[1];
if (typeof reply === "function") reply(stream);
else stream.finish(reply.stdout ?? "", reply.code ?? 0, reply.stderr ?? "");
return this;
}
end() {
if (this.ended) return;
this.ended = true;
this.emit("close");
}
}
@@ -0,0 +1,143 @@
import { afterEach, describe, expect, it } from "vitest";
import express from "express";
import { createMockCtx } from "@termix/plugin-sdk/testing";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { activate } from "../../src/backend/index.js";
import {
normalizeImportedHost,
readDockerHostSettings,
} from "../../src/backend/host-settings.js";
import {
diffContainerStates,
parseContainerStates,
} from "../../src/backend/container-state.js";
import { manifest, startServer, type TestServer } from "./server";
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
describe("docker activate", () => {
it("fails closed without network:serve", async () => {
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter(
(cap) => cap !== "network:serve",
),
router: () => express.Router(),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
});
it("cannot reach a host without ssh:connect", async () => {
server = await startServer({
mock: {
capabilities: manifest.capabilities.filter(
(cap) => cap !== "ssh:connect",
),
},
});
const response = await server.request("POST", "/ssh/connect", {
body: { sessionId: "s1", hostId: 7 },
});
expect(response.status).toBe(500);
});
it("registers its import normalizer", async () => {
server = await startServer();
expect(
server.mock.ctx.registry.consume("docker.hostImportNormalizer"),
).toBe(normalizeImportedHost);
});
});
describe("docker host settings", () => {
it("reads the switch and runtime with defaults", async () => {
server = await startServer({ dockerOn: false });
expect(await readDockerHostSettings(server.mock.ctx, 7)).toEqual({
enabled: false,
runtime: "docker",
});
await server.mock.ctx.settings.setHost(7, "enableDocker", true);
await server.mock.ctx.settings.setHost(7, "containerRuntime", "podman");
expect(await readDockerHostSettings(server.mock.ctx, 7)).toEqual({
enabled: true,
runtime: "podman",
});
});
it("takes an export's nested settings over the old flat fields", () => {
expect(
normalizeImportedHost({
enableDocker: false,
pluginSettings: {
docker: { enableDocker: true, containerRuntime: "podman" },
},
}),
).toEqual({ enableDocker: true, containerRuntime: "podman" });
});
it("reads 2.8 flat fields and proxmox-created rows", () => {
expect(
normalizeImportedHost({
enableDocker: 1,
dockerConfig: '{"runtime":"podman"}',
}),
).toEqual({ enableDocker: true, containerRuntime: "podman" });
expect(normalizeImportedHost({ enableDocker: true })).toEqual({
enableDocker: true,
});
expect(normalizeImportedHost({ name: "plain" })).toBeNull();
});
});
/**
* The polling side needs SSH, so what is tested here is the pure part:
* turning `ps` output into states, and two snapshots into events.
*/
describe("container state", () => {
it("reads name, state and health out of the ps output", () => {
const states = parseContainerStates(
[
'{"name":"web","state":"running","status":"Up 2 hours"}',
"",
"not json at all",
'{"state":"running"}',
'{"name":"api","state":"running","status":"Up 1 hour (unhealthy)"}',
].join("\n"),
);
expect([...states.keys()]).toEqual(["web", "api"]);
expect(states.get("api")).toEqual({ state: "running", unhealthy: true });
});
it("reports transitions and skips containers seen for the first time", () => {
const running = { state: "running", unhealthy: false };
const exited = { state: "exited", unhealthy: false };
const restarting = { state: "restarting", unhealthy: false };
expect(
diffContainerStates(
new Map([
["web", running],
["db", exited],
["cache", running],
]),
new Map([
["web", exited],
["db", running],
["cache", restarting],
["new", running],
]),
),
).toEqual([
{ container: "web", event: "exited" },
{ container: "db", event: "started" },
{ container: "cache", event: "restarting" },
]);
});
});
+282
View File
@@ -0,0 +1,282 @@
import { afterEach, describe, expect, it } from "vitest";
import type {
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
import { startServer, type TestServer } from "./server";
import { FakeClient } from "./fake-ssh";
let server: TestServer | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
async function connect(s: TestServer, sessionId = "s1", hostId = 7) {
return s.request("POST", "/ssh/connect", { body: { sessionId, hostId } });
}
/** Swaps ctx.ssh.connect for one that runs `flow` against the prompt channel. */
function promptingConnect(
s: TestServer,
flow: (
ask: NonNullable<PluginSshConnectOptions["prompt"]>["ask"],
) => Promise<void>,
) {
s.mock.ctx.ssh.connect = (async (
host: PluginSshHost,
options?: PluginSshConnectOptions,
) => {
await flow(options!.prompt!.ask);
return {
client: s.client as never,
jumpClient: null,
host,
dispose: () => s.client.end(),
};
}) as never;
}
describe("docker routes", () => {
it("refuses every route without docker.use", async () => {
server = await startServer({ permissions: [] });
const calls: Array<[string, string]> = [
["POST", "/ssh/connect"],
["POST", "/ssh/connect-totp"],
["POST", "/ssh/connect-warpgate"],
["POST", "/ssh/disconnect"],
["POST", "/ssh/keepalive"],
["GET", "/ssh/status?sessionId=s1"],
["GET", "/validate/s1"],
["GET", "/containers/s1"],
["GET", "/containers/s1/abc"],
["POST", "/containers/s1/abc/start"],
["DELETE", "/containers/s1/abc/remove"],
["GET", "/containers/s1/abc/logs"],
["GET", "/containers/s1/abc/stats"],
];
for (const [method, path] of calls) {
const response = await server.request(method, path, {
body: method === "GET" ? undefined : {},
});
expect(response.status, `${method} ${path}`).toBe(403);
}
});
it("refuses a host with Docker switched off", async () => {
server = await startServer({ dockerOn: false });
const response = await connect(server);
expect(response.status).toBe(403);
expect(response.body.code).toBe("DOCKER_DISABLED");
});
it("answers 404 for a host the user cannot reach", async () => {
server = await startServer();
expect((await connect(server, "s1", 99)).status).toBe(404);
});
it("connects, validates and manages containers on the session", async () => {
server = await startServer();
const connected = await connect(server);
expect(connected.status).toBe(200);
expect(connected.body.success).toBe(true);
expect(server.mock.statusReports).toContainEqual({ hostId: 7, ok: true });
const validation = await server.request("GET", "/validate/s1");
expect(validation.body).toEqual({
available: true,
version: "27.1.1",
runtime: "docker",
});
const list = await server.request("GET", "/containers/s1");
expect(list.body).toEqual([
expect.objectContaining({ id: "abc123", name: "web", state: "running" }),
]);
server.client.reply(/ start abc123$/, { stdout: "abc123" });
const started = await server.request("POST", "/containers/s1/abc123/start");
expect(started.status).toBe(200);
expect(
server.client.commands.some((c) => c.endsWith("docker start abc123")),
).toBe(true);
server.client.reply(/ logs abc123/, { stdout: "line one\nline two\n" });
const logs = await server.request(
"GET",
"/containers/s1/abc123/logs?tail=50&since=2026-01-01T00:00:00Z",
);
expect(logs.body).toEqual({ success: true, logs: "line one\nline two\n" });
expect(server.client.commands.at(-1)).toContain(
"logs abc123 --tail 50 --since 2026-01-01T00:00:00Z 2>&1",
);
server.client.reply(/ stats abc123/, {
stdout:
'{"cpu":"1.5%","memory":"10MiB / 1GiB","memoryPercent":"1%","netIO":"1kB / 2kB","blockIO":"0B / 0B","pids":"3"}',
});
const stats = await server.request("GET", "/containers/s1/abc123/stats");
expect(stats.body).toMatchObject({
cpu: "1.5%",
memoryUsed: "10MiB",
memoryLimit: "1GiB",
netInput: "1kB",
netOutput: "2kB",
});
server.client.reply(/ rm abc123/, {
code: 1,
stderr: "Error: No such container: abc123",
});
const removed = await server.request(
"DELETE",
"/containers/s1/abc123/remove",
);
expect(removed.status).toBe(404);
expect(
(await server.request("POST", "/containers/s1/abc123/explode")).status,
).toBe(404);
expect(
(await server.request("GET", "/containers/s1/bad;id/logs")).status,
).toBe(400);
await server.request("POST", "/ssh/disconnect", {
body: { sessionId: "s1" },
});
const status = await server.request("GET", "/ssh/status?sessionId=s1");
expect(status.body.connected).toBe(false);
});
it("uses Podman when the host says so", async () => {
server = await startServer();
await server.mock.ctx.settings.setHost(7, "containerRuntime", "podman");
await connect(server);
await server.request("GET", "/containers/s1");
expect(server.client.commands.at(-1)).toMatch(/ podman ps -a --format/);
});
it("keeps another user's session to its owner", async () => {
server = await startServer();
await connect(server);
const response = await server.request("GET", "/containers/s1", {
user: "user-2",
});
expect(response.status).toBe(400);
const status = await server.request("GET", "/ssh/status?sessionId=s1", {
user: "user-2",
});
expect(status.body.connected).toBe(false);
});
it("parks a TOTP prompt and re-asks after a wrong code", async () => {
server = await startServer();
promptingConnect(server, async (ask) => {
let code = await ask({ kind: "totp", prompt: "Code:", retry: false });
while (code !== "123456") {
if (code === null) throw new Error("Cancelled");
code = await ask({ kind: "totp", prompt: "Code:", retry: true });
}
});
const first = await connect(server);
expect(first.body).toMatchObject({ requires_totp: true, prompt: "Code:" });
const pending = await server.request("GET", "/containers/s1");
expect(pending.body.code).toBe("AUTH_PENDING");
const wrong = await server.request("POST", "/ssh/connect-totp", {
body: { sessionId: "s1", totpCode: "000000" },
});
expect(wrong.body).toMatchObject({ requires_totp: true, retry: true });
const stranger = await server.request("POST", "/ssh/connect-totp", {
user: "user-2",
body: { sessionId: "s1", totpCode: "123456" },
});
expect(stranger.status).toBe(404);
const right = await server.request("POST", "/ssh/connect-totp", {
body: { sessionId: "s1", totpCode: "123456" },
});
expect(right.body).toMatchObject({ success: true, status: "success" });
expect((await server.request("GET", "/containers/s1")).status).toBe(200);
});
it("continues a Warpgate sign-in and refuses a code for it", async () => {
server = await startServer();
promptingConnect(server, async (ask) => {
await ask({
kind: "warpgate",
url: "https://warpgate.example/login",
securityKey: "KEY",
instructions: "",
});
});
const first = await connect(server);
expect(first.body).toMatchObject({
requires_warpgate: true,
url: "https://warpgate.example/login",
securityKey: "KEY",
});
const wrongKind = await server.request("POST", "/ssh/connect-totp", {
body: { sessionId: "s1", totpCode: "1" },
});
expect(wrongKind.status).toBe(400);
const done = await server.request("POST", "/ssh/connect-warpgate", {
body: { sessionId: "s1" },
});
expect(done.body.success).toBe(true);
});
it("asks for credentials when a host with no secret gets a password prompt", async () => {
server = await startServer({
mock: {
sshHosts: [
{
id: 7,
userId: "user-1",
ip: "10.0.0.7",
port: 22,
username: "root",
authType: "none",
},
],
},
});
promptingConnect(server, async (ask) => {
const answer = await ask({
kind: "input",
prompt: "Password:",
echo: false,
isPush: false,
});
if (answer === null)
throw new Error("All configured authentication methods failed");
});
const response = await connect(server);
expect(response.body).toEqual({
status: "auth_required",
reason: "no_keyboard",
});
});
it("reports a failed login to core", async () => {
const client = new FakeClient();
server = await startServer({ client });
server.mock.ctx.ssh.connect = (async () => {
throw new Error("All configured authentication methods failed");
}) as never;
const response = await connect(server);
expect(response.status).toBe(500);
expect(server.mock.statusReports).toContainEqual({
hostId: 7,
ok: false,
hostKeyChanged: false,
});
});
});
+116
View File
@@ -0,0 +1,116 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import express, { type Router } from "express";
import {
createMockCtx,
type MockContextOptions,
type MockPluginContext,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
import { activate } from "../../src/backend/index.js";
import { FakeClient } from "./fake-ssh";
export const manifest = manifestJson as unknown as PluginManifest;
export const PS_LINE =
'{"id":"abc123","name":"web","image":"nginx","status":"Up 2 hours","state":"running","ports":"80/tcp","created":"2026-01-01"}';
/** A host owned by user-1, as ctx.ssh.resolveHost sees it. */
export function sshHost(id: number, extra: Record<string, unknown> = {}) {
return {
id,
userId: "user-1",
name: `host-${id}`,
ip: `10.0.0.${id}`,
port: 22,
username: "root",
authType: "password",
password: "pw",
...extra,
};
}
/** A client that looks like a Linux host with Docker running. */
export function dockerClient(): FakeClient {
return new FakeClient([
[/^ver$/, { stdout: "" }],
[/--version/, { stdout: "Docker version 27.1.1, build abc" }],
[/ ps -a --format/, { stdout: `${PS_LINE}\n` }],
[/ ps$/, { stdout: "" }],
]);
}
export interface TestServer {
mock: MockPluginContext;
client: FakeClient;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
/** The plugin activated with its router served the way core mounts it. */
export async function startServer(
options: {
permissions?: string[];
dockerOn?: boolean;
client?: FakeClient;
mock?: Partial<MockContextOptions>;
} = {},
): Promise<TestServer> {
const client = options.client ?? dockerClient();
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
router: () => (router = express.Router()),
permissions: options.permissions ?? ["docker.use"],
sshHosts: [sshHost(7), sshHost(8)],
sshClient: client,
...options.mock,
});
if (options.dockerOn !== false) {
await mock.ctx.settings.setHost(7, "enableDocker", true);
}
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
mock,
client,
async request(method, path, { user = "user-1", body } = {}) {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
for (const dispose of mock.disposals.reverse()) await dispose();
await new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}
@@ -0,0 +1,105 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type {
DockerEvent,
DockerEventsV1,
DockerServiceV1,
} from "../../src/backend/index.js";
import { startServer, type TestServer } from "./server";
let server: TestServer | null = null;
afterEach(async () => {
vi.useRealTimers();
await server?.close();
server = null;
});
const psState = (state: string, status = "Up") =>
`{"name":"web","state":"${state}","status":"${status}"}\n`;
describe("docker.containers service", () => {
it("lists containers and runs actions as the caller", async () => {
server = await startServer();
const docker = server.mock.services.get(
"docker.containers",
) as DockerServiceV1;
const containers = await server.mock.ctx.asUser("user-1", () =>
docker.listContainers(7),
);
expect(containers).toEqual([
expect.objectContaining({ id: "abc123", name: "web" }),
]);
await server.mock.ctx.asUser("user-1", () =>
docker.action(7, "web", "restart"),
);
expect(server.client.commands.at(-1)).toMatch(/ docker restart web$/);
expect(server.mock.sshConnections.at(-1)).toMatchObject({
host: 7,
pool: "docker",
});
await expect(
server.mock.ctx.asUser("user-1", () =>
docker.action(7, "web; rm -rf /", "stop"),
),
).rejects.toThrow(/Invalid container name/);
});
it("needs a calling user", async () => {
server = await startServer();
const docker = server.mock.services.get(
"docker.containers",
) as DockerServiceV1;
await expect(docker.listContainers(7)).rejects.toThrow(/calling user/);
});
});
describe("docker.events service", () => {
it("reports state changes between polls to every subscriber", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
server = await startServer();
const events = server.mock.services.get("docker.events") as DockerEventsV1;
const seen: DockerEvent[] = [];
const listener = (event: DockerEvent) => seen.push(event);
const unsubscribe = await server.mock.ctx.asUser("user-1", () =>
events.subscribe(7, listener),
);
// Subscribing the same listener again is a no-op.
await server.mock.ctx.asUser("user-1", () => events.subscribe(7, listener));
const poll = async (output: string) => {
server!.client.reply(/ ps -a --format/, { stdout: output });
vi.setSystemTime(Date.now() + 61_000);
await server!.mock.runScheduled();
};
await poll(psState("running"));
expect(seen).toEqual([]);
await poll(psState("exited", "Exited (1)"));
expect(seen).toEqual([{ hostId: 7, container: "web", event: "exited" }]);
await poll(psState("running", "Up 1 second (unhealthy)"));
expect(seen.slice(1)).toEqual([
{ hostId: 7, container: "web", event: "started" },
{ hostId: 7, container: "web", event: "unhealthy" },
]);
unsubscribe();
const before = server.client.commands.length;
await poll(psState("exited"));
expect(server.client.commands.length).toBe(before);
expect(seen).toHaveLength(3);
});
it("stops polling once the plugin is disposed", async () => {
server = await startServer();
const events = server.mock.services.get("docker.events") as DockerEventsV1;
await server.mock.ctx.asUser("user-1", () => events.subscribe(7, () => {}));
for (const dispose of [...server.mock.disposals].reverse()) await dispose();
expect(server.mock.scheduled.every((job) => job.stopped)).toBe(true);
});
});
@@ -0,0 +1,139 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
function host(enableDocker: boolean) {
return {
id: "7",
name: "box",
ip: "10.0.0.7",
port: 22,
username: "root",
enableSsh: true,
pluginSettings: { docker: { enableDocker } },
};
}
function stubApi(connectResult: Record<string, unknown>) {
const post = vi.fn(async (url: string) => {
if (url === "/ssh/connect") return { data: connectResult };
return { data: { success: true } };
});
const get = vi.fn(async (url: string) => {
if (url.startsWith("/validate/")) {
return {
data: { available: true, version: "27.1.1", runtime: "docker" },
};
}
if (url.startsWith("/containers/")) {
return {
data: [
{
id: "abc123",
name: "web",
image: "nginx",
status: "Up",
state: "running",
ports: "",
created: "",
},
],
};
}
return { data: {} };
});
return {
post,
get,
delete: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
} as unknown as {
post: typeof post;
get: typeof get;
} & PluginApiClient;
}
describe("DockerManager", () => {
it("says Docker is off for a host without the switch", async () => {
const api = stubApi({ success: true });
rendered = await renderWithApp(plugin, {
manifest,
locales,
hosts: [host(false)],
api,
});
rendered.renderTab("docker", {
host: host(false),
label: "box",
isVisible: true,
});
expect(
await screen.findByText("Docker is not enabled for this host"),
).toBeTruthy();
expect(api.post).not.toHaveBeenCalled();
});
it("connects through the plugin api and lists containers", async () => {
const api = stubApi({ success: true, status: "success" });
rendered = await renderWithApp(plugin, {
manifest,
locales,
hosts: [host(true)],
api,
});
rendered.renderTab("docker", {
host: host(true),
label: "box",
isVisible: true,
});
await waitFor(() =>
expect(api.post).toHaveBeenCalledWith(
"/ssh/connect",
expect.objectContaining({ hostId: 7 }),
),
);
await waitFor(() =>
expect(api.get).toHaveBeenCalledWith(
expect.stringMatching(/^\/validate\//),
),
);
});
it("opens the code prompt when the host asks for one", async () => {
const api = stubApi({
requires_totp: true,
sessionId: "s1",
prompt: "Verification code:",
});
rendered = await renderWithApp(plugin, {
manifest,
locales,
hosts: [host(true)],
api,
});
rendered.renderTab("docker", {
host: host(true),
label: "box",
isVisible: true,
});
expect(await screen.findByText("Verification code:")).toBeTruthy();
});
});
@@ -36,6 +36,11 @@ describe(`${manifest.id} activate`, () => {
for (const id of rendered.registered.dashboardCards()) {
expect(cards).toContain(id);
}
// Host settings render from the manifest; there is no editor tab.
expect(rendered.registered.hostEditorSections()).toEqual([]);
expect(rendered.registered.hostActions()).toEqual([
expect.objectContaining({ id: "docker", tabType: "docker" }),
]);
});
it("removes everything it registered on deactivate", async () => {
-6
View File
@@ -1,10 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"compilerOptions": {
"paths": {
"@/types/*": ["../../src/types/*"],
"@/*": ["../../src/ui/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}