feat: finish the remote-desktop plugin (B14)

This commit is contained in:
LukeGus committed 2026-09-24 01:02:45 -05:00
1 parent 0af2c94983
commit 14eb558b85
215 files changed
+30029 -3921

No files matched your search

+12
View File
@@ -3,3 +3,15 @@
## 1.0.0
Bundled with Termix 2.9.0.
- Runs entirely through the plugin SDK: no imports from Termix core.
- Remote Desktop's admin switch and guacd URL, each user's RDP defaults and
every host's RDP, VNC and Telnet options are this plugin's settings now,
moved over on upgrade.
- The user's RDP defaults now apply to saved hosts too, not only Quick Connect.
- Provides `sessions.live` for RDP, VNC and Telnet, so these sessions can be
shared and presented in collab rooms.
- Jump host tunnels and the macOS VNC proxy close with their session instead
of after an hour.
- Opening the Windows Remote Desktop client goes through the desktop app's
backend.
+46 -54
View File
@@ -1,68 +1,60 @@
# Remote Desktop
RDP, VNC and Telnet remote desktop sessions, packaged as a first-party plugin.
RDP, VNC and Telnet sessions over guacd, with clipboard, file transfer through
RDP drive redirection, session recording and session sharing.
## Why this plugin runs in-process
## guacd
Every other plugin runs in a `worker_threads` worker and reaches the server
through a structured-clone `postMessage` boundary. That boundary is what makes
the capability gate and the audit trail mean anything: a worker plugin cannot
hold a db handle, an ssh2 `Client` or a live socket, so every privileged action
has to go through the broker.
guacd (the Apache Guacamole proxy daemon that speaks RDP, VNC and Telnet) is an
external service this plugin talks to over TCP. The stock Docker compose runs
it as its own `guacamole/guacd` container. This plugin never starts it.
This plugin cannot work that way:
Where to find it is the admin setting **guacd URL** (`host:port`). The
`GUACD_URL`, or `GUACD_HOST` and `GUACD_PORT`, environment variables override
the setting, so a compose file stays the source of truth where it sets them.
Changing the setting rebuilds the connection server without a restart.
- it owns a `guacamole-lite` WebSocket server on port 30008,
- its `/connect-host/:hostId` route does multi-step credential and tunnel
resolution (SSH jump-host tunnels, a macOS VNC compatibility proxy) using
raw `net.Socket` plumbing,
- it needs plaintext host credentials to connect at all.
Other environment variables the plugin reads:
None of that can cross a structured-clone boundary, so this plugin runs on the
main thread instead, under the `process:transport-owner` tier, the same as
`ssh-terminal`, `docker` and `host-metrics`.
| Variable | What it sets |
| ------------------------------ | --------------------------------------------------------------------------- |
| `GUACD_TUNNEL_HOST` | The name guacd uses to reach Termix for a jump host tunnel (default termix) |
| `GUACD_RECORDING_PATH` | Where guacd writes recordings, as guacd sees it |
| `GUACD_RECORDING_BACKEND_PATH` | The same folder as Termix sees it |
| `GUACD_DRIVE_PATH` | The root of each user's RDP drive folder on the guacd host |
| `GUACAMOLE_ENCRYPTION_KEY` | The connection token key; derived from `JWT_SECRET` when unset |
## guacd is not spawned by this plugin
## Settings
guacd (the Apache Guacamole proxy daemon that actually speaks RDP/VNC/Telnet)
is a separate, external service reached over plain TCP -- by default a
sibling Docker container, or any network-reachable host configured through the
admin "guacd URL" setting. This plugin does not download, bundle or spawn
guacd as a child process.
- **Admin**: turn Remote Desktop on or off, and the guacd URL.
- **User**: RDP defaults (colour depth, resize method, wallpaper, clipboard
and the rest). A host's own values win; anything a host leaves on its
default takes the user's.
- **Host**: the RDP, VNC and Telnet switches, ports, security mode,
certificate handling and every guacd parameter, edited in the host
editor's RDP, VNC and Telnet tabs. The toolbar switch is in the Plugins tab.
That is a deliberate limitation, not an oversight: Apache Guacamole does not
publish prebuilt guacd binaries for any platform (only source tarballs and
their own Docker image), and there is no viable native guacd for Windows at
all. Bundling guacd for Linux and macOS is possible in principle but would
make Termix the ongoing upstream builder and security patcher for guacd plus
FreeRDP/libvncclient/libssh2/libtelnet -- out of scope for this conversion.
guacd's availability is handled as an external dependency with graceful
degradation, exactly as it was before this plugin existed.
The logins (users, passwords, stored credentials, domain) stay on the host in
core, because sharing decides which of them a recipient may use.
## What lives where
## Capabilities
Unlike `ssh-terminal` (whose implementation stays in core because
`session-manager.ts` has consumers well beyond the terminal), this plugin's
backend and frontend code is fully relocated here from
`src/backend/hosts/guacamole/` and `src/ui/features/guacamole/`, the same
shape `docker` and `host-metrics` use.
- `credentials:read`: guacd needs the host's RDP, VNC or Telnet password in
plain text, so the plugin reads it through `ctx.credentials`. A shared
recipient only ever gets the owner's shared snapshot or their own override.
- `ssh:connect`, `credentials:use`: jump host tunnels, through
`ctx.ssh.jumpChain`, or the tunnels plugin's `tunnels.access` for a single
hop when it is running.
- `network:serve`: the routes and the `/display` socket.
- `desktop:window`: opening the Windows Remote Desktop client from the desktop
app.
- `hosts:read`: the online indicator while a session is open.
- `ui:surface`: everything the frontend registers.
Two pieces of shared infrastructure still reach across the plugin boundary
into this plugin rather than living in core:
## Services
- `src/backend/hosts/collab/routes.ts` and
`src/backend/hosts/session-sharing/` import `token-service.ts` and
`guacamole-server.ts` directly to resolve and validate live Guacamole
sessions for collaborative/shared viewing.
- `src/ui/features/collab/` and `src/ui/features/session-sharing/` import
`GuacamoleDisplay.tsx` directly for the same reason.
This is an accepted, temporary coupling: collab and session-sharing are
expected to become plugins of their own later, at which point these become
real inter-plugin references instead of raw relative imports.
## Disabling
Disabling behaves like any other plugin: the rdp/vnc/telnet tabs stop being
offered from the host list, command palette and quick connect, the
`/guacamole` router 404s, and the WebSocket server on port 30008 is closed.
Provides `sessions.live` under the names `rdp`, `vnc` and `telnet`, which is
how session sharing and collab rooms find a live session and mint a viewer
token for it. Uses `recordings.writer` (session-recording) to decide whether
to record and to list finished recordings, and `tunnels.access` (tunnels)
for single-hop jump tunnels. Both are optional.
+127 -3
View File
@@ -73,7 +73,8 @@
"fitToScreen": "Fit to screen",
"switchToTrackpad": "Switch to trackpad mode (drag to move cursor, tap to click)",
"switchToTouch": "Switch to touch mode (tap directly where you want to click)"
}
},
"disabled": "Remote Desktop is turned off. An admin can turn it on in Settings."
},
"hosts": {
"nativeRdpOpened": "Opened in Windows Remote Desktop",
@@ -204,11 +205,134 @@
"fontName": "Font Name",
"fontSize": "Font Size",
"colorScheme": "Color Scheme",
"backspaceKey": "Backspace Key"
}
"backspaceKey": "Backspace Key",
"colorDepth": "Color Depth",
"resizeMethod": "Resize Method"
},
"remoteDesktop": "Remote Desktop",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Virtual Network",
"unencryptedShell": "Unencrypted shell",
"remoteDesktopNotes": "Notes",
"userDefaultsNote": "Settings left on their default use your Remote Desktop defaults from Settings.",
"connectionOriginNote": "On the desktop app, RDP, VNC and Telnet use the remote server unless the host is set to This device, which needs a guacd this machine can reach. Set one in the Remote Desktop settings, or per host under guacd Proxy."
},
"onboarding": {
"feature_desktop": "Remote desktop",
"feature_desktop_desc": "Open RDP, VNC and Telnet sessions in a tab, same as SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Color Depth",
"description": "Used for RDP hosts that leave it on their default."
},
"resizeMethod": {
"label": "Resize Method",
"description": "How the remote screen follows the window size."
},
"resizeDisplayUpdate": "Display Update",
"resizeReconnect": "Reconnect",
"inherit": "Inherit",
"on": "On",
"off": "Off",
"forceLossless": {
"label": "Force Lossless",
"description": "Force lossless image encoding (higher quality, more bandwidth)"
},
"enableWallpaper": {
"label": "Wallpaper",
"description": "Show desktop wallpaper (disabling improves performance)"
},
"enableFontSmoothing": {
"label": "Font Smoothing",
"description": "Enable ClearType font rendering"
},
"enableDesktopComposition": {
"label": "Desktop Composition",
"description": "Enable Aero glass effects"
},
"disableAudio": {
"label": "Disable Audio",
"description": "Mute all audio from the remote session"
},
"enablePrinting": {
"label": "Enable Printing",
"description": "Redirect local printers to the remote session"
},
"enableDrive": {
"label": "Enable Drive Redirection",
"description": "Map a local folder as a drive in the remote session"
},
"disableCopy": {
"label": "Disable Copy",
"description": "Prevent copying text from the remote session"
},
"disablePaste": {
"label": "Disable Paste",
"description": "Prevent pasting text into the remote session"
},
"group": "Remote Desktop defaults",
"colorDepth16": "16-bit",
"colorDepth24": "24-bit",
"colorDepth32": "32-bit"
},
"admin": {
"enabled": {
"label": "Enable Remote Desktop",
"description": "Allow RDP, VNC and Telnet connections through guacd."
},
"guacdUrl": {
"label": "guacd URL",
"description": "Where guacd listens, as host:port. The GUACD_URL, GUACD_HOST and GUACD_PORT environment variables override this.",
"placeholder": "guacd:4822"
}
},
"host": {
"enableRdp": {
"label": "RDP"
},
"enableVnc": {
"label": "VNC"
},
"enableTelnet": {
"label": "Telnet"
},
"rdpPort": {
"label": "RDP port"
},
"vncPort": {
"label": "VNC port"
},
"telnetPort": {
"label": "Telnet port"
},
"rdpSecurity": {
"label": "RDP security mode"
},
"rdpIgnoreCert": {
"label": "Ignore certificate errors"
},
"guacamoleConfig": {
"label": "guacd settings"
},
"enableToolbar": {
"label": "Remote desktop toolbar",
"description": "Show the toolbar in RDP, VNC and Telnet sessions."
}
}
},
"palette": {
"connectRdp": "Connect with RDP",
"connectVnc": "Connect with VNC",
"connectTelnet": "Connect with Telnet"
},
"permissions": {
"sessions": {
"title": "Share remote desktop sessions",
"description": "Let session sharing and collab rooms show this user's RDP, VNC and Telnet sessions to others."
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Lettertipe Naam",
"fontSize": "Lettergrootte",
"colorScheme": "Kleurskema",
"backspaceKey": "Terugspasie-sleutel"
}
"backspaceKey": "Terugspasie-sleutel",
"colorDepth": "Kleurdiepte",
"resizeMethod": "Vergrootingsmetode"
},
"remoteDesktop": "Afstandslessenaar",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Virtuele Netwerk",
"unencryptedShell": "Ongeënkripteerde dop"
},
"onboarding": {
"feature_desktop": "Afstandlessenaar",
"feature_desktop_desc": "Maak RDP-, VNC- en Telnet-sessies in 'n oortjie oop, dieselfde as SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Kleurdiepte"
},
"resizeMethod": {
"label": "Vergrootingsmetode"
},
"resizeDisplayUpdate": "Skermopdatering",
"resizeReconnect": "Herkoppel",
"inherit": "Erf",
"on": "Aan",
"off": "Af",
"forceLossless": {
"label": "Forseer verliesloos",
"description": "Forseer verlieslose beeldkodering (hoër gehalte, meer bandwydte)"
},
"enableWallpaper": {
"label": "Agtergrond",
"description": "Wys lessenaar agtergrond (deaktivering verbeter werkverrigting)"
},
"enableFontSmoothing": {
"label": "Lettertipe-gladmaak",
"description": "Aktiveer ClearType-lettertipe-weergawe"
},
"enableDesktopComposition": {
"label": "Komposisie op die lessenaar",
"description": "Aktiveer Aero-glaseffekte"
},
"disableAudio": {
"label": "Deaktiveer oudio",
"description": "Demp alle klank van die afstandsessie"
},
"enablePrinting": {
"label": "Aktiveer drukwerk",
"description": "Herlei plaaslike drukkers na die afgeleë sessie"
},
"enableDrive": {
"label": "Aktiveer dryfherleiding",
"description": "Karaktereer 'n plaaslike vouer as 'n skyf in die afstandsessie"
},
"disableCopy": {
"label": "Deaktiveer Kopieer",
"description": "Voorkom die kopiëring van teks vanaf die afgeleë sessie"
},
"disablePaste": {
"label": "Deaktiveer plak",
"description": "Voorkom die plak van teks in die afgeleë sessie"
}
},
"admin": {
"guacdUrl": {
"label": "guacd-URL"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "اسم الخط",
"fontSize": "حجم الخط",
"colorScheme": "نظام الألوان",
"backspaceKey": "مفتاح Backspace"
}
"backspaceKey": "مفتاح Backspace",
"colorDepth": "عمق اللون",
"resizeMethod": "طريقة تغيير الحجم"
},
"remoteDesktop": "سطح المكتب البعيد",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "الشبكة الافتراضية",
"unencryptedShell": "shell غير مشفر"
},
"onboarding": {
"feature_desktop": "سطح المكتب البعيد",
"feature_desktop_desc": "افتح جلسات RDP وVNC وTelnet في علامة تبويب، تمامًا مثل SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "عمق اللون"
},
"resizeMethod": {
"label": "طريقة تغيير الحجم"
},
"resizeDisplayUpdate": "تحديث الشاشة",
"resizeReconnect": "إعادة الاتصال",
"inherit": "يرث",
"on": "على",
"off": "عن",
"forceLossless": {
"label": "فرض بدون فقدان",
"description": "فرض ترميز الصور بدون فقدان (جودة أعلى، استهلاك أكبر للنطاق الترددي)"
},
"enableWallpaper": {
"label": "خلفية سطح المكتب",
"description": "إظهار خلفية سطح المكتب (التعطيل يحسن الأداء)"
},
"enableFontSmoothing": {
"label": "تنعيم الخطوط",
"description": "تمكين عرض خطوط ClearType"
},
"enableDesktopComposition": {
"label": "تركيب سطح المكتب",
"description": "تمكين تأثيرات Aero الزجاجية"
},
"disableAudio": {
"label": "تعطيل الصوت",
"description": "كتم كل الصوت من الجلسة البعيدة"
},
"enablePrinting": {
"label": "تمكين الطباعة",
"description": "إعادة توجيه الطابعات المحلية إلى الجلسة البعيدة"
},
"enableDrive": {
"label": "تمكين إعادة توجيه القرص",
"description": "تعيين مجلد محلي كقرص في الجلسة البعيدة"
},
"disableCopy": {
"label": "تعطيل النسخ",
"description": "منع نسخ النص من الجلسة البعيدة"
},
"disablePaste": {
"label": "تعطيل اللصق",
"description": "منع لصق النص في الجلسة البعيدة"
}
},
"admin": {
"guacdUrl": {
"label": "رابط guacd"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Име на шрифта",
"fontSize": "Размер на шрифта",
"colorScheme": "Цветова схема",
"backspaceKey": "Клавиш Backspace"
}
"backspaceKey": "Клавиш Backspace",
"colorDepth": "Дълбочина на цвета",
"resizeMethod": "Метод за преоразмеряване"
},
"remoteDesktop": "Отдалечен работен плот",
"tabRdp": "ПРСР",
"tabVnc": "VNC",
"tabTelnet": "Телнет",
"virtualNetwork": "Виртуална мрежа",
"unencryptedShell": "Некриптирана обвивка"
},
"onboarding": {
"feature_desktop": "Отдалечен работен плот",
"feature_desktop_desc": "Отваряне на RDP, VNC и Telnet сесии в раздел, както при SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Дълбочина на цвета"
},
"resizeMethod": {
"label": "Метод за преоразмеряване"
},
"resizeDisplayUpdate": "Актуализация на дисплея",
"resizeReconnect": "Свържете се отново",
"inherit": "Наследяване",
"on": "Включено",
"off": "Изключено",
"forceLossless": {
"label": "Принудително беззагубно",
"description": "Принудително кодиране на изображения без загуби (по-високо качество, по-голяма честотна лента)"
},
"enableWallpaper": {
"label": "Тапет",
"description": "Показване на тапет на работния плот (деактивирането му подобрява производителността)"
},
"enableFontSmoothing": {
"label": "Изглаждане на шрифта",
"description": "Активиране на рендирането на шрифтове ClearType"
},
"enableDesktopComposition": {
"label": "Композиция на работния плот",
"description": "Активиране на ефектите на Aero Glass"
},
"disableAudio": {
"label": "Деактивиране на звука",
"description": "Изключване на звука от отдалечената сесия"
},
"enablePrinting": {
"label": "Активиране на печат",
"description": "Пренасочване на локални принтери към отдалечена сесия"
},
"enableDrive": {
"label": "Активиране на пренасочване на устройството",
"description": "Карта на локална папка като устройство в отдалечена сесия"
},
"disableCopy": {
"label": "Деактивиране на копирането",
"description": "Предотвратяване на копирането на текст от отдалечената сесия"
},
"disablePaste": {
"label": "Деактивиране на поставянето",
"description": "Предотвратяване на поставянето на текст в отдалечената сесия"
}
},
"admin": {
"guacdUrl": {
"label": "URL адрес на GUACD"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "ফন্টের নাম",
"fontSize": "ফন্ট সাইজ",
"colorScheme": "রঙের পরিকল্পনা",
"backspaceKey": "ব্যাকস্পেস কী"
}
"backspaceKey": "ব্যাকস্পেস কী",
"colorDepth": "রঙের গভীরতা",
"resizeMethod": "আকার পরিবর্তন পদ্ধতি"
},
"remoteDesktop": "রিমোট ডেস্কটপ",
"tabRdp": "আরডিপি",
"tabVnc": "ভিএনসি",
"tabTelnet": "টেলনেট",
"virtualNetwork": "ভার্চুয়াল নেটওয়ার্ক",
"unencryptedShell": "এনক্রিপ্টবিহীন শেল"
},
"onboarding": {
"feature_desktop": "রিমোট ডেস্কটপ",
"feature_desktop_desc": "SSH-এর মতোই একটি ট্যাবে RDP, VNC এবং Telnet সেশন খুলুন।"
},
"settings": {
"user": {
"colorDepth": {
"label": "রঙের গভীরতা"
},
"resizeMethod": {
"label": "আকার পরিবর্তন পদ্ধতি"
},
"resizeDisplayUpdate": "ডিসপ্লে আপডেট",
"resizeReconnect": "পুনরায় সংযোগ করুন",
"inherit": "উত্তরাধিকার সূত্রে",
"on": "চালু",
"off": "বন্ধ",
"forceLossless": {
"label": "বল ক্ষয়হীন",
"description": "লসলেস ইমেজ এনকোডিং বাধ্যতামূলক করুন (উচ্চতর গুণমান, বেশি ব্যান্ডউইথ)"
},
"enableWallpaper": {
"label": "ওয়ালপেপার",
"description": "ডেস্কটপ ওয়ালপেপার দেখান (এটি বন্ধ করলে পারফরম্যান্স উন্নত হয়)"
},
"enableFontSmoothing": {
"label": "ফন্ট মসৃণকরণ",
"description": "ক্লিয়ারটাইপ ফন্ট রেন্ডারিং সক্ষম করুন"
},
"enableDesktopComposition": {
"label": "ডেস্কটপ কম্পোজিশন",
"description": "এরো গ্লাস এফেক্টস সক্রিয় করুন"
},
"disableAudio": {
"label": "অডিও নিষ্ক্রিয় করুন",
"description": "রিমোট সেশন থেকে সমস্ত অডিও মিউট করুন"
},
"enablePrinting": {
"label": "প্রিন্টিং সক্ষম করুন",
"description": "স্থানীয় প্রিন্টারগুলিকে রিমোট সেশনে পুনঃনির্দেশিত করুন"
},
"enableDrive": {
"label": "ড্রাইভ পুনঃনির্দেশনা সক্ষম করুন",
"description": "রিমোট সেশনে একটি স্থানীয় ফোল্ডারকে ড্রাইভ হিসেবে ম্যাপ করুন।"
},
"disableCopy": {
"label": "কপি নিষ্ক্রিয় করুন",
"description": "রিমোট সেশন থেকে টেক্সট কপি করা প্রতিরোধ করুন"
},
"disablePaste": {
"label": "পেস্ট নিষ্ক্রিয় করুন",
"description": "রিমোট সেশনে টেক্সট পেস্ট করা প্রতিরোধ করুন"
}
},
"admin": {
"guacdUrl": {
"label": "guacd URL"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Nom de la font",
"fontSize": "Mida de la lletra",
"colorScheme": "Esquema de colors",
"backspaceKey": "Tecla Retrocés"
}
"backspaceKey": "Tecla Retrocés",
"colorDepth": "Profunditat de color",
"resizeMethod": "Mètode de redimensionament"
},
"remoteDesktop": "Escriptori remot",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Xarxa virtual",
"unencryptedShell": "Shell sense xifrar"
},
"onboarding": {
"feature_desktop": "Escriptori remot",
"feature_desktop_desc": "Obriu sessions RDP, VNC i Telnet en una pestanya, igual que SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Profunditat de color"
},
"resizeMethod": {
"label": "Mètode de redimensionament"
},
"resizeDisplayUpdate": "Actualització de la pantalla",
"resizeReconnect": "Reconnecta",
"inherit": "Heretar",
"on": "Activat",
"off": "Desactivat",
"forceLossless": {
"label": "Força sense pèrdues",
"description": "Força la codificació d'imatges sense pèrdues (més qualitat, més amplada de banda)"
},
"enableWallpaper": {
"label": "Fons de pantalla",
"description": "Mostra el fons de pantalla de l'escriptori (desactivar-lo millora el rendiment)"
},
"enableFontSmoothing": {
"label": "Suavitzat de fonts",
"description": "Activa la renderització de fonts ClearType"
},
"enableDesktopComposition": {
"label": "Composició d'escriptori",
"description": "Activa els efectes Aero Glass"
},
"disableAudio": {
"label": "Desactiva l'àudio",
"description": "Silencia tot l'àudio de la sessió remota"
},
"enablePrinting": {
"label": "Habilita la impressió",
"description": "Redirigir les impressores locals a la sessió remota"
},
"enableDrive": {
"label": "Activa la redirecció de la unitat",
"description": "Assignar una carpeta local com a unitat a la sessió remota"
},
"disableCopy": {
"label": "Desactiva la còpia",
"description": "Impedir la còpia de text des de la sessió remota"
},
"disablePaste": {
"label": "Desactiva l'enganxament",
"description": "Impedir enganxar text a la sessió remota"
}
},
"admin": {
"guacdUrl": {
"label": "URL de guacd"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Název písma",
"fontSize": "Velikost písma",
"colorScheme": "Barevné schéma",
"backspaceKey": "Klávesa Backspace"
}
"backspaceKey": "Klávesa Backspace",
"colorDepth": "Barevná hloubka",
"resizeMethod": "Metoda změny velikosti"
},
"remoteDesktop": "Vzdálená plocha",
"tabRdp": "PRV",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Virtuální síť",
"unencryptedShell": "Nešifrovaný shell"
},
"onboarding": {
"feature_desktop": "Vzdálená plocha",
"feature_desktop_desc": "Otevírejte relace RDP, VNC a Telnet na kartě, stejně jako SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Barevná hloubka"
},
"resizeMethod": {
"label": "Metoda změny velikosti"
},
"resizeDisplayUpdate": "Aktualizace displeje",
"resizeReconnect": "Znovu se připojte",
"inherit": "Zdědit",
"on": "Na",
"off": "Vypnuto",
"forceLossless": {
"label": "Vynutit bezztrátové",
"description": "Vynucení bezztrátového kódování obrazu (vyšší kvalita, větší šířka pásma)"
},
"enableWallpaper": {
"label": "Tapeta",
"description": "Zobrazit tapetu plochy (zakázání zlepšuje výkon)"
},
"enableFontSmoothing": {
"label": "Vyhlazování písma",
"description": "Povolit vykreslování písma ClearType"
},
"enableDesktopComposition": {
"label": "Složení plochy",
"description": "Povolit efekty Aero Glass"
},
"disableAudio": {
"label": "Zakázat zvuk",
"description": "Ztlumit veškerý zvuk ze vzdálené relace"
},
"enablePrinting": {
"label": "Povolit tisk",
"description": "Přesměrování lokálních tiskáren na vzdálenou relaci"
},
"enableDrive": {
"label": "Povolit přesměrování disku",
"description": "Namapování lokální složky jako jednotky ve vzdálené relaci"
},
"disableCopy": {
"label": "Zakázat kopírování",
"description": "Zabránit kopírování textu ze vzdálené relace"
},
"disablePaste": {
"label": "Zakázat vkládání",
"description": "Zabránit vkládání textu do vzdálené relace"
}
},
"admin": {
"guacdUrl": {
"label": "URL GUACD"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Skrifttypenavn",
"fontSize": "Skriftstørrelse",
"colorScheme": "Farveskema",
"backspaceKey": "Tilbagetast"
}
"backspaceKey": "Tilbagetast",
"colorDepth": "Farvedybde",
"resizeMethod": "Ændre størrelse-metode"
},
"remoteDesktop": "Fjernskrivebord",
"tabRdp": "Landdistriktsudviklingsplan",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Virtuelt netværk",
"unencryptedShell": "Ukrypteret skal"
},
"onboarding": {
"feature_desktop": "Fjernskrivebord",
"feature_desktop_desc": "Åbn RDP-, VNC- og Telnet-sessioner i en fane, ligesom med SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Farvedybde"
},
"resizeMethod": {
"label": "Ændre størrelse-metode"
},
"resizeDisplayUpdate": "Opdatering af display",
"resizeReconnect": "Genopret forbindelse",
"inherit": "Arve",
"on": "På",
"off": "Slukket",
"forceLossless": {
"label": "Tving tabsfri",
"description": "Tving tabsfri billedkodning (højere kvalitet, mere båndbredde)"
},
"enableWallpaper": {
"label": "Tapet",
"description": "Vis skrivebordsbaggrund (deaktivering forbedrer ydeevnen)"
},
"enableFontSmoothing": {
"label": "Skrifttypeudjævning",
"description": "Aktivér gengivelse af ClearType-skrifttyper"
},
"enableDesktopComposition": {
"label": "Skrivebordskomposition",
"description": "Aktivér Aero Glass-effekter"
},
"disableAudio": {
"label": "Deaktiver lyd",
"description": "Slå al lyd fra fra fjernsessionen"
},
"enablePrinting": {
"label": "Aktivér udskrivning",
"description": "Omdiriger lokale printere til fjernsessionen"
},
"enableDrive": {
"label": "Aktivér drevomdirigering",
"description": "Tilknyt en lokal mappe som et drev i den eksterne session"
},
"disableCopy": {
"label": "Deaktiver kopiering",
"description": "Forhindr kopiering af tekst fra fjernsessionen"
},
"disablePaste": {
"label": "Deaktiver indsætning",
"description": "Forhindr indsættelse af tekst i fjernsessionen"
}
},
"admin": {
"guacdUrl": {
"label": "guacd-URL"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Schriftname",
"fontSize": "Schriftgröße",
"colorScheme": "Farbschema",
"backspaceKey": "Backspace-Taste"
}
"backspaceKey": "Backspace-Taste",
"colorDepth": "Farbtiefe",
"resizeMethod": "Skalierungsmethode"
},
"remoteDesktop": "Remotedesktop",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Virtuelles Netzwerk",
"unencryptedShell": "Unverschlüsselte Shell"
},
"onboarding": {
"feature_desktop": "Remote-Desktop",
"feature_desktop_desc": "Öffnen Sie RDP-, VNC- und Telnet-Sitzungen in einem Tab, genau wie bei SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Farbtiefe"
},
"resizeMethod": {
"label": "Skalierungsmethode"
},
"resizeDisplayUpdate": "Anzeigeaktualisierung",
"resizeReconnect": "Wiederverbinden",
"inherit": "Erben",
"on": "An",
"off": "Aus",
"forceLossless": {
"label": "Verlustfreie Übertragung erzwingen",
"description": "Verlustfreie Bildcodierung erzwingen (höhere Qualität, mehr Bandbreite)"
},
"enableWallpaper": {
"label": "Hintergrundbild",
"description": "Desktop-Hintergrundbild anzeigen (Deaktivierung verbessert die Leistung)"
},
"enableFontSmoothing": {
"label": "Schriftglättung",
"description": "ClearType-Schriftdarstellung aktivieren"
},
"enableDesktopComposition": {
"label": "Desktopkomposition",
"description": "Aero-Glaseffekte aktivieren"
},
"disableAudio": {
"label": "Audio deaktivieren",
"description": "Gesamten Ton der Remote-Sitzung stummschalten"
},
"enablePrinting": {
"label": "Drucken aktivieren",
"description": "Lokale Drucker an die Remote-Sitzung umleiten"
},
"enableDrive": {
"label": "Laufwerksumleitung aktivieren",
"description": "Lokalen Ordner als Laufwerk in der Remote-Sitzung einbinden"
},
"disableCopy": {
"label": "Kopieren deaktivieren",
"description": "Kopieren von Text aus der entfernten Sitzung verhindern"
},
"disablePaste": {
"label": "Einfügen deaktivieren",
"description": "Einfügen von Text in die entfernte Sitzung verhindern"
}
},
"admin": {
"guacdUrl": {
"label": "guacd-URL"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Όνομα γραμματοσειράς",
"fontSize": "Μέγεθος γραμματοσειράς",
"colorScheme": "Χρωματικό σχήμα",
"backspaceKey": "Πλήκτρο Backspace"
}
"backspaceKey": "Πλήκτρο Backspace",
"colorDepth": "Βάθος χρώματος",
"resizeMethod": "Μέθοδος αλλαγής μεγέθους"
},
"remoteDesktop": "Απομακρυσμένη επιφάνεια εργασίας",
"tabRdp": "ΠΑΑ",
"tabVnc": "VNC",
"tabTelnet": "Τέλνετ",
"virtualNetwork": "Εικονικό Δίκτυο",
"unencryptedShell": "Μη κρυπτογραφημένο κέλυφος"
},
"onboarding": {
"feature_desktop": "Απομακρυσμένη επιφάνεια εργασίας",
"feature_desktop_desc": "Ανοίξτε τις συνεδρίες RDP, VNC και Telnet σε μια καρτέλα, όπως και το SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Βάθος χρώματος"
},
"resizeMethod": {
"label": "Μέθοδος αλλαγής μεγέθους"
},
"resizeDisplayUpdate": "Ενημέρωση οθόνης",
"resizeReconnect": "Επανασύνδεση",
"inherit": "Κληρονομώ",
"on": "Επί",
"off": "Μακριά από",
"forceLossless": {
"label": "Δύναμη χωρίς απώλειες",
"description": "Επιβολή κωδικοποίησης εικόνας χωρίς απώλειες (υψηλότερη ποιότητα, μεγαλύτερο εύρος ζώνης)"
},
"enableWallpaper": {
"label": "Ταπετσαρία",
"description": "Εμφάνιση ταπετσαρίας επιφάνειας εργασίας (η απενεργοποίηση βελτιώνει την απόδοση)"
},
"enableFontSmoothing": {
"label": "Εξομάλυνση γραμματοσειράς",
"description": "Ενεργοποίηση απόδοσης γραμματοσειράς ClearType"
},
"enableDesktopComposition": {
"label": "Σύνθεση επιφάνειας εργασίας",
"description": "Ενεργοποίηση εφέ Aero glass"
},
"disableAudio": {
"label": "Απενεργοποίηση ήχου",
"description": "Σίγαση όλου του ήχου από την απομακρυσμένη συνεδρία"
},
"enablePrinting": {
"label": "Ενεργοποίηση εκτύπωσης",
"description": "Ανακατεύθυνση τοπικών εκτυπωτών στην απομακρυσμένη συνεδρία"
},
"enableDrive": {
"label": "Ενεργοποίηση ανακατεύθυνσης μονάδας δίσκου",
"description": "Αντιστοίχιση τοπικού φακέλου ως μονάδας δίσκου στην απομακρυσμένη συνεδρία"
},
"disableCopy": {
"label": "Απενεργοποίηση αντιγραφής",
"description": "Αποτροπή αντιγραφής κειμένου από την απομακρυσμένη συνεδρία"
},
"disablePaste": {
"label": "Απενεργοποίηση επικόλλησης",
"description": "Αποτροπή επικόλλησης κειμένου στην απομακρυσμένη συνεδρία"
}
},
"admin": {
"guacdUrl": {
"label": "URL guacd"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Nombre de fuente",
"fontSize": "Tamaño de fuente",
"colorScheme": "Esquema de color",
"backspaceKey": "Tecla de retroceso"
}
"backspaceKey": "Tecla de retroceso",
"colorDepth": "Profundidad de color",
"resizeMethod": "Método de redimensionamiento"
},
"remoteDesktop": "Escritorio remoto",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Red virtual",
"unencryptedShell": "Shell sin cifrar"
},
"onboarding": {
"feature_desktop": "Escritorio remoto",
"feature_desktop_desc": "Abre sesiones RDP, VNC y Telnet en una pestaña, igual que con SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Profundidad de color"
},
"resizeMethod": {
"label": "Método de redimensionamiento"
},
"resizeDisplayUpdate": "Actualización de pantalla",
"resizeReconnect": "Reconectar",
"inherit": "Heredar",
"on": "En",
"off": "Apagado",
"forceLossless": {
"label": "Forzar sin pérdida",
"description": "Forzar codificación de imagen sin pérdida (más calidad, más ancho de banda)"
},
"enableWallpaper": {
"label": "Fondo de pantalla",
"description": "Mostrar fondo de escritorio (desactivarlo mejora el rendimiento)"
},
"enableFontSmoothing": {
"label": "Suavizado de fuentes",
"description": "Activar renderizado de fuentes ClearType"
},
"enableDesktopComposition": {
"label": "Composición de escritorio",
"description": "Activar efectos Aero Glass"
},
"disableAudio": {
"label": "Desactivar audio",
"description": "Silenciar todo el audio de la sesión remota"
},
"enablePrinting": {
"label": "Activar impresión",
"description": "Redirigir las impresoras locales a la sesión remota"
},
"enableDrive": {
"label": "Activar redirección de unidades",
"description": "Asignar una carpeta local como una unidad en la sesión remota"
},
"disableCopy": {
"label": "Deshabilitar copia",
"description": "Impedir la copia de texto de la sesión remota"
},
"disablePaste": {
"label": "Deshabilitar pegado",
"description": "Impedir el pegado de texto en la sesión remota"
}
},
"admin": {
"guacdUrl": {
"label": "URL de guacd"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Fontin nimi",
"fontSize": "Fonttikoko",
"colorScheme": "Värimaailma",
"backspaceKey": "Askelpalautin-näppäin"
}
"backspaceKey": "Askelpalautin-näppäin",
"colorDepth": "Värisyvyys",
"resizeMethod": "Koon muuttamismenetelmä"
},
"remoteDesktop": "Etätyöpöytä",
"tabRdp": "Maaseudun kehittämisohjelma",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Virtuaaliverkko",
"unencryptedShell": "Salaamaton kuori"
},
"onboarding": {
"feature_desktop": "Etätyöpöytä",
"feature_desktop_desc": "Avaa RDP-, VNC- ja Telnet-istunnot välilehdellä, samalla tavalla kuin SSH:ssa."
},
"settings": {
"user": {
"colorDepth": {
"label": "Värisyvyys"
},
"resizeMethod": {
"label": "Koon muuttamismenetelmä"
},
"resizeDisplayUpdate": "Näyttöpäivitys",
"resizeReconnect": "Yhdistä uudelleen",
"inherit": "Periä",
"on": "Päällä",
"off": "Pois",
"forceLossless": {
"label": "Pakota häviötön",
"description": "Pakota häviötön kuvan koodaus (korkeampi laatu, enemmän kaistanleveyttä)"
},
"enableWallpaper": {
"label": "Taustakuva",
"description": "Näytä työpöydän taustakuva (poistaminen käytöstä parantaa suorituskykyä)"
},
"enableFontSmoothing": {
"label": "Fontin pehmennys",
"description": "Ota ClearType-fonttien renderöinti käyttöön"
},
"enableDesktopComposition": {
"label": "Työpöydän sommittelu",
"description": "Ota Aero Glass -tehosteet käyttöön"
},
"disableAudio": {
"label": "Poista ääni käytöstä",
"description": "Mykistä kaikki etäistunnon äänet"
},
"enablePrinting": {
"label": "Ota tulostus käyttöön",
"description": "Ohjaa paikalliset tulostimet etäistuntoon"
},
"enableDrive": {
"label": "Ota aseman uudelleenohjaus käyttöön",
"description": "Yhdistä paikallinen kansio asemaksi etäistunnossa"
},
"disableCopy": {
"label": "Poista kopiointi käytöstä",
"description": "Estä tekstin kopioiminen etäistunnosta"
},
"disablePaste": {
"label": "Poista liittäminen käytöstä",
"description": "Estä tekstin liittäminen etäistuntoon"
}
},
"admin": {
"guacdUrl": {
"label": "guacd-URL-osoite"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Nom de la police",
"fontSize": "Taille de la police",
"colorScheme": "Schéma de couleurs",
"backspaceKey": "Touche Backspace"
}
"backspaceKey": "Touche Backspace",
"colorDepth": "Profondeur de couleur",
"resizeMethod": "Méthode de redimensionnement"
},
"remoteDesktop": "Bureau à distance",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Réseau virtuel",
"unencryptedShell": "Shell non chiffré"
},
"onboarding": {
"feature_desktop": "Bureau à distance",
"feature_desktop_desc": "Ouvrez les sessions RDP, VNC et Telnet dans un onglet, de la même manière que SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Profondeur de couleur"
},
"resizeMethod": {
"label": "Méthode de redimensionnement"
},
"resizeDisplayUpdate": "Mise à jour de l'affichage",
"resizeReconnect": "Reconnect",
"inherit": "Hériter",
"on": "Sur",
"off": "Désactivé",
"forceLossless": {
"label": "Forcer sans perte",
"description": "Forcer l'encodage d'image sans perte (meilleure qualité, plus de bande passante)"
},
"enableWallpaper": {
"label": "Fond d'écran",
"description": "Afficher le fond d'écran du bureau (désactiver cette option améliore les performances)"
},
"enableFontSmoothing": {
"label": "Lissage des polices",
"description": "Activer le rendu des polices ClearType"
},
"enableDesktopComposition": {
"label": "Composition du bureau",
"description": "Activer les effets Aero Glass"
},
"disableAudio": {
"label": "Désactiver l'audio",
"description": "Couper tout le son de la session distante"
},
"enablePrinting": {
"label": "Activer l'impression",
"description": "Rediriger les imprimantes locales vers la session distante"
},
"enableDrive": {
"label": "Activer la redirection de lecteur",
"description": "Monter un dossier local en tant que lecteur dans la session distante"
},
"disableCopy": {
"label": "Désactiver la copie",
"description": "Empêcher la copie de texte depuis la session distante"
},
"disablePaste": {
"label": "Désactiver le collage",
"description": "Empêcher le collage de texte dans la session distante"
}
},
"admin": {
"guacdUrl": {
"label": "URL guacd"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "שם הגופן",
"fontSize": "גודל גופן",
"colorScheme": "ערכת צבעים",
"backspaceKey": "מקש Backspace"
}
"backspaceKey": "מקש Backspace",
"colorDepth": "עומק צבע",
"resizeMethod": "שיטת שינוי גודל"
},
"remoteDesktop": "שולחן עבודה מרוחק",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "טלנט",
"virtualNetwork": "רשת וירטואלית",
"unencryptedShell": "מעטפת לא מוצפנת"
},
"onboarding": {
"feature_desktop": "שולחן עבודה מרוחק",
"feature_desktop_desc": "פתחו הפעלות RDP, VNC ו-Telnet בכרטיסייה, בדומה ל-SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "עומק צבע"
},
"resizeMethod": {
"label": "שיטת שינוי גודל"
},
"resizeDisplayUpdate": "עדכון תצוגה",
"resizeReconnect": "התחבר מחדש",
"inherit": "לָרֶשֶׁת",
"on": "עַל",
"off": "כבוי",
"forceLossless": {
"label": "כפיית אובדן נתונים",
"description": "כפיית קידוד תמונה ללא אובדן נתונים (איכות גבוהה יותר, רוחב פס גדול יותר)"
},
"enableWallpaper": {
"label": "טָפֵּט",
"description": "הצגת טפט שולחן עבודה (השבתה משפרת את הביצועים)"
},
"enableFontSmoothing": {
"label": "החלקת גופנים",
"description": "הפעלת עיבוד גופני ClearType"
},
"enableDesktopComposition": {
"label": "קומפוזיציה לשולחן העבודה",
"description": "הפעלת אפקטים של Aero Glass"
},
"disableAudio": {
"label": "השבתת שמע",
"description": "השתקת כל האודיו מההפעלה המרוחקת"
},
"enablePrinting": {
"label": "הפעל הדפסה",
"description": "ניתוב מחדש של מדפסות מקומיות להפעלה מרוחקת"
},
"enableDrive": {
"label": "הפעל ניתוב מחדש של כונן",
"description": "מיפוי תיקייה מקומית ככונן בהפעלה מרוחקת"
},
"disableCopy": {
"label": "השבתת העתקה",
"description": "מניעת העתקת טקסט מההפעלה המרוחקת"
},
"disablePaste": {
"label": "השבתת הדבקה",
"description": "מניעת הדבקת טקסט לתוך ההפעלה המרוחקת"
}
},
"admin": {
"guacdUrl": {
"label": "כתובת אתר של guacd"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "फ़ॉन्ट नाम",
"fontSize": "फ़ॉन्ट आकार",
"colorScheme": "रंग योजना",
"backspaceKey": "बैकस्पेस कुंजी"
}
"backspaceKey": "बैकस्पेस कुंजी",
"colorDepth": "रंग गहराई",
"resizeMethod": "रीसाइज़ विधि"
},
"remoteDesktop": "दूरस्थ डेस्कटॉप",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "वर्चुअल नेटवर्क",
"unencryptedShell": "अनएन्क्रिप्टेड शेल"
},
"onboarding": {
"feature_desktop": "दूरवर्ती डेस्कटॉप",
"feature_desktop_desc": "RDP, VNC और Telnet सेशन को SSH की तरह ही एक टैब में खोलें।"
},
"settings": {
"user": {
"colorDepth": {
"label": "रंग गहराई"
},
"resizeMethod": {
"label": "रीसाइज़ विधि"
},
"resizeDisplayUpdate": "डिस्प्ले अपडेट",
"resizeReconnect": "रिकनेक्ट",
"inherit": "इनहेरिट",
"on": "पर",
"off": "बंद",
"forceLossless": {
"label": "दोषरहित बाध्य करें",
"description": "दोषरहित छवि एन्कोडिंग बाध्य करें (उच्च गुणवत्ता, अधिक बैंडविड्थ)"
},
"enableWallpaper": {
"label": "वॉलपेपर",
"description": "डेस्कटॉप वॉलपेपर दिखाएं (अक्षम करने से प्रदर्शन बेहतर होता है)"
},
"enableFontSmoothing": {
"label": "फ़ॉन्ट स्मूथिंग",
"description": "ClearType फ़ॉन्ट रेंडरिंग सक्षम करें"
},
"enableDesktopComposition": {
"label": "डेस्कटॉप कंपोज़िशन",
"description": "Aero ग्लास प्रभाव सक्षम करें"
},
"disableAudio": {
"label": "ऑडियो अक्षम करें",
"description": "दूरस्थ सत्र से सभी ऑडियो म्यूट करें"
},
"enablePrinting": {
"label": "प्रिंटिंग सक्षम करें",
"description": "स्थानीय प्रिंटर को दूरस्थ सत्र में पुनर्निर्देशित करें"
},
"enableDrive": {
"label": "ड्राइव पुनर्निर्देशन सक्षम करें",
"description": "स्थानीय फ़ोल्डर को दूरस्थ सत्र में ड्राइव के रूप में मैप करें"
},
"disableCopy": {
"label": "कॉपी अक्षम करें",
"description": "रिमोट सत्र से टेक्स्ट कॉपी करना रोकें"
},
"disablePaste": {
"label": "पेस्ट अक्षम करें",
"description": "रिमोट सत्र में टेक्स्ट पेस्ट करना रोकें"
}
},
"admin": {
"guacdUrl": {
"label": "guacd URL"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Betűtípus neve",
"fontSize": "Betűméret",
"colorScheme": "Színséma",
"backspaceKey": "Visszatörlés billentyű"
}
"backspaceKey": "Visszatörlés billentyű",
"colorDepth": "Színmélység",
"resizeMethod": "Átméretezési módszer"
},
"remoteDesktop": "Távoli asztal",
"tabRdp": "Vidékfejlesztési Program",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Virtuális hálózat",
"unencryptedShell": "Titkosítatlan shell"
},
"onboarding": {
"feature_desktop": "Távoli asztal",
"feature_desktop_desc": "Nyisson meg RDP, VNC és Telnet munkameneteket egy lapon, ugyanúgy, mint az SSH-n."
},
"settings": {
"user": {
"colorDepth": {
"label": "Színmélység"
},
"resizeMethod": {
"label": "Átméretezési módszer"
},
"resizeDisplayUpdate": "Kijelzőfrissítés",
"resizeReconnect": "Újracsatlakozás",
"inherit": "Örököl",
"on": "On",
"off": "Le",
"forceLossless": {
"label": "Veszteségmentes erő",
"description": "Veszteségmentes képkódolás kényszerítése (jobb minőség, nagyobb sávszélesség)"
},
"enableWallpaper": {
"label": "Tapéta",
"description": "Asztali háttérkép megjelenítése (a letiltás javítja a teljesítményt)"
},
"enableFontSmoothing": {
"label": "Betűsimítás",
"description": "ClearType betűtípus-megjelenítés engedélyezése"
},
"enableDesktopComposition": {
"label": "Asztali kompozíció",
"description": "Aero Glass effektek engedélyezése"
},
"disableAudio": {
"label": "Hang letiltása",
"description": "Némítsa el az összes hangot a távoli munkamenetből"
},
"enablePrinting": {
"label": "Nyomtatás engedélyezése",
"description": "Helyi nyomtatók átirányítása a távoli munkamenetre"
},
"enableDrive": {
"label": "Meghajtó átirányításának engedélyezése",
"description": "Helyi mappa meghajtóként való leképezése a távoli munkamenetben"
},
"disableCopy": {
"label": "Másolás letiltása",
"description": "Szöveg másolásának megakadályozása a távoli munkamenetből"
},
"disablePaste": {
"label": "Beillesztés letiltása",
"description": "Szöveg beillesztésének megakadályozása a távoli munkamenetbe"
}
},
"admin": {
"guacdUrl": {
"label": "guacd URL"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Nama Font",
"fontSize": "Ukuran Huruf",
"colorScheme": "Skema Warna",
"backspaceKey": "Tombol Backspace"
}
"backspaceKey": "Tombol Backspace",
"colorDepth": "Kedalaman Warna",
"resizeMethod": "Metode Pengubahan Ukuran"
},
"remoteDesktop": "Desktop Jarak Jauh",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Jaringan Virtual",
"unencryptedShell": "Shell yang tidak terenkripsi"
},
"onboarding": {
"feature_desktop": "Desktop jarak jauh",
"feature_desktop_desc": "Buka sesi RDP, VNC, dan Telnet dalam satu tab, sama seperti SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Kedalaman Warna"
},
"resizeMethod": {
"label": "Metode Pengubahan Ukuran"
},
"resizeDisplayUpdate": "Pembaruan Tampilan",
"resizeReconnect": "Terhubung kembali",
"inherit": "Mewarisi",
"on": "Pada",
"off": "Mati",
"forceLossless": {
"label": "Paksa Tanpa Kehilangan",
"description": "Paksa pengkodean gambar tanpa kehilangan data (kualitas lebih tinggi, bandwidth lebih besar)"
},
"enableWallpaper": {
"label": "Wallpaper",
"description": "Tampilkan wallpaper desktop (menonaktifkannya akan meningkatkan kinerja)"
},
"enableFontSmoothing": {
"label": "Penghalusan Font",
"description": "Aktifkan rendering font ClearType"
},
"enableDesktopComposition": {
"label": "Komposisi Desktop",
"description": "Aktifkan efek kaca Aero"
},
"disableAudio": {
"label": "Nonaktifkan Audio",
"description": "Nonaktifkan semua audio dari sesi jarak jauh."
},
"enablePrinting": {
"label": "Aktifkan Pencetakan",
"description": "Alihkan printer lokal ke sesi jarak jauh."
},
"enableDrive": {
"label": "Aktifkan Pengalihan Drive",
"description": "Petakan folder lokal sebagai drive di sesi jarak jauh."
},
"disableCopy": {
"label": "Nonaktifkan Salin",
"description": "Mencegah penyalinan teks dari sesi jarak jauh"
},
"disablePaste": {
"label": "Nonaktifkan Tempel",
"description": "Mencegah penyisipan teks ke dalam sesi jarak jauh"
}
},
"admin": {
"guacdUrl": {
"label": "URL guacd"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Nome font",
"fontSize": "Dimensione font",
"colorScheme": "Schema colori",
"backspaceKey": "Tasto Backspace"
}
"backspaceKey": "Tasto Backspace",
"colorDepth": "Profondità colore",
"resizeMethod": "Metodo di ridimensionamento"
},
"remoteDesktop": "Desktop remoto",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Rete virtuale",
"unencryptedShell": "Shell non crittografata"
},
"onboarding": {
"feature_desktop": "Desktop remoto",
"feature_desktop_desc": "Apri le sessioni RDP, VNC e Telnet in una scheda, proprio come per SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Profondità colore"
},
"resizeMethod": {
"label": "Metodo di ridimensionamento"
},
"resizeDisplayUpdate": "Aggiornamento della visualizzazione",
"resizeReconnect": "Ricollega",
"inherit": "Ereditare",
"on": "SU",
"off": "Spento",
"forceLossless": {
"label": "Forza lossless",
"description": "Forza la codifica lossless delle immagini (qualità più alta, maggiore utilizzo di banda)"
},
"enableWallpaper": {
"label": "Sfondo",
"description": "Mostra lo sfondo del desktop (disabilitarlo migliora le prestazioni)"
},
"enableFontSmoothing": {
"label": "Smussamento caratteri",
"description": "Abilita il rendering dei caratteri ClearType"
},
"enableDesktopComposition": {
"label": "Composizione desktop",
"description": "Abilita gli effetti Aero Glass"
},
"disableAudio": {
"label": "Disabilita audio",
"description": "Disattiva tutto l'audio dalla sessione remota"
},
"enablePrinting": {
"label": "Abilita stampa",
"description": "Reindirizza le stampanti locali alla sessione remota"
},
"enableDrive": {
"label": "Abilita reindirizzamento unità",
"description": "Mappa una cartella locale come unità nella sessione remota"
},
"disableCopy": {
"label": "Disabilita copia",
"description": "Impedisce la copia di testo dalla sessione remota"
},
"disablePaste": {
"label": "Disabilita incolla",
"description": "Impedisce di incollare testo nella sessione remota"
}
},
"admin": {
"guacdUrl": {
"label": "URL guacd"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "フォント名",
"fontSize": "フォントサイズ",
"colorScheme": "カラースキーム",
"backspaceKey": "バックスペースキー"
}
"backspaceKey": "バックスペースキー",
"colorDepth": "色深度",
"resizeMethod": "リサイズ方法"
},
"remoteDesktop": "リモートデスクトップ",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "仮想ネットワーク",
"unencryptedShell": "暗号化されていないシェル"
},
"onboarding": {
"feature_desktop": "リモートデスクトップ",
"feature_desktop_desc": "RDP、VNC、Telnetのセッションは、SSHと同様にタブで開きます。"
},
"settings": {
"user": {
"colorDepth": {
"label": "色深度"
},
"resizeMethod": {
"label": "リサイズ方法"
},
"resizeDisplayUpdate": "ディスプレイの更新",
"resizeReconnect": "再接続",
"inherit": "継承する",
"on": "の上",
"off": "オフ",
"forceLossless": {
"label": "ロスレスを強制",
"description": "ロスレス画像エンコーディングを強制する(高品質だが帯域幅を多く消費)"
},
"enableWallpaper": {
"label": "壁紙",
"description": "デスクトップ壁紙を表示する(無効にするとパフォーマンスが向上)"
},
"enableFontSmoothing": {
"label": "フォントスムージング",
"description": "ClearTypeフォントレンダリングを有効にする"
},
"enableDesktopComposition": {
"label": "デスクトップコンポジション",
"description": "Aero Glass効果を有効にする"
},
"disableAudio": {
"label": "オーディオを無効化",
"description": "リモートセッションの音声をすべてミュートする"
},
"enablePrinting": {
"label": "印刷を有効化",
"description": "ローカルプリンターをリモートセッションにリダイレクトする"
},
"enableDrive": {
"label": "ドライブリダイレクションを有効化",
"description": "リモートセッションでローカルフォルダーをドライブとしてマッピングする"
},
"disableCopy": {
"label": "コピーを無効にする",
"description": "リモートセッションからのテキストコピーを禁止します"
},
"disablePaste": {
"label": "貼り付けを無効にする",
"description": "リモートセッションへのテキスト貼り付けを禁止します"
}
},
"admin": {
"guacdUrl": {
"label": "guacd URL"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "글꼴 이름",
"fontSize": "글꼴 크기",
"colorScheme": "색 구성표",
"backspaceKey": "백스페이스 키"
}
"backspaceKey": "백스페이스 키",
"colorDepth": "색 농도",
"resizeMethod": "크기 조정 방법"
},
"remoteDesktop": "원격 데스크톱",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "텔넷",
"virtualNetwork": "가상 네트워크",
"unencryptedShell": "암호화되지 않은 셸"
},
"onboarding": {
"feature_desktop": "원격 데스크톱",
"feature_desktop_desc": "SSH와 마찬가지로 RDP, VNC 및 Telnet 세션을 탭에서 열 수 있습니다."
},
"settings": {
"user": {
"colorDepth": {
"label": "색 농도"
},
"resizeMethod": {
"label": "크기 조정 방법"
},
"resizeDisplayUpdate": "디스플레이 업데이트",
"resizeReconnect": "다시 연결",
"inherit": "상속하다",
"on": "~에",
"off": "끄다",
"forceLossless": {
"label": "무손실 강제",
"description": "무손실 이미지 인코딩 강제 (더 높은 품질, 더 많은 대역폭)"
},
"enableWallpaper": {
"label": "배경 화면",
"description": "바탕 화면 배경 표시 (비활성화 시 성능 향상)"
},
"enableFontSmoothing": {
"label": "글꼴 다듬기",
"description": "ClearType 글꼴 렌더링 사용"
},
"enableDesktopComposition": {
"label": "데스크톱 구성",
"description": "Aero 글래스 효과 활성화"
},
"disableAudio": {
"label": "오디오 비활성화",
"description": "원격 세션의 모든 오디오 음소거"
},
"enablePrinting": {
"label": "인쇄 활성화",
"description": "로컬 프린터를 원격 세션으로 리디렉션"
},
"enableDrive": {
"label": "드라이브 리디렉션 활성화",
"description": "로컬 폴더를 원격 세션에서 드라이브로 매핑"
},
"disableCopy": {
"label": "복사 비활성화",
"description": "원격 세션에서 텍스트 복사 방지"
},
"disablePaste": {
"label": "붙여넣기 비활성화",
"description": "원격 세션으로 텍스트 붙여넣기 방지"
}
},
"admin": {
"guacdUrl": {
"label": "guacd URL"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Lettertypenaam",
"fontSize": "Lettergrootte",
"colorScheme": "Kleurenschema",
"backspaceKey": "Backspace-toets"
}
"backspaceKey": "Backspace-toets",
"colorDepth": "Kleurdiepte",
"resizeMethod": "Formaatwijzigingsmethode"
},
"remoteDesktop": "Extern bureaublad",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Virtueel netwerk",
"unencryptedShell": "Niet-versleutelde shell"
},
"onboarding": {
"feature_desktop": "Extern bureaublad",
"feature_desktop_desc": "Open RDP-, VNC- en Telnet-sessies in een tabblad, net als bij SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Kleurdiepte"
},
"resizeMethod": {
"label": "Formaatwijzigingsmethode"
},
"resizeDisplayUpdate": "Weergave bijwerken",
"resizeReconnect": "Opnieuw verbinding maken",
"inherit": "Erven",
"on": "Op",
"off": "Uit",
"forceLossless": {
"label": "Krachtverliesloos",
"description": "Forceer verliesvrije beeldcodering (hogere kwaliteit, meer bandbreedte)"
},
"enableWallpaper": {
"label": "Behang",
"description": "Toon bureaubladachtergrond (uitschakelen verbetert de prestaties)"
},
"enableFontSmoothing": {
"label": "Lettertype-vloeiendheid",
"description": "Schakel ClearType-lettertypeweergave in"
},
"enableDesktopComposition": {
"label": "Bureaubladcompositie",
"description": "Schakel Aero-glaseffecten in"
},
"disableAudio": {
"label": "Geluid uitschakelen",
"description": "Demp alle audio van de externe sessie."
},
"enablePrinting": {
"label": "Afdrukken inschakelen",
"description": "Lokale printers doorverwijzen naar de externe sessie"
},
"enableDrive": {
"label": "Schakel schijfomleiding in",
"description": "Een lokale map toewijzen als schijfstation in de externe sessie."
},
"disableCopy": {
"label": "Kopiëren uitschakelen",
"description": "Voorkom het kopiëren van tekst vanuit de externe sessie."
},
"disablePaste": {
"label": "Plakken uitschakelen",
"description": "Voorkom dat tekst in de externe sessie wordt geplakt."
}
},
"admin": {
"guacdUrl": {
"label": "guacd URL"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Skriftnavn",
"fontSize": "Skriftstørrelse",
"colorScheme": "Fargeskjema",
"backspaceKey": "Tilbaketast"
}
"backspaceKey": "Tilbaketast",
"colorDepth": "Fargedybde",
"resizeMethod": "Endre størrelse-metoden"
},
"remoteDesktop": "Eksternt skrivebord",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Virtuelt nettverk",
"unencryptedShell": "Ukryptert skall"
},
"onboarding": {
"feature_desktop": "Eksternt skrivebord",
"feature_desktop_desc": "Åpne RDP-, VNC- og Telnet-økter i en fane, på samme måte som SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Fargedybde"
},
"resizeMethod": {
"label": "Endre størrelse-metoden"
},
"resizeDisplayUpdate": "Oppdatering av skjerm",
"resizeReconnect": "Koble til igjen",
"inherit": "Arve",
"on": "På",
"off": "Av",
"forceLossless": {
"label": "Tving tapsfri",
"description": "Tving frem tapsfri bildekoding (høyere kvalitet, mer båndbredde)"
},
"enableWallpaper": {
"label": "Bakgrunn",
"description": "Vis skrivebordsbakgrunn (deaktivering forbedrer ytelsen)"
},
"enableFontSmoothing": {
"label": "Skriftutjevning",
"description": "Aktiver gjengivelse av ClearType-skrifttyper"
},
"enableDesktopComposition": {
"label": "Skrivebordskomposisjon",
"description": "Aktiver Aero Glass-effekter"
},
"disableAudio": {
"label": "Deaktiver lyd",
"description": "Demp all lyd fra den eksterne økten"
},
"enablePrinting": {
"label": "Aktiver utskrift",
"description": "Omdiriger lokale skrivere til den eksterne økten"
},
"enableDrive": {
"label": "Aktiver omdirigering av stasjoner",
"description": "Tilordne en lokal mappe som en stasjon i den eksterne økten"
},
"disableCopy": {
"label": "Deaktiver kopiering",
"description": "Forhindre kopiering av tekst fra den eksterne økten"
},
"disablePaste": {
"label": "Deaktiver liming",
"description": "Forhindre innliming av tekst i den eksterne økten"
}
},
"admin": {
"guacdUrl": {
"label": "guacd-URL"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Nazwa czcionki",
"fontSize": "Rozmiar czcionki",
"colorScheme": "Schemat kolorów",
"backspaceKey": "Klawisz Backspace"
}
"backspaceKey": "Klawisz Backspace",
"colorDepth": "Głębia koloru",
"resizeMethod": "Metoda zmiany rozmiaru"
},
"remoteDesktop": "Pulpit zdalny",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Sieć wirtualna",
"unencryptedShell": "Niezaszyfrowana powłoka"
},
"onboarding": {
"feature_desktop": "Zdalny pulpit",
"feature_desktop_desc": "Otwieraj sesje RDP, VNC i Telnet w karcie, tak samo jak SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Głębia koloru"
},
"resizeMethod": {
"label": "Metoda zmiany rozmiaru"
},
"resizeDisplayUpdate": "Aktualizacja ekranu",
"resizeReconnect": "Połącz ponownie",
"inherit": "Dziedzicz",
"on": "Wł.",
"off": "Wył.",
"forceLossless": {
"label": "Wymuś bezstratność",
"description": "Wymuś bezstratne kodowanie obrazu (wyższa jakość, większa przepustowość)"
},
"enableWallpaper": {
"label": "Tapeta",
"description": "Pokaż tapetę pulpitu (wyłączenie poprawia wydajność)"
},
"enableFontSmoothing": {
"label": "Wygładzanie czcionek",
"description": "Włącz renderowanie czcionek ClearType"
},
"enableDesktopComposition": {
"label": "Kompozycja pulpitu",
"description": "Włącz efekty Aero Glass"
},
"disableAudio": {
"label": "Wyłącz dźwięk",
"description": "Wycisz cały dźwięk z sesji zdalnej"
},
"enablePrinting": {
"label": "Włącz drukowanie",
"description": "Przekieruj drukarki lokalne do sesji zdalnej"
},
"enableDrive": {
"label": "Włącz przekierowanie dysku",
"description": "Mapuj folder lokalny jako dysk w sesji zdalnej"
},
"disableCopy": {
"label": "Wyłącz kopiowanie",
"description": "Zapobiegaj kopiowaniu tekstu z sesji zdalnej"
},
"disablePaste": {
"label": "Wyłącz wklejanie",
"description": "Zapobiegaj wklejaniu tekstu do sesji zdalnej"
}
},
"admin": {
"guacdUrl": {
"label": "Adres URL guacd"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Nome da fonte",
"fontSize": "Tamanho da fonte",
"colorScheme": "Esquema de cores",
"backspaceKey": "Tecla Backspace"
}
"backspaceKey": "Tecla Backspace",
"colorDepth": "Profundidade de cor",
"resizeMethod": "Método de redimensionamento"
},
"remoteDesktop": "Área de Trabalho Remota",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Rede Virtual",
"unencryptedShell": "Shell não criptografado"
},
"onboarding": {
"feature_desktop": "Área de trabalho remota",
"feature_desktop_desc": "Abra sessões RDP, VNC e Telnet em uma aba, da mesma forma que o SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Profundidade de cor"
},
"resizeMethod": {
"label": "Método de redimensionamento"
},
"resizeDisplayUpdate": "Atualização de exibição",
"resizeReconnect": "Reconectar",
"inherit": "Herdar",
"on": "Sobre",
"off": "Desligado",
"forceLossless": {
"label": "Força sem perdas",
"description": "Forçar codificação de imagem sem perdas (maior qualidade, mais largura de banda)"
},
"enableWallpaper": {
"label": "Papel de parede",
"description": "Exibir papel de parede da área de trabalho (desativar melhora o desempenho)"
},
"enableFontSmoothing": {
"label": "Suavização de fontes",
"description": "Ativar renderização de fontes ClearType"
},
"enableDesktopComposition": {
"label": "Composição de mesa",
"description": "Ative os efeitos de vidro Aero"
},
"disableAudio": {
"label": "Desativar áudio",
"description": "Silenciar todo o áudio da sessão remota."
},
"enablePrinting": {
"label": "Habilitar impressão",
"description": "Redirecionar impressoras locais para a sessão remota."
},
"enableDrive": {
"label": "Ativar o redirecionamento de unidades",
"description": "Mapear uma pasta local como uma unidade na sessão remota."
},
"disableCopy": {
"label": "Desativar cópia",
"description": "Impedir a cópia de texto da sessão remota"
},
"disablePaste": {
"label": "Desativar colagem",
"description": "Impedir a colagem de texto na sessão remota."
}
},
"admin": {
"guacdUrl": {
"label": "URL do guacd"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Nome da Fonte",
"fontSize": "Tamanho da Fonte",
"colorScheme": "Esquema de Cores",
"backspaceKey": "Tecla Backspace"
}
"backspaceKey": "Tecla Backspace",
"colorDepth": "Profundidade de cor",
"resizeMethod": "Método de redimensionamento"
},
"remoteDesktop": "Ambiente de trabalho remoto",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Rede Virtual",
"unencryptedShell": "Shell não encriptado"
},
"onboarding": {
"feature_desktop": "Área de trabalho remota",
"feature_desktop_desc": "Abra as sessões RDP, VNC e Telnet num separador, da mesma forma que o SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Profundidade de cor"
},
"resizeMethod": {
"label": "Método de redimensionamento"
},
"resizeDisplayUpdate": "Atualização de visualização",
"resizeReconnect": "Reconectar",
"inherit": "Herdar",
"on": "Em",
"off": "Desligado",
"forceLossless": {
"label": "Forçar sem perdas",
"description": "Forçar codificação de imagem sem perdas (maior qualidade, mais largura de banda)"
},
"enableWallpaper": {
"label": "Fundo de ecrã",
"description": "Mostrar fundo de ecrã (desativar melhora o desempenho)"
},
"enableFontSmoothing": {
"label": "Suavização de Fontes",
"description": "Ativar renderização de fontes ClearType"
},
"enableDesktopComposition": {
"label": "Composição do ambiente de trabalho",
"description": "Ativar efeitos de transparência Aero"
},
"disableAudio": {
"label": "Desativar Áudio",
"description": "Silenciar todo o áudio da sessão remota"
},
"enablePrinting": {
"label": "Ativar Impressão",
"description": "Redirecionar impressoras locais para a sessão remota"
},
"enableDrive": {
"label": "Ativar Redirecionamento de Unidades",
"description": "Mapear uma pasta local como unidade na sessão remota"
},
"disableCopy": {
"label": "Desativar Cópia",
"description": "Impedir a cópia de texto da sessão remota"
},
"disablePaste": {
"label": "Desativar Colagem",
"description": "Impedir a colagem de texto na sessão remota"
}
},
"admin": {
"guacdUrl": {
"label": "URL do guacd"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Nume font",
"fontSize": "Dimensiunea fontului",
"colorScheme": "Schemă de culori",
"backspaceKey": "Tasta Backspace"
}
"backspaceKey": "Tasta Backspace",
"colorDepth": "Adâncimea culorii",
"resizeMethod": "Metodă de redimensionare"
},
"remoteDesktop": "Desktop la distanță",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Rețea virtuală",
"unencryptedShell": "Shell necriptat"
},
"onboarding": {
"feature_desktop": "Desktop la distanță",
"feature_desktop_desc": "Deschideți sesiuni RDP, VNC și Telnet într-o filă, la fel ca în cazul SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Adâncimea culorii"
},
"resizeMethod": {
"label": "Metodă de redimensionare"
},
"resizeDisplayUpdate": "Actualizare afișare",
"resizeReconnect": "Reconectați-vă",
"inherit": "Moşteni",
"on": "Pe",
"off": "Dezactivat",
"forceLossless": {
"label": "Forța fără pierderi",
"description": "Forțează codificarea imaginilor fără pierderi (calitate mai mare, lățime de bandă mai mare)"
},
"enableWallpaper": {
"label": "Tapet",
"description": "Afișează imaginea de fundal a desktopului (dezactivarea îmbunătățește performanța)"
},
"enableFontSmoothing": {
"label": "Netezirea fonturilor",
"description": "Activează randarea fonturilor ClearType"
},
"enableDesktopComposition": {
"label": "Compoziție desktop",
"description": "Activează efectele Aero Glass"
},
"disableAudio": {
"label": "Dezactivați sunetul",
"description": "Dezactivați tot sunetul din sesiunea la distanță"
},
"enablePrinting": {
"label": "Activează imprimarea",
"description": "Redirecționează imprimantele locale către sesiunea la distanță"
},
"enableDrive": {
"label": "Activați redirecționarea unității",
"description": "Maparea unui folder local ca unitate în sesiunea la distanță"
},
"disableCopy": {
"label": "Dezactivați copierea",
"description": "Împiedicați copierea textului din sesiunea la distanță"
},
"disablePaste": {
"label": "Dezactivați lipirea",
"description": "Împiedicați lipirea textului în sesiunea la distanță"
}
},
"admin": {
"guacdUrl": {
"label": "URL-ul guacd"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Название шрифта",
"fontSize": "Размер шрифта",
"colorScheme": "Цветовая схема",
"backspaceKey": "Клавиша Backspace"
}
"backspaceKey": "Клавиша Backspace",
"colorDepth": "Глубина цвета",
"resizeMethod": "Метод изменения размера"
},
"remoteDesktop": "Удаленный рабочий стол",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Виртуальная сеть",
"unencryptedShell": "Незашифрованная оболочка"
},
"onboarding": {
"feature_desktop": "Удаленный рабочий стол",
"feature_desktop_desc": "Открывайте сеансы RDP, VNC и Telnet во вкладке, как и SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Глубина цвета"
},
"resizeMethod": {
"label": "Метод изменения размера"
},
"resizeDisplayUpdate": "Обновление дисплея",
"resizeReconnect": "Переподключитесь",
"inherit": "Наследовать",
"on": "На",
"off": "Выключенный",
"forceLossless": {
"label": "Принудительно без потерь",
"description": "Принудительное кодирование изображений без потерь (более высокое качество, больший расход трафика)"
},
"enableWallpaper": {
"label": "Обои",
"description": "Показывать обои рабочего стола (отключение повышает производительность)"
},
"enableFontSmoothing": {
"label": "Сглаживание шрифтов",
"description": "Включить рендеринг шрифтов ClearType"
},
"enableDesktopComposition": {
"label": "Композиция рабочего стола",
"description": "Включить эффекты Aero Glass"
},
"disableAudio": {
"label": "Отключить звук",
"description": "Отключить весь звук в удаленном сеансе"
},
"enablePrinting": {
"label": "Включить печать",
"description": "Перенаправлять локальные принтеры в удаленный сеанс"
},
"enableDrive": {
"label": "Включить перенаправление дисков",
"description": "Подключать локальную папку как диск в удаленном сеансе"
},
"disableCopy": {
"label": "Отключить копирование",
"description": "Запретить копирование текста из удаленного сеанса"
},
"disablePaste": {
"label": "Отключить вставку",
"description": "Запретить вставку текста в удаленный сеанс"
}
},
"admin": {
"guacdUrl": {
"label": "URL guacd"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Назив фонта",
"fontSize": "Величина фонта",
"colorScheme": "Шема боја",
"backspaceKey": "Тастер Backspace"
}
"backspaceKey": "Тастер Backspace",
"colorDepth": "Дубина боје",
"resizeMethod": "Метод промене величине"
},
"remoteDesktop": "Удаљена радна површина",
"tabRdp": "РДП",
"tabVnc": "VNC",
"tabTelnet": "Телнет",
"virtualNetwork": "Виртуелна мрежа",
"unencryptedShell": "Нешифрована љуска"
},
"onboarding": {
"feature_desktop": "Удаљена радна површина",
"feature_desktop_desc": "Отворите RDP, VNC и Telnet сесије у картици, исто као и SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Дубина боје"
},
"resizeMethod": {
"label": "Метод промене величине"
},
"resizeDisplayUpdate": "Ажурирање екрана",
"resizeReconnect": "Поново се повежите",
"inherit": "Наследи",
"on": "Укључено",
"off": "Искључено",
"forceLossless": {
"label": "Присилно без губитака",
"description": "Присилно кодирање слике без губитака (виши квалитет, већи пропусни опсег)"
},
"enableWallpaper": {
"label": "Позадина",
"description": "Прикажи позадину радне површине (онемогућавање побољшава перформансе)"
},
"enableFontSmoothing": {
"label": "Изглађивање фонта",
"description": "Омогући приказивање ClearType фонта"
},
"enableDesktopComposition": {
"label": "Композиција на радној површини",
"description": "Омогући ефекте Aero Glass-а"
},
"disableAudio": {
"label": "Онемогући звук",
"description": "Искључите сав звук из удаљене сесије"
},
"enablePrinting": {
"label": "Омогући штампање",
"description": "Преусмерите локалне штампаче на удаљену сесију"
},
"enableDrive": {
"label": "Омогући преусмеравање диска",
"description": "Мапирајте локалну фасциклу као диск у удаљеној сесији"
},
"disableCopy": {
"label": "Онемогући копирање",
"description": "Спречите копирање текста из удаљене сесије"
},
"disablePaste": {
"label": "Онемогући лепљење",
"description": "Спречите лепљење текста у удаљену сесију"
}
},
"admin": {
"guacdUrl": {
"label": "URL адреса guacd-а"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Typsnittsnamn",
"fontSize": "Fontstorlek",
"colorScheme": "Färgschema",
"backspaceKey": "Backstegstangenten"
}
"backspaceKey": "Backstegstangenten",
"colorDepth": "Färgdjup",
"resizeMethod": "Ändra storleksmetod"
},
"remoteDesktop": "Fjärrskrivbord",
"tabRdp": "Landsbygdsutveckling",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Virtuellt nätverk",
"unencryptedShell": "Okrypterat skal"
},
"onboarding": {
"feature_desktop": "Fjärrskrivbord",
"feature_desktop_desc": "Öppna RDP-, VNC- och Telnet-sessioner i en flik, samma som med SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Färgdjup"
},
"resizeMethod": {
"label": "Ändra storleksmetod"
},
"resizeDisplayUpdate": "Uppdatering av displayen",
"resizeReconnect": "Återanslut",
"inherit": "Ärva",
"on": "På",
"off": "Av",
"forceLossless": {
"label": "Tvinga förlustfri",
"description": "Tvinga fram förlustfri bildkodning (högre kvalitet, mer bandbredd)"
},
"enableWallpaper": {
"label": "Tapet",
"description": "Visa skrivbordsbakgrund (inaktivering förbättrar prestandan)"
},
"enableFontSmoothing": {
"label": "Utjämning av teckensnitt",
"description": "Aktivera rendering av ClearType-teckensnitt"
},
"enableDesktopComposition": {
"label": "Skrivbordskomposition",
"description": "Aktivera Aero Glass-effekter"
},
"disableAudio": {
"label": "Inaktivera ljud",
"description": "Stäng av allt ljud från fjärrsessionen"
},
"enablePrinting": {
"label": "Aktivera utskrift",
"description": "Omdirigera lokala skrivare till fjärrsessionen"
},
"enableDrive": {
"label": "Aktivera omdirigering av kördata",
"description": "Mappa en lokal mapp som en enhet i fjärrsessionen"
},
"disableCopy": {
"label": "Inaktivera kopiering",
"description": "Förhindra kopiering av text från fjärrsessionen"
},
"disablePaste": {
"label": "Inaktivera inklistring",
"description": "Förhindra att text klistras in i fjärrsessionen"
}
},
"admin": {
"guacdUrl": {
"label": "guacd-URL"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "ชื่อแบบอักษร",
"fontSize": "ขนาดตัวอักษร",
"colorScheme": "โทนสี",
"backspaceKey": "ปุ่มลบ"
}
"backspaceKey": "ปุ่มลบ",
"colorDepth": "ความลึกของสี",
"resizeMethod": "วิธีการปรับขนาด"
},
"remoteDesktop": "รีโมทเดสก์ท็อป",
"tabRdp": "อาร์ดีพี",
"tabVnc": "วีเอ็นซี",
"tabTelnet": "เทลเน็ต",
"virtualNetwork": "เครือข่ายเสมือน",
"unencryptedShell": "เชลล์ที่ไม่ได้เข้ารหัส"
},
"onboarding": {
"feature_desktop": "การเข้าถึงเดสก์ท็อประยะไกล",
"feature_desktop_desc": "เปิดเซสชัน RDP, VNC และ Telnet ในแท็บเดียวกัน เหมือนกับการเปิด SSH"
},
"settings": {
"user": {
"colorDepth": {
"label": "ความลึกของสี"
},
"resizeMethod": {
"label": "วิธีการปรับขนาด"
},
"resizeDisplayUpdate": "อัปเดตการแสดงผล",
"resizeReconnect": "เชื่อมต่อใหม่",
"inherit": "สืบทอด",
"on": "บน",
"off": "ปิด",
"forceLossless": {
"label": "บังคับการสูญเสีย",
"description": "บังคับใช้การเข้ารหัสภาพแบบไม่สูญเสียคุณภาพ (คุณภาพสูงกว่า ใช้แบนด์วิดท์มากกว่า)"
},
"enableWallpaper": {
"label": "วอลเปเปอร์",
"description": "แสดงภาพพื้นหลังเดสก์ท็อป (การปิดใช้งานจะช่วยเพิ่มประสิทธิภาพ)"
},
"enableFontSmoothing": {
"label": "การปรับความเรียบของแบบอักษร",
"description": "เปิดใช้งานการแสดงผลฟอนต์ ClearType"
},
"enableDesktopComposition": {
"label": "การจัดองค์ประกอบเดสก์ท็อป",
"description": "เปิดใช้งานเอฟเฟกต์กระจก Aero"
},
"disableAudio": {
"label": "ปิดใช้งานเสียง",
"description": "ปิดเสียงทั้งหมดจากการเชื่อมต่อระยะไกล"
},
"enablePrinting": {
"label": "เปิดใช้งานการพิมพ์",
"description": "โอนย้ายเครื่องพิมพ์ในเครื่องไปยังเซสชันระยะไกล"
},
"enableDrive": {
"label": "เปิดใช้งานการเปลี่ยนเส้นทางไดรฟ์",
"description": "แมปโฟลเดอร์ในเครื่องเป็นไดรฟ์ในเซสชันระยะไกล"
},
"disableCopy": {
"label": "ปิดใช้งานการคัดลอก",
"description": "ป้องกันการคัดลอกข้อความจากเซสชันระยะไกล"
},
"disablePaste": {
"label": "ปิดใช้งานการวาง",
"description": "ป้องกันการวางข้อความลงในเซสชันระยะไกล"
}
},
"admin": {
"guacdUrl": {
"label": "URL guacd"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Yazı Tipi Adı",
"fontSize": "Yazı Tipi Boyutu",
"colorScheme": "Renk Şeması",
"backspaceKey": "Geri Silme Tuşu"
}
"backspaceKey": "Geri Silme Tuşu",
"colorDepth": "Renk Derinliği",
"resizeMethod": "Yeniden Boyutlandırma Yöntemi"
},
"remoteDesktop": "Uzak Masaüstü",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Sanal Ağ",
"unencryptedShell": "Şifrelenmemiş Shell"
},
"onboarding": {
"feature_desktop": "Uzaktan masaüstü",
"feature_desktop_desc": "SSH'de olduğu gibi, RDP, VNC ve Telnet oturumlarını da bir sekmede açın."
},
"settings": {
"user": {
"colorDepth": {
"label": "Renk Derinliği"
},
"resizeMethod": {
"label": "Yeniden Boyutlandırma Yöntemi"
},
"resizeDisplayUpdate": "Ekran Güncellemesi",
"resizeReconnect": "Yeniden bağlan",
"inherit": "Miras almak",
"on": "Açık",
"off": "Kapalı",
"forceLossless": {
"label": "Kayıpsız Zorla",
"description": "Kayıpsız görüntü kodlamasını zorla (daha yüksek kalite, daha fazla bant genişliği)"
},
"enableWallpaper": {
"label": "Duvar Kağıdı",
"description": "Masaüstü duvar kağıdını göster (devre dışı bırakmak performansı artırır)"
},
"enableFontSmoothing": {
"label": "Yazı Tipi Yumuşatma",
"description": "ClearType yazı tipi işlemeyi etkinleştir"
},
"enableDesktopComposition": {
"label": "Masaüstü Birleştirme",
"description": "Aero cam efektlerini etkinleştir"
},
"disableAudio": {
"label": "Sesi Devre Dışı Bırak",
"description": "Uzak oturumdaki tüm sesleri kapat"
},
"enablePrinting": {
"label": "Yazdırmayı Etkinleştir",
"description": "Yerel yazıcıları uzak oturuma yönlendir"
},
"enableDrive": {
"label": "Sürücü Yönlendirmesini Etkinleştir",
"description": "Yerel bir klasörü uzak oturumda sürücü olarak eşleştir"
},
"disableCopy": {
"label": "Kopyalamayı Devre Dışı Bırak",
"description": "Uzak oturumdan metin kopyalamayı engelle"
},
"disablePaste": {
"label": "Yapıştırmayı Devre Dışı Bırak",
"description": "Uzak oturuma metin yapıştırmayı engelle"
}
},
"admin": {
"guacdUrl": {
"label": "guacd URL'si"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Назва шрифту",
"fontSize": "Розмір шрифту",
"colorScheme": "Колірна схема",
"backspaceKey": "Клавіша Backspace"
}
"backspaceKey": "Клавіша Backspace",
"colorDepth": "Глибина кольору",
"resizeMethod": "Метод зміни розміру"
},
"remoteDesktop": "Віддалений робочий стіл",
"tabRdp": "ПРП",
"tabVnc": "VNC",
"tabTelnet": "Телнет",
"virtualNetwork": "Віртуальна мережа",
"unencryptedShell": "Незашифрована оболонка"
},
"onboarding": {
"feature_desktop": "Віддалений робочий стіл",
"feature_desktop_desc": "Відкривати сеанси RDP, VNC та Telnet у вкладці, так само, як SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Глибина кольору"
},
"resizeMethod": {
"label": "Метод зміни розміру"
},
"resizeDisplayUpdate": "Оновлення дисплея",
"resizeReconnect": "Знову підключитися",
"inherit": "Успадкувати",
"on": "Увімкнено",
"off": "Вимкнено",
"forceLossless": {
"label": "Примусово без втрат",
"description": "Примусове кодування зображень без втрат (вища якість, більша пропускна здатність)"
},
"enableWallpaper": {
"label": "Шпалери",
"description": "Показувати шпалери робочого столу (вимикання покращує продуктивність)"
},
"enableFontSmoothing": {
"label": "Згладжування шрифтів",
"description": "Увімкнути рендеринг шрифтів ClearType"
},
"enableDesktopComposition": {
"label": "Композиція робочого столу",
"description": "Увімкнути ефекти Aero Glass"
},
"disableAudio": {
"label": "Вимкнути аудіо",
"description": "Вимкнути весь звук під час віддаленого сеансу"
},
"enablePrinting": {
"label": "Увімкнути друк",
"description": "Перенаправити локальні принтери на віддалений сеанс"
},
"enableDrive": {
"label": "Увімкнути перенаправлення диска",
"description": "Призначення локальної папки як диска у віддаленому сеансі"
},
"disableCopy": {
"label": "Вимкнути копіювання",
"description": "Запобігти копіюванню тексту з віддаленого сеансу"
},
"disablePaste": {
"label": "Вимкнути вставку",
"description": "Запобігти вставці тексту у віддалений сеанс"
}
},
"admin": {
"guacdUrl": {
"label": "URL-адреса GUACD"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "Tên phông chữ",
"fontSize": "Cỡ chữ",
"colorScheme": "Bảng màu",
"backspaceKey": "Phím Backspace"
}
"backspaceKey": "Phím Backspace",
"colorDepth": "Độ sâu màu",
"resizeMethod": "Phương thức thay đổi kích thước"
},
"remoteDesktop": "Máy tính từ xa",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "Mạng ảo",
"unencryptedShell": "Shell không mã hóa"
},
"onboarding": {
"feature_desktop": "Máy tính từ xa",
"feature_desktop_desc": "Mở các phiên RDP, VNC và Telnet trong cùng một tab, tương tự như SSH."
},
"settings": {
"user": {
"colorDepth": {
"label": "Độ sâu màu"
},
"resizeMethod": {
"label": "Phương thức thay đổi kích thước"
},
"resizeDisplayUpdate": "Cập nhật hiển thị",
"resizeReconnect": "Kết nối lại",
"inherit": "Kế thừa",
"on": "TRÊN",
"off": "Tắt",
"forceLossless": {
"label": "Ép buộc Lossless",
"description": "Ép buộc mã hóa ảnh không mất dữ liệu (chất lượng cao hơn, tốn băng thông)"
},
"enableWallpaper": {
"label": "Hình nền",
"description": "Hiển thị hình nền desktop (tắt để cải thiện hiệu suất)"
},
"enableFontSmoothing": {
"label": "Làm mịn phông chữ",
"description": "Bật kết xuất phông chữ ClearType"
},
"enableDesktopComposition": {
"label": "Tổ hợp màn hình nền",
"description": "Bật hiệu ứng kính Aero"
},
"disableAudio": {
"label": "Tắt âm thanh",
"description": "Tắt tất cả âm thanh từ phiên từ xa"
},
"enablePrinting": {
"label": "Bật in",
"description": "Chuyển hướng máy in cục bộ đến phiên từ xa"
},
"enableDrive": {
"label": "Bật chuyển hướng ổ đĩa",
"description": "Ánh xạ thư mục cục bộ thành ổ đĩa trong phiên từ xa"
},
"disableCopy": {
"label": "Tắt sao chép",
"description": "Ngăn sao chép văn bản từ phiên từ xa"
},
"disablePaste": {
"label": "Tắt dán",
"description": "Ngăn dán văn bản vào phiên từ xa"
}
},
"admin": {
"guacdUrl": {
"label": "URL guacd"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "字体名称",
"fontSize": "字体大小",
"colorScheme": "配色方案",
"backspaceKey": "退格键"
}
"backspaceKey": "退格键",
"colorDepth": "颜色深度",
"resizeMethod": "调整大小方法"
},
"remoteDesktop": "远程桌面",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "虚拟网络",
"unencryptedShell": "未加密 Shell"
},
"onboarding": {
"feature_desktop": "远程桌面",
"feature_desktop_desc": "像 SSH 一样,在标签页中直接打开 RDP、VNC 和 Telnet 会话。"
},
"settings": {
"user": {
"colorDepth": {
"label": "颜色深度"
},
"resizeMethod": {
"label": "调整大小方法"
},
"resizeDisplayUpdate": "显示更新",
"resizeReconnect": "重新连接",
"inherit": "继承",
"on": "开启",
"off": "关闭",
"forceLossless": {
"label": "强制无损",
"description": "强制无损图像编码(质量更高,占用更多带宽)"
},
"enableWallpaper": {
"label": "壁纸",
"description": "显示桌面壁纸(禁用可提高性能)"
},
"enableFontSmoothing": {
"label": "字体平滑",
"description": "启用 ClearType 字体渲染"
},
"enableDesktopComposition": {
"label": "桌面合成",
"description": "启用 Aero 玻璃效果"
},
"disableAudio": {
"label": "禁用音频",
"description": "将远程会话的所有音频静音"
},
"enablePrinting": {
"label": "启用打印",
"description": "将本地打印机重定向到远程会话"
},
"enableDrive": {
"label": "启用驱动器重定向",
"description": "将本地文件夹映射为远程会话中的驱动器"
},
"disableCopy": {
"label": "禁用复制",
"description": "禁止从远程会话复制文本"
},
"disablePaste": {
"label": "禁用粘贴",
"description": "禁止粘贴文本到远程会话"
}
},
"admin": {
"guacdUrl": {
"label": "guacd URL"
}
}
}
}
@@ -204,11 +204,75 @@
"fontName": "字型名稱",
"fontSize": "字型大小",
"colorScheme": "色彩配置",
"backspaceKey": "退格鍵"
}
"backspaceKey": "退格鍵",
"colorDepth": "色彩深度",
"resizeMethod": "調整尺寸方法"
},
"remoteDesktop": "遠端桌面",
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTelnet": "Telnet",
"virtualNetwork": "虛擬網路",
"unencryptedShell": "未加密殼層"
},
"onboarding": {
"feature_desktop": "遠端桌面",
"feature_desktop_desc": "像 SSH 一樣,在同一個標籤頁中開啟 RDP、VNC 和 Telnet 會話。"
},
"settings": {
"user": {
"colorDepth": {
"label": "色彩深度"
},
"resizeMethod": {
"label": "調整尺寸方法"
},
"resizeDisplayUpdate": "顯示更新",
"resizeReconnect": "重新連接",
"inherit": "繼承",
"on": "在",
"off": "離開",
"forceLossless": {
"label": "強制無損",
"description": "強制使用無損影像編碼(品質較高,頻寬需求較大)"
},
"enableWallpaper": {
"label": "桌面背景",
"description": "顯示桌面背景(停用可提升效能)"
},
"enableFontSmoothing": {
"label": "字型平滑處理",
"description": "啟用 ClearType 字型轉譯"
},
"enableDesktopComposition": {
"label": "桌面合成",
"description": "啟用 Aero 玻璃效果"
},
"disableAudio": {
"label": "停用音訊",
"description": "將遠端工作階段的音訊全部靜音"
},
"enablePrinting": {
"label": "啟用列印功能",
"description": "將本機印表機重新導向至遠端工作階段"
},
"enableDrive": {
"label": "啟用磁碟機重新導向",
"description": "在遠端工作階段中將本機資料夾對應為磁碟機"
},
"disableCopy": {
"label": "停用複製",
"description": "防止從遠端工作階段複製文字"
},
"disablePaste": {
"label": "停用貼上",
"description": "防止將文字貼上至遠端工作階段"
}
},
"admin": {
"guacdUrl": {
"label": "guacd URL"
}
}
}
}
+376 -19
View File
@@ -16,15 +16,33 @@
},
"capabilities": [
"hosts:read",
"credentials:read",
"credentials:use",
"ssh:connect",
"network:serve",
"audit:read",
"desktop:window",
"ui:surface"
],
"optionalDependencies": {
"session-recording": "^1.0.0",
"tunnels": "^1.0.0"
},
"provides": [
{
"service": "sessions.live",
"version": "1.0.0",
"permission": "remote-desktop.sessions",
"names": ["rdp", "vnc", "telnet"]
}
],
"requires": [
{
"service": "recordings.writer",
"versionRange": "^1.1.0",
"optional": true
},
{
"service": "tunnels.access",
"versionRange": "^1.0.0",
"optional": true
}
@@ -50,30 +68,369 @@
"openFrom": ["host-context-menu", "palette"]
}
],
"hostCapability": [
{
"key": "enableRdp",
"labelKey": "hosts.tabRdp",
"editorTab": "rdp"
},
{
"key": "enableVnc",
"labelKey": "hosts.tabVnc",
"editorTab": "vnc"
},
{
"key": "enableTelnet",
"labelKey": "hosts.tabTelnet",
"editorTab": "telnet"
}
],
"actionSlots": [
{
"id": "remote-desktop.toolbar",
"accepts": ["button"]
}
],
"guest": true
"guest": true,
"permissions": [
{
"name": "sessions",
"titleKey": "permissions.sessions.title",
"descriptionKey": "permissions.sessions.description",
"defaultRoles": ["admin", "user"]
}
],
"settings": {
"admin": [
{
"key": "enabled",
"type": "boolean",
"labelKey": "settings.admin.enabled.label",
"descriptionKey": "settings.admin.enabled.description",
"default": true
},
{
"key": "guacdUrl",
"type": "string",
"labelKey": "settings.admin.guacdUrl.label",
"descriptionKey": "settings.admin.guacdUrl.description",
"placeholderKey": "settings.admin.guacdUrl.placeholder",
"requires": "enabled",
"default": ""
}
],
"user": [
{
"key": "colorDepth",
"type": "select",
"labelKey": "settings.user.colorDepth.label",
"descriptionKey": "settings.user.colorDepth.description",
"group": "settings.user.group",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "16",
"labelKey": "settings.user.colorDepth16"
},
{
"value": "24",
"labelKey": "settings.user.colorDepth24"
},
{
"value": "32",
"labelKey": "settings.user.colorDepth32"
}
],
"default": "inherit"
},
{
"key": "resizeMethod",
"type": "select",
"labelKey": "settings.user.resizeMethod.label",
"descriptionKey": "settings.user.resizeMethod.description",
"group": "settings.user.group",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "display-update",
"labelKey": "settings.user.resizeDisplayUpdate"
},
{
"value": "reconnect",
"labelKey": "settings.user.resizeReconnect"
}
],
"default": "inherit"
},
{
"key": "forceLossless",
"type": "select",
"labelKey": "settings.user.forceLossless.label",
"descriptionKey": "settings.user.forceLossless.description",
"group": "settings.user.group",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "on",
"labelKey": "settings.user.on"
},
{
"value": "off",
"labelKey": "settings.user.off"
}
],
"default": "inherit"
},
{
"key": "enableWallpaper",
"type": "select",
"labelKey": "settings.user.enableWallpaper.label",
"descriptionKey": "settings.user.enableWallpaper.description",
"group": "settings.user.group",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "on",
"labelKey": "settings.user.on"
},
{
"value": "off",
"labelKey": "settings.user.off"
}
],
"default": "inherit"
},
{
"key": "enableFontSmoothing",
"type": "select",
"labelKey": "settings.user.enableFontSmoothing.label",
"descriptionKey": "settings.user.enableFontSmoothing.description",
"group": "settings.user.group",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "on",
"labelKey": "settings.user.on"
},
{
"value": "off",
"labelKey": "settings.user.off"
}
],
"default": "inherit"
},
{
"key": "enableDesktopComposition",
"type": "select",
"labelKey": "settings.user.enableDesktopComposition.label",
"descriptionKey": "settings.user.enableDesktopComposition.description",
"group": "settings.user.group",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "on",
"labelKey": "settings.user.on"
},
{
"value": "off",
"labelKey": "settings.user.off"
}
],
"default": "inherit"
},
{
"key": "disableAudio",
"type": "select",
"labelKey": "settings.user.disableAudio.label",
"descriptionKey": "settings.user.disableAudio.description",
"group": "settings.user.group",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "on",
"labelKey": "settings.user.on"
},
{
"value": "off",
"labelKey": "settings.user.off"
}
],
"default": "inherit"
},
{
"key": "enablePrinting",
"type": "select",
"labelKey": "settings.user.enablePrinting.label",
"descriptionKey": "settings.user.enablePrinting.description",
"group": "settings.user.group",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "on",
"labelKey": "settings.user.on"
},
{
"value": "off",
"labelKey": "settings.user.off"
}
],
"default": "inherit"
},
{
"key": "enableDrive",
"type": "select",
"labelKey": "settings.user.enableDrive.label",
"descriptionKey": "settings.user.enableDrive.description",
"group": "settings.user.group",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "on",
"labelKey": "settings.user.on"
},
{
"value": "off",
"labelKey": "settings.user.off"
}
],
"default": "inherit"
},
{
"key": "disableCopy",
"type": "select",
"labelKey": "settings.user.disableCopy.label",
"descriptionKey": "settings.user.disableCopy.description",
"group": "settings.user.group",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "on",
"labelKey": "settings.user.on"
},
{
"value": "off",
"labelKey": "settings.user.off"
}
],
"default": "inherit"
},
{
"key": "disablePaste",
"type": "select",
"labelKey": "settings.user.disablePaste.label",
"descriptionKey": "settings.user.disablePaste.description",
"group": "settings.user.group",
"options": [
{
"value": "inherit",
"labelKey": "settings.user.inherit"
},
{
"value": "on",
"labelKey": "settings.user.on"
},
{
"value": "off",
"labelKey": "settings.user.off"
}
],
"default": "inherit"
}
],
"host": {
"fields": [
{
"key": "enableRdp",
"type": "boolean",
"labelKey": "settings.host.enableRdp.label",
"default": false,
"hidden": true
},
{
"key": "enableVnc",
"type": "boolean",
"labelKey": "settings.host.enableVnc.label",
"default": false,
"hidden": true
},
{
"key": "enableTelnet",
"type": "boolean",
"labelKey": "settings.host.enableTelnet.label",
"default": false,
"hidden": true
},
{
"key": "rdpPort",
"type": "number",
"labelKey": "settings.host.rdpPort.label",
"min": 1,
"max": 65535,
"default": 3389,
"hidden": true
},
{
"key": "vncPort",
"type": "number",
"labelKey": "settings.host.vncPort.label",
"min": 1,
"max": 65535,
"default": 5900,
"hidden": true
},
{
"key": "telnetPort",
"type": "number",
"labelKey": "settings.host.telnetPort.label",
"min": 1,
"max": 65535,
"default": 23,
"hidden": true
},
{
"key": "rdpSecurity",
"type": "string",
"labelKey": "settings.host.rdpSecurity.label",
"default": "",
"hidden": true
},
{
"key": "rdpIgnoreCert",
"type": "boolean",
"labelKey": "settings.host.rdpIgnoreCert.label",
"default": false,
"hidden": true
},
{
"key": "guacamoleConfig",
"type": "json",
"labelKey": "settings.host.guacamoleConfig.label",
"default": {},
"hidden": true
},
{
"key": "enableToolbar",
"type": "boolean",
"labelKey": "settings.host.enableToolbar.label",
"descriptionKey": "settings.host.enableToolbar.description",
"default": true
}
]
}
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
@@ -1,18 +1,20 @@
import GuacamoleLite from "guacamole-lite";
import { guacLogger } from "../../../../src/backend/utils/logger.js";
import {
GuacamoleTokenService,
type GuacamoleRecordingMetadata,
} from "./token-service.js";
import { getCurrentSettingValue } from "../../../../src/backend/database/repositories/factory.js";
import { resolveGuacdOptions } from "../../../../src/backend/utils/guacd-config.js";
import fs from "fs";
import path from "path";
const tokenService = GuacamoleTokenService.getInstance();
import type { IncomingMessage } from "http";
import type { Duplex } from "stream";
import type {
GuacamoleRecordingMetadata,
GuacamoleTokenService,
TermixGuacMeta,
} from "./token-service.js";
import type { GuacdOptions } from "./guacd-config.js";
import type { RemoteSessions } from "./sessions.js";
import { errorMessage, type RemoteDesktopLogger } from "./log.js";
/** The recordings.writer service, when the session-recording plugin provides it. */
export interface RecordingsWriter {
enabledFor: (hostId: number) => Promise<boolean>;
createFinished: (input: {
hostId: number;
userId: string;
@@ -25,147 +27,38 @@ export interface RecordingsWriter {
}) => Promise<{ id: number }>;
}
let pluginRecordings: RecordingsWriter | null = null;
export function setPluginRecordings(recordings: RecordingsWriter | null): void {
pluginRecordings = recordings;
}
function readGuacdOptions(): { host: string; port: number } {
let dbUrl: string | undefined;
try {
dbUrl = getCurrentSettingValue("guac_url") ?? undefined;
} catch {
// DB not available yet, use env var defaults
}
return resolveGuacdOptions(dbUrl);
}
const DATA_DIR = process.env.DATA_DIR || "./db/data";
const GUACAMOLE_RECORDINGS_DIR =
process.env.GUACD_RECORDING_BACKEND_PATH ||
path.join(DATA_DIR, "session_recordings", "guacamole");
type GuacamoleClientConnection = {
/** Where the backend reads guacd's recordings from. */
export function recordingsDir(env: NodeJS.ProcessEnv = process.env): string {
return (
env.GUACD_RECORDING_BACKEND_PATH ||
path.join(DATA_DIR, "session_recordings", "guacamole")
);
}
type ClientConnection = {
guacamoleConnectionId?: string;
connectionSettings?: {
connection?: { type?: string; join?: string; readOnly?: boolean };
recording?: GuacamoleRecordingMetadata;
termixMeta?: {
termixConnectId: string;
hostId: number;
ownerUserId: string;
protocol: string;
};
termixMeta?: TermixGuacMeta;
};
close?: () => void;
};
export interface GuacSessionInfo {
guacamoleConnectionId: string;
hostId: number;
ownerUserId: string;
protocol: string;
openedAt: number;
}
// Keyed by termixConnectId (routes.ts's correlation id), populated once the
// primary connection's guacd handshake completes.
const guacSessionByConnectId = new Map<string, GuacSessionInfo>();
// Keyed by guacd's own guacamoleConnectionId, for join-time lookups.
const guacSessionByGuacamoleId = new Map<string, GuacSessionInfo>();
export function getGuacSessionByConnectId(
connectId: string,
userId: string,
): GuacSessionInfo | null {
const info = guacSessionByConnectId.get(connectId);
return info?.ownerUserId === userId ? info : null;
}
export function getGuacSessionInfo(
guacamoleConnectionId: string,
): GuacSessionInfo | null {
return guacSessionByGuacamoleId.get(guacamoleConnectionId) ?? null;
}
async function persistGuacamoleRecording(
clientConnection: GuacamoleClientConnection,
): Promise<void> {
const recording = clientConnection.connectionSettings?.recording;
if (!recording) return;
const resolvedPath = path.resolve(GUACAMOLE_RECORDINGS_DIR, recording.path);
const allowedBase = `${path.resolve(GUACAMOLE_RECORDINGS_DIR)}${path.sep}`;
if (!resolvedPath.startsWith(allowedBase)) return;
// guacd may flush/rename the recording just after the websocket closes.
for (
let attempt = 0;
attempt < 10 && !fs.existsSync(resolvedPath);
attempt++
) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (!fs.existsSync(resolvedPath)) {
const guacdPath = recording.guacdPath ?? GUACAMOLE_RECORDINGS_DIR;
guacLogger.warn("Guacamole recording file was not found", {
operation: "guac_recording_missing",
hostId: recording.hostId,
path: resolvedPath,
guacdPath,
hint:
"guacd writes the recording to guacdPath, the backend reads it from path. " +
"When guacd runs in its own container these must be the same volume — set " +
"GUACD_RECORDING_PATH to guacd's mount point and GUACD_RECORDING_BACKEND_PATH to this one.",
});
return;
}
if (!pluginRecordings) {
guacLogger.warn(
"Session Recording plugin is off; guacamole recording not saved to the log",
{ operation: "guac_recording_no_writer", hostId: recording.hostId },
);
return;
}
const endedAt = new Date();
const startedAt = new Date(recording.startedAt);
await pluginRecordings.createFinished({
hostId: recording.hostId,
userId: recording.userId,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
duration: Math.max(
0,
Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000),
),
recordingPath: resolvedPath,
protocol: recording.protocol,
format: "guacamole",
});
}
// noServer: core owns the listening socket. guacamole-lite passes this object
// straight to ws, and exposes the WebSocketServer it builds, so the plugin can
// hand it an upgrade from /plugin-ws/remote-desktop/display. There is no
// guacamole port any more.
const websocketOptions = {
noServer: true,
};
const clientOptions = {
const clientOptions = (
tokens: GuacamoleTokenService,
log: RemoteDesktopLogger,
) => ({
crypt: {
cypher: "AES-256-CBC",
key: tokenService.getEncryptionKey(),
key: tokens.getEncryptionKey(),
},
log: {
level: "ERRORS",
stdLog: (...args: unknown[]) => {
guacLogger.info(args.join(" "));
},
errorLog: (...args: unknown[]) => {
guacLogger.error(args.join(" "));
},
stdLog: (...args: unknown[]) => log.info(args.join(" ")),
errorLog: (...args: unknown[]) => log.error(args.join(" ")),
},
allowedUnencryptedConnectionSettings: {
rdp: ["width", "height", "dpi"],
@@ -202,150 +95,224 @@ const clientOptions = {
"terminal-type": "xterm-256color",
},
},
};
});
const _origConsoleLog = console.log;
console.log = (...args: unknown[]) => {
const msg = args[0];
if (typeof msg === "string" && msg.startsWith("New client connection"))
return;
_origConsoleLog(...args);
};
export interface GuacamoleServerDeps {
log: RemoteDesktopLogger;
tokens: GuacamoleTokenService;
sessions: RemoteSessions;
guacd: () => GuacdOptions;
recordings: () => RecordingsWriter | null;
/** A socket event has no request behind it, so the row is written as the session's user. */
asUser: <T>(userId: string, fn: () => Promise<T>) => Promise<T>;
/** Called when a primary session opens; returns what to release on close. */
onOpen?: (hostId: number) => () => void;
}
function createGuacServer(): GuacamoleLite {
const guacdOptions = readGuacdOptions();
const server = new GuacamoleLite(
websocketOptions,
guacdOptions,
clientOptions,
);
export interface GuacamoleServer {
handleUpgrade: (
request: IncomingMessage,
socket: Duplex,
head: Buffer,
) => void;
/** Rebuilds the server so a changed guacd address takes effect. */
restart: () => void;
/** Closes a session and everyone watching it. */
endSession: (guacamoleConnectionId: string) => boolean;
close: () => void;
}
server.on("open", (clientConnection: GuacamoleClientConnection) => {
guacLogger.info("Guacamole connection opened", {
operation: "guac_connection_open",
type: clientConnection.connectionSettings?.connection?.type,
});
/**
* guacamole-lite in noServer mode: core owns the listening socket and hands
* upgrades from /plugin-ws/remote-desktop/display to its WebSocketServer.
*/
export function createGuacamoleServer(
deps: GuacamoleServerDeps,
): GuacamoleServer {
const { log, sessions } = deps;
let server: GuacamoleLite | null = null;
const termixMeta = clientConnection.connectionSettings?.termixMeta;
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
const persistRecording = async (connection: ClientConnection) => {
const recording = connection.connectionSettings?.recording;
if (!recording) return;
if (!isJoin && termixMeta && guacamoleConnectionId) {
const info: GuacSessionInfo = {
guacamoleConnectionId,
hostId: termixMeta.hostId,
ownerUserId: termixMeta.ownerUserId,
protocol: termixMeta.protocol,
openedAt: Date.now(),
};
guacSessionByConnectId.set(termixMeta.termixConnectId, info);
guacSessionByGuacamoleId.set(guacamoleConnectionId, info);
const dir = recordingsDir();
const resolvedPath = path.resolve(dir, recording.path);
if (!resolvedPath.startsWith(`${path.resolve(dir)}${path.sep}`)) return;
// guacd may flush or rename the recording just after the socket closes.
for (
let attempt = 0;
attempt < 10 && !fs.existsSync(resolvedPath);
attempt++
) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
});
server.on("close", (clientConnection: GuacamoleClientConnection) => {
guacLogger.info("Guacamole connection closed", {
operation: "guac_connection_close",
type: clientConnection.connectionSettings?.connection?.type,
});
const isJoin = !!clientConnection.connectionSettings?.connection?.join;
const termixMeta = clientConnection.connectionSettings?.termixMeta;
const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
if (!isJoin && termixMeta && guacamoleConnectionId) {
guacSessionByConnectId.delete(termixMeta.termixConnectId);
guacSessionByGuacamoleId.delete(guacamoleConnectionId);
if (!fs.existsSync(resolvedPath)) {
log.warn("Guacamole recording file was not found", {
operation: "guac_recording_missing",
hostId: recording.hostId,
path: resolvedPath,
guacdPath: recording.guacdPath ?? dir,
hint:
"guacd writes the recording to guacdPath, the backend reads it from path. " +
"When guacd runs in its own container these must be the same volume: set " +
"GUACD_RECORDING_PATH to guacd's mount point and GUACD_RECORDING_BACKEND_PATH to this one.",
});
return;
}
persistGuacamoleRecording(clientConnection).catch((error) => {
guacLogger.error("Failed to persist Guacamole recording", error, {
operation: "guac_recording_persist_error",
const writer = await deps.asUser(recording.userId, async () =>
deps.recordings(),
);
if (!writer) {
log.warn(
"Session Recording is off; the recording was kept on disk only",
{
operation: "guac_recording_no_writer",
hostId: recording.hostId,
},
);
return;
}
const endedAt = new Date();
const startedAt = new Date(recording.startedAt);
await deps.asUser(recording.userId, () =>
writer.createFinished({
hostId: recording.hostId,
userId: recording.userId,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
duration: Math.max(
0,
Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000),
),
recordingPath: resolvedPath,
protocol: recording.protocol,
format: "guacamole",
}),
);
};
const build = (): GuacamoleLite => {
// guacamole-lite fills in port 8080 unless it sees a server key, and ws
// refuses a port next to noServer, so the port is cleared explicitly.
const guacamole = new GuacamoleLite(
{ noServer: true, port: undefined },
deps.guacd(),
clientOptions(deps.tokens, log),
);
// It also installs its own SIGTERM and SIGINT handlers. Core decides
// when the process stops and closes this through deactivate instead.
const handlers = guacamole as unknown as {
sigTermHandler?: () => void;
sigIntHandler?: () => void;
};
if (handlers.sigTermHandler) {
process.off("SIGTERM", handlers.sigTermHandler);
}
if (handlers.sigIntHandler) {
process.off("SIGINT", handlers.sigIntHandler);
}
guacamole.on("open", (connection: ClientConnection) => {
const meta = connection.connectionSettings?.termixMeta;
const guacamoleConnectionId = connection.guacamoleConnectionId;
const isJoin = !!connection.connectionSettings?.connection?.join;
if (isJoin || !meta || !guacamoleConnectionId) return;
const release = deps.onOpen?.(meta.hostId);
sessions.opened(meta, guacamoleConnectionId, release ? [release] : []);
});
guacamole.on("close", (connection: ClientConnection) => {
const meta = connection.connectionSettings?.termixMeta;
const isJoin = !!connection.connectionSettings?.connection?.join;
if (!isJoin && meta) sessions.closed(meta.termixConnectId);
persistRecording(connection).catch((error) => {
log.error("Failed to persist Guacamole recording", {
operation: "guac_recording_persist_error",
error: errorMessage(error),
});
});
});
});
server.on(
"error",
(clientConnection: GuacamoleClientConnection, error: Error) => {
guacLogger.error("Guacamole connection error", error, {
guacamole.on("error", (connection: ClientConnection, error: Error) => {
log.error("Guacamole connection error", {
operation: "guac_connection_error",
type: clientConnection.connectionSettings?.connection?.type,
type: connection.connectionSettings?.connection?.type,
error: errorMessage(error),
});
});
return guacamole;
};
const closeServer = () => {
if (!server) return;
try {
server.close();
} catch (error) {
log.error("Error closing the Guacamole server", {
error: errorMessage(error),
});
}
server = null;
};
server = build();
return {
handleUpgrade(request, socket, head) {
const wss = (
server as unknown as {
webSocketServer?: {
handleUpgrade: (
request: IncomingMessage,
socket: Duplex,
head: Buffer,
done: (ws: unknown) => void,
) => void;
emit: (event: string, ...args: unknown[]) => void;
};
} | null
)?.webSocketServer;
if (!wss) {
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit("connection", ws, request);
});
},
);
return server;
restart() {
if (!server) return;
closeServer();
server = build();
},
endSession(guacamoleConnectionId) {
const active = (
server as unknown as {
activeConnections?: Map<unknown, ClientConnection>;
} | null
)?.activeConnections;
if (!active) return false;
let closed = false;
for (const connection of [...active.values()]) {
const join = connection.connectionSettings?.connection?.join;
if (
connection.guacamoleConnectionId === guacamoleConnectionId ||
join === guacamoleConnectionId
) {
connection.close?.();
closed = true;
}
}
return closed;
},
close: closeServer,
};
}
let guacServer: GuacamoleLite | null = null;
/**
* Builds the guacamole-lite server. Called from this plugin's activate().
* Nothing binds a port: handleGuacamoleUpgrade below feeds it connections.
*/
export async function startGuacamoleService(): Promise<void> {
guacServer = createGuacServer();
}
/**
* Hands one upgrade to guacamole-lite's own WebSocketServer.
*
* guacamole-lite constructs that server internally and has no "attach to this
* http server" option, so giving it the upgrade is the only way in. Core has
* already authenticated the request by the time this runs.
*/
export function handleGuacamoleUpgrade(
request: import("http").IncomingMessage,
socket: import("stream").Duplex,
head: Buffer,
): void {
const server = guacServer as unknown as {
webSocketServer?: {
handleUpgrade: (
request: import("http").IncomingMessage,
socket: import("stream").Duplex,
head: Buffer,
done: (ws: unknown) => void,
) => void;
emit: (event: string, ...args: unknown[]) => void;
};
} | null;
const wss = server?.webSocketServer;
if (!wss) {
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit("connection", ws, request);
});
}
/**
* Stops the guacamole-lite WebSocket server. Called from this plugin's
* deactivate().
*/
export async function stopGuacamoleService(): Promise<void> {
if (!guacServer) return;
try {
guacServer.close();
} catch (err) {
guacLogger.error("Error closing guac server during shutdown", err as Error);
}
guacServer = null;
}
export async function restartGuacServer(): Promise<void> {
// A no-op while the plugin is disabled: creating a new server here would
// silently resurrect the WS server behind deactivate()'s back.
if (!guacServer) return;
try {
guacServer.close();
} catch (err) {
guacLogger.error("Error closing guac server during restart", err as Error);
}
guacServer = createGuacServer();
}
export { guacServer, tokenService };
@@ -0,0 +1,86 @@
export type GuacdOptions = {
host: string;
port: number;
};
const DEFAULT_GUACD_OPTIONS: GuacdOptions = {
host: "localhost",
port: 4822,
};
function parsePort(value: string | undefined, fallback: number) {
const port = parseInt(value || "", 10);
return Number.isFinite(port) ? port : fallback;
}
export function parseGuacdUrl(
value: string,
fallback: GuacdOptions = DEFAULT_GUACD_OPTIONS,
): GuacdOptions {
const raw = value.trim();
if (!raw) {
return fallback;
}
if (raw.includes("://")) {
try {
const url = new URL(raw);
return {
host: url.hostname || fallback.host,
port: parsePort(url.port, fallback.port),
};
} catch {
return fallback;
}
}
const bracketedIpv6 = raw.match(/^\[([^\]]+)\](?::(\d+))?$/);
if (bracketedIpv6) {
return {
host: bracketedIpv6[1],
port: parsePort(bracketedIpv6[2], fallback.port),
};
}
const [host, port] = raw.split(":");
return {
host: host || fallback.host,
port: parsePort(port, fallback.port),
};
}
export function getGuacdEnvOptions(): GuacdOptions | null {
if (process.env.GUACD_URL) {
return parseGuacdUrl(process.env.GUACD_URL);
}
if (!process.env.GUACD_HOST && !process.env.GUACD_PORT) {
return null;
}
return {
host: process.env.GUACD_HOST || DEFAULT_GUACD_OPTIONS.host,
port: parsePort(process.env.GUACD_PORT, DEFAULT_GUACD_OPTIONS.port),
};
}
export function resolveGuacdOptions(dbUrl?: string | null): GuacdOptions {
const envOptions = getGuacdEnvOptions();
if (envOptions) {
return envOptions;
}
if (dbUrl) {
return parseGuacdUrl(dbUrl);
}
return DEFAULT_GUACD_OPTIONS;
}
export function formatGuacdOptions(options: GuacdOptions): string {
return `${options.host}:${options.port}`;
}
export function getDefaultGuacdUrl(): string {
return formatGuacdOptions(resolveGuacdOptions());
}
@@ -0,0 +1,187 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
export type RemoteProtocol = "rdp" | "vnc" | "telnet";
export const PLUGIN_ID = "remote-desktop";
export interface RemoteDesktopHostSettings {
enableRdp: boolean;
enableVnc: boolean;
enableTelnet: boolean;
rdpPort: number;
vncPort: number;
telnetPort: number;
rdpSecurity: string;
rdpIgnoreCert: boolean;
guacamoleConfig: Record<string, unknown>;
enableToolbar: boolean;
}
export const ENABLE_KEY: Record<
RemoteProtocol,
keyof RemoteDesktopHostSettings
> = { rdp: "enableRdp", vnc: "enableVnc", telnet: "enableTelnet" };
export const PORT_KEY: Record<RemoteProtocol, keyof RemoteDesktopHostSettings> =
{ rdp: "rdpPort", vnc: "vncPort", telnet: "telnetPort" };
export const DEFAULT_PORT: Record<RemoteProtocol, number> = {
rdp: 3389,
vnc: 5900,
telnet: 23,
};
export function isRemoteProtocol(value: unknown): value is RemoteProtocol {
return value === "rdp" || value === "vnc" || value === "telnet";
}
function asPort(value: unknown): number | undefined {
const port = typeof value === "string" ? Number(value) : value;
return typeof port === "number" &&
Number.isInteger(port) &&
port > 0 &&
port <= 65535
? port
: undefined;
}
function asObject(value: unknown): Record<string, unknown> | undefined {
let parsed = value;
if (typeof value === "string") {
try {
parsed = JSON.parse(value);
} catch {
return undefined;
}
}
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined;
}
function asBool(value: unknown): boolean | undefined {
if (typeof value === "boolean") return value;
if (value === 1 || value === "1" || value === "true") return true;
if (value === 0 || value === "0" || value === "false") return false;
return undefined;
}
export async function readHostSettings(
ctx: PluginContext,
hostId: number,
): Promise<RemoteDesktopHostSettings> {
const values = await ctx.settings.getAll("host", hostId);
return {
enableRdp: values.enableRdp === true,
enableVnc: values.enableVnc === true,
enableTelnet: values.enableTelnet === true,
rdpPort: asPort(values.rdpPort) ?? DEFAULT_PORT.rdp,
vncPort: asPort(values.vncPort) ?? DEFAULT_PORT.vnc,
telnetPort: asPort(values.telnetPort) ?? DEFAULT_PORT.telnet,
rdpSecurity:
typeof values.rdpSecurity === "string" ? values.rdpSecurity : "",
rdpIgnoreCert: values.rdpIgnoreCert === true,
guacamoleConfig: asObject(values.guacamoleConfig) ?? {},
enableToolbar: values.enableToolbar !== false,
};
}
/**
* Turns an imported host row into this plugin's host settings.
*
* A Termix export carries them under pluginSettings["remote-desktop"]. Older
* exports, other tools and a plugin creating a host (proxmox) use the flat
* fields the host row had before 2.9.0, including the connectionType-only
* shape of hosts from before the per-protocol switches existed.
*/
export function normalizeImportedHost(
raw: Record<string, unknown>,
): Record<string, unknown> | null {
const nested =
asObject(asObject(raw.pluginSettings)?.[PLUGIN_ID]) ??
({} as Record<string, unknown>);
const pick = (key: string, legacy?: string) =>
nested[key] !== undefined
? nested[key]
: raw[key] !== undefined
? raw[key]
: legacy !== undefined
? raw[legacy]
: undefined;
const out: Record<string, unknown> = {};
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
const key = ENABLE_KEY[protocol];
const flag = asBool(pick(key));
if (flag !== undefined) out[key] = flag;
const port = asPort(pick(PORT_KEY[protocol]));
if (port !== undefined) out[PORT_KEY[protocol]] = port;
}
const anyFlag = ["enableRdp", "enableVnc", "enableTelnet"].some(
(key) => out[key] === true,
);
if (!anyFlag && isRemoteProtocol(raw.connectionType)) {
out[ENABLE_KEY[raw.connectionType]] = true;
const port = asPort(raw.port);
if (port !== undefined && out[PORT_KEY[raw.connectionType]] === undefined) {
out[PORT_KEY[raw.connectionType]] = port;
}
}
const security = pick("rdpSecurity", "security");
if (typeof security === "string" && security) out.rdpSecurity = security;
const ignoreCert = asBool(pick("rdpIgnoreCert", "ignoreCert"));
if (ignoreCert !== undefined) out.rdpIgnoreCert = ignoreCert;
const config = asObject(pick("guacamoleConfig"));
if (config && Object.keys(config).length > 0) out.guacamoleConfig = config;
const toolbar = asBool(pick("enableToolbar", "enableTerminalToolbar"));
if (toolbar !== undefined) out.enableToolbar = toolbar;
return Object.keys(out).length > 0 ? out : null;
}
/** User setting key to the guacd parameter it sets. */
const USER_DEFAULT_PARAMS: Record<string, string> = {
colorDepth: "color-depth",
resizeMethod: "resize-method",
forceLossless: "force-lossless",
enableWallpaper: "enable-wallpaper",
enableFontSmoothing: "enable-font-smoothing",
enableDesktopComposition: "enable-desktop-composition",
disableAudio: "disable-audio",
enablePrinting: "enable-printing",
enableDrive: "enable-drive",
disableCopy: "disable-copy",
disablePaste: "disable-paste",
};
/**
* The user's RDP defaults as guacd parameters. "inherit" leaves a parameter
* to the host and guacd; on and off become booleans.
*/
export function userDefaultParams(
values: Record<string, unknown>,
): Record<string, unknown> {
const params: Record<string, unknown> = {};
for (const [key, param] of Object.entries(USER_DEFAULT_PARAMS)) {
const value = values[key];
if (value === undefined || value === null || value === "inherit") continue;
if (key === "colorDepth") {
const depth = Number(value);
if (Number.isInteger(depth) && depth > 0) params[param] = depth;
} else if (key === "resizeMethod") {
if (typeof value === "string" && value) params[param] = value;
} else if (value === "on" || value === "off") {
params[param] = value === "on";
}
}
return params;
}
export async function readUserDefaults(
ctx: PluginContext,
userId: string,
): Promise<Record<string, unknown>> {
return userDefaultParams(await ctx.settings.getAll("user", userId));
}
+85 -40
View File
@@ -1,63 +1,108 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { GuacamoleTokenService } from "./token-service.js";
import { RemoteSessions } from "./sessions.js";
import {
getGuacSessionInfo,
handleGuacamoleUpgrade,
restartGuacServer,
setPluginRecordings,
startGuacamoleService,
stopGuacamoleService,
tokenService,
createGuacamoleServer,
type RecordingsWriter,
} from "./guacamole-server.js";
import { startRemoteDesktopService } from "./routes.js";
import { resolveGuacdOptions } from "./guacd-config.js";
import { registerRoutes } from "./routes.js";
import { createLiveSessions } from "./live-sessions.js";
import { normalizeImportedHost } from "./host-settings.js";
import { createLogger } from "./log.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
const log = createLogger(ctx.log);
const tokens = new GuacamoleTokenService(log);
const sessions = new RemoteSessions();
ctx.disposables.add(() => sessions.clear());
// Optional: without the session-recording plugin, a guacd recording is
// still made on disk but never gets a row, so it never shows in the list.
const recordings = ctx.services.get<RecordingsWriter>("recordings.writer");
setPluginRecordings("createFinished" in recordings ? recordings : null);
ctx.disposables.add(() => setPluginRecordings(null));
// The admin setting, unless GUACD_URL / GUACD_HOST / GUACD_PORT override it.
let guacdUrl = (await ctx.settings.get<string>("guacdUrl")) ?? "";
const guacd = () => resolveGuacdOptions(guacdUrl || undefined);
const enabled = async () =>
(await ctx.settings.get<boolean>("enabled")) !== false;
await startGuacamoleService();
// Optional: without session-recording nothing is recorded.
const recordings = (): RecordingsWriter | null => {
const service =
ctx.services.get<Partial<RecordingsWriter>>("recordings.writer");
return typeof service.createFinished === "function" &&
typeof service.enabledFor === "function"
? (service as RecordingsWriter)
: null;
};
startRemoteDesktopService(ctx.http.router());
// guacamole-lite prints every new connection to stdout.
const consoleLog = console.log;
console.log = (...args: unknown[]) => {
if (
typeof args[0] === "string" &&
args[0].startsWith("New client connection")
)
return;
consoleLog(...args);
};
ctx.disposables.add(() => {
console.log = consoleLog;
});
const server = createGuacamoleServer({
log,
tokens,
sessions,
guacd,
recordings,
asUser: (userId, fn) => ctx.asUser(userId, fn),
onOpen: (hostId) => ctx.hosts.trackSession(hostId),
});
ctx.disposables.add(() => server.close());
ctx.settings.onChange("guacdUrl", (value) => {
guacdUrl = typeof value === "string" ? value : "";
server.restart();
});
registerRoutes(ctx.http.router(), {
ctx,
log,
tokens,
sessions,
guacd,
enabled,
recordings,
});
// guacamole-lite owns its own WebSocketServer, so it takes the raw upgrade.
//
// Public because a Guacamole display authenticates with the encrypted,
// single-use connection token minted by POST /token, not with a session
// JWT. guacamole-lite decrypts and validates that token itself, and the
// route that mints one is authenticated normally.
// Public because a display authenticates with the encrypted, single-use
// token minted by an authenticated route, not with a session JWT.
ctx.ws.upgrade(
"/display",
(request, socket, head) =>
handleGuacamoleUpgrade(
request as Parameters<typeof handleGuacamoleUpgrade>[0],
socket as Parameters<typeof handleGuacamoleUpgrade>[1],
server.handleUpgrade(
request as Parameters<typeof server.handleUpgrade>[0],
socket as Parameters<typeof server.handleUpgrade>[1],
head as Buffer,
),
{ public: true },
);
// What collab, session-sharing and the admin guacd setting need from a
// running Guacamole server. Revoked automatically on deactivate, and
// hosts/guacamole-sessions.ts degrades safely while it is gone.
ctx.registry.provide("remote-desktop.sessions", {
restart: restartGuacServer,
createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) =>
tokenService.createJoinToken(guacamoleConnectionId, readOnly),
getSessionInfo: getGuacSessionInfo,
});
for (const protocol of ["rdp", "vnc", "telnet"] as const) {
ctx.services.provide(
"sessions.live",
createLiveSessions(protocol, {
sessions,
tokens,
endSession: server.endSession,
}),
{ name: protocol },
);
}
ctx.log.info(
"Remote Desktop mounted at /plugin-api/remote-desktop and /plugin-ws/remote-desktop/display",
ctx.registry.provide(
"remote-desktop.hostImportNormalizer",
normalizeImportedHost,
);
}
export async function deactivate() {
await stopGuacamoleService();
}
export async function deactivate() {}
@@ -0,0 +1,145 @@
import net from "net";
import type { Client } from "ssh2";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { RemoteDesktopLogger } from "./log.js";
/** The tunnels plugin's service, as ctx.services.get("tunnels.access") returns it. */
interface TunnelsAccess {
forward(
sourceHostId: number,
target: { targetHost: string; targetPort: number; bindHost?: string },
options?: { name?: string; idleTimeoutMs?: number },
): Promise<{
bindHost: string;
bindPort: number;
close: () => Promise<void>;
}>;
}
/** The tunnels plugin keeps these names for on-demand forwards. */
const TUNNEL_NAME_PREFIX = "web:";
/** A forward nothing is using closes itself after this long. */
const FORWARD_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
export interface JumpTunnel {
port: number;
close: () => void;
}
export interface JumpTunnelRequest {
jumpHosts: Array<{ hostId: number }>;
targetHost: string;
targetPort: number;
bindHost: string;
/** Names the forward, so each session gets one of its own. */
connectId: string;
}
/**
* A local port guacd can dial that reaches the target through the host's
* jump hosts.
*
* One hop is exactly what the tunnels plugin's forward does, so that is used
* when the plugin is running and the user may use it. A chain, or no tunnels
* plugin, goes through ctx.ssh.jumpChain with a listener of our own. Either
* way the caller closes it when the session ends.
*/
export async function openJumpTunnel(
ctx: PluginContext,
log: RemoteDesktopLogger,
request: JumpTunnelRequest,
): Promise<JumpTunnel> {
if (request.jumpHosts.length === 1) {
const viaTunnels = await forwardThroughTunnels(ctx, log, request);
if (viaTunnels) return viaTunnels;
}
return forwardThroughChain(ctx, request);
}
async function forwardThroughTunnels(
ctx: PluginContext,
log: RemoteDesktopLogger,
request: JumpTunnelRequest,
): Promise<JumpTunnel | null> {
const tunnels = ctx.services.get<Partial<TunnelsAccess>>("tunnels.access");
if (typeof tunnels.forward !== "function") return null;
const hopId = request.jumpHosts[0].hostId;
try {
const handle = await tunnels.forward(
hopId,
{
targetHost: request.targetHost,
targetPort: request.targetPort,
bindHost: request.bindHost,
},
{
name: `${TUNNEL_NAME_PREFIX}${hopId}:rd-${request.connectId}`,
idleTimeoutMs: FORWARD_IDLE_TIMEOUT_MS,
},
);
return {
port: handle.bindPort,
close: () => void handle.close().catch(() => undefined),
};
} catch (error) {
// A user without the tunnels permission still reaches their host.
log.warn("Tunnels forward failed, using a jump chain instead", {
operation: "guac_tunnels_forward",
hopId,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
async function forwardThroughChain(
ctx: PluginContext,
request: JumpTunnelRequest,
): Promise<JumpTunnel> {
// The chain dials the first hop through that hop's own SOCKS5 settings;
// the target host's proxy config does not apply to it.
const { client, dispose } = await ctx.ssh.jumpChain<Client>(
request.jumpHosts,
);
const sockets = new Set<net.Socket>();
const server = net.createServer((socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
client.forwardOut(
"127.0.0.1",
0,
request.targetHost,
request.targetPort,
(error, stream) => {
if (error) {
socket.destroy();
return;
}
socket.pipe(stream).pipe(socket);
},
);
});
let port: number;
try {
port = await new Promise<number>((resolve, reject) => {
server.once("error", reject);
server.listen(0, request.bindHost, () =>
resolve((server.address() as net.AddressInfo).port),
);
});
} catch (error) {
await dispose();
throw error;
}
return {
port,
close: () => {
for (const socket of sockets) socket.destroy();
server.close();
void dispose();
},
};
}
@@ -0,0 +1,65 @@
import type { GuacamoleTokenService } from "./token-service.js";
import type { RemoteSessions } from "./sessions.js";
import type { RemoteProtocol } from "./host-settings.js";
/** sessions.live v1, as session-sharing reads it (plugins/session-sharing/src/backend/live.ts). */
export interface LiveSessionInfo {
id: string;
userId: string;
hostId: number;
hostName: string;
isConnected: boolean;
createdAt: number;
tabInstanceId: string | null;
}
export interface RemoteLiveSessions {
getSession: (sessionId: string) => LiveSessionInfo | null;
ownerEndSession: (sessionId: string, reason: string) => void;
createViewerToken: (sessionId: string, readOnly: boolean) => string;
}
/**
* One provider per protocol. A session id is guacd's own connection id,
* which is what a viewer's join token names.
*/
export function createLiveSessions(
protocol: RemoteProtocol,
deps: {
sessions: RemoteSessions;
tokens: GuacamoleTokenService;
endSession: (guacamoleConnectionId: string) => boolean;
},
): RemoteLiveSessions {
const find = (sessionId: string) => {
const session = deps.sessions.byGuacamole(sessionId);
return session && session.protocol === protocol ? session : null;
};
return {
getSession(sessionId) {
const session = find(sessionId);
if (!session) return null;
return {
id: session.guacamoleConnectionId,
userId: session.ownerUserId,
hostId: session.hostId,
hostName: session.hostName,
isConnected: true,
createdAt: session.openedAt,
tabInstanceId: session.tabInstanceId,
};
},
ownerEndSession(sessionId) {
if (find(sessionId)) deps.endSession(sessionId);
},
createViewerToken(sessionId, readOnly) {
if (!find(sessionId)) {
throw new Error(`No live ${protocol} session ${sessionId}`);
}
return deps.tokens.createJoinToken(sessionId, readOnly);
},
};
}
+30
View File
@@ -0,0 +1,30 @@
import type { PluginLogger } from "@termix/plugin-sdk/backend";
export type Meta = Record<string, unknown>;
export interface RemoteDesktopLogger {
info: (message: string, meta?: Meta) => void;
warn: (message: string, meta?: Meta) => void;
error: (message: string, meta?: Meta) => void;
}
function withMeta(message: string, meta?: Meta): string {
return meta && Object.keys(meta).length > 0
? `${message} ${JSON.stringify(meta)}`
: message;
}
/** ctx.log with the structured context the old guac logger carried. */
export function createLogger(log: PluginLogger): RemoteDesktopLogger {
return {
info: (message, meta) => log.info(withMeta(message, meta)),
warn: (message, meta) => log.warn(withMeta(message, meta)),
error: (message, meta) => log.error(withMeta(message, meta)),
};
}
export function errorMessage(error: unknown, fallback = "Unknown error") {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error;
return fallback;
}
@@ -34,31 +34,3 @@ export function resolveRdpDomain(
? promptedDomain
: storedDomain;
}
type SharedRdpAuthResolution =
| { source: "personal-override" }
| { source: "owner-shared"; authType: string }
| { source: "secretless" }
| { source: "required" }
| null;
export function resolveRdpAuthTypeForConnect({
storedAuthType,
credentialId,
sharedResolution,
}: {
storedAuthType?: string | null;
credentialId?: number | null;
sharedResolution?: SharedRdpAuthResolution;
}): string {
if (sharedResolution === undefined) {
return storedAuthType || (credentialId ? "credential" : "direct");
}
if (sharedResolution?.source === "personal-override") return "credential";
if (sharedResolution?.source === "owner-shared") {
return sharedResolution.authType;
}
return sharedResolution?.source === "secretless" && storedAuthType === "none"
? "none"
: "direct";
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,116 @@
import type { TermixGuacMeta } from "./token-service.js";
export interface RemoteSession {
guacamoleConnectionId: string;
termixConnectId: string;
hostId: number;
hostName: string;
ownerUserId: string;
protocol: TermixGuacMeta["protocol"];
tabInstanceId: string | null;
openedAt: number;
}
type Cleanup = () => void;
/** How long a minted token's tunnel waits for guacd to connect through it. */
export const PENDING_TIMEOUT_MS = 5 * 60 * 1000;
/**
* Live guacd sessions and what each one holds open.
*
* A connect request builds its jump tunnel or VNC proxy before guacd dials
* in, so those resources are parked under the request's connect id and move
* onto the session when it opens. They are released when the session
* closes, when guacd never shows up, or when the plugin stops.
*/
export class RemoteSessions {
private readonly pending = new Map<
string,
{ cleanups: Cleanup[]; timer: ReturnType<typeof setTimeout> }
>();
private readonly byConnectId = new Map<string, RemoteSession>();
private readonly byGuacamoleId = new Map<string, RemoteSession>();
private readonly cleanupsByConnectId = new Map<string, Cleanup[]>();
constructor(private readonly pendingTimeoutMs = PENDING_TIMEOUT_MS) {}
/** Resources for a connection that guacd has not opened yet. */
park(termixConnectId: string, cleanups: Cleanup[]): void {
if (cleanups.length === 0) return;
const timer = setTimeout(() => {
this.pending.delete(termixConnectId);
run(cleanups);
}, this.pendingTimeoutMs);
timer.unref?.();
this.pending.set(termixConnectId, { cleanups, timer });
}
opened(
meta: TermixGuacMeta,
guacamoleConnectionId: string,
extra: Cleanup[] = [],
): RemoteSession {
const session: RemoteSession = {
guacamoleConnectionId,
termixConnectId: meta.termixConnectId,
hostId: meta.hostId,
hostName: meta.hostName,
ownerUserId: meta.ownerUserId,
protocol: meta.protocol,
tabInstanceId: meta.tabInstanceId ?? null,
openedAt: Date.now(),
};
const parked = this.pending.get(meta.termixConnectId);
if (parked) {
clearTimeout(parked.timer);
this.pending.delete(meta.termixConnectId);
}
this.cleanupsByConnectId.set(meta.termixConnectId, [
...(parked?.cleanups ?? []),
...extra,
]);
this.byConnectId.set(meta.termixConnectId, session);
this.byGuacamoleId.set(guacamoleConnectionId, session);
return session;
}
closed(termixConnectId: string): void {
const session = this.byConnectId.get(termixConnectId);
if (session) this.byGuacamoleId.delete(session.guacamoleConnectionId);
this.byConnectId.delete(termixConnectId);
run(this.cleanupsByConnectId.get(termixConnectId) ?? []);
this.cleanupsByConnectId.delete(termixConnectId);
}
byConnect(termixConnectId: string): RemoteSession | null {
return this.byConnectId.get(termixConnectId) ?? null;
}
byGuacamole(guacamoleConnectionId: string): RemoteSession | null {
return this.byGuacamoleId.get(guacamoleConnectionId) ?? null;
}
/** Releases everything, for deactivate. */
clear(): void {
for (const { cleanups, timer } of this.pending.values()) {
clearTimeout(timer);
run(cleanups);
}
this.pending.clear();
for (const cleanups of this.cleanupsByConnectId.values()) run(cleanups);
this.cleanupsByConnectId.clear();
this.byConnectId.clear();
this.byGuacamoleId.clear();
}
}
function run(cleanups: Cleanup[]): void {
for (const cleanup of cleanups) {
try {
cleanup();
} catch {
// One failed close must not keep the rest open.
}
}
}
-36
View File
@@ -1,36 +0,0 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
@@ -1,5 +1,9 @@
import crypto from "crypto";
import { guacLogger } from "../../../../src/backend/utils/logger.js";
interface TokenLogger {
warn: (message: string, meta?: Record<string, unknown>) => void;
error: (message: string, meta?: Record<string, unknown>) => void;
}
export interface GuacamoleConnectionSettings {
type?: "rdp" | "vnc" | "telnet";
@@ -33,8 +37,10 @@ export interface GuacamoleConnectionSettings {
export interface TermixGuacMeta {
termixConnectId: string;
hostId: number;
hostName: string;
ownerUserId: string;
protocol: "rdp" | "vnc" | "telnet";
tabInstanceId: string | null;
}
export interface GuacamoleToken {
@@ -57,21 +63,19 @@ export interface GuacamoleRecordingMetadata {
const CIPHER = "aes-256-cbc";
const KEY_LENGTH = 32;
const silentLogger: TokenLogger = { warn: () => {}, error: () => {} };
/**
* Encrypts the connection tokens guacamole-lite decrypts. Built in activate;
* the key is derived from the environment, so a restart keeps it.
*/
export class GuacamoleTokenService {
private static instance: GuacamoleTokenService;
private encryptionKey: Buffer;
private constructor() {
constructor(private readonly log: TokenLogger = silentLogger) {
this.encryptionKey = this.initializeKey();
}
static getInstance(): GuacamoleTokenService {
if (!GuacamoleTokenService.instance) {
GuacamoleTokenService.instance = new GuacamoleTokenService();
}
return GuacamoleTokenService.instance;
}
private initializeKey(): Buffer {
const existingKey = process.env.GUACAMOLE_ENCRYPTION_KEY;
if (existingKey) {
@@ -91,12 +95,9 @@ export class GuacamoleTokenService {
.digest();
}
guacLogger.warn(
"No persistent encryption key found, generating random key",
{
operation: "guac_key_generation",
},
);
this.log.warn("No persistent encryption key found, generating random key", {
operation: "guac_key_generation",
});
return crypto.randomBytes(KEY_LENGTH);
}
@@ -134,8 +135,9 @@ export class GuacamoleTokenService {
return JSON.parse(decrypted) as GuacamoleToken;
} catch (error) {
guacLogger.error("Failed to decrypt guacamole token", error, {
this.log.error("Failed to decrypt guacamole token", {
operation: "guac_token_decrypt_error",
error: error instanceof Error ? error.message : String(error),
});
return null;
}
@@ -1,5 +1,4 @@
import { watchGuacamoleConnectionId } from "./guacamole-session-id";
import { getErrorMessage } from "@/lib/error-message.js";
import React, {
useState,
useEffect,
@@ -14,40 +13,42 @@ import {
type GuacamoleDisplayHandle,
type GuacamoleTouchMode,
} from "./GuacamoleDisplay.tsx";
import { getSSHHosts, logActivity, isElectron } from "@/main-axios";
import { getGuacamoleTokenFromHost, getGuacdStatus } from "./guacamole-api";
import { readConfiguredDimension } from "./guacamole-display-size.ts";
import { getGuacamoleToken, parseGuacamoleConfig } from "./guacamole-api";
import {
resolveConnectionOrigin,
type ConnectionOrigin,
} from "@/lib/connection-origin.ts";
import { getGuacamoleToken } from "./guacamole-api";
import {
useHost,
useTranslation,
useConnectionRetry,
} from "@termix/plugin-sdk/frontend";
import { GuacamoleToolbar } from "./GuacamoleToolbar.tsx";
import { GuacamoleFileBrowser } from "./GuacamoleFileBrowser.tsx";
import { describeUploadError } from "./guacamole-filesystem.ts";
import { canUploadToRdpDrive } from "./guacamole-file-drop.ts";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { PasswordInput } from "@/components/password-input.tsx";
import {
Button,
ConnectionLogProvider,
ConnectionScreen,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import {
ConnectionLogProvider,
Input,
PasswordInput,
isElectron,
logActivity,
resolveConnectionOrigin,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import type { SSHHost } from "@/types";
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
import { resolveConnectionDefaults } from "@/lib/connection-defaults";
type ConnectionOrigin,
} from "@termix/plugin-sdk/ui";
import type { GuacamoleConfig } from "./guacamole-config";
import {
errorMessage,
hostRemoteOptions,
type Protocol,
type RemoteHostLogin,
} from "./host-remote";
import { GuacamoleToolbar } from "./GuacamoleToolbar.tsx";
import { GuacamoleFileBrowser } from "./GuacamoleFileBrowser.tsx";
import { describeUploadError } from "./guacamole-filesystem.ts";
import { canUploadToRdpDrive } from "./guacamole-file-drop.ts";
import { needsRdpCredentialPrompt } from "./rdp-credential-prompt";
interface GuacamoleAppProps {
@@ -59,10 +60,48 @@ interface GuacamoleAppProps {
quickConnectHost?: GuacamoleQuickHost;
}
/** What GuacamoleApp needs from a host that has no database row. */
export type GuacamoleQuickHost = GuacamoleAppInnerProps["hostConfig"] & {
/**
* What a session needs from its host. A saved host brings its options; a
* quick-connect host has no row, so it also carries its address and login.
*/
export interface GuacamoleHostConfig {
name?: string;
};
ip: string;
connectionType?: Protocol;
domain?: string;
guacamoleConfig?: GuacamoleConfig;
rdpAuthType?: string;
authOverrides?: RemoteHostLogin["authOverrides"];
syncId?: string | null;
connectionOrigin?: "local" | "remote" | null;
showToolbar?: boolean;
port?: number;
username?: string;
password?: string;
}
export type GuacamoleQuickHost = GuacamoleHostConfig;
function savedHostConfig(
host: RemoteHostLogin,
protocol: Protocol | undefined,
): GuacamoleHostConfig {
const options = hostRemoteOptions(host);
return {
name: host.name ?? undefined,
ip: host.ip ?? "",
connectionType:
protocol ??
(options.enableRdp ? "rdp" : options.enableVnc ? "vnc" : "telnet"),
domain: host.domain,
guacamoleConfig: options.guacamoleConfig,
rdpAuthType: host.rdpAuthType,
authOverrides: host.authOverrides,
syncId: host.syncId,
connectionOrigin: host.connectionOrigin,
showToolbar: options.enableToolbar,
};
}
export interface GuacamoleAppHandle {
disconnect: () => void;
@@ -77,50 +116,20 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
ref,
) {
const { t } = useTranslation();
const defaults = useConnectionDefaults();
const [hostConfig, setHostConfig] = useState<GuacamoleQuickHost | null>(
null,
);
const [loading, setLoading] = useState(true);
const savedHost = useHost(quickConnectHost ? undefined : hostId);
const hostConfig: GuacamoleHostConfig | null = quickConnectHost
? quickConnectHost
: savedHost
? savedHostConfig(savedHost as RemoteHostLogin, protocol)
: null;
// The host list loads with the shell; a tab restored before it arrives
// waits for it instead of reporting the host missing.
const [waited, setWaited] = useState(false);
useEffect(() => {
if (!defaults.ready) return;
if (quickConnectHost) {
const connectionType = protocol ?? quickConnectHost.connectionType;
setHostConfig({
...quickConnectHost,
guacamoleConfig: resolveConnectionDefaults(
connectionType === "rdp" ? defaults.rdp : {},
{},
),
});
setLoading(false);
return;
}
if (!hostId) {
setLoading(false);
return;
}
getSSHHosts()
.then((hosts) => {
const host = hosts.find((h) => h.id === parseInt(hostId, 10));
if (!host) {
setHostConfig(null);
return;
}
const connectionType = protocol ?? host.connectionType;
const protocolDefaults = connectionType === "rdp" ? defaults.rdp : {};
setHostConfig({
...host,
guacamoleConfig: resolveConnectionDefaults(
protocolDefaults,
parseGuacamoleConfig(host.guacamoleConfig),
),
});
})
.catch(() => setHostConfig(null))
.finally(() => setLoading(false));
}, [hostId, protocol, defaults.ready, defaults.rdp, quickConnectHost]);
const timer = setTimeout(() => setWaited(true), 3000);
return () => clearTimeout(timer);
}, []);
const loading = !hostConfig && !!hostId && !waited;
if (loading) {
return (
@@ -159,23 +168,7 @@ const GuacamoleApp = React.forwardRef<GuacamoleAppHandle, GuacamoleAppProps>(
interface GuacamoleAppInnerProps {
hostId: number;
hostConfig: Pick<
SSHHost,
| "connectionType"
| "domain"
| "guacamoleConfig"
| "rdpAuthType"
| "authOverrides"
| "syncId"
| "connectionOrigin"
| "ip"
| "rdpPort"
| "vncPort"
| "rdpUser"
| "rdpPassword"
| "vncUser"
| "vncPassword"
> & { enableTerminalToolbar?: boolean };
hostConfig: GuacamoleHostConfig;
hostName: string;
tabId?: string;
protocol?: "rdp" | "vnc" | "telnet";
@@ -223,7 +216,7 @@ const GuacamoleAppInner = React.forwardRef<
const [toolbarHidden, setToolbarHidden] = useState(false);
const [pendingUploads, setPendingUploads] = useState<File[]>([]);
const guacConfig = parseGuacamoleConfig(hostConfig.guacamoleConfig);
const guacConfig = hostConfig.guacamoleConfig ?? {};
const allowUpload = guacConfig.disableUpload !== true;
const allowDownload = guacConfig.disableDownload !== true;
@@ -311,10 +304,10 @@ const GuacamoleAppInner = React.forwardRef<
let resolvedOrigin: ConnectionOrigin = "local";
if (isElectron()) {
resolvedOrigin = await resolveConnectionOrigin({
connectionType: resolvedProtocolForConnect,
connectionOrigin: hostConfig.connectionOrigin,
});
resolvedOrigin = await resolveConnectionOrigin(
{ connectionOrigin: hostConfig.connectionOrigin },
{ defaultRemote: true },
);
if (resolvedOrigin === "remote") {
const remoteConfig = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
@@ -331,6 +324,9 @@ const GuacamoleAppInner = React.forwardRef<
message: t("remoteDesktop.checkingGuacd"),
});
const status = await getGuacdStatus(resolvedOrigin);
if (status.enabled === false) {
throw new Error(t("remoteDesktop.disabled"));
}
if (status.guacd.status !== "connected") {
throw new Error(t("remoteDesktop.guacdUnavailable"));
}
@@ -351,21 +347,12 @@ const GuacamoleAppInner = React.forwardRef<
{
protocol: resolvedProtocolForConnect,
hostname: hostConfig.ip,
port:
resolvedProtocolForConnect === "vnc"
? hostConfig.vncPort
: hostConfig.rdpPort,
username:
resolvedProtocolForConnect === "vnc"
? hostConfig.vncUser
: hostConfig.rdpUser,
password:
resolvedProtocolForConnect === "vnc"
? hostConfig.vncPassword
: hostConfig.rdpPassword,
port: hostConfig.port,
username: hostConfig.username,
password: hostConfig.password,
domain: hostConfig.domain,
ignoreCert: true,
guacamoleConfig: parseGuacamoleConfig(hostConfig.guacamoleConfig),
guacamoleConfig: hostConfig.guacamoleConfig,
},
resolvedOrigin,
)
@@ -375,6 +362,7 @@ const GuacamoleAppInner = React.forwardRef<
protocol,
promptedCredentials ?? undefined,
hostConfig.syncId,
tabId,
);
if (result) {
setSessionLookup(
@@ -397,6 +385,7 @@ const GuacamoleAppInner = React.forwardRef<
promptedCredentials,
resolvedProtocolForConnect,
hostConfig,
tabId,
addLog,
t,
]);
@@ -407,10 +396,7 @@ const GuacamoleAppInner = React.forwardRef<
await fetchToken();
tokenRetryRef.current.markConnected();
} catch (err: unknown) {
const message = getErrorMessage(
err,
t("remoteDesktop.failedToConnect"),
);
const message = errorMessage(err, t("remoteDesktop.failedToConnect"));
setError(message || t("remoteDesktop.failedToConnect"));
addLog({ type: "error", stage: "error", message });
tokenRetryRef.current.markFailed();
@@ -632,7 +618,7 @@ const GuacamoleAppInner = React.forwardRef<
onClose={() => setFileBrowserOpen(false)}
/>
)}
{hostConfig.enableTerminalToolbar !== false && !toolbarHidden && (
{hostConfig.showToolbar !== false && !toolbarHidden && (
<GuacamoleToolbar
displayRef={displayRef}
protocol={resolvedProtocol}
@@ -1,4 +1,3 @@
import { getErrorMessage } from "@/lib/error-message.js";
import type React from "react";
import {
useEffect,
@@ -11,15 +10,18 @@ import {
import Guacamole from "guacamole-common-js";
import { Upload } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { isElectron } from "@/main-axios";
import { getGuacamoleToken } from "./guacamole-api";
import { getBasePath } from "@/lib/base-path.ts";
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
import {
resolveConnectionOrigin,
buildOriginWsUrl,
getBasePath,
guacStateToStage,
isElectron,
resolveConnectionOrigin,
type ConnectionOrigin,
} from "@/lib/connection-origin.ts";
type ConnectionStage,
} from "@termix/plugin-sdk/ui";
import { errorMessage } from "./host-remote";
import { isPasteShortcut, pasteTextToRemote } from "./guacamole-clipboard.ts";
import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts";
import { bindPointerInput } from "./guacamole-pointer.ts";
@@ -31,8 +33,6 @@ import {
uploadFileToClient,
type GuacamoleFileStreamClient,
} from "./guacamole-filesystem.ts";
import { guacStateToStage } from "@/components/connection/connection-status.ts";
import type { ConnectionStage } from "@/types/connection-log.ts";
import { clampGuacamoleZoom, stepGuacamoleZoom } from "./guacamole-zoom.ts";
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
@@ -243,10 +243,10 @@ export const GuacamoleDisplay = forwardRef<
// Resolved before the token is minted, not just before the socket is
// opened: the token has to come from whichever backend will serve the
// session, so both steps have to agree on the origin.
const origin = await resolveConnectionOrigin({
connectionType: connectionProtocol,
connectionOrigin: connectionConfig.connectionOrigin,
});
const origin = await resolveConnectionOrigin(
{ connectionOrigin: connectionConfig.connectionOrigin },
{ defaultRemote: true },
);
if (connectionConfig.token) {
token = connectionConfig.token;
@@ -317,8 +317,8 @@ export const GuacamoleDisplay = forwardRef<
if (displaySize.dpi) params.set("dpi", String(displaySize.dpi));
return { url: wsBase, query: params.toString() };
} catch (error) {
const errorMessage = getErrorMessage(error);
onError?.(errorMessage);
const message = errorMessage(error);
onError?.(message);
return null;
}
},
@@ -698,7 +698,7 @@ export const GuacamoleDisplay = forwardRef<
if (!isMountedRef.current) return;
setIsReady(false);
setHasError(true);
onError?.(getErrorMessage(error, t("remoteDesktop.connectionError")));
onError?.(errorMessage(error, t("remoteDesktop.connectionError")));
}
}, [
getWebSocketConnection,
@@ -1,4 +1,4 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { errorMessage } from "./host-remote";
import { useCallback, useEffect, useRef, useState } from "react";
import Guacamole from "guacamole-common-js";
import { useTranslation } from "@termix/plugin-sdk/frontend";
@@ -12,7 +12,7 @@ import {
X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button.tsx";
import { Button } from "@termix/plugin-sdk/ui";
import {
downloadFile,
listDirectory,
@@ -56,7 +56,7 @@ export function GuacamoleFileBrowser({
setEntries(await listDirectory(filesystem, target));
} catch (err) {
setEntries([]);
setError(getErrorMessage(err, t("remoteDesktop.files.listFailed")));
setError(errorMessage(err, t("remoteDesktop.files.listFailed")));
} finally {
setLoading(false);
}
@@ -26,14 +26,14 @@ import {
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip.tsx";
} from "@termix/plugin-sdk/ui";
import type {
GuacamoleDisplayHandle,
GuacamoleTouchMode,
} from "./GuacamoleDisplay.tsx";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { ActionSlot } from "@termix/plugin-sdk/ui";
import { cn } from "@/lib/utils";
import { cn } from "@termix/plugin-sdk/ui";
import {
detectRuntimeMetaKeyFamily,
metaKeyLabels,
@@ -1,8 +1,12 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { FakeSwitch, SectionCard, SettingRow } from "@/components/section-card";
import type { Host } from "@/types/ui-types";
import {
FakeSwitch,
Input,
PasswordInput,
SectionCard,
Select2,
SettingRow,
} from "@termix/plugin-sdk/ui";
import {
Activity,
Copy,
@@ -16,13 +20,10 @@ import {
Zap,
Cpu,
} from "lucide-react";
import type { HostEditorForm } from "@/sidebar/HostEditorData";
import { Select2 } from "@/components/select2";
import type { RemoteDesktopForm, RemoteFormSetField } from "./remote-form";
type HostEditorSetField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
type HostEditorForm = RemoteDesktopForm;
type HostEditorSetField = RemoteFormSetField;
type GuacFieldSetter = (key: string, value: unknown) => void;
@@ -49,7 +50,7 @@ export function HostEditorRdpTab({
form: HostEditorForm;
setField: HostEditorSetField;
setGuacField: GuacFieldSetter;
host?: Host | null;
host?: { macAddress?: string | null } | null;
credentials?: { id: string; name: string; username: string }[];
}) {
const { t } = useTranslation();
@@ -984,7 +985,7 @@ export function HostEditorVncTab({
form: HostEditorForm;
setField: HostEditorSetField;
setGuacField: GuacFieldSetter;
host?: Host | null;
host?: { macAddress?: string | null } | null;
credentials?: { id: string; name: string; username: string }[];
}) {
const { t } = useTranslation();
@@ -1523,7 +1524,6 @@ export function HostEditorTelnetTab({
form: HostEditorForm;
setField: HostEditorSetField;
setGuacField: GuacFieldSetter;
host?: Host | null;
credentials?: { id: string; name: string; username: string }[];
}) {
const { t } = useTranslation();
@@ -1,13 +1,18 @@
import type { PluginApiClient, TermixApp } from "@termix/plugin-sdk/frontend";
import {
authApi,
getRemoteGuacamoleApi,
handleApiError,
isElectron,
} from "@/main-axios";
import type { AxiosInstance } from "axios";
import type { GuacamoleConfig } from "@/types/guacamole-config";
import { resolveRemoteHostId } from "@/lib/remote-server-api";
import type { ConnectionOrigin } from "@/lib/connection-origin";
resolveRemoteHostId,
type ConnectionOrigin,
} from "@termix/plugin-sdk/ui";
import type { GuacamoleConfig } from "./guacamole-config";
import { errorMessage } from "./host-remote";
let app: Pick<TermixApp, "apiFor"> | null = null;
/** Set in activate, cleared on deactivate. */
export function setRemoteDesktopApp(next: Pick<TermixApp, "apiFor"> | null) {
app = next;
}
/**
* Picks the backend that will actually serve this session, so the token and
@@ -24,9 +29,13 @@ import type { ConnectionOrigin } from "@/lib/connection-origin";
* silently sent a missed call site to the remote server, which fails with a
* bare network error on a desktop that has none configured.
*/
function guacamoleApi(origin: ConnectionOrigin): AxiosInstance {
if (!isElectron()) return authApi;
return origin === "local" ? authApi : getRemoteGuacamoleApi();
function guacamoleApi(origin: ConnectionOrigin): PluginApiClient {
if (!app) throw new Error("Remote Desktop is not active");
return app.apiFor(origin);
}
function apiError(error: unknown, action: string): Error {
return new Error(errorMessage(error, `Failed to ${action}`));
}
export interface GuacamoleTokenRequest {
@@ -51,18 +60,6 @@ type GuacamoleConfigSource = {
guacamoleConfig?: string | Record<string, unknown> | null;
};
export function parseGuacamoleConfig(
config?: string | GuacamoleConfig | null,
): GuacamoleConfig {
if (!config) return {};
if (typeof config !== "string") return config;
try {
return JSON.parse(config) as GuacamoleConfig;
} catch {
return {};
}
}
export function getGuacamoleDpi(
source?: GuacamoleConfigSource,
): number | undefined {
@@ -174,8 +171,8 @@ export async function getGuacamoleToken(
try {
const guacParams = toGuacamoleParams(request.guacamoleConfig);
const response = await guacamoleApi(origin).post(
"/plugin-api/remote-desktop/token",
const response = await guacamoleApi(origin).post<GuacamoleTokenResponse>(
"/token",
{
type: request.protocol,
hostname: request.hostname,
@@ -190,7 +187,7 @@ export async function getGuacamoleToken(
);
return response.data;
} catch (error) {
throw handleApiError(error, "get guacamole token");
throw apiError(error, "get a connection token");
}
}
@@ -204,6 +201,7 @@ export async function getGuacamoleTokenFromHost(
domain?: string;
},
syncId?: string | null,
tabInstanceId?: string,
): Promise<GuacamoleTokenResponse> {
try {
// A locally-originated session is served by the embedded backend, which
@@ -216,10 +214,11 @@ export async function getGuacamoleTokenFromHost(
throw new Error("The synced host does not exist on the remote server");
}
const targetHostId = remoteHostId ?? hostId;
const response = await guacamoleApi(origin).post(
`/plugin-api/remote-desktop/connect-host/${targetHostId}`,
const response = await guacamoleApi(origin).post<GuacamoleTokenResponse>(
`/connect-host/${targetHostId}`,
{
...(protocol ? { protocol } : {}),
...(tabInstanceId ? { tabInstanceId } : {}),
...(promptedCredentials?.username
? { promptedUsername: promptedCredentials.username }
: {}),
@@ -233,15 +232,22 @@ export async function getGuacamoleTokenFromHost(
);
return response.data;
} catch (error) {
throw handleApiError(error, "get guacamole token from host");
throw apiError(error, "get a connection token");
}
}
export async function getGuacdStatus(origin: ConnectionOrigin): Promise<{
export interface RemoteDesktopStatus {
enabled: boolean;
guacd: { status: string };
}> {
const response = await guacamoleApi(origin).get(
"/plugin-api/remote-desktop/status",
}
/** `probe: false` skips dialing guacd, for a caller that only needs `enabled`. */
export async function getGuacdStatus(
origin: ConnectionOrigin,
options: { probe?: boolean } = {},
): Promise<RemoteDesktopStatus> {
const response = await guacamoleApi(origin).get<RemoteDesktopStatus>(
options.probe === false ? "/status?probe=0" : "/status",
);
return response.data;
}
@@ -251,9 +257,34 @@ export async function getGuacamoleConnectionId(
origin: ConnectionOrigin,
signal?: AbortSignal,
): Promise<string | null> {
const response = await guacamoleApi(origin).get(
`/plugin-api/remote-desktop/connection/${encodeURIComponent(connectId)}`,
{ signal },
);
const response = await guacamoleApi(origin).get<{
guacamoleConnectionId?: string | null;
}>(`/connection/${encodeURIComponent(connectId)}`, { signal });
return response.data.guacamoleConnectionId ?? null;
}
export async function nativeRdpAvailable(): Promise<boolean> {
if (!isElectron()) return false;
try {
const response = await guacamoleApi("local").get<{ available: boolean }>(
"/native-rdp",
);
return response.data.available === true;
} catch {
return false;
}
}
/** Always the embedded backend: it is the one that can reach Electron. */
export async function openNativeRdp(request: {
host: string;
port?: number;
username?: string;
domain?: string;
}): Promise<{ success: boolean; error?: string }> {
const response = await guacamoleApi("local").post<{
success: boolean;
error?: string;
}>("/native-rdp", request);
return response.data;
}
@@ -0,0 +1,64 @@
/**
* Per-host Guacamole connection settings, as edited in the host editor and
* forwarded to guacd. Field names are camelCase here and translated to the
* hyphenated protocol parameters in toGuacamoleParams().
*/
export interface GuacamoleConfig {
colorDepth?: number;
width?: number;
height?: number;
dpi?: number;
resizeMethod?: string;
forceLossless?: boolean;
disableAudio?: boolean;
enableAudioInput?: boolean;
enableWallpaper?: boolean;
enableTheming?: boolean;
enableFontSmoothing?: boolean;
enableFullWindowDrag?: boolean;
enableDesktopComposition?: boolean;
enableMenuAnimations?: boolean;
disableBitmapCaching?: boolean;
disableOffscreenCaching?: boolean;
disableGlyphCaching?: boolean;
disableGfx?: boolean;
enablePrinting?: boolean;
printerName?: string;
enableDrive?: boolean;
driveName?: string;
drivePath?: string;
createDrivePath?: boolean;
disableDownload?: boolean;
disableUpload?: boolean;
enableTouch?: boolean;
clientName?: string;
console?: boolean;
initialProgram?: string;
serverLayout?: string;
timezone?: string;
gatewayHostname?: string;
gatewayPort?: number;
gatewayUsername?: string;
gatewayPassword?: string;
gatewayDomain?: string;
remoteApp?: string;
remoteAppDir?: string;
remoteAppArgs?: string;
normalizeClipboard?: string;
disableCopy?: boolean;
disablePaste?: boolean;
cursor?: string;
swapRedBlue?: boolean;
readOnly?: boolean;
recordingPath?: string;
recordingName?: string;
createRecordingPath?: boolean;
recordingExcludeOutput?: boolean;
recordingExcludeMouse?: boolean;
recordingIncludeKeys?: boolean;
wolSendPacket?: boolean;
wolMacAddr?: string;
wolBroadcastAddr?: string;
wolUdpPort?: number;
wolWaitTime?: number;
}
@@ -1,5 +1,5 @@
import { getGuacamoleConnectionId } from "./guacamole-api";
import type { ConnectionOrigin } from "@/lib/connection-origin";
import type { ConnectionOrigin } from "@termix/plugin-sdk/ui";
export function watchGuacamoleConnectionId(
connectId: string,
@@ -0,0 +1,139 @@
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
import type { GuacamoleConfig } from "./guacamole-config";
export const PLUGIN_ID = "remote-desktop";
export type Protocol = "rdp" | "vnc" | "telnet";
/** This plugin's host settings, as they arrive on host.pluginSettings. */
export interface RemoteHostOptions {
enableRdp: boolean;
enableVnc: boolean;
enableTelnet: boolean;
rdpPort: number;
vncPort: number;
telnetPort: number;
rdpSecurity: string;
rdpIgnoreCert: boolean;
guacamoleConfig: GuacamoleConfig;
enableToolbar: boolean;
}
export const ENABLE_KEY = {
rdp: "enableRdp",
vnc: "enableVnc",
telnet: "enableTelnet",
} as const satisfies Record<Protocol, keyof RemoteHostOptions>;
export const PORT_KEY = {
rdp: "rdpPort",
vnc: "vncPort",
telnet: "telnetPort",
} as const satisfies Record<Protocol, keyof RemoteHostOptions>;
export const DEFAULT_PORT: Record<Protocol, number> = {
rdp: 3389,
vnc: 5900,
telnet: 23,
};
/** The core fields holding a protocol's login, which stay on the host. */
export interface RemoteHostLogin {
id?: number | string;
name?: string | null;
ip?: string;
domain?: string;
syncId?: string | null;
connectionOrigin?: "local" | "remote" | null;
rdpAuthType?: string;
rdpUser?: string;
rdpPassword?: string;
vncUser?: string;
vncPassword?: string;
authOverrides?: Record<
string,
{
credentialId?: number | string | null;
required?: boolean;
ownerAuthShared?: boolean;
}
>;
pluginSettings?: Record<string, Record<string, unknown>>;
}
function port(value: unknown, fallback: number): number {
const parsed = typeof value === "string" ? Number(value) : value;
return typeof parsed === "number" && Number.isInteger(parsed) && parsed > 0
? parsed
: fallback;
}
export function parseGuacamoleConfig(value: unknown): GuacamoleConfig {
if (!value) return {};
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
return typeof value === "object" ? (value as GuacamoleConfig) : {};
}
export function remoteOptions(
settings: Record<string, unknown> | undefined,
): RemoteHostOptions {
const values = settings ?? {};
return {
enableRdp: values.enableRdp === true,
enableVnc: values.enableVnc === true,
enableTelnet: values.enableTelnet === true,
rdpPort: port(values.rdpPort, DEFAULT_PORT.rdp),
vncPort: port(values.vncPort, DEFAULT_PORT.vnc),
telnetPort: port(values.telnetPort, DEFAULT_PORT.telnet),
rdpSecurity:
typeof values.rdpSecurity === "string" ? values.rdpSecurity : "",
rdpIgnoreCert: values.rdpIgnoreCert === true,
guacamoleConfig: parseGuacamoleConfig(values.guacamoleConfig),
enableToolbar: values.enableToolbar !== false,
};
}
export function hostRemoteOptions(
host:
| Pick<RemoteHostLogin, "pluginSettings">
| PluginHostRecord
| null
| undefined,
): RemoteHostOptions {
const bag = (host as RemoteHostLogin | null | undefined)?.pluginSettings;
return remoteOptions(bag?.[PLUGIN_ID]);
}
export function protocolEnabled(
host:
| Pick<RemoteHostLogin, "pluginSettings">
| PluginHostRecord
| null
| undefined,
protocol: Protocol,
): boolean {
return hostRemoteOptions(host)[ENABLE_KEY[protocol]];
}
/** Quick Connect hosts are never saved; their ids carry this prefix. */
const QUICK_CONNECT_ID_PREFIX = "quick-connect-";
export function isQuickConnectHost(host: { id?: unknown } | null | undefined) {
return String(host?.id ?? "").startsWith(QUICK_CONNECT_ID_PREFIX);
}
export function errorMessage(error: unknown, fallback = "Unknown error") {
const data = (error as { response?: { data?: { error?: unknown } } })
?.response?.data?.error;
if (typeof data === "string" && data) return data;
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error;
return fallback;
}
+172 -126
View File
@@ -1,23 +1,21 @@
import type { ComponentType, Ref } from "react";
import i18next from "i18next";
import { toast } from "sonner";
import {
Info,
MessagesSquare,
Monitor,
MonitorUp,
MousePointerClick,
Settings,
} from "lucide-react";
import {
useTranslation,
type HostEditorSectionProps,
type PluginHostRecord,
type StandaloneViewProps,
type TabProps,
type TermixApp,
} from "@termix/plugin-sdk/frontend";
import { FakeSwitch, SectionCard, SettingRow } from "@termix/plugin-sdk/ui";
import type { Host } from "@/types/ui-types";
import { isQuickConnectHost } from "@/sidebar/quick-connect-host";
import { SectionCard, isElectron } from "@termix/plugin-sdk/ui";
import { toast } from "sonner";
import GuacamoleApp, { type GuacamoleAppHandle } from "./GuacamoleApp";
import { GuacamoleDisplay } from "./GuacamoleDisplay";
import {
@@ -25,48 +23,70 @@ import {
HostEditorTelnetTab,
HostEditorVncTab,
} from "./HostEditorGuacamoleTabs";
import { getGuacamoleTokenFromHost } from "./guacamole-api";
import {
getGuacamoleTokenFromHost,
getGuacdStatus,
nativeRdpAvailable,
openNativeRdp,
setRemoteDesktopApp,
} from "./guacamole-api";
import { quickConnectGuacHost } from "./quick-connect-guac-host";
import { REMOTE_DESKTOP_TOOLBAR_SLOT } from "./GuacamoleToolbar.tsx";
type Protocol = "rdp" | "vnc" | "telnet";
import {
DEFAULT_PORT,
ENABLE_KEY,
PORT_KEY,
hostRemoteOptions,
isQuickConnectHost,
protocolEnabled,
type Protocol,
type RemoteHostLogin,
} from "./host-remote";
import { remoteDesktopForm } from "./remote-form";
const PROTOCOLS: {
id: Protocol;
titleKey: string;
descriptionKey: string;
paletteKey: string;
icon: ComponentType<{ className?: string }>;
enableKey: "enableRdp" | "enableVnc" | "enableTelnet";
priority: number;
order: number;
quickConnect?: { showDomain?: boolean };
}[] = [
{
id: "rdp",
titleKey: "hosts.tabRdp",
descriptionKey: "hosts.remoteDesktop",
paletteKey: "palette.connectRdp",
icon: Monitor,
enableKey: "enableRdp",
priority: 50,
order: 100,
quickConnect: { showDomain: true },
},
{
id: "vnc",
titleKey: "hosts.tabVnc",
descriptionKey: "hosts.virtualNetwork",
paletteKey: "palette.connectVnc",
icon: MousePointerClick,
enableKey: "enableVnc",
priority: 40,
order: 110,
quickConnect: {},
},
{
id: "telnet",
titleKey: "hosts.tabTelnet",
descriptionKey: "hosts.unencryptedShell",
paletteKey: "palette.connectTelnet",
icon: MessagesSquare,
enableKey: "enableTelnet",
priority: 30,
order: 120,
},
];
function RemoteDesktopTab({ tab, host, isVisible, handleRef }: TabProps) {
const shellHost = host as unknown as Host;
const record = host as unknown as RemoteHostLogin | undefined;
return (
<GuacamoleApp
ref={handleRef as Ref<GuacamoleAppHandle>}
@@ -75,8 +95,8 @@ function RemoteDesktopTab({ tab, host, isVisible, handleRef }: TabProps) {
protocol={tab.type as Protocol}
isVisible={isVisible}
quickConnectHost={
isQuickConnectHost(shellHost)
? quickConnectGuacHost(shellHost)
record && isQuickConnectHost(record)
? quickConnectGuacHost(record)
: undefined
}
/>
@@ -111,79 +131,65 @@ function RemoteDisplay({
);
}
/** Applies one remote desktop field and stops inheriting user defaults. */
function useGuacFieldSetter(
updateForm: HostEditorSectionProps["updateForm"],
): (key: string, value: unknown) => void {
return (key, value) =>
updateForm((current) => ({
inheritRemoteDesktopDefaults: false,
guacamoleConfig: {
...((current.guacamoleConfig as Record<string, unknown>) ?? {}),
[key]: value,
},
}));
function SectionNotes({ protocol }: { protocol: Protocol }) {
const { t } = useTranslation();
return (
<SectionCard
title={t("hosts.remoteDesktopNotes")}
icon={<Info className="size-3.5" />}
>
<div className="flex flex-col gap-2 py-3 text-xs text-muted-foreground">
{protocol === "rdp" && <p>{t("hosts.userDefaultsNote")}</p>}
{isElectron() && <p>{t("hosts.connectionOriginNote")}</p>}
</div>
</SectionCard>
);
}
function RdpSection(props: HostEditorSectionProps) {
const { t } = useTranslation();
const setGuacField = useGuacFieldSetter(props.updateForm);
const { form, setField, setGuacField } = remoteDesktopForm(props);
return (
<>
<SectionCard
title={t("hosts.rdpDefaults", { defaultValue: "RDP defaults" })}
icon={<Settings className="size-3.5" />}
>
<SettingRow
label={t("hosts.useUserDefaults", {
defaultValue: "Use user defaults",
})}
description={t("hosts.useUserRdpDefaultsDesc", {
defaultValue:
"Inherit performance, redirection, and clipboard settings from User Profile.",
})}
>
<FakeSwitch
checked={!!props.form.inheritRemoteDesktopDefaults}
onChange={(value) =>
props.setField("inheritRemoteDesktopDefaults", value)
}
/>
</SettingRow>
</SectionCard>
<HostEditorRdpTab
form={props.form}
setField={props.setField as never}
setGuacField={setGuacField as never}
host={props.host as unknown as Host}
form={form}
setField={setField}
setGuacField={setGuacField}
host={props.host as { macAddress?: string | null } | undefined}
credentials={props.credentials as never}
/>
<SectionNotes protocol="rdp" />
</>
);
}
function VncSection(props: HostEditorSectionProps) {
const setGuacField = useGuacFieldSetter(props.updateForm);
const { form, setField, setGuacField } = remoteDesktopForm(props);
return (
<HostEditorVncTab
form={props.form}
setField={props.setField as never}
setGuacField={setGuacField as never}
host={props.host as unknown as Host}
credentials={props.credentials as never}
/>
<>
<HostEditorVncTab
form={form}
setField={setField}
setGuacField={setGuacField}
host={props.host as { macAddress?: string | null } | undefined}
credentials={props.credentials as never}
/>
<SectionNotes protocol="vnc" />
</>
);
}
function TelnetSection(props: HostEditorSectionProps) {
const setGuacField = useGuacFieldSetter(props.updateForm);
const { form, setField, setGuacField } = remoteDesktopForm(props);
return (
<HostEditorTelnetTab
form={props.form}
setField={props.setField as never}
setGuacField={setGuacField as never}
credentials={props.credentials as never}
/>
<>
<HostEditorTelnetTab
form={form}
setField={setField}
setGuacField={setGuacField}
credentials={props.credentials as never}
/>
<SectionNotes protocol="telnet" />
</>
);
}
@@ -193,23 +199,96 @@ const SECTIONS: Record<Protocol, ComponentType<HostEditorSectionProps>> = {
telnet: TelnetSection,
};
async function openNativeRdp(host: Record<string, unknown>): Promise<void> {
const t = (key: string) => i18next.t(`remote-desktop:${key}`);
try {
const result = await window.electronAPI.openNativeRdp({
host: String(host.ip),
port: (host.rdpPort as number | undefined) ?? 3389,
username: host.rdpUser as string | undefined,
domain: host.domain as string | undefined,
function registerNativeRdp(app: TermixApp): void {
let disposed = false;
app.onDispose(() => {
disposed = true;
});
void nativeRdpAvailable().then((available) => {
if (disposed || !available) return;
app.registerHostAction({
id: "rdp-native",
titleKey: "hosts.openNativeRdp",
icon: MonitorUp,
kind: "open",
order: 105,
when: (host) => protocolEnabled(host, "rdp"),
run: (host) => {
const record = host as unknown as RemoteHostLogin;
void openNativeRdp({
host: String(record.ip ?? ""),
port: hostRemoteOptions(record).rdpPort,
username: record.rdpUser,
domain: record.domain,
})
.then((result) => {
if (result.success) toast.success(app.t("hosts.nativeRdpOpened"));
else toast.error(result.error || app.t("hosts.nativeRdpFailed"));
})
.catch(() => toast.error(app.t("hosts.nativeRdpFailed")));
},
});
});
}
function registerHostSurfaces(app: TermixApp): void {
for (const protocol of PROTOCOLS) {
const when = (host: PluginHostRecord) => protocolEnabled(host, protocol.id);
app.registerHostProtocol({
id: protocol.id,
settingKey: ENABLE_KEY[protocol.id],
portKey: PORT_KEY[protocol.id],
defaultPort: DEFAULT_PORT[protocol.id],
titleKey: protocol.titleKey,
descriptionKey: protocol.descriptionKey,
icon: protocol.icon,
order: protocol.order,
quickConnect: protocol.quickConnect,
});
app.registerHostAction({
id: protocol.id,
titleKey: protocol.titleKey,
icon: protocol.icon,
kind: "connect",
priority: protocol.priority,
order: protocol.order,
tabType: protocol.id,
copyUrlView: protocol.id,
when,
});
app.registerPaletteEntry({
id: `remote-desktop.${protocol.id}`,
titleKey: protocol.paletteKey,
icon: protocol.icon,
keywords: [protocol.id],
scope: "host",
when: (host) => !!host && when(host),
run: (shell, host) => {
if (host) shell.openTab(host, protocol.id);
},
});
app.registerHostEditorSection({
id: protocol.id,
group: "top",
titleKey: protocol.titleKey,
icon: protocol.icon,
order: protocol.order / 5,
visible: (protocols) => !!protocols[ENABLE_KEY[protocol.id]],
component: SECTIONS[protocol.id],
});
if (result.success) toast.success(t("hosts.nativeRdpOpened"));
else toast.error(result.error || t("hosts.nativeRdpFailed"));
} catch {
toast.error(t("hosts.nativeRdpFailed"));
}
if (isElectron()) registerNativeRdp(app);
}
export function activate(app: TermixApp): void {
setRemoteDesktopApp(app);
app.onDispose(() => setRemoteDesktopApp(null));
// Guest pages only draw shared streams.
app.registerSlotContribution("session.remoteDisplay", {
actionId: "remote-desktop.display",
@@ -253,57 +332,24 @@ export function activate(app: TermixApp): void {
noHostMessageKey: "remoteDesktop.noHostSelected",
persistent: true,
session: true,
restore: (host) => !!host[protocol.enableKey],
restore: (host) => protocolEnabled(host, protocol.id),
activityTypes: [protocol.id],
standalone: RemoteDesktopStandalone,
preload: () => import("./GuacamoleApp"),
});
app.registerHostAction({
id: protocol.id,
titleKey: protocol.titleKey,
icon: protocol.icon,
kind: "connect",
priority: protocol.priority,
order: protocol.order,
tabType: protocol.id,
copyUrlView: protocol.id,
when: (host) => !!host[protocol.enableKey],
});
app.registerHostEditorSection({
id: protocol.id,
group: "top",
titleKey: protocol.titleKey,
icon: protocol.icon,
order: protocol.order / 5,
visible: (protocols) => !!protocols[protocol.enableKey],
component: SECTIONS[protocol.id],
});
}
// The desktop app on Windows can hand RDP to the system client instead.
if (window.electronAPI?.isElectron) {
let disposed = false;
app.onDispose(() => {
disposed = true;
// An admin can turn Remote Desktop off; its ways in go with it.
let disposed = false;
app.onDispose(() => {
disposed = true;
});
void getGuacdStatus("local", { probe: false })
.then((status) => status.enabled !== false)
.catch(() => true)
.then((enabled) => {
if (!disposed && enabled) registerHostSurfaces(app);
});
window.electronAPI
.getPlatform()
.then((platform: string) => {
if (disposed || platform !== "win32") return;
app.registerHostAction({
id: "rdp-native",
titleKey: "hosts.openNativeRdp",
icon: MonitorUp,
kind: "open",
order: 105,
when: (host) => !!host.enableRdp,
run: (host) => void openNativeRdp(host),
});
})
.catch(() => {});
}
app.registerSlotContribution("onboarding.features", {
actionId: "remote-desktop.feature",
@@ -1,19 +1,24 @@
import type { Host } from "@/types/ui-types";
import type { GuacamoleQuickHost } from "./GuacamoleApp";
import {
hostRemoteOptions,
type Protocol,
type RemoteHostLogin,
} from "./host-remote";
/** The slice of a quick-connect host that GuacamoleApp mints a token from. */
export function quickConnectGuacHost(host: Host): GuacamoleQuickHost {
export function quickConnectGuacHost(
host: RemoteHostLogin,
): GuacamoleQuickHost {
const options = hostRemoteOptions(host);
const connectionType: Protocol = options.enableVnc ? "vnc" : "rdp";
return {
name: host.name,
ip: host.ip,
connectionType: host.enableVnc ? "vnc" : "rdp",
name: host.name ?? undefined,
ip: host.ip ?? "",
connectionType,
domain: host.domain,
rdpPort: host.rdpPort,
vncPort: host.vncPort,
port: connectionType === "vnc" ? options.vncPort : options.rdpPort,
rdpAuthType: host.rdpAuthType,
rdpUser: host.rdpUser,
rdpPassword: host.rdpPassword,
vncUser: host.vncUser,
vncPassword: host.vncPassword,
username: connectionType === "vnc" ? host.vncUser : host.rdpUser,
password: connectionType === "vnc" ? host.vncPassword : host.rdpPassword,
};
}
@@ -1,4 +1,4 @@
import type { HostAuthOverrides } from "@/types/auth-protocols";
import type { RemoteHostLogin } from "./host-remote";
export function needsRdpCredentialPrompt({
protocol,
@@ -7,7 +7,7 @@ export function needsRdpCredentialPrompt({
}: {
protocol: "rdp" | "vnc" | "telnet";
rdpAuthType?: string;
authOverrides?: HostAuthOverrides;
authOverrides?: RemoteHostLogin["authOverrides"];
}): boolean {
return (
protocol === "rdp" &&
@@ -0,0 +1,100 @@
import type { HostEditorSectionProps } from "@termix/plugin-sdk/frontend";
import { PLUGIN_ID, remoteOptions } from "./host-remote";
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* The host editor fields the RDP, VNC and Telnet tabs edit. The logins are
* core host fields; the options (ports, security, guacd settings) are this
* plugin's host settings, presented here under the names the tabs use.
*/
export interface RemoteDesktopForm {
domain: string;
security: string;
ignoreCert: boolean;
rdpPort: number;
vncPort: number;
telnetPort: number;
guacamoleConfig: Record<string, any>;
rdpAuthType: "direct" | "credential" | "none";
rdpCredentialId: string;
rdpUser: string;
rdpPassword: string;
vncAuthType: "direct" | "credential";
vncCredentialId: string;
vncUser: string;
vncPassword: string;
telnetAuthType: "direct" | "credential";
telnetCredentialId: string;
telnetUser: string;
telnetPassword: string;
statsConfig: Record<string, any>;
}
export type RemoteFormSetField = <K extends keyof RemoteDesktopForm>(
key: K,
value: RemoteDesktopForm[K],
) => void;
/** Tab field name to this plugin's host setting key. */
const SETTING_FOR_FIELD: Partial<Record<keyof RemoteDesktopForm, string>> = {
rdpPort: "rdpPort",
vncPort: "vncPort",
telnetPort: "telnetPort",
security: "rdpSecurity",
ignoreCert: "rdpIgnoreCert",
};
type PluginSettingsBag = Record<string, Record<string, unknown>>;
function patchSettings(
current: Record<string, unknown>,
patch: (settings: Record<string, unknown>) => Record<string, unknown>,
): Record<string, unknown> {
const bag = (current.pluginSettings as PluginSettingsBag | undefined) ?? {};
const mine = bag[PLUGIN_ID] ?? {};
return {
pluginSettings: { ...bag, [PLUGIN_ID]: { ...mine, ...patch(mine) } },
};
}
export function remoteDesktopForm(props: HostEditorSectionProps): {
form: RemoteDesktopForm;
setField: RemoteFormSetField;
setGuacField: (key: string, value: unknown) => void;
} {
const bag = props.form?.pluginSettings as PluginSettingsBag | undefined;
const options = remoteOptions(bag?.[PLUGIN_ID]);
const form: RemoteDesktopForm = {
...(props.form as RemoteDesktopForm),
rdpPort: options.rdpPort,
vncPort: options.vncPort,
telnetPort: options.telnetPort,
security: options.rdpSecurity,
ignoreCert: options.rdpIgnoreCert,
guacamoleConfig: options.guacamoleConfig as Record<string, any>,
};
const setField: RemoteFormSetField = (key, value) => {
const setting = SETTING_FOR_FIELD[key];
if (!setting) {
props.setField(key, value);
return;
}
props.updateForm((current) =>
patchSettings(current, () => ({ [setting]: value })),
);
};
const setGuacField = (key: string, value: unknown) =>
props.updateForm((current) =>
patchSettings(current, (mine) => ({
guacamoleConfig: {
...remoteOptions(mine).guacamoleConfig,
[key]: value,
},
})),
);
return { form, setField, setGuacField };
}
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import {
normalizeImportedHost,
userDefaultParams,
} from "../../src/backend/host-settings.js";
describe("normalizeImportedHost", () => {
it("reads a Termix export's pluginSettings", () => {
expect(
normalizeImportedHost({
pluginSettings: {
"remote-desktop": {
enableVnc: true,
vncPort: 5901,
guacamoleConfig: { cursor: "local" },
},
},
}),
).toEqual({
enableVnc: true,
vncPort: 5901,
guacamoleConfig: { cursor: "local" },
});
});
it("reads the flat fields hosts had before 2.9.0", () => {
expect(
normalizeImportedHost({
enableRdp: true,
rdpPort: "3390",
security: "nla",
ignoreCert: 1,
guacamoleConfig: JSON.stringify({ dpi: 120 }),
enableTerminalToolbar: false,
}),
).toEqual({
enableRdp: true,
rdpPort: 3390,
rdpSecurity: "nla",
rdpIgnoreCert: true,
guacamoleConfig: { dpi: 120 },
enableToolbar: false,
});
});
it("turns a connectionType-only host into its switch and port", () => {
expect(
normalizeImportedHost({ connectionType: "telnet", port: 2323 }),
).toEqual({ enableTelnet: true, telnetPort: 2323 });
});
it("writes nothing for a plain SSH row", () => {
expect(
normalizeImportedHost({ connectionType: "ssh", port: 22 }),
).toBeNull();
});
it("drops a port out of range", () => {
expect(normalizeImportedHost({ enableRdp: true, rdpPort: 70000 })).toEqual({
enableRdp: true,
});
});
});
describe("userDefaultParams", () => {
it("maps the user settings onto guacd parameters, skipping inherit", () => {
expect(
userDefaultParams({
colorDepth: "24",
resizeMethod: "reconnect",
disableCopy: "on",
enableWallpaper: "off",
enableDrive: "inherit",
}),
).toEqual({
"color-depth": 24,
"resize-method": "reconnect",
"disable-copy": true,
"enable-wallpaper": false,
});
});
});
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
buildRdpSettings,
resolveRdpAuthTypeForConnect,
resolveRdpDomain,
} from "../../src/backend/rdp-settings.js";
@@ -53,32 +52,3 @@ describe("buildRdpSettings", () => {
expect(resolveRdpDomain("none", undefined, "SAVED")).toBe("SAVED");
});
});
describe("resolveRdpAuthTypeForConnect", () => {
it("keeps prompt-on-connect authentication for a secretless recipient", () => {
expect(
resolveRdpAuthTypeForConnect({
storedAuthType: "none",
sharedResolution: { source: "secretless" },
}),
).toBe("none");
});
it("uses a recipient override instead of prompting", () => {
expect(
resolveRdpAuthTypeForConnect({
storedAuthType: "none",
sharedResolution: { source: "personal-override" },
}),
).toBe("credential");
});
it("uses authentication shared by the owner", () => {
expect(
resolveRdpAuthTypeForConnect({
storedAuthType: "credential",
sharedResolution: { source: "owner-shared", authType: "credential" },
}),
).toBe("credential");
});
});
@@ -0,0 +1,318 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import express, { type Router } from "express";
import { afterEach, describe, expect, it } from "vitest";
import {
createMockCtx,
type MockPluginContext,
} from "@termix/plugin-sdk/testing";
import type {
PluginContext,
PluginProtocolTarget,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
import { activate } from "../../src/backend/index.js";
import { GuacamoleTokenService } from "../../src/backend/token-service.js";
const manifest = manifestJson as unknown as PluginManifest;
// The token key comes from JWT_SECRET; without one each service picks its own.
process.env.JWT_SECRET = "remote-desktop-test-secret";
function target(
overrides: Partial<PluginProtocolTarget["auth"]> = {},
): PluginProtocolTarget {
return {
host: {
id: 7,
name: "Win box",
ip: "10.0.0.7",
port: 22,
ownerUserId: "user-1",
jumpHosts: [],
},
shared: false,
auth: {
authType: "direct",
username: "admin",
password: "secret",
domain: "CORP",
...overrides,
},
};
}
interface Server {
mock: MockPluginContext;
request: (
method: string,
path: string,
body?: unknown,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
let server: Server | null = null;
afterEach(async () => {
await server?.close();
server = null;
});
async function start(
options: {
protocolTargets?: Record<string, PluginProtocolTarget>;
services?: Record<string, object>;
capabilities?: string[];
} = {},
): Promise<Server> {
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: options.capabilities ?? manifest.capabilities,
router: () => (router = express.Router()),
protocolTargets: options.protocolTargets,
services: options.services,
});
await activate(mock.ctx as PluginContext);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? "user-1");
next();
});
app.use((req, res, next) => router!(req, res, next));
const httpServer = http.createServer(app);
await new Promise<void>((resolve) => httpServer.listen(0, resolve));
const { port } = httpServer.address() as AddressInfo;
return {
mock,
async request(method, path, body) {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: body ? { "content-type": "application/json" } : {},
body: body ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
for (const dispose of [...mock.disposals].reverse()) await dispose();
},
};
}
const tokens = new GuacamoleTokenService();
describe("POST /connect-host/:hostId", () => {
it("mints a token from the resolved login and the host's own settings", async () => {
server = await start({ protocolTargets: { "7:rdp": target() } });
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
await server.mock.ctx.settings.setHost(7, "rdpPort", 3390);
await server.mock.ctx.settings.setHost(7, "rdpSecurity", "nla");
await server.mock.ctx.settings.setHost(7, "guacamoleConfig", {
"color-depth": 16,
"enable-wallpaper": "auto",
});
const response = await server.request("POST", "/connect-host/7", {
protocol: "rdp",
});
expect(response.status).toBe(200);
expect(response.body.termixConnectId).toEqual(expect.any(String));
const token = tokens.decryptToken(response.body.token);
expect(token?.connection.settings).toMatchObject({
hostname: "10.0.0.7",
port: 3390,
username: "admin",
password: "secret",
domain: "CORP",
security: "nla",
"color-depth": 16,
});
expect(token?.connection.settings).not.toHaveProperty("enable-wallpaper");
expect(token?.termixMeta).toMatchObject({
hostId: 7,
hostName: "Win box",
protocol: "rdp",
});
expect(server.mock.audits.at(-1)).toMatchObject({
action: "rdp_connect",
success: true,
});
});
it("puts the user's RDP defaults under the host's own values", async () => {
server = await start({ protocolTargets: { "7:rdp": target() } });
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
await server.mock.ctx.settings.setHost(7, "guacamoleConfig", {
"color-depth": 32,
});
await server.mock.ctx.settings.setUser("user-1", "colorDepth", "16");
await server.mock.ctx.settings.setUser("user-1", "disableCopy", "on");
const response = await server.request("POST", "/connect-host/7", {
protocol: "rdp",
});
const settings = tokens.decryptToken(response.body.token)?.connection
.settings;
expect(settings?.["color-depth"]).toBe(32);
expect(settings?.["disable-copy"]).toBe(true);
});
it("uses the prompted login when the host asks at connect time", async () => {
server = await start({
protocolTargets: { "7:rdp": target({ authType: "none", username: "" }) },
});
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
const response = await server.request("POST", "/connect-host/7", {
protocol: "rdp",
promptedUsername: "bob",
promptedPassword: "pw",
promptedDomain: "",
});
const settings = tokens.decryptToken(response.body.token)?.connection
.settings;
expect(settings).toMatchObject({ username: "bob", password: "pw" });
});
it("refuses a protocol the host has switched off", async () => {
server = await start({ protocolTargets: { "7:vnc": target() } });
const response = await server.request("POST", "/connect-host/7", {
protocol: "vnc",
});
expect(response.status).toBe(400);
});
it("answers 404 when the caller cannot reach the host", async () => {
server = await start();
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
const response = await server.request("POST", "/connect-host/7", {
protocol: "rdp",
});
expect(response.status).toBe(404);
});
it("refuses everything while an admin has turned it off", async () => {
server = await start({ protocolTargets: { "7:rdp": target() } });
await server.mock.ctx.settings.set("enabled", false);
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
expect(
(await server.request("POST", "/connect-host/7", { protocol: "rdp" }))
.status,
).toBe(403);
expect(
(
await server.request("POST", "/token", {
type: "rdp",
hostname: "10.0.0.9",
})
).status,
).toBe(403);
const status = await server.request("GET", "/status?probe=0");
expect(status.body.enabled).toBe(false);
});
it("records only when session recording is on for the host", async () => {
const enabledFor = async (hostId: number) => hostId === 7;
server = await start({
protocolTargets: { "7:rdp": target() },
services: {
"recordings.writer": {
enabledFor,
createFinished: async () => ({ id: 1 }),
},
},
});
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
const response = await server.request("POST", "/connect-host/7", {
protocol: "rdp",
});
const token = tokens.decryptToken(response.body.token);
expect(token?.recording).toMatchObject({ hostId: 7, protocol: "rdp" });
expect(token?.connection.settings["recording-name"]).toEqual(
expect.any(String),
);
});
});
describe("the rest of the routes", () => {
it("mints a quick connect token with the caller's defaults", async () => {
server = await start();
await server.mock.ctx.settings.setUser("user-1", "colorDepth", "24");
const response = await server.request("POST", "/token", {
type: "rdp",
hostname: "10.0.0.9",
username: "u",
password: "p",
});
expect(response.status).toBe(200);
expect(
tokens.decryptToken(response.body.token)?.connection.settings,
).toMatchObject({ hostname: "10.0.0.9", port: 3389, "color-depth": 24 });
});
it("offers native RDP only in the desktop app", async () => {
server = await start();
expect((await server.request("GET", "/native-rdp")).body).toEqual({
available: false,
});
expect(
(await server.request("POST", "/native-rdp", { host: "10.0.0.7" }))
.status,
).toBe(404);
});
it("does not hand out another user's session id", async () => {
server = await start();
const response = await server.request("GET", "/connection/unknown");
expect(response.body).toEqual({ guacamoleConnectionId: null });
});
});
describe("activate", () => {
it("fails closed without network:serve", async () => {
await expect(
start({
capabilities: manifest.capabilities.filter(
(capability) => capability !== "network:serve",
),
}),
).rejects.toThrow(/network:serve/);
});
it("cannot read a host's login without credentials:read", async () => {
server = await start({
protocolTargets: { "7:rdp": target() },
capabilities: manifest.capabilities.filter(
(capability) => capability !== "credentials:read",
),
});
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
const response = await server.request("POST", "/connect-host/7", {
protocol: "rdp",
});
expect(response.status).toBe(500);
expect(server.mock.checked).toContain("credentials:read");
});
it("provides sessions.live for rdp, vnc and telnet", async () => {
server = await start();
expect([...server.mock.services.keys()].sort()).toEqual(
expect.arrayContaining([
"sessions.live#rdp",
"sessions.live#telnet",
"sessions.live#vnc",
]),
);
});
});
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { RemoteSessions } from "../../src/backend/sessions.js";
import { createLiveSessions } from "../../src/backend/live-sessions.js";
import { GuacamoleTokenService } from "../../src/backend/token-service.js";
const meta = {
termixConnectId: "c1",
hostId: 7,
hostName: "Win box",
ownerUserId: "user-1",
protocol: "rdp" as const,
tabInstanceId: "tab-1",
};
afterEach(() => {
vi.useRealTimers();
});
describe("RemoteSessions", () => {
it("moves parked resources onto the session and releases them on close", () => {
const sessions = new RemoteSessions();
const tunnel = vi.fn();
const tracking = vi.fn();
sessions.park("c1", [tunnel]);
sessions.opened(meta, "g1", [tracking]);
expect(sessions.byConnect("c1")?.guacamoleConnectionId).toBe("g1");
expect(sessions.byGuacamole("g1")?.hostName).toBe("Win box");
expect(tunnel).not.toHaveBeenCalled();
sessions.closed("c1");
expect(tunnel).toHaveBeenCalledOnce();
expect(tracking).toHaveBeenCalledOnce();
expect(sessions.byGuacamole("g1")).toBeNull();
});
it("releases a tunnel guacd never used", () => {
vi.useFakeTimers();
const sessions = new RemoteSessions(1000);
const tunnel = vi.fn();
sessions.park("c1", [tunnel]);
vi.advanceTimersByTime(1000);
expect(tunnel).toHaveBeenCalledOnce();
});
it("releases everything on clear, and one failing close does not stop the rest", () => {
const sessions = new RemoteSessions();
const failing = vi.fn(() => {
throw new Error("already closed");
});
const pending = vi.fn();
const open = vi.fn();
sessions.park("c2", [failing, pending]);
sessions.park("c1", [open]);
sessions.opened(meta, "g1");
sessions.clear();
expect(pending).toHaveBeenCalledOnce();
expect(open).toHaveBeenCalledOnce();
});
});
describe("sessions.live providers", () => {
const tokens = new GuacamoleTokenService();
it("describes a live session of its own protocol only", () => {
const sessions = new RemoteSessions();
sessions.opened(meta, "g1");
const deps = { sessions, tokens, endSession: vi.fn(() => true) };
const rdp = createLiveSessions("rdp", deps);
const vnc = createLiveSessions("vnc", deps);
expect(rdp.getSession("g1")).toMatchObject({
id: "g1",
userId: "user-1",
hostId: 7,
hostName: "Win box",
isConnected: true,
tabInstanceId: "tab-1",
});
expect(vnc.getSession("g1")).toBeNull();
});
it("mints a join token for a viewer and refuses an unknown session", () => {
const sessions = new RemoteSessions();
sessions.opened(meta, "g1");
const rdp = createLiveSessions("rdp", {
sessions,
tokens,
endSession: vi.fn(() => true),
});
const token = tokens.decryptToken(rdp.createViewerToken("g1", true));
expect(token?.connection).toMatchObject({ join: "g1", readOnly: true });
expect(token?.recording).toBeUndefined();
expect(() => rdp.createViewerToken("nope", false)).toThrow();
});
it("ends the session for its owner", () => {
const sessions = new RemoteSessions();
sessions.opened(meta, "g1");
const endSession = vi.fn(() => true);
const rdp = createLiveSessions("rdp", { sessions, tokens, endSession });
rdp.ownerEndSession("g1", "owner ended");
expect(endSession).toHaveBeenCalledWith("g1");
});
});
@@ -1,98 +0,0 @@
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { expect, it, vi } from "vitest";
it("returns the minted token without waiting for a connection that needs that token", async () => {
const source = readFileSync(
new URL("../../src/backend/routes.ts", import.meta.url),
"utf8",
);
const ast = ts.createSourceFile(
"routes.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const route = ast.statements.find(
(s) =>
ts.isExpressionStatement(s) &&
ts.isCallExpression(s.expression) &&
s.expression.arguments[0]?.getText(ast) === '"/connect-host/:hostId"',
) as ts.ExpressionStatement;
const callback = (route.expression as ts.CallExpression)
.arguments[1] as ts.ArrowFunction;
const block = (callback.body as ts.Block).statements[0] as ts.TryStatement;
const index = block.tryBlock.statements
.map((s, i) => (ts.isSwitchStatement(s) ? i : -1))
.filter((i) => i >= 0)
.at(-1)!;
expect(index).toBeGreaterThan(-1);
const tail = block.tryBlock.statements
.slice(index + 1)
.map((s) => s.getText(ast))
.join("\n");
const json = vi.fn();
const wait = vi.fn(() => {
throw new Error("Client has not received the token yet");
});
await runInNewContext(
ts.transpileModule(`(async () => { ${tail} })()`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText,
{
waitForGuacdOpen: wait,
token: "minted-token",
termixConnectId: "correlation-id",
req: {},
getRequestMeta: () => ({}),
logAudit: async () => {},
getAuditUsername: async () => "owner",
userId: "owner",
connectionType: "rdp",
hostId: 7,
hostname: "host",
port: 3389,
res: { json },
},
);
expect(wait).not.toHaveBeenCalled();
expect(json).toHaveBeenCalledWith({
token: "minted-token",
termixConnectId: "correlation-id",
guacamoleConnectionId: null,
});
});
it("only reveals the live session ID to its owner", () => {
const source = readFileSync(
new URL("../../src/backend/guacamole-server.ts", import.meta.url),
"utf8",
);
const ast = ts.createSourceFile(
"server.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const fn = ast.statements.find(
(s) =>
ts.isFunctionDeclaration(s) &&
s.name?.text === "getGuacSessionByConnectId",
)!;
const info = { ownerUserId: "owner", guacamoleConnectionId: "guacd-id" };
const context = {
exports: {} as Record<string, (id: string, user: string) => unknown>,
guacSessionByConnectId: new Map([["connect-id", info]]),
};
runInNewContext(
ts.transpileModule(fn.getText(ast), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText,
context,
);
const lookup = context.exports.getGuacSessionByConnectId;
expect(lookup("connect-id", "owner")).toEqual(info);
expect(lookup("connect-id", "other")).toBeNull();
expect(lookup("pending", "owner")).toBeNull();
});
@@ -1,20 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
vi.mock("../../../../src/backend/utils/logger.js", () => ({
guacLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
const { GuacamoleTokenService } =
await import("../../src/backend/token-service.js");
import { GuacamoleTokenService } from "../../src/backend/token-service.js";
describe("GuacamoleTokenService", () => {
const tokenService = GuacamoleTokenService.getInstance();
const tokenService = new GuacamoleTokenService();
it("disables RDP pre-authentication when no credentials are configured", () => {
const token = tokenService.createRdpToken("windows.example.test", "", "");
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
renderWithApp,
type RenderedPluginApp,
@@ -45,9 +45,39 @@ describe(`${manifest.id} activate`, () => {
expect(app.registered.panels()).toEqual([]);
expect(app.registered.railItems()).toEqual([]);
expect(app.registered.hostActions()).toEqual([]);
expect(app.registered.hostProtocols()).toEqual([]);
expect(app.registered.hostEditorSections()).toEqual([]);
expect(app.registered.dashboardCards()).toEqual([]);
expect(app.registered.settingsComponents()).toEqual([]);
expect(app.registered.actions()).toEqual([]);
});
it("adds RDP, VNC and Telnet as host protocols while turned on", async () => {
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: { get: async () => ({ data: { enabled: true } }) } as never,
});
await vi.waitFor(() =>
expect(rendered!.registered.hostProtocols().sort()).toEqual([
"rdp",
"telnet",
"vnc",
]),
);
expect(rendered.registered.hostActions().map((a) => a.id)).toEqual(
expect.arrayContaining(["rdp", "vnc", "telnet"]),
);
});
it("offers no way in while an admin has turned it off", async () => {
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: { get: async () => ({ data: { enabled: false } }) } as never,
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(rendered.registered.hostProtocols()).toEqual([]);
expect(rendered.registered.hostActions()).toEqual([]);
});
});
@@ -11,22 +11,27 @@ const remoteApiMock = vi.hoisted(() => ({
const isElectronMock = vi.hoisted(() => vi.fn(() => false));
const resolveRemoteHostIdMock = vi.hoisted(() => vi.fn());
vi.mock("@/main-axios", () => ({
authApi: authApiMock,
getRemoteGuacamoleApi: () => remoteApiMock,
vi.mock("@termix/plugin-sdk/ui", () => ({
isElectron: isElectronMock,
handleApiError: (error: unknown) => error,
}));
vi.mock("@/lib/remote-server-api", () => ({
resolveRemoteHostId: resolveRemoteHostIdMock,
}));
import {
setRemoteDesktopApp,
getGuacdStatus,
getGuacamoleConnectionId,
getGuacamoleTokenFromHost,
} from "../../src/frontend/guacamole-api";
// Mirrors app.apiFor: the remote server only for a remote origin in the
// desktop app, the ordinary client otherwise.
setRemoteDesktopApp({
apiFor: (origin) =>
(origin === "remote" && isElectronMock()
? remoteApiMock
: authApiMock) as never,
});
beforeEach(() => {
authApiMock.get.mockClear();
authApiMock.post.mockClear();
@@ -42,9 +47,7 @@ describe("guacamole API origin", () => {
await getGuacdStatus("remote");
await getGuacamoleTokenFromHost(9, "remote", "vnc");
expect(authApiMock.get).toHaveBeenCalledWith(
"/plugin-api/remote-desktop/status",
);
expect(authApiMock.get).toHaveBeenCalledWith("/status");
expect(authApiMock.post).toHaveBeenCalledOnce();
expect(remoteApiMock.get).not.toHaveBeenCalled();
expect(remoteApiMock.post).not.toHaveBeenCalled();
@@ -60,9 +63,7 @@ describe("guacamole API origin", () => {
expect(status.guacd.status).toBe("connected");
expect(token.token).toBe("remote-token");
expect(remoteApiMock.get).toHaveBeenCalledWith(
"/plugin-api/remote-desktop/status",
);
expect(remoteApiMock.get).toHaveBeenCalledWith("/status");
expect(remoteApiMock.post).toHaveBeenCalledOnce();
expect(authApiMock.get).not.toHaveBeenCalled();
expect(authApiMock.post).not.toHaveBeenCalled();
@@ -84,15 +85,12 @@ describe("guacamole API origin", () => {
"host-sync-id",
);
expect(remoteApiMock.post).toHaveBeenCalledWith(
"/plugin-api/remote-desktop/connect-host/41",
{
protocol: "rdp",
promptedUsername: "admin",
promptedPassword: "secret",
promptedDomain: "EXAMPLE",
},
);
expect(remoteApiMock.post).toHaveBeenCalledWith("/connect-host/41", {
protocol: "rdp",
promptedUsername: "admin",
promptedPassword: "secret",
promptedDomain: "EXAMPLE",
});
expect(resolveRemoteHostIdMock).toHaveBeenCalledWith("host-sync-id");
});
@@ -105,15 +103,12 @@ describe("guacamole API origin", () => {
domain: "",
});
expect(authApiMock.post).toHaveBeenCalledWith(
"/plugin-api/remote-desktop/connect-host/9",
{
protocol: "rdp",
promptedUsername: "local-admin",
promptedPassword: "secret",
promptedDomain: "",
},
);
expect(authApiMock.post).toHaveBeenCalledWith("/connect-host/9", {
protocol: "rdp",
promptedUsername: "local-admin",
promptedPassword: "secret",
promptedDomain: "",
});
});
// Support#1240: a host set to "This Device" is served by the embedded
@@ -125,12 +120,9 @@ describe("guacamole API origin", () => {
const token = await getGuacamoleTokenFromHost(9, "local", "rdp");
expect(token.token).toBe("local-token");
expect(authApiMock.post).toHaveBeenCalledWith(
"/plugin-api/remote-desktop/connect-host/9",
{
protocol: "rdp",
},
);
expect(authApiMock.post).toHaveBeenCalledWith("/connect-host/9", {
protocol: "rdp",
});
expect(remoteApiMock.post).not.toHaveBeenCalled();
expect(resolveRemoteHostIdMock).not.toHaveBeenCalled();
});
@@ -141,9 +133,7 @@ describe("guacamole API origin", () => {
const status = await getGuacdStatus("local");
expect(status.guacd.status).toBe("disconnected");
expect(authApiMock.get).toHaveBeenCalledWith(
"/plugin-api/remote-desktop/status",
);
expect(authApiMock.get).toHaveBeenCalledWith("/status");
expect(remoteApiMock.get).not.toHaveBeenCalled();
});
@@ -177,9 +167,8 @@ it("looks up the session on the backend that issued its token", async () => {
isElectronMock.mockReturnValue(true);
const signal = new AbortController().signal;
await getGuacamoleConnectionId("connect/id", "remote", signal);
expect(remoteApiMock.get).toHaveBeenCalledWith(
"/plugin-api/remote-desktop/connection/connect%2Fid",
{ signal },
);
expect(remoteApiMock.get).toHaveBeenCalledWith("/connection/connect%2Fid", {
signal,
});
expect(authApiMock.get).not.toHaveBeenCalled();
});
@@ -1,36 +0,0 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { buildRdpFile, validateNativeRdpOptions } =
require("../../../../electron/native-rdp.cjs") as {
buildRdpFile: (options: Record<string, unknown>) => string;
validateNativeRdpOptions: (options: Record<string, unknown>) => unknown;
};
describe("native Windows RDP", () => {
it("creates a credential-prompting RDP file without a password", () => {
const result = buildRdpFile({
host: "server.example.com",
port: 3390,
username: "alice",
domain: "EXAMPLE",
password: "must-not-be-written",
});
expect(result).toContain("full address:s:server.example.com:3390");
expect(result).toContain("username:s:EXAMPLE\\alice");
expect(result).toContain("prompt for credentials:i:1");
expect(result).not.toContain("must-not-be-written");
});
it("rejects unsafe host and port values", () => {
expect(() =>
validateNativeRdpOptions({ host: "bad\r\nvalue" }),
).not.toThrow();
expect(() => validateNativeRdpOptions({ host: "server/path" })).toThrow();
expect(() =>
validateNativeRdpOptions({ host: "server", port: 70000 }),
).toThrow();
});
});
@@ -1,41 +1,51 @@
import { describe, expect, it } from "vitest";
import { createQuickConnectHost } from "@/sidebar/quick-connect-host";
import { quickConnectGuacHost } from "../../src/frontend/quick-connect-guac-host";
// The shape core's Quick Connect builds for a plugin protocol: the switch and
// port in this plugin's host settings, the login on the protocol's fields.
function quickHost(protocol: "rdp" | "vnc", extra: Record<string, unknown>) {
return {
id: "quick-connect-1",
name: "admin@10.0.0.2",
ip: "10.0.0.2",
pluginSettings: {
"remote-desktop": {
[protocol === "rdp" ? "enableRdp" : "enableVnc"]: true,
[protocol === "rdp" ? "rdpPort" : "vncPort"]:
protocol === "rdp" ? 3390 : 5901,
},
},
...extra,
};
}
describe("quickConnectGuacHost", () => {
it("carries an RDP quick connect's address and credentials", () => {
const host = createQuickConnectHost({
ip: "10.0.0.2",
port: 3390,
username: "admin",
authType: "password",
password: "pw",
protocol: "rdp",
domain: "CORP",
});
expect(quickConnectGuacHost(host)).toMatchObject({
expect(
quickConnectGuacHost(
quickHost("rdp", {
rdpUser: "admin",
rdpPassword: "pw",
domain: "CORP",
}),
),
).toMatchObject({
ip: "10.0.0.2",
connectionType: "rdp",
rdpPort: 3390,
rdpUser: "admin",
rdpPassword: "pw",
port: 3390,
username: "admin",
password: "pw",
domain: "CORP",
});
});
it("puts a VNC quick connect's password on the VNC fields", () => {
const host = createQuickConnectHost({
ip: "10.0.0.3",
port: 5901,
username: "",
authType: "password",
password: "vncpw",
protocol: "vnc",
});
expect(quickConnectGuacHost(host)).toMatchObject({
it("takes a VNC quick connect's login from the VNC fields", () => {
expect(
quickConnectGuacHost(quickHost("vnc", { vncPassword: "vncpw" })),
).toMatchObject({
connectionType: "vnc",
vncPort: 5901,
vncPassword: "vncpw",
port: 5901,
password: "vncpw",
});
});
});
-6
View File
@@ -1,10 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"compilerOptions": {
"paths": {
"@/types/*": ["../../src/types/*"],
"@/*": ["../../src/ui/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}