mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move vault ssh signing into a plugin (C5)
This commit is contained in:
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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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": "重新開啟登入視窗"
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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": "*"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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 });
|
||||
});
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user