
@@ -39,7 +51,7 @@ free and self-hosted alternative to Termius available for all platforms.
# Features
-- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components
+- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components.
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring and support for -l or -r connections
- **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly with sudo support.
- **Docker Management** - Start, stop, pause, remove containers. View container stats. Control container using docker exec terminal. It was not made to replace Portainer or Dockge but rather to simply manage your containers compared to creating them.
@@ -47,19 +59,20 @@ free and self-hosted alternative to Termius available for all platforms.
- **Server Stats** - View CPU, memory, and disk usage along with network, uptime, system information, firewall, port monitor, on most Linux based servers
- **Dashboard** - View server information at a glance on your dashboard
- **RBAC** - Create roles and share hosts across users/roles
-- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together.
+- **User Authentication** - Secure user management with admin controls and OIDC (with access control) and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together.
- **Database Encryption** - Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more.
- **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data
- **Automatic SSL Setup** - Built-in SSL certificate generation and management with HTTPS redirects
- **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn. Choose between dark or light mode based UI. Use URL routes to open any connection in full-screen.
- **Languages** - Built-in support ~30 languages (managed by [Crowdin](https://docs.termix.site/translations))
-- **Platform Support** - Available as a web app, desktop application (Windows, Linux, and macOS), PWA, and dedicated mobile/tablet app for iOS and Android.
+- **Platform Support** - Available as a web app, desktop application (Windows, Linux, and macOS, can be run standalone without Termix backend), PWA, and dedicated mobile/tablet app for iOS and Android.
- **SSH Tools** - Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals.
- **Command History** - Auto-complete and view previously ran SSH commands
- **Quick Connect** - Connect to a server without having to save the connection data
- **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard
- **SSH Feature Rich** - Supports jump hosts, Warpgate, TOTP based connections, SOCKS5, host key verification, password autofill, [OPKSSH](https://github.com/openpubkey/opkssh), etc.
- **Network Graph** - Customize your Dashboard to visualize your homelab based off your SSH connections with status support
+- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
# Planned Features
@@ -86,7 +99,7 @@ Supported Devices:
- Homebrew
- iOS/iPadOS (v15.1+)
- Apple App Store
- - ISO
+ - IPA
- Android (v7.0+)
- Google Play Store
- APK
@@ -140,7 +153,7 @@ channel, however, response times may be longer.
# Screenshots
-[](https://youtu.be/8OYCPwS7ciA)
+[](https://youtu.be/sjKIqfCK0NY)
+
+إذا كنت ترغب في ذلك، يمكنك دعم المشروع هنا!\
+[](https://github.com/sponsors/LukeGus)
+
+# نظرة عامة
+
+
+
+Termix هي منصة مفتوحة المصدر ومجانية للأبد وذاتية الاستضافة لإدارة الخوادم بشكل شامل. توفر حلاً متعدد المنصات لإدارة خوادمك وبنيتك التحتية من خلال واجهة واحدة وسهلة الاستخدام. يوفر Termix الوصول إلى طرفية SSH، وقدرات إنشاء أنفاق SSH، وإدارة الملفات عن بُعد، والعديد من الأدوات الأخرى. يُعد Termix البديل المثالي المجاني وذاتي الاستضافة لـ Termius المتاح لجميع المنصات.
+
+# الميزات
+
+- **الوصول إلى طرفية SSH** - طرفية كاملة الميزات مع دعم تقسيم الشاشة (حتى 4 لوحات) مع نظام علامات تبويب شبيه بالمتصفح. يتضمن دعم تخصيص الطرفية بما في ذلك السمات الشائعة والخطوط والمكونات الأخرى
+- **إدارة أنفاق SSH** - إنشاء وإدارة أنفاق SSH مع إعادة الاتصال التلقائي ومراقبة الحالة ودعم اتصالات -l أو -r
+- **مدير الملفات عن بُعد** - إدارة الملفات مباشرة على الخوادم البعيدة مع دعم عرض وتحرير الكود والصور والصوت والفيديو. رفع وتنزيل وإعادة تسمية وحذف ونقل الملفات بسلاسة مع دعم sudo
+- **إدارة Docker** - تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها
+- **مدير مضيفات SSH** - حفظ وتنظيم وإدارة اتصالات SSH الخاصة بك باستخدام العلامات والمجلدات، وحفظ بيانات تسجيل الدخول القابلة لإعادة الاستخدام بسهولة مع إمكانية أتمتة نشر مفاتيح SSH
+- **إحصائيات الخادم** - عرض استخدام المعالج والذاكرة والقرص إلى جانب الشبكة ووقت التشغيل ومعلومات النظام وجدار الحماية ومراقب المنافذ على معظم الخوادم المبنية على Linux
+- **لوحة التحكم** - عرض معلومات الخادم بنظرة واحدة على لوحة التحكم
+- **RBAC** - إنشاء الأدوار ومشاركة المضيفات عبر المستخدمين/الأدوار
+- **مصادقة المستخدمين** - إدارة آمنة للمستخدمين مع ضوابط إدارية ودعم OIDC و 2FA (TOTP). عرض جلسات المستخدمين النشطة عبر جميع المنصات وإلغاء الصلاحيات. ربط حسابات OIDC/المحلية معاً
+- **تشفير قاعدة البيانات** - يُخزَّن الخادم الخلفي كملفات قاعدة بيانات SQLite مشفرة. اطلع على [الوثائق](https://docs.termix.site/security) لمزيد من المعلومات
+- **تصدير/استيراد البيانات** - تصدير واستيراد مضيفات SSH وبيانات الاعتماد وبيانات مدير الملفات
+- **إعداد SSL تلقائي** - إنشاء وإدارة شهادات SSL مدمجة مع إعادة التوجيه إلى HTTPS
+- **واجهة مستخدم حديثة** - واجهة نظيفة متوافقة مع سطح المكتب والهاتف المحمول مبنية بـ React و Tailwind CSS و Shadcn. الاختيار بين الوضع الداكن أو الفاتح. استخدام مسارات URL لفتح أي اتصال في وضع ملء الشاشة
+- **اللغات** - دعم مدمج لحوالي 30 لغة (تُدار بواسطة [Crowdin](https://docs.termix.site/translations))
+- **دعم المنصات** - متاح كتطبيق ويب، وتطبيق سطح مكتب (Windows و Linux و macOS)، و PWA، وتطبيق مخصص للهاتف المحمول/الجهاز اللوحي لـ iOS و Android
+- **أدوات SSH** - إنشاء مقتطفات أوامر قابلة لإعادة الاستخدام تُنفَّذ بنقرة واحدة. تشغيل أمر واحد في وقت واحد عبر عدة طرفيات مفتوحة
+- **سجل الأوامر** - الإكمال التلقائي وعرض أوامر SSH التي تم تنفيذها سابقاً
+- **الاتصال السريع** - الاتصال بخادم دون الحاجة إلى حفظ بيانات الاتصال
+- **لوحة الأوامر** - اضغط مرتين على Shift الأيسر للوصول السريع إلى اتصالات SSH باستخدام لوحة المفاتيح
+- **ميزات SSH الغنية** - دعم مضيفات القفز، Warpgate، الاتصالات المبنية على TOTP، SOCKS5، التحقق من مفتاح المضيف، الملء التلقائي لكلمة المرور، [OPKSSH](https://github.com/openpubkey/opkssh)، وغيرها
+- **الرسم البياني للشبكة** - تخصيص لوحة التحكم لتصور مختبرك المنزلي بناءً على اتصالات SSH مع دعم الحالة
+- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+
+# الميزات المخططة
+
+راجع [المشاريع](https://github.com/orgs/Termix-SSH/projects/2) لعرض جميع الميزات المخططة. إذا كنت تتطلع للمساهمة، راجع [المساهمة](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
+
+# التثبيت
+
+الأجهزة المدعومة:
+
+- الموقع الإلكتروني (أي متصفح حديث على أي منصة مثل Chrome و Safari و Firefox) (يتضمن دعم PWA)
+- Windows (x64/ia32)
+ - نسخة محمولة
+ - مثبت MSI
+ - مدير حزم Chocolatey
+- Linux (x64/ia32)
+ - نسخة محمولة
+ - AUR
+ - AppImage
+ - Deb
+ - Flatpak
+- macOS (x64/ia32 على الإصدار 12.0+)
+ - Apple App Store
+ - DMG
+ - Homebrew
+- iOS/iPadOS (الإصدار 15.1+)
+ - Apple App Store
+ - IPA
+- Android (الإصدار 7.0+)
+ - Google Play Store
+ - APK
+
+قم بزيارة [وثائق](https://docs.termix.site/install) Termix للحصول على مزيد من المعلومات حول كيفية تثبيت Termix على جميع المنصات. بخلاف ذلك، يمكنك الاطلاع على نموذج ملف Docker Compose هنا:
+
+```yaml
+services:
+ termix:
+ image: ghcr.io/lukegus/termix:latest
+ container_name: termix
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ volumes:
+ - termix-data:/app/data
+ environment:
+ PORT: "8080"
+
+volumes:
+ termix-data:
+ driver: local
+```
+
+# الرعاة
+
+
+
+# الدعم
+
+إذا كنت بحاجة إلى مساعدة أو ترغب في طلب ميزة لـ Termix، قم بزيارة صفحة [المشكلات](https://github.com/Termix-SSH/Support/issues)، وسجل الدخول، واضغط على `New Issue`.
+يرجى أن تكون مفصلاً قدر الإمكان في مشكلتك، ويُفضَّل كتابتها باللغة الإنجليزية. يمكنك أيضاً الانضمام إلى خادم [Discord](https://discord.gg/jVQGdvHDrf) وزيارة قناة الدعم، ومع ذلك قد تكون أوقات الاستجابة أطول.
+
+# لقطات الشاشة
+
+[](https://youtu.be/sjKIqfCK0NY)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+قد تكون بعض مقاطع الفيديو والصور قديمة أو قد لا تعرض الميزات بشكل مثالي.
+
+# الترخيص
+
+موزع بموجب رخصة Apache License الإصدار 2.0. راجع ملف LICENSE لمزيد من المعلومات.
diff --git a/readme/README-CN.md b/readme/README-CN.md
new file mode 100644
index 00000000..b4f73562
--- /dev/null
+++ b/readme/README-CN.md
@@ -0,0 +1,189 @@
+# 仓库统计
+
+
+
+Wenn Sie möchten, können Sie das Projekt hier unterstützen!\
+[](https://github.com/sponsors/LukeGus)
+
+# Überblick
+
+
+
+Termix ist eine quelloffene, dauerhaft kostenlose, selbst gehostete All-in-One-Serververwaltungsplattform. Sie bietet eine plattformübergreifende Lösung zur Verwaltung Ihrer Server und Infrastruktur über eine einzige, intuitive Oberfläche. Termix bietet SSH-Terminalzugriff, SSH-Tunneling-Funktionen, Remote-Dateiverwaltung und viele weitere Werkzeuge. Termix ist die perfekte kostenlose und selbst gehostete Alternative zu Termius, verfügbar für alle Plattformen.
+
+# Funktionen
+
+- **SSH-Terminalzugriff** - Voll ausgestattetes Terminal mit Split-Screen-Unterstützung (bis zu 4 Panels) mit einem browserähnlichen Tab-System. Enthält Unterstützung für die Anpassung des Terminals einschließlich gängiger Terminal-Themes, Schriftarten und anderer Komponenten
+- **SSH-Tunnelverwaltung** - Erstellen und verwalten Sie SSH-Tunnel mit automatischer Wiederverbindung und Gesundheitsüberwachung sowie Unterstützung für -l oder -r Verbindungen
+- **Remote-Dateimanager** - Verwalten Sie Dateien direkt auf Remote-Servern mit Unterstützung für das Anzeigen und Bearbeiten von Code, Bildern, Audio und Video. Laden Sie Dateien hoch, herunter, benennen Sie sie um, löschen oder verschieben Sie sie nahtlos mit Sudo-Unterstützung.
+- **Docker-Verwaltung** - Container starten, stoppen, pausieren, entfernen. Container-Statistiken anzeigen. Container über Docker-Exec-Terminal steuern. Es wurde nicht entwickelt, um Portainer oder Dockge zu ersetzen, sondern um Ihre Container einfach zu verwalten, anstatt sie zu erstellen.
+- **SSH-Host-Manager** - Speichern, organisieren und verwalten Sie Ihre SSH-Verbindungen mit Tags und Ordnern und speichern Sie einfach wiederverwendbare Anmeldeinformationen mit der Möglichkeit, die Bereitstellung von SSH-Schlüsseln zu automatisieren
+- **Serverstatistiken** - CPU-, Arbeitsspeicher- und Festplattenauslastung sowie Netzwerk, Betriebszeit, Systeminformationen, Firewall, Port-Monitor auf den meisten Linux-basierten Servern anzeigen
+- **Dashboard** - Serverinformationen auf einen Blick auf Ihrem Dashboard anzeigen
+- **RBAC** - Rollen erstellen und Hosts über Benutzer/Rollen teilen
+- **Benutzerauthentifizierung** - Sichere Benutzerverwaltung mit Admin-Kontrollen und OIDC- sowie 2FA (TOTP)-Unterstützung. Aktive Benutzersitzungen über alle Plattformen anzeigen und Berechtigungen widerrufen. OIDC-/Lokale Konten miteinander verknüpfen.
+- **Datenbankverschlüsselung** - Backend gespeichert als verschlüsselte SQLite-Datenbankdateien. Weitere Informationen in der [Dokumentation](https://docs.termix.site/security).
+- **Datenexport/-import** - SSH-Hosts, Anmeldeinformationen und Dateimanager-Daten exportieren und importieren
+- **Automatische SSL-Einrichtung** - Integrierte SSL-Zertifikatsgenerierung und -verwaltung mit HTTPS-Weiterleitungen
+- **Moderne Benutzeroberfläche** - Saubere desktop-/mobilfreundliche Oberfläche, erstellt mit React, Tailwind CSS und Shadcn. Wählen Sie zwischen dunklem oder hellem Modus. Verwenden Sie URL-Routen, um jede Verbindung im Vollbildmodus zu öffnen.
+- **Sprachen** - Integrierte Unterstützung für ~30 Sprachen (verwaltet über [Crowdin](https://docs.termix.site/translations))
+- **Plattformunterstützung** - Verfügbar als Web-App, Desktop-Anwendung (Windows, Linux und macOS), PWA und dedizierte Mobil-/Tablet-App für iOS und Android.
+- **SSH-Werkzeuge** - Erstellen Sie wiederverwendbare Befehlsvorlagen, die mit einem einzigen Klick ausgeführt werden. Führen Sie einen Befehl gleichzeitig in mehreren geöffneten Terminals aus.
+- **Befehlsverlauf** - Autovervollständigung und Anzeige zuvor ausgeführter SSH-Befehle
+- **Schnellverbindung** - Verbinden Sie sich mit einem Server, ohne die Verbindungsdaten speichern zu müssen
+- **Befehlspalette** - Doppeltippen Sie die linke Umschalttaste, um schnell auf SSH-Verbindungen mit Ihrer Tastatur zuzugreifen
+- **SSH-Funktionsreich** - Unterstützt Jump-Hosts, Warpgate, TOTP-basierte Verbindungen, SOCKS5, Host-Key-Verifizierung, automatisches Ausfüllen von Passwörtern, [OPKSSH](https://github.com/openpubkey/opkssh) usw.
+- **Netzwerkgraph** - Passen Sie Ihr Dashboard an, um Ihr Homelab basierend auf Ihren SSH-Verbindungen mit Statusunterstützung zu visualisieren
+- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+
+# Geplante Funktionen
+
+Siehe [Projekte](https://github.com/orgs/Termix-SSH/projects/2) für alle geplanten Funktionen. Wenn Sie beitragen möchten, siehe [Mitwirken](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
+
+# Installation
+
+Unterstützte Geräte:
+
+- Website (jeder moderne Browser auf jeder Plattform wie Chrome, Safari und Firefox) (einschließlich PWA-Unterstützung)
+- Windows (x64/ia32)
+ - Portabel
+ - MSI-Installationsprogramm
+ - Chocolatey-Paketmanager
+- Linux (x64/ia32)
+ - Portabel
+ - AUR
+ - AppImage
+ - Deb
+ - Flatpak
+- macOS (x64/ia32 ab v12.0+)
+ - Apple App Store
+ - DMG
+ - Homebrew
+- iOS/iPadOS (v15.1+)
+ - Apple App Store
+ - IPA
+- Android (v7.0+)
+ - Google Play Store
+ - APK
+
+Besuchen Sie die Termix-[Dokumentation](https://docs.termix.site/install) für weitere Informationen zur Installation von Termix auf allen Plattformen. Alternativ finden Sie hier eine Docker Compose-Beispieldatei:
+
+```yaml
+services:
+ termix:
+ image: ghcr.io/lukegus/termix:latest
+ container_name: termix
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ volumes:
+ - termix-data:/app/data
+ environment:
+ PORT: "8080"
+
+volumes:
+ termix-data:
+ driver: local
+```
+
+# Sponsoren
+
+
+
+# Support
+
+Wenn Sie Hilfe benötigen oder eine Funktion für Termix anfragen möchten, besuchen Sie die [Issues](https://github.com/Termix-SSH/Support/issues)-Seite, melden Sie sich an und klicken Sie auf `New Issue`.
+Bitte beschreiben Sie Ihr Anliegen so detailliert wie möglich, vorzugsweise auf Englisch. Sie können auch dem [Discord](https://discord.gg/jVQGdvHDrf)-Server beitreten und den Support-Kanal besuchen, allerdings können die Antwortzeiten dort länger sein.
+
+# Screenshots
+
+[](https://youtu.be/sjKIqfCK0NY)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Einige Videos und Bilder können veraltet sein oder Funktionen möglicherweise nicht perfekt darstellen.
+
+# Lizenz
+
+Verteilt unter der Apache License Version 2.0. Siehe LICENSE für weitere Informationen.
diff --git a/readme/README-ES.md b/readme/README-ES.md
new file mode 100644
index 00000000..328c9de3
--- /dev/null
+++ b/readme/README-ES.md
@@ -0,0 +1,187 @@
+# Estadísticas del Repositorio
+
+
+
+Si lo desea, puede apoyar el proyecto aquí.\
+[](https://github.com/sponsors/LukeGus)
+
+# Descripción General
+
+
+
+Termix es una plataforma de gestión de servidores todo en uno, de código abierto, siempre gratuita y autoalojada. Proporciona una solución multiplataforma para gestionar sus servidores e infraestructura a través de una interfaz única e intuitiva. Termix ofrece acceso a terminal SSH, capacidades de túneles SSH, gestión remota de archivos y muchas otras herramientas. Termix es la alternativa perfecta, gratuita y autoalojada a Termius, disponible para todas las plataformas.
+
+# Características
+
+- **Acceso a Terminal SSH** - Terminal completo con soporte de pantalla dividida (hasta 4 paneles) con un sistema de pestañas similar al navegador. Incluye soporte para personalizar el terminal incluyendo temas comunes de terminal, fuentes y otros componentes
+- **Gestión de Túneles SSH** - Cree y gestione túneles SSH con reconexión automática y monitoreo de estado, con soporte para conexiones -l o -r
+- **Gestor Remoto de Archivos** - Gestione archivos directamente en servidores remotos con soporte para visualizar y editar código, imágenes, audio y video. Suba, descargue, renombre, elimine y mueva archivos sin problemas con soporte sudo.
+- **Gestión de Docker** - Inicie, detenga, pause, elimine contenedores. Vea estadísticas de contenedores. Controle contenedores usando el terminal Docker Exec. No fue creado para reemplazar Portainer o Dockge, sino para simplemente gestionar sus contenedores en lugar de crearlos.
+- **Gestor de Hosts SSH** - Guarde, organice y gestione sus conexiones SSH con etiquetas y carpetas, y guarde fácilmente información de inicio de sesión reutilizable con la capacidad de automatizar el despliegue de claves SSH
+- **Estadísticas del Servidor** - Vea el uso de CPU, memoria y disco junto con red, tiempo de actividad, información del sistema, firewall, monitor de puertos en la mayoría de los servidores basados en Linux
+- **Dashboard** - Vea la información del servidor de un vistazo en su dashboard
+- **RBAC** - Cree roles y comparta hosts entre usuarios/roles
+- **Autenticación de Usuarios** - Gestión segura de usuarios con controles de administrador y soporte para OIDC y 2FA (TOTP). Vea sesiones activas de usuarios en todas las plataformas y revoque permisos. Vincule sus cuentas OIDC/Locales entre sí.
+- **Cifrado de Base de Datos** - Backend almacenado como archivos de base de datos SQLite cifrados. Consulte la [documentación](https://docs.termix.site/security) para más información.
+- **Exportación/Importación de Datos** - Exporte e importe hosts SSH, credenciales y datos del gestor de archivos
+- **Configuración Automática de SSL** - Generación y gestión integrada de certificados SSL con redirecciones HTTPS
+- **Interfaz Moderna** - Interfaz limpia compatible con escritorio/móvil construida con React, Tailwind CSS y Shadcn. Elija entre modo oscuro o claro. Use rutas URL para abrir cualquier conexión en pantalla completa.
+- **Idiomas** - Soporte integrado para ~30 idiomas (gestionado por [Crowdin](https://docs.termix.site/translations))
+- **Soporte de Plataformas** - Disponible como aplicación web, aplicación de escritorio (Windows, Linux y macOS), PWA y aplicación dedicada para móviles/tablets en iOS y Android.
+- **Herramientas SSH** - Cree fragmentos de comandos reutilizables que se ejecutan con un solo clic. Ejecute un comando simultáneamente en múltiples terminales abiertos.
+- **Historial de Comandos** - Autocompletado y visualización de comandos SSH ejecutados anteriormente
+- **Conexión Rápida** - Conéctese a un servidor sin necesidad de guardar los datos de conexión
+- **Paleta de Comandos** - Pulse dos veces la tecla Shift izquierda para acceder rápidamente a las conexiones SSH con su teclado
+- **SSH Rico en Funciones** - Soporta jump hosts, Warpgate, conexiones basadas en TOTP, SOCKS5, verificación de clave de host, autocompletado de contraseñas, [OPKSSH](https://github.com/openpubkey/opkssh), etc.
+- **Gráfico de Red** - Personalice su Dashboard para visualizar su homelab basado en sus conexiones SSH con soporte de estado
+- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+
+# Características Planeadas
+
+Consulte [Proyectos](https://github.com/orgs/Termix-SSH/projects/2) para todas las características planeadas. Si desea contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
+
+# Instalación
+
+Dispositivos soportados:
+
+- Sitio web (cualquier navegador moderno en cualquier plataforma como Chrome, Safari y Firefox) (incluye soporte PWA)
+- Windows (x64/ia32)
+ - Portable
+ - Instalador MSI
+ - Gestor de paquetes Chocolatey
+- Linux (x64/ia32)
+ - Portable
+ - AUR
+ - AppImage
+ - Deb
+ - Flatpak
+- macOS (x64/ia32 en v12.0+)
+ - Apple App Store
+ - DMG
+ - Homebrew
+- iOS/iPadOS (v15.1+)
+ - Apple App Store
+ - IPA
+- Android (v7.0+)
+ - Google Play Store
+ - APK
+
+Visite la [documentación](https://docs.termix.site/install) de Termix para más información sobre cómo instalar Termix en todas las plataformas. De lo contrario, vea un archivo Docker Compose de ejemplo aquí:
+
+```yaml
+services:
+ termix:
+ image: ghcr.io/lukegus/termix:latest
+ container_name: termix
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ volumes:
+ - termix-data:/app/data
+ environment:
+ PORT: "8080"
+
+volumes:
+ termix-data:
+ driver: local
+```
+
+# Patrocinadores
+
+
+
+# Soporte
+
+Si necesita ayuda o desea solicitar una función para Termix, visite la página de [Issues](https://github.com/Termix-SSH/Support/issues), inicie sesión y pulse `New Issue`.
+Por favor, sea lo más detallado posible en su reporte, preferiblemente escrito en inglés. También puede unirse al servidor de [Discord](https://discord.gg/jVQGdvHDrf) y visitar el canal de soporte, sin embargo, los tiempos de respuesta pueden ser más largos.
+
+# Capturas de Pantalla
+
+[](https://youtu.be/sjKIqfCK0NY)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Algunos videos e imágenes pueden estar desactualizados o no mostrar perfectamente las características.
+
+# Licencia
+
+Distribuido bajo la Licencia Apache Versión 2.0. Consulte LICENSE para más información.
diff --git a/readme/README-FR.md b/readme/README-FR.md
new file mode 100644
index 00000000..afe7791f
--- /dev/null
+++ b/readme/README-FR.md
@@ -0,0 +1,186 @@
+# Statistiques du dépôt
+
+
+
+Termix est une plateforme de gestion de serveurs tout-en-un, open source, à jamais gratuite et auto-hébergée. Elle fournit une solution multiplateforme pour gérer vos serveurs et votre infrastructure à travers une interface unique et intuitive. Termix offre un accès terminal SSH, des capacités de tunneling SSH, la gestion de fichiers à distance, et de nombreux autres outils. Termix est l'alternative parfaite, gratuite et auto-hébergée à Termius, disponible sur toutes les plateformes.
+
+# Fonctionnalités
+
+- **Accès terminal SSH** - Terminal complet avec support d'écran partagé (jusqu'à 4 panneaux) et un système d'onglets inspiré des navigateurs. Inclut la personnalisation du terminal avec des thèmes courants, des polices et d'autres composants
+- **Gestion des tunnels SSH** - Créez et gérez des tunnels SSH avec reconnexion automatique et surveillance de l'état, avec support des connexions -l ou -r
+- **Gestionnaire de fichiers distant** - Gérez les fichiers directement sur les serveurs distants avec support de la visualisation et de l'édition de code, images, audio et vidéo. Téléversez, téléchargez, renommez, supprimez et déplacez des fichiers de manière fluide avec support sudo
+- **Gestion Docker** - Démarrez, arrêtez, mettez en pause, supprimez des conteneurs. Consultez les statistiques des conteneurs. Contrôlez les conteneurs via le terminal docker exec. Non conçu pour remplacer Portainer ou Dockge, mais plutôt pour gérer simplement vos conteneurs plutôt que de les créer
+- **Gestionnaire d'hôtes SSH** - Enregistrez, organisez et gérez vos connexions SSH avec des tags et des dossiers, et sauvegardez facilement les informations de connexion réutilisables tout en automatisant le déploiement des clés SSH
+- **Statistiques serveur** - Visualisez l'utilisation du CPU, de la mémoire et du disque ainsi que le réseau, le temps de fonctionnement, les informations système, le pare-feu et le moniteur de ports sur la plupart des serveurs Linux
+- **Tableau de bord** - Consultez les informations de vos serveurs en un coup d'œil depuis votre tableau de bord
+- **RBAC** - Créez des rôles et partagez des hôtes entre utilisateurs/rôles
+- **Authentification des utilisateurs** - Gestion sécurisée des utilisateurs avec contrôles administrateur et support OIDC et 2FA (TOTP). Visualisez les sessions utilisateur actives sur toutes les plateformes et révoquez les permissions. Liez vos comptes OIDC/locaux ensemble
+- **Chiffrement de la base de données** - Le backend est stocké sous forme de fichiers de base de données SQLite chiffrés. Consultez la [documentation](https://docs.termix.site/security) pour plus de détails
+- **Export/Import de données** - Exportez et importez les hôtes SSH, les identifiants et les données du gestionnaire de fichiers
+- **Configuration SSL automatique** - Génération et gestion intégrées de certificats SSL avec redirections HTTPS
+- **Interface moderne** - Interface épurée compatible desktop/mobile construite avec React, Tailwind CSS et Shadcn. Choisissez entre un thème sombre ou clair. Utilisez les routes URL pour ouvrir n'importe quelle connexion en plein écran
+- **Langues** - Support intégré d'environ 30 langues (géré par [Crowdin](https://docs.termix.site/translations))
+- **Support multiplateforme** - Disponible en tant qu'application web, application de bureau (Windows, Linux et macOS), PWA, et application mobile/tablette dédiée pour iOS et Android
+- **Outils SSH** - Créez des extraits de commandes réutilisables exécutables en un seul clic. Exécutez une commande simultanément sur plusieurs terminaux ouverts
+- **Historique des commandes** - Auto-complétion et consultation des commandes SSH précédemment exécutées
+- **Connexion rapide** - Connectez-vous à un serveur sans avoir à sauvegarder les données de connexion
+- **Palette de commandes** - Appuyez deux fois sur Shift gauche pour accéder rapidement aux connexions SSH avec votre clavier
+- **SSH riche en fonctionnalités** - Support des hôtes de rebond, Warpgate, connexions basées sur TOTP, SOCKS5, vérification des clés d'hôte, remplissage automatique des mots de passe, [OPKSSH](https://github.com/openpubkey/opkssh), etc.
+- **Graphe réseau** - Personnalisez votre tableau de bord pour visualiser votre homelab basé sur vos connexions SSH avec support des statuts
+- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+
+# Fonctionnalités prévues
+
+Consultez les [Projects](https://github.com/orgs/Termix-SSH/projects/2) pour toutes les fonctionnalités prévues. Si vous souhaitez contribuer, consultez [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
+
+# Installation
+
+Appareils supportés :
+
+- Site web (tout navigateur moderne sur toute plateforme comme Chrome, Safari et Firefox) (support PWA inclus)
+- Windows (x64/ia32)
+ - Portable
+ - Installateur MSI
+ - Gestionnaire de paquets Chocolatey
+- Linux (x64/ia32)
+ - Portable
+ - AUR
+ - AppImage
+ - Deb
+ - Flatpak
+- macOS (x64/ia32 sur v12.0+)
+ - Apple App Store
+ - DMG
+ - Homebrew
+- iOS/iPadOS (v15.1+)
+ - Apple App Store
+ - IPA
+- Android (v7.0+)
+ - Google Play Store
+ - APK
+
+Visitez la [documentation](https://docs.termix.site/install) de Termix pour plus d'informations sur l'installation de Termix sur toutes les plateformes. Sinon, voici un exemple de fichier Docker Compose :
+
+```yaml
+services:
+ termix:
+ image: ghcr.io/lukegus/termix:latest
+ container_name: termix
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ volumes:
+ - termix-data:/app/data
+ environment:
+ PORT: "8080"
+
+volumes:
+ termix-data:
+ driver: local
+```
+
+# Sponsors
+
+
+
+# Support
+
+Si vous avez besoin d'aide ou souhaitez demander une fonctionnalité pour Termix, visitez la page [Issues](https://github.com/Termix-SSH/Support/issues), connectez-vous et appuyez sur `New Issue`. Veuillez être aussi détaillé que possible dans votre issue, de préférence rédigée en anglais. Vous pouvez également rejoindre le serveur [Discord](https://discord.gg/jVQGdvHDrf) et visiter le canal de support, cependant les temps de réponse peuvent être plus longs.
+
+# Captures d'écran
+
+[](https://youtu.be/sjKIqfCK0NY)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Certaines vidéos et images peuvent être obsolètes ou ne pas présenter parfaitement les fonctionnalités.
+
+# Licence
+
+Distribué sous la licence Apache Version 2.0. Consultez LICENSE pour plus d'informations.
diff --git a/readme/README-HI.md b/readme/README-HI.md
new file mode 100644
index 00000000..140d0c6e
--- /dev/null
+++ b/readme/README-HI.md
@@ -0,0 +1,187 @@
+# रिपॉजिटरी आँकड़े
+
+
+
+यदि आप चाहें, तो आप यहाँ प्रोजेक्ट को सपोर्ट कर सकते हैं!\
+[](https://github.com/sponsors/LukeGus)
+
+# अवलोकन
+
+
+
+Termix एक ओपन-सोर्स, हमेशा के लिए मुफ़्त, सेल्फ-होस्टेड ऑल-इन-वन सर्वर प्रबंधन प्लेटफ़ॉर्म है। यह एक एकल, सहज इंटरफ़ेस के माध्यम से आपके सर्वर और बुनियादी ढाँचे के प्रबंधन के लिए एक मल्टी-प्लेटफ़ॉर्म समाधान प्रदान करता है। Termix SSH टर्मिनल एक्सेस, SSH टनलिंग क्षमताएँ, रिमोट फ़ाइल प्रबंधन, और कई अन्य उपकरण प्रदान करता है। Termix सभी प्लेटफ़ॉर्म पर उपलब्ध Termius का सही मुफ़्त और सेल्फ-होस्टेड विकल्प है।
+
+# विशेषताएँ
+
+- **SSH टर्मिनल एक्सेस** - ब्राउज़र जैसी टैब प्रणाली के साथ स्प्लिट-स्क्रीन सपोर्ट (4 पैनल तक) वाला पूर्ण-विशेषता वाला टर्मिनल। इसमें लोकप्रिय टर्मिनल थीम, फ़ॉन्ट और अन्य कंपोनेंट सहित टर्मिनल को कस्टमाइज़ करने का सपोर्ट शामिल है
+- **SSH टनल प्रबंधन** - ऑटोमैटिक रीकनेक्शन और हेल्थ मॉनिटरिंग के साथ SSH टनल बनाएँ और प्रबंधित करें, -l या -r कनेक्शन के सपोर्ट के साथ
+- **रिमोट फ़ाइल मैनेजर** - कोड, इमेज, ऑडियो और वीडियो देखने और संपादित करने के सपोर्ट के साथ रिमोट सर्वर पर सीधे फ़ाइलें प्रबंधित करें। sudo सपोर्ट के साथ फ़ाइलें अपलोड, डाउनलोड, रीनेम, डिलीट और मूव करें
+- **Docker प्रबंधन** - कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है
+- **SSH होस्ट मैनेजर** - टैग और फ़ोल्डर के साथ अपने SSH कनेक्शन सहेजें, व्यवस्थित करें और प्रबंधित करें, और SSH कुंजियों की तैनाती को स्वचालित करने की क्षमता के साथ पुन: उपयोग योग्य लॉगिन जानकारी आसानी से सहेजें
+- **सर्वर आँकड़े** - अधिकांश Linux आधारित सर्वर पर नेटवर्क, अपटाइम, सिस्टम जानकारी, फ़ायरवॉल, पोर्ट मॉनिटर के साथ CPU, मेमोरी और डिस्क उपयोग देखें
+- **डैशबोर्ड** - अपने डैशबोर्ड पर एक नज़र में सर्वर की जानकारी देखें
+- **RBAC** - भूमिकाएँ बनाएँ और उपयोगकर्ताओं/भूमिकाओं में होस्ट साझा करें
+- **उपयोगकर्ता प्रमाणीकरण** - व्यवस्थापक नियंत्रण और OIDC और 2FA (TOTP) सपोर्ट के साथ सुरक्षित उपयोगकर्ता प्रबंधन। सभी प्लेटफ़ॉर्म पर सक्रिय उपयोगकर्ता सत्र देखें और अनुमतियाँ रद्द करें। अपने OIDC/स्थानीय खातों को एक साथ जोड़ें
+- **डेटाबेस एन्क्रिप्शन** - बैकएंड एन्क्रिप्टेड SQLite डेटाबेस फ़ाइलों के रूप में संग्रहीत। अधिक जानकारी के लिए [डॉक्स](https://docs.termix.site/security) देखें
+- **डेटा एक्सपोर्ट/इम्पोर्ट** - SSH होस्ट, क्रेडेंशियल और फ़ाइल मैनेजर डेटा एक्सपोर्ट और इम्पोर्ट करें
+- **स्वचालित SSL सेटअप** - HTTPS रीडायरेक्ट के साथ बिल्ट-इन SSL सर्टिफ़िकेट जनरेशन और प्रबंधन
+- **आधुनिक UI** - React, Tailwind CSS, और Shadcn से बना साफ़ डेस्कटॉप/मोबाइल-फ़्रेंडली इंटरफ़ेस। डार्क या लाइट मोड UI के बीच चुनें। किसी भी कनेक्शन को फ़ुल-स्क्रीन में खोलने के लिए URL रूट का उपयोग करें
+- **भाषाएँ** - लगभग 30 भाषाओं का बिल्ट-इन सपोर्ट ([Crowdin](https://docs.termix.site/translations) द्वारा प्रबंधित)
+- **प्लेटफ़ॉर्म सपोर्ट** - वेब ऐप, डेस्कटॉप एप्लिकेशन (Windows, Linux, और macOS), PWA, और iOS और Android के लिए समर्पित मोबाइल/टैबलेट ऐप के रूप में उपलब्ध
+- **SSH टूल्स** - एक क्लिक से निष्पादित होने वाले पुन: उपयोग योग्य कमांड स्निपेट बनाएँ। एक साथ कई खुले टर्मिनलों में एक कमांड चलाएँ
+- **कमांड इतिहास** - पहले चलाए गए SSH कमांड का ऑटो-कम्प्लीट और दृश्य
+- **क्विक कनेक्ट** - कनेक्शन डेटा सहेजे बिना सर्वर से कनेक्ट करें
+- **कमांड पैलेट** - अपने कीबोर्ड से SSH कनेक्शन तक त्वरित पहुँच के लिए बाएँ Shift को दो बार टैप करें
+- **SSH सुविधाओं से भरपूर** - जम्प होस्ट, Warpgate, TOTP आधारित कनेक्शन, SOCKS5, होस्ट की वेरिफ़िकेशन, पासवर्ड ऑटोफ़िल, [OPKSSH](https://github.com/openpubkey/opkssh) आदि का सपोर्ट
+- **नेटवर्क ग्राफ़** - स्थिति सपोर्ट के साथ अपने SSH कनेक्शन के आधार पर अपने होमलैब को विज़ुअलाइज़ करने के लिए अपना डैशबोर्ड कस्टमाइज़ करें
+- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+
+# नियोजित विशेषताएँ
+
+सभी नियोजित विशेषताओं के लिए [प्रोजेक्ट्स](https://github.com/orgs/Termix-SSH/projects/2) देखें। यदि आप योगदान देना चाहते हैं, तो [योगदान](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) देखें।
+
+# इंस्टॉलेशन
+
+समर्थित डिवाइस:
+
+- वेबसाइट (किसी भी प्लेटफ़ॉर्म पर कोई भी आधुनिक ब्राउज़र जैसे Chrome, Safari, और Firefox) (PWA सपोर्ट सहित)
+- Windows (x64/ia32)
+ - पोर्टेबल
+ - MSI इंस्टॉलर
+ - Chocolatey पैकेज मैनेजर
+- Linux (x64/ia32)
+ - पोर्टेबल
+ - AUR
+ - AppImage
+ - Deb
+ - Flatpak
+- macOS (v12.0+ पर x64/ia32)
+ - Apple App Store
+ - DMG
+ - Homebrew
+- iOS/iPadOS (v15.1+)
+ - Apple App Store
+ - IPA
+- Android (v7.0+)
+ - Google Play Store
+ - APK
+
+सभी प्लेटफ़ॉर्म पर Termix इंस्टॉल करने के बारे में अधिक जानकारी के लिए Termix [डॉक्स](https://docs.termix.site/install) पर जाएँ। अन्यथा, यहाँ एक नमूना Docker Compose फ़ाइल देखें:
+
+```yaml
+services:
+ termix:
+ image: ghcr.io/lukegus/termix:latest
+ container_name: termix
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ volumes:
+ - termix-data:/app/data
+ environment:
+ PORT: "8080"
+
+volumes:
+ termix-data:
+ driver: local
+```
+
+# प्रायोजक
+
+
+
+# सहायता
+
+यदि आपको सहायता चाहिए या Termix के लिए किसी विशेषता का अनुरोध करना चाहते हैं, तो [इश्यूज़](https://github.com/Termix-SSH/Support/issues) पेज पर जाएँ, लॉग इन करें, और `New Issue` दबाएँ।
+कृपया अपने इश्यू में यथासंभव विस्तृत विवरण दें, अधिमानतः अंग्रेज़ी में लिखें। आप [Discord](https://discord.gg/jVQGdvHDrf) सर्वर में भी शामिल हो सकते हैं और सहायता चैनल पर जा सकते हैं, हालाँकि, प्रतिक्रिया समय अधिक हो सकता है।
+
+# स्क्रीनशॉट
+
+[](https://youtu.be/sjKIqfCK0NY)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+कुछ वीडियो और छवियाँ पुरानी हो सकती हैं या विशेषताओं को पूरी तरह से प्रदर्शित नहीं कर सकती हैं।
+
+# लाइसेंस
+
+Apache License Version 2.0 के तहत वितरित। अधिक जानकारी के लिए LICENSE देखें।
diff --git a/readme/README-IT.md b/readme/README-IT.md
new file mode 100644
index 00000000..ed950774
--- /dev/null
+++ b/readme/README-IT.md
@@ -0,0 +1,187 @@
+# Statistiche Repo
+
+
+
+Termix è una piattaforma di gestione server tutto-in-uno, open-source, per sempre gratuita e self-hosted. Fornisce una soluzione multipiattaforma per gestire i tuoi server e la tua infrastruttura attraverso un'unica interfaccia intuitiva. Termix offre accesso al terminale SSH, funzionalità di tunneling SSH, gestione remota dei file e molti altri strumenti. Termix è la perfetta alternativa gratuita e self-hosted a Termius, disponibile per tutte le piattaforme.
+
+# Funzionalità
+
+- **Accesso Terminale SSH** - Terminale completo con supporto schermo divIPA (fino a 4 pannelli) con un sistema di schede in stile browser. Include il supporto per la personalizzazione del terminale, inclusi temi, font e altri componenti comuni
+- **Gestione Tunnel SSH** - Crea e gestisci tunnel SSH con riconnessione automatica e monitoraggio dello stato, con supporto per connessioni -l o -r
+- **Gestore File Remoto** - Gestisci i file direttamente sui server remoti con supporto per la visualizzazione e la modifica di codice, immagini, audio e video. Carica, scarica, rinomina, elimina e sposta file senza problemi con supporto sudo.
+- **Gestione Docker** - Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei container. Controlla i container tramite terminale docker exec. Non è stato creato per sostituire Portainer o Dockge, ma piuttosto per gestire semplicemente i tuoi container rispetto alla loro creazione.
+- **Gestore Host SSH** - Salva, organizza e gestisci le tue connessioni SSH con tag e cartelle, salva facilmente le informazioni di accesso riutilizzabili e automatizza il deployment delle chiavi SSH
+- **Statistiche Server** - Visualizza l'utilizzo di CPU, memoria e disco insieme a rete, uptime, informazioni di sistema, firewall, monitoraggio porte sulla maggior parte dei server basati su Linux
+- **Dashboard** - Visualizza le informazioni del server a colpo d'occhio sulla tua dashboard
+- **RBAC** - Crea ruoli e condividi host tra utenti/ruoli
+- **Autenticazione Utente** - Gestione utenti sicura con controlli amministrativi e supporto OIDC e 2FA (TOTP). Visualizza le sessioni utente attive su tutte le piattaforme e revoca i permessi. Collega i tuoi account OIDC/Locali tra loro.
+- **Crittografia Database** - Il backend è archiviato come file di database SQLite crittografati. Consulta la [documentazione](https://docs.termix.site/security) per maggiori informazioni.
+- **Esportazione/Importazione Dati** - Esporta e importa host SSH, credenziali e dati del gestore file
+- **Configurazione SSL Automatica** - Generazione e gestione integrata dei certificati SSL con reindirizzamenti HTTPS
+- **Interfaccia Moderna** - Interfaccia pulita e responsive per desktop/mobile costruita con React, Tailwind CSS e Shadcn. Scegli tra modalità scura o chiara. Usa i percorsi URL per aprire qualsiasi connessione a schermo intero.
+- **Lingue** - Supporto integrato per ~30 lingue (gestito da [Crowdin](https://docs.termix.site/translations))
+- **Supporto Piattaforme** - Disponibile come app web, applicazione desktop (Windows, Linux e macOS), PWA e app dedicata per mobile/tablet su iOS e Android.
+- **Strumenti SSH** - Crea snippet di comandi riutilizzabili che si eseguono con un singolo clic. Esegui un comando simultaneamente su più terminali aperti.
+- **Cronologia Comandi** - Autocompletamento e visualizzazione dei comandi SSH eseguiti in precedenza
+- **Connessione Rapida** - Connettiti a un server senza dover salvare i dati di connessione
+- **Palette Comandi** - Premi due volte shift sinistro per accedere rapidamente alle connessioni SSH con la tastiera
+- **SSH Ricco di Funzionalità** - Supporta jump host, Warpgate, connessioni basate su TOTP, SOCKS5, verifica chiave host, compilazione automatica password, [OPKSSH](https://github.com/openpubkey/opkssh), ecc.
+- **Grafico di Rete** - Personalizza la tua Dashboard per visualizzare il tuo homelab basato sulle connessioni SSH con supporto dello stato
+- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+
+# Funzionalità Pianificate
+
+Consulta [Progetti](https://github.com/orgs/Termix-SSH/projects/2) per tutte le funzionalità pianificate. Se desideri contribuire, consulta [Contribuire](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
+
+# Installazione
+
+Dispositivi Supportati:
+
+- Sito web (qualsiasi browser moderno su qualsiasi piattaforma come Chrome, Safari e Firefox) (include supporto PWA)
+- Windows (x64/ia32)
+ - Portable
+ - MSI Installer
+ - Chocolatey Package Manager
+- Linux (x64/ia32)
+ - Portable
+ - AUR
+ - AppImage
+ - Deb
+ - Flatpak
+- macOS (x64/ia32 su v12.0+)
+ - Apple App Store
+ - DMG
+ - Homebrew
+- iOS/iPadOS (v15.1+)
+ - Apple App Store
+ - IPA
+- Android (v7.0+)
+ - Google Play Store
+ - APK
+
+Visita la [Documentazione](https://docs.termix.site/install) di Termix per maggiori informazioni su come installare Termix su tutte le piattaforme. In alternativa, visualizza un file Docker Compose di esempio qui:
+
+```yaml
+services:
+ termix:
+ image: ghcr.io/lukegus/termix:latest
+ container_name: termix
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ volumes:
+ - termix-data:/app/data
+ environment:
+ PORT: "8080"
+
+volumes:
+ termix-data:
+ driver: local
+```
+
+# Sponsor
+
+
+
+# Supporto
+
+Se hai bIPAgno di aiuto o vuoi richiedere una funzionalità per Termix, visita la pagina [Segnalazioni](https://github.com/Termix-SSH/Support/issues), accedi e premi `New Issue`.
+Per favore, sii il più dettagliato possibile nella tua segnalazione, preferibilmente scritta in inglese. Puoi anche unirti al server [Discord](https://discord.gg/jVQGdvHDrf) e visitare il canale di supporto, tuttavia i tempi di risposta potrebbero essere più lunghi.
+
+# Screenshot
+
+[](https://youtu.be/sjKIqfCK0NY)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Alcuni video e immagini potrebbero non essere aggiornati o potrebbero non mostrare perfettamente le funzionalità.
+
+# Licenza
+
+Distribuito sotto la Licenza Apache Versione 2.0. Consulta LICENSE per maggiori informazioni.
diff --git a/readme/README-JA.md b/readme/README-JA.md
new file mode 100644
index 00000000..d615845f
--- /dev/null
+++ b/readme/README-JA.md
@@ -0,0 +1,186 @@
+# リポジトリ統計
+
+
+
+Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버 관리 플랫폼입니다. 단일 직관적인 인터페이스를 통해 서버와 인프라를 관리할 수 있는 멀티 플랫폼 솔루션을 제공합니다. Termix는 SSH 터미널 접속, SSH 터널링 기능, 원격 파일 관리 및 기타 다양한 도구를 제공합니다. Termix는 모든 플랫폼에서 사용 가능한 Termius의 완벽한 무료 셀프 호스팅 대안입니다.
+
+# 기능
+
+- **SSH 터미널 접속** - 브라우저 스타일 탭 시스템과 분할 화면 지원(최대 4개 패널)을 갖춘 완전한 기능의 터미널. 일반 터미널 테마, 글꼴 및 기타 구성 요소를 포함한 터미널 사용자 정의 지원
+- **SSH 터널 관리** - 자동 재연결 및 상태 모니터링 기능을 갖춘 SSH 터널 생성 및 관리, -l 또는 -r 연결 지원
+- **원격 파일 관리자** - 코드, 이미지, 오디오, 비디오의 보기 및 편집을 지원하여 원격 서버에서 파일을 직접 관리. sudo 지원으로 파일 업로드, 다운로드, 이름 변경, 삭제, 이동을 원활하게 수행
+- **Docker 관리** - 컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다
+- **SSH 호스트 관리자** - 태그와 폴더로 SSH 연결을 저장, 정리, 관리하고, 재사용 가능한 로그인 정보를 쉽게 저장하면서 SSH 키 배포를 자동화
+- **서버 통계** - 대부분의 Linux 기반 서버에서 CPU, 메모리, 디스크 사용량과 함께 네트워크, 업타임, 시스템 정보, 방화벽, 포트 모니터를 표시
+- **대시보드** - 대시보드에서 서버 정보를 한눈에 확인
+- **RBAC** - 역할을 생성하고 사용자/역할 간에 호스트 공유
+- **사용자 인증** - 관리자 제어와 OIDC 및 2FA(TOTP) 지원을 통한 안전한 사용자 관리. 모든 플랫폼에서 활성 사용자 세션을 보고 권한을 취소 가능. OIDC/로컬 계정 연동
+- **데이터베이스 암호화** - 백엔드가 암호화된 SQLite 데이터베이스 파일로 저장됨. 자세한 내용은 [문서](https://docs.termix.site/security)를 참조하세요
+- **데이터 내보내기/가져오기** - SSH 호스트, 자격 증명, 파일 관리자 데이터의 내보내기 및 가져오기
+- **자동 SSL 설정** - HTTPS 리디렉션을 포함한 내장 SSL 인증서 생성 및 관리
+- **모던 UI** - React, Tailwind CSS, Shadcn으로 구축된 깔끔한 데스크톱/모바일 친화적 인터페이스. 다크 또는 라이트 모드 기반 UI 선택. URL 라우트를 사용하여 모든 연결을 전체 화면으로 열기 가능
+- **다국어 지원** - 약 30개 언어 내장 지원([Crowdin](https://docs.termix.site/translations)으로 관리)
+- **플랫폼 지원** - 웹 앱, 데스크톱 애플리케이션(Windows, Linux, macOS), PWA, iOS 및 Android 전용 모바일/태블릿 앱으로 제공
+- **SSH 도구** - 한 번의 클릭으로 실행 가능한 재사용 가능 명령어 스니펫 생성. 여러 열린 터미널에서 동시에 하나의 명령어 실행
+- **명령어 기록** - 이전에 실행한 SSH 명령어의 자동 완성 및 조회
+- **빠른 연결** - 연결 데이터를 저장하지 않고 서버에 접속
+- **명령어 팔레트** - 왼쪽 Shift 키를 두 번 눌러 키보드로 SSH 연결에 빠르게 접근
+- **풍부한 SSH 기능** - 점프 호스트, Warpgate, TOTP 기반 연결, SOCKS5, 호스트 키 검증, 비밀번호 자동 입력, [OPKSSH](https://github.com/openpubkey/opkssh) 등 지원
+- **네트워크 그래프** - 대시보드를 사용자 정의하여 SSH 연결 기반의 홈랩 네트워크를 상태 표시와 함께 시각화
+- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+
+# 계획된 기능
+
+모든 계획된 기능은 [Projects](https://github.com/orgs/Termix-SSH/projects/2)를 참조하세요. 기여를 원하시면 [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)을 참조하세요.
+
+# 설치
+
+지원 기기:
+
+- 웹사이트 (Chrome, Safari, Firefox 등 모든 플랫폼의 최신 브라우저) (PWA 지원 포함)
+- Windows (x64/ia32)
+ - 포터블
+ - MSI 설치 프로그램
+ - Chocolatey 패키지 관리자
+- Linux (x64/ia32)
+ - 포터블
+ - AUR
+ - AppImage
+ - Deb
+ - Flatpak
+- macOS (x64/ia32, v12.0 이상)
+ - Apple App Store
+ - DMG
+ - Homebrew
+- iOS/iPadOS (v15.1 이상)
+ - Apple App Store
+ - IPA
+- Android (v7.0 이상)
+ - Google Play Store
+ - APK
+
+모든 플랫폼에 Termix를 설치하는 방법에 대한 자세한 내용은 Termix [문서](https://docs.termix.site/install)를 방문하세요. 다음은 Docker Compose 파일 예시입니다:
+
+```yaml
+services:
+ termix:
+ image: ghcr.io/lukegus/termix:latest
+ container_name: termix
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ volumes:
+ - termix-data:/app/data
+ environment:
+ PORT: "8080"
+
+volumes:
+ termix-data:
+ driver: local
+```
+
+# 스폰서
+
+
+
+# 지원
+
+Termix에 대한 도움이 필요하거나 기능을 요청하려면 [Issues](https://github.com/Termix-SSH/Support/issues) 페이지를 방문하여 로그인하고 `New Issue`를 누르세요. 이슈는 가능한 한 상세하게 작성하고, 영어로 작성하는 것이 좋습니다. [Discord](https://discord.gg/jVQGdvHDrf) 서버에 참여하여 지원 채널을 이용할 수도 있지만, 응답 시간이 더 길 수 있습니다.
+
+# 스크린샷
+
+[](https://youtu.be/sjKIqfCK0NY)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+일부 비디오 및 이미지는 최신이 아니거나 기능을 완벽하게 보여주지 않을 수 있습니다.
+
+# 라이선스
+
+Apache License Version 2.0에 따라 배포됩니다. 자세한 내용은 LICENSE를 참조하세요.
diff --git a/readme/README-PT.md b/readme/README-PT.md
new file mode 100644
index 00000000..cf5d735d
--- /dev/null
+++ b/readme/README-PT.md
@@ -0,0 +1,187 @@
+# Estatísticas do Repositório
+
+
+
+Termix é uma plataforma de gerenciamento de servidores tudo-em-um, de código aberto, sempre gratuita e auto-hospedada. Ela fornece uma solução multiplataforma para gerenciar seus servidores e infraestrutura através de uma interface única e intuitiva. Termix oferece acesso a terminal SSH, capacidades de tunelamento SSH, gerenciamento remoto de arquivos e muitas outras ferramentas. Termix é a alternativa perfeita, gratuita e auto-hospedada ao Termius, disponível para todas as plataformas.
+
+# Funcionalidades
+
+- **Acesso ao Terminal SSH** - Terminal completo com suporte a tela dividida (até 4 painéis) com um sistema de abas similar ao navegador. Inclui suporte para personalização do terminal incluindo temas comuns de terminal, fontes e outros componentes
+- **Gerenciamento de Túneis SSH** - Crie e gerencie túneis SSH com reconexão automática e monitoramento de saúde, com suporte para conexões -l ou -r
+- **Gerenciador Remoto de Arquivos** - Gerencie arquivos diretamente em servidores remotos com suporte para visualizar e editar código, imagens, áudio e vídeo. Faça upload, download, renomeie, exclua e mova arquivos facilmente com suporte sudo.
+- **Gerenciamento de Docker** - Inicie, pare, pause, remova contêineres. Visualize estatísticas de contêineres. Controle contêineres usando o terminal Docker Exec. Não foi feito para substituir Portainer ou Dockge, mas sim para simplesmente gerenciar seus contêineres em vez de criá-los.
+- **Gerenciador de Hosts SSH** - Salve, organize e gerencie suas conexões SSH com tags e pastas, e salve facilmente informações de login reutilizáveis com a capacidade de automatizar a implantação de chaves SSH
+- **Estatísticas do Servidor** - Visualize o uso de CPU, memória e disco junto com rede, tempo de atividade, informações do sistema, firewall, monitor de portas na maioria dos servidores baseados em Linux
+- **Dashboard** - Visualize informações do servidor de relance no seu dashboard
+- **RBAC** - Crie funções e compartilhe hosts entre usuários/funções
+- **Autenticação de Usuários** - Gerenciamento seguro de usuários com controles de administrador e suporte para OIDC e 2FA (TOTP). Visualize sessões ativas de usuários em todas as plataformas e revogue permissões. Vincule suas contas OIDC/Locais entre si.
+- **Criptografia de Banco de Dados** - Backend armazenado como arquivos de banco de dados SQLite criptografados. Consulte a [documentação](https://docs.termix.site/security) para mais informações.
+- **Exportação/Importação de Dados** - Exporte e importe hosts SSH, credenciais e dados do gerenciador de arquivos
+- **Configuração Automática de SSL** - Geração e gerenciamento integrado de certificados SSL com redirecionamentos HTTPS
+- **Interface Moderna** - Interface limpa compatível com desktop/mobile construída com React, Tailwind CSS e Shadcn. Escolha entre modo escuro ou claro. Use rotas de URL para abrir qualquer conexão em tela cheia.
+- **Idiomas** - Suporte integrado para ~30 idiomas (gerenciado pelo [Crowdin](https://docs.termix.site/translations))
+- **Suporte a Plataformas** - Disponível como aplicação web, aplicação desktop (Windows, Linux e macOS), PWA e aplicativo dedicado para celular/tablet para iOS e Android.
+- **Ferramentas SSH** - Crie trechos de comandos reutilizáveis que são executados com um único clique. Execute um comando simultaneamente em múltiplos terminais abertos.
+- **Histórico de Comandos** - Autocompletar e visualizar comandos SSH executados anteriormente
+- **Conexão Rápida** - Conecte-se a um servidor sem precisar salvar os dados de conexão
+- **Paleta de Comandos** - Pressione duas vezes a tecla Shift esquerda para acessar rapidamente as conexões SSH com seu teclado
+- **SSH Rico em Funcionalidades** - Suporta jump hosts, Warpgate, conexões baseadas em TOTP, SOCKS5, verificação de chave do host, preenchimento automático de senhas, [OPKSSH](https://github.com/openpubkey/opkssh), etc.
+- **Gráfico de Rede** - Personalize seu Dashboard para visualizar seu homelab baseado nas suas conexões SSH com suporte de status
+- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+
+# Funcionalidades Planejadas
+
+Consulte [Projetos](https://github.com/orgs/Termix-SSH/projects/2) para todas as funcionalidades planejadas. Se você deseja contribuir, consulte [Contribuir](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
+
+# Instalação
+
+Dispositivos suportados:
+
+- Website (qualquer navegador moderno em qualquer plataforma como Chrome, Safari e Firefox) (inclui suporte PWA)
+- Windows (x64/ia32)
+ - Portátil
+ - Instalador MSI
+ - Gerenciador de pacotes Chocolatey
+- Linux (x64/ia32)
+ - Portátil
+ - AUR
+ - AppImage
+ - Deb
+ - Flatpak
+- macOS (x64/ia32 em v12.0+)
+ - Apple App Store
+ - DMG
+ - Homebrew
+- iOS/iPadOS (v15.1+)
+ - Apple App Store
+ - IPA
+- Android (v7.0+)
+ - Google Play Store
+ - APK
+
+Visite a [documentação](https://docs.termix.site/install) do Termix para mais informações sobre como instalar o Termix em todas as plataformas. Caso contrário, veja um arquivo Docker Compose de exemplo aqui:
+
+```yaml
+services:
+ termix:
+ image: ghcr.io/lukegus/termix:latest
+ container_name: termix
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ volumes:
+ - termix-data:/app/data
+ environment:
+ PORT: "8080"
+
+volumes:
+ termix-data:
+ driver: local
+```
+
+# Patrocinadores
+
+
+
+# Suporte
+
+Se você precisa de ajuda ou deseja solicitar uma funcionalidade para o Termix, visite a página de [Issues](https://github.com/Termix-SSH/Support/issues), faça login e clique em `New Issue`.
+Por favor, seja o mais detalhado possível no seu relato, preferencialmente escrito em inglês. Você também pode entrar no servidor do [Discord](https://discord.gg/jVQGdvHDrf) e visitar o canal de suporte, porém, os tempos de resposta podem ser mais longos.
+
+# Capturas de Tela
+
+[](https://youtu.be/sjKIqfCK0NY)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Alguns vídeos e imagens podem estar desatualizados ou podem não mostrar perfeitamente as funcionalidades.
+
+# Licença
+
+Distribuído sob a Licença Apache Versão 2.0. Consulte LICENSE para mais informações.
diff --git a/readme/README-RU.md b/readme/README-RU.md
new file mode 100644
index 00000000..86af78f9
--- /dev/null
+++ b/readme/README-RU.md
@@ -0,0 +1,187 @@
+# Статистика репозитория
+
+
+
+Termix — это платформа для управления серверами с открытым исходным кодом, навсегда бесплатная и размещаемая на собственном сервере. Она предоставляет мультиплатформенное решение для управления вашими серверами и инфраструктурой через единый интуитивно понятный интерфейс. Termix предлагает доступ к SSH-терминалу, возможности SSH-туннелирования, удалённое управление файлами и множество других инструментов. Termix — это идеальная бесплатная альтернатива Termius с возможностью размещения на собственном сервере, доступная для всех платформ.
+
+# Возможности
+
+- **Доступ к SSH-терминалу** — Полнофункциональный терминал с поддержкой разделения экрана (до 4 панелей) и системой вкладок, как в браузере. Включает поддержку настройки терминала, включая популярные темы, шрифты и другие компоненты
+- **Управление SSH-туннелями** — Создание и управление SSH-туннелями с автоматическим переподключением и мониторингом состояния, с поддержкой соединений -l и -r
+- **Удалённый файловый менеджер** — Управление файлами непосредственно на удалённых серверах с поддержкой просмотра и редактирования кода, изображений, аудио и видео. Загрузка, скачивание, переименование, удаление и перемещение файлов с поддержкой sudo
+- **Управление Docker** — Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием
+- **Менеджер SSH-хостов** — Сохранение, организация и управление SSH-подключениями с помощью тегов и папок, с возможностью сохранения данных для повторного входа и автоматизации развёртывания SSH-ключей
+- **Статистика сервера** — Просмотр использования CPU, памяти и диска, а также сети, времени работы, информации о системе, файрвола и монитора портов на большинстве серверов на базе Linux
+- **Панель управления** — Просмотр информации о сервере на панели управления одним взглядом
+- **RBAC** — Создание ролей и предоставление общего доступа к хостам для пользователей/ролей
+- **Аутентификация пользователей** — Безопасное управление пользователями с административным контролем и поддержкой OIDC и 2FA (TOTP). Просмотр активных сессий пользователей на всех платформах и отзыв прав доступа. Связывание аккаунтов OIDC/локальных аккаунтов
+- **Шифрование базы данных** — Бэкенд хранится в виде зашифрованных файлов базы данных SQLite. Подробнее в [документации](https://docs.termix.site/security)
+- **Экспорт/импорт данных** — Экспорт и импорт SSH-хостов, учётных данных и данных файлового менеджера
+- **Автоматическая настройка SSL** — Встроенная генерация и управление SSL-сертификатами с перенаправлением на HTTPS
+- **Современный интерфейс** — Чистый интерфейс для десктопа и мобильных устройств, построенный на React, Tailwind CSS и Shadcn. Выбор между тёмной и светлой темой. Использование URL-маршрутов для открытия любого подключения в полноэкранном режиме
+- **Языки** — Встроенная поддержка ~30 языков (управляется через [Crowdin](https://docs.termix.site/translations))
+- **Поддержка платформ** — Доступен как веб-приложение, настольное приложение (Windows, Linux и macOS), PWA и специализированное мобильное/планшетное приложение для iOS и Android
+- **Инструменты SSH** — Создание переиспользуемых фрагментов команд, выполняемых одним нажатием. Запуск одной команды одновременно в нескольких открытых терминалах
+- **История команд** — Автодополнение и просмотр ранее выполненных SSH-команд
+- **Быстрое подключение** — Подключение к серверу без необходимости сохранения данных подключения
+- **Командная палитра** — Двойное нажатие левого Shift для быстрого доступа к SSH-подключениям с клавиатуры
+- **Богатый функционал SSH** — Поддержка jump-хостов, Warpgate, подключений на основе TOTP, SOCKS5, верификации ключей хоста, автозаполнения паролей, [OPKSSH](https://github.com/openpubkey/opkssh) и др.
+- **Сетевой граф** — Настройте панель управления для визуализации вашей домашней лаборатории на основе SSH-подключений с поддержкой статусов
+- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+
+# Запланированные функции
+
+Смотрите [Проекты](https://github.com/orgs/Termix-SSH/projects/2) для просмотра всех запланированных функций. Если вы хотите внести вклад, смотрите [Участие в разработке](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
+
+# Установка
+
+Поддерживаемые устройства:
+
+- Веб-сайт (любой современный браузер на любой платформе, включая Chrome, Safari и Firefox) (включая поддержку PWA)
+- Windows (x64/ia32)
+ - Портативная версия
+ - Установщик MSI
+ - Менеджер пакетов Chocolatey
+- Linux (x64/ia32)
+ - Портативная версия
+ - AUR
+ - AppImage
+ - Deb
+ - Flatpak
+- macOS (x64/ia32, версия 12.0+)
+ - Apple App Store
+ - DMG
+ - Homebrew
+- iOS/iPadOS (версия 15.1+)
+ - Apple App Store
+ - IPA
+- Android (версия 7.0+)
+ - Google Play Store
+ - APK
+
+Посетите [документацию](https://docs.termix.site/install) Termix для получения дополнительной информации об установке Termix на всех платформах. Также вы можете ознакомиться с примером файла Docker Compose здесь:
+
+```yaml
+services:
+ termix:
+ image: ghcr.io/lukegus/termix:latest
+ container_name: termix
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ volumes:
+ - termix-data:/app/data
+ environment:
+ PORT: "8080"
+
+volumes:
+ termix-data:
+ driver: local
+```
+
+# Спонсоры
+
+
+
+# Поддержка
+
+Если вам нужна помощь или вы хотите запросить новую функцию для Termix, посетите страницу [Проблемы](https://github.com/Termix-SSH/Support/issues), войдите в систему и нажмите `New Issue`.
+Пожалуйста, опишите вашу проблему как можно подробнее, предпочтительно на английском языке. Вы также можете присоединиться к серверу [Discord](https://discord.gg/jVQGdvHDrf) и обратиться в канал поддержки, однако время ответа может быть дольше.
+
+# Скриншоты
+
+[](https://youtu.be/sjKIqfCK0NY)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Некоторые видео и изображения могут быть устаревшими или не полностью отражать функциональность.
+
+# Лицензия
+
+Распространяется по лицензии Apache License Version 2.0. Подробнее см. в файле LICENSE.
diff --git a/readme/README-TR.md b/readme/README-TR.md
new file mode 100644
index 00000000..d52345e5
--- /dev/null
+++ b/readme/README-TR.md
@@ -0,0 +1,187 @@
+# Repo İstatistikleri
+
+
+
+Projeyi desteklemek isterseniz, buradan destek olabilirsiniz!\
+[](https://github.com/sponsors/LukeGus)
+
+# Genel Bakış
+
+
+
+Termix, açık kaynaklı, sonsuza kadar ücretsiz, kendi sunucunuzda barındırabileceğiniz hepsi bir arada sunucu yönetim platformudur. Sunucularınızı ve altyapınızı tek bir sezgisel arayüz üzerinden yönetmek için çok platformlu bir çözüm sunar. Termix, SSH terminal erişimi, SSH tünelleme yetenekleri, uzak dosya yönetimi ve daha birçok araç sağlar. Termix, tüm platformlarda kullanılabilen Termius'un mükemmel ücretsiz ve kendi barındırmalı alternatifidir.
+
+# Özellikler
+
+- **SSH Terminal Erişimi** - Tarayıcı benzeri sekme sistemiyle bölünmüş ekran desteğine sahip (4 panele kadar) tam özellikli terminal. Yaygın terminal temaları, yazı tipleri ve diğer bileşenler dahil olmak üzere terminal özelleştirme desteği içerir
+- **SSH Tünel Yönetimi** - Otomatik yeniden bağlanma ve sağlık izleme ile SSH tünelleri oluşturun ve yönetin, -l veya -r bağlantıları desteğiyle
+- **Uzak Dosya Yöneticisi** - Uzak sunuculardaki dosyaları doğrudan yönetin; kod, görüntü, ses ve video görüntüleme ve düzenleme desteğiyle. Sudo desteğiyle dosyaları sorunsuzca yükleyin, indirin, yeniden adlandırın, silin ve taşıyın.
+- **Docker Yönetimi** - Konteynerleri başlatın, durdurun, duraklatın, kaldırın. Konteyner istatistiklerini görüntüleyin. Docker exec terminali kullanarak konteyneri kontrol edin. Portainer veya Dockge'nin yerini almak için değil, konteynerlerinizi oluşturmak yerine basitçe yönetmek için tasarlanmıştır.
+- **SSH Ana Bilgisayar Yöneticisi** - SSH bağlantılarınızı etiketler ve klasörlerle kaydedin, düzenleyin ve yönetin; yeniden kullanılabilir giriş bilgilerini kolayca kaydedin ve SSH anahtarlarının dağıtımını otomatikleştirin
+- **Sunucu İstatistikleri** - Çoğu Linux tabanlı sunucularda CPU, bellek ve disk kullanımını ağ, çalışma süresi, sistem bilgisi, güvenlik duvarı, port izleme ile birlikte görüntüleyin
+- **Kontrol Paneli** - Kontrol panelinizde sunucu bilgilerini bir bakışta görüntüleyin
+- **RBAC** - Roller oluşturun ve ana bilgisayarları kullanıcılar/roller arasında paylaşın
+- **Kullanıcı Kimlik Doğrulama** - Yönetici kontrolleri, OIDC ve 2FA (TOTP) desteğiyle güvenli kullanıcı yönetimi. Tüm platformlardaki aktif kullanıcı oturumlarını görüntüleyin ve izinleri iptal edin. OIDC/Yerel hesaplarınızı birbirine bağlayın.
+- **Veritabanı Şifreleme** - Arka uç, şifrelenmiş SQLite veritabanı dosyaları olarak depolanır. Daha fazla bilgi için [belgelere](https://docs.termix.site/security) bakın.
+- **Veri Dışa/İçe Aktarma** - SSH ana bilgisayarlarını, kimlik bilgilerini ve dosya yöneticisi verilerini dışa ve içe aktarın
+- **Otomatik SSL Kurulumu** - HTTPS yönlendirmeleriyle yerleşik SSL sertifika oluşturma ve yönetimi
+- **Modern Arayüz** - React, Tailwind CSS ve Shadcn ile oluşturulmuş temiz masaüstü/mobil uyumlu arayüz. Karanlık veya açık tema arasında seçim yapın. Herhangi bir bağlantıyı tam ekranda açmak için URL yollarını kullanın.
+- **Diller** - ~30 dil için yerleşik destek ([Crowdin](https://docs.termix.site/translations) tarafından yönetilir)
+- **Platform Desteği** - Web uygulaması, masaüstü uygulaması (Windows, Linux ve macOS), PWA ve iOS ile Android için özel mobil/tablet uygulaması olarak kullanılabilir.
+- **SSH Araçları** - Tek tıklamayla çalıştırılan yeniden kullanılabilir komut parçacıkları oluşturun. Birden fazla açık terminalde aynı anda tek bir komut çalıştırın.
+- **Komut Geçmişi** - Daha önce çalıştırılan SSH komutlarını otomatik tamamlayın ve görüntüleyin
+- **Hızlı Bağlantı** - Bağlantı verilerini kaydetmeden bir sunucuya bağlanın
+- **Komut Paleti** - Sol shift tuşuna iki kez basarak SSH bağlantılarına klavyenizle hızlıca erişin
+- **SSH Zengin Özellikler** - Atlama ana bilgisayarları, Warpgate, TOTP tabanlı bağlantılar, SOCKS5, ana bilgisayar anahtar doğrulama, otomatik şifre doldurma, [OPKSSH](https://github.com/openpubkey/opkssh) vb. destekler.
+- **Ağ Grafiği** - Kontrol panelinizi, SSH bağlantılarınıza dayalı olarak ev laboratuvarınızı durum desteğiyle görselleştirmek için özelleştirin
+- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+
+# Planlanan Özellikler
+
+Tüm planlanan özellikler için [Projeler](https://github.com/orgs/Termix-SSH/projects/2) sayfasına bakın. Katkıda bulunmak istiyorsanız, [Katkıda Bulunma](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md) sayfasına bakın.
+
+# Kurulum
+
+Desteklenen Cihazlar:
+
+- Web sitesi (Chrome, Safari ve Firefox gibi herhangi bir platformda herhangi bir modern tarayıcı) (PWA desteği dahil)
+- Windows (x64/ia32)
+ - Taşınabilir
+ - MSI Yükleyici
+ - Chocolatey Paket Yöneticisi
+- Linux (x64/ia32)
+ - Taşınabilir
+ - AUR
+ - AppImage
+ - Deb
+ - Flatpak
+- macOS (v12.0+ üzerinde x64/ia32)
+ - Apple App Store
+ - DMG
+ - Homebrew
+- iOS/iPadOS (v15.1+)
+ - Apple App Store
+ - IPA
+- Android (v7.0+)
+ - Google Play Store
+ - APK
+
+Termix'i tüm platformlara nasıl kuracağınız hakkında daha fazla bilgi için Termix [Belgelerine](https://docs.termix.site/install) bakın. Aksi takdirde, örnek bir Docker Compose dosyasını burada görüntüleyin:
+
+```yaml
+services:
+ termix:
+ image: ghcr.io/lukegus/termix:latest
+ container_name: termix
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ volumes:
+ - termix-data:/app/data
+ environment:
+ PORT: "8080"
+
+volumes:
+ termix-data:
+ driver: local
+```
+
+# Sponsorlar
+
+
+
+# Destek
+
+Termix ile ilgili yardıma ihtiyacınız varsa veya bir özellik talep etmek istiyorsanız, [Sorunlar](https://github.com/Termix-SSH/Support/issues) sayfasını ziyaret edin, giriş yapın ve `New Issue` butonuna basın.
+Lütfen sorununuzu mümkün olduğunca ayrıntılı yazın, tercihen İngilizce olarak. Ayrıca [Discord](https://discord.gg/jVQGdvHDrf) sunucusuna katılabilir ve destek kanalını ziyaret edebilirsiniz, ancak yanıt süreleri daha uzun olabilir.
+
+# Ekran Görüntüleri
+
+[](https://youtu.be/sjKIqfCK0NY)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Bazı videolar ve görseller güncel olmayabilir veya özellikleri tam olarak yansıtmayabilir.
+
+# Lisans
+
+Apache Lisansı Sürüm 2.0 altında dağıtılmaktadır. Daha fazla bilgi için LICENSE dosyasına bakın.
diff --git a/readme/README-VI.md b/readme/README-VI.md
new file mode 100644
index 00000000..634e3b36
--- /dev/null
+++ b/readme/README-VI.md
@@ -0,0 +1,187 @@
+# Thống Kê Repo
+
+
+
+Nếu bạn muốn, bạn có thể hỗ trợ dự án tại đây!\
+[](https://github.com/sponsors/LukeGus)
+
+# Tổng Quan
+
+
+
+Termix là nền tảng quản lý máy chủ tất cả trong một, mã nguồn mở, miễn phí vĩnh viễn, tự lưu trữ. Nó cung cấp giải pháp đa nền tảng để quản lý máy chủ và cơ sở hạ tầng của bạn thông qua một giao diện trực quan duy nhất. Termix cung cấp quyền truy cập terminal SSH, khả năng tạo đường hầm SSH, quản lý tệp từ xa và nhiều công cụ khác. Termix là giải pháp thay thế miễn phí và tự lưu trữ hoàn hảo cho Termius, khả dụng trên tất cả các nền tảng.
+
+# Tính Năng
+
+- **Truy Cập Terminal SSH** - Terminal đầy đủ tính năng với hỗ trợ chia màn hình (lên đến 4 bảng) với hệ thống tab kiểu trình duyệt. Bao gồm hỗ trợ tùy chỉnh terminal bao gồm các chủ đề terminal phổ biến, phông chữ và các thành phần khác
+- **Quản Lý Đường Hầm SSH** - Tạo và quản lý đường hầm SSH với tự động kết nối lại và giám sát sức khỏe, hỗ trợ kết nối -l hoặc -r
+- **Trình Quản Lý Tệp Từ Xa** - Quản lý tệp trực tiếp trên máy chủ từ xa với hỗ trợ xem và chỉnh sửa mã, hình ảnh, âm thanh và video. Tải lên, tải xuống, đổi tên, xóa và di chuyển tệp liền mạch với hỗ trợ sudo.
+- **Quản Lý Docker** - Khởi động, dừng, tạm dừng, xóa container. Xem thống kê container. Điều khiển container bằng terminal docker exec. Không được tạo ra để thay thế Portainer hay Dockge mà đơn giản là để quản lý container của bạn thay vì tạo mới chúng.
+- **Trình Quản Lý Máy Chủ SSH** - Lưu, sắp xếp và quản lý các kết nối SSH của bạn với thẻ và thư mục, dễ dàng lưu thông tin đăng nhập có thể tái sử dụng đồng thời có thể tự động hóa việc triển khai khóa SSH
+- **Thống Kê Máy Chủ** - Xem mức sử dụng CPU, bộ nhớ và ổ đĩa cùng với mạng, thời gian hoạt động, thông tin hệ thống, tường lửa, giám sát cổng trên hầu hết các máy chủ chạy Linux
+- **Bảng Điều Khiển** - Xem thông tin máy chủ trong nháy mắt trên bảng điều khiển của bạn
+- **RBAC** - Tạo vai trò và chia sẻ máy chủ giữa người dùng/vai trò
+- **Xác Thực Người Dùng** - Quản lý người dùng an toàn với quyền quản trị và hỗ trợ OIDC và 2FA (TOTP). Xem phiên hoạt động của người dùng trên tất cả các nền tảng và thu hồi quyền. Liên kết tài khoản OIDC/Nội bộ của bạn với nhau.
+- **Mã Hóa Cơ Sở Dữ Liệu** - Backend được lưu trữ dưới dạng tệp cơ sở dữ liệu SQLite được mã hóa. Xem [tài liệu](https://docs.termix.site/security) để biết thêm.
+- **Xuất/Nhập Dữ Liệu** - Xuất và nhập máy chủ SSH, thông tin xác thực và dữ liệu trình quản lý tệp
+- **Thiết Lập SSL Tự Động** - Tạo và quản lý chứng chỉ SSL tích hợp với chuyển hướng HTTPS
+- **Giao Diện Hiện Đại** - Giao diện sạch sẽ, thân thiện với máy tính/di động được xây dựng bằng React, Tailwind CSS và Shadcn. Chọn giữa giao diện chế độ tối hoặc sáng. Sử dụng đường dẫn URL để mở bất kỳ kết nối nào ở chế độ toàn màn hình.
+- **Ngôn Ngữ** - Hỗ trợ tích hợp ~30 ngôn ngữ (được quản lý bởi [Crowdin](https://docs.termix.site/translations))
+- **Hỗ Trợ Nền Tảng** - Khả dụng dưới dạng ứng dụng web, ứng dụng máy tính (Windows, Linux và macOS), PWA và ứng dụng di động/máy tính bảng chuyên dụng cho iOS và Android.
+- **Công Cụ SSH** - Tạo đoạn lệnh có thể tái sử dụng, thực thi chỉ với một cú nhấp chuột. Chạy một lệnh đồng thời trên nhiều terminal đang mở.
+- **Lịch Sử Lệnh** - Tự động hoàn thành và xem các lệnh SSH đã chạy trước đó
+- **Kết Nối Nhanh** - Kết nối đến máy chủ mà không cần lưu dữ liệu kết nối
+- **Bảng Lệnh** - Nhấn đúp phím shift trái để truy cập nhanh các kết nối SSH bằng bàn phím
+- **SSH Giàu Tính Năng** - Hỗ trợ jump host, Warpgate, kết nối dựa trên TOTP, SOCKS5, xác minh khóa máy chủ, tự động điền mật khẩu, [OPKSSH](https://github.com/openpubkey/opkssh), v.v.
+- **Biểu Đồ Mạng** - Tùy chỉnh Bảng Điều Khiển để trực quan hóa homelab của bạn dựa trên các kết nối SSH với hỗ trợ trạng thái
+- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+
+# Tính Năng Dự Kiến
+
+Xem [Dự Án](https://github.com/orgs/Termix-SSH/projects/2) để biết tất cả các tính năng dự kiến. Nếu bạn muốn đóng góp, xem [Đóng Góp](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).
+
+# Cài Đặt
+
+Thiết Bị Được Hỗ Trợ:
+
+- Trang web (bất kỳ trình duyệt hiện đại nào trên bất kỳ nền tảng nào như Chrome, Safari và Firefox) (bao gồm hỗ trợ PWA)
+- Windows (x64/ia32)
+ - Portable
+ - MSI Installer
+ - Chocolatey Package Manager
+- Linux (x64/ia32)
+ - Portable
+ - AUR
+ - AppImage
+ - Deb
+ - Flatpak
+- macOS (x64/ia32 trên v12.0+)
+ - Apple App Store
+ - DMG
+ - Homebrew
+- iOS/iPadOS (v15.1+)
+ - Apple App Store
+ - IPA
+- Android (v7.0+)
+ - Google Play Store
+ - APK
+
+Truy cập [Tài Liệu](https://docs.termix.site/install) Termix để biết thêm thông tin về cách cài đặt Termix trên tất cả các nền tảng. Ngoài ra, xem tệp Docker Compose mẫu tại đây:
+
+```yaml
+services:
+ termix:
+ image: ghcr.io/lukegus/termix:latest
+ container_name: termix
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ volumes:
+ - termix-data:/app/data
+ environment:
+ PORT: "8080"
+
+volumes:
+ termix-data:
+ driver: local
+```
+
+# Nhà Tài Trợ
+
+
+
+# Hỗ Trợ
+
+Nếu bạn cần trợ giúp hoặc muốn yêu cầu tính năng với Termix, hãy truy cập trang [Vấn Đề](https://github.com/Termix-SSH/Support/issues), đăng nhập và nhấn `New Issue`.
+Vui lòng mô tả vấn đề càng chi tiết càng tốt, ưu tiên viết bằng tiếng Anh. Bạn cũng có thể tham gia máy chủ [Discord](https://discord.gg/jVQGdvHDrf) và truy cập kênh hỗ trợ, tuy nhiên thời gian phản hồi có thể lâu hơn.
+
+# Ảnh Chụp Màn Hình
+
+[](https://youtu.be/sjKIqfCK0NY)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Một số video và hình ảnh có thể đã lỗi thời hoặc không thể hiện chính xác hoàn toàn các tính năng.
+
+# Giấy Phép
+
+Được phân phối theo Giấy Phép Apache Phiên Bản 2.0. Xem LICENSE để biết thêm thông tin.
diff --git a/repo-images/YouTube.jpg b/repo-images/YouTube.jpg
new file mode 100644
index 00000000..d37d0ead
Binary files /dev/null and b/repo-images/YouTube.jpg differ
diff --git a/repo-images/YouTube.png b/repo-images/YouTube.png
deleted file mode 100644
index 5b7c9654..00000000
Binary files a/repo-images/YouTube.png and /dev/null differ
diff --git a/src/backend/dashboard.ts b/src/backend/dashboard.ts
index 207f7248..ac02bce0 100644
--- a/src/backend/dashboard.ts
+++ b/src/backend/dashboard.ts
@@ -8,7 +8,7 @@ import {
hostAccess,
dashboardPreferences,
} from "./database/db/schema.js";
-import { eq, and, desc, or, sql } from "drizzle-orm";
+import { eq, and, desc, sql } from "drizzle-orm";
import { dashboardLogger } from "./utils/logger.js";
import { SimpleDBOps } from "./utils/simple-db-ops.js";
import { AuthManager } from "./utils/auth-manager.js";
@@ -55,6 +55,10 @@ app.use(
);
app.use(cookieParser());
app.use(express.json({ limit: "1mb" }));
+app.use((_req, res, next) => {
+ res.setHeader("Cache-Control", "no-store");
+ next();
+});
app.use(authManager.createAuthMiddleware());
@@ -85,9 +89,6 @@ app.use(authManager.createAuthMiddleware());
*/
app.get("/uptime", async (req, res) => {
try {
- const startTime = Date.now();
- const userId = (req as AuthenticatedRequest).userId;
-
const uptimeMs = Date.now() - serverStartTime;
const uptimeSeconds = Math.floor(uptimeMs / 1000);
const days = Math.floor(uptimeSeconds / 86400);
@@ -296,7 +297,7 @@ app.post("/activity/log", async (req, res) => {
if (allActivities.length > 100) {
const toDelete = allActivities.slice(100);
- for (const activity of toDelete) {
+ for (let i = 0; i < toDelete.length; i++) {
await SimpleDBOps.delete(recentActivity, "recent_activity", userId);
}
}
diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts
index 22e9c232..322426b4 100644
--- a/src/backend/database/database.ts
+++ b/src/backend/database/database.ts
@@ -45,6 +45,10 @@ import type {
} from "../../types/index.js";
import { getDb, DatabaseSaveTrigger } from "./db/index.js";
import Database from "better-sqlite3";
+import { fileURLToPath } from "url";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
const app = express();
@@ -203,6 +207,10 @@ app.use(bodyParser.json({ limit: "1gb" }));
app.use(bodyParser.urlencoded({ limit: "1gb", extended: true }));
app.use(bodyParser.raw({ limit: "5gb", type: "application/octet-stream" }));
app.use(cookieParser());
+app.use((_req, res, next) => {
+ res.setHeader("Cache-Control", "no-store");
+ next();
+});
/**
* @openapi
@@ -595,21 +603,41 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
try {
const userId = (req as AuthenticatedRequest).userId;
const { password } = req.body;
-
- if (!password) {
- return res.status(400).json({
- error: "Password required for export",
- code: "PASSWORD_REQUIRED",
- });
- }
const deviceInfo = parseUserAgent(req);
- const unlocked = await authManager.authenticateUser(
- userId,
- password,
- deviceInfo.type,
- );
- if (!unlocked) {
- return res.status(401).json({ error: "Invalid password" });
+
+ const user = await getDb().select().from(users).where(eq(users.id, userId));
+ if (!user || user.length === 0) {
+ return res.status(404).json({ error: "User not found" });
+ }
+
+ const isOidcUser = !!user[0].isOidc;
+
+ if (!isOidcUser) {
+ if (!password) {
+ return res.status(400).json({
+ error: "Password required for export",
+ code: "PASSWORD_REQUIRED",
+ });
+ }
+
+ const unlocked = await authManager.authenticateUser(
+ userId,
+ password,
+ deviceInfo.type,
+ );
+ if (!unlocked) {
+ return res.status(401).json({ error: "Invalid password" });
+ }
+ } else if (!DataCrypto.getUserDataKey(userId)) {
+ const oidcUnlocked = await authManager.authenticateOIDCUser(
+ userId,
+ deviceInfo.type,
+ );
+ if (!oidcUnlocked) {
+ return res.status(403).json({
+ error: "Failed to unlock user data with SSO credentials",
+ });
+ }
}
apiLogger.info("Exporting user data as SQLite", {
@@ -622,11 +650,6 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
throw new Error("User data not unlocked");
}
- const user = await getDb().select().from(users).where(eq(users.id, userId));
- if (!user || user.length === 0) {
- throw new Error(`User not found: ${userId}`);
- }
-
const tempDir =
process.env.NODE_ENV === "production"
? path.join(process.env.DATA_DIR || "./db/data", ".temp", "exports")
@@ -805,20 +828,20 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
userRecord.id,
userRecord.username,
"[EXPORTED_USER_NO_PASSWORD]",
- userRecord.is_admin ? 1 : 0,
- userRecord.is_oidc ? 1 : 0,
- userRecord.oidc_identifier || null,
- userRecord.client_id || null,
- userRecord.client_secret || null,
- userRecord.issuer_url || null,
- userRecord.authorization_url || null,
- userRecord.token_url || null,
- userRecord.identifier_path || null,
- userRecord.name_path || null,
+ userRecord.isAdmin ? 1 : 0,
+ userRecord.isOidc ? 1 : 0,
+ userRecord.oidcIdentifier || null,
+ userRecord.clientId || null,
+ userRecord.clientSecret || null,
+ userRecord.issuerUrl || null,
+ userRecord.authorizationUrl || null,
+ userRecord.tokenUrl || null,
+ userRecord.identifierPath || null,
+ userRecord.namePath || null,
userRecord.scopes || null,
- userRecord.totp_secret || null,
- userRecord.totp_enabled ? 1 : 0,
- userRecord.totp_backup_codes || null,
+ userRecord.totpSecret || null,
+ userRecord.totpEnabled ? 1 : 0,
+ userRecord.totpBackupCodes || null,
);
const sshHosts = await getDb()
@@ -851,31 +874,31 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
decrypted.forceKeyboardInteractive || null,
decrypted.password || null,
decrypted.key || null,
- decrypted.key_password || null,
+ decrypted.keyPassword || null,
decrypted.keyType || null,
decrypted.sudoPassword || null,
decrypted.autostartPassword || null,
decrypted.autostartKey || null,
decrypted.autostartKeyPassword || null,
decrypted.credentialId || null,
- Boolean(decrypted.overrideCredentialUsername) ? 1 : 0,
- Boolean(decrypted.enableTerminal) ? 1 : 0,
- Boolean(decrypted.enableTunnel) ? 1 : 0,
+ decrypted.overrideCredentialUsername ? 1 : 0,
+ decrypted.enableTerminal ? 1 : 0,
+ decrypted.enableTunnel ? 1 : 0,
decrypted.tunnelConnections || null,
decrypted.jumpHosts || null,
- Boolean(decrypted.enableFileManager) ? 1 : 0,
- Boolean(decrypted.enableDocker) ? 1 : 0,
- Boolean(decrypted.showTerminalInSidebar) ? 1 : 0,
- Boolean(decrypted.showFileManagerInSidebar) ? 1 : 0,
- Boolean(decrypted.showTunnelInSidebar) ? 1 : 0,
- Boolean(decrypted.showDockerInSidebar) ? 1 : 0,
- Boolean(decrypted.showServerStatsInSidebar) ? 1 : 0,
+ decrypted.enableFileManager ? 1 : 0,
+ decrypted.enableDocker ? 1 : 0,
+ decrypted.showTerminalInSidebar ? 1 : 0,
+ decrypted.showFileManagerInSidebar ? 1 : 0,
+ decrypted.showTunnelInSidebar ? 1 : 0,
+ decrypted.showDockerInSidebar ? 1 : 0,
+ decrypted.showServerStatsInSidebar ? 1 : 0,
decrypted.defaultPath || null,
decrypted.statsConfig || null,
decrypted.terminalConfig || null,
decrypted.quickActions || null,
decrypted.notes || null,
- Boolean(decrypted.useSocks5) ? 1 : 0,
+ decrypted.useSocks5 ? 1 : 0,
decrypted.socks5Host || null,
decrypted.socks5Port || null,
decrypted.socks5Username || null,
@@ -913,9 +936,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
decrypted.username,
decrypted.password || null,
decrypted.key || null,
- decrypted.private_key || null,
- decrypted.public_key || null,
- decrypted.key_password || null,
+ decrypted.privateKey || null,
+ decrypted.publicKey || null,
+ decrypted.keyPassword || null,
decrypted.keyType || null,
decrypted.detectedKeyType || null,
decrypted.usageCount || 0,
@@ -1135,7 +1158,7 @@ app.post(
return res.status(404).json({ error: "User not found" });
}
- const isOidcUser = !!userRecords[0].is_oidc;
+ const isOidcUser = !!userRecords[0].isOidc;
if (!isOidcUser) {
if (!password) {
@@ -1506,7 +1529,7 @@ app.post(
.select()
.from(users)
.where(eq(users.id, userId));
- if (targetUser.length > 0 && targetUser[0].is_admin) {
+ if (targetUser.length > 0 && targetUser[0].isAdmin) {
try {
const importedSettings = importDb
.prepare("SELECT * FROM settings")
@@ -1744,7 +1767,33 @@ app.use("/terminal", terminalRoutes);
app.use("/network-topology", networkTopologyRoutes);
app.use("/rbac", rbacRoutes);
+const frontendDistPaths = [
+ path.join(__dirname, "../../../dist"),
+ path.join(__dirname, "../../dist"),
+ path.join(process.cwd(), "dist"),
+];
+
+const frontendDist = frontendDistPaths.find((p) =>
+ fs.existsSync(path.join(p, "index.html")),
+);
+
+if (frontendDist) {
+ databaseLogger.info(`Serving frontend from: ${frontendDist}`, {
+ operation: "static_files",
+ });
+ app.use(express.static(frontendDist));
+
+ app.use((req, res, next) => {
+ if (req.method === "GET" && req.accepts("html")) {
+ res.sendFile(path.join(frontendDist, "index.html"));
+ } else {
+ next();
+ }
+ });
+}
+
app.use(
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
(
err: unknown,
req: express.Request,
@@ -1819,13 +1868,17 @@ app.get(
if (status.hasUnencryptedDb) {
try {
unencryptedSize = fs.statSync(dbPath).size;
- } catch (error) {}
+ } catch {
+ // expected - file may not exist
+ }
}
if (status.hasEncryptedDb) {
try {
encryptedSize = fs.statSync(encryptedDbPath).size;
- } catch (error) {}
+ } catch {
+ // expected - file may not exist
+ }
}
res.json({
diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts
index 11afd2f4..b2ae22d5 100644
--- a/src/backend/database/db/index.ts
+++ b/src/backend/database/db/index.ts
@@ -37,10 +37,11 @@ async function initializeDatabaseAsync(): Promise {
memoryDatabase = new Database(decryptedBuffer);
try {
- const sessionCount = memoryDatabase
+ memoryDatabase
.prepare("SELECT COUNT(*) as count FROM sessions")
.get() as { count: number };
- } catch (countError) {
+ } catch {
+ // expected - sessions table may not exist yet
}
} else {
const migration = new DatabaseMigration(dataDir);
@@ -182,6 +183,18 @@ async function initializeCompleteDatabase(): Promise {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
+ CREATE TABLE IF NOT EXISTS trusted_devices (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ device_fingerprint TEXT NOT NULL,
+ device_type TEXT NOT NULL,
+ device_info TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ expires_at TEXT NOT NULL,
+ last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+ );
+
CREATE TABLE IF NOT EXISTS ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -750,6 +763,33 @@ const migrateSchema = () => {
}
}
+ try {
+ sqlite
+ .prepare("SELECT id FROM trusted_devices LIMIT 1")
+ .get();
+ } catch {
+ try {
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS trusted_devices (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ device_fingerprint TEXT NOT NULL,
+ device_type TEXT NOT NULL,
+ device_info TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ expires_at TEXT NOT NULL,
+ last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+ );
+ `);
+ } catch (createError) {
+ databaseLogger.warn("Failed to create trusted_devices table", {
+ operation: "schema_migration",
+ error: createError,
+ });
+ }
+ }
+
try {
sqlite
.prepare("SELECT id FROM network_topology LIMIT 1")
@@ -1033,23 +1073,15 @@ const migrateSchema = () => {
try {
const validSystemRoles = ['admin', 'user'];
const unwantedRoleNames = ['superAdmin', 'powerUser', 'readonly', 'member'];
- let deletedCount = 0;
-
const deleteByName = sqlite.prepare("DELETE FROM roles WHERE name = ?");
for (const roleName of unwantedRoleNames) {
- const result = deleteByName.run(roleName);
- if (result.changes > 0) {
- deletedCount += result.changes;
- }
+ deleteByName.run(roleName);
}
const deleteOldSystemRole = sqlite.prepare("DELETE FROM roles WHERE name = ? AND is_system = 1");
for (const role of existingRoles) {
if (role.is_system === 1 && !validSystemRoles.includes(role.name) && !unwantedRoleNames.includes(role.name)) {
- const result = deleteOldSystemRole.run(role.name);
- if (result.changes > 0) {
- deletedCount += result.changes;
- }
+ deleteOldSystemRole.run(role.name);
}
}
} catch (cleanupError) {
@@ -1107,7 +1139,7 @@ const migrateSchema = () => {
for (const admin of adminUsers) {
try {
insertUserRole.run(admin.id, adminRole.id);
- } catch (error) {
+ } catch {
// Ignore duplicate errors
}
}
@@ -1122,7 +1154,7 @@ const migrateSchema = () => {
for (const user of normalUsers) {
try {
insertUserRole.run(user.id, userRole.id);
- } catch (error) {
+ } catch {
// Ignore duplicate errors
}
}
@@ -1156,10 +1188,11 @@ async function saveMemoryDatabaseToFile() {
}
try {
- const sessionCount = memoryDatabase
+ memoryDatabase
.prepare("SELECT COUNT(*) as count FROM sessions")
.get() as { count: number };
- } catch (countError) {
+ } catch {
+ // expected - sessions table may not exist yet
}
if (enableFileEncryption) {
@@ -1170,6 +1203,8 @@ async function saveMemoryDatabaseToFile() {
} else {
fs.writeFileSync(dbPath, buffer);
}
+
+ DatabaseSaveTrigger.markClean();
} catch (error) {
databaseLogger.error("Failed to save in-memory database", error, {
operation: "memory_db_save_failed",
@@ -1185,7 +1220,11 @@ async function handlePostInitFileEncryption() {
if (memoryDatabase) {
await saveMemoryDatabaseToFile();
- setInterval(saveMemoryDatabaseToFile, 15 * 1000);
+ setInterval(() => {
+ if (DatabaseSaveTrigger.isDirty) {
+ saveMemoryDatabaseToFile();
+ }
+ }, 5 * 60 * 1000);
DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);
}
@@ -1254,15 +1293,18 @@ async function cleanupDatabase() {
try {
fs.unlinkSync(path.join(tempDir, file));
} catch {
+ // expected - file cleanup best effort
}
}
try {
fs.rmdirSync(tempDir);
} catch {
+ // expected - dir cleanup best effort
}
}
} catch {
+ // expected - temp dir cleanup best effort
}
}
@@ -1271,6 +1313,7 @@ process.on("exit", () => {
try {
sqlite.close();
} catch {
+ // expected - database may already be closed
}
}
});
diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts
index 474f0a31..3a17c077 100644
--- a/src/backend/database/db/schema.ts
+++ b/src/backend/database/db/schema.ts
@@ -4,25 +4,25 @@ import { sql } from "drizzle-orm";
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
username: text("username").notNull(),
- password_hash: text("password_hash").notNull(),
- is_admin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
+ passwordHash: text("password_hash").notNull(),
+ isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
- is_oidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false),
- oidc_identifier: text("oidc_identifier"),
- client_id: text("client_id"),
- client_secret: text("client_secret"),
- issuer_url: text("issuer_url"),
- authorization_url: text("authorization_url"),
- token_url: text("token_url"),
- identifier_path: text("identifier_path"),
- name_path: text("name_path"),
+ isOidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false),
+ oidcIdentifier: text("oidc_identifier"),
+ clientId: text("client_id"),
+ clientSecret: text("client_secret"),
+ issuerUrl: text("issuer_url"),
+ authorizationUrl: text("authorization_url"),
+ tokenUrl: text("token_url"),
+ identifierPath: text("identifier_path"),
+ namePath: text("name_path"),
scopes: text().default("openid email profile"),
- totp_secret: text("totp_secret"),
- totp_enabled: integer("totp_enabled", { mode: "boolean" })
+ totpSecret: text("totp_secret"),
+ totpEnabled: integer("totp_enabled", { mode: "boolean" })
.notNull()
.default(false),
- totp_backup_codes: text("totp_backup_codes"),
+ totpBackupCodes: text("totp_backup_codes"),
});
export const settings = sqliteTable("settings", {
@@ -47,6 +47,23 @@ export const sessions = sqliteTable("sessions", {
.default(sql`CURRENT_TIMESTAMP`),
});
+export const trustedDevices = sqliteTable("trusted_devices", {
+ id: text("id").primaryKey(),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ deviceFingerprint: text("device_fingerprint").notNull(),
+ deviceType: text("device_type").notNull(),
+ deviceInfo: text("device_info").notNull(),
+ createdAt: text("created_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+ expiresAt: text("expires_at").notNull(),
+ lastUsedAt: text("last_used_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+});
+
export const sshData = sqliteTable("ssh_data", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
@@ -64,7 +81,7 @@ export const sshData = sqliteTable("ssh_data", {
password: text("password"),
key: text("key", { length: 8192 }),
- key_password: text("key_password"),
+ keyPassword: text("key_password"),
keyType: text("key_type"),
sudoPassword: text("sudo_password"),
@@ -202,9 +219,9 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
username: text("username"),
password: text("password"),
key: text("key", { length: 16384 }),
- private_key: text("private_key", { length: 16384 }),
- public_key: text("public_key", { length: 4096 }),
- key_password: text("key_password"),
+ privateKey: text("private_key", { length: 16384 }),
+ publicKey: text("public_key", { length: 4096 }),
+ keyPassword: text("key_password"),
keyType: text("key_type"),
detectedKeyType: text("detected_key_type"),
diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts
index dacd6854..86a3cc92 100644
--- a/src/backend/database/routes/credentials.ts
+++ b/src/backend/database/routes/credentials.ts
@@ -225,9 +225,9 @@ router.post(
username: username?.trim() || null,
password: plainPassword,
key: plainKey,
- private_key: keyInfo?.privateKey || plainKey,
- public_key: keyInfo?.publicKey || null,
- key_password: plainKeyPassword,
+ privateKey: keyInfo?.privateKey || plainKey,
+ publicKey: keyInfo?.publicKey || null,
+ keyPassword: plainKeyPassword,
keyType: keyType || null,
detectedKeyType: keyInfo?.keyType || null,
usageCount: 0,
@@ -434,14 +434,14 @@ router.get(
if (credential.key) {
output.key = credential.key;
}
- if (credential.private_key) {
- output.privateKey = credential.private_key;
+ if (credential.privateKey) {
+ output.privateKey = credential.privateKey;
}
- if (credential.public_key) {
- output.publicKey = credential.public_key;
+ if (credential.publicKey) {
+ output.publicKey = credential.publicKey;
}
- if (credential.key_password) {
- output.keyPassword = credential.key_password;
+ if (credential.keyPassword) {
+ output.keyPassword = credential.keyPassword;
}
res.json(output);
@@ -564,13 +564,13 @@ router.put(
error: `Invalid SSH key: ${keyInfo.error}`,
});
}
- updateFields.private_key = keyInfo.privateKey;
- updateFields.public_key = keyInfo.publicKey;
+ updateFields.privateKey = keyInfo.privateKey;
+ updateFields.publicKey = keyInfo.publicKey;
updateFields.detectedKeyType = keyInfo.keyType;
}
}
if (updateData.keyPassword !== undefined) {
- updateFields.key_password = updateData.keyPassword || null;
+ updateFields.keyPassword = updateData.keyPassword || null;
}
if (Object.keys(updateFields).length === 0) {
@@ -606,15 +606,15 @@ router.put(
userId,
);
- const { SharedCredentialManager } =
- await import("../../utils/shared-credential-manager.js");
+ const { SharedCredentialManager } = await import(
+ "../../utils/shared-credential-manager.js"
+ );
const sharedCredManager = SharedCredentialManager.getInstance();
await sharedCredManager.updateSharedCredentialsForOriginal(
parseInt(id),
userId,
);
- const credential = updated[0];
authLogger.success("SSH credential updated", {
operation: "credential_update_success",
userId,
@@ -706,7 +706,7 @@ router.delete(
credentialId: null,
password: null,
key: null,
- key_password: null,
+ keyPassword: null,
authType: "password",
})
.where(
@@ -737,8 +737,9 @@ router.delete(
}
}
- const { SharedCredentialManager } =
- await import("../../utils/shared-credential-manager.js");
+ const { SharedCredentialManager } = await import(
+ "../../utils/shared-credential-manager.js"
+ );
const sharedCredManager = SharedCredentialManager.getInstance();
await sharedCredManager.deleteSharedCredentialsForOriginal(parseInt(id));
@@ -751,7 +752,6 @@ router.delete(
),
);
- const credential = credentialToDelete[0];
authLogger.success("SSH credential deleted", {
operation: "credential_delete_success",
userId,
@@ -841,10 +841,10 @@ router.post(
.set({
credentialId: parseInt(credentialId),
username: (credential.username as string) || "",
- authType: (credential.auth_type || credential.authType) as string,
+ authType: credential.authType as string,
password: null,
key: null,
- key_password: null,
+ keyPassword: null,
keyType: null,
updatedAt: new Date().toISOString(),
})
@@ -954,15 +954,15 @@ function formatCredentialOutput(
? credential.tags.split(",").filter(Boolean)
: []
: [],
- authType: credential.authType || credential.auth_type,
+ authType: credential.authType,
username: credential.username || null,
- publicKey: credential.public_key || credential.publicKey,
- keyType: credential.key_type || credential.keyType,
- detectedKeyType: credential.detected_key_type || credential.detectedKeyType,
- usageCount: credential.usage_count || credential.usageCount || 0,
- lastUsed: credential.last_used || credential.lastUsed,
- createdAt: credential.created_at || credential.createdAt,
- updatedAt: credential.updated_at || credential.updatedAt,
+ publicKey: credential.publicKey,
+ keyType: credential.keyType,
+ detectedKeyType: credential.detectedKeyType,
+ usageCount: credential.usageCount || 0,
+ lastUsed: credential.lastUsed,
+ createdAt: credential.createdAt,
+ updatedAt: credential.updatedAt,
};
}
@@ -1508,7 +1508,7 @@ async function deploySSHKeyToHost(
hostConfig: Record,
credData: CredentialBackend,
): Promise<{ success: boolean; message?: string; error?: string }> {
- const publicKey = credData.public_key as string;
+ const publicKey = credData.publicKey as string;
return new Promise((resolve) => {
const conn = new Client();
@@ -1934,7 +1934,7 @@ router.post(
});
}
- const publicKey = credData.public_key;
+ const publicKey = credData.publicKey;
if (!publicKey) {
return res.status(400).json({
success: false,
@@ -1990,15 +1990,14 @@ router.post(
if (hostCredential && hostCredential.length > 0) {
const cred = hostCredential[0];
- hostConfig.authType = cred.auth_type || cred.authType;
+ hostConfig.authType = cred.authType;
hostConfig.username = cred.username;
- if ((cred.auth_type || cred.authType) === "password") {
+ if (cred.authType === "password") {
hostConfig.password = cred.password;
- } else if ((cred.auth_type || cred.authType) === "key") {
- hostConfig.privateKey =
- cred.private_key || cred.privateKey || cred.key;
- hostConfig.keyPassword = cred.key_password || cred.keyPassword;
+ } else if (cred.authType === "key") {
+ hostConfig.privateKey = cred.privateKey || cred.key;
+ hostConfig.keyPassword = cred.keyPassword;
}
} else {
return res.status(400).json({
diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts
index 03879bab..98ba1ba0 100644
--- a/src/backend/database/routes/rbac.ts
+++ b/src/backend/database/routes/rbac.ts
@@ -7,11 +7,10 @@ import {
users,
roles,
userRoles,
- auditLogs,
sharedCredentials,
} from "../db/schema.js";
import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm";
-import type { Request, Response } from "express";
+import type { Response } from "express";
import { databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
@@ -202,8 +201,9 @@ router.post(
.delete(sharedCredentials)
.where(eq(sharedCredentials.hostAccessId, existing[0].id));
- const { SharedCredentialManager } =
- await import("../../utils/shared-credential-manager.js");
+ const { SharedCredentialManager } = await import(
+ "../../utils/shared-credential-manager.js"
+ );
const sharedCredManager = SharedCredentialManager.getInstance();
if (targetType === "user") {
await sharedCredManager.createSharedCredentialForUser(
@@ -244,8 +244,9 @@ router.post(
expiresAt,
});
- const { SharedCredentialManager } =
- await import("../../utils/shared-credential-manager.js");
+ const { SharedCredentialManager } = await import(
+ "../../utils/shared-credential-manager.js"
+ );
const sharedCredManager = SharedCredentialManager.getInstance();
if (targetType === "user") {
@@ -857,10 +858,7 @@ router.delete(
permissionManager.invalidateUserPermissionCache(userId);
}
- const deletedHostAccess = await db
- .delete(hostAccess)
- .where(eq(hostAccess.roleId, roleId))
- .returning({ id: hostAccess.id });
+ await db.delete(hostAccess).where(eq(hostAccess.roleId, roleId));
await db.delete(roles).where(eq(roles.id, roleId));
@@ -983,8 +981,9 @@ router.post(
.innerJoin(sshData, eq(hostAccess.hostId, sshData.id))
.where(eq(hostAccess.roleId, roleId));
- const { SharedCredentialManager } =
- await import("../../utils/shared-credential-manager.js");
+ const { SharedCredentialManager } = await import(
+ "../../utils/shared-credential-manager.js"
+ );
const sharedCredManager = SharedCredentialManager.getInstance();
for (const { host_access, ssh_data } of hostsSharedWithRole) {
diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts
index a7cf8ed4..6870f6a5 100644
--- a/src/backend/database/routes/snippets.ts
+++ b/src/backend/database/routes/snippets.ts
@@ -655,7 +655,7 @@ router.post(
let password = host.password;
let privateKey = host.key;
- let passphrase = host.key_password;
+ let passphrase = host.keyPassword;
let authType = host.authType;
if (host.credentialId) {
@@ -675,12 +675,12 @@ router.post(
if (credResult.length > 0) {
const cred = credResult[0];
- authType = (cred.auth_type || cred.authType || authType) as string;
+ authType = (cred.authType || authType) as string;
password = (cred.password || undefined) as string | undefined;
- privateKey = (cred.private_key || cred.key || undefined) as
+ privateKey = (cred.privateKey || cred.key || undefined) as
| string
| undefined;
- passphrase = (cred.key_password || undefined) as string | undefined;
+ passphrase = (cred.keyPassword || undefined) as string | undefined;
}
}
@@ -731,7 +731,7 @@ router.post(
reject(err);
});
- const config: any = {
+ const config: Record = {
host: host.ip,
port: host.port,
username: host.username,
diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts
index 5a8a0d1b..d164d33d 100644
--- a/src/backend/database/routes/ssh.ts
+++ b/src/backend/database/routes/ssh.ts
@@ -251,7 +251,7 @@ router.get("/db/host/internal/all", async (req: Request, res: Response) => {
username: host.username,
password: host.autostartPassword || host.password,
key: host.autostartKey || host.key,
- keyPassword: host.autostartKeyPassword || host.key_password,
+ keyPassword: host.autostartKeyPassword || host.keyPassword,
autostartPassword: host.autostartPassword,
autostartKey: host.autostartKey,
autostartKeyPassword: host.autostartKeyPassword,
@@ -426,8 +426,16 @@ router.post(
showDockerInSidebar: showDockerInSidebar ? 1 : 0,
showServerStatsInSidebar: showServerStatsInSidebar ? 1 : 0,
defaultPath: defaultPath || null,
- statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
- terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
+ statsConfig: statsConfig
+ ? typeof statsConfig === "string"
+ ? statsConfig
+ : JSON.stringify(statsConfig)
+ : null,
+ terminalConfig: terminalConfig
+ ? typeof terminalConfig === "string"
+ ? terminalConfig
+ : JSON.stringify(terminalConfig)
+ : null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
notes: notes || null,
sudoPassword: sudoPassword || null,
@@ -444,7 +452,7 @@ router.post(
if (effectiveAuthType === "password") {
sshDataObj.password = password || null;
sshDataObj.key = null;
- sshDataObj.key_password = null;
+ sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (effectiveAuthType === "key") {
if (key && typeof key === "string") {
@@ -481,13 +489,13 @@ router.post(
}
sshDataObj.key = key || null;
- sshDataObj.key_password = keyPassword || null;
+ sshDataObj.keyPassword = keyPassword || null;
sshDataObj.keyType = keyType;
sshDataObj.password = null;
} else {
sshDataObj.password = null;
sshDataObj.key = null;
- sshDataObj.key_password = null;
+ sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
}
@@ -682,16 +690,10 @@ router.post(
const cred = credentials[0];
resolvedPassword = cred.password as string | undefined;
- resolvedKey = (cred.private_key || cred.privateKey || cred.key) as
- | string
- | undefined;
- resolvedKeyPassword = (cred.key_password || cred.keyPassword) as
- | string
- | undefined;
- resolvedKeyType = (cred.key_type || cred.keyType) as string | undefined;
- resolvedAuthType = (cred.auth_type || cred.authType) as
- | string
- | undefined;
+ resolvedKey = cred.privateKey as string | undefined;
+ resolvedKeyPassword = cred.keyPassword as string | undefined;
+ resolvedKeyType = cred.keyType as string | undefined;
+ resolvedAuthType = cred.authType as string | undefined;
if (!overrideCredentialUsername) {
resolvedUsername = cred.username as string;
@@ -909,8 +911,16 @@ router.put(
showDockerInSidebar: showDockerInSidebar ? 1 : 0,
showServerStatsInSidebar: showServerStatsInSidebar ? 1 : 0,
defaultPath: defaultPath || null,
- statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
- terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
+ statsConfig: statsConfig
+ ? typeof statsConfig === "string"
+ ? statsConfig
+ : JSON.stringify(statsConfig)
+ : null,
+ terminalConfig: terminalConfig
+ ? typeof terminalConfig === "string"
+ ? terminalConfig
+ : JSON.stringify(terminalConfig)
+ : null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
notes: notes || null,
sudoPassword: sudoPassword || null,
@@ -929,7 +939,7 @@ router.put(
sshDataObj.password = password;
}
sshDataObj.key = null;
- sshDataObj.key_password = null;
+ sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (effectiveAuthType === "key") {
if (key && typeof key === "string") {
@@ -969,7 +979,7 @@ router.put(
sshDataObj.key = key;
}
if (keyPassword !== undefined) {
- sshDataObj.key_password = keyPassword || null;
+ sshDataObj.keyPassword = keyPassword || null;
}
if (keyType) {
sshDataObj.keyType = keyType;
@@ -978,7 +988,7 @@ router.put(
} else {
sshDataObj.password = null;
sshDataObj.key = null;
- sshDataObj.key_password = null;
+ sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
}
@@ -1055,10 +1065,9 @@ router.put(
hostRecord[0].credentialId !== null &&
sshDataObj.credentialId === null
) {
- const revokedShares = await db
+ await db
.delete(hostAccess)
- .where(eq(hostAccess.hostId, Number(hostId)))
- .returning({ id: hostAccess.id, userId: hostAccess.userId });
+ .where(eq(hostAccess.hostId, Number(hostId)));
}
}
@@ -1189,7 +1198,7 @@ router.get(
authType: sshData.authType,
password: sshData.password,
key: sshData.key,
- keyPassword: sshData.key_password,
+ keyPassword: sshData.keyPassword,
keyType: sshData.keyType,
enableTerminal: sshData.enableTerminal,
enableTunnel: sshData.enableTunnel,
@@ -1264,7 +1273,7 @@ router.get(
const ownHosts = rawData.filter((row) => row.userId === userId);
const sharedHosts = rawData.filter((row) => row.userId !== userId);
- let decryptedOwnHosts: any[] = [];
+ let decryptedOwnHosts: Record[] = [];
try {
decryptedOwnHosts = await SimpleDBOps.select(
Promise.resolve(ownHosts),
@@ -1447,24 +1456,53 @@ router.get(
authType: resolvedHost.authType,
password: resolvedHost.password || null,
key: resolvedHost.key || null,
- keyPassword: resolvedHost.key_password || null,
+ keyPassword: resolvedHost.keyPassword || null,
keyType: resolvedHost.keyType || null,
+ credentialId: resolvedHost.credentialId || null,
+ overrideCredentialUsername: !!resolvedHost.overrideCredentialUsername,
folder: resolvedHost.folder,
tags:
typeof resolvedHost.tags === "string"
? resolvedHost.tags.split(",").filter(Boolean)
: resolvedHost.tags || [],
pin: !!resolvedHost.pin,
+ notes: resolvedHost.notes || null,
enableTerminal: !!resolvedHost.enableTerminal,
enableTunnel: !!resolvedHost.enableTunnel,
enableFileManager: !!resolvedHost.enableFileManager,
+ enableDocker: !!resolvedHost.enableDocker,
+ showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
+ showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
+ showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
+ showDockerInSidebar: !!resolvedHost.showDockerInSidebar,
+ showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar,
defaultPath: resolvedHost.defaultPath,
+ sudoPassword: resolvedHost.sudoPassword || null,
tunnelConnections: resolvedHost.tunnelConnections
? JSON.parse(resolvedHost.tunnelConnections as string)
: [],
+ jumpHosts: resolvedHost.jumpHosts
+ ? JSON.parse(resolvedHost.jumpHosts as string)
+ : null,
+ quickActions: resolvedHost.quickActions
+ ? JSON.parse(resolvedHost.quickActions as string)
+ : null,
+ statsConfig: resolvedHost.statsConfig
+ ? JSON.parse(resolvedHost.statsConfig as string)
+ : null,
+ terminalConfig: resolvedHost.terminalConfig
+ ? JSON.parse(resolvedHost.terminalConfig as string)
+ : null,
+ forceKeyboardInteractive:
+ resolvedHost.forceKeyboardInteractive === "true",
+ useSocks5: !!resolvedHost.useSocks5,
+ socks5Host: resolvedHost.socks5Host || null,
+ socks5Port: resolvedHost.socks5Port || null,
+ socks5Username: resolvedHost.socks5Username || null,
+ socks5Password: resolvedHost.socks5Password || null,
socks5ProxyChain: resolvedHost.socks5ProxyChain
? JSON.parse(resolvedHost.socks5ProxyChain as string)
- : [],
+ : null,
};
sshLogger.success("Host exported with decrypted credentials", {
@@ -1583,7 +1621,6 @@ router.delete(
.delete(sshData)
.where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId)));
- const host = hostToDelete[0];
databaseLogger.success("SSH host deleted", {
operation: "host_delete_success",
userId,
@@ -2417,8 +2454,8 @@ async function resolveHostCredentials(
...host,
password: credential.password,
key: credential.key,
- keyPassword: credential.key_password || credential.keyPassword,
- keyType: credential.key_type || credential.keyType,
+ keyPassword: credential.keyPassword,
+ keyType: credential.keyType,
};
if (!host.overrideCredentialUsername) {
@@ -2429,14 +2466,7 @@ async function resolveHostCredentials(
}
}
- const result = { ...host };
- if (host.key_password !== undefined) {
- if (result.keyPassword === undefined) {
- result.keyPassword = host.key_password;
- }
- delete result.key_password;
- }
- return result;
+ return { ...host };
} catch (error) {
sshLogger.warn(
`Failed to resolve credentials for host ${host.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
@@ -2846,12 +2876,142 @@ router.delete(
* 400:
* description: Invalid request body.
*/
+
+/**
+ * @swagger
+ * /ssh/bulk-update:
+ * patch:
+ * summary: Bulk update partial fields on multiple SSH hosts
+ * tags: [SSH]
+ * security:
+ * - bearerAuth: []
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * hostIds:
+ * type: array
+ * items:
+ * type: number
+ * updates:
+ * type: object
+ * responses:
+ * 200:
+ * description: Bulk update completed.
+ * 400:
+ * description: Invalid request body.
+ */
+router.patch(
+ "/bulk-update",
+ authenticateJWT,
+ async (req: Request, res: Response) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const { hostIds, updates } = req.body;
+
+ if (!Array.isArray(hostIds) || hostIds.length === 0) {
+ return res
+ .status(400)
+ .json({ error: "hostIds array is required and must not be empty" });
+ }
+
+ if (hostIds.length > 1000) {
+ return res
+ .status(400)
+ .json({ error: "Maximum 1000 hosts allowed per bulk update" });
+ }
+
+ if (
+ !updates ||
+ typeof updates !== "object" ||
+ Object.keys(updates).length === 0
+ ) {
+ return res.status(400).json({
+ error: "updates object is required and must contain at least one field",
+ });
+ }
+
+ try {
+ const ownedHosts = await db
+ .select({ id: sshData.id, statsConfig: sshData.statsConfig })
+ .from(sshData)
+ .where(and(inArray(sshData.id, hostIds), eq(sshData.userId, userId)));
+
+ const ownedIds = ownedHosts.map((h) => h.id);
+ const unauthorizedIds = hostIds.filter(
+ (id: number) => !ownedIds.includes(id),
+ );
+
+ if (ownedIds.length === 0) {
+ return res.status(404).json({ error: "No matching hosts found" });
+ }
+
+ const errors: string[] = [];
+ if (unauthorizedIds.length > 0) {
+ errors.push(`${unauthorizedIds.length} host(s) not found or not owned`);
+ }
+
+ const simpleUpdates: Record = {};
+ if (typeof updates.pin === "boolean") simpleUpdates.pin = updates.pin;
+ if (typeof updates.folder === "string")
+ simpleUpdates.folder = updates.folder || null;
+ if (typeof updates.enableTerminal === "boolean")
+ simpleUpdates.enableTerminal = updates.enableTerminal;
+ if (typeof updates.enableTunnel === "boolean")
+ simpleUpdates.enableTunnel = updates.enableTunnel;
+ if (typeof updates.enableFileManager === "boolean")
+ simpleUpdates.enableFileManager = updates.enableFileManager;
+ if (typeof updates.enableDocker === "boolean")
+ simpleUpdates.enableDocker = updates.enableDocker;
+
+ if (Object.keys(simpleUpdates).length > 0) {
+ await db
+ .update(sshData)
+ .set(simpleUpdates)
+ .where(
+ and(inArray(sshData.id, ownedIds), eq(sshData.userId, userId)),
+ );
+ }
+
+ if (updates.statsConfig && typeof updates.statsConfig === "object") {
+ for (const host of ownedHosts) {
+ try {
+ const existing = host.statsConfig
+ ? JSON.parse(host.statsConfig as string)
+ : {};
+ const merged = { ...existing, ...updates.statsConfig };
+ await db
+ .update(sshData)
+ .set({ statsConfig: JSON.stringify(merged) })
+ .where(and(eq(sshData.id, host.id), eq(sshData.userId, userId)));
+ } catch (e) {
+ errors.push(`Failed to update statsConfig for host ${host.id}`);
+ }
+ }
+ }
+
+ DatabaseSaveTrigger.triggerSave("bulk_update");
+
+ return res.json({
+ updated: ownedIds.length,
+ failed: unauthorizedIds.length,
+ errors,
+ });
+ } catch (error) {
+ sshLogger.error("Failed to bulk update hosts:", error);
+ return res.status(500).json({ error: "Failed to bulk update hosts" });
+ }
+ },
+);
+
router.post(
"/bulk-import",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
- const { hosts } = req.body;
+ const { hosts, overwrite } = req.body;
if (!Array.isArray(hosts) || hosts.length === 0) {
return res
@@ -2867,10 +3027,30 @@ router.post(
const results = {
success: 0,
+ updated: 0,
+ skipped: 0,
failed: 0,
errors: [] as string[],
};
+ let existingHostMap: Map | undefined;
+ if (overwrite) {
+ try {
+ const allHosts = await SimpleDBOps.select>(
+ db.select().from(sshData).where(eq(sshData.userId, userId)),
+ "ssh_data",
+ userId,
+ );
+ existingHostMap = new Map();
+ for (const h of allHosts) {
+ const key = `${h.ip}:${h.port}:${h.username}`;
+ existingHostMap.set(key, { id: h.id as number });
+ }
+ } catch {
+ existingHostMap = undefined;
+ }
+ }
+
for (let i = 0; i < hosts.length; i++) {
const hostData = hosts[i];
@@ -2940,9 +3120,7 @@ router.post(
hostData.authType === "credential" ? hostData.credentialId : null,
key: hostData.authType === "key" ? hostData.key : null,
keyPassword:
- hostData.authType === "key"
- ? hostData.keyPassword || hostData.key_password || null
- : null,
+ hostData.authType === "key" ? hostData.keyPassword || null : null,
keyType:
hostData.authType === "key" ? hostData.keyType || "auto" : null,
pin: hostData.pin || false,
@@ -2950,7 +3128,13 @@ router.post(
enableTunnel: hostData.enableTunnel !== false,
enableFileManager: hostData.enableFileManager !== false,
enableDocker: hostData.enableDocker || false,
+ showTerminalInSidebar: hostData.showTerminalInSidebar ? 1 : 0,
+ showFileManagerInSidebar: hostData.showFileManagerInSidebar ? 1 : 0,
+ showTunnelInSidebar: hostData.showTunnelInSidebar ? 1 : 0,
+ showDockerInSidebar: hostData.showDockerInSidebar ? 1 : 0,
+ showServerStatsInSidebar: hostData.showServerStatsInSidebar ? 1 : 0,
defaultPath: hostData.defaultPath || "/",
+ sudoPassword: hostData.sudoPassword || null,
tunnelConnections: hostData.tunnelConnections
? JSON.stringify(hostData.tunnelConnections)
: "[]",
@@ -2981,12 +3165,26 @@ router.post(
overrideCredentialUsername: hostData.overrideCredentialUsername
? 1
: 0,
- createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
- await SimpleDBOps.insert(sshData, "ssh_data", sshDataObj, userId);
- results.success++;
+ const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
+ const existing = existingHostMap?.get(lookupKey);
+
+ if (existing) {
+ await SimpleDBOps.update(
+ sshData,
+ "ssh_data",
+ eq(sshData.id, existing.id),
+ sshDataObj,
+ userId,
+ );
+ results.updated++;
+ } else {
+ sshDataObj.createdAt = new Date().toISOString();
+ await SimpleDBOps.insert(sshData, "ssh_data", sshDataObj, userId);
+ results.success++;
+ }
} catch (error) {
results.failed++;
results.errors.push(
@@ -2996,8 +3194,10 @@ router.post(
}
res.json({
- message: `Import completed: ${results.success} successful, ${results.failed} failed`,
+ message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
success: results.success,
+ updated: results.updated,
+ skipped: results.skipped,
failed: results.failed,
errors: results.errors,
});
@@ -3130,7 +3330,7 @@ router.post(
...tunnel,
endpointPassword: decryptedEndpoint.password || null,
endpointKey: decryptedEndpoint.key || null,
- endpointKeyPassword: decryptedEndpoint.key_password || null,
+ endpointKeyPassword: decryptedEndpoint.keyPassword || null,
endpointAuthType: endpointHost.authType,
};
}
@@ -3153,7 +3353,7 @@ router.post(
.set({
autostartPassword: decryptedConfig.password || null,
autostartKey: decryptedConfig.key || null,
- autostartKeyPassword: decryptedConfig.key_password || null,
+ autostartKeyPassword: decryptedConfig.keyPassword || null,
tunnelConnections: updatedTunnelConnections,
})
.where(eq(sshData.id, sshConfigId));
@@ -3466,9 +3666,17 @@ function rewriteOPKSSHHtml(
): string {
const basePath = `/ssh/${routePrefix}/${requestId}`;
- html = html.replace(/action=["']?\//g, `action="${basePath}/`);
- html = html.replace(/href=["']?\//g, `href="${basePath}/`);
- html = html.replace(/src=["']?\//g, `src="${basePath}/`);
+ const attrPatterns = ["action", "href", "src"];
+ for (const attr of attrPatterns) {
+ html = html.replace(
+ new RegExp(`${attr}="(/[^"]*)`, "g"),
+ `${attr}="${basePath}$1`,
+ );
+ html = html.replace(
+ new RegExp(`${attr}='(/[^']*)`, "g"),
+ `${attr}='${basePath}$1`,
+ );
+ }
html = html.replace(
/href=["']?http:\/\/localhost:\d+\/([^"'\s]*)/g,
@@ -3518,11 +3726,37 @@ function rewriteOPKSSHHtml(
const baseTag = ``;
if (html.includes("]*>/i, baseTag);
- } else {
+ } else if (html.includes("")) {
+ sshLogger.info("Inserting base tag into head", {
+ operation: "opkssh_html_rewrite_base_tag_insert",
+ requestId,
+ basePath,
+ });
html = html.replace(//i, `${baseTag}`);
+ } else {
+ sshLogger.warn("No tag found, wrapping HTML", {
+ operation: "opkssh_html_rewrite_no_head",
+ requestId,
+ htmlLength: html.length,
+ htmlPreview: html.substring(0, 200),
+ });
+ html = `${baseTag}${html}`;
}
+ sshLogger.info("HTML rewrite complete", {
+ operation: "opkssh_html_rewrite_complete",
+ requestId,
+ routePrefix,
+ hasBaseTag: html.includes("
@@ -3682,6 +3934,14 @@ router.use(
const targetUrl = `http://localhost:${session.localPort}${targetPath}`;
+ sshLogger.info("Proxying to OPKSSH chooser", {
+ operation: "opkssh_chooser_proxy_request_to_opkssh",
+ requestId,
+ targetUrl,
+ localPort: session.localPort,
+ targetPath,
+ });
+
const response = await axios({
method: req.method,
url: targetUrl,
@@ -3696,6 +3956,15 @@ router.use(
responseType: "arraybuffer",
});
+ sshLogger.info("OPKSSH chooser response received", {
+ operation: "opkssh_chooser_proxy_response",
+ requestId,
+ statusCode: response.status,
+ contentType: response.headers["content-type"],
+ contentLength: response.headers["content-length"],
+ hasLocation: !!response.headers.location,
+ });
+
Object.entries(response.headers).forEach(([key, value]) => {
if (key.toLowerCase() === "transfer-encoding") {
return;
@@ -3821,57 +4090,103 @@ router.use(
*/
router.get("/opkssh-callback", async (req: Request, res: Response) => {
try {
- const { getActiveSessionsAll } = await import("../../ssh/opkssh-auth.js");
+ sshLogger.info("OAuth callback received", {
+ operation: "opkssh_static_callback_received",
+ url: req.url,
+ originalUrl: req.originalUrl,
+ query: req.query,
+ headers: {
+ host: req.headers.host,
+ "x-forwarded-proto": req.headers["x-forwarded-proto"],
+ "x-forwarded-host": req.headers["x-forwarded-host"],
+ "x-forwarded-port": req.headers["x-forwarded-port"],
+ },
+ });
- const allSessions = getActiveSessionsAll();
- const session = allSessions[allSessions.length - 1];
+ const { getUserIdFromRequest, getActiveSessionsForUser } =
+ await import("../../ssh/opkssh-auth.js");
- if (!session) {
+ const userId = await getUserIdFromRequest({
+ cookies: req.cookies,
+ headers: req.headers as Record,
+ });
+
+ sshLogger.info("User ID resolved", {
+ operation: "opkssh_callback_user_lookup",
+ userId: userId || "null",
+ hasCookies: !!req.cookies?.jwt,
+ cookieKeys: Object.keys(req.cookies || {}),
+ });
+
+ if (!userId) {
+ sshLogger.error("No userId from callback request", {
+ operation: "opkssh_callback_unauthorized",
+ cookies: Object.keys(req.cookies || {}),
+ headers: Object.keys(req.headers),
+ });
+ res.status(401).send("Unauthorized - no valid session");
+ return;
+ }
+
+ const userSessions = getActiveSessionsForUser(userId);
+
+ sshLogger.info("Active sessions for user", {
+ operation: "opkssh_callback_session_lookup",
+ userId,
+ sessionCount: userSessions.length,
+ sessions: userSessions.map((s) => ({
+ requestId: s.requestId,
+ status: s.status,
+ hasCallbackPort: !!s.callbackPort,
+ callbackPort: s.callbackPort,
+ hasLocalPort: !!s.localPort,
+ localPort: s.localPort,
+ })),
+ });
+
+ if (userSessions.length === 0) {
+ sshLogger.error("No active sessions for callback", {
+ operation: "opkssh_callback_no_sessions",
+ userId,
+ });
res.status(404).send("No active authentication session found");
return;
}
+ const session = userSessions[userSessions.length - 1];
+
if (!session.callbackPort) {
+ sshLogger.error("Session callback port not ready", {
+ operation: "opkssh_callback_port_not_ready",
+ userId,
+ requestId: session.requestId,
+ sessionStatus: session.status,
+ hasLocalPort: !!session.localPort,
+ });
res.status(503).send("OPKSSH callback listener not ready yet");
return;
}
- const axios = (await import("axios")).default;
const queryString = req.url.includes("?")
? req.url.substring(req.url.indexOf("?"))
: "";
- const targetUrl = `http://localhost:${session.callbackPort}/login-callback${queryString}`;
+ const redirectUrl = `/ssh/opkssh-callback/${session.requestId}/login-callback${queryString}`;
- sshLogger.info("Proxying OAuth callback to OPKSSH", {
- operation: "opkssh_static_callback",
+ sshLogger.info("Redirecting OAuth callback to dynamic route", {
+ operation: "opkssh_static_callback_redirect",
+ userId,
+ requestId: session.requestId,
callbackPort: session.callbackPort,
- targetUrl,
+ queryParams: Object.keys(req.query),
+ redirectUrl,
});
- const response = await axios({
- method: req.method,
- url: targetUrl,
- headers: {
- ...req.headers,
- host: `localhost:${session.callbackPort}`,
- },
- data: req.body,
- timeout: 10000,
- validateStatus: () => true,
- maxRedirects: 0,
- responseType: "arraybuffer",
- });
-
- Object.entries(response.headers).forEach(([key, value]) => {
- if (key.toLowerCase() !== "transfer-encoding") {
- res.setHeader(key, value as string);
- }
- });
-
- res.status(response.status).send(response.data);
+ res.redirect(302, redirectUrl);
} catch (error) {
sshLogger.error("Error handling OPKSSH static callback", error, {
operation: "opkssh_static_callback_error",
+ url: req.url,
+ originalUrl: req.originalUrl,
});
res.status(500).send("Authentication callback failed");
}
@@ -4139,4 +4454,80 @@ router.use(
},
);
+/**
+ * @openapi
+ * /db/proxy/test:
+ * post:
+ * summary: Test proxy connectivity
+ * description: Tests connectivity through a proxy configuration to a target host.
+ * tags:
+ * - SSH
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * singleProxy:
+ * type: object
+ * properties:
+ * host:
+ * type: string
+ * port:
+ * type: number
+ * type:
+ * type: string
+ * username:
+ * type: string
+ * password:
+ * type: string
+ * proxyChain:
+ * type: array
+ * items:
+ * type: object
+ * testTarget:
+ * type: object
+ * properties:
+ * host:
+ * type: string
+ * port:
+ * type: number
+ * responses:
+ * 200:
+ * description: Test result
+ * 500:
+ * description: Proxy connection failed
+ */
+router.post(
+ "/db/proxy/test",
+ authenticateJWT,
+ requireDataAccess,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ const { singleProxy, proxyChain, testTarget } = req.body;
+
+ const { testProxyConnectivity } =
+ await import("../../utils/proxy-helper.js");
+
+ const result = await testProxyConnectivity({
+ singleProxy,
+ proxyChain,
+ testTarget,
+ });
+
+ res.json(result);
+ } catch (error) {
+ sshLogger.error("Proxy connectivity test failed", error, {
+ operation: "proxy_test",
+ userId: req.userId,
+ });
+ res.status(500).json({
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error",
+ });
+ }
+ },
+);
+
export default router;
diff --git a/src/backend/database/routes/terminal.ts b/src/backend/database/routes/terminal.ts
index be48359e..6b35107e 100644
--- a/src/backend/database/routes/terminal.ts
+++ b/src/backend/database/routes/terminal.ts
@@ -285,4 +285,118 @@ router.delete(
},
);
+/**
+ * @openapi
+ * /terminal/session_settings:
+ * get:
+ * summary: Get session persistence settings
+ * description: Returns the session timeout and persistence enabled flag.
+ * tags:
+ * - Terminal
+ * responses:
+ * 200:
+ * description: Session settings.
+ * 500:
+ * description: Failed to fetch settings.
+ */
+router.get(
+ "/session_settings",
+ authenticateJWT,
+ async (_req: Request, res: Response) => {
+ try {
+ const timeoutRow = db.$client
+ .prepare(
+ "SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'",
+ )
+ .get() as { value: string } | undefined;
+ const enabledRow = db.$client
+ .prepare(
+ "SELECT value FROM settings WHERE key = 'terminal_session_persistence_enabled'",
+ )
+ .get() as { value: string } | undefined;
+
+ res.json({
+ timeoutMinutes: timeoutRow ? parseInt(timeoutRow.value, 10) : 30,
+ enabled: enabledRow ? enabledRow.value === "true" : true,
+ });
+ } catch (err) {
+ authLogger.error("Failed to fetch session settings", err);
+ res.status(500).json({
+ error: err instanceof Error ? err.message : "Failed to fetch settings",
+ });
+ }
+ },
+);
+
+/**
+ * @openapi
+ * /terminal/session_settings:
+ * post:
+ * summary: Update session persistence settings
+ * description: Saves session timeout and persistence enabled flag.
+ * tags:
+ * - Terminal
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * timeoutMinutes:
+ * type: integer
+ * enabled:
+ * type: boolean
+ * responses:
+ * 200:
+ * description: Settings saved successfully.
+ * 400:
+ * description: Invalid parameters.
+ * 500:
+ * description: Failed to save settings.
+ */
+router.post(
+ "/session_settings",
+ authenticateJWT,
+ async (req: Request, res: Response) => {
+ const { timeoutMinutes, enabled } = req.body;
+
+ if (
+ timeoutMinutes !== undefined &&
+ (typeof timeoutMinutes !== "number" ||
+ timeoutMinutes < 1 ||
+ timeoutMinutes > 1440)
+ ) {
+ return res
+ .status(400)
+ .json({ error: "timeoutMinutes must be between 1 and 1440" });
+ }
+
+ try {
+ if (timeoutMinutes !== undefined) {
+ db.$client
+ .prepare(
+ "INSERT OR REPLACE INTO settings (key, value) VALUES ('terminal_session_timeout_minutes', ?)",
+ )
+ .run(String(timeoutMinutes));
+ }
+
+ if (enabled !== undefined) {
+ db.$client
+ .prepare(
+ "INSERT OR REPLACE INTO settings (key, value) VALUES ('terminal_session_persistence_enabled', ?)",
+ )
+ .run(String(enabled));
+ }
+
+ res.json({ success: true });
+ } catch (err) {
+ authLogger.error("Failed to save session settings", err);
+ res.status(500).json({
+ error: err instanceof Error ? err.message : "Failed to save settings",
+ });
+ }
+ },
+);
+
export default router;
diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts
index 10612b17..63b10212 100644
--- a/src/backend/database/routes/users.ts
+++ b/src/backend/database/routes/users.ts
@@ -24,26 +24,28 @@ import {
sharedCredentials,
auditLogs,
sessionRecordings,
+ networkTopology,
+ dashboardPreferences,
+ opksshTokens,
} from "../db/schema.js";
-import { eq, and } from "drizzle-orm";
+import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
import speakeasy from "speakeasy";
import QRCode from "qrcode";
import type { Request, Response } from "express";
-import { authLogger, databaseLogger } from "../../utils/logger.js";
+import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
-import { parseUserAgent } from "../../utils/user-agent-parser.js";
+import {
+ parseUserAgent,
+ generateDeviceFingerprint,
+} from "../../utils/user-agent-parser.js";
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
const authManager = AuthManager.getInstance();
-/**
- * Get OIDC configuration from environment variables.
- * Returns null if required env vars are not set.
- */
function getOIDCConfigFromEnv(): {
client_id: string;
client_secret: string;
@@ -54,6 +56,7 @@ function getOIDCConfigFromEnv(): {
identifier_path: string;
name_path: string;
scopes: string;
+ allowed_users: string;
} | null {
const client_id = process.env.OIDC_CLIENT_ID;
const client_secret = process.env.OIDC_CLIENT_SECRET;
@@ -81,9 +84,40 @@ function getOIDCConfigFromEnv(): {
identifier_path: process.env.OIDC_IDENTIFIER_PATH || "sub",
name_path: process.env.OIDC_NAME_PATH || "name",
scopes: process.env.OIDC_SCOPES || "openid email profile",
+ allowed_users: process.env.OIDC_ALLOWED_USERS || "",
};
}
+function isOIDCUserAllowed(
+ allowedUsers: string,
+ identifier: string,
+ email?: string,
+): boolean {
+ if (!allowedUsers || !allowedUsers.trim()) return true;
+ const patterns = allowedUsers
+ .split(",")
+ .map((p) => p.trim())
+ .filter(Boolean);
+ if (patterns.length === 0) return true;
+
+ const values = [
+ identifier,
+ ...(email && email !== identifier ? [email] : []),
+ ];
+ for (const pattern of patterns) {
+ if (pattern === "*") return true;
+ for (const value of values) {
+ if (!value) continue;
+ if (pattern.toLowerCase().startsWith("@")) {
+ if (value.toLowerCase().endsWith(pattern.toLowerCase())) return true;
+ } else {
+ if (value.toLowerCase() === pattern.toLowerCase()) return true;
+ }
+ }
+ }
+ return false;
+}
+
async function verifyOIDCToken(
idToken: string,
issuerUrl: string,
@@ -137,6 +171,7 @@ async function verifyOIDCToken(
);
}
} else {
+ // expected - non-ok response, try next URL
}
} catch {
continue;
@@ -232,6 +267,12 @@ async function deleteUserAndRelatedData(userId: string): Promise {
await db.delete(sshData).where(eq(sshData.userId, userId));
await db.delete(sshCredentials).where(eq(sshCredentials.userId, userId));
+ await db.delete(networkTopology).where(eq(networkTopology.userId, userId));
+ await db
+ .delete(dashboardPreferences)
+ .where(eq(dashboardPreferences.userId, userId));
+ await db.delete(opksshTokens).where(eq(opksshTokens.userId, userId));
+
db.$client
.prepare("DELETE FROM settings WHERE key LIKE ?")
.run(`user_%_${userId}`);
@@ -345,20 +386,20 @@ router.post("/create", async (req, res) => {
await db.insert(users).values({
id,
username,
- password_hash,
- is_admin: isFirstUser,
- is_oidc: false,
- client_id: "",
- client_secret: "",
- issuer_url: "",
- authorization_url: "",
- token_url: "",
- identifier_path: "",
- name_path: "",
+ passwordHash: password_hash,
+ isAdmin: isFirstUser,
+ isOidc: false,
+ clientId: "",
+ clientSecret: "",
+ issuerUrl: "",
+ authorizationUrl: "",
+ tokenUrl: "",
+ identifierPath: "",
+ namePath: "",
scopes: "openid email profile",
- totp_secret: null,
- totp_enabled: false,
- totp_backup_codes: null,
+ totpSecret: null,
+ totpEnabled: false,
+ totpBackupCodes: null,
});
try {
@@ -453,7 +494,7 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
- if (!user || user.length === 0 || !user[0].is_admin) {
+ if (!user || user.length === 0 || !user[0].isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
@@ -467,6 +508,7 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => {
identifier_path,
name_path,
scopes,
+ allowed_users,
} = req.body;
const isDisableRequest =
@@ -524,6 +566,7 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => {
identifier_path,
name_path,
scopes: scopes || "openid email profile",
+ allowed_users: allowed_users || "",
};
let encryptedConfig;
@@ -603,7 +646,7 @@ router.delete("/oidc-config", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
- if (!user || user.length === 0 || !user[0].is_admin) {
+ if (!user || user.length === 0 || !user[0].isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
@@ -707,7 +750,7 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
} else {
config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]";
}
- } catch (decryptError) {
+ } catch {
authLogger.warn("Failed to decrypt OIDC config for admin", {
operation: "oidc_config_decrypt_failed",
userId,
@@ -721,7 +764,7 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
"base64",
).toString("utf8");
config.client_secret = decoded;
- } catch (decodeError) {
+ } catch {
authLogger.warn("Failed to decode OIDC config for admin", {
operation: "oidc_config_decode_failed",
userId,
@@ -755,6 +798,17 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
*/
router.get("/oidc/authorize", async (req, res) => {
try {
+ authLogger.info("OIDC authorize request headers", {
+ protocol: req.protocol,
+ host: req.get("Host"),
+ origin: req.get("Origin"),
+ referer: req.get("Referer"),
+ "x-forwarded-proto": req.get("X-Forwarded-Proto"),
+ "x-forwarded-host": req.get("X-Forwarded-Host"),
+ "x-forwarded-port": req.get("X-Forwarded-Port"),
+ secure: req.secure,
+ });
+
const envConfig = getOIDCConfigFromEnv();
let config;
@@ -772,28 +826,46 @@ router.get("/oidc/authorize", async (req, res) => {
const state = nanoid();
const nonce = nanoid();
- let origin =
- req.get("Origin") ||
- req.get("Referer")?.replace(/\/[^/]*$/, "") ||
- "http://localhost:5173";
+ const protocol =
+ process.env.OIDC_FORCE_HTTPS === "true"
+ ? "https"
+ : req.get("X-Forwarded-Proto") || req.protocol;
- if (origin.includes("localhost")) {
- origin = "http://localhost:30001";
+ const host = req.get("Host");
+ const origin = `${protocol}://${host}`;
+ const backendCallbackUri = `${origin}/users/oidc/callback`;
+
+ const referer = req.get("Referer");
+ let frontendOrigin;
+ if (referer) {
+ const refererUrl = new URL(referer);
+ frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`;
+ } else {
+ frontendOrigin = origin;
}
- const redirectUri = `${origin}/users/oidc/callback`;
-
db.$client
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.run(`oidc_state_${state}`, nonce);
db.$client
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
- .run(`oidc_redirect_${state}`, redirectUri);
+ .run(`oidc_backend_callback_${state}`, backendCallbackUri);
+
+ db.$client
+ .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
+ .run(`oidc_frontend_origin_${state}`, frontendOrigin);
+
+ authLogger.info("OIDC authorization initiated", {
+ operation: "oidc_authorize",
+ backendCallbackUri,
+ frontendOrigin,
+ referer,
+ });
const authUrl = new URL(config.authorization_url);
authUrl.searchParams.set("client_id", config.client_id);
- authUrl.searchParams.set("redirect_uri", redirectUri);
+ authUrl.searchParams.set("redirect_uri", backendCallbackUri);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", config.scopes);
authUrl.searchParams.set("state", state);
@@ -831,15 +903,23 @@ router.get("/oidc/callback", async (req, res) => {
return res.status(400).json({ error: "Code and state are required" });
}
- const storedRedirectRow = db.$client
+ const storedBackendCallbackRow = db.$client
.prepare("SELECT value FROM settings WHERE key = ?")
- .get(`oidc_redirect_${state}`);
- if (!storedRedirectRow) {
+ .get(`oidc_backend_callback_${state}`);
+ const storedFrontendOriginRow = db.$client
+ .prepare("SELECT value FROM settings WHERE key = ?")
+ .get(`oidc_frontend_origin_${state}`);
+
+ if (!storedBackendCallbackRow || !storedFrontendOriginRow) {
return res
.status(400)
- .json({ error: "Invalid state parameter - redirect URI not found" });
+ .json({ error: "Invalid state parameter - redirect URIs not found" });
}
- const redirectUri = (storedRedirectRow as Record)
+
+ const backendCallbackUri = (
+ storedBackendCallbackRow as Record
+ ).value as string;
+ const frontendOrigin = (storedFrontendOriginRow as Record)
.value as string;
try {
@@ -850,13 +930,6 @@ router.get("/oidc/callback", async (req, res) => {
return res.status(400).json({ error: "Invalid state parameter" });
}
- db.$client
- .prepare("DELETE FROM settings WHERE key = ?")
- .run(`oidc_state_${state}`);
- db.$client
- .prepare("DELETE FROM settings WHERE key = ?")
- .run(`oidc_redirect_${state}`);
-
const envConfig = getOIDCConfigFromEnv();
let config;
@@ -874,6 +947,12 @@ router.get("/oidc/callback", async (req, res) => {
);
}
+ authLogger.info("OIDC token exchange attempt", {
+ operation: "oidc_token_exchange",
+ backendCallbackUri,
+ frontendOrigin,
+ });
+
const tokenResponse = await fetch(config.token_url, {
method: "POST",
headers: {
@@ -885,15 +964,20 @@ router.get("/oidc/callback", async (req, res) => {
client_id: config.client_id,
client_secret: config.client_secret,
code: code,
- redirect_uri: redirectUri,
+ redirect_uri: backendCallbackUri,
}),
});
if (!tokenResponse.ok) {
- authLogger.error(
- "OIDC token exchange failed",
- await tokenResponse.text(),
- );
+ const errorText = await tokenResponse.text();
+ authLogger.error("OIDC token exchange failed", {
+ operation: "oidc_token_exchange_failed",
+ status: tokenResponse.status,
+ statusText: tokenResponse.statusText,
+ backendCallbackUri,
+ frontendOrigin,
+ errorResponse: errorText,
+ });
return res
.status(400)
.json({ error: "Failed to exchange authorization code" });
@@ -901,6 +985,16 @@ router.get("/oidc/callback", async (req, res) => {
const tokenData = (await tokenResponse.json()) as Record;
+ db.$client
+ .prepare("DELETE FROM settings WHERE key = ?")
+ .run(`oidc_state_${state}`);
+ db.$client
+ .prepare("DELETE FROM settings WHERE key = ?")
+ .run(`oidc_backend_callback_${state}`);
+ db.$client
+ .prepare("DELETE FROM settings WHERE key = ?")
+ .run(`oidc_frontend_origin_${state}`);
+
let userInfo: Record = null;
const userInfoUrls: string[] = [];
@@ -1032,7 +1126,7 @@ router.get("/oidc/callback", async (req, res) => {
let user = await db
.select()
.from(users)
- .where(eq(users.oidc_identifier, identifier));
+ .where(eq(users.oidcIdentifier, identifier));
let isFirstUser = false;
if (!user || user.length === 0) {
@@ -1041,6 +1135,20 @@ router.get("/oidc/callback", async (req, res) => {
.get();
isFirstUser = ((countResult as { count?: number })?.count || 0) === 0;
+ if (!isFirstUser && config.allowed_users) {
+ const email = userInfo.email as string | undefined;
+ if (!isOIDCUserAllowed(config.allowed_users, identifier, email)) {
+ authLogger.warn("OIDC user not in allowed list", {
+ operation: "oidc_user_not_allowed",
+ identifier,
+ email,
+ });
+ const redirectUrl = new URL(frontendOrigin);
+ redirectUrl.searchParams.set("error", "user_not_allowed");
+ return res.redirect(redirectUrl.toString());
+ }
+ }
+
if (!isFirstUser) {
try {
const regRow = db.$client
@@ -1058,14 +1166,7 @@ router.get("/oidc/callback", async (req, res) => {
},
);
- let frontendUrl = (redirectUri as string).replace(
- "/users/oidc/callback",
- "",
- );
- if (frontendUrl.includes("localhost")) {
- frontendUrl = "http://localhost:5173";
- }
- const redirectUrl = new URL(frontendUrl);
+ const redirectUrl = new URL(frontendOrigin);
redirectUrl.searchParams.set("error", "registration_disabled");
return res.redirect(redirectUrl.toString());
@@ -1082,17 +1183,17 @@ router.get("/oidc/callback", async (req, res) => {
await db.insert(users).values({
id,
username: name,
- password_hash: "",
- is_admin: isFirstUser,
- is_oidc: true,
- oidc_identifier: identifier,
- client_id: String(config.client_id),
- client_secret: String(config.client_secret),
- issuer_url: String(config.issuer_url),
- authorization_url: String(config.authorization_url),
- token_url: String(config.token_url),
- identifier_path: String(config.identifier_path),
- name_path: String(config.name_path),
+ passwordHash: "",
+ isAdmin: isFirstUser,
+ isOidc: true,
+ oidcIdentifier: identifier,
+ clientId: String(config.client_id),
+ clientSecret: String(config.client_secret),
+ issuerUrl: String(config.issuer_url),
+ authorizationUrl: String(config.authorization_url),
+ tokenUrl: String(config.token_url),
+ identifierPath: String(config.identifier_path),
+ namePath: String(config.name_path),
scopes: String(config.scopes),
});
@@ -1135,7 +1236,7 @@ router.get("/oidc/callback", async (req, res) => {
const sessionDurationMs =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? 30 * 24 * 60 * 60 * 1000
- : 7 * 24 * 60 * 60 * 1000;
+ : 2 * 60 * 60 * 1000;
await authManager.registerOIDCUser(id, sessionDurationMs);
} catch (encryptionError) {
await db.delete(users).where(eq(users.id, id));
@@ -1164,8 +1265,23 @@ router.get("/oidc/callback", async (req, res) => {
user = await db.select().from(users).where(eq(users.id, id));
} else {
+ if (config.allowed_users) {
+ const email = userInfo.email as string | undefined;
+ if (!isOIDCUserAllowed(config.allowed_users, identifier, email)) {
+ authLogger.warn("OIDC user not in allowed list (existing user)", {
+ operation: "oidc_user_not_allowed_existing",
+ identifier,
+ email,
+ userId: user[0].id,
+ });
+ const redirectUrl = new URL(frontendOrigin);
+ redirectUrl.searchParams.set("error", "user_not_allowed");
+ return res.redirect(redirectUrl.toString());
+ }
+ }
+
const isDualAuth =
- user[0].password_hash && user[0].password_hash.trim() !== "";
+ user[0].passwordHash && user[0].passwordHash.trim() !== "";
if (!isDualAuth) {
await db
@@ -1188,6 +1304,15 @@ router.get("/oidc/callback", async (req, res) => {
});
}
+ try {
+ const { SharedCredentialManager } =
+ await import("../../utils/shared-credential-manager.js");
+ const sharedCredManager = SharedCredentialManager.getInstance();
+ await sharedCredManager.reEncryptPendingCredentialsForUser(userRecord.id);
+ } catch {
+ // expected - re-encryption may fail if no pending credentials
+ }
+
const token = await authManager.generateJWTToken(userRecord.id, {
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
@@ -1199,22 +1324,13 @@ router.get("/oidc/callback", async (req, res) => {
username: userRecord.username,
});
- let frontendUrl = (redirectUri as string).replace(
- "/users/oidc/callback",
- "",
- );
-
- if (frontendUrl.includes("localhost")) {
- frontendUrl = "http://localhost:5173";
- }
-
- const redirectUrl = new URL(frontendUrl);
+ const redirectUrl = new URL(frontendOrigin);
redirectUrl.searchParams.set("success", "true");
const maxAge =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? 30 * 24 * 60 * 60 * 1000
- : 7 * 24 * 60 * 60 * 1000;
+ : 2 * 60 * 60 * 1000;
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
@@ -1224,16 +1340,7 @@ router.get("/oidc/callback", async (req, res) => {
} catch (err) {
authLogger.error("OIDC callback failed", err);
- let frontendUrl = (redirectUri as string).replace(
- "/users/oidc/callback",
- "",
- );
-
- if (frontendUrl.includes("localhost")) {
- frontendUrl = "http://localhost:5173";
- }
-
- const redirectUrl = new URL(frontendUrl);
+ const redirectUrl = new URL(frontendOrigin);
redirectUrl.searchParams.set("error", "OIDC authentication failed");
res.redirect(redirectUrl.toString());
@@ -1274,7 +1381,7 @@ router.get("/oidc/callback", async (req, res) => {
* description: Login failed.
*/
router.post("/login", async (req, res) => {
- const { username, password } = req.body;
+ const { username, password, rememberMe } = req.body;
const clientIp = req.ip || req.socket.remoteAddress || "unknown";
authLogger.info("User login request received", {
operation: "user_login_request",
@@ -1344,8 +1451,8 @@ router.post("/login", async (req, res) => {
const userRecord = user[0];
if (
- userRecord.is_oidc &&
- (!userRecord.password_hash || userRecord.password_hash.trim() === "")
+ userRecord.isOidc &&
+ (!userRecord.passwordHash || userRecord.passwordHash.trim() === "")
) {
authLogger.warn("OIDC-only user attempted traditional login", {
operation: "user_login",
@@ -1357,7 +1464,7 @@ router.post("/login", async (req, res) => {
.json({ error: "This user uses external authentication" });
}
- const isMatch = await bcrypt.compare(password, userRecord.password_hash);
+ const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
if (!isMatch) {
loginRateLimiter.recordFailedAttempt(clientIp, username);
authLogger.warn(`Login failed: incorrect password`, {
@@ -1382,12 +1489,14 @@ router.post("/login", async (req, res) => {
if (kekSalt.length === 0) {
await authManager.registerUser(userRecord.id, password);
}
- } catch (error) {}
+ } catch {
+ // expected - KEK salt registration may fail for existing users
+ }
const deviceInfo = parseUserAgent(req);
let dataUnlocked = false;
- if (userRecord.is_oidc) {
+ if (userRecord.isOidc) {
dataUnlocked = await authManager.authenticateOIDCUser(
userRecord.id,
deviceInfo.type,
@@ -1417,19 +1526,35 @@ router.post("/login", async (req, res) => {
});
}
- if (userRecord.totp_enabled) {
- const tempToken = await authManager.generateJWTToken(userRecord.id, {
- pendingTOTP: true,
- expiresIn: "10m",
- });
- return res.json({
- success: true,
- requires_totp: true,
- temp_token: tempToken,
- });
+ if (userRecord.totpEnabled) {
+ const deviceFingerprint = generateDeviceFingerprint(deviceInfo);
+
+ const isTrusted = rememberMe
+ ? await authManager.isTrustedDevice(userRecord.id, deviceFingerprint)
+ : false;
+
+ if (isTrusted) {
+ authLogger.info("TOTP bypassed for trusted device", {
+ operation: "totp_bypass",
+ userId: userRecord.id,
+ deviceFingerprint,
+ });
+ } else {
+ const tempToken = await authManager.generateJWTToken(userRecord.id, {
+ pendingTOTP: true,
+ expiresIn: "10m",
+ });
+ return res.json({
+ success: true,
+ requires_totp: true,
+ temp_token: tempToken,
+ rememberMe: !!rememberMe,
+ });
+ }
}
const token = await authManager.generateJWTToken(userRecord.id, {
+ rememberMe: !!rememberMe,
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
});
@@ -1446,7 +1571,7 @@ router.post("/login", async (req, res) => {
const response: Record = {
success: true,
- is_admin: !!userRecord.is_admin,
+ is_admin: !!userRecord.isAdmin,
username: userRecord.username,
};
@@ -1458,10 +1583,7 @@ router.post("/login", async (req, res) => {
response.token = token;
}
- const maxAge =
- deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
- ? 30 * 24 * 60 * 60 * 1000
- : 7 * 24 * 60 * 60 * 1000;
+ const maxAge = rememberMe ? 30 * 24 * 60 * 60 * 1000 : 2 * 60 * 60 * 1000;
return res
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
@@ -1500,7 +1622,9 @@ router.post("/logout", authenticateJWT, async (req, res) => {
try {
const payload = await authManager.verifyJWTToken(token);
sessionId = payload?.sessionId;
- } catch (error) {}
+ } catch {
+ // expected - token verification may fail during logout
+ }
}
await authManager.logoutUser(userId, sessionId);
@@ -1551,17 +1675,17 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
}
const hasPassword =
- user[0].password_hash && user[0].password_hash.trim() !== "";
- const hasOidc = user[0].is_oidc && user[0].oidc_identifier;
+ user[0].passwordHash && user[0].passwordHash.trim() !== "";
+ const hasOidc = user[0].isOidc && user[0].oidcIdentifier;
const isDualAuth = hasPassword && hasOidc;
res.json({
userId: user[0].id,
username: user[0].username,
- is_admin: !!user[0].is_admin,
- is_oidc: !!user[0].is_oidc,
+ is_admin: !!user[0].isAdmin,
+ is_oidc: !!user[0].isOidc,
is_dual_auth: isDualAuth,
- totp_enabled: !!user[0].totp_enabled,
+ totp_enabled: !!user[0].totpEnabled,
});
} catch (err) {
authLogger.error("Failed to get username", err);
@@ -1619,7 +1743,7 @@ router.get("/count", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
- if (!user[0] || !user[0].is_admin) {
+ if (!user[0] || !user[0].isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}
@@ -1717,7 +1841,7 @@ router.patch("/registration-allowed", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
- if (!user || user.length === 0 || !user[0].is_admin) {
+ if (!user || user.length === 0 || !user[0].isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
const { allowed } = req.body;
@@ -1793,7 +1917,7 @@ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
- if (!user || user.length === 0 || !user[0].is_admin) {
+ if (!user || user.length === 0 || !user[0].isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
const { allowed } = req.body;
@@ -1871,7 +1995,7 @@ router.patch("/password-reset-allowed", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
- if (!user || user.length === 0 || !user[0].is_admin) {
+ if (!user || user.length === 0 || !user[0].isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
const { allowed } = req.body;
@@ -1939,14 +2063,14 @@ router.delete("/delete-account", authenticateJWT, async (req, res) => {
const userRecord = user[0];
- if (userRecord.is_oidc) {
+ if (userRecord.isOidc) {
return res.status(403).json({
error:
"Cannot delete external authentication accounts through this endpoint",
});
}
- const isMatch = await bcrypt.compare(password, userRecord.password_hash);
+ const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
if (!isMatch) {
authLogger.warn(
`Incorrect password provided for account deletion: ${userRecord.username}`,
@@ -1954,7 +2078,7 @@ router.delete("/delete-account", authenticateJWT, async (req, res) => {
return res.status(401).json({ error: "Incorrect password" });
}
- if (userRecord.is_admin) {
+ if (userRecord.isAdmin) {
const adminCount = db.$client
.prepare("SELECT COUNT(*) as count FROM users WHERE is_admin = 1")
.get();
@@ -2040,7 +2164,7 @@ router.post("/initiate-reset", async (req, res) => {
return res.status(404).json({ error: "User not found" });
}
- if (user[0].is_oidc) {
+ if (user[0].isOidc) {
return res.status(403).json({
error: "Password reset not available for external authentication users",
});
@@ -2308,7 +2432,7 @@ router.post("/complete-reset", async (req, res) => {
await db
.update(users)
- .set({ password_hash })
+ .set({ passwordHash: password_hash })
.where(eq(users.id, userId));
authManager.logoutUser(userId);
authLogger.success(
@@ -2336,7 +2460,7 @@ router.post("/complete-reset", async (req, res) => {
} else {
await db
.update(users)
- .set({ password_hash })
+ .set({ passwordHash: password_hash })
.where(eq(users.username, username));
try {
@@ -2370,9 +2494,9 @@ router.post("/complete-reset", async (req, res) => {
await db
.update(users)
.set({
- totp_enabled: false,
- totp_secret: null,
- totp_backup_codes: null,
+ totpEnabled: false,
+ totpSecret: null,
+ totpBackupCodes: null,
})
.where(eq(users.id, userId));
@@ -2468,7 +2592,7 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
return res.status(404).json({ error: "User not found" });
}
- const isMatch = await bcrypt.compare(oldPassword, user[0].password_hash);
+ const isMatch = await bcrypt.compare(oldPassword, user[0].passwordHash);
if (!isMatch) {
authLogger.warn("Password change failed - old password incorrect", {
operation: "password_change_failed",
@@ -2491,7 +2615,10 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
const saltRounds = parseInt(process.env.SALT || "10", 10);
const password_hash = await bcrypt.hash(newPassword, saltRounds);
- await db.update(users).set({ password_hash }).where(eq(users.id, userId));
+ await db
+ .update(users)
+ .set({ passwordHash: password_hash })
+ .where(eq(users.id, userId));
authManager.logoutUser(userId);
authLogger.success("Password changed successfully", {
@@ -2522,7 +2649,7 @@ router.get("/list", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
- if (!user || user.length === 0 || !user[0].is_admin) {
+ if (!user || user.length === 0 || !user[0].isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
@@ -2530,9 +2657,9 @@ router.get("/list", authenticateJWT, async (req, res) => {
.select({
id: users.id,
username: users.username,
- is_admin: users.is_admin,
- is_oidc: users.is_oidc,
- password_hash: users.password_hash,
+ isAdmin: users.isAdmin,
+ isOidc: users.isOidc,
+ passwordHash: users.passwordHash,
})
.from(users);
@@ -2582,7 +2709,7 @@ router.post("/make-admin", authenticateJWT, async (req, res) => {
try {
const adminUser = await db.select().from(users).where(eq(users.id, userId));
- if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) {
+ if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
@@ -2594,13 +2721,13 @@ router.post("/make-admin", authenticateJWT, async (req, res) => {
return res.status(404).json({ error: "User not found" });
}
- if (targetUser[0].is_admin) {
+ if (targetUser[0].isAdmin) {
return res.status(400).json({ error: "User is already an admin" });
}
await db
.update(users)
- .set({ is_admin: true })
+ .set({ isAdmin: true })
.where(eq(users.username, username));
try {
@@ -2665,7 +2792,7 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => {
try {
const adminUser = await db.select().from(users).where(eq(users.id, userId));
- if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) {
+ if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
@@ -2683,13 +2810,13 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => {
return res.status(404).json({ error: "User not found" });
}
- if (!targetUser[0].is_admin) {
+ if (!targetUser[0].isAdmin) {
return res.status(400).json({ error: "User is not an admin" });
}
await db
.update(users)
- .set({ is_admin: false })
+ .set({ isAdmin: false })
.where(eq(users.username, username));
try {
@@ -2744,7 +2871,7 @@ router.post("/totp/setup", authenticateJWT, async (req, res) => {
const userRecord = user[0];
- if (userRecord.totp_enabled) {
+ if (userRecord.totpEnabled) {
return res.status(400).json({ error: "TOTP is already enabled" });
}
@@ -2755,7 +2882,7 @@ router.post("/totp/setup", authenticateJWT, async (req, res) => {
await db
.update(users)
- .set({ totp_secret: secret.base32 })
+ .set({ totpSecret: secret.base32 })
.where(eq(users.id, userId));
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url || "");
@@ -2815,16 +2942,16 @@ router.post("/totp/enable", authenticateJWT, async (req, res) => {
const userRecord = user[0];
- if (userRecord.totp_enabled) {
+ if (userRecord.totpEnabled) {
return res.status(400).json({ error: "TOTP is already enabled" });
}
- if (!userRecord.totp_secret) {
+ if (!userRecord.totpSecret) {
return res.status(400).json({ error: "TOTP setup not initiated" });
}
const verified = speakeasy.totp.verify({
- secret: userRecord.totp_secret,
+ secret: userRecord.totpSecret,
encoding: "base32",
token: totp_code,
window: 2,
@@ -2841,8 +2968,8 @@ router.post("/totp/enable", authenticateJWT, async (req, res) => {
await db
.update(users)
.set({
- totp_enabled: true,
- totp_backup_codes: JSON.stringify(backupCodes),
+ totpEnabled: true,
+ totpBackupCodes: JSON.stringify(backupCodes),
})
.where(eq(users.id, userId));
authLogger.info("Two-factor authentication enabled", {
@@ -2907,18 +3034,18 @@ router.post("/totp/disable", authenticateJWT, async (req, res) => {
const userRecord = user[0];
- if (!userRecord.totp_enabled) {
+ if (!userRecord.totpEnabled) {
return res.status(400).json({ error: "TOTP is not enabled" });
}
- if (password && !userRecord.is_oidc) {
- const isMatch = await bcrypt.compare(password, userRecord.password_hash);
+ if (password && !userRecord.isOidc) {
+ const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
if (!isMatch) {
return res.status(401).json({ error: "Incorrect password" });
}
} else if (totp_code) {
const verified = speakeasy.totp.verify({
- secret: userRecord.totp_secret!,
+ secret: userRecord.totpSecret!,
encoding: "base32",
token: totp_code,
window: 2,
@@ -2934,9 +3061,9 @@ router.post("/totp/disable", authenticateJWT, async (req, res) => {
await db
.update(users)
.set({
- totp_enabled: false,
- totp_secret: null,
- totp_backup_codes: null,
+ totpEnabled: false,
+ totpSecret: null,
+ totpBackupCodes: null,
})
.where(eq(users.id, userId));
authLogger.info("Two-factor authentication disabled", {
@@ -2998,18 +3125,18 @@ router.post("/totp/backup-codes", authenticateJWT, async (req, res) => {
const userRecord = user[0];
- if (!userRecord.totp_enabled) {
+ if (!userRecord.totpEnabled) {
return res.status(400).json({ error: "TOTP is not enabled" });
}
- if (password && !userRecord.is_oidc) {
- const isMatch = await bcrypt.compare(password, userRecord.password_hash);
+ if (password && !userRecord.isOidc) {
+ const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
if (!isMatch) {
return res.status(401).json({ error: "Incorrect password" });
}
} else if (totp_code) {
const verified = speakeasy.totp.verify({
- secret: userRecord.totp_secret!,
+ secret: userRecord.totpSecret!,
encoding: "base32",
token: totp_code,
window: 2,
@@ -3028,7 +3155,7 @@ router.post("/totp/backup-codes", authenticateJWT, async (req, res) => {
await db
.update(users)
- .set({ totp_backup_codes: JSON.stringify(backupCodes) })
+ .set({ totpBackupCodes: JSON.stringify(backupCodes) })
.where(eq(users.id, userId));
res.json({ backup_codes: backupCodes });
@@ -3070,7 +3197,7 @@ router.post("/totp/backup-codes", authenticateJWT, async (req, res) => {
* description: TOTP verification failed.
*/
router.post("/totp/verify-login", async (req, res) => {
- const { temp_token, totp_code } = req.body;
+ const { temp_token, totp_code, rememberMe } = req.body;
if (!temp_token || !totp_code) {
return res.status(400).json({ error: "Token and TOTP code are required" });
@@ -3108,7 +3235,7 @@ router.post("/totp/verify-login", async (req, res) => {
loginRateLimiter.recordFailedTOTPAttempt(userRecord.id);
- if (!userRecord.totp_enabled || !userRecord.totp_secret) {
+ if (!userRecord.totpEnabled || !userRecord.totpSecret) {
return res.status(400).json({ error: "TOTP not enabled for this user" });
}
@@ -3121,7 +3248,7 @@ router.post("/totp/verify-login", async (req, res) => {
}
const totpSecret = LazyFieldEncryption.safeGetFieldValue(
- userRecord.totp_secret,
+ userRecord.totpSecret,
userDataKey,
userRecord.id,
"totp_secret",
@@ -3131,9 +3258,9 @@ router.post("/totp/verify-login", async (req, res) => {
await db
.update(users)
.set({
- totp_enabled: false,
- totp_secret: null,
- totp_backup_codes: null,
+ totpEnabled: false,
+ totpSecret: null,
+ totpBackupCodes: null,
})
.where(eq(users.id, userRecord.id));
@@ -3153,8 +3280,8 @@ router.post("/totp/verify-login", async (req, res) => {
if (!verified) {
let backupCodes = [];
try {
- backupCodes = userRecord.totp_backup_codes
- ? JSON.parse(userRecord.totp_backup_codes)
+ backupCodes = userRecord.totpBackupCodes
+ ? JSON.parse(userRecord.totpBackupCodes)
: [];
} catch {
backupCodes = [];
@@ -3185,14 +3312,31 @@ router.post("/totp/verify-login", async (req, res) => {
backupCodes.splice(backupIndex, 1);
await db
.update(users)
- .set({ totp_backup_codes: JSON.stringify(backupCodes) })
+ .set({ totpBackupCodes: JSON.stringify(backupCodes) })
.where(eq(users.id, userRecord.id));
}
loginRateLimiter.resetTOTPAttempts(userRecord.id);
const deviceInfo = parseUserAgent(req);
+
+ if (rememberMe) {
+ const deviceFingerprint = generateDeviceFingerprint(deviceInfo);
+ await authManager.addTrustedDevice(
+ userRecord.id,
+ deviceFingerprint,
+ deviceInfo.type,
+ deviceInfo.deviceInfo,
+ );
+ authLogger.info("Device automatically trusted via Remember Me", {
+ operation: "totp_auto_trust",
+ userId: userRecord.id,
+ deviceType: deviceInfo.type,
+ });
+ }
+
const token = await authManager.generateJWTToken(userRecord.id, {
+ rememberMe: !!rememberMe,
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
});
@@ -3210,21 +3354,18 @@ router.post("/totp/verify-login", async (req, res) => {
const response: Record = {
success: true,
- is_admin: !!userRecord.is_admin,
+ is_admin: !!userRecord.isAdmin,
username: userRecord.username,
userId: userRecord.id,
- is_oidc: !!userRecord.is_oidc,
- totp_enabled: !!userRecord.totp_enabled,
+ is_oidc: !!userRecord.isOidc,
+ totp_enabled: !!userRecord.totpEnabled,
};
if (isElectron) {
response.token = token;
}
- const maxAge =
- deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
- ? 30 * 24 * 60 * 60 * 1000
- : 7 * 24 * 60 * 60 * 1000;
+ const maxAge = rememberMe ? 30 * 24 * 60 * 60 * 1000 : 2 * 60 * 60 * 1000;
return res
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
@@ -3274,7 +3415,7 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
try {
const adminUser = await db.select().from(users).where(eq(users.id, userId));
- if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) {
+ if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
return res.status(403).json({ error: "Not authorized" });
}
@@ -3290,7 +3431,7 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
return res.status(404).json({ error: "User not found" });
}
- if (targetUser[0].is_admin) {
+ if (targetUser[0].isAdmin) {
const adminCount = db.$client
.prepare("SELECT COUNT(*) as count FROM users WHERE is_admin = 1")
.get();
@@ -3447,7 +3588,7 @@ router.get("/sessions", authenticateJWT, async (req, res) => {
const userRecord = user[0];
let sessionList;
- if (userRecord.is_admin) {
+ if (userRecord.isAdmin) {
sessionList = await authManager.getAllSessions();
const enrichedSessions = await Promise.all(
@@ -3533,7 +3674,7 @@ router.delete("/sessions/:sessionId", authenticateJWT, async (req, res) => {
const session = sessionRecords[0];
- if (!userRecord.is_admin && session.userId !== userId) {
+ if (!userRecord.isAdmin && session.userId !== userId) {
return res
.status(403)
.json({ error: "Not authorized to revoke this session" });
@@ -3600,7 +3741,7 @@ router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => {
const userRecord = user[0];
let revokeUserId = userId;
- if (targetUserId && userRecord.is_admin) {
+ if (targetUserId && userRecord.isAdmin) {
revokeUserId = targetUserId;
} else if (targetUserId && targetUserId !== userId) {
return res.status(403).json({
@@ -3687,7 +3828,7 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
.select()
.from(users)
.where(eq(users.id, adminUserId));
- if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) {
+ if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}
@@ -3701,7 +3842,7 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
const oidcUser = oidcUserRecords[0];
- if (!oidcUser.is_oidc) {
+ if (!oidcUser.isOidc) {
return res.status(400).json({
error: "Source user is not an OIDC user",
});
@@ -3717,13 +3858,13 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
const targetUser = targetUserRecords[0];
- if (targetUser.is_oidc || !targetUser.password_hash) {
+ if (targetUser.isOidc || !targetUser.passwordHash) {
return res.status(400).json({
error: "Target user must be a password-based account",
});
}
- if (targetUser.client_id && targetUser.oidc_identifier) {
+ if (targetUser.clientId && targetUser.oidcIdentifier) {
return res.status(400).json({
error: "Target user already has OIDC authentication configured",
});
@@ -3741,15 +3882,15 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
await db
.update(users)
.set({
- is_oidc: true,
- oidc_identifier: oidcUser.oidc_identifier,
- client_id: oidcUser.client_id,
- client_secret: oidcUser.client_secret,
- issuer_url: oidcUser.issuer_url,
- authorization_url: oidcUser.authorization_url,
- token_url: oidcUser.token_url,
- identifier_path: oidcUser.identifier_path,
- name_path: oidcUser.name_path,
+ isOidc: true,
+ oidcIdentifier: oidcUser.oidcIdentifier,
+ clientId: oidcUser.clientId,
+ clientSecret: oidcUser.clientSecret,
+ issuerUrl: oidcUser.issuerUrl,
+ authorizationUrl: oidcUser.authorizationUrl,
+ tokenUrl: oidcUser.tokenUrl,
+ identifierPath: oidcUser.identifierPath,
+ namePath: oidcUser.namePath,
scopes: oidcUser.scopes || "openid email profile",
})
.where(eq(users.id, targetUser.id));
@@ -3768,15 +3909,15 @@ router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
await db
.update(users)
.set({
- is_oidc: false,
- oidc_identifier: null,
- client_id: "",
- client_secret: "",
- issuer_url: "",
- authorization_url: "",
- token_url: "",
- identifier_path: "",
- name_path: "",
+ isOidc: false,
+ oidcIdentifier: null,
+ clientId: "",
+ clientSecret: "",
+ issuerUrl: "",
+ authorizationUrl: "",
+ tokenUrl: "",
+ identifierPath: "",
+ namePath: "",
scopes: "openid email profile",
})
.where(eq(users.id, targetUser.id));
@@ -3882,7 +4023,7 @@ router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => {
.from(users)
.where(eq(users.id, adminUserId));
- if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) {
+ if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
authLogger.warn("Non-admin attempted to unlink OIDC from password", {
operation: "unlink_oidc_unauthorized",
adminUserId,
@@ -3906,13 +4047,13 @@ router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => {
const targetUser = targetUserRecords[0];
- if (!targetUser.is_oidc) {
+ if (!targetUser.isOidc) {
return res.status(400).json({
error: "User does not have OIDC authentication enabled",
});
}
- if (!targetUser.password_hash || targetUser.password_hash === "") {
+ if (!targetUser.passwordHash || targetUser.passwordHash === "") {
return res.status(400).json({
error:
"Cannot unlink OIDC from a user without password authentication. This would leave the user unable to login.",
@@ -3929,15 +4070,15 @@ router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => {
await db
.update(users)
.set({
- is_oidc: false,
- oidc_identifier: null,
- client_id: "",
- client_secret: "",
- issuer_url: "",
- authorization_url: "",
- token_url: "",
- identifier_path: "",
- name_path: "",
+ isOidc: false,
+ oidcIdentifier: null,
+ clientId: "",
+ clientSecret: "",
+ issuerUrl: "",
+ authorizationUrl: "",
+ tokenUrl: "",
+ identifierPath: "",
+ namePath: "",
scopes: "openid email profile",
})
.where(eq(users.id, targetUser.id));
diff --git a/src/backend/scripts/enable-ssl.sh b/src/backend/scripts/enable-ssl.sh
index 9fd76e1e..f5be625a 100644
--- a/src/backend/scripts/enable-ssl.sh
+++ b/src/backend/scripts/enable-ssl.sh
@@ -99,5 +99,4 @@ main() {
setup_ssl_certificates
}
-# Run main function
-main "$@"
\ No newline at end of file
+main "$@"
diff --git a/src/backend/ssh/auth-manager.ts b/src/backend/ssh/auth-manager.ts
index 54d7549c..46a50654 100644
--- a/src/backend/ssh/auth-manager.ts
+++ b/src/backend/ssh/auth-manager.ts
@@ -1,12 +1,9 @@
-import { Client } from "ssh2";
import type { WebSocket } from "ws";
import { sshLogger, authLogger } from "../utils/logger.js";
import { getDb } from "../database/db/index.js";
-import { sshCredentials, sshData } from "../database/db/schema.js";
+import { sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
-import { UserCrypto } from "../utils/user-crypto.js";
-
interface ResolvedCredentials {
username: string;
password?: string;
@@ -44,9 +41,6 @@ interface AuthContext {
totpAttempts: number;
}
-const userCrypto = UserCrypto.getInstance();
-const MAX_TOTP_ATTEMPTS = 3;
-
export class SSHAuthManager {
public context: AuthContext;
@@ -85,7 +79,7 @@ export class SSHAuthManager {
key: cred.privateKey
? Buffer.from(cred.privateKey as string)
: undefined,
- keyPassword: (cred.passphrase as string) || undefined,
+ keyPassword: (cred.keyPassword as string) || undefined,
authType: (cred.authType as string) || "none",
};
}
@@ -163,7 +157,7 @@ export class SSHAuthManager {
);
if (urlMatch) {
- this.context.keyboardInteractiveFinish = (responses: string[]) => {
+ this.context.keyboardInteractiveFinish = () => {
finish([""]);
};
@@ -358,7 +352,7 @@ export class SSHAuthManager {
stage: string,
level: string,
message: string,
- details?: Record,
+ details?: Record,
): void {
this.context.ws.send(
JSON.stringify({
diff --git a/src/backend/ssh/docker-console.ts b/src/backend/ssh/docker-console.ts
index e45cb559..eee441b4 100644
--- a/src/backend/ssh/docker-console.ts
+++ b/src/backend/ssh/docker-console.ts
@@ -13,7 +13,7 @@ const sshLogger = systemLogger;
interface SSHSession {
client: SSHClient;
- stream: any;
+ stream: import("ssh2").ClientChannel | null;
isConnected: boolean;
containerId?: string;
shell?: string;
@@ -42,7 +42,7 @@ const wss = new WebSocketServer({
}
return true;
- } catch (error) {
+ } catch {
return false;
}
},
@@ -92,7 +92,7 @@ async function detectShell(
}
async function createJumpHostChain(
- jumpHosts: any[],
+ jumpHosts: Array<{ hostId: number }>,
userId: string,
): Promise {
if (!jumpHosts || jumpHosts.length === 0) {
@@ -129,7 +129,12 @@ async function createJumpHostChain(
}
}
- let resolvedCredentials: any = {
+ let resolvedCredentials: {
+ password?: string;
+ sshKey?: string;
+ keyPassword?: string;
+ authType?: string;
+ } = {
password: jumpHost.password,
sshKey: jumpHost.key,
keyPassword: jumpHost.keyPassword,
@@ -154,19 +159,18 @@ async function createJumpHostChain(
if (credentials.length > 0) {
const credential = credentials[0];
resolvedCredentials = {
- password: credential.password,
- sshKey:
- credential.private_key || credential.privateKey || credential.key,
- keyPassword: credential.key_password || credential.keyPassword,
- authType: credential.auth_type || credential.authType,
+ password: credential.password as string | undefined,
+ sshKey: credential.privateKey as string | undefined,
+ keyPassword: credential.keyPassword as string | undefined,
+ authType: credential.authType as string | undefined,
};
}
}
const client = new SSHClient();
- const config: any = {
- host: jumpHost.ip,
+ const config: Record = {
+ host: jumpHost.ip?.replace(/^\[|\]$/g, "") || jumpHost.ip,
port: jumpHost.port || 22,
username: jumpHost.username,
tryKeyboard: true,
@@ -225,7 +229,7 @@ async function createJumpHostChain(
}
wss.on("connection", async (ws: WebSocket, req) => {
- const userId = (req as any).userId;
+ const userId = (req as unknown as { userId: string }).userId;
const sessionId = `docker-console-${Date.now()}-${Math.random()}`;
sshLogger.info("Docker console WebSocket connected", {
operation: "docker_console_connect",
@@ -235,6 +239,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
let sshSession: SSHSession | null = null;
+ const wsPingInterval = setInterval(() => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.ping();
+ }
+ }, 30000);
+
ws.on("message", async (data) => {
try {
const message = JSON.parse(data.toString());
@@ -286,7 +296,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
try {
- let resolvedCredentials: any = {
+ let resolvedCredentials: {
+ password?: string;
+ sshKey?: string;
+ keyPassword?: string;
+ authType?: string;
+ } = {
password: hostConfig.password,
sshKey: hostConfig.key,
keyPassword: hostConfig.keyPassword,
@@ -311,22 +326,18 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (credentials.length > 0) {
const credential = credentials[0];
resolvedCredentials = {
- password: credential.password,
- sshKey:
- credential.private_key ||
- credential.privateKey ||
- credential.key,
- keyPassword:
- credential.key_password || credential.keyPassword,
- authType: credential.auth_type || credential.authType,
+ password: credential.password as string | undefined,
+ sshKey: credential.privateKey as string | undefined,
+ keyPassword: credential.keyPassword as string | undefined,
+ authType: credential.authType as string | undefined,
};
}
}
const client = new SSHClient();
- const config: any = {
- host: hostConfig.ip,
+ const config: Record = {
+ host: hostConfig.ip?.replace(/^\[|\]$/g, "") || hostConfig.ip,
port: hostConfig.port || 22,
username: hostConfig.username,
tryKeyboard: true,
@@ -362,18 +373,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
userId,
);
if (jumpClient) {
- const stream = await new Promise((resolve, reject) => {
- jumpClient.forwardOut(
- "127.0.0.1",
- 0,
- hostConfig.ip,
- hostConfig.port || 22,
- (err, stream) => {
- if (err) return reject(err);
- resolve(stream);
- },
- );
- });
+ const stream = await new Promise(
+ (resolve, reject) => {
+ jumpClient.forwardOut(
+ "127.0.0.1",
+ 0,
+ hostConfig.ip,
+ hostConfig.port || 22,
+ (err, stream) => {
+ if (err) return reject(err);
+ resolve(stream);
+ },
+ );
+ },
+ );
config.sock = stream;
}
}
@@ -496,7 +509,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
});
- stream.stderr.on("data", (data: Buffer) => {});
+ stream.stderr.on("data", () => {
+ // stderr output ignored
+ });
stream.on("close", () => {
if (ws.readyState === WebSocket.OPEN) {
@@ -556,7 +571,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
case "resize": {
if (sshSession && sshSession.stream) {
const { cols, rows } = message.data;
- sshSession.stream.setWindow(rows, cols);
+ sshSession.stream.setWindow(rows, cols, rows, cols);
}
break;
}
@@ -608,6 +623,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
});
ws.on("close", () => {
+ clearInterval(wsPingInterval);
sshLogger.info("Docker console disconnected", {
operation: "docker_console_disconnect",
sessionId,
@@ -641,7 +657,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
});
process.on("SIGTERM", () => {
- activeSessions.forEach((session, sessionId) => {
+ activeSessions.forEach((session) => {
if (session.stream) {
session.stream.end();
}
diff --git a/src/backend/ssh/docker.ts b/src/backend/ssh/docker.ts
index 234d8cc7..b2d8cdf5 100644
--- a/src/backend/ssh/docker.ts
+++ b/src/backend/ssh/docker.ts
@@ -3,15 +3,17 @@ import cors from "cors";
import cookieParser from "cookie-parser";
import axios from "axios";
import { Client as SSHClient } from "ssh2";
-import type { ClientChannel } from "ssh2";
import { getDb } from "../database/db/index.js";
import { sshData, sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { logger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
-import { createSocks5Connection } from "../utils/socks5-helper.js";
-import type { AuthenticatedRequest, SSHHost } from "../../types/index.js";
+import {
+ createSocks5Connection,
+ type SOCKS5Config,
+} from "../utils/socks5-helper.js";
+import type { SSHHost, ProxyNode } from "../../types/index.js";
import type { LogEntry, ConnectionStage } from "../../types/connection-log.js";
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
@@ -21,7 +23,7 @@ function createConnectionLog(
type: "info" | "success" | "warning" | "error",
stage: ConnectionStage,
message: string,
- details?: Record,
+ details?: Record,
): Omit {
return {
type,
@@ -43,7 +45,7 @@ interface SSHSession {
interface PendingTOTPSession {
client: SSHClient;
finish: (responses: string[]) => void;
- config: any;
+ config: Record;
createdAt: number;
sessionId: string;
hostId?: number;
@@ -70,7 +72,9 @@ setInterval(() => {
if (now - session.createdAt > 180000) {
try {
session.client.end();
- } catch {}
+ } catch {
+ // expected
+ }
delete pendingTOTPSessions[sessionId];
}
});
@@ -94,7 +98,9 @@ function cleanupSession(sessionId: string) {
try {
session.client.end();
- } catch (error) {}
+ } catch {
+ // expected
+ }
clearTimeout(session.timeout);
delete sshSessions[sessionId];
}
@@ -111,10 +117,24 @@ function scheduleSessionCleanup(sessionId: string) {
}
}
+interface JumpHostConfig {
+ id: number;
+ ip: string;
+ port: number;
+ username: string;
+ password?: string;
+ key?: string;
+ keyPassword?: string;
+ keyType?: string;
+ authType?: string;
+ credentialId?: number;
+ [key: string]: unknown;
+}
+
async function resolveJumpHost(
hostId: number,
userId: string,
-): Promise {
+): Promise {
try {
const hosts = await SimpleDBOps.select(
getDb()
@@ -150,17 +170,16 @@ async function resolveJumpHost(
const credential = credentials[0];
return {
...host,
- password: credential.password,
- key:
- credential.private_key || credential.privateKey || credential.key,
- keyPassword: credential.key_password || credential.keyPassword,
- keyType: credential.key_type || credential.keyType,
- authType: credential.auth_type || credential.authType,
- };
+ password: credential.password as string | undefined,
+ key: credential.privateKey as string | undefined,
+ keyPassword: credential.keyPassword as string | undefined,
+ keyType: credential.keyType as string | undefined,
+ authType: credential.authType as string | undefined,
+ } as JumpHostConfig;
}
}
- return host;
+ return host as JumpHostConfig;
} catch (error) {
sshLogger.error("Failed to resolve jump host", error, {
operation: "resolve_jump_host",
@@ -174,6 +193,7 @@ async function resolveJumpHost(
async function createJumpHostChain(
jumpHosts: Array<{ hostId: number }>,
userId: string,
+ socks5Config?: SOCKS5Config | null,
): Promise {
if (!jumpHosts || jumpHosts.length === 0) {
return null;
@@ -187,19 +207,33 @@ async function createJumpHostChain(
jumpHosts.map((jh) => resolveJumpHost(jh.hostId, userId)),
);
+ const totalHops = jumpHostConfigs.length;
+
for (let i = 0; i < jumpHostConfigs.length; i++) {
if (!jumpHostConfigs[i]) {
sshLogger.error(`Jump host ${i + 1} not found`, undefined, {
operation: "jump_host_chain",
hostId: jumpHosts[i].hostId,
+ hopIndex: i,
+ totalHops,
});
clients.forEach((c) => c.end());
return null;
}
}
+ let proxySocket: import("net").Socket | null = null;
+ if (socks5Config?.useSocks5) {
+ const firstHop = jumpHostConfigs[0]!;
+ proxySocket = await createSocks5Connection(
+ firstHop.ip,
+ firstHop.port || 22,
+ socks5Config,
+ );
+ }
+
for (let i = 0; i < jumpHostConfigs.length; i++) {
- const jumpHostConfig = jumpHostConfigs[i];
+ const jumpHostConfig = jumpHostConfigs[i]!;
const jumpClient = new SSHClient();
clients.push(jumpClient);
@@ -225,16 +259,29 @@ async function createJumpHostChain(
jumpClient.on("error", (err) => {
clearTimeout(timeout);
- sshLogger.error(`Jump host ${i + 1} connection failed`, err, {
- operation: "jump_host_connect",
- hostId: jumpHostConfig.id,
- ip: jumpHostConfig.ip,
- });
+ sshLogger.error(
+ `Jump host ${i + 1}/${totalHops} connection failed`,
+ err,
+ {
+ operation: "jump_host_connect",
+ hostId: jumpHostConfig.id,
+ ip: jumpHostConfig.ip,
+ hopIndex: i,
+ totalHops,
+ previousHop:
+ i > 0
+ ? jumpHostConfigs[i - 1]?.ip
+ : proxySocket
+ ? "proxy"
+ : "direct",
+ usedProxySocket: i === 0 && !!proxySocket,
+ },
+ );
resolve(false);
});
- const connectConfig: any = {
- host: jumpHostConfig.ip,
+ const connectConfig: Record = {
+ host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
port: jumpHostConfig.port || 22,
username: jumpHostConfig.username,
tryKeyboard: true,
@@ -271,6 +318,9 @@ async function createJumpHostChain(
jumpClient.connect(connectConfig);
},
);
+ } else if (proxySocket) {
+ connectConfig.sock = proxySocket;
+ jumpClient.connect(connectConfig);
} else {
jumpClient.connect(connectConfig);
}
@@ -411,6 +461,10 @@ app.use(
app.use(cookieParser());
app.use(express.json({ limit: "100mb" }));
app.use(express.urlencoded({ limit: "100mb", extended: true }));
+app.use((_req, res, next) => {
+ res.setHeader("Cache-Control", "no-store");
+ next();
+});
const authManager = AuthManager.getInstance();
app.use(authManager.createAuthMiddleware());
@@ -450,7 +504,6 @@ app.post("/docker/ssh/connect", async (req, res) => {
userProvidedPassword,
userProvidedSshKey,
userProvidedKeyPassword,
- forceKeyboardInteractive,
useSocks5,
socks5Host,
socks5Port,
@@ -458,7 +511,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
socks5Password,
socks5ProxyChain,
} = req.body;
- const userId = (req as any).userId;
+ const userId = (req as unknown as { userId: string }).userId;
const connectionLogs: Array> = [];
@@ -533,8 +586,9 @@ app.post("/docker/ssh/connect", async (req, res) => {
const host = hosts[0] as unknown as SSHHost;
if (host.userId !== userId) {
- const { PermissionManager } =
- await import("../utils/permission-manager.js");
+ const { PermissionManager } = await import(
+ "../utils/permission-manager.js"
+ );
const permissionManager = PermissionManager.getInstance();
const accessInfo = await permissionManager.canAccessHost(
userId,
@@ -605,11 +659,18 @@ app.post("/docker/ssh/connect", async (req, res) => {
if (pendingTOTPSessions[sessionId]) {
try {
pendingTOTPSessions[sessionId].client.end();
- } catch {}
+ } catch {
+ // expected
+ }
delete pendingTOTPSessions[sessionId];
}
- let resolvedCredentials: any = {
+ let resolvedCredentials: {
+ password?: string;
+ sshKey?: string;
+ keyPassword?: string;
+ authType?: string;
+ } = {
password: host.password,
sshKey: host.key,
keyPassword: host.keyPassword,
@@ -632,8 +693,9 @@ app.post("/docker/ssh/connect", async (req, res) => {
if (userId !== ownerId) {
try {
- const { SharedCredentialManager } =
- await import("../utils/shared-credential-manager.js");
+ const { SharedCredentialManager } = await import(
+ "../utils/shared-credential-manager.js"
+ );
const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
host.id,
@@ -673,11 +735,10 @@ app.post("/docker/ssh/connect", async (req, res) => {
if (credentials.length > 0) {
const credential = credentials[0];
resolvedCredentials = {
- password: credential.password,
- sshKey:
- credential.private_key || credential.privateKey || credential.key,
- keyPassword: credential.key_password || credential.keyPassword,
- authType: credential.auth_type || credential.authType,
+ password: credential.password as string | undefined,
+ sshKey: credential.privateKey as string | undefined,
+ keyPassword: credential.keyPassword as string | undefined,
+ authType: credential.authType as string | undefined,
};
}
}
@@ -685,8 +746,8 @@ app.post("/docker/ssh/connect", async (req, res) => {
const client = new SSHClient();
- const config: any = {
- host: host.ip,
+ const config: Record = {
+ host: host.ip?.replace(/^\[|\]$/g, "") || host.ip,
port: host.port || 22,
username: host.username,
tryKeyboard: true,
@@ -706,6 +767,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
};
if (resolvedCredentials.authType === "none") {
+ // no credentials needed
} else if (resolvedCredentials.authType === "password") {
if (resolvedCredentials.password) {
config.password = resolvedCredentials.password;
@@ -871,8 +933,6 @@ app.post("/docker/ssh/connect", async (req, res) => {
}
let responseSent = false;
- let keyboardInteractiveResponded = false;
-
connectionLogs.push(
createConnectionLog("info", "dns", `Resolving DNS for ${host.ip}`),
);
@@ -1089,8 +1149,6 @@ app.post("/docker/ssh/connect", async (req, res) => {
if (responseSent) return;
responseSent = true;
- keyboardInteractiveResponded = true;
-
pendingTOTPSessions[sessionId] = {
client,
finish,
@@ -1158,8 +1216,6 @@ app.post("/docker/ssh/connect", async (req, res) => {
return;
}
- keyboardInteractiveResponded = true;
-
pendingTOTPSessions[sessionId] = {
client,
finish,
@@ -1244,8 +1300,6 @@ app.post("/docker/ssh/connect", async (req, res) => {
return;
}
- keyboardInteractiveResponded = true;
-
pendingTOTPSessions[sessionId] = {
client,
finish,
@@ -1283,121 +1337,160 @@ app.post("/docker/ssh/connect", async (req, res) => {
},
);
- if (
+ const proxyConfig: SOCKS5Config | null =
useSocks5 &&
- (socks5Host || (socks5ProxyChain && (socks5ProxyChain as any).length > 0))
- ) {
- try {
- connectionLogs.push(
- createConnectionLog("info", "proxy", "Connecting via SOCKS5 proxy"),
- );
- const socks5Socket = await createSocks5Connection(
- host.ip,
- host.port || 22,
- {
+ (socks5Host ||
+ (socks5ProxyChain && (socks5ProxyChain as ProxyNode[]).length > 0))
+ ? {
useSocks5,
socks5Host,
socks5Port,
socks5Username,
socks5Password,
- socks5ProxyChain: socks5ProxyChain as any,
- },
+ socks5ProxyChain: socks5ProxyChain as ProxyNode[],
+ }
+ : null;
+
+ const hasJumpHosts = host.jumpHosts && host.jumpHosts.length > 0;
+
+ if (hasJumpHosts) {
+ try {
+ if (proxyConfig) {
+ connectionLogs.push(
+ createConnectionLog(
+ "info",
+ "proxy",
+ "Connecting via proxy + jump hosts",
+ ),
+ );
+ }
+ connectionLogs.push(
+ createConnectionLog(
+ "info",
+ "jump",
+ `Connecting via ${host.jumpHosts!.length} jump host(s)`,
+ ),
+ );
+ const jumpClient = await createJumpHostChain(
+ host.jumpHosts as Array<{ hostId: number }>,
+ userId,
+ proxyConfig,
);
- if (socks5Socket) {
- config.sock = socks5Socket;
- client.connect(config);
- return;
+ if (!jumpClient) {
+ connectionLogs.push(
+ createConnectionLog(
+ "error",
+ "jump",
+ "Failed to establish jump host chain",
+ ),
+ );
+ return res.status(500).json({
+ error: "Failed to establish jump host chain",
+ connectionLogs,
+ });
}
- } catch (socks5Error) {
- sshLogger.error("SOCKS5 connection failed", socks5Error, {
- operation: "docker_socks5_connect",
+
+ jumpClient.forwardOut(
+ "127.0.0.1",
+ 0,
+ host.ip,
+ host.port || 22,
+ (err, stream) => {
+ if (err) {
+ sshLogger.error("Failed to forward through jump host", err, {
+ operation: "docker_jump_forward",
+ sessionId,
+ hostId,
+ });
+ connectionLogs.push(
+ createConnectionLog(
+ "error",
+ "jump",
+ `Failed to forward through jump host: ${err.message}`,
+ ),
+ );
+ jumpClient.end();
+ if (!responseSent) {
+ responseSent = true;
+ return res.status(500).json({
+ error: "Failed to forward through jump host: " + err.message,
+ connectionLogs,
+ });
+ }
+ return;
+ }
+
+ config.sock = stream;
+ client.connect(config);
+ },
+ );
+ } catch (jumpError) {
+ sshLogger.error("Jump host connection failed", jumpError, {
+ operation: "docker_jump_connect",
sessionId,
hostId,
- proxyHost: socks5Host,
- proxyPort: socks5Port || 1080,
});
connectionLogs.push(
createConnectionLog(
"error",
- "proxy",
- `SOCKS5 proxy connection failed: ${socks5Error instanceof Error ? socks5Error.message : "Unknown error"}`,
+ "jump",
+ `Jump host connection failed: ${jumpError instanceof Error ? jumpError.message : "Unknown error"}`,
),
);
if (!responseSent) {
responseSent = true;
return res.status(500).json({
error:
- "SOCKS5 proxy connection failed: " +
- (socks5Error instanceof Error
- ? socks5Error.message
+ "Jump host connection failed: " +
+ (jumpError instanceof Error
+ ? jumpError.message
: "Unknown error"),
connectionLogs,
});
}
return;
}
- } else if (host.jumpHosts && host.jumpHosts.length > 0) {
+ } else if (proxyConfig) {
connectionLogs.push(
- createConnectionLog(
- "info",
- "jump",
- `Connecting via ${host.jumpHosts.length} jump host(s)`,
- ),
+ createConnectionLog("info", "proxy", "Connecting via proxy"),
);
- const jumpClient = await createJumpHostChain(
- host.jumpHosts as Array<{ hostId: number }>,
- userId,
- );
-
- if (!jumpClient) {
+ try {
+ const proxySocket = await createSocks5Connection(
+ host.ip,
+ host.port || 22,
+ proxyConfig,
+ );
+ if (proxySocket) {
+ config.sock = proxySocket;
+ }
+ client.connect(config);
+ } catch (proxyError) {
+ sshLogger.error("Proxy connection failed", proxyError, {
+ operation: "docker_proxy_connect",
+ sessionId,
+ hostId,
+ });
connectionLogs.push(
createConnectionLog(
"error",
- "jump",
- "Failed to establish jump host chain",
+ "proxy",
+ `Proxy connection failed: ${proxyError instanceof Error ? proxyError.message : "Unknown error"}`,
),
);
- return res.status(500).json({
- error: "Failed to establish jump host chain",
- connectionLogs,
- });
+ if (!responseSent) {
+ responseSent = true;
+ return res.status(500).json({
+ error:
+ "Proxy connection failed: " +
+ (proxyError instanceof Error
+ ? proxyError.message
+ : "Unknown error"),
+ connectionLogs,
+ });
+ }
+ return;
}
-
- jumpClient.forwardOut(
- "127.0.0.1",
- 0,
- host.ip,
- host.port || 22,
- (err, stream) => {
- if (err) {
- sshLogger.error("Failed to forward through jump host", err, {
- operation: "docker_jump_forward",
- sessionId,
- hostId,
- });
- connectionLogs.push(
- createConnectionLog(
- "error",
- "jump",
- `Failed to forward through jump host: ${err.message}`,
- ),
- );
- jumpClient.end();
- if (!responseSent) {
- responseSent = true;
- return res.status(500).json({
- error: "Failed to forward through jump host: " + err.message,
- connectionLogs,
- });
- }
- return;
- }
-
- config.sock = stream;
- client.connect(config);
- },
- );
} else {
client.connect(config);
}
@@ -1489,7 +1582,7 @@ app.post("/docker/ssh/disconnect", async (req, res) => {
*/
app.post("/docker/ssh/connect-totp", async (req, res) => {
const { sessionId, totpCode } = req.body;
- const userId = (req as any).userId;
+ const userId = (req as unknown as { userId: string }).userId;
if (!userId) {
sshLogger.error("TOTP verification rejected: no authenticated user", {
@@ -1521,7 +1614,9 @@ app.post("/docker/ssh/connect-totp", async (req, res) => {
delete pendingTOTPSessions[sessionId];
try {
session.client.end();
- } catch {}
+ } catch {
+ // expected
+ }
sshLogger.warn("TOTP session timeout before code submission", {
operation: "docker_totp_verify",
sessionId,
@@ -1544,7 +1639,19 @@ app.post("/docker/ssh/connect-totp", async (req, res) => {
});
let responseSent = false;
- let responseTimeout: NodeJS.Timeout;
+
+ const responseTimeout = setTimeout(() => {
+ if (!responseSent) {
+ responseSent = true;
+ delete pendingTOTPSessions[sessionId];
+ sshLogger.warn("TOTP verification timeout", {
+ operation: "docker_totp_verify",
+ sessionId,
+ userId,
+ });
+ res.status(408).json({ error: "TOTP verification timeout" });
+ }
+ }, 60000);
session.client.once("ready", () => {
if (responseSent) return;
@@ -1633,19 +1740,6 @@ app.post("/docker/ssh/connect-totp", async (req, res) => {
res.status(401).json({ status: "error", message: "Invalid TOTP code" });
});
- responseTimeout = setTimeout(() => {
- if (!responseSent) {
- responseSent = true;
- delete pendingTOTPSessions[sessionId];
- sshLogger.warn("TOTP verification timeout", {
- operation: "docker_totp_verify",
- sessionId,
- userId,
- });
- res.status(408).json({ error: "TOTP verification timeout" });
- }
- }, 60000);
-
session.finish(responses);
});
@@ -1679,7 +1773,7 @@ app.post("/docker/ssh/connect-totp", async (req, res) => {
*/
app.post("/docker/ssh/connect-warpgate", async (req, res) => {
const { sessionId } = req.body;
- const userId = (req as any).userId;
+ const userId = (req as unknown as { userId: string }).userId;
if (!userId) {
sshLogger.error("Warpgate verification rejected: no authenticated user", {
@@ -1715,7 +1809,9 @@ app.post("/docker/ssh/connect-warpgate", async (req, res) => {
delete pendingTOTPSessions[sessionId];
try {
session.client.end();
- } catch {}
+ } catch {
+ // expected
+ }
sshLogger.warn("Warpgate session timeout before completion", {
operation: "docker_warpgate_verify",
sessionId,
@@ -1728,7 +1824,19 @@ app.post("/docker/ssh/connect-warpgate", async (req, res) => {
}
let responseSent = false;
- let responseTimeout: NodeJS.Timeout;
+
+ const responseTimeout = setTimeout(() => {
+ if (!responseSent) {
+ responseSent = true;
+ delete pendingTOTPSessions[sessionId];
+ sshLogger.warn("Warpgate verification timeout", {
+ operation: "docker_warpgate_verify",
+ sessionId,
+ userId,
+ });
+ res.status(408).json({ error: "Warpgate verification timeout" });
+ }
+ }, 60000);
session.client.once("ready", () => {
if (responseSent) return;
@@ -1819,19 +1927,6 @@ app.post("/docker/ssh/connect-warpgate", async (req, res) => {
.json({ status: "error", message: "Warpgate authentication failed" });
});
- responseTimeout = setTimeout(() => {
- if (!responseSent) {
- responseSent = true;
- delete pendingTOTPSessions[sessionId];
- sshLogger.warn("Warpgate verification timeout", {
- operation: "docker_warpgate_verify",
- sessionId,
- userId,
- });
- res.status(408).json({ error: "Warpgate verification timeout" });
- }
- }, 60000);
-
session.finish([""]);
});
@@ -1941,7 +2036,7 @@ app.get("/docker/ssh/status", async (req, res) => {
*/
app.get("/docker/validate/:sessionId", async (req, res) => {
const { sessionId } = req.params;
- const userId = (req as any).userId;
+ const userId = (req as unknown as { userId: string }).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
@@ -2020,7 +2115,7 @@ app.get("/docker/validate/:sessionId", async (req, res) => {
code: "DOCKER_ERROR",
});
}
- } catch (installError) {
+ } catch {
session.activeOperations--;
return res.json({
available: false,
@@ -2073,7 +2168,7 @@ app.get("/docker/validate/:sessionId", async (req, res) => {
app.get("/docker/containers/:sessionId", async (req, res) => {
const { sessionId } = req.params;
const all = req.query.all !== "false";
- const userId = (req as any).userId;
+ const userId = (req as unknown as { userId: string }).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
@@ -2115,7 +2210,7 @@ app.get("/docker/containers/:sessionId", async (req, res) => {
.map((line) => {
try {
return JSON.parse(line);
- } catch (e) {
+ } catch {
sshLogger.warn("Failed to parse container line", {
operation: "parse_container",
line,
@@ -2174,7 +2269,7 @@ app.get("/docker/containers/:sessionId", async (req, res) => {
*/
app.get("/docker/containers/:sessionId/:containerId", async (req, res) => {
const { sessionId, containerId } = req.params;
- const userId = (req as any).userId;
+ const userId = (req as unknown as { userId: string }).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
@@ -2269,7 +2364,7 @@ app.post(
"/docker/containers/:sessionId/:containerId/start",
async (req, res) => {
const { sessionId, containerId } = req.params;
- const userId = (req as any).userId;
+ const userId = (req as unknown as { userId: string }).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
@@ -2369,7 +2464,7 @@ app.post(
"/docker/containers/:sessionId/:containerId/stop",
async (req, res) => {
const { sessionId, containerId } = req.params;
- const userId = (req as any).userId;
+ const userId = (req as unknown as { userId: string }).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
@@ -2469,7 +2564,7 @@ app.post(
"/docker/containers/:sessionId/:containerId/restart",
async (req, res) => {
const { sessionId, containerId } = req.params;
- const userId = (req as any).userId;
+ const userId = (req as unknown as { userId: string }).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
@@ -2569,7 +2664,7 @@ app.post(
"/docker/containers/:sessionId/:containerId/pause",
async (req, res) => {
const { sessionId, containerId } = req.params;
- const userId = (req as any).userId;
+ const userId = (req as unknown as { userId: string }).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
@@ -2669,7 +2764,7 @@ app.post(
"/docker/containers/:sessionId/:containerId/unpause",
async (req, res) => {
const { sessionId, containerId } = req.params;
- const userId = (req as any).userId;
+ const userId = (req as unknown as { userId: string }).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
@@ -2774,7 +2869,7 @@ app.delete(
async (req, res) => {
const { sessionId, containerId } = req.params;
const force = req.query.force === "true";
- const userId = (req as any).userId;
+ const userId = (req as unknown as { userId: string }).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
@@ -2902,7 +2997,7 @@ app.get("/docker/containers/:sessionId/:containerId/logs", async (req, res) => {
const timestamps = req.query.timestamps === "true";
const since = req.query.since as string;
const until = req.query.until as string;
- const userId = (req as any).userId;
+ const userId = (req as unknown as { userId: string }).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
@@ -3011,7 +3106,7 @@ app.get(
"/docker/containers/:sessionId/:containerId/stats",
async (req, res) => {
const { sessionId, containerId } = req.params;
- const userId = (req as any).userId;
+ const userId = (req as unknown as { userId: string }).userId;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts
index af2d93b5..c108c60d 100644
--- a/src/backend/ssh/file-manager.ts
+++ b/src/backend/ssh/file-manager.ts
@@ -6,11 +6,14 @@ import { Client as SSHClient } from "ssh2";
import { getDb } from "../database/db/index.js";
import { sshCredentials, sshData } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
-import { fileLogger, sshLogger } from "../utils/logger.js";
+import { fileLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
-import type { AuthenticatedRequest } from "../../types/index.js";
-import { createSocks5Connection } from "../utils/socks5-helper.js";
+import type { AuthenticatedRequest, ProxyNode } from "../../types/index.js";
+import {
+ createSocks5Connection,
+ type SOCKS5Config,
+} from "../utils/socks5-helper.js";
import type { LogEntry, ConnectionStage } from "../../types/connection-log.js";
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
@@ -18,7 +21,7 @@ function createConnectionLog(
type: "info" | "success" | "warning" | "error",
stage: ConnectionStage,
message: string,
- details?: Record,
+ details?: Record,
): Omit {
return {
type,
@@ -149,14 +152,32 @@ app.use(cookieParser());
app.use(express.json({ limit: "1gb" }));
app.use(express.urlencoded({ limit: "1gb", extended: true }));
app.use(express.raw({ limit: "5gb", type: "application/octet-stream" }));
+app.use((_req, res, next) => {
+ res.setHeader("Cache-Control", "no-store");
+ next();
+});
const authManager = AuthManager.getInstance();
app.use(authManager.createAuthMiddleware());
+interface JumpHostConfig {
+ id: number;
+ ip: string;
+ port: number;
+ username: string;
+ password?: string;
+ key?: string;
+ keyPassword?: string;
+ keyType?: string;
+ authType?: string;
+ credentialId?: number;
+ [key: string]: unknown;
+}
+
async function resolveJumpHost(
hostId: number,
userId: string,
-): Promise {
+): Promise {
try {
const hosts = await SimpleDBOps.select(
getDb()
@@ -192,17 +213,16 @@ async function resolveJumpHost(
const credential = credentials[0];
return {
...host,
- password: credential.password,
- key:
- credential.private_key || credential.privateKey || credential.key,
- keyPassword: credential.key_password || credential.keyPassword,
- keyType: credential.key_type || credential.keyType,
- authType: credential.auth_type || credential.authType,
- };
+ password: credential.password as string | undefined,
+ key: credential.privateKey as string | undefined,
+ keyPassword: credential.keyPassword as string | undefined,
+ keyType: credential.keyType as string | undefined,
+ authType: credential.authType as string | undefined,
+ } as JumpHostConfig;
}
}
- return host;
+ return host as JumpHostConfig;
} catch (error) {
fileLogger.error("Failed to resolve jump host", error, {
operation: "resolve_jump_host",
@@ -216,6 +236,7 @@ async function resolveJumpHost(
async function createJumpHostChain(
jumpHosts: Array<{ hostId: number }>,
userId: string,
+ socks5Config?: SOCKS5Config | null,
): Promise {
if (!jumpHosts || jumpHosts.length === 0) {
return null;
@@ -225,17 +246,40 @@ async function createJumpHostChain(
const clients: SSHClient[] = [];
try {
+ const jumpHostConfigs: Array>> =
+ [];
for (let i = 0; i < jumpHosts.length; i++) {
- const jumpHostConfig = await resolveJumpHost(jumpHosts[i].hostId, userId);
+ const config = await resolveJumpHost(jumpHosts[i].hostId, userId);
+ jumpHostConfigs.push(config);
+ }
- if (!jumpHostConfig) {
+ const totalHops = jumpHostConfigs.length;
+
+ for (let i = 0; i < jumpHostConfigs.length; i++) {
+ if (!jumpHostConfigs[i]) {
fileLogger.error(`Jump host ${i + 1} not found`, undefined, {
operation: "jump_host_chain",
hostId: jumpHosts[i].hostId,
+ hopIndex: i,
+ totalHops,
});
clients.forEach((c) => c.end());
return null;
}
+ }
+
+ let proxySocket: import("net").Socket | null = null;
+ if (socks5Config?.useSocks5) {
+ const firstHop = jumpHostConfigs[0]!;
+ proxySocket = await createSocks5Connection(
+ firstHop.ip,
+ firstHop.port || 22,
+ socks5Config,
+ );
+ }
+
+ for (let i = 0; i < jumpHostConfigs.length; i++) {
+ const jumpHostConfig = jumpHostConfigs[i]!;
const jumpClient = new SSHClient();
clients.push(jumpClient);
@@ -261,16 +305,29 @@ async function createJumpHostChain(
jumpClient.on("error", (err) => {
clearTimeout(timeout);
- fileLogger.error(`Jump host ${i + 1} connection failed`, err, {
- operation: "jump_host_connect",
- hostId: jumpHostConfig.id,
- ip: jumpHostConfig.ip,
- });
+ fileLogger.error(
+ `Jump host ${i + 1}/${totalHops} connection failed`,
+ err,
+ {
+ operation: "jump_host_connect",
+ hostId: jumpHostConfig.id,
+ ip: jumpHostConfig.ip,
+ hopIndex: i,
+ totalHops,
+ previousHop:
+ i > 0
+ ? jumpHostConfigs[i - 1]?.ip
+ : proxySocket
+ ? "proxy"
+ : "direct",
+ usedProxySocket: i === 0 && !!proxySocket,
+ },
+ );
resolve(false);
});
- const connectConfig: any = {
- host: jumpHostConfig.ip,
+ const connectConfig: Record = {
+ host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
port: jumpHostConfig.port || 22,
username: jumpHostConfig.username,
tryKeyboard: true,
@@ -307,6 +364,9 @@ async function createJumpHostChain(
jumpClient.connect(connectConfig);
},
);
+ } else if (proxySocket) {
+ connectConfig.sock = proxySocket;
+ jumpClient.connect(connectConfig);
} else {
jumpClient.connect(connectConfig);
}
@@ -337,6 +397,8 @@ interface SSHSession {
timeout?: NodeJS.Timeout;
activeOperations: number;
sudoPassword?: string;
+ sftp?: import("ssh2").SFTPWrapper;
+ poolKey?: string;
}
interface PendingTOTPSession {
@@ -398,6 +460,29 @@ function execWithSudo(
});
}
+function getSessionSftp(
+ session: SSHSession,
+): Promise {
+ if (session.sftp) {
+ return Promise.resolve(session.sftp);
+ }
+ return new Promise((resolve, reject) => {
+ session.client.sftp((err, sftp) => {
+ if (err) {
+ return reject(err);
+ }
+ session.sftp = sftp;
+ sftp.on("error", () => {
+ session.sftp = undefined;
+ });
+ sftp.on("close", () => {
+ session.sftp = undefined;
+ });
+ resolve(sftp);
+ });
+ });
+}
+
function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
@@ -414,9 +499,19 @@ function cleanupSession(sessionId: string) {
return;
}
+ try {
+ if (session.sftp) {
+ session.sftp.end();
+ session.sftp = undefined;
+ }
+ } catch {
+ // expected
+ }
try {
session.client.end();
- } catch (error) {}
+ } catch {
+ // expected
+ }
clearTimeout(session.timeout);
delete sshSessions[sessionId];
}
@@ -626,8 +721,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
keyPassword,
authType,
credentialId,
- userProvidedPassword,
- forceKeyboardInteractive,
jumpHosts,
useSocks5,
socks5Host,
@@ -692,7 +785,9 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
if (pendingTOTPSessions[sessionId]) {
try {
pendingTOTPSessions[sessionId].client.end();
- } catch {}
+ } catch {
+ // expected
+ }
delete pendingTOTPSessions[sessionId];
}
@@ -727,10 +822,9 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
const credential = credentials[0];
resolvedCredentials = {
password: credential.password,
- sshKey:
- credential.private_key || credential.privateKey || credential.key,
- keyPassword: credential.key_password || credential.keyPassword,
- authType: credential.auth_type || credential.authType,
+ sshKey: credential.privateKey,
+ keyPassword: credential.keyPassword,
+ authType: credential.authType,
};
connectionLogs.push(
createConnectionLog(
@@ -782,7 +876,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
}
const config: Record = {
- host: ip,
+ host: ip?.replace(/^\[|\]$/g, "") || ip,
port,
username,
tryKeyboard: true,
@@ -1171,7 +1265,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
"error",
errorStage,
`DNS resolution failed: ${err.message}`,
- { errorCode: (err as any).code, errorLevel: (err as any).level },
+ {
+ errorCode: (err as unknown as Record).code,
+ errorLevel: (err as unknown as Record).level,
+ },
),
);
} else if (
@@ -1184,7 +1281,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
"error",
errorStage,
`TCP connection failed: ${err.message}`,
- { errorCode: (err as any).code, errorLevel: (err as any).level },
+ {
+ errorCode: (err as unknown as Record).code,
+ errorLevel: (err as unknown as Record).level,
+ },
),
);
} else if (
@@ -1197,7 +1297,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
"error",
errorStage,
`SSH handshake failed: ${err.message}`,
- { errorCode: (err as any).code, errorLevel: (err as any).level },
+ {
+ errorCode: (err as unknown as Record).code,
+ errorLevel: (err as unknown as Record).level,
+ },
),
);
} else if (
@@ -1210,7 +1313,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
"error",
errorStage,
`Authentication failed: ${err.message}`,
- { errorCode: (err as any).code, errorLevel: (err as any).level },
+ {
+ errorCode: (err as unknown as Record).code,
+ errorLevel: (err as unknown as Record).level,
+ },
),
);
} else if (err.message.includes("verification failed")) {
@@ -1220,7 +1326,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
"error",
errorStage,
`SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.`,
- { errorCode: (err as any).code, errorLevel: (err as any).level },
+ {
+ errorCode: (err as unknown as Record).code,
+ errorLevel: (err as unknown as Record).level,
+ },
),
);
} else {
@@ -1229,7 +1338,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
"error",
"error",
`SSH connection failed: ${err.message}`,
- { errorCode: (err as any).code, errorLevel: (err as any).level },
+ {
+ errorCode: (err as unknown as Record).code,
+ errorLevel: (err as unknown as Record).level,
+ },
),
);
}
@@ -1262,8 +1374,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
cleanupSession(sessionId);
});
- let keyboardInteractiveResponded = false;
-
client.on(
"keyboard-interactive",
(
@@ -1292,8 +1402,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
if (responseSent) return;
responseSent = true;
- keyboardInteractiveResponded = true;
-
connectionLogs.push(
createConnectionLog(
"info",
@@ -1362,8 +1470,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
return;
}
- keyboardInteractiveResponded = true;
-
connectionLogs.push(
createConnectionLog(
"info",
@@ -1445,8 +1551,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
return;
}
- keyboardInteractiveResponded = true;
-
pendingTOTPSessions[sessionId] = {
client,
finish,
@@ -1485,76 +1589,33 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
},
);
- if (
+ const proxyConfig: SOCKS5Config | null =
useSocks5 &&
- (socks5Host || (socks5ProxyChain && (socks5ProxyChain as any).length > 0))
- ) {
- connectionLogs.push(
- createConnectionLog("info", "proxy", "Connecting via SOCKS5 proxy", {
- proxyHost: socks5Host,
- proxyPort: socks5Port || 1080,
- }),
- );
- try {
- const socks5Socket = await createSocks5Connection(ip, port, {
- useSocks5,
- socks5Host,
- socks5Port,
- socks5Username,
- socks5Password,
- socks5ProxyChain: socks5ProxyChain as any,
- });
+ (socks5Host ||
+ (socks5ProxyChain && (socks5ProxyChain as ProxyNode[]).length > 0))
+ ? {
+ useSocks5,
+ socks5Host,
+ socks5Port,
+ socks5Username,
+ socks5Password,
+ socks5ProxyChain: socks5ProxyChain as ProxyNode[],
+ }
+ : null;
- if (socks5Socket) {
+ const hasJumpHosts = jumpHosts && jumpHosts.length > 0 && userId;
+
+ if (hasJumpHosts) {
+ try {
+ if (proxyConfig) {
connectionLogs.push(
createConnectionLog(
- "success",
+ "info",
"proxy",
- "SOCKS5 proxy connected successfully",
- ),
- );
- config.sock = socks5Socket;
- client.connect(config);
- return;
- } else {
- fileLogger.error("SOCKS5 socket is null for SFTP", undefined, {
- operation: "sftp_socks5_socket_null",
- sessionId,
- });
- connectionLogs.push(
- createConnectionLog(
- "error",
- "proxy",
- "SOCKS5 socket creation returned null",
+ "Connecting via proxy + jump hosts",
),
);
}
- } catch (socks5Error) {
- fileLogger.error("SOCKS5 connection failed", socks5Error, {
- operation: "socks5_connect",
- sessionId,
- hostId,
- proxyHost: socks5Host,
- proxyPort: socks5Port || 1080,
- });
- connectionLogs.push(
- createConnectionLog(
- "error",
- "proxy",
- `SOCKS5 proxy connection failed: ${socks5Error instanceof Error ? socks5Error.message : "Unknown error"}`,
- ),
- );
- return res.status(500).json({
- error:
- "SOCKS5 proxy connection failed: " +
- (socks5Error instanceof Error
- ? socks5Error.message
- : "Unknown error"),
- connectionLogs,
- });
- }
- } else if (jumpHosts && jumpHosts.length > 0 && userId) {
- try {
connectionLogs.push(
createConnectionLog(
"info",
@@ -1562,7 +1623,11 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
`Connecting via ${jumpHosts.length} jump host(s)`,
),
);
- const jumpClient = await createJumpHostChain(jumpHosts, userId);
+ const jumpClient = await createJumpHostChain(
+ jumpHosts,
+ userId,
+ proxyConfig,
+ );
if (!jumpClient) {
fileLogger.error("Failed to establish jump host chain", {
@@ -1627,6 +1692,48 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
connectionLogs,
});
}
+ } else if (proxyConfig) {
+ connectionLogs.push(
+ createConnectionLog("info", "proxy", "Connecting via proxy", {
+ proxyHost: socks5Host,
+ proxyPort: socks5Port || 1080,
+ }),
+ );
+ try {
+ const proxySocket = await createSocks5Connection(ip, port, proxyConfig);
+ if (proxySocket) {
+ connectionLogs.push(
+ createConnectionLog(
+ "success",
+ "proxy",
+ "Proxy connected successfully",
+ ),
+ );
+ config.sock = proxySocket;
+ }
+ client.connect(config);
+ } catch (proxyError) {
+ fileLogger.error("Proxy connection failed", proxyError, {
+ operation: "proxy_connect",
+ sessionId,
+ hostId,
+ proxyHost: socks5Host,
+ proxyPort: socks5Port || 1080,
+ });
+ connectionLogs.push(
+ createConnectionLog(
+ "error",
+ "proxy",
+ `Proxy connection failed: ${proxyError instanceof Error ? proxyError.message : "Unknown error"}`,
+ ),
+ );
+ return res.status(500).json({
+ error:
+ "Proxy connection failed: " +
+ (proxyError instanceof Error ? proxyError.message : "Unknown error"),
+ connectionLogs,
+ });
+ }
} else {
client.connect(config);
}
@@ -1687,7 +1794,9 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
delete pendingTOTPSessions[sessionId];
try {
session.client.end();
- } catch (error) {}
+ } catch {
+ // expected
+ }
fileLogger.warn("TOTP session timeout before code submission", {
operation: "file_totp_verify",
sessionId,
@@ -1710,7 +1819,6 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
});
let responseSent = false;
- let responseTimeout: NodeJS.Timeout;
session.client.once("ready", () => {
if (responseSent) return;
@@ -1799,7 +1907,7 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
res.status(401).json({ status: "error", message: "Invalid TOTP code" });
});
- responseTimeout = setTimeout(() => {
+ const responseTimeout = setTimeout(() => {
if (!responseSent) {
responseSent = true;
delete pendingTOTPSessions[sessionId];
@@ -1884,7 +1992,9 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => {
delete pendingTOTPSessions[sessionId];
try {
session.client.end();
- } catch (error) {}
+ } catch {
+ // expected
+ }
fileLogger.warn("Warpgate session timeout before completion", {
operation: "file_warpgate_verify",
sessionId,
@@ -1897,7 +2007,19 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => {
}
let responseSent = false;
- let responseTimeout: NodeJS.Timeout;
+
+ const responseTimeout = setTimeout(() => {
+ if (!responseSent) {
+ responseSent = true;
+ delete pendingTOTPSessions[sessionId];
+ fileLogger.warn("Warpgate verification timeout", {
+ operation: "file_warpgate_verify",
+ sessionId,
+ userId,
+ });
+ res.status(408).json({ error: "Warpgate verification timeout" });
+ }
+ }, 60000);
session.client.once("ready", () => {
if (responseSent) return;
@@ -1987,19 +2109,6 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => {
.json({ status: "error", message: "Warpgate authentication failed" });
});
- responseTimeout = setTimeout(() => {
- if (!responseSent) {
- responseSent = true;
- delete pendingTOTPSessions[sessionId];
- fileLogger.warn("Warpgate verification timeout", {
- operation: "file_warpgate_verify",
- sessionId,
- userId,
- });
- res.status(408).json({ error: "Warpgate verification timeout" });
- }
- }, 60000);
-
session.finish([""]);
});
@@ -2188,26 +2297,285 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
userId,
path: sshPath,
});
- sshConn.client.sftp((err, sftp) => {
- if (err) {
+ getSessionSftp(sshConn)
+ .then((sftp) => {
+ sftp.readdir(sshPath, (readdirErr, list) => {
+ if (readdirErr) {
+ fileLogger.warn(
+ `SFTP readdir failed, trying fallback: ${readdirErr.message}`,
+ );
+ tryFallbackMethod();
+ return;
+ }
+
+ const symlinks: Array<{ index: number; path: string }> = [];
+ const files: Array<{
+ name: string;
+ type: string;
+ size: number | undefined;
+ modified: string;
+ permissions: string;
+ owner: string;
+ group: string;
+ linkTarget: string | undefined;
+ path: string;
+ executable: boolean;
+ }> = [];
+
+ for (const entry of list) {
+ if (entry.filename === "." || entry.filename === "..") continue;
+
+ const attrs = entry.attrs;
+ const permissions = modeToPermissions(attrs.mode);
+ const isDirectory = attrs.isDirectory();
+ const isLink = attrs.isSymbolicLink();
+
+ const fileEntry = {
+ name: entry.filename,
+ type: isDirectory ? "directory" : isLink ? "link" : "file",
+ size: isDirectory ? undefined : attrs.size,
+ modified: formatMtime(attrs.mtime),
+ permissions,
+ owner: String(attrs.uid),
+ group: String(attrs.gid),
+ linkTarget: undefined as string | undefined,
+ path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${entry.filename}`,
+ executable:
+ !isDirectory && !isLink
+ ? isExecutableFile(permissions, entry.filename)
+ : false,
+ };
+
+ if (isLink) {
+ symlinks.push({ index: files.length, path: fileEntry.path });
+ }
+
+ files.push(fileEntry);
+ }
+
+ if (symlinks.length === 0) {
+ sshConn.activeOperations--;
+ return res.json({ files, path: sshPath });
+ }
+
+ let resolved = 0;
+ let responded = false;
+
+ const sendResponse = () => {
+ if (responded) return;
+ responded = true;
+ sshConn.activeOperations--;
+ res.json({ files, path: sshPath });
+ };
+
+ const readlinkTimeout = setTimeout(sendResponse, 5000);
+
+ for (const link of symlinks) {
+ sftp.readlink(link.path, (linkErr, target) => {
+ resolved++;
+ if (!linkErr && target) {
+ files[link.index].linkTarget = target;
+ }
+ if (resolved === symlinks.length) {
+ clearTimeout(readlinkTimeout);
+ sendResponse();
+ }
+ });
+ }
+ });
+ })
+ .catch((err: Error) => {
fileLogger.warn(
`SFTP failed for listFiles, trying fallback: ${err.message}`,
);
tryFallbackMethod();
- return;
- }
+ });
+ } catch (sftpErr: unknown) {
+ const errMsg =
+ sftpErr instanceof Error ? sftpErr.message : "Unknown error";
+ fileLogger.warn(`SFTP connection error, trying fallback: ${errMsg}`);
+ tryFallbackMethod();
+ }
+ };
- sftp.readdir(sshPath, (readdirErr, list) => {
- if (readdirErr) {
- sftp.end();
- fileLogger.warn(
- `SFTP readdir failed, trying fallback: ${readdirErr.message}`,
- );
- tryFallbackMethod();
- return;
+ const tryFallbackMethod = () => {
+ try {
+ const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
+ sshConn.client.exec(
+ `command ls -la --color=never '${escapedPath}'`,
+ (err, stream) => {
+ if (err) {
+ sshConn.activeOperations--;
+ fileLogger.error("SSH listFiles error:", err);
+ return res.status(500).json({ error: err.message });
}
- const symlinks: Array<{ index: number; path: string }> = [];
+ let data = "";
+ let errorData = "";
+
+ stream.on("data", (chunk: Buffer) => {
+ data += chunk.toString();
+ });
+
+ stream.stderr.on("data", (chunk: Buffer) => {
+ errorData += chunk.toString();
+ });
+
+ stream.on("close", (code) => {
+ if (code !== 0) {
+ const isPermissionDenied =
+ errorData.toLowerCase().includes("permission denied") ||
+ errorData.toLowerCase().includes("access denied");
+
+ if (isPermissionDenied) {
+ if (sshConn.sudoPassword) {
+ fileLogger.info(
+ `Permission denied for listFiles, retrying with sudo: ${sshPath}`,
+ );
+ tryWithSudo();
+ return;
+ }
+
+ sshConn.activeOperations--;
+ fileLogger.warn(
+ `Permission denied for listFiles, sudo required: ${sshPath}`,
+ );
+ return res.status(403).json({
+ error: `Permission denied: Cannot access ${sshPath}`,
+ needsSudo: true,
+ path: sshPath,
+ });
+ }
+
+ sshConn.activeOperations--;
+ fileLogger.error(
+ `SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
+ );
+ return res
+ .status(500)
+ .json({ error: `Command failed: ${errorData}` });
+ }
+ sshConn.activeOperations--;
+
+ const lines = data.split("\n").filter((line) => line.trim());
+ const files = [];
+
+ for (let i = 1; i < lines.length; i++) {
+ const line = lines[i];
+ const parts = line.split(/\s+/);
+ if (parts.length >= 9) {
+ const permissions = parts[0];
+ const owner = parts[2];
+ const group = parts[3];
+ const size = parseInt(parts[4], 10);
+
+ let dateStr = "";
+ const nameStartIndex = 8;
+
+ if (parts[5] && parts[6] && parts[7]) {
+ dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
+ }
+
+ const name = parts.slice(nameStartIndex).join(" ");
+ const isDirectory = permissions.startsWith("d");
+ const isLink = permissions.startsWith("l");
+
+ if (name === "." || name === "..") continue;
+
+ let actualName = name;
+ let linkTarget = undefined;
+ if (isLink && name.includes(" -> ")) {
+ const linkParts = name.split(" -> ");
+ actualName = linkParts[0];
+ linkTarget = linkParts[1];
+ }
+
+ files.push({
+ name: actualName,
+ type: isDirectory ? "directory" : isLink ? "link" : "file",
+ size: isDirectory ? undefined : size,
+ modified: dateStr,
+ permissions,
+ owner,
+ group,
+ linkTarget,
+ path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
+ executable:
+ !isDirectory && !isLink
+ ? isExecutableFile(permissions, actualName)
+ : false,
+ });
+ }
+ }
+
+ res.json({ files, path: sshPath });
+ });
+ },
+ );
+ } catch (execErr: unknown) {
+ sshConn.activeOperations--;
+ const errMsg =
+ execErr instanceof Error ? execErr.message : "Unknown error";
+ fileLogger.error(`Fallback listFiles exec failed: ${errMsg}`);
+ if (!res.headersSent) {
+ return res.status(500).json({ error: errMsg });
+ }
+ }
+ };
+
+ const tryWithSudo = () => {
+ try {
+ const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
+ const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'");
+ const sudoCommand = `echo '${escapedPassword}' | sudo -S /bin/ls -la --color=never '${escapedPath}' 2>&1`;
+
+ sshConn.client.exec(sudoCommand, (err, stream) => {
+ if (err) {
+ sshConn.activeOperations--;
+ fileLogger.error("SSH sudo listFiles error:", err);
+ return res.status(500).json({ error: err.message });
+ }
+
+ let data = "";
+ let errorData = "";
+
+ stream.on("data", (chunk: Buffer) => {
+ data += chunk.toString();
+ });
+
+ stream.stderr.on("data", (chunk: Buffer) => {
+ errorData += chunk.toString();
+ });
+
+ stream.on("close", (code) => {
+ sshConn.activeOperations--;
+
+ data = data.replace(/\[sudo\] password for .+?:\s*/g, "");
+
+ if (
+ data.toLowerCase().includes("sorry, try again") ||
+ data.toLowerCase().includes("incorrect password") ||
+ errorData.toLowerCase().includes("sorry, try again")
+ ) {
+ sshConn.sudoPassword = undefined;
+ return res.status(403).json({
+ error: "Sudo authentication failed. Please try again.",
+ needsSudo: true,
+ sudoFailed: true,
+ path: sshPath,
+ });
+ }
+
+ if (code !== 0 && !data.trim()) {
+ fileLogger.error(
+ `SSH sudo listFiles failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
+ );
+ return res
+ .status(500)
+ .json({ error: `Sudo command failed: ${errorData || data}` });
+ }
+
+ const lines = data.split("\n").filter((line) => line.trim());
const files: Array<{
name: string;
type: string;
@@ -2221,308 +2589,68 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
executable: boolean;
}> = [];
- for (const entry of list) {
- if (entry.filename === "." || entry.filename === "..") continue;
+ for (let i = 1; i < lines.length; i++) {
+ const line = lines[i];
+ const parts = line.split(/\s+/);
+ if (parts.length >= 9) {
+ const permissions = parts[0];
+ const owner = parts[2];
+ const group = parts[3];
+ const size = parseInt(parts[4], 10);
- const attrs = entry.attrs;
- const permissions = modeToPermissions(attrs.mode);
- const isDirectory = attrs.isDirectory();
- const isLink = attrs.isSymbolicLink();
+ let dateStr = "";
+ const nameStartIndex = 8;
- const fileEntry = {
- name: entry.filename,
- type: isDirectory ? "directory" : isLink ? "link" : "file",
- size: isDirectory ? undefined : attrs.size,
- modified: formatMtime(attrs.mtime),
- permissions,
- owner: String(attrs.uid),
- group: String(attrs.gid),
- linkTarget: undefined as string | undefined,
- path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${entry.filename}`,
- executable:
- !isDirectory && !isLink
- ? isExecutableFile(permissions, entry.filename)
- : false,
- };
+ if (parts[5] && parts[6] && parts[7]) {
+ dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
+ }
- if (isLink) {
- symlinks.push({ index: files.length, path: fileEntry.path });
+ const name = parts.slice(nameStartIndex).join(" ");
+ const isDirectory = permissions.startsWith("d");
+ const isLink = permissions.startsWith("l");
+
+ if (name === "." || name === "..") continue;
+
+ let actualName = name;
+ let linkTarget = undefined;
+ if (isLink && name.includes(" -> ")) {
+ const linkParts = name.split(" -> ");
+ actualName = linkParts[0];
+ linkTarget = linkParts[1];
+ }
+
+ files.push({
+ name: actualName,
+ type: isDirectory ? "directory" : isLink ? "link" : "file",
+ size: isDirectory ? undefined : size,
+ modified: dateStr,
+ permissions,
+ owner,
+ group,
+ linkTarget,
+ path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
+ executable:
+ !isDirectory && !isLink
+ ? isExecutableFile(permissions, actualName)
+ : false,
+ });
}
-
- files.push(fileEntry);
}
- if (symlinks.length === 0) {
- sftp.end();
- sshConn.activeOperations--;
- return res.json({ files, path: sshPath });
- }
-
- let resolved = 0;
- let responded = false;
-
- const sendResponse = () => {
- if (responded) return;
- responded = true;
- sftp.end();
- sshConn.activeOperations--;
- res.json({ files, path: sshPath });
- };
-
- const readlinkTimeout = setTimeout(sendResponse, 5000);
-
- for (const link of symlinks) {
- sftp.readlink(link.path, (linkErr, target) => {
- resolved++;
- if (!linkErr && target) {
- files[link.index].linkTarget = target;
- }
- if (resolved === symlinks.length) {
- clearTimeout(readlinkTimeout);
- sendResponse();
- }
- });
- }
+ res.json({ files, path: sshPath });
});
});
- } catch (sftpErr: unknown) {
+ } catch (execErr: unknown) {
+ sshConn.activeOperations--;
const errMsg =
- sftpErr instanceof Error ? sftpErr.message : "Unknown error";
- fileLogger.warn(`SFTP connection error, trying fallback: ${errMsg}`);
- tryFallbackMethod();
+ execErr instanceof Error ? execErr.message : "Unknown error";
+ fileLogger.error(`Sudo listFiles exec failed: ${errMsg}`);
+ if (!res.headersSent) {
+ return res.status(500).json({ error: errMsg });
+ }
}
};
- const tryFallbackMethod = () => {
- const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
- sshConn.client.exec(`command ls -la '${escapedPath}'`, (err, stream) => {
- if (err) {
- sshConn.activeOperations--;
- fileLogger.error("SSH listFiles error:", err);
- return res.status(500).json({ error: err.message });
- }
-
- let data = "";
- let errorData = "";
-
- stream.on("data", (chunk: Buffer) => {
- data += chunk.toString();
- });
-
- stream.stderr.on("data", (chunk: Buffer) => {
- errorData += chunk.toString();
- });
-
- stream.on("close", (code) => {
- if (code !== 0) {
- const isPermissionDenied =
- errorData.toLowerCase().includes("permission denied") ||
- errorData.toLowerCase().includes("access denied");
-
- if (isPermissionDenied) {
- if (sshConn.sudoPassword) {
- fileLogger.info(
- `Permission denied for listFiles, retrying with sudo: ${sshPath}`,
- );
- tryWithSudo();
- return;
- }
-
- sshConn.activeOperations--;
- fileLogger.warn(
- `Permission denied for listFiles, sudo required: ${sshPath}`,
- );
- return res.status(403).json({
- error: `Permission denied: Cannot access ${sshPath}`,
- needsSudo: true,
- path: sshPath,
- });
- }
-
- sshConn.activeOperations--;
- fileLogger.error(
- `SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
- );
- return res
- .status(500)
- .json({ error: `Command failed: ${errorData}` });
- }
- sshConn.activeOperations--;
-
- const lines = data.split("\n").filter((line) => line.trim());
- const files = [];
-
- for (let i = 1; i < lines.length; i++) {
- const line = lines[i];
- const parts = line.split(/\s+/);
- if (parts.length >= 9) {
- const permissions = parts[0];
- const owner = parts[2];
- const group = parts[3];
- const size = parseInt(parts[4], 10);
-
- let dateStr = "";
- const nameStartIndex = 8;
-
- if (parts[5] && parts[6] && parts[7]) {
- dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
- }
-
- const name = parts.slice(nameStartIndex).join(" ");
- const isDirectory = permissions.startsWith("d");
- const isLink = permissions.startsWith("l");
-
- if (name === "." || name === "..") continue;
-
- let actualName = name;
- let linkTarget = undefined;
- if (isLink && name.includes(" -> ")) {
- const linkParts = name.split(" -> ");
- actualName = linkParts[0];
- linkTarget = linkParts[1];
- }
-
- files.push({
- name: actualName,
- type: isDirectory ? "directory" : isLink ? "link" : "file",
- size: isDirectory ? undefined : size,
- modified: dateStr,
- permissions,
- owner,
- group,
- linkTarget,
- path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
- executable:
- !isDirectory && !isLink
- ? isExecutableFile(permissions, actualName)
- : false,
- });
- }
- }
-
- res.json({ files, path: sshPath });
- });
- });
- };
-
- const tryWithSudo = () => {
- const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
- const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'");
- const sudoCommand = `echo '${escapedPassword}' | sudo -S ls -la '${escapedPath}' 2>&1`;
-
- sshConn.client.exec(sudoCommand, (err, stream) => {
- if (err) {
- sshConn.activeOperations--;
- fileLogger.error("SSH sudo listFiles error:", err);
- return res.status(500).json({ error: err.message });
- }
-
- let data = "";
- let errorData = "";
-
- stream.on("data", (chunk: Buffer) => {
- data += chunk.toString();
- });
-
- stream.stderr.on("data", (chunk: Buffer) => {
- errorData += chunk.toString();
- });
-
- stream.on("close", (code) => {
- sshConn.activeOperations--;
-
- data = data.replace(/\[sudo\] password for .+?:\s*/g, "");
-
- if (
- data.toLowerCase().includes("sorry, try again") ||
- data.toLowerCase().includes("incorrect password") ||
- errorData.toLowerCase().includes("sorry, try again")
- ) {
- sshConn.sudoPassword = undefined;
- return res.status(403).json({
- error: "Sudo authentication failed. Please try again.",
- needsSudo: true,
- sudoFailed: true,
- path: sshPath,
- });
- }
-
- if (code !== 0 && !data.trim()) {
- fileLogger.error(
- `SSH sudo listFiles failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
- );
- return res
- .status(500)
- .json({ error: `Sudo command failed: ${errorData || data}` });
- }
-
- const lines = data.split("\n").filter((line) => line.trim());
- const files: Array<{
- name: string;
- type: string;
- size: number | undefined;
- modified: string;
- permissions: string;
- owner: string;
- group: string;
- linkTarget: string | undefined;
- path: string;
- executable: boolean;
- }> = [];
-
- for (let i = 1; i < lines.length; i++) {
- const line = lines[i];
- const parts = line.split(/\s+/);
- if (parts.length >= 9) {
- const permissions = parts[0];
- const owner = parts[2];
- const group = parts[3];
- const size = parseInt(parts[4], 10);
-
- let dateStr = "";
- const nameStartIndex = 8;
-
- if (parts[5] && parts[6] && parts[7]) {
- dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
- }
-
- const name = parts.slice(nameStartIndex).join(" ");
- const isDirectory = permissions.startsWith("d");
- const isLink = permissions.startsWith("l");
-
- if (name === "." || name === "..") continue;
-
- let actualName = name;
- let linkTarget = undefined;
- if (isLink && name.includes(" -> ")) {
- const linkParts = name.split(" -> ");
- actualName = linkParts[0];
- linkTarget = linkParts[1];
- }
-
- files.push({
- name: actualName,
- type: isDirectory ? "directory" : isLink ? "link" : "file",
- size: isDirectory ? undefined : size,
- modified: dateStr,
- permissions,
- owner,
- group,
- linkTarget,
- path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
- executable:
- !isDirectory && !isLink
- ? isExecutableFile(permissions, actualName)
- : false,
- });
- }
- }
-
- res.json({ files, path: sshPath });
- });
- });
- };
-
trySFTP();
});
@@ -2620,6 +2748,98 @@ app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
});
});
+/**
+ * @openapi
+ * /ssh/file_manager/ssh/resolvePath:
+ * get:
+ * summary: Resolve a path with environment variables
+ * description: Expands environment variables and ~ in a path via the SSH session.
+ * tags:
+ * - File Manager
+ * parameters:
+ * - in: query
+ * name: sessionId
+ * required: true
+ * schema:
+ * type: string
+ * - in: query
+ * name: path
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: The resolved absolute path.
+ * 400:
+ * description: Missing required parameters.
+ * 500:
+ * description: Failed to resolve path.
+ */
+app.get("/ssh/file_manager/ssh/resolvePath", (req, res) => {
+ const sessionId = req.query.sessionId as string;
+ const sshConn = sshSessions[sessionId];
+ const rawPath = decodeURIComponent(req.query.path as string);
+
+ if (!sessionId) {
+ return res.status(400).json({ error: "Session ID is required" });
+ }
+
+ if (!sshConn?.isConnected) {
+ return res.status(400).json({ error: "SSH connection not established" });
+ }
+
+ if (!rawPath) {
+ return res.status(400).json({ error: "Path is required" });
+ }
+
+ sshConn.lastActive = Date.now();
+
+ let expandPath = rawPath;
+ if (expandPath.startsWith("~")) {
+ expandPath = "$HOME" + expandPath.substring(1);
+ }
+
+ const escapedPath = expandPath.replace(/"/g, '\\"');
+ const command = `echo "${escapedPath}"`;
+
+ sshConn.client.exec(command, (err, stream) => {
+ if (err) {
+ fileLogger.error("SSH resolvePath error:", err);
+ return res.status(500).json({ error: err.message });
+ }
+
+ let data = "";
+ let errorData = "";
+
+ stream.on("data", (chunk: Buffer) => {
+ data += chunk.toString();
+ });
+
+ stream.stderr.on("data", (chunk: Buffer) => {
+ errorData += chunk.toString();
+ });
+
+ stream.on("close", (code) => {
+ if (code !== 0) {
+ fileLogger.error(
+ `SSH resolvePath command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
+ );
+ return res.json({ resolvedPath: rawPath });
+ }
+
+ const resolved = data.trim();
+ res.json({ resolvedPath: resolved || rawPath });
+ });
+
+ stream.on("error", (streamErr) => {
+ fileLogger.error("SSH resolvePath stream error:", streamErr);
+ if (!res.headersSent) {
+ res.json({ resolvedPath: rawPath });
+ }
+ });
+ });
+});
+
/**
* @openapi
* /ssh/file_manager/ssh/readFile:
@@ -2870,113 +3090,115 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
userId,
path: filePath,
});
- sshConn.client.sftp((err, sftp) => {
- if (err) {
+ getSessionSftp(sshConn)
+ .then((sftp) => {
+ let fileBuffer;
+ try {
+ if (typeof content === "string") {
+ try {
+ const testBuffer = Buffer.from(content, "base64");
+ if (testBuffer.toString("base64") === content) {
+ fileBuffer = testBuffer;
+ } else {
+ fileBuffer = Buffer.from(content, "utf8");
+ }
+ } catch {
+ fileBuffer = Buffer.from(content, "utf8");
+ }
+ } else if (Buffer.isBuffer(content)) {
+ fileBuffer = content;
+ } else {
+ fileBuffer = Buffer.from(content);
+ }
+ } catch (bufferErr) {
+ fileLogger.error("Buffer conversion error:", bufferErr);
+ if (!res.headersSent) {
+ return res
+ .status(500)
+ .json({ error: "Invalid file content format" });
+ }
+ return;
+ }
+
+ const writeStream = sftp.createWriteStream(filePath);
+
+ let hasError = false;
+ let hasFinished = false;
+
+ writeStream.on("error", (streamErr) => {
+ if (hasError || hasFinished) return;
+ hasError = true;
+ fileLogger.warn(
+ `SFTP write failed, trying fallback method: ${streamErr.message}`,
+ );
+ tryFallbackMethod();
+ });
+
+ writeStream.on("finish", () => {
+ if (hasError || hasFinished) return;
+ hasFinished = true;
+ fileLogger.success("File written successfully", {
+ operation: "file_write_success",
+ sessionId,
+ userId,
+ path: filePath,
+ bytes: fileBuffer.length,
+ });
+ if (!res.headersSent) {
+ res.json({
+ message: "File written successfully",
+ path: filePath,
+ toast: {
+ type: "success",
+ message: `File written: ${filePath}`,
+ },
+ });
+ }
+ });
+
+ writeStream.on("close", () => {
+ if (hasError || hasFinished) return;
+ hasFinished = true;
+ fileLogger.success("File written successfully", {
+ operation: "file_write_success",
+ sessionId,
+ userId,
+ path: filePath,
+ bytes: fileBuffer.length,
+ });
+ if (!res.headersSent) {
+ res.json({
+ message: "File written successfully",
+ path: filePath,
+ toast: {
+ type: "success",
+ message: `File written: ${filePath}`,
+ },
+ });
+ }
+ });
+
+ try {
+ writeStream.write(fileBuffer);
+ writeStream.end();
+ } catch (writeErr) {
+ if (hasError || hasFinished) return;
+ hasError = true;
+ fileLogger.warn(
+ `SFTP write operation failed, trying fallback method: ${writeErr.message}`,
+ );
+ tryFallbackMethod();
+ }
+ })
+ .catch((err: Error) => {
fileLogger.warn(
`SFTP failed, trying fallback method: ${err.message}`,
);
tryFallbackMethod();
- return;
- }
-
- let fileBuffer;
- try {
- if (typeof content === "string") {
- try {
- const testBuffer = Buffer.from(content, "base64");
- if (testBuffer.toString("base64") === content) {
- fileBuffer = testBuffer;
- } else {
- fileBuffer = Buffer.from(content, "utf8");
- }
- } catch {
- fileBuffer = Buffer.from(content, "utf8");
- }
- } else if (Buffer.isBuffer(content)) {
- fileBuffer = content;
- } else {
- fileBuffer = Buffer.from(content);
- }
- } catch (bufferErr) {
- fileLogger.error("Buffer conversion error:", bufferErr);
- if (!res.headersSent) {
- return res
- .status(500)
- .json({ error: "Invalid file content format" });
- }
- return;
- }
-
- const writeStream = sftp.createWriteStream(filePath);
-
- let hasError = false;
- let hasFinished = false;
-
- writeStream.on("error", (streamErr) => {
- if (hasError || hasFinished) return;
- hasError = true;
- sftp.end();
- fileLogger.warn(
- `SFTP write failed, trying fallback method: ${streamErr.message}`,
- );
- tryFallbackMethod();
});
-
- writeStream.on("finish", () => {
- if (hasError || hasFinished) return;
- hasFinished = true;
- sftp.end();
- fileLogger.success("File written successfully", {
- operation: "file_write_success",
- sessionId,
- userId,
- path: filePath,
- bytes: fileBuffer.length,
- });
- if (!res.headersSent) {
- res.json({
- message: "File written successfully",
- path: filePath,
- toast: { type: "success", message: `File written: ${filePath}` },
- });
- }
- });
-
- writeStream.on("close", () => {
- if (hasError || hasFinished) return;
- hasFinished = true;
- sftp.end();
- fileLogger.success("File written successfully", {
- operation: "file_write_success",
- sessionId,
- userId,
- path: filePath,
- bytes: fileBuffer.length,
- });
- if (!res.headersSent) {
- res.json({
- message: "File written successfully",
- path: filePath,
- toast: { type: "success", message: `File written: ${filePath}` },
- });
- }
- });
-
- try {
- writeStream.write(fileBuffer);
- writeStream.end();
- } catch (writeErr) {
- if (hasError || hasFinished) return;
- hasError = true;
- fileLogger.warn(
- `SFTP write operation failed, trying fallback method: ${writeErr.message}`,
- );
- tryFallbackMethod();
- }
- });
} catch (sftpErr) {
fileLogger.warn(
- `SFTP connection error, trying fallback method: ${sftpErr.message}`,
+ `SFTP connection error, trying fallback method: ${(sftpErr as Error).message}`,
);
tryFallbackMethod();
}
@@ -3154,113 +3376,115 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
userId,
path: fullPath,
});
- sshConn.client.sftp((err, sftp) => {
- if (err) {
+ getSessionSftp(sshConn)
+ .then((sftp) => {
+ let fileBuffer;
+ try {
+ if (typeof content === "string") {
+ fileBuffer = Buffer.from(content, "base64");
+ } else if (Buffer.isBuffer(content)) {
+ fileBuffer = content;
+ } else {
+ fileBuffer = Buffer.from(content);
+ }
+ } catch (bufferErr) {
+ fileLogger.error("Buffer conversion error:", bufferErr);
+ if (!res.headersSent) {
+ return res
+ .status(500)
+ .json({ error: "Invalid file content format" });
+ }
+ return;
+ }
+
+ const writeStream = sftp.createWriteStream(fullPath);
+
+ let hasError = false;
+ let hasFinished = false;
+
+ writeStream.on("error", (streamErr) => {
+ if (hasError || hasFinished) return;
+ hasError = true;
+ fileLogger.warn(
+ `SFTP write failed, trying fallback method: ${streamErr.message}`,
+ {
+ operation: "file_upload",
+ sessionId,
+ fileName,
+ fileSize: contentSize,
+ error: streamErr.message,
+ },
+ );
+ tryFallbackMethod();
+ });
+
+ writeStream.on("finish", () => {
+ if (hasError || hasFinished) return;
+ hasFinished = true;
+ fileLogger.success("File upload completed", {
+ operation: "file_upload_complete",
+ sessionId,
+ userId,
+ path: fullPath,
+ bytes: fileBuffer.length,
+ duration: Date.now() - uploadStartTime,
+ });
+ if (!res.headersSent) {
+ res.json({
+ message: "File uploaded successfully",
+ path: fullPath,
+ toast: {
+ type: "success",
+ message: `File uploaded: ${fullPath}`,
+ },
+ });
+ }
+ });
+
+ writeStream.on("close", () => {
+ if (hasError || hasFinished) return;
+ hasFinished = true;
+ fileLogger.success("File upload completed", {
+ operation: "file_upload_complete",
+ sessionId,
+ userId,
+ path: fullPath,
+ bytes: fileBuffer.length,
+ duration: Date.now() - uploadStartTime,
+ });
+ if (!res.headersSent) {
+ res.json({
+ message: "File uploaded successfully",
+ path: fullPath,
+ toast: {
+ type: "success",
+ message: `File uploaded: ${fullPath}`,
+ },
+ });
+ }
+ });
+
+ try {
+ writeStream.write(fileBuffer);
+ writeStream.end();
+ } catch (writeErr) {
+ if (hasError || hasFinished) return;
+ hasError = true;
+ fileLogger.warn(
+ `SFTP write operation failed, trying fallback method: ${(writeErr as Error).message}`,
+ );
+ tryFallbackMethod();
+ }
+ })
+ .catch((err: Error) => {
fileLogger.warn(
`SFTP failed, trying fallback method: ${err.message}`,
);
tryFallbackMethod();
- return;
- }
-
- let fileBuffer;
- try {
- if (typeof content === "string") {
- fileBuffer = Buffer.from(content, "base64");
- } else if (Buffer.isBuffer(content)) {
- fileBuffer = content;
- } else {
- fileBuffer = Buffer.from(content);
- }
- } catch (bufferErr) {
- fileLogger.error("Buffer conversion error:", bufferErr);
- if (!res.headersSent) {
- return res
- .status(500)
- .json({ error: "Invalid file content format" });
- }
- return;
- }
-
- const writeStream = sftp.createWriteStream(fullPath);
-
- let hasError = false;
- let hasFinished = false;
-
- writeStream.on("error", (streamErr) => {
- if (hasError || hasFinished) return;
- hasError = true;
- sftp.end();
- fileLogger.warn(
- `SFTP write failed, trying fallback method: ${streamErr.message}`,
- {
- operation: "file_upload",
- sessionId,
- fileName,
- fileSize: contentSize,
- error: streamErr.message,
- },
- );
- tryFallbackMethod();
});
-
- writeStream.on("finish", () => {
- if (hasError || hasFinished) return;
- hasFinished = true;
- sftp.end();
- fileLogger.success("File upload completed", {
- operation: "file_upload_complete",
- sessionId,
- userId,
- path: fullPath,
- bytes: fileBuffer.length,
- duration: Date.now() - uploadStartTime,
- });
- if (!res.headersSent) {
- res.json({
- message: "File uploaded successfully",
- path: fullPath,
- toast: { type: "success", message: `File uploaded: ${fullPath}` },
- });
- }
- });
-
- writeStream.on("close", () => {
- if (hasError || hasFinished) return;
- hasFinished = true;
- sftp.end();
- fileLogger.success("File upload completed", {
- operation: "file_upload_complete",
- sessionId,
- userId,
- path: fullPath,
- bytes: fileBuffer.length,
- duration: Date.now() - uploadStartTime,
- });
- if (!res.headersSent) {
- res.json({
- message: "File uploaded successfully",
- path: fullPath,
- toast: { type: "success", message: `File uploaded: ${fullPath}` },
- });
- }
- });
-
- try {
- writeStream.write(fileBuffer);
- writeStream.end();
- } catch (writeErr) {
- if (hasError || hasFinished) return;
- hasError = true;
- fileLogger.warn(
- `SFTP write operation failed, trying fallback method: ${writeErr.message}`,
- );
- tryFallbackMethod();
- }
- });
} catch (sftpErr) {
fileLogger.warn(
- `SFTP connection error, trying fallback method: ${sftpErr.message}`,
+ `SFTP connection error, trying fallback method: ${(sftpErr as Error).message}`,
);
tryFallbackMethod();
}
@@ -4285,80 +4509,77 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
path: filePath,
});
- sshConn.client.sftp((err, sftp) => {
- if (err) {
- fileLogger.error("SFTP connection failed for download:", err);
- return res.status(500).json({ error: "SFTP connection failed" });
- }
-
- sftp.stat(filePath, (statErr, stats) => {
- if (statErr) {
- sftp.end();
- fileLogger.error("File stat failed for download:", statErr);
- return res
- .status(500)
- .json({ error: `Cannot access file: ${statErr.message}` });
- }
-
- if (!stats.isFile()) {
- fileLogger.warn("Attempted to download non-file", {
- operation: "file_download",
- sessionId,
- filePath,
- isFile: stats.isFile(),
- isDirectory: stats.isDirectory(),
- });
- return res
- .status(400)
- .json({ error: "Cannot download directories or special files" });
- }
-
- const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024;
- if (stats.size > MAX_FILE_SIZE) {
- fileLogger.warn("File too large for download", {
- operation: "file_download",
- sessionId,
- filePath,
- fileSize: stats.size,
- maxSize: MAX_FILE_SIZE,
- });
- return res.status(400).json({
- error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB, file is ${(stats.size / 1024 / 1024).toFixed(2)}MB`,
- });
- }
-
- sftp.readFile(filePath, (readErr, data) => {
- if (readErr) {
- sftp.end();
- fileLogger.error("File read failed for download:", readErr);
+ getSessionSftp(sshConn)
+ .then((sftp) => {
+ sftp.stat(filePath, (statErr, stats) => {
+ if (statErr) {
+ fileLogger.error("File stat failed for download:", statErr);
return res
.status(500)
- .json({ error: `Failed to read file: ${readErr.message}` });
+ .json({ error: `Cannot access file: ${statErr.message}` });
}
- sftp.end();
- const base64Content = data.toString("base64");
- const fileName = filePath.split("/").pop() || "download";
- fileLogger.success("File download completed", {
- operation: "file_download_complete",
- sessionId,
- userId,
- hostId,
- path: filePath,
- bytes: stats.size,
- duration: Date.now() - downloadStartTime,
- });
+ if (!stats.isFile()) {
+ fileLogger.warn("Attempted to download non-file", {
+ operation: "file_download",
+ sessionId,
+ filePath,
+ isFile: stats.isFile(),
+ isDirectory: stats.isDirectory(),
+ });
+ return res
+ .status(400)
+ .json({ error: "Cannot download directories or special files" });
+ }
- res.json({
- content: base64Content,
- fileName: fileName,
- size: stats.size,
- mimeType: getMimeType(fileName),
- path: filePath,
+ const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024;
+ if (stats.size > MAX_FILE_SIZE) {
+ fileLogger.warn("File too large for download", {
+ operation: "file_download",
+ sessionId,
+ filePath,
+ fileSize: stats.size,
+ maxSize: MAX_FILE_SIZE,
+ });
+ return res.status(400).json({
+ error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB, file is ${(stats.size / 1024 / 1024).toFixed(2)}MB`,
+ });
+ }
+
+ sftp.readFile(filePath, (readErr, data) => {
+ if (readErr) {
+ fileLogger.error("File read failed for download:", readErr);
+ return res
+ .status(500)
+ .json({ error: `Failed to read file: ${readErr.message}` });
+ }
+
+ const base64Content = data.toString("base64");
+ const fileName = filePath.split("/").pop() || "download";
+ fileLogger.success("File download completed", {
+ operation: "file_download_complete",
+ sessionId,
+ userId,
+ hostId,
+ path: filePath,
+ bytes: stats.size,
+ duration: Date.now() - downloadStartTime,
+ });
+
+ res.json({
+ content: base64Content,
+ fileName: fileName,
+ size: stats.size,
+ mimeType: getMimeType(fileName),
+ path: filePath,
+ });
});
});
+ })
+ .catch((err) => {
+ fileLogger.error("SFTP connection failed for download:", err);
+ return res.status(500).json({ error: "SFTP connection failed" });
});
- });
});
/**
@@ -4980,7 +5201,9 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
let errorOutput = "";
- stream.on("data", (data: Buffer) => {});
+ stream.on("data", () => {
+ /* consume stdout */
+ });
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
@@ -5192,7 +5415,9 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
let errorOutput = "";
- stream.on("data", (data: Buffer) => {});
+ stream.on("data", () => {
+ /* consume stdout */
+ });
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
diff --git a/src/backend/ssh/host-key-verifier.ts b/src/backend/ssh/host-key-verifier.ts
index a9057f8f..81ed056f 100644
--- a/src/backend/ssh/host-key-verifier.ts
+++ b/src/backend/ssh/host-key-verifier.ts
@@ -3,7 +3,6 @@ import { db } from "../database/db/index.js";
import { sshData } from "../database/db/schema.js";
import { eq } from "drizzle-orm";
import { sshLogger } from "../utils/logger.js";
-import crypto from "crypto";
interface HostKeyVerificationData {
scenario: "new" | "changed";
diff --git a/src/backend/ssh/opkssh-auth.ts b/src/backend/ssh/opkssh-auth.ts
index 317617d4..cf3f08bc 100644
--- a/src/backend/ssh/opkssh-auth.ts
+++ b/src/backend/ssh/opkssh-auth.ts
@@ -51,7 +51,9 @@ const cleanupInProgress = new Set();
export function getRequestOrigin(req: IncomingMessage): string {
const protoHeader =
req.headers["x-forwarded-proto"] ||
- ((req.socket as any).encrypted ? "https" : "http");
+ ((req.socket as unknown as { encrypted?: boolean }).encrypted
+ ? "https"
+ : "http");
const proto =
typeof protoHeader === "string"
? protoHeader.split(",")[0].trim()
@@ -157,7 +159,7 @@ async function checkOPKConfigExists(): Promise<{
return {
exists: false,
configPath,
- error: `OPKSSH configuration is missing 'redirect_uris' field.`,
+ error: `OPKSSH configuration is missing 'redirect_uris' field. This field must contain the Termix callback URL that you registered with your OAuth provider (e.g., http://localhost:8080/ssh/opkssh-callback for Docker). The static callback route will internally redirect to the dynamic route for proper URL rewriting.`,
};
}
@@ -235,11 +237,11 @@ export async function startOPKSSHAuth(
try {
const binaryPath = OPKSSHBinaryManager.getBinaryPath();
const configPath = getOPKConfigPath();
- const configDir = path.dirname(configPath);
const args = [
"login",
"--print-key",
+ "--disable-browser-open",
`--config-path=${configPath}`,
`--remote-redirect-uri=${remoteRedirectUri}`,
];
@@ -284,7 +286,10 @@ export async function startOPKSSHAuth(
opksshProcess.stderr?.on("data", async (data) => {
const stderr = data.toString();
- if (stderr.includes("Opening browser to")) {
+ if (
+ stderr.includes("Opening browser to") ||
+ stderr.includes("Open your browser to:")
+ ) {
handleOPKSSHOutput(requestId, stderr);
}
@@ -371,7 +376,7 @@ function handleOPKSSHOutput(requestId: string, output: string): void {
session.stdoutBuffer += output;
const chooserUrlMatch = session.stdoutBuffer.match(
- /Opening browser to http:\/\/localhost:(\d+)\/chooser/,
+ /(?:Opening browser to|Open your browser to:)\s*http:\/\/localhost:(\d+)\/chooser/,
);
if (chooserUrlMatch && session.status === "starting") {
const actualPort = parseInt(chooserUrlMatch[1], 10);
@@ -784,7 +789,10 @@ export function getActiveSessionsAll(): OPKSSHAuthSession[] {
return Array.from(activeAuthSessions.values());
}
-export async function getUserIdFromRequest(req: any): Promise {
+export async function getUserIdFromRequest(req: {
+ cookies?: Record;
+ headers: Record;
+}): Promise {
try {
const { AuthManager } = await import("../utils/auth-manager.js");
const authManager = AuthManager.getInstance();
@@ -797,7 +805,7 @@ export async function getUserIdFromRequest(req: any): Promise {
const decoded = await authManager.verifyJWTToken(token);
return decoded?.userId || null;
- } catch (error) {
+ } catch {
return null;
}
}
diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts
index 1b50a8fe..6817cf63 100644
--- a/src/backend/ssh/server-stats.ts
+++ b/src/backend/ssh/server-stats.ts
@@ -6,7 +6,7 @@ import { Client, type ConnectConfig } from "ssh2";
import { getDb } from "../database/db/index.js";
import { sshData, sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
-import { statsLogger, sshLogger } from "../utils/logger.js";
+import { statsLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
import { PermissionManager } from "../utils/permission-manager.js";
@@ -22,14 +22,18 @@ import { collectSystemMetrics } from "./widgets/system-collector.js";
import { collectLoginStats } from "./widgets/login-stats-collector.js";
import { collectPortsMetrics } from "./widgets/ports-collector.js";
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
-import { createSocks5Connection } from "../utils/socks5-helper.js";
+import {
+ createSocks5Connection,
+ type SOCKS5Config,
+} from "../utils/socks5-helper.js";
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
+import { connectionPool, withConnection } from "./ssh-connection-pool.js";
function createConnectionLog(
type: "info" | "success" | "warning" | "error",
stage: ConnectionStage,
message: string,
- details?: Record,
+ details?: Record,
): Omit {
return {
type,
@@ -39,10 +43,24 @@ function createConnectionLog(
};
}
+interface JumpHostConfig {
+ id: number;
+ ip: string;
+ port: number;
+ username: string;
+ password?: string;
+ key?: string;
+ keyPassword?: string;
+ keyType?: string;
+ authType?: string;
+ credentialId?: number;
+ [key: string]: unknown;
+}
+
async function resolveJumpHost(
hostId: number,
userId: string,
-): Promise {
+): Promise {
try {
const hosts = await SimpleDBOps.select(
getDb()
@@ -78,17 +96,16 @@ async function resolveJumpHost(
const credential = credentials[0];
return {
...host,
- password: credential.password,
- key:
- credential.private_key || credential.privateKey || credential.key,
- keyPassword: credential.key_password || credential.keyPassword,
- keyType: credential.key_type || credential.keyType,
- authType: credential.auth_type || credential.authType,
- };
+ password: credential.password as string | undefined,
+ key: credential.privateKey as string | undefined,
+ keyPassword: credential.keyPassword as string | undefined,
+ keyType: credential.keyType as string | undefined,
+ authType: credential.authType as string | undefined,
+ } as JumpHostConfig;
}
}
- return host;
+ return host as JumpHostConfig;
} catch (error) {
statsLogger.error("Failed to resolve jump host", error, {
operation: "resolve_jump_host",
@@ -102,6 +119,7 @@ async function resolveJumpHost(
async function createJumpHostChain(
jumpHosts: Array<{ hostId: number }>,
userId: string,
+ socks5Config?: SOCKS5Config | null,
): Promise {
if (!jumpHosts || jumpHosts.length === 0) {
return null;
@@ -111,17 +129,40 @@ async function createJumpHostChain(
const clients: Client[] = [];
try {
+ const jumpHostConfigs: Array>> =
+ [];
for (let i = 0; i < jumpHosts.length; i++) {
- const jumpHostConfig = await resolveJumpHost(jumpHosts[i].hostId, userId);
+ const config = await resolveJumpHost(jumpHosts[i].hostId, userId);
+ jumpHostConfigs.push(config);
+ }
- if (!jumpHostConfig) {
+ const totalHops = jumpHostConfigs.length;
+
+ for (let i = 0; i < jumpHostConfigs.length; i++) {
+ if (!jumpHostConfigs[i]) {
statsLogger.error(`Jump host ${i + 1} not found`, undefined, {
operation: "jump_host_chain",
hostId: jumpHosts[i].hostId,
+ hopIndex: i,
+ totalHops,
});
clients.forEach((c) => c.end());
return null;
}
+ }
+
+ let proxySocket: import("net").Socket | null = null;
+ if (socks5Config?.useSocks5) {
+ const firstHop = jumpHostConfigs[0]!;
+ proxySocket = await createSocks5Connection(
+ firstHop.ip,
+ firstHop.port || 22,
+ socks5Config,
+ );
+ }
+
+ for (let i = 0; i < jumpHostConfigs.length; i++) {
+ const jumpHostConfig = jumpHostConfigs[i]!;
const jumpClient = new Client();
clients.push(jumpClient);
@@ -147,16 +188,29 @@ async function createJumpHostChain(
jumpClient.on("error", (err) => {
clearTimeout(timeout);
- statsLogger.error(`Jump host ${i + 1} connection failed`, err, {
- operation: "jump_host_connect",
- hostId: jumpHostConfig.id,
- ip: jumpHostConfig.ip,
- });
+ statsLogger.error(
+ `Jump host ${i + 1}/${totalHops} connection failed`,
+ err,
+ {
+ operation: "jump_host_connect",
+ hostId: jumpHostConfig.id,
+ ip: jumpHostConfig.ip,
+ hopIndex: i,
+ totalHops,
+ previousHop:
+ i > 0
+ ? jumpHostConfigs[i - 1]?.ip
+ : proxySocket
+ ? "proxy"
+ : "direct",
+ usedProxySocket: i === 0 && !!proxySocket,
+ },
+ );
resolve(false);
});
- const connectConfig: any = {
- host: jumpHostConfig.ip,
+ const connectConfig: Record = {
+ host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
port: jumpHostConfig.port || 22,
username: jumpHostConfig.username,
tryKeyboard: true,
@@ -193,6 +247,9 @@ async function createJumpHostChain(
jumpClient.connect(connectConfig);
},
);
+ } else if (proxySocket) {
+ connectConfig.sock = proxySocket;
+ jumpClient.connect(connectConfig);
} else {
jumpClient.connect(connectConfig);
}
@@ -216,13 +273,6 @@ async function createJumpHostChain(
}
}
-interface PooledConnection {
- client: Client;
- lastUsed: number;
- inUse: boolean;
- hostKey: string;
-}
-
interface MetricsSession {
client: Client;
isConnected: boolean;
@@ -275,7 +325,9 @@ function cleanupMetricsSession(sessionId: string) {
try {
session.client.end();
- } catch (error) {}
+ } catch {
+ // expected
+ }
clearTimeout(session.timeout);
delete metricsSessions[sessionId];
}
@@ -299,355 +351,6 @@ function getSessionKey(hostId: number, userId: string): string {
return `${userId}:${hostId}`;
}
-class SSHConnectionPool {
- private connections = new Map();
- private maxConnectionsPerHost = 3;
- private connectionTimeout = 30000;
- private cleanupInterval: NodeJS.Timeout;
-
- constructor() {
- this.cleanupInterval = setInterval(
- () => {
- this.cleanup();
- },
- 2 * 60 * 1000,
- );
- }
-
- private getHostKey(host: SSHHostWithCredentials): string {
- const socks5Key = host.useSocks5
- ? `:socks5:${host.socks5Host}:${host.socks5Port}:${JSON.stringify(host.socks5ProxyChain || [])}`
- : "";
- return `${host.ip}:${host.port}:${host.username}${socks5Key}`;
- }
-
- private isConnectionHealthy(client: Client): boolean {
- try {
- const sock = (client as any)._sock;
- if (sock && (sock.destroyed || !sock.writable)) {
- return false;
- }
- return true;
- } catch (error) {
- return false;
- }
- }
-
- async getConnection(host: SSHHostWithCredentials): Promise {
- const hostKey = this.getHostKey(host);
- let connections = this.connections.get(hostKey) || [];
-
- const available = connections.find((conn) => !conn.inUse);
- if (available) {
- if (!this.isConnectionHealthy(available.client)) {
- statsLogger.warn("Removing unhealthy connection from pool", {
- operation: "remove_dead_connection",
- hostKey,
- });
- try {
- available.client.end();
- } catch (error) {
- // Ignore cleanup errors
- }
- connections = connections.filter((c) => c !== available);
- this.connections.set(hostKey, connections);
- } else {
- available.inUse = true;
- available.lastUsed = Date.now();
- return available.client;
- }
- }
-
- if (connections.length < this.maxConnectionsPerHost) {
- const client = await this.createConnection(host);
- const pooled: PooledConnection = {
- client,
- lastUsed: Date.now(),
- inUse: true,
- hostKey,
- };
- connections.push(pooled);
- this.connections.set(hostKey, connections);
- return client;
- }
-
- return new Promise((resolve) => {
- const checkAvailable = () => {
- const available = connections.find((conn) => !conn.inUse);
- if (available) {
- available.inUse = true;
- available.lastUsed = Date.now();
- resolve(available.client);
- } else {
- setTimeout(checkAvailable, 100);
- }
- };
- checkAvailable();
- });
- }
-
- private async createConnection(
- host: SSHHostWithCredentials,
- ): Promise {
- const config = await buildSshConfig(host);
- return new Promise(async (resolve, reject) => {
- const client = new Client();
- const timeout = setTimeout(() => {
- client.end();
- reject(new Error("SSH connection timeout"));
- }, this.connectionTimeout);
-
- client.on("ready", () => {
- clearTimeout(timeout);
- resolve(client);
- });
-
- client.on("error", (err) => {
- clearTimeout(timeout);
- reject(err);
- });
-
- client.on(
- "keyboard-interactive",
- (
- name: string,
- instructions: string,
- instructionsLang: string,
- prompts: Array<{ prompt: string; echo: boolean }>,
- finish: (responses: string[]) => void,
- ) => {
- const totpPromptIndex = prompts.findIndex((p) =>
- /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
- p.prompt,
- ),
- );
-
- if (totpPromptIndex !== -1) {
- const sessionId = `totp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
-
- pendingTOTPSessions[sessionId] = {
- client,
- finish,
- config,
- createdAt: Date.now(),
- sessionId,
- hostId: host.id,
- userId: host.userId!,
- prompts: prompts.map((p) => ({
- prompt: p.prompt,
- echo: p.echo ?? false,
- })),
- totpPromptIndex,
- resolvedPassword: host.password,
- totpAttempts: 0,
- };
-
- return;
- } else if (host.password) {
- const responses = prompts.map((p) => {
- if (/password/i.test(p.prompt)) {
- return host.password || "";
- }
- return "";
- });
- finish(responses);
- } else {
- finish(prompts.map(() => ""));
- }
- },
- );
-
- try {
- if (
- host.useSocks5 &&
- (host.socks5Host ||
- (host.socks5ProxyChain && host.socks5ProxyChain.length > 0))
- ) {
- try {
- const socks5Socket = await createSocks5Connection(
- host.ip,
- host.port,
- {
- useSocks5: host.useSocks5,
- socks5Host: host.socks5Host,
- socks5Port: host.socks5Port,
- socks5Username: host.socks5Username,
- socks5Password: host.socks5Password,
- socks5ProxyChain: host.socks5ProxyChain,
- },
- );
-
- if (socks5Socket) {
- config.sock = socks5Socket;
- client.connect(config);
- return;
- } else {
- statsLogger.error("SOCKS5 socket is null", undefined, {
- operation: "socks5_socket_null",
- hostIp: host.ip,
- });
- }
- } catch (socks5Error) {
- clearTimeout(timeout);
- statsLogger.error("SOCKS5 connection error", socks5Error, {
- operation: "socks5_connection_error",
- hostIp: host.ip,
- errorMessage:
- socks5Error instanceof Error ? socks5Error.message : "Unknown",
- });
- reject(
- new Error(
- "SOCKS5 proxy connection failed: " +
- (socks5Error instanceof Error
- ? socks5Error.message
- : "Unknown error"),
- ),
- );
- return;
- }
- }
-
- if (host.jumpHosts && host.jumpHosts.length > 0 && host.userId) {
- const jumpClient = await createJumpHostChain(
- host.jumpHosts,
- host.userId,
- );
-
- if (!jumpClient) {
- clearTimeout(timeout);
- reject(new Error("Failed to establish jump host chain"));
- return;
- }
-
- jumpClient.forwardOut(
- "127.0.0.1",
- 0,
- host.ip,
- host.port,
- (err, stream) => {
- if (err) {
- clearTimeout(timeout);
- jumpClient.end();
- reject(
- new Error(
- "Failed to forward through jump host: " + err.message,
- ),
- );
- return;
- }
-
- config.sock = stream;
- client.connect(config);
- },
- );
- } else {
- client.connect(config);
- }
- } catch (err) {
- clearTimeout(timeout);
- reject(err);
- }
- });
- }
-
- releaseConnection(host: SSHHostWithCredentials, client: Client): void {
- const hostKey = this.getHostKey(host);
- const connections = this.connections.get(hostKey) || [];
- const pooled = connections.find((conn) => conn.client === client);
- if (pooled) {
- pooled.inUse = false;
- pooled.lastUsed = Date.now();
- }
- }
-
- clearHostConnections(host: SSHHostWithCredentials): void {
- const hostKey = this.getHostKey(host);
- const connections = this.connections.get(hostKey) || [];
-
- for (const conn of connections) {
- try {
- conn.client.end();
- } catch (error) {
- statsLogger.error("Error closing connection during cleanup", error, {
- operation: "clear_connection_error",
- });
- }
- }
-
- this.connections.delete(hostKey);
- }
-
- private cleanup(): void {
- const now = Date.now();
- const maxAge = 10 * 60 * 1000;
- let totalCleaned = 0;
- let totalUnhealthy = 0;
-
- for (const [hostKey, connections] of this.connections.entries()) {
- const activeConnections = connections.filter((conn) => {
- if (!conn.inUse && now - conn.lastUsed > maxAge) {
- try {
- conn.client.end();
- } catch (error) {}
- totalCleaned++;
- return false;
- }
- if (!this.isConnectionHealthy(conn.client)) {
- statsLogger.warn("Removing unhealthy connection during cleanup", {
- operation: "cleanup_unhealthy",
- hostKey,
- inUse: conn.inUse,
- });
- try {
- conn.client.end();
- } catch (error) {}
- totalUnhealthy++;
- return false;
- }
- return true;
- });
-
- if (activeConnections.length === 0) {
- this.connections.delete(hostKey);
- } else {
- this.connections.set(hostKey, activeConnections);
- }
- }
- }
-
- clearAllConnections(): void {
- for (const [hostKey, connections] of this.connections.entries()) {
- for (const conn of connections) {
- try {
- conn.client.end();
- } catch (error) {
- statsLogger.error(
- "Error closing connection during full cleanup",
- error,
- {
- operation: "clear_all_error",
- hostKey,
- },
- );
- }
- }
- }
- this.connections.clear();
- }
-
- destroy(): void {
- clearInterval(this.cleanupInterval);
- for (const connections of this.connections.values()) {
- for (const conn of connections) {
- try {
- conn.client.end();
- } catch (error) {}
- }
- }
- this.connections.clear();
- }
-}
-
class RequestQueue {
private queues = new Map Promise>>();
private processing = new Set();
@@ -695,7 +398,9 @@ class RequestQueue {
if (request) {
try {
await request();
- } catch (error) {}
+ } catch {
+ // expected
+ }
}
}
@@ -898,7 +603,6 @@ class PollingBackoff {
}
}
-const connectionPool = new SSHConnectionPool();
const requestQueue = new RequestQueue();
const metricsCache = new MetricsCache();
const authFailureTracker = new AuthFailureTracker();
@@ -951,14 +655,16 @@ interface StatsConfig {
enabledWidgets: string[];
statusCheckEnabled: boolean;
statusCheckInterval: number;
+ useGlobalStatusInterval?: boolean;
metricsEnabled: boolean;
metricsInterval: number;
+ useGlobalMetricsInterval?: boolean;
}
const DEFAULT_STATS_CONFIG: StatsConfig = {
enabledWidgets: ["cpu", "memory", "disk", "network", "uptime", "system"],
statusCheckEnabled: true,
- statusCheckInterval: 30,
+ statusCheckInterval: 60,
metricsEnabled: true,
metricsInterval: 30,
};
@@ -991,6 +697,41 @@ class PollingManager {
}, 60000);
}
+ private getGlobalDefaults(): {
+ statusCheckInterval: number;
+ metricsInterval: number;
+ } {
+ try {
+ const db = getDb();
+ const statusRow = db.$client
+ .prepare(
+ "SELECT value FROM settings WHERE key = 'global_status_check_interval'",
+ )
+ .get() as { value: string } | undefined;
+ const metricsRow = db.$client
+ .prepare(
+ "SELECT value FROM settings WHERE key = 'global_metrics_interval'",
+ )
+ .get() as { value: string } | undefined;
+
+ return {
+ statusCheckInterval: statusRow
+ ? parseInt(statusRow.value, 10) ||
+ DEFAULT_STATS_CONFIG.statusCheckInterval
+ : DEFAULT_STATS_CONFIG.statusCheckInterval,
+ metricsInterval: metricsRow
+ ? parseInt(metricsRow.value, 10) ||
+ DEFAULT_STATS_CONFIG.metricsInterval
+ : DEFAULT_STATS_CONFIG.metricsInterval,
+ };
+ } catch {
+ return {
+ statusCheckInterval: DEFAULT_STATS_CONFIG.statusCheckInterval,
+ metricsInterval: DEFAULT_STATS_CONFIG.metricsInterval,
+ };
+ }
+ }
+
parseStatsConfig(statsConfigStr?: string | StatsConfig): StatsConfig {
if (!statsConfigStr) {
return DEFAULT_STATS_CONFIG;
@@ -1002,13 +743,13 @@ class PollingManager {
parsed = statsConfigStr;
} else {
try {
- let temp: any = JSON.parse(statsConfigStr);
+ let temp: unknown = JSON.parse(statsConfigStr);
if (typeof temp === "string") {
temp = JSON.parse(temp);
}
- parsed = temp;
+ parsed = temp as StatsConfig;
} catch (error) {
statsLogger.warn(
`Failed to parse statsConfig: ${error instanceof Error ? error.message : "Unknown error"}`,
@@ -1023,6 +764,14 @@ class PollingManager {
const result = { ...DEFAULT_STATS_CONFIG, ...parsed };
+ const globalDefaults = this.getGlobalDefaults();
+ if (result.useGlobalStatusInterval !== false) {
+ result.statusCheckInterval = globalDefaults.statusCheckInterval;
+ }
+ if (result.useGlobalMetricsInterval !== false) {
+ result.metricsInterval = globalDefaults.metricsInterval;
+ }
+
return result;
}
@@ -1128,7 +877,7 @@ class PollingManager {
lastChecked: new Date().toISOString(),
};
this.statusStore.set(refreshedHost.id, statusEntry);
- } catch (error) {
+ } catch {
const statusEntry: StatusEntry = {
status: "offline",
lastChecked: new Date().toISOString(),
@@ -1155,7 +904,6 @@ class PollingManager {
const hasExistingMetrics = this.metricsStore.has(refreshedHost.id);
if (hasExistingMetrics && pollingBackoff.shouldSkip(host.id)) {
- const backoffInfo = pollingBackoff.getBackoffInfo(host.id);
return;
}
@@ -1167,9 +915,6 @@ class PollingManager {
});
pollingBackoff.reset(refreshedHost.id);
} catch (error) {
- const errorMessage =
- error instanceof Error ? error.message : String(error);
-
pollingBackoff.recordFailure(refreshedHost.id);
const latestConfig = this.pollingConfigs.get(refreshedHost.id);
@@ -1385,8 +1130,13 @@ app.use(
);
app.use(cookieParser());
app.use(express.json({ limit: "1mb" }));
+app.use((_req, res, next) => {
+ res.setHeader("Cache-Control", "no-store");
+ next();
+});
app.use(authManager.createAuthMiddleware());
+const requireAdmin = authManager.createAdminMiddleware();
async function fetchAllHosts(
userId: string,
@@ -1509,32 +1259,35 @@ async function resolveHostCredentials(
const isSharedHost = userId !== ownerId;
if (isSharedHost) {
- const { SharedCredentialManager } =
- await import("../utils/shared-credential-manager.js");
+ const { SharedCredentialManager } = await import(
+ "../utils/shared-credential-manager.js"
+ );
const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
host.id as number,
userId,
);
- baseHost.credentialId = host.credentialId;
- baseHost.authType = sharedCred.authType;
+ if (sharedCred) {
+ baseHost.credentialId = host.credentialId;
+ baseHost.authType = sharedCred.authType;
- if (!host.overrideCredentialUsername) {
- baseHost.username = sharedCred.username;
- }
+ if (!host.overrideCredentialUsername) {
+ baseHost.username = sharedCred.username;
+ }
- if (sharedCred.password) {
- baseHost.password = sharedCred.password;
- }
- if (sharedCred.key) {
- baseHost.key = sharedCred.key;
- }
- if (sharedCred.keyPassword) {
- baseHost.keyPassword = sharedCred.keyPassword;
- }
- if (sharedCred.keyType) {
- baseHost.keyType = sharedCred.keyType;
+ if (sharedCred.password) {
+ baseHost.password = sharedCred.password;
+ }
+ if (sharedCred.key) {
+ baseHost.key = sharedCred.key;
+ }
+ if (sharedCred.keyPassword) {
+ baseHost.keyPassword = sharedCred.keyPassword;
+ }
+ if (sharedCred.keyType) {
+ baseHost.keyType = sharedCred.keyType;
+ }
}
} else {
const credentials = await SimpleDBOps.select(
@@ -1550,12 +1303,11 @@ async function resolveHostCredentials(
const credential = credentials[0];
baseHost.credentialId = credential.id;
baseHost.authType =
- credential.auth_type ||
credential.authType ||
(credential.password
? "password"
: credential.key ||
- (credential as Record).private_key
+ (credential as Record).privateKey
? "key"
: "none");
@@ -1569,12 +1321,11 @@ async function resolveHostCredentials(
if (credential.key) {
baseHost.key = credential.key;
}
- if (credential.key_password || credential.keyPassword) {
- baseHost.keyPassword =
- credential.key_password || credential.keyPassword;
+ if (credential.keyPassword) {
+ baseHost.keyPassword = credential.keyPassword;
}
- if (credential.key_type || credential.keyType) {
- baseHost.keyType = credential.key_type || credential.keyType;
+ if (credential.keyType) {
+ baseHost.keyType = credential.keyType;
}
} else {
addLegacyCredentials(baseHost, host);
@@ -1619,7 +1370,7 @@ function addLegacyCredentials(
): void {
baseHost.password = host.password || null;
baseHost.key = host.key || null;
- baseHost.keyPassword = host.key_password || host.keyPassword || null;
+ baseHost.keyPassword = host.keyPassword || null;
baseHost.keyType = host.keyType;
}
@@ -1627,7 +1378,7 @@ async function buildSshConfig(
host: SSHHostWithCredentials,
): Promise {
const base: ConnectConfig = {
- host: host.ip,
+ host: host.ip?.replace(/^\[|\]$/g, "") || host.ip,
port: host.port,
username: host.username,
tryKeyboard: true,
@@ -1738,7 +1489,9 @@ async function buildSshConfig(
throw new Error(`Invalid SSH key format for host ${host.ip}`);
}
} else if (host.authType === "none") {
+ // no credentials needed
} else if (host.authType === "opkssh") {
+ // handled externally
} else if (host.authType === "credential") {
if (host.password) {
base.password = host.password;
@@ -1766,18 +1519,168 @@ async function buildSshConfig(
return base;
}
+function getPoolKey(host: SSHHostWithCredentials): string {
+ const socks5Key = host.useSocks5
+ ? `:socks5:${host.socks5Host}:${host.socks5Port}`
+ : "";
+ return `stats:${host.userId}:${host.ip}:${host.port}:${host.username}${socks5Key}`;
+}
+
+function createSshFactory(host: SSHHostWithCredentials): () => Promise {
+ return async () => {
+ const config = await buildSshConfig(host);
+ const client = new Client();
+
+ const proxyConfig: SOCKS5Config | null =
+ host.useSocks5 &&
+ (host.socks5Host ||
+ (host.socks5ProxyChain && host.socks5ProxyChain.length > 0))
+ ? {
+ useSocks5: host.useSocks5,
+ socks5Host: host.socks5Host,
+ socks5Port: host.socks5Port,
+ socks5Username: host.socks5Username,
+ socks5Password: host.socks5Password,
+ socks5ProxyChain: host.socks5ProxyChain,
+ }
+ : null;
+
+ const hasJumpHosts =
+ host.jumpHosts && host.jumpHosts.length > 0 && host.userId;
+
+ let jumpClient: Client | null = null;
+ if (hasJumpHosts) {
+ jumpClient = await createJumpHostChain(
+ host.jumpHosts!,
+ host.userId!,
+ proxyConfig,
+ );
+
+ if (!jumpClient) {
+ throw new Error("Failed to establish jump host chain");
+ }
+ } else if (proxyConfig) {
+ try {
+ const proxySocket = await createSocks5Connection(
+ host.ip,
+ host.port,
+ proxyConfig,
+ );
+ if (proxySocket) {
+ config.sock = proxySocket;
+ }
+ } catch (proxyError) {
+ throw new Error(
+ "Proxy connection failed: " +
+ (proxyError instanceof Error
+ ? proxyError.message
+ : "Unknown error"),
+ );
+ }
+ }
+
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ client.end();
+ reject(new Error("SSH connection timeout"));
+ }, 30000);
+
+ client.on("ready", () => {
+ clearTimeout(timeout);
+ resolve(client);
+ });
+
+ client.on("error", (err) => {
+ clearTimeout(timeout);
+ reject(err);
+ });
+
+ client.on(
+ "keyboard-interactive",
+ (
+ _name: string,
+ _instructions: string,
+ _instructionsLang: string,
+ prompts: Array<{ prompt: string; echo: boolean }>,
+ finish: (responses: string[]) => void,
+ ) => {
+ const totpPromptIndex = prompts.findIndex((p) =>
+ /verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
+ p.prompt,
+ ),
+ );
+
+ if (totpPromptIndex !== -1) {
+ const sessionId = `totp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+
+ pendingTOTPSessions[sessionId] = {
+ client,
+ finish,
+ config,
+ createdAt: Date.now(),
+ sessionId,
+ hostId: host.id,
+ userId: host.userId!,
+ prompts: prompts.map((p) => ({
+ prompt: p.prompt,
+ echo: p.echo ?? false,
+ })),
+ totpPromptIndex,
+ resolvedPassword: host.password,
+ totpAttempts: 0,
+ };
+
+ return;
+ } else if (host.password) {
+ const responses = prompts.map((p) => {
+ if (/password/i.test(p.prompt)) {
+ return host.password || "";
+ }
+ return "";
+ });
+ finish(responses);
+ } else {
+ finish(prompts.map(() => ""));
+ }
+ },
+ );
+
+ if (jumpClient) {
+ jumpClient.forwardOut(
+ "127.0.0.1",
+ 0,
+ host.ip,
+ host.port,
+ (err, stream) => {
+ if (err) {
+ clearTimeout(timeout);
+ jumpClient!.end();
+ reject(
+ new Error(
+ "Failed to forward through jump host: " + err.message,
+ ),
+ );
+ return;
+ }
+
+ config.sock = stream;
+ client.connect(config);
+ },
+ );
+ } else {
+ client.connect(config);
+ }
+ });
+ };
+}
+
async function withSshConnection(
host: SSHHostWithCredentials,
fn: (client: Client) => Promise,
): Promise {
- const client = await connectionPool.getConnection(host);
-
- try {
- const result = await fn(client);
- return result;
- } finally {
- connectionPool.releaseConnection(host, client);
- }
+ const key = getPoolKey(host);
+ const factory = createSshFactory(host);
+ return withConnection(key, factory, fn);
}
async function collectMetrics(host: SSHHostWithCredentials): Promise<{
@@ -1861,7 +1764,9 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
};
try {
login_stats = await collectLoginStats(client);
- } catch (e) {}
+ } catch {
+ // expected
+ }
let ports: {
source: "ss" | "netstat" | "none";
@@ -1879,7 +1784,9 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
};
try {
ports = await collectPortsMetrics(client);
- } catch (e) {}
+ } catch {
+ // expected
+ }
let firewall: {
type: "iptables" | "nftables" | "none";
@@ -1907,7 +1814,9 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
};
try {
firewall = await collectFirewallMetrics(client);
- } catch (e) {}
+ } catch {
+ // expected
+ }
const result = {
cpu,
@@ -1975,20 +1884,52 @@ function tcpPing(
const socket = new net.Socket();
let settled = false;
- const onDone = (result: boolean) => {
+ const finish = (result: boolean) => {
if (settled) return;
settled = true;
+ resolve(result);
+ };
+
+ const cleanup = () => {
try {
socket.destroy();
- } catch (error) {}
- resolve(result);
+ } catch {
+ // expected
+ }
};
socket.setTimeout(timeoutMs);
- socket.once("connect", () => onDone(true));
- socket.once("timeout", () => onDone(false));
- socket.once("error", () => onDone(false));
+ socket.once("connect", () => {
+ const dataTimeout = setTimeout(() => {
+ cleanup();
+ finish(true);
+ }, 2000);
+
+ socket.once("data", (data) => {
+ clearTimeout(dataTimeout);
+ if (data.toString().startsWith("SSH-")) {
+ try {
+ socket.end("SSH-2.0-TermixHealthCheck\r\n");
+ } catch {
+ // expected
+ }
+ setTimeout(cleanup, 200);
+ } else {
+ cleanup();
+ }
+ finish(true);
+ });
+ });
+
+ socket.once("timeout", () => {
+ cleanup();
+ finish(false);
+ });
+ socket.once("error", () => {
+ cleanup();
+ finish(false);
+ });
socket.connect(port, host);
});
}
@@ -2180,7 +2121,7 @@ app.post("/host-updated", async (req, res) => {
try {
const host = await fetchHostById(hostId, userId);
if (host) {
- connectionPool.clearHostConnections(host);
+ connectionPool.clearKeyConnections(getPoolKey(host));
await pollingManager.startPollingForHost(host);
res.json({ message: "Host polling started" });
@@ -2806,7 +2747,9 @@ app.post("/metrics/connect-totp", async (req, res) => {
delete pendingTOTPSessions[sessionId];
try {
session.client.end();
- } catch {}
+ } catch {
+ // expected
+ }
return res.status(408).json({ error: "TOTP session timeout" });
}
@@ -2819,7 +2762,9 @@ app.post("/metrics/connect-totp", async (req, res) => {
delete pendingTOTPSessions[sessionId];
try {
session.client.end();
- } catch {}
+ } catch {
+ // expected
+ }
return res.status(429).json({ error: "Too many TOTP attempts" });
}
@@ -2907,7 +2852,9 @@ app.post("/metrics/connect-totp", async (req, res) => {
delete pendingTOTPSessions[sessionId];
try {
session.client.end();
- } catch {}
+ } catch {
+ // expected
+ }
}
res.status(401).json({
@@ -3097,6 +3044,157 @@ app.post("/metrics/unregister-viewer", async (req, res) => {
}
});
+/**
+ * @openapi
+ * /global-settings:
+ * get:
+ * summary: Get global monitoring defaults
+ * tags:
+ * - Stats
+ * responses:
+ * 200:
+ * description: Global monitoring settings.
+ * 403:
+ * description: Requires admin privileges.
+ */
+app.get("/global-settings", requireAdmin, async (_req, res) => {
+ try {
+ const db = getDb();
+
+ try {
+ db.$client.prepare("SELECT 1 FROM settings LIMIT 1").get();
+ } catch (tableError) {
+ statsLogger.warn("Settings table does not exist, using defaults", {
+ operation: "global_settings_table_check",
+ error:
+ tableError instanceof Error ? tableError.message : String(tableError),
+ });
+ return res.json({
+ statusCheckInterval: DEFAULT_STATS_CONFIG.statusCheckInterval,
+ metricsInterval: DEFAULT_STATS_CONFIG.metricsInterval,
+ });
+ }
+
+ const statusRow = db.$client
+ .prepare(
+ "SELECT value FROM settings WHERE key = 'global_status_check_interval'",
+ )
+ .get() as { value: string } | undefined;
+ const metricsRow = db.$client
+ .prepare(
+ "SELECT value FROM settings WHERE key = 'global_metrics_interval'",
+ )
+ .get() as { value: string } | undefined;
+
+ res.json({
+ statusCheckInterval: statusRow
+ ? parseInt(statusRow.value, 10)
+ : DEFAULT_STATS_CONFIG.statusCheckInterval,
+ metricsInterval: metricsRow
+ ? parseInt(metricsRow.value, 10)
+ : DEFAULT_STATS_CONFIG.metricsInterval,
+ });
+ } catch (error) {
+ statsLogger.error("Failed to fetch global settings", {
+ operation: "global_settings_fetch_error",
+ error: error instanceof Error ? error.message : String(error),
+ });
+ res.status(500).json({ error: "Failed to fetch global settings" });
+ }
+});
+
+/**
+ * @openapi
+ * /global-settings:
+ * post:
+ * summary: Update global monitoring defaults
+ * tags:
+ * - Stats
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * statusCheckInterval:
+ * type: integer
+ * metricsInterval:
+ * type: integer
+ * responses:
+ * 200:
+ * description: Settings saved.
+ * 400:
+ * description: Invalid parameters.
+ * 403:
+ * description: Requires admin privileges.
+ */
+app.post("/global-settings", requireAdmin, async (req, res) => {
+ const { statusCheckInterval, metricsInterval } = req.body;
+
+ if (
+ statusCheckInterval !== undefined &&
+ (typeof statusCheckInterval !== "number" ||
+ statusCheckInterval < 5 ||
+ statusCheckInterval > 3600)
+ ) {
+ return res.status(400).json({
+ error: "statusCheckInterval must be between 5 and 3600 seconds",
+ });
+ }
+ if (
+ metricsInterval !== undefined &&
+ (typeof metricsInterval !== "number" ||
+ metricsInterval < 5 ||
+ metricsInterval > 3600)
+ ) {
+ return res
+ .status(400)
+ .json({ error: "metricsInterval must be between 5 and 3600 seconds" });
+ }
+
+ try {
+ const db = getDb();
+
+ try {
+ db.$client.prepare("SELECT 1 FROM settings LIMIT 1").get();
+ } catch (tableError) {
+ statsLogger.error("Settings table does not exist, cannot save settings", {
+ operation: "global_settings_table_check",
+ error:
+ tableError instanceof Error ? tableError.message : String(tableError),
+ });
+ return res.status(500).json({
+ error:
+ "Database settings table is missing. Please check database initialization.",
+ });
+ }
+
+ if (statusCheckInterval !== undefined) {
+ db.$client
+ .prepare(
+ "INSERT OR REPLACE INTO settings (key, value) VALUES ('global_status_check_interval', ?)",
+ )
+ .run(String(statusCheckInterval));
+ }
+ if (metricsInterval !== undefined) {
+ db.$client
+ .prepare(
+ "INSERT OR REPLACE INTO settings (key, value) VALUES ('global_metrics_interval', ?)",
+ )
+ .run(String(metricsInterval));
+ }
+
+ res.json({ success: true });
+ } catch (error) {
+ statsLogger.error("Failed to save global settings", {
+ operation: "global_settings_save_error",
+ error: error instanceof Error ? error.message : String(error),
+ });
+ res.status(500).json({ error: "Failed to save global settings" });
+ }
+});
+
process.on("SIGINT", () => {
pollingManager.destroy();
connectionPool.destroy();
diff --git a/src/backend/ssh/ssh-connection-pool.ts b/src/backend/ssh/ssh-connection-pool.ts
new file mode 100644
index 00000000..b521842b
--- /dev/null
+++ b/src/backend/ssh/ssh-connection-pool.ts
@@ -0,0 +1,225 @@
+import { Client } from "ssh2";
+import { sshLogger } from "../utils/logger.js";
+
+interface PooledConnection {
+ client: Client;
+ lastUsed: number;
+ inUse: boolean;
+ hostKey: string;
+}
+
+class SSHConnectionPool {
+ private connections = new Map();
+ private maxConnectionsPerHost = 3;
+ private cleanupInterval: NodeJS.Timeout;
+
+ constructor() {
+ this.cleanupInterval = setInterval(
+ () => {
+ this.cleanup();
+ },
+ 2 * 60 * 1000,
+ );
+ }
+
+ private isConnectionHealthy(client: Client): boolean {
+ try {
+ const sock = (
+ client as unknown as {
+ _sock?: { destroyed?: boolean; writable?: boolean };
+ }
+ )._sock;
+ if (sock && (sock.destroyed || !sock.writable)) {
+ return false;
+ }
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ async getConnection(
+ key: string,
+ factory: () => Promise,
+ ): Promise {
+ let connections = this.connections.get(key) || [];
+
+ const available = connections.find((conn) => !conn.inUse);
+ if (available) {
+ if (!this.isConnectionHealthy(available.client)) {
+ sshLogger.warn("Removing unhealthy connection from pool", {
+ operation: "pool_remove_dead",
+ hostKey: key,
+ });
+ try {
+ available.client.end();
+ } catch {
+ // expected
+ }
+ connections = connections.filter((c) => c !== available);
+ this.connections.set(key, connections);
+ } else {
+ available.inUse = true;
+ available.lastUsed = Date.now();
+ return available.client;
+ }
+ }
+
+ if (connections.length < this.maxConnectionsPerHost) {
+ const client = await factory();
+ const pooled: PooledConnection = {
+ client,
+ lastUsed: Date.now(),
+ inUse: true,
+ hostKey: key,
+ };
+ connections.push(pooled);
+ this.connections.set(key, connections);
+
+ client.on("end", () => {
+ this.removeConnection(key, client);
+ });
+ client.on("close", () => {
+ this.removeConnection(key, client);
+ });
+
+ return client;
+ }
+
+ return new Promise((resolve) => {
+ const checkAvailable = () => {
+ const conns = this.connections.get(key) || [];
+ const avail = conns.find((conn) => !conn.inUse);
+ if (avail) {
+ if (!this.isConnectionHealthy(avail.client)) {
+ try {
+ avail.client.end();
+ } catch {
+ // expected
+ }
+ const filtered = conns.filter((c) => c !== avail);
+ this.connections.set(key, filtered);
+ factory().then((client) => {
+ const pooled: PooledConnection = {
+ client,
+ lastUsed: Date.now(),
+ inUse: true,
+ hostKey: key,
+ };
+ filtered.push(pooled);
+ this.connections.set(key, filtered);
+ client.on("end", () => this.removeConnection(key, client));
+ client.on("close", () => this.removeConnection(key, client));
+ resolve(client);
+ });
+ } else {
+ avail.inUse = true;
+ avail.lastUsed = Date.now();
+ resolve(avail.client);
+ }
+ } else {
+ setTimeout(checkAvailable, 100);
+ }
+ };
+ checkAvailable();
+ });
+ }
+
+ releaseConnection(key: string, client: Client): void {
+ const connections = this.connections.get(key) || [];
+ const pooled = connections.find((conn) => conn.client === client);
+ if (pooled) {
+ pooled.inUse = false;
+ pooled.lastUsed = Date.now();
+ }
+ }
+
+ private removeConnection(key: string, client: Client): void {
+ const connections = this.connections.get(key);
+ if (!connections) return;
+ const filtered = connections.filter((c) => c.client !== client);
+ if (filtered.length === 0) {
+ this.connections.delete(key);
+ } else {
+ this.connections.set(key, filtered);
+ }
+ }
+
+ clearKeyConnections(key: string): void {
+ const connections = this.connections.get(key) || [];
+ for (const conn of connections) {
+ try {
+ conn.client.end();
+ } catch {
+ // expected
+ }
+ }
+ this.connections.delete(key);
+ }
+
+ private cleanup(): void {
+ const now = Date.now();
+ const maxAge = 10 * 60 * 1000;
+
+ for (const [hostKey, connections] of this.connections.entries()) {
+ const activeConnections = connections.filter((conn) => {
+ if (!conn.inUse && now - conn.lastUsed > maxAge) {
+ try {
+ conn.client.end();
+ } catch {
+ // expected
+ }
+ return false;
+ }
+ if (!this.isConnectionHealthy(conn.client)) {
+ try {
+ conn.client.end();
+ } catch {
+ // expected
+ }
+ return false;
+ }
+ return true;
+ });
+
+ if (activeConnections.length === 0) {
+ this.connections.delete(hostKey);
+ } else {
+ this.connections.set(hostKey, activeConnections);
+ }
+ }
+ }
+
+ clearAllConnections(): void {
+ for (const connections of this.connections.values()) {
+ for (const conn of connections) {
+ try {
+ conn.client.end();
+ } catch {
+ // expected
+ }
+ }
+ }
+ this.connections.clear();
+ }
+
+ destroy(): void {
+ clearInterval(this.cleanupInterval);
+ this.clearAllConnections();
+ }
+}
+
+export const connectionPool = new SSHConnectionPool();
+
+export async function withConnection(
+ key: string,
+ factory: () => Promise,
+ fn: (client: Client) => Promise,
+): Promise {
+ const client = await connectionPool.getConnection(key, factory);
+ try {
+ return await fn(client);
+ } finally {
+ connectionPool.releaseConnection(key, client);
+ }
+}
diff --git a/src/backend/ssh/terminal-session-manager.ts b/src/backend/ssh/terminal-session-manager.ts
new file mode 100644
index 00000000..9a127355
--- /dev/null
+++ b/src/backend/ssh/terminal-session-manager.ts
@@ -0,0 +1,489 @@
+import { type Client, type ClientChannel } from "ssh2";
+import { WebSocket } from "ws";
+import { sshLogger } from "../utils/logger.js";
+import { getDb } from "../database/db/index.js";
+
+const MAX_BUFFER_BYTES = 512 * 1024;
+const DEFAULT_TIMEOUT_MINUTES = 30;
+const HEALTH_CHECK_INTERVAL_MS = 60_000;
+const MAX_SESSIONS_PER_USER = 10;
+
+export interface TerminalSession {
+ id: string;
+ userId: string;
+ hostId: number;
+ hostName: string;
+ tabInstanceId?: string;
+ attachedTabInstanceId?: string;
+
+ sshConn: Client | null;
+ sshStream: ClientChannel | null;
+ jumpClient: Client | null;
+ opksshTempFiles: { keyPath: string; certPath: string } | null;
+
+ cols: number;
+ rows: number;
+ isConnected: boolean;
+ createdAt: number;
+
+ attachedWs: WebSocket | null;
+ lastDetachedAt: number | null;
+ detachTimeout: NodeJS.Timeout | null;
+
+ outputBuffer: string[];
+ outputBufferBytes: number;
+}
+
+class TerminalSessionManager {
+ private static instance: TerminalSessionManager;
+ private sessions = new Map();
+ private healthCheckTimer: NodeJS.Timeout | null = null;
+
+ private constructor() {
+ this.healthCheckTimer = setInterval(
+ () => this.healthCheck(),
+ HEALTH_CHECK_INTERVAL_MS,
+ );
+ }
+
+ static getInstance(): TerminalSessionManager {
+ if (!TerminalSessionManager.instance) {
+ TerminalSessionManager.instance = new TerminalSessionManager();
+ }
+ return TerminalSessionManager.instance;
+ }
+
+ createSession(
+ userId: string,
+ hostId: number,
+ hostName: string,
+ cols: number,
+ rows: number,
+ tabInstanceId?: string,
+ ): string {
+ const userSessions = this.getUserSessions(userId);
+ if (userSessions.length >= MAX_SESSIONS_PER_USER) {
+ const detached = userSessions
+ .filter((s) => s.attachedWs === null)
+ .sort(
+ (a, b) =>
+ (a.lastDetachedAt ?? a.createdAt) -
+ (b.lastDetachedAt ?? b.createdAt),
+ );
+ if (detached.length > 0) {
+ this.destroySession(detached[0].id);
+ }
+ }
+
+ if (tabInstanceId) {
+ const tabSessions = userSessions.filter(
+ (s) => s.tabInstanceId === tabInstanceId,
+ );
+ if (tabSessions.length > 0) {
+ sshLogger.warn("Tab instance already has session, destroying old", {
+ operation: "session_tab_duplicate_cleanup",
+ existingSessionId: tabSessions[0].id,
+ tabInstanceId,
+ });
+ this.destroySession(tabSessions[0].id);
+ }
+ }
+
+ const id = crypto.randomUUID();
+ const session: TerminalSession = {
+ id,
+ userId,
+ hostId,
+ hostName,
+ tabInstanceId,
+ sshConn: null,
+ sshStream: null,
+ jumpClient: null,
+ opksshTempFiles: null,
+ cols,
+ rows,
+ isConnected: false,
+ createdAt: Date.now(),
+ attachedWs: null,
+ lastDetachedAt: null,
+ detachTimeout: null,
+ outputBuffer: [],
+ outputBufferBytes: 0,
+ };
+ this.sessions.set(id, session);
+
+ sshLogger.info("Terminal session created", {
+ operation: "session_created",
+ sessionId: id,
+ userId,
+ hostId,
+ });
+
+ return id;
+ }
+
+ getSession(sessionId: string | null): TerminalSession | null {
+ if (!sessionId) return null;
+ return this.sessions.get(sessionId) ?? null;
+ }
+
+ setSSHState(
+ sessionId: string,
+ conn: Client,
+ stream: ClientChannel,
+ jumpClient?: Client | null,
+ opksshTempFiles?: { keyPath: string; certPath: string } | null,
+ ): void {
+ const session = this.sessions.get(sessionId);
+ if (!session) return;
+
+ session.sshConn = conn;
+ session.sshStream = stream;
+ session.jumpClient = jumpClient ?? null;
+ session.opksshTempFiles = opksshTempFiles ?? null;
+ session.isConnected = true;
+ }
+
+ attachWs(
+ sessionId: string,
+ userId: string,
+ ws: WebSocket,
+ tabInstanceId?: string,
+ ): TerminalSession | null {
+ const session = this.sessions.get(sessionId);
+ if (!session) {
+ sshLogger.warn("Session not found for attachment", {
+ operation: "session_attach_not_found",
+ sessionId,
+ userId,
+ });
+ return null;
+ }
+ if (session.userId !== userId) {
+ sshLogger.warn("Session userId mismatch", {
+ operation: "session_attach_user_mismatch",
+ sessionId,
+ expectedUserId: session.userId,
+ providedUserId: userId,
+ });
+ return null;
+ }
+ if (!session.isConnected) {
+ sshLogger.warn("Session not connected", {
+ operation: "session_attach_not_connected",
+ sessionId,
+ userId,
+ createdAt: session.createdAt,
+ elapsed: Date.now() - session.createdAt,
+ });
+ return null;
+ }
+
+ const isDetached =
+ !session.attachedWs || session.attachedWs.readyState !== WebSocket.OPEN;
+ const isOriginalTab = session.tabInstanceId === tabInstanceId;
+
+ if (
+ !isDetached &&
+ !isOriginalTab &&
+ session.tabInstanceId &&
+ tabInstanceId
+ ) {
+ sshLogger.warn("Session actively attached to different tab instance", {
+ operation: "session_attach_instance_conflict",
+ sessionId,
+ sessionInstanceId: session.tabInstanceId,
+ providedInstanceId: tabInstanceId,
+ });
+ try {
+ ws.send(
+ JSON.stringify({
+ type: "sessionExpired",
+ sessionId,
+ message: "Session belongs to a different tab instance",
+ }),
+ );
+ } catch {
+ /* ignore */
+ }
+ return null;
+ }
+
+ if (
+ session.tabInstanceId &&
+ tabInstanceId &&
+ session.tabInstanceId !== tabInstanceId
+ ) {
+ sshLogger.info(
+ "Session attached to different tab instance (split-screen)",
+ {
+ operation: "session_attach_split_screen",
+ originalInstanceId: session.tabInstanceId,
+ newInstanceId: tabInstanceId,
+ sessionId,
+ },
+ );
+ }
+
+ if (session.attachedWs && session.attachedWs !== ws) {
+ try {
+ session.attachedWs.send(
+ JSON.stringify({
+ type: "sessionTakenOver",
+ sessionId,
+ message: "Session was attached from another tab",
+ }),
+ );
+ } catch {
+ /* ignore */
+ }
+ session.attachedWs = null;
+ }
+
+ if (session.detachTimeout) {
+ clearTimeout(session.detachTimeout);
+ session.detachTimeout = null;
+ }
+
+ session.attachedWs = ws;
+ session.attachedTabInstanceId = tabInstanceId;
+ session.lastDetachedAt = null;
+
+ sshLogger.info("WebSocket attached to session", {
+ operation: "session_attach",
+ sessionId,
+ userId,
+ tabInstanceId,
+ });
+
+ return session;
+ }
+
+ detachWs(sessionId: string): void {
+ const session = this.sessions.get(sessionId);
+ if (!session) return;
+
+ if (session.detachTimeout) {
+ clearTimeout(session.detachTimeout);
+ session.detachTimeout = null;
+ }
+
+ session.attachedWs = null;
+ session.lastDetachedAt = Date.now();
+
+ const timeoutMs = this.getTimeoutMs();
+
+ session.detachTimeout = setTimeout(() => {
+ sshLogger.info("Session idle timeout expired", {
+ operation: "session_idle_timeout",
+ sessionId,
+ userId: session.userId,
+ });
+ this.destroySession(sessionId);
+ }, timeoutMs);
+
+ sshLogger.info("WebSocket detached from session", {
+ operation: "session_detach",
+ sessionId,
+ userId: session.userId,
+ timeoutMinutes: timeoutMs / 60_000,
+ });
+ }
+
+ destroySession(sessionId: string): void {
+ const session = this.sessions.get(sessionId);
+ if (!session) return;
+
+ if (session.detachTimeout) {
+ clearTimeout(session.detachTimeout);
+ session.detachTimeout = null;
+ }
+
+ if (session.sshStream) {
+ try {
+ session.sshStream.end();
+ } catch {
+ /* ignore */
+ }
+ session.sshStream = null;
+ }
+
+ if (session.sshConn) {
+ try {
+ session.sshConn.end();
+ } catch {
+ /* ignore */
+ }
+ session.sshConn = null;
+ }
+
+ if (session.jumpClient) {
+ try {
+ session.jumpClient.end();
+ } catch {
+ /* ignore */
+ }
+ session.jumpClient = null;
+ }
+
+ if (session.opksshTempFiles) {
+ const tempFiles = session.opksshTempFiles;
+ session.opksshTempFiles = null;
+ this.cleanupOpksshFiles(tempFiles);
+ }
+
+ session.isConnected = false;
+ session.outputBuffer = [];
+ session.outputBufferBytes = 0;
+
+ this.sessions.delete(sessionId);
+
+ sshLogger.info("Terminal session destroyed", {
+ operation: "session_destroyed",
+ sessionId,
+ userId: session.userId,
+ hostId: session.hostId,
+ });
+ }
+
+ getUserSessions(userId: string): TerminalSession[] {
+ const result: TerminalSession[] = [];
+ for (const session of this.sessions.values()) {
+ if (session.userId === userId) {
+ result.push(session);
+ }
+ }
+ return result;
+ }
+
+ bufferOutput(sessionId: string, data: string): void {
+ const session = this.sessions.get(sessionId);
+ if (!session) return;
+
+ session.outputBuffer.push(data);
+ session.outputBufferBytes += data.length;
+
+ while (
+ session.outputBufferBytes > MAX_BUFFER_BYTES &&
+ session.outputBuffer.length > 0
+ ) {
+ const removed = session.outputBuffer.shift();
+ if (removed) session.outputBufferBytes -= removed.length;
+ }
+ }
+
+ flushBuffer(session: TerminalSession): string | null {
+ if (session.outputBuffer.length === 0) return null;
+ const data = session.outputBuffer.join("");
+ session.outputBuffer = [];
+ session.outputBufferBytes = 0;
+ return data;
+ }
+
+ getBuffer(session: TerminalSession): string | null {
+ if (session.outputBuffer.length === 0) return null;
+ return session.outputBuffer.join("");
+ }
+
+ private getTimeoutMs(): number {
+ try {
+ const db = getDb();
+ const row = db.$client
+ .prepare(
+ "SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'",
+ )
+ .get() as { value: string } | undefined;
+ if (row) {
+ const minutes = parseInt(row.value, 10);
+ if (!isNaN(minutes) && minutes > 0) {
+ return minutes * 60_000;
+ }
+ }
+ } catch {
+ // DB not available, use default
+ }
+ return DEFAULT_TIMEOUT_MINUTES * 60_000;
+ }
+
+ private healthCheck(): void {
+ const toDestroy: string[] = [];
+ const now = Date.now();
+ const GRACE_PERIOD_MS = 10_000;
+
+ for (const [id, session] of this.sessions) {
+ if (!session.isConnected) continue;
+
+ if (
+ session.attachedWs &&
+ session.attachedWs.readyState === WebSocket.OPEN
+ ) {
+ continue;
+ }
+
+ if (session.sshStream?.destroyed) {
+ const detachedDuration = session.lastDetachedAt
+ ? now - session.lastDetachedAt
+ : 0;
+
+ if (detachedDuration > GRACE_PERIOD_MS) {
+ sshLogger.info(
+ "SSH stream destroyed during detach window, cleaning up",
+ {
+ operation: "session_health_check_stream_destroyed",
+ sessionId: id,
+ userId: session.userId,
+ detachedFor: detachedDuration,
+ },
+ );
+ toDestroy.push(id);
+ }
+ }
+
+ if (!session.sshConn) {
+ toDestroy.push(id);
+ }
+ }
+
+ for (const id of toDestroy) {
+ this.destroySession(id);
+ }
+ }
+
+ private async cleanupOpksshFiles(tempFiles: {
+ keyPath: string;
+ certPath: string;
+ }): Promise {
+ try {
+ const { promises: fs } = await import("fs");
+ const results = await Promise.allSettled([
+ fs.unlink(tempFiles.keyPath),
+ fs.unlink(tempFiles.certPath),
+ ]);
+ results.forEach((result, index) => {
+ if (result.status === "rejected") {
+ sshLogger.warn("Failed to cleanup OPKSSH temp file", {
+ operation: "opkssh_temp_cleanup_failed",
+ file: index === 0 ? "keyPath" : "certPath",
+ });
+ }
+ });
+ } catch (error) {
+ sshLogger.error("Failed to cleanup OPKSSH temp files", {
+ operation: "opkssh_temp_cleanup_error",
+ error,
+ });
+ }
+ }
+
+ destroyAll(): void {
+ for (const id of [...this.sessions.keys()]) {
+ this.destroySession(id);
+ }
+ if (this.healthCheckTimer) {
+ clearInterval(this.healthCheckTimer);
+ this.healthCheckTimer = null;
+ }
+ }
+}
+
+export const sessionManager = TerminalSessionManager.getInstance();
diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts
index 6ade781c..40efeda8 100644
--- a/src/backend/ssh/terminal.ts
+++ b/src/backend/ssh/terminal.ts
@@ -1,10 +1,5 @@
import { WebSocketServer, WebSocket, type RawData } from "ws";
-import {
- Client,
- type ClientChannel,
- type PseudoTtyOptions,
- type ConnectConfig,
-} from "ssh2";
+import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
import { parse as parseUrl } from "url";
import axios from "axios";
import { getDb } from "../database/db/index.js";
@@ -14,15 +9,21 @@ import { sshLogger, authLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
import { UserCrypto } from "../utils/user-crypto.js";
-import { createSocks5Connection } from "../utils/socks5-helper.js";
+import {
+ createSocks5Connection,
+ type SOCKS5Config,
+} from "../utils/socks5-helper.js";
import { SSHAuthManager } from "./auth-manager.js";
+import type { ProxyNode } from "../../types/index.js";
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
+import { sessionManager } from "./terminal-session-manager.js";
interface ConnectToHostData {
cols: number;
rows: number;
hostConfig: {
id: number;
+ instanceId?: string;
ip: string;
port: number;
username: string;
@@ -41,6 +42,11 @@ interface ConnectToHostData {
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: unknown;
+ terminalConfig?: {
+ keepaliveInterval?: number;
+ keepaliveCountMax?: number;
+ [key: string]: unknown;
+ };
};
initialPath?: string;
executeCommand?: string;
@@ -67,10 +73,24 @@ const userCrypto = UserCrypto.getInstance();
const userConnections = new Map>();
+interface JumpHostConfig {
+ id: number;
+ ip: string;
+ port: number;
+ username: string;
+ password?: string;
+ key?: string;
+ keyPassword?: string;
+ keyType?: string;
+ authType?: string;
+ credentialId?: number;
+ [key: string]: unknown;
+}
+
async function resolveJumpHost(
hostId: number,
userId: string,
-): Promise {
+): Promise {
sshLogger.info("Resolving jump host", {
operation: "terminal_jumphost_resolve",
userId,
@@ -111,17 +131,16 @@ async function resolveJumpHost(
const credential = credentials[0];
return {
...host,
- password: credential.password,
- key:
- credential.private_key || credential.privateKey || credential.key,
- keyPassword: credential.key_password || credential.keyPassword,
- keyType: credential.key_type || credential.keyType,
- authType: credential.auth_type || credential.authType,
- };
+ password: credential.password as string | undefined,
+ key: credential.privateKey as string | undefined,
+ keyPassword: credential.keyPassword as string | undefined,
+ keyType: credential.keyType as string | undefined,
+ authType: credential.authType as string | undefined,
+ } as JumpHostConfig;
}
}
- return host;
+ return host as JumpHostConfig;
} catch (error) {
sshLogger.error("Failed to resolve jump host", error, {
operation: "resolve_jump_host",
@@ -135,6 +154,7 @@ async function resolveJumpHost(
async function createJumpHostChain(
jumpHosts: Array<{ hostId: number }>,
userId: string,
+ socks5Config?: SOCKS5Config | null,
): Promise {
if (!jumpHosts || jumpHosts.length === 0) {
return null;
@@ -148,17 +168,31 @@ async function createJumpHostChain(
jumpHosts.map((jh) => resolveJumpHost(jh.hostId, userId)),
);
+ const totalHops = jumpHostConfigs.length;
+
for (let i = 0; i < jumpHostConfigs.length; i++) {
if (!jumpHostConfigs[i]) {
sshLogger.error(`Jump host ${i + 1} not found`, undefined, {
operation: "jump_host_chain",
hostId: jumpHosts[i].hostId,
+ hopIndex: i,
+ totalHops,
});
clients.forEach((c) => c.end());
return null;
}
}
+ let proxySocket: import("net").Socket | null = null;
+ if (socks5Config?.useSocks5) {
+ const firstHop = jumpHostConfigs[0];
+ proxySocket = await createSocks5Connection(
+ firstHop.ip,
+ firstHop.port || 22,
+ socks5Config,
+ );
+ }
+
for (let i = 0; i < jumpHostConfigs.length; i++) {
const jumpHostConfig = jumpHostConfigs[i];
@@ -187,22 +221,38 @@ async function createJumpHostChain(
hostId: jumpHostConfig.id,
ip: jumpHostConfig.ip,
depth: i,
+ hopIndex: i,
+ totalHops,
+ usedProxySocket: i === 0 && !!proxySocket,
});
resolve(true);
});
jumpClient.on("error", (err) => {
clearTimeout(timeout);
- sshLogger.error(`Jump host ${i + 1} connection failed`, err, {
- operation: "jump_host_connect",
- hostId: jumpHostConfig.id,
- ip: jumpHostConfig.ip,
- });
+ sshLogger.error(
+ `Jump host ${i + 1}/${totalHops} connection failed`,
+ err,
+ {
+ operation: "jump_host_connect",
+ hostId: jumpHostConfig.id,
+ ip: jumpHostConfig.ip,
+ hopIndex: i,
+ totalHops,
+ previousHop:
+ i > 0
+ ? jumpHostConfigs[i - 1]?.ip
+ : proxySocket
+ ? "proxy"
+ : "direct",
+ usedProxySocket: i === 0 && !!proxySocket,
+ },
+ );
resolve(false);
});
- const connectConfig: any = {
- host: jumpHostConfig.ip,
+ const connectConfig: Record = {
+ host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
port: jumpHostConfig.port || 22,
username: jumpHostConfig.username,
tryKeyboard: true,
@@ -239,6 +289,9 @@ async function createJumpHostChain(
jumpClient.connect(connectConfig);
},
);
+ } else if (proxySocket) {
+ connectConfig.sock = proxySocket;
+ jumpClient.connect(connectConfig);
} else {
jumpClient.connect(connectConfig);
}
@@ -358,8 +411,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
userId,
});
+ let currentSessionId: string | null = null;
let sshConn: Client | null = null;
let sshStream: ClientChannel | null = null;
+ let lastJumpClient: Client | null = null;
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
let totpPromptSent = false;
let totpTimeout: NodeJS.Timeout | null = null;
@@ -374,7 +429,14 @@ wss.on("connection", async (ws: WebSocket, req) => {
let isAwaitingAuthCredentials = false;
let opksshTempFiles: { keyPath: string; certPath: string } | null = null;
+ const wsPingInterval = setInterval(() => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.ping();
+ }
+ }, 30000);
+
ws.on("close", () => {
+ clearInterval(wsPingInterval);
sshLogger.info("Terminal WebSocket disconnected", {
operation: "terminal_ws_disconnect",
sessionId,
@@ -388,7 +450,16 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
}
- cleanupSSH();
+ if (currentSessionId) {
+ const session = sessionManager.getSession(currentSessionId);
+ if (session?.isConnected) {
+ sessionManager.detachWs(currentSessionId);
+ } else {
+ sessionManager.destroySession(currentSessionId);
+ currentSessionId = null;
+ }
+ }
+ cleanupAuthState();
});
function resetConnectionState() {
@@ -455,6 +526,110 @@ wss.on("connection", async (ws: WebSocket, req) => {
break;
}
+ case "attachSession": {
+ const attachData = data as {
+ sessionId: string;
+ cols: number;
+ rows: number;
+ tabInstanceId?: string;
+ };
+ sshLogger.info("Attempting to attach session", {
+ operation: "terminal_attach_session",
+ sessionId: attachData.sessionId,
+ tabInstanceId: attachData.tabInstanceId,
+ userId,
+ requestedCols: attachData.cols,
+ requestedRows: attachData.rows,
+ });
+ const session = sessionManager.attachWs(
+ attachData.sessionId,
+ userId,
+ ws,
+ attachData.tabInstanceId,
+ );
+ if (session) {
+ sshLogger.success("Session attached successfully", {
+ operation: "terminal_attach_success",
+ sessionId: attachData.sessionId,
+ sessionCreatedAt: session.createdAt,
+ wasDetached: !!session.lastDetachedAt,
+ detachedDuration: session.lastDetachedAt
+ ? Date.now() - session.lastDetachedAt
+ : 0,
+ });
+ currentSessionId = attachData.sessionId;
+ sshStream = session.sshStream;
+ sshConn = session.sshConn;
+ isConnecting = false;
+ isConnected = true;
+ const buffered = sessionManager.getBuffer(session);
+ if (buffered) {
+ ws.send(JSON.stringify({ type: "data", data: buffered }));
+ }
+ if (
+ attachData.cols !== session.cols ||
+ attachData.rows !== session.rows
+ ) {
+ session.sshStream?.setWindow(
+ attachData.rows,
+ attachData.cols,
+ attachData.rows,
+ attachData.cols,
+ );
+ session.cols = attachData.cols;
+ session.rows = attachData.rows;
+ }
+
+ ws.send(
+ JSON.stringify({
+ type: "sessionAttached",
+ sessionId: attachData.sessionId,
+ }),
+ );
+ ws.send(
+ JSON.stringify({
+ type: "connected",
+ message: "Session reattached",
+ }),
+ );
+ } else {
+ sshLogger.warn(
+ "Session attachment failed - will create new connection",
+ {
+ operation: "terminal_attach_failed",
+ sessionId: attachData.sessionId,
+ tabInstanceId: attachData.tabInstanceId,
+ userId,
+ reason: "session_not_found_or_invalid",
+ },
+ );
+ ws.send(
+ JSON.stringify({
+ type: "sessionExpired",
+ sessionId: attachData.sessionId,
+ }),
+ );
+ }
+ break;
+ }
+
+ case "listSessions": {
+ const sessions = sessionManager.getUserSessions(userId);
+ ws.send(
+ JSON.stringify({
+ type: "sessionList",
+ sessions: sessions.map((s) => ({
+ id: s.id,
+ hostId: s.hostId,
+ hostName: s.hostName,
+ createdAt: s.createdAt,
+ lastDetachedAt: s.lastDetachedAt,
+ })),
+ }),
+ );
+ break;
+ }
+
case "resize": {
const resizeData = data as ResizeData;
handleResize(resizeData);
@@ -462,29 +637,37 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
case "disconnect":
- cleanupSSH();
+ if (currentSessionId) {
+ sessionManager.destroySession(currentSessionId);
+ currentSessionId = null;
+ }
+ cleanupAuthState();
+ sshConn = null;
+ sshStream = null;
break;
case "input": {
const inputData = data as string;
- if (sshStream) {
+ const inputStream =
+ sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
+ if (inputStream) {
if (inputData === "\t") {
- sshStream.write(inputData);
+ inputStream.write(inputData);
} else if (
typeof inputData === "string" &&
inputData.startsWith("\x1b")
) {
- sshStream.write(inputData);
+ inputStream.write(inputData);
} else {
try {
- sshStream.write(Buffer.from(inputData, "utf8"));
+ inputStream.write(Buffer.from(inputData, "utf8"));
} catch (error) {
sshLogger.error("Error writing input to SSH stream", error, {
operation: "ssh_input_encoding",
userId,
dataLength: inputData.length,
});
- sshStream.write(Buffer.from(inputData, "latin1"));
+ inputStream.write(Buffer.from(inputData, "latin1"));
}
}
}
@@ -579,7 +762,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (credentialsData.password) {
credentialsData.hostConfig.password = credentialsData.password;
credentialsData.hostConfig.authType = "password";
- (credentialsData.hostConfig as any).userProvidedPassword = true;
+ (
+ credentialsData.hostConfig as Record
+ ).userProvidedPassword = true;
} else if (credentialsData.sshKey) {
credentialsData.hostConfig.key = credentialsData.sshKey;
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
@@ -587,7 +772,13 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
isAwaitingAuthCredentials = false;
- cleanupSSH();
+ if (currentSessionId) {
+ sessionManager.destroySession(currentSessionId);
+ currentSessionId = null;
+ }
+ cleanupAuthState();
+ sshConn = null;
+ sshStream = null;
const reconnectData: ConnectToHostData = {
cols: credentialsData.cols,
@@ -617,8 +808,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
case "opkssh_start_auth": {
const opksshData = data as { hostId: number };
try {
- const { startOPKSSHAuth, getRequestOrigin } =
- await import("./opkssh-auth.js");
+ const { startOPKSSHAuth, getRequestOrigin } = await import(
+ "./opkssh-auth.js"
+ );
const db = getDb();
const hostRow = await db
.select()
@@ -693,7 +885,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
hostId: number;
cols?: number;
rows?: number;
- hostConfig?: any;
+ hostConfig?: ConnectToHostData["hostConfig"];
};
resetConnectionState();
@@ -703,7 +895,13 @@ wss.on("connection", async (ws: WebSocket, req) => {
rows: completedData.rows || 24,
hostConfig:
completedData.hostConfig ||
- ({ id: completedData.hostId, userId } as any),
+ ({
+ id: completedData.hostId,
+ ip: "",
+ port: 22,
+ username: "",
+ userId,
+ } as ConnectToHostData["hostConfig"]),
};
handleConnectToHost(reconnectConfig).catch((error) => {
@@ -737,7 +935,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
const { hostConfig, initialPath, executeCommand } = data;
const {
id,
- ip,
+ ip: rawIp,
port,
username,
password,
@@ -747,6 +945,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
authType,
credentialId,
} = hostConfig;
+ const ip = rawIp?.replace(/^\[|\]$/g, "").trim() || rawIp;
sshLogger.info("Resolving SSH host configuration", {
operation: "terminal_host_resolve",
sessionId,
@@ -758,7 +957,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
stage: string,
level: string,
message: string,
- details?: Record,
+ details?: Record,
) => {
ws.send(
JSON.stringify({
@@ -841,12 +1040,23 @@ wss.on("connection", async (ws: WebSocket, req) => {
ws.send(
JSON.stringify({ type: "error", message: "SSH connection timeout" }),
);
- cleanupSSH(connectionTimeout);
+ if (currentSessionId) {
+ sessionManager.destroySession(currentSessionId);
+ currentSessionId = null;
+ }
+ cleanupAuthState(connectionTimeout);
}
}, 120000);
- let resolvedCredentials = { password, key, keyPassword, keyType, authType };
- let authMethodNotAvailable = false;
+ let resolvedCredentials = {
+ username,
+ password,
+ key,
+ keyPassword,
+ keyType,
+ authType,
+ };
+ const authMethodNotAvailable = false;
if (credentialId && id && hostConfig.userId) {
try {
const credentials = await SimpleDBOps.select(
@@ -866,19 +1076,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (credentials.length > 0) {
const credential = credentials[0];
resolvedCredentials = {
+ username: (credential.username as string | undefined) || username,
password: credential.password as string | undefined,
- key: (credential.private_key ||
- credential.privateKey ||
- credential.key) as string | undefined,
- keyPassword: (credential.key_password || credential.keyPassword) as
- | string
- | undefined,
- keyType: (credential.key_type || credential.keyType) as
- | string
- | undefined,
- authType: (credential.auth_type || credential.authType) as
- | string
- | undefined,
+ key: credential.privateKey as string | undefined,
+ keyPassword: credential.keyPassword as string | undefined,
+ keyType: credential.keyType as string | undefined,
+ authType: credential.authType as string | undefined,
};
} else {
sshLogger.warn(`No credentials found for host ${id}`, {
@@ -926,6 +1129,27 @@ wss.on("connection", async (ws: WebSocket, req) => {
sendLog("auth", "success", `Authentication successful for ${username}`);
sendLog("connected", "success", "Connection established");
+ const hostDisplayName = `${username}@${ip}:${port}`;
+ const tabInstanceId = hostConfig.instanceId;
+ currentSessionId = sessionManager.createSession(
+ userId,
+ id,
+ hostDisplayName,
+ data.cols,
+ data.rows,
+ tabInstanceId,
+ );
+
+ sshLogger.info("Terminal session created after SSH ready", {
+ operation: "terminal_session_created",
+ sessionId: currentSessionId,
+ userId,
+ hostId: id,
+ tabInstanceId,
+ ip,
+ port,
+ });
+
const conn = sshConn;
if (!conn || isCleaningUp || !sshConn) {
@@ -949,6 +1173,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
"SSH connection was closed before terminal could be created",
}),
);
+ if (currentSessionId) {
+ sessionManager.destroySession(currentSessionId);
+ currentSessionId = null;
+ }
+ cleanupAuthState(connectionTimeout);
return;
}
@@ -971,6 +1200,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
}),
);
isShellInitializing = false;
+ if (currentSessionId) {
+ sessionManager.destroySession(currentSessionId);
+ currentSessionId = null;
+ }
+ cleanupAuthState(connectionTimeout);
return;
}
@@ -1000,7 +1234,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
"Shell creation timeout. The server may not support interactive shells or the connection was interrupted.",
}),
);
- cleanupSSH(connectionTimeout);
+ if (currentSessionId) {
+ sessionManager.destroySession(currentSessionId);
+ currentSessionId = null;
+ }
+ cleanupAuthState(connectionTimeout);
}
}, 15000);
@@ -1029,7 +1267,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
message: "Shell error: " + err.message,
}),
);
- cleanupSSH(connectionTimeout);
+ if (currentSessionId) {
+ sessionManager.destroySession(currentSessionId);
+ currentSessionId = null;
+ }
+ cleanupAuthState(connectionTimeout);
return;
}
@@ -1042,32 +1284,82 @@ wss.on("connection", async (ws: WebSocket, req) => {
termType: "xterm-256color",
});
+ if (currentSessionId) {
+ sessionManager.setSSHState(
+ currentSessionId,
+ sshConn!,
+ stream,
+ lastJumpClient,
+ opksshTempFiles,
+ );
+ sessionManager.attachWs(currentSessionId, userId, ws);
+
+ ws.send(
+ JSON.stringify({
+ type: "sessionCreated",
+ sessionId: currentSessionId,
+ }),
+ );
+
+ sshLogger.info("Session ready for persistence", {
+ operation: "session_ready",
+ sessionId: currentSessionId,
+ userId,
+ hostId: id,
+ });
+ }
+
+ const boundSessionId = currentSessionId;
+
stream.on("data", (data: Buffer) => {
try {
const utf8String = data.toString("utf-8");
- ws.send(JSON.stringify({ type: "data", data: utf8String }));
+ const session = sessionManager.getSession(boundSessionId);
+ if (session) {
+ sessionManager.bufferOutput(boundSessionId!, utf8String);
+
+ if (session.attachedWs?.readyState === WebSocket.OPEN) {
+ session.attachedWs.send(
+ JSON.stringify({ type: "data", data: utf8String }),
+ );
+ }
+ }
} catch (error) {
sshLogger.error("Error encoding terminal data", error, {
operation: "terminal_data_encoding",
hostId: id,
dataLength: data.length,
});
- ws.send(
- JSON.stringify({
- type: "data",
- data: data.toString("latin1"),
- }),
- );
+ const fallback = data.toString("latin1");
+ const session = sessionManager.getSession(boundSessionId);
+ if (session) {
+ sessionManager.bufferOutput(boundSessionId!, fallback);
+
+ if (session.attachedWs?.readyState === WebSocket.OPEN) {
+ session.attachedWs.send(
+ JSON.stringify({ type: "data", data: fallback }),
+ );
+ }
+ }
}
});
stream.on("close", () => {
- ws.send(
- JSON.stringify({
- type: "disconnected",
- message: "Connection lost",
- }),
- );
+ const session = sessionManager.getSession(boundSessionId);
+ if (session?.attachedWs?.readyState === WebSocket.OPEN) {
+ session.attachedWs.send(
+ JSON.stringify({
+ type: "disconnected",
+ message: "Connection lost",
+ }),
+ );
+ }
+ if (boundSessionId) {
+ sessionManager.destroySession(boundSessionId);
+ if (currentSessionId === boundSessionId) {
+ currentSessionId = null;
+ }
+ }
});
stream.on("error", (err: Error) => {
@@ -1078,12 +1370,15 @@ wss.on("connection", async (ws: WebSocket, req) => {
port,
username,
});
- ws.send(
- JSON.stringify({
- type: "error",
- message: "SSH stream error: " + err.message,
- }),
- );
+ const session = sessionManager.getSession(boundSessionId);
+ if (session?.attachedWs?.readyState === WebSocket.OPEN) {
+ session.attachedWs.send(
+ JSON.stringify({
+ type: "error",
+ message: "SSH stream error: " + err.message,
+ }),
+ );
+ }
});
if (initialPath && initialPath.trim() !== "") {
@@ -1195,14 +1490,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
})();
- clearTimeout(connectionTimeout);
- if (sshConn) {
- try {
- sshConn.end();
- } catch (e) {}
- sshConn = null;
+ if (currentSessionId) {
+ sessionManager.destroySession(currentSessionId);
+ currentSessionId = null;
}
- resetConnectionState();
+ cleanupAuthState(connectionTimeout);
sendLog(
"auth",
@@ -1231,15 +1523,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
"error",
"Server does not support keyboard-interactive authentication",
);
- clearTimeout(connectionTimeout);
isAwaitingAuthCredentials = true;
- if (sshConn) {
- try {
- sshConn.end();
- } catch (e) {}
- sshConn = null;
+ if (currentSessionId) {
+ sessionManager.destroySession(currentSessionId);
+ currentSessionId = null;
}
- resetConnectionState();
+ cleanupAuthState(connectionTimeout);
ws.send(
JSON.stringify({
type: "auth_method_not_available",
@@ -1256,15 +1545,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
!isKeyboardInteractive &&
!keyboardInteractiveResponded
) {
- clearTimeout(connectionTimeout);
isAwaitingAuthCredentials = true;
- if (sshConn) {
- try {
- sshConn.end();
- } catch (e) {}
- sshConn = null;
+ if (currentSessionId) {
+ sessionManager.destroySession(currentSessionId);
+ currentSessionId = null;
}
- resetConnectionState();
+ cleanupAuthState(connectionTimeout);
ws.send(
JSON.stringify({
type: "auth_method_not_available",
@@ -1333,6 +1619,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
} else if (err.message.includes("ECONNREFUSED")) {
errorMessage =
"SSH error: Connection refused. The server may not be running or the port may be incorrect.";
+ } else if (err.message.includes("ENETUNREACH")) {
+ const isIPv6 = ip && ip.includes(":");
+ errorMessage = isIPv6
+ ? "SSH error: Network unreachable. IPv6 may not be available in this environment. If running in Docker, enable IPv6 in the Docker daemon and network configuration."
+ : "SSH error: Network unreachable. Check your network configuration and routing.";
} else if (err.message.includes("ETIMEDOUT")) {
errorMessage =
"SSH error: Connection timed out. Check your network connection and server availability.";
@@ -1351,7 +1642,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
ws.send(JSON.stringify({ type: "error", message: errorMessage }));
- cleanupSSH(connectionTimeout);
+ if (currentSessionId) {
+ sessionManager.destroySession(currentSessionId);
+ currentSessionId = null;
+ }
+ cleanupAuthState(connectionTimeout);
});
sshConn.on("close", () => {
@@ -1364,7 +1659,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
});
if (isAwaitingAuthCredentials) {
- cleanupSSH(connectionTimeout);
+ if (currentSessionId) {
+ sessionManager.destroySession(currentSessionId);
+ currentSessionId = null;
+ }
+ cleanupAuthState(connectionTimeout);
return;
}
@@ -1378,22 +1677,30 @@ wss.on("connection", async (ws: WebSocket, req) => {
isShellInitializing,
hasStream: !!sshStream,
});
- ws.send(
- JSON.stringify({
- type: "error",
- message:
- "Connection closed during shell initialization. The server may have rejected the shell request.",
- }),
- );
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(
+ JSON.stringify({
+ type: "error",
+ message:
+ "Connection closed during shell initialization. The server may have rejected the shell request.",
+ }),
+ );
+ }
} else if (!sshStream) {
- ws.send(
- JSON.stringify({
- type: "disconnected",
- message: "Connection closed",
- }),
- );
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(
+ JSON.stringify({
+ type: "disconnected",
+ message: "Connection closed",
+ }),
+ );
+ }
}
- cleanupSSH(connectionTimeout);
+ if (currentSessionId) {
+ sessionManager.destroySession(currentSessionId);
+ currentSessionId = null;
+ }
+ cleanupAuthState(connectionTimeout);
});
const sshAuthManager = new SSHAuthManager({
@@ -1429,7 +1736,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
instructionsLang,
prompts,
finish,
- resolvedCredentials as any,
+ resolvedCredentials as unknown as Parameters<
+ typeof sshAuthManager.handleKeyboardInteractive
+ >[5],
);
isKeyboardInteractive = sshAuthManager.context.isKeyboardInteractive;
@@ -1444,13 +1753,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
},
);
- const connectConfig: any = {
+ const hostKeepaliveInterval = hostConfig.terminalConfig?.keepaliveInterval;
+ const hostKeepaliveCountMax = hostConfig.terminalConfig?.keepaliveCountMax;
+
+ const connectConfig: Record = {
host: ip,
port,
username,
tryKeyboard: true,
- keepaliveInterval: 30000,
- keepaliveCountMax: 3,
+ keepaliveInterval:
+ typeof hostKeepaliveInterval === "number"
+ ? hostKeepaliveInterval
+ : 30000,
+ keepaliveCountMax:
+ typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 3,
readyTimeout: 120000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
@@ -1527,6 +1843,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
};
if (resolvedCredentials.authType === "none") {
+ // no credentials needed
} else if (resolvedCredentials.authType === "password") {
if (!resolvedCredentials.password) {
sshLogger.error(
@@ -1659,58 +1976,32 @@ wss.on("connection", async (ws: WebSocket, req) => {
return;
}
- if (
+ const proxyConfig: SOCKS5Config | null =
hostConfig.useSocks5 &&
(hostConfig.socks5Host ||
(hostConfig.socks5ProxyChain &&
- (hostConfig.socks5ProxyChain as any).length > 0))
- ) {
- try {
- const socks5Socket = await createSocks5Connection(ip, port, {
- useSocks5: hostConfig.useSocks5,
- socks5Host: hostConfig.socks5Host,
- socks5Port: hostConfig.socks5Port,
- socks5Username: hostConfig.socks5Username,
- socks5Password: hostConfig.socks5Password,
- socks5ProxyChain: hostConfig.socks5ProxyChain as any,
- });
+ (hostConfig.socks5ProxyChain as ProxyNode[]).length > 0))
+ ? {
+ useSocks5: hostConfig.useSocks5,
+ socks5Host: hostConfig.socks5Host,
+ socks5Port: hostConfig.socks5Port,
+ socks5Username: hostConfig.socks5Username,
+ socks5Password: hostConfig.socks5Password,
+ socks5ProxyChain: hostConfig.socks5ProxyChain as ProxyNode[],
+ }
+ : null;
- if (socks5Socket) {
- connectConfig.sock = socks5Socket;
- sshConn.connect(connectConfig);
- return;
- }
- } catch (socks5Error) {
- sshLogger.error("SOCKS5 connection failed", socks5Error, {
- operation: "socks5_connect",
- hostId: id,
- proxyHost: hostConfig.socks5Host,
- proxyPort: hostConfig.socks5Port || 1080,
- });
- ws.send(
- JSON.stringify({
- type: "error",
- message:
- "SOCKS5 proxy connection failed: " +
- (socks5Error instanceof Error
- ? socks5Error.message
- : "Unknown error"),
- }),
- );
- cleanupSSH(connectionTimeout);
- return;
- }
- }
-
- if (
+ const hasJumpHosts =
hostConfig.jumpHosts &&
hostConfig.jumpHosts.length > 0 &&
- hostConfig.userId
- ) {
+ hostConfig.userId;
+
+ if (hasJumpHosts) {
try {
const jumpClient = await createJumpHostChain(
- hostConfig.jumpHosts,
- hostConfig.userId,
+ hostConfig.jumpHosts!,
+ hostConfig.userId!,
+ proxyConfig,
);
if (!jumpClient) {
@@ -1721,9 +2012,14 @@ wss.on("connection", async (ws: WebSocket, req) => {
message: "Failed to connect through jump hosts",
}),
);
- cleanupSSH(connectionTimeout);
+ if (currentSessionId) {
+ sessionManager.destroySession(currentSessionId);
+ currentSessionId = null;
+ }
+ cleanupAuthState(connectionTimeout);
return;
}
+ lastJumpClient = jumpClient;
jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => {
if (err) {
@@ -1740,7 +2036,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
}),
);
jumpClient.end();
- cleanupSSH(connectionTimeout);
+ if (currentSessionId) {
+ sessionManager.destroySession(currentSessionId);
+ currentSessionId = null;
+ }
+ cleanupAuthState(connectionTimeout);
return;
}
@@ -1748,7 +2048,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
sendLog(
"handshake",
"info",
- "Starting SSH session through jump host",
+ "Starting SSH session through jump host" +
+ (proxyConfig ? " (via proxy)" : ""),
);
sendLog("auth", "info", `Authenticating as ${username}`);
sshLogger.info("Initiating SSH connection", {
@@ -1760,6 +2061,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
port,
username,
authType: resolvedCredentials.authType,
+ viaProxy: !!proxyConfig,
});
sshConn.connect(connectConfig);
});
@@ -1774,9 +2076,57 @@ wss.on("connection", async (ws: WebSocket, req) => {
message: "Failed to connect through jump hosts",
}),
);
- cleanupSSH(connectionTimeout);
+ if (currentSessionId) {
+ sessionManager.destroySession(currentSessionId);
+ currentSessionId = null;
+ }
+ cleanupAuthState(connectionTimeout);
return;
}
+ } else if (proxyConfig) {
+ try {
+ const proxySocket = await createSocks5Connection(ip, port, proxyConfig);
+ if (proxySocket) {
+ connectConfig.sock = proxySocket;
+ }
+ } catch (proxyError) {
+ sshLogger.error("Proxy connection failed", proxyError, {
+ operation: "proxy_connect",
+ hostId: id,
+ proxyHost: hostConfig.socks5Host,
+ proxyPort: hostConfig.socks5Port || 1080,
+ });
+ ws.send(
+ JSON.stringify({
+ type: "error",
+ message:
+ "Proxy connection failed: " +
+ (proxyError instanceof Error
+ ? proxyError.message
+ : "Unknown error"),
+ }),
+ );
+ if (currentSessionId) {
+ sessionManager.destroySession(currentSessionId);
+ currentSessionId = null;
+ }
+ cleanupAuthState(connectionTimeout);
+ return;
+ }
+ sendLog("handshake", "info", "Starting SSH session (via proxy)");
+ sendLog("auth", "info", `Authenticating as ${username}`);
+ sshLogger.info("Initiating SSH connection", {
+ operation: "terminal_ssh_connect_attempt",
+ sessionId,
+ userId,
+ hostId: id,
+ ip,
+ port,
+ username,
+ authType: resolvedCredentials.authType,
+ viaProxy: true,
+ });
+ sshConn.connect(connectConfig);
} else {
sendLog("handshake", "info", "Starting SSH session");
sendLog("auth", "info", `Authenticating as ${username}`);
@@ -1795,33 +2145,22 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
function handleResize(data: ResizeData) {
- if (sshStream && sshStream.setWindow) {
- sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
+ const resizeStream =
+ sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
+ if (resizeStream && resizeStream.setWindow) {
+ resizeStream.setWindow(data.rows, data.cols, data.rows, data.cols);
+ const session = sessionManager.getSession(currentSessionId);
+ if (session) {
+ session.cols = data.cols;
+ session.rows = data.rows;
+ }
ws.send(
JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }),
);
}
}
- function cleanupSSH(timeoutId?: NodeJS.Timeout) {
- if (isCleaningUp) {
- return;
- }
-
- if (isShellInitializing) {
- sshLogger.warn(
- "Cleanup attempted during shell initialization, deferring",
- {
- operation: "cleanup_deferred",
- userId,
- },
- );
- setTimeout(() => cleanupSSH(timeoutId), 100);
- return;
- }
-
- isCleaningUp = true;
-
+ function cleanupAuthState(timeoutId?: NodeJS.Timeout) {
if (timeoutId) {
clearTimeout(timeoutId);
}
@@ -1836,67 +2175,14 @@ wss.on("connection", async (ws: WebSocket, req) => {
warpgateAuthTimeout = null;
}
- if (sshStream) {
- try {
- sshStream.end();
- } catch (e: unknown) {
- sshLogger.error(
- "Error closing stream: " +
- (e instanceof Error ? e.message : "Unknown error"),
- );
- }
- sshStream = null;
- }
-
- if (sshConn) {
- try {
- sshConn.end();
- } catch (e: unknown) {
- sshLogger.error(
- "Error closing connection: " +
- (e instanceof Error ? e.message : "Unknown error"),
- );
- }
- sshConn = null;
- }
+ sshStream = null;
+ sshConn = null;
+ lastJumpClient = null;
+ opksshTempFiles = null;
resetConnectionState();
isCleaningUp = false;
isAwaitingAuthCredentials = false;
-
- if (opksshTempFiles) {
- const tempFilesToClean = opksshTempFiles;
- opksshTempFiles = null;
-
- (async () => {
- try {
- const { promises: fs } = await import("fs");
- const cleanupResults = await Promise.allSettled([
- fs.unlink(tempFilesToClean.keyPath),
- fs.unlink(tempFilesToClean.certPath),
- ]);
-
- cleanupResults.forEach((result, index) => {
- if (result.status === "rejected") {
- sshLogger.warn(`Failed to cleanup OPKSSH temp file`, {
- operation: "opkssh_temp_cleanup_failed",
- file: index === 0 ? "keyPath" : "certPath",
- path:
- index === 0
- ? tempFilesToClean.keyPath
- : tempFilesToClean.certPath,
- error: result.reason,
- });
- }
- });
- } catch (error) {
- sshLogger.error("Failed to cleanup OPKSSH temp files", {
- operation: "opkssh_temp_cleanup_error",
- error,
- });
- }
- })();
- }
}
// Note: PTY-level keepalive (writing \x00 to the stream) was removed.
diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts
index 77aea0bd..8cb60afd 100644
--- a/src/backend/ssh/tunnel.ts
+++ b/src/backend/ssh/tunnel.ts
@@ -6,7 +6,7 @@ import { ChildProcess } from "child_process";
import axios from "axios";
import { getDb } from "../database/db/index.js";
import { sshCredentials } from "../database/db/schema.js";
-import { eq, and } from "drizzle-orm";
+import { eq } from "drizzle-orm";
import type {
SSHHost,
TunnelConfig,
@@ -16,13 +16,14 @@ import type {
AuthenticatedRequest,
} from "../../types/index.js";
import { CONNECTION_STATES } from "../../types/index.js";
-import { tunnelLogger, sshLogger } from "../utils/logger.js";
+import { tunnelLogger } from "../utils/logger.js";
import { SystemCrypto } from "../utils/system-crypto.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { createSocks5Connection } from "../utils/socks5-helper.js";
import { AuthManager } from "../utils/auth-manager.js";
import { PermissionManager } from "../utils/permission-manager.js";
+import { withConnection } from "./ssh-connection-pool.js";
const app = express();
app.use(
@@ -61,6 +62,10 @@ app.use(
);
app.use(cookieParser());
app.use(express.json());
+app.use((_req, res, next) => {
+ res.setHeader("Cache-Control", "no-store");
+ next();
+});
const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
@@ -603,8 +608,9 @@ async function connectSSHTunnel(
tunnelConfig.requestingUserId &&
tunnelConfig.requestingUserId !== tunnelConfig.sourceUserId
) {
- const { SharedCredentialManager } =
- await import("../utils/shared-credential-manager.js");
+ const { SharedCredentialManager } = await import(
+ "../utils/shared-credential-manager.js"
+ );
const sharedCredManager = SharedCredentialManager.getInstance();
if (tunnelConfig.sourceHostId) {
@@ -655,16 +661,10 @@ async function connectSSHTunnel(
const credential = credentials[0];
resolvedSourceCredentials = {
password: credential.password as string | undefined,
- sshKey: (credential.private_key ||
- credential.privateKey ||
- credential.key) as string | undefined,
- keyPassword: (credential.key_password ||
- credential.keyPassword) as string | undefined,
- keyType: (credential.key_type || credential.keyType) as
- | string
- | undefined,
- authMethod: (credential.auth_type ||
- credential.authType) as string,
+ sshKey: credential.privateKey as string | undefined,
+ keyPassword: credential.keyPassword as string | undefined,
+ keyType: credential.keyType as string | undefined,
+ authMethod: credential.authType as string,
};
}
}
@@ -746,16 +746,10 @@ async function connectSSHTunnel(
const credential = credentials[0];
resolvedEndpointCredentials = {
password: credential.password as string | undefined,
- sshKey: (credential.private_key ||
- credential.privateKey ||
- credential.key) as string | undefined,
- keyPassword: (credential.key_password || credential.keyPassword) as
- | string
- | undefined,
- keyType: (credential.key_type || credential.keyType) as
- | string
- | undefined,
- authMethod: (credential.auth_type || credential.authType) as string,
+ sshKey: credential.privateKey as string | undefined,
+ keyPassword: credential.keyPassword as string | undefined,
+ keyType: credential.keyType as string | undefined,
+ authMethod: credential.authType as string,
};
} else {
tunnelLogger.warn("No endpoint credentials found in database", {
@@ -802,7 +796,9 @@ async function connectSSHTunnel(
try {
conn.end();
- } catch (error) {}
+ } catch {
+ // expected
+ }
activeTunnels.delete(tunnelName);
@@ -996,7 +992,9 @@ async function connectSSHTunnel(
const verification = tunnelVerifications.get(tunnelName);
if (verification?.timeout) clearTimeout(verification.timeout);
verification?.conn.end();
- } catch (error) {}
+ } catch {
+ // expected
+ }
tunnelVerifications.delete(tunnelName);
}
@@ -1110,7 +1108,8 @@ async function connectSSHTunnel(
});
const connOptions: Record = {
- host: tunnelConfig.sourceIP,
+ host:
+ tunnelConfig.sourceIP?.replace(/^\[|\]$/g, "") || tunnelConfig.sourceIP,
port: tunnelConfig.sourceSSHPort,
username: tunnelConfig.sourceUsername,
tryKeyboard: true,
@@ -1347,16 +1346,10 @@ async function killRemoteTunnelByMarker(
const credential = credentials[0];
resolvedSourceCredentials = {
password: credential.password as string | undefined,
- sshKey: (credential.private_key ||
- credential.privateKey ||
- credential.key) as string | undefined,
- keyPassword: (credential.key_password || credential.keyPassword) as
- | string
- | undefined,
- keyType: (credential.key_type || credential.keyType) as
- | string
- | undefined,
- authMethod: (credential.auth_type || credential.authType) as string,
+ sshKey: credential.privateKey as string | undefined,
+ keyPassword: credential.keyPassword as string | undefined,
+ keyType: credential.keyType as string | undefined,
+ authMethod: credential.authType as string,
};
}
}
@@ -1369,50 +1362,6 @@ async function killRemoteTunnelByMarker(
}
}
- const conn = new Client();
- const connOptions: Record = {
- host: tunnelConfig.sourceIP,
- port: tunnelConfig.sourceSSHPort,
- username: tunnelConfig.sourceUsername,
- keepaliveInterval: 30000,
- keepaliveCountMax: 3,
- readyTimeout: 60000,
- tcpKeepAlive: true,
- tcpKeepAliveInitialDelay: 15000,
- algorithms: {
- kex: [
- "diffie-hellman-group14-sha256",
- "diffie-hellman-group14-sha1",
- "diffie-hellman-group1-sha1",
- "diffie-hellman-group-exchange-sha256",
- "diffie-hellman-group-exchange-sha1",
- "ecdh-sha2-nistp256",
- "ecdh-sha2-nistp384",
- "ecdh-sha2-nistp521",
- ],
- cipher: [
- "aes128-ctr",
- "aes192-ctr",
- "aes256-ctr",
- "aes128-gcm@openssh.com",
- "aes256-gcm@openssh.com",
- "aes128-cbc",
- "aes192-cbc",
- "aes256-cbc",
- "3des-cbc",
- ],
- hmac: [
- "hmac-sha2-256-etm@openssh.com",
- "hmac-sha2-512-etm@openssh.com",
- "hmac-sha2-256",
- "hmac-sha2-512",
- "hmac-sha1",
- "hmac-md5",
- ],
- compress: ["none", "zlib@openssh.com", "zlib"],
- },
- };
-
if (
resolvedSourceCredentials.authMethod === "key" &&
resolvedSourceCredentials.sshKey
@@ -1424,151 +1373,83 @@ async function killRemoteTunnelByMarker(
callback(new Error("Invalid SSH key format"));
return;
}
-
- const cleanKey = resolvedSourceCredentials.sshKey
- .trim()
- .replace(/\r\n/g, "\n")
- .replace(/\r/g, "\n");
- connOptions.privateKey = Buffer.from(cleanKey, "utf8");
- if (resolvedSourceCredentials.keyPassword) {
- connOptions.passphrase = resolvedSourceCredentials.keyPassword;
- }
- if (
- resolvedSourceCredentials.keyType &&
- resolvedSourceCredentials.keyType !== "auto"
- ) {
- connOptions.privateKeyType = resolvedSourceCredentials.keyType;
- }
- } else {
- connOptions.password = resolvedSourceCredentials.password;
}
- conn.on("ready", () => {
- const tunnelType = tunnelConfig.tunnelType || "remote";
- const tunnelFlag = tunnelType === "local" ? "-L" : "-R";
- const checkCmd = `ps aux | grep -E '(${tunnelMarker}|ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}:.*:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}|sshpass.*ssh.*${tunnelFlag})' | grep -v grep`;
+ const poolKey = `tunnel:${tunnelConfig.sourceUserId}:${tunnelConfig.sourceIP}:${tunnelConfig.sourceSSHPort}:${tunnelConfig.sourceUsername}`;
- conn.exec(checkCmd, (_err, stream) => {
- let foundProcesses = false;
+ const factory = async (): Promise => {
+ const connOptions: Record = {
+ host:
+ tunnelConfig.sourceIP?.replace(/^\[|\]$/g, "") || tunnelConfig.sourceIP,
+ port: tunnelConfig.sourceSSHPort,
+ username: tunnelConfig.sourceUsername,
+ keepaliveInterval: 30000,
+ keepaliveCountMax: 3,
+ readyTimeout: 60000,
+ tcpKeepAlive: true,
+ tcpKeepAliveInitialDelay: 15000,
+ algorithms: {
+ kex: [
+ "diffie-hellman-group14-sha256",
+ "diffie-hellman-group14-sha1",
+ "diffie-hellman-group1-sha1",
+ "diffie-hellman-group-exchange-sha256",
+ "diffie-hellman-group-exchange-sha1",
+ "ecdh-sha2-nistp256",
+ "ecdh-sha2-nistp384",
+ "ecdh-sha2-nistp521",
+ ],
+ cipher: [
+ "aes128-ctr",
+ "aes192-ctr",
+ "aes256-ctr",
+ "aes128-gcm@openssh.com",
+ "aes256-gcm@openssh.com",
+ "aes128-cbc",
+ "aes192-cbc",
+ "aes256-cbc",
+ "3des-cbc",
+ ],
+ hmac: [
+ "hmac-sha2-256-etm@openssh.com",
+ "hmac-sha2-512-etm@openssh.com",
+ "hmac-sha2-256",
+ "hmac-sha2-512",
+ "hmac-sha1",
+ "hmac-md5",
+ ],
+ compress: ["none", "zlib@openssh.com", "zlib"],
+ },
+ };
- stream.on("data", (data) => {
- const output = data.toString().trim();
- if (output) {
- foundProcesses = true;
- }
- });
+ if (
+ resolvedSourceCredentials.authMethod === "key" &&
+ resolvedSourceCredentials.sshKey
+ ) {
+ const cleanKey = resolvedSourceCredentials.sshKey
+ .trim()
+ .replace(/\r\n/g, "\n")
+ .replace(/\r/g, "\n");
+ connOptions.privateKey = Buffer.from(cleanKey, "utf8");
+ if (resolvedSourceCredentials.keyPassword) {
+ connOptions.passphrase = resolvedSourceCredentials.keyPassword;
+ }
+ if (
+ resolvedSourceCredentials.keyType &&
+ resolvedSourceCredentials.keyType !== "auto"
+ ) {
+ connOptions.privateKeyType = resolvedSourceCredentials.keyType;
+ }
+ } else {
+ connOptions.password = resolvedSourceCredentials.password;
+ }
- stream.on("close", () => {
- if (!foundProcesses) {
- tunnelLogger.warn("Remote tunnel process not found", {
- operation: "tunnel_remote_not_found",
- userId: tunnelConfig.sourceUserId,
- hostId: tunnelConfig.sourceHostId,
- tunnelName,
- marker: tunnelMarker,
- });
- conn.end();
- callback();
- return;
- }
- tunnelLogger.info("Remote tunnel process found, proceeding to kill", {
- operation: "tunnel_remote_found",
- userId: tunnelConfig.sourceUserId,
- hostId: tunnelConfig.sourceHostId,
- tunnelName,
- marker: tunnelMarker,
- });
-
- const killCmds = [
- `pkill -TERM -f '${tunnelMarker}'`,
- `sleep 1 && pkill -f 'ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}:.*:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`,
- `sleep 1 && pkill -f 'sshpass.*ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}'`,
- `sleep 2 && pkill -9 -f '${tunnelMarker}'`,
- ];
-
- let commandIndex = 0;
-
- function executeNextKillCommand() {
- if (commandIndex >= killCmds.length) {
- conn.exec(checkCmd, (_err, verifyStream) => {
- let stillRunning = false;
-
- verifyStream.on("data", (data) => {
- const output = data.toString().trim();
- if (output) {
- stillRunning = true;
- tunnelLogger.warn(
- `Processes still running after cleanup for '${tunnelName}': ${output}`,
- );
- }
- });
-
- verifyStream.on("close", () => {
- if (stillRunning) {
- tunnelLogger.warn(
- `Some tunnel processes may still be running for '${tunnelName}'`,
- );
- } else {
- tunnelLogger.success("Remote tunnel process killed", {
- operation: "tunnel_remote_killed",
- userId: tunnelConfig.sourceUserId,
- hostId: tunnelConfig.sourceHostId,
- tunnelName,
- });
- }
- conn.end();
- callback();
- });
- });
- return;
- }
-
- const killCmd = killCmds[commandIndex];
-
- conn.exec(killCmd, (err, stream) => {
- if (err) {
- tunnelLogger.warn(
- `Kill command ${commandIndex + 1} failed for '${tunnelName}': ${err.message}`,
- );
- }
-
- stream.on("close", () => {
- commandIndex++;
- executeNextKillCommand();
- });
-
- stream.on("data", () => {});
-
- stream.stderr.on("data", (data) => {
- const output = data.toString().trim();
- if (output && !output.includes("debug1")) {
- tunnelLogger.warn(
- `Kill command ${commandIndex + 1} stderr for '${tunnelName}': ${output}`,
- );
- }
- });
- });
- }
-
- executeNextKillCommand();
- });
- });
- });
-
- conn.on("error", (err) => {
- tunnelLogger.error(
- `Failed to connect to source host for killing tunnel '${tunnelName}': ${err.message}`,
- );
- callback(err);
- });
-
- if (
- tunnelConfig.useSocks5 &&
- (tunnelConfig.socks5Host ||
- (tunnelConfig.socks5ProxyChain &&
- tunnelConfig.socks5ProxyChain.length > 0))
- ) {
- (async () => {
+ if (
+ tunnelConfig.useSocks5 &&
+ (tunnelConfig.socks5Host ||
+ (tunnelConfig.socks5ProxyChain &&
+ tunnelConfig.socks5ProxyChain.length > 0))
+ ) {
try {
const socks5Socket = await createSocks5Connection(
tunnelConfig.sourceIP,
@@ -1585,9 +1466,8 @@ async function killRemoteTunnelByMarker(
if (socks5Socket) {
connOptions.sock = socks5Socket;
- conn.connect(connOptions);
} else {
- callback(new Error("Failed to create SOCKS5 connection"));
+ throw new Error("Failed to create SOCKS5 connection");
}
} catch (socks5Error) {
tunnelLogger.error(
@@ -1600,18 +1480,109 @@ async function killRemoteTunnelByMarker(
proxyPort: tunnelConfig.socks5Port || 1080,
},
);
- callback(
- new Error(
- "SOCKS5 proxy connection failed: " +
- (socks5Error instanceof Error
- ? socks5Error.message
- : "Unknown error"),
- ),
+ throw new Error(
+ "SOCKS5 proxy connection failed: " +
+ (socks5Error instanceof Error
+ ? socks5Error.message
+ : "Unknown error"),
);
}
- })();
- } else {
- conn.connect(connOptions);
+ }
+
+ return new Promise((resolve, reject) => {
+ const conn = new Client();
+ conn.on("ready", () => resolve(conn));
+ conn.on("error", (err) => reject(err));
+ conn.connect(connOptions);
+ });
+ };
+
+ const execCommand = (client: Client, cmd: string): Promise =>
+ new Promise((resolve, reject) => {
+ client.exec(cmd, (err, stream) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ let output = "";
+ stream.on("data", (data: Buffer) => {
+ output += data.toString();
+ });
+ stream.stderr.on("data", (data: Buffer) => {
+ const stderr = data.toString().trim();
+ if (stderr && !stderr.includes("debug1")) {
+ tunnelLogger.warn(
+ `Kill command stderr for '${tunnelName}': ${stderr}`,
+ );
+ }
+ });
+ stream.on("close", () => resolve(output.trim()));
+ });
+ });
+
+ try {
+ await withConnection(poolKey, factory, async (client) => {
+ const tunnelType = tunnelConfig.tunnelType || "remote";
+ const tunnelFlag = tunnelType === "local" ? "-L" : "-R";
+ const checkCmd = `ps aux | grep -E '(${tunnelMarker}|ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}:.*:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}|sshpass.*ssh.*${tunnelFlag})' | grep -v grep`;
+
+ const checkOutput = await execCommand(client, checkCmd);
+ if (!checkOutput) {
+ tunnelLogger.warn("Remote tunnel process not found", {
+ operation: "tunnel_remote_not_found",
+ userId: tunnelConfig.sourceUserId,
+ hostId: tunnelConfig.sourceHostId,
+ tunnelName,
+ marker: tunnelMarker,
+ });
+ return;
+ }
+
+ tunnelLogger.info("Remote tunnel process found, proceeding to kill", {
+ operation: "tunnel_remote_found",
+ userId: tunnelConfig.sourceUserId,
+ hostId: tunnelConfig.sourceHostId,
+ tunnelName,
+ marker: tunnelMarker,
+ });
+
+ const killCmds = [
+ `pkill -TERM -f '${tunnelMarker}'`,
+ `sleep 1 && pkill -f 'ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}:.*:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}'`,
+ `sleep 1 && pkill -f 'sshpass.*ssh.*${tunnelFlag}.*${tunnelConfig.endpointPort}'`,
+ `sleep 2 && pkill -9 -f '${tunnelMarker}'`,
+ ];
+
+ for (const killCmd of killCmds) {
+ try {
+ await execCommand(client, killCmd);
+ } catch (err) {
+ tunnelLogger.warn(
+ `Kill command failed for '${tunnelName}': ${(err as Error).message}`,
+ );
+ }
+ }
+
+ const verifyOutput = await execCommand(client, checkCmd);
+ if (verifyOutput) {
+ tunnelLogger.warn(
+ `Some tunnel processes may still be running for '${tunnelName}'`,
+ );
+ } else {
+ tunnelLogger.success("Remote tunnel process killed", {
+ operation: "tunnel_remote_killed",
+ userId: tunnelConfig.sourceUserId,
+ hostId: tunnelConfig.sourceHostId,
+ tunnelName,
+ });
+ }
+ });
+ callback();
+ } catch (err) {
+ tunnelLogger.error(
+ `Failed to connect to source host for killing tunnel '${tunnelName}': ${(err as Error).message}`,
+ );
+ callback(err as Error);
}
}
@@ -1750,7 +1721,7 @@ app.post(
if (pendingTunnelOperations.has(tunnelName)) {
try {
await pendingTunnelOperations.get(tunnelName);
- } catch (error) {
+ } catch {
tunnelLogger.warn(`Previous tunnel operation failed`, { tunnelName });
}
}
diff --git a/src/backend/ssh/widgets/common-utils.ts b/src/backend/ssh/widgets/common-utils.ts
index 5394ba56..66361aed 100644
--- a/src/backend/ssh/widgets/common-utils.ts
+++ b/src/backend/ssh/widgets/common-utils.ts
@@ -1,4 +1,4 @@
-import type { Client } from "ssh2";
+import type { Client, ClientChannel } from "ssh2";
export function execCommand(
client: Client,
@@ -11,7 +11,7 @@ export function execCommand(
}> {
return new Promise((resolve, reject) => {
let settled = false;
- let stream: any = null;
+ let stream: ClientChannel | null = null;
const timeout = setTimeout(() => {
if (!settled) {
@@ -30,8 +30,8 @@ export function execCommand(
stream.stderr.removeAllListeners();
}
stream.destroy();
- } catch (error) {
- // Ignore cleanup errors
+ } catch {
+ // expected - cleanup errors ignored
}
}
};
diff --git a/src/backend/ssh/widgets/cpu-collector.ts b/src/backend/ssh/widgets/cpu-collector.ts
index 90eb579b..2328f0b3 100644
--- a/src/backend/ssh/widgets/cpu-collector.ts
+++ b/src/backend/ssh/widgets/cpu-collector.ts
@@ -77,7 +77,7 @@ export async function collectCpuMetrics(client: Client): Promise<{
const coresNum = Number((coresOut.stdout || "").trim());
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
- } catch (e) {
+ } catch {
cpuPercent = null;
cores = null;
loadTriplet = null;
diff --git a/src/backend/ssh/widgets/disk-collector.ts b/src/backend/ssh/widgets/disk-collector.ts
index b221cee2..79ae76c2 100644
--- a/src/backend/ssh/widgets/disk-collector.ts
+++ b/src/backend/ssh/widgets/disk-collector.ts
@@ -51,7 +51,7 @@ export async function collectDiskMetrics(client: Client): Promise<{
);
}
}
- } catch (e) {
+ } catch {
diskPercent = null;
usedHuman = null;
totalHuman = null;
diff --git a/src/backend/ssh/widgets/login-stats-collector.ts b/src/backend/ssh/widgets/login-stats-collector.ts
index a3894e74..ccb2befe 100644
--- a/src/backend/ssh/widgets/login-stats-collector.ts
+++ b/src/backend/ssh/widgets/login-stats-collector.ts
@@ -1,6 +1,5 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
-import { statsLogger } from "../../utils/logger.js";
export interface LoginRecord {
user: string;
@@ -53,7 +52,7 @@ export async function collectLoginStats(client: Client): Promise {
parsedTime = isNaN(date.getTime())
? new Date().toISOString()
: date.toISOString();
- } catch (e) {
+ } catch {
parsedTime = new Date().toISOString();
}
@@ -70,7 +69,9 @@ export async function collectLoginStats(client: Client): Promise {
}
}
}
- } catch (e) {}
+ } catch {
+ // expected
+ }
try {
const failedOut = await execCommand(
@@ -111,7 +112,7 @@ export async function collectLoginStats(client: Client): Promise {
parsedTime = isNaN(date.getTime())
? new Date().toISOString()
: date.toISOString();
- } catch (e) {
+ } catch {
parsedTime = new Date().toISOString();
}
@@ -126,7 +127,9 @@ export async function collectLoginStats(client: Client): Promise {
}
}
}
- } catch (e) {}
+ } catch {
+ // expected
+ }
return {
recentLogins: recentLogins.slice(0, 10),
diff --git a/src/backend/ssh/widgets/memory-collector.ts b/src/backend/ssh/widgets/memory-collector.ts
index 3dce5c64..e25652f5 100644
--- a/src/backend/ssh/widgets/memory-collector.ts
+++ b/src/backend/ssh/widgets/memory-collector.ts
@@ -27,7 +27,7 @@ export async function collectMemoryMetrics(client: Client): Promise<{
usedGiB = kibToGiB(usedKb);
totalGiB = kibToGiB(totalKb);
}
- } catch (e) {
+ } catch {
memPercent = null;
usedGiB = null;
totalGiB = null;
diff --git a/src/backend/ssh/widgets/network-collector.ts b/src/backend/ssh/widgets/network-collector.ts
index c24b75e6..831c3883 100644
--- a/src/backend/ssh/widgets/network-collector.ts
+++ b/src/backend/ssh/widgets/network-collector.ts
@@ -1,6 +1,5 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
-import { statsLogger } from "../../utils/logger.js";
export async function collectNetworkMetrics(client: Client): Promise<{
interfaces: Array<{
@@ -68,7 +67,9 @@ export async function collectNetworkMetrics(client: Client): Promise<{
txBytes: null,
});
}
- } catch (e) {}
+ } catch {
+ // expected
+ }
return { interfaces };
}
diff --git a/src/backend/ssh/widgets/processes-collector.ts b/src/backend/ssh/widgets/processes-collector.ts
index 09d62612..cde0626f 100644
--- a/src/backend/ssh/widgets/processes-collector.ts
+++ b/src/backend/ssh/widgets/processes-collector.ts
@@ -1,6 +1,5 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
-import { statsLogger } from "../../utils/logger.js";
export async function collectProcessesMetrics(client: Client): Promise<{
total: number | null;
@@ -54,7 +53,9 @@ export async function collectProcessesMetrics(client: Client): Promise<{
const runningCount2 = Number(runningCount.stdout.trim());
runningProcesses = Number.isFinite(runningCount2) ? runningCount2 : null;
- } catch (e) {}
+ } catch {
+ // expected
+ }
return {
total: totalProcesses,
diff --git a/src/backend/ssh/widgets/system-collector.ts b/src/backend/ssh/widgets/system-collector.ts
index c5007d55..4e20ae4a 100644
--- a/src/backend/ssh/widgets/system-collector.ts
+++ b/src/backend/ssh/widgets/system-collector.ts
@@ -1,6 +1,5 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
-import { statsLogger } from "../../utils/logger.js";
export async function collectSystemMetrics(client: Client): Promise<{
hostname: string | null;
@@ -22,8 +21,8 @@ export async function collectSystemMetrics(client: Client): Promise<{
hostname = hostnameOut.stdout.trim() || null;
kernel = kernelOut.stdout.trim() || null;
os = osOut.stdout.trim() || null;
- } catch (e) {
- // No error log
+ } catch {
+ // expected
}
return {
diff --git a/src/backend/ssh/widgets/uptime-collector.ts b/src/backend/ssh/widgets/uptime-collector.ts
index 3571b8a0..6349f7da 100644
--- a/src/backend/ssh/widgets/uptime-collector.ts
+++ b/src/backend/ssh/widgets/uptime-collector.ts
@@ -1,6 +1,5 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
-import { statsLogger } from "../../utils/logger.js";
export async function collectUptimeMetrics(client: Client): Promise<{
seconds: number | null;
@@ -21,7 +20,9 @@ export async function collectUptimeMetrics(client: Client): Promise<{
uptimeFormatted = `${days}d ${hours}h ${minutes}m`;
}
}
- } catch (e) {}
+ } catch {
+ // expected
+ }
return {
seconds: uptimeSeconds,
diff --git a/src/backend/starter.ts b/src/backend/starter.ts
index 215e11dd..dc102a3f 100644
--- a/src/backend/starter.ts
+++ b/src/backend/starter.ts
@@ -22,7 +22,9 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
if (persistentConfig.parsed) {
Object.assign(process.env, persistentConfig.parsed);
}
- } catch (error) {}
+ } catch {
+ // expected - env file may not exist
+ }
systemLogger.info("Termix backend initialization started", {
operation: "backend_init_start",
@@ -110,8 +112,9 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
await authManager.initialize();
DataCrypto.initialize();
- const { OPKSSHBinaryManager } =
- await import("./utils/opkssh-binary-manager.js");
+ const { OPKSSHBinaryManager } = await import(
+ "./utils/opkssh-binary-manager.js"
+ );
try {
await OPKSSHBinaryManager.ensureBinary();
} catch (error) {
@@ -163,6 +166,16 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
process.exit(0);
});
+ process.on("message", (msg: { type?: string }) => {
+ if (msg?.type === "shutdown") {
+ systemLogger.info(
+ "Received IPC shutdown, initiating graceful shutdown...",
+ { operation: "shutdown" },
+ );
+ process.exit(0);
+ }
+ });
+
process.on("uncaughtException", (error) => {
systemLogger.error("Uncaught exception occurred", error, {
operation: "error_handling",
diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts
index 7a8c0d55..b22ff6a7 100644
--- a/src/backend/utils/auth-manager.ts
+++ b/src/backend/utils/auth-manager.ts
@@ -5,7 +5,7 @@ import { DataCrypto } from "./data-crypto.js";
import { databaseLogger, authLogger } from "./logger.js";
import type { Request, Response, NextFunction } from "express";
import { db } from "../database/db/index.js";
-import { sessions } from "../database/db/schema.js";
+import { sessions, trustedDevices } from "../database/db/schema.js";
import { eq, and, sql } from "drizzle-orm";
import { nanoid } from "nanoid";
import type { DeviceType } from "./user-agent-parser.js";
@@ -99,7 +99,7 @@ class AuthManager {
const sessionDurationMs =
deviceType === "desktop" || deviceType === "mobile"
? 30 * 24 * 60 * 60 * 1000
- : 7 * 24 * 60 * 60 * 1000;
+ : 2 * 60 * 60 * 1000;
const authenticated = await this.userCrypto.authenticateOIDCUser(
userId,
@@ -121,7 +121,7 @@ class AuthManager {
const sessionDurationMs =
deviceType === "desktop" || deviceType === "mobile"
? 30 * 24 * 60 * 60 * 1000
- : 7 * 24 * 60 * 60 * 1000;
+ : 2 * 60 * 60 * 1000;
const authenticated = await this.userCrypto.authenticateUser(
userId,
@@ -154,8 +154,9 @@ class AuthManager {
return;
}
- const { getSqlite, saveMemoryDatabaseToFile } =
- await import("../database/db/index.js");
+ const { getSqlite, saveMemoryDatabaseToFile } = await import(
+ "../database/db/index.js"
+ );
const sqlite = getSqlite();
@@ -170,8 +171,9 @@ class AuthManager {
}
try {
- const { CredentialSystemEncryptionMigration } =
- await import("./credential-system-encryption-migration.js");
+ const { CredentialSystemEncryptionMigration } = await import(
+ "./credential-system-encryption-migration.js"
+ );
const credMigration = new CredentialSystemEncryptionMigration();
const credResult = await credMigration.migrateUserCredentials(userId);
@@ -199,6 +201,7 @@ class AuthManager {
options: {
expiresIn?: string;
pendingTOTP?: boolean;
+ rememberMe?: boolean;
deviceType?: DeviceType;
deviceInfo?: string;
} = {},
@@ -207,13 +210,13 @@ class AuthManager {
let expiresIn = options.expiresIn;
if (!expiresIn && !options.pendingTOTP) {
- if (options.deviceType === "desktop" || options.deviceType === "mobile") {
+ if (options.rememberMe) {
expiresIn = "30d";
} else {
- expiresIn = "7d";
+ expiresIn = "2h";
}
} else if (!expiresIn) {
- expiresIn = "7d";
+ expiresIn = "2h";
}
const payload: JWTPayload = { userId };
@@ -247,8 +250,9 @@ class AuthManager {
});
try {
- const { saveMemoryDatabaseToFile } =
- await import("../database/db/index.js");
+ const { saveMemoryDatabaseToFile } = await import(
+ "../database/db/index.js"
+ );
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
@@ -276,7 +280,7 @@ class AuthManager {
private parseExpiresIn(expiresIn: string): number {
const match = expiresIn.match(/^(\d+)([smhd])$/);
- if (!match) return 7 * 24 * 60 * 60 * 1000;
+ if (!match) return 2 * 60 * 60 * 1000;
const value = parseInt(match[1]);
const unit = match[2];
@@ -291,7 +295,7 @@ class AuthManager {
case "d":
return value * 24 * 60 * 60 * 1000;
default:
- return 7 * 24 * 60 * 60 * 1000;
+ return 2 * 60 * 60 * 1000;
}
}
@@ -340,9 +344,15 @@ class AuthManager {
}
}
- invalidateJWTToken(token: string): void {}
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ invalidateJWTToken(_token: string): void {
+ // expected - no-op, JWT tokens are stateless
+ }
- invalidateUserTokens(userId: string): void {}
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ invalidateUserTokens(_userId: string): void {
+ // expected - no-op, handled by session management
+ }
async revokeSession(sessionId: string): Promise {
try {
@@ -354,8 +364,9 @@ class AuthManager {
await db.delete(sessions).where(eq(sessions.id, sessionId));
try {
- const { saveMemoryDatabaseToFile } =
- await import("../database/db/index.js");
+ const { saveMemoryDatabaseToFile } = await import(
+ "../database/db/index.js"
+ );
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
@@ -412,8 +423,9 @@ class AuthManager {
}
try {
- const { saveMemoryDatabaseToFile } =
- await import("../database/db/index.js");
+ const { saveMemoryDatabaseToFile } = await import(
+ "../database/db/index.js"
+ );
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
@@ -454,8 +466,9 @@ class AuthManager {
.where(sql`${sessions.expiresAt} < datetime('now')`);
try {
- const { saveMemoryDatabaseToFile } =
- await import("../database/db/index.js");
+ const { saveMemoryDatabaseToFile } = await import(
+ "../database/db/index.js"
+ );
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
@@ -488,7 +501,7 @@ class AuthManager {
}
}
- async getAllSessions(): Promise {
+ async getAllSessions(): Promise[]> {
try {
const allSessions = await db.select().from(sessions);
return allSessions;
@@ -500,7 +513,7 @@ class AuthManager {
}
}
- async getUserSessions(userId: string): Promise {
+ async getUserSessions(userId: string): Promise[]> {
try {
const userSessions = await db
.select()
@@ -518,7 +531,7 @@ class AuthManager {
getSecureCookieOptions(
req: RequestWithHeaders,
- maxAge: number = 7 * 24 * 60 * 60 * 1000,
+ maxAge: number = 2 * 60 * 60 * 1000,
) {
return {
httpOnly: false,
@@ -600,8 +613,9 @@ class AuthManager {
.where(eq(sessions.id, payload.sessionId))
.then(async () => {
try {
- const { saveMemoryDatabaseToFile } =
- await import("../database/db/index.js");
+ const { saveMemoryDatabaseToFile } = await import(
+ "../database/db/index.js"
+ );
await saveMemoryDatabaseToFile();
const remainingSessions = await db
@@ -711,7 +725,7 @@ class AuthManager {
.from(users)
.where(eq(users.id, payload.userId));
- if (!user || user.length === 0 || !user[0].is_admin) {
+ if (!user || user.length === 0 || !user[0].isAdmin) {
databaseLogger.warn(
"Non-admin user attempted to access admin endpoint",
{
@@ -745,8 +759,9 @@ class AuthManager {
await db.delete(sessions).where(eq(sessions.id, sessionId));
try {
- const { saveMemoryDatabaseToFile } =
- await import("../database/db/index.js");
+ const { saveMemoryDatabaseToFile } = await import(
+ "../database/db/index.js"
+ );
await saveMemoryDatabaseToFile();
} catch (saveError) {
databaseLogger.error(
@@ -768,6 +783,7 @@ class AuthManager {
if (remainingSessions.length === 0) {
this.userCrypto.logoutUser(userId);
} else {
+ // expected - other sessions still active, keep user crypto state
}
} catch (error) {
databaseLogger.error("Failed to delete session on logout", error, {
@@ -810,6 +826,121 @@ class AuthManager {
newPassword,
);
}
+
+ /**
+ * Check if device is trusted for TOTP bypass
+ */
+ async isTrustedDevice(
+ userId: string,
+ deviceFingerprint: string,
+ ): Promise {
+ try {
+ const device = await db
+ .select()
+ .from(trustedDevices)
+ .where(
+ and(
+ eq(trustedDevices.userId, userId),
+ eq(trustedDevices.deviceFingerprint, deviceFingerprint),
+ ),
+ )
+ .limit(1);
+
+ if (!device || device.length === 0) {
+ return false;
+ }
+
+ const now = new Date();
+ const expiresAt = new Date(device[0].expiresAt);
+
+ if (now > expiresAt) {
+ await this.removeTrustedDevice(userId, deviceFingerprint);
+ return false;
+ }
+
+ await db
+ .update(trustedDevices)
+ .set({ lastUsedAt: now.toISOString() })
+ .where(
+ and(
+ eq(trustedDevices.userId, userId),
+ eq(trustedDevices.deviceFingerprint, deviceFingerprint),
+ ),
+ );
+
+ return true;
+ } catch (error) {
+ authLogger.error("Failed to check trusted device", { userId, error });
+ return false;
+ }
+ }
+
+ /**
+ * Add device to trusted list for TOTP bypass
+ */
+ async addTrustedDevice(
+ userId: string,
+ deviceFingerprint: string,
+ deviceType: string,
+ deviceInfo: string,
+ ): Promise {
+ const now = new Date();
+ const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
+
+ const existingDevice = await db
+ .select()
+ .from(trustedDevices)
+ .where(
+ and(
+ eq(trustedDevices.userId, userId),
+ eq(trustedDevices.deviceFingerprint, deviceFingerprint),
+ ),
+ )
+ .limit(1);
+
+ if (existingDevice && existingDevice.length > 0) {
+ await db
+ .update(trustedDevices)
+ .set({
+ expiresAt: expiresAt.toISOString(),
+ lastUsedAt: now.toISOString(),
+ })
+ .where(
+ and(
+ eq(trustedDevices.userId, userId),
+ eq(trustedDevices.deviceFingerprint, deviceFingerprint),
+ ),
+ );
+ } else {
+ await db.insert(trustedDevices).values({
+ id: nanoid(),
+ userId,
+ deviceFingerprint,
+ deviceType,
+ deviceInfo,
+ createdAt: now.toISOString(),
+ expiresAt: expiresAt.toISOString(),
+ lastUsedAt: now.toISOString(),
+ });
+ }
+ }
+
+ /**
+ * Remove trusted device
+ */
+ async removeTrustedDevice(
+ userId: string,
+ deviceFingerprint: string,
+ ): Promise {
+ await db
+ .delete(trustedDevices)
+ .where(
+ and(
+ eq(trustedDevices.userId, userId),
+ eq(trustedDevices.deviceFingerprint, deviceFingerprint),
+ ),
+ );
+ }
}
export { AuthManager, type AuthenticationResult, type JWTPayload };
diff --git a/src/backend/utils/auto-ssl-setup.ts b/src/backend/utils/auto-ssl-setup.ts
index acb85d21..e6a2a86c 100644
--- a/src/backend/utils/auto-ssl-setup.ts
+++ b/src/backend/utils/auto-ssl-setup.ts
@@ -233,7 +233,9 @@ IP.3 = 0.0.0.0
let envContent = "";
try {
envContent = await fs.readFile(this.ENV_FILE, "utf8");
- } catch (error) {}
+ } catch {
+ // expected - env file may not exist yet
+ }
let updatedContent = envContent;
let hasChanges = false;
diff --git a/src/backend/utils/credential-system-encryption-migration.ts b/src/backend/utils/credential-system-encryption-migration.ts
index ffabd66a..4dd3008f 100644
--- a/src/backend/utils/credential-system-encryption-migration.ts
+++ b/src/backend/utils/credential-system-encryption-migration.ts
@@ -59,9 +59,9 @@ export class CredentialSystemEncryptionMigration {
)
: null;
- const plainKeyPassword = cred.key_password
+ const plainKeyPassword = cred.keyPassword
? FieldCrypto.decryptField(
- cred.key_password,
+ cred.keyPassword,
userDEK,
cred.id.toString(),
"key_password",
diff --git a/src/backend/utils/data-crypto.ts b/src/backend/utils/data-crypto.ts
index 37033444..f129b443 100644
--- a/src/backend/utils/data-crypto.ts
+++ b/src/backend/utils/data-crypto.ts
@@ -131,12 +131,12 @@ class DataCrypto {
db.prepare(updateQuery).run(
updatedRecord.password || null,
updatedRecord.key || null,
- updatedRecord.key_password || updatedRecord.keyPassword || null,
- updatedRecord.keyType || null,
- updatedRecord.autostartPassword || null,
- updatedRecord.autostartKey || null,
- updatedRecord.autostartKeyPassword || null,
- updatedRecord.sudoPassword || null,
+ updatedRecord.key_password || null,
+ updatedRecord.key_type || null,
+ updatedRecord.autostart_password || null,
+ updatedRecord.autostart_key || null,
+ updatedRecord.autostart_key_password || null,
+ updatedRecord.sudo_password || null,
record.id,
);
@@ -171,10 +171,10 @@ class DataCrypto {
db.prepare(updateQuery).run(
updatedRecord.password || null,
updatedRecord.key || null,
- updatedRecord.key_password || updatedRecord.keyPassword || null,
- updatedRecord.private_key || updatedRecord.privateKey || null,
- updatedRecord.public_key || updatedRecord.publicKey || null,
- updatedRecord.keyType || null,
+ updatedRecord.key_password || null,
+ updatedRecord.private_key || null,
+ updatedRecord.public_key || null,
+ updatedRecord.key_type || null,
record.id,
);
@@ -207,14 +207,10 @@ class DataCrypto {
WHERE id = ?
`;
db.prepare(updateQuery).run(
- updatedRecord.totp_secret || updatedRecord.totpSecret || null,
- updatedRecord.totp_backup_codes ||
- updatedRecord.totpBackupCodes ||
- null,
- updatedRecord.client_secret || updatedRecord.clientSecret || null,
- updatedRecord.oidc_identifier ||
- updatedRecord.oidcIdentifier ||
- null,
+ updatedRecord.totp_secret || null,
+ updatedRecord.totp_backup_codes || null,
+ updatedRecord.client_secret || null,
+ updatedRecord.oidc_identifier || null,
userId,
);
@@ -267,46 +263,41 @@ class DataCrypto {
"password",
"key",
"key_password",
- "keyPassword",
- "keyType",
- "autostartPassword",
- "autostartKey",
- "autostartKeyPassword",
+ "sudo_password",
+ "autostart_password",
+ "autostart_key",
+ "autostart_key_password",
],
},
{
table: "ssh_credentials",
fields: [
"password",
- "private_key",
- "privateKey",
- "key_password",
- "keyPassword",
"key",
+ "private_key",
"public_key",
- "publicKey",
- "keyType",
+ "key_password",
],
},
{
table: "users",
fields: [
"client_secret",
- "clientSecret",
"totp_secret",
- "totpSecret",
"totp_backup_codes",
- "totpBackupCodes",
"oidc_identifier",
- "oidcIdentifier",
],
},
];
for (const { table, fields } of tablesToReencrypt) {
try {
+ const selectQuery =
+ table === "users"
+ ? `SELECT * FROM ${table} WHERE id = ?`
+ : `SELECT * FROM ${table} WHERE user_id = ?`;
const records = db
- .prepare(`SELECT * FROM ${table} WHERE user_id = ?`)
+ .prepare(selectQuery)
.all(userId) as DatabaseRecord[];
for (const record of records) {
@@ -359,7 +350,13 @@ class DataCrypto {
(field) => updatedRecord[field] !== record[field],
);
if (updateFields.length > 0) {
- const updateQuery = `UPDATE ${table} SET ${updateFields.map((f) => `${f} = ?`).join(", ")}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`;
+ const setClause = updateFields
+ .map((f) => `${f} = ?`)
+ .join(", ");
+ const updateQuery =
+ table === "users"
+ ? `UPDATE ${table} SET ${setClause} WHERE id = ?`
+ : `UPDATE ${table} SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`;
const updateValues = updateFields.map(
(field) => updatedRecord[field],
);
@@ -507,9 +504,9 @@ class DataCrypto {
);
}
- if (record.key_password && typeof record.key_password === "string") {
+ if (record.keyPassword && typeof record.keyPassword === "string") {
systemEncrypted.systemKeyPassword = FieldCrypto.encryptField(
- record.key_password as string,
+ record.keyPassword as string,
systemKey,
recordId as string,
"key_password",
diff --git a/src/backend/utils/database-file-encryption.ts b/src/backend/utils/database-file-encryption.ts
index a0217578..4e31a2dd 100644
--- a/src/backend/utils/database-file-encryption.ts
+++ b/src/backend/utils/database-file-encryption.ts
@@ -327,7 +327,9 @@ class DatabaseFileEncryption {
fs.accessSync(envPath, fs.constants.R_OK);
envFileReadable = true;
}
- } catch (error) {}
+ } catch {
+ // expected - env file access check may fail
+ }
databaseLogger.error(
"Database decryption authentication failed - possible causes: wrong DATABASE_KEY, corrupted files, or interrupted write",
@@ -581,7 +583,9 @@ class DatabaseFileEncryption {
try {
fs.accessSync(envPath, fs.constants.R_OK);
result.environment.envFileReadable = true;
- } catch (error) {}
+ } catch {
+ // expected - env file access check may fail
+ }
}
if (
diff --git a/src/backend/utils/database-save-trigger.ts b/src/backend/utils/database-save-trigger.ts
index b3c2da21..08d07e43 100644
--- a/src/backend/utils/database-save-trigger.ts
+++ b/src/backend/utils/database-save-trigger.ts
@@ -5,12 +5,21 @@ export class DatabaseSaveTrigger {
private static isInitialized = false;
private static pendingSave = false;
private static saveTimeout: NodeJS.Timeout | null = null;
+ private static _dirty = false;
static initialize(saveFunction: () => Promise): void {
this.saveFunction = saveFunction;
this.isInitialized = true;
}
+ static get isDirty(): boolean {
+ return this._dirty;
+ }
+
+ static markClean(): void {
+ this._dirty = false;
+ }
+
static async triggerSave(
reason: string = "data_modification",
): Promise {
@@ -22,6 +31,8 @@ export class DatabaseSaveTrigger {
return;
}
+ this._dirty = true;
+
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
@@ -35,6 +46,7 @@ export class DatabaseSaveTrigger {
try {
await this.saveFunction!();
+ this._dirty = false;
} catch (error) {
databaseLogger.error("Database save failed", error, {
operation: "db_save_trigger_failed",
diff --git a/src/backend/utils/field-crypto.ts b/src/backend/utils/field-crypto.ts
index bf524913..52527597 100644
--- a/src/backend/utils/field-crypto.ts
+++ b/src/backend/utils/field-crypto.ts
@@ -16,43 +16,29 @@ class FieldCrypto {
private static readonly ENCRYPTED_FIELDS = {
users: new Set([
- "password_hash",
"passwordHash",
- "client_secret",
"clientSecret",
- "totp_secret",
"totpSecret",
- "totp_backup_codes",
"totpBackupCodes",
- "oidc_identifier",
"oidcIdentifier",
]),
ssh_data: new Set([
"password",
"key",
- "key_password",
"keyPassword",
+ "sudoPassword",
"autostartPassword",
"autostartKey",
"autostartKeyPassword",
- "sudoPassword",
]),
ssh_credentials: new Set([
"password",
- "private_key",
"privateKey",
- "key_password",
"keyPassword",
"key",
- "public_key",
"publicKey",
]),
- opkssh_tokens: new Set([
- "ssh_cert",
- "sshCert",
- "private_key",
- "privateKey",
- ]),
+ opkssh_tokens: new Set(["sshCert", "privateKey"]),
};
static encryptField(
diff --git a/src/backend/utils/lazy-field-encryption.ts b/src/backend/utils/lazy-field-encryption.ts
index ae7ee615..62d72d64 100644
--- a/src/backend/utils/lazy-field-encryption.ts
+++ b/src/backend/utils/lazy-field-encryption.ts
@@ -82,19 +82,21 @@ export class LazyFieldEncryption {
legacyFieldName,
);
return decrypted;
- } catch (error) {}
+ } catch {
+ // expected - legacy decryption may fail, try other methods
+ }
}
const sensitiveFields = [
- "totp_secret",
- "totp_backup_codes",
+ "totpSecret",
+ "totpBackupCodes",
"password",
"key",
- "key_password",
- "private_key",
- "public_key",
- "client_secret",
- "oidc_identifier",
+ "keyPassword",
+ "privateKey",
+ "publicKey",
+ "clientSecret",
+ "oidcIdentifier",
];
if (sensitiveFields.includes(fieldName)) {
@@ -174,7 +176,9 @@ export class LazyFieldEncryption {
wasPlaintext: false,
wasLegacyEncryption: true,
};
- } catch (error) {}
+ } catch {
+ // expected - re-encryption may fail, return original
+ }
}
return {
encrypted: fieldValue,
@@ -200,7 +204,8 @@ export class LazyFieldEncryption {
let needsUpdate = false;
for (const fieldName of sensitiveFields) {
- const fieldValue = record[fieldName];
+ const column = this.propertyToColumn(fieldName);
+ const fieldValue = record[column] ?? record[fieldName];
if (fieldValue) {
try {
@@ -213,7 +218,7 @@ export class LazyFieldEncryption {
);
if (wasPlaintext || wasLegacyEncryption) {
- updatedRecord[fieldName] = encrypted;
+ updatedRecord[column] = encrypted;
migratedFields.push(fieldName);
needsUpdate = true;
}
@@ -230,22 +235,48 @@ export class LazyFieldEncryption {
return { updatedRecord, migratedFields, needsUpdate };
}
+ private static readonly PROPERTY_TO_COLUMN: Record = {
+ keyPassword: "key_password",
+ privateKey: "private_key",
+ publicKey: "public_key",
+ sudoPassword: "sudo_password",
+ autostartPassword: "autostart_password",
+ autostartKey: "autostart_key",
+ autostartKeyPassword: "autostart_key_password",
+ totpSecret: "totp_secret",
+ totpBackupCodes: "totp_backup_codes",
+ clientSecret: "client_secret",
+ oidcIdentifier: "oidc_identifier",
+ };
+
static getSensitiveFieldsForTable(tableName: string): string[] {
const sensitiveFieldsMap: Record = {
- ssh_data: ["password", "key", "key_password"],
+ ssh_data: [
+ "password",
+ "key",
+ "keyPassword",
+ "sudoPassword",
+ "autostartPassword",
+ "autostartKey",
+ "autostartKeyPassword",
+ ],
ssh_credentials: [
"password",
"key",
- "key_password",
- "private_key",
- "public_key",
+ "keyPassword",
+ "privateKey",
+ "publicKey",
],
- users: ["totp_secret", "totp_backup_codes"],
+ users: ["totpSecret", "totpBackupCodes"],
};
return sensitiveFieldsMap[tableName] || [];
}
+ static propertyToColumn(propertyName: string): string {
+ return this.PROPERTY_TO_COLUMN[propertyName] || propertyName;
+ }
+
static fieldNeedsMigration(
fieldValue: string,
userKEK: Buffer,
@@ -310,10 +341,11 @@ export class LazyFieldEncryption {
const hostPlaintextFields: string[] = [];
for (const field of sensitiveFields) {
+ const column = this.propertyToColumn(field);
if (
- host[field] &&
+ host[column] &&
this.fieldNeedsMigration(
- host[field] as string,
+ host[column] as string,
userKEK,
host.id.toString(),
field,
@@ -344,10 +376,11 @@ export class LazyFieldEncryption {
const credentialPlaintextFields: string[] = [];
for (const field of sensitiveFields) {
+ const column = this.propertyToColumn(field);
if (
- credential[field] &&
+ credential[column] &&
this.fieldNeedsMigration(
- credential[field] as string,
+ credential[column] as string,
userKEK,
credential.id.toString(),
field,
@@ -373,9 +406,10 @@ export class LazyFieldEncryption {
const userPlaintextFields: string[] = [];
for (const field of sensitiveFields) {
+ const column = this.propertyToColumn(field);
if (
- user[field] &&
- this.fieldNeedsMigration(user[field], userKEK, userId, field)
+ user[column] &&
+ this.fieldNeedsMigration(user[column], userKEK, userId, field)
) {
userPlaintextFields.push(field);
needsMigration = true;
diff --git a/src/backend/utils/permission-manager.ts b/src/backend/utils/permission-manager.ts
index dd05b0fa..ff1cd4ec 100644
--- a/src/backend/utils/permission-manager.ts
+++ b/src/backend/utils/permission-manager.ts
@@ -66,15 +66,14 @@ class PermissionManager {
private async cleanupExpiredAccess(): Promise {
try {
const now = new Date().toISOString();
- const result = await db
+ await db
.delete(hostAccess)
.where(
and(
sql`${hostAccess.expiresAt} IS NOT NULL`,
sql`${hostAccess.expiresAt} <= ${now}`,
),
- )
- .returning({ id: hostAccess.id });
+ );
} catch (error) {
databaseLogger.error("Failed to cleanup expired host access", error, {
operation: "host_access_cleanup_failed",
@@ -280,7 +279,7 @@ class PermissionManager {
async isAdmin(userId: string): Promise {
try {
const user = await db
- .select({ isAdmin: users.is_admin })
+ .select({ isAdmin: users.isAdmin })
.from(users)
.where(eq(users.id, userId))
.limit(1);
@@ -383,7 +382,8 @@ class PermissionManager {
});
}
- (req as any).hostAccessInfo = accessInfo;
+ (req as unknown as { hostAccessInfo: HostAccessInfo }).hostAccessInfo =
+ accessInfo;
next();
};
diff --git a/src/backend/utils/proxy-agent.ts b/src/backend/utils/proxy-agent.ts
index 71a9d6e7..6e73e275 100644
--- a/src/backend/utils/proxy-agent.ts
+++ b/src/backend/utils/proxy-agent.ts
@@ -18,9 +18,7 @@ export function getProxyAgent(targetUrl?: string): Agent | undefined {
const trimmed = entry.trim().toLowerCase();
if (!trimmed) continue;
- const normalized = trimmed
- .replace(/^\*\./, "")
- .replace(/^\./, "");
+ const normalized = trimmed.replace(/^\*\./, "").replace(/^\./, "");
if (hostname === normalized || hostname.endsWith(`.${normalized}`)) {
return undefined;
diff --git a/src/backend/utils/proxy-helper.ts b/src/backend/utils/proxy-helper.ts
new file mode 100644
index 00000000..7c4a3aa5
--- /dev/null
+++ b/src/backend/utils/proxy-helper.ts
@@ -0,0 +1,340 @@
+import { SocksClient } from "socks";
+import type { SocksClientOptions } from "socks";
+import net from "net";
+import { sshLogger } from "./logger.js";
+import type { ProxyNode } from "../../types/index.js";
+
+export interface SOCKS5Config {
+ useSocks5?: boolean;
+ socks5Host?: string;
+ socks5Port?: number;
+ socks5Username?: string;
+ socks5Password?: string;
+ socks5ProxyChain?: ProxyNode[];
+}
+
+export async function createProxyConnection(
+ targetHost: string,
+ targetPort: number,
+ socks5Config: SOCKS5Config,
+): Promise {
+ if (!socks5Config.useSocks5) {
+ return null;
+ }
+
+ if (
+ socks5Config.socks5ProxyChain &&
+ socks5Config.socks5ProxyChain.length > 0
+ ) {
+ return createMixedProxyChainConnection(
+ targetHost,
+ targetPort,
+ socks5Config.socks5ProxyChain,
+ );
+ }
+
+ if (socks5Config.socks5Host) {
+ return createSingleProxyConnection(targetHost, targetPort, socks5Config);
+ }
+
+ return null;
+}
+
+export const createSocks5Connection = createProxyConnection;
+
+async function createSingleProxyConnection(
+ targetHost: string,
+ targetPort: number,
+ socks5Config: SOCKS5Config,
+): Promise {
+ const socksOptions: SocksClientOptions = {
+ proxy: {
+ host: socks5Config.socks5Host!,
+ port: socks5Config.socks5Port || 1080,
+ type: 5,
+ userId: socks5Config.socks5Username,
+ password: socks5Config.socks5Password,
+ },
+ command: "connect",
+ destination: {
+ host: targetHost,
+ port: targetPort,
+ },
+ };
+
+ try {
+ const info = await SocksClient.createConnection(socksOptions);
+
+ return info.socket;
+ } catch (error) {
+ sshLogger.error("SOCKS5 connection failed", error, {
+ operation: "socks5_connect_failed",
+ proxyHost: socks5Config.socks5Host,
+ proxyPort: socks5Config.socks5Port || 1080,
+ targetHost,
+ targetPort,
+ errorMessage: error instanceof Error ? error.message : "Unknown error",
+ });
+ throw error;
+ }
+}
+
+export async function createHttpConnectConnection(
+ targetHost: string,
+ targetPort: number,
+ proxyHost: string,
+ proxyPort: number,
+ username?: string,
+ password?: string,
+ existingSocket?: net.Socket,
+): Promise {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(
+ new Error(
+ `HTTP CONNECT proxy timeout connecting to ${proxyHost}:${proxyPort}`,
+ ),
+ );
+ }, 15000);
+
+ function sendConnect(socket: net.Socket) {
+ let connectReq = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n`;
+ if (username && password) {
+ const credentials = Buffer.from(`${username}:${password}`).toString(
+ "base64",
+ );
+ connectReq += `Proxy-Authorization: Basic ${credentials}\r\n`;
+ }
+ connectReq += "\r\n";
+
+ let responseBuffer = "";
+
+ function onData(chunk: Buffer) {
+ responseBuffer += chunk.toString("utf8");
+ const headerEnd = responseBuffer.indexOf("\r\n\r\n");
+ if (headerEnd === -1) return;
+
+ clearTimeout(timeout);
+ socket.removeListener("data", onData);
+ socket.removeListener("error", onError);
+
+ const statusLine = responseBuffer.slice(
+ 0,
+ responseBuffer.indexOf("\r\n"),
+ );
+ const statusCode = parseInt(statusLine.split(" ")[1], 10);
+
+ if (statusCode === 200) {
+ resolve(socket);
+ } else {
+ socket.destroy();
+ reject(
+ new Error(
+ `HTTP CONNECT proxy returned ${statusCode}: ${statusLine}`,
+ ),
+ );
+ }
+ }
+
+ function onError(err: Error) {
+ clearTimeout(timeout);
+ reject(new Error(`HTTP CONNECT proxy error: ${err.message}`));
+ }
+
+ socket.on("data", onData);
+ socket.on("error", onError);
+ socket.write(connectReq);
+ }
+
+ if (existingSocket) {
+ sendConnect(existingSocket);
+ } else {
+ const socket = net.connect(proxyPort, proxyHost, () => {
+ sendConnect(socket);
+ });
+
+ socket.on("error", (err) => {
+ clearTimeout(timeout);
+ reject(new Error(`HTTP CONNECT proxy TCP error: ${err.message}`));
+ });
+ }
+ });
+}
+
+export async function createMixedProxyChainConnection(
+ targetHost: string,
+ targetPort: number,
+ proxyChain: ProxyNode[],
+): Promise {
+ if (proxyChain.length === 0) {
+ throw new Error("Proxy chain is empty");
+ }
+
+ const hasMixedTypes = proxyChain.some((p) => p.type === "http");
+
+ if (!hasMixedTypes) {
+ return createPureSocksChainConnection(targetHost, targetPort, proxyChain);
+ }
+
+ return createHopByHopConnection(targetHost, targetPort, proxyChain);
+}
+
+async function createPureSocksChainConnection(
+ targetHost: string,
+ targetPort: number,
+ proxyChain: ProxyNode[],
+): Promise {
+ try {
+ const info = await SocksClient.createConnectionChain({
+ proxies: proxyChain.map((p) => ({
+ host: p.host,
+ port: p.port,
+ type: p.type as 4 | 5,
+ userId: p.username,
+ password: p.password,
+ timeout: 10000,
+ })),
+ command: "connect",
+ destination: {
+ host: targetHost,
+ port: targetPort,
+ },
+ });
+ return info.socket;
+ } catch (error) {
+ sshLogger.error("SOCKS proxy chain connection failed", error, {
+ operation: "socks5_chain_connect_failed",
+ chainLength: proxyChain.length,
+ targetHost,
+ targetPort,
+ errorMessage: error instanceof Error ? error.message : "Unknown error",
+ });
+ throw error;
+ }
+}
+
+async function createHopByHopConnection(
+ targetHost: string,
+ targetPort: number,
+ proxyChain: ProxyNode[],
+): Promise {
+ let currentSocket: net.Socket | null = null;
+
+ try {
+ for (let i = 0; i < proxyChain.length; i++) {
+ const node = proxyChain[i];
+ const isLast = i === proxyChain.length - 1;
+ const nextTarget = isLast
+ ? { host: targetHost, port: targetPort }
+ : { host: proxyChain[i + 1].host, port: proxyChain[i + 1].port };
+
+ if (node.type === "http") {
+ currentSocket = await createHttpConnectConnection(
+ nextTarget.host,
+ nextTarget.port,
+ node.host,
+ node.port,
+ node.username,
+ node.password,
+ currentSocket ?? undefined,
+ );
+ } else {
+ const socksOptions: SocksClientOptions = {
+ proxy: {
+ host: node.host,
+ port: node.port,
+ type: node.type as 4 | 5,
+ userId: node.username,
+ password: node.password,
+ },
+ command: "connect",
+ destination: nextTarget,
+ };
+ if (currentSocket) {
+ socksOptions.existing_socket = currentSocket;
+ }
+ const info = await SocksClient.createConnection(socksOptions);
+ currentSocket = info.socket;
+ }
+ }
+
+ if (!currentSocket) {
+ throw new Error("Proxy chain produced no socket");
+ }
+ return currentSocket;
+ } catch (error) {
+ if (currentSocket) {
+ currentSocket.destroy();
+ }
+ sshLogger.error("Mixed proxy chain connection failed", error, {
+ operation: "mixed_chain_connect_failed",
+ chainLength: proxyChain.length,
+ targetHost,
+ targetPort,
+ errorMessage: error instanceof Error ? error.message : "Unknown error",
+ });
+ throw error;
+ }
+}
+
+export async function testProxyConnectivity(options: {
+ singleProxy?: {
+ host: string;
+ port: number;
+ type?: 4 | 5 | "http";
+ username?: string;
+ password?: string;
+ };
+ proxyChain?: ProxyNode[];
+ testTarget?: { host: string; port: number };
+}): Promise<{ success: boolean; latencyMs: number }> {
+ const target = options.testTarget ?? { host: "google.com", port: 443 };
+ const start = Date.now();
+
+ let socket: net.Socket | null = null;
+ try {
+ if (options.proxyChain && options.proxyChain.length > 0) {
+ socket = await createMixedProxyChainConnection(
+ target.host,
+ target.port,
+ options.proxyChain,
+ );
+ } else if (options.singleProxy) {
+ const proxy = options.singleProxy;
+ if (proxy.type === "http") {
+ socket = await createHttpConnectConnection(
+ target.host,
+ target.port,
+ proxy.host,
+ proxy.port,
+ proxy.username,
+ proxy.password,
+ );
+ } else {
+ const socksOptions: SocksClientOptions = {
+ proxy: {
+ host: proxy.host,
+ port: proxy.port,
+ type: (proxy.type as 4 | 5) || 5,
+ userId: proxy.username,
+ password: proxy.password,
+ },
+ command: "connect",
+ destination: target,
+ timeout: 10000,
+ };
+ const info = await SocksClient.createConnection(socksOptions);
+ socket = info.socket;
+ }
+ } else {
+ throw new Error("No proxy configuration provided");
+ }
+
+ const latencyMs = Date.now() - start;
+ socket.destroy();
+ return { success: true, latencyMs };
+ } catch (error) {
+ if (socket) socket.destroy();
+ throw error;
+ }
+}
diff --git a/src/backend/utils/shared-credential-manager.ts b/src/backend/utils/shared-credential-manager.ts
index 86d68362..d58f7eba 100644
--- a/src/backend/utils/shared-credential-manager.ts
+++ b/src/backend/utils/shared-credential-manager.ts
@@ -3,7 +3,6 @@ import {
sharedCredentials,
sshCredentials,
hostAccess,
- users,
userRoles,
sshData,
} from "../database/db/schema.js";
@@ -293,10 +292,9 @@ class SharedCredentialManager {
credentialId: number,
): Promise {
try {
- const result = await db
+ await db
.delete(sharedCredentials)
- .where(eq(sharedCredentials.originalCredentialId, credentialId))
- .returning({ id: sharedCredentials.id });
+ .where(eq(sharedCredentials.originalCredentialId, credentialId));
} catch (error) {
databaseLogger.error("Failed to delete shared credentials", error, {
operation: "delete_shared_credentials",
@@ -364,9 +362,9 @@ class SharedCredentialManager {
key: cred.key
? this.decryptField(cred.key, ownerDEK, credentialId, "key")
: undefined,
- keyPassword: cred.key_password
+ keyPassword: cred.keyPassword
? this.decryptField(
- cred.key_password,
+ cred.keyPassword,
ownerDEK,
credentialId,
"key_password",
@@ -534,7 +532,7 @@ class SharedCredentialManager {
recordId.toString(),
fieldName,
);
- } catch (error) {
+ } catch {
databaseLogger.warn("Field decryption failed, returning as-is", {
operation: "decrypt_field",
fieldName,
diff --git a/src/backend/utils/simple-db-ops.ts b/src/backend/utils/simple-db-ops.ts
index 12fbee1b..ab435503 100644
--- a/src/backend/utils/simple-db-ops.ts
+++ b/src/backend/utils/simple-db-ops.ts
@@ -1,6 +1,7 @@
import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
import { DataCrypto } from "./data-crypto.js";
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
+import type { SQL } from "drizzle-orm";
type TableName =
| "users"
@@ -11,7 +12,7 @@ type TableName =
class SimpleDBOps {
static async insert>(
- table: SQLiteTable,
+ table: SQLiteTable,
tableName: TableName,
data: T,
userId: string,
@@ -109,7 +110,7 @@ class SimpleDBOps {
}
static async update>(
- table: SQLiteTable,
+ table: SQLiteTable,
tableName: TableName,
where: unknown,
data: Partial,
@@ -141,7 +142,7 @@ class SimpleDBOps {
const result = await getDb()
.update(table)
.set(encryptedData)
- .where(where as any)
+ .where(where as SQL | undefined)
.returning();
DatabaseSaveTrigger.triggerSave(`update_${tableName}`);
@@ -157,13 +158,13 @@ class SimpleDBOps {
}
static async delete(
- table: SQLiteTable,
+ table: SQLiteTable,
tableName: TableName,
where: unknown,
): Promise {
const result = await getDb()
.delete(table)
- .where(where as any)
+ .where(where as SQL | undefined)
.returning();
DatabaseSaveTrigger.triggerSave(`delete_${tableName}`);
diff --git a/src/backend/utils/socks5-helper.ts b/src/backend/utils/socks5-helper.ts
index 7647fd6a..4799c2f9 100644
--- a/src/backend/utils/socks5-helper.ts
+++ b/src/backend/utils/socks5-helper.ts
@@ -1,117 +1,8 @@
-import { SocksClient } from "socks";
-import type { SocksClientOptions } from "socks";
-import net from "net";
-import { sshLogger } from "./logger.js";
-import type { ProxyNode } from "../../types/index.js";
-
-export interface SOCKS5Config {
- useSocks5?: boolean;
- socks5Host?: string;
- socks5Port?: number;
- socks5Username?: string;
- socks5Password?: string;
- socks5ProxyChain?: ProxyNode[];
-}
-
-export async function createSocks5Connection(
- targetHost: string,
- targetPort: number,
- socks5Config: SOCKS5Config,
-): Promise {
- if (!socks5Config.useSocks5) {
- return null;
- }
-
- if (
- socks5Config.socks5ProxyChain &&
- socks5Config.socks5ProxyChain.length > 0
- ) {
- return createProxyChainConnection(
- targetHost,
- targetPort,
- socks5Config.socks5ProxyChain,
- );
- }
-
- if (socks5Config.socks5Host) {
- return createSingleProxyConnection(targetHost, targetPort, socks5Config);
- }
-
- return null;
-}
-
-async function createSingleProxyConnection(
- targetHost: string,
- targetPort: number,
- socks5Config: SOCKS5Config,
-): Promise {
- const socksOptions: SocksClientOptions = {
- proxy: {
- host: socks5Config.socks5Host!,
- port: socks5Config.socks5Port || 1080,
- type: 5,
- userId: socks5Config.socks5Username,
- password: socks5Config.socks5Password,
- },
- command: "connect",
- destination: {
- host: targetHost,
- port: targetPort,
- },
- };
-
- try {
- const info = await SocksClient.createConnection(socksOptions);
-
- return info.socket;
- } catch (error) {
- sshLogger.error("SOCKS5 connection failed", error, {
- operation: "socks5_connect_failed",
- proxyHost: socks5Config.socks5Host,
- proxyPort: socks5Config.socks5Port || 1080,
- targetHost,
- targetPort,
- errorMessage: error instanceof Error ? error.message : "Unknown error",
- });
- throw error;
- }
-}
-
-async function createProxyChainConnection(
- targetHost: string,
- targetPort: number,
- proxyChain: ProxyNode[],
-): Promise {
- if (proxyChain.length === 0) {
- throw new Error("Proxy chain is empty");
- }
-
- const chainPath = proxyChain.map((p) => `${p.host}:${p.port}`).join(" → ");
- try {
- const info = await SocksClient.createConnectionChain({
- proxies: proxyChain.map((p) => ({
- host: p.host,
- port: p.port,
- type: p.type,
- userId: p.username,
- password: p.password,
- timeout: 10000,
- })),
- command: "connect",
- destination: {
- host: targetHost,
- port: targetPort,
- },
- });
- return info.socket;
- } catch (error) {
- sshLogger.error("SOCKS proxy chain connection failed", error, {
- operation: "socks5_chain_connect_failed",
- chainLength: proxyChain.length,
- targetHost,
- targetPort,
- errorMessage: error instanceof Error ? error.message : "Unknown error",
- });
- throw error;
- }
-}
+export {
+ createSocks5Connection,
+ createProxyConnection,
+ createHttpConnectConnection,
+ createMixedProxyChainConnection,
+ testProxyConnectivity,
+} from "./proxy-helper.js";
+export type { SOCKS5Config } from "./proxy-helper.js";
diff --git a/src/backend/utils/ssh-key-utils.ts b/src/backend/utils/ssh-key-utils.ts
index 8685ec99..a4c9efc4 100644
--- a/src/backend/utils/ssh-key-utils.ts
+++ b/src/backend/utils/ssh-key-utils.ts
@@ -1,5 +1,4 @@
import ssh2Pkg from "ssh2";
-import { sshLogger } from "./logger.js";
const ssh2Utils = ssh2Pkg.utils;
function detectKeyTypeFromContent(keyContent: string): string {
@@ -85,7 +84,9 @@ function detectKeyTypeFromContent(keyContent: string): string {
} else if (decodedString.includes("1.3.101.112")) {
return "ssh-ed25519";
}
- } catch (error) {}
+ } catch {
+ // expected - base64 decode may fail for some key formats
+ }
if (content.length < 800) {
return "ssh-ed25519";
@@ -141,7 +142,9 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
} else if (decodedString.includes("1.3.101.112")) {
return "ssh-ed25519";
}
- } catch (error) {}
+ } catch {
+ // expected - base64 decode may fail for some key formats
+ }
if (content.length < 400) {
return "ssh-ed25519";
@@ -243,7 +246,9 @@ export function parseSSHKey(
useSSH2 = true;
}
- } catch (error) {}
+ } catch {
+ // expected - ssh2 key parsing may fail
+ }
}
if (!useSSH2) {
@@ -269,7 +274,9 @@ export function parseSSHKey(
success: true,
};
}
- } catch (error) {}
+ } catch {
+ // expected - fallback key type detection may fail
+ }
return {
privateKey: privateKeyData,
diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts
index 34f60cee..1d43238e 100644
--- a/src/backend/utils/system-crypto.ts
+++ b/src/backend/utils/system-crypto.ts
@@ -52,7 +52,9 @@ class SystemCrypto {
},
);
}
- } catch (fileError) {}
+ } catch {
+ // expected - env file may not exist
+ }
await this.generateAndGuideUser();
} catch (error) {
@@ -78,12 +80,6 @@ class SystemCrypto {
const envKey = process.env.DATABASE_KEY;
if (envKey && envKey.length >= 64) {
this.databaseKey = Buffer.from(envKey, "hex");
- const keyFingerprint = crypto
- .createHash("sha256")
- .update(this.databaseKey)
- .digest("hex")
- .substring(0, 16);
-
return;
}
@@ -93,17 +89,13 @@ class SystemCrypto {
if (dbKeyMatch && dbKeyMatch[1] && dbKeyMatch[1].length >= 64) {
this.databaseKey = Buffer.from(dbKeyMatch[1], "hex");
process.env.DATABASE_KEY = dbKeyMatch[1];
-
- const keyFingerprint = crypto
- .createHash("sha256")
- .update(this.databaseKey)
- .digest("hex")
- .substring(0, 16);
-
return;
} else {
+ // expected - key not found or invalid length in env file
}
- } catch (fileError) {}
+ } catch {
+ // expected - env file may not exist
+ }
await this.generateAndGuideDatabaseKey();
} catch (error) {
@@ -141,7 +133,9 @@ class SystemCrypto {
process.env.INTERNAL_AUTH_TOKEN = tokenMatch[1];
return;
}
- } catch (error) {}
+ } catch {
+ // expected - env file may not exist
+ }
await this.generateAndGuideInternalAuthToken();
} catch (error) {
@@ -178,7 +172,9 @@ class SystemCrypto {
process.env.CREDENTIAL_SHARING_KEY = csKeyMatch[1];
return;
}
- } catch (fileError) {}
+ } catch {
+ // expected - env file may not exist
+ }
await this.generateAndGuideCredentialSharingKey();
} catch (error) {
diff --git a/src/backend/utils/user-agent-parser.ts b/src/backend/utils/user-agent-parser.ts
index fb1a3563..91e43c0d 100644
--- a/src/backend/utils/user-agent-parser.ts
+++ b/src/backend/utils/user-agent-parser.ts
@@ -1,4 +1,5 @@
import type { Request } from "express";
+import crypto from "crypto";
export type DeviceType = "web" | "desktop" | "mobile";
@@ -237,3 +238,22 @@ function parseMacVersion(userAgent: string): string {
}
return "macOS";
}
+
+/**
+ * Generate a stable device fingerprint based on device type, browser, and OS.
+ * Ignores minor version numbers to handle browser auto-updates.
+ */
+export function generateDeviceFingerprint(deviceInfo: DeviceInfo): string {
+ let fingerprintString = "";
+
+ if (deviceInfo.type === "desktop") {
+ fingerprintString = `${deviceInfo.type}|${deviceInfo.browser}|${deviceInfo.os}`;
+ } else if (deviceInfo.type === "mobile") {
+ fingerprintString = `${deviceInfo.type}|${deviceInfo.browser}|${deviceInfo.os}`;
+ } else {
+ const browserMajor = deviceInfo.version.split(".")[0];
+ fingerprintString = `${deviceInfo.type}|${deviceInfo.browser} ${browserMajor}|${deviceInfo.os}`;
+ }
+
+ return crypto.createHash("sha256").update(fingerprintString).digest("hex");
+}
diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts
index b536bcf7..063cfbe1 100644
--- a/src/backend/utils/user-crypto.ts
+++ b/src/backend/utils/user-crypto.ts
@@ -303,8 +303,9 @@ class UserCrypto {
await this.storeKEKSalt(userId, newKekSalt);
await this.storeEncryptedDEK(userId, newEncryptedDEK);
- const { saveMemoryDatabaseToFile } =
- await import("../database/db/index.js");
+ const { saveMemoryDatabaseToFile } = await import(
+ "../database/db/index.js"
+ );
await saveMemoryDatabaseToFile();
oldKEK.fill(0);
@@ -340,8 +341,9 @@ class UserCrypto {
await this.storeKEKSalt(userId, newKekSalt);
await this.storeEncryptedDEK(userId, newEncryptedDEK);
- const { saveMemoryDatabaseToFile } =
- await import("../database/db/index.js");
+ const { saveMemoryDatabaseToFile } = await import(
+ "../database/db/index.js"
+ );
await saveMemoryDatabaseToFile();
newKEK.fill(0);
@@ -416,8 +418,9 @@ class UserCrypto {
},
);
- const { saveMemoryDatabaseToFile } =
- await import("../database/db/index.js");
+ const { saveMemoryDatabaseToFile } = await import(
+ "../database/db/index.js"
+ );
await saveMemoryDatabaseToFile();
} catch (error) {
databaseLogger.error("Failed to convert to OIDC encryption", error, {
diff --git a/src/backend/utils/user-data-import.ts b/src/backend/utils/user-data-import.ts
index 956f7ce6..31783e35 100644
--- a/src/backend/utils/user-data-import.ts
+++ b/src/backend/utils/user-data-import.ts
@@ -194,7 +194,7 @@ class UserDataImport {
continue;
}
- const newHostData: any = {
+ const newHostData: Record = {
...host,
userId: targetUserId,
updatedAt: new Date().toISOString(),
@@ -204,7 +204,7 @@ class UserDataImport {
newHostData.createdAt = new Date().toISOString();
}
- let processedHostData: any = newHostData;
+ let processedHostData: Record = newHostData;
if (options.userDataKey) {
processedHostData = DataCrypto.encryptRecord(
"ssh_data",
@@ -275,7 +275,7 @@ class UserDataImport {
continue;
}
- const newCredentialData: any = {
+ const newCredentialData: Record = {
...credential,
userId: targetUserId,
updatedAt: new Date().toISOString(),
@@ -287,7 +287,8 @@ class UserDataImport {
newCredentialData.createdAt = new Date().toISOString();
}
- let processedCredentialData: any = newCredentialData;
+ let processedCredentialData: Record =
+ newCredentialData;
if (options.userDataKey) {
processedCredentialData = DataCrypto.encryptRecord(
"ssh_credentials",
diff --git a/src/constants/terminal-themes.ts b/src/constants/terminal-themes.ts
index d9736ea9..e7235c9f 100644
--- a/src/constants/terminal-themes.ts
+++ b/src/constants/terminal-themes.ts
@@ -762,6 +762,8 @@ export const DEFAULT_TERMINAL_CONFIG = {
autoMosh: false,
moshCommand: "mosh-server new -s -l LANG=en_US.UTF-8",
sudoPasswordAutoFill: false,
+ keepaliveInterval: undefined as number | undefined,
+ keepaliveCountMax: undefined as number | undefined,
};
export type TerminalConfigType = typeof DEFAULT_TERMINAL_CONFIG;
diff --git a/src/lib/clipboard-provider.ts b/src/lib/clipboard-provider.ts
new file mode 100644
index 00000000..9254fbe2
--- /dev/null
+++ b/src/lib/clipboard-provider.ts
@@ -0,0 +1,47 @@
+import type {
+ IClipboardProvider,
+ ClipboardSelectionType,
+} from "@xterm/addon-clipboard";
+
+export class RobustClipboardProvider implements IClipboardProvider {
+ private pendingWrite: string | null = null;
+ private readonly focusHandler: () => void;
+
+ constructor() {
+ this.focusHandler = () => {
+ if (this.pendingWrite !== null) {
+ const text = this.pendingWrite;
+ this.pendingWrite = null;
+ navigator.clipboard.writeText(text).catch(() => {
+ this.pendingWrite = text;
+ });
+ }
+ };
+ window.addEventListener("focus", this.focusHandler);
+ }
+
+ dispose(): void {
+ window.removeEventListener("focus", this.focusHandler);
+ this.pendingWrite = null;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ readText(selection: ClipboardSelectionType): string {
+ return "";
+ }
+
+ async writeText(
+ selection: ClipboardSelectionType,
+ text: string,
+ ): Promise {
+ try {
+ if (window.electronClipboard) {
+ window.electronClipboard.writeText(text);
+ return;
+ }
+ await navigator.clipboard.writeText(text);
+ } catch {
+ this.pendingWrite = text;
+ }
+ }
+}
diff --git a/src/lib/terminal-syntax-highlighter.ts b/src/lib/terminal-syntax-highlighter.ts
index b9aef563..b9195670 100644
--- a/src/lib/terminal-syntax-highlighter.ts
+++ b/src/lib/terminal-syntax-highlighter.ts
@@ -113,14 +113,14 @@ const PATTERNS: HighlightPattern[] = [
function hasExistingAnsiCodes(text: string): boolean {
const ansiCount = (
text.match(
- /\x1b[\[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRZcf-nq-uy=><~]/g,
+ /\x1b[[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRZcf-nq-uy=><~]/g,
) || []
).length;
return ansiCount > MAX_ANSI_CODES;
}
function hasIncompleteAnsiSequence(text: string): boolean {
- return /\x1b(?:\[(?:[0-9;]*)?)?$/.test(text);
+ return /\x1b(?:\[(?:[0-9;?>=!]*)?)?$/.test(text);
}
interface TextSegment {
@@ -130,7 +130,7 @@ interface TextSegment {
function parseAnsiSegments(text: string): TextSegment[] {
const segments: TextSegment[] = [];
- const ansiRegex = /\x1b(?:[@-Z\\-_]|\[[0-9;]*[@-~])/g;
+ const ansiRegex = /\x1b(?:[@-Z\\-_]|\[[0-9;?>=!]*[@-~])/g;
let lastIndex = 0;
let match;
diff --git a/src/locales/en.json b/src/locales/en.json
index 856d8d4a..b5058ffc 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -331,6 +331,8 @@
"twoSplit": "2-Way",
"threeSplit": "3-Way",
"fourSplit": "4-Way",
+ "fiveSplit": "5-Way",
+ "sixSplit": "6-Way",
"availableTabs": "Available Tabs",
"dragTabsHint": "Drag tabs to the layout cells below to assign them",
"layout": "Split Screen Layout",
@@ -372,7 +374,11 @@
"warning": "Warning",
"notValidatedWarning": "URL not validated - ensure it's correct",
"changeServer": "Change Server",
- "mustIncludeProtocol": "Server URL must start with http:// or https://"
+ "mustIncludeProtocol": "Server URL must start with http:// or https://",
+ "useEmbedded": "Use Local Server",
+ "embeddedDesc": "Run Termix with the built-in local server (no remote server needed)",
+ "embeddedConnecting": "Connecting to local server...",
+ "embeddedNotReady": "Local server is not ready yet. Please wait a moment and try again."
},
"versionCheck": {
"error": "Version Check Error",
@@ -471,6 +477,7 @@
"confirm": "Confirm",
"yes": "Yes",
"no": "No",
+ "or": "OR",
"ok": "OK",
"enabled": "Enabled",
"disabled": "Disabled",
@@ -601,6 +608,8 @@
"userDeletedSuccessfully": "User {{username}} deleted successfully",
"failedToDeleteUser": "Failed to delete user",
"overrideUserInfoUrl": "Override User Info URL (not required)",
+ "allowedUsers": "Allowed Users",
+ "allowedUsersDescription": "Comma-separated list of allowed email patterns. Use exact emails (user@example.com), domain suffixes (@example.com), or leave empty to allow all users.",
"failedToFetchSessions": "Failed to fetch sessions",
"sessionRevokedSuccessfully": "Session revoked successfully",
"failedToRevokeSession": "Failed to revoke session",
@@ -794,6 +803,13 @@
"oidcRequiredWarning": "CRITICAL: Password login is disabled. If you reset or misconfigure OIDC, you will lose all access to Termix and brick your instance. Only proceed if you are absolutely certain.",
"confirmDisableOIDCWarning": "WARNING: You are about to disable OIDC while password login is also disabled. This will brick your Termix instance and you will lose all access. Are you absolutely sure you want to proceed?",
"failedToUpdatePasswordLoginStatus": "Failed to update password login status",
+ "monitoringDefaults": "Monitoring Defaults",
+ "monitoringDefaultsDesc": "Global default intervals for status checks and metrics collection. Per-host settings override these values.",
+ "globalStatusCheckInterval": "Default Status Check Interval",
+ "globalMetricsInterval": "Default Metrics Collection Interval",
+ "globalSettingsSaved": "Global monitoring settings saved",
+ "failedToSaveGlobalSettings": "Failed to save global monitoring settings",
+ "failedToLoadGlobalSettings": "Failed to load global monitoring settings",
"sessionManagement": "Session Management",
"loadingSessions": "Loading sessions...",
"noActiveSessions": "No active sessions found.",
@@ -849,6 +865,11 @@
"noHostsInJson": "No hosts found in JSON file",
"maxHostsAllowed": "Maximum 100 hosts allowed per import",
"importCompleted": "Import completed: {{success}} successful, {{failed}} failed",
+ "importCreated": "created",
+ "importUpdated": "updated",
+ "importFailedCount": "failed",
+ "importSkipExisting": "Import (skip existing)",
+ "importOverwriteExisting": "Import (overwrite existing)",
"importFailed": "Import failed",
"importError": "Import error",
"failedToImportJson": "Failed to import JSON file",
@@ -1027,6 +1048,9 @@
"statusCheckEnabledDesc": "Check if the server is online or offline",
"statusCheckInterval": "Status Check Interval",
"statusCheckIntervalDesc": "How often to check if host is online (5s - 1h)",
+ "useGlobalStatusInterval": "Use global default",
+ "useGlobalMetricsInterval": "Use global default",
+ "usingGlobalDefault": "Using global default ({{value}}s)",
"metricsEnabled": "Enable Metrics Monitoring",
"metricsEnabledDesc": "Collect CPU, RAM, disk, and other system statistics",
"metricsInterval": "Metrics Collection Interval",
@@ -1184,8 +1208,20 @@
"sudoPasswordAutoFillDesc": "Automatically offer to insert SSH password when sudo prompts for password",
"sudoPassword": "Sudo Password",
"sudoPasswordDesc": "Optional password for sudo commands (useful with key authentication)",
+ "keepaliveSettings": "SSH Keepalive",
+ "keepaliveSettingsDesc": "Configure SSH-level keepalive behavior. Set interval to 0 to disable SSH keepalives (useful for MikroTik and other devices that ignore keepalive requests).",
+ "keepaliveInterval": "Keepalive Interval (ms)",
+ "keepaliveIntervalDesc": "How often to send SSH keepalive pings. Default: 30000 (30s). Set to 0 to disable.",
+ "keepaliveCountMax": "Keepalive Max Failures",
+ "keepaliveCountMaxDesc": "Disconnect after this many unanswered keepalives. Default: 3.",
"socks4": "SOCKS4",
"socks5": "SOCKS5",
+ "httpConnect": "HTTP CONNECT",
+ "testProxy": "Test Connection",
+ "testingProxy": "Testing...",
+ "proxyTestSuccess": "Proxy connection successful ({{latency}}ms)",
+ "proxyTestFailed": "Proxy test failed: {{error}}",
+ "connectionPath": "Connection Path",
"executeSnippetOnConnect": "Execute a snippet when the terminal connects",
"autoMosh": "Auto-MOSH",
"autoMoshDesc": "Automatically run MOSH command on connect",
@@ -1296,7 +1332,35 @@
"showTimestamps": "Show Timestamps",
"autoRefresh": "Auto Refresh",
"filterLogsPlaceholder": "Filter logs...",
- "noLogsAvailable": "No logs available"
+ "noLogsAvailable": "No logs available",
+ "selectMode": "Select",
+ "exitSelectMode": "Cancel",
+ "selectedCount": "{{count}} selected",
+ "bulkMonitoring": "Monitoring",
+ "bulkFeatures": "Features",
+ "bulkMoveFolder": "Move to Folder",
+ "bulkPin": "Pin",
+ "bulkUnpin": "Unpin",
+ "enableAllFeatures": "Enable All Features",
+ "disableAllFeatures": "Disable All Features",
+ "enableStatusCheck": "Enable Status Check",
+ "disableStatusCheck": "Disable Status Check",
+ "enableMetrics": "Enable Metrics",
+ "disableMetrics": "Disable Metrics",
+ "bulkEnableTerminal": "Enable Terminal",
+ "bulkDisableTerminal": "Disable Terminal",
+ "bulkEnableTunnel": "Enable Tunnel",
+ "bulkDisableTunnel": "Disable Tunnel",
+ "bulkEnableFileManager": "Enable File Manager",
+ "bulkDisableFileManager": "Disable File Manager",
+ "bulkEnableDocker": "Enable Docker",
+ "bulkDisableDocker": "Disable Docker",
+ "bulkUpdateSuccess": "Updated {{count}} host(s) successfully",
+ "bulkUpdateFailed": "Bulk update failed",
+ "selectAll": "Select all",
+ "deselectAll": "Deselect all",
+ "useGlobalStatusDefault": "Use Global Default (Status)",
+ "useGlobalMetricsDefault": "Use Global Default (Metrics)"
},
"terminal": {
"title": "Split Screen",
@@ -1304,6 +1368,8 @@
"twoSplit": "2-Split",
"threeSplit": "3-Split",
"fourSplit": "4-Split",
+ "fiveSplit": "5-Split",
+ "sixSplit": "6-Split",
"availableTabs": "Available Tabs",
"dragTabsHint": "Drag tabs into the grid below to position them",
"layout": "Layout",
@@ -1339,6 +1405,8 @@
"connectionClosed": "Connection closed",
"connectionError": "Connection error: {{message}}",
"connected": "Connected",
+ "clipboardWriteFailed": "Failed to copy to clipboard. Make sure the page is served over HTTPS or localhost.",
+ "clipboardReadFailed": "Failed to read from clipboard. Make sure clipboard permissions are granted.",
"sshConnected": "SSH connection established",
"authError": "Authentication failed: {{message}}",
"unknownError": "Unknown error occurred",
@@ -1393,7 +1461,9 @@
"totpTimeout": "TOTP verification timeout. Please reconnect.",
"passwordTimeout": "Password verification timeout. Please reconnect.",
"connectionRejected": "Connection rejected by server. Please check your authentication and network configuration.",
- "hostKeyRejected": "SSH host key verification rejected. Connection cancelled."
+ "hostKeyRejected": "SSH host key verification rejected. Connection cancelled.",
+ "sessionTakenOver": "Session was opened in another tab. Reconnecting...",
+ "sessionAttachTimeout": "Session attachment timed out. Creating new connection..."
},
"fileManager": {
"title": "File Manager",
@@ -1718,7 +1788,13 @@
"write": "Write",
"execute": "Execute",
"permissionsChangedSuccessfully": "Permissions changed successfully",
- "failedToChangePermissions": "Failed to change permissions"
+ "failedToChangePermissions": "Failed to change permissions",
+ "name": "Name",
+ "sortByName": "Name",
+ "sortByDate": "Date Modified",
+ "sortBySize": "Size",
+ "ascending": "Ascending",
+ "descending": "Descending"
},
"tunnel": {
"noTunnelsConfigured": "No Tunnels Configured",
@@ -1897,7 +1973,7 @@
"loginButton": "Login",
"registerButton": "Register",
"forgotPassword": "Forgot Password?",
- "rememberMe": "Remember Me",
+ "rememberMe": "Remember Device for 30 Days (includes TOTP)",
"noAccount": "Don't have an account?",
"hasAccount": "Already have an account?",
"loginSuccess": "Login successful",
@@ -2062,6 +2138,7 @@
"processing": "Processing...",
"pleaseWait": "Please wait...",
"registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator.",
+ "userNotAllowed": "Your account is not authorized to register. Please contact an administrator.",
"databaseConnected": "Database connected successfully",
"databaseConnectionFailed": "Failed to connect to the database server",
"checkServerConnection": "Please check your server connection and try again",
@@ -2117,7 +2194,9 @@
"appearanceDesc": "Select the color theme for the application",
"terminalSyntaxHighlightingDesc": "Automatically highlight commands, paths, IPs, and log levels in terminal output",
"enableCommandPaletteShortcut": "Enable Command Palette Shortcut",
- "enableCommandPaletteShortcutDesc": "Double-tap left Shift to open the Command Palette for quick access to hosts"
+ "enableCommandPaletteShortcutDesc": "Double-tap left Shift to open the Command Palette for quick access to hosts",
+ "enableTerminalSessionPersistence": "Persistent Terminal Sessions",
+ "enableTerminalSessionPersistenceDesc": "Maintain SSH connections when switching tabs or closing the browser (may be unstable)"
},
"user": {
"failedToLoadVersionInfo": "Failed to load version information"
@@ -2153,6 +2232,7 @@
"usernameField": "name",
"scopes": "openid email profile",
"userinfoUrl": "https://your-provider.com/application/o/userinfo/",
+ "allowedUsers": "user@example.com, @company.com",
"enterUsername": "Enter username to make admin",
"searchHosts": "Search hosts by name, username, IP, folder, tags...",
"enterPassword": "Enter your password",
diff --git a/src/locales/translated/af_ZA.json b/src/locales/translated/af_ZA.json
index 818763b0..b274ae73 100644
--- a/src/locales/translated/af_ZA.json
+++ b/src/locales/translated/af_ZA.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Geloofsbriewe",
"credentialsViewer": "Geloofsbriewe-kyker",
"manageYourSSHCredentials": "Bestuur jou SSH-bewyse veilig",
"addCredential": "Voeg geloofsbriewe by",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Dit sal die publieke sleutel veilig by die teikengasheer se ~/.ssh/authorized_keys-lêer voeg sonder om bestaande sleutels te oorskryf. Die bewerking is omkeerbaar.",
"chooseHostToDeploy": "Kies 'n gasheer om na te ontplooi...",
"deploying": "Implementeer tans...",
- "copyDeployCommand": "Kopieer Ontplooi-opdrag",
- "copyDeployCommandDescription": "Kopieer 'n dopopdrag om die publieke sleutel handmatig by 'n gasheer se authorized_keys-lêer te voeg. Nuttig wanneer wagwoordverifikasie gedeaktiveer is.",
- "deployCommandCopied": "Ontplooi-opdrag gekopieer na knipbord",
- "manualDeployInfo": "Plak hierdie opdrag in die teikengasheer se terminaal (bv. Proxmox-konsole, IPMI of fisiese toegang) om die SSH-sleutel by te voeg.",
"name": "Naam",
"noHostsAvailable": "Geen gashere beskikbaar nie",
"noHostsMatchSearch": "Geen gashere stem ooreen met jou soektog nie",
@@ -303,9 +298,7 @@
"createFolder": "Skep vouer",
"editFolder": "Wysig vouer",
"editFolderDescription": "Pas jou brokkie-lêergids aan",
- "createFolderDescription": "Organiseer jou brokkies in dopgehou",
- "confirmExecution": "Voer \"{{name}}\" uit?",
- "confirmExecutionDesc": "Is jy seker jy wil hierdie fragment uitvoer?"
+ "createFolderDescription": "Organiseer jou brokkies in dopgehou"
},
"commandHistory": {
"title": "Geskiedenis",
@@ -409,7 +402,6 @@
"required": "Vereis",
"optional": "Opsioneel",
"connect": "Verbind",
- "copied": "Gekopieer",
"connecting": "Verbind...",
"creating": "Skep...",
"clear": "Duidelik",
@@ -502,7 +494,6 @@
"checking": "Kontroleer...",
"checkingDatabase": "Kontroleer databasisverbinding...",
"checkingAuthentication": "Kontroleer tans verifikasie...",
- "backendReconnected": "Bedienerverbinding herstel",
"actions": "Aksies",
"remove": "Verwyder",
"revoke": "Herroep",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Gasheerbestuurder",
- "hosts": "Gashere",
"sshHosts": "SSH-gashere",
"noHosts": "Geen SSH-gashere nie",
"noHostsMessage": "Jy het nog geen SSH-gashere bygevoeg nie. Klik \"Voeg gasheer by\" om te begin.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Kon nie JSON-lêer invoer nie",
"connectionDetails": "Verbindingsbesonderhede",
"organization": "Organisasie",
- "ipAddress": "IP-adres of gasheernaam",
+ "ipAddress": "IP-adres",
"port": "Hawe",
"name": "Naam",
"username": "Gebruikersnaam",
@@ -925,8 +915,6 @@
"key": "Sleutel",
"credential": "Geloofsbrief",
"none": "Geen",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Vra jou vir OPKSSH webmagtiging met elke verbinding. Jou magtigingstoken sal 24 uur lank geldig wees totdat dit hernu moet word.",
"selectCredential": "Kies geloofsbriewe",
"selectCredentialPlaceholder": "Kies 'n geloofsbrief...",
"credentialRequired": "Geloofsbriewe word vereis wanneer geloofsbriewe-verifikasie gebruik word",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Sleutelbord-Interaktiewe Verifikasie",
"noneAuthDescription": "Hierdie verifikasiemetode sal sleutelbord-interaktiewe verifikasie gebruik wanneer daar aan die SSH-bediener gekoppel word.",
"noneAuthDetails": "Sleutelbord-interaktiewe verifikasie laat die bediener toe om jou vir geloofsbriewe te vra tydens verbinding. Dit is nuttig vir bedieners wat multifaktor-verifikasie vereis of as jy nie geloofsbriewe plaaslik wil stoor nie.",
- "opksshAuthTitle": "OPKSSH-verifikasie",
"forceKeyboardInteractive": "Forseer sleutelbord-interaktief",
"forceKeyboardInteractiveDesc": "Dwing die gebruik van sleutelbord-interaktiewe verifikasie af. Dit word soms vereis vir bedieners wat tweefaktor-verifikasie (TOTP/2FA) gebruik.",
"overrideCredentialUsername": "Oorskryf geloofsbriewe gebruikersnaam",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Maak oop in blaaier",
"warpgateContinue": "Ek het verifikasie voltooi",
"warpgateTimeout": "Warpgate-verifikasietydverstryking. Koppel asseblief weer.",
- "opksshAuthRequired": "OPKSSH-verifikasie vereis",
- "opksshAuthDescription": "Voltooi verifikasie in jou blaaier om voort te gaan. Hierdie sessie sal vir 24 uur geldig bly.",
- "opksshAuthUrl": "Verifikasie-URL",
- "opksshOpenBrowser": "Maak blaaier oop om te verifieer",
- "opksshWaitingForAuth": "Wag vir verifikasie in blaaier...",
- "opksshAuthenticating": "Verwerk verifikasie...",
- "opksshTimeout": "Verifikasie het verstryk. Probeer asseblief weer.",
- "opksshAuthFailed": "Verifikasie het misluk. Kontroleer asseblief jou geloofsbriewe en probeer weer.",
- "opksshConfigMissing": "OPKSSH-konfigurasie nie gevind nie. Skep asseblief ~/.opk/config.yml met jou OIDC-verskafferinstellings. Sien dokumentasie: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Wagwoord invoeg?",
"sudoPasswordPopupHint": "Druk Enter om in te voeg, Esc om te verwerp",
"sudoPasswordPopupConfirm": "Invoeg",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Probeer outomaties {{method}} verifikasie...",
"totpTimeout": "TOTP-verifikasie het uitgeloop. Koppel asseblief weer aan.",
"passwordTimeout": "Wagwoordverifikasie het uitgeloop. Koppel asseblief weer aan.",
- "connectionRejected": "Verbinding deur bediener verwerp. Kontroleer asseblief u verifikasie en netwerkkonfigurasie.",
- "hostKeyRejected": "SSH-gasheersleutelverifikasie verwerp. Verbinding gekanselleer."
+ "connectionRejected": "Verbinding deur bediener verwerp. Kontroleer asseblief u verifikasie en netwerkkonfigurasie."
},
"fileManager": {
"title": "Lêerbestuurder",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Wagwoordherstel suksesvol",
"passwordResetSuccessDesc": "Jou wagwoord is suksesvol herstel. Jy kan nou met jou nuwe wagwoord aanmeld."
},
- "hostKey": {
- "verifyNewHost": "Verifieer SSH-gasheersleutel",
- "keyChangedWarning": "SSH-gasheersleutel verander",
- "firstConnectionTitle": "Eerste keer dat jy met hierdie gasheer verbind",
- "firstConnectionDescription": "Die egtheid van hierdie gasheer kan nie vasgestel word nie. Verifieer dat die vingerafdruk ooreenstem met wat jy verwag.",
- "keyChangedDescription": "Die gasheersleutel vir hierdie bediener het verander sedert jou laaste verbinding. Dit kan op 'n sekuriteitsprobleem dui.",
- "previousKey": "Vorige Sleutel",
- "newFingerprint": "Nuwe vingerafdruk",
- "fingerprint": "Vingerafdruk",
- "verifyInstructions": "As jy hierdie gasheer vertrou, klik Aanvaar om voort te gaan en hierdie vingerafdruk vir toekomstige verbindings te stoor.",
- "securityWarning": "Sekuriteitswaarskuwing",
- "acceptAndContinue": "Aanvaar en Gaan voort",
- "acceptNewKey": "Aanvaar Nuwe Sleutel & Gaan Voort"
- },
"errors": {
"notFound": "Bladsy nie gevind nie",
"unauthorized": "Ongemagtigde toegang",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Terminaal",
"hostSidebarSettings": "Gasheer en sybalk",
"snippetsSettings": "Brokkies",
- "confirmSnippetExecution": "Bevestig Uitvoering van Brokkie",
- "confirmSnippetExecutionDesc": "Wys bevestigingsdialoog voordat brokkies uitgevoer word",
"updateSettings": "Opdaterings",
"disableUpdateCheck": "Deaktiveer Opdateringskontrole",
"disableUpdateCheckDesc": "Hou op om vir nuwe weergawes te kyk tydens opstart en op die dashboard. Verminder netwerkversoeke.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 of example.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/ar_SA.json b/src/locales/translated/ar_SA.json
index f506847a..f3858d8c 100644
--- a/src/locales/translated/ar_SA.json
+++ b/src/locales/translated/ar_SA.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "وثائق التفويض",
"credentialsViewer": "عارض وثائق التفويض",
"manageYourSSHCredentials": "إدارة بيانات اعتماد SSH الخاصة بك بشكل آمن",
"addCredential": "إضافة بيانات اعتماد",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "سيؤدي هذا بأمان إلى إضافة المفتاح العمومي إلى ملف المضيف الهدف ~/.ssh/authorized_keyys دون الكتابة فوق المفاتيح الموجودة. العملية قابلة للرجوع عنها.",
"chooseHostToDeploy": "اختر مضيف ليقوم بنشره إلى...",
"deploying": "النشر...",
- "copyDeployCommand": "نسخ أمر النشر",
- "copyDeployCommandDescription": "نسخ أمر Shell لإضافة المفتاح العام يدوياً إلى ملف مفتاح_مفاتيح مضيف مضيف. مفيد عندما يتم تعطيل مصادقة كلمة المرور.",
- "deployCommandCopied": "نشر أمر نسخ إلى الحافظة",
- "manualDeployInfo": "قم بلصق هذا الأمر في محطة المضيف المستهدفة (مثل وحدة تحكم Proxmox أو IPMI، أو الوصول المادي) لإضافة مفتاح SSH.",
"name": "الاسم",
"noHostsAvailable": "لا يوجد مضيفين متاحين",
"noHostsMatchSearch": "لا يوجد مضيف يطابق بحثك",
@@ -303,9 +298,7 @@
"createFolder": "إنشاء مجلد",
"editFolder": "تعديل المجلد",
"editFolderDescription": "تخصيص مجلد كتلة الكود",
- "createFolderDescription": "تنظيم كتل الكتل في مجلدات",
- "confirmExecution": "تنفيذ \"{{name}}\"؟",
- "confirmExecutionDesc": "هل أنت متأكد من أنك تريد تنفيذ كتلة الكود هذه؟"
+ "createFolderDescription": "تنظيم كتل الكتل في مجلدات"
},
"commandHistory": {
"title": "التاريخ",
@@ -409,7 +402,6 @@
"required": "مطلوب",
"optional": "اختياري",
"connect": "الاتصال",
- "copied": "منسوخ",
"connecting": "جاري الاتصال...",
"creating": "إنشاء...",
"clear": "مسح",
@@ -502,7 +494,6 @@
"checking": "يتحقق...",
"checkingDatabase": "التحقق من اتصال قاعدة البيانات...",
"checkingAuthentication": "التحقق من المصادقة...",
- "backendReconnected": "تم استعادة اتصال الخادم",
"actions": "الإجراءات",
"remove": "إزالة",
"revoke": "Revoke",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "مدير المضيف",
- "hosts": "المضيفون",
"sshHosts": "مضيفي SSH",
"noHosts": "لا يوجد مضيفين SSH",
"noHostsMessage": "لم تقم بإضافة أي مضيف SSH حتى الآن. انقر فوق \"إضافة مضيف\" للبدء.",
@@ -846,7 +836,7 @@
"failedToImportJson": "فشل استيراد ملف JSON",
"connectionDetails": "تفاصيل الاتصال",
"organization": "المنظمة",
- "ipAddress": "عنوان IP أو اسم المضيف",
+ "ipAddress": "عنوان IP",
"port": "المنفذ",
"name": "الاسم",
"username": "اسم المستخدم",
@@ -925,8 +915,6 @@
"key": "المفتاح",
"credential": "بيانات",
"none": "لا",
- "opkssh": "فتح",
- "opksshAuthDescription": "يمنحك الحصول على مصادقة ويب OPKSSH عند كل اتصال. رمز المصادقة الخاص بك سوف يستمر لمدة 24 ساعة حتى يجب تجديده.",
"selectCredential": "حدد بيانات الاعتماد",
"selectCredentialPlaceholder": "اختر بيانات الاعتماد...",
"credentialRequired": "الاعتماد مطلوب عند استخدام مصادقة الاعتماد",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "المصادقة التفاعلية على لوحة المفاتيح",
"noneAuthDescription": "طريقة المصادقة هذه ستستخدم مصادقة لوحة المفاتيح التفاعلية عند الاتصال بخادم SSH.",
"noneAuthDetails": "المصادقة التفاعلية بلوحة المفاتيح تسمح للخادم بمطالبتك للحصول على بيانات الاعتماد أثناء الاتصال. هذا مفيد للخوادم التي تتطلب مصادقة متعددة العناصر أو إذا كنت لا تريد حفظ بيانات الاعتماد محلياً.",
- "opksshAuthTitle": "المصادقة المفتوحة",
"forceKeyboardInteractive": "تفعيل لوحة المفاتيح",
"forceKeyboardInteractiveDesc": "يفرض استخدام مصادقة لوحة المفاتيح التفاعلية. هذا مطلوب أحيانا للخوادم التي تستخدم المصادقة الثنائية (TOTP/2FA).",
"overrideCredentialUsername": "تجاوز اسم المستخدم",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "فتح في المتصفح",
"warpgateContinue": "لقد أكملت المصادقة",
"warpgateTimeout": "انتهت مهلة مصادقة التحذير. الرجاء إعادة الاتصال.",
- "opksshAuthRequired": "المصادقة مفتوحة مطلوبة",
- "opksshAuthDescription": "أكمل المصادقة في المتصفح الخاص بك للمتابعة. ستبقى هذه الجلسة صالحة لمدة 24 ساعة.",
- "opksshAuthUrl": "رابط المصادقة",
- "opksshOpenBrowser": "فتح المتصفح للمصادقة",
- "opksshWaitingForAuth": "في انتظار المصادقة في المتصفح...",
- "opksshAuthenticating": "جاري معالجة المصادقة...",
- "opksshTimeout": "انتهت مهلة المصادقة. الرجاء المحاولة مرة أخرى.",
- "opksshAuthFailed": "فشلت المصادقة. الرجاء التحقق من بيانات الاعتماد الخاصة بك وحاول مرة أخرى.",
- "opksshConfigMissing": "لم يتم العثور على تكوين الفتح. الرجاء إنشاء ~/.opk/config.yml مع إعدادات موفر OIDC. انظر الوثائق: https://github.com/openpubkey/opkssh#config.",
"sudoPasswordPopupTitle": "إدراج كلمة المرور؟",
"sudoPasswordPopupHint": "اضغط Enter للإدراج ، Esc للإلغاء",
"sudoPasswordPopupConfirm": "Insert",
@@ -1359,8 +1337,7 @@
"automaticFallback": "جرب مصادقة {{method}} تلقائياً...",
"totpTimeout": "انتهت مهلة التحقق من TOTP. الرجاء إعادة الاتصال.",
"passwordTimeout": "انتهت مهلة التحقق من كلمة المرور. الرجاء إعادة الاتصال.",
- "connectionRejected": "تم رفض الاتصال بواسطة الخادم. الرجاء التحقق من المصادقة الخاصة بك وتكوين الشبكة.",
- "hostKeyRejected": "تم رفض التحقق من مفتاح المضيف SSH. تم إلغاء الاتصال."
+ "connectionRejected": "تم رفض الاتصال بواسطة الخادم. الرجاء التحقق من المصادقة الخاصة بك وتكوين الشبكة."
},
"fileManager": {
"title": "مدير الملفات",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "تم إعادة تعيين كلمة المرور بنجاح",
"passwordResetSuccessDesc": "تم إعادة تعيين كلمة المرور الخاصة بك بنجاح. يمكنك الآن تسجيل الدخول باستخدام كلمة المرور الجديدة."
},
- "hostKey": {
- "verifyNewHost": "التحقق من مفتاح استضافة SSH",
- "keyChangedWarning": "تم تغيير مفتاح المضيف SSH",
- "firstConnectionTitle": "أول مرة تتصل بهذا المضيف",
- "firstConnectionDescription": "لا يمكن إنشاء صحة هذا المضيف. تحقق من بصمة الإصبع تطابق ما تتوقعه.",
- "keyChangedDescription": "مفتاح المضيف لهذا الخادم قد تغير منذ الاتصال الأخير. يمكن أن يشير هذا إلى مشكلة أمنية.",
- "previousKey": "المفتاح السابق",
- "newFingerprint": "بصمة جديدة",
- "fingerprint": "بصمة",
- "verifyInstructions": "إذا كنت تثق بهذا المضيف، انقر فوق قبول للمتابعة وحفظ بصمة الإصبع هذه للاتصال في المستقبل.",
- "securityWarning": "تحذير الأمان",
- "acceptAndContinue": "قبول ومتابعة",
- "acceptNewKey": "قبول المفتاح الجديد ومتابعة"
- },
"errors": {
"notFound": "لم يتم العثور على الصفحة",
"unauthorized": "الوصول غير المصرح به",
@@ -2063,8 +2026,6 @@
"terminalSettings": "المحطة",
"hostSidebarSettings": "المضيف و الشريط الجانبي",
"snippetsSettings": "كتل الكود",
- "confirmSnippetExecution": "تأكيد تنفيذ كتلة الكود",
- "confirmSnippetExecutionDesc": "إظهار مربع حوار التأكيد قبل تنفيذ كتل الكود",
"updateSettings": "التحديثات",
"disableUpdateCheck": "تعطيل التحقق من التحديث",
"disableUpdateCheckDesc": "إيقاف التحقق من الإصدارات الجديدة عند بدء التشغيل و لوحة التحكم. يقلل من طلبات الشبكة.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1-1 أو مثال.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/bg_BG.json b/src/locales/translated/bg_BG.json
index 6d9008d8..a34aad64 100644
--- a/src/locales/translated/bg_BG.json
+++ b/src/locales/translated/bg_BG.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Пълномощия",
"credentialsViewer": "Преглед на идентификационни данни",
"manageYourSSHCredentials": "Управлявайте сигурно вашите SSH идентификационни данни",
"addCredential": "Добавяне на идентификационни данни",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Това безопасно ще добави публичния ключ към файла ~/.ssh/authorized_keys на целевия хост, без да презаписва съществуващите ключове. Операцията е обратима.",
"chooseHostToDeploy": "Изберете хост, на който да се разположи...",
"deploying": "Разгръщане...",
- "copyDeployCommand": "Копиране на командата за разгръщане",
- "copyDeployCommandDescription": "Копирайте команда от shell, за да добавите ръчно публичния ключ към файла authorized_keys на хоста. Полезно е, когато удостоверяването с парола е деактивирано.",
- "deployCommandCopied": "Командата за разгръщане е копирана в клипборда",
- "manualDeployInfo": "Поставете тази команда в терминала на целевия хост (напр. конзола на Proxmox, IPMI или физически достъп), за да добавите SSH ключа.",
"name": "Име",
"noHostsAvailable": "Няма налични хостове",
"noHostsMatchSearch": "Няма хостове, които да отговарят на вашето търсене",
@@ -303,9 +298,7 @@
"createFolder": "Създаване на папка",
"editFolder": "Редактиране на папка",
"editFolderDescription": "Персонализирайте папката си с фрагменти",
- "createFolderDescription": "Организирайте фрагментите си в папки",
- "confirmExecution": "Да се изпълни „{{name}}“?",
- "confirmExecutionDesc": "Сигурни ли сте, че искате да изпълните този фрагмент?"
+ "createFolderDescription": "Организирайте фрагментите си в папки"
},
"commandHistory": {
"title": "История",
@@ -409,7 +402,6 @@
"required": "Задължително",
"optional": "По избор",
"connect": "Свържете се",
- "copied": "Копирано",
"connecting": "Свързване...",
"creating": "Създаване...",
"clear": "Изчисти",
@@ -502,7 +494,6 @@
"checking": "Проверка...",
"checkingDatabase": "Проверка на връзката с базата данни...",
"checkingAuthentication": "Проверка на удостоверяването...",
- "backendReconnected": "Връзката със сървъра е възстановена",
"actions": "Действия",
"remove": "Премахване",
"revoke": "Отмяна",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Мениджър на домакини",
- "hosts": "Домакини",
"sshHosts": "SSH хостове",
"noHosts": "Няма SSH хостове",
"noHostsMessage": "Все още не сте добавили SSH хостове. Кликнете върху „Добавяне на хост“, за да започнете.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Импортирането на JSON файл не бе успешно",
"connectionDetails": "Детайли за връзката",
"organization": "Организация",
- "ipAddress": "IP адрес или име на хост",
+ "ipAddress": "IP адрес",
"port": "Порт",
"name": "Име",
"username": "Потребителско име",
@@ -925,8 +915,6 @@
"key": "Ключ",
"credential": "Пълномощия",
"none": "Няма",
- "opkssh": "ОПКСШ",
- "opksshAuthDescription": "Подканва ви за OPKSSH уеб-авторизация при всяко свързване. Вашият токен за удостоверяване ще бъде валиден 24 часа, докато не се наложи да бъде подновен.",
"selectCredential": "Изберете идентификационни данни",
"selectCredentialPlaceholder": "Изберете удостоверение за самоличност...",
"credentialRequired": "Изисква се удостоверяване на идентификационни данни при използване на удостоверяване с идентификационни данни",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Интерактивно удостоверяване с клавиатура",
"noneAuthDescription": "Този метод за удостоверяване ще използва интерактивно удостоверяване с клавиатура при свързване към SSH сървъра.",
"noneAuthDetails": "Интерактивното удостоверяване с клавиатура позволява на сървъра да ви пита за идентификационни данни по време на връзка. Това е полезно за сървъри, които изискват многофакторно удостоверяване или ако не искате да запазвате идентификационните данни локално.",
- "opksshAuthTitle": "OPKSSH удостоверяване",
"forceKeyboardInteractive": "Принудително интерактивно с клавиатура",
"forceKeyboardInteractiveDesc": "Налага използването на интерактивно удостоверяване с клавиатура. Това понякога е необходимо за сървъри, които използват двуфакторно удостоверяване (TOTP/2FA).",
"overrideCredentialUsername": "Замяна на потребителско име за идентификационни данни",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Отваряне в браузъра",
"warpgateContinue": "Завърших удостоверяването",
"warpgateTimeout": "Времето за изчакване за удостоверяване на Warpgate изтече. Моля, свържете се отново.",
- "opksshAuthRequired": "Изисква се удостоверяване на OPKSSH",
- "opksshAuthDescription": "За да продължите, завършете удостоверяването в браузъра си. Тази сесия ще остане валидна 24 часа.",
- "opksshAuthUrl": "URL адрес за удостоверяване",
- "opksshOpenBrowser": "Отворете браузъра за удостоверяване",
- "opksshWaitingForAuth": "Чака се удостоверяване в браузъра...",
- "opksshAuthenticating": "Обработва се удостоверяване...",
- "opksshTimeout": "Времето за изчакване на удостоверяването изтече. Моля, опитайте отново.",
- "opksshAuthFailed": "Удостоверяването не бе успешно. Моля, проверете идентификационните си данни и опитайте отново.",
- "opksshConfigMissing": "Конфигурацията на OPKSSH не е намерена. Моля, създайте ~/.opk/config.yml с настройките на вашия OIDC доставчик. Вижте документацията: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Въвеждане на парола?",
"sudoPasswordPopupHint": "Натиснете Enter за вмъкване, Esc за отхвърляне",
"sudoPasswordPopupConfirm": "Вмъкване",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Автоматичен опит за {{method}} удостоверяване...",
"totpTimeout": "Времето за изчакване за проверка на TOTP изтече. Моля, свържете се отново.",
"passwordTimeout": "Времето за изчакване за потвърждение на паролата изтече. Моля, свържете се отново.",
- "connectionRejected": "Връзката е отхвърлена от сървъра. Моля, проверете удостоверяването и мрежовата си конфигурация.",
- "hostKeyRejected": "Проверката на SSH ключа за хост е отхвърлена. Връзката е прекратена."
+ "connectionRejected": "Връзката е отхвърлена от сървъра. Моля, проверете удостоверяването и мрежовата си конфигурация."
},
"fileManager": {
"title": "Файлов мениджър",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Успешно възстановяване на паролата",
"passwordResetSuccessDesc": "Паролата ви е нулирана успешно. Вече можете да влезете с новата си парола."
},
- "hostKey": {
- "verifyNewHost": "Проверка на SSH хост ключа",
- "keyChangedWarning": "SSH ключът на хоста е променен",
- "firstConnectionTitle": "Първо свързване с този хост",
- "firstConnectionDescription": "Автентичността на този хост не може да бъде установена. Проверете дали пръстовият отпечатък съответства на очакванията ви.",
- "keyChangedDescription": "Ключът на хоста за този сървър се е променил от последното ви свързване. Това може да показва проблем със сигурността.",
- "previousKey": "Предишен ключ",
- "newFingerprint": "Нов пръстов отпечатък",
- "fingerprint": "Пръстов отпечатък",
- "verifyInstructions": "Ако имате доверие на този хост, щракнете върху „Приемам“, за да продължите и да запазите този пръстов отпечатък за бъдещи връзки.",
- "securityWarning": "Предупреждение за сигурност",
- "acceptAndContinue": "Приемам и продължавам",
- "acceptNewKey": "Приемете новия ключ и продължете"
- },
"errors": {
"notFound": "Страницата не е намерена",
"unauthorized": "Неоторизиран достъп",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Терминал",
"hostSidebarSettings": "Хост и странична лента",
"snippetsSettings": "Откъси",
- "confirmSnippetExecution": "Потвърждаване на изпълнението на фрагмента",
- "confirmSnippetExecutionDesc": "Показване на диалогов прозорец за потвърждение преди изпълнение на фрагменти",
"updateSettings": "Актуализации",
"disableUpdateCheck": "Деактивиране на проверката за актуализации",
"disableUpdateCheckDesc": "Спрете проверката за нови версии при стартиране и таблото за управление. Намалява мрежовите заявки.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 или example.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/bn_BD.json b/src/locales/translated/bn_BD.json
index 333462f1..c1bedc8e 100644
--- a/src/locales/translated/bn_BD.json
+++ b/src/locales/translated/bn_BD.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "পরিচয়পত্র",
"credentialsViewer": "শংসাপত্র দর্শক",
"manageYourSSHCredentials": "আপনার SSH শংসাপত্রগুলি নিরাপদে পরিচালনা করুন",
"addCredential": "শংসাপত্র যোগ করুন",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "এটি বিদ্যমান কীগুলিকে ওভাররাইট না করেই টার্গেট হোস্টের ~/.ssh/authorized_keys ফাইলে নিরাপদে পাবলিক কী যুক্ত করবে। এই অপারেশনটি বিপরীতমুখী।",
"chooseHostToDeploy": "স্থাপনের জন্য একটি হোস্ট বেছে নিন...",
"deploying": "স্থাপন করা হচ্ছে...",
- "copyDeployCommand": "ডিপ্লয় কমান্ড কপি করুন",
- "copyDeployCommandDescription": "হোস্টের authorized_keys ফাইলে পাবলিক কী ম্যানুয়ালি যোগ করার জন্য একটি শেল কমান্ড কপি করুন। পাসওয়ার্ড প্রমাণীকরণ অক্ষম থাকলে এটি কার্যকর।",
- "deployCommandCopied": "ডিপ্লয় কমান্ড ক্লিপবোর্ডে কপি করা হয়েছে",
- "manualDeployInfo": "SSH কী যোগ করতে টার্গেট হোস্টের টার্মিনালে (যেমন, Proxmox কনসোল, IPMI, অথবা ফিজিক্যাল অ্যাক্সেস) এই কমান্ডটি পেস্ট করুন।",
"name": "নাম",
"noHostsAvailable": "কোনও হোস্ট উপলব্ধ নেই",
"noHostsMatchSearch": "আপনার অনুসন্ধানের সাথে কোনও হোস্ট মেলেনি।",
@@ -303,9 +298,7 @@
"createFolder": "ফোল্ডার তৈরি করুন",
"editFolder": "ফোল্ডার সম্পাদনা করুন",
"editFolderDescription": "আপনার স্নিপেট ফোল্ডারটি কাস্টমাইজ করুন",
- "createFolderDescription": "আপনার স্নিপেটগুলিকে ফোল্ডারে সাজান",
- "confirmExecution": "\"{{name}}\" কার্যকর করবেন?",
- "confirmExecutionDesc": "আপনি কি নিশ্চিত যে আপনি এই স্নিপেটটি কার্যকর করতে চান?"
+ "createFolderDescription": "আপনার স্নিপেটগুলিকে ফোল্ডারে সাজান"
},
"commandHistory": {
"title": "ইতিহাস",
@@ -409,7 +402,6 @@
"required": "প্রয়োজনীয়",
"optional": "ঐচ্ছিক",
"connect": "সংযোগ করুন",
- "copied": "কপি করা হয়েছে",
"connecting": "সংযোগ করা হচ্ছে...",
"creating": "তৈরি করা হচ্ছে...",
"clear": "পরিষ্কার",
@@ -502,7 +494,6 @@
"checking": "পরীক্ষা করা হচ্ছে...",
"checkingDatabase": "ডাটাবেস সংযোগ পরীক্ষা করা হচ্ছে...",
"checkingAuthentication": "প্রমাণীকরণ পরীক্ষা করা হচ্ছে...",
- "backendReconnected": "সার্ভার সংযোগ পুনরুদ্ধার করা হয়েছে",
"actions": "কর্ম",
"remove": "অপসারণ",
"revoke": "প্রত্যাহার করুন",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "হোস্ট ম্যানেজার",
- "hosts": "হোস্ট",
"sshHosts": "SSH হোস্ট",
"noHosts": "কোনও SSH হোস্ট নেই",
"noHostsMessage": "আপনি এখনও কোনও SSH হোস্ট যোগ করেননি। শুরু করতে \"হোস্ট যোগ করুন\" এ ক্লিক করুন।",
@@ -846,7 +836,7 @@
"failedToImportJson": "JSON ফাইল আমদানি করা যায়নি",
"connectionDetails": "সংযোগের বিবরণ",
"organization": "সংগঠন",
- "ipAddress": "আইপি ঠিকানা অথবা হোস্টনেম",
+ "ipAddress": "আইপি ঠিকানা",
"port": "বন্দর",
"name": "নাম",
"username": "ব্যবহারকারীর নাম",
@@ -925,8 +915,6 @@
"key": "চাবি",
"credential": "পরিচয়পত্র",
"none": "কোনটিই নয়",
- "opkssh": "OPKSSH সম্পর্কে",
- "opksshAuthDescription": "প্রতিটি সংযোগের সময় আপনাকে OPKSSH ওয়েব-অনুমোদনের জন্য অনুরোধ করবে। আপনার অথ টোকেনটি পুনর্নবীকরণ না করা পর্যন্ত 24 ঘন্টা স্থায়ী হবে।",
"selectCredential": "শংসাপত্র নির্বাচন করুন",
"selectCredentialPlaceholder": "একটি শংসাপত্র বেছে নিন...",
"credentialRequired": "শংসাপত্র প্রমাণীকরণ ব্যবহার করার সময় শংসাপত্র প্রয়োজন",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "কীবোর্ড-ইন্টারেক্টিভ প্রমাণীকরণ",
"noneAuthDescription": "এই প্রমাণীকরণ পদ্ধতিটি SSH সার্ভারের সাথে সংযোগ করার সময় কীবোর্ড-ইন্টারেক্টিভ প্রমাণীকরণ ব্যবহার করবে।",
"noneAuthDetails": "কীবোর্ড-ইন্টারেক্টিভ প্রমাণীকরণের মাধ্যমে সার্ভার সংযোগের সময় আপনাকে শংসাপত্রের জন্য অনুরোধ করতে পারে। এটি এমন সার্ভারগুলির জন্য কার্যকর যাদের মাল্টি-ফ্যাক্টর প্রমাণীকরণের প্রয়োজন হয় অথবা যদি আপনি স্থানীয়ভাবে শংসাপত্র সংরক্ষণ করতে না চান।",
- "opksshAuthTitle": "OPKSSH প্রমাণীকরণ",
"forceKeyboardInteractive": "জোর করে কীবোর্ড-ইন্টারেক্টিভ",
"forceKeyboardInteractiveDesc": "কীবোর্ড-ইন্টারেক্টিভ প্রমাণীকরণ ব্যবহার করতে বাধ্য করে। কখনও কখনও টু-ফ্যাক্টর প্রমাণীকরণ (TOTP/2FA) ব্যবহার করে এমন সার্ভারগুলির জন্য এটি প্রয়োজন হয়।",
"overrideCredentialUsername": "শংসাপত্র ব্যবহারকারীর নাম ওভাররাইড করুন",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "ব্রাউজারে খুলুন",
"warpgateContinue": "আমি প্রমাণীকরণ সম্পন্ন করেছি।",
"warpgateTimeout": "ওয়ার্পগেট প্রমাণীকরণের সময়সীমা শেষ। অনুগ্রহ করে পুনরায় সংযোগ করুন।",
- "opksshAuthRequired": "OPKSSH প্রমাণীকরণ প্রয়োজন",
- "opksshAuthDescription": "চালিয়ে যেতে আপনার ব্রাউজারে প্রমাণীকরণ সম্পূর্ণ করুন। এই সেশনটি 24 ঘন্টার জন্য বৈধ থাকবে।",
- "opksshAuthUrl": "প্রমাণীকরণ URL",
- "opksshOpenBrowser": "প্রমাণীকরণের জন্য ব্রাউজার খুলুন",
- "opksshWaitingForAuth": "ব্রাউজারে প্রমাণীকরণের জন্য অপেক্ষা করা হচ্ছে...",
- "opksshAuthenticating": "প্রমাণীকরণ প্রক্রিয়া করা হচ্ছে...",
- "opksshTimeout": "প্রমাণীকরণের সময় শেষ। অনুগ্রহ করে আবার চেষ্টা করুন।",
- "opksshAuthFailed": "প্রমাণীকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে আপনার শংসাপত্রগুলি পরীক্ষা করে আবার চেষ্টা করুন।",
- "opksshConfigMissing": "OPKSSH কনফিগারেশন খুঁজে পাওয়া যায়নি। আপনার OIDC প্রোভাইডার সেটিংস দিয়ে ~/.opk/config.yml তৈরি করুন। ডকুমেন্টেশন দেখুন: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "পাসওয়ার্ড ঢোকাবেন?",
"sudoPasswordPopupHint": "সন্নিবেশ করতে Enter টিপুন এবং খারিজ করতে Esc টিপুন",
"sudoPasswordPopupConfirm": "ঢোকান",
@@ -1359,8 +1337,7 @@
"automaticFallback": "স্বয়ংক্রিয়ভাবে {{method}} প্রমাণীকরণ চেষ্টা করা হচ্ছে...",
"totpTimeout": "TOTP যাচাইকরণের সময়সীমা শেষ। অনুগ্রহ করে পুনরায় সংযোগ করুন।",
"passwordTimeout": "পাসওয়ার্ড যাচাইকরণের সময়সীমা শেষ। অনুগ্রহ করে পুনরায় সংযোগ করুন।",
- "connectionRejected": "সার্ভার সংযোগ প্রত্যাখ্যান করেছে। অনুগ্রহ করে আপনার প্রমাণীকরণ এবং নেটওয়ার্ক কনফিগারেশন পরীক্ষা করুন।",
- "hostKeyRejected": "SSH হোস্ট কী যাচাইকরণ প্রত্যাখ্যাত হয়েছে। সংযোগ বাতিল করা হয়েছে।"
+ "connectionRejected": "সার্ভার সংযোগ প্রত্যাখ্যান করেছে। অনুগ্রহ করে আপনার প্রমাণীকরণ এবং নেটওয়ার্ক কনফিগারেশন পরীক্ষা করুন।"
},
"fileManager": {
"title": "ফাইল ম্যানেজার",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "পাসওয়ার্ড রিসেট সফল হয়েছে",
"passwordResetSuccessDesc": "আপনার পাসওয়ার্ড সফলভাবে রিসেট করা হয়েছে। আপনি এখন আপনার নতুন পাসওয়ার্ড দিয়ে লগ ইন করতে পারেন।"
},
- "hostKey": {
- "verifyNewHost": "SSH হোস্ট কী যাচাই করুন",
- "keyChangedWarning": "SSH হোস্ট কী পরিবর্তন করা হয়েছে",
- "firstConnectionTitle": "এই হোস্টের সাথে প্রথমবার সংযোগ করা হচ্ছে",
- "firstConnectionDescription": "এই হোস্টের সত্যতা যাচাই করা যাচ্ছে না। আপনার প্রত্যাশার সাথে আঙুলের ছাপের মিল আছে কিনা তা যাচাই করুন।",
- "keyChangedDescription": "আপনার শেষ সংযোগের পর থেকে এই সার্ভারের হোস্ট কী পরিবর্তিত হয়েছে। এটি কোনও নিরাপত্তা সমস্যা নির্দেশ করতে পারে।",
- "previousKey": "পূর্ববর্তী কী",
- "newFingerprint": "নতুন ফিঙ্গারপ্রিন্ট",
- "fingerprint": "আঙুলের ছাপ",
- "verifyInstructions": "যদি আপনি এই হোস্টটিকে বিশ্বাস করেন, তাহলে চালিয়ে যেতে \"গ্রহণ করুন\" এ ক্লিক করুন এবং ভবিষ্যতের সংযোগের জন্য এই আঙ্গুলের ছাপটি সংরক্ষণ করুন।",
- "securityWarning": "নিরাপত্তা সতর্কতা",
- "acceptAndContinue": "গ্রহণ করুন এবং চালিয়ে যান",
- "acceptNewKey": "নতুন কী গ্রহণ করুন এবং চালিয়ে যান"
- },
"errors": {
"notFound": "পৃষ্ঠাটি খুঁজে পাওয়া যায়নি",
"unauthorized": "অননুমোদিত প্রবেশাধিকার",
@@ -2063,8 +2026,6 @@
"terminalSettings": "টার্মিনাল",
"hostSidebarSettings": "হোস্ট এবং সাইডবার",
"snippetsSettings": "স্নিপেটস",
- "confirmSnippetExecution": "স্নিপেট এক্সিকিউশন নিশ্চিত করুন",
- "confirmSnippetExecutionDesc": "স্নিপেট চালানোর আগে নিশ্চিতকরণ ডায়ালগ দেখান",
"updateSettings": "আপডেট",
"disableUpdateCheck": "আপডেট চেক অক্ষম করুন",
"disableUpdateCheckDesc": "স্টার্টআপ এবং ড্যাশবোর্ডে নতুন সংস্করণ পরীক্ষা করা বন্ধ করুন। নেটওয়ার্ক অনুরোধ হ্রাস করে।",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "০০০০০০",
- "ipAddress": "১৯২.১৬৮.১.১ অথবা example.com",
+ "ipAddress": "১২৭.০.০.১",
"port": "২২",
"maxRetries": "৩",
"retryInterval": "১০",
diff --git a/src/locales/translated/ca_ES.json b/src/locales/translated/ca_ES.json
index 47603f0b..9a5271a9 100644
--- a/src/locales/translated/ca_ES.json
+++ b/src/locales/translated/ca_ES.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Credencials",
"credentialsViewer": "Visualitzador de credencials",
"manageYourSSHCredentials": "Gestiona les teves credencials SSH de manera segura",
"addCredential": "Afegeix credencials",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Això afegirà de manera segura la clau pública al fitxer ~/.ssh/authorized_keys de l'amfitrió de destinació sense sobreescriure les claus existents. L'operació és reversible.",
"chooseHostToDeploy": "Trieu un amfitrió on implementar...",
"deploying": "S'està desplegant...",
- "copyDeployCommand": "Copia l'ordre de desplegament",
- "copyDeployCommandDescription": "Copia una ordre de shell per afegir manualment la clau pública al fitxer authorized_keys d'un host. Útil quan l'autenticació amb contrasenya està desactivada.",
- "deployCommandCopied": "L'ordre de desplegament s'ha copiat al porta-retalls",
- "manualDeployInfo": "Enganxeu aquesta ordre al terminal de l'amfitrió de destinació (per exemple, la consola de Proxmox, IPMI o l'accés físic) per afegir la clau SSH.",
"name": "Nom",
"noHostsAvailable": "No hi ha amfitrions disponibles",
"noHostsMatchSearch": "No hi ha amfitrions que coincideixin amb la teva cerca",
@@ -303,9 +298,7 @@
"createFolder": "Crea una carpeta",
"editFolder": "Edita la carpeta",
"editFolderDescription": "Personalitza la carpeta de fragments",
- "createFolderDescription": "Organitza els teus fragments en carpetes",
- "confirmExecution": "Executar \"{{name}}\"?",
- "confirmExecutionDesc": "Esteu segur que voleu executar aquest fragment?"
+ "createFolderDescription": "Organitza els teus fragments en carpetes"
},
"commandHistory": {
"title": "Història",
@@ -409,7 +402,6 @@
"required": "Obligatori",
"optional": "Opcional",
"connect": "Connecta",
- "copied": "Copiat",
"connecting": "Connectant...",
"creating": "Creant...",
"clear": "Clar",
@@ -502,7 +494,6 @@
"checking": "S'està revisant...",
"checkingDatabase": "S'està comprovant la connexió a la base de dades...",
"checkingAuthentication": "S'està comprovant l'autenticació...",
- "backendReconnected": "Connexió al servidor restaurada",
"actions": "Accions",
"remove": "Elimina",
"revoke": "Revocar",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Gestor d'amfitrions",
- "hosts": "Amfitrions",
"sshHosts": "Amfitrions SSH",
"noHosts": "Sense amfitrions SSH",
"noHostsMessage": "Encara no has afegit cap amfitrió SSH. Fes clic a \"Afegeix amfitrió\" per començar.",
@@ -846,7 +836,7 @@
"failedToImportJson": "No s'ha pogut importar el fitxer JSON",
"connectionDetails": "Detalls de la connexió",
"organization": "Organització",
- "ipAddress": "Adreça IP o nom d'amfitrió",
+ "ipAddress": "Adreça IP",
"port": "Port",
"name": "Nom",
"username": "Nom d'usuari",
@@ -925,8 +915,6 @@
"key": "Clau",
"credential": "Credencial",
"none": "Cap",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Et demana l'autenticació web OPKSSH cada vegada que et connectes. El teu testimoni d'autenticació durarà 24 hores fins que s'hagi de renovar.",
"selectCredential": "Selecciona la credencial",
"selectCredentialPlaceholder": "Trieu una credencial...",
"credentialRequired": "Cal tenir credencials quan s'utilitza l'autenticació de credencials",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Autenticació interactiva amb teclat",
"noneAuthDescription": "Aquest mètode d'autenticació utilitzarà l'autenticació interactiva del teclat quan es connecti al servidor SSH.",
"noneAuthDetails": "L'autenticació interactiva amb teclat permet que el servidor us demani credencials durant la connexió. Això és útil per a servidors que requereixen autenticació multifactor o si no voleu desar les credencials localment.",
- "opksshAuthTitle": "Autenticació OPKSSH",
"forceKeyboardInteractive": "Forçar la interacció del teclat",
"forceKeyboardInteractiveDesc": "Força l'ús de l'autenticació interactiva amb teclat. De vegades, això és necessari per a servidors que utilitzen l'autenticació de dos factors (TOTP/2FA).",
"overrideCredentialUsername": "Substitueix les credencials i el nom d'usuari",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Obre al navegador",
"warpgateContinue": "He completat l'autenticació",
"warpgateTimeout": "Temps d'espera d'autenticació de Warpgate. Si us plau, torneu a connectar-vos.",
- "opksshAuthRequired": "Cal autenticació OPKSSH",
- "opksshAuthDescription": "Completeu l'autenticació al navegador per continuar. Aquesta sessió serà vàlida durant 24 hores.",
- "opksshAuthUrl": "URL d'autenticació",
- "opksshOpenBrowser": "Obre el navegador per autenticar-te",
- "opksshWaitingForAuth": "Esperant l'autenticació al navegador...",
- "opksshAuthenticating": "Processant l'autenticació...",
- "opksshTimeout": "S'ha esgotat el temps d'autenticació. Torna-ho a provar.",
- "opksshAuthFailed": "L'autenticació ha fallat. Si us plau, comproveu les vostres credencials i torneu-ho a intentar.",
- "opksshConfigMissing": "No s'ha trobat la configuració d'OPKSSH. Si us plau, creeu ~/.opk/config.yml amb la configuració del vostre proveïdor OIDC. Vegeu la documentació: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Voleu inserir la contrasenya?",
"sudoPasswordPopupHint": "Premeu Intro per inserir, Esc per tancar",
"sudoPasswordPopupConfirm": "Insereix",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Intentant automàticament l'autenticació {{method}} ...",
"totpTimeout": "Temps d'espera de verificació TOTP superat. Si us plau, torneu a connectar-vos.",
"passwordTimeout": "Temps d'espera de verificació de la contrasenya. Si us plau, torneu a connectar-vos.",
- "connectionRejected": "Connexió rebutjada pel servidor. Si us plau, comproveu l'autenticació i la configuració de xarxa.",
- "hostKeyRejected": "Verificació de la clau de l'amfitrió SSH rebutjada. Connexió cancel·lada."
+ "connectionRejected": "Connexió rebutjada pel servidor. Si us plau, comproveu l'autenticació i la configuració de xarxa."
},
"fileManager": {
"title": "Gestor de fitxers",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Restabliment de la contrasenya correcte",
"passwordResetSuccessDesc": "La teva contrasenya s'ha restablert correctament. Ara pots iniciar la sessió amb la nova contrasenya."
},
- "hostKey": {
- "verifyNewHost": "Verifica la clau d'amfitrió SSH",
- "keyChangedWarning": "Clau d'amfitrió SSH canviada",
- "firstConnectionTitle": "Primera vegada que em connecto a aquest amfitrió",
- "firstConnectionDescription": "No es pot establir l'autenticitat d'aquest amfitrió. Verifiqueu que l'empremta digital coincideixi amb el que espereu.",
- "keyChangedDescription": "La clau d'amfitrió d'aquest servidor ha canviat des de la vostra última connexió. Això podria indicar un problema de seguretat.",
- "previousKey": "Clau anterior",
- "newFingerprint": "Nova empremta digital",
- "fingerprint": "Empremta digital",
- "verifyInstructions": "Si confieu en aquest amfitrió, feu clic a Accepta per continuar i desar aquesta empremta digital per a futures connexions.",
- "securityWarning": "Avís de seguretat",
- "acceptAndContinue": "Accepta i continua",
- "acceptNewKey": "Accepta la clau nova i continua"
- },
"errors": {
"notFound": "Pàgina no trobada",
"unauthorized": "Accés no autoritzat",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Terminal",
"hostSidebarSettings": "Amfitrió i barra lateral",
"snippetsSettings": "Fragments",
- "confirmSnippetExecution": "Confirma l'execució del fragment",
- "confirmSnippetExecutionDesc": "Mostra el diàleg de confirmació abans d'executar fragments",
"updateSettings": "Actualitzacions",
"disableUpdateCheck": "Desactiva la comprovació d'actualitzacions",
"disableUpdateCheckDesc": "Deixa de comprovar si hi ha noves versions a l'inici i al tauler de control. Redueix les sol·licituds de xarxa.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 o example.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/cs_CZ.json b/src/locales/translated/cs_CZ.json
index 9216e997..6979349c 100644
--- a/src/locales/translated/cs_CZ.json
+++ b/src/locales/translated/cs_CZ.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Pověření",
"credentialsViewer": "Prohlížeč přihlašovacích údajů",
"manageYourSSHCredentials": "Bezpečná správa přihlašovacích údajů SSH",
"addCredential": "Přidat pověření",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Toto bezpečně přidá veřejný klíč do souboru ~/.ssh/authorized_keys bez přepsání existujících klíčů. Operace je vratná.",
"chooseHostToDeploy": "Vyberte hostitele pro nasazení...",
"deploying": "Nasazování...",
- "copyDeployCommand": "Kopírovat příkaz pro nasazení",
- "copyDeployCommandDescription": "Zkopírujte příkaz shell do manuálního přidání veřejného klíče do souboru autorized_keys. Užitečné, pokud je heslo vypnuto.",
- "deployCommandCopied": "Příkaz nasazení zkopírován do schránky",
- "manualDeployInfo": "Vložte tento příkaz do terminálu cílového hostitele (např. Proxmox konzola, IPMI nebo fyzický přístup) pro přidání SSH klíče.",
"name": "Název",
"noHostsAvailable": "Nejsou k dispozici žádní hostitelé",
"noHostsMatchSearch": "Žádné hostitele neodpovídají vašemu hledání",
@@ -303,9 +298,7 @@
"createFolder": "Vytvořit složku",
"editFolder": "Upravit složku",
"editFolderDescription": "Přizpůsobit složku snippetu",
- "createFolderDescription": "Uspořádat úryvky do složek",
- "confirmExecution": "Spustit \"{{name}}\"?",
- "confirmExecutionDesc": "Jste si jisti, že chcete spustit tento snippet?"
+ "createFolderDescription": "Uspořádat úryvky do složek"
},
"commandHistory": {
"title": "Historie",
@@ -409,7 +402,6 @@
"required": "Požadováno",
"optional": "Nepovinné",
"connect": "Připojit",
- "copied": "Zkopírováno",
"connecting": "Připojování...",
"creating": "Vytváření...",
"clear": "Vyčistit",
@@ -502,7 +494,6 @@
"checking": "Kontroluje...",
"checkingDatabase": "Kontrola připojení k databázi...",
"checkingAuthentication": "Kontrola ověřování...",
- "backendReconnected": "Připojení k serveru obnoveno",
"actions": "Akce",
"remove": "Odebrat",
"revoke": "Revoke",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Manažer serveru",
- "hosts": "Hostitelé",
"sshHosts": "SSH hostitelé",
"noHosts": "Žádné SSH hostitele",
"noHostsMessage": "Zatím jste nepřidali žádné SSH hostitele. Chcete-li začít, klikněte na \"Přidat hostitele\".",
@@ -846,7 +836,7 @@
"failedToImportJson": "Nepodařilo se importovat soubor JSON",
"connectionDetails": "Detaily připojení",
"organization": "Organizace",
- "ipAddress": "IP adresa nebo název hostitele",
+ "ipAddress": "IP adresa",
"port": "Přístav",
"name": "Název",
"username": "Uživatelské jméno",
@@ -925,8 +915,6 @@
"key": "Klíč",
"credential": "Pověření",
"none": "Nic",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Vyzývá vás k ověření webového serveru OPKSSH při každém připojení. Váš autentizační token bude trvat 24 hodin, dokud nebude nutné jej obnovit.",
"selectCredential": "Vybrat pověření",
"selectCredentialPlaceholder": "Zvolte přihlašovací údaje...",
"credentialRequired": "Při použití ověření pověření je vyžadováno pověření",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Klávesnice – interaktivní ověření",
"noneAuthDescription": "Tato metoda ověřování bude při připojování k serveru SSH používat klávesnice-interaktivní ověřování.",
"noneAuthDetails": "Klávesnice - interaktivní autentizace umožňuje serveru požádat vás o přihlašovací údaje při připojení. Toto je užitečné pro servery, které vyžadují vícefázové autentifikaci, nebo pokud nechcete lokálně ukládat přihlašovací údaje.",
- "opksshAuthTitle": "Ověření OPKSSH",
"forceKeyboardInteractive": "Vynutit interaktivní klávesnici",
"forceKeyboardInteractiveDesc": "Vynucuje použití klávesnice interaktivní autentizace. To je někdy nutné u serverů, které používají dvoufaktorové ověřování (TOTP/2FA).",
"overrideCredentialUsername": "Přepsat pověření uživatelské jméno",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Otevřít v prohlížeči",
"warpgateContinue": "Dokončil jsem ověření",
"warpgateTimeout": "Časový limit autentizace Warpgate vypršel. Připojte se.",
- "opksshAuthRequired": "Vyžadováno ověření OPKSSH",
- "opksshAuthDescription": "Dokončete ověření ve vašem prohlížeči, abyste mohli pokračovat. Tato relace zůstane v platnosti 24 hodin.",
- "opksshAuthUrl": "Ověřovací URL",
- "opksshOpenBrowser": "Otevřete prohlížeč pro ověření",
- "opksshWaitingForAuth": "Čekání na ověřování v prohlížeči...",
- "opksshAuthenticating": "Zpracovávání ověřování...",
- "opksshTimeout": "Vypršel časový limit ověření. Zkuste to prosím znovu.",
- "opksshAuthFailed": "Ověření se nezdařilo. Zkontrolujte prosím vaše přihlašovací údaje a zkuste to znovu.",
- "opksshConfigMissing": "Nastavení OPKSSH nebylo nalezeno. Vytvořte prosím ~/.opk/config.yml s nastavením OIDC poskytovatele. Viz dokumentace: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Vložit heslo?",
"sudoPasswordPopupHint": "Stiskněte Enter pro vložení, Esc pro zamítnutí",
"sudoPasswordPopupConfirm": "Insert",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Automaticky zkouším {{method}} autentizaci...",
"totpTimeout": "Časový limit ověřování TOTP. Připojte se znovu.",
"passwordTimeout": "Časový limit ověření hesla vypršel. Připojte se znovu.",
- "connectionRejected": "Připojení odmítnuto serverem. Zkontrolujte prosím ověření a nastavení sítě.",
- "hostKeyRejected": "Ověření SSH hostitele bylo zamítnuto. Připojení bylo zrušeno."
+ "connectionRejected": "Připojení odmítnuto serverem. Zkontrolujte prosím ověření a nastavení sítě."
},
"fileManager": {
"title": "Správce souborů",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Obnovení hesla úspěšné",
"passwordResetSuccessDesc": "Vaše heslo bylo úspěšně obnoveno. Nyní se můžete přihlásit svým novým heslem."
},
- "hostKey": {
- "verifyNewHost": "Ověřit klíč SSH hostitele",
- "keyChangedWarning": "SSH klíč hostitele změněn",
- "firstConnectionTitle": "První připojení k tomuto hostiteli",
- "firstConnectionDescription": "Nelze určit pravost tohoto hostitele. Ověřte shodu s tím, co očekáváte.",
- "keyChangedDescription": "Hostitelský klíč pro tento server se změnil od vašeho posledního připojení. To by mohlo znamenat bezpečnostní problém.",
- "previousKey": "Předchozí klíč",
- "newFingerprint": "Nový otisk prstu",
- "fingerprint": "Otisk prstu",
- "verifyInstructions": "Pokud tomuto hostiteli důvěřujete, klepněte na tlačítko Přijmout pro pokračování a uložení tohoto otisku pro budoucí připojení.",
- "securityWarning": "Bezpečnostní varování",
- "acceptAndContinue": "Přijmout a pokračovat",
- "acceptNewKey": "Přijmout nový klíč a pokračovat"
- },
"errors": {
"notFound": "Stránka nenalezena",
"unauthorized": "Neoprávněný přístup",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Terminál",
"hostSidebarSettings": "Hostitel & postranní panel",
"snippetsSettings": "Výstřižky bloků",
- "confirmSnippetExecution": "Potvrdit spuštění snippetu",
- "confirmSnippetExecutionDesc": "Zobrazit potvrzovací dialog před spuštěním snippetů",
"updateSettings": "Aktualizace",
"disableUpdateCheck": "Zakázat kontrolu aktualizací",
"disableUpdateCheckDesc": "Zastavit kontrolu nových verzí na startu a řídicím panelu. Sníží síťové požadavky.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 nebo příklad.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/da_DK.json b/src/locales/translated/da_DK.json
index aeb9ef55..79dc8190 100644
--- a/src/locales/translated/da_DK.json
+++ b/src/locales/translated/da_DK.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Legitimation",
"credentialsViewer": "Legitimationsfremviser",
"manageYourSSHCredentials": "Administrer dine SSH-akkreditiver sikkert",
"addCredential": "Tilføj Credential",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Dette vil sikkert tilføje den offentlige nøgle til målet værtens ~/.ssh / authorized_keys fil uden at overskrive eksisterende nøgler. Handlingen er reversibel.",
"chooseHostToDeploy": "Vælg en vært at implementere til...",
"deploying": "Implementerer...",
- "copyDeployCommand": "Kopier Implementeringskommando",
- "copyDeployCommandDescription": "Kopiér en skal- kommando for manuelt at tilføje den offentlige nøgle til en værts autoriseret _ keys- fil. Nyttigt når adgangskodegodkendelse er deaktiveret.",
- "deployCommandCopied": "Deploy kommando kopieret til udklipsholder",
- "manualDeployInfo": "Indsæt denne kommando i målet værtens terminal (fx, Proxmox konsol, IPMI, eller fysisk adgang) for at tilføje SSH-nøglen.",
"name": "Navn",
"noHostsAvailable": "Ingen værter tilgængelige",
"noHostsMatchSearch": "Ingen værter matcher din søgning",
@@ -303,9 +298,7 @@
"createFolder": "Opret Mappe",
"editFolder": "Rediger Mappe",
"editFolderDescription": "Tilpas din snippet mappe",
- "createFolderDescription": "Organiser dine snippets i mapper",
- "confirmExecution": "Kør \"{{name}}\"?",
- "confirmExecutionDesc": "Er du sikker på, at du vil udføre denne snippet?"
+ "createFolderDescription": "Organiser dine snippets i mapper"
},
"commandHistory": {
"title": "Historik",
@@ -409,7 +402,6 @@
"required": "Påkrævet",
"optional": "Valgfri",
"connect": "Forbind",
- "copied": "Kopieret",
"connecting": "Forbinder...",
"creating": "Opretter...",
"clear": "Ryd",
@@ -502,7 +494,6 @@
"checking": "Kontrollerer...",
"checkingDatabase": "Kontrollerer databaseforbindelse...",
"checkingAuthentication": "Kontrollerer godkendelse...",
- "backendReconnected": "Server forbindelse gendannet",
"actions": "Handlinger",
"remove": "Fjern",
"revoke": "Revoke",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Vært Manager",
- "hosts": "Værter",
"sshHosts": "SSH Værter",
"noHosts": "Ingen SSH værter",
"noHostsMessage": "Du har endnu ikke tilføjet nogen SSH-værter. Klik på \"Tilføj vært\" for at komme i gang.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Kunne ikke importere JSON- fil",
"connectionDetails": "Forbindelse Detaljer",
"organization": "Organisation",
- "ipAddress": "Ip adresse eller værtsnavn",
+ "ipAddress": "Ip Adresse",
"port": "Port",
"name": "Navn",
"username": "Brugernavn",
@@ -925,8 +915,6 @@
"key": "Nøgle",
"credential": "Credential",
"none": "Ingen",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Spørg dig om OPKSSH web-auth ved hver forbindelse. Din auth token vil vare i 24 timer, indtil den skal fornyes.",
"selectCredential": "Vælg Credential",
"selectCredentialPlaceholder": "Vælg en legitimationsoplysning...",
"credentialRequired": "Oplysninger er påkrævet, når du bruger legitimationsoplysninger godkendelse",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Tastatur-Interaktiv Autentificering",
"noneAuthDescription": "Denne godkendelsesmetode vil bruge tastatur-interaktiv godkendelse, når der oprettes forbindelse til SSH-serveren.",
"noneAuthDetails": "Tastatur-interaktiv godkendelse tillader serveren at bede dig om legitimationsoplysninger under forbindelsen. Dette er nyttigt for servere, der kræver multi-faktor godkendelse, eller hvis du ikke ønsker at gemme legitimationsoplysninger lokalt.",
- "opksshAuthTitle": "OPKSSH- Godkendelse",
"forceKeyboardInteractive": "Tving Tastatur-Interaktiv",
"forceKeyboardInteractiveDesc": "Kræver brugen af tastatur- interaktiv godkendelse. Dette kræves sommetider for servere, der bruger to- faktor godkendelse (TOTP/2FA).",
"overrideCredentialUsername": "Tilsidesæt Credential Brugernavn",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Åbn i browser",
"warpgateContinue": "Jeg Er Afsluttet Godkendelse",
"warpgateTimeout": "Warpgate authentication timeout. Genforbind.",
- "opksshAuthRequired": "OPKSSH Godkendelse Kræves",
- "opksshAuthDescription": "Komplet godkendelse i din browser for at fortsætte. Denne session vil forblive gyldig i 24 timer.",
- "opksshAuthUrl": "Godkendelses-URL",
- "opksshOpenBrowser": "Åbn browser for at godkende",
- "opksshWaitingForAuth": "Venter på godkendelse i browser...",
- "opksshAuthenticating": "Behandler godkendelse...",
- "opksshTimeout": "Godkendelse fik timeout. Prøv venligst igen.",
- "opksshAuthFailed": "Godkendelse mislykkedes. Kontroller dine legitimationsoplysninger og prøv igen.",
- "opksshConfigMissing": "OPKSSH konfiguration ikke fundet. Opret venligst ~/.opk/config.yml med dine OIDC udbyder indstillinger. Se dokumentation: https://github.com/openpubkey/opkssh#konfiguration",
"sudoPasswordPopupTitle": "Indsæt Adgangskode?",
"sudoPasswordPopupHint": "Tryk på Enter for at indsætte, Esc for at afvise",
"sudoPasswordPopupConfirm": "Insert",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Prøver automatisk {{method}} autentificering...",
"totpTimeout": "TOTP bekræftelses-timeout. Genforbind.",
"passwordTimeout": "Adgangskodens bekræftelses-timeout. Opret venligst ny forbindelse.",
- "connectionRejected": "Forbindelse afvist af serveren. Tjek venligst din godkendelse og netværkskonfiguration.",
- "hostKeyRejected": "SSH-værtnøglebekræftelse afvist. Forbindelse annulleret."
+ "connectionRejected": "Forbindelse afvist af serveren. Tjek venligst din godkendelse og netværkskonfiguration."
},
"fileManager": {
"title": "Filhåndtering",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Adgangskode Nulstilling Lykkedes",
"passwordResetSuccessDesc": "Din adgangskode er blevet nulstillet. Du kan nu logge ind med din nye adgangskode."
},
- "hostKey": {
- "verifyNewHost": "Verificér SSH-værtnøgle",
- "keyChangedWarning": "SSH Værtsnøgle Ændret",
- "firstConnectionTitle": "Første gang der oprettes forbindelse til værten",
- "firstConnectionDescription": "Denne værts ægthed kan ikke fastslås. Kontrollér, at fingeraftrykket matcher det, du forventer.",
- "keyChangedDescription": "Værtsnøglen for denne server er ændret siden din sidste forbindelse. Dette kan indikere et sikkerhedsproblem.",
- "previousKey": "Forrige Nøgle",
- "newFingerprint": "Nyt Fingeraftryk",
- "fingerprint": "Fingeraftryk",
- "verifyInstructions": "Hvis du stoler på denne vært, skal du klikke på Accepter for at fortsætte og gemme dette fingeraftryk til fremtidige forbindelser.",
- "securityWarning": "Advarsel Om Sikkerhed",
- "acceptAndContinue": "Accepter & Fortsæt",
- "acceptNewKey": "Accepter Ny Nøgle Og Fortsæt"
- },
"errors": {
"notFound": "Siden blev ikke fundet",
"unauthorized": "Uautoriseret adgang",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Terminal",
"hostSidebarSettings": "Vært & Sidebjælke",
"snippetsSettings": "Stumper",
- "confirmSnippetExecution": "Bekræft Udførelse Af Snippet",
- "confirmSnippetExecutionDesc": "Vis bekræftelsesdialog før udførelse af snippets",
"updateSettings": "Opdateringer",
"disableUpdateCheck": "Deaktivér Opdateringstjek",
"disableUpdateCheckDesc": "Stop med at kontrollere for nye versioner ved opstart og dashboard. Reducerer netværksanmodninger.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 eller eksempel.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/de_DE.json b/src/locales/translated/de_DE.json
index bed720a5..2a7d95f4 100644
--- a/src/locales/translated/de_DE.json
+++ b/src/locales/translated/de_DE.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Anmeldedaten",
"credentialsViewer": "Zugangsdaten-Betrachter",
"manageYourSSHCredentials": "SSH Zugangsdaten sicher verwalten",
"addCredential": "Zugangsdaten hinzufügen",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Dies fügt den öffentlichen Schlüssel sicher zur Datei ~/.ssh/authorized_keys des Zielservers hinzu, ohne vorhandene Schlüssel zu überschreiben. Die Operation ist reversibel.",
"chooseHostToDeploy": "Wählen Sie einen Host für den sie bereitstellen soll...",
"deploying": "Verteilen...",
- "copyDeployCommand": "Deploy-Befehl kopieren",
- "copyDeployCommandDescription": "Kopieren Sie einen Shell-Befehl, um den öffentlichen Schlüssel manuell zu der Datei authorized_keys eines Hosts hinzuzufügen. Nützlich, wenn die Passwort-Authentifizierung deaktiviert ist.",
- "deployCommandCopied": "Deploy-Befehl in Zwischenablage kopiert",
- "manualDeployInfo": "Fügen Sie diesen Befehl in das Terminal des Ziel-Hosts ein (z.B. Proxmox-Konsole, IPMI oder physikalischer Zugang), um den SSH-Schlüssel hinzuzufügen.",
"name": "Name",
"noHostsAvailable": "Keine Hosts verfügbar",
"noHostsMatchSearch": "Keine Hosts entsprechen Ihrer Suche",
@@ -303,9 +298,7 @@
"createFolder": "Ordner erstellen",
"editFolder": "Ordner bearbeiten",
"editFolderDescription": "Passen Sie Ihren Snippet Ordner an",
- "createFolderDescription": "Organisieren Sie Ihre Snippets in Ordner",
- "confirmExecution": "\"{{name}} \"ausführen?",
- "confirmExecutionDesc": "Sind Sie sicher, dass Sie dieses Snippet ausführen möchten?"
+ "createFolderDescription": "Organisieren Sie Ihre Snippets in Ordner"
},
"commandHistory": {
"title": "Verlauf",
@@ -409,7 +402,6 @@
"required": "Benötigt",
"optional": "Optional",
"connect": "Verbinden",
- "copied": "Kopiert",
"connecting": "Verbinden...",
"creating": "Erstellen...",
"clear": "Leeren",
@@ -502,7 +494,6 @@
"checking": "Überprüfen...",
"checkingDatabase": "Überprüfe Datenbankverbindung...",
"checkingAuthentication": "Authentifizierung wird überprüft...",
- "backendReconnected": "Serververbindung wiederhergestellt",
"actions": "Aktionen",
"remove": "Entfernen",
"revoke": "Revoke",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Host-Manager",
- "hosts": "Hosts",
"sshHosts": "SSH-Hosts",
"noHosts": "Keine SSH-Hosts",
"noHostsMessage": "Sie haben noch keine SSH-Hosts hinzugefügt. Klicken Sie auf \"Host hinzufügen\", um loszulegen.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Import der JSON-Datei fehlgeschlagen",
"connectionDetails": "Verbindungsdetails",
"organization": "Organisation",
- "ipAddress": "IP-Adresse oder Hostname",
+ "ipAddress": "IP-Adresse",
"port": "Port",
"name": "Name",
"username": "Benutzername",
@@ -925,8 +915,6 @@
"key": "Schlüssel",
"credential": "Anmeldedaten",
"none": "Keine",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Fordern Sie bei jeder Verbindung nach OPKSSH web-auth an. Ihr Auth-Token hält 24 Stunden an, bis es erneuert werden muss.",
"selectCredential": "Anmeldeinformationen auswählen",
"selectCredentialPlaceholder": "Anmeldedaten auswählen...",
"credentialRequired": "Anmeldeinformationen werden benötigt, wenn die Anmeldeinformationen verwendet werden",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Tastatur-Interaktive Authentifizierung",
"noneAuthDescription": "Diese Authentifizierungsmethode verwendet die Tastatur-interaktive Authentifizierung bei der Verbindung zum SSH-Server.",
"noneAuthDetails": "Die interaktive Keyboard-Authentifizierung erlaubt es dem Server, Sie während der Verbindung nach Anmeldeinformationen zu fragen. Dies ist nützlich für Server, die Multi-Faktor-Authentifizierung benötigen oder wenn Sie nicht lokal Anmeldedaten speichern möchten.",
- "opksshAuthTitle": "OPKSSH Authentifizierung",
"forceKeyboardInteractive": "Tastatur-Interaktiv erzwingen",
"forceKeyboardInteractiveDesc": "Erzwingt die Verwendung von Tastatur-interaktiver Authentifizierung. Dies wird manchmal für Server benötigt, die eine Zwei-Faktor-Authentifizierung verwenden (TOTP/2FA).",
"overrideCredentialUsername": "Benutzername überschreiben",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Im Browser öffnen",
"warpgateContinue": "Ich habe die Authentifizierung abgeschlossen",
"warpgateTimeout": "Warpgate Authentifizierungs-Timeout. Bitte verbinden Sie sich erneut.",
- "opksshAuthRequired": "OPKSSH Authentifizierung erforderlich",
- "opksshAuthDescription": "Vollständige Authentifizierung in Ihrem Browser, um fortzufahren. Diese Sitzung bleibt 24 Stunden gültig.",
- "opksshAuthUrl": "Authentifizierungs-URL",
- "opksshOpenBrowser": "Browser zum Authentifizieren öffnen",
- "opksshWaitingForAuth": "Warte auf Authentifizierung im Browser...",
- "opksshAuthenticating": "Authentifizierung wird verarbeitet...",
- "opksshTimeout": "Timeout der Authentifizierung. Bitte versuchen Sie es erneut.",
- "opksshAuthFailed": "Authentifizierung fehlgeschlagen. Bitte überprüfen Sie Ihre Zugangsdaten und versuchen Sie es erneut.",
- "opksshConfigMissing": "OPKSSH Konfiguration nicht gefunden. Bitte erstellen Sie ~/.opk/config.yml mit Ihren OIDC Providereinstellungen. Siehe Dokumentation: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Passwort einfügen?",
"sudoPasswordPopupHint": "Drücken Sie die Eingabetaste, Esc zum Verwerfen",
"sudoPasswordPopupConfirm": "Insert",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Automatisch {{method}} Authentifizierung...",
"totpTimeout": "TOTP-Verifizierungs-Timeout. Bitte erneut verbinden.",
"passwordTimeout": "Timeout zur Passwortüberprüfung. Bitte erneut verbinden.",
- "connectionRejected": "Verbindung vom Server abgelehnt. Bitte überprüfen Sie Ihre Authentifizierung und Netzwerkkonfiguration.",
- "hostKeyRejected": "Überprüfung des SSH-Host-Schlüssels abgelehnt. Verbindung abgebrochen."
+ "connectionRejected": "Verbindung vom Server abgelehnt. Bitte überprüfen Sie Ihre Authentifizierung und Netzwerkkonfiguration."
},
"fileManager": {
"title": "Datei-Manager",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Passwort erfolgreich zurückgesetzt",
"passwordResetSuccessDesc": "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich jetzt mit Ihrem neuen Passwort anmelden."
},
- "hostKey": {
- "verifyNewHost": "SSH-Hostschlüssel überprüfen",
- "keyChangedWarning": "SSH-Hostschlüssel geändert",
- "firstConnectionTitle": "Verbindung zu diesem Host zum ersten Mal",
- "firstConnectionDescription": "Die Authentizität dieses Hosts kann nicht festgestellt werden. Überprüfen Sie den Fingerabdruck mit dem, was Sie erwarten.",
- "keyChangedDescription": "Der Host-Schlüssel für diesen Server hat sich seit Ihrer letzten Verbindung geändert. Dies könnte auf ein Sicherheitsproblem hinweisen.",
- "previousKey": "Vorheriger Schlüssel",
- "newFingerprint": "Neuer Fingerabdruck",
- "fingerprint": "Fingerabdruck",
- "verifyInstructions": "Wenn Sie diesem Host vertrauen, klicken Sie auf Akzeptieren, um fortzufahren und den Fingerabdruck für zukünftige Verbindungen zu speichern.",
- "securityWarning": "Sicherheitswarnung",
- "acceptAndContinue": "Akzeptieren & fortfahren",
- "acceptNewKey": "Neuen Schlüssel akzeptieren & fortfahren"
- },
"errors": {
"notFound": "Seite nicht gefunden",
"unauthorized": "Unberechtigter Zugriff",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Terminal",
"hostSidebarSettings": "Host & Seitenleiste",
"snippetsSettings": "Schnipsel",
- "confirmSnippetExecution": "Snippet Ausführung bestätigen",
- "confirmSnippetExecutionDesc": "Bestätigungsdialog vor dem Ausführen von Snippets anzeigen",
"updateSettings": "Updates",
"disableUpdateCheck": "Updateprüfung deaktivieren",
"disableUpdateCheckDesc": "Stoppt die Suche nach neuen Versionen beim Start und Dashboard. Reduziert Netzwerkanfragen.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 oder beispiel.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/el_GR.json b/src/locales/translated/el_GR.json
index 8f474b83..722820f6 100644
--- a/src/locales/translated/el_GR.json
+++ b/src/locales/translated/el_GR.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Διαπιστευτήρια",
"credentialsViewer": "Προβολέας Πιστοποιητικών",
"manageYourSSHCredentials": "Διαχειριστείτε τα SSH διαπιστευτήρια σας με ασφάλεια",
"addCredential": "Προσθήκη Διαπιστευτηρίου",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Αυτό θα προσθέσει με ασφάλεια το δημόσιο κλειδί στο αρχείο ~/.ssh/authorized_keys του κεντρικού υπολογιστή του στόχου χωρίς να αντικαταστήσει τα υπάρχοντα κλειδιά. Η λειτουργία είναι αναστρέψιμη.",
"chooseHostToDeploy": "Επιλέξτε έναν κεντρικό υπολογιστή για ανάπτυξη...",
"deploying": "Εφαρμογή...",
- "copyDeployCommand": "Αντιγραφή Εφαρμογής Εντολής",
- "copyDeployCommandDescription": "Αντιγράψτε μια εντολή κελύφους για να προσθέσετε χειροκίνητα το δημόσιο κλειδί στο αρχείο authorized_keys ενός κεντρικού υπολογιστή. Χρήσιμο όταν η ταυτοποίηση κωδικού πρόσβασης είναι απενεργοποιημένη.",
- "deployCommandCopied": "Η εντολή ανάπτυξης αντιγράφηκε στο πρόχειρο",
- "manualDeployInfo": "Επικολλήστε αυτήν την εντολή στο τερματικό του κεντρικού υπολογιστή προορισμού (π.χ., κονσόλα Proxmox, IPMI ή φυσική πρόσβαση) για να προσθέσετε το SSH κλειδί.",
"name": "Όνομα",
"noHostsAvailable": "Δεν υπάρχουν διαθέσιμοι υπολογιστές",
"noHostsMatchSearch": "Κανένας υπολογιστής δεν ταιριάζει με την αναζήτησή σας",
@@ -303,9 +298,7 @@
"createFolder": "Δημιουργία Φακέλου",
"editFolder": "Επεξεργασία Φακέλου",
"editFolderDescription": "Προσαρμόστε το φάκελο αποκοπής",
- "createFolderDescription": "Οργανώστε τα αποσπάσματα σας σε φακέλους",
- "confirmExecution": "Εκτέλεση \"{{name}}\"?",
- "confirmExecutionDesc": "Είστε βέβαιοι ότι θέλετε να εκτελέσετε αυτό το απόφραξη?"
+ "createFolderDescription": "Οργανώστε τα αποσπάσματα σας σε φακέλους"
},
"commandHistory": {
"title": "Ιστορικό",
@@ -409,7 +402,6 @@
"required": "Απαιτείται",
"optional": "Προαιρετικό",
"connect": "Σύνδεση",
- "copied": "Αντιγράφηκε",
"connecting": "Σύνδεση...",
"creating": "Δημιουργία...",
"clear": "Εκκαθάριση",
@@ -502,7 +494,6 @@
"checking": "Έλεγχος...",
"checkingDatabase": "Έλεγχος σύνδεσης βάσης δεδομένων...",
"checkingAuthentication": "Έλεγχος ταυτοποίησης...",
- "backendReconnected": "Έγινε επαναφορά σύνδεσης διακομιστή",
"actions": "Ενέργειες",
"remove": "Αφαίρεση",
"revoke": "Revoke",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Διαχειριστής Υπολογιστών",
- "hosts": "Υπολογιστές",
"sshHosts": "SSH Hosts",
"noHosts": "Δεν Υπάρχουν Υπολογιστές SSH",
"noHostsMessage": "Δεν έχετε προσθέσει ακόμα κανένα SSH hosts. Κάντε κλικ στο κουμπί \"Προσθήκη Host\" για να ξεκινήσετε.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Αποτυχία εισαγωγής αρχείου JSON",
"connectionDetails": "Λεπτομέρειες Σύνδεσης",
"organization": "Οργανισμός",
- "ipAddress": "Διεύθυνση IP ή όνομα κεντρικού υπολογιστή",
+ "ipAddress": "Διεύθυνση IP",
"port": "Θύρα",
"name": "Όνομα",
"username": "Όνομα Χρήστη",
@@ -925,8 +915,6 @@
"key": "Κλειδί",
"credential": "Διαπιστευτήριο",
"none": "Κανένα",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Σας παρακινεί για την ταυτοποίηση ιστού OPKSSH σε κάθε σύνδεση. Το διακριτικό ταυτοποίησης θα διαρκέσει 24 ώρες μέχρι να ανανεωθεί.",
"selectCredential": "Επιλέξτε Διαπιστευτήρια",
"selectCredentialPlaceholder": "Επιλέξτε ένα πιστοποιητικό...",
"credentialRequired": "Απαιτείται διαπιστευτήριο όταν χρησιμοποιείται έλεγχος ταυτότητας διαπιστευτηρίων",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Πληκτρολόγιο-Διαδραστική Ταυτοποίηση",
"noneAuthDescription": "Αυτή η μέθοδος ταυτοποίησης θα χρησιμοποιεί τον διαδραστικό έλεγχο ταυτότητας πληκτρολογίου κατά τη σύνδεση με τον διακομιστή SSH.",
"noneAuthDetails": "Ο διαδραστικός έλεγχος ταυτότητας πληκτρολογίου επιτρέπει στο διακομιστή να σας ζητά διαπιστευτήρια κατά τη διάρκεια της σύνδεσης. Αυτό είναι χρήσιμο για διακομιστές που απαιτούν έλεγχο ταυτότητας πολλαπλών παραγόντων ή αν δεν θέλετε να αποθηκεύσετε τα διαπιστευτήρια τοπικά.",
- "opksshAuthTitle": "Επαλήθευση OPKSSH",
"forceKeyboardInteractive": "Εξαναγκασμός Keyboard-Interactive",
"forceKeyboardInteractiveDesc": "Δυνάμει της χρήσης διαδραστικού ελέγχου ταυτότητας πληκτρολογίου. Αυτό μερικές φορές απαιτείται για διακομιστές που χρησιμοποιούν έλεγχο ταυτότητας δύο παραγόντων (TOTP/2FA).",
"overrideCredentialUsername": "Παράκαμψη Ονόματος Διαπιστευτηρίου Χρήστη",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Άνοιγμα σε περιηγητή",
"warpgateContinue": "Έχω Ολοκληρώσει την Πιστοποίηση",
"warpgateTimeout": "Λήξη χρονικού ορίου ταυτοποίησης. Παρακαλώ επανασύνδεση.",
- "opksshAuthRequired": "Απαιτείται Πιστοποίηση OPKSSH",
- "opksshAuthDescription": "Πλήρης ταυτοποίηση στο πρόγραμμα περιήγησής σας για να συνεχίσετε. Αυτή η συνεδρία θα παραμείνει έγκυρη για 24 ώρες.",
- "opksshAuthUrl": "URL Ταυτοποίησης",
- "opksshOpenBrowser": "Άνοιγμα περιηγητή για έλεγχο ταυτότητας",
- "opksshWaitingForAuth": "Αναμονή για έλεγχο ταυτότητας στο πρόγραμμα περιήγησης...",
- "opksshAuthenticating": "Επεξεργασία ταυτοποίησης...",
- "opksshTimeout": "Λήξη χρονικού ορίου επαλήθευσης. Παρακαλώ προσπαθήστε ξανά.",
- "opksshAuthFailed": "Ο έλεγχος ταυτότητας απέτυχε. Παρακαλώ ελέγξτε τα στοιχεία σας και προσπαθήστε ξανά.",
- "opksshConfigMissing": "Η ρύθμιση παραμέτρων OPKSSH δεν βρέθηκε. Παρακαλώ δημιουργήστε ~/.opk/config.yml με τις ρυθμίσεις παρόχου OIDC. Δείτε την τεκμηρίωση: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Εισαγωγή Κωδικού Πρόσβασης?",
"sudoPasswordPopupHint": "Πατήστε Enter για εισαγωγή, Esc για απόρριψη",
"sudoPasswordPopupConfirm": "Insert",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Αυτόματη προσπάθεια ελέγχου ταυτότητας {{method}}...",
"totpTimeout": "Λήξη χρονικού ορίου επαλήθευσης TOTP. Επανασύνδεση.",
"passwordTimeout": "Λήξη χρονικού ορίου επαλήθευσης κωδικού πρόσβασης. Επανασύνδεση.",
- "connectionRejected": "Η σύνδεση απορρίφθηκε από το διακομιστή. Παρακαλώ ελέγξτε τον έλεγχο ταυτότητας και τις ρυθμίσεις δικτύου.",
- "hostKeyRejected": "Η επαλήθευση SSH κεντρικού υπολογιστή απορρίφθηκε. Η σύνδεση ακυρώθηκε."
+ "connectionRejected": "Η σύνδεση απορρίφθηκε από το διακομιστή. Παρακαλώ ελέγξτε τον έλεγχο ταυτότητας και τις ρυθμίσεις δικτύου."
},
"fileManager": {
"title": "Διαχειριστής Αρχείων",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Επιτυχής Επαναφορά Κωδικού Πρόσβασης",
"passwordResetSuccessDesc": "Επιτυχής επαναφορά του κωδικού πρόσβασης. Μπορείτε τώρα να συνδεθείτε με το νέο κωδικό πρόσβασης."
},
- "hostKey": {
- "verifyNewHost": "Επαλήθευση Κλειδιού Διακομιστή SSH",
- "keyChangedWarning": "Το Κλειδί Υπολογιστή SSH Άλλαξε",
- "firstConnectionTitle": "Πρώτη σύνδεση σε αυτόν τον υπολογιστή",
- "firstConnectionDescription": "Η αυθεντικότητα αυτού του κεντρικού υπολογιστή δεν μπορεί να καθοριστεί. Βεβαιωθείτε ότι το δακτυλικό αποτύπωμα ταιριάζει με αυτό που περιμένετε.",
- "keyChangedDescription": "Το κλειδί φιλοξενίας αυτού του διακομιστή έχει αλλάξει από την τελευταία σας σύνδεση. Αυτό μπορεί να υποδηλώνει ένα πρόβλημα ασφάλειας.",
- "previousKey": "Προηγούμενο Κλειδί",
- "newFingerprint": "Νέο Δακτυλικό Αποτύπωμα",
- "fingerprint": "Αποτύπωμα",
- "verifyInstructions": "Αν εμπιστεύεστε αυτόν τον οικοδεσπότη, κάντε κλικ για να συνεχίσετε και να αποθηκεύσετε αυτό το δακτυλικό αποτύπωμα για μελλοντικές συνδέσεις.",
- "securityWarning": "Προειδοποίηση Ασφαλείας",
- "acceptAndContinue": "Αποδοχή & Συνέχεια",
- "acceptNewKey": "Αποδοχή Νέου Κλειδιού & Συνέχεια"
- },
"errors": {
"notFound": "Η σελίδα δεν βρέθηκε",
"unauthorized": "Μη εξουσιοδοτημένη πρόσβαση",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Τερματικό",
"hostSidebarSettings": "Κεντρικός Υπολογιστής & Πλαϊνή Μπάρα",
"snippetsSettings": "Δείγματα",
- "confirmSnippetExecution": "Επιβεβαίωση Εκτέλεσης Δείκτη",
- "confirmSnippetExecutionDesc": "Εμφάνιση διαλόγου επιβεβαίωσης πριν την εκτέλεση αποσπασμάτων",
"updateSettings": "Ενημερώσεις",
"disableUpdateCheck": "Απενεργοποίηση Ελέγχου Ενημέρωσης",
"disableUpdateCheckDesc": "Διακοπή ελέγχου για νέες εκδόσεις κατά την εκκίνηση και τον πίνακα ελέγχου. Μειώνει τα αιτήματα δικτύου.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 ή example.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/es_ES.json b/src/locales/translated/es_ES.json
index 3a60502f..c807924e 100644
--- a/src/locales/translated/es_ES.json
+++ b/src/locales/translated/es_ES.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Credenciales",
"credentialsViewer": "Visor de credenciales",
"manageYourSSHCredentials": "Administrar sus credenciales SSH de forma segura",
"addCredential": "Añadir credencial",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Esto añadirá con seguridad la clave pública al archivo ~/.ssh/authorized_keys del host de destino sin sobrescribir las claves existentes. La operación es reversible.",
"chooseHostToDeploy": "Seleccione un host al que desplegar...",
"deploying": "Desplegando...",
- "copyDeployCommand": "Copiar comando de despliegue",
- "copyDeployCommandDescription": "Copie un comando de shell para agregar manualmente la clave pública al archivo authorized_keys de un host. Útil cuando la autenticación de contraseña está desactivada.",
- "deployCommandCopied": "Desplegar comando copiado al portapapeles",
- "manualDeployInfo": "Pegue este comando en el terminal del host de destino (por ejemplo, consola Proxmox, IPMI o acceso físico) para añadir la clave SSH.",
"name": "Nombre",
"noHostsAvailable": "No hay hosts disponibles",
"noHostsMatchSearch": "Ningún host coincide con tu búsqueda",
@@ -303,9 +298,7 @@
"createFolder": "Crear carpeta",
"editFolder": "Editar carpeta",
"editFolderDescription": "Personaliza tu carpeta de fragmentos",
- "createFolderDescription": "Organiza tus fragmentos en carpetas",
- "confirmExecution": "¿Ejecutar \"{{name}}\"?",
- "confirmExecutionDesc": "¿Está seguro de que desea ejecutar este snippet?"
+ "createFolderDescription": "Organiza tus fragmentos en carpetas"
},
"commandHistory": {
"title": "Historial",
@@ -409,7 +402,6 @@
"required": "Requerido",
"optional": "Opcional",
"connect": "Conectar",
- "copied": "Copiado",
"connecting": "Conectando...",
"creating": "Creando...",
"clear": "Claro",
@@ -502,7 +494,6 @@
"checking": "Comprobando...",
"checkingDatabase": "Comprobando conexión a la base de datos...",
"checkingAuthentication": "Comprobando autenticación...",
- "backendReconnected": "Conexión con el servidor restaurada",
"actions": "Acciones",
"remove": "Eliminar",
"revoke": "Revoke",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Gestor de Anfitriones",
- "hosts": "Anfitriones",
"sshHosts": "Equipos SSH",
"noHosts": "No hay hosts SSH",
"noHostsMessage": "Aún no has añadido ningún host SSH. Haz clic en \"Añadir Host\" para empezar.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Error al importar el archivo JSON",
"connectionDetails": "Detalles de la conexión",
"organization": "Organización",
- "ipAddress": "Dirección IP o nombre de host",
+ "ipAddress": "Dirección IP",
"port": "Puerto",
"name": "Nombre",
"username": "Usuario",
@@ -925,8 +915,6 @@
"key": "Clave",
"credential": "Credencial",
"none": "Ninguna",
- "opkssh": "OPKSH",
- "opksshAuthDescription": "Te solicita la autenticación web de OPKSSH en cada conexión. Tu token de autenticación durará 24 horas hasta que deba ser renovado.",
"selectCredential": "Seleccionar credencial",
"selectCredentialPlaceholder": "Elija una credencial...",
"credentialRequired": "Se requiere credencial cuando se utiliza autenticación de credenciales",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Autenticación interactiva del teclado",
"noneAuthDescription": "Este método de autenticación utilizará la autenticación interactiva del teclado cuando se conecte al servidor SSH.",
"noneAuthDetails": "La autenticación interactiva de teclado permite al servidor pedir credenciales durante la conexión. Esto es útil para servidores que requieren autenticación de múltiples factores o si no desea guardar las credenciales localmente.",
- "opksshAuthTitle": "Autenticación OPKSSH",
"forceKeyboardInteractive": "Forzar interactivo del teclado",
"forceKeyboardInteractiveDesc": "Fuerza el uso de autenticación interactiva de teclado. Esto a veces es necesario para servidores que utilizan Autenticación de Doble Factor (TOTP/2FA).",
"overrideCredentialUsername": "Reemplazar nombre de usuario de credenciales",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Abrir en el navegador",
"warpgateContinue": "He completado la autenticación",
"warpgateTimeout": "Se agotó el tiempo de autenticación de Warpgate. Por favor, vuelva a conectar.",
- "opksshAuthRequired": "Autenticación OPKSSH requerida",
- "opksshAuthDescription": "Autenticación completa en su navegador para continuar. Esta sesión permanecerá válida durante 24 horas.",
- "opksshAuthUrl": "URL de autenticación",
- "opksshOpenBrowser": "Abrir navegador para autenticar",
- "opksshWaitingForAuth": "Esperando la autenticación en el navegador...",
- "opksshAuthenticating": "Procesando autenticación...",
- "opksshTimeout": "Se ha agotado el tiempo de autenticación. Por favor, inténtalo de nuevo.",
- "opksshAuthFailed": "Error de autenticación. Por favor, comprueba tus credenciales e inténtalo de nuevo.",
- "opksshConfigMissing": "Configuración OPKSSH no encontrada. Por favor cree ~/.opk/config.yml con la configuración de su proveedor OIDC. Vea la documentación: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "¿Insertar contraseña?",
"sudoPasswordPopupHint": "Presione Enter para insertar, Esc para descartar",
"sudoPasswordPopupConfirm": "Insert",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Intentando automáticamente autenticación {{method}}...",
"totpTimeout": "Tiempo de verificación TOTP. Por favor, vuelva a conectar.",
"passwordTimeout": "Tiempo de verificación de contraseña. Por favor, vuelva a conectar.",
- "connectionRejected": "Conexión rechazada por el servidor. Por favor, compruebe su autenticación y configuración de red.",
- "hostKeyRejected": "Verificación de clave del host SSH rechazada. Conexión cancelada."
+ "connectionRejected": "Conexión rechazada por el servidor. Por favor, compruebe su autenticación y configuración de red."
},
"fileManager": {
"title": "Gestor de archivos",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Contraseña restablecida con éxito",
"passwordResetSuccessDesc": "Su contraseña se ha restablecido con éxito. Ahora puede iniciar sesión con su nueva contraseña."
},
- "hostKey": {
- "verifyNewHost": "Verificar clave SSH Host",
- "keyChangedWarning": "SSH Host Key cambiada",
- "firstConnectionTitle": "Primera conexión a este host",
- "firstConnectionDescription": "La autenticidad de este host no se puede establecer. Verifique que la huella digital coincide con lo que espera.",
- "keyChangedDescription": "La clave de host para este servidor ha cambiado desde su última conexión. Esto podría indicar un problema de seguridad.",
- "previousKey": "Clave anterior",
- "newFingerprint": "Nueva huella dactilar",
- "fingerprint": "Huella dactilar",
- "verifyInstructions": "Si confía en este host, haga clic en Aceptar para continuar y guardar esta huella dactilar para conexiones futuras.",
- "securityWarning": "Advertencia de seguridad",
- "acceptAndContinue": "Aceptar y continuar",
- "acceptNewKey": "Aceptar nueva clave y continuar"
- },
"errors": {
"notFound": "Página no encontrada",
"unauthorized": "Acceso no autorizado",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Terminal",
"hostSidebarSettings": "Anfitrión y barra lateral",
"snippetsSettings": "Fragmentos",
- "confirmSnippetExecution": "Confirmar ejecución de Snippet",
- "confirmSnippetExecutionDesc": "Mostrar diálogo de confirmación antes de ejecutar fragmentos",
"updateSettings": "Actualizaciones",
"disableUpdateCheck": "Desactivar revisión de actualización",
"disableUpdateCheckDesc": "Deja de buscar nuevas versiones en el arranque y el tablero. Reduce las solicitudes de red.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 o ejemplo.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/fi_FI.json b/src/locales/translated/fi_FI.json
index addf2a18..6fce4c8c 100644
--- a/src/locales/translated/fi_FI.json
+++ b/src/locales/translated/fi_FI.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Käyttäjätunnukset",
"credentialsViewer": "Käyttöoikeustietojen Katselin",
"manageYourSSHCredentials": "Hallitse SSH tunnuksiasi turvallisesti",
"addCredential": "Lisää Käyttöoikeustieto",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Tämä lisää turvallisesti julkisen avaimen kohdetiedoston ~/.ssh/authorized_keys tiedostoon ilman olemassa olevien avainten korvaamista. Toiminto on palautettavissa.",
"chooseHostToDeploy": "Valitse isäntä johon haluat ottaa käyttöön...",
"deploying": "Otetaan Käyttöön...",
- "copyDeployCommand": "Kopioi Deploy Komento",
- "copyDeployCommandDescription": "Kopioi komento, jolla julkinen avain lisätään manuaalisesti isännän authorized_keys tiedostoon. Hyödyllinen, kun salasanan todennus on pois päältä.",
- "deployCommandCopied": "Julkaise komento kopioitu leikepöydälle",
- "manualDeployInfo": "Liitä tämä komento kohdeisännän päätteeseen (esim. Proxmox konsoli, IPMI tai fyysinen pääsy) lisätäksesi SSH avaimen.",
"name": "Nimi",
"noHostsAvailable": "Ei isäntiä saatavilla",
"noHostsMatchSearch": "Yksikään isäntä ei vastaa hakuasi",
@@ -303,9 +298,7 @@
"createFolder": "Luo Kansio",
"editFolder": "Muokkaa Kansiota",
"editFolderDescription": "Muokkaa snippet-kansiota",
- "createFolderDescription": "Järjestä leikkeet kansioihin",
- "confirmExecution": "Suorita \"{{name}}\"?",
- "confirmExecutionDesc": "Oletko varma, että haluat suorittaa tämän ohjelman?"
+ "createFolderDescription": "Järjestä leikkeet kansioihin"
},
"commandHistory": {
"title": "Historia",
@@ -409,7 +402,6 @@
"required": "Pakollinen",
"optional": "Valinnainen",
"connect": "Yhdistä",
- "copied": "Kopioitu",
"connecting": "Yhdistetään...",
"creating": "Luodaan...",
"clear": "Tyhjennä",
@@ -502,7 +494,6 @@
"checking": "Tarkistetaan...",
"checkingDatabase": "Tarkistetaan tietokantayhteyttä...",
"checkingAuthentication": "Tarkistetaan todennusta...",
- "backendReconnected": "Palvelinyhteys palautettu",
"actions": "Toiminnot",
"remove": "Poista",
"revoke": "Revoke",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Isäntälaitteen Hallinta",
- "hosts": "Isäntä",
"sshHosts": "Ssh Isäntä",
"noHosts": "Ei Ssh Isäntiä",
"noHostsMessage": "Et ole vielä lisännyt yhtään SSH isäntää. Napsauta \"Lisää isäntä\" aloittaaksesi.",
@@ -846,7 +836,7 @@
"failedToImportJson": "JSON-tiedoston tuonti epäonnistui",
"connectionDetails": "Yhteyden Tiedot",
"organization": "Organisaatio",
- "ipAddress": "IP-osoite tai isäntänimi",
+ "ipAddress": "Ip Osoite",
"port": "Portti",
"name": "Nimi",
"username": "Käyttäjätunnus",
@@ -925,8 +915,6 @@
"key": "Avain",
"credential": "Käyttöoikeustiedot",
"none": "Ei Mitään",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Pyytää sinua OPKSSH web-authiin jokaisen yhteyden yhteydessä. Käyttöoikeustunnuksesi kestää 24 tuntia, kunnes se on uusittava.",
"selectCredential": "Valitse Käyttöoikeustieto",
"selectCredentialPlaceholder": "Valitse käyttäjätunnus...",
"credentialRequired": "Käyttöoikeustiedot vaaditaan, kun käytetään käyttöoikeustietojen todennusta",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Näppäimistöinteraktiivinen todennus",
"noneAuthDescription": "Tämä todennusmenetelmä käyttää näppäimistöinteraktiivista todennusta yhdistettäessä SSH-palvelimeen.",
"noneAuthDetails": "Näppäimistötodennus antaa palvelimen pyytää sinulta tunnistetietoja yhteyden muodostamisen aikana. Tämä on hyödyllistä palvelimilla, jotka vaativat monivaiheisen todennuksen tai jos et halua tallentaa tunnistetietoja paikallisesti.",
- "opksshAuthTitle": "OPKSSH Todennus",
"forceKeyboardInteractive": "Pakota näppäimistöinteraktiivinen",
"forceKeyboardInteractiveDesc": "Pakottaa näppäimistöinteraktiivisen todennuksen käytön. Tätä vaaditaan joskus palvelimilla, jotka käyttävät kaksivaiheista todennusta (TOTP/2FA).",
"overrideCredentialUsername": "Ohita tunnistetiedot Käyttäjätunnus",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Avaa selaimessa",
"warpgateContinue": "Olen Suorittanut Todentamisen",
"warpgateTimeout": "Warpgate autentikoinnin aikakatkaisu. Yhdistä uudelleen.",
- "opksshAuthRequired": "OPKSSH Todennus Vaaditaan",
- "opksshAuthDescription": "Lopeta todennus selaimessasi jatkaaksesi. Tämä istunto pysyy voimassa 24 tuntia.",
- "opksshAuthUrl": "Todennuksen URL",
- "opksshOpenBrowser": "Avaa selain todennettavaksi",
- "opksshWaitingForAuth": "Odotetaan todennusta selaimessa...",
- "opksshAuthenticating": "Käsitellään todennusta...",
- "opksshTimeout": "Todennus aikakatkaistiin. Yritä uudelleen.",
- "opksshAuthFailed": "Todennus epäonnistui. Tarkista käyttäjätunnuksesi ja yritä uudelleen.",
- "opksshConfigMissing": "OPKSSH konfiguraatiota ei löytynyt. Luo ~/.opk/config.yml OIDC palveluntarjoajan asetuksissa. Katso dokumentaatio: https://github.com/openpubkey/opkssh#konfiguraatio",
"sudoPasswordPopupTitle": "Syötä salasana?",
"sudoPasswordPopupHint": "Lisää painamalla Enter, hylkää painamalla Esc",
"sudoPasswordPopupConfirm": "Lisää",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Yritetään automaattisesti {{method}} todennusta...",
"totpTimeout": "TOTP vahvistuksen aikakatkaisu. Yhdistä uudelleen.",
"passwordTimeout": "Salasanan varmennuksen aikakatkaisu. Yhdistä uudelleen.",
- "connectionRejected": "Palvelin hylkäsi yhteyden. Tarkista todennus ja verkon asetukset.",
- "hostKeyRejected": "SSH isäntäavaimen vahvistus hylätty. Yhteys peruutettu."
+ "connectionRejected": "Palvelin hylkäsi yhteyden. Tarkista todennus ja verkon asetukset."
},
"fileManager": {
"title": "Tiedostojen Hallinta",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Salasanan palautus onnistui",
"passwordResetSuccessDesc": "Salasanasi on nollattu onnistuneesti. Voit nyt kirjautua sisään uudella salasanallasi."
},
- "hostKey": {
- "verifyNewHost": "Vahvista Ssh Isäntäavain",
- "keyChangedWarning": "Ssh Isäntäavain Vaihdettu",
- "firstConnectionTitle": "Yhteyden muodostaminen tähän palvelimeen ensimmäistä kertaa",
- "firstConnectionDescription": "Tämän isännän aitoutta ei voida vahvistaa. Varmista sormenjälki vastaa sitä, mitä odotat.",
- "keyChangedDescription": "Palvelimen isäntäavain on muuttunut viimeisen yhteytesi jälkeen. Tämä saattaa merkitä tietoturvaongelmaa.",
- "previousKey": "Edellinen Avain",
- "newFingerprint": "Uusi Sormenjälki",
- "fingerprint": "Sormenjälki",
- "verifyInstructions": "Jos luotat tähän isäntään, jatka ja tallenna sormenjälki tulevia yhteyksiä varten.",
- "securityWarning": "Turvallisuusvaroitus",
- "acceptAndContinue": "Hyväksy & Jatka",
- "acceptNewKey": "Hyväksy Uusi Avain Ja Jatka"
- },
"errors": {
"notFound": "Sivua ei löytynyt",
"unauthorized": "Luvaton pääsy",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Pääte",
"hostSidebarSettings": "Isäntä ja sivupalkki",
"snippetsSettings": "Projekti",
- "confirmSnippetExecution": "Vahvista Projektin Suoritus",
- "confirmSnippetExecutionDesc": "Näytä vahvistusvalintaikkuna ennen snippettien suorittamista",
"updateSettings": "Päivitykset",
"disableUpdateCheck": "Poista Päivitystarkistus Käytöstä",
"disableUpdateCheckDesc": "Lopeta uusien versioiden tarkistaminen käynnistettäessä ja hallintapaneelissa. Vähennä verkkopyyntöjä.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 tai esimerkki.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/fr_FR.json b/src/locales/translated/fr_FR.json
index 1cfaf419..4931459c 100644
--- a/src/locales/translated/fr_FR.json
+++ b/src/locales/translated/fr_FR.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Identifiants",
"credentialsViewer": "Visionneuse des identifiants",
"manageYourSSHCredentials": "Gérez vos identifiants SSH en toute sécurité",
"addCredential": "Ajouter un mot de passe",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Ceci ajoutera la clé publique au fichier ~/.ssh/authorized_keys de l'hôte cible sans écraser les clés existantes. L'opération est réversible.",
"chooseHostToDeploy": "Choisissez un hôte à déployer vers...",
"deploying": "Déploiement...",
- "copyDeployCommand": "Copier la commande de déploiement",
- "copyDeployCommandDescription": "Copiez une commande shell pour ajouter manuellement la clé publique au fichier authorized_keys d'un hôte. Utile lorsque l'authentification par mot de passe est désactivée.",
- "deployCommandCopied": "Déployer la commande copiée dans le presse-papiers",
- "manualDeployInfo": "Collez cette commande dans le terminal de l'hôte cible (par exemple, console Proxmox, IPMI, ou accès physique) pour ajouter la clé SSH.",
"name": "Nom",
"noHostsAvailable": "Aucun hôte disponible",
"noHostsMatchSearch": "Aucun hôte ne correspond à votre recherche",
@@ -303,9 +298,7 @@
"createFolder": "Créer un dossier",
"editFolder": "Modifier le dossier",
"editFolderDescription": "Personnaliser votre dossier de snippet",
- "createFolderDescription": "Organiser vos modules de texte dans des dossiers",
- "confirmExecution": "Exécuter \"{{name}} \" ?",
- "confirmExecutionDesc": "Êtes-vous sûr de vouloir exécuter ce snippet?"
+ "createFolderDescription": "Organiser vos modules de texte dans des dossiers"
},
"commandHistory": {
"title": "Historique",
@@ -409,7 +402,6 @@
"required": "Requis",
"optional": "Optionnel",
"connect": "Connecter",
- "copied": "Copié",
"connecting": "Connexion en cours...",
"creating": "Création en cours...",
"clear": "Nettoyer",
@@ -502,7 +494,6 @@
"checking": "Vérification...",
"checkingDatabase": "Vérification de la connexion à la base de données...",
"checkingAuthentication": "Vérification de l'authentification...",
- "backendReconnected": "Connexion au serveur restaurée",
"actions": "Actions",
"remove": "Retirer",
"revoke": "Revoke",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Responsable d'hôte",
- "hosts": "Hôtes",
"sshHosts": "Hôtes SSH",
"noHosts": "Aucun hôte SSH",
"noHostsMessage": "Vous n'avez pas encore ajouté d'hôtes SSH. Cliquez sur \"Ajouter Hôte\" pour commencer.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Impossible d'importer le fichier JSON",
"connectionDetails": "Détails de la connexion",
"organization": "Organisation",
- "ipAddress": "Adresse IP ou nom d'hôte",
+ "ipAddress": "Adresse IP",
"port": "Port",
"name": "Nom",
"username": "Nom d'utilisateur",
@@ -925,8 +915,6 @@
"key": "Clés",
"credential": "Identification",
"none": "Aucun",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Vous invite à l'authentification OPKSSH à chaque connexion. Votre jeton d'authentification durera 24 heures jusqu'à ce qu'il soit renouvelé.",
"selectCredential": "Sélectionner les informations d'identification",
"selectCredentialPlaceholder": "Choisissez un identifiant...",
"credentialRequired": "Les informations d'identification sont requises lors de l'utilisation de l'authentification",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Authentification interactive du clavier",
"noneAuthDescription": "Cette méthode d'authentification utilisera l'authentification interactive au clavier lors de la connexion au serveur SSH.",
"noneAuthDetails": "L'authentification interactive au clavier permet au serveur de vous demander des informations d'identification pendant la connexion. Ceci est utile pour les serveurs qui nécessitent une authentification multi-facteurs ou si vous ne voulez pas enregistrer les identifiants localement.",
- "opksshAuthTitle": "Authentification OPKSSH",
"forceKeyboardInteractive": "Forcer l'interaction du clavier",
"forceKeyboardInteractiveDesc": "Force l'utilisation de l'authentification interactive au clavier. Ceci est parfois nécessaire pour les serveurs qui utilisent l'authentification à deux facteurs (TOTP/2FA).",
"overrideCredentialUsername": "Remplacer le nom d'utilisateur de l'identifiant",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Ouvrir dans le navigateur",
"warpgateContinue": "J'ai terminé l'authentification",
"warpgateTimeout": "Délai d'authentification de Warpgate. Veuillez vous reconnecter.",
- "opksshAuthRequired": "Authentification OPKSSH requise",
- "opksshAuthDescription": "Authentification complète dans votre navigateur pour continuer. Cette session restera valide pendant 24 heures.",
- "opksshAuthUrl": "URL d'authentification",
- "opksshOpenBrowser": "Ouvrez le navigateur pour vous authentifier",
- "opksshWaitingForAuth": "En attente d'authentification dans le navigateur...",
- "opksshAuthenticating": "Traitement de l'authentification...",
- "opksshTimeout": "Délai d'authentification dépassé. Veuillez réessayer.",
- "opksshAuthFailed": "L'authentification a échoué. Veuillez vérifier vos identifiants et réessayer.",
- "opksshConfigMissing": "La configuration OPKSSH est introuvable. Veuillez créer ~/.opk/config.yml avec vos paramètres de fournisseur OIDC. Voir la documentation : https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Insérer un mot de passe ?",
"sudoPasswordPopupHint": "Appuyez sur Entrée pour insérer, Échap pour rejeter",
"sudoPasswordPopupConfirm": "Insert",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Essai automatique de l'authentification {{method}}...",
"totpTimeout": "Délai de vérification TOTP. Veuillez vous reconnecter.",
"passwordTimeout": "Délai de vérification du mot de passe. Veuillez vous reconnecter.",
- "connectionRejected": "Connexion refusée par le serveur. Veuillez vérifier votre authentification et la configuration du réseau.",
- "hostKeyRejected": "La vérification de la clé d'hôte SSH a été rejetée. La connexion a été annulée."
+ "connectionRejected": "Connexion refusée par le serveur. Veuillez vérifier votre authentification et la configuration du réseau."
},
"fileManager": {
"title": "Gestionnaire de fichiers",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Mot de passe réinitialisé avec succès",
"passwordResetSuccessDesc": "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
},
- "hostKey": {
- "verifyNewHost": "Vérifier la clé d'hôte SSH",
- "keyChangedWarning": "Clé d'hôte SSH modifiée",
- "firstConnectionTitle": "Première connexion à cet hôte",
- "firstConnectionDescription": "L'authenticité de cet hôte ne peut pas être établie. Vérifiez que l'empreinte digitale correspond à ce que vous attendez.",
- "keyChangedDescription": "La clé hôte de ce serveur a changé depuis votre dernière connexion. Cela pourrait indiquer un problème de sécurité.",
- "previousKey": "Clé précédente",
- "newFingerprint": "Nouvelle empreinte digitale",
- "fingerprint": "Empreinte digitale",
- "verifyInstructions": "Si vous faites confiance à cet hôte, cliquez sur Accepter pour continuer et enregistrer cette empreinte digitale pour de futures connexions.",
- "securityWarning": "Avertissement de sécurité",
- "acceptAndContinue": "Accepter et continuer",
- "acceptNewKey": "Accepter la nouvelle clé et continuer"
- },
"errors": {
"notFound": "Page introuvable",
"unauthorized": "Accès non autorisé",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Terminal",
"hostSidebarSettings": "Hôte et barre latérale",
"snippetsSettings": "Extraits",
- "confirmSnippetExecution": "Confirmer l'exécution du snippet",
- "confirmSnippetExecutionDesc": "Afficher la boîte de dialogue de confirmation avant d'exécuter les snippets",
"updateSettings": "Mises à jour",
"disableUpdateCheck": "Désactiver la vérification des mises à jour",
"disableUpdateCheckDesc": "Arrêter la vérification des nouvelles versions au démarrage et au tableau de bord. Réduit les requêtes réseau.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 ou exemple.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/he_IL.json b/src/locales/translated/he_IL.json
index 89f19f13..cb35a482 100644
--- a/src/locales/translated/he_IL.json
+++ b/src/locales/translated/he_IL.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "אישורים",
"credentialsViewer": "מציג אישורים",
"manageYourSSHCredentials": "נהל את פרטי ה-SSH שלך בצורה מאובטחת",
"addCredential": "הוסף אישור",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "פעולה זו תוסיף בבטחה את המפתח הציבורי לקובץ ~/.ssh/authorized_keys של מארח היעד מבלי להחליף מפתחות קיימים. הפעולה היא הפיכה.",
"chooseHostToDeploy": "בחר מארח לפריסה...",
"deploying": "פריסה...",
- "copyDeployCommand": "העתק את פקודת הפריסה",
- "copyDeployCommandDescription": "העתק פקודת מעטפת כדי להוסיף ידנית את המפתח הציבורי לקובץ authorized_keys של מחשב מארח. שימושי כאשר אימות סיסמה מושבת.",
- "deployCommandCopied": "פקודת הפריסה הועתקה ללוח",
- "manualDeployInfo": "הדבק פקודה זו בטרמינל של מחשב המארח (למשל, קונסולת Proxmox, IPMI או גישה פיזית) כדי להוסיף את מפתח ה-SSH.",
"name": "שֵׁם",
"noHostsAvailable": "אין מארחים זמינים",
"noHostsMatchSearch": "אין מארחים התואמים את החיפוש שלך",
@@ -303,9 +298,7 @@
"createFolder": "צור תיקייה",
"editFolder": "עריכת תיקייה",
"editFolderDescription": "התאם אישית את תיקיית הקטעים שלך",
- "createFolderDescription": "ארגנו את הקטעים שלכם בתיקיות",
- "confirmExecution": "להפעיל את \"{{name}}\"?",
- "confirmExecutionDesc": "האם אתה בטוח שאתה רוצה להפעיל את הקטע הזה?"
+ "createFolderDescription": "ארגנו את הקטעים שלכם בתיקיות"
},
"commandHistory": {
"title": "הִיסטוֹרִיָה",
@@ -409,7 +402,6 @@
"required": "דָרוּשׁ",
"optional": "אופציונלי",
"connect": "לְחַבֵּר",
- "copied": "מוּעֲתָק",
"connecting": "מְקַשֵׁר...",
"creating": "יוצר...",
"clear": "בָּרוּר",
@@ -502,7 +494,6 @@
"checking": "בודק...",
"checkingDatabase": "בודק חיבור למסד הנתונים...",
"checkingAuthentication": "בודק אימות...",
- "backendReconnected": "חיבור השרת שוחזר",
"actions": "פעולות",
"remove": "לְהַסִיר",
"revoke": "לְבַטֵל",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "מנהל מארח",
- "hosts": "מארחים",
"sshHosts": "מארחי SSH",
"noHosts": "אין מארחי SSH",
"noHostsMessage": "עדיין לא הוספת מארחי SSH. לחץ על \"הוסף מארח\" כדי להתחיל.",
@@ -846,7 +836,7 @@
"failedToImportJson": "ייבוא קובץ JSON נכשל",
"connectionDetails": "פרטי חיבור",
"organization": "אִרגוּן",
- "ipAddress": "כתובת IP או שם מארח",
+ "ipAddress": "כתובת IP",
"port": "נָמָל",
"name": "שֵׁם",
"username": "שם משתמש",
@@ -925,8 +915,6 @@
"key": "מַפְתֵחַ",
"credential": "תְעוּדָה",
"none": "אַף לֹא אֶחָד",
- "opkssh": "אופקש",
- "opksshAuthDescription": "מבקש ממך אימות אינטרנט של OPKSSH בכל חיבור. אסימון האימות שלך יהיה בתוקף למשך 24 שעות עד שיהיה צורך לחדשו.",
"selectCredential": "בחר אישור",
"selectCredentialPlaceholder": "בחר אישור...",
"credentialRequired": "נדרשת אישור בעת שימוש באימות אישורים",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "אימות אינטראקטיבי באמצעות מקלדת",
"noneAuthDescription": "שיטת אימות זו תשתמש באימות אינטראקטיבי באמצעות מקלדת בעת התחברות לשרת SSH.",
"noneAuthDetails": "אימות אינטראקטיבי באמצעות מקלדת מאפשר לשרת לבקש ממך אישורים במהלך החיבור. זה שימושי עבור שרתים הדורשים אימות רב-גורמי או אם אינך מעוניין לשמור אישורים באופן מקומי.",
- "opksshAuthTitle": "אימות OPKSSH",
"forceKeyboardInteractive": "כפיית מקלדת אינטראקטיבית",
"forceKeyboardInteractiveDesc": "כופה שימוש באימות אינטראקטיבי באמצעות מקלדת. זה נדרש לעיתים עבור שרתים המשתמשים באימות דו-שלבי (TOTP/2FA).",
"overrideCredentialUsername": "עקיפת שם משתמש של פרטי כניסה",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "פתח בדפדפן",
"warpgateContinue": "סיימתי את האימות",
"warpgateTimeout": "פסק זמן לאימות Warpgate. אנא התחבר מחדש.",
- "opksshAuthRequired": "נדרש אימות OPKSSH",
- "opksshAuthDescription": "השלם את האימות בדפדפן שלך כדי להמשיך. סשן זה יישאר תקף למשך 24 שעות.",
- "opksshAuthUrl": "כתובת URL לאימות",
- "opksshOpenBrowser": "פתח את הדפדפן כדי לאמת",
- "opksshWaitingForAuth": "ממתין לאימות בדפדפן...",
- "opksshAuthenticating": "מעבד אימות...",
- "opksshTimeout": "הזמן שהוקצב לאימות הסתיים. אנא נסה שוב.",
- "opksshAuthFailed": "האימות נכשל. אנא בדוק את פרטי הגישה שלך ונסה שוב.",
- "opksshConfigMissing": "תצורת OPKSSH לא נמצאה. אנא צור ~/.opk/config.yml עם הגדרות ספק ה-OIDC שלך. עיין בתיעוד: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "להכניס סיסמה?",
"sudoPasswordPopupHint": "לחץ על Enter כדי להוסיף, Esc כדי לסגור",
"sudoPasswordPopupConfirm": "לְהַכנִיס",
@@ -1359,8 +1337,7 @@
"automaticFallback": "מנסה אוטומטית אימות {{method}} ...",
"totpTimeout": "פסק זמן לאימות TOTP. אנא התחבר מחדש.",
"passwordTimeout": "פסק זמן לאימות סיסמה. אנא התחבר מחדש.",
- "connectionRejected": "החיבור נדחה על ידי השרת. אנא בדוק את האימות ותצורת הרשת שלך.",
- "hostKeyRejected": "אימות מפתח מארח SSH נדחה. החיבור בוטל."
+ "connectionRejected": "החיבור נדחה על ידי השרת. אנא בדוק את האימות ותצורת הרשת שלך."
},
"fileManager": {
"title": "מנהל הקבצים",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "איפוס הסיסמה הצליח",
"passwordResetSuccessDesc": "הסיסמה שלך אופסה בהצלחה. כעת תוכל להתחבר באמצעות הסיסמה החדשה שלך."
},
- "hostKey": {
- "verifyNewHost": "אימות מפתח מארח SSH",
- "keyChangedWarning": "מפתח מארח SSH שונה",
- "firstConnectionTitle": "בפעם הראשונה שמתחברת למארח הזה",
- "firstConnectionDescription": "לא ניתן לקבוע את האותנטיות של מארח זה. ודא שטביעת האצבע תואמת את מה שאתה מצפה.",
- "keyChangedDescription": "מפתח המארח של שרת זה השתנה מאז החיבור האחרון שלך. זה יכול להצביע על בעיית אבטחה.",
- "previousKey": "מפתח קודם",
- "newFingerprint": "טביעת אצבע חדשה",
- "fingerprint": "טְבִיעַת אֶצבָּעוֹת",
- "verifyInstructions": "אם אתה בוטח במארח זה, לחץ על קבל כדי להמשיך ולשמור את טביעת האצבע הזו לחיבורים עתידיים.",
- "securityWarning": "אזהרת אבטחה",
- "acceptAndContinue": "קבל והמשך",
- "acceptNewKey": "קבל את המפתח החדש והמשך"
- },
"errors": {
"notFound": "הדף לא נמצא",
"unauthorized": "גישה לא מורשית",
@@ -2063,8 +2026,6 @@
"terminalSettings": "מָסוֹף",
"hostSidebarSettings": "מארח וסרגל צד",
"snippetsSettings": "קטעי טקסט",
- "confirmSnippetExecution": "אישור ביצוע קטע",
- "confirmSnippetExecutionDesc": "הצג תיבת דו-שיח לאישור לפני ביצוע קטעי טקסט",
"updateSettings": "עדכונים",
"disableUpdateCheck": "השבתת בדיקת עדכונים",
"disableUpdateCheckDesc": "הפסק לבדוק גרסאות חדשות בעת ההפעלה ובלוח המחוונים. מפחית בקשות רשת.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 או example.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/hi_IN.json b/src/locales/translated/hi_IN.json
index 0db18306..95d27d8e 100644
--- a/src/locales/translated/hi_IN.json
+++ b/src/locales/translated/hi_IN.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "साख",
"credentialsViewer": "क्रेडेंशियल व्यूअर",
"manageYourSSHCredentials": "अपने SSH क्रेडेंशियल्स को सुरक्षित रूप से प्रबंधित करें",
"addCredential": "क्रेडेंशियल जोड़ें",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "इससे सार्वजनिक कुंजी सुरक्षित रूप से लक्ष्य होस्ट की ~/.ssh/authorized_keys फ़ाइल में जुड़ जाएगी, और मौजूदा कुंजियों को ओवरराइट नहीं किया जाएगा। यह प्रक्रिया प्रतिवर्ती है।",
"chooseHostToDeploy": "परिनियोजन के लिए होस्ट चुनें...",
"deploying": "तैनाती जारी है...",
- "copyDeployCommand": "परिनियोजन कमांड कॉपी करें",
- "copyDeployCommandDescription": "किसी होस्ट की authorized_keys फ़ाइल में सार्वजनिक कुंजी को मैन्युअल रूप से जोड़ने के लिए शेल कमांड को कॉपी करें। यह तब उपयोगी होता है जब पासवर्ड प्रमाणीकरण अक्षम हो।",
- "deployCommandCopied": "डिप्लॉयमेंट कमांड क्लिपबोर्ड पर कॉपी हो गई है",
- "manualDeployInfo": "SSH कुंजी जोड़ने के लिए इस कमांड को टारगेट होस्ट के टर्मिनल (जैसे, प्रॉक्समॉक्स कंसोल, IPMI, या फिजिकल एक्सेस) में पेस्ट करें।",
"name": "नाम",
"noHostsAvailable": "कोई होस्ट उपलब्ध नहीं है",
"noHostsMatchSearch": "आपकी खोज से मेल खाने वाले कोई होस्ट नहीं हैं",
@@ -303,9 +298,7 @@
"createFolder": "फ़ोल्डर बनाएँ",
"editFolder": "फ़ोल्डर संपादित करें",
"editFolderDescription": "अपने स्निपेट फ़ोल्डर को अनुकूलित करें",
- "createFolderDescription": "अपने लेखों के अंशों को फ़ोल्डरों में व्यवस्थित करें।",
- "confirmExecution": "\"{{name}}\" निष्पादित करें?",
- "confirmExecutionDesc": "क्या आप वाकई इस कोड स्निपेट को चलाना चाहते हैं?"
+ "createFolderDescription": "अपने लेखों के अंशों को फ़ोल्डरों में व्यवस्थित करें।"
},
"commandHistory": {
"title": "इतिहास",
@@ -409,7 +402,6 @@
"required": "आवश्यक",
"optional": "वैकल्पिक",
"connect": "जोड़ना",
- "copied": "कॉपी किया गया",
"connecting": "कनेक्ट हो रहा है...",
"creating": "सृजन...",
"clear": "स्पष्ट",
@@ -502,7 +494,6 @@
"checking": "जाँच चल रही है...",
"checkingDatabase": "डेटाबेस कनेक्शन की जाँच की जा रही है...",
"checkingAuthentication": "प्रमाणीकरण की जाँच की जा रही है...",
- "backendReconnected": "सर्वर कनेक्शन बहाल हो गया",
"actions": "कार्रवाई",
"remove": "निकालना",
"revoke": "रद्द करना",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "मेजबान प्रबंधक",
- "hosts": "मेजबान",
"sshHosts": "एसएसएच होस्ट",
"noHosts": "कोई एसएसएच होस्ट नहीं",
"noHostsMessage": "आपने अभी तक कोई SSH होस्ट नहीं जोड़ा है। शुरू करने के लिए \"होस्ट जोड़ें\" पर क्लिक करें।",
@@ -846,7 +836,7 @@
"failedToImportJson": "JSON फ़ाइल आयात करने में विफल",
"connectionDetails": "कनेक्शन विवरण",
"organization": "संगठन",
- "ipAddress": "आईपी पता या होस्टनाम",
+ "ipAddress": "आईपी पता",
"port": "पत्तन",
"name": "नाम",
"username": "उपयोगकर्ता नाम",
@@ -925,8 +915,6 @@
"key": "चाबी",
"credential": "क्रेडेंशियल",
"none": "कोई नहीं",
- "opkssh": "ओपीकेएसएच",
- "opksshAuthDescription": "हर बार कनेक्ट होने पर यह आपसे OPKSSH वेब-प्रमाणीकरण के लिए पूछेगा। आपका प्रमाणीकरण टोकन 24 घंटे तक वैध रहेगा, जिसके बाद इसे नवीनीकृत करना होगा।",
"selectCredential": "क्रेडेंशियल चुनें",
"selectCredentialPlaceholder": "एक प्रमाण पत्र चुनें...",
"credentialRequired": "क्रेडेंशियल प्रमाणीकरण का उपयोग करते समय क्रेडेंशियल आवश्यक है।",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "कीबोर्ड-इंटरैक्टिव प्रमाणीकरण",
"noneAuthDescription": "एसएसएच सर्वर से कनेक्ट करते समय यह प्रमाणीकरण विधि कीबोर्ड-इंटरैक्टिव प्रमाणीकरण का उपयोग करेगी।",
"noneAuthDetails": "कीबोर्ड-इंटरैक्टिव प्रमाणीकरण सर्वर को कनेक्शन के दौरान आपसे क्रेडेंशियल मांगने की अनुमति देता है। यह उन सर्वरों के लिए उपयोगी है जिन्हें मल्टी-फैक्टर प्रमाणीकरण की आवश्यकता होती है या यदि आप क्रेडेंशियल को स्थानीय रूप से सहेजना नहीं चाहते हैं।",
- "opksshAuthTitle": "ओपीकेएसएसएच प्रमाणीकरण",
"forceKeyboardInteractive": "फ़ोर्स कीबोर्ड-इंटरैक्टिव",
"forceKeyboardInteractiveDesc": "कीबोर्ड-आधारित प्रमाणीकरण को अनिवार्य बनाता है। यह कभी-कभी उन सर्वरों के लिए आवश्यक होता है जो दो-कारक प्रमाणीकरण (TOTP/2FA) का उपयोग करते हैं।",
"overrideCredentialUsername": "क्रेडेंशियल उपयोगकर्ता नाम को ओवरराइड करें",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "ब्राउज़र में खोलें",
"warpgateContinue": "मैंने प्रमाणीकरण पूरा कर लिया है",
"warpgateTimeout": "वॉरपगेट प्रमाणीकरण में समय समाप्त हो गया है। कृपया पुनः कनेक्ट करें।",
- "opksshAuthRequired": "OPKSSH प्रमाणीकरण आवश्यक है",
- "opksshAuthDescription": "आगे बढ़ने के लिए अपने ब्राउज़र में प्रमाणीकरण पूरा करें। यह सत्र 24 घंटे तक वैध रहेगा।",
- "opksshAuthUrl": "प्रमाणीकरण यूआरएल",
- "opksshOpenBrowser": "प्रमाणीकरण के लिए ब्राउज़र खोलें",
- "opksshWaitingForAuth": "ब्राउज़र में प्रमाणीकरण की प्रतीक्षा हो रही है...",
- "opksshAuthenticating": "प्रमाणीकरण प्रक्रिया जारी है...",
- "opksshTimeout": "प्रमाणीकरण का समय समाप्त हो गया। कृपया पुनः प्रयास करें।",
- "opksshAuthFailed": "प्रमाणीकरण विफल रहा। कृपया अपनी पहचान सत्यापित करें और पुनः प्रयास करें।",
- "opksshConfigMissing": "OPKSSH कॉन्फ़िगरेशन नहीं मिला। कृपया अपने OIDC प्रदाता सेटिंग्स के साथ ~/.opk/config.yml फ़ाइल बनाएँ। दस्तावेज़ देखें: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "पासवर्ड डालें?",
"sudoPasswordPopupHint": "सम्मिलित करने के लिए Enter दबाएँ, हटाने के लिए Esc दबाएँ",
"sudoPasswordPopupConfirm": "डालना",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Automatically trying {{method}} authentication...",
"totpTimeout": "टीओटीपी सत्यापन में समय समाप्त हो गया है। कृपया पुनः कनेक्ट करें।",
"passwordTimeout": "पासवर्ड सत्यापन में समय समाप्त हो गया है। कृपया पुनः कनेक्ट करें।",
- "connectionRejected": "सर्वर द्वारा कनेक्शन अस्वीकृत कर दिया गया है। कृपया अपनी प्रमाणीकरण और नेटवर्क कॉन्फ़िगरेशन की जाँच करें।",
- "hostKeyRejected": "SSH होस्ट कुंजी सत्यापन अस्वीकृत। कनेक्शन रद्द।"
+ "connectionRejected": "सर्वर द्वारा कनेक्शन अस्वीकृत कर दिया गया है। कृपया अपनी प्रमाणीकरण और नेटवर्क कॉन्फ़िगरेशन की जाँच करें।"
},
"fileManager": {
"title": "फ़ाइल मैनेजर",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "पासवर्ड रीसेट सफल रहा",
"passwordResetSuccessDesc": "आपका पासवर्ड सफलतापूर्वक रीसेट हो गया है। अब आप अपने नए पासवर्ड से लॉग इन कर सकते हैं।"
},
- "hostKey": {
- "verifyNewHost": "SSH होस्ट कुंजी सत्यापित करें",
- "keyChangedWarning": "SSH होस्ट कुंजी बदल गई",
- "firstConnectionTitle": "पहली बार इस होस्ट से कनेक्ट हो रहा है",
- "firstConnectionDescription": "इस होस्ट की प्रामाणिकता स्थापित नहीं की जा सकती। कृपया सुनिश्चित करें कि फिंगरप्रिंट आपकी अपेक्षा के अनुरूप है।",
- "keyChangedDescription": "आपके पिछले कनेक्शन के बाद से इस सर्वर की होस्ट कुंजी बदल गई है। यह किसी सुरक्षा समस्या का संकेत हो सकता है।",
- "previousKey": "पिछली कुंजी",
- "newFingerprint": "नया फिंगरप्रिंट",
- "fingerprint": "अंगुली की छाप",
- "verifyInstructions": "यदि आप इस होस्ट पर भरोसा करते हैं, तो जारी रखने के लिए स्वीकार करें पर क्लिक करें और भविष्य के कनेक्शनों के लिए इस फिंगरप्रिंट को सहेजें।",
- "securityWarning": "सुरक्षा चेतावनी",
- "acceptAndContinue": "स्वीकार करें और जारी रखें",
- "acceptNewKey": "नई कुंजी स्वीकार करें और जारी रखें"
- },
"errors": {
"notFound": "पृष्ठ नहीं मिला",
"unauthorized": "अनधिकृत पहुंच",
@@ -2063,8 +2026,6 @@
"terminalSettings": "टर्मिनल",
"hostSidebarSettings": "होस्ट और साइडबार",
"snippetsSettings": "स्निपेट्स",
- "confirmSnippetExecution": "स्निपेट निष्पादन की पुष्टि करें",
- "confirmSnippetExecutionDesc": "स्निपेट्स को निष्पादित करने से पहले पुष्टिकरण डायलॉग दिखाएँ",
"updateSettings": "अपडेट",
"disableUpdateCheck": "अपडेट जांच अक्षम करें",
"disableUpdateCheckDesc": "स्टार्टअप और डैशबोर्ड पर नए संस्करणों की जाँच करना बंद करें। इससे नेटवर्क अनुरोध कम होते हैं।",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 या example.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/hu_HU.json b/src/locales/translated/hu_HU.json
index e30b91f9..a4d9dca9 100644
--- a/src/locales/translated/hu_HU.json
+++ b/src/locales/translated/hu_HU.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Hitelesítő adatok",
"credentialsViewer": "Hitelesítő adatok megtekintője",
"manageYourSSHCredentials": "SSH-hitelesítő adatainak biztonságos kezelése",
"addCredential": "Hitelesítő adat hozzáadása",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Ez biztonságosan hozzáadja a nyilvános kulcsot a célgép ~/.ssh/authorized_keys fájljához a meglévő kulcsok felülírása nélkül. A művelet visszafordítható.",
"chooseHostToDeploy": "Válasszon egy hosztot a telepítéshez...",
"deploying": "Telepítés...",
- "copyDeployCommand": "Telepítési parancs másolása",
- "copyDeployCommandDescription": "Egy shell parancs másolásával manuálisan hozzáadható a nyilvános kulcs a gazdagép authorized_keys fájljához. Hasznos, ha a jelszó-hitelesítés le van tiltva.",
- "deployCommandCopied": "A telepítési parancs a vágólapra másolva",
- "manualDeployInfo": "Illeszd be ezt a parancsot a célgép termináljába (pl. Proxmox konzol, IPMI vagy fizikai hozzáférés) az SSH kulcs hozzáadásához.",
"name": "Név",
"noHostsAvailable": "Nincsenek elérhető házigazdák",
"noHostsMatchSearch": "Nincsenek a keresésnek megfelelő házigazdák",
@@ -303,9 +298,7 @@
"createFolder": "Mappa létrehozása",
"editFolder": "Mappa szerkesztése",
"editFolderDescription": "Kódrészlet mappa testreszabása",
- "createFolderDescription": "Rendezd a kódrészleteket mappákba",
- "confirmExecution": "Végrehajtani a következőt: \"{{name}}\"?",
- "confirmExecutionDesc": "Biztosan végrehajtod ezt a kódrészletet?"
+ "createFolderDescription": "Rendezd a kódrészleteket mappákba"
},
"commandHistory": {
"title": "Történelem",
@@ -409,7 +402,6 @@
"required": "Kívánt",
"optional": "Választható",
"connect": "Csatlakozás",
- "copied": "Másolva",
"connecting": "Kapcsolódás...",
"creating": "Létrehozás...",
"clear": "Világos",
@@ -502,7 +494,6 @@
"checking": "Ellenőrzés...",
"checkingDatabase": "Adatbázis-kapcsolat ellenőrzése...",
"checkingAuthentication": "Hitelesítés ellenőrzése...",
- "backendReconnected": "Szerverkapcsolat helyreállt",
"actions": "Műveletek",
"remove": "Eltávolítás",
"revoke": "Visszavonás",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Házigazda-kezelő",
- "hosts": "Házigazdák",
"sshHosts": "SSH-hosztok",
"noHosts": "Nincsenek SSH-hosztok",
"noHostsMessage": "Még nem adott hozzá SSH-hosztokat. Kattintson a „Hoszt hozzáadása” gombra a kezdéshez.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Nem sikerült importálni a JSON fájlt",
"connectionDetails": "Kapcsolat részletei",
"organization": "Szervezet",
- "ipAddress": "IP-cím vagy hostnév",
+ "ipAddress": "IP-cím",
"port": "Kikötő",
"name": "Név",
"username": "Felhasználónév",
@@ -925,8 +915,6 @@
"key": "Kulcsfontosságú",
"credential": "Hitelesítő adat",
"none": "Egyik sem",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Minden csatlakozáskor OPKSSH webes hitelesítést kér. A hitelesítési token 24 óráig érvényes, amíg meg nem újítják.",
"selectCredential": "Hitelesítő adat kiválasztása",
"selectCredentialPlaceholder": "Válasszon hitelesítő adatlapot...",
"credentialRequired": "Hitelesítő adat szükséges hitelesítő adat alapú hitelesítéshez",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Billentyűzet-interaktív hitelesítés",
"noneAuthDescription": "Ez a hitelesítési módszer billentyűzet-interaktív hitelesítést használ az SSH-kiszolgálóhoz való csatlakozáskor.",
"noneAuthDetails": "A billentyűzet-interaktív hitelesítés lehetővé teszi, hogy a szerver kérje a hitelesítő adatokat a csatlakozás során. Ez olyan szervereknél hasznos, amelyek többtényezős hitelesítést igényelnek, vagy ha nem szeretné helyben menteni a hitelesítő adatokat.",
- "opksshAuthTitle": "OPKSSH hitelesítés",
"forceKeyboardInteractive": "Kényszerítse a billentyűzet interaktivitását",
"forceKeyboardInteractiveDesc": "Kényszeríti a billentyűzet-interaktív hitelesítés használatát. Ez néha szükséges a kétfaktoros hitelesítést (TOTP/2FA) használó szervereknél.",
"overrideCredentialUsername": "Hitelesítő adat felülírása Felhasználónév",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Megnyitás böngészőben",
"warpgateContinue": "Befejeztem a hitelesítést",
"warpgateTimeout": "Warpgate hitelesítési időtúllépés. Kérjük, csatlakozzon újra.",
- "opksshAuthRequired": "OPKSSH hitelesítés szükséges",
- "opksshAuthDescription": "A folytatáshoz végezze el a hitelesítést a böngészőjében. Ez a munkamenet 24 órán át érvényes marad.",
- "opksshAuthUrl": "Hitelesítési URL",
- "opksshOpenBrowser": "Böngésző megnyitása a hitelesítéshez",
- "opksshWaitingForAuth": "Várakozás a böngésző hitelesítésére...",
- "opksshAuthenticating": "Hitelesítés feldolgozása...",
- "opksshTimeout": "A hitelesítés időtúllépést okozott. Kérjük, próbálja újra.",
- "opksshAuthFailed": "Sikertelen hitelesítés. Kérjük, ellenőrizze a hitelesítő adatait, és próbálja újra.",
- "opksshConfigMissing": "Az OPKSSH konfiguráció nem található. Kérjük, hozza létre a ~/.opk/config.yml fájlt az OIDC-szolgáltató beállításaival. Lásd a dokumentációt: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Jelszó beszúrása?",
"sudoPasswordPopupHint": "Nyomja meg az Enter billentyűt a beszúráshoz, az Esc billentyűt az elvetéshez",
"sudoPasswordPopupConfirm": "Beszúrás",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Automatikusan megpróbáljuk a {{method}} hitelesítést...",
"totpTimeout": "TOTP ellenőrzési időtúllépés. Kérjük, csatlakozzon újra.",
"passwordTimeout": "Jelszó-ellenőrzési időtúllépés. Kérjük, csatlakozzon újra.",
- "connectionRejected": "A szerver elutasította a kapcsolatot. Kérjük, ellenőrizze a hitelesítést és a hálózati konfigurációt.",
- "hostKeyRejected": "SSH host kulcs ellenőrzése elutasítva. Kapcsolat megszakítva."
+ "connectionRejected": "A szerver elutasította a kapcsolatot. Kérjük, ellenőrizze a hitelesítést és a hálózati konfigurációt."
},
"fileManager": {
"title": "Fájlkezelő",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Jelszó visszaállítása sikeres",
"passwordResetSuccessDesc": "Jelszava sikeresen visszaállításra került. Most már bejelentkezhet új jelszavával."
},
- "hostKey": {
- "verifyNewHost": "SSH host kulcs ellenőrzése",
- "keyChangedWarning": "SSH állomáskulcs megváltozott",
- "firstConnectionTitle": "Első csatlakozás ehhez a gazdagéphez",
- "firstConnectionDescription": "A gazdagép hitelessége nem állapítható meg. Ellenőrizze, hogy az ujjlenyomat megfelel-e a vártnak.",
- "keyChangedDescription": "A szerver host key-je megváltozott az utolsó csatlakozás óta. Ez biztonsági problémára utalhat.",
- "previousKey": "Előző kulcs",
- "newFingerprint": "Új ujjlenyomat",
- "fingerprint": "Ujjlenyomat",
- "verifyInstructions": "Ha megbízik ebben a gazdagépben, kattintson az Elfogadás gombra a folytatáshoz és az ujjlenyomat mentéséhez a jövőbeli kapcsolatokhoz.",
- "securityWarning": "Biztonsági figyelmeztetés",
- "acceptAndContinue": "Elfogadom és folytatom",
- "acceptNewKey": "Új kulcs elfogadása és folytatás"
- },
"errors": {
"notFound": "Az oldal nem található",
"unauthorized": "Jogosulatlan hozzáférés",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Terminál",
"hostSidebarSettings": "Gazdagép és oldalsáv",
"snippetsSettings": "Kódrészletek",
- "confirmSnippetExecution": "Kódrészlet végrehajtásának megerősítése",
- "confirmSnippetExecutionDesc": "Megerősítő párbeszédpanel megjelenítése a kódrészletek végrehajtása előtt",
"updateSettings": "Frissítések",
"disableUpdateCheck": "Frissítésellenőrzés letiltása",
"disableUpdateCheckDesc": "Új verziók keresésének leállítása indításkor és az irányítópulton. Csökkenti a hálózati kérések számát.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 vagy example.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/id_ID.json b/src/locales/translated/id_ID.json
index a0f1393d..da037764 100644
--- a/src/locales/translated/id_ID.json
+++ b/src/locales/translated/id_ID.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Kredensial",
"credentialsViewer": "Penampil Kredensial",
"manageYourSSHCredentials": "Kelola kredensial SSH Anda dengan aman.",
"addCredential": "Tambahkan Kredensial",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Ini akan menambahkan kunci publik dengan aman ke file ~/.ssh/authorized_keys pada host target tanpa menimpa kunci yang sudah ada. Operasi ini dapat dibatalkan.",
"chooseHostToDeploy": "Pilih host tempat untuk melakukan deployment...",
"deploying": "Sedang melakukan penyebaran...",
- "copyDeployCommand": "Salin Perintah Penyebaran",
- "copyDeployCommandDescription": "Salin perintah shell untuk menambahkan kunci publik secara manual ke file authorized_keys host. Berguna saat otentikasi kata sandi dinonaktifkan.",
- "deployCommandCopied": "Perintah penyebaran disalin ke papan klip",
- "manualDeployInfo": "Salin perintah ini ke terminal host target (misalnya, konsol Proxmox, IPMI, atau akses fisik) untuk menambahkan kunci SSH.",
"name": "Nama",
"noHostsAvailable": "Tidak ada tuan rumah yang tersedia.",
"noHostsMatchSearch": "Tidak ada host yang sesuai dengan pencarian Anda.",
@@ -303,9 +298,7 @@
"createFolder": "Buat Folder",
"editFolder": "Edit Folder",
"editFolderDescription": "Sesuaikan folder cuplikan Anda",
- "createFolderDescription": "Susun cuplikan Anda ke dalam folder.",
- "confirmExecution": "Jalankan \"{{name}}\"?",
- "confirmExecutionDesc": "Apakah Anda yakin ingin menjalankan cuplikan kode ini?"
+ "createFolderDescription": "Susun cuplikan Anda ke dalam folder."
},
"commandHistory": {
"title": "Sejarah",
@@ -409,7 +402,6 @@
"required": "Diperlukan",
"optional": "Opsional",
"connect": "Menghubungkan",
- "copied": "Disalin",
"connecting": "Menghubungkan...",
"creating": "Membuat...",
"clear": "Jernih",
@@ -502,7 +494,6 @@
"checking": "Sedang memeriksa...",
"checkingDatabase": "Memeriksa koneksi basis data...",
"checkingAuthentication": "Memeriksa autentikasi...",
- "backendReconnected": "Koneksi server dipulihkan.",
"actions": "Tindakan",
"remove": "Menghapus",
"revoke": "Menarik kembali",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Manajer Host",
- "hosts": "Tuan rumah",
"sshHosts": "Host SSH",
"noHosts": "Tidak ada Host SSH",
"noHostsMessage": "Anda belum menambahkan host SSH apa pun. Klik \"Tambah Host\" untuk memulai.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Gagal mengimpor file JSON",
"connectionDetails": "Detail Koneksi",
"organization": "Organisasi",
- "ipAddress": "Alamat IP atau Nama Host",
+ "ipAddress": "Alamat IP",
"port": "Pelabuhan",
"name": "Nama",
"username": "Nama belakang",
@@ -925,8 +915,6 @@
"key": "Kunci",
"credential": "Mandat",
"none": "Tidak ada",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Meminta Anda untuk memasukkan otentikasi web OPKSSH setiap kali terhubung. Token otentikasi Anda akan berlaku selama 24 jam sebelum perlu diperbarui.",
"selectCredential": "Pilih Kredensial",
"selectCredentialPlaceholder": "Pilih sertifikasi...",
"credentialRequired": "Kredensial diperlukan saat menggunakan autentikasi kredensial.",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Otentikasi Interaktif Keyboard",
"noneAuthDescription": "Metode autentikasi ini akan menggunakan autentikasi interaktif keyboard saat terhubung ke server SSH.",
"noneAuthDetails": "Autentikasi interaktif keyboard memungkinkan server untuk meminta kredensial Anda selama koneksi. Ini berguna untuk server yang memerlukan autentikasi multi-faktor atau jika Anda tidak ingin menyimpan kredensial secara lokal.",
- "opksshAuthTitle": "Otentikasi OPKSSH",
"forceKeyboardInteractive": "Paksa Interaktif Keyboard",
"forceKeyboardInteractiveDesc": "Memaksa penggunaan autentikasi interaktif keyboard. Ini terkadang diperlukan untuk server yang menggunakan Autentikasi Dua Faktor (TOTP/2FA).",
"overrideCredentialUsername": "Ganti Nama Pengguna Kredensial",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Buka di Browser",
"warpgateContinue": "Saya telah menyelesaikan otentikasi.",
"warpgateTimeout": "Otentikasi Warpgate habis waktu. Silakan sambungkan kembali.",
- "opksshAuthRequired": "Otentikasi OPKSSH Diperlukan",
- "opksshAuthDescription": "Lakukan otentikasi di browser Anda untuk melanjutkan. Sesi ini akan tetap berlaku selama 24 jam.",
- "opksshAuthUrl": "URL Otentikasi",
- "opksshOpenBrowser": "Buka Browser untuk Melakukan Otentikasi",
- "opksshWaitingForAuth": "Menunggu otentikasi di browser...",
- "opksshAuthenticating": "Memproses otentikasi...",
- "opksshTimeout": "Autentikasi habis waktu. Silakan coba lagi.",
- "opksshAuthFailed": "Autentikasi gagal. Silakan periksa kredensial Anda dan coba lagi.",
- "opksshConfigMissing": "Konfigurasi OPKSSH tidak ditemukan. Silakan buat ~/.opk/config.yml dengan pengaturan penyedia OIDC Anda. Lihat dokumentasi: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Masukkan kata sandi?",
"sudoPasswordPopupHint": "Tekan Enter untuk memasukkan, Esc untuk menutup.",
"sudoPasswordPopupConfirm": "Menyisipkan",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Mencoba otentikasi {{method}} secara otomatis...",
"totpTimeout": "Waktu verifikasi TOTP habis. Silakan sambungkan kembali.",
"passwordTimeout": "Waktu verifikasi kata sandi habis. Silakan sambungkan kembali.",
- "connectionRejected": "Koneksi ditolak oleh server. Harap periksa otentikasi dan konfigurasi jaringan Anda.",
- "hostKeyRejected": "Verifikasi kunci host SSH ditolak. Koneksi dibatalkan."
+ "connectionRejected": "Koneksi ditolak oleh server. Harap periksa otentikasi dan konfigurasi jaringan Anda."
},
"fileManager": {
"title": "Pengelola File",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Reset Kata Sandi Berhasil",
"passwordResetSuccessDesc": "Kata sandi Anda telah berhasil direset. Anda sekarang dapat masuk dengan kata sandi baru Anda."
},
- "hostKey": {
- "verifyNewHost": "Verifikasi Kunci Host SSH",
- "keyChangedWarning": "Kunci Host SSH Berubah",
- "firstConnectionTitle": "Ini adalah kali pertama terhubung ke host ini.",
- "firstConnectionDescription": "Keaslian host ini tidak dapat dipastikan. Verifikasi apakah sidik jari sesuai dengan yang Anda harapkan.",
- "keyChangedDescription": "Kunci host untuk server ini telah berubah sejak koneksi terakhir Anda. Ini bisa mengindikasikan masalah keamanan.",
- "previousKey": "Kunci Sebelumnya",
- "newFingerprint": "Sidik Jari Baru",
- "fingerprint": "Sidik jari",
- "verifyInstructions": "Jika Anda mempercayai host ini, klik Terima untuk melanjutkan dan simpan sidik jari ini untuk koneksi di masa mendatang.",
- "securityWarning": "Peringatan Keamanan",
- "acceptAndContinue": "Terima & Lanjutkan",
- "acceptNewKey": "Terima Kunci Baru & Lanjutkan"
- },
"errors": {
"notFound": "Halaman tidak ditemukan",
"unauthorized": "Akses tidak sah",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Terminal",
"hostSidebarSettings": "Host & Sidebar",
"snippetsSettings": "Cuplikan",
- "confirmSnippetExecution": "Konfirmasi Eksekusi Cuplikan",
- "confirmSnippetExecutionDesc": "Tampilkan dialog konfirmasi sebelum menjalankan cuplikan kode.",
"updateSettings": "Pembaruan",
"disableUpdateCheck": "Nonaktifkan Pemeriksaan Pembaruan",
"disableUpdateCheckDesc": "Hentikan pengecekan versi baru saat startup dan dasbor. Mengurangi permintaan jaringan.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 atau example.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/it_IT.json b/src/locales/translated/it_IT.json
index 82d28344..33bf53f9 100644
--- a/src/locales/translated/it_IT.json
+++ b/src/locales/translated/it_IT.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Credenziali",
"credentialsViewer": "Visualizzatore Di Credenziali",
"manageYourSSHCredentials": "Gestisci le tue credenziali SSH in modo sicuro",
"addCredential": "Aggiungi Credenziali",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Questo aggiungerà la chiave pubblica al file ~/.ssh/authorized_keys dell'host di destinazione senza sovrascrivere le chiavi esistenti. L'operazione è reversibile.",
"chooseHostToDeploy": "Scegli un host in cui distribuire...",
"deploying": "Distribuzione...",
- "copyDeployCommand": "Copia Il Comando Di Deploy",
- "copyDeployCommandDescription": "Copia un comando shell per aggiungere manualmente la chiave pubblica al file authorized_keys di un host. Utile quando l'autenticazione con password è disabilitata.",
- "deployCommandCopied": "Distribuisci comando copiato negli appunti",
- "manualDeployInfo": "Incolla questo comando nel terminale dell'host di destinazione (ad esempio, console Proxmox, IPMI o accesso fisico) per aggiungere il tasto SSH.",
"name": "Nome",
"noHostsAvailable": "Nessun host disponibile",
"noHostsMatchSearch": "Nessun host corrisponde alla tua ricerca",
@@ -303,9 +298,7 @@
"createFolder": "Crea Cartella",
"editFolder": "Modifica Cartella",
"editFolderDescription": "Personalizza la cartella snippet",
- "createFolderDescription": "Organizza i pezzetti di codice nelle cartelle",
- "confirmExecution": "Eseguire \"{{name}}\"?",
- "confirmExecutionDesc": "Sei sicuro di voler eseguire questa snippet?"
+ "createFolderDescription": "Organizza i pezzetti di codice nelle cartelle"
},
"commandHistory": {
"title": "Storico",
@@ -409,7 +402,6 @@
"required": "Richiesto",
"optional": "Facoltativo",
"connect": "Connetti",
- "copied": "Copiato",
"connecting": "Connessione...",
"creating": "Creazione...",
"clear": "Pulisci",
@@ -502,7 +494,6 @@
"checking": "Controllo...",
"checkingDatabase": "Controllo connessione database...",
"checkingAuthentication": "Controllo autenticazione...",
- "backendReconnected": "Connessione al server ripristinata",
"actions": "Azioni",
"remove": "Rimuovi",
"revoke": "Revoke",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Gestore Host",
- "hosts": "Host",
"sshHosts": "Host SSH",
"noHosts": "Nessun Host SSH",
"noHostsMessage": "Non hai ancora aggiunto nessun host SSH. Clicca su \"Aggiungi host\" per iniziare.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Importazione del file JSON non riuscita",
"connectionDetails": "Dettagli Della Connessione",
"organization": "Organizzazione",
- "ipAddress": "Indirizzo IP o nome host",
+ "ipAddress": "Indirizzo IP",
"port": "Porta",
"name": "Nome",
"username": "Username",
@@ -925,8 +915,6 @@
"key": "Chiave",
"credential": "Credenziali",
"none": "Nessuno",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Ti consente di eseguire il web-auth di OPKSSH su ogni connessione. Il tuo token di autenticazione durerà 24 ore fino a quando deve essere rinnovato.",
"selectCredential": "Seleziona Credenziali",
"selectCredentialPlaceholder": "Scegli una credenziale...",
"credentialRequired": "La credenziale è richiesta quando si utilizza l'autenticazione credenziali",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Autenticazione Tastiera-Interattiva",
"noneAuthDescription": "Questo metodo di autenticazione utilizzerà l'autenticazione interattiva con tastiera durante la connessione al server SSH.",
"noneAuthDetails": "L'autenticazione interattiva con tastiera consente al server di richiedere le credenziali durante la connessione. Questo è utile per i server che richiedono l'autenticazione a più fattori o se non si desidera salvare le credenziali localmente.",
- "opksshAuthTitle": "Autenticazione OPKSSH",
"forceKeyboardInteractive": "Forza Tastiera-Interattiva",
"forceKeyboardInteractiveDesc": "Forza l'uso dell'autenticazione interattiva con tastiera. A volte è necessario per i server che utilizzano Autenticazione a due fattori (TOTP/2FA).",
"overrideCredentialUsername": "Ignora Il Nome Utente Credenziale",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Apri nel browser",
"warpgateContinue": "Ho Completato L'Autenticazione",
"warpgateTimeout": "Timeout autenticazione Warpgate. Per favore riconnetterti.",
- "opksshAuthRequired": "Richiesta Autenticazione OPKSSH",
- "opksshAuthDescription": "Completa l'autenticazione nel tuo browser per continuare. Questa sessione rimarrà valida per 24 ore.",
- "opksshAuthUrl": "Url Di Autenticazione",
- "opksshOpenBrowser": "Apri Browser per Autenticare",
- "opksshWaitingForAuth": "In attesa di autenticazione nel browser...",
- "opksshAuthenticating": "Elaborazione autenticazione...",
- "opksshTimeout": "Autenticazione scaduta. Riprova.",
- "opksshAuthFailed": "Autenticazione non riuscita. Controlla le tue credenziali e riprova.",
- "opksshConfigMissing": "Configurazione OPKSSH non trovata. Si prega di creare ~/.opk/config.yml con le impostazioni del provider OIDC. Vedi documentazione: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Inserire Password?",
"sudoPasswordPopupHint": "Premere Invio per inserire, Esc per eliminare",
"sudoPasswordPopupConfirm": "Insert",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Prova automaticamente l'autenticazione {{method}}...",
"totpTimeout": "Timeout verifica TOTP. Riconnettersi.",
"passwordTimeout": "Timeout verifica password. Si prega di riconnettersi.",
- "connectionRejected": "Connessione rifiutata dal server. Controlla la tua autenticazione e configurazione di rete.",
- "hostKeyRejected": "Verifica della chiave host SSH rifiutata. Connessione annullata."
+ "connectionRejected": "Connessione rifiutata dal server. Controlla la tua autenticazione e configurazione di rete."
},
"fileManager": {
"title": "Gestore File",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Reset Password Con Successo",
"passwordResetSuccessDesc": "La password è stata reimpostata con successo. Ora puoi accedere con la tua nuova password."
},
- "hostKey": {
- "verifyNewHost": "Verifica La Chiave Host Ssh",
- "keyChangedWarning": "Chiave Host Ssh Cambiata",
- "firstConnectionTitle": "Prima volta che ci si connette a questo host",
- "firstConnectionDescription": "L'autenticità di questo host non può essere stabilita. Verifica che l'impronta digitale corrisponda a quanto ci si aspetta.",
- "keyChangedDescription": "La chiave host di questo server è cambiata dall'ultima connessione. Questo potrebbe indicare un problema di sicurezza.",
- "previousKey": "Chiave Precedente",
- "newFingerprint": "Nuova Impronta Digitale",
- "fingerprint": "Impronta",
- "verifyInstructions": "Se ti fidi di questo host, fai clic su Accetta per continuare e salvare questa impronta digitale per le connessioni future.",
- "securityWarning": "Avviso Di Sicurezza",
- "acceptAndContinue": "Accetta E Continua",
- "acceptNewKey": "Accetta La Nuova Chiave E Continua"
- },
"errors": {
"notFound": "Pagina non trovata",
"unauthorized": "Accesso non autorizzato",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Terminale",
"hostSidebarSettings": "& Barra Laterale Dell'Host",
"snippetsSettings": "Snippet",
- "confirmSnippetExecution": "Conferma Esecuzione Snippet",
- "confirmSnippetExecutionDesc": "Mostra la finestra di conferma prima di eseguire snippet",
"updateSettings": "Aggiornamenti",
"disableUpdateCheck": "Disabilita Controllo Aggiornamenti",
"disableUpdateCheckDesc": "Smetti di controllare nuove versioni all'avvio e alla dashboard. Riduce le richieste di rete.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 o example.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/ja_JP.json b/src/locales/translated/ja_JP.json
index 05a5773e..28024b27 100644
--- a/src/locales/translated/ja_JP.json
+++ b/src/locales/translated/ja_JP.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "資格情報",
"credentialsViewer": "資格情報ビューアー",
"manageYourSSHCredentials": "SSH認証情報を安全に管理します",
"addCredential": "証明書の追加",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "これにより、既存の鍵を上書きすることなく、公開鍵をホストの ~/.ssh/authorized_keys ファイルに安全に追加します。操作は取り消し可能です。",
"chooseHostToDeploy": "デプロイするホストを選択してください...",
"deploying": "デプロイ中...",
- "copyDeployCommand": "デプロイコマンドをコピー",
- "copyDeployCommandDescription": "シェルコマンドをコピーして、ホストの authorized_keys ファイルに公開鍵を手動で追加します。パスワード認証が無効になっている場合に便利です。",
- "deployCommandCopied": "デプロイコマンドがクリップボードにコピーされました",
- "manualDeployInfo": "SSHキーを追加するには、このコマンドをターゲットホストの端末(例:Proxmoxコンソール、IPMI、または物理アクセス)に貼り付けます。",
"name": "名前",
"noHostsAvailable": "利用可能なホストがありません",
"noHostsMatchSearch": "検索条件に一致するホストがありません",
@@ -303,9 +298,7 @@
"createFolder": "フォルダを作成",
"editFolder": "フォルダを編集",
"editFolderDescription": "スニペットフォルダをカスタマイズ",
- "createFolderDescription": "スニペットをフォルダーに整理する",
- "confirmExecution": "「{{name}}」を実行しますか?",
- "confirmExecutionDesc": "このスニペットを実行してもよろしいですか?"
+ "createFolderDescription": "スニペットをフォルダーに整理する"
},
"commandHistory": {
"title": "沿革",
@@ -409,7 +402,6 @@
"required": "必須",
"optional": "省略可能",
"connect": "接続する",
- "copied": "コピーしました",
"connecting": "接続中...",
"creating": "作成中...",
"clear": "クリア",
@@ -502,7 +494,6 @@
"checking": "確認しています...",
"checkingDatabase": "データベース接続を確認しています...",
"checkingAuthentication": "認証中char@@0",
- "backendReconnected": "サーバー接続が復元されました",
"actions": "アクション",
"remove": "削除",
"revoke": "Revoke",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "ホスト マネージャー",
- "hosts": "ホスト",
"sshHosts": "SSH ホスト",
"noHosts": "SSHホストがありません",
"noHostsMessage": "SSHホストはまだ追加されていません。「ホストを追加」をクリックして開始してください。",
@@ -846,7 +836,7 @@
"failedToImportJson": "JSON ファイルのインポートに失敗しました",
"connectionDetails": "接続詳細",
"organization": "組織",
- "ipAddress": "IPアドレスまたはホスト名",
+ "ipAddress": "IP アドレス",
"port": "ポート",
"name": "名前",
"username": "ユーザー名",
@@ -925,8 +915,6 @@
"key": "キー",
"credential": "資格情報",
"none": "なし",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "接続ごとにOPKSSHのweb-authを表示します。認証トークンは更新されるまで24時間持続します。",
"selectCredential": "証明書を選択",
"selectCredentialPlaceholder": "資格情報を選択...",
"credentialRequired": "資格情報認証を使用するには認証情報が必要です",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "キーボード対話型認証",
"noneAuthDescription": "この認証方法は、SSH サーバーに接続する際にキーボード対話型認証を使用します。",
"noneAuthDetails": "キーボード対話型認証により、サーバーは接続中に資格情報の入力を求めることができます。 これは、多要素認証を必要とするサーバーや、ローカルで資格情報を保存したくないサーバーに便利です。",
- "opksshAuthTitle": "OPKSSH 認証",
"forceKeyboardInteractive": "キーボード対話を強制する",
"forceKeyboardInteractiveDesc": "2要素認証(TOTP/2FA)を使用するサーバーでは、キーボード対話型認証を強制的に使用します。",
"overrideCredentialUsername": "認証情報のユーザー名を上書きする",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "ブラウザで開く",
"warpgateContinue": "認証を完了しました",
"warpgateTimeout": "ワープゲート認証がタイムアウトしました。再接続してください。",
- "opksshAuthRequired": "OPKSSH 認証が必要です",
- "opksshAuthDescription": "ブラウザで認証を完了してください。このセッションは24時間有効です。",
- "opksshAuthUrl": "認証URL",
- "opksshOpenBrowser": "ブラウザを開いて認証する",
- "opksshWaitingForAuth": "ブラウザでの認証を待っています...",
- "opksshAuthenticating": "認証を処理中...",
- "opksshTimeout": "認証がタイムアウトしました。もう一度やり直してください。",
- "opksshAuthFailed": "認証に失敗しました。資格情報を確認して、もう一度やり直してください。",
- "opksshConfigMissing": "OPKSSH設定が見つかりません。OIDCプロバイダ設定で~/.opk/config.ymlを作成してください。ドキュメントを参照してください: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "パスワードを入力してください。",
"sudoPasswordPopupHint": "Enterキーを押して挿入、Escキーで閉じる",
"sudoPasswordPopupConfirm": "Insert",
@@ -1359,8 +1337,7 @@
"automaticFallback": "{{method}} 認証を自動的に試みています...",
"totpTimeout": "TOTP 認証がタイムアウトしました。再接続してください。",
"passwordTimeout": "パスワードの検証がタイムアウトしました。再接続してください。",
- "connectionRejected": "サーバーによって接続が拒否されました。認証とネットワーク設定を確認してください。",
- "hostKeyRejected": "SSHホスト鍵の検証が拒否されました。接続がキャンセルされました。"
+ "connectionRejected": "サーバーによって接続が拒否されました。認証とネットワーク設定を確認してください。"
},
"fileManager": {
"title": "ファイルマネージャー",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "パスワードをリセットしました",
"passwordResetSuccessDesc": "パスワードが正常にリセットされました。新しいパスワードでログインできるようになりました。"
},
- "hostKey": {
- "verifyNewHost": "SSHホストキーを確認",
- "keyChangedWarning": "SSH ホストキーが変更されました",
- "firstConnectionTitle": "初めてこのホストに接続しました",
- "firstConnectionDescription": "このホストの信頼性を確立できません。指紋が期待されているものと一致することを確認してください。",
- "keyChangedDescription": "このサーバーのホストキーは、最後の接続から変更されました。これはセキュリティ上の問題を示している可能性があります。",
- "previousKey": "前のキー",
- "newFingerprint": "新しいフィンガープリント",
- "fingerprint": "フィンガープリント",
- "verifyInstructions": "このホストを信頼している場合は、format@@0をクリックして続行し、今後の接続のためにこの指紋を保存します。",
- "securityWarning": "セキュリティ警告",
- "acceptAndContinue": "承認して続ける",
- "acceptNewKey": "新しいキーを承認して続ける"
- },
"errors": {
"notFound": "ページが見つかりません",
"unauthorized": "権限のないアクセス",
@@ -2063,8 +2026,6 @@
"terminalSettings": "ターミナル",
"hostSidebarSettings": "ホスト & サイドバー",
"snippetsSettings": "Snippets",
- "confirmSnippetExecution": "スニペットの実行を確認",
- "confirmSnippetExecutionDesc": "スニペットを実行する前に確認ダイアログを表示する",
"updateSettings": "更新",
"disableUpdateCheck": "アップデートチェックを無効にする",
"disableUpdateCheckDesc": "起動時とダッシュボードで新しいバージョンの確認を停止します。ネットワークリクエストを削減します。",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1またはexample.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/ko_KR.json b/src/locales/translated/ko_KR.json
index a6eaac90..d7649caf 100644
--- a/src/locales/translated/ko_KR.json
+++ b/src/locales/translated/ko_KR.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "신임장",
"credentialsViewer": "자격 증명 뷰어",
"manageYourSSHCredentials": "SSH 자격 증명을 안전하게 관리하세요",
"addCredential": "자격 증명 추가",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "이렇게 하면 기존 키를 덮어쓰지 않고 대상 호스트의 ~/.ssh/authorized_keys 파일에 공개 키가 안전하게 추가됩니다. 이 작업은 되돌릴 수 있습니다.",
"chooseHostToDeploy": "배포할 호스트를 선택하세요...",
"deploying": "배포 중...",
- "copyDeployCommand": "배포 명령 복사",
- "copyDeployCommandDescription": "호스트의 authorized_keys 파일에 공개 키를 수동으로 추가하는 셸 명령어를 복사합니다. 암호 인증이 비활성화된 경우 유용합니다.",
- "deployCommandCopied": "배포 명령어가 클립보드에 복사되었습니다.",
- "manualDeployInfo": "대상 호스트의 터미널(예: Proxmox 콘솔, IPMI 또는 물리적 접근)에 다음 명령어를 붙여넣어 SSH 키를 추가하십시오.",
"name": "이름",
"noHostsAvailable": "호스트를 사용할 수 없습니다.",
"noHostsMatchSearch": "검색 조건에 맞는 호스트가 없습니다.",
@@ -303,9 +298,7 @@
"createFolder": "폴더 생성",
"editFolder": "폴더 편집",
"editFolderDescription": "스니펫 폴더를 사용자 지정하세요",
- "createFolderDescription": "메모들을 폴더별로 정리하세요",
- "confirmExecution": "\"{{name}}\"을 실행하시겠습니까?",
- "confirmExecutionDesc": "이 코드 조각을 실행하시겠습니까?"
+ "createFolderDescription": "메모들을 폴더별로 정리하세요"
},
"commandHistory": {
"title": "역사",
@@ -409,7 +402,6 @@
"required": "필수의",
"optional": "선택 과목",
"connect": "연결하다",
- "copied": "복사됨",
"connecting": "연결 중...",
"creating": "생성 중...",
"clear": "분명한",
@@ -502,7 +494,6 @@
"checking": "확인 중...",
"checkingDatabase": "데이터베이스 연결을 확인하는 중...",
"checkingAuthentication": "인증 상태를 확인하는 중...",
- "backendReconnected": "서버 연결이 복구되었습니다.",
"actions": "행위",
"remove": "제거하다",
"revoke": "취소",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "호스트 관리자",
- "hosts": "호스트",
"sshHosts": "SSH 호스트",
"noHosts": "SSH 호스트 없음",
"noHostsMessage": "아직 SSH 호스트를 추가하지 않았습니다. 시작하려면 \"호스트 추가\"를 클릭하세요.",
@@ -846,7 +836,7 @@
"failedToImportJson": "JSON 파일을 가져오는 데 실패했습니다.",
"connectionDetails": "연결 정보",
"organization": "조직",
- "ipAddress": "IP 주소 또는 호스트 이름",
+ "ipAddress": "IP 주소",
"port": "포트",
"name": "이름",
"username": "사용자 이름",
@@ -925,8 +915,6 @@
"key": "열쇠",
"credential": "신임장",
"none": "없음",
- "opkssh": "오프크시",
- "opksshAuthDescription": "접속할 때마다 OPKSSH 웹 인증을 요청합니다. 인증 토큰은 24시간 동안 유효하며, 갱신해야 합니다.",
"selectCredential": "자격증명을 선택하세요",
"selectCredentialPlaceholder": "자격증을 선택하세요...",
"credentialRequired": "자격 증명 인증을 사용할 때는 자격 증명이 필요합니다.",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "키보드 인터랙티브 인증",
"noneAuthDescription": "이 인증 방식은 SSH 서버에 연결할 때 키보드 대화형 인증을 사용합니다.",
"noneAuthDetails": "키보드 대화형 인증을 사용하면 서버에 연결하는 동안 자격 증명을 입력하라는 메시지가 표시됩니다. 이는 다단계 인증이 필요한 서버나 자격 증명을 로컬에 저장하지 않으려는 경우에 유용합니다.",
- "opksshAuthTitle": "OPKSSH 인증",
"forceKeyboardInteractive": "키보드 상호작용 강제 적용",
"forceKeyboardInteractiveDesc": "키보드 입력 방식의 인증을 강제로 사용하게 합니다. 이는 2단계 인증(TOTP/2FA)을 사용하는 서버에서 필요한 경우가 있습니다.",
"overrideCredentialUsername": "자격 증명 사용자 이름 재정의",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "브라우저에서 열기",
"warpgateContinue": "인증이 완료되었습니다",
"warpgateTimeout": "워프게이트 인증 시간 초과입니다. 다시 연결해 주세요.",
- "opksshAuthRequired": "OPKSSH 인증이 필요합니다",
- "opksshAuthDescription": "계속하려면 브라우저에서 인증을 완료하세요. 이 세션은 24시간 동안 유효합니다.",
- "opksshAuthUrl": "인증 URL",
- "opksshOpenBrowser": "인증하려면 브라우저를 여세요",
- "opksshWaitingForAuth": "브라우저에서 인증을 기다리는 중...",
- "opksshAuthenticating": "인증 처리 중...",
- "opksshTimeout": "인증 시간이 초과되었습니다. 다시 시도해 주세요.",
- "opksshAuthFailed": "인증에 실패했습니다. 자격 증명을 확인하고 다시 시도해 주세요.",
- "opksshConfigMissing": "OPKSSH 구성 파일을 찾을 수 없습니다. OIDC 공급자 설정이 포함된 ~/.opk/config.yml 파일을 생성하십시오. 자세한 내용은 다음 문서를 참조하십시오. https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "비밀번호를 입력하시겠습니까?",
"sudoPasswordPopupHint": "Enter 키를 눌러 삽입하고, Esc 키를 눌러 닫습니다.",
"sudoPasswordPopupConfirm": "끼워 넣다",
@@ -1359,8 +1337,7 @@
"automaticFallback": "{{method}} 인증을 자동으로 시도합니다...",
"totpTimeout": "TOTP 인증 시간이 초과되었습니다. 다시 연결해 주세요.",
"passwordTimeout": "비밀번호 인증 시간이 초과되었습니다. 다시 연결해 주세요.",
- "connectionRejected": "서버에서 연결이 거부되었습니다. 인증 및 네트워크 구성을 확인하십시오.",
- "hostKeyRejected": "SSH 호스트 키 인증이 거부되었습니다. 연결이 취소되었습니다."
+ "connectionRejected": "서버에서 연결이 거부되었습니다. 인증 및 네트워크 구성을 확인하십시오."
},
"fileManager": {
"title": "파일 관리자",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "비밀번호 재설정 성공",
"passwordResetSuccessDesc": "비밀번호가 성공적으로 재설정되었습니다. 이제 새 비밀번호로 로그인할 수 있습니다."
},
- "hostKey": {
- "verifyNewHost": "SSH 호스트 키 확인",
- "keyChangedWarning": "SSH 호스트 키가 변경되었습니다.",
- "firstConnectionTitle": "이 호스트에 처음 연결합니다.",
- "firstConnectionDescription": "이 호스트의 진위 여부를 확인할 수 없습니다. 지문이 예상과 일치하는지 확인하십시오.",
- "keyChangedDescription": "이 서버의 호스트 키가 마지막 접속 이후 변경되었습니다. 이는 보안 문제를 나타낼 수 있습니다.",
- "previousKey": "이전 키",
- "newFingerprint": "새로운 지문",
- "fingerprint": "지문",
- "verifyInstructions": "이 호스트를 신뢰한다면 '수락'을 클릭하여 계속 진행하고 향후 연결을 위해 이 지문을 저장하세요.",
- "securityWarning": "보안 경고",
- "acceptAndContinue": "수락 및 계속",
- "acceptNewKey": "새 키를 수락하고 계속 진행하세요"
- },
"errors": {
"notFound": "페이지를 찾을 수 없습니다",
"unauthorized": "무단 접근",
@@ -2063,8 +2026,6 @@
"terminalSettings": "터미널",
"hostSidebarSettings": "호스트 및 사이드바",
"snippetsSettings": "스니펫",
- "confirmSnippetExecution": "스니펫 실행 확인",
- "confirmSnippetExecutionDesc": "코드 조각 실행 전에 확인 대화 상자를 표시합니다.",
"updateSettings": "업데이트",
"disableUpdateCheck": "업데이트 확인 비활성화",
"disableUpdateCheckDesc": "시작 시 및 대시보드에서 새 버전 확인을 중지합니다. 네트워크 요청이 줄어듭니다.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 또는 example.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/nl_NL.json b/src/locales/translated/nl_NL.json
index 4eba9384..b4b9d5e5 100644
--- a/src/locales/translated/nl_NL.json
+++ b/src/locales/translated/nl_NL.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Aanmeldgegevens",
"credentialsViewer": "Referenties bekijken",
"manageYourSSHCredentials": "Beheer uw SSH inloggegevens veilig",
"addCredential": "Toegangsgegevens toevoegen",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Dit zal veilig de publieke sleutel toevoegen aan het doel host's ~/.ssh/authorized_keys bestand zonder bestaande sleutels te overschrijven. De bewerking is omkeerbaar.",
"chooseHostToDeploy": "Kies een host om naar te implementeren...",
"deploying": "Implementeren...",
- "copyDeployCommand": "Kopieer implementatieopdracht",
- "copyDeployCommandDescription": "Kopieer een shell commando om handmatig de publieke sleutel toe te voegen aan het authorized_keys bestand van een host. Handig wanneer wachtwoordauthenticatie is uitgeschakeld.",
- "deployCommandCopied": "Implementeer opdracht gekopieerd naar klembord",
- "manualDeployInfo": "Plak deze opdracht in de terminal van de doel host (bijv. Proxmox console, IPMI of fysieke toegang) om de SSH sleutel toe te voegen.",
"name": "naam",
"noHostsAvailable": "Geen hosts beschikbaar",
"noHostsMatchSearch": "Er zijn geen hosts die voldoen aan jouw zoekopdracht",
@@ -303,9 +298,7 @@
"createFolder": "Map aanmaken",
"editFolder": "Map bewerken",
"editFolderDescription": "Tekstbouwstenenmap aanpassen",
- "createFolderDescription": "Organiseer uw snippets in mappen",
- "confirmExecution": "Uitvoeren \"{{name}}\"?",
- "confirmExecutionDesc": "Weet u zeker dat u deze snippet wilt uitvoeren?"
+ "createFolderDescription": "Organiseer uw snippets in mappen"
},
"commandHistory": {
"title": "Geschiedenis",
@@ -409,7 +402,6 @@
"required": "vereist",
"optional": "Optioneel",
"connect": "Verbinden",
- "copied": "Gekoppeld",
"connecting": "Verbinden...",
"creating": "Maken...",
"clear": "Verwijderen",
@@ -502,7 +494,6 @@
"checking": "Controleren...",
"checkingDatabase": "Database-verbinding controleren...",
"checkingAuthentication": "Authenticatie controleren...",
- "backendReconnected": "Serververbinding hersteld",
"actions": "acties",
"remove": "Verwijderen",
"revoke": "Revoke",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Host Manager",
- "hosts": "Verantwoordelijken",
"sshHosts": "SSH hosts",
"noHosts": "Geen SSH hosts",
"noHostsMessage": "U heeft nog geen SSH hosts toegevoegd. Klik op \"Host toevoegen\" om te beginnen.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Kan JSON bestand niet importeren",
"connectionDetails": "Connectie Details",
"organization": "Rekening",
- "ipAddress": "IP-adres of hostnaam",
+ "ipAddress": "IP adres",
"port": "Poort",
"name": "naam",
"username": "Gebruikersnaam",
@@ -925,8 +915,6 @@
"key": "Sleutel",
"credential": "Toegangsgegevens",
"none": "geen",
- "opkssh": "OPKENEN",
- "opksshAuthDescription": "Verzoekt u naar OPKSSH web-authenticatie bij elke verbinding. Uw autorisatietoken zal 24 uur duren tot het moet worden vernieuwd.",
"selectCredential": "Selecteer referenties",
"selectCredentialPlaceholder": "Kies een inloggegevens...",
"credentialRequired": "Referentiegegevens zijn vereist bij het gebruik van referentieauthenticatie",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Toetsenbord-Interactieve authenticatie",
"noneAuthDescription": "Deze verificatiemethode gebruikt keyboard-interactieve authenticatie bij het verbinden met de SSH-server.",
"noneAuthDetails": "Keyboard-interactieve authenticatie maakt het mogelijk dat de server je om inloggegevens vraagt tijdens het verbinden. Dit is handig voor servers waarvoor multi-factor authenticatie vereist is of als u geen lokale inloggegevens wilt opslaan.",
- "opksshAuthTitle": "OPKSSH authenticatie",
"forceKeyboardInteractive": "Forceer toetsenbord-interactief",
"forceKeyboardInteractiveDesc": "Forceert het gebruik van keyboard-interactieve authenticatie. Dit is soms vereist voor servers die gebruik maken van tweestapsverificatie (TOTP/2FA).",
"overrideCredentialUsername": "Aanmeldgebruikersnaam overschrijven",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Open in browser",
"warpgateContinue": "Ik heb authenticatie voltooid",
"warpgateTimeout": "Warpgate authenticatie time-out. Maak opnieuw verbinding.",
- "opksshAuthRequired": "OPKSSH authenticatie vereist",
- "opksshAuthDescription": "Voltooi de authenticatie in je browser om door te gaan. Deze sessie blijft 24 uur geldig.",
- "opksshAuthUrl": "URL authenticatie",
- "opksshOpenBrowser": "Browser openen om te verifiëren",
- "opksshWaitingForAuth": "Wachten op authenticatie in browser...",
- "opksshAuthenticating": "Authenticatie verwerken...",
- "opksshTimeout": "Authenticatie is verlopen. Probeer het opnieuw.",
- "opksshAuthFailed": "Authenticatie mislukt. Controleer uw inloggegevens en probeer het opnieuw.",
- "opksshConfigMissing": "OPKSSH configuratie niet gevonden. Maak ~/.opk/config.yml aan met uw OIDC providerinstellingen. Zie documentatie: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Wachtwoord invoeren?",
"sudoPasswordPopupHint": "Druk op Enter om in te voegen, Esc om te sluiten",
"sudoPasswordPopupConfirm": "Insert",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Authenticatie {{method}} automatisch proberen...",
"totpTimeout": "TOTP-verificatie time-out. Gelieve opnieuw te verbinden.",
"passwordTimeout": "Wachtwoord verificatie time-out. Gelieve opnieuw te verbinden.",
- "connectionRejected": "Verbinding geweigerd door de server. Controleer uw authenticatie en netwerkconfiguratie.",
- "hostKeyRejected": "SSH host sleutel verificatie afgewezen. Verbinding geannuleerd."
+ "connectionRejected": "Verbinding geweigerd door de server. Controleer uw authenticatie en netwerkconfiguratie."
},
"fileManager": {
"title": "Bestands Beheer",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Wachtwoord opnieuw instellen geslaagd",
"passwordResetSuccessDesc": "Uw wachtwoord is met succes gereset. U kunt nu inloggen met uw nieuwe wachtwoord."
},
- "hostKey": {
- "verifyNewHost": "Verifieer SSH host-sleutel",
- "keyChangedWarning": "SSH host-sleutel gewijzigd",
- "firstConnectionTitle": "Eerste keer verbinden met deze host",
- "firstConnectionDescription": "De authenticiteit van deze host kan niet worden vastgesteld. Controleer of de vingerafdruk overeenkomt met uw verwachting.",
- "keyChangedDescription": "De hostsleutel voor deze server is veranderd sinds je laatste verbinding. Dit kan duiden op een beveiligingsprobleem.",
- "previousKey": "Vorige sleutel",
- "newFingerprint": "Nieuwe vingerafdruk",
- "fingerprint": "Vingerafdruk",
- "verifyInstructions": "Als je deze host vertrouwt, klik je op Accepteren om door te gaan en sla deze vingerafdruk op voor toekomstige verbindingen.",
- "securityWarning": "Waarschuwing beveiliging",
- "acceptAndContinue": "Accepteren & doorgaan",
- "acceptNewKey": "Accepteer Nieuwe Sleutel & Doorgaan"
- },
"errors": {
"notFound": "Pagina niet gevonden",
"unauthorized": "Onbevoegde toegang",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Terminal",
"hostSidebarSettings": "Host & Sidebar",
"snippetsSettings": "Tekstbouwstenen",
- "confirmSnippetExecution": "Bevestig de uitvoering van de Snippet",
- "confirmSnippetExecutionDesc": "Toon bevestigingsvenster voor het uitvoeren van snippets",
"updateSettings": "Bijwerken",
"disableUpdateCheck": "Updatecontrole uitschakelen",
"disableUpdateCheckDesc": "Stop met controleren op nieuwe versies bij opstarten en dashboard. Vermindert netwerkaanvragen.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 of voorbeeld.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/no_NO.json b/src/locales/translated/no_NO.json
index 31e60028..cb4733c4 100644
--- a/src/locales/translated/no_NO.json
+++ b/src/locales/translated/no_NO.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Legitimasjon",
"credentialsViewer": "Legitimasjonsvisning",
"manageYourSSHCredentials": "Administrer SSH-legitimasjon sikkert",
"addCredential": "Legg til legitimasjon",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Dette legger den offentlige nøkkelen trygt til målserverens ~/.ssh/authorized_keys uten å overskrive eksisterende nøkler. Operasjonen kan reverseres.",
"chooseHostToDeploy": "Velg en vert å distribuere til...",
"deploying": "Distribuerer...",
- "copyDeployCommand": "Kopier distribueringskommandoen",
- "copyDeployCommandDescription": "Kopier en skallkommando for å legge den offentlige nøkkelen til en verts autorisert_key-fil. Nyttig når passordautentisering er deaktivert.",
- "deployCommandCopied": "Distribuer kommandoen kopiert til utklippstavlen",
- "manualDeployInfo": "Lim inn denne kommandoen i målvertens terminal (f.eks. Proxmox konsoll, IPMI eller fysisk tilgang) for å legge til SSH-nøkkelen.",
"name": "Navn",
"noHostsAvailable": "Ingen verter tilgjengelig",
"noHostsMatchSearch": "Ingen verter samsvarer med søket",
@@ -303,9 +298,7 @@
"createFolder": "Opprett mappe",
"editFolder": "Rediger mappe",
"editFolderDescription": "Tilpass utdragsmappen din",
- "createFolderDescription": "Organiser snippeten dine i mapper",
- "confirmExecution": "Utfør \"{{name}}\"?",
- "confirmExecutionDesc": "Er du sikker på at du vil utføre dette snippet?"
+ "createFolderDescription": "Organiser snippeten dine i mapper"
},
"commandHistory": {
"title": "Historikk",
@@ -409,7 +402,6 @@
"required": "Påkrevd",
"optional": "Valgfritt",
"connect": "Koble til",
- "copied": "Kopiert",
"connecting": "Kobler til...",
"creating": "Oppretter...",
"clear": "Tøm",
@@ -502,7 +494,6 @@
"checking": "Kontrollerer...",
"checkingDatabase": "Kontrollerer databaseforbindelse...",
"checkingAuthentication": "Kontrollerer autentisering...",
- "backendReconnected": "Servertilkobling gjenopprettet",
"actions": "Handlinger",
"remove": "Fjern",
"revoke": "Revoke",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Vertadministrator",
- "hosts": "Verter",
"sshHosts": "SSH-verter",
"noHosts": "Ingen SSH-verter",
"noHostsMessage": "Du har ikke lagt til noen SSH-verter ennå. Klikk \"Legg til vert\" for å begynne.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Kunne ikke importere JSON-fil",
"connectionDetails": "Tilkoblingsdetaljer",
"organization": "Organisasjon",
- "ipAddress": "IP adresse eller vertsnavn",
+ "ipAddress": "IP-adresse",
"port": "Port",
"name": "Navn",
"username": "Brukernavn",
@@ -925,8 +915,6 @@
"key": "Nøkkel",
"credential": "Legitimasjon",
"none": "Ingen",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Ledetekster deg for OPKSSH web-auth ved hver tilkobling. Auth token varer i 24 timer til den må fornyes.",
"selectCredential": "Velg legitimasjon",
"selectCredentialPlaceholder": "Velg en legitimasjon...",
"credentialRequired": "Legitimasjon kreves ved legitimasjonsautentisering",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Tastaturinteraktiv autentisering",
"noneAuthDescription": "Denne autentiseringsmetoden bruker tastaturinteraktiv autentisering ved tilkobling til SSH-serveren.",
"noneAuthDetails": "Tastaturinteraktiv autentisering lar serveren be deg om legitimasjon under tilkobling. Nyttig for servere som krever flerfaktorautentisering eller hvis du ikke vil lagre legitimasjon lokalt.",
- "opksshAuthTitle": "OPKSSH godkjenning",
"forceKeyboardInteractive": "Tving tastaturinteraktiv",
"forceKeyboardInteractiveDesc": "Tving bruk av tastaturinteraktiv autentisering. Ofte nødvendig for servere som bruker tofaktorautentisering (TOTP/2FA).",
"overrideCredentialUsername": "Overstyr brukernavn fra legitimasjon",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Åpne i nettleser",
"warpgateContinue": "Jeg har fullført godkjenning",
"warpgateTimeout": "Warpgate autentisering tidsavbrudd. Vennligst koble til på nytt.",
- "opksshAuthRequired": "OPKSSH godkjenning kreves",
- "opksshAuthDescription": "Fullstendig autentisering i nettleseren din for å fortsette. Denne økten er gyldig i 24 timer.",
- "opksshAuthUrl": "Autentisering URL",
- "opksshOpenBrowser": "Åpne nettleseren for å godkjenne",
- "opksshWaitingForAuth": "Venter på godkjenning i nettleseren...",
- "opksshAuthenticating": "Behandler autentisering...",
- "opksshTimeout": "Autentisering ble tidsavbrutt. Vennligst prøv igjen.",
- "opksshAuthFailed": "Godkjenning mislyktes. Kontroller påloggingsinformasjonen, og prøv på nytt.",
- "opksshConfigMissing": "OPKSSH konfigurasjon ikke funnet. Lag ~/.opk/config.yml med dine OIDC leverandørinnstillinger. Se dokumentasjon: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Sette inn passord?",
"sudoPasswordPopupHint": "Trykk på Enter for å sette inn, Esc for å fjerne",
"sudoPasswordPopupConfirm": "Insert",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Prøver automatisk {{method}} autentisering...",
"totpTimeout": "TOTP-verifisering tidsavbrudd. Vennligst koble til på nytt.",
"passwordTimeout": "Tidsavbrudd for bekreftelse av passord. Vennligst koble til på nytt.",
- "connectionRejected": "Tilkobling avvist av serveren. Kontroller godkjennings- og nettverkskonfigurasjonen.",
- "hostKeyRejected": "SSH vertsnøkkelverifisering ble avvist. Tilkoblingen avbrutt."
+ "connectionRejected": "Tilkobling avvist av serveren. Kontroller godkjennings- og nettverkskonfigurasjonen."
},
"fileManager": {
"title": "Filbehandler",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Tilbakestilling av passord vellykket",
"passwordResetSuccessDesc": "Passordet er blitt tilbakestilt. Du kan nå logge inn med ditt nye passord."
},
- "hostKey": {
- "verifyNewHost": "Verifiser SSH vertsnøkkel",
- "keyChangedWarning": "SSH vertsnøkkel endret",
- "firstConnectionTitle": "Første gang du kobler til denne verten",
- "firstConnectionDescription": "Vertens autensitet kan ikke etableres. Kontroller fingeravtrykket samsvarer med det du forventer.",
- "keyChangedDescription": "Vertsnøkkelen for denne serveren er endret siden forrige tilkobling. Dette kan angi et sikkerhetsproblem.",
- "previousKey": "Forrige nøkkel",
- "newFingerprint": "Nytt fingeravtrykk",
- "fingerprint": "Fingeravtrykk",
- "verifyInstructions": "Hvis du stoler på denne verten, klikk Godta for å fortsette og lagre dette fingeravtrykket for fremtidige tilkoblinger.",
- "securityWarning": "Sikkerhetsadvarsel",
- "acceptAndContinue": "Godta og fortsett",
- "acceptNewKey": "Aksepter ny nøkkel og fortsett"
- },
"errors": {
"notFound": "Siden ble ikke funnet",
"unauthorized": "Uautorisert tilgang",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Terminal",
"hostSidebarSettings": "Verts- og sidepanelet",
"snippetsSettings": "Snippets",
- "confirmSnippetExecution": "Bekreft kjøring av bruddtekst",
- "confirmSnippetExecutionDesc": "Vis bekreftelsesdialog før du utfører snippets",
"updateSettings": "Oppdateringer",
"disableUpdateCheck": "Deaktiver oppdateringskontroll",
"disableUpdateCheckDesc": "Slutt å sjekke nye versjoner ved oppstart og dashbord. Reduserer nettverksforespørsler.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 eller eksempel.no",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/pl_PL.json b/src/locales/translated/pl_PL.json
index 86838aed..739189df 100644
--- a/src/locales/translated/pl_PL.json
+++ b/src/locales/translated/pl_PL.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Dane logowania",
"credentialsViewer": "Przeglądarka danych logowania",
"manageYourSSHCredentials": "Zarządzaj swoimi danymi uwierzytelniającymi SSH bezpiecznie",
"addCredential": "Dodaj poświadczenia",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "To bezpiecznie doda klucz publiczny do pliku hosta ~/.ssh/authorized_keys bez nadpisywania istniejących kluczy. Operacja jest odwracalna.",
"chooseHostToDeploy": "Wybierz hosta do wdrożenia...",
"deploying": "Wdrażanie...",
- "copyDeployCommand": "Kopiuj polecenie wdrożenia",
- "copyDeployCommandDescription": "Skopiuj polecenie powłoki, aby ręcznie dodać klucz publiczny do pliku authorized_keys hosta. Przydatne, gdy uwierzytelnianie hasła jest wyłączone.",
- "deployCommandCopied": "Wdrożenie polecenia skopiowane do schowka",
- "manualDeployInfo": "Wklej to polecenie w terminalu docelowego hosta (np. konsola Proxmox, IPMI lub fizyczny dostęp) aby dodać klucz SSH.",
"name": "Nazwisko",
"noHostsAvailable": "Brak dostępnych hostów",
"noHostsMatchSearch": "Brak hostów pasujących do Twojego wyszukiwania",
@@ -303,9 +298,7 @@
"createFolder": "Utwórz folder",
"editFolder": "Edytuj folder",
"editFolderDescription": "Dostosuj swój folder snippet",
- "createFolderDescription": "Organizuj swoje fragmenty w folderach",
- "confirmExecution": "Wykonać \"{{name}}\"?",
- "confirmExecutionDesc": "Czy na pewno chcesz wykonać ten snippet?"
+ "createFolderDescription": "Organizuj swoje fragmenty w folderach"
},
"commandHistory": {
"title": "Historia",
@@ -409,7 +402,6 @@
"required": "Wymagane",
"optional": "Opcjonalnie",
"connect": "Połącz",
- "copied": "Skopiowano",
"connecting": "Łączenie...",
"creating": "Tworzenie...",
"clear": "Wyczyść",
@@ -502,7 +494,6 @@
"checking": "Sprawdzanie...",
"checkingDatabase": "Sprawdzanie połączenia z bazą danych...",
"checkingAuthentication": "Sprawdzanie uwierzytelniania...",
- "backendReconnected": "Połączenie z serwerem przywrócone",
"actions": "Akcje",
"remove": "Usuń",
"revoke": "Revoke",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Host Manager",
- "hosts": "Hosty",
"sshHosts": "Hosty SSH",
"noHosts": "Brak hostów SSH",
"noHostsMessage": "Nie dodano jeszcze żadnych hostów SSH. Kliknij \"Dodaj hosta\", aby rozpocząć.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Nie udało się zaimportować pliku JSON",
"connectionDetails": "Szczegóły połączenia",
"organization": "Organizacja",
- "ipAddress": "Adres IP lub nazwa hosta",
+ "ipAddress": "Adres IP",
"port": "Port",
"name": "Nazwisko",
"username": "Nazwa użytkownika",
@@ -925,8 +915,6 @@
"key": "Klucz",
"credential": "Dane logowania",
"none": "Brak",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Pyta Cię o autoryzację sieciową OPKSSH przy każdym połączeniu. Twój token uwierzytelniania potrwa 24 godziny, dopóki nie będzie musiał zostać przedłużony.",
"selectCredential": "Wybierz dane logowania",
"selectCredentialPlaceholder": "Wybierz poświadczenie...",
"credentialRequired": "Dane logowania są wymagane podczas korzystania z uwierzytelniania logowania",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Interaktywne uwierzytelnianie klawiatury",
"noneAuthDescription": "Ta metoda uwierzytelniania będzie używała interaktywnego uwierzytelniania klawiatury podczas łączenia z serwerem SSH.",
"noneAuthDetails": "Interaktywne uwierzytelnianie klawiatury pozwala na zapytanie serwera o poświadczenia podczas połączenia. Jest to przydatne dla serwerów, które wymagają wieloetapowego uwierzytelniania lub jeśli nie chcesz zapisywać danych logowania lokalnie.",
- "opksshAuthTitle": "Uwierzytelnianie OPKSSH",
"forceKeyboardInteractive": "Wymuś Interaktywną Klawiaturę",
"forceKeyboardInteractiveDesc": "Wymusza użycie interaktywnego uwierzytelniania klawiatury. Jest to czasami wymagane dla serwerów, które używają dwustopniowego uwierzytelniania (TOTP/2FA).",
"overrideCredentialUsername": "Zastąp nazwę użytkownika",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Otwórz w przeglądarce",
"warpgateContinue": "Zakończyłem uwierzytelnianie",
"warpgateTimeout": "Limit czasu uwierzytelniania Warpgate. Proszę połączyć się ponownie.",
- "opksshAuthRequired": "Wymagane uwierzytelnienie OPKSSH",
- "opksshAuthDescription": "Zakończ uwierzytelnianie w przeglądarce, aby kontynuować. Ta sesja pozostanie ważna przez 24 godziny.",
- "opksshAuthUrl": "Adres URL uwierzytelniania",
- "opksshOpenBrowser": "Otwórz przeglądarkę do uwierzytelniania",
- "opksshWaitingForAuth": "Oczekiwanie na uwierzytelnianie w przeglądarce...",
- "opksshAuthenticating": "Przetwarzanie uwierzytelniania...",
- "opksshTimeout": "Upłynął limit czasu uwierzytelniania. Spróbuj ponownie.",
- "opksshAuthFailed": "Uwierzytelnianie nie powiodło się. Sprawdź swoje poświadczenia i spróbuj ponownie.",
- "opksshConfigMissing": "Konfiguracja OPKSSH nie została znaleziona. Proszę utworzyć ~/.opk/config.yml z ustawieniami dostawcy OIDC. Zobacz dokumentacja: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Wprowadzić hasło?",
"sudoPasswordPopupHint": "Naciśnij Enter, aby wstawić, Esc, aby odrzucić",
"sudoPasswordPopupConfirm": "Insert",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Automatycznie próbuje uwierzytelnić {{method}}...",
"totpTimeout": "Limit czasu weryfikacji TOTP. Proszę ponownie połączyć.",
"passwordTimeout": "Limit czasu weryfikacji hasła. Proszę połączyć się ponownie.",
- "connectionRejected": "Połączenie odrzucone przez serwer. Sprawdź swoje uwierzytelnianie i konfigurację sieci.",
- "hostKeyRejected": "Weryfikacja klucza hosta SSH odrzucona. Połączenie anulowane."
+ "connectionRejected": "Połączenie odrzucone przez serwer. Sprawdź swoje uwierzytelnianie i konfigurację sieci."
},
"fileManager": {
"title": "Menedżer plików",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Hasło zostało zresetowane",
"passwordResetSuccessDesc": "Twoje hasło zostało pomyślnie zresetowane. Możesz teraz zalogować się przy użyciu nowego hasła."
},
- "hostKey": {
- "verifyNewHost": "Zweryfikuj klucz hosta SSH",
- "keyChangedWarning": "Klucz hosta SSH został zmieniony",
- "firstConnectionTitle": "Pierwsze połączenie z tym hostem",
- "firstConnectionDescription": "Nie można ustalić autentyczności tego hosta. Sprawdź, czy odcisk palca odpowiada oczekiwaniom.",
- "keyChangedDescription": "Klucz hosta dla tego serwera zmienił się od ostatniego połączenia. Może to wskazywać na problem bezpieczeństwa.",
- "previousKey": "Poprzedni klucz",
- "newFingerprint": "Nowy odcisk palca",
- "fingerprint": "Odcisk palca",
- "verifyInstructions": "Jeśli ufasz temu hostowi, kliknij przycisk Akceptuj, aby kontynuować i zapisać ten odcisk palca dla przyszłych połączeń.",
- "securityWarning": "Ostrzeżenie bezpieczeństwa",
- "acceptAndContinue": "Zaakceptuj i kontynuuj",
- "acceptNewKey": "Akceptuj nowy klucz i kontynuuj"
- },
"errors": {
"notFound": "Strona nie znaleziona",
"unauthorized": "Nieautoryzowany dostęp",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Terminal",
"hostSidebarSettings": "Pasek hosta i boczny",
"snippetsSettings": "Fragmenty",
- "confirmSnippetExecution": "Potwierdź wykonanie Snippet",
- "confirmSnippetExecutionDesc": "Pokaż okno dialogowe potwierdzenia przed wykonaniem fragmentów",
"updateSettings": "Aktualizacje",
"disableUpdateCheck": "Wyłącz sprawdzanie aktualizacji",
"disableUpdateCheckDesc": "Zatrzymaj sprawdzanie nowych wersji na tablicy startowej i menedżerskiej. Zmniejsza żądania sieci.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 lub przykład.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/pt_BR.json b/src/locales/translated/pt_BR.json
index 5cd86843..006956ae 100644
--- a/src/locales/translated/pt_BR.json
+++ b/src/locales/translated/pt_BR.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Credenciais",
"credentialsViewer": "Visualizador de credenciais",
"manageYourSSHCredentials": "Gerencie suas credenciais SSH de forma segura",
"addCredential": "Adicionar Credencial",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Isto irá adicionar com segurança a chave pública ao arquivo ~/.ssh/authorized_keys sem sobrescrever as chaves existentes. A operação é reversível.",
"chooseHostToDeploy": "Escolha um host para implantar em...",
"deploying": "Implementando...",
- "copyDeployCommand": "Copiar Comando de Implementação",
- "copyDeployCommandDescription": "Copie um comando do shell para adicionar manualmente a chave pública ao arquivo authorized_keys de um host. Útil quando a autenticação de senha está desativada.",
- "deployCommandCopied": "Implantar comando copiado para a área de transferência",
- "manualDeployInfo": "Cole este comando no terminal do host alvo (por exemplo, console Proxmox, IPMI ou acesso físico) para adicionar a chave SSH.",
"name": "Nome:",
"noHostsAvailable": "Nenhum host disponível",
"noHostsMatchSearch": "Nenhum host corresponde à sua pesquisa",
@@ -303,9 +298,7 @@
"createFolder": "Criar pasta",
"editFolder": "Editar Pasta",
"editFolderDescription": "Personalize sua pasta de snippet",
- "createFolderDescription": "Organize seus snippets em pastas",
- "confirmExecution": "Executar \"{{name}}\"?",
- "confirmExecutionDesc": "Tem certeza que deseja executar este snippet?"
+ "createFolderDescription": "Organize seus snippets em pastas"
},
"commandHistory": {
"title": "Histórico",
@@ -409,7 +402,6 @@
"required": "Obrigatório",
"optional": "Opcional",
"connect": "Conectar",
- "copied": "Copiado",
"connecting": "Conectandochar@@0",
"creating": "Criando...",
"clear": "Limpar",
@@ -502,7 +494,6 @@
"checking": "Verificandochar@@0",
"checkingDatabase": "Verificando conexão com o banco de dados...",
"checkingAuthentication": "Verificando autenticação...",
- "backendReconnected": "Conexão do servidor restaurada",
"actions": "Ações.",
"remove": "Excluir",
"revoke": "Revoke",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Gerenciador de Host",
- "hosts": "Anfitriões",
"sshHosts": "Hosts SSH",
"noHosts": "Nenhum host SSH",
"noHostsMessage": "Você ainda não adicionou nenhum host SSH. Clique em \"Adicionar Host\" para começar.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Falha ao importar arquivo JSON",
"connectionDetails": "Detalhes da conexão",
"organization": "Cliente",
- "ipAddress": "Endereço IP ou Nome de Host",
+ "ipAddress": "Endereço IP",
"port": "Porta",
"name": "Nome:",
"username": "Usuário:",
@@ -925,8 +915,6 @@
"key": "Chave",
"credential": "Credencial",
"none": "Nenhuma",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Solicita a autenticação web do OPKSSH em cada conexão. Seu token de autenticação vai durar 24 horas até que ele deve ser renovado.",
"selectCredential": "Selecionar Credencial",
"selectCredentialPlaceholder": "Escolha uma credencial...",
"credentialRequired": "A credencial é necessária para usar autenticação de credenciais",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Autenticação interativa",
"noneAuthDescription": "Este método de autenticação usará a autenticação interativa de teclado ao conectar ao servidor SSH.",
"noneAuthDetails": "A autenticação interativa permite que o servidor lhe peça credenciais durante a conexão. Isso é útil para servidores que requerem autenticação multi-fatores ou você não quer salvar as credenciais localmente.",
- "opksshAuthTitle": "Autenticação OPKSSH",
"forceKeyboardInteractive": "Forçar teclado interativo",
"forceKeyboardInteractiveDesc": "Força o uso de autenticação interativa de teclado. Isso às vezes é necessário para servidores que usam a Autenticação de Dois Fatores (TOTP/2FA).",
"overrideCredentialUsername": "Substituir o nome de usuário credencial",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Abrir no Navegador",
"warpgateContinue": "Eu concluí a autenticação",
"warpgateTimeout": "A autenticação do Warpgate expirou. Por favor reconecte-se.",
- "opksshAuthRequired": "Autenticação OPKSSH necessária",
- "opksshAuthDescription": "Conclua a autenticação no seu navegador para continuar. Essa sessão permanecerá válida por 24 horas.",
- "opksshAuthUrl": "URL de autenticação",
- "opksshOpenBrowser": "Abra o navegador para autenticar",
- "opksshWaitingForAuth": "Aguardando autenticação no navegador...",
- "opksshAuthenticating": "Processando autenticação...",
- "opksshTimeout": "Tempo de autenticação esgotado. Por favor, tente novamente.",
- "opksshAuthFailed": "Falha na autenticação. Por favor, verifique suas credenciais e tente novamente.",
- "opksshConfigMissing": "Configuração OPKSSH não encontrada. Por favor, crie ~/.opk/config.yml com as configurações do provedor OIDC. Veja documentação: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Inserir senha?",
"sudoPasswordPopupHint": "Pressione Enter para inserir, Esc para dispensar",
"sudoPasswordPopupConfirm": "Insert",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Experimentando automaticamente autenticação {{method}}...",
"totpTimeout": "A verificação TOTP expirou. Por favor, reconecte-se.",
"passwordTimeout": "Verificação de senha expirada. Por favor, reconecte-se.",
- "connectionRejected": "Conexão rejeitada pelo servidor. Verifique sua autenticação e configuração de rede.",
- "hostKeyRejected": "Verificação de chave do host SSH rejeitada. Conexão cancelada."
+ "connectionRejected": "Conexão rejeitada pelo servidor. Verifique sua autenticação e configuração de rede."
},
"fileManager": {
"title": "Gerenciador de Arquivos",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Senha redefinida com sucesso",
"passwordResetSuccessDesc": "Sua senha foi redefinida com sucesso. Você agora pode entrar com sua nova senha."
},
- "hostKey": {
- "verifyNewHost": "Verificar chave do host SSH",
- "keyChangedWarning": "Chave do host SSH alterada",
- "firstConnectionTitle": "Primeira vez conectando-se a este host",
- "firstConnectionDescription": "A autenticidade deste host não pode ser estabelecida. Verifique a impressão digital que você espera.",
- "keyChangedDescription": "A chave do host para este servidor mudou desde a sua última conexão. Isso pode indicar um problema de segurança.",
- "previousKey": "Chave anterior",
- "newFingerprint": "Nova impressão digital",
- "fingerprint": "Impressão digital",
- "verifyInstructions": "Caso você confie neste host, clique em Aceitar para continuar e salvar esta impressão digital para conexões futuras.",
- "securityWarning": "Aviso de segurança",
- "acceptAndContinue": "Aceitar e Continuar",
- "acceptNewKey": "Aceitar nova chave e continuar"
- },
"errors": {
"notFound": "Página não encontrada",
"unauthorized": "Acesso não autorizado",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Terminal",
"hostSidebarSettings": "Host e Barra Lateral",
"snippetsSettings": "Trechos",
- "confirmSnippetExecution": "Confirmar execução de Snippet",
- "confirmSnippetExecutionDesc": "Mostrar diálogo antes de executar snippets",
"updateSettings": "Atualizações",
"disableUpdateCheck": "Desativar verificação de atualizações",
"disableUpdateCheckDesc": "Parar de verificar novas versões na inicialização e no painel. Reduz as solicitações de rede.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 ou exemplo.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/pt_PT.json b/src/locales/translated/pt_PT.json
index 2fd9cbde..2da1d18b 100644
--- a/src/locales/translated/pt_PT.json
+++ b/src/locales/translated/pt_PT.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Credenciais",
"credentialsViewer": "Visualizador de credenciais",
"manageYourSSHCredentials": "Gerencie suas credenciais SSH de forma segura",
"addCredential": "Adicionar Credencial",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Isto irá adicionar com segurança a chave pública ao arquivo ~/.ssh/authorized_keys sem sobrescrever as chaves existentes. A operação é reversível.",
"chooseHostToDeploy": "Escolha um host para implantar em...",
"deploying": "Implementando...",
- "copyDeployCommand": "Copiar Comando de Implementação",
- "copyDeployCommandDescription": "Copie um comando do shell para adicionar manualmente a chave pública ao arquivo authorized_keys de um host. Útil quando a autenticação de senha está desativada.",
- "deployCommandCopied": "Implantar comando copiado para a área de transferência",
- "manualDeployInfo": "Cole este comando no terminal do host alvo (por exemplo, console Proxmox, IPMI ou acesso físico) para adicionar a chave SSH.",
"name": "Nome:",
"noHostsAvailable": "Nenhum host disponível",
"noHostsMatchSearch": "Nenhum host corresponde à sua pesquisa",
@@ -303,9 +298,7 @@
"createFolder": "Criar pasta",
"editFolder": "Editar Pasta",
"editFolderDescription": "Personalize sua pasta de snippet",
- "createFolderDescription": "Organize seus snippets em pastas",
- "confirmExecution": "Executar \"{{name}}\"?",
- "confirmExecutionDesc": "Tem certeza que deseja executar este snippet?"
+ "createFolderDescription": "Organize seus snippets em pastas"
},
"commandHistory": {
"title": "Histórico",
@@ -409,7 +402,6 @@
"required": "Obrigatório",
"optional": "Opcional",
"connect": "Conectar",
- "copied": "Copiado",
"connecting": "Conectandochar@@0",
"creating": "Criando...",
"clear": "Limpar",
@@ -502,7 +494,6 @@
"checking": "Verificandochar@@0",
"checkingDatabase": "Verificando conexão com o banco de dados...",
"checkingAuthentication": "Verificando autenticação...",
- "backendReconnected": "Conexão do servidor restaurada",
"actions": "Ações.",
"remove": "Excluir",
"revoke": "Revoke",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Gerenciador de Host",
- "hosts": "Anfitriões",
"sshHosts": "Hosts SSH",
"noHosts": "Nenhum host SSH",
"noHostsMessage": "Você ainda não adicionou nenhum host SSH. Clique em \"Adicionar Host\" para começar.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Falha ao importar arquivo JSON",
"connectionDetails": "Detalhes da conexão",
"organization": "Cliente",
- "ipAddress": "Endereço IP ou Nome de Host",
+ "ipAddress": "Endereço IP",
"port": "Porta",
"name": "Nome:",
"username": "Usuário:",
@@ -925,8 +915,6 @@
"key": "Chave",
"credential": "Credencial",
"none": "Nenhuma",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Solicita a autenticação web do OPKSSH em cada conexão. Seu token de autenticação vai durar 24 horas até que ele deve ser renovado.",
"selectCredential": "Selecionar Credencial",
"selectCredentialPlaceholder": "Escolha uma credencial...",
"credentialRequired": "A credencial é necessária para usar autenticação de credenciais",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Autenticação interativa",
"noneAuthDescription": "Este método de autenticação usará a autenticação interativa de teclado ao conectar ao servidor SSH.",
"noneAuthDetails": "A autenticação interativa permite que o servidor lhe peça credenciais durante a conexão. Isso é útil para servidores que requerem autenticação multi-fatores ou você não quer salvar as credenciais localmente.",
- "opksshAuthTitle": "Autenticação OPKSSH",
"forceKeyboardInteractive": "Forçar teclado interativo",
"forceKeyboardInteractiveDesc": "Força o uso de autenticação interativa de teclado. Isso às vezes é necessário para servidores que usam a Autenticação de Dois Fatores (TOTP/2FA).",
"overrideCredentialUsername": "Substituir o nome de usuário credencial",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Abrir no Navegador",
"warpgateContinue": "Eu concluí a autenticação",
"warpgateTimeout": "A autenticação do Warpgate expirou. Por favor reconecte-se.",
- "opksshAuthRequired": "Autenticação OPKSSH necessária",
- "opksshAuthDescription": "Conclua a autenticação no seu navegador para continuar. Essa sessão permanecerá válida por 24 horas.",
- "opksshAuthUrl": "URL de autenticação",
- "opksshOpenBrowser": "Abra o navegador para autenticar",
- "opksshWaitingForAuth": "Aguardando autenticação no navegador...",
- "opksshAuthenticating": "Processando autenticação...",
- "opksshTimeout": "Tempo de autenticação esgotado. Por favor, tente novamente.",
- "opksshAuthFailed": "Falha na autenticação. Por favor, verifique suas credenciais e tente novamente.",
- "opksshConfigMissing": "Configuração OPKSSH não encontrada. Por favor, crie ~/.opk/config.yml com as configurações do provedor OIDC. Veja documentação: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Inserir senha?",
"sudoPasswordPopupHint": "Pressione Enter para inserir, Esc para dispensar",
"sudoPasswordPopupConfirm": "Insert",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Experimentando automaticamente autenticação {{method}}...",
"totpTimeout": "A verificação TOTP expirou. Por favor, reconecte-se.",
"passwordTimeout": "Verificação de senha expirada. Por favor, reconecte-se.",
- "connectionRejected": "Conexão rejeitada pelo servidor. Verifique sua autenticação e configuração de rede.",
- "hostKeyRejected": "Verificação de chave do host SSH rejeitada. Conexão cancelada."
+ "connectionRejected": "Conexão rejeitada pelo servidor. Verifique sua autenticação e configuração de rede."
},
"fileManager": {
"title": "Gerenciador de Arquivos",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Senha redefinida com sucesso",
"passwordResetSuccessDesc": "Sua senha foi redefinida com sucesso. Você agora pode entrar com sua nova senha."
},
- "hostKey": {
- "verifyNewHost": "Verificar chave do host SSH",
- "keyChangedWarning": "Chave do host SSH alterada",
- "firstConnectionTitle": "Primeira vez conectando-se a este host",
- "firstConnectionDescription": "A autenticidade deste host não pode ser estabelecida. Verifique a impressão digital que você espera.",
- "keyChangedDescription": "A chave do host para este servidor mudou desde a sua última conexão. Isso pode indicar um problema de segurança.",
- "previousKey": "Chave anterior",
- "newFingerprint": "Nova impressão digital",
- "fingerprint": "Impressão digital",
- "verifyInstructions": "Caso você confie neste host, clique em Aceitar para continuar e salvar esta impressão digital para conexões futuras.",
- "securityWarning": "Aviso de segurança",
- "acceptAndContinue": "Aceitar e Continuar",
- "acceptNewKey": "Aceitar nova chave e continuar"
- },
"errors": {
"notFound": "Página não encontrada",
"unauthorized": "Acesso não autorizado",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Terminal",
"hostSidebarSettings": "Host e Barra Lateral",
"snippetsSettings": "Trechos",
- "confirmSnippetExecution": "Confirmar execução de Snippet",
- "confirmSnippetExecutionDesc": "Mostrar diálogo antes de executar snippets",
"updateSettings": "Atualizações",
"disableUpdateCheck": "Desativar verificação de atualizações",
"disableUpdateCheckDesc": "Parar de verificar novas versões na inicialização e no painel. Reduz as solicitações de rede.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 ou exemplo.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/ro_RO.json b/src/locales/translated/ro_RO.json
index e77266a9..e497068b 100644
--- a/src/locales/translated/ro_RO.json
+++ b/src/locales/translated/ro_RO.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Acreditări",
"credentialsViewer": "Vizualizare acreditări",
"manageYourSSHCredentials": "Gestionează credențialele SSH în siguranță",
"addCredential": "Adaugă acreditare",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Acest lucru va adăuga în siguranță cheia publică la fișierul ~/.ssh/Autorized_keys al gazdei țintă, fără a suprascrie cheile existente. Operația este reversibilă.",
"chooseHostToDeploy": "Alege o gazdă pentru a o implementa...",
"deploying": "Desfășurare...",
- "copyDeployCommand": "Copiază comanda de lansare",
- "copyDeployCommandDescription": "Copiați o comandă shell pentru a adăuga manual cheia publică la fișierul cu chei autorizate. Useful când autentificarea cu parolă este dezactivată.",
- "deployCommandCopied": "Lansează comanda copiată în clipboard",
- "manualDeployInfo": "Lipiți această comandă în terminalul gazdei țintă (de exemplu, consola Proxmox, IPMI, sau acces fizic) pentru a adăuga cheia SSH.",
"name": "Nume",
"noHostsAvailable": "Nu sunt gazde disponibile",
"noHostsMatchSearch": "Nicio gazdă nu se potrivește cu căutarea ta",
@@ -303,9 +298,7 @@
"createFolder": "Creare folder",
"editFolder": "Editare dosar",
"editFolderDescription": "Personalizați folderul dvs. snippet",
- "createFolderDescription": "Organizați fragmentele în dosare",
- "confirmExecution": "Execută \"{{name}}\"?",
- "confirmExecutionDesc": "Ești sigur că vrei să execuți acest snippet?"
+ "createFolderDescription": "Organizați fragmentele în dosare"
},
"commandHistory": {
"title": "Istoric",
@@ -409,7 +402,6 @@
"required": "Necesar",
"optional": "Opţional",
"connect": "Conectează-te",
- "copied": "Copiat",
"connecting": "Conectare...",
"creating": "Creare...",
"clear": "Curăță",
@@ -502,7 +494,6 @@
"checking": "Verificare...",
"checkingDatabase": "Verificare conexiune la baza de date...",
"checkingAuthentication": "Verificare autentificare...",
- "backendReconnected": "Conexiune server restaurată",
"actions": "Acțiuni",
"remove": "Elimină",
"revoke": "Revoke",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Manager Gazdă",
- "hosts": "Gazde",
"sshHosts": "Gazde SSH",
"noHosts": "Fără gazde SSH",
"noHostsMessage": "Nu ai adăugat încă nicio gazdă SSH. Apasă pe \"Adaugă Gazdă\" pentru a începe.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Nu s-a putut importa fișierul JSON",
"connectionDetails": "Detalii conexiune",
"organization": "Organizație",
- "ipAddress": "Adresă IP sau nume gazdă",
+ "ipAddress": "Adresă IP",
"port": "Portul",
"name": "Nume",
"username": "Nume",
@@ -925,8 +915,6 @@
"key": "Cheie",
"credential": "Acreditări",
"none": "Niciunul",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Vă promite pentru OPKSSH web auth la fiecare conectare. Token-ul de autentificare va dura 24 de ore până va trebui reînnoit.",
"selectCredential": "Selectare acreditare",
"selectCredentialPlaceholder": "Alege o acreditare...",
"credentialRequired": "Acreditarea este necesară atunci când se utilizează autentificarea acreditărilor",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Autentificare tastatură-interactivă",
"noneAuthDescription": "Această metodă de autentificare va utiliza autentificarea interactivă a tastaturii la conectarea la serverul SSH.",
"noneAuthDetails": "Autentificarea interactivă tastatură permite serverului să vă solicite acreditări în timpul conectării. Acest lucru este util pentru serverele care necesită autentificare multi-factor sau dacă nu doriţi să salvaţi acreditările la nivel local.",
- "opksshAuthTitle": "Autentificare OPKSSH",
"forceKeyboardInteractive": "Forțează tastatura- interactivă",
"forceKeyboardInteractiveDesc": "Forțează utilizarea autentificării interactive a tastaturii. Acest lucru este uneori necesar pentru serverele care utilizează autentificarea în doi pași (TOTP/2FA).",
"overrideCredentialUsername": "Suprascrie utilizatorul de acreditare",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Deschide în browser",
"warpgateContinue": "Am finalizat autentificarea",
"warpgateTimeout": "Autentificare la curbură expirată. Te rugăm să te reconectezi.",
- "opksshAuthRequired": "Autentificare OPKSSH necesară",
- "opksshAuthDescription": "Finalizați autentificarea în browser-ul dvs. pentru a continua. Această sesiune va rămâne valabilă 24 de ore.",
- "opksshAuthUrl": "URL autentificare",
- "opksshOpenBrowser": "Deschide browser-ul pentru autentificare",
- "opksshWaitingForAuth": "Se așteaptă autentificarea în browser...",
- "opksshAuthenticating": "Se procesează autentificarea...",
- "opksshTimeout": "Autentificarea a expirat. Încercați din nou.",
- "opksshAuthFailed": "Autentificare eșuată. Te rugăm să verifici acreditările și să încerci din nou.",
- "opksshConfigMissing": "Configurația OPKSSH nu a fost găsită. Vă rugăm să creați ~/.opk/config.yml cu setările furnizorului OIDC. Vedeți documentația: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Introduceți parola?",
"sudoPasswordPopupHint": "Apăsați Enter pentru inserare, Esc pentru a respinge",
"sudoPasswordPopupConfirm": "Insert",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Se încearcă autentificarea {{method}}...",
"totpTimeout": "Verificare TOTP expirată. Te rugăm să te reconectezi.",
"passwordTimeout": "Verificarea parolei a expirat. Vă rugăm să vă reconectați.",
- "connectionRejected": "Conexiune respinsă de server. Verificați autentificarea și configurarea rețelei.",
- "hostKeyRejected": "Verificarea cheii gazdă SSH a fost respinsă. Conexiune anulată."
+ "connectionRejected": "Conexiune respinsă de server. Verificați autentificarea și configurarea rețelei."
},
"fileManager": {
"title": "Manager fişiere",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Resetare parola reusita",
"passwordResetSuccessDesc": "Parola ta a fost resetată cu succes. Acum te poți autentifica cu noua parolă."
},
- "hostKey": {
- "verifyNewHost": "Verificare cheie gazdă SSH",
- "keyChangedWarning": "Cheie gazdă SSH schimbată",
- "firstConnectionTitle": "Conectare pentru prima dată la această gazdă",
- "firstConnectionDescription": "Autenticitatea acestui gazdă nu poate fi stabilită. Verificați ca amprenta să corespundă cu ceea ce așteptați.",
- "keyChangedDescription": "Cheia gazdă a acestui server s-a schimbat de la ultima conexiune. Aceasta ar putea indica o problemă de securitate.",
- "previousKey": "Cheie anterioară",
- "newFingerprint": "Amprentă nouă",
- "fingerprint": "Amprentă",
- "verifyInstructions": "Dacă aveți încredere în această gazdă, faceți clic pe Accept pentru a continua și a salva amprenta pentru conexiunile viitoare.",
- "securityWarning": "Avertizare de securitate",
- "acceptAndContinue": "Acceptați și continuați",
- "acceptNewKey": "Acceptați o nouă cheie și continuați"
- },
"errors": {
"notFound": "Pagina nu a fost găsită",
"unauthorized": "Acces neautorizat",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Terminal",
"hostSidebarSettings": "Găzduire și bară laterală",
"snippetsSettings": "Snippet-uri",
- "confirmSnippetExecution": "Confirmare execuție snippet",
- "confirmSnippetExecutionDesc": "Arată dialog de confirmare înainte de a executa fragmente",
"updateSettings": "Actualizări",
"disableUpdateCheck": "Dezactivează verificarea actualizării",
"disableUpdateCheckDesc": "Opriți verificarea de noi versiuni la pornire și tablou de bord. Reduce cererile de rețea.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 sau exemplu.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/ru_RU.json b/src/locales/translated/ru_RU.json
index 39ae8725..820f86c9 100644
--- a/src/locales/translated/ru_RU.json
+++ b/src/locales/translated/ru_RU.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Полномочия",
"credentialsViewer": "Просмотр учетных данных",
"manageYourSSHCredentials": "Безопасное управление учётными данными SSH",
"addCredential": "Добавить учетные данные",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Это безопасно добавит публичный ключ к файлу ~/.ssh/authorized_keys целевого хоста без перезаписи существующих ключей. Операция может быть отменена.",
"chooseHostToDeploy": "Выберите хост для развертывания...",
"deploying": "Развертывание...",
- "copyDeployCommand": "Копировать команду развертывания",
- "copyDeployCommandDescription": "Скопируйте команду оболочки, чтобы вручную добавить открытый ключ в файл authorized_keys узла. Полезно, если отключена аутентификация пароля.",
- "deployCommandCopied": "Команда развертывания скопирована в буфер обмена",
- "manualDeployInfo": "Вставьте эту команду в терминал целевого хоста (например, консоль Proxmox, IPMI или физический доступ), чтобы добавить ключ SSH.",
"name": "Наименование",
"noHostsAvailable": "Нет хостов",
"noHostsMatchSearch": "Нет хостов, соответствующих вашему запросу",
@@ -303,9 +298,7 @@
"createFolder": "Создать папку",
"editFolder": "Изменить папку",
"editFolderDescription": "Настройка папки сниппетов",
- "createFolderDescription": "Организовать ваши сниппеты в папках",
- "confirmExecution": "Выполнить \"{{name}}\"?",
- "confirmExecutionDesc": "Вы уверены, что хотите выполнить этот сниппет?"
+ "createFolderDescription": "Организовать ваши сниппеты в папках"
},
"commandHistory": {
"title": "История",
@@ -409,7 +402,6 @@
"required": "Требуется",
"optional": "Опционально",
"connect": "Подключиться",
- "copied": "Скопировано",
"connecting": "Подключение...",
"creating": "Создание...",
"clear": "Очистить",
@@ -502,7 +494,6 @@
"checking": "Проверка...",
"checkingDatabase": "Проверка подключения к базе данных...",
"checkingAuthentication": "Проверка подлинности...",
- "backendReconnected": "Соединение с сервером восстановлено",
"actions": "Действия",
"remove": "Удалить",
"revoke": "Revoke",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Менеджер хостов",
- "hosts": "Узлы",
"sshHosts": "SSH хосты",
"noHosts": "Нет SSH хостов",
"noHostsMessage": "Вы еще не добавили ни одного хоста SSH. Нажмите \"Добавить хост\", чтобы начать.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Не удалось импортировать JSON файл",
"connectionDetails": "Детали подключения",
"organization": "Организация",
- "ipAddress": "IP-адрес или имя хоста",
+ "ipAddress": "IP-адрес",
"port": "Порт",
"name": "Наименование",
"username": "Имя пользователя",
@@ -925,8 +915,6 @@
"key": "Спецификация",
"credential": "Полномочия",
"none": "Нет",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Запрашивает OPKSSH авторизацию при каждом подключении. Ваш токен авторизации будет длиться 24 часа, пока он не будет продлен.",
"selectCredential": "Выберите учетные данные",
"selectCredentialPlaceholder": "Выберите учетные данные...",
"credentialRequired": "Учетные данные требуются при использовании аутентификации",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Клавиатура - Интерактивная аутентификация",
"noneAuthDescription": "Этот метод аутентификации будет использовать интерактивную аутентификацию с клавиатурой при подключении к SSH серверу.",
"noneAuthDetails": "Интерактивная аутентификация на клавиатуре позволяет серверу запрашивать учетные данные во время подключения. Это полезно для серверов, которые требуют многофакторной аутентификации или если вы не хотите сохранять учетные данные локально.",
- "opksshAuthTitle": "OPKSSH аутентификация",
"forceKeyboardInteractive": "Интерактивная клавиатура",
"forceKeyboardInteractiveDesc": "Принудительно использовать интерактивную аутентификацию с клавиатуры. Иногда требуется для серверов, использующих двухфакторную аутентификацию (TOTP/2FA).",
"overrideCredentialUsername": "Переопределить имя пользователя",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Открыть в браузере",
"warpgateContinue": "Я завершаю аутентификацию",
"warpgateTimeout": "Тайм-аут аутентификации Warpgate. Пожалуйста, подключитесь снова.",
- "opksshAuthRequired": "Требуется аутентификация OPKSSH",
- "opksshAuthDescription": "Завершите аутентификацию в браузере, чтобы продолжить. Этот сеанс будет оставаться действительным в течение 24 часов.",
- "opksshAuthUrl": "URL аутентификации",
- "opksshOpenBrowser": "Аутентификация в браузере",
- "opksshWaitingForAuth": "Ожидание аутентификации в браузере...",
- "opksshAuthenticating": "Обработка аутентификации...",
- "opksshTimeout": "Истекло время аутентификации. Попробуйте еще раз.",
- "opksshAuthFailed": "Аутентификация не удалась. Проверьте учетные данные и повторите попытку.",
- "opksshConfigMissing": "OPKSSH конфигурация не найдена. Пожалуйста, создайте ~/.opk/config.yml с вашими настройками OIDC провайдера. См. документацию: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Вставить пароль?",
"sudoPasswordPopupHint": "Нажмите Enter для вставки, Esc для отключения",
"sudoPasswordPopupConfirm": "Insert",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Автоматически пытаться аутентификации {{method}}...",
"totpTimeout": "Тайм-аут проверки TOTP. Пожалуйста, подключитесь снова.",
"passwordTimeout": "Время ожидания подтверждения пароля истекло. Пожалуйста, подключитесь снова.",
- "connectionRejected": "Соединение отклонено сервером. Проверьте вашу аутентификацию и конфигурацию сети.",
- "hostKeyRejected": "Проверка SSH ключа хоста отклонена. Соединение отменено."
+ "connectionRejected": "Соединение отклонено сервером. Проверьте вашу аутентификацию и конфигурацию сети."
},
"fileManager": {
"title": "Файловый менеджер",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Пароль успешно сброшен",
"passwordResetSuccessDesc": "Ваш пароль был успешно сброшен. Теперь вы можете войти с новым паролем."
},
- "hostKey": {
- "verifyNewHost": "Проверка SSH ключа хоста",
- "keyChangedWarning": "SSH ключ хоста изменен",
- "firstConnectionTitle": "Впервые подключение к этому узлу",
- "firstConnectionDescription": "Логичность этого узла не может быть установлена. Проверьте отпечаток пальца так, что вы ожидаете.",
- "keyChangedDescription": "Ключ хоста для этого сервера изменился с момента вашего последнего подключения. Это может указывать на проблему безопасности.",
- "previousKey": "Предыдущий ключ",
- "newFingerprint": "Новый отпечаток пальца",
- "fingerprint": "Отпечаток пальца",
- "verifyInstructions": "Если вы доверяете этому узлу, нажмите «Принять», чтобы продолжить, и сохраните отпечаток пальца для будущих подключений.",
- "securityWarning": "Предупреждение о безопасности",
- "acceptAndContinue": "Принять и продолжить",
- "acceptNewKey": "Принять новый ключ и продолжить"
- },
"errors": {
"notFound": "Страница не найдена",
"unauthorized": "Неавторизованный доступ",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Терминал",
"hostSidebarSettings": "Узел и боковая панель",
"snippetsSettings": "Сниппеты",
- "confirmSnippetExecution": "Подтвердите исполнение сниппета",
- "confirmSnippetExecutionDesc": "Показывать окно подтверждения перед выполнением сниппетов",
"updateSettings": "Обновления",
"disableUpdateCheck": "Отключить проверку обновлений",
"disableUpdateCheckDesc": "Не проверять наличие новых версий при запуске и панели управления. Снижает сетевые запросы.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 или example.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/sr_SP.json b/src/locales/translated/sr_SP.json
index 5639ba4d..75fd20e2 100644
--- a/src/locales/translated/sr_SP.json
+++ b/src/locales/translated/sr_SP.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Акредитиви",
"credentialsViewer": "Прегледач акредитива",
"manageYourSSHCredentials": "Безбедно управљајте својим SSH акредитивима",
"addCredential": "Додај акредитив",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Ово ће безбедно додати јавни кључ у датотеку ~/.ssh/authorized_keys циљног хоста без преписивања постојећих кључева. Операција је реверзибилна.",
"chooseHostToDeploy": "Изаберите хост за распоређивање...",
"deploying": "Распоређивање...",
- "copyDeployCommand": "Копирај команду за распоређивање",
- "copyDeployCommandDescription": "Копирајте команду шелла да бисте ручно додали јавни кључ у датотеку authorized_keys хоста. Корисно када је аутентификација лозинком онемогућена.",
- "deployCommandCopied": "Команда за распоређивање копирана у међуспремник",
- "manualDeployInfo": "Налепите ову команду у терминал циљног хоста (нпр. Proxmox конзолу, IPMI или физички приступ) да бисте додали SSH кључ.",
"name": "Име",
"noHostsAvailable": "Нема доступних хостова",
"noHostsMatchSearch": "Ниједан хост не одговара вашој претрази",
@@ -303,9 +298,7 @@
"createFolder": "Направи фасциклу",
"editFolder": "Уреди фасциклу",
"editFolderDescription": "Прилагодите своју фасциклу са фрагментима кода",
- "createFolderDescription": "Организујте своје фрагменте у фасцикле",
- "confirmExecution": "Извршити „{{name}}“?",
- "confirmExecutionDesc": "Да ли сте сигурни да желите да извршите овај исечак?"
+ "createFolderDescription": "Организујте своје фрагменте у фасцикле"
},
"commandHistory": {
"title": "Историја",
@@ -409,7 +402,6 @@
"required": "Обавезно",
"optional": "Опционо",
"connect": "Повежи се",
- "copied": "Копирано",
"connecting": "Повезивање...",
"creating": "Креирање...",
"clear": "Јасно",
@@ -502,7 +494,6 @@
"checking": "Провера...",
"checkingDatabase": "Провера везе са базом података...",
"checkingAuthentication": "Провера аутентификације...",
- "backendReconnected": "Веза са сервером је враћена",
"actions": "Акције",
"remove": "Уклони",
"revoke": "Поништи",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Менаџер домаћина",
- "hosts": "Домаћини",
"sshHosts": "SSH хостови",
"noHosts": "Нема SSH хостова",
"noHostsMessage": "Још нисте додали ниједан SSH хост. Кликните на „Додај хост“ да бисте започели.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Увоз JSON датотеке није успео",
"connectionDetails": "Детаљи везе",
"organization": "Организација",
- "ipAddress": "IP адреса или име хоста",
+ "ipAddress": "ИП адреса",
"port": "Лука",
"name": "Име",
"username": "Корисничко име",
@@ -925,8 +915,6 @@
"key": "Кључ",
"credential": "Акредитив",
"none": "Ниједан",
- "opkssh": "ОПКШ",
- "opksshAuthDescription": "Пита вас за OPKSSH веб-аутентификацију при сваком повезивању. Ваш токен за аутентификацију ће трајати 24 сата док се не обнови.",
"selectCredential": "Изаберите акредитив",
"selectCredentialPlaceholder": "Изаберите акредитив...",
"credentialRequired": "Акредитив је потребан када се користи аутентификација акредитива",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Интерактивна аутентификација помоћу тастатуре",
"noneAuthDescription": "Ова метода аутентификације ће користити интерактивну аутентификацију помоћу тастатуре приликом повезивања са SSH сервером.",
"noneAuthDetails": "Интерактивна аутентификација помоћу тастатуре омогућава серверу да вас пита за акредитиве током повезивања. Ово је корисно за сервере који захтевају вишефакторску аутентификацију или ако не желите да локално чувате акредитиве.",
- "opksshAuthTitle": "OPKSSH аутентификација",
"forceKeyboardInteractive": "Присилно интерактивно коришћење тастатуре",
"forceKeyboardInteractiveDesc": "Приморава употребу интерактивне аутентификације помоћу тастатуре. Ово је понекад потребно за сервере који користе двофакторску аутентификацију (TOTP/2FA).",
"overrideCredentialUsername": "Замени корисничко име за акредитиве",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Отвори у прегледачу",
"warpgateContinue": "Завршио/ла сам аутентификацију",
"warpgateTimeout": "Временско ограничење за аутентификацију Варпгејта је истекло. Молимо вас да се поново повежете.",
- "opksshAuthRequired": "Потребна је OPKSSH аутентификација",
- "opksshAuthDescription": "Завршите аутентификацију у прегледачу да бисте наставили. Ова сесија ће остати важећа 24 сата.",
- "opksshAuthUrl": "URL адреса за аутентификацију",
- "opksshOpenBrowser": "Отворите прегледач за аутентификацију",
- "opksshWaitingForAuth": "Чекање аутентификације у прегледачу...",
- "opksshAuthenticating": "Обрада аутентификације...",
- "opksshTimeout": "Временско ограничење за аутентификацију је истекло. Молимо покушајте поново.",
- "opksshAuthFailed": "Аутентификација није успела. Проверите своје акредитиве и покушајте поново.",
- "opksshConfigMissing": "Конфигурација OPKSSH није пронађена. Молимо вас да креирате ~/.opk/config.yml са подешавањима вашег OIDC провајдера. Погледајте документацију: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Унети лозинку?",
"sudoPasswordPopupHint": "Притисните Enter да бисте унели, Esc да бисте одбацили",
"sudoPasswordPopupConfirm": "Уметни",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Аутоматски покушај {{method}} аутентификације...",
"totpTimeout": "Временско ограничење за верификацију TOTP-а је истекло. Молимо вас да се поново повежете.",
"passwordTimeout": "Временско ограничење за верификацију лозинке је истекло. Молимо вас да се поново повежете.",
- "connectionRejected": "Сервер је одбио везу. Проверите аутентификацију и конфигурацију мреже.",
- "hostKeyRejected": "Верификација SSH кључа хоста је одбијена. Веза је отказана."
+ "connectionRejected": "Сервер је одбио везу. Проверите аутентификацију и конфигурацију мреже."
},
"fileManager": {
"title": "Менаџер датотека",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Ресетовање лозинке је успешно",
"passwordResetSuccessDesc": "Ваша лозинка је успешно ресетована. Сада се можете пријавити са новом лозинком."
},
- "hostKey": {
- "verifyNewHost": "Проверите SSH кључ хоста",
- "keyChangedWarning": "SSH кључ хоста је промењен",
- "firstConnectionTitle": "Прво повезивање са овим хостом",
- "firstConnectionDescription": "Аутентичност овог хоста не може бити утврђена. Проверите да ли отисак прста одговара очекивањима.",
- "keyChangedDescription": "Кључ хоста за овај сервер се променио од ваше последње везе. Ово би могло да указује на безбедносни проблем.",
- "previousKey": "Претходни кључ",
- "newFingerprint": "Нови отисак прста",
- "fingerprint": "Отисак прста",
- "verifyInstructions": "Ако верујете овом хосту, кликните на „Прихвати“ да бисте наставили и сачували овај отисак прста за будуће везе.",
- "securityWarning": "Безбедносно упозорење",
- "acceptAndContinue": "Прихвати и настави",
- "acceptNewKey": "Прихвати нови кључ и настави"
- },
"errors": {
"notFound": "Страница није пронађена",
"unauthorized": "Неовлашћени приступ",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Терминал",
"hostSidebarSettings": "Домаћин и бочна трака",
"snippetsSettings": "Исечци",
- "confirmSnippetExecution": "Потврди извршавање исечка",
- "confirmSnippetExecutionDesc": "Прикажи дијалог за потврду пре извршавања исечака",
"updateSettings": "Ажурирања",
"disableUpdateCheck": "Онемогући проверу ажурирања",
"disableUpdateCheckDesc": "Зауставите проверу нових верзија при покретању и на контролној табли. Смањује мрежне захтеве.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 или example.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/sv_SE.json b/src/locales/translated/sv_SE.json
index 81957e97..cdda960a 100644
--- a/src/locales/translated/sv_SE.json
+++ b/src/locales/translated/sv_SE.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Användaruppgifter",
"credentialsViewer": "Visning av användaruppgifter",
"manageYourSSHCredentials": "Hantera dina SSH-uppgifter säkert",
"addCredential": "Lägg till autentiseringsuppgifter",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Detta kommer säkert att lägga till den publika nyckeln till målvärdens ~/.ssh/authorized_keys fil utan att skriva över befintliga nycklar. Åtgärden är vändbar.",
"chooseHostToDeploy": "Välj en värd att distribuera till...",
"deploying": "Utrustar...",
- "copyDeployCommand": "Kopiera distribuera kommando",
- "copyDeployCommandDescription": "Kopiera ett skalkommando för att manuellt lägga till den offentliga nyckeln till en värds authorized_keys-fil. Användbar när lösenordsautentisering är inaktiverad.",
- "deployCommandCopied": "Distributionskommandot kopierat till urklipp",
- "manualDeployInfo": "Klistra in detta kommando i målvärdens terminal (t.ex. Proxmox-konsol, IPMI eller fysisk åtkomst) för att lägga till SSH-tangenten.",
"name": "Namn",
"noHostsAvailable": "Inga tillgängliga värdar",
"noHostsMatchSearch": "Inga värdar matchar din sökning",
@@ -303,9 +298,7 @@
"createFolder": "Skapa mapp",
"editFolder": "Redigera mapp",
"editFolderDescription": "Anpassa din textmodul-mapp",
- "createFolderDescription": "Organisera dina textmoduler i mappar",
- "confirmExecution": "Kör \"{{name}}\"?",
- "confirmExecutionDesc": "Är du säker på att du vill köra denna snippet?"
+ "createFolderDescription": "Organisera dina textmoduler i mappar"
},
"commandHistory": {
"title": "Historik",
@@ -409,7 +402,6 @@
"required": "Krävs",
"optional": "Valfri",
"connect": "Anslut",
- "copied": "Kopierad",
"connecting": "Ansluter...",
"creating": "Skapar...",
"clear": "Rensa",
@@ -502,7 +494,6 @@
"checking": "Kontrollerar...",
"checkingDatabase": "Kontrollerar databasanslutning...",
"checkingAuthentication": "Kontrollerar autentisering...",
- "backendReconnected": "Serveranslutning återställd",
"actions": "Åtgärder",
"remove": "Radera",
"revoke": "Revoke",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Värdhanterare",
- "hosts": "Värdar",
"sshHosts": "SSH värdar",
"noHosts": "Inga SSH-värdar",
"noHostsMessage": "Du har inte lagt till några SSH-värdar än. Klicka på \"Lägg till värd\" för att komma igång.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Det gick inte att importera JSON-fil",
"connectionDetails": "Kontaktuppgifter",
"organization": "Organisation",
- "ipAddress": "IP-adress eller värdnamn",
+ "ipAddress": "IP-adress",
"port": "Port",
"name": "Namn",
"username": "Användarnamn",
@@ -925,8 +915,6 @@
"key": "Nyckel",
"credential": "Uppgifter",
"none": "Ingen",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Fråga dig för OPKSSH web-auth vid varje anslutning. Din auth token kommer att pågå i 24 timmar tills den måste förnyas.",
"selectCredential": "Välj referens",
"selectCredentialPlaceholder": "Välj en inloggning...",
"credentialRequired": "Uppgifter krävs när autentisering används",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Tangentbord-Interaktiv autentisering",
"noneAuthDescription": "Denna autentiseringsmetod kommer att använda tangentbord-interaktiv autentisering när du ansluter till SSH-servern.",
"noneAuthDetails": "Tangentbord-interaktiv autentisering gör det möjligt för servern att fråga dig om autentiseringsuppgifter under anslutningen. Detta är användbart för servrar som kräver multi-faktor autentisering eller om du inte vill spara uppgifter lokalt.",
- "opksshAuthTitle": "OPKSSH autentisering",
"forceKeyboardInteractive": "Tvinga interaktiv tangentbord",
"forceKeyboardInteractiveDesc": "Tvingar användning av tangentbords-interaktiv autentisering. Detta krävs ibland för servrar som använder tvåfaktorsautentisering (TOTP/2FA).",
"overrideCredentialUsername": "Åsidosätt Användarnamn",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Öppna i webbläsare",
"warpgateContinue": "Jag har slutfört autentisering",
"warpgateTimeout": "Warpgate-autentiserings-timeout. Vänligen återanslut.",
- "opksshAuthRequired": "OPKSSH-autentisering krävs",
- "opksshAuthDescription": "Fullständig autentisering i din webbläsare för att fortsätta. Denna session kommer att förbli giltig i 24 timmar.",
- "opksshAuthUrl": "URL för autentisering",
- "opksshOpenBrowser": "Öppna webbläsaren för att autentisera",
- "opksshWaitingForAuth": "Väntar på autentisering i webbläsaren...",
- "opksshAuthenticating": "Bearbetar autentisering...",
- "opksshTimeout": "Autentisering gick ut. Försök igen.",
- "opksshAuthFailed": "Autentisering misslyckades. Kontrollera dina uppgifter och försök igen.",
- "opksshConfigMissing": "OPKSSH-konfigurationen hittades inte. Vänligen skapa ~/.opk/config.yml med dina OIDC-leverantörsinställningar. Se dokumentation: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Infoga lösenord?",
"sudoPasswordPopupHint": "Tryck Enter för att infoga Esc för att avfärda",
"sudoPasswordPopupConfirm": "Insert",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Försöker automatiskt {{method}} autentisering...",
"totpTimeout": "TOTP-verifiering timeout. Vänligen återanslut.",
"passwordTimeout": "Lösenordsverifiering är avklarad. Vänligen återanslut.",
- "connectionRejected": "Anslutning avvisad av servern. Kontrollera din autentisering och nätverkskonfiguration.",
- "hostKeyRejected": "Verifiering av SSH-värdnyckel avvisad. Anslutningen avbröts."
+ "connectionRejected": "Anslutning avvisad av servern. Kontrollera din autentisering och nätverkskonfiguration."
},
"fileManager": {
"title": "Filhanterare",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Lösenordsåterställning lyckades",
"passwordResetSuccessDesc": "Ditt lösenord har återställts. Du kan nu logga in med ditt nya lösenord."
},
- "hostKey": {
- "verifyNewHost": "Verifiera SSH-värdnyckel",
- "keyChangedWarning": "SSH Värdnyckel ändrad",
- "firstConnectionTitle": "Första gången du ansluter till denna värd",
- "firstConnectionDescription": "Autenticiteten hos denna värd kan inte fastställas. Kontrollera att fingeravtrycket matchar det du förväntar dig.",
- "keyChangedDescription": "Värdnyckeln för denna server har ändrats sedan din senaste anslutning. Detta kan indikera ett säkerhetsproblem.",
- "previousKey": "Föregående nyckel",
- "newFingerprint": "Nytt fingeravtryck",
- "fingerprint": "Fingeravtryck",
- "verifyInstructions": "Om du litar på detta värd, klicka på Acceptera för att fortsätta och spara detta fingeravtryck för framtida anslutningar.",
- "securityWarning": "Varning för säkerhet",
- "acceptAndContinue": "Godkänn och fortsätt",
- "acceptNewKey": "Acceptera ny nyckel och fortsätt"
- },
"errors": {
"notFound": "Sidan hittades inte",
"unauthorized": "Obehörig åtkomst",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Terminal",
"hostSidebarSettings": "Värd och sidofält",
"snippetsSettings": "Textmoduler",
- "confirmSnippetExecution": "Bekräfta utförande av textmodulen",
- "confirmSnippetExecutionDesc": "Visa bekräftelsedialog innan du utför textmoduler",
"updateSettings": "Uppdateringar",
"disableUpdateCheck": "Inaktivera uppdateringskontroll",
"disableUpdateCheckDesc": "Sluta leta efter nya versioner vid uppstart och instrumentbräda. Minskar nätverksförfrågningar.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 eller exempel.se",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/th_TH.json b/src/locales/translated/th_TH.json
index 864da92f..32f09b89 100644
--- a/src/locales/translated/th_TH.json
+++ b/src/locales/translated/th_TH.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "คุณสมบัติ",
"credentialsViewer": "โปรแกรมดูข้อมูลประจำตัว",
"manageYourSSHCredentials": "จัดการข้อมูลประจำตัว SSH ของคุณอย่างปลอดภัย",
"addCredential": "เพิ่มข้อมูลรับรอง",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "การดำเนินการนี้จะเพิ่มคีย์สาธารณะลงในไฟล์ ~/.ssh/authorized_keys ของโฮสต์เป้าหมายอย่างปลอดภัยโดยไม่เขียนทับคีย์ที่มีอยู่ การดำเนินการนี้สามารถย้อนกลับได้",
"chooseHostToDeploy": "เลือกโฮสต์ที่จะใช้ในการติดตั้ง...",
"deploying": "กำลังติดตั้ง...",
- "copyDeployCommand": "คัดลอกคำสั่งปรับใช้",
- "copyDeployCommandDescription": "คัดลอกคำสั่งเชลล์เพื่อเพิ่มคีย์สาธารณะลงในไฟล์ authorized_keys ของโฮสต์ด้วยตนเอง มีประโยชน์เมื่อปิดใช้งานการตรวจสอบสิทธิ์ด้วยรหัสผ่าน",
- "deployCommandCopied": "คัดลอกคำสั่งปรับใช้ไปยังคลิปบอร์ดแล้ว",
- "manualDeployInfo": "วางคำสั่งนี้ลงในเทอร์มินัลของโฮสต์เป้าหมาย (เช่น คอนโซล Proxmox, IPMI หรือการเข้าถึงทางกายภาพ) เพื่อเพิ่มคีย์ SSH",
"name": "ชื่อ",
"noHostsAvailable": "ไม่มีโฮสต์ให้บริการ",
"noHostsMatchSearch": "ไม่พบโฮสต์ใดตรงกับผลการค้นหาของคุณ",
@@ -303,9 +298,7 @@
"createFolder": "สร้างโฟลเดอร์",
"editFolder": "แก้ไขโฟลเดอร์",
"editFolderDescription": "ปรับแต่งโฟลเดอร์โค้ดสั้นของคุณ",
- "createFolderDescription": "จัดระเบียบข้อความย่อของคุณลงในโฟลเดอร์",
- "confirmExecution": "ดำเนินการ \"{{name}}\"?",
- "confirmExecutionDesc": "คุณแน่ใจหรือไม่ว่าต้องการเรียกใช้โค้ดส่วนนี้?"
+ "createFolderDescription": "จัดระเบียบข้อความย่อของคุณลงในโฟลเดอร์"
},
"commandHistory": {
"title": "ประวัติศาสตร์",
@@ -409,7 +402,6 @@
"required": "ที่จำเป็น",
"optional": "ไม่จำเป็น",
"connect": "เชื่อมต่อ",
- "copied": "คัดลอกแล้ว",
"connecting": "กำลังเชื่อมต่อ...",
"creating": "กำลังสร้าง...",
"clear": "ชัดเจน",
@@ -502,7 +494,6 @@
"checking": "กำลังตรวจสอบ...",
"checkingDatabase": "กำลังตรวจสอบการเชื่อมต่อฐานข้อมูล...",
"checkingAuthentication": "กำลังตรวจสอบการยืนยันตัวตน...",
- "backendReconnected": "การเชื่อมต่อเซิร์ฟเวอร์กลับมาใช้งานได้แล้ว",
"actions": "การกระทำ",
"remove": "ลบ",
"revoke": "ถอน",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "ผู้จัดการโฮสต์",
- "hosts": "โฮสต์",
"sshHosts": "โฮสต์ SSH",
"noHosts": "ไม่มีโฮสต์ SSH",
"noHostsMessage": "คุณยังไม่ได้เพิ่มโฮสต์ SSH ใดๆ คลิก \"เพิ่มโฮสต์\" เพื่อเริ่มต้น",
@@ -846,7 +836,7 @@
"failedToImportJson": "ไม่สามารถนำเข้าไฟล์ JSON ได้",
"connectionDetails": "รายละเอียดการเชื่อมต่อ",
"organization": "องค์กร",
- "ipAddress": "ที่อยู่ IP หรือชื่อโฮสต์",
+ "ipAddress": "ที่อยู่ IP",
"port": "ท่าเรือ",
"name": "ชื่อ",
"username": "ชื่อผู้ใช้",
@@ -925,8 +915,6 @@
"key": "สำคัญ",
"credential": "ใบรับรอง",
"none": "ไม่มี",
- "opkssh": "โอพีเคเอสเอช",
- "opksshAuthDescription": "ระบบจะขอให้คุณป้อนรหัสยืนยันตัวตน OPKSSH ทุกครั้งที่เชื่อมต่อ รหัสยืนยันตัวตนของคุณจะมีอายุ 24 ชั่วโมงก่อนที่จะต้องต่ออายุใหม่",
"selectCredential": "เลือกข้อมูลประจำตัว",
"selectCredentialPlaceholder": "เลือกข้อมูลประจำตัว...",
"credentialRequired": "จำเป็นต้องระบุข้อมูลประจำตัวเมื่อใช้การตรวจสอบสิทธิ์ด้วยข้อมูลประจำตัว",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "การตรวจสอบสิทธิ์แบบโต้ตอบด้วยแป้นพิมพ์",
"noneAuthDescription": "วิธีการยืนยันตัวตนนี้จะใช้การยืนยันตัวตนผ่านแป้นพิมพ์เมื่อเชื่อมต่อกับเซิร์ฟเวอร์ SSH",
"noneAuthDetails": "การตรวจสอบสิทธิ์แบบโต้ตอบด้วยแป้นพิมพ์ช่วยให้เซิร์ฟเวอร์แจ้งให้คุณป้อนข้อมูลประจำตัวระหว่างการเชื่อมต่อ ซึ่งมีประโยชน์สำหรับเซิร์ฟเวอร์ที่ต้องการการตรวจสอบสิทธิ์แบบหลายปัจจัย หรือหากคุณไม่ต้องการบันทึกข้อมูลประจำตัวไว้ในเครื่อง",
- "opksshAuthTitle": "การตรวจสอบสิทธิ์ OPKSSH",
"forceKeyboardInteractive": "แป้นพิมพ์แบบอินเทอร์แอคทีฟของ Force",
"forceKeyboardInteractiveDesc": "บังคับให้ใช้การตรวจสอบสิทธิ์แบบโต้ตอบผ่านแป้นพิมพ์ ซึ่งบางครั้งจำเป็นสำหรับเซิร์ฟเวอร์ที่ใช้การตรวจสอบสิทธิ์แบบสองปัจจัย (TOTP/2FA)",
"overrideCredentialUsername": "แทนที่ชื่อผู้ใช้ข้อมูลประจำตัว",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "เปิดในเบราว์เซอร์",
"warpgateContinue": "ฉันทำการยืนยันตัวตนเสร็จเรียบร้อยแล้ว",
"warpgateTimeout": "การตรวจสอบสิทธิ์ผ่านวาร์ปหมดเวลา โปรดเชื่อมต่อใหม่อีกครั้ง",
- "opksshAuthRequired": "ต้องใช้การตรวจสอบสิทธิ์ OPKSSH",
- "opksshAuthDescription": "โปรดยืนยันตัวตนในเบราว์เซอร์ของคุณเพื่อดำเนินการต่อ เซสชันนี้จะมีอายุใช้งาน 24 ชั่วโมง",
- "opksshAuthUrl": "URL สำหรับการตรวจสอบสิทธิ์",
- "opksshOpenBrowser": "เปิดเบราว์เซอร์เพื่อยืนยันตัวตน",
- "opksshWaitingForAuth": "กำลังรอการยืนยันตัวตนในเบราว์เซอร์...",
- "opksshAuthenticating": "กำลังตรวจสอบสิทธิ์...",
- "opksshTimeout": "การตรวจสอบสิทธิ์หมดเวลา โปรดลองอีกครั้ง",
- "opksshAuthFailed": "การตรวจสอบสิทธิ์ล้มเหลว โปรดตรวจสอบข้อมูลประจำตัวของคุณและลองอีกครั้ง",
- "opksshConfigMissing": "ไม่พบไฟล์การกำหนดค่า OPKSSH โปรดสร้างไฟล์ ~/.opk/config.yml ด้วยการตั้งค่าผู้ให้บริการ OIDC ของคุณ ดูเอกสารประกอบได้ที่: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "ใส่รหัสผ่าน?",
"sudoPasswordPopupHint": "กด Enter เพื่อแทรก กด Esc เพื่อปิด",
"sudoPasswordPopupConfirm": "แทรก",
@@ -1359,8 +1337,7 @@
"automaticFallback": "กำลังลองยืนยันตัวตน {{method}} โดยอัตโนมัติ...",
"totpTimeout": "การยืนยัน TOTP หมดเวลา โปรดเชื่อมต่อใหม่อีกครั้ง",
"passwordTimeout": "การตรวจสอบรหัสผ่านหมดเวลา โปรดเชื่อมต่อใหม่อีกครั้ง",
- "connectionRejected": "เซิร์ฟเวอร์ปฏิเสธการเชื่อมต่อ โปรดตรวจสอบการยืนยันตัวตนและการกำหนดค่าเครือข่ายของคุณ",
- "hostKeyRejected": "การตรวจสอบคีย์โฮสต์ SSH ถูกปฏิเสธ การเชื่อมต่อถูกยกเลิก"
+ "connectionRejected": "เซิร์ฟเวอร์ปฏิเสธการเชื่อมต่อ โปรดตรวจสอบการยืนยันตัวตนและการกำหนดค่าเครือข่ายของคุณ"
},
"fileManager": {
"title": "ตัวจัดการไฟล์",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "การรีเซ็ตรหัสผ่านสำเร็จ",
"passwordResetSuccessDesc": "รหัสผ่านของคุณถูกรีเซ็ตสำเร็จแล้ว คุณสามารถเข้าสู่ระบบด้วยรหัสผ่านใหม่ของคุณได้แล้ว"
},
- "hostKey": {
- "verifyNewHost": "ตรวจสอบคีย์โฮสต์ SSH",
- "keyChangedWarning": "คีย์โฮสต์ SSH เปลี่ยนแล้ว",
- "firstConnectionTitle": "นี่เป็นครั้งแรกที่เชื่อมต่อกับโฮสต์นี้",
- "firstConnectionDescription": "ไม่สามารถตรวจสอบความถูกต้องของโฮสต์นี้ได้ โปรดตรวจสอบว่าลายนิ้วมือตรงกับที่คุณคาดหวังหรือไม่",
- "keyChangedDescription": "รหัสโฮสต์ของเซิร์ฟเวอร์นี้มีการเปลี่ยนแปลงนับตั้งแต่การเชื่อมต่อครั้งล่าสุดของคุณ ซึ่งอาจบ่งชี้ถึงปัญหาด้านความปลอดภัย",
- "previousKey": "คีย์ก่อนหน้า",
- "newFingerprint": "ลายนิ้วมือใหม่",
- "fingerprint": "ลายนิ้วมือ",
- "verifyInstructions": "หากคุณเชื่อถือโฮสต์นี้ โปรดคลิก ยอมรับ เพื่อดำเนินการต่อและบันทึกข้อมูลนี้สำหรับการเชื่อมต่อในอนาคต",
- "securityWarning": "คำเตือนด้านความปลอดภัย",
- "acceptAndContinue": "ยอมรับและดำเนินการต่อ",
- "acceptNewKey": "ยอมรับรหัสใหม่และดำเนินการต่อ"
- },
"errors": {
"notFound": "ไม่พบหน้าเว็บ",
"unauthorized": "การเข้าถึงโดยไม่ได้รับอนุญาต",
@@ -2063,8 +2026,6 @@
"terminalSettings": "เทอร์มินัล",
"hostSidebarSettings": "โฮสต์และแถบด้านข้าง",
"snippetsSettings": "เศษเสี้ยว",
- "confirmSnippetExecution": "ยืนยันการเรียกใช้โค้ดสั้น",
- "confirmSnippetExecutionDesc": "แสดงกล่องโต้ตอบยืนยันก่อนเรียกใช้โค้ดสั้นๆ",
"updateSettings": "การอัปเดต",
"disableUpdateCheck": "ปิดใช้งานการตรวจสอบการอัปเดต",
"disableUpdateCheckDesc": "หยุดการตรวจสอบเวอร์ชันใหม่เมื่อเริ่มต้นระบบและบนแดชบอร์ด ช่วยลดจำนวนการร้องขอเครือข่าย",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 หรือ example.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/tr_TR.json b/src/locales/translated/tr_TR.json
index 78ef4b3a..a7ec671c 100644
--- a/src/locales/translated/tr_TR.json
+++ b/src/locales/translated/tr_TR.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Kimlik Bilgileri",
"credentialsViewer": "Kimlik Bilgileri Görüntüleyici",
"manageYourSSHCredentials": "SSH kimlik bilgilerinizi güvenli bir şekilde yönetin.",
"addCredential": "Kimlik Bilgisi Ekle",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Bu işlem, mevcut anahtarların üzerine yazmadan, hedef sunucunun ~/.ssh/authorized_keys dosyasına genel anahtarı güvenli bir şekilde ekleyecektir. İşlem geri alınabilir.",
"chooseHostToDeploy": "Dağıtım yapılacak sunucuyu seçin...",
"deploying": "Dağıtıma başlanıyor...",
- "copyDeployCommand": "Dağıtım Komutunu Kopyala",
- "copyDeployCommandDescription": "Bir sunucunun authorized_keys dosyasına genel anahtarı manuel olarak eklemek için bir shell komutunu kopyalayın. Parola kimlik doğrulamasının devre dışı bırakıldığı durumlarda kullanışlıdır.",
- "deployCommandCopied": "Dağıtım komutu panoya kopyalandı.",
- "manualDeployInfo": "SSH anahtarını eklemek için bu komutu hedef sunucunun terminaline (örneğin, Proxmox konsolu, IPMI veya fiziksel erişim) yapıştırın.",
"name": "İsim",
"noHostsAvailable": "Hiç ev sahibi mevcut değil.",
"noHostsMatchSearch": "Arama kriterlerinize uyan ev sahibi bulunamadı.",
@@ -303,9 +298,7 @@
"createFolder": "Klasör Oluştur",
"editFolder": "Klasörü Düzenle",
"editFolderDescription": "Kod parçacığı klasörünüzü özelleştirin.",
- "createFolderDescription": "Kod parçacıklarınızı klasörler halinde düzenleyin.",
- "confirmExecution": "\"{{name}}\"'yi yürüt?",
- "confirmExecutionDesc": "Bu kod parçasını çalıştırmak istediğinizden emin misiniz?"
+ "createFolderDescription": "Kod parçacıklarınızı klasörler halinde düzenleyin."
},
"commandHistory": {
"title": "Tarih",
@@ -409,7 +402,6 @@
"required": "Gerekli",
"optional": "İsteğe bağlı",
"connect": "Bağlamak",
- "copied": "Kopyalandı",
"connecting": "Bağlanıyor...",
"creating": "Oluşturuluyor...",
"clear": "Temizlemek",
@@ -502,7 +494,6 @@
"checking": "Kontrol ediliyor...",
"checkingDatabase": "Veritabanı bağlantısı kontrol ediliyor...",
"checkingAuthentication": "Kimlik doğrulama kontrol ediliyor...",
- "backendReconnected": "Sunucu bağlantısı yeniden kuruldu",
"actions": "Eylemler",
"remove": "Kaldırmak",
"revoke": "Geri çekmek",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Sunucu Yöneticisi",
- "hosts": "Ev sahipleri",
"sshHosts": "SSH Sunucuları",
"noHosts": "SSH Sunucusu Yok",
"noHostsMessage": "Henüz hiçbir SSH sunucusu eklemediniz. Başlamak için \"Sunucu Ekle\"ye tıklayın.",
@@ -846,7 +836,7 @@
"failedToImportJson": "JSON dosyasını içe aktarma başarısız oldu.",
"connectionDetails": "Bağlantı Ayrıntıları",
"organization": "Organizasyon",
- "ipAddress": "IP Adresi veya Ana Bilgisayar Adı",
+ "ipAddress": "IP Adresi",
"port": "Liman",
"name": "İsim",
"username": "Kullanıcı adı",
@@ -925,8 +915,6 @@
"key": "Anahtar",
"credential": "Kimlik belgesi",
"none": "Hiçbiri",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Her bağlantıda OPKSSH web kimlik doğrulaması için sizden onay ister. Kimlik doğrulama belirteciniz 24 saat boyunca geçerli olacak ve daha sonra yenilenmesi gerekecektir.",
"selectCredential": "Kimlik Bilgisi Seçin",
"selectCredentialPlaceholder": "Bir yeterlilik belgesi seçin...",
"credentialRequired": "Kimlik doğrulama kullanılırken kimlik bilgisi gereklidir.",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Klavye Etkileşimli Kimlik Doğrulama",
"noneAuthDescription": "Bu kimlik doğrulama yöntemi, SSH sunucusuna bağlanırken klavye etkileşimli kimlik doğrulamasını kullanacaktır.",
"noneAuthDetails": "Klavyeyle etkileşimli kimlik doğrulama, sunucunun bağlantı sırasında sizden kimlik bilgilerini istemesine olanak tanır. Bu, çok faktörlü kimlik doğrulama gerektiren sunucular için veya kimlik bilgilerini yerel olarak kaydetmek istemiyorsanız kullanışlıdır.",
- "opksshAuthTitle": "OPKSSH Kimlik Doğrulaması",
"forceKeyboardInteractive": "Klavyeyle Etkileşimi Zorla",
"forceKeyboardInteractiveDesc": "Klavyeyle etkileşimli kimlik doğrulama kullanımını zorunlu kılar. Bu, bazen İki Faktörlü Kimlik Doğrulama (TOTP/2FA) kullanan sunucular için gereklidir.",
"overrideCredentialUsername": "Kimlik Bilgisi Kullanıcı Adını Geçersiz Kıl",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Tarayıcıda aç",
"warpgateContinue": "Kimlik doğrulama işlemini tamamladım.",
"warpgateTimeout": "Warpgate kimlik doğrulama zaman aşımı. Lütfen yeniden bağlanın.",
- "opksshAuthRequired": "OPKSSH Kimlik Doğrulaması Gerekli",
- "opksshAuthDescription": "Devam etmek için tarayıcınızda kimlik doğrulama işlemini tamamlayın. Bu oturum 24 saat boyunca geçerli olacaktır.",
- "opksshAuthUrl": "Kimlik Doğrulama URL'si",
- "opksshOpenBrowser": "Kimlik doğrulaması için tarayıcıyı açın.",
- "opksshWaitingForAuth": "Tarayıcıda kimlik doğrulaması bekleniyor...",
- "opksshAuthenticating": "Kimlik doğrulama işlemi gerçekleştiriliyor...",
- "opksshTimeout": "Kimlik doğrulama işlemi zaman aşımına uğradı. Lütfen tekrar deneyin.",
- "opksshAuthFailed": "Kimlik doğrulama başarısız oldu. Lütfen kimlik bilgilerinizi kontrol edin ve tekrar deneyin.",
- "opksshConfigMissing": "OPKSSH yapılandırması bulunamadı. Lütfen OIDC sağlayıcı ayarlarınızla birlikte ~/.opk/config.yml dosyasını oluşturun. Belgelere bakın: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Şifrenizi girin?",
"sudoPasswordPopupHint": "Eklemek için Enter'a, silmek için Esc'ye basın.",
"sudoPasswordPopupConfirm": "Sokmak",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Otomatik olarak {{method}} kimlik doğrulaması deneniyor...",
"totpTimeout": "TOTP doğrulama zaman aşımına uğradı. Lütfen yeniden bağlanın.",
"passwordTimeout": "Parola doğrulama zaman aşımına uğradı. Lütfen yeniden bağlanın.",
- "connectionRejected": "Sunucu bağlantıyı reddetti. Lütfen kimlik doğrulama ve ağ yapılandırmanızı kontrol edin.",
- "hostKeyRejected": "SSH sunucu anahtarı doğrulama işlemi reddedildi. Bağlantı iptal edildi."
+ "connectionRejected": "Sunucu bağlantıyı reddetti. Lütfen kimlik doğrulama ve ağ yapılandırmanızı kontrol edin."
},
"fileManager": {
"title": "Dosya Yöneticisi",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Şifre Sıfırlama Başarılı",
"passwordResetSuccessDesc": "Parolanız başarıyla sıfırlandı. Artık yeni parolanızla giriş yapabilirsiniz."
},
- "hostKey": {
- "verifyNewHost": "SSH Ana Bilgisayar Anahtarını Doğrulayın",
- "keyChangedWarning": "SSH Ana Bilgisayar Anahtarı Değiştirildi",
- "firstConnectionTitle": "Bu sunucuya ilk kez bağlanıyorum.",
- "firstConnectionDescription": "Bu sunucunun kimliği doğrulanamamıştır. Parmak izinin beklediğinizle eşleştiğini doğrulayın.",
- "keyChangedDescription": "Bu sunucunun anahtar kodu son bağlantınızdan bu yana değişti. Bu bir güvenlik sorununa işaret edebilir.",
- "previousKey": "Önceki Anahtar",
- "newFingerprint": "Yeni Parmak İzi",
- "fingerprint": "Parmak izi",
- "verifyInstructions": "Bu sunucuya güveniyorsanız, devam etmek ve bu parmak izini gelecekteki bağlantılar için kaydetmek üzere Kabul Et'e tıklayın.",
- "securityWarning": "Güvenlik Uyarısı",
- "acceptAndContinue": "Kabul Et ve Devam Et",
- "acceptNewKey": "Yeni Anahtarı Kabul Et ve Devam Et"
- },
"errors": {
"notFound": "Sayfa bulunamadı.",
"unauthorized": "Yetkisiz erişim",
@@ -2063,8 +2026,6 @@
"terminalSettings": "terminal",
"hostSidebarSettings": "Sunucu ve Yan Menü",
"snippetsSettings": "Kısa bölümler",
- "confirmSnippetExecution": "Kod Parçasının Yürütülmesini Onayla",
- "confirmSnippetExecutionDesc": "Kod parçacıklarını çalıştırmadan önce onay iletişim kutusunu göster.",
"updateSettings": "Güncellemeler",
"disableUpdateCheck": "Güncelleme Kontrolünü Devre Dışı Bırak",
"disableUpdateCheckDesc": "Başlangıçta ve kontrol panelinde yeni sürümleri kontrol etmeyi durdurun. Ağ isteklerini azaltır.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 veya example.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/uk_UA.json b/src/locales/translated/uk_UA.json
index e3c2405c..f1a6f8cb 100644
--- a/src/locales/translated/uk_UA.json
+++ b/src/locales/translated/uk_UA.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Дані доступу",
"credentialsViewer": "Переглядач облікових даних",
"manageYourSSHCredentials": "Безпечно керувати вашими обліковими даними SSH",
"addCredential": "Додати",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Це безпечно додасть відкритий ключ до цільового хоста ~/.ssh/authorized_keys файл без перезапису існуючих ключів. Операція незворотна.",
"chooseHostToDeploy": "Виберіть хост для розгортання...",
"deploying": "Розгортання...",
- "copyDeployCommand": "Скопіювати Команду Розгортання",
- "copyDeployCommandDescription": "Скопіювати команду оболонки в ручне додавання відкритого ключа в файл authorized_keys. Корисно, коли автентифікація пароля вимкнена.",
- "deployCommandCopied": "Команда розгортання скопійована до буферу обміну",
- "manualDeployInfo": "Вставити цю команду в терміналі цільового хоста (наприклад, консоль Proxmox, IPMI або фізичний доступ), щоб додати SSH ключ.",
"name": "Ім'я",
"noHostsAvailable": "Немає доступних хостів",
"noHostsMatchSearch": "Немає хостів, які відповідають вашому пошуку",
@@ -303,9 +298,7 @@
"createFolder": "Створити теку",
"editFolder": "Змінити теку",
"editFolderDescription": "Налаштуйте вашу теку зі сніпетом",
- "createFolderDescription": "Організуйте ваші сніпети у папки",
- "confirmExecution": "Виконати{{name}}\"?",
- "confirmExecutionDesc": "Ви впевнені, що хочете зробити цей сніпет?"
+ "createFolderDescription": "Організуйте ваші сніпети у папки"
},
"commandHistory": {
"title": "Історія",
@@ -409,7 +402,6 @@
"required": "Обов'язково",
"optional": "За бажанням",
"connect": "Підключитися",
- "copied": "Скопійовано",
"connecting": "З’єднання...",
"creating": "Створюю...",
"clear": "Очистити",
@@ -502,7 +494,6 @@
"checking": "Перевірка...",
"checkingDatabase": "Перевірка підключення до бази даних...",
"checkingAuthentication": "Перевірка автентифікації...",
- "backendReconnected": "З'єднання з сервером відновлено",
"actions": "Дії",
"remove": "Видалити",
"revoke": "Revoke",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Менеджер хостів",
- "hosts": "Хости",
"sshHosts": "SSH хости",
"noHosts": "Немає SSH хостів",
"noHostsMessage": "Ви ще не додали жодного SSH хоста. Натисніть \"Додати хоста\".",
@@ -846,7 +836,7 @@
"failedToImportJson": "Не вдалося імпортувати файл JSON",
"connectionDetails": "Відомості про підключення",
"organization": "Організація",
- "ipAddress": "IP-адреса або ім'я хоста",
+ "ipAddress": "IP-адреса",
"port": "Порт",
"name": "Ім'я",
"username": "Ім'я користувача",
@@ -925,8 +915,6 @@
"key": "Ключ",
"credential": "Облікові дані",
"none": "Без ефекту",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Ви заохочуєте вас до ОПК-авторизацію при кожному підключенні. Токен автентифікації буде тривати 24 години, поки він буде буде поновлений.",
"selectCredential": "Оберіть облікові дані",
"selectCredentialPlaceholder": "Виберіть облікові дані...",
"credentialRequired": "Обліковий запис потрібен під час використання аутентифікації облікових даних",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Клавіатура-інтерактивна автентифікація",
"noneAuthDescription": "Цей метод автентифікації використовуватиме клавіатурну інтерактивну автентифікацію при підключенні до SSH сервера.",
"noneAuthDetails": "Увімкнута інтерактивна клавіатура дозволяє серверу підводити Ваші дані під час підключення. Це корисно для серверів, які потребують багатофакторної аутентифікації або якщо ви не хочете зберегти облікові дані локально.",
- "opksshAuthTitle": "Аутентифікація OPKSSH",
"forceKeyboardInteractive": "Примусова інтерактивна клавіатура",
"forceKeyboardInteractiveDesc": "Вказує використання клавіатури для автентифікації. Іноді це потрібно для серверів, які використовують двофакторну автентифікацію (TOTP/2FA).",
"overrideCredentialUsername": "Перевизначити ім'я користувача",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Відкрити у браузері",
"warpgateContinue": "Я вже завершив автентифікацію",
"warpgateTimeout": "Тайм-аут аутентифікації warpgate будь ласка перепід'єднайтеся.",
- "opksshAuthRequired": "Необхідна автентифікація OPKSSH",
- "opksshAuthDescription": "Завершіть автентифікацію в вашому браузері, щоб продовжити. Ця сесія залишиться дійсною протягом 24 годин.",
- "opksshAuthUrl": "URL автентифікації",
- "opksshOpenBrowser": "Відкрити браузер для аутентифікації",
- "opksshWaitingForAuth": "Очікування аутентифікації в браузері...",
- "opksshAuthenticating": "Обробка автентифікації...",
- "opksshTimeout": "Час аутентифікації минув. Будь ласка, спробуйте ще раз.",
- "opksshAuthFailed": "Помилка авторизації. Будь ласка, перевірте свої облікові дані та спробуйте ще раз.",
- "opksshConfigMissing": "Конфігурацію OPKSSH не знайдено. Будь ласка, створіть ~/.opk/config.yml з параметрами постачальника OIDC. Див. документацію: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Введіть пароль?",
"sudoPasswordPopupHint": "Натисніть Enter, щоб вставити, Esc для відхилення",
"sudoPasswordPopupConfirm": "Insert",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Автоматично пробує автентифікацію {{method}}...",
"totpTimeout": "Таймаут перевірки TOTP. Будь ласка, під'єднайтеся.",
"passwordTimeout": "Час очікування підтвердження пароля. Будь ласка, під'єднайтеся.",
- "connectionRejected": "З'єднання відхилено сервером. Будь ласка, перевірте налаштування аутентифікації та мережі.",
- "hostKeyRejected": "SSH-ключ хоста відхилено. Підключення скасовано."
+ "connectionRejected": "З'єднання відхилено сервером. Будь ласка, перевірте налаштування аутентифікації та мережі."
},
"fileManager": {
"title": "Файловий менеджер",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Пароль успішно скинуто",
"passwordResetSuccessDesc": "Ваш пароль успішно скинуто. Тепер ви можете увійти в систему, використовуючи новий пароль."
},
- "hostKey": {
- "verifyNewHost": "Перевірити SSH ключ хоста",
- "keyChangedWarning": "SSH ключ хоста змінено",
- "firstConnectionTitle": "вперше при підключенні до цього хосту",
- "firstConnectionDescription": "Неможливо встановити автентифікацію цього хоста. Перевірте, чи збігається ваш запит.",
- "keyChangedDescription": "Ключ хоста для цього сервера було змінено після вашого останнього підключення. Це може означати проблему безпеки.",
- "previousKey": "Попередній ключ",
- "newFingerprint": "Новий відбиток пальця",
- "fingerprint": "Відбиток пальця",
- "verifyInstructions": "Щоб продовжити і зберегти цей відбиток для майбутніх з'єднань, натисніть кнопку \"Прийняти\".",
- "securityWarning": "Попередження системи безпеки",
- "acceptAndContinue": "Прийняти та продовжити",
- "acceptNewKey": "Прийняти новий ключ і продовжити"
- },
"errors": {
"notFound": "Сторінка не знайдена",
"unauthorized": "Неавторизований доступ",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Термінал",
"hostSidebarSettings": "Хост і бічна панель",
"snippetsSettings": "Сніпети",
- "confirmSnippetExecution": "Підтвердити виконання сніпета",
- "confirmSnippetExecutionDesc": "Показати діалогове вікно підтвердження перед виконанням сніпетів",
"updateSettings": "Оновлення",
"disableUpdateCheck": "Вимкнути перевірку оновлень",
"disableUpdateCheckDesc": "Зупинити перевірку для нових версій при запуску і панелі керування. Зменшує запити на мережі.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 або приклад.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/vi_VN.json b/src/locales/translated/vi_VN.json
index 7680073f..364980c6 100644
--- a/src/locales/translated/vi_VN.json
+++ b/src/locales/translated/vi_VN.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "Thông tin xác thực",
"credentialsViewer": "Trình xem thông tin đăng nhập",
"manageYourSSHCredentials": "Quản lý thông tin đăng nhập SSH của bạn một cách an toàn",
"addCredential": "Thêm thông tin đăng nhập",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "Thao tác này sẽ thêm khóa công khai một cách an toàn vào tệp ~/.ssh/authorized_keys của máy chủ đích mà không ghi đè lên các khóa hiện có. Thao tác này có thể đảo ngược.",
"chooseHostToDeploy": "Chọn máy chủ để triển khai...",
"deploying": "Đang triển khai...",
- "copyDeployCommand": "Sao chép lệnh triển khai",
- "copyDeployCommandDescription": "Sao chép lệnh shell để thêm thủ công khóa công khai vào tệp authorized_keys của máy chủ. Hữu ích khi xác thực bằng mật khẩu bị vô hiệu hóa.",
- "deployCommandCopied": "Lệnh triển khai đã được sao chép vào clipboard",
- "manualDeployInfo": "Dán lệnh này vào cửa sổ terminal của máy chủ đích (ví dụ: console Proxmox, IPMI hoặc truy cập vật lý) để thêm khóa SSH.",
"name": "Tên",
"noHostsAvailable": "Hiện không có máy chủ nào khả dụng",
"noHostsMatchSearch": "Không có máy chủ nào phù hợp với tìm kiếm của bạn",
@@ -303,9 +298,7 @@
"createFolder": "Tạo thư mục",
"editFolder": "Chỉnh sửa thư mục",
"editFolderDescription": "Tùy chỉnh thư mục đoạn mã của bạn",
- "createFolderDescription": "Sắp xếp các đoạn văn bản của bạn vào các thư mục.",
- "confirmExecution": "Thực thi \"{{name}}\"?",
- "confirmExecutionDesc": "Bạn có chắc chắn muốn thực thi đoạn mã này không?"
+ "createFolderDescription": "Sắp xếp các đoạn văn bản của bạn vào các thư mục."
},
"commandHistory": {
"title": "Lịch sử",
@@ -409,7 +402,6 @@
"required": "Yêu cầu",
"optional": "Không bắt buộc",
"connect": "Kết nối",
- "copied": "Đã sao chép",
"connecting": "Đang kết nối...",
"creating": "Đang tạo...",
"clear": "Thông thoáng",
@@ -502,7 +494,6 @@
"checking": "Đang kiểm tra...",
"checkingDatabase": "Kiểm tra kết nối cơ sở dữ liệu...",
"checkingAuthentication": "Đang kiểm tra xác thực...",
- "backendReconnected": "Kết nối máy chủ đã được khôi phục.",
"actions": "Hành động",
"remove": "Di dời",
"revoke": "Thu hồi",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "Quản lý máy chủ",
- "hosts": "Người dẫn chương trình",
"sshHosts": "Máy chủ SSH",
"noHosts": "Không có máy chủ SSH",
"noHostsMessage": "Bạn chưa thêm bất kỳ máy chủ SSH nào. Nhấp vào \"Thêm máy chủ\" để bắt đầu.",
@@ -846,7 +836,7 @@
"failedToImportJson": "Không thể nhập tệp JSON.",
"connectionDetails": "Chi tiết kết nối",
"organization": "Tổ chức",
- "ipAddress": "Địa chỉ IP hoặc tên máy chủ",
+ "ipAddress": "Địa chỉ IP",
"port": "Cảng",
"name": "Tên",
"username": "Tên người dùng",
@@ -925,8 +915,6 @@
"key": "Chìa khóa",
"credential": "Chứng chỉ",
"none": "Không có",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "Hệ thống sẽ yêu cầu bạn xác thực web bằng OPKSSH mỗi khi kết nối. Mã xác thực của bạn sẽ có hiệu lực trong 24 giờ trước khi cần được gia hạn.",
"selectCredential": "Chọn thông tin xác thực",
"selectCredentialPlaceholder": "Chọn một loại chứng chỉ...",
"credentialRequired": "Cần có thông tin đăng nhập khi sử dụng phương thức xác thực bằng thông tin đăng nhập.",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "Xác thực tương tác bàn phím",
"noneAuthDescription": "Phương thức xác thực này sẽ sử dụng xác thực tương tác bàn phím khi kết nối với máy chủ SSH.",
"noneAuthDetails": "Xác thực tương tác bàn phím cho phép máy chủ yêu cầu bạn nhập thông tin đăng nhập trong quá trình kết nối. Điều này hữu ích cho các máy chủ yêu cầu xác thực đa yếu tố hoặc nếu bạn không muốn lưu thông tin đăng nhập cục bộ.",
- "opksshAuthTitle": "Xác thực OPKSSH",
"forceKeyboardInteractive": "Buộc tương tác bàn phím",
"forceKeyboardInteractiveDesc": "Buộc sử dụng xác thực tương tác bàn phím. Điều này đôi khi cần thiết đối với các máy chủ sử dụng xác thực hai yếu tố (TOTP/2FA).",
"overrideCredentialUsername": "Ghi đè tên người dùng xác thực",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "Mở trong trình duyệt",
"warpgateContinue": "Tôi đã hoàn tất xác thực.",
"warpgateTimeout": "Lỗi xác thực Warpgate đã hết thời gian chờ. Vui lòng kết nối lại.",
- "opksshAuthRequired": "Yêu cầu xác thực OPKSSH",
- "opksshAuthDescription": "Hoàn tất xác thực trên trình duyệt của bạn để tiếp tục. Phiên này sẽ có hiệu lực trong 24 giờ.",
- "opksshAuthUrl": "URL xác thực",
- "opksshOpenBrowser": "Mở trình duyệt để xác thực.",
- "opksshWaitingForAuth": "Đang chờ xác thực trên trình duyệt...",
- "opksshAuthenticating": "Đang xử lý xác thực...",
- "opksshTimeout": "Quá trình xác thực đã hết hạn. Vui lòng thử lại.",
- "opksshAuthFailed": "Xác thực không thành công. Vui lòng kiểm tra lại thông tin đăng nhập của bạn và thử lại.",
- "opksshConfigMissing": "Không tìm thấy cấu hình OPKSSH. Vui lòng tạo tệp ~/.opk/config.yml với các cài đặt nhà cung cấp OIDC của bạn. Xem tài liệu: https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "Nhập mật khẩu?",
"sudoPasswordPopupHint": "Nhấn Enter để chèn, Esc để xóa",
"sudoPasswordPopupConfirm": "Chèn",
@@ -1359,8 +1337,7 @@
"automaticFallback": "Đang tự động thử xác thực {{method}} ...",
"totpTimeout": "Đã hết thời gian xác thực TOTP. Vui lòng kết nối lại.",
"passwordTimeout": "Đã hết thời gian xác thực mật khẩu. Vui lòng kết nối lại.",
- "connectionRejected": "Kết nối bị máy chủ từ chối. Vui lòng kiểm tra cấu hình xác thực và mạng của bạn.",
- "hostKeyRejected": "Xác thực khóa máy chủ SSH bị từ chối. Kết nối bị hủy."
+ "connectionRejected": "Kết nối bị máy chủ từ chối. Vui lòng kiểm tra cấu hình xác thực và mạng của bạn."
},
"fileManager": {
"title": "Trình quản lý tập tin",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "Đặt lại mật khẩu thành công",
"passwordResetSuccessDesc": "Mật khẩu của bạn đã được đặt lại thành công. Giờ bạn có thể đăng nhập bằng mật khẩu mới."
},
- "hostKey": {
- "verifyNewHost": "Xác minh khóa máy chủ SSH",
- "keyChangedWarning": "Khóa máy chủ SSH đã thay đổi",
- "firstConnectionTitle": "Lần đầu tiên kết nối với máy chủ này.",
- "firstConnectionDescription": "Không thể xác minh tính xác thực của máy chủ này. Hãy kiểm tra xem dấu vân tay có khớp với những gì bạn mong đợi hay không.",
- "keyChangedDescription": "Khóa máy chủ (host key) của máy chủ này đã thay đổi kể từ lần kết nối cuối cùng của bạn. Điều này có thể cho thấy một vấn đề về bảo mật.",
- "previousKey": "Phím trước đó",
- "newFingerprint": "Dấu vân tay mới",
- "fingerprint": "Dấu vân tay",
- "verifyInstructions": "Nếu bạn tin tưởng máy chủ này, hãy nhấp vào Chấp nhận để tiếp tục và lưu dấu vân tay này cho các kết nối trong tương lai.",
- "securityWarning": "Cảnh báo an ninh",
- "acceptAndContinue": "Chấp nhận và tiếp tục",
- "acceptNewKey": "Chấp nhận khóa mới và tiếp tục"
- },
"errors": {
"notFound": "Không tìm thấy trang",
"unauthorized": "Truy cập trái phép",
@@ -2063,8 +2026,6 @@
"terminalSettings": "Phần cuối",
"hostSidebarSettings": "Máy chủ & Thanh bên",
"snippetsSettings": "Những đoạn trích",
- "confirmSnippetExecution": "Xác nhận việc thực thi đoạn mã",
- "confirmSnippetExecutionDesc": "Hiển thị hộp thoại xác nhận trước khi thực thi các đoạn mã.",
"updateSettings": "Cập nhật",
"disableUpdateCheck": "Tắt kiểm tra cập nhật",
"disableUpdateCheckDesc": "Ngừng kiểm tra các phiên bản mới khi khởi động và trên bảng điều khiển. Giảm số lượng yêu cầu mạng.",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 hoặc example.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/zh_CN.json b/src/locales/translated/zh_CN.json
index dc87b069..b429a2c2 100644
--- a/src/locales/translated/zh_CN.json
+++ b/src/locales/translated/zh_CN.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "全权证书",
"credentialsViewer": "凭证查看器",
"manageYourSSHCredentials": "安全地管理您的 SSH 凭证",
"addCredential": "添加凭证",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "这将安全地将公钥添加到目标主机的 ~/.ssh/authorized_keys 文件中,而不会覆盖现有密钥。此操作可逆。",
"chooseHostToDeploy": "选择要部署到的主机……",
"deploying": "正在部署……",
- "copyDeployCommand": "复制部署命令",
- "copyDeployCommandDescription": "复制 shell 命令手动将公钥添加到主机的授权键文件。当密码验证被禁用时有用。",
- "deployCommandCopied": "部署命令已复制到剪贴板",
- "manualDeployInfo": "将此命令粘贴到目标主机终端中(例如,Proxmox console、 IPMI 或物理访问) 以添加 SSH 密钥。",
"name": "名称",
"noHostsAvailable": "暂无可用主机",
"noHostsMatchSearch": "没有符合您搜索条件的主机",
@@ -303,9 +298,7 @@
"createFolder": "创建文件夹",
"editFolder": "编辑文件夹",
"editFolderDescription": "自定义代码片段文件夹",
- "createFolderDescription": "将你的代码片段归类到文件夹中",
- "confirmExecution": "执行“{{name}}”?",
- "confirmExecutionDesc": "您确定要执行此代码片段吗?"
+ "createFolderDescription": "将你的代码片段归类到文件夹中"
},
"commandHistory": {
"title": "历史",
@@ -409,7 +402,6 @@
"required": "必需的",
"optional": "选修的",
"connect": "连接",
- "copied": "已复制",
"connecting": "正在连接...",
"creating": "正在创建……",
"clear": "清除",
@@ -502,7 +494,6 @@
"checking": "检查...",
"checkingDatabase": "正在检查数据库连接...",
"checkingAuthentication": "正在检查认证...",
- "backendReconnected": "服务器连接已恢复",
"actions": "激活",
"remove": "移除",
"revoke": "撤销",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "主机管理",
- "hosts": "主机",
"sshHosts": "SSH主机",
"noHosts": "无 SSH 主机",
"noHostsMessage": "您尚未添加任何 SSH 主机。点击“添加主机”开始操作。",
@@ -846,7 +836,7 @@
"failedToImportJson": "导入 JSON 文件失败",
"connectionDetails": "连接详情",
"organization": "组织",
- "ipAddress": "IP地址或主机名",
+ "ipAddress": "IP地址",
"port": "端口",
"name": "名称",
"username": "用户名",
@@ -925,8 +915,6 @@
"key": "密钥",
"credential": "凭证",
"none": "无",
- "opkssh": "打开",
- "opksshAuthDescription": "在每个连接上提示您使用 OPKSSH web-auth。您的认证令牌将持续24个小时,直到它被更新。",
"selectCredential": "选择凭证",
"selectCredentialPlaceholder": "选择一种凭证……",
"credentialRequired": "使用凭证身份验证时需要凭证。",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "键盘交互认证",
"noneAuthDescription": "连接到 SSH 服务器时,此身份验证方法将使用键盘交互式身份验证。",
"noneAuthDetails": "键盘交互式身份验证允许服务器在连接过程中提示您输入凭证。这对于需要多因素身份验证的服务器或您不想在本地保存凭证的情况非常有用。",
- "opksshAuthTitle": "OPKSSH 身份验证",
"forceKeyboardInteractive": "强制键盘交互",
"forceKeyboardInteractiveDesc": "强制使用键盘交互式身份验证。对于使用双因素身份验证(TOTP/2FA)的服务器,有时需要这样做。",
"overrideCredentialUsername": "覆盖凭证用户名",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "在浏览器中打开",
"warpgateContinue": "我已经完成身份验证",
"warpgateTimeout": "Warpgate身份验证超时,请重新连接。",
- "opksshAuthRequired": "需要OPKSSH 身份验证",
- "opksshAuthDescription": "在您的浏览器中完成身份验证以继续。此会话将持续24小时有效。",
- "opksshAuthUrl": "身份验证URL",
- "opksshOpenBrowser": "打开浏览器进行身份验证",
- "opksshWaitingForAuth": "正在等待浏览器的身份验证...",
- "opksshAuthenticating": "正在处理身份验证...",
- "opksshTimeout": "认证超时。请重试。",
- "opksshAuthFailed": "认证失败。请检查您的凭据,然后重试。",
- "opksshConfigMissing": "找不到 OPKSSH 配置。请使用您的 OIDC 提供商设置创建 ~/.opk/config.yml 。请参阅文档:https://github.com/openpubkey/opkssh#config.",
"sudoPasswordPopupTitle": "输入密码?",
"sudoPasswordPopupHint": "按 Enter 键插入,按 Esc 键关闭",
"sudoPasswordPopupConfirm": "确认",
@@ -1359,8 +1337,7 @@
"automaticFallback": "正在自动尝试 {{method}} 身份验证...",
"totpTimeout": "TOTP验证超时,请重新连接。",
"passwordTimeout": "密码验证超时,请重新连接。",
- "connectionRejected": "服务器拒绝连接。请检查您的身份验证和网络配置。",
- "hostKeyRejected": "SSH 主机验证被拒绝。连接已取消。"
+ "connectionRejected": "服务器拒绝连接。请检查您的身份验证和网络配置。"
},
"fileManager": {
"title": "文件管理器",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "密码重置成功",
"passwordResetSuccessDesc": "您的密码已成功重置。您现在可以使用新密码登录。"
},
- "hostKey": {
- "verifyNewHost": "验证 SSH 主机密钥",
- "keyChangedWarning": "SSH 主机密钥已更改",
- "firstConnectionTitle": "第一次连接到此主机",
- "firstConnectionDescription": "此主机的真实性无法确认。请验证指纹符合您的期望。",
- "keyChangedDescription": "此服务器的主机密钥自您上次连接以来已经改变。这可能会显示一个安全问题。",
- "previousKey": "上一键",
- "newFingerprint": "新建指纹",
- "fingerprint": "指纹",
- "verifyInstructions": "如果您信任此主机,请单击接受以继续保存此指纹以备将来的连接。",
- "securityWarning": "安全警告",
- "acceptAndContinue": "接受并继续",
- "acceptNewKey": "接受新密钥并继续"
- },
"errors": {
"notFound": "页面未找到",
"unauthorized": "未经授权的访问",
@@ -2063,8 +2026,6 @@
"terminalSettings": "终端",
"hostSidebarSettings": "主机和侧边栏",
"snippetsSettings": "代码片段",
- "confirmSnippetExecution": "确认代码片段执行",
- "confirmSnippetExecutionDesc": "执行代码片段前显示确认对话框",
"updateSettings": "更新",
"disableUpdateCheck": "禁用更新检查",
"disableUpdateCheckDesc": "停止在启动和控制面板中检查新版本。减少网络请求。",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 或 example.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/locales/translated/zh_TW.json b/src/locales/translated/zh_TW.json
index 2404f242..a526b766 100644
--- a/src/locales/translated/zh_TW.json
+++ b/src/locales/translated/zh_TW.json
@@ -1,6 +1,5 @@
{
"credentials": {
- "credentials": "證書",
"credentialsViewer": "憑證檢視器",
"manageYourSSHCredentials": "安全地管理您的 SSH 憑證",
"addCredential": "新增憑證",
@@ -100,10 +99,6 @@
"deploymentProcessDescription": "這將安全地將公鑰新增至目標主機的 ~/.ssh/authorized_keys 檔案中,而不會覆蓋現有金鑰。此操作可逆。",
"chooseHostToDeploy": "選擇要部署到的主機…",
"deploying": "正在部署…",
- "copyDeployCommand": "複製部署命令",
- "copyDeployCommandDescription": "複製一條 shell 指令,用於手動將公鑰新增至主機的 authorized_keys 檔案中。當密碼驗證被停用時,此方法非常有用。",
- "deployCommandCopied": "部署指令已複製到剪貼簿",
- "manualDeployInfo": "將此命令貼上到目標主機的終端(例如,Proxmox 控制台、IPMI 或實體存取)中,以新增 SSH 金鑰。",
"name": "姓名",
"noHostsAvailable": "暫無可用主機",
"noHostsMatchSearch": "沒有符合您搜尋條件的主機",
@@ -303,9 +298,7 @@
"createFolder": "建立資料夾",
"editFolder": "編輯資料夾",
"editFolderDescription": "自訂程式碼片段資料夾",
- "createFolderDescription": "將你的程式碼片段整理到資料夾中",
- "confirmExecution": "執行“{{name}}”?",
- "confirmExecutionDesc": "您確定要執行此程式碼片段嗎?"
+ "createFolderDescription": "將你的程式碼片段整理到資料夾中"
},
"commandHistory": {
"title": "歷史",
@@ -409,7 +402,6 @@
"required": "必需的",
"optional": "選擇性",
"connect": "連接",
- "copied": "已複製",
"connecting": "正在連接...",
"creating": "正在建立…",
"clear": "清除",
@@ -502,7 +494,6 @@
"checking": "檢查...",
"checkingDatabase": "正在檢查資料庫連線...",
"checkingAuthentication": "正在檢查身份驗證...",
- "backendReconnected": "伺服器連線已恢復",
"actions": "行動",
"remove": "消除",
"revoke": "撤銷",
@@ -815,7 +806,6 @@
},
"hosts": {
"title": "主機管理器",
- "hosts": "主持人",
"sshHosts": "SSH 主機",
"noHosts": "無 SSH 主機",
"noHostsMessage": "您尚未新增任何 SSH 主機。點選「新增主機」開始操作。",
@@ -846,7 +836,7 @@
"failedToImportJson": "導入 JSON 檔案失敗",
"connectionDetails": "連線詳情",
"organization": "組織",
- "ipAddress": "IP位址或主機名",
+ "ipAddress": "IP位址",
"port": "連接埠",
"name": "姓名",
"username": "使用者名稱",
@@ -925,8 +915,6 @@
"key": "金鑰",
"credential": "憑證",
"none": "沒有",
- "opkssh": "OPKSSH",
- "opksshAuthDescription": "每次連線時都會提示您進行 OPKSSH Web 驗證。您的身份驗證令牌有效期為 24 小時,之後需要更新。",
"selectCredential": "選擇憑證",
"selectCredentialPlaceholder": "選擇一種憑證…",
"credentialRequired": "使用憑證驗證時需要憑證。",
@@ -1095,7 +1083,6 @@
"noneAuthTitle": "鍵盤互動式驗證",
"noneAuthDescription": "連接到 SSH 伺服器時,此身份驗證方法將使用鍵盤互動式身份驗證。",
"noneAuthDetails": "鍵盤互動式驗證可讓伺服器在連線過程中提示您輸入憑證。這對於需要多因素身份驗證的伺服器或您不想在本地儲存憑證的情況非常有用。",
- "opksshAuthTitle": "OPKSSH 驗證",
"forceKeyboardInteractive": "強制鍵盤交互",
"forceKeyboardInteractiveDesc": "強制使用鍵盤互動式身份驗證。對於使用雙重認證(TOTP/2FA)的伺服器,有時需要這樣做。",
"overrideCredentialUsername": "覆蓋憑證使用者名稱",
@@ -1329,15 +1316,6 @@
"warpgateOpenBrowser": "在瀏覽器中開啟",
"warpgateContinue": "我已經完成身份驗證",
"warpgateTimeout": "Warpgate身份驗證逾時,請重新連線。",
- "opksshAuthRequired": "需要 OPKSSH 驗證",
- "opksshAuthDescription": "請在瀏覽器中完成身份驗證以繼續。此會話有效期限為 24 小時。",
- "opksshAuthUrl": "身份驗證 URL",
- "opksshOpenBrowser": "開啟瀏覽器進行身份驗證",
- "opksshWaitingForAuth": "正在等待瀏覽器驗證...",
- "opksshAuthenticating": "正在處理身份驗證...",
- "opksshTimeout": "身份驗證超時,請重試。",
- "opksshAuthFailed": "身份驗證失敗。請檢查您的憑證並重試。",
- "opksshConfigMissing": "未找到 OPKSSH 配置。請建立 ~/.opk/config.yml 文件,並新增您的 OIDC 提供者設定。請參閱文件:https://github.com/openpubkey/opkssh#configuration",
"sudoPasswordPopupTitle": "輸入密碼?",
"sudoPasswordPopupHint": "按 Enter 鍵插入,按 Esc 鍵關閉",
"sudoPasswordPopupConfirm": "插入",
@@ -1359,8 +1337,7 @@
"automaticFallback": "正在自動嘗試 {{method}} 驗證...",
"totpTimeout": "TOTP驗證逾時,請重新連線。",
"passwordTimeout": "密碼驗證逾時,請重新連線。",
- "connectionRejected": "伺服器拒絕連線。請檢查您的身份驗證和網路配置。",
- "hostKeyRejected": "SSH主機金鑰驗證失敗。連線已取消。"
+ "connectionRejected": "伺服器拒絕連線。請檢查您的身份驗證和網路配置。"
},
"fileManager": {
"title": "檔案管理器",
@@ -1957,20 +1934,6 @@
"passwordResetSuccess": "密碼重置成功",
"passwordResetSuccessDesc": "您的密碼已成功重設。現在您可以使用新密碼登入。"
},
- "hostKey": {
- "verifyNewHost": "驗證 SSH 主機金鑰",
- "keyChangedWarning": "SSH主機金鑰已更改",
- "firstConnectionTitle": "首次連線到此主機",
- "firstConnectionDescription": "無法確認此主機的真實性。請驗證指紋是否符合預期。",
- "keyChangedDescription": "此伺服器的主機金鑰自您上次連線以來已變更。這可能表示存在安全問題。",
- "previousKey": "上一個密鑰",
- "newFingerprint": "新指紋",
- "fingerprint": "指紋",
- "verifyInstructions": "如果您信任此主機,請按一下「接受」繼續並儲存此指紋以便將來連線。",
- "securityWarning": "安全警告",
- "acceptAndContinue": "接受並繼續",
- "acceptNewKey": "接受新密鑰並繼續"
- },
"errors": {
"notFound": "頁面未找到",
"unauthorized": "未經授權的訪問",
@@ -2063,8 +2026,6 @@
"terminalSettings": "終端機",
"hostSidebarSettings": "主機和側邊欄",
"snippetsSettings": "片段",
- "confirmSnippetExecution": "確認程式碼片段執行",
- "confirmSnippetExecutionDesc": "執行程式碼片段前顯示確認對話框",
"updateSettings": "更新",
"disableUpdateCheck": "禁用更新檢查",
"disableUpdateCheckDesc": "停止在啟動和控制面板中檢查新版本。減少網路請求。",
@@ -2085,7 +2046,7 @@
},
"placeholders": {
"enterCode": "000000",
- "ipAddress": "192.168.1.1 或 example.com",
+ "ipAddress": "127.0.0.1",
"port": "22",
"maxRetries": "3",
"retryInterval": "10",
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts
index ec3321e4..6ded0abc 100644
--- a/src/types/electron.d.ts
+++ b/src/types/electron.d.ts
@@ -88,5 +88,9 @@ declare global {
interface Window {
electronAPI: ElectronAPI;
IS_ELECTRON: boolean;
+ electronClipboard?: {
+ writeText(text: string): void;
+ readText(): string;
+ };
}
}
diff --git a/src/types/index.ts b/src/types/index.ts
index 843704dd..48b81e62 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -82,7 +82,7 @@ export interface QuickActionData {
export interface ProxyNode {
host: string;
port: number;
- type: 4 | 5;
+ type: 4 | 5 | "http";
username?: string;
password?: string;
}
@@ -173,9 +173,9 @@ export interface CredentialBackend {
username: string | null;
password: string | null;
key: string;
- private_key?: string;
- public_key?: string;
- key_password: string | null;
+ privateKey?: string;
+ publicKey?: string;
+ keyPassword: string | null;
keyType?: string;
detectedKeyType: string;
usageCount: number;
@@ -395,6 +395,8 @@ export interface TerminalConfig {
autoMosh: boolean;
moshCommand: string;
sudoPasswordAutoFill: boolean;
+ keepaliveInterval?: number;
+ keepaliveCountMax?: number;
}
// ============================================================================
@@ -403,6 +405,7 @@ export interface TerminalConfig {
export interface TabContextTab {
id: number;
+ instanceId?: string;
type:
| "home"
| "terminal"
diff --git a/src/types/stats-widgets.ts b/src/types/stats-widgets.ts
index f8e18fc0..69aaa31f 100644
--- a/src/types/stats-widgets.ts
+++ b/src/types/stats-widgets.ts
@@ -53,8 +53,10 @@ export interface StatsConfig {
enabledWidgets: WidgetType[];
statusCheckEnabled: boolean;
statusCheckInterval: number;
+ useGlobalStatusInterval?: boolean;
metricsEnabled: boolean;
metricsInterval: number;
+ useGlobalMetricsInterval?: boolean;
}
export const DEFAULT_STATS_CONFIG: StatsConfig = {
@@ -69,6 +71,8 @@ export const DEFAULT_STATS_CONFIG: StatsConfig = {
],
statusCheckEnabled: true,
statusCheckInterval: 30,
+ useGlobalStatusInterval: true,
metricsEnabled: true,
metricsInterval: 30,
+ useGlobalMetricsInterval: true,
};
diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx
index 62e91a70..e9411c95 100644
--- a/src/ui/desktop/DesktopApp.tsx
+++ b/src/ui/desktop/DesktopApp.tsx
@@ -1,4 +1,12 @@
-import React, { useState, useEffect, useCallback, useRef } from "react";
+import React, {
+ useState,
+ useEffect,
+ useCallback,
+ useRef,
+ Component,
+ type ErrorInfo,
+ type ReactNode,
+} from "react";
import { LeftSidebar } from "@/ui/desktop/navigation/LeftSidebar.tsx";
import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx";
import { AppView } from "@/ui/desktop/navigation/AppView.tsx";
@@ -148,8 +156,9 @@ function AppContent({
if (hostIdentifier) {
const openTerminal = async () => {
try {
- const { getSSHHostById, getSSHHosts } =
- await import("@/ui/main-axios.ts");
+ const { getSSHHostById, getSSHHosts } = await import(
+ "@/ui/main-axios.ts"
+ );
let host = null;
if (/^\d+$/.test(hostIdentifier)) {
@@ -616,16 +625,54 @@ function AppContent({
);
}
+class TabErrorBoundary extends Component<
+ { children: ReactNode },
+ { hasError: boolean; errorCount: number }
+> {
+ constructor(props: { children: ReactNode }) {
+ super(props);
+ this.state = { hasError: false, errorCount: 0 };
+ }
+
+ static getDerivedStateFromError(error: Error) {
+ if (error.message?.includes("useTabs must be used within a TabProvider")) {
+ return { hasError: true };
+ }
+ throw error;
+ }
+
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ if (error.message?.includes("useTabs must be used within a TabProvider")) {
+ console.warn(
+ "TabProvider mounting race condition detected, recovering...",
+ );
+ this.setState((prev) => ({ errorCount: prev.errorCount + 1 }));
+ setTimeout(() => {
+ this.setState({ hasError: false });
+ }, 0);
+ }
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return null;
+ }
+ return this.props.children;
+ }
+}
+
function DesktopApp() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
return (
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/src/ui/desktop/apps/admin/AdminSettings.tsx b/src/ui/desktop/apps/admin/AdminSettings.tsx
index 878efdd8..0ef8ab53 100644
--- a/src/ui/desktop/apps/admin/AdminSettings.tsx
+++ b/src/ui/desktop/apps/admin/AdminSettings.tsx
@@ -61,6 +61,7 @@ export function AdminSettings({
name_path: "name",
scopes: "openid email profile",
userinfo_url: "",
+ allowed_users: "",
});
const [users, setUsers] = React.useState<
diff --git a/src/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx b/src/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx
index 1ca5c436..5c38955f 100644
--- a/src/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx
+++ b/src/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx
@@ -31,15 +31,22 @@ export function DatabaseSecurityTab({
[currentUser?.is_oidc],
);
- const handleExportDatabase = async () => {
- if (!showPasswordInput) {
- setShowPasswordInput(true);
- return;
- }
+ const requiresExportPassword = React.useMemo(
+ () => !currentUser?.is_oidc,
+ [currentUser?.is_oidc],
+ );
- if (!exportPassword.trim()) {
- toast.error(t("admin.passwordRequired"));
- return;
+ const handleExportDatabase = async () => {
+ if (requiresExportPassword) {
+ if (!showPasswordInput) {
+ setShowPasswordInput(true);
+ return;
+ }
+
+ if (!exportPassword.trim()) {
+ toast.error(t("admin.passwordRequired"));
+ return;
+ }
}
setExportLoading(true);
@@ -65,7 +72,9 @@ export function DatabaseSecurityTab({
"Content-Type": "application/json",
},
credentials: "include",
- body: JSON.stringify({ password: exportPassword }),
+ body: JSON.stringify(
+ requiresExportPassword ? { password: exportPassword } : {},
+ ),
});
if (response.ok) {
@@ -211,7 +220,7 @@ export function DatabaseSecurityTab({