feat: move vault ssh signing into a plugin (C5)

This commit is contained in:
LukeGus committed 2026-09-24 20:24:09 -05:00
1 parent b027717c46
commit 6e56c90aac
198 files changed
+19717 -3445

No files matched your search

@@ -584,7 +584,6 @@ export async function activate(ctx: PluginContext) {
let resolvedSocks5Username = socks5Username;
let resolvedSocks5Password = socks5Password;
let resolvedSocks5ProxyChain = socks5ProxyChain;
let resolvedVaultProfileId: number | null = null;
const resolveFrom = async () => {
const resolvedHost = hostSyncId
@@ -607,8 +606,6 @@ export async function activate(ctx: PluginContext) {
resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as
Record<string, unknown> | undefined;
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
resolvedVaultProfileId =
(resolvedHost.vaultProfile as { id?: number } | undefined)?.id ?? null;
if (resolvedHost.useSocks5) {
resolvedUseSocks5 = resolvedHost.useSocks5;
resolvedSocks5Host = resolvedHost.socks5Host;
@@ -717,9 +714,6 @@ export async function activate(ctx: PluginContext) {
keyPassword: resolvedCredentials.keyPassword,
certPublicKey: resolvedCredentials.certPublicKey,
terminalConfig: resolvedTerminalConfig ?? null,
vaultProfile: resolvedVaultProfileId
? { id: resolvedVaultProfileId }
: null,
jumpHosts: resolvedJumpHosts,
useSocks5: resolvedUseSocks5,
socks5Host: resolvedSocks5Host,
-4
View File
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} window",
"tmuxWindows": "Windows",
"unknownError": "Unknown error occurred",
"vaultAuthDescription": "A window has opened to sign in to HashiCorp Vault. Complete the sign-in there; this connection will continue automatically.",
"vaultAuthFailed": "Vault authentication failed. Please try again.",
"vaultAuthTitle": "Vault sign-in required",
"vaultReopen": "Reopen sign-in window",
"websocketAbnormalClose": "Connection closed unexpectedly. This may be due to a reverse proxy or SSL configuration issue. Please check server logs.",
"websocketError": "WebSocket connection error"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} venster",
"tmuxWindows": "Vensters",
"unknownError": "Onbekende fout het voorgekom",
"vaultAuthDescription": "'n Venster het oopgemaak om by HashiCorp Vault aan te meld. Voltooi die aanmelding daar; hierdie verbinding sal outomaties voortgaan.",
"vaultAuthFailed": "Kluisverifikasie het misluk. Probeer asseblief weer.",
"vaultAuthTitle": "Kluisaanmelding vereis",
"vaultReopen": "Maak aanmeldvenster weer oop",
"websocketAbnormalClose": "Verbinding het onverwags gesluit. Dit kan wees as gevolg van 'n omgekeerde proxy- of SSL-konfigurasieprobleem. Gaan asseblief bedienerlogboeke na.",
"websocketError": "WebSocket-verbindingsfout"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} نافذة",
"tmuxWindows": "النوافذ",
"unknownError": "حدث خطأ غير معروف",
"vaultAuthDescription": "تم فتح نافذة لتسجيل الدخول إلى HashiCorp Vault. أكمل تسجيل الدخول هناك؛ سيستمر هذا الاتصال تلقائيًا.",
"vaultAuthFailed": "فشلت مصادقة Vault. يرجى المحاولة مرة أخرى.",
"vaultAuthTitle": "مطلوب تسجيل الدخول إلى Vault",
"vaultReopen": "أعد فتح نافذة تسجيل الدخول",
"websocketAbnormalClose": "أُغلق الاتصال بشكل غير متوقع. قد يكون السبب مشكلة في الوكيل العكسي أو إعدادات SSL. يرجى مراجعة سجلات الخادم.",
"websocketError": "خطأ في اتصال WebSocket"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} прозорец",
"tmuxWindows": "Прозорци",
"unknownError": "Възникна неизвестна грешка",
"vaultAuthDescription": "Отвори се прозорец за влизане в HashiCorp Vault. Завършете влизането там; тази връзка ще продължи автоматично.",
"vaultAuthFailed": "Удостоверяването на трезора не бе успешно. Моля, опитайте отново.",
"vaultAuthTitle": "Изисква се влизане в трезора",
"vaultReopen": "Отваряне отново на прозореца за влизане",
"websocketAbnormalClose": "Връзката се прекъсна неочаквано. Това може да се дължи на проблем с обратен прокси или SSL конфигурация. Моля, проверете лог файловете на сървъра.",
"websocketError": "Грешка при свързване с WebSocket"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} জানালা",
"tmuxWindows": "উইন্ডোজ",
"unknownError": "অজানা ত্রুটি ঘটেছে",
"vaultAuthDescription": "হ্যাশিকর্প ভল্টে সাইন ইন করার জন্য একটি উইন্ডো খুলেছে। সেখানে সাইন-ইন সম্পন্ন করুন; এই সংযোগটি স্বয়ংক্রিয়ভাবে চলতে থাকবে।",
"vaultAuthFailed": "ভল্ট প্রমাণীকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে আবার চেষ্টা করুন।",
"vaultAuthTitle": "ভল্টে সাইন-ইন করা আবশ্যক",
"vaultReopen": "সাইন-ইন উইন্ডো পুনরায় খুলুন",
"websocketAbnormalClose": "সংযোগটি অপ্রত্যাশিতভাবে বন্ধ হয়ে গেছে। এটি রিভার্স প্রক্সি বা SSL কনফিগারেশন সংক্রান্ত সমস্যার কারণে হতে পারে। অনুগ্রহ করে সার্ভার লগ পরীক্ষা করুন।",
"websocketError": "ওয়েবসকেট সংযোগ ত্রুটি"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "finestra {{count}}",
"tmuxWindows": "Finestres",
"unknownError": "S'ha produït un error desconegut",
"vaultAuthDescription": "S'ha obert una finestra per iniciar la sessió a HashiCorp Vault. Completeu l'inici de sessió allà; aquesta connexió continuarà automàticament.",
"vaultAuthFailed": "L'autenticació de la caixa forta ha fallat. Torna-ho a provar.",
"vaultAuthTitle": "Cal iniciar sessió a la caixa forta",
"vaultReopen": "Torna a obrir la finestra d'inici de sessió",
"websocketAbnormalClose": "La connexió s'ha tancat inesperadament. Això pot ser degut a un problema de configuració del proxy invers o de l'SSL. Si us plau, reviseu els registres del servidor.",
"websocketError": "Error de connexió de WebSocket"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} okno",
"tmuxWindows": "Okna",
"unknownError": "Došlo k neznámé chybě",
"vaultAuthDescription": "Otevřelo se okno pro přihlášení do HashiCorp Vault. Dokončete přihlášení tam; toto připojení bude automaticky pokračovat.",
"vaultAuthFailed": "Ověření v trezoru se nezdařilo. Zkuste to prosím znovu.",
"vaultAuthTitle": "Vyžaduje se přihlášení do trezoru",
"vaultReopen": "Znovu otevřít přihlašovací okno",
"websocketAbnormalClose": "Spojení se neočekávaně ukončilo. Může to být způsobeno problémem s reverzní proxy nebo konfigurací SSL. Zkontrolujte prosím protokoly serveru.",
"websocketError": "Chyba připojení k WebSocketu"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} vindue",
"tmuxWindows": "Vinduer",
"unknownError": "Der opstod en ukendt fejl",
"vaultAuthDescription": "Et vindue er åbnet, hvor du kan logge ind på HashiCorp Vault. Fuldfør loginprocessen der; forbindelsen fortsætter automatisk.",
"vaultAuthFailed": "Godkendelse af arkiv mislykkedes. Prøv igen.",
"vaultAuthTitle": "Login til Vault kræves",
"vaultReopen": "Genåbn loginvinduet",
"websocketAbnormalClose": "Forbindelsen blev lukket uventet. Dette kan skyldes et problem med reverse proxy eller SSL-konfigurationen. Tjek venligst serverlogfilerne.",
"websocketError": "WebSocket-forbindelsesfejl"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} Fenster",
"tmuxWindows": "Fenster",
"unknownError": "Unbekannter Fehler aufgetreten",
"vaultAuthDescription": "Ein Fenster zur Anmeldung bei HashiCorp Vault wurde geöffnet. Schließen Sie die Anmeldung dort ab; diese Verbindung wird automatisch fortgesetzt.",
"vaultAuthFailed": "Vault-Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"vaultAuthTitle": "Vault-Anmeldung erforderlich",
"vaultReopen": "Anmeldefenster erneut öffnen",
"websocketAbnormalClose": "Verbindung unerwartet geschlossen. Dies kann auf ein Problem mit dem Reverse-Proxy oder der SSL-Konfiguration zurückzuführen sein. Bitte überprüfen Sie die Server-Logs.",
"websocketError": "WebSocket-Verbindungsfehler"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "παράθυρο {{count}}",
"tmuxWindows": "Παράθυρα",
"unknownError": "Παρουσιάστηκε άγνωστο σφάλμα",
"vaultAuthDescription": "Έχει ανοίξει ένα παράθυρο για να συνδεθείτε στο HashiCorp Vault. Ολοκληρώστε τη σύνδεση εκεί. Αυτή η σύνδεση θα συνεχιστεί αυτόματα.",
"vaultAuthFailed": "Ο έλεγχος ταυτότητας του vault απέτυχε. Δοκιμάστε ξανά.",
"vaultAuthTitle": "Απαιτείται σύνδεση στο Vault",
"vaultReopen": "Επανάνοιγμα παραθύρου σύνδεσης",
"websocketAbnormalClose": "Η σύνδεση έκλεισε απροσδόκητα. Αυτό μπορεί να οφείλεται σε πρόβλημα αντίστροφης μεσολάβησης ή ρύθμισης παραμέτρων SSL. Ελέγξτε τα αρχεία καταγραφής διακομιστή.",
"websocketError": "Σφάλμα σύνδεσης WebSocket"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} ventana",
"tmuxWindows": "Ventanas",
"unknownError": "Se ha producido un error desconocido.",
"vaultAuthDescription": "Se ha abierto una ventana para iniciar sesión en HashiCorp Vault. Completa el inicio de sesión allí; esta conexión continuará automáticamente.",
"vaultAuthFailed": "La autenticación de Vault falló. Inténtalo de nuevo.",
"vaultAuthTitle": "Inicio de sesión en Vault requerido",
"vaultReopen": "Reabrir ventana de inicio de sesión",
"websocketAbnormalClose": "La conexión se cerró de forma inesperada. Esto puede deberse a un problema con el proxy inverso o la configuración SSL. Revisa los registros del servidor.",
"websocketError": "Error de conexión WebSocket"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} ikkuna",
"tmuxWindows": "Ikkunat",
"unknownError": "Tuntematon virhe tapahtui",
"vaultAuthDescription": "Ikkuna HashiCorp-holviin kirjautumista varten on avautunut. Kirjaudu sisään siinä. Yhteys jatkuu automaattisesti.",
"vaultAuthFailed": "Holvin todennus epäonnistui. Yritä uudelleen.",
"vaultAuthTitle": "Holviin kirjautuminen vaaditaan",
"vaultReopen": "Avaa kirjautumisikkuna uudelleen",
"websocketAbnormalClose": "Yhteys katkesi odottamatta. Tämä voi johtua käänteisestä välityspalvelimesta tai SSL-määritysongelmasta. Tarkista palvelimen lokit.",
"websocketError": "WebSocket-yhteysvirhe"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} fenêtre(s)",
"tmuxWindows": "Fenêtres",
"unknownError": "Une erreur inconnue s'est produite",
"vaultAuthDescription": "Une fenêtre s'est ouverte pour vous connecter à HashiCorp Vault. Terminez la connexion dans cette fenêtre ; cette connexion continuera automatiquement.",
"vaultAuthFailed": "Échec de l'authentification Vault. Veuillez réessayer.",
"vaultAuthTitle": "Connexion à Vault requise",
"vaultReopen": "Rouvrir la fenêtre de connexion",
"websocketAbnormalClose": "Connexion fermée de manière inattendue. Cela peut être dû à un problème de proxy inverse ou de configuration SSL. Veuillez vérifier les journaux du serveur.",
"websocketError": "Erreur de connexion WebSocket"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "חלון {{count}}",
"tmuxWindows": "חלונות",
"unknownError": "אירעה שגיאה לא ידועה",
"vaultAuthDescription": "נפתח חלון לכניסה לכספת HashiCorp. השלם את תהליך הכניסה שם; חיבור זה ימשיך אוטומטית.",
"vaultAuthFailed": "אימות הכספת נכשל. אנא נסה שוב.",
"vaultAuthTitle": "נדרשת כניסה לכספת",
"vaultReopen": "פתח מחדש את חלון הכניסה",
"websocketAbnormalClose": "החיבור נסגר באופן בלתי צפוי. ייתכן שהדבר נובע מבעיית פרוקסי הפוך או מבעיית תצורת SSL. אנא בדוק את יומני השרת.",
"websocketError": "שגיאת חיבור WebSocket"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} विंडो",
"tmuxWindows": "विंडो",
"unknownError": "अज्ञात त्रुटि हुई",
"vaultAuthDescription": "HashiCorp Vault में साइन इन करने के लिए एक विंडो खुल गई है। वहाँ साइन-इन पूरा करें; यह कनेक्शन स्वचालित रूप से जारी रहेगा।",
"vaultAuthFailed": "Vault प्रमाणीकरण विफल। कृपया पुनः प्रयास करें।",
"vaultAuthTitle": "Vault साइन-इन आवश्यक है",
"vaultReopen": "साइन-इन विंडो फिर से खोलें",
"websocketAbnormalClose": "कनेक्शन अप्रत्याशित रूप से बंद हो गया। यह रिवर्स प्रॉक्सी या SSL कॉन्फ़िगरेशन समस्या के कारण हो सकता है। कृपया सर्वर लॉग जाँचें।",
"websocketError": "WebSocket कनेक्शन त्रुटि"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} ablak",
"tmuxWindows": "Ablakok",
"unknownError": "Ismeretlen hiba történt",
"vaultAuthDescription": "Megnyílt egy ablak a HashiCorp Vaultba való bejelentkezéshez. Fejezze be a bejelentkezést ott; a kapcsolat automatikusan folytatódik.",
"vaultAuthFailed": "A trezor hitelesítése sikertelen. Próbálja újra.",
"vaultAuthTitle": "Bejelentkezés szükséges a széfbe",
"vaultReopen": "Bejelentkezési ablak újranyitása",
"websocketAbnormalClose": "A kapcsolat váratlanul megszakadt. Ennek oka lehet fordított proxy vagy SSL konfigurációs probléma. Kérjük, ellenőrizze a szervernaplókat.",
"websocketError": "WebSocket csatlakozási hiba"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} jendela",
"tmuxWindows": "Windows",
"unknownError": "Terjadi kesalahan yang tidak diketahui.",
"vaultAuthDescription": "Jendela telah terbuka untuk masuk ke HashiCorp Vault. Selesaikan proses masuk di sana; koneksi ini akan berlanjut secara otomatis.",
"vaultAuthFailed": "Autentikasi Vault gagal. Silakan coba lagi.",
"vaultAuthTitle": "Login ke Vault diperlukan.",
"vaultReopen": "Buka kembali jendela masuk",
"websocketAbnormalClose": "Koneksi terputus secara tidak terduga. Ini mungkin disebabkan oleh masalah konfigurasi reverse proxy atau SSL. Silakan periksa log server.",
"websocketError": "Kesalahan koneksi WebSocket"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} finestra",
"tmuxWindows": "Finestre",
"unknownError": "Si è verificato un errore sconosciuto",
"vaultAuthDescription": "Una finestra si è aperta per accedere a HashiCorp Vault. Completa l'accesso lì; la connessione continuerà automaticamente.",
"vaultAuthFailed": "Autenticazione Vault fallita. Riprova.",
"vaultAuthTitle": "Accesso a Vault richiesto",
"vaultReopen": "Riapri la finestra di accesso",
"websocketAbnormalClose": "La connessione si è chiusa in modo imprevisto. Potrebbe trattarsi di un problema di reverse proxy o configurazione SSL. Controlla i log del server.",
"websocketError": "Errore di connessione WebSocket"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} ウィンドウ",
"tmuxWindows": "ウィンドウ",
"unknownError": "不明なエラーが発生しました",
"vaultAuthDescription": "HashiCorp Vaultへのサインイン用のウィンドウが開きました。そこでサインインを完了すると、この接続は自動的に続行されます。",
"vaultAuthFailed": "Vault 認証に失敗しました。もう一度お試しください。",
"vaultAuthTitle": "Vault サインインが必要です",
"vaultReopen": "サインインウィンドウを再表示",
"websocketAbnormalClose": "接続が予期せず切断されました。リバースプロキシまたはSSLの設定に問題がある可能性があります。サーバーログを確認してください。",
"websocketError": "WebSocket接続エラー"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}}개 윈도우",
"tmuxWindows": "윈도우",
"unknownError": "알 수 없는 오류가 발생했습니다.",
"vaultAuthDescription": "HashiCorp Vault 로그인을 위한 창이 열렸습니다. 해당 창에서 로그인을 완료하면 연결이 자동으로 계속됩니다.",
"vaultAuthFailed": "Vault 인증에 실패했습니다. 다시 시도하세요.",
"vaultAuthTitle": "Vault 로그인 필요",
"vaultReopen": "로그인 창 다시 열기",
"websocketAbnormalClose": "연결이 예기치 않게 종료되었습니다. 리버스 프록시 또는 SSL 구성 문제일 수 있습니다. 서버 로그를 확인하세요.",
"websocketError": "WebSocket 연결 오류"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} venster",
"tmuxWindows": "Windows",
"unknownError": "Er is een onbekende fout opgetreden.",
"vaultAuthDescription": "Er is een venster geopend om in te loggen bij HashiCorp Vault. Voltooi de aanmelding daar; deze verbinding wordt automatisch tot stand gebracht.",
"vaultAuthFailed": "Authenticatie voor de kluis is mislukt. Probeer het opnieuw.",
"vaultAuthTitle": "Aanmelden bij Vault is vereist.",
"vaultReopen": "Heropen het aanmeldvenster",
"websocketAbnormalClose": "De verbinding is onverwacht verbroken. Dit kan te wijten zijn aan een probleem met de reverse proxy of de SSL-configuratie. Controleer de serverlogboeken.",
"websocketError": "WebSocket-verbindingsfout"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} vindu",
"tmuxWindows": "Vinduer",
"unknownError": "Ukjent feil oppstod",
"vaultAuthDescription": "Et vindu har åpnet seg for å logge på HashiCorp Vault. Fullfør påloggingen der; denne tilkoblingen vil fortsette automatisk.",
"vaultAuthFailed": "Hvelv-autentisering mislyktes. Prøv på nytt.",
"vaultAuthTitle": "Pålogging til arkivet kreves",
"vaultReopen": "Åpne påloggingsvinduet på nytt",
"websocketAbnormalClose": "Tilkoblingen ble avsluttet uventet. Dette kan skyldes et problem med omvendt proxy eller SSL-konfigurasjon. Sjekk serverloggene.",
"websocketError": "WebSocket-tilkoblingsfeil"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} okno",
"tmuxWindows": "Okna",
"unknownError": "Wystąpił nieznany błąd",
"vaultAuthDescription": "Otworzyło się okno logowania do HashiCorp Vault. Dokończ logowanie, a połączenie zostanie nawiązane automatycznie.",
"vaultAuthFailed": "Uwierzytelnienie w Vault nie powiodło się. Spróbuj ponownie.",
"vaultAuthTitle": "Wymagane logowanie do Vault",
"vaultReopen": "Otwórz ponownie okno logowania",
"websocketAbnormalClose": "Połączenie zostało nieoczekiwanie zamknięte. Przyczyną może być reverse proxy lub problem z konfiguracją SSL. Sprawdź logi serwera.",
"websocketError": "Błąd połączenia WebSocket"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} janela",
"tmuxWindows": "Windows",
"unknownError": "Ocorreu um erro desconhecido.",
"vaultAuthDescription": "Uma janela foi aberta para fazer login no HashiCorp Vault. Conclua o login; esta conexão será estabelecida automaticamente.",
"vaultAuthFailed": "A autenticação do cofre falhou. Tente novamente.",
"vaultAuthTitle": "É necessário fazer login no cofre.",
"vaultReopen": "Reabrir janela de login",
"websocketAbnormalClose": "A conexão foi encerrada inesperadamente. Isso pode ter ocorrido devido a um problema de configuração de proxy reverso ou SSL. Verifique os registros do servidor.",
"websocketError": "Erro de conexão WebSocket"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} janela",
"tmuxWindows": "Janelas",
"unknownError": "Ocorreu um erro desconhecido.",
"vaultAuthDescription": "Foi aberta uma janela para iniciar sessão no HashiCorp Vault. Conclua o início de sessão aí; esta ligação continuará automaticamente.",
"vaultAuthFailed": "A autenticação no Vault falhou. Tente novamente.",
"vaultAuthTitle": "Início de sessão no Vault necessário",
"vaultReopen": "Reabrir janela de início de sessão",
"websocketAbnormalClose": "A ligação foi fechada inesperadamente. Isto pode dever-se a um problema de configuração do proxy reverso ou SSL. Verifique os registos do servidor.",
"websocketError": "Erro de ligação WebSocket"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "fereastră {{count}}",
"tmuxWindows": "Ferestre",
"unknownError": "A apărut o eroare necunoscută",
"vaultAuthDescription": "S-a deschis o fereastră pentru a vă conecta la HashiCorp Vault. Finalizați conectarea acolo; această conexiune va continua automat.",
"vaultAuthFailed": "Autentificarea în seif a eșuat. Vă rugăm să încercați din nou.",
"vaultAuthTitle": "Este necesară conectarea la seif",
"vaultReopen": "Redeschideți fereastra de conectare",
"websocketAbnormalClose": "Conexiunea s-a închis pe neașteptate. Acest lucru se poate datora unei probleme de configurare a proxy-ului invers sau a SSL. Vă rugăm să verificați jurnalele serverului.",
"websocketError": "Eroare de conexiune WebSocket"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} окно",
"tmuxWindows": "Окна",
"unknownError": "Произошла неизвестная ошибка",
"vaultAuthDescription": "Открыто окно для входа в HashiCorp Vault. Завершите вход там; подключение продолжится автоматически.",
"vaultAuthFailed": "Аутентификация Vault не удалась. Попробуйте снова.",
"vaultAuthTitle": "Требуется вход в Vault",
"vaultReopen": "Повторно открыть окно входа",
"websocketAbnormalClose": "Соединение неожиданно закрыто. Возможно, проблема с обратным прокси или конфигурацией SSL. Проверьте журналы сервера.",
"websocketError": "Ошибка подключения WebSocket"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} прозор",
"tmuxWindows": "Прозори",
"unknownError": "Дошло је до непознате грешке",
"vaultAuthDescription": "Отворио се прозор за пријављивање на HashiCorp Vault. Завршите пријављивање тамо; ова веза ће се аутоматски наставити.",
"vaultAuthFailed": "Аутентификација трезора није успела. Молимо покушајте поново.",
"vaultAuthTitle": "Потребно је пријављивање у трезор",
"vaultReopen": "Поново отвори прозор за пријављивање",
"websocketAbnormalClose": "Веза је неочекивано прекинута. Ово може бити због проблема са обрнутим проксијем или SSL конфигурацијом. Молимо вас да проверите логове сервера.",
"websocketError": "Грешка у повезивању са WebSocket-ом"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} fönster",
"tmuxWindows": "Fönster",
"unknownError": "Okänt fel inträffade",
"vaultAuthDescription": "Ett fönster har öppnats för att logga in på HashiCorp Vault. Slutför inloggningen där; anslutningen fortsätter automatiskt.",
"vaultAuthFailed": "Valvautentisering misslyckades. Försök igen.",
"vaultAuthTitle": "Inloggning till arkivet krävs",
"vaultReopen": "Öppna inloggningsfönstret igen",
"websocketAbnormalClose": "Anslutningen stängdes oväntat. Detta kan bero på ett problem med omvänd proxy eller SSL-konfiguration. Kontrollera serverloggarna.",
"websocketError": "WebSocket-anslutningsfel"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} หน้าต่าง",
"tmuxWindows": "วินโดวส์",
"unknownError": "เกิดข้อผิดพลาดที่ไม่ทราบสาเหตุ",
"vaultAuthDescription": "หน้าต่างสำหรับเข้าสู่ระบบ HashiCorp Vault ได้เปิดขึ้นแล้ว โปรดทำการเข้าสู่ระบบให้เสร็จสมบูรณ์ การเชื่อมต่อนี้จะดำเนินการต่อโดยอัตโนมัติ",
"vaultAuthFailed": "การตรวจสอบสิทธิ์ Vault ล้มเหลว โปรดลองอีกครั้ง",
"vaultAuthTitle": "ต้องลงชื่อเข้าใช้ Vault",
"vaultReopen": "เปิดหน้าต่างเข้าสู่ระบบอีกครั้ง",
"websocketAbnormalClose": "การเชื่อมต่อถูกตัดโดยไม่คาดคิด อาจเกิดจากปัญหาเกี่ยวกับรีเวิร์สพร็อกซีหรือการกำหนดค่า SSL โปรดตรวจสอบบันทึกของเซิร์ฟเวอร์",
"websocketError": "ข้อผิดพลาดในการเชื่อมต่อ WebSocket"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} pencere",
"tmuxWindows": "Pencereler",
"unknownError": "Bilinmeyen bir hata oluştu",
"vaultAuthDescription": "HashiCorp Vault'ta oturum açmak için bir pencere açıldı. Orada oturum açmayı tamamlayın; bu bağlantı otomatik olarak devam edecek.",
"vaultAuthFailed": "Vault kimlik doğrulaması başarısız oldu. Lütfen tekrar deneyin.",
"vaultAuthTitle": "Vault Oturum Açması Gerekli",
"vaultReopen": "Oturum açma penceresini yeniden aç",
"websocketAbnormalClose": "Bağlantı beklenmedik şekilde kapandı. Bu, ters proxy veya SSL yapılandırma sorunundan kaynaklanabilir. Lütfen sunucu günlüklerini kontrol edin.",
"websocketError": "WebSocket bağlantı hatası"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "вікно {{count}}",
"tmuxWindows": "Вікна",
"unknownError": "Сталася невідома помилка",
"vaultAuthDescription": "Відкрилося вікно для входу в HashiCorp Vault. Завершіть вхід там; це з’єднання буде продовжено автоматично.",
"vaultAuthFailed": "Не вдалося виконати автентифікацію сховища. Спробуйте ще раз.",
"vaultAuthTitle": "Потрібен вхід до Сейфа",
"vaultReopen": "Знову відкрити вікно входу",
"websocketAbnormalClose": "З’єднання несподівано завершилося. Можливо, це пов’язано зі зворотним проксі-сервером або проблемою конфігурації SSL. Перевірте журнали сервера.",
"websocketError": "Помилка підключення WebSocket"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} cửa sổ",
"tmuxWindows": "Cửa sổ",
"unknownError": "Lỗi không xác định đã xảy ra",
"vaultAuthDescription": "Một cửa sổ đã mở để đăng nhập vào HashiCorp Vault. Hoàn tất đăng nhập ở đó; kết nối này sẽ tự động tiếp tục.",
"vaultAuthFailed": "Xác thực Vault thất bại. Vui lòng thử lại.",
"vaultAuthTitle": "Yêu cầu đăng nhập Vault",
"vaultReopen": "Mở lại cửa sổ đăng nhập",
"websocketAbnormalClose": "Kết nối đóng đột ngột. Có thể do sự cố cấu hình reverse proxy hoặc SSL. Vui lòng kiểm tra nhật ký máy chủ.",
"websocketError": "Lỗi kết nối WebSocket"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} 个窗口",
"tmuxWindows": "窗口",
"unknownError": "发生未知错误",
"vaultAuthDescription": "已打开窗口以登录 HashiCorp Vault。在该处完成登录;此连接将自动继续。",
"vaultAuthFailed": "Vault 认证失败,请重试。",
"vaultAuthTitle": "需要 Vault 登录",
"vaultReopen": "重新打开登录窗口",
"websocketAbnormalClose": "连接意外关闭。这可能是因为反向代理或SSL配置问题。请检查服务器日志。",
"websocketError": "WebSocket 连接错误"
},
@@ -74,10 +74,6 @@
"tmuxWindowCount": "{{count}} 個視窗",
"tmuxWindows": "視窗",
"unknownError": "發生未知錯誤",
"vaultAuthDescription": "已開啟視窗以登入 HashiCorp Vault。在該處完成登入;此連線將自動繼續。",
"vaultAuthFailed": "Vault 驗證失敗。請再試一次。",
"vaultAuthTitle": "需要 Vault 登入",
"vaultReopen": "重新開啟登入視窗",
"websocketAbnormalClose": "連線意外關閉。這可能是由於反向代理或 SSL 設定問題所導致。請檢查伺服器記錄。",
"websocketError": "WebSocket 連線錯誤"
},
@@ -1141,7 +1141,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
/**
* Starts the browser sign-in behind an "<interaction>_auth_required"
* message. The provider for the host's auth type wins; otherwise the first
* provider that owns this interaction (Vault attaches to any auth type).
* provider that owns this interaction.
*/
async function startAuthInteraction(
interaction: string,
@@ -342,15 +342,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const currentHostIdRef = useRef<number | null>(null);
const currentHostConfigRef = useRef<TerminalHostConfig | null>(null);
// Vault SSH signer interactive OIDC flow
const [vaultDialog, setVaultDialog] = useState<{
stage: "waiting" | "error";
error?: string;
} | null>(null);
const vaultFailedRef = useRef(false);
const vaultPopupRef = useRef<Window | null>(null);
const vaultTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [hostKeyVerification, setHostKeyVerification] = useState<{
isOpen: boolean;
scenario: "new" | "changed";
@@ -1909,7 +1900,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
updateConnectionError(errorMessage);
setIsConnecting(false);
} else if (msg.type === "connected") {
vaultFailedRef.current = false;
wasConnectedRef.current = true;
setIsConnected(true);
setIsConnecting(false);
@@ -2104,86 +2094,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
webSocketRef.current.close();
}
}, 300000);
} else if (msg.type === "vault_auth_required") {
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
if (vaultFailedRef.current) {
setVaultDialog({
stage: "error",
error: t("terminal.vaultAuthFailed"),
});
updateConnectionError(t("terminal.vaultAuthFailed"));
addLog({
type: "error",
stage: "auth",
message: t("terminal.vaultAuthFailed"),
});
} else {
vaultFailedRef.current = true;
webSocketRef.current?.send(
JSON.stringify({
type: "vault_start_auth",
data: { hostId: msg.hostId },
}),
);
}
} else if (msg.type === "vault_auth_url") {
if (connectionErrorRef.current) return;
try {
vaultPopupRef.current = window.open(
msg.url,
"termix-vault-oidc",
"width=540,height=720",
);
} catch {
vaultPopupRef.current = null;
}
setVaultDialog({ stage: "waiting" });
if (vaultTimeoutRef.current) clearTimeout(vaultTimeoutRef.current);
vaultTimeoutRef.current = setTimeout(() => {
setVaultDialog(null);
webSocketRef.current?.close();
}, 300000);
} else if (msg.type === "vault_completed") {
if (vaultTimeoutRef.current) {
clearTimeout(vaultTimeoutRef.current);
vaultTimeoutRef.current = null;
}
try {
vaultPopupRef.current?.close();
} catch {
// popup may already be closed
}
setVaultDialog(null);
if (webSocketRef.current && terminal) {
webSocketRef.current.send(
JSON.stringify({
type: "vault_auth_completed",
data: {
hostId: currentHostIdRef.current,
cols: terminal.cols || 80,
rows: terminal.rows || 24,
hostConfig: currentHostConfigRef.current,
},
}),
);
}
} else if (msg.type === "vault_error") {
if (connectionErrorRef.current) return;
vaultFailedRef.current = true;
if (vaultTimeoutRef.current) {
clearTimeout(vaultTimeoutRef.current);
vaultTimeoutRef.current = null;
}
try {
vaultPopupRef.current?.close();
} catch {
// popup may already be closed
}
setVaultDialog({ stage: "error", error: msg.error });
setIsConnecting(false);
} else if (msg.type === "keyboard_interactive_available") {
setKeyboardInteractiveDetected(true);
setIsConnecting(false);
@@ -2240,7 +2150,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
} else if (msg.type === "sessionAttached") {
isAttachingSessionRef.current = false;
vaultFailedRef.current = false;
wasConnectedRef.current = true;
setIsConnected(true);
setIsConnecting(false);
@@ -3884,70 +3793,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
props={overlayProps as unknown as Record<string, unknown>}
/>
{vaultDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="w-[420px] max-w-[90%] border border-border bg-background p-5 shadow-lg">
<h3 className="text-sm font-bold mb-2 text-foreground">
{vaultDialog.stage === "error"
? t("terminal.vaultAuthFailed")
: t("terminal.vaultAuthTitle")}
</h3>
{vaultDialog.stage === "error" ? (
<p className="text-xs text-destructive mb-4 break-words">
{vaultDialog.error || t("terminal.vaultAuthFailed")}
</p>
) : (
<p className="text-xs text-muted-foreground mb-4">
{t("terminal.vaultAuthDescription")}
</p>
)}
<div className="flex justify-end gap-2">
{vaultDialog.stage === "waiting" && (
<Button
variant="outline"
size="sm"
onClick={() => {
try {
vaultPopupRef.current?.focus();
} catch {
// popup may be gone
}
}}
>
{t("terminal.vaultReopen")}
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => {
if (vaultTimeoutRef.current) {
clearTimeout(vaultTimeoutRef.current);
vaultTimeoutRef.current = null;
}
try {
vaultPopupRef.current?.close();
} catch {
// popup may already be closed
}
webSocketRef.current?.send(
JSON.stringify({
type: "vault_cancel",
data: { hostId: currentHostIdRef.current },
}),
);
setVaultDialog(null);
}}
>
{vaultDialog.stage === "error"
? t("common.close")
: t("hosts.cancelBtn")}
</Button>
</div>
</div>
</div>
)}
{hostKeyVerification?.isOpen && (
<HostKeyVerificationDialog
isOpen={true}
+17
View File
@@ -0,0 +1,17 @@
# Changelog
## 1.0.0
Bundled with Termix 2.9.0.
- Vault SSH signing moved out of core. The callback is now
`/plugin-api/vault/oidc/callback`; the 2.8 URI `/vault/oidc/callback`
still works and upgraded installs keep sending it until an admin turns
off "Use the old redirect URI".
- Profiles keep their ids and sync ids. A host's profile is now a host
setting of this plugin.
- Signed certificates are stored encrypted with the installation key.
Certificates cached by 2.8 are not carried over; the next connection asks
for one sign-in.
- Sharing a profile needs the "Share Vault profiles" permission, which
admins have by default.
+29
View File
@@ -0,0 +1,29 @@
# HashiCorp Vault
Adds the `vault` SSH auth type: Termix signs you in to Vault with OIDC in the
browser, has Vault's SSH secrets engine sign a fresh key, and keeps the
short-lived certificate until it expires.
Termix talks to Vault's HTTP API directly (`auth/<mount>/oidc/auth_url`,
`auth/<mount>/oidc/callback`, `<ssh mount>/sign/<role>`). No Vault token,
AppRole secret or long-lived key is ever stored.
## Profiles
A profile holds the connection settings only: the Vault address and
namespace, the OIDC mount and role, the SSH secrets mount and signer role,
the valid principals and the key type. Pick one in the host editor after
choosing "Vault" as the authentication type, and manage profiles from the
same place. Users with the "Share Vault profiles" permission can share a
profile with everyone.
## Settings
Admin settings, under Settings > Plugins > HashiCorp Vault:
- **Redirect URI**: add it to `allowed_redirect_uris` in each Vault OIDC
role: `<base URL>/plugin-api/vault/oidc/callback`, or
`<base URL>/vault/oidc/callback` while "Use the old redirect URI" is on.
An install upgraded from 2.8 that used Vault keeps the old redirect URI
turned on.
+69
View File
@@ -0,0 +1,69 @@
{
"editor": {
"profile": "Vault Signer Profile",
"selectProfile": "Select a Vault profile...",
"hint": "Settings come from the shared profile; you'll sign in to Vault via OIDC when you connect. No secrets are stored.",
"docs": "View docs",
"sharedProfile": "{{name}} (shared)"
},
"profiles": {
"new": "New profile",
"manage": "Manage Vault profiles",
"name": "Friendly Name",
"vaultAddr": "Vault Address",
"namespace": "Namespace",
"oidcMount": "OIDC Auth Mount",
"oidcRole": "OIDC Role",
"sshMount": "SSH Secrets Mount",
"sshRole": "SSH Signer Role",
"validPrincipals": "Valid Principals",
"keyType": "Ephemeral Key Type",
"shareWithAll": "Share with all users",
"create": "Create profile",
"created": "Vault profile created",
"saved": "Vault profile saved",
"deleted": "Vault profile deleted",
"saveFailed": "Failed to save Vault profile",
"deleteFailed": "Failed to delete Vault profile",
"save": "Save profile",
"validationError": "Name, Vault address and SSH signer role are required",
"none": "No Vault profiles yet.",
"sharedBadge": "shared",
"edit": "Edit",
"delete": "Delete"
},
"hosts": {
"filterAuthVault": "Vault"
},
"dialog": {
"title": "Vault sign-in required",
"description": "A window has opened to sign in to HashiCorp Vault. Complete the sign-in there; this connection will continue automatically.",
"failed": "Vault authentication failed. Please try again.",
"reopen": "Reopen sign-in window"
},
"permissions": {
"use": {
"title": "Use Vault SSH signing",
"description": "Create Vault signer profiles and connect to hosts that use Vault."
},
"share": {
"title": "Share Vault profiles",
"description": "Mark a Vault signer profile as shared so every user can pick it."
}
},
"settings": {
"redirectUri": {
"label": "Redirect URI",
"description": "Add this URL to allowed_redirect_uris in each Vault OIDC role that Termix signs in with."
},
"legacyCallback": {
"label": "Use the old redirect URI",
"description": "Keep sending /vault/oidc/callback, the redirect URI used before 2.9. Turn this off once your Vault roles allow the new one."
},
"host": {
"profileId": {
"label": "Vault signer profile"
}
}
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Kluis Ondertekenaar Profiel",
"selectProfile": "Kies 'n kluisprofiel...",
"hint": "Instellings kom van die gedeelde profiel; jy sal by Vault aanmeld via OIDC wanneer jy koppel. Geen geheime word gestoor nie.",
"docs": "Bekyk dokumente"
},
"profiles": {
"new": "Nuwe profiel",
"manage": "Bestuur Vault-profiele",
"name": "Vriendelike Naam",
"vaultAddr": "Kluisadres",
"namespace": "Naamruimte",
"oidcMount": "OIDC-magtigingsmontering",
"oidcRole": "OIDC-rol",
"sshMount": "SSH-geheime berg",
"sshRole": "SSH-ondertekenaarrol",
"validPrincipals": "Geldige Prinsipale",
"keyType": "Efemere Sleuteltipe",
"shareWithAll": "Deel met alle gebruikers",
"create": "Skep profiel",
"created": "Kluisprofiel geskep",
"saved": "Kluisprofiel gestoor",
"deleted": "Kluisprofiel uitgevee",
"saveFailed": "Kon nie kluisprofiel stoor nie",
"deleteFailed": "Kon nie kluisprofiel uitvee nie",
"save": "Stoor profiel",
"validationError": "Naam, kluisadres en SSH-ondertekenrol word vereis",
"none": "Geen Vault-profiele nog nie.",
"sharedBadge": "gedeel"
},
"dialog": {
"title": "Kluisaanmelding vereis",
"description": "'n Venster het oopgemaak om by HashiCorp Vault aan te meld. Voltooi die aanmelding daar; hierdie verbinding sal outomaties voortgaan.",
"failed": "Kluisverifikasie het misluk. Probeer asseblief weer.",
"reopen": "Maak aanmeldvenster weer oop"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "ملف توقيع Vault",
"selectProfile": "اختيار ملف توقيع Vault...",
"hint": "تأتي الإعدادات من الملف المشترك؛ ستقوم بتسجيل الدخول إلى Vault عبر OIDC عند الاتصال. لا يتم تخزين أي أسرار.",
"docs": "عرض المستندات"
},
"profiles": {
"new": "ملف جديد",
"manage": "إدارة ملفات Vault",
"name": "الاسم المألوف",
"vaultAddr": "عنوان Vault",
"namespace": "مساحة الاسم",
"oidcMount": "تثبيت مصادقة OIDC",
"oidcRole": "دور OIDC",
"sshMount": "تثبيت أسرار SSH",
"sshRole": "دور موقع SSH",
"validPrincipals": "المستخدمون المسموح بهم",
"keyType": "نوع المفتاح المؤقت",
"shareWithAll": "مشاركة مع جميع المستخدمين",
"create": "إنشاء ملف تعريف",
"created": "تم إنشاء ملف تعريف Vault",
"saved": "تم حفظ ملف تعريف Vault",
"deleted": "تم حذف ملف تعريف Vault",
"saveFailed": "فشل حفظ ملف تعريف Vault",
"deleteFailed": "فشل حذف ملف تعريف Vault",
"save": "حفظ ملف التعريف",
"validationError": "الاسم وعنوان Vault ودور الموقّع SSH مطلوب",
"none": "لا توجد ملفات تعريف Vault بعد.",
"sharedBadge": "مشترك"
},
"dialog": {
"title": "مطلوب تسجيل الدخول إلى Vault",
"description": "تم فتح نافذة لتسجيل الدخول إلى HashiCorp Vault. أكمل تسجيل الدخول هناك؛ سيستمر هذا الاتصال تلقائيًا.",
"failed": "فشلت مصادقة Vault. يرجى المحاولة مرة أخرى.",
"reopen": "أعد فتح نافذة تسجيل الدخول"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Профил на подписващ в трезора",
"selectProfile": "Изберете профил в трезора...",
"hint": "Настройките идват от споделения профил; ще влезете в Vault чрез OIDC, когато се свържете. Не се съхраняват тайни.",
"docs": "Преглед на документи"
},
"profiles": {
"new": "Нов профил",
"manage": "Управление на профили в хранилището",
"name": "Приятелско име",
"vaultAddr": "Адрес на трезора",
"namespace": "Именно пространство",
"oidcMount": "OIDC Authentication Mount",
"oidcRole": "Роля на OIDC",
"sshMount": "SSH Secrets Mount",
"sshRole": "Роля на SSH подписващ",
"validPrincipals": "Валидни принципали",
"keyType": "Тип ефимерен ключ",
"shareWithAll": "Споделяне с всички потребители",
"create": "Създаване на профил",
"created": "Профилът на трезора е създаден",
"saved": "Профилът на трезора е запазен",
"deleted": "Профилът в трезора е изтрит",
"saveFailed": "Запазването на профила в Трезора не бе успешно",
"deleteFailed": "Изтриването на профила в Vault не бе успешно",
"save": "Запазване на профил",
"validationError": "Име, адрес на трезора и роля на SSH подписващ са задължителни",
"none": "Все още няма профили в Трезора.",
"sharedBadge": "споделено"
},
"dialog": {
"title": "Изисква се влизане в трезора",
"description": "Отвори се прозорец за влизане в HashiCorp Vault. Завършете влизането там; тази връзка ще продължи автоматично.",
"failed": "Удостоверяването на трезора не бе успешно. Моля, опитайте отново.",
"reopen": "Отваряне отново на прозореца за влизане"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "ভল্ট স্বাক্ষরকারীর প্রোফাইল",
"selectProfile": "একটি ভল্ট প্রোফাইল নির্বাচন করুন...",
"hint": "সেটিংস শেয়ার করা প্রোফাইল থেকে আসে; সংযোগ করার সময় আপনি OIDC-এর মাধ্যমে ভল্টে সাইন ইন করবেন। কোনো গোপনীয় তথ্য সংরক্ষিত থাকে না।",
"docs": "নথি দেখুন"
},
"profiles": {
"new": "নতুন প্রোফাইল",
"manage": "ভল্ট প্রোফাইলগুলি পরিচালনা করুন",
"name": "বন্ধুত্বপূর্ণ নাম",
"vaultAddr": "ভল্ট ঠিকানা",
"namespace": "নেমস্পেস",
"oidcMount": "OIDC অথোরাইজেশন মাউন্ট",
"oidcRole": "OIDC ভূমিকা",
"sshMount": "SSH সিক্রেটস মাউন্ট",
"sshRole": "SSH স্বাক্ষরকারীর ভূমিকা",
"validPrincipals": "বৈধ অধ্যক্ষরা",
"keyType": "ক্ষণস্থায়ী কী টাইপ",
"shareWithAll": "সকল ব্যবহারকারীর সাথে শেয়ার করুন",
"create": "প্রোফাইল তৈরি করুন",
"created": "ভল্ট প্রোফাইল তৈরি করা হয়েছে",
"saved": "ভল্ট প্রোফাইল সংরক্ষিত হয়েছে",
"deleted": "ভল্ট প্রোফাইল মুছে ফেলা হয়েছে",
"saveFailed": "ভল্ট প্রোফাইল সংরক্ষণ করতে ব্যর্থ হয়েছে",
"deleteFailed": "ভল্ট প্রোফাইল মুছে ফেলা ব্যর্থ হয়েছে",
"save": "প্রোফাইল সংরক্ষণ করুন",
"validationError": "নাম, ভল্ট অ্যাড্রেস এবং SSH সাইনার রোল প্রয়োজন।",
"none": "এখনো কোনো ভল্ট প্রোফাইল তৈরি হয়নি।",
"sharedBadge": "শেয়ার করা"
},
"dialog": {
"title": "ভল্টে সাইন-ইন করা আবশ্যক",
"description": "হ্যাশিকর্প ভল্টে সাইন ইন করার জন্য একটি উইন্ডো খুলেছে। সেখানে সাইন-ইন সম্পন্ন করুন; এই সংযোগটি স্বয়ংক্রিয়ভাবে চলতে থাকবে।",
"failed": "ভল্ট প্রমাণীকরণ ব্যর্থ হয়েছে। অনুগ্রহ করে আবার চেষ্টা করুন।",
"reopen": "সাইন-ইন উইন্ডো পুনরায় খুলুন"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Perfil del signant de la caixa forta",
"selectProfile": "Seleccioneu un perfil de Vault...",
"hint": "La configuració prové del perfil compartit; iniciaràs la sessió a Vault mitjançant OIDC quan et connectis. No s'emmagatzemen secrets.",
"docs": "Veure documents"
},
"profiles": {
"new": "Nou perfil",
"manage": "Gestiona els perfils de Vault",
"name": "Nom amigable",
"vaultAddr": "Adreça de la caixa forta",
"namespace": "Espai de noms",
"oidcMount": "Muntatge d'autenticació OIDC",
"oidcRole": "Rol de l'OIDC",
"sshMount": "Muntanya de Secrets SSH",
"sshRole": "Rol de signant SSH",
"validPrincipals": "Principals vàlids",
"keyType": "Tipus de clau efímera",
"shareWithAll": "Comparteix amb tots els usuaris",
"create": "Crea un perfil",
"created": "Perfil de la caixa forta creat",
"saved": "Perfil de la caixa forta desat",
"deleted": "Perfil de la caixa forta suprimit",
"saveFailed": "No s'ha pogut desar el perfil de Vault",
"deleteFailed": "No s'ha pogut suprimir el perfil de Vault",
"save": "Desa el perfil",
"validationError": "El nom, l'adreça de la caixa forta i el rol de signant SSH són obligatoris.",
"none": "Encara no hi ha perfils de Vault.",
"sharedBadge": "compartit"
},
"dialog": {
"title": "Cal iniciar sessió a la caixa forta",
"description": "S'ha obert una finestra per iniciar la sessió a HashiCorp Vault. Completeu l'inici de sessió allà; aquesta connexió continuarà automàticament.",
"failed": "L'autenticació de la caixa forta ha fallat. Torna-ho a provar.",
"reopen": "Torna a obrir la finestra d'inici de sessió"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Profil podepisujícího trezoru",
"selectProfile": "Vyberte profil úložiště...",
"hint": "Nastavení pocházejí ze sdíleného profilu; po připojení se do úložiště přihlásíte přes OIDC. Žádné tajné kódy se neukládají.",
"docs": "Zobrazit dokumenty"
},
"profiles": {
"new": "Nový profil",
"manage": "Správa profilů úložiště",
"name": "Přátelský název",
"vaultAddr": "Adresa trezoru",
"namespace": "Jmenný prostor",
"oidcMount": "Připojení autorizace OIDC",
"oidcRole": "Role OIDC",
"sshMount": "SSH tajemství Mount",
"sshRole": "Role SSH podepisujícího",
"validPrincipals": "Platné principy",
"keyType": "Typ dočasného klíče",
"shareWithAll": "Sdílet se všemi uživateli",
"create": "Vytvořit profil",
"created": "Profil úložiště vytvořen",
"saved": "Profil trezoru uložen",
"deleted": "Profil trezoru smazán",
"saveFailed": "Uložení profilu úložiště se nezdařilo.",
"deleteFailed": "Profil v úložišti se nepodařilo smazat.",
"save": "Uložit profil",
"validationError": "Jméno, adresa trezoru a role SSH signatáře jsou povinné.",
"none": "Zatím žádné profily v úložišti Vault.",
"sharedBadge": "sdílené"
},
"dialog": {
"title": "Vyžaduje se přihlášení do trezoru",
"description": "Otevřelo se okno pro přihlášení do HashiCorp Vault. Dokončete přihlášení tam; toto připojení bude automaticky pokračovat.",
"failed": "Ověření v trezoru se nezdařilo. Zkuste to prosím znovu.",
"reopen": "Znovu otevřít přihlašovací okno"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Profil for underskriver af arkiv",
"selectProfile": "Vælg en Vault-profil...",
"hint": "Indstillingerne kommer fra den delte profil; du logger ind på Vault via OIDC, når du opretter forbindelse. Der gemmes ingen hemmeligheder.",
"docs": "Se dokumenter"
},
"profiles": {
"new": "Ny profil",
"manage": "Administrer Vault-profiler",
"name": "Venligt navn",
"vaultAddr": "Adresse i boksen",
"namespace": "Navnerum",
"oidcMount": "OIDC-godkendelsesmontering",
"oidcRole": "OIDC-rolle",
"sshMount": "SSH-hemmeligheder monteres",
"sshRole": "SSH-underskriverrolle",
"validPrincipals": "Gyldige principper",
"keyType": "Kortvarig nøgletype",
"shareWithAll": "Del med alle brugere",
"create": "Opret profil",
"created": "Vault-profil oprettet",
"saved": "Vault-profil gemt",
"deleted": "Vault-profil slettet",
"saveFailed": "Kunne ikke gemme Vault-profilen",
"deleteFailed": "Kunne ikke slette Vault-profilen",
"save": "Gem profil",
"validationError": "Navn, Vault-adresse og SSH-underskriverrolle er påkrævet",
"none": "Ingen Vault-profiler endnu.",
"sharedBadge": "delt"
},
"dialog": {
"title": "Login til Vault kræves",
"description": "Et vindue er åbnet, hvor du kan logge ind på HashiCorp Vault. Fuldfør loginprocessen der; forbindelsen fortsætter automatisk.",
"failed": "Godkendelse af arkiv mislykkedes. Prøv igen.",
"reopen": "Genåbn loginvinduet"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Vault-Signer-Profil",
"selectProfile": "Vault-Profil auswählen...",
"hint": "Die Einstellungen stammen aus dem freigegebenen Profil; Sie melden sich beim Verbinden per OIDC bei Vault an. Es werden keine Geheimnisse gespeichert.",
"docs": "Dokumentation anzeigen"
},
"profiles": {
"new": "Neues Profil",
"manage": "Vault-Profile verwalten",
"name": "Anzeigename",
"vaultAddr": "Vault-Adresse",
"namespace": "Namespace",
"oidcMount": "OIDC-Auth-Mount",
"oidcRole": "OIDC-Rolle",
"sshMount": "SSH-Secrets-Mount",
"sshRole": "SSH-Signer-Rolle",
"validPrincipals": "Gültige Principals",
"keyType": "Ephemerer Schlüsseltyp",
"shareWithAll": "Mit allen Benutzern teilen",
"create": "Profil erstellen",
"created": "Vault-Profil erstellt",
"saved": "Vault-Profil gespeichert",
"deleted": "Vault-Profil gelöscht",
"saveFailed": "Fehler beim Speichern des Vault-Profils",
"deleteFailed": "Fehler beim Löschen des Vault-Profils",
"save": "Profil speichern",
"validationError": "Name, Vault-Adresse und SSH-Signer-Rolle sind erforderlich",
"none": "Noch keine Vault-Profile.",
"sharedBadge": "geteilt"
},
"dialog": {
"title": "Vault-Anmeldung erforderlich",
"description": "Ein Fenster zur Anmeldung bei HashiCorp Vault wurde geöffnet. Schließen Sie die Anmeldung dort ab; diese Verbindung wird automatisch fortgesetzt.",
"failed": "Vault-Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"reopen": "Anmeldefenster erneut öffnen"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Προφίλ Υπογράφοντος Vault",
"selectProfile": "Επιλέξτε ένα προφίλ Vault...",
"hint": "Οι ρυθμίσεις προέρχονται από το κοινόχρηστο προφίλ. Θα συνδεθείτε στο Vault μέσω OIDC όταν συνδεθείτε. Δεν αποθηκεύονται μυστικά.",
"docs": "Προβολή εγγράφων"
},
"profiles": {
"new": "Νέο προφίλ",
"manage": "Διαχείριση προφίλ Vault",
"name": "Φιλικό όνομα",
"vaultAddr": "Διεύθυνση θησαυροφυλακίου",
"namespace": "Χώρος ονομάτων",
"oidcMount": "Μοντάζ ελέγχου ταυτότητας OIDC",
"oidcRole": "Ρόλος OIDC",
"sshMount": "SSH Secrets Mount",
"sshRole": "Ρόλος Υπογράφοντος SSH",
"validPrincipals": "Έγκυρες Αρχές",
"keyType": "Εφήμερος Τύπος Κλειδιού",
"shareWithAll": "Κοινοποίηση με όλους τους χρήστες",
"create": "Δημιουργία προφίλ",
"created": "Δημιουργήθηκε προφίλ Vault",
"saved": "Το προφίλ του Vault αποθηκεύτηκε",
"deleted": "Το προφίλ Vault διαγράφηκε",
"saveFailed": "Αποτυχία αποθήκευσης προφίλ Vault",
"deleteFailed": "Αποτυχία διαγραφής προφίλ Vault",
"save": "Αποθήκευση προφίλ",
"validationError": "Απαιτούνται όνομα, διεύθυνση vault και ρόλος υπογράφοντος SSH",
"none": "Δεν υπάρχουν ακόμη προφίλ Vault.",
"sharedBadge": "κοινόχρηστο"
},
"dialog": {
"title": "Απαιτείται σύνδεση στο Vault",
"description": "Έχει ανοίξει ένα παράθυρο για να συνδεθείτε στο HashiCorp Vault. Ολοκληρώστε τη σύνδεση εκεί. Αυτή η σύνδεση θα συνεχιστεί αυτόματα.",
"failed": "Ο έλεγχος ταυτότητας του vault απέτυχε. Δοκιμάστε ξανά.",
"reopen": "Επανάνοιγμα παραθύρου σύνδεσης"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Perfil de firmante de Vault",
"selectProfile": "Seleccionar un perfil de Vault...",
"hint": "La configuración proviene del perfil compartido; se iniciará sesión en Vault a través de OIDC al conectarse. No se almacenan secretos.",
"docs": "Ver documentación"
},
"profiles": {
"new": "Nuevo perfil",
"manage": "Administrar perfiles de Vault",
"name": "Nombre descriptivo",
"vaultAddr": "Dirección de Vault",
"namespace": "Espacio de nombres",
"oidcMount": "Montaje de autenticación OIDC",
"oidcRole": "Rol OIDC",
"sshMount": "Montaje de secretos SSH",
"sshRole": "Rol de firmante SSH",
"validPrincipals": "Principals válidos",
"keyType": "Tipo de clave efímera",
"shareWithAll": "Compartir con todos los usuarios",
"create": "Crear perfil",
"created": "Perfil de Vault creado",
"saved": "Perfil de Vault guardado",
"deleted": "Perfil de Vault eliminado",
"saveFailed": "Error al guardar el perfil de Vault",
"deleteFailed": "Error al eliminar el perfil de Vault",
"save": "Guardar perfil",
"validationError": "Se requieren el nombre, la dirección de Vault y el rol de firmante SSH",
"none": "Aún no hay perfiles de Vault.",
"sharedBadge": "compartido"
},
"dialog": {
"title": "Inicio de sesión en Vault requerido",
"description": "Se ha abierto una ventana para iniciar sesión en HashiCorp Vault. Completa el inicio de sesión allí; esta conexión continuará automáticamente.",
"failed": "La autenticación de Vault falló. Inténtalo de nuevo.",
"reopen": "Reabrir ventana de inicio de sesión"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Varaston allekirjoittajan profiili",
"selectProfile": "Valitse holviprofiili...",
"hint": "Asetukset tulevat jaetusta profiilista; kirjaudut Holviin OIDC:n kautta, kun muodostat yhteyden. Salaisuuksia ei tallenneta.",
"docs": "Näytä dokumentit"
},
"profiles": {
"new": "Uusi profiili",
"manage": "Hallinnoi holviprofiileja",
"name": "Ystävällinen nimi",
"vaultAddr": "Holvin osoite",
"namespace": "Nimiavaruus",
"oidcMount": "OIDC-todennusliitos",
"oidcRole": "OIDC-rooli",
"sshMount": "SSH-salaisuuksien kiinnitys",
"sshRole": "SSH-allekirjoittajan rooli",
"validPrincipals": "Voimassa olevat pääasialliset",
"keyType": "Lyhytaikainen avaintyyppi",
"shareWithAll": "Jaa kaikkien käyttäjien kanssa",
"create": "Luo profiili",
"created": "Holvin profiili luotu",
"saved": "Holvin profiili tallennettu",
"deleted": "Holvin profiili poistettu",
"saveFailed": "Holvin profiilin tallentaminen epäonnistui",
"deleteFailed": "Holviprofiilin poistaminen epäonnistui",
"save": "Tallenna profiili",
"validationError": "Nimi, holvin osoite ja SSH-allekirjoittajan rooli vaaditaan",
"none": "Ei vielä holviprofiileja.",
"sharedBadge": "jaettu"
},
"dialog": {
"title": "Holviin kirjautuminen vaaditaan",
"description": "Ikkuna HashiCorp-holviin kirjautumista varten on avautunut. Kirjaudu sisään siinä. Yhteys jatkuu automaattisesti.",
"failed": "Holvin todennus epäonnistui. Yritä uudelleen.",
"reopen": "Avaa kirjautumisikkuna uudelleen"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Profil de signature Vault",
"selectProfile": "Sélectionner un profil Vault...",
"hint": "Les paramètres proviennent du profil partagé ; vous vous connecterez à Vault via OIDC lors de la connexion. Aucun secret n'est stocké.",
"docs": "Voir la documentation"
},
"profiles": {
"new": "Nouveau profil",
"manage": "Gérer les profils Vault",
"name": "Nom convivial",
"vaultAddr": "Adresse Vault",
"namespace": "Espace de noms",
"oidcMount": "Point de montage d'authentification OIDC",
"oidcRole": "Rôle OIDC",
"sshMount": "Point de montage des secrets SSH",
"sshRole": "Rôle de signature SSH",
"validPrincipals": "Principaux valides",
"keyType": "Type de clé éphémère",
"shareWithAll": "Partager avec tous les utilisateurs",
"create": "Créer un profil",
"created": "Profil Vault créé",
"saved": "Profil Vault enregistré",
"deleted": "Profil Vault supprimé",
"saveFailed": "Échec de l'enregistrement du profil Vault",
"deleteFailed": "Échec de la suppression du profil Vault",
"save": "Enregistrer le profil",
"validationError": "Le nom, l'adresse Vault et le rôle de signataire SSH sont requis",
"none": "Aucun profil Vault pour le moment",
"sharedBadge": "partagé"
},
"dialog": {
"title": "Connexion à Vault requise",
"description": "Une fenêtre s'est ouverte pour vous connecter à HashiCorp Vault. Terminez la connexion dans cette fenêtre ; cette connexion continuera automatiquement.",
"failed": "Échec de l'authentification Vault. Veuillez réessayer.",
"reopen": "Rouvrir la fenêtre de connexion"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "פרופיל חותם בכספת",
"selectProfile": "בחר פרופיל כספת...",
"hint": "ההגדרות מגיעות מהפרופיל המשותף; תיכנס לכספת דרך OIDC כשתתחבר. לא נשמרים סודות.",
"docs": "הצג מסמכים"
},
"profiles": {
"new": "פרופיל חדש",
"manage": "ניהול פרופילי כספת",
"name": "שם ידידותי",
"vaultAddr": "כתובת הכספת",
"namespace": "מרחב השמות",
"oidcMount": "אישור OIDC",
"oidcRole": "תפקיד OIDC",
"sshMount": "סודות SSH מאוורר",
"sshRole": "תפקיד חותם SSH",
"validPrincipals": "עקרונות תקפים",
"keyType": "סוג מפתח זמני",
"shareWithAll": "שתף עם כל המשתמשים",
"create": "צור פרופיל",
"created": "פרופיל הכספת נוצר",
"saved": "פרופיל הכספת נשמר",
"deleted": "פרופיל הכספת נמחק",
"saveFailed": "שמירת פרופיל הכספת נכשלה",
"deleteFailed": "נכשלה מחיקת פרופיל הכספת",
"save": "שמור פרופיל",
"validationError": "שם, כתובת כספת ותפקיד חתימה ב-SSH נדרשים",
"none": "אין עדיין פרופילים בכספת.",
"sharedBadge": "מְשׁוּתָף"
},
"dialog": {
"title": "נדרשת כניסה לכספת",
"description": "נפתח חלון לכניסה לכספת HashiCorp. השלם את תהליך הכניסה שם; חיבור זה ימשיך אוטומטית.",
"failed": "אימות הכספת נכשל. אנא נסה שוב.",
"reopen": "פתח מחדש את חלון הכניסה"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Vault साइनर प्रोफ़ाइल",
"selectProfile": "एक Vault प्रोफ़ाइल चुनें...",
"hint": "सेटिंग साझा प्रोफ़ाइल से आती हैं; कनेक्ट करते समय आप OIDC के माध्यम से Vault में साइन इन करेंगे। कोई सीक्रेट संग्रहीत नहीं किए जाते।",
"docs": "दस्तावेज़ देखें"
},
"profiles": {
"new": "नई प्रोफ़ाइल",
"manage": "Vault प्रोफ़ाइल प्रबंधित करें",
"name": "अनुकूल नाम",
"vaultAddr": "Vault पता",
"namespace": "नेमस्पेस",
"oidcMount": "OIDC ऑथ माउंट",
"oidcRole": "OIDC रोल",
"sshMount": "SSH सीक्रेट माउंट",
"sshRole": "SSH साइनर रोल",
"validPrincipals": "मान्य प्रिंसिपल",
"keyType": "एफेमरल कुंजी प्रकार",
"shareWithAll": "सभी उपयोगकर्ताओं के साथ साझा करें",
"create": "प्रोफ़ाइल बनाएँ",
"created": "Vault प्रोफ़ाइल बनाई गई",
"saved": "Vault प्रोफ़ाइल सहेजी गई",
"deleted": "Vault प्रोफ़ाइल हटाई गई",
"saveFailed": "Vault प्रोफ़ाइल सहेजने में विफल",
"deleteFailed": "Vault प्रोफ़ाइल हटाने में विफल",
"save": "प्रोफ़ाइल सहेजें",
"validationError": "नाम, Vault पता और SSH हस्ताक्षरकर्ता भूमिका आवश्यक हैं",
"none": "अभी तक कोई Vault प्रोफ़ाइल नहीं है।",
"sharedBadge": "साझा"
},
"dialog": {
"title": "Vault साइन-इन आवश्यक है",
"description": "HashiCorp Vault में साइन इन करने के लिए एक विंडो खुल गई है। वहाँ साइन-इन पूरा करें; यह कनेक्शन स्वचालित रूप से जारी रहेगा।",
"failed": "Vault प्रमाणीकरण विफल। कृपया पुनः प्रयास करें।",
"reopen": "साइन-इन विंडो फिर से खोलें"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Vault aláírói profil",
"selectProfile": "Válasszon ki egy Vault profilt...",
"hint": "A beállítások a megosztott profilból származnak; a csatlakozáskor OIDC-n keresztül fog bejelentkezni a Vaultba. A rendszer nem tárol titkos adatokat.",
"docs": "Dokumentumok megtekintése"
},
"profiles": {
"new": "Új profil",
"manage": "Vault profilok kezelése",
"name": "Barátságos név",
"vaultAddr": "Trezor címe",
"namespace": "Névtér",
"oidcMount": "OIDC hitelesítési csatolás",
"oidcRole": "OIDC szerepkör",
"sshMount": "SSH titkok felhúzása",
"sshRole": "SSH aláíró szerepkör",
"validPrincipals": "Érvényes megbízók",
"keyType": "Efemer kulcstípus",
"shareWithAll": "Megosztás az összes felhasználóval",
"create": "Profil létrehozása",
"created": "Létrehozva a trezor profilja",
"saved": "A trezor profilja mentve",
"deleted": "Tárolóprofil törölve",
"saveFailed": "Nem sikerült menteni a Vault profilját",
"deleteFailed": "Nem sikerült törölni a Vault profilját",
"save": "Profil mentése",
"validationError": "Név, trezor címe és SSH aláírói szerepkör megadása kötelező.",
"none": "Még nincsenek Vault-profilok.",
"sharedBadge": "megosztott"
},
"dialog": {
"title": "Bejelentkezés szükséges a széfbe",
"description": "Megnyílt egy ablak a HashiCorp Vaultba való bejelentkezéshez. Fejezze be a bejelentkezést ott; a kapcsolat automatikusan folytatódik.",
"failed": "A trezor hitelesítése sikertelen. Próbálja újra.",
"reopen": "Bejelentkezési ablak újranyitása"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Profil Penandatangan Vault",
"selectProfile": "Pilih profil Vault...",
"hint": "Pengaturan berasal dari profil bersama; Anda akan masuk ke Vault melalui OIDC saat terhubung. Tidak ada rahasia yang disimpan.",
"docs": "Lihat dokumen"
},
"profiles": {
"new": "Profil baru",
"manage": "Kelola profil Vault",
"name": "Nama yang Ramah",
"vaultAddr": "Alamat Brankas",
"namespace": "Ruang nama",
"oidcMount": "OIDC Otorisasi Pemasangan",
"oidcRole": "Peran OIDC",
"sshMount": "Pemasangan Rahasia SSH",
"sshRole": "Peran Penandatangan SSH",
"validPrincipals": "Prinsip-prinsip yang Sah",
"keyType": "Jenis Kunci Sementara",
"shareWithAll": "Bagikan dengan semua pengguna",
"create": "Buat profil",
"created": "Profil Vault telah dibuat.",
"saved": "Profil Vault tersimpan.",
"deleted": "Profil Vault dihapus",
"saveFailed": "Gagal menyimpan profil Vault",
"deleteFailed": "Gagal menghapus profil Vault",
"save": "Simpan profil",
"validationError": "Nama, alamat Vault, dan peran penandatangan SSH wajib diisi.",
"none": "Belum ada profil Vault.",
"sharedBadge": "dibagikan"
},
"dialog": {
"title": "Login ke Vault diperlukan.",
"description": "Jendela telah terbuka untuk masuk ke HashiCorp Vault. Selesaikan proses masuk di sana; koneksi ini akan berlanjut secara otomatis.",
"failed": "Autentikasi Vault gagal. Silakan coba lagi.",
"reopen": "Buka kembali jendela masuk"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Profilo firmatario Vault",
"selectProfile": "Seleziona un profilo Vault...",
"hint": "Le impostazioni provengono dal profilo condiviso; effettuerai l'accesso a Vault tramite OIDC quando ti connetti. Nessun segreto è memorizzato.",
"docs": "Visualizza documentazione"
},
"profiles": {
"new": "Nuovo profilo",
"manage": "Gestisci profili Vault",
"name": "Nome descrittivo",
"vaultAddr": "Indirizzo Vault",
"namespace": "Namespace",
"oidcMount": "Mount di autenticazione OIDC",
"oidcRole": "Ruolo OIDC",
"sshMount": "Mount dei segreti SSH",
"sshRole": "Ruolo firmatario SSH",
"validPrincipals": "Principal validi",
"keyType": "Tipo di chiave effimera",
"shareWithAll": "Condividi con tutti gli utenti",
"create": "Crea profilo",
"created": "Profilo Vault creato",
"saved": "Profilo Vault salvato",
"deleted": "Profilo Vault eliminato",
"saveFailed": "Salvataggio profilo Vault non riuscito",
"deleteFailed": "Eliminazione profilo Vault non riuscita",
"save": "Salva profilo",
"validationError": "Nome, indirizzo Vault e ruolo di firma SSH sono obbligatori",
"none": "Nessun profilo Vault ancora.",
"sharedBadge": "condiviso"
},
"dialog": {
"title": "Accesso a Vault richiesto",
"description": "Una finestra si è aperta per accedere a HashiCorp Vault. Completa l'accesso lì; la connessione continuerà automaticamente.",
"failed": "Autenticazione Vault fallita. Riprova.",
"reopen": "Riapri la finestra di accesso"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Vault 署名者プロファイル",
"selectProfile": "Vault プロファイルを選択...",
"hint": "設定は共有プロファイルから取得され、接続時に OIDC で Vault にサインインします。シークレットは保存されません。",
"docs": "ドキュメントを表示"
},
"profiles": {
"new": "新規プロファイル",
"manage": "Vault プロファイルの管理",
"name": "表示名",
"vaultAddr": "Vault アドレス",
"namespace": "名前空間",
"oidcMount": "OIDC 認証マウント",
"oidcRole": "OIDC ロール",
"sshMount": "SSH シークレットマウント",
"sshRole": "SSH 署名者ロール",
"validPrincipals": "有効なプリンシパル",
"keyType": "エフェメラルキータイプ",
"shareWithAll": "すべてのユーザーと共有",
"create": "プロファイルを作成",
"created": "Vaultプロファイルが作成されました",
"saved": "Vaultプロファイルが保存されました",
"deleted": "Vaultプロファイルが削除されました",
"saveFailed": "Vaultプロファイルの保存に失敗しました",
"deleteFailed": "Vaultプロファイルの削除に失敗しました",
"save": "プロファイルを保存",
"validationError": "名前、Vaultアドレス、SSH署名ロールは必須です",
"none": "まだVaultプロファイルがありません。",
"sharedBadge": "共有"
},
"dialog": {
"title": "Vault サインインが必要です",
"description": "HashiCorp Vaultへのサインイン用のウィンドウが開きました。そこでサインインを完了すると、この接続は自動的に続行されます。",
"failed": "Vault 認証に失敗しました。もう一度お試しください。",
"reopen": "サインインウィンドウを再表示"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Vault 서명자 프로필",
"selectProfile": "Vault 프로필 선택...",
"hint": "설정은 공유 프로필에서 가져옵니다. 연결 시 OIDC를 통해 Vault에 로그인하게 됩니다. 비밀은 저장되지 않습니다.",
"docs": "문서 보기"
},
"profiles": {
"new": "새 프로필",
"manage": "Vault 프로필 관리",
"name": "별칭",
"vaultAddr": "Vault 주소",
"namespace": "네임스페이스",
"oidcMount": "OIDC 인증 마운트",
"oidcRole": "OIDC 역할",
"sshMount": "SSH 시크릿 마운트",
"sshRole": "SSH 서명자 역할",
"validPrincipals": "유효한 프린시펄",
"keyType": "임시 키 유형",
"shareWithAll": "모든 사용자와 공유",
"create": "프로필 생성",
"created": "Vault 프로필 생성됨",
"saved": "Vault 프로필 저장됨",
"deleted": "Vault 프로필 삭제됨",
"saveFailed": "Vault 프로필 저장 실패",
"deleteFailed": "Vault 프로필 삭제 실패",
"save": "프로필 저장",
"validationError": "이름, Vault 주소 및 SSH 서명자 역할이 필요합니다.",
"none": "아직 Vault 프로필이 없습니다.",
"sharedBadge": "공유"
},
"dialog": {
"title": "Vault 로그인 필요",
"description": "HashiCorp Vault 로그인을 위한 창이 열렸습니다. 해당 창에서 로그인을 완료하면 연결이 자동으로 계속됩니다.",
"failed": "Vault 인증에 실패했습니다. 다시 시도하세요.",
"reopen": "로그인 창 다시 열기"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Vault-ondertekenaarprofiel",
"selectProfile": "Selecteer een Vault-profiel...",
"hint": "De instellingen zijn afkomstig van het gedeelde profiel; u meldt zich bij Vault aan via OIDC wanneer u verbinding maakt. Er worden geen geheimen opgeslagen.",
"docs": "Documenten bekijken"
},
"profiles": {
"new": "Nieuw profiel",
"manage": "Beheer Vault-profielen",
"name": "Vriendelijke naam",
"vaultAddr": "Kluisadres",
"namespace": "Naamruimte",
"oidcMount": "OIDC-authenticatiekoppeling",
"oidcRole": "OIDC-rol",
"sshMount": "SSH-geheimen Mount",
"sshRole": "SSH-ondertekenaarrol",
"validPrincipals": "Geldige principes",
"keyType": "Tijdelijk sleuteltype",
"shareWithAll": "Delen met alle gebruikers",
"create": "Profiel aanmaken",
"created": "Vault-profiel aangemaakt",
"saved": "Vault-profiel opgeslagen",
"deleted": "Kluisprofiel verwijderd",
"saveFailed": "Het opslaan van het Vault-profiel is mislukt.",
"deleteFailed": "Het verwijderen van het Vault-profiel is mislukt.",
"save": "Profiel opslaan",
"validationError": "Naam, kluisadres en SSH-ondertekeningsrol zijn vereist.",
"none": "Nog geen Vault-profielen beschikbaar.",
"sharedBadge": "gedeeld"
},
"dialog": {
"title": "Aanmelden bij Vault is vereist.",
"description": "Er is een venster geopend om in te loggen bij HashiCorp Vault. Voltooi de aanmelding daar; deze verbinding wordt automatisch tot stand gebracht.",
"failed": "Authenticatie voor de kluis is mislukt. Probeer het opnieuw.",
"reopen": "Heropen het aanmeldvenster"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Profil for hvelvsigner",
"selectProfile": "Velg en Vault-profil...",
"hint": "Innstillingene kommer fra den delte profilen. Du logger deg på Vault via OIDC når du kobler til. Ingen hemmeligheter lagres.",
"docs": "Vis dokumenter"
},
"profiles": {
"new": "Ny profil",
"manage": "Administrer Vault-profiler",
"name": "Vennlig navn",
"vaultAddr": "Hvelvadresse",
"namespace": "Navnerom",
"oidcMount": "OIDC-autentiseringsmontering",
"oidcRole": "OIDC-rolle",
"sshMount": "SSH-hemmeligheter monteres",
"sshRole": "SSH-signatørrolle",
"validPrincipals": "Gyldige prinsipaler",
"keyType": "Kortvarig nøkkeltype",
"shareWithAll": "Del med alle brukere",
"create": "Opprett profil",
"created": "Vault-profil opprettet",
"saved": "Vault-profilen er lagret",
"deleted": "Vault-profilen er slettet",
"saveFailed": "Kunne ikke lagre Vault-profilen",
"deleteFailed": "Kunne ikke slette Vault-profilen",
"save": "Lagre profil",
"validationError": "Navn, hvelvadresse og SSH-signerrolle er obligatorisk",
"none": "Ingen Vault-profiler ennå.",
"sharedBadge": "delt"
},
"dialog": {
"title": "Pålogging til arkivet kreves",
"description": "Et vindu har åpnet seg for å logge på HashiCorp Vault. Fullfør påloggingen der; denne tilkoblingen vil fortsette automatisk.",
"failed": "Hvelv-autentisering mislyktes. Prøv på nytt.",
"reopen": "Åpne påloggingsvinduet på nytt"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Profil podpisujący Vault",
"selectProfile": "Wybierz profil Vault...",
"hint": "Ustawienia pochodzą z profilu współdzielonego. Po połączeniu zalogujesz się do Vault przez OIDC. Nie są przechowywane żadne sekrety.",
"docs": "Zobacz dokumentację"
},
"profiles": {
"new": "Nowy profil",
"manage": "Zarządzaj profilami Vault",
"name": "Przyjazna nazwa",
"vaultAddr": "Adres Vault",
"namespace": "Przestrzeń nazw",
"oidcMount": "Mount uwierzytelniania OIDC",
"oidcRole": "Rola OIDC",
"sshMount": "Mount sekretów SSH",
"sshRole": "Rola podpisująca SSH",
"validPrincipals": "Dozwoleni użytkownicy (principals)",
"keyType": "Typ klucza efemerycznego",
"shareWithAll": "Udostępnij wszystkim użytkownikom",
"create": "Utwórz profil",
"created": "Utworzono profil Vault",
"saved": "Zapisano profil Vault",
"deleted": "Usunięto profil Vault",
"saveFailed": "Nie udało się zapisać profilu Vault",
"deleteFailed": "Nie udało się usunąć profilu Vault",
"save": "Zapisz profil",
"validationError": "Wymagane są nazwa, adres Vault i rola podpisująca SSH",
"none": "Brak profili Vault.",
"sharedBadge": "współdzielony"
},
"dialog": {
"title": "Wymagane logowanie do Vault",
"description": "Otworzyło się okno logowania do HashiCorp Vault. Dokończ logowanie, a połączenie zostanie nawiązane automatycznie.",
"failed": "Uwierzytelnienie w Vault nie powiodło się. Spróbuj ponownie.",
"reopen": "Otwórz ponownie okno logowania"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Perfil do assinante do Vault",
"selectProfile": "Selecione um perfil do Vault...",
"hint": "As configurações são provenientes do perfil compartilhado; você fará login no Vault via OIDC ao se conectar. Nenhum segredo é armazenado.",
"docs": "Ver documentos"
},
"profiles": {
"new": "Novo perfil",
"manage": "Gerenciar perfis do Vault",
"name": "Nome amigável",
"vaultAddr": "Endereço do cofre",
"namespace": "Espaço de nomes",
"oidcMount": "Montagem de autenticação OIDC",
"oidcRole": "Função OIDC",
"sshMount": "Montagem de segredos SSH",
"sshRole": "Função de assinatura SSH",
"validPrincipals": "Principais válidos",
"keyType": "Tipo de chave efêmera",
"shareWithAll": "Compartilhe com todos os usuários",
"create": "Criar perfil",
"created": "Perfil do Vault criado",
"saved": "Perfil do Vault salvo",
"deleted": "Perfil do Vault excluído",
"saveFailed": "Falha ao salvar o perfil do Vault",
"deleteFailed": "Falha ao excluir o perfil do Vault",
"save": "Salvar perfil",
"validationError": "Nome, endereço do Vault e função de assinatura SSH são obrigatórios.",
"none": "Ainda não há perfis no Vault.",
"sharedBadge": "compartilhado"
},
"dialog": {
"title": "É necessário fazer login no cofre.",
"description": "Uma janela foi aberta para fazer login no HashiCorp Vault. Conclua o login; esta conexão será estabelecida automaticamente.",
"failed": "A autenticação do cofre falhou. Tente novamente.",
"reopen": "Reabrir janela de login"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Perfil de assinatura Vault",
"selectProfile": "Selecionar um perfil Vault...",
"hint": "As definições provêm do perfil partilhado; ao ligar, iniciará sessão no Vault via OIDC. Nenhum segredo é armazenado.",
"docs": "Ver documentação"
},
"profiles": {
"new": "Novo perfil",
"manage": "Gerir perfis Vault",
"name": "Nome Amigável",
"vaultAddr": "Endereço Vault",
"namespace": "Namespace",
"oidcMount": "Mount de autenticação OIDC",
"oidcRole": "Função OIDC",
"sshMount": "Mount de segredos SSH",
"sshRole": "Função de assinatura SSH",
"validPrincipals": "Principais válidos",
"keyType": "Tipo de chave efémera",
"shareWithAll": "Partilhar com todos os utilizadores",
"create": "Criar perfil",
"created": "Perfil Vault criado",
"saved": "Perfil Vault guardado",
"deleted": "Perfil Vault eliminado",
"saveFailed": "Falha ao guardar perfil Vault",
"deleteFailed": "Falha ao eliminar perfil Vault",
"save": "Guardar perfil",
"validationError": "Nome, endereço Vault e função de signatário SSH são obrigatórios",
"none": "Ainda sem perfis Vault.",
"sharedBadge": "partilhado"
},
"dialog": {
"title": "Início de sessão no Vault necessário",
"description": "Foi aberta uma janela para iniciar sessão no HashiCorp Vault. Conclua o início de sessão aí; esta ligação continuará automaticamente.",
"failed": "A autenticação no Vault falhou. Tente novamente.",
"reopen": "Reabrir janela de início de sessão"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Profilul semnatarului din seif",
"selectProfile": "Selectați un profil Vault...",
"hint": "Setările provin din profilul partajat; te vei conecta la Vault prin OIDC când te conectezi. Nu sunt stocate secrete.",
"docs": "Vizualizați documentele"
},
"profiles": {
"new": "Profil nou",
"manage": "Gestionați profilurile din seif",
"name": "Nume prietenos",
"vaultAddr": "Adresă seif",
"namespace": "Spațiu de nume",
"oidcMount": "Montare autentificare OIDC",
"oidcRole": "Rolul OIDC",
"sshMount": "Secretele SSH Mount",
"sshRole": "Rolul semnatarului SSH",
"validPrincipals": "Principali validi",
"keyType": "Tip de cheie efemeră",
"shareWithAll": "Partajați cu toți utilizatorii",
"create": "Creați un profil",
"created": "Profilul seifului a fost creat",
"saved": "Profilul seifului a fost salvat",
"deleted": "Profilul seifului a fost șters",
"saveFailed": "Nu s-a putut salva profilul din Seif",
"deleteFailed": "Nu s-a putut șterge profilul Vault",
"save": "Salvați profilul",
"validationError": "Numele, adresa seifului și rolul semnatarului SSH sunt obligatorii.",
"none": "Încă nu există profiluri Vault.",
"sharedBadge": "partajat"
},
"dialog": {
"title": "Este necesară conectarea la seif",
"description": "S-a deschis o fereastră pentru a vă conecta la HashiCorp Vault. Finalizați conectarea acolo; această conexiune va continua automat.",
"failed": "Autentificarea în seif a eșuat. Vă rugăm să încercați din nou.",
"reopen": "Redeschideți fereastra de conectare"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Профиль подписчика Vault",
"selectProfile": "Выберите профиль Vault...",
"hint": "Настройки берутся из общего профиля; при подключении вы войдёте в Vault через OIDC. Секреты не хранятся.",
"docs": "Документация"
},
"profiles": {
"new": "Новый профиль",
"manage": "Управление профилями Vault",
"name": "Дружественное имя",
"vaultAddr": "Адрес Vault",
"namespace": "Пространство имён",
"oidcMount": "Монтирование аутентификации OIDC",
"oidcRole": "Роль OIDC",
"sshMount": "Монтирование секретов SSH",
"sshRole": "Роль подписчика SSH",
"validPrincipals": "Допустимые принципалы",
"keyType": "Тип эфемерного ключа",
"shareWithAll": "Общий доступ для всех пользователей",
"create": "Создать профиль",
"created": "Профиль Vault создан",
"saved": "Профиль Vault сохранён",
"deleted": "Профиль Vault удалён",
"saveFailed": "Не удалось сохранить профиль Vault",
"deleteFailed": "Не удалось удалить профиль Vault",
"save": "Сохранить профиль",
"validationError": "Требуются имя, адрес Vault и роль SSH подписанта",
"none": "Пока нет профилей Vault.",
"sharedBadge": "общий"
},
"dialog": {
"title": "Требуется вход в Vault",
"description": "Открыто окно для входа в HashiCorp Vault. Завершите вход там; подключение продолжится автоматически.",
"failed": "Аутентификация Vault не удалась. Попробуйте снова.",
"reopen": "Повторно открыть окно входа"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Профил потписника трезора",
"selectProfile": "Изаберите профил трезора...",
"hint": "Подешавања долазе из дељеног профила; пријавићете се у Vault преко OIDC-а када се повежете. Ниједне тајне се не чувају.",
"docs": "Прикажи документе"
},
"profiles": {
"new": "Нови профил",
"manage": "Управљање профилима трезора",
"name": "Пријатељско име",
"vaultAddr": "Адреса трезора",
"namespace": "Именски простор",
"oidcMount": "OIDC аутентификација монтирања",
"oidcRole": "Улога OIDC-а",
"sshMount": "SSH тајне носач",
"sshRole": "Улога SSH потписника",
"validPrincipals": "Важећи принципали",
"keyType": "Тип ефемерног кључа",
"shareWithAll": "Подели са свим корисницима",
"create": "Направи профил",
"created": "Профил трезора је креиран",
"saved": "Профил трезора је сачуван",
"deleted": "Профил трезора је обрисан",
"saveFailed": "Чување профила у трезору није успело",
"deleteFailed": "Брисање профила у Трезору није успело",
"save": "Сачувај профил",
"validationError": "Име, адреса трезора и улога SSH потписника су обавезни",
"none": "Још нема профила у Трезору.",
"sharedBadge": "дељено"
},
"dialog": {
"title": "Потребно је пријављивање у трезор",
"description": "Отворио се прозор за пријављивање на HashiCorp Vault. Завршите пријављивање тамо; ова веза ће се аутоматски наставити.",
"failed": "Аутентификација трезора није успела. Молимо покушајте поново.",
"reopen": "Поново отвори прозор за пријављивање"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Profil för valvsignerare",
"selectProfile": "Välj en Vault-profil...",
"hint": "Inställningarna kommer från den delade profilen; du loggar in på Vault via OIDC när du ansluter. Inga hemligheter lagras.",
"docs": "Visa dokument"
},
"profiles": {
"new": "Ny profil",
"manage": "Hantera Vault-profiler",
"name": "Vänligt namn",
"vaultAddr": "Valvadress",
"namespace": "Namnrymd",
"oidcMount": "OIDC-autentiseringsmontering",
"oidcRole": "OIDC-roll",
"sshMount": "SSH-hemligheter monteras",
"sshRole": "SSH-signerarroll",
"validPrincipals": "Giltiga principer",
"keyType": "Tillfällig nyckeltyp",
"shareWithAll": "Dela med alla användare",
"create": "Skapa profil",
"created": "Arkivprofil skapad",
"saved": "Arkivprofilen har sparats",
"deleted": "Arkivprofil raderad",
"saveFailed": "Kunde inte spara arkivprofilen",
"deleteFailed": "Misslyckades med att ta bort Arkiv-profilen",
"save": "Spara profil",
"validationError": "Namn, valvadress och SSH-signerarroll krävs",
"none": "Inga Vault-profiler ännu.",
"sharedBadge": "delad"
},
"dialog": {
"title": "Inloggning till arkivet krävs",
"description": "Ett fönster har öppnats för att logga in på HashiCorp Vault. Slutför inloggningen där; anslutningen fortsätter automatiskt.",
"failed": "Valvautentisering misslyckades. Försök igen.",
"reopen": "Öppna inloggningsfönstret igen"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "โปรไฟล์ผู้ลงนาม Vault",
"selectProfile": "เลือกโปรไฟล์ Vault...",
"hint": "การตั้งค่าต่างๆ มาจากโปรไฟล์ที่ใช้ร่วมกัน คุณจะเข้าสู่ระบบ Vault ผ่าน OIDC เมื่อเชื่อมต่อ ไม่มีข้อมูลลับใดๆ ถูกจัดเก็บไว้",
"docs": "ดูเอกสาร"
},
"profiles": {
"new": "โปรไฟล์ใหม่",
"manage": "จัดการโปรไฟล์ Vault",
"name": "ชื่อที่เป็นมิตร",
"vaultAddr": "ที่อยู่ห้องนิรภัย",
"namespace": "เนมสเปซ",
"oidcMount": "OIDC Auth Mount",
"oidcRole": "บทบาทของ OIDC",
"sshMount": "SSH Secrets Mount",
"sshRole": "บทบาทผู้ลงนาม SSH",
"validPrincipals": "หลักการที่ถูกต้อง",
"keyType": "ประเภทคีย์ชั่วคราว",
"shareWithAll": "แชร์กับผู้ใช้ทั้งหมด",
"create": "สร้างโปรไฟล์",
"created": "สร้างโปรไฟล์ Vault แล้ว",
"saved": "บันทึกโปรไฟล์ Vault แล้ว",
"deleted": "โปรไฟล์ Vault ถูกลบแล้ว",
"saveFailed": "ไม่สามารถบันทึกโปรไฟล์ Vault ได้",
"deleteFailed": "ไม่สามารถลบโปรไฟล์ Vault ได้",
"save": "บันทึกโปรไฟล์",
"validationError": "จำเป็นต้องระบุชื่อ ที่อยู่ Vault และบทบาทผู้ลงนาม SSH",
"none": "ยังไม่มีโปรไฟล์ Vault ใดๆ",
"sharedBadge": "แชร์"
},
"dialog": {
"title": "ต้องลงชื่อเข้าใช้ Vault",
"description": "หน้าต่างสำหรับเข้าสู่ระบบ HashiCorp Vault ได้เปิดขึ้นแล้ว โปรดทำการเข้าสู่ระบบให้เสร็จสมบูรณ์ การเชื่อมต่อนี้จะดำเนินการต่อโดยอัตโนมัติ",
"failed": "การตรวจสอบสิทธิ์ Vault ล้มเหลว โปรดลองอีกครั้ง",
"reopen": "เปิดหน้าต่างเข้าสู่ระบบอีกครั้ง"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Vault İmzalayıcı Profili",
"selectProfile": "Bir Vault profili seçin...",
"hint": "Ayarlar paylaşılan profilden gelir; bağlandığınızda OIDC ile Vault'ta oturum açarsınız. Hiçbir gizli bilgi saklanmaz.",
"docs": "Dokümanları görüntüle"
},
"profiles": {
"new": "Yeni profil",
"manage": "Vault Profillerini Yönet",
"name": "Gösterim Adı",
"vaultAddr": "Vault Adresi",
"namespace": "Ad Alanı",
"oidcMount": "OIDC Kimlik Doğrulama Bağlama",
"oidcRole": "OIDC Rolü",
"sshMount": "SSH Gizli Dizin Bağlama",
"sshRole": "SSH İmzalayıcı Rolü",
"validPrincipals": "Geçerli Asıllar",
"keyType": "Geçici Anahtar Türü",
"shareWithAll": "Tüm Kullanıcılarla Paylaş",
"create": "Profil oluştur",
"created": "Vault profili oluşturuldu",
"saved": "Vault profili kaydedildi",
"deleted": "Vault profili silindi",
"saveFailed": "Vault profili kaydedilemedi",
"deleteFailed": "Vault profili silinemedi",
"save": "Profili kaydet",
"validationError": "Ad, Vault adresi ve SSH imzalama rolü gereklidir",
"none": "Henüz Vault profili yok.",
"sharedBadge": "paylaşılan"
},
"dialog": {
"title": "Vault Oturum Açması Gerekli",
"description": "HashiCorp Vault'ta oturum açmak için bir pencere açıldı. Orada oturum açmayı tamamlayın; bu bağlantı otomatik olarak devam edecek.",
"failed": "Vault kimlik doğrulaması başarısız oldu. Lütfen tekrar deneyin.",
"reopen": "Oturum açma penceresini yeniden aç"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Профіль підписанта сховища",
"selectProfile": "Виберіть профіль Сховища...",
"hint": "Налаштування надходять зі спільного профілю; ви входите у Сейф через OIDC під час підключення. Секрети не зберігаються.",
"docs": "Переглянути документи"
},
"profiles": {
"new": "Новий профіль",
"manage": "Керування профілями Сховища",
"name": "Зрозуміле ім'я",
"vaultAddr": "Адреса сховища",
"namespace": "Простір імен",
"oidcMount": "Монтування автентифікації OIDC",
"oidcRole": "Роль OIDC",
"sshMount": "SSH-секрети кріплення",
"sshRole": "Роль підписувача SSH",
"validPrincipals": "Дійсні принципали",
"keyType": "Тип тимчасового ключа",
"shareWithAll": "Поділитися з усіма користувачами",
"create": "Створити профіль",
"created": "Профіль сховища створено",
"saved": "Профіль сховища збережено",
"deleted": "Профіль сховища видалено",
"saveFailed": "Не вдалося зберегти профіль Сейфа",
"deleteFailed": "Не вдалося видалити профіль Сейфа",
"save": "Зберегти профіль",
"validationError": "Ім'я, адреса сховища та роль підписувача SSH обов'язкові",
"none": "Поки що немає профілів Сейфа.",
"sharedBadge": "спільний"
},
"dialog": {
"title": "Потрібен вхід до Сейфа",
"description": "Відкрилося вікно для входу в HashiCorp Vault. Завершіть вхід там; це з’єднання буде продовжено автоматично.",
"failed": "Не вдалося виконати автентифікацію сховища. Спробуйте ще раз.",
"reopen": "Знову відкрити вікно входу"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Hồ sơ người ký Vault",
"selectProfile": "Chọn hồ sơ Vault...",
"hint": "Cài đặt từ hồ sơ dùng chung; bạn sẽ đăng nhập Vault qua OIDC khi kết nối. Không lưu bí mật.",
"docs": "Xem tài liệu"
},
"profiles": {
"new": "Hồ sơ mới",
"manage": "Quản lý hồ sơ Vault",
"name": "Tên hiển thị",
"vaultAddr": "Địa chỉ Vault",
"namespace": "Không gian tên",
"oidcMount": "Điểm gắn xác thực OIDC",
"oidcRole": "Vai trò OIDC",
"sshMount": "Điểm gắn bí mật SSH",
"sshRole": "Vai trò ký SSH",
"validPrincipals": "Các danh tính hợp lệ",
"keyType": "Loại khóa tạm thời",
"shareWithAll": "Chia sẻ với tất cả người dùng",
"create": "Tạo hồ sơ",
"created": "Đã tạo hồ sơ Vault",
"saved": "Đã lưu hồ sơ Vault",
"deleted": "Đã xóa hồ sơ Vault",
"saveFailed": "Không thể lưu hồ sơ Vault",
"deleteFailed": "Không thể xóa hồ sơ Vault",
"save": "Lưu hồ sơ",
"validationError": "Tên, địa chỉ Vault và vai trò ký SSH là bắt buộc",
"none": "Chưa có hồ sơ Vault nào.",
"sharedBadge": "được chia sẻ"
},
"dialog": {
"title": "Yêu cầu đăng nhập Vault",
"description": "Một cửa sổ đã mở để đăng nhập vào HashiCorp Vault. Hoàn tất đăng nhập ở đó; kết nối này sẽ tự động tiếp tục.",
"failed": "Xác thực Vault thất bại. Vui lòng thử lại.",
"reopen": "Mở lại cửa sổ đăng nhập"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Vault 签名者配置文件",
"selectProfile": "选择 Vault 配置文件...",
"hint": "设置来自共享配置文件;连接时将使用 OIDC 登录 Vault。不会存储任何机密。",
"docs": "查看文档"
},
"profiles": {
"new": "新建配置文件",
"manage": "管理 Vault 配置文件",
"name": "友好名称",
"vaultAddr": "Vault 地址",
"namespace": "命名空间",
"oidcMount": "OIDC 认证挂载点",
"oidcRole": "OIDC 角色",
"sshMount": "SSH 机密挂载点",
"sshRole": "SSH 签名者角色",
"validPrincipals": "有效主体",
"keyType": "临时密钥类型",
"shareWithAll": "与所有用户共享",
"create": "创建配置文件",
"created": "Vault 配置文件已创建",
"saved": "Vault 配置文件已保存",
"deleted": "Vault 配置文件已删除",
"saveFailed": "保存 Vault 配置文件失败",
"deleteFailed": "删除 Vault 配置文件失败",
"save": "保存配置文件",
"validationError": "名称、Vault 地址和 SSH 签名者角色为必填项",
"none": "暂无 Vault 配置文件。",
"sharedBadge": "共享"
},
"dialog": {
"title": "需要 Vault 登录",
"description": "已打开窗口以登录 HashiCorp Vault。在该处完成登录;此连接将自动继续。",
"failed": "Vault 认证失败,请重试。",
"reopen": "重新打开登录窗口"
}
}
@@ -0,0 +1,38 @@
{
"editor": {
"profile": "Vault 簽署者設定檔",
"selectProfile": "選取 Vault 設定檔...",
"hint": "設定來自共用設定檔;連線時您將透過 OIDC 登入 Vault。不會儲存任何機密。",
"docs": "檢視文件"
},
"profiles": {
"new": "新增設定檔",
"manage": "管理 Vault 設定檔",
"name": "易記名稱",
"vaultAddr": "Vault 位址",
"namespace": "命名空間",
"oidcMount": "OIDC 驗證掛載",
"oidcRole": "OIDC 角色",
"sshMount": "SSH 機密掛載",
"sshRole": "SSH 簽署者角色",
"validPrincipals": "有效主體",
"keyType": "暫時金鑰類型",
"shareWithAll": "與所有使用者共用",
"create": "建立設定檔",
"created": "Vault 設定檔已建立",
"saved": "Vault 設定檔已儲存",
"deleted": "Vault 設定檔已刪除",
"saveFailed": "無法儲存 Vault 設定檔",
"deleteFailed": "無法刪除 Vault 設定檔",
"save": "儲存設定檔",
"validationError": "名稱、Vault 位址和 SSH 簽署者角色為必填",
"none": "尚無 Vault 設定檔。",
"sharedBadge": "已共用"
},
"dialog": {
"title": "需要 Vault 登入",
"description": "已開啟視窗以登入 HashiCorp Vault。在該處完成登入;此連線將自動繼續。",
"failed": "Vault 驗證失敗。請再試一次。",
"reopen": "重新開啟登入視窗"
}
}
+72
View File
@@ -0,0 +1,72 @@
{
"id": "vault",
"name": "HashiCorp Vault",
"version": "1.0.0",
"description": "Sign in to hosts with short-lived SSH certificates from a HashiCorp Vault SSH secrets engine, after an OIDC sign-in to Vault in the browser.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Access & Security",
"icon": "KeyRound",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"db:own",
"secrets:own",
"network:outbound",
"network:serve",
"auth:provide",
"ui:surface"
],
"contributes": {
"auth": {
"sshAuthTypes": ["vault"]
},
"permissions": [
{
"name": "use",
"titleKey": "permissions.use.title",
"descriptionKey": "permissions.use.description",
"defaultRoles": ["admin", "user"]
},
{
"name": "share",
"titleKey": "permissions.share.title",
"descriptionKey": "permissions.share.description",
"defaultRoles": ["admin"]
}
],
"settings": {
"admin": [
{
"key": "redirectUri",
"type": "custom",
"component": "redirectUri"
},
{
"key": "legacyCallback",
"type": "boolean",
"labelKey": "settings.legacyCallback.label",
"descriptionKey": "settings.legacyCallback.description",
"default": false
}
],
"host": {
"fields": [
{
"key": "profileId",
"type": "number",
"labelKey": "settings.host.profileId.label",
"hidden": true
}
]
}
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
@@ -0,0 +1,37 @@
-- vault 0001: adopt_vault_tables
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS `vault_profiles` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`name` text NOT NULL,
`description` text,
`folder` text,
`tags` text,
`vault_addr` text NOT NULL,
`vault_namespace` text,
`oidc_mount` text,
`oidc_role` text,
`ssh_mount` text,
`ssh_role` text NOT NULL,
`valid_principals` text,
`key_type` text,
`shared` boolean NOT NULL DEFAULT false,
`sync_id` varchar(255) UNIQUE,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
RENAME TABLE `vault_profiles` TO `p_vault_profiles`;
CREATE TABLE IF NOT EXISTS `vault_tokens` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`profile_id` int NOT NULL,
`ssh_cert` text NOT NULL,
`private_key` text NOT NULL,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`expires_at` varchar(255) NOT NULL,
`last_used` text,
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
);
RENAME TABLE `vault_tokens` TO `p_vault_tokens`;
@@ -0,0 +1,38 @@
-- vault 0001: adopt_vault_tables
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "vault_profiles" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"name" text NOT NULL,
"description" text,
"folder" text,
"tags" text,
"vault_addr" text NOT NULL,
"vault_namespace" text,
"oidc_mount" text,
"oidc_role" text,
"ssh_mount" text,
"ssh_role" text NOT NULL,
"valid_principals" text,
"key_type" text,
"shared" boolean NOT NULL DEFAULT false,
"sync_id" varchar(255) UNIQUE,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
ALTER TABLE "vault_profiles" RENAME TO "p_vault_profiles";
CREATE TABLE IF NOT EXISTS "vault_tokens" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"profile_id" integer NOT NULL,
"ssh_cert" text NOT NULL,
"private_key" text NOT NULL,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" varchar(255) NOT NULL,
"last_used" text,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_vault_tokens_user_profile" ON "vault_tokens" ("user_id", "profile_id");
ALTER TABLE "vault_tokens" RENAME TO "p_vault_tokens";
+127
View File
@@ -0,0 +1,127 @@
{
"version": 1,
"tables": {
"profiles": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"name": {
"type": "text",
"notNull": true
},
"description": {
"type": "text"
},
"folder": {
"type": "text"
},
"tags": {
"type": "text"
},
"vaultAddr": {
"type": "text",
"notNull": true
},
"vaultNamespace": {
"type": "text"
},
"oidcMount": {
"type": "text"
},
"oidcRole": {
"type": "text"
},
"sshMount": {
"type": "text"
},
"sshRole": {
"type": "text",
"notNull": true
},
"validPrincipals": {
"type": "text"
},
"keyType": {
"type": "text"
},
"shared": {
"type": "boolean",
"notNull": true,
"defaultValue": false
},
"syncId": {
"type": "varchar",
"length": 255,
"unique": true
},
"createdAt": {
"type": "text",
"notNull": true,
"defaultNow": true
},
"updatedAt": {
"type": "text",
"notNull": true,
"defaultNow": true
}
},
"indexes": [],
"adopts": "vault_profiles"
},
"tokens": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"profileId": {
"type": "integer",
"notNull": true
},
"sshCert": {
"type": "text",
"notNull": true
},
"privateKey": {
"type": "text",
"notNull": true
},
"createdAt": {
"type": "text",
"notNull": true,
"defaultNow": true
},
"expiresAt": {
"type": "varchar",
"length": 255,
"notNull": true
},
"lastUsed": {
"type": "text"
}
},
"indexes": [
{
"name": "idx_vault_tokens_user_profile",
"columns": ["userId", "profileId"],
"unique": true
}
],
"adopts": "vault_tokens"
}
}
}
@@ -0,0 +1,38 @@
-- vault 0001: adopt_vault_tables
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "vault_profiles" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"name" text NOT NULL,
"description" text,
"folder" text,
"tags" text,
"vault_addr" text NOT NULL,
"vault_namespace" text,
"oidc_mount" text,
"oidc_role" text,
"ssh_mount" text,
"ssh_role" text NOT NULL,
"valid_principals" text,
"key_type" text,
"shared" integer NOT NULL DEFAULT 0,
"sync_id" text UNIQUE,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
ALTER TABLE "vault_profiles" RENAME TO "p_vault_profiles";
CREATE TABLE IF NOT EXISTS "vault_tokens" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"profile_id" integer NOT NULL,
"ssh_cert" text NOT NULL,
"private_key" text NOT NULL,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" text NOT NULL,
"last_used" text,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_vault_tokens_user_profile" ON "vault_tokens" ("user_id", "profile_id");
ALTER TABLE "vault_tokens" RENAME TO "p_vault_tokens";
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@termix-plugin/vault",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
}
+206
View File
@@ -0,0 +1,206 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { TokenStore } from "./token-store.js";
import {
completeVaultOidc,
generateEphemeralKeyPair,
signWithVault,
startVaultOidc,
type VaultProfileConfig,
} from "./vault-client.js";
/** The redirect URI 2.8 had Vault roles allow. */
export const LEGACY_CALLBACK_PATH = "/vault/oidc/callback";
export const CALLBACK_PATH = "/plugin-api/vault/oidc/callback";
const AUTH_TIMEOUT_MS = 5 * 60 * 1000;
/** The terminal socket, as far as a sign-in needs it. */
export interface SignInSocket {
send: (data: string) => void;
on?: (event: "close", listener: () => void) => void;
}
export interface StartRequest {
userId: string;
hostId: number;
profile: VaultProfileConfig;
socket: SignInSocket;
requestOrigin: string;
}
export interface CallbackResult {
ok: boolean;
message: string;
}
interface Session {
state: string;
userId: string;
hostId: number;
profile: VaultProfileConfig;
clientNonce: string;
privateKey: string;
publicKey: string;
socket: SignInSocket;
timeout: ReturnType<typeof setTimeout>;
}
function send(socket: SignInSocket, message: Record<string, unknown>): void {
try {
socket.send(JSON.stringify(message));
} catch {
// socket already gone
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export type AuthSessions = ReturnType<typeof createAuthSessions>;
/**
* Vault OIDC sign-ins in flight, keyed by the state Vault put in its auth
* URL so the unauthenticated browser callback finds its way back. Created
* per activation; closeAll runs on deactivate.
*/
export function createAuthSessions(ctx: PluginContext, tokens: TokenStore) {
const sessions = new Map<string, Session>();
function end(session: Session): void {
clearTimeout(session.timeout);
sessions.delete(session.state);
}
async function callbackPath(): Promise<string> {
const legacy = await ctx.settings.get<boolean>("legacyCallback");
return legacy ? LEGACY_CALLBACK_PATH : CALLBACK_PATH;
}
return {
/** Asks Vault for an auth URL and sends it to the terminal. */
async start(request: StartRequest): Promise<void> {
try {
const keyPair = generateEphemeralKeyPair(request.profile.keyType);
const redirectUri = `${request.requestOrigin}${await callbackPath()}`;
const { authUrl, state, clientNonce } = await startVaultOidc(
ctx.fetch,
request.profile,
redirectUri,
);
const existing = sessions.get(state);
if (existing) end(existing);
const session: Session = {
state,
userId: request.userId,
hostId: request.hostId,
profile: request.profile,
clientNonce,
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey,
socket: request.socket,
timeout: setTimeout(() => {
const current = sessions.get(state);
if (current) end(current);
}, AUTH_TIMEOUT_MS),
};
sessions.set(state, session);
request.socket.on?.("close", () => {
const current = sessions.get(state);
if (current) end(current);
});
send(request.socket, {
type: "vault_auth_url",
hostId: request.hostId,
requestId: state,
url: authUrl,
});
} catch (error) {
const message = errorMessage(error);
ctx.log.error(`Failed to start Vault authentication: ${message}`);
send(request.socket, {
type: "vault_error",
hostId: request.hostId,
error: message,
});
}
},
/**
* The identity provider's redirect, via Vault: exchanges the code for a
* Vault token, has Vault sign the ephemeral key and caches the result.
*/
async complete(state: string, code: string): Promise<CallbackResult> {
const session = sessions.get(state);
if (!session) {
return {
ok: false,
message: "This sign-in request is no longer active.",
};
}
end(session);
try {
const token = await completeVaultOidc(ctx.fetch, session.profile, {
state,
code,
clientNonce: session.clientNonce,
});
const signed = await signWithVault(
ctx.fetch,
session.profile,
token,
session.publicKey,
);
const expiresAt = await tokens.save(
session.userId,
session.profile.id,
session.privateKey,
signed,
);
ctx.log.info(
`Vault signed a certificate for host ${session.hostId} with profile ${session.profile.id}`,
);
send(session.socket, {
type: "vault_completed",
hostId: session.hostId,
requestId: state,
expiresAt,
});
return {
ok: true,
message: "You can close this window and return to Termix.",
};
} catch (error) {
const message = errorMessage(error);
ctx.log.error(`Vault sign-in failed: ${message}`);
send(session.socket, {
type: "vault_error",
hostId: session.hostId,
requestId: state,
error: message,
});
return { ok: false, message };
}
},
/** Cancels by request id, or every sign-in the user has for a host. */
cancel(userId: string, hostId?: number, requestId?: string): void {
for (const session of [...sessions.values()]) {
if (session.userId !== userId) continue;
if (
requestId ? session.state === requestId : session.hostId === hostId
) {
end(session);
}
}
},
closeAll(): void {
for (const session of [...sessions.values()]) end(session);
},
};
}
+41
View File
@@ -0,0 +1,41 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { profiles as profilesTable, tokens as tokensTable } from "./tables.js";
import { createProfileStore, SYNC_ENTITY } from "./profile-store.js";
import { createTokenStore } from "./token-store.js";
import { createAuthSessions } from "./auth-session.js";
import { createVaultProvider } from "./provider.js";
import { PUBLIC_PATHS, registerRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
const profilesDb = await ctx.db.define(profilesTable);
const tokensDb = await ctx.db.define(tokensTable);
const profiles = createProfileStore(ctx, profilesDb, tokensDb);
const tokens = createTokenStore(ctx, tokensDb);
const sessions = createAuthSessions(ctx, tokens);
ctx.disposables.add(() => sessions.closeAll());
ctx.auth.registerSshAuthProvider(
createVaultProvider(ctx, profiles, tokens, sessions),
);
// Same wire name as 2.8, where core synced it. Hosts point at a profile
// through the profileId host setting, not a column, so nothing references it.
ctx.sync.registerEntity({
type: SYNC_ENTITY,
table: profilesDb,
order: 20,
});
registerRoutes(
ctx,
ctx.http.router<Router>({ public: PUBLIC_PATHS }),
profiles,
sessions,
);
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
+139
View File
@@ -0,0 +1,139 @@
import { randomUUID } from "node:crypto";
import { desc, eq, or } from "drizzle-orm";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { VaultProfileConfig } from "./vault-client.js";
/* eslint-disable @typescript-eslint/no-explicit-any */
// ctx.db.define hands the table back untyped and the drizzle handle is the
// server's own, so both are typed at this module's edge.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export const SYNC_ENTITY = "vaultProfiles";
export interface ProfileRow {
id: number;
userId: string;
name: string;
description: string | null;
folder: string | null;
tags: string | null;
vaultAddr: string;
vaultNamespace: string | null;
oidcMount: string | null;
oidcRole: string | null;
sshMount: string | null;
sshRole: string;
validPrincipals: string | null;
keyType: string | null;
shared: boolean | number;
syncId: string | null;
createdAt: string;
updatedAt: string;
}
export type ProfileInput = Partial<
Omit<ProfileRow, "id" | "userId" | "syncId" | "createdAt" | "updatedAt">
>;
export function toConfig(row: ProfileRow): VaultProfileConfig {
return {
id: row.id,
vaultAddr: row.vaultAddr,
vaultNamespace: row.vaultNamespace ?? null,
oidcMount: row.oidcMount ?? null,
oidcRole: row.oidcRole ?? null,
sshMount: row.sshMount ?? null,
sshRole: row.sshRole,
validPrincipals: row.validPrincipals ?? null,
keyType: row.keyType ?? null,
};
}
export type ProfileStore = ReturnType<typeof createProfileStore>;
export function createProfileStore(
ctx: PluginContext,
table: Table,
tokensTable: Table,
) {
const client = () => ctx.db.client<Drizzle>();
async function findById(id: number): Promise<ProfileRow | null> {
const rows = await (
await client()
)
.select()
.from(table)
.where(eq(table.id, id))
.limit(1);
return rows[0] ?? null;
}
async function findBySyncId(syncId: string): Promise<ProfileRow | null> {
const rows = await (
await client()
)
.select()
.from(table)
.where(eq(table.syncId, syncId))
.limit(1);
return rows[0] ?? null;
}
return {
findById,
/** The user's own profiles plus every shared one. */
async listVisible(userId: string): Promise<ProfileRow[]> {
return (await client())
.select()
.from(table)
.where(or(eq(table.userId, userId), eq(table.shared, true)))
.orderBy(desc(table.updatedAt));
},
async create(
userId: string,
input: ProfileInput & {
name: string;
vaultAddr: string;
sshRole: string;
},
): Promise<ProfileRow> {
const syncId = randomUUID();
const now = new Date().toISOString();
await (await client()).insert(table).values({
...input,
userId,
shared: input.shared ? true : false,
syncId,
createdAt: now,
updatedAt: now,
});
await ctx.db.persist();
return (await findBySyncId(syncId)) as ProfileRow;
},
async update(id: number, input: ProfileInput): Promise<ProfileRow | null> {
await (
await client()
)
.update(table)
.set({ ...input, updatedAt: new Date().toISOString() })
.where(eq(table.id, id));
await ctx.db.persist();
return findById(id);
},
/** Deletes the profile, its cached certificates and records a tombstone. */
async remove(row: ProfileRow): Promise<void> {
const db = await client();
await db.delete(tokensTable).where(eq(tokensTable.profileId, row.id));
await db.delete(table).where(eq(table.id, row.id));
await ctx.db.persist();
await ctx.sync.recordTombstone(row.userId, SYNC_ENTITY, row.syncId);
},
};
}
+124
View File
@@ -0,0 +1,124 @@
import type {
PluginContext,
PluginSshAuthProvider,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
import { applyCertificateAuth } from "@termix/plugin-sdk/ssh-certs";
import type { AuthSessions, SignInSocket } from "./auth-session.js";
import {
toConfig,
type ProfileRow,
type ProfileStore,
} from "./profile-store.js";
import type { TokenStore } from "./token-store.js";
export const AUTH_TYPE = "vault";
const REQUIRED_MESSAGE =
"Vault SSH signer authentication required. Please open a Terminal connection first.";
const AUTH_FAILED_PATTERN = /All configured authentication methods failed/i;
/** The profile a host points at, from its host settings. */
export async function profileForHost(
ctx: PluginContext,
profiles: ProfileStore,
hostId: number,
): Promise<ProfileRow | null> {
const raw = await ctx.settings.getHost<number | string>(hostId, "profileId");
const id = Number(raw);
if (!Number.isInteger(id) || id <= 0) return null;
return profiles.findById(id);
}
/**
* Hosts with auth type "vault" connect with a certificate Vault signed for
* the host's profile. Without one, the terminal is asked for a browser
* sign-in ("vault_auth_required"), which startInteraction runs.
*/
export function createVaultProvider(
ctx: PluginContext,
profiles: ProfileStore,
tokens: TokenStore,
sessions: AuthSessions,
): PluginSshAuthProvider {
return {
type: AUTH_TYPE,
labelKey: "hosts.filterAuthVault",
needsUserInteraction: true,
supportsBackground: false,
interaction: AUTH_TYPE,
prepare: async (config, host: PluginSshHost, env) => {
const profile = await profileForHost(ctx, profiles, host.id);
if (!profile) {
return {
status: "error",
code: "failed",
message: "Host has no Vault signer profile configured",
};
}
const cert = await tokens.get(env.userId, profile.id);
if (!cert) {
env.log(
"info",
"No valid Vault certificate found, requesting authentication",
);
return {
status: "interaction-required",
interaction: AUTH_TYPE,
message: REQUIRED_MESSAGE,
flag: "requiresVaultAuth",
};
}
try {
await applyCertificateAuth(
config as never,
env.client as never,
{ privateKey: cert.privateKey, certificate: cert.sshCert },
host.username,
);
} catch (error) {
return {
status: "error",
code: "failed",
message:
"Vault SSH signer authentication failed: " +
(error instanceof Error ? error.message : String(error)),
};
}
env.log("info", "Using cached Vault-signed certificate");
return { status: "ready" };
},
onAuthFailed: (host, env, context) => {
if (!AUTH_FAILED_PATTERN.test(context.error.message)) return undefined;
ctx.log.warn("Vault certificate authentication failed, forgetting it");
void profileForHost(ctx, profiles, host.id)
.then((profile) =>
profile ? tokens.remove(env.userId, profile.id) : undefined,
)
.catch(() => undefined);
return {
status: "interaction-required",
interaction: AUTH_TYPE,
message:
"Vault authentication failed or expired. Please authenticate again.",
};
},
startInteraction: async (request) => {
const profile = await profileForHost(ctx, profiles, request.hostId);
if (!profile) {
throw new Error("No Vault signer profile configured for this host");
}
await sessions.start({
userId: request.userId,
hostId: request.hostId,
profile: toConfig(profile),
socket: request.socket as SignInSocket,
requestOrigin: request.requestOrigin,
});
},
cancelInteraction: (request) => {
sessions.cancel(request.userId, request.hostId, request.requestId);
},
};
}
+337
View File
@@ -0,0 +1,337 @@
import type { Request, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { AuthSessions } from "./auth-session.js";
import type {
ProfileInput,
ProfileRow,
ProfileStore,
} from "./profile-store.js";
/**
* Vault sends the browser back here without a Termix session: the sign-in is
* matched by the state Vault issued.
*/
export const PUBLIC_PATHS = ["/oidc/callback"];
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function resultPage(ok: boolean, message: string): string {
return `<!doctype html><html><head><meta charset="utf-8"><title>Termix</title>
<style>body{font-family:system-ui,sans-serif;background:#111;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
main{max-width:28rem;padding:2rem;border:1px solid #333;background:#181818}h1{font-size:1.1rem;margin:0 0 .5rem}p{margin:0;color:#aaa}</style></head>
<body><main><h1>${ok ? "Vault sign-in complete" : "Vault sign-in failed"}</h1><p>${escapeHtml(message)}</p></main>
${ok ? "<script>setTimeout(function(){window.close()},1500)</script>" : ""}</body></html>`;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function optional(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function formatProfile(row: ProfileRow, userId: string) {
return {
id: row.id,
name: row.name,
description: row.description,
folder: row.folder,
tags: row.tags ? row.tags.split(",").filter(Boolean) : [],
vaultAddr: row.vaultAddr,
vaultNamespace: row.vaultNamespace,
oidcMount: row.oidcMount,
oidcRole: row.oidcRole,
sshMount: row.sshMount,
sshRole: row.sshRole,
validPrincipals: row.validPrincipals,
keyType: row.keyType,
shared: !!row.shared,
owned: row.userId === userId,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function parseId(req: Request): number | null {
const id = Number.parseInt(String(req.params.id), 10);
return Number.isFinite(id) ? id : null;
}
export function registerRoutes(
ctx: PluginContext,
router: Router,
profiles: ProfileStore,
sessions: AuthSessions,
): void {
const userId = () => ctx.currentActor() as string;
/**
* @openapi
* /plugin-api/vault/oidc/callback:
* get:
* summary: Vault OIDC callback
* description: Public. The redirect URI a Vault OIDC role allows. Matches the sign-in by the state Vault issued, has Vault sign the ephemeral key and tells the terminal to reconnect. The 2.8 URI /vault/oidc/callback redirects here.
* tags: [Vault]
* parameters:
* - in: query
* name: state
* schema:
* type: string
* - in: query
* name: code
* schema:
* type: string
* - in: query
* name: error
* schema:
* type: string
* responses:
* 200:
* description: Signed in; an HTML page that closes itself.
* 400:
* description: The sign-in failed or is no longer active.
*/
router.get("/oidc/callback", async (req: Request, res: Response) => {
const state = typeof req.query.state === "string" ? req.query.state : "";
const code = typeof req.query.code === "string" ? req.query.code : "";
const error = typeof req.query.error === "string" ? req.query.error : "";
const reply = (ok: boolean, message: string) =>
res
.status(ok ? 200 : 400)
.type("html")
.send(resultPage(ok, message));
if (error) return reply(false, `Vault returned: ${error}`);
if (!state || !code) return reply(false, "Missing state or code.");
const result = await sessions.complete(state, code);
return reply(result.ok, result.message);
});
router.use("/profiles", ctx.rbac.require("use") as never);
/**
* @openapi
* /plugin-api/vault/profiles:
* get:
* summary: List Vault profiles
* description: The caller's own Vault signer profiles and every shared one. Needs vault.use.
* tags: [Vault]
* responses:
* 200:
* description: Array of Vault profile objects.
* 403:
* description: Missing vault.use.
*/
router.get("/profiles", async (_req: Request, res: Response) => {
const rows = await profiles.listVisible(userId());
res.json(rows.map((row) => formatProfile(row, userId())));
});
/**
* @openapi
* /plugin-api/vault/profiles:
* post:
* summary: Create a Vault profile
* description: Creates a Vault signer profile owned by the caller. Needs vault.use, and vault.share to mark it shared.
* tags: [Vault]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [name, vaultAddr, sshRole]
* properties:
* name:
* type: string
* vaultAddr:
* type: string
* vaultNamespace:
* type: string
* oidcMount:
* type: string
* oidcRole:
* type: string
* sshMount:
* type: string
* sshRole:
* type: string
* validPrincipals:
* type: string
* keyType:
* type: string
* shared:
* type: boolean
* responses:
* 201:
* description: The created profile.
* 400:
* description: Missing required fields.
* 403:
* description: Missing vault.use, or vault.share for a shared profile.
*/
router.post("/profiles", async (req: Request, res: Response) => {
const body = (req.body ?? {}) as Record<string, unknown>;
if (
!isNonEmptyString(body.name) ||
!isNonEmptyString(body.vaultAddr) ||
!isNonEmptyString(body.sshRole)
) {
return res
.status(400)
.json({ error: "name, vaultAddr and sshRole are required" });
}
const shared = !!body.shared;
if (shared && !(await ctx.rbac.has("share"))) {
return res
.status(403)
.json({ error: "You are not allowed to share Vault profiles" });
}
const row = await profiles.create(userId(), {
name: body.name.trim(),
description: optional(body.description),
folder: optional(body.folder),
tags: Array.isArray(body.tags)
? body.tags.join(",")
: typeof body.tags === "string"
? body.tags
: "",
vaultAddr: body.vaultAddr.trim(),
vaultNamespace: optional(body.vaultNamespace),
oidcMount: optional(body.oidcMount),
oidcRole: optional(body.oidcRole),
sshMount: optional(body.sshMount),
sshRole: body.sshRole.trim(),
validPrincipals: optional(body.validPrincipals),
keyType: optional(body.keyType),
shared,
});
res.status(201).json(formatProfile(row, userId()));
});
/**
* @openapi
* /plugin-api/vault/profiles/{id}:
* put:
* summary: Update a Vault profile
* description: Only the owner may edit a profile. Needs vault.use, and vault.share to mark it shared.
* tags: [Vault]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* content:
* application/json:
* schema:
* type: object
* responses:
* 200:
* description: The updated profile.
* 400:
* description: Invalid profile id.
* 403:
* description: Not the owner, or sharing without vault.share.
* 404:
* description: Profile not found.
*/
router.put("/profiles/:id", async (req: Request, res: Response) => {
const id = parseId(req);
if (id === null)
return res.status(400).json({ error: "Invalid profile id" });
const existing = await profiles.findById(id);
if (!existing) return res.status(404).json({ error: "Profile not found" });
if (existing.userId !== userId()) {
return res
.status(403)
.json({ error: "Only the owner can edit this profile" });
}
const body = (req.body ?? {}) as Record<string, unknown>;
const fields: ProfileInput = {};
if (isNonEmptyString(body.name)) fields.name = body.name.trim();
if (body.description !== undefined)
fields.description = optional(body.description);
if (body.folder !== undefined) fields.folder = optional(body.folder);
if (body.tags !== undefined) {
fields.tags = Array.isArray(body.tags)
? body.tags.join(",")
: typeof body.tags === "string"
? body.tags
: "";
}
if (isNonEmptyString(body.vaultAddr))
fields.vaultAddr = body.vaultAddr.trim();
if (body.vaultNamespace !== undefined)
fields.vaultNamespace = optional(body.vaultNamespace);
if (body.oidcMount !== undefined)
fields.oidcMount = optional(body.oidcMount);
if (body.oidcRole !== undefined) fields.oidcRole = optional(body.oidcRole);
if (body.sshMount !== undefined) fields.sshMount = optional(body.sshMount);
if (isNonEmptyString(body.sshRole)) fields.sshRole = body.sshRole.trim();
if (body.validPrincipals !== undefined)
fields.validPrincipals = optional(body.validPrincipals);
if (body.keyType !== undefined) fields.keyType = optional(body.keyType);
if (body.shared !== undefined) {
if (body.shared && !(await ctx.rbac.has("share"))) {
return res
.status(403)
.json({ error: "You are not allowed to share Vault profiles" });
}
fields.shared = !!body.shared;
}
const updated = await profiles.update(id, fields);
if (!updated) return res.status(404).json({ error: "Profile not found" });
res.json(formatProfile(updated, userId()));
});
/**
* @openapi
* /plugin-api/vault/profiles/{id}:
* delete:
* summary: Delete a Vault profile
* description: Deletes a profile and every certificate cached for it. Only the owner may delete it. Needs vault.use.
* tags: [Vault]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Deleted.
* 400:
* description: Invalid profile id.
* 403:
* description: Not the owner.
* 404:
* description: Profile not found.
*/
router.delete("/profiles/:id", async (req: Request, res: Response) => {
const id = parseId(req);
if (id === null)
return res.status(400).json({ error: "Invalid profile id" });
const existing = await profiles.findById(id);
if (!existing) return res.status(404).json({ error: "Profile not found" });
if (existing.userId !== userId()) {
return res
.status(403)
.json({ error: "Only the owner can delete this profile" });
}
await profiles.remove(existing);
res.json({ success: true });
});
}
+74
View File
@@ -0,0 +1,74 @@
import {
adoptLegacyTable,
boolean,
defineTable,
id,
integer,
refUser,
text,
varchar,
} from "@termix/plugin-sdk/db";
/**
* Vault signer profiles: connection settings only, no secrets. A shared
* profile is visible to every user. Adopted from core's vault_profiles.
*/
export const profiles = adoptLegacyTable(
"vault_profiles",
defineTable("profiles", {
id: id(),
userId: refUser(),
name: text().notNull(),
description: text(),
folder: text(),
tags: text(),
vaultAddr: text().notNull(),
vaultNamespace: text(),
oidcMount: text(),
oidcRole: text(),
sshMount: text(),
sshRole: text().notNull(),
validPrincipals: text(),
keyType: text(),
shared: boolean().notNull().default(false),
syncId: varchar().unique(),
createdAt: text().notNull().defaultNow(),
updatedAt: text().notNull().defaultNow(),
}),
);
/**
* One signed certificate and its ephemeral key per user and profile, sealed
* with ctx.secrets.seal. Adopted from core's vault_tokens. Rows 2.8 wrote were
* encrypted with the user's data key; they do not unseal and are dropped.
*
* profileId has no foreign key on a fresh install (the SDK has no builder
* for one to the plugin's own table), so deleting a profile deletes its
* tokens in code. Upgraded installs keep 2.8's cascading key.
*/
export const tokens = adoptLegacyTable(
"vault_tokens",
defineTable(
"tokens",
{
id: id(),
userId: refUser(),
profileId: integer().notNull(),
sshCert: text().notNull(),
privateKey: text().notNull(),
createdAt: text().notNull().defaultNow(),
expiresAt: varchar().notNull(),
lastUsed: text(),
},
{
uniques: [
{
name: "idx_vault_tokens_user_profile",
columns: ["userId", "profileId"],
},
],
},
),
);
export const tables = [profiles, tokens];
+114
View File
@@ -0,0 +1,114 @@
import { and, eq } from "drizzle-orm";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { parseCertValidBefore } from "./vault-client.js";
/* eslint-disable @typescript-eslint/no-explicit-any */
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
// Sign again once a certificate is this close to expiring.
const EXPIRY_SKEW_MS = 60 * 1000;
// When the certificate's expiry cannot be read.
const FALLBACK_TTL_MS = 5 * 60 * 1000;
export interface CachedCertificate {
privateKey: string;
sshCert: string;
}
export type TokenStore = ReturnType<typeof createTokenStore>;
export function createTokenStore(ctx: PluginContext, table: Table) {
const client = () => ctx.db.client<Drizzle>();
const match = (userId: string, profileId: number) =>
and(eq(table.userId, userId), eq(table.profileId, profileId));
async function find(userId: string, profileId: number) {
const rows = await (
await client()
)
.select()
.from(table)
.where(match(userId, profileId))
.limit(1);
return rows[0] ?? null;
}
async function remove(userId: string, profileId: number): Promise<void> {
if (!(await find(userId, profileId))) return;
await (await client()).delete(table).where(match(userId, profileId));
await ctx.db.persist();
}
return {
/** Stores a signed certificate and returns when it expires. */
async save(
userId: string,
profileId: number,
privateKey: string,
sshCert: string,
): Promise<string> {
const validBefore = parseCertValidBefore(sshCert);
const expiresAt = new Date(
validBefore > 0 ? validBefore * 1000 : Date.now() + FALLBACK_TTL_MS,
).toISOString();
const values = {
sshCert: await ctx.secrets.seal(sshCert),
privateKey: await ctx.secrets.seal(privateKey),
createdAt: new Date().toISOString(),
expiresAt,
lastUsed: null,
};
if (await find(userId, profileId)) {
await (
await client()
)
.update(table)
.set(values)
.where(match(userId, profileId));
} else {
await (
await client()
)
.insert(table)
.values({ userId, profileId, ...values });
}
await ctx.db.persist();
return expiresAt;
},
/** A usable certificate, or null. Expired or unreadable rows are deleted. */
async get(
userId: string,
profileId: number,
): Promise<CachedCertificate | null> {
const row = await find(userId, profileId);
if (!row) return null;
const expiresAt = new Date(row.expiresAt).getTime();
if (
!Number.isFinite(expiresAt) ||
expiresAt - EXPIRY_SKEW_MS < Date.now()
) {
await remove(userId, profileId);
return null;
}
const sshCert = await ctx.secrets.unseal(row.sshCert);
const privateKey = await ctx.secrets.unseal(row.privateKey);
if (!sshCert || !privateKey) {
await remove(userId, profileId);
return null;
}
await (
await client()
)
.update(table)
.set({ lastUsed: new Date().toISOString() })
.where(match(userId, profileId));
await ctx.db.persist({ lazy: true });
return { sshCert, privateKey };
},
remove,
};
}
+286
View File
@@ -0,0 +1,286 @@
import crypto from "node:crypto";
import ssh2Pkg from "ssh2";
import type { PluginFetch } from "@termix/plugin-sdk/backend";
const { utils: ssh2Utils } = ssh2Pkg;
/**
* Vault's HTTP API for SSH signing: an OIDC login for a short-lived Vault
* token, then the SSH secrets engine signs an ephemeral public key. No Vault
* token or long-lived key is ever stored.
*/
export interface VaultProfileConfig {
id: number;
vaultAddr: string;
vaultNamespace?: string | null;
oidcMount?: string | null;
oidcRole?: string | null;
sshMount?: string | null;
sshRole: string;
validPrincipals?: string | null;
keyType?: string | null;
}
export interface EphemeralKeyPair {
privateKey: string;
publicKey: string;
}
/* eslint-disable @typescript-eslint/no-explicit-any */
type VaultJson = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export function normalizeAddr(addr: string): string {
return addr.trim().replace(/\/+$/, "");
}
export function trimMount(
mount: string | null | undefined,
fallback: string,
): string {
return (mount?.trim() || fallback).replace(/^\/+|\/+$/g, "");
}
function vaultHeaders(profile: VaultProfileConfig): Record<string, string> {
const headers: Record<string, string> = {};
if (profile.vaultNamespace?.trim()) {
headers["X-Vault-Namespace"] = profile.vaultNamespace.trim();
}
return headers;
}
/**
* The profile's own Vault server may be private: someone configured it by
* name, which 2.8 allowed too. Nothing else private is reachable.
*/
export function allowedHosts(profile: VaultProfileConfig): string[] {
try {
return [new URL(normalizeAddr(profile.vaultAddr)).hostname];
} catch {
return [];
}
}
async function vaultRequest(
fetch: PluginFetch,
profile: VaultProfileConfig,
url: string,
method: "GET" | "POST",
headers: Record<string, string>,
body?: Record<string, unknown>,
): Promise<VaultJson> {
let response: Response;
try {
response = await fetch(url, {
method,
headers: {
...(body ? { "Content-Type": "application/json" } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
allowPrivateHosts: allowedHosts(profile),
});
} catch (e) {
throw new Error(
`Failed to reach Vault at ${url}: ${
e instanceof Error ? e.message : String(e)
}`,
);
}
const text = await response.text();
let json: VaultJson;
if (text) {
try {
json = JSON.parse(text);
} catch {
// not JSON
}
}
if (!response.ok) {
const errs =
json && Array.isArray(json.errors) && json.errors.length
? json.errors.join("; ")
: text || `HTTP ${response.status}`;
throw new Error(`Vault request failed (${response.status}): ${errs}`);
}
return json;
}
/** An ephemeral SSH keypair in OpenSSH format. */
export function generateEphemeralKeyPair(
keyType?: string | null,
): EphemeralKeyPair {
let type: "ed25519" | "rsa" | "ecdsa" = "ed25519";
const options: { bits?: number } = {};
switch ((keyType || "ssh-ed25519").trim()) {
case "ssh-rsa":
type = "rsa";
options.bits = 4096;
break;
case "ecdsa-sha2-nistp256":
type = "ecdsa";
options.bits = 256;
break;
default:
type = "ed25519";
}
const pair = ssh2Utils.generateKeyPairSync(type as never, options as never);
return { privateKey: pair.private, publicKey: pair.public };
}
/**
* Starts a Vault OIDC login. `redirectUri` must be allowed by the Vault
* role's allowed_redirect_uris and by the identity provider.
*/
export async function startVaultOidc(
fetch: PluginFetch,
profile: VaultProfileConfig,
redirectUri: string,
): Promise<{ authUrl: string; state: string; clientNonce: string }> {
const addr = normalizeAddr(profile.vaultAddr);
const mount = trimMount(profile.oidcMount, "oidc");
const clientNonce = crypto.randomBytes(20).toString("hex");
const json = await vaultRequest(
fetch,
profile,
`${addr}/v1/auth/${mount}/oidc/auth_url`,
"POST",
vaultHeaders(profile),
{
role: profile.oidcRole?.trim() || "",
redirect_uri: redirectUri,
client_nonce: clientNonce,
},
);
const authUrl: string | undefined = json?.data?.auth_url;
if (!authUrl) {
throw new Error("Vault did not return an OIDC auth_url");
}
// Vault puts its own state in the auth_url; the callback is matched on it.
let state = "";
try {
state = new URL(authUrl).searchParams.get("state") || "";
} catch {
const m = authUrl.match(/[?&]state=([^&]+)/);
state = m ? decodeURIComponent(m[1]) : "";
}
if (!state) {
throw new Error("Could not determine OIDC state from Vault auth_url");
}
return { authUrl, state, clientNonce };
}
/** Completes the OIDC callback and returns a short-lived Vault token. */
export async function completeVaultOidc(
fetch: PluginFetch,
profile: VaultProfileConfig,
params: { state: string; code: string; clientNonce: string },
): Promise<string> {
const addr = normalizeAddr(profile.vaultAddr);
const mount = trimMount(profile.oidcMount, "oidc");
const url = new URL(`${addr}/v1/auth/${mount}/oidc/callback`);
url.searchParams.set("state", params.state);
url.searchParams.set("code", params.code);
url.searchParams.set("client_nonce", params.clientNonce);
const json = await vaultRequest(
fetch,
profile,
url.toString(),
"GET",
vaultHeaders(profile),
);
const token: string | undefined = json?.auth?.client_token;
if (!token) {
throw new Error("Vault OIDC callback did not return a client token");
}
return token;
}
/** Signs an SSH public key with Vault, returning the OpenSSH certificate line. */
export async function signWithVault(
fetch: PluginFetch,
profile: VaultProfileConfig,
vaultToken: string,
publicKey: string,
): Promise<string> {
const addr = normalizeAddr(profile.vaultAddr);
const mount = trimMount(profile.sshMount, "ssh-client-signer");
const body: Record<string, unknown> = {
public_key: publicKey.trim(),
cert_type: "user",
};
if (profile.validPrincipals?.trim()) {
body.valid_principals = profile.validPrincipals.trim();
}
const json = await vaultRequest(
fetch,
profile,
`${addr}/v1/${mount}/sign/${encodeURIComponent(profile.sshRole.trim())}`,
"POST",
{ ...vaultHeaders(profile), "X-Vault-Token": vaultToken },
body,
);
const signedKey: string | undefined = json?.data?.signed_key;
if (!signedKey) {
throw new Error("Vault sign response did not include a signed_key");
}
return signedKey.trim();
}
/**
* The "valid before" Unix timestamp of an OpenSSH certificate, or 0 when it
* cannot be parsed (the caller then uses a short fallback).
*/
export function parseCertValidBefore(signedKey: string): number {
try {
const parts = signedKey.trim().split(/\s+/);
if (parts.length < 2) return 0;
const certType = parts[0];
const blob = Buffer.from(parts[1], "base64");
let pos = 0;
const skipString = (): void => {
const len = blob.readUInt32BE(pos);
pos += 4 + len;
};
const readUint64 = (): number => {
const hi = blob.readUInt32BE(pos);
const lo = blob.readUInt32BE(pos + 4);
pos += 8;
return hi * 0x100000000 + lo;
};
skipString(); // format id
skipString(); // nonce
let keyFields: number;
if (certType.startsWith("ssh-ed25519")) keyFields = 1;
else if (certType.startsWith("ecdsa-sha2-")) keyFields = 2;
else if (certType.startsWith("ssh-rsa")) keyFields = 2;
else if (certType.startsWith("ssh-dss")) keyFields = 4;
else if (certType.startsWith("sk-ssh-ed25519")) keyFields = 2;
else if (certType.startsWith("sk-ecdsa-sha2-")) keyFields = 3;
else return 0;
for (let i = 0; i < keyFields; i++) skipString();
readUint64(); // serial
pos += 4; // type
skipString(); // key id
skipString(); // valid principals
readUint64(); // valid after
return readUint64();
} catch {
return 0;
}
}
@@ -0,0 +1,54 @@
import { toast } from "sonner";
import {
useTranslation,
type SettingsComponentProps,
} from "@termix/plugin-sdk/frontend";
import { Button, Input, copyToClipboard } from "@termix/plugin-sdk/ui";
const CALLBACK_PATH = "plugin-api/vault/oidc/callback";
const LEGACY_CALLBACK_PATH = "vault/oidc/callback";
/** The redirect URI to allow in the Vault OIDC role. */
export function redirectUri(
legacy: boolean,
baseUri = document.baseURI,
): string {
return new URL(
legacy ? LEGACY_CALLBACK_PATH : CALLBACK_PATH,
baseUri,
).toString();
}
export function RedirectUriSetting({ values }: SettingsComponentProps) {
const { t } = useTranslation();
const uri = redirectUri(values.legacyCallback === true);
return (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium">
{t("settings.redirectUri.label")}
</span>
<div className="flex gap-2">
<Input
value={uri}
readOnly
className="rounded-none font-mono text-xs"
/>
<Button
type="button"
variant="outline"
className="rounded-none"
onClick={async () => {
if (await copyToClipboard(uri)) toast.success(t("common.copied"));
else toast.error(t("common.copyFailed"));
}}
>
{t("common.copy")}
</Button>
</div>
<p className="text-[10px] text-muted-foreground">
{t("settings.redirectUri.description")}
</p>
</div>
);
}
@@ -0,0 +1,108 @@
import { useCallback, useEffect, useState } from "react";
import {
usePermission,
usePluginApi,
useTranslation,
type SshAuthEditorProps,
} from "@termix/plugin-sdk/frontend";
import { Select2 } from "@termix/plugin-sdk/ui";
import { VaultProfileManager } from "./VaultProfileManager";
import type { VaultProfile } from "./types";
const PLUGIN_ID = "vault";
const DOCS_URL = "https://docs.termix.site/features/authentication/vault";
type PluginSettingsBag = Record<string, Record<string, unknown>>;
/**
* The host editor's "vault" auth method: pick a signer profile, and manage
* profiles in place. The choice is the profileId host setting, which the
* editor saves with the rest of the host's plugin settings.
*/
export function VaultAuthEditor({ form, setField }: SshAuthEditorProps) {
const { t } = useTranslation();
const api = usePluginApi();
const canUse = usePermission("use");
const [profiles, setProfiles] = useState<VaultProfile[]>([]);
const [showManager, setShowManager] = useState(false);
const bag = (form.pluginSettings as PluginSettingsBag | undefined) ?? {};
const profileId = bag[PLUGIN_ID]?.profileId;
const reload = useCallback(() => {
if (!canUse) return;
api
.get<VaultProfile[]>("/profiles")
.then(({ data }) => setProfiles(Array.isArray(data) ? data : []))
.catch(() => setProfiles([]));
}, [api, canUse]);
useEffect(() => {
reload();
}, [reload]);
const select = (value: string) => {
setField("pluginSettings", {
...bag,
[PLUGIN_ID]: {
...(bag[PLUGIN_ID] ?? {}),
profileId: value ? Number(value) : null,
},
});
};
return (
<>
<div className="flex flex-col gap-1.5 col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("editor.profile")}
</label>
<Select2
value={profileId != null ? String(profileId) : ""}
onChange={(e) => select(e.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("editor.selectProfile")}</option>
{profiles.map((profile) => (
<option key={profile.id} value={String(profile.id)}>
{profile.shared
? t("editor.sharedProfile", { name: profile.name })
: profile.name}
</option>
))}
</Select2>
<div className="flex items-center justify-between">
<p className="text-[10px] text-muted-foreground">
{t("editor.hint")}
</p>
<div className="flex items-center gap-2 shrink-0">
<a
href={DOCS_URL}
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("editor.docs")}
</a>
{canUse && (
<button
type="button"
className="text-[10px] text-accent-brand hover:text-accent-brand/80"
onClick={() => setShowManager((shown) => !shown)}
>
{t("profiles.manage")}
</button>
)}
</div>
</div>
</div>
{showManager && (
<VaultProfileManager
profiles={profiles}
onChanged={reload}
onClose={() => setShowManager(false)}
/>
)}
</>
);
}
+245
View File
@@ -0,0 +1,245 @@
import { useEffect, useRef, useState } from "react";
import { AlertCircle, KeyRound, Loader2 } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Button } from "@termix/plugin-sdk/ui";
/** What the ssh-terminal plugin hands a "terminal.overlay" component. */
export interface TerminalOverlayProps {
host: { id: number | string; [key: string]: unknown } | undefined;
backgroundColor?: string;
subscribe: (
listener: (message: { type: string; [key: string]: unknown }) => void,
) => () => void;
holdConnectTimeout: (held: boolean) => void;
fail: (message: string) => void;
disconnect: () => void;
send: (type: string, data?: unknown) => void;
connectPayload: () => Record<string, unknown>;
}
interface DialogState {
stage: "waiting" | "error";
error?: string;
hostId?: unknown;
requestId?: string;
}
/** How long the dialog waits before giving up on the connection. */
const DIALOG_TIMEOUT_MS = 300_000;
const footerButton =
"rounded-none text-[10px] font-bold uppercase tracking-widest";
/**
* Runs the Vault OIDC sign-in for a terminal: asks the server to start it,
* opens Vault's auth URL in a popup, and reconnects once Vault has signed
* the certificate.
*/
export function VaultOverlay({
subscribe,
holdConnectTimeout,
fail,
disconnect,
send,
connectPayload,
backgroundColor,
}: TerminalOverlayProps) {
const { t } = useTranslation();
const [dialog, setDialog] = useState<DialogState | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const popupRef = useRef<Window | null>(null);
const heldRef = useRef(false);
// A second "auth required" after a sign-in means the certificate did not work.
const failedRef = useRef(false);
useEffect(() => {
const clearTimer = () => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
};
const closePopup = () => {
try {
popupRef.current?.close();
} catch {
// already closed
}
popupRef.current = null;
};
const hold = (held: boolean) => {
if (heldRef.current === held) return;
heldRef.current = held;
holdConnectTimeout(held);
};
const unsubscribe = subscribe((message) => {
switch (message.type) {
case "vault_auth_required":
hold(true);
if (failedRef.current) {
clearTimer();
hold(false);
setDialog({ stage: "error", error: t("dialog.failed") });
fail(t("dialog.failed"));
} else {
failedRef.current = true;
send("vault_start_auth", { hostId: message.hostId });
}
break;
case "vault_auth_url":
try {
popupRef.current = window.open(
String(message.url ?? ""),
"termix-vault-oidc",
"width=540,height=720",
);
} catch {
popupRef.current = null;
}
setDialog({
stage: "waiting",
hostId: message.hostId,
requestId:
typeof message.requestId === "string"
? message.requestId
: undefined,
});
clearTimer();
timerRef.current = setTimeout(() => {
setDialog(null);
hold(false);
disconnect();
}, DIALOG_TIMEOUT_MS);
break;
case "vault_completed":
clearTimer();
closePopup();
setDialog(null);
send("vault_auth_completed", connectPayload());
break;
case "vault_error":
failedRef.current = true;
clearTimer();
closePopup();
hold(false);
setDialog({ stage: "error", error: String(message.error ?? "") });
break;
case "connected":
case "sessionAttached":
failedRef.current = false;
hold(false);
break;
case "session_closed":
clearTimer();
setDialog(null);
hold(false);
break;
}
});
return () => {
unsubscribe();
clearTimer();
if (heldRef.current) {
heldRef.current = false;
holdConnectTimeout(false);
}
};
}, [
subscribe,
holdConnectTimeout,
fail,
disconnect,
send,
connectPayload,
t,
]);
if (!dialog) return null;
const close = () => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
try {
popupRef.current?.close();
} catch {
// already closed
}
if (dialog.stage === "waiting") {
send("vault_cancel", {
hostId: dialog.hostId,
requestId: dialog.requestId,
});
}
setDialog(null);
if (heldRef.current) {
heldRef.current = false;
holdConnectTimeout(false);
}
};
return (
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
<div
className="absolute inset-0 bg-canvas"
style={{ backgroundColor: backgroundColor || undefined }}
/>
<div className="bg-card border border-border w-full max-w-md mx-4 relative z-10">
<div className="p-4 border-b border-border">
<div className="flex items-center gap-2">
<KeyRound className="size-4 text-accent-brand" />
<h3 className="text-xs font-bold uppercase tracking-widest">
{dialog.stage === "error"
? t("dialog.failed")
: t("dialog.title")}
</h3>
</div>
</div>
<div className="p-4 flex flex-col gap-4">
{dialog.stage === "waiting" ? (
<div className="flex items-center gap-3 py-2">
<Loader2 className="size-4 animate-spin text-accent-brand shrink-0" />
<p className="text-xs text-muted-foreground">
{t("dialog.description")}
</p>
</div>
) : (
<div className="flex items-start gap-3 p-3 border border-destructive/20 bg-destructive/10">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<p className="text-xs text-destructive/90 whitespace-pre-wrap break-words">
{dialog.error || t("dialog.failed")}
</p>
</div>
)}
<div className="flex justify-end gap-2">
{dialog.stage === "waiting" && (
<Button
type="button"
variant="outline"
className={footerButton}
onClick={() => {
try {
popupRef.current?.focus();
} catch {
// popup gone
}
}}
>
{t("dialog.reopen")}
</Button>
)}
<Button
type="button"
variant="ghost"
className={footerButton}
onClick={close}
>
{dialog.stage === "error"
? t("common.close")
: t("common.cancel")}
</Button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,286 @@
import { useState } from "react";
import { Pencil, Plus, Trash2, X } from "lucide-react";
import {
usePermission,
usePluginApi,
useToast,
useTranslation,
} from "@termix/plugin-sdk/frontend";
import { Button, Input, Select2 } from "@termix/plugin-sdk/ui";
import {
errorMessage,
type VaultProfile,
type VaultProfilePayload,
} from "./types";
type FormState = {
id?: number;
name: string;
vaultAddr: string;
vaultNamespace: string;
oidcMount: string;
oidcRole: string;
sshMount: string;
sshRole: string;
validPrincipals: string;
keyType: string;
shared: boolean;
};
type TextKey = Exclude<keyof FormState, "id" | "shared">;
const emptyForm: FormState = {
name: "",
vaultAddr: "",
vaultNamespace: "",
oidcMount: "oidc",
oidcRole: "",
sshMount: "ssh-client-signer",
sshRole: "",
validPrincipals: "",
keyType: "ssh-ed25519",
shared: false,
};
function toForm(p: VaultProfile): FormState {
return {
id: p.id,
name: p.name,
vaultAddr: p.vaultAddr,
vaultNamespace: p.vaultNamespace ?? "",
oidcMount: p.oidcMount ?? "oidc",
oidcRole: p.oidcRole ?? "",
sshMount: p.sshMount ?? "ssh-client-signer",
sshRole: p.sshRole,
validPrincipals: p.validPrincipals ?? "",
keyType: p.keyType ?? "ssh-ed25519",
shared: p.shared,
};
}
const blank = (value: string) => value.trim() || null;
/** Create, edit and delete the caller's Vault profiles. */
export function VaultProfileManager({
profiles,
onChanged,
onClose,
}: {
profiles: VaultProfile[];
onChanged: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const api = usePluginApi();
const toast = useToast();
const canShare = usePermission("share");
const [form, setForm] = useState<FormState | null>(null);
const [saving, setSaving] = useState(false);
const setField = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((current) => (current ? { ...current, [key]: value } : current));
const handleSave = async () => {
if (!form) return;
if (!form.name.trim() || !form.vaultAddr.trim() || !form.sshRole.trim()) {
toast.error(t("profiles.validationError"));
return;
}
setSaving(true);
try {
const payload: VaultProfilePayload = {
name: form.name.trim(),
vaultAddr: form.vaultAddr.trim(),
vaultNamespace: blank(form.vaultNamespace),
oidcMount: blank(form.oidcMount),
oidcRole: blank(form.oidcRole),
sshMount: blank(form.sshMount),
sshRole: form.sshRole.trim(),
validPrincipals: blank(form.validPrincipals),
keyType: blank(form.keyType),
shared: canShare && form.shared,
};
if (form.id) {
await api.put(`/profiles/${form.id}`, payload);
toast.success(t("profiles.saved"));
} else {
await api.post("/profiles", payload);
toast.success(t("profiles.created"));
}
setForm(null);
onChanged();
} catch (error) {
toast.error(errorMessage(error, t("profiles.saveFailed")));
} finally {
setSaving(false);
}
};
const handleDelete = async (profile: VaultProfile) => {
try {
await api.delete(`/profiles/${profile.id}`);
toast.success(t("profiles.deleted"));
onChanged();
} catch (error) {
toast.error(errorMessage(error, t("profiles.deleteFailed")));
}
};
const field = (label: string, key: TextKey, placeholder?: string) => (
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{label}
</label>
<Input
className="h-8 text-xs rounded-none"
placeholder={placeholder}
value={form?.[key] ?? ""}
onChange={(e) => setField(key, e.target.value)}
/>
</div>
);
return (
<div className="flex flex-col gap-3 col-span-2 border border-border bg-muted/20 p-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("profiles.manage")}
</span>
<button
type="button"
aria-label={t("common.close")}
onClick={onClose}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
</div>
{!form && (
<>
<div className="flex flex-col divide-y divide-border/50">
{profiles.length === 0 && (
<span className="text-[11px] text-muted-foreground py-1">
{t("profiles.none")}
</span>
)}
{profiles.map((profile) => (
<div
key={profile.id}
className="flex items-center justify-between py-1.5"
>
<div className="flex flex-col min-w-0">
<span className="text-xs text-foreground truncate">
{profile.name}
{profile.shared && (
<span className="ml-1 text-[9px] text-accent-brand">
{t("profiles.sharedBadge")}
</span>
)}
</span>
<span className="text-[10px] text-muted-foreground truncate">
{profile.vaultAddr}
</span>
</div>
{profile.owned && (
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
title={t("profiles.edit")}
onClick={() => setForm(toForm(profile))}
className="size-7 flex items-center justify-center text-muted-foreground/60 hover:text-foreground hover:bg-muted-foreground/10"
>
<Pencil className="size-3.5" />
</button>
<button
type="button"
title={t("profiles.delete")}
onClick={() => handleDelete(profile)}
className="size-7 flex items-center justify-center text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="size-3.5" />
</button>
</div>
)}
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-[10px] self-start rounded-none"
onClick={() => setForm({ ...emptyForm })}
>
<Plus className="size-3 mr-1" /> {t("profiles.new")}
</Button>
</>
)}
{form && (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{field(t("profiles.name"), "name", "Production Vault")}
{field(t("profiles.vaultAddr"), "vaultAddr", "https://vault:8200")}
{field(t("profiles.namespace"), "vaultNamespace", "admin")}
{field(t("profiles.oidcMount"), "oidcMount", "oidc")}
{field(t("profiles.oidcRole"), "oidcRole", "default")}
{field(t("profiles.sshMount"), "sshMount", "ssh-client-signer")}
{field(t("profiles.sshRole"), "sshRole", "my-role")}
{field(
t("profiles.validPrincipals"),
"validPrincipals",
"root,deploy",
)}
</div>
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("profiles.keyType")}
</label>
<Select2
value={form.keyType}
onChange={(e) => setField("keyType", e.target.value)}
className="flex h-8 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="ssh-ed25519">Ed25519</option>
<option value="ecdsa-sha2-nistp256">ECDSA (nistp256)</option>
<option value="ssh-rsa">RSA (4096)</option>
</Select2>
</div>
{canShare && (
<label className="flex items-center gap-2 text-xs text-foreground">
<input
type="checkbox"
checked={form.shared}
onChange={(e) => setField("shared", e.target.checked)}
/>
{t("profiles.shareWithAll")}
</label>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="rounded-none"
onClick={() => setForm(null)}
disabled={saving}
>
{t("common.cancel")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="rounded-none border-accent-brand/40 text-accent-brand"
onClick={handleSave}
disabled={saving}
>
{form.id ? t("profiles.save") : t("profiles.create")}
</Button>
</div>
</div>
)}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { ComponentType } from "react";
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { VaultAuthEditor } from "./VaultAuthEditor";
import { VaultOverlay } from "./VaultOverlay";
import { RedirectUriSetting } from "./RedirectUriSetting";
export function activate(app: TermixApp): void {
app.registerSshAuthEditor({
authType: "vault",
titleKey: "hosts.filterAuthVault",
component: VaultAuthEditor,
});
// The Vault sign-in while a terminal connects to a Vault host.
app.registerSlotContribution("terminal.overlay", {
actionId: "vault.signIn",
titleKey: "dialog.title",
kind: "component",
component: VaultOverlay as unknown as ComponentType<
Record<string, unknown>
>,
});
app.registerSettingsComponent("redirectUri", RedirectUriSetting);
}
export function deactivate(): void {
// Registrations through app are disposed by core.
}
+34
View File
@@ -0,0 +1,34 @@
/** A Vault signer profile as GET /profiles returns it. */
export interface VaultProfile {
id: number;
name: string;
vaultAddr: string;
vaultNamespace: string | null;
oidcMount: string | null;
oidcRole: string | null;
sshMount: string | null;
sshRole: string;
validPrincipals: string | null;
keyType: string | null;
shared: boolean;
owned: boolean;
}
export interface VaultProfilePayload {
name: string;
vaultAddr: string;
vaultNamespace: string | null;
oidcMount: string | null;
oidcRole: string | null;
sshMount: string | null;
sshRole: string;
validPrincipals: string | null;
keyType: string | null;
shared: boolean;
}
export function errorMessage(error: unknown, fallback: string): string {
const data = (error as { response?: { data?: { error?: unknown } } })
?.response?.data;
return typeof data?.error === "string" ? data.error : fallback;
}
+174
View File
@@ -0,0 +1,174 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
type TestDbOptions,
} from "@termix/plugin-sdk/testing";
import type {
PluginFetchInit,
PluginSshAuthProvider,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export const VAULT_ADDR = "https://vault.internal:8200";
export const SIGNED_CERT = "ssh-ed25519-cert-v01@openssh.com AAAAsigned";
/** A fake Vault answering the three calls the plugin makes. */
export function createFakeVault(
options: { signStatus?: number; state?: string } = {},
) {
const calls: Array<{ url: string; init?: PluginFetchInit }> = [];
const state = options.state ?? "vault-state-1";
const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status });
const fetch = async (url: string, init?: PluginFetchInit) => {
calls.push({ url, init });
const { pathname } = new URL(url);
if (pathname === "/v1/auth/oidc/oidc/auth_url") {
return json(200, {
data: {
auth_url: `https://idp.test/authorize?client_id=termix&state=${state}`,
},
});
}
if (pathname === "/v1/auth/oidc/oidc/callback") {
return json(200, { auth: { client_token: "hvs.token" } });
}
if (pathname.startsWith("/v1/ssh-client-signer/sign/")) {
if (options.signStatus && options.signStatus !== 200) {
return json(options.signStatus, { errors: ["permission denied"] });
}
return json(200, { data: { signed_key: SIGNED_CERT } });
}
return json(404, { errors: ["not found"] });
};
return { fetch, calls, state };
}
/** A fake terminal socket that records what it was sent. */
export function fakeSocket() {
const sent: Array<Record<string, unknown>> = [];
return {
sent,
socket: { send: (data: string) => sent.push(JSON.parse(data)) },
};
}
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
provider: () => PluginSshAuthProvider;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any; text: string }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* the way core mounts it: JSON parsed and the acting user taken from the
* request.
*/
export async function startServer(
options: {
permissions?: string[];
settings?: Record<string, unknown>;
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
before?: TestDbOptions["before"];
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir, { before: options.before });
for (const user of ["user-1", "user-2"]) {
db.sqlite.prepare("INSERT OR IGNORE INTO users (id) VALUES (?)").run(user);
}
db.sqlite.prepare("INSERT OR IGNORE INTO ssh_data (id) VALUES (?)").run(7);
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions,
settings: options.settings,
fetch: options.fetch,
actor: "user-1",
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
provider: () =>
mock.auth.sshAuthProviders.find((provider) => provider.type === "vault")!,
async request(method, path, { user = "user-1", body } = {}) {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
redirect: "manual",
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
let parsed: unknown = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = null;
}
return { status: response.status, body: parsed, text };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
export const env = (userId = "user-1", hostId = 7) => ({
client: {},
userId,
hostId,
purpose: "terminal",
interactive: true,
log: () => {},
});
export const HOST = { id: 7, ip: "10.0.0.7", port: 22, username: "root" };
export const PROFILE = {
name: "Prod",
vaultAddr: VAULT_ADDR,
sshRole: "ops",
oidcRole: "dev",
};
@@ -0,0 +1,121 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers";
// vault_profiles and vault_tokens as core's SQLite bootstrap created them
// before 2.9.0, including the sync_id column added later.
const LEGACY_DDL = `
CREATE TABLE vault_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
folder TEXT,
tags TEXT,
vault_addr TEXT NOT NULL,
vault_namespace TEXT,
oidc_mount TEXT,
oidc_role TEXT,
ssh_mount TEXT,
ssh_role TEXT NOT NULL,
valid_principals TEXT,
key_type TEXT,
shared INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
sync_id TEXT,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE vault_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
profile_id INTEGER NOT NULL,
ssh_cert TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TEXT NOT NULL,
last_used TEXT,
UNIQUE(user_id, profile_id),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (profile_id) REFERENCES vault_profiles (id) ON DELETE CASCADE
);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
describe("adopting vault_profiles and vault_tokens", () => {
it("keeps every existing row when the legacy tables are there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
sqlite.exec(LEGACY_DDL);
sqlite
.prepare(
"INSERT INTO vault_profiles (id, user_id, name, vault_addr, ssh_role, shared, sync_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.run(4, "user-1", "Prod", "https://vault:8200", "ops", 1, "sync-a");
sqlite
.prepare(
"INSERT INTO vault_tokens (user_id, profile_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)",
)
.run("user-2", 4, "cert", "key", "2999-01-01T00:00:00.000Z");
},
});
expect(tableExists("vault_profiles")).toBe(false);
expect(tableExists("vault_tokens")).toBe(false);
expect(db.sqlite.prepare("SELECT * FROM p_vault_profiles").all()).toEqual([
expect.objectContaining({
id: 4,
user_id: "user-1",
name: "Prod",
shared: 1,
sync_id: "sync-a",
}),
]);
expect(db.sqlite.prepare("SELECT * FROM p_vault_tokens").all()).toEqual([
expect.objectContaining({ user_id: "user-2", profile_id: 4 }),
]);
// The 2.8 foreign key follows the rename, so deleting a profile still
// takes its cached certificates with it.
db.sqlite.exec("PRAGMA foreign_keys = ON");
db.sqlite.prepare("DELETE FROM p_vault_profiles WHERE id = 4").run();
expect(
db.sqlite.prepare("SELECT COUNT(*) AS n FROM p_vault_tokens").get(),
).toEqual({ n: 0 });
});
it("creates both tables on a fresh install", async () => {
db = await createTestDb(pluginDir);
expect(tableExists("p_vault_profiles")).toBe(true);
expect(tableExists("p_vault_tokens")).toBe(true);
expect(
db.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_vault_tokens_user_profile'",
)
.get(),
).toBeTruthy();
});
it("keeps one certificate per user and profile", async () => {
db = await createTestDb(pluginDir);
db.sqlite.exec("INSERT INTO users (id) VALUES ('user-1')");
const insert = db.sqlite.prepare(
"INSERT INTO p_vault_tokens (user_id, profile_id, ssh_cert, private_key, expires_at) VALUES ('user-1', 1, 'c', 'k', 'x')",
);
insert.run();
expect(() => insert.run()).toThrow(/UNIQUE/);
});
});
@@ -0,0 +1,216 @@
import { describe, it, expect, beforeAll } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import ssh2Pkg from "ssh2";
import type { PluginFetchInit } from "@termix/plugin-sdk/backend";
import {
allowedHosts,
completeVaultOidc,
generateEphemeralKeyPair,
parseCertValidBefore,
signWithVault,
startVaultOidc,
type VaultProfileConfig,
} from "../../src/backend/vault-client.js";
const { utils: ssh2Utils } = ssh2Pkg;
describe("generateEphemeralKeyPair", () => {
for (const keyType of [
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ssh-rsa",
] as const) {
it(`generates a parseable ${keyType} keypair`, () => {
const pair = generateEphemeralKeyPair(keyType);
expect(pair.privateKey).toContain("BEGIN OPENSSH PRIVATE KEY");
expect(pair.publicKey.split(/\s+/)[0]).toBe(keyType);
expect(ssh2Utils.parseKey(pair.privateKey) instanceof Error).toBe(false);
expect(ssh2Utils.parseKey(pair.publicKey) instanceof Error).toBe(false);
});
}
it("defaults to ed25519 for unknown key types", () => {
const pair = generateEphemeralKeyPair("nonsense");
expect(pair.publicKey.startsWith("ssh-ed25519")).toBe(true);
});
});
describe("parseCertValidBefore", () => {
let cert = "";
let signedAt = 0;
let haveSshKeygen = true;
beforeAll(() => {
const dir = mkdtempSync(path.join(os.tmpdir(), "vault-cert-test-"));
try {
for (const name of ["ca", "user"]) {
execFileSync("ssh-keygen", [
"-t",
"ed25519",
"-f",
`${dir}/${name}`,
"-N",
"",
"-q",
]);
}
signedAt = Math.floor(Date.now() / 1000);
execFileSync("ssh-keygen", [
"-s",
`${dir}/ca`,
"-I",
"test-id",
"-n",
"root",
"-V",
"+60m",
`${dir}/user.pub`,
]);
cert = readFileSync(`${dir}/user-cert.pub`, "utf8").trim();
} catch {
haveSshKeygen = false;
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("reads valid_before from a real ssh-keygen certificate", () => {
if (!haveSshKeygen) return;
const validBefore = parseCertValidBefore(cert);
expect(validBefore).toBeGreaterThan(signedAt + 3300);
expect(validBefore).toBeLessThan(signedAt + 3900);
});
it("returns 0 for malformed input", () => {
expect(parseCertValidBefore("")).toBe(0);
expect(parseCertValidBefore("not-a-cert")).toBe(0);
expect(parseCertValidBefore("ssh-ed25519 AAAAnotbase64!!")).toBe(0);
});
});
describe("the Vault HTTP flow", () => {
const profile: VaultProfileConfig = {
id: 1,
vaultAddr: "https://vault.example.com:8200/",
vaultNamespace: "team-a",
oidcMount: "oidc",
oidcRole: "developer",
sshMount: "ssh-client-signer",
sshRole: "my-role",
validPrincipals: "root,deploy",
keyType: "ssh-ed25519",
};
function fakeFetch(respond: () => { status?: number; body: unknown }) {
const calls: Array<{ url: string; init?: PluginFetchInit }> = [];
const fetch = async (url: string, init?: PluginFetchInit) => {
calls.push({ url, init });
const { status = 200, body } = respond();
return new Response(JSON.stringify(body), { status });
};
return { fetch, calls };
}
it("allows only the profile's own Vault host past the private address guard", () => {
expect(allowedHosts(profile)).toEqual(["vault.example.com"]);
expect(allowedHosts({ ...profile, vaultAddr: "not a url" })).toEqual([]);
});
it("startVaultOidc posts auth_url and reads the state", async () => {
const { fetch, calls } = fakeFetch(() => ({
body: {
data: {
auth_url:
"https://idp.example.com/authorize?client_id=x&state=ST-abc123&nonce=n",
},
},
}));
const result = await startVaultOidc(
fetch,
profile,
"https://termix/plugin-api/vault/oidc/callback",
);
expect(result.state).toBe("ST-abc123");
expect(result.clientNonce).toMatch(/^[0-9a-f]{40}$/);
expect(calls[0].url).toBe(
"https://vault.example.com:8200/v1/auth/oidc/oidc/auth_url",
);
expect(calls[0].init?.method).toBe("POST");
expect(calls[0].init?.headers?.["X-Vault-Namespace"]).toBe("team-a");
expect(JSON.parse(String(calls[0].init?.body))).toMatchObject({
role: "developer",
redirect_uri: "https://termix/plugin-api/vault/oidc/callback",
client_nonce: result.clientNonce,
});
});
it("completeVaultOidc returns the client token", async () => {
const { fetch, calls } = fakeFetch(() => ({
body: { auth: { client_token: "hvs.TESTTOKEN" } },
}));
const token = await completeVaultOidc(fetch, profile, {
state: "ST-abc123",
code: "auth-code",
clientNonce: "nonce123",
});
expect(token).toBe("hvs.TESTTOKEN");
const url = new URL(calls[0].url);
expect(url.pathname).toBe("/v1/auth/oidc/oidc/callback");
expect(url.searchParams.get("state")).toBe("ST-abc123");
expect(url.searchParams.get("code")).toBe("auth-code");
expect(url.searchParams.get("client_nonce")).toBe("nonce123");
expect(calls[0].init?.method).toBe("GET");
});
it("signWithVault posts the public key and returns signed_key", async () => {
const { fetch, calls } = fakeFetch(() => ({
body: {
data: { signed_key: "ssh-ed25519-cert-v01@openssh.com AAAAcert" },
},
}));
const cert = await signWithVault(
fetch,
profile,
"hvs.TESTTOKEN",
"ssh-ed25519 AAAApub comment",
);
expect(cert).toBe("ssh-ed25519-cert-v01@openssh.com AAAAcert");
expect(calls[0].url).toBe(
"https://vault.example.com:8200/v1/ssh-client-signer/sign/my-role",
);
expect(calls[0].init?.headers?.["X-Vault-Token"]).toBe("hvs.TESTTOKEN");
expect(JSON.parse(String(calls[0].init?.body))).toMatchObject({
public_key: "ssh-ed25519 AAAApub comment",
cert_type: "user",
valid_principals: "root,deploy",
});
});
it("surfaces Vault error messages", async () => {
const { fetch } = fakeFetch(() => ({
status: 400,
body: { errors: ["role not found", "permission denied"] },
}));
await expect(
signWithVault(fetch, profile, "tok", "ssh-ed25519 AAAA"),
).rejects.toThrow(/role not found; permission denied/);
});
it("reports an unreachable Vault", async () => {
const fetch = async () => {
throw new Error("refused");
};
await expect(
startVaultOidc(fetch, profile, "https://termix/cb"),
).rejects.toThrow(/Failed to reach Vault/);
});
});
+471
View File
@@ -0,0 +1,471 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { createMockCtx, createTestDb } from "@termix/plugin-sdk/testing";
import {
HOST,
PROFILE,
SIGNED_CERT,
VAULT_ADDR,
createFakeVault,
env,
fakeSocket,
manifest,
pluginDir,
startServer,
type TestServer,
} from "./helpers.js";
const certs = vi.hoisted(() => ({ applyCertificateAuth: vi.fn() }));
vi.mock("@termix/plugin-sdk/ssh-certs", () => certs);
let server: TestServer | null = null;
beforeEach(() => {
certs.applyCertificateAuth.mockReset();
});
afterEach(async () => {
await server?.close();
server = null;
});
/** Creates a profile as user-1 and points host 7 at it. */
async function profileOnHost(options: { shared?: boolean } = {}) {
const created = await server!.request("POST", "/profiles", {
body: { ...PROFILE, shared: options.shared ?? false },
});
expect(created.status).toBe(201);
await server!.mock.ctx.settings.setHost(7, "profileId", created.body.id);
return created.body as { id: number };
}
async function startSignIn(userId = "user-1") {
const socket = fakeSocket();
await server!.provider().startInteraction!({
userId,
hostId: 7,
host: { name: "box", ip: HOST.ip, username: HOST.username },
socket: socket.socket,
requestOrigin: "https://termix.test",
payload: {},
});
return socket;
}
describe("activate", () => {
it("registers the vault auth type, the sync entity and a public callback", async () => {
server = await startServer();
const provider = server.provider();
expect(provider).toMatchObject({
type: "vault",
interaction: "vault",
needsUserInteraction: true,
supportsBackground: false,
});
expect(server.mock.syncEntities).toEqual([
expect.objectContaining({ type: "vaultProfiles", order: 20 }),
]);
expect(server.mock.httpRouters[0]?.public).toEqual(["/oidc/callback"]);
});
// secrets:own and network:outbound are checked when used, ui:surface by the
// frontend.
it.each(["db:own", "network:serve", "auth:provide"])(
"fails closed without %s",
async (capability) => {
const db = await createTestDb(pluginDir);
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== capability),
db: db.database,
router: () => ({ get: () => {}, use: () => {} }),
});
const { activate } = await import("../../src/backend/index.js");
await expect(activate(mock.ctx)).rejects.toThrow(PluginCapabilityError);
db.close();
},
);
});
describe("profiles", () => {
it("creates, lists, updates and deletes a profile", async () => {
server = await startServer();
const created = await server.request("POST", "/profiles", {
body: { ...PROFILE, vaultNamespace: " team ", tags: ["a", "b"] },
});
expect(created.status).toBe(201);
expect(created.body).toMatchObject({
name: "Prod",
vaultAddr: VAULT_ADDR,
vaultNamespace: "team",
sshRole: "ops",
tags: ["a", "b"],
shared: false,
owned: true,
});
const listed = await server.request("GET", "/profiles");
expect(listed.body).toHaveLength(1);
const updated = await server.request(
"PUT",
`/profiles/${created.body.id}`,
{ body: { name: "Staging", keyType: "ssh-rsa" } },
);
expect(updated.status).toBe(200);
expect(updated.body).toMatchObject({ name: "Staging", keyType: "ssh-rsa" });
const deleted = await server.request(
"DELETE",
`/profiles/${created.body.id}`,
);
expect(deleted.status).toBe(200);
expect((await server.request("GET", "/profiles")).body).toEqual([]);
});
it("records a tombstone and drops cached certificates on delete", async () => {
server = await startServer();
const { id } = await profileOnHost();
server.db.sqlite
.prepare(
"INSERT INTO p_vault_tokens (user_id, profile_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)",
)
.run("user-1", id, "c", "k", "2999-01-01T00:00:00.000Z");
const syncId = (
server.db.sqlite
.prepare("SELECT sync_id FROM p_vault_profiles WHERE id = ?")
.get(id) as { sync_id: string }
).sync_id;
await server.request("DELETE", `/profiles/${id}`);
expect(server.mock.tombstones).toEqual([
{ userId: "user-1", entityType: "vaultProfiles", syncId },
]);
expect(
server.db.sqlite
.prepare("SELECT COUNT(*) AS n FROM p_vault_tokens")
.get(),
).toEqual({ n: 0 });
});
it("requires name, address and signer role", async () => {
server = await startServer();
const response = await server.request("POST", "/profiles", {
body: { name: "x" },
});
expect(response.status).toBe(400);
});
it("shows other users only shared profiles, and only the owner may change one", async () => {
server = await startServer();
const own = await server.request("POST", "/profiles", { body: PROFILE });
const shared = await server.request("POST", "/profiles", {
body: { ...PROFILE, name: "Shared", shared: true },
});
const seen = await server.request("GET", "/profiles", { user: "user-2" });
expect(seen.body.map((p: { name: string }) => p.name)).toEqual(["Shared"]);
expect(seen.body[0].owned).toBe(false);
for (const id of [own.body.id, shared.body.id]) {
const put = await server.request("PUT", `/profiles/${id}`, {
user: "user-2",
body: { name: "mine" },
});
expect(put.status).toBe(403);
const del = await server.request("DELETE", `/profiles/${id}`, {
user: "user-2",
});
expect(del.status).toBe(403);
}
});
it("needs the share permission to share a profile", async () => {
server = await startServer({ permissions: ["vault.use"] });
const create = await server.request("POST", "/profiles", {
body: { ...PROFILE, shared: true },
});
expect(create.status).toBe(403);
const own = await server.request("POST", "/profiles", { body: PROFILE });
expect(own.status).toBe(201);
const share = await server.request("PUT", `/profiles/${own.body.id}`, {
body: { shared: true },
});
expect(share.status).toBe(403);
const unshare = await server.request("PUT", `/profiles/${own.body.id}`, {
body: { shared: false },
});
expect(unshare.status).toBe(200);
});
it("refuses every profile route without the use permission", async () => {
server = await startServer({ permissions: [] });
for (const [method, path] of [
["GET", "/profiles"],
["POST", "/profiles"],
["PUT", "/profiles/1"],
["DELETE", "/profiles/1"],
]) {
const response = await server.request(method, path, {
body: method === "GET" ? undefined : PROFILE,
});
expect(response.status, `${method} ${path}`).toBe(403);
}
});
it("answers 404 for a profile that does not exist", async () => {
server = await startServer();
expect(
(await server.request("PUT", "/profiles/99", { body: {} })).status,
).toBe(404);
expect((await server.request("DELETE", "/profiles/99")).status).toBe(404);
expect((await server.request("DELETE", "/profiles/abc")).status).toBe(400);
});
});
describe("signing a certificate", () => {
it("runs the OIDC flow against Vault and stores the certificate sealed", async () => {
const vault = createFakeVault();
server = await startServer({ fetch: vault.fetch });
const { id } = await profileOnHost();
const socket = await startSignIn();
expect(socket.sent).toEqual([
expect.objectContaining({
type: "vault_auth_url",
hostId: 7,
requestId: vault.state,
url: expect.stringContaining("https://idp.test/authorize"),
}),
]);
const authUrlCall = vault.calls[0];
expect(JSON.parse(String(authUrlCall.init?.body))).toMatchObject({
role: "dev",
redirect_uri: "https://termix.test/plugin-api/vault/oidc/callback",
});
expect(authUrlCall.init?.allowPrivateHosts).toEqual(["vault.internal"]);
const callback = await server.request(
"GET",
`/oidc/callback?state=${vault.state}&code=abc`,
{ user: "" },
);
expect(callback.status).toBe(200);
expect(callback.text).toContain("Vault sign-in complete");
expect(socket.sent.at(-1)).toMatchObject({
type: "vault_completed",
hostId: 7,
});
const signCall = vault.calls.find((call) => call.url.includes("/sign/"));
expect(signCall?.url).toBe(`${VAULT_ADDR}/v1/ssh-client-signer/sign/ops`);
expect(signCall?.init?.headers?.["X-Vault-Token"]).toBe("hvs.token");
const row = server.db.sqlite
.prepare("SELECT * FROM p_vault_tokens WHERE profile_id = ?")
.get(id) as { ssh_cert: string; private_key: string };
expect(row.ssh_cert).not.toBe(SIGNED_CERT);
expect(await server.mock.ctx.secrets.unseal(row.ssh_cert)).toBe(
SIGNED_CERT,
);
expect(await server.mock.ctx.secrets.unseal(row.private_key)).toContain(
"OPENSSH PRIVATE KEY",
);
const outcome = await server.provider().prepare({}, HOST, env());
expect(outcome).toEqual({ status: "ready" });
expect(certs.applyCertificateAuth).toHaveBeenCalledWith(
{},
{},
expect.objectContaining({ certificate: SIGNED_CERT }),
"root",
);
});
it("answers a second use of the same callback with an error", async () => {
const vault = createFakeVault();
server = await startServer({ fetch: vault.fetch });
await profileOnHost();
await startSignIn();
const path = `/oidc/callback?state=${vault.state}&code=abc`;
expect((await server.request("GET", path)).status).toBe(200);
expect((await server.request("GET", path)).status).toBe(400);
});
it("reports Vault refusing to sign", async () => {
const vault = createFakeVault({ signStatus: 403 });
server = await startServer({ fetch: vault.fetch });
await profileOnHost();
const socket = await startSignIn();
const callback = await server.request(
"GET",
`/oidc/callback?state=${vault.state}&code=abc`,
);
expect(callback.status).toBe(400);
expect(socket.sent.at(-1)).toMatchObject({
type: "vault_error",
error: expect.stringContaining("permission denied"),
});
});
it("passes an identity provider error through", async () => {
server = await startServer();
const callback = await server.request(
"GET",
"/oidc/callback?error=access_denied",
);
expect(callback.status).toBe(400);
expect(callback.text).toContain("access_denied");
});
it("sends the 2.8 redirect URI while legacyCallback is on", async () => {
const vault = createFakeVault();
server = await startServer({
fetch: vault.fetch,
settings: { legacyCallback: true },
});
await profileOnHost();
await startSignIn();
expect(JSON.parse(String(vault.calls[0].init?.body)).redirect_uri).toBe(
"https://termix.test/vault/oidc/callback",
);
});
it("lets a pending sign-in be cancelled", async () => {
const vault = createFakeVault();
server = await startServer({ fetch: vault.fetch });
await profileOnHost();
await startSignIn();
await server.provider().cancelInteraction!({ userId: "user-1", hostId: 7 });
const callback = await server.request(
"GET",
`/oidc/callback?state=${vault.state}&code=abc`,
);
expect(callback.status).toBe(400);
});
it("refuses to start without a profile on the host", async () => {
server = await startServer();
await expect(startSignIn()).rejects.toThrow(/No Vault signer profile/);
});
});
describe("the vault provider", () => {
it("fails without a profile on the host", async () => {
server = await startServer();
expect(await server.provider().prepare({}, HOST, env())).toMatchObject({
status: "error",
message: "Host has no Vault signer profile configured",
});
});
it("asks for a sign-in without a certificate", async () => {
server = await startServer();
await profileOnHost();
expect(await server.provider().prepare({}, HOST, env())).toEqual({
status: "interaction-required",
interaction: "vault",
message: expect.any(String),
flag: "requiresVaultAuth",
});
});
it("uses a profile another user shared with the host's owner", async () => {
server = await startServer();
const shared = await server.request("POST", "/profiles", {
user: "user-2",
body: { ...PROFILE, shared: true },
});
await server.mock.ctx.settings.setHost(7, "profileId", shared.body.id);
expect((await server.provider().prepare({}, HOST, env())).status).toBe(
"interaction-required",
);
});
it("drops a certificate 2.8 cached with the user's data key", async () => {
server = await startServer();
const { id } = await profileOnHost();
server.db.sqlite
.prepare(
"INSERT INTO p_vault_tokens (user_id, profile_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)",
)
.run(
"user-1",
id,
"v1:dek-cipher",
"v1:dek-cipher",
"2999-01-01T00:00:00.000Z",
);
const outcome = await server.provider().prepare({}, HOST, env());
expect(outcome.status).toBe("interaction-required");
expect(
server.db.sqlite
.prepare("SELECT COUNT(*) AS n FROM p_vault_tokens")
.get(),
).toEqual({ n: 0 });
});
it("forgets an expired certificate", async () => {
server = await startServer();
const { id } = await profileOnHost();
const seal = server.mock.ctx.secrets.seal;
server.db.sqlite
.prepare(
"INSERT INTO p_vault_tokens (user_id, profile_id, ssh_cert, private_key, expires_at) VALUES (?, ?, ?, ?, ?)",
)
.run(
"user-1",
id,
await seal("c"),
await seal("k"),
"2000-01-01T00:00:00.000Z",
);
expect((await server.provider().prepare({}, HOST, env())).status).toBe(
"interaction-required",
);
expect(
server.db.sqlite
.prepare("SELECT COUNT(*) AS n FROM p_vault_tokens")
.get(),
).toEqual({ n: 0 });
});
it("drops the certificate when the host rejects it", async () => {
const vault = createFakeVault();
server = await startServer({ fetch: vault.fetch });
await profileOnHost();
await startSignIn();
await server.request("GET", `/oidc/callback?state=${vault.state}&code=abc`);
const outcome = server.provider().onAuthFailed!(HOST, env(), {
error: new Error("All configured authentication methods failed"),
retries: 0,
canRetry: false,
methodNotAvailable: false,
});
expect(outcome).toMatchObject({ status: "interaction-required" });
await vi.waitFor(() =>
expect(
server!.db.sqlite
.prepare("SELECT COUNT(*) AS n FROM p_vault_tokens")
.get(),
).toEqual({ n: 0 }),
);
});
it("ignores other auth failures", async () => {
server = await startServer();
expect(
server.provider().onAuthFailed!(HOST, env(), {
error: new Error("connection reset"),
retries: 0,
canRetry: false,
methodNotAvailable: false,
}),
).toBeUndefined();
});
});
@@ -0,0 +1,268 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import { VaultOverlay } from "../../src/frontend/VaultOverlay";
import { VaultAuthEditor } from "../../src/frontend/VaultAuthEditor";
import { redirectUri } from "../../src/frontend/RedirectUriSetting";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const stubs = vi.hoisted(() => ({
permissions: new Set<string>(["use", "share"]),
api: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
patch: vi.fn(),
},
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
}));
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<typeof import("@termix/plugin-sdk/frontend")>()),
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) =>
options?.name ? `${key}:${options.name}` : key,
}),
usePluginApi: () => stubs.api,
usePermission: (permission: string) => stubs.permissions.has(permission),
useToast: () => stubs.toast,
}));
const manifest = manifestJson as unknown as PluginManifest;
let rendered: RenderedPluginApp | null = null;
beforeEach(() => {
stubs.permissions = new Set(["use", "share"]);
for (const fn of Object.values(stubs.api)) fn.mockReset();
stubs.api.get.mockResolvedValue({ data: [] });
});
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
describe("vault activate", () => {
it("registers the terminal overlay and the settings field, and removes them on deactivate", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.settingsComponents()).toEqual(["redirectUri"]);
expect(rendered.registered.slot("terminal.overlay")).toEqual([
"vault.signIn",
]);
await rendered.deactivate();
expect(rendered.registered.settingsComponents()).toEqual([]);
expect(rendered.registered.slot("terminal.overlay")).toEqual([]);
rendered = null;
});
});
describe("redirectUri", () => {
it("names the plugin callback, or the 2.8 one", () => {
expect(redirectUri(false, "https://t.example/app/")).toBe(
"https://t.example/app/plugin-api/vault/oidc/callback",
);
expect(redirectUri(true, "https://t.example/")).toBe(
"https://t.example/vault/oidc/callback",
);
});
});
describe("VaultAuthEditor", () => {
const profiles = [
{
id: 3,
name: "Prod",
vaultAddr: "https://v",
sshRole: "r",
shared: false,
owned: true,
},
{
id: 4,
name: "Team",
vaultAddr: "https://v",
sshRole: "r",
shared: true,
owned: false,
},
];
it("lists the profiles and stores the choice as the profileId host setting", async () => {
stubs.api.get.mockResolvedValue({ data: profiles });
const setField = vi.fn();
render(
<VaultAuthEditor
form={{ pluginSettings: { other: { x: 1 } } }}
setField={setField}
/>,
);
await waitFor(() => expect(screen.getByText("Prod")).toBeTruthy());
expect(stubs.api.get).toHaveBeenCalledWith("/profiles");
expect(screen.getByText("editor.sharedProfile:Team")).toBeTruthy();
fireEvent.change(screen.getByRole("combobox"), { target: { value: "4" } });
expect(setField).toHaveBeenCalledWith("pluginSettings", {
other: { x: 1 },
vault: { profileId: 4 },
});
});
it("shows the saved profile", async () => {
stubs.api.get.mockResolvedValue({ data: profiles });
render(
<VaultAuthEditor
form={{ pluginSettings: { vault: { profileId: 3 } } }}
setField={vi.fn()}
/>,
);
await waitFor(() =>
expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe(
"3",
),
);
});
it("creates a profile from the manager, shared only with the share permission", async () => {
stubs.api.post.mockResolvedValue({ data: {} });
render(<VaultAuthEditor form={{}} setField={vi.fn()} />);
fireEvent.click(screen.getByText("profiles.manage"));
fireEvent.click(screen.getByText("profiles.new"));
const inputs = screen.getAllByRole("textbox");
fireEvent.change(inputs[0], { target: { value: "Prod" } });
fireEvent.change(inputs[1], { target: { value: "https://vault:8200" } });
fireEvent.change(inputs[6], { target: { value: "ops" } });
fireEvent.click(screen.getByRole("checkbox"));
await act(async () => {
fireEvent.click(screen.getByText("profiles.create"));
});
expect(stubs.api.post).toHaveBeenCalledWith(
"/profiles",
expect.objectContaining({
name: "Prod",
vaultAddr: "https://vault:8200",
sshRole: "ops",
shared: true,
}),
);
});
it("hides sharing without the share permission and managing without use", async () => {
stubs.permissions = new Set(["use"]);
const { unmount } = render(
<VaultAuthEditor form={{}} setField={vi.fn()} />,
);
fireEvent.click(screen.getByText("profiles.manage"));
fireEvent.click(screen.getByText("profiles.new"));
expect(screen.queryByRole("checkbox")).toBeNull();
unmount();
stubs.permissions = new Set();
stubs.api.get.mockClear();
render(<VaultAuthEditor form={{}} setField={vi.fn()} />);
expect(screen.queryByText("profiles.manage")).toBeNull();
expect(stubs.api.get).not.toHaveBeenCalled();
});
});
describe("VaultOverlay", () => {
function setup() {
let listener: (message: {
type: string;
[key: string]: unknown;
}) => void = () => {};
const props = {
host: { id: 7 },
subscribe: vi.fn((next: typeof listener) => {
listener = next;
return () => {};
}),
holdConnectTimeout: vi.fn(),
fail: vi.fn(),
disconnect: vi.fn(),
send: vi.fn(),
connectPayload: vi.fn(() => ({ hostId: 7, cols: 80, rows: 24 })),
};
render(<VaultOverlay {...props} />);
const emit = (message: { type: string; [key: string]: unknown }) =>
act(() => listener(message));
return { props, emit };
}
it("starts the sign-in once, then reports a second request as a failure", () => {
const { props, emit } = setup();
emit({ type: "vault_auth_required", hostId: 7 });
expect(props.send).toHaveBeenCalledWith("vault_start_auth", { hostId: 7 });
expect(props.holdConnectTimeout).toHaveBeenCalledWith(true);
emit({ type: "vault_auth_required", hostId: 7 });
expect(props.fail).toHaveBeenCalledWith("dialog.failed");
});
it("opens Vault's sign-in, waits, and reconnects once signed", () => {
const close = vi.fn();
const open = vi
.spyOn(window, "open")
.mockReturnValue({ close, focus: vi.fn() } as unknown as Window);
const { props, emit } = setup();
emit({ type: "vault_auth_required", hostId: 7 });
emit({
type: "vault_auth_url",
hostId: 7,
requestId: "st-1",
url: "https://idp.test/authorize",
});
expect(open).toHaveBeenCalledWith(
"https://idp.test/authorize",
"termix-vault-oidc",
"width=540,height=720",
);
expect(screen.getByText("dialog.description")).toBeTruthy();
emit({ type: "vault_completed", hostId: 7 });
expect(close).toHaveBeenCalled();
expect(props.send).toHaveBeenCalledWith("vault_auth_completed", {
hostId: 7,
cols: 80,
rows: 24,
});
expect(screen.queryByText("dialog.title")).toBeNull();
open.mockRestore();
});
it("cancels a pending sign-in", () => {
const open = vi.spyOn(window, "open").mockReturnValue(null);
const { props, emit } = setup();
emit({
type: "vault_auth_url",
hostId: 7,
requestId: "st-2",
url: "https://idp.test/authorize",
});
fireEvent.click(screen.getByText("common.cancel"));
expect(props.send).toHaveBeenCalledWith("vault_cancel", {
hostId: 7,
requestId: "st-2",
});
open.mockRestore();
});
it("shows Vault's error", () => {
const { emit } = setup();
emit({ type: "vault_error", hostId: 7, error: "permission denied" });
expect(screen.getByText("permission denied")).toBeTruthy();
});
});
Loaded 100 of 198 files, more files were not shown because too many files have changed in this diff. Show more