mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
v2.0.0 (#621)
* Guacd, Docker-Compose, RDP (#475) * fix select edit host but not update view (#438) * fix: Checksum issue with chocolatey * fix: Remove homebrew old stuff * Add Korean translation (#439) Co-authored-by: 송준우 <2484@coreit.co.kr> * feat: Automate flatpak * fix: Add imagemagik to electron builder to resolve build error * fix: Build error with runtime repo flag * fix: Flatpak runtime error and install freedesktop ver warning * fix: Flatpak runtime error and install freedesktop ver warning * feat: Re-add homebrew cask and move scripts to backend * fix: No sandbox flag issue * fix: Change name for electron macos cask output * fix: Sandbox error with Linux * fix: Remove comming soon for app stores in readme * Adding Comment at the end of the public_key on the host on deploy (#440) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * -Add New Interface for Credential DB -Add Credential Name as a comment into the server authorized_key file --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Sudo auto fill password (#441) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Feature Sudo password auto-fill; * Fix locale json shema; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Added Italian Language; (#445) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Added Italian Language; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Auto collapse snippet folders (#448) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * feat: Add collapsable snippets (customizable in user profile) * Translations (#447) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Added Italian Language; * Fix translations; Removed duplicate keys, synchronised other languages using English as the source, translated added keys, fixed inaccurate translations. --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Remove PTY-level keepalive (#449) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Remove PTY-level keepalive to prevent unwanted terminal output; use SSH-level keepalive instead --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add Guacamole support for RDP, VNC, and Telnet connections - Implemented WebSocket support for Guacamole in Nginx configuration. - Added REST API endpoints for generating connection tokens and checking guacd status. - Created Guacamole server using guacamole-lite for handling connections. - Developed frontend components for testing RDP/VNC connections and displaying the remote session. - Updated package dependencies to include guacamole-common-js and guacamole-lite. - Enhanced logging for Guacamole operations. * feat: enhance Guacamole support with RDP and VNC connection settings and UI updates * feat: Seperate server stats and tunnel management (improved both UI's) then started initial docker implementation * fix: finalize adding docker to db * fix: merge syntax errors * feat: implement mouse coordinate adjustment based on scale factor in GuacamoleDisplay * feat: add TypeScript definitions for guacamole-common-js module * feat: enhance Mouse.State constructor to accept optional parameters and object destructuring * feat: Add support for RDP and VNC connections in SSH host management - Introduced connectionType field to differentiate between SSH, RDP, VNC, and Telnet in host data structures. - Updated backend routes to handle RDP/VNC specific fields: domain, security, and ignoreCert. - Enhanced the HostManagerEditor to include RDP/VNC specific settings and authentication options. - Implemented token retrieval for RDP/VNC connections using Guacamole API. - Updated UI components to reflect connection type changes and provide appropriate connection buttons. - Removed the GuacamoleTestDialog component as its functionality is integrated into the HostManagerEditor. - Adjusted the TopNavbar and Host components to accommodate new connection types and their respective actions. * feat: Enhance Guacamole integration with extended configuration options - Added detailed Guacamole configuration interface for RDP/VNC/Telnet connections, including display, audio, performance, and session settings. - Implemented logging for token requests and received options for better debugging. - Updated HostManagerEditor to support new Guacamole configuration fields with validation and default values. - Integrated Guacamole configuration parsing in HostManagerViewer and Host components. - Enhanced API requests to include extended Guacamole configuration parameters in the token request. - Refactored code to convert camelCase configuration keys to kebab-case for compatibility with Guacamole API. * feat: merge guacd into 2.0.0 and improve UI for host manager and made general bug fixes --------- Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: junu <bigdwarf_@naver.com> Co-authored-by: 송준우 <2484@coreit.co.kr> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: rename api routes and files * feat: improve guacd ui/backend * feat: improve guacd ui/backend * fix: state persistance issues causing refresh * feat: improge guacd connections, fixed telnet not opening, and improved general guacd integration * feat: continue improving integration also with bug fixes * Merge 2.0.0 with 2.0.0 that includes bug fixes (#620) * Guacd, Docker-Compose, RDP (#475) * fix select edit host but not update view (#438) * fix: Checksum issue with chocolatey * fix: Remove homebrew old stuff * Add Korean translation (#439) Co-authored-by: 송준우 <2484@coreit.co.kr> * feat: Automate flatpak * fix: Add imagemagik to electron builder to resolve build error * fix: Build error with runtime repo flag * fix: Flatpak runtime error and install freedesktop ver warning * fix: Flatpak runtime error and install freedesktop ver warning * feat: Re-add homebrew cask and move scripts to backend * fix: No sandbox flag issue * fix: Change name for electron macos cask output * fix: Sandbox error with Linux * fix: Remove comming soon for app stores in readme * Adding Comment at the end of the public_key on the host on deploy (#440) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * -Add New Interface for Credential DB -Add Credential Name as a comment into the server authorized_key file --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Sudo auto fill password (#441) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Feature Sudo password auto-fill; * Fix locale json shema; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Added Italian Language; (#445) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Added Italian Language; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Auto collapse snippet folders (#448) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * feat: Add collapsable snippets (customizable in user profile) * Translations (#447) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Added Italian Language; * Fix translations; Removed duplicate keys, synchronised other languages using English as the source, translated added keys, fixed inaccurate translations. --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Remove PTY-level keepalive (#449) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Remove PTY-level keepalive to prevent unwanted terminal output; use SSH-level keepalive instead --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add Guacamole support for RDP, VNC, and Telnet connections - Implemented WebSocket support for Guacamole in Nginx configuration. - Added REST API endpoints for generating connection tokens and checking guacd status. - Created Guacamole server using guacamole-lite for handling connections. - Developed frontend components for testing RDP/VNC connections and displaying the remote session. - Updated package dependencies to include guacamole-common-js and guacamole-lite. - Enhanced logging for Guacamole operations. * feat: enhance Guacamole support with RDP and VNC connection settings and UI updates * feat: Seperate server stats and tunnel management (improved both UI's) then started initial docker implementation * fix: finalize adding docker to db * fix: merge syntax errors * feat: implement mouse coordinate adjustment based on scale factor in GuacamoleDisplay * feat: add TypeScript definitions for guacamole-common-js module * feat: enhance Mouse.State constructor to accept optional parameters and object destructuring * feat: Add support for RDP and VNC connections in SSH host management - Introduced connectionType field to differentiate between SSH, RDP, VNC, and Telnet in host data structures. - Updated backend routes to handle RDP/VNC specific fields: domain, security, and ignoreCert. - Enhanced the HostManagerEditor to include RDP/VNC specific settings and authentication options. - Implemented token retrieval for RDP/VNC connections using Guacamole API. - Updated UI components to reflect connection type changes and provide appropriate connection buttons. - Removed the GuacamoleTestDialog component as its functionality is integrated into the HostManagerEditor. - Adjusted the TopNavbar and Host components to accommodate new connection types and their respective actions. * feat: Enhance Guacamole integration with extended configuration options - Added detailed Guacamole configuration interface for RDP/VNC/Telnet connections, including display, audio, performance, and session settings. - Implemented logging for token requests and received options for better debugging. - Updated HostManagerEditor to support new Guacamole configuration fields with validation and default values. - Integrated Guacamole configuration parsing in HostManagerViewer and Host components. - Enhanced API requests to include extended Guacamole configuration parameters in the token request. - Refactored code to convert camelCase configuration keys to kebab-case for compatibility with Guacamole API. * feat: merge guacd into 2.0.0 and improve UI for host manager and made general bug fixes --------- Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: junu <bigdwarf_@naver.com> Co-authored-by: 송준우 <2484@coreit.co.kr> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: rename api routes and files * feat: improve guacd ui/backend * feat: improve guacd ui/backend * fix: state persistance issues causing refresh * feat: improge guacd connections, fixed telnet not opening, and improved general guacd integration * feat: continue improving integration also with bug fixes --------- Co-authored-by: Wesley Reid <starhound@lostsouls.org> Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com> Co-authored-by: junu <bigdwarf_@naver.com> Co-authored-by: 송준우 <2484@coreit.co.kr> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: allow customizing guacd backened url * fix: ssh route mistmatching and guacamole url not changing * chore: increment ver * feat: change default to work with default compose, added splits creen support, updated readmes * fix: linux app not starting due to better sqlite isuses, improved copy/paste system so no context menu, added oidc remember me toggle, improved OS detection for sessions, flatpak invalid key, and sharing hosts with other users errors * fix: global settings not setting * chore: update compose * feat: improve the global status input * chore: cleanup files * chore: update export/improt with new host fields * fix: file manager and docker not loading properly --------- Co-authored-by: Wesley Reid <starhound@lostsouls.org> Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com> Co-authored-by: junu <bigdwarf_@naver.com> Co-authored-by: 송준우 <2484@coreit.co.kr> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -46,12 +46,13 @@ If you would like, you can support the project here!\
|
|||||||
|
|
||||||
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a multi-platform
|
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a multi-platform
|
||||||
solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal
|
solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal
|
||||||
access, SSH tunneling capabilities, remote file management, and many other tools. Termix is the perfect
|
access, remote desktop control (RDP, VNC, Telnet), SSH tunneling capabilities, remote SSH file management, and many other tools. Termix is the perfect
|
||||||
free and self-hosted alternative to Termius available for all platforms.
|
free and self-hosted alternative to Termius available for all platforms.
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
|
|
||||||
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components.
|
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components.
|
||||||
|
- **Remote Desktop Access** - RDP, VNC, and Telnet support over the browser with complete customization and split screening
|
||||||
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring and support for -l or -r connections
|
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring and support for -l or -r connections
|
||||||
- **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly with sudo support.
|
- **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly with sudo support.
|
||||||
- **Docker Management** - Start, stop, pause, remove containers. View container stats. Control container using docker exec terminal. It was not made to replace Portainer or Dockge but rather to simply manage your containers compared to creating them.
|
- **Docker Management** - Start, stop, pause, remove containers. View container stats. Control container using docker exec terminal. It was not made to replace Portainer or Dockge but rather to simply manage your containers compared to creating them.
|
||||||
@@ -105,7 +106,7 @@ Supported Devices:
|
|||||||
- APK
|
- APK
|
||||||
|
|
||||||
Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix on all platforms. Otherwise, view
|
Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix on all platforms. Otherwise, view
|
||||||
a sample Docker Compose file here:
|
a sample Docker Compose file here (you can omit guacd and the network if you don't plan on using remote desktop features):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -119,10 +120,27 @@ services:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
depends_on:
|
||||||
|
- guacd
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
|
guacd:
|
||||||
|
image: guacamole/guacd:latest
|
||||||
|
container_name: guacd
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4822:4822"
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
termix-data:
|
termix-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
termix-net:
|
||||||
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
# Sponsors
|
# Sponsors
|
||||||
@@ -153,7 +171,7 @@ channel, however, response times may be longer.
|
|||||||
|
|
||||||
# Screenshots
|
# Screenshots
|
||||||
|
|
||||||
[](https://youtu.be/sjKIqfCK0NY)
|
[](https://www.youtube.com/@TermixSSH/videos)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./repo-images/Image 1.png" width="400" alt="Termix Demo 1"/>
|
<img src="./repo-images/Image 1.png" width="400" alt="Termix Demo 1"/>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
termix:
|
||||||
|
image: ghcr.io/lukegus/termix:latest
|
||||||
|
container_name: termix
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- termix-data:/app/data
|
||||||
|
environment:
|
||||||
|
PORT: "8080"
|
||||||
|
depends_on:
|
||||||
|
- guacd
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
|
guacd:
|
||||||
|
image: guacamole/guacd:latest
|
||||||
|
container_name: guacd
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4822:4822"
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
termix-data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
termix-net:
|
||||||
|
driver: bridge
|
||||||
+79
-13
@@ -218,7 +218,7 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/quick-connect {
|
location /host/quick-connect {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
@@ -230,8 +230,8 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/ssh/opkssh-chooser(/.*)?$ {
|
location ~ ^/host/opkssh-chooser(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30001/ssh/opkssh-chooser$1$is_args$args;
|
proxy_pass http://127.0.0.1:30001/host/opkssh-chooser$1$is_args$args;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||||
@@ -245,8 +245,8 @@ http {
|
|||||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/ssh/opkssh-callback(/.*)?$ {
|
location ~ ^/host/opkssh-callback(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30001/ssh/opkssh-callback$1$is_args$args;
|
proxy_pass http://127.0.0.1:30001/host/opkssh-callback$1$is_args$args;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||||
@@ -260,7 +260,7 @@ http {
|
|||||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/ {
|
location /host/ {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
@@ -294,7 +294,41 @@ http {
|
|||||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/tunnel/ {
|
location ^~ /guacamole/websocket/ {
|
||||||
|
proxy_pass http://127.0.0.1:30008/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
|
||||||
|
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/guacamole(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:30001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /host/tunnel/ {
|
||||||
proxy_pass http://127.0.0.1:30003;
|
proxy_pass http://127.0.0.1:30003;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
@@ -303,7 +337,7 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/recent {
|
location /host/file_manager/recent {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
@@ -312,7 +346,7 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/pinned {
|
location /host/file_manager/pinned {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
@@ -321,7 +355,7 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/shortcuts {
|
location /host/file_manager/shortcuts {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
@@ -330,7 +364,7 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/sudo-password {
|
location /host/file_manager/sudo-password {
|
||||||
proxy_pass http://127.0.0.1:30004;
|
proxy_pass http://127.0.0.1:30004;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
@@ -339,7 +373,28 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/ssh/ {
|
location /ssh/file_manager/ {
|
||||||
|
client_max_body_size 5G;
|
||||||
|
client_body_timeout 300s;
|
||||||
|
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:30004;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /host/file_manager/ssh/ {
|
||||||
client_max_body_size 5G;
|
client_max_body_size 5G;
|
||||||
client_body_timeout 300s;
|
client_body_timeout 300s;
|
||||||
|
|
||||||
@@ -400,6 +455,15 @@ http {
|
|||||||
proxy_read_timeout 60s;
|
proxy_read_timeout 60s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~ ^/global-settings(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:30005;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location ~ ^/uptime(/.*)?$ {
|
location ~ ^/uptime(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30006;
|
proxy_pass http://127.0.0.1:30006;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -428,7 +492,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ^~ /docker/console/ {
|
location ^~ /docker/console/ {
|
||||||
proxy_pass http://127.0.0.1:30008/;
|
proxy_pass http://127.0.0.1:30009/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
@@ -439,6 +503,8 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
|
||||||
proxy_read_timeout 86400s;
|
proxy_read_timeout 86400s;
|
||||||
proxy_send_timeout 86400s;
|
proxy_send_timeout 86400s;
|
||||||
|
|||||||
+79
-13
@@ -207,7 +207,7 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/quick-connect {
|
location /host/quick-connect {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
@@ -219,8 +219,8 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/ssh/opkssh-chooser(/.*)?$ {
|
location ~ ^/host/opkssh-chooser(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30001/ssh/opkssh-chooser$1$is_args$args;
|
proxy_pass http://127.0.0.1:30001/host/opkssh-chooser$1$is_args$args;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||||
@@ -234,8 +234,8 @@ http {
|
|||||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/ssh/opkssh-callback(/.*)?$ {
|
location ~ ^/host/opkssh-callback(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30001/ssh/opkssh-callback$1$is_args$args;
|
proxy_pass http://127.0.0.1:30001/host/opkssh-callback$1$is_args$args;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
|
||||||
@@ -249,7 +249,7 @@ http {
|
|||||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/ {
|
location /host/ {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
@@ -283,7 +283,41 @@ http {
|
|||||||
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/tunnel/ {
|
location ^~ /guacamole/websocket/ {
|
||||||
|
proxy_pass http://127.0.0.1:30008/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
|
||||||
|
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/guacamole(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:30001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /host/tunnel/ {
|
||||||
proxy_pass http://127.0.0.1:30003;
|
proxy_pass http://127.0.0.1:30003;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
@@ -292,7 +326,7 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/recent {
|
location /host/file_manager/recent {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
@@ -301,7 +335,7 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/pinned {
|
location /host/file_manager/pinned {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
@@ -310,7 +344,7 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/shortcuts {
|
location /host/file_manager/shortcuts {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
@@ -319,7 +353,7 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/sudo-password {
|
location /host/file_manager/sudo-password {
|
||||||
proxy_pass http://127.0.0.1:30004;
|
proxy_pass http://127.0.0.1:30004;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
@@ -328,7 +362,28 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /ssh/file_manager/ssh/ {
|
location /ssh/file_manager/ {
|
||||||
|
client_max_body_size 5G;
|
||||||
|
client_body_timeout 300s;
|
||||||
|
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||||
|
|
||||||
|
proxy_pass http://127.0.0.1:30004;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /host/file_manager/ssh/ {
|
||||||
client_max_body_size 5G;
|
client_max_body_size 5G;
|
||||||
client_body_timeout 300s;
|
client_body_timeout 300s;
|
||||||
|
|
||||||
@@ -389,6 +444,15 @@ http {
|
|||||||
proxy_read_timeout 60s;
|
proxy_read_timeout 60s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~ ^/global-settings(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:30005;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location ~ ^/uptime(/.*)?$ {
|
location ~ ^/uptime(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30006;
|
proxy_pass http://127.0.0.1:30006;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -417,7 +481,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location ^~ /docker/console/ {
|
location ^~ /docker/console/ {
|
||||||
proxy_pass http://127.0.0.1:30008/;
|
proxy_pass http://127.0.0.1:30009/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
@@ -428,6 +492,8 @@ http {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
|
||||||
proxy_read_timeout 86400s;
|
proxy_read_timeout 86400s;
|
||||||
proxy_send_timeout 86400s;
|
proxy_send_timeout 86400s;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
},
|
},
|
||||||
"buildDependenciesFromSource": false,
|
"buildDependenciesFromSource": false,
|
||||||
"nodeGypRebuild": false,
|
"nodeGypRebuild": false,
|
||||||
"npmRebuild": false,
|
"npmRebuild": true,
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -99,8 +99,6 @@ function getBackendEntryPath() {
|
|||||||
if (isDev) {
|
if (isDev) {
|
||||||
return path.join(appRoot, "dist", "backend", "backend", "starter.js");
|
return path.join(appRoot, "dist", "backend", "backend", "starter.js");
|
||||||
}
|
}
|
||||||
// In production, asar is disabled (asar: false in electron-builder.json)
|
|
||||||
// so backend is directly in appRoot/dist
|
|
||||||
return path.join(appRoot, "dist", "backend", "backend", "starter.js");
|
return path.join(appRoot, "dist", "backend", "backend", "starter.js");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,13 +131,11 @@ function startBackendServer() {
|
|||||||
logToFile("Data directory:", dataDir);
|
logToFile("Data directory:", dataDir);
|
||||||
logToFile("Backend cwd:", appRoot);
|
logToFile("Backend cwd:", appRoot);
|
||||||
|
|
||||||
// Verify all required paths exist
|
|
||||||
logToFile("Checking paths...");
|
logToFile("Checking paths...");
|
||||||
logToFile(" entryPath exists:", fs.existsSync(entryPath));
|
logToFile(" entryPath exists:", fs.existsSync(entryPath));
|
||||||
logToFile(" dataDir exists:", fs.existsSync(dataDir));
|
logToFile(" dataDir exists:", fs.existsSync(dataDir));
|
||||||
logToFile(" appRoot exists:", fs.existsSync(appRoot));
|
logToFile(" appRoot exists:", fs.existsSync(appRoot));
|
||||||
|
|
||||||
// List contents of dist directory
|
|
||||||
const distPath = path.join(appRoot, "dist");
|
const distPath = path.join(appRoot, "dist");
|
||||||
if (fs.existsSync(distPath)) {
|
if (fs.existsSync(distPath)) {
|
||||||
logToFile(" dist directory contents:", fs.readdirSync(distPath));
|
logToFile(" dist directory contents:", fs.readdirSync(distPath));
|
||||||
@@ -214,7 +210,6 @@ function stopBackendServer() {
|
|||||||
|
|
||||||
console.log("Stopping embedded backend server...");
|
console.log("Stopping embedded backend server...");
|
||||||
|
|
||||||
// Use IPC for graceful shutdown (SIGTERM doesn't work on Windows)
|
|
||||||
try {
|
try {
|
||||||
backendProcess.send({ type: "shutdown" });
|
backendProcess.send({ type: "shutdown" });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -256,7 +251,6 @@ function createTray() {
|
|||||||
|
|
||||||
let trayIcon;
|
let trayIcon;
|
||||||
if (process.platform === "darwin") {
|
if (process.platform === "darwin") {
|
||||||
// macOS: use 16x16 Template image for menu bar
|
|
||||||
const iconPath = path.join(appRoot, "public", "icons", "16x16.png");
|
const iconPath = path.join(appRoot, "public", "icons", "16x16.png");
|
||||||
trayIcon = nativeImage.createFromPath(iconPath);
|
trayIcon = nativeImage.createFromPath(iconPath);
|
||||||
trayIcon.setTemplateImage(true);
|
trayIcon.setTemplateImage(true);
|
||||||
@@ -304,7 +298,6 @@ function createTray() {
|
|||||||
console.log("System tray created successfully");
|
console.log("System tray created successfully");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to create system tray:", err);
|
console.error("Failed to create system tray:", err);
|
||||||
// Tray is non-critical; app still works without it
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -871,7 +864,6 @@ app.whenReady().then(async () => {
|
|||||||
);
|
);
|
||||||
createMenu();
|
createMenu();
|
||||||
|
|
||||||
// Start embedded backend server (skip in dev mode, backend runs separately via npm run dev:backend)
|
|
||||||
if (!isDev) {
|
if (!isDev) {
|
||||||
const result = await startBackendServer();
|
const result = await startBackendServer();
|
||||||
logToFile("startBackendServer result:", result);
|
logToFile("startBackendServer result:", result);
|
||||||
@@ -887,7 +879,6 @@ app.whenReady().then(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
// If tray exists, keep backend alive; otherwise quit normally
|
|
||||||
if (!tray || tray.isDestroyed()) {
|
if (!tray || tray.isDestroyed()) {
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ Branch=stable
|
|||||||
Title=Termix - SSH Server Management Platform
|
Title=Termix - SSH Server Management Platform
|
||||||
IsRuntime=false
|
IsRuntime=false
|
||||||
Url=https://github.com/Termix-SSH/Termix/releases/download/VERSION_PLACEHOLDER/termix_linux_flatpak.flatpak
|
Url=https://github.com/Termix-SSH/Termix/releases/download/VERSION_PLACEHOLDER/termix_linux_flatpak.flatpak
|
||||||
GPGKey=
|
|
||||||
RuntimeRepo=https://flathub.org/repo/flathub.flatpakrepo
|
RuntimeRepo=https://flathub.org/repo/flathub.flatpakrepo
|
||||||
Comment=Web-based server management platform with SSH terminal, tunneling, and file editing
|
Comment=Web-based server management platform with SSH terminal, tunneling, and file editing
|
||||||
Description=Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides SSH terminal access, tunneling capabilities, and remote file management.
|
Description=Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides SSH terminal access, tunneling capabilities, and remote file management.
|
||||||
|
|||||||
Generated
+30
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "1.11.2",
|
"version": "2.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "1.11.2",
|
"version": "2.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/autocomplete": "^6.18.7",
|
"@codemirror/autocomplete": "^6.18.7",
|
||||||
"@codemirror/commands": "^6.3.3",
|
"@codemirror/commands": "^6.3.3",
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cookie-parser": "^1.4.9",
|
"@types/cookie-parser": "^1.4.9",
|
||||||
"@types/cytoscape": "^3.21.9",
|
"@types/cytoscape": "^3.21.9",
|
||||||
|
"@types/guacamole-common-js": "^1.5.5",
|
||||||
"@types/jszip": "^3.4.0",
|
"@types/jszip": "^3.4.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
@@ -62,6 +63,8 @@
|
|||||||
"dotenv": "^17.2.0",
|
"dotenv": "^17.2.0",
|
||||||
"drizzle-orm": "^0.44.3",
|
"drizzle-orm": "^0.44.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"guacamole-common-js": "^1.5.0",
|
||||||
|
"guacamole-lite": "^1.2.0",
|
||||||
"https-proxy-agent": "^7.0.6",
|
"https-proxy-agent": "^7.0.6",
|
||||||
"i18n-auto-translation": "^2.2.3",
|
"i18n-auto-translation": "^2.2.3",
|
||||||
"i18next": "^25.4.2",
|
"i18next": "^25.4.2",
|
||||||
@@ -5417,6 +5420,12 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/guacamole-common-js": {
|
||||||
|
"version": "1.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/guacamole-common-js/-/guacamole-common-js-1.5.5.tgz",
|
||||||
|
"integrity": "sha512-dqDYo/PhbOXFGSph23rFDRZRzXdKPXy/nsTkovFMb6P3iGrd0qGB5r5BXHmX5Cr/LK7L1TK9nYrTMbtPkhdXyg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/hast": {
|
"node_modules/@types/hast": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||||
@@ -10582,6 +10591,25 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/guacamole-common-js": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/guacamole-common-js/-/guacamole-common-js-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-zxztif3GGhKbg1RgOqwmqot8kXgv2HmHFg1EvWwd4q7UfEKvBcYZ0f+7G8HzvU+FUxF0Psqm9Kl5vCbgfrRgJg==",
|
||||||
|
"license": "Apache 2.0"
|
||||||
|
},
|
||||||
|
"node_modules/guacamole-lite": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/guacamole-lite/-/guacamole-lite-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-NeSYgbT5s5rxF0SE/kzJsV5Gg0IvnqoTOCbNIUMl23z1+SshaVfLExpxrEXSGTG0cdvY5lfZC1fOAepYriaXGg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"deep-extend": "^0.6.0",
|
||||||
|
"ws": "^8.15.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
|||||||
+4
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.11.2",
|
"version": "2.0.0",
|
||||||
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
|
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
|
||||||
"author": "Karmaa",
|
"author": "Karmaa",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cookie-parser": "^1.4.9",
|
"@types/cookie-parser": "^1.4.9",
|
||||||
"@types/cytoscape": "^3.21.9",
|
"@types/cytoscape": "^3.21.9",
|
||||||
|
"@types/guacamole-common-js": "^1.5.5",
|
||||||
"@types/jszip": "^3.4.0",
|
"@types/jszip": "^3.4.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
@@ -85,6 +86,8 @@
|
|||||||
"dotenv": "^17.2.0",
|
"dotenv": "^17.2.0",
|
||||||
"drizzle-orm": "^0.44.3",
|
"drizzle-orm": "^0.44.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"guacamole-common-js": "^1.5.0",
|
||||||
|
"guacamole-lite": "^1.2.0",
|
||||||
"https-proxy-agent": "^7.0.6",
|
"https-proxy-agent": "^7.0.6",
|
||||||
"i18n-auto-translation": "^2.2.3",
|
"i18n-auto-translation": "^2.2.3",
|
||||||
"i18next": "^25.4.2",
|
"i18next": "^25.4.2",
|
||||||
|
|||||||
+20
-2
@@ -49,6 +49,7 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
|
|||||||
# الميزات
|
# الميزات
|
||||||
|
|
||||||
- **الوصول إلى طرفية SSH** - طرفية كاملة الميزات مع دعم تقسيم الشاشة (حتى 4 لوحات) مع نظام علامات تبويب شبيه بالمتصفح. يتضمن دعم تخصيص الطرفية بما في ذلك السمات الشائعة والخطوط والمكونات الأخرى
|
- **الوصول إلى طرفية SSH** - طرفية كاملة الميزات مع دعم تقسيم الشاشة (حتى 4 لوحات) مع نظام علامات تبويب شبيه بالمتصفح. يتضمن دعم تخصيص الطرفية بما في ذلك السمات الشائعة والخطوط والمكونات الأخرى
|
||||||
|
- **الوصول إلى سطح المكتب البعيد** - دعم RDP و VNC و Telnet عبر المتصفح مع تخصيص كامل وتقسيم الشاشة
|
||||||
- **إدارة أنفاق SSH** - إنشاء وإدارة أنفاق SSH مع إعادة الاتصال التلقائي ومراقبة الحالة ودعم اتصالات -l أو -r
|
- **إدارة أنفاق SSH** - إنشاء وإدارة أنفاق SSH مع إعادة الاتصال التلقائي ومراقبة الحالة ودعم اتصالات -l أو -r
|
||||||
- **مدير الملفات عن بُعد** - إدارة الملفات مباشرة على الخوادم البعيدة مع دعم عرض وتحرير الكود والصور والصوت والفيديو. رفع وتنزيل وإعادة تسمية وحذف ونقل الملفات بسلاسة مع دعم sudo
|
- **مدير الملفات عن بُعد** - إدارة الملفات مباشرة على الخوادم البعيدة مع دعم عرض وتحرير الكود والصور والصوت والفيديو. رفع وتنزيل وإعادة تسمية وحذف ونقل الملفات بسلاسة مع دعم sudo
|
||||||
- **إدارة Docker** - تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها
|
- **إدارة Docker** - تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها
|
||||||
@@ -69,7 +70,7 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
|
|||||||
- **لوحة الأوامر** - اضغط مرتين على Shift الأيسر للوصول السريع إلى اتصالات SSH باستخدام لوحة المفاتيح
|
- **لوحة الأوامر** - اضغط مرتين على Shift الأيسر للوصول السريع إلى اتصالات SSH باستخدام لوحة المفاتيح
|
||||||
- **ميزات SSH الغنية** - دعم مضيفات القفز، Warpgate، الاتصالات المبنية على TOTP، SOCKS5، التحقق من مفتاح المضيف، الملء التلقائي لكلمة المرور، [OPKSSH](https://github.com/openpubkey/opkssh)، وغيرها
|
- **ميزات SSH الغنية** - دعم مضيفات القفز، Warpgate، الاتصالات المبنية على TOTP، SOCKS5، التحقق من مفتاح المضيف، الملء التلقائي لكلمة المرور، [OPKSSH](https://github.com/openpubkey/opkssh)، وغيرها
|
||||||
- **الرسم البياني للشبكة** - تخصيص لوحة التحكم لتصور مختبرك المنزلي بناءً على اتصالات SSH مع دعم الحالة
|
- **الرسم البياني للشبكة** - تخصيص لوحة التحكم لتصور مختبرك المنزلي بناءً على اتصالات SSH مع دعم الحالة
|
||||||
- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
|
- **علامات التبويب الدائمة** - تبقى جلسات SSH وعلامات التبويب مفتوحة عبر الأجهزة/التحديثات إذا تم تفعيلها في ملف تعريف المستخدم
|
||||||
|
|
||||||
# الميزات المخططة
|
# الميزات المخططة
|
||||||
|
|
||||||
@@ -115,10 +116,27 @@ services:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
depends_on:
|
||||||
|
- guacd
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
|
guacd:
|
||||||
|
image: guacamole/guacd:latest
|
||||||
|
container_name: guacd
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4822:4822"
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
termix-data:
|
termix-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
termix-net:
|
||||||
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
# الرعاة
|
# الرعاة
|
||||||
@@ -148,7 +166,7 @@ volumes:
|
|||||||
|
|
||||||
# لقطات الشاشة
|
# لقطات الشاشة
|
||||||
|
|
||||||
[](https://youtu.be/sjKIqfCK0NY)
|
[](https://www.youtube.com/@TermixSSH/videos)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||||
|
|||||||
+97
-79
@@ -1,97 +1,98 @@
|
|||||||
# 仓库统计
|
# 仓库统计
|
||||||
|
|
||||||
<p align="IPAenter">
|
<p align="center">
|
||||||
<a href="../README.md"><img srIPA="https://flagIPAdn.IPAom/us.svg" alt="English" width="24" height="16"> English</a> ·
|
<a href="../README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English</a> ·
|
||||||
<img srIPA="https://flagIPAdn.IPAom/IPAn.svg" alt="中文" width="24" height="16"> 中文 ·
|
<img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文 ·
|
||||||
<a href="README-JA.md"><img srIPA="https://flagIPAdn.IPAom/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
|
<a href="README-JA.md"><img src="https://flagcdn.com/jp.svg" alt="日本語" width="24" height="16"> 日本語</a> ·
|
||||||
<a href="README-KO.md"><img srIPA="https://flagIPAdn.IPAom/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
|
<a href="README-KO.md"><img src="https://flagcdn.com/kr.svg" alt="한국어" width="24" height="16"> 한국어</a> ·
|
||||||
<a href="README-FR.md"><img srIPA="https://flagIPAdn.IPAom/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
|
<a href="README-FR.md"><img src="https://flagcdn.com/fr.svg" alt="Français" width="24" height="16"> Français</a> ·
|
||||||
<a href="README-DE.md"><img srIPA="https://flagIPAdn.IPAom/de.svg" alt="DeutsIPAh" width="24" height="16"> DeutsIPAh</a> ·
|
<a href="README-DE.md"><img src="https://flagcdn.com/de.svg" alt="Deutsch" width="24" height="16"> Deutsch</a> ·
|
||||||
<a href="README-ES.md"><img srIPA="https://flagIPAdn.IPAom/es.svg" alt="Español" width="24" height="16"> Español</a> ·
|
<a href="README-ES.md"><img src="https://flagcdn.com/es.svg" alt="Español" width="24" height="16"> Español</a> ·
|
||||||
<a href="README-PT.md"><img srIPA="https://flagIPAdn.IPAom/br.svg" alt="Português" width="24" height="16"> Português</a> ·
|
<a href="README-PT.md"><img src="https://flagcdn.com/br.svg" alt="Português" width="24" height="16"> Português</a> ·
|
||||||
<a href="README-RU.md"><img srIPA="https://flagIPAdn.IPAom/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
|
<a href="README-RU.md"><img src="https://flagcdn.com/ru.svg" alt="Русский" width="24" height="16"> Русский</a> ·
|
||||||
<a href="README-AR.md"><img srIPA="https://flagIPAdn.IPAom/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
|
<a href="README-AR.md"><img src="https://flagcdn.com/sa.svg" alt="العربية" width="24" height="16"> العربية</a> ·
|
||||||
<a href="README-HI.md"><img srIPA="https://flagIPAdn.IPAom/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
|
<a href="README-HI.md"><img src="https://flagcdn.com/in.svg" alt="हिन्दी" width="24" height="16"> हिन्दी</a> ·
|
||||||
<a href="README-TR.md"><img srIPA="https://flagIPAdn.IPAom/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
|
<a href="README-TR.md"><img src="https://flagcdn.com/tr.svg" alt="Türkçe" width="24" height="16"> Türkçe</a> ·
|
||||||
<a href="README-VI.md"><img srIPA="https://flagIPAdn.IPAom/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
|
<a href="README-VI.md"><img src="https://flagcdn.com/vn.svg" alt="Tiếng Việt" width="24" height="16"> Tiếng Việt</a> ·
|
||||||
<a href="README-IT.md"><img srIPA="https://flagIPAdn.IPAom/it.svg" alt="Italiano" width="24" height="16"> Italiano</a>
|
<a href="README-IT.md"><img src="https://flagcdn.com/it.svg" alt="Italiano" width="24" height="16"> Italiano</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
<a href="https://disIPAord.gg/jVQGdvHDrf"><img alt="DisIPAord" srIPA="https://img.shields.io/disIPAord/1347374268253470720"></a>
|
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
|
||||||
|
|
||||||
<p align="IPAenter">
|
<p align="center">
|
||||||
<img srIPA="../repo-images/RepoOfTheDay.png" alt="Repo of the Day AIPAhievement" style="width: 300px; height: auto;">
|
<img src="../repo-images/RepoOfTheDay.png" alt="Repo of the Day Achievement" style="width: 300px; height: auto;">
|
||||||
<br>
|
<br>
|
||||||
<small style="IPAolor: #666;">2025年9月1日获得</small>
|
<small style="color: #666;">2025年9月1日获得</small>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
<p align="IPAenter">
|
<p align="center">
|
||||||
<a href="https://github.IPAom/Termix-SSH/Termix">
|
<a href="https://github.com/Termix-SSH/Termix">
|
||||||
<img alt="Termix Banner" srIPA=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
<img alt="Termix Banner" src=../repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
如果你愿意,可以在这里支持这个项目!\
|
如果你愿意,可以在这里支持这个项目!\
|
||||||
[](https://github.IPAom/sponsors/LukeGus)
|
[](https://github.com/sponsors/LukeGus)
|
||||||
|
|
||||||
# 概览
|
# 概览
|
||||||
|
|
||||||
<p align="IPAenter">
|
<p align="center">
|
||||||
<a href="https://github.IPAom/Termix-SSH/Termix">
|
<a href="https://github.com/Termix-SSH/Termix">
|
||||||
<img alt="Termix Banner" srIPA=../publiIPA/iIPAon.svg style="width: 250px; height: 250px;"> </a>
|
<img alt="Termix Banner" src=../public/icon.svg style="width: 250px; height: 250px;"> </a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个基于网页的解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix
|
Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个多平台解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix
|
||||||
提供 SSH 终端访问、SSH 隧道功能以及远程文件管理,还会陆续添加更多工具。Termix 是适用于所有平台的完美免费自托管 Termius 替代品。
|
提供 SSH 终端访问、远程桌面控制(RDP、VNC、Telnet)、SSH 隧道功能以及远程文件管理,还会陆续添加更多工具。Termix 是适用于所有平台的完美免费自托管 Termius 替代品。
|
||||||
|
|
||||||
# 功能
|
# 功能
|
||||||
|
|
||||||
- **SSH 终端访问** - 功能齐全的终端,具有分屏支持(最多 4 个面板)和类似浏览器的选项卡系统。包括对自定义终端的支持,包括常见终端主题、字体和其他组件
|
- **SSH 终端访问** - 功能齐全的终端,具有分屏支持(最多 4 个面板)和类似浏览器的选项卡系统。包括对自定义终端的支持,包括常见终端主题、字体和其他组件
|
||||||
- **SSH 隧道管理** - 创建和管理 SSH 隧道,具有自动重新连接和健康监控功能
|
- **远程桌面访问** - 通过浏览器支持 RDP、VNC 和 Telnet,具有完整的自定义和分屏功能
|
||||||
- **远程文件管理器** - 直接在远程服务器上管理文件,支持查看和编辑代码、图像、音频和视频。无缝上传、下载、重命名、删除和移动文件
|
- **SSH 隧道管理** - 创建和管理 SSH 隧道,具有自动重新连接和健康监控功能,支持 -l 或 -r 连接
|
||||||
- **DoIPAker 管理** - 启动、停止、暂停、删除容器。查看容器统计信息。使用 doIPAker exeIPA 终端控制容器。它不是用来替代 Portainer 或 DoIPAkge,而是用于简单管理你的容器而不是创建它们。
|
- **远程文件管理器** - 直接在远程服务器上管理文件,支持查看和编辑代码、图像、音频和视频。无缝上传、下载、重命名、删除和移动文件,支持 sudo
|
||||||
|
- **Docker 管理** - 启动、停止、暂停、删除容器。查看容器统计信息。使用 docker exec 终端控制容器。它不是用来替代 Portainer 或 Dockge,而是用于简单管理你的容器而不是创建它们
|
||||||
- **SSH 主机管理器** - 保存、组织和管理您的 SSH 连接,支持标签和文件夹,并轻松保存可重用的登录信息,同时能够自动部署 SSH 密钥
|
- **SSH 主机管理器** - 保存、组织和管理您的 SSH 连接,支持标签和文件夹,并轻松保存可重用的登录信息,同时能够自动部署 SSH 密钥
|
||||||
- **服务器统计** - 在任何 SSH 服务器上查看 IPAPU、内存和磁盘使用情况以及网络、正常运行时间和系统信息
|
- **服务器统计** - 在大多数 Linux 服务器上查看 CPU、内存和磁盘使用情况以及网络、正常运行时间、系统信息、防火墙、端口监控
|
||||||
- **仪表板** - 在仪表板上一目了然地查看服务器信息
|
- **仪表板** - 在仪表板上一目了然地查看服务器信息
|
||||||
- **RBAIPA** - 创建角色并在用户/角色之间共享主机
|
- **RBAC** - 创建角色并在用户/角色之间共享主机
|
||||||
- **用户认证** - 安全的用户管理,具有管理员控制以及 OIDIPA 和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDIPA/本地帐户链接在一起。
|
- **用户认证** - 安全的用户管理,具有管理员控制以及 OIDC 和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地帐户链接在一起
|
||||||
- **数据库加密** - 后端存储为加密的 SQLite 数据库文件。查看[文档](https://doIPAs.termix.site/seIPAurity)了解更多信息。
|
- **数据库加密** - 后端存储为加密的 SQLite 数据库文件。查看[文档](https://docs.termix.site/security)了解更多信息
|
||||||
- **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据
|
- **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据
|
||||||
- **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向
|
- **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向
|
||||||
- **现代用户界面** - 使用 ReaIPAt、Tailwind IPASS 和 ShadIPAn 构建的简洁的桌面/移动设备友好界面。可选择基于深色或浅色模式的用户界面。
|
- **现代用户界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁的桌面/移动设备友好界面。可选择基于深色或浅色模式的用户界面。使用 URL 路由以全屏方式打开任何连接
|
||||||
- **语言** - 内置支持约 30 种语言(由 [IPArowdin](https://doIPAs.termix.site/translations) 管理)
|
- **语言** - 内置支持约 30 种语言(由 [Crowdin](https://docs.termix.site/translations) 管理)
|
||||||
- **平台支持** - 可作为 Web 应用程序、桌面应用程序(Windows、Linux 和 maIPAOS)、PWA 以及适用于 iOS 和 Android 的专用移动/平板电脑应用程序。
|
- **平台支持** - 可作为 Web 应用程序、桌面应用程序(Windows、Linux 和 macOS)、PWA 以及适用于 iOS 和 Android 的专用移动/平板电脑应用程序
|
||||||
- **SSH 工具** - 创建可重用的命令片段,单击即可执行。在多个打开的终端上同时运行一个命令。
|
- **SSH 工具** - 创建可重用的命令片段,单击即可执行。在多个打开的终端上同时运行一个命令
|
||||||
- **命令历史** - 自动完成并查看以前运行的 SSH 命令
|
- **命令历史** - 自动完成并查看以前运行的 SSH 命令
|
||||||
- **快速连接** - 无需保存连接数据即可连接到服务器
|
- **快速连接** - 无需保存连接数据即可连接到服务器
|
||||||
- **命令面板** - 双击左 Shift 键可快速使用键盘访问 SSH 连接
|
- **命令面板** - 双击左 Shift 键可快速使用键盘访问 SSH 连接
|
||||||
- **SSH 功能丰富** - 支持跳板机、Warpgate、基于 TOTP 的连接、SOIPAKS5、主机密钥验证、密码自动填充、[OPKSSH](https://github.IPAom/openpubkey/opkssh)等。
|
- **SSH 功能丰富** - 支持跳板机、Warpgate、基于 TOTP 的连接、SOCKS5、主机密钥验证、密码自动填充、[OPKSSH](https://github.com/openpubkey/opkssh)等
|
||||||
- **网络图** - 自定义您的仪表板,根据您的 SSH 连接可视化您的家庭实验室,支持状态显示
|
- **网络图** - 自定义您的仪表板,根据您的 SSH 连接可视化您的家庭实验室,支持状态显示
|
||||||
- **持久标签页** - 如果在用户配置文件中启用,SSH 会话和标签页在设备/刷新后保持打开状态
|
- **持久标签页** - 如果在用户配置文件中启用,SSH 会话和标签页在设备/刷新后保持打开状态
|
||||||
|
|
||||||
# 计划功能
|
# 计划功能
|
||||||
|
|
||||||
查看 [项目](https://github.IPAom/orgs/Termix-SSH/projeIPAts/2) 了解所有计划功能。如果你想贡献代码,请参阅 [贡献指南](https://github.IPAom/Termix-SSH/Termix/blob/main/IPAONTRIBUTING.md)。
|
查看 [项目](https://github.com/orgs/Termix-SSH/projects/2) 了解所有计划功能。如果你想贡献代码,请参阅 [贡献指南](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)。
|
||||||
|
|
||||||
# 安装
|
# 安装
|
||||||
|
|
||||||
支持的设备:
|
支持的设备:
|
||||||
|
|
||||||
- 网站(任何平台上的任何现代浏览器,如 IPAhrome、Safari 和 Firefox)(包括 PWA 支持)
|
- 网站(任何平台上的任何现代浏览器,如 Chrome、Safari 和 Firefox)(包括 PWA 支持)
|
||||||
- Windows(x64/ia32)
|
- Windows(x64/ia32)
|
||||||
- 便携版
|
- 便携版
|
||||||
- MSI 安装程序
|
- MSI 安装程序
|
||||||
- IPAhoIPAolatey 软件包管理器
|
- Chocolatey 软件包管理器
|
||||||
- Linux(x64/ia32)
|
- Linux(x64/ia32)
|
||||||
- 便携版
|
- 便携版
|
||||||
- AUR
|
- AUR
|
||||||
- AppImage
|
- AppImage
|
||||||
- Deb
|
- Deb
|
||||||
- Flatpak
|
- Flatpak
|
||||||
- maIPAOS(x64/ia32 on v12.0+)
|
- macOS(x64/ia32 on v12.0+)
|
||||||
- Apple App Store
|
- Apple App Store
|
||||||
- DMG
|
- DMG
|
||||||
- Homebrew
|
- Homebrew
|
||||||
@@ -102,13 +103,13 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
|
|||||||
- Google Play 商店
|
- Google Play 商店
|
||||||
- APK
|
- APK
|
||||||
|
|
||||||
访问 Termix [文档](https://doIPAs.termix.site/install) 了解有关如何在所有平台上安装 Termix 的更多信息。或者,在此处查看示例 DoIPAker IPAompose 文件:
|
访问 Termix [文档](https://docs.termix.site/install) 了解有关如何在所有平台上安装 Termix 的更多信息。或者,在此处查看示例 Docker Compose 文件(如果不打算使用远程桌面功能,可以省略 guacd 和 network):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
serviIPAes:
|
services:
|
||||||
termix:
|
termix:
|
||||||
image: ghIPAr.io/lukegus/termix:latest
|
image: ghcr.io/lukegus/termix:latest
|
||||||
IPAontainer_name: termix
|
container_name: termix
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
@@ -116,74 +117,91 @@ serviIPAes:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
depends_on:
|
||||||
|
- guacd
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
|
guacd:
|
||||||
|
image: guacamole/guacd:latest
|
||||||
|
container_name: guacd
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4822:4822"
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
termix-data:
|
termix-data:
|
||||||
driver: loIPAal
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
termix-net:
|
||||||
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
# 赞助商
|
# 赞助商
|
||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<a href="https://www.digitaloIPAean.IPAom/">
|
<a href="https://www.digitalocean.com/">
|
||||||
<img srIPA="https://opensourIPAe.nyIPA3.IPAdn.digitaloIPAeanspaIPAes.IPAom/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50" alt="DigitalOIPAean">
|
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50" alt="DigitalOcean">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="https://IPArowdin.IPAom/">
|
<a href="https://crowdin.com/">
|
||||||
<img srIPA="https://support.IPArowdin.IPAom/assets/logos/IPAore-logo/svg/IPArowdin-IPAore-logo-IPADark.svg" height="50" alt="IPArowdin">
|
<img src="https://support.crowdin.com/assets/logos/core-logo/svg/crowdin-core-logo-cDark.svg" height="50" alt="Crowdin">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="https://www.blaIPAksmith.sh/">
|
<a href="https://www.blacksmith.sh/">
|
||||||
<img srIPA="https://IPAdn.prod.website-files.IPAom/681bfb0IPA9a4601bIPA6e288eIPA4/683IPAa9e2IPA5186757092611b8_e8IPAb22127df4da0811IPA4120a523722d2_logo-baIPAksmith-wordmark-light.svg" height="50" alt="IPArowdin">
|
<img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="50" alt="Blacksmith">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="https://www.IPAloudflare.IPAom/">
|
<a href="https://www.cloudflare.com/">
|
||||||
<img srIPA="https://sirv.sirv.IPAom/website/sIPAreenshots/IPAloudflare/IPAloudflare-logo.png?w=300" height="50" alt="IPArowdin">
|
<img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="50" alt="Cloudflare">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
# 支持
|
# 支持
|
||||||
|
|
||||||
如果你需要 Termix 的帮助或想要请求功能,请访问 [Issues](https://github.IPAom/Termix-SSH/Support/issues) 页面,登录并点击 `New Issue`。
|
如果你需要 Termix 的帮助或想要请求功能,请访问 [Issues](https://github.com/Termix-SSH/Support/issues) 页面,登录并点击 `New Issue`。
|
||||||
请尽可能详细地描述你的问题,最好使用英语。你也可以加入 [DisIPAord](https://disIPAord.gg/jVQGdvHDrf) 服务器并访问支持
|
请尽可能详细地描述你的问题,最好使用英语。你也可以加入 [Discord](https://discord.gg/jVQGdvHDrf) 服务器并访问支持
|
||||||
频道,但响应时间可能较长。
|
频道,但响应时间可能较长。
|
||||||
|
|
||||||
# 展示
|
# 展示
|
||||||
|
|
||||||
[](https://youtu.be/sjKIqfIPAK0NY)
|
[](https://www.youtube.com/@TermixSSH/videos)
|
||||||
|
|
||||||
<p align="IPAenter">
|
<p align="center">
|
||||||
<img srIPA="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||||
<img srIPA="../repo-images/Image%202.png" width="400" alt="Termix Demo 2"/>
|
<img src="../repo-images/Image%202.png" width="400" alt="Termix Demo 2"/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="IPAenter">
|
<p align="center">
|
||||||
<img srIPA="../repo-images/Image%203.png" width="400" alt="Termix Demo 3"/>
|
<img src="../repo-images/Image%203.png" width="400" alt="Termix Demo 3"/>
|
||||||
<img srIPA="../repo-images/Image%204.png" width="400" alt="Termix Demo 4"/>
|
<img src="../repo-images/Image%204.png" width="400" alt="Termix Demo 4"/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="IPAenter">
|
<p align="center">
|
||||||
<img srIPA="../repo-images/Image%205.png" width="400" alt="Termix Demo 5"/>
|
<img src="../repo-images/Image%205.png" width="400" alt="Termix Demo 5"/>
|
||||||
<img srIPA="../repo-images/Image%206.png" width="400" alt="Termix Demo 6"/>
|
<img src="../repo-images/Image%206.png" width="400" alt="Termix Demo 6"/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="IPAenter">
|
<p align="center">
|
||||||
<img srIPA="../repo-images/Image%207.png" width="400" alt="Termix Demo 7"/>
|
<img src="../repo-images/Image%207.png" width="400" alt="Termix Demo 7"/>
|
||||||
<img srIPA="../repo-images/Image%208.png" width="400" alt="Termix Demo 8"/>
|
<img src="../repo-images/Image%208.png" width="400" alt="Termix Demo 8"/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="IPAenter">
|
<p align="center">
|
||||||
<img srIPA="../repo-images/Image%209.png" width="400" alt="Termix Demo 9"/>
|
<img src="../repo-images/Image%209.png" width="400" alt="Termix Demo 9"/>
|
||||||
<img srIPA="../repo-images/Image%2010.png" width="400" alt="Termix Demo 10"/>
|
<img src="../repo-images/Image%2010.png" width="400" alt="Termix Demo 10"/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="IPAenter">
|
<p align="center">
|
||||||
<img srIPA="../repo-images/Image%2011.png" width="400" alt="Termix Demo 11"/>
|
<img src="../repo-images/Image%2011.png" width="400" alt="Termix Demo 11"/>
|
||||||
<img srIPA="../repo-images/Image%2012.png" width="400" alt="Termix Demo 12"/>
|
<img src="../repo-images/Image%2012.png" width="400" alt="Termix Demo 12"/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
某些视频和图像可能已过时或可能无法完美展示功能。
|
某些视频和图像可能已过时或可能无法完美展示功能。
|
||||||
|
|
||||||
# 许可证
|
# 许可证
|
||||||
|
|
||||||
根据 ApaIPAhe LiIPAense Version 2.0 发布。更多信息请参见 LIIPAENSE。
|
根据 Apache License Version 2.0 发布。更多信息请参见 LICENSE。
|
||||||
|
|||||||
+20
-2
@@ -49,6 +49,7 @@ Termix ist eine quelloffene, dauerhaft kostenlose, selbst gehostete All-in-One-S
|
|||||||
# Funktionen
|
# Funktionen
|
||||||
|
|
||||||
- **SSH-Terminalzugriff** - Voll ausgestattetes Terminal mit Split-Screen-Unterstützung (bis zu 4 Panels) mit einem browserähnlichen Tab-System. Enthält Unterstützung für die Anpassung des Terminals einschließlich gängiger Terminal-Themes, Schriftarten und anderer Komponenten
|
- **SSH-Terminalzugriff** - Voll ausgestattetes Terminal mit Split-Screen-Unterstützung (bis zu 4 Panels) mit einem browserähnlichen Tab-System. Enthält Unterstützung für die Anpassung des Terminals einschließlich gängiger Terminal-Themes, Schriftarten und anderer Komponenten
|
||||||
|
- **Remote-Desktop-Zugriff** - RDP-, VNC- und Telnet-Unterstützung über den Browser mit vollständiger Anpassung und Split-Screen
|
||||||
- **SSH-Tunnelverwaltung** - Erstellen und verwalten Sie SSH-Tunnel mit automatischer Wiederverbindung und Gesundheitsüberwachung sowie Unterstützung für -l oder -r Verbindungen
|
- **SSH-Tunnelverwaltung** - Erstellen und verwalten Sie SSH-Tunnel mit automatischer Wiederverbindung und Gesundheitsüberwachung sowie Unterstützung für -l oder -r Verbindungen
|
||||||
- **Remote-Dateimanager** - Verwalten Sie Dateien direkt auf Remote-Servern mit Unterstützung für das Anzeigen und Bearbeiten von Code, Bildern, Audio und Video. Laden Sie Dateien hoch, herunter, benennen Sie sie um, löschen oder verschieben Sie sie nahtlos mit Sudo-Unterstützung.
|
- **Remote-Dateimanager** - Verwalten Sie Dateien direkt auf Remote-Servern mit Unterstützung für das Anzeigen und Bearbeiten von Code, Bildern, Audio und Video. Laden Sie Dateien hoch, herunter, benennen Sie sie um, löschen oder verschieben Sie sie nahtlos mit Sudo-Unterstützung.
|
||||||
- **Docker-Verwaltung** - Container starten, stoppen, pausieren, entfernen. Container-Statistiken anzeigen. Container über Docker-Exec-Terminal steuern. Es wurde nicht entwickelt, um Portainer oder Dockge zu ersetzen, sondern um Ihre Container einfach zu verwalten, anstatt sie zu erstellen.
|
- **Docker-Verwaltung** - Container starten, stoppen, pausieren, entfernen. Container-Statistiken anzeigen. Container über Docker-Exec-Terminal steuern. Es wurde nicht entwickelt, um Portainer oder Dockge zu ersetzen, sondern um Ihre Container einfach zu verwalten, anstatt sie zu erstellen.
|
||||||
@@ -69,7 +70,7 @@ Termix ist eine quelloffene, dauerhaft kostenlose, selbst gehostete All-in-One-S
|
|||||||
- **Befehlspalette** - Doppeltippen Sie die linke Umschalttaste, um schnell auf SSH-Verbindungen mit Ihrer Tastatur zuzugreifen
|
- **Befehlspalette** - Doppeltippen Sie die linke Umschalttaste, um schnell auf SSH-Verbindungen mit Ihrer Tastatur zuzugreifen
|
||||||
- **SSH-Funktionsreich** - Unterstützt Jump-Hosts, Warpgate, TOTP-basierte Verbindungen, SOCKS5, Host-Key-Verifizierung, automatisches Ausfüllen von Passwörtern, [OPKSSH](https://github.com/openpubkey/opkssh) usw.
|
- **SSH-Funktionsreich** - Unterstützt Jump-Hosts, Warpgate, TOTP-basierte Verbindungen, SOCKS5, Host-Key-Verifizierung, automatisches Ausfüllen von Passwörtern, [OPKSSH](https://github.com/openpubkey/opkssh) usw.
|
||||||
- **Netzwerkgraph** - Passen Sie Ihr Dashboard an, um Ihr Homelab basierend auf Ihren SSH-Verbindungen mit Statusunterstützung zu visualisieren
|
- **Netzwerkgraph** - Passen Sie Ihr Dashboard an, um Ihr Homelab basierend auf Ihren SSH-Verbindungen mit Statusunterstützung zu visualisieren
|
||||||
- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
|
- **Persistente Tabs** - SSH-Sitzungen und Tabs bleiben über Geräte/Aktualisierungen hinweg offen, wenn im Benutzerprofil aktiviert
|
||||||
|
|
||||||
# Geplante Funktionen
|
# Geplante Funktionen
|
||||||
|
|
||||||
@@ -115,10 +116,27 @@ services:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
depends_on:
|
||||||
|
- guacd
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
|
guacd:
|
||||||
|
image: guacamole/guacd:latest
|
||||||
|
container_name: guacd
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4822:4822"
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
termix-data:
|
termix-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
termix-net:
|
||||||
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
# Sponsoren
|
# Sponsoren
|
||||||
@@ -148,7 +166,7 @@ Bitte beschreiben Sie Ihr Anliegen so detailliert wie möglich, vorzugsweise auf
|
|||||||
|
|
||||||
# Screenshots
|
# Screenshots
|
||||||
|
|
||||||
[](https://youtu.be/sjKIqfCK0NY)
|
[](https://www.youtube.com/@TermixSSH/videos)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||||
|
|||||||
+20
-2
@@ -49,6 +49,7 @@ Termix es una plataforma de gestión de servidores todo en uno, de código abier
|
|||||||
# Características
|
# Características
|
||||||
|
|
||||||
- **Acceso a Terminal SSH** - Terminal completo con soporte de pantalla dividida (hasta 4 paneles) con un sistema de pestañas similar al navegador. Incluye soporte para personalizar el terminal incluyendo temas comunes de terminal, fuentes y otros componentes
|
- **Acceso a Terminal SSH** - Terminal completo con soporte de pantalla dividida (hasta 4 paneles) con un sistema de pestañas similar al navegador. Incluye soporte para personalizar el terminal incluyendo temas comunes de terminal, fuentes y otros componentes
|
||||||
|
- **Acceso a Escritorio Remoto** - Soporte RDP, VNC y Telnet a través del navegador con personalización completa y pantalla dividida
|
||||||
- **Gestión de Túneles SSH** - Cree y gestione túneles SSH con reconexión automática y monitoreo de estado, con soporte para conexiones -l o -r
|
- **Gestión de Túneles SSH** - Cree y gestione túneles SSH con reconexión automática y monitoreo de estado, con soporte para conexiones -l o -r
|
||||||
- **Gestor Remoto de Archivos** - Gestione archivos directamente en servidores remotos con soporte para visualizar y editar código, imágenes, audio y video. Suba, descargue, renombre, elimine y mueva archivos sin problemas con soporte sudo.
|
- **Gestor Remoto de Archivos** - Gestione archivos directamente en servidores remotos con soporte para visualizar y editar código, imágenes, audio y video. Suba, descargue, renombre, elimine y mueva archivos sin problemas con soporte sudo.
|
||||||
- **Gestión de Docker** - Inicie, detenga, pause, elimine contenedores. Vea estadísticas de contenedores. Controle contenedores usando el terminal Docker Exec. No fue creado para reemplazar Portainer o Dockge, sino para simplemente gestionar sus contenedores en lugar de crearlos.
|
- **Gestión de Docker** - Inicie, detenga, pause, elimine contenedores. Vea estadísticas de contenedores. Controle contenedores usando el terminal Docker Exec. No fue creado para reemplazar Portainer o Dockge, sino para simplemente gestionar sus contenedores en lugar de crearlos.
|
||||||
@@ -69,7 +70,7 @@ Termix es una plataforma de gestión de servidores todo en uno, de código abier
|
|||||||
- **Paleta de Comandos** - Pulse dos veces la tecla Shift izquierda para acceder rápidamente a las conexiones SSH con su teclado
|
- **Paleta de Comandos** - Pulse dos veces la tecla Shift izquierda para acceder rápidamente a las conexiones SSH con su teclado
|
||||||
- **SSH Rico en Funciones** - Soporta jump hosts, Warpgate, conexiones basadas en TOTP, SOCKS5, verificación de clave de host, autocompletado de contraseñas, [OPKSSH](https://github.com/openpubkey/opkssh), etc.
|
- **SSH Rico en Funciones** - Soporta jump hosts, Warpgate, conexiones basadas en TOTP, SOCKS5, verificación de clave de host, autocompletado de contraseñas, [OPKSSH](https://github.com/openpubkey/opkssh), etc.
|
||||||
- **Gráfico de Red** - Personalice su Dashboard para visualizar su homelab basado en sus conexiones SSH con soporte de estado
|
- **Gráfico de Red** - Personalice su Dashboard para visualizar su homelab basado en sus conexiones SSH con soporte de estado
|
||||||
- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
|
- **Pestañas Persistentes** - Las sesiones SSH y pestañas permanecen abiertas entre dispositivos/actualizaciones si está habilitado en el perfil de usuario
|
||||||
|
|
||||||
# Características Planeadas
|
# Características Planeadas
|
||||||
|
|
||||||
@@ -115,10 +116,27 @@ services:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
depends_on:
|
||||||
|
- guacd
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
|
guacd:
|
||||||
|
image: guacamole/guacd:latest
|
||||||
|
container_name: guacd
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4822:4822"
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
termix-data:
|
termix-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
termix-net:
|
||||||
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
# Patrocinadores
|
# Patrocinadores
|
||||||
@@ -148,7 +166,7 @@ Por favor, sea lo más detallado posible en su reporte, preferiblemente escrito
|
|||||||
|
|
||||||
# Capturas de Pantalla
|
# Capturas de Pantalla
|
||||||
|
|
||||||
[](https://youtu.be/sjKIqfCK0NY)
|
[](https://www.youtube.com/@TermixSSH/videos)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||||
|
|||||||
+20
-2
@@ -49,6 +49,7 @@ Termix est une plateforme de gestion de serveurs tout-en-un, open source, à jam
|
|||||||
# Fonctionnalités
|
# Fonctionnalités
|
||||||
|
|
||||||
- **Accès terminal SSH** - Terminal complet avec support d'écran partagé (jusqu'à 4 panneaux) et un système d'onglets inspiré des navigateurs. Inclut la personnalisation du terminal avec des thèmes courants, des polices et d'autres composants
|
- **Accès terminal SSH** - Terminal complet avec support d'écran partagé (jusqu'à 4 panneaux) et un système d'onglets inspiré des navigateurs. Inclut la personnalisation du terminal avec des thèmes courants, des polices et d'autres composants
|
||||||
|
- **Accès Bureau à Distance** - Support RDP, VNC et Telnet via navigateur avec personnalisation complète et écran partagé
|
||||||
- **Gestion des tunnels SSH** - Créez et gérez des tunnels SSH avec reconnexion automatique et surveillance de l'état, avec support des connexions -l ou -r
|
- **Gestion des tunnels SSH** - Créez et gérez des tunnels SSH avec reconnexion automatique et surveillance de l'état, avec support des connexions -l ou -r
|
||||||
- **Gestionnaire de fichiers distant** - Gérez les fichiers directement sur les serveurs distants avec support de la visualisation et de l'édition de code, images, audio et vidéo. Téléversez, téléchargez, renommez, supprimez et déplacez des fichiers de manière fluide avec support sudo
|
- **Gestionnaire de fichiers distant** - Gérez les fichiers directement sur les serveurs distants avec support de la visualisation et de l'édition de code, images, audio et vidéo. Téléversez, téléchargez, renommez, supprimez et déplacez des fichiers de manière fluide avec support sudo
|
||||||
- **Gestion Docker** - Démarrez, arrêtez, mettez en pause, supprimez des conteneurs. Consultez les statistiques des conteneurs. Contrôlez les conteneurs via le terminal docker exec. Non conçu pour remplacer Portainer ou Dockge, mais plutôt pour gérer simplement vos conteneurs plutôt que de les créer
|
- **Gestion Docker** - Démarrez, arrêtez, mettez en pause, supprimez des conteneurs. Consultez les statistiques des conteneurs. Contrôlez les conteneurs via le terminal docker exec. Non conçu pour remplacer Portainer ou Dockge, mais plutôt pour gérer simplement vos conteneurs plutôt que de les créer
|
||||||
@@ -69,7 +70,7 @@ Termix est une plateforme de gestion de serveurs tout-en-un, open source, à jam
|
|||||||
- **Palette de commandes** - Appuyez deux fois sur Shift gauche pour accéder rapidement aux connexions SSH avec votre clavier
|
- **Palette de commandes** - Appuyez deux fois sur Shift gauche pour accéder rapidement aux connexions SSH avec votre clavier
|
||||||
- **SSH riche en fonctionnalités** - Support des hôtes de rebond, Warpgate, connexions basées sur TOTP, SOCKS5, vérification des clés d'hôte, remplissage automatique des mots de passe, [OPKSSH](https://github.com/openpubkey/opkssh), etc.
|
- **SSH riche en fonctionnalités** - Support des hôtes de rebond, Warpgate, connexions basées sur TOTP, SOCKS5, vérification des clés d'hôte, remplissage automatique des mots de passe, [OPKSSH](https://github.com/openpubkey/opkssh), etc.
|
||||||
- **Graphe réseau** - Personnalisez votre tableau de bord pour visualiser votre homelab basé sur vos connexions SSH avec support des statuts
|
- **Graphe réseau** - Personnalisez votre tableau de bord pour visualiser votre homelab basé sur vos connexions SSH avec support des statuts
|
||||||
- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
|
- **Onglets Persistants** - Les sessions SSH et les onglets restent ouverts sur tous les appareils/actualisations si activé dans le profil utilisateur
|
||||||
|
|
||||||
# Fonctionnalités prévues
|
# Fonctionnalités prévues
|
||||||
|
|
||||||
@@ -115,10 +116,27 @@ services:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
depends_on:
|
||||||
|
- guacd
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
|
guacd:
|
||||||
|
image: guacamole/guacd:latest
|
||||||
|
container_name: guacd
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4822:4822"
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
termix-data:
|
termix-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
termix-net:
|
||||||
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
# Sponsors
|
# Sponsors
|
||||||
@@ -147,7 +165,7 @@ Si vous avez besoin d'aide ou souhaitez demander une fonctionnalité pour Termix
|
|||||||
|
|
||||||
# Captures d'écran
|
# Captures d'écran
|
||||||
|
|
||||||
[](https://youtu.be/sjKIqfCK0NY)
|
[](https://www.youtube.com/@TermixSSH/videos)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||||
|
|||||||
+20
-2
@@ -49,6 +49,7 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
|
|||||||
# विशेषताएँ
|
# विशेषताएँ
|
||||||
|
|
||||||
- **SSH टर्मिनल एक्सेस** - ब्राउज़र जैसी टैब प्रणाली के साथ स्प्लिट-स्क्रीन सपोर्ट (4 पैनल तक) वाला पूर्ण-विशेषता वाला टर्मिनल। इसमें लोकप्रिय टर्मिनल थीम, फ़ॉन्ट और अन्य कंपोनेंट सहित टर्मिनल को कस्टमाइज़ करने का सपोर्ट शामिल है
|
- **SSH टर्मिनल एक्सेस** - ब्राउज़र जैसी टैब प्रणाली के साथ स्प्लिट-स्क्रीन सपोर्ट (4 पैनल तक) वाला पूर्ण-विशेषता वाला टर्मिनल। इसमें लोकप्रिय टर्मिनल थीम, फ़ॉन्ट और अन्य कंपोनेंट सहित टर्मिनल को कस्टमाइज़ करने का सपोर्ट शामिल है
|
||||||
|
- **रिमोट डेस्कटॉप एक्सेस** - ब्राउज़र पर RDP, VNC और Telnet सपोर्ट, पूर्ण कस्टमाइज़ेशन और स्प्लिट स्क्रीन के साथ
|
||||||
- **SSH टनल प्रबंधन** - ऑटोमैटिक रीकनेक्शन और हेल्थ मॉनिटरिंग के साथ SSH टनल बनाएँ और प्रबंधित करें, -l या -r कनेक्शन के सपोर्ट के साथ
|
- **SSH टनल प्रबंधन** - ऑटोमैटिक रीकनेक्शन और हेल्थ मॉनिटरिंग के साथ SSH टनल बनाएँ और प्रबंधित करें, -l या -r कनेक्शन के सपोर्ट के साथ
|
||||||
- **रिमोट फ़ाइल मैनेजर** - कोड, इमेज, ऑडियो और वीडियो देखने और संपादित करने के सपोर्ट के साथ रिमोट सर्वर पर सीधे फ़ाइलें प्रबंधित करें। sudo सपोर्ट के साथ फ़ाइलें अपलोड, डाउनलोड, रीनेम, डिलीट और मूव करें
|
- **रिमोट फ़ाइल मैनेजर** - कोड, इमेज, ऑडियो और वीडियो देखने और संपादित करने के सपोर्ट के साथ रिमोट सर्वर पर सीधे फ़ाइलें प्रबंधित करें। sudo सपोर्ट के साथ फ़ाइलें अपलोड, डाउनलोड, रीनेम, डिलीट और मूव करें
|
||||||
- **Docker प्रबंधन** - कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है
|
- **Docker प्रबंधन** - कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है
|
||||||
@@ -69,7 +70,7 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
|
|||||||
- **कमांड पैलेट** - अपने कीबोर्ड से SSH कनेक्शन तक त्वरित पहुँच के लिए बाएँ Shift को दो बार टैप करें
|
- **कमांड पैलेट** - अपने कीबोर्ड से SSH कनेक्शन तक त्वरित पहुँच के लिए बाएँ Shift को दो बार टैप करें
|
||||||
- **SSH सुविधाओं से भरपूर** - जम्प होस्ट, Warpgate, TOTP आधारित कनेक्शन, SOCKS5, होस्ट की वेरिफ़िकेशन, पासवर्ड ऑटोफ़िल, [OPKSSH](https://github.com/openpubkey/opkssh) आदि का सपोर्ट
|
- **SSH सुविधाओं से भरपूर** - जम्प होस्ट, Warpgate, TOTP आधारित कनेक्शन, SOCKS5, होस्ट की वेरिफ़िकेशन, पासवर्ड ऑटोफ़िल, [OPKSSH](https://github.com/openpubkey/opkssh) आदि का सपोर्ट
|
||||||
- **नेटवर्क ग्राफ़** - स्थिति सपोर्ट के साथ अपने SSH कनेक्शन के आधार पर अपने होमलैब को विज़ुअलाइज़ करने के लिए अपना डैशबोर्ड कस्टमाइज़ करें
|
- **नेटवर्क ग्राफ़** - स्थिति सपोर्ट के साथ अपने SSH कनेक्शन के आधार पर अपने होमलैब को विज़ुअलाइज़ करने के लिए अपना डैशबोर्ड कस्टमाइज़ करें
|
||||||
- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
|
- **परसिस्टेंट टैब** - उपयोगकर्ता प्रोफ़ाइल में सक्षम होने पर SSH सेशन और टैब डिवाइस/रीफ्रेश के पार खुले रहते हैं
|
||||||
|
|
||||||
# नियोजित विशेषताएँ
|
# नियोजित विशेषताएँ
|
||||||
|
|
||||||
@@ -115,10 +116,27 @@ services:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
depends_on:
|
||||||
|
- guacd
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
|
guacd:
|
||||||
|
image: guacamole/guacd:latest
|
||||||
|
container_name: guacd
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4822:4822"
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
termix-data:
|
termix-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
termix-net:
|
||||||
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
# प्रायोजक
|
# प्रायोजक
|
||||||
@@ -148,7 +166,7 @@ volumes:
|
|||||||
|
|
||||||
# स्क्रीनशॉट
|
# स्क्रीनशॉट
|
||||||
|
|
||||||
[](https://youtu.be/sjKIqfCK0NY)
|
[](https://www.youtube.com/@TermixSSH/videos)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||||
|
|||||||
+21
-3
@@ -48,7 +48,8 @@ Termix è una piattaforma di gestione server tutto-in-uno, open-source, per semp
|
|||||||
|
|
||||||
# Funzionalità
|
# Funzionalità
|
||||||
|
|
||||||
- **Accesso Terminale SSH** - Terminale completo con supporto schermo divIPA (fino a 4 pannelli) con un sistema di schede in stile browser. Include il supporto per la personalizzazione del terminale, inclusi temi, font e altri componenti comuni
|
- **Accesso Terminale SSH** - Terminale completo con supporto schermo diviso (fino a 4 pannelli) con un sistema di schede in stile browser. Include il supporto per la personalizzazione del terminale, inclusi temi, font e altri componenti comuni
|
||||||
|
- **Accesso Desktop Remoto** - Supporto RDP, VNC e Telnet tramite browser con personalizzazione completa e schermo diviso
|
||||||
- **Gestione Tunnel SSH** - Crea e gestisci tunnel SSH con riconnessione automatica e monitoraggio dello stato, con supporto per connessioni -l o -r
|
- **Gestione Tunnel SSH** - Crea e gestisci tunnel SSH con riconnessione automatica e monitoraggio dello stato, con supporto per connessioni -l o -r
|
||||||
- **Gestore File Remoto** - Gestisci i file direttamente sui server remoti con supporto per la visualizzazione e la modifica di codice, immagini, audio e video. Carica, scarica, rinomina, elimina e sposta file senza problemi con supporto sudo.
|
- **Gestore File Remoto** - Gestisci i file direttamente sui server remoti con supporto per la visualizzazione e la modifica di codice, immagini, audio e video. Carica, scarica, rinomina, elimina e sposta file senza problemi con supporto sudo.
|
||||||
- **Gestione Docker** - Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei container. Controlla i container tramite terminale docker exec. Non è stato creato per sostituire Portainer o Dockge, ma piuttosto per gestire semplicemente i tuoi container rispetto alla loro creazione.
|
- **Gestione Docker** - Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei container. Controlla i container tramite terminale docker exec. Non è stato creato per sostituire Portainer o Dockge, ma piuttosto per gestire semplicemente i tuoi container rispetto alla loro creazione.
|
||||||
@@ -69,7 +70,7 @@ Termix è una piattaforma di gestione server tutto-in-uno, open-source, per semp
|
|||||||
- **Palette Comandi** - Premi due volte shift sinistro per accedere rapidamente alle connessioni SSH con la tastiera
|
- **Palette Comandi** - Premi due volte shift sinistro per accedere rapidamente alle connessioni SSH con la tastiera
|
||||||
- **SSH Ricco di Funzionalità** - Supporta jump host, Warpgate, connessioni basate su TOTP, SOCKS5, verifica chiave host, compilazione automatica password, [OPKSSH](https://github.com/openpubkey/opkssh), ecc.
|
- **SSH Ricco di Funzionalità** - Supporta jump host, Warpgate, connessioni basate su TOTP, SOCKS5, verifica chiave host, compilazione automatica password, [OPKSSH](https://github.com/openpubkey/opkssh), ecc.
|
||||||
- **Grafico di Rete** - Personalizza la tua Dashboard per visualizzare il tuo homelab basato sulle connessioni SSH con supporto dello stato
|
- **Grafico di Rete** - Personalizza la tua Dashboard per visualizzare il tuo homelab basato sulle connessioni SSH con supporto dello stato
|
||||||
- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
|
- **Schede Persistenti** - Le sessioni SSH e le schede rimangono aperte tra dispositivi/aggiornamenti se abilitato nel profilo utente
|
||||||
|
|
||||||
# Funzionalità Pianificate
|
# Funzionalità Pianificate
|
||||||
|
|
||||||
@@ -115,10 +116,27 @@ services:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
depends_on:
|
||||||
|
- guacd
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
|
guacd:
|
||||||
|
image: guacamole/guacd:latest
|
||||||
|
container_name: guacd
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4822:4822"
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
termix-data:
|
termix-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
termix-net:
|
||||||
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
# Sponsor
|
# Sponsor
|
||||||
@@ -148,7 +166,7 @@ Per favore, sii il più dettagliato possibile nella tua segnalazione, preferibil
|
|||||||
|
|
||||||
# Screenshot
|
# Screenshot
|
||||||
|
|
||||||
[](https://youtu.be/sjKIqfCK0NY)
|
[](https://www.youtube.com/@TermixSSH/videos)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||||
|
|||||||
+20
-2
@@ -49,6 +49,7 @@ Termixは、オープンソースで永久無料のセルフホスト型オー
|
|||||||
# 機能
|
# 機能
|
||||||
|
|
||||||
- **SSHターミナルアクセス** - ブラウザ風タブシステムによる分割画面対応(最大4パネル)のフル機能ターミナル。一般的なターミナルテーマ、フォント、その他のコンポーネントを含むターミナルカスタマイズに対応
|
- **SSHターミナルアクセス** - ブラウザ風タブシステムによる分割画面対応(最大4パネル)のフル機能ターミナル。一般的なターミナルテーマ、フォント、その他のコンポーネントを含むターミナルカスタマイズに対応
|
||||||
|
- **リモートデスクトップアクセス** - ブラウザ上でRDP、VNC、Telnetをサポート、完全なカスタマイズと分割画面に対応
|
||||||
- **SSHトンネル管理** - 自動再接続とヘルスモニタリング機能を備えたSSHトンネルの作成・管理、-l または -r 接続に対応
|
- **SSHトンネル管理** - 自動再接続とヘルスモニタリング機能を備えたSSHトンネルの作成・管理、-l または -r 接続に対応
|
||||||
- **リモートファイルマネージャー** - コード、画像、音声、動画の表示・編集に対応し、リモートサーバー上のファイルを直接管理。sudo対応でファイルのアップロード、ダウンロード、名前変更、削除、移動をシームレスに実行
|
- **リモートファイルマネージャー** - コード、画像、音声、動画の表示・編集に対応し、リモートサーバー上のファイルを直接管理。sudo対応でファイルのアップロード、ダウンロード、名前変更、削除、移動をシームレスに実行
|
||||||
- **Docker管理** - コンテナの起動、停止、一時停止、削除。コンテナの統計情報を表示。docker execターミナルでコンテナを操作。PortainerやDockgeの代替ではなく、コンテナの作成よりも簡易管理を目的としています
|
- **Docker管理** - コンテナの起動、停止、一時停止、削除。コンテナの統計情報を表示。docker execターミナルでコンテナを操作。PortainerやDockgeの代替ではなく、コンテナの作成よりも簡易管理を目的としています
|
||||||
@@ -69,7 +70,7 @@ Termixは、オープンソースで永久無料のセルフホスト型オー
|
|||||||
- **コマンドパレット** - 左Shiftキーを2回押すことで、キーボードからSSH接続に素早くアクセス
|
- **コマンドパレット** - 左Shiftキーを2回押すことで、キーボードからSSH接続に素早くアクセス
|
||||||
- **SSH機能充実** - ジャンプホスト、Warpgate、TOTPベースの接続、SOCKS5、ホストキー検証、パスワード自動入力、[OPKSSH](https://github.com/openpubkey/opkssh)などに対応
|
- **SSH機能充実** - ジャンプホスト、Warpgate、TOTPベースの接続、SOCKS5、ホストキー検証、パスワード自動入力、[OPKSSH](https://github.com/openpubkey/opkssh)などに対応
|
||||||
- **ネットワークグラフ** - ダッシュボードをカスタマイズして、SSH接続に基づくホームラボのネットワークをステータス表示付きで可視化
|
- **ネットワークグラフ** - ダッシュボードをカスタマイズして、SSH接続に基づくホームラボのネットワークをステータス表示付きで可視化
|
||||||
- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
|
- **永続タブ** - ユーザープロフィールで有効にすると、SSHセッションとタブがデバイス/更新をまたいで開いたまま保持されます
|
||||||
|
|
||||||
# 予定されている機能
|
# 予定されている機能
|
||||||
|
|
||||||
@@ -115,10 +116,27 @@ services:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
depends_on:
|
||||||
|
- guacd
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
|
guacd:
|
||||||
|
image: guacamole/guacd:latest
|
||||||
|
container_name: guacd
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4822:4822"
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
termix-data:
|
termix-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
termix-net:
|
||||||
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
# スポンサー
|
# スポンサー
|
||||||
@@ -147,7 +165,7 @@ Termixに関するヘルプや機能リクエストが必要な場合は、[Issu
|
|||||||
|
|
||||||
# スクリーンショット
|
# スクリーンショット
|
||||||
|
|
||||||
[](https://youtu.be/sjKIqfCK0NY)
|
[](https://www.youtube.com/@TermixSSH/videos)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||||
|
|||||||
+20
-2
@@ -49,6 +49,7 @@ Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버
|
|||||||
# 기능
|
# 기능
|
||||||
|
|
||||||
- **SSH 터미널 접속** - 브라우저 스타일 탭 시스템과 분할 화면 지원(최대 4개 패널)을 갖춘 완전한 기능의 터미널. 일반 터미널 테마, 글꼴 및 기타 구성 요소를 포함한 터미널 사용자 정의 지원
|
- **SSH 터미널 접속** - 브라우저 스타일 탭 시스템과 분할 화면 지원(최대 4개 패널)을 갖춘 완전한 기능의 터미널. 일반 터미널 테마, 글꼴 및 기타 구성 요소를 포함한 터미널 사용자 정의 지원
|
||||||
|
- **원격 데스크톱 접속** - 완전한 사용자 정의와 분할 화면을 지원하는 브라우저 기반 RDP, VNC, Telnet 지원
|
||||||
- **SSH 터널 관리** - 자동 재연결 및 상태 모니터링 기능을 갖춘 SSH 터널 생성 및 관리, -l 또는 -r 연결 지원
|
- **SSH 터널 관리** - 자동 재연결 및 상태 모니터링 기능을 갖춘 SSH 터널 생성 및 관리, -l 또는 -r 연결 지원
|
||||||
- **원격 파일 관리자** - 코드, 이미지, 오디오, 비디오의 보기 및 편집을 지원하여 원격 서버에서 파일을 직접 관리. sudo 지원으로 파일 업로드, 다운로드, 이름 변경, 삭제, 이동을 원활하게 수행
|
- **원격 파일 관리자** - 코드, 이미지, 오디오, 비디오의 보기 및 편집을 지원하여 원격 서버에서 파일을 직접 관리. sudo 지원으로 파일 업로드, 다운로드, 이름 변경, 삭제, 이동을 원활하게 수행
|
||||||
- **Docker 관리** - 컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다
|
- **Docker 관리** - 컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다
|
||||||
@@ -69,7 +70,7 @@ Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버
|
|||||||
- **명령어 팔레트** - 왼쪽 Shift 키를 두 번 눌러 키보드로 SSH 연결에 빠르게 접근
|
- **명령어 팔레트** - 왼쪽 Shift 키를 두 번 눌러 키보드로 SSH 연결에 빠르게 접근
|
||||||
- **풍부한 SSH 기능** - 점프 호스트, Warpgate, TOTP 기반 연결, SOCKS5, 호스트 키 검증, 비밀번호 자동 입력, [OPKSSH](https://github.com/openpubkey/opkssh) 등 지원
|
- **풍부한 SSH 기능** - 점프 호스트, Warpgate, TOTP 기반 연결, SOCKS5, 호스트 키 검증, 비밀번호 자동 입력, [OPKSSH](https://github.com/openpubkey/opkssh) 등 지원
|
||||||
- **네트워크 그래프** - 대시보드를 사용자 정의하여 SSH 연결 기반의 홈랩 네트워크를 상태 표시와 함께 시각화
|
- **네트워크 그래프** - 대시보드를 사용자 정의하여 SSH 연결 기반의 홈랩 네트워크를 상태 표시와 함께 시각화
|
||||||
- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
|
- **지속 탭** - 사용자 프로필에서 활성화된 경우 SSH 세션 및 탭이 기기/새로 고침 간에 열린 상태 유지
|
||||||
|
|
||||||
# 계획된 기능
|
# 계획된 기능
|
||||||
|
|
||||||
@@ -115,10 +116,27 @@ services:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
depends_on:
|
||||||
|
- guacd
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
|
guacd:
|
||||||
|
image: guacamole/guacd:latest
|
||||||
|
container_name: guacd
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4822:4822"
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
termix-data:
|
termix-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
termix-net:
|
||||||
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
# 스폰서
|
# 스폰서
|
||||||
@@ -147,7 +165,7 @@ Termix에 대한 도움이 필요하거나 기능을 요청하려면 [Issues](ht
|
|||||||
|
|
||||||
# 스크린샷
|
# 스크린샷
|
||||||
|
|
||||||
[](https://youtu.be/sjKIqfCK0NY)
|
[](https://www.youtube.com/@TermixSSH/videos)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||||
|
|||||||
+20
-2
@@ -49,6 +49,7 @@ Termix é uma plataforma de gerenciamento de servidores tudo-em-um, de código a
|
|||||||
# Funcionalidades
|
# Funcionalidades
|
||||||
|
|
||||||
- **Acesso ao Terminal SSH** - Terminal completo com suporte a tela dividida (até 4 painéis) com um sistema de abas similar ao navegador. Inclui suporte para personalização do terminal incluindo temas comuns de terminal, fontes e outros componentes
|
- **Acesso ao Terminal SSH** - Terminal completo com suporte a tela dividida (até 4 painéis) com um sistema de abas similar ao navegador. Inclui suporte para personalização do terminal incluindo temas comuns de terminal, fontes e outros componentes
|
||||||
|
- **Acesso à Área de Trabalho Remota** - Suporte a RDP, VNC e Telnet pelo navegador com personalização completa e tela dividida
|
||||||
- **Gerenciamento de Túneis SSH** - Crie e gerencie túneis SSH com reconexão automática e monitoramento de saúde, com suporte para conexões -l ou -r
|
- **Gerenciamento de Túneis SSH** - Crie e gerencie túneis SSH com reconexão automática e monitoramento de saúde, com suporte para conexões -l ou -r
|
||||||
- **Gerenciador Remoto de Arquivos** - Gerencie arquivos diretamente em servidores remotos com suporte para visualizar e editar código, imagens, áudio e vídeo. Faça upload, download, renomeie, exclua e mova arquivos facilmente com suporte sudo.
|
- **Gerenciador Remoto de Arquivos** - Gerencie arquivos diretamente em servidores remotos com suporte para visualizar e editar código, imagens, áudio e vídeo. Faça upload, download, renomeie, exclua e mova arquivos facilmente com suporte sudo.
|
||||||
- **Gerenciamento de Docker** - Inicie, pare, pause, remova contêineres. Visualize estatísticas de contêineres. Controle contêineres usando o terminal Docker Exec. Não foi feito para substituir Portainer ou Dockge, mas sim para simplesmente gerenciar seus contêineres em vez de criá-los.
|
- **Gerenciamento de Docker** - Inicie, pare, pause, remova contêineres. Visualize estatísticas de contêineres. Controle contêineres usando o terminal Docker Exec. Não foi feito para substituir Portainer ou Dockge, mas sim para simplesmente gerenciar seus contêineres em vez de criá-los.
|
||||||
@@ -69,7 +70,7 @@ Termix é uma plataforma de gerenciamento de servidores tudo-em-um, de código a
|
|||||||
- **Paleta de Comandos** - Pressione duas vezes a tecla Shift esquerda para acessar rapidamente as conexões SSH com seu teclado
|
- **Paleta de Comandos** - Pressione duas vezes a tecla Shift esquerda para acessar rapidamente as conexões SSH com seu teclado
|
||||||
- **SSH Rico em Funcionalidades** - Suporta jump hosts, Warpgate, conexões baseadas em TOTP, SOCKS5, verificação de chave do host, preenchimento automático de senhas, [OPKSSH](https://github.com/openpubkey/opkssh), etc.
|
- **SSH Rico em Funcionalidades** - Suporta jump hosts, Warpgate, conexões baseadas em TOTP, SOCKS5, verificação de chave do host, preenchimento automático de senhas, [OPKSSH](https://github.com/openpubkey/opkssh), etc.
|
||||||
- **Gráfico de Rede** - Personalize seu Dashboard para visualizar seu homelab baseado nas suas conexões SSH com suporte de status
|
- **Gráfico de Rede** - Personalize seu Dashboard para visualizar seu homelab baseado nas suas conexões SSH com suporte de status
|
||||||
- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
|
- **Abas Persistentes** - Sessões SSH e abas permanecem abertas entre dispositivos/atualizações se habilitado no perfil do usuário
|
||||||
|
|
||||||
# Funcionalidades Planejadas
|
# Funcionalidades Planejadas
|
||||||
|
|
||||||
@@ -115,10 +116,27 @@ services:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
depends_on:
|
||||||
|
- guacd
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
|
guacd:
|
||||||
|
image: guacamole/guacd:latest
|
||||||
|
container_name: guacd
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4822:4822"
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
termix-data:
|
termix-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
termix-net:
|
||||||
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
# Patrocinadores
|
# Patrocinadores
|
||||||
@@ -148,7 +166,7 @@ Por favor, seja o mais detalhado possível no seu relato, preferencialmente escr
|
|||||||
|
|
||||||
# Capturas de Tela
|
# Capturas de Tela
|
||||||
|
|
||||||
[](https://youtu.be/sjKIqfCK0NY)
|
[](https://www.youtube.com/@TermixSSH/videos)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||||
|
|||||||
+20
-2
@@ -49,6 +49,7 @@ Termix — это платформа для управления сервера
|
|||||||
# Возможности
|
# Возможности
|
||||||
|
|
||||||
- **Доступ к SSH-терминалу** — Полнофункциональный терминал с поддержкой разделения экрана (до 4 панелей) и системой вкладок, как в браузере. Включает поддержку настройки терминала, включая популярные темы, шрифты и другие компоненты
|
- **Доступ к SSH-терминалу** — Полнофункциональный терминал с поддержкой разделения экрана (до 4 панелей) и системой вкладок, как в браузере. Включает поддержку настройки терминала, включая популярные темы, шрифты и другие компоненты
|
||||||
|
- **Доступ к удалённому рабочему столу** — Поддержка RDP, VNC и Telnet через браузер с полной настройкой и разделением экрана
|
||||||
- **Управление SSH-туннелями** — Создание и управление SSH-туннелями с автоматическим переподключением и мониторингом состояния, с поддержкой соединений -l и -r
|
- **Управление SSH-туннелями** — Создание и управление SSH-туннелями с автоматическим переподключением и мониторингом состояния, с поддержкой соединений -l и -r
|
||||||
- **Удалённый файловый менеджер** — Управление файлами непосредственно на удалённых серверах с поддержкой просмотра и редактирования кода, изображений, аудио и видео. Загрузка, скачивание, переименование, удаление и перемещение файлов с поддержкой sudo
|
- **Удалённый файловый менеджер** — Управление файлами непосредственно на удалённых серверах с поддержкой просмотра и редактирования кода, изображений, аудио и видео. Загрузка, скачивание, переименование, удаление и перемещение файлов с поддержкой sudo
|
||||||
- **Управление Docker** — Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием
|
- **Управление Docker** — Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием
|
||||||
@@ -69,7 +70,7 @@ Termix — это платформа для управления сервера
|
|||||||
- **Командная палитра** — Двойное нажатие левого Shift для быстрого доступа к SSH-подключениям с клавиатуры
|
- **Командная палитра** — Двойное нажатие левого Shift для быстрого доступа к SSH-подключениям с клавиатуры
|
||||||
- **Богатый функционал SSH** — Поддержка jump-хостов, Warpgate, подключений на основе TOTP, SOCKS5, верификации ключей хоста, автозаполнения паролей, [OPKSSH](https://github.com/openpubkey/opkssh) и др.
|
- **Богатый функционал SSH** — Поддержка jump-хостов, Warpgate, подключений на основе TOTP, SOCKS5, верификации ключей хоста, автозаполнения паролей, [OPKSSH](https://github.com/openpubkey/opkssh) и др.
|
||||||
- **Сетевой граф** — Настройте панель управления для визуализации вашей домашней лаборатории на основе SSH-подключений с поддержкой статусов
|
- **Сетевой граф** — Настройте панель управления для визуализации вашей домашней лаборатории на основе SSH-подключений с поддержкой статусов
|
||||||
- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
|
- **Постоянные вкладки** — SSH-сессии и вкладки остаются открытыми на всех устройствах/при обновлении страницы, если включено в профиле пользователя
|
||||||
|
|
||||||
# Запланированные функции
|
# Запланированные функции
|
||||||
|
|
||||||
@@ -115,10 +116,27 @@ services:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
depends_on:
|
||||||
|
- guacd
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
|
guacd:
|
||||||
|
image: guacamole/guacd:latest
|
||||||
|
container_name: guacd
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4822:4822"
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
termix-data:
|
termix-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
termix-net:
|
||||||
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
# Спонсоры
|
# Спонсоры
|
||||||
@@ -148,7 +166,7 @@ volumes:
|
|||||||
|
|
||||||
# Скриншоты
|
# Скриншоты
|
||||||
|
|
||||||
[](https://youtu.be/sjKIqfCK0NY)
|
[](https://www.youtube.com/@TermixSSH/videos)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||||
|
|||||||
+20
-2
@@ -49,6 +49,7 @@ Termix, açık kaynaklı, sonsuza kadar ücretsiz, kendi sunucunuzda barındıra
|
|||||||
# Özellikler
|
# Özellikler
|
||||||
|
|
||||||
- **SSH Terminal Erişimi** - Tarayıcı benzeri sekme sistemiyle bölünmüş ekran desteğine sahip (4 panele kadar) tam özellikli terminal. Yaygın terminal temaları, yazı tipleri ve diğer bileşenler dahil olmak üzere terminal özelleştirme desteği içerir
|
- **SSH Terminal Erişimi** - Tarayıcı benzeri sekme sistemiyle bölünmüş ekran desteğine sahip (4 panele kadar) tam özellikli terminal. Yaygın terminal temaları, yazı tipleri ve diğer bileşenler dahil olmak üzere terminal özelleştirme desteği içerir
|
||||||
|
- **Uzak Masaüstü Erişimi** - Tam özelleştirme ve bölünmüş ekran ile tarayıcı üzerinden RDP, VNC ve Telnet desteği
|
||||||
- **SSH Tünel Yönetimi** - Otomatik yeniden bağlanma ve sağlık izleme ile SSH tünelleri oluşturun ve yönetin, -l veya -r bağlantıları desteğiyle
|
- **SSH Tünel Yönetimi** - Otomatik yeniden bağlanma ve sağlık izleme ile SSH tünelleri oluşturun ve yönetin, -l veya -r bağlantıları desteğiyle
|
||||||
- **Uzak Dosya Yöneticisi** - Uzak sunuculardaki dosyaları doğrudan yönetin; kod, görüntü, ses ve video görüntüleme ve düzenleme desteğiyle. Sudo desteğiyle dosyaları sorunsuzca yükleyin, indirin, yeniden adlandırın, silin ve taşıyın.
|
- **Uzak Dosya Yöneticisi** - Uzak sunuculardaki dosyaları doğrudan yönetin; kod, görüntü, ses ve video görüntüleme ve düzenleme desteğiyle. Sudo desteğiyle dosyaları sorunsuzca yükleyin, indirin, yeniden adlandırın, silin ve taşıyın.
|
||||||
- **Docker Yönetimi** - Konteynerleri başlatın, durdurun, duraklatın, kaldırın. Konteyner istatistiklerini görüntüleyin. Docker exec terminali kullanarak konteyneri kontrol edin. Portainer veya Dockge'nin yerini almak için değil, konteynerlerinizi oluşturmak yerine basitçe yönetmek için tasarlanmıştır.
|
- **Docker Yönetimi** - Konteynerleri başlatın, durdurun, duraklatın, kaldırın. Konteyner istatistiklerini görüntüleyin. Docker exec terminali kullanarak konteyneri kontrol edin. Portainer veya Dockge'nin yerini almak için değil, konteynerlerinizi oluşturmak yerine basitçe yönetmek için tasarlanmıştır.
|
||||||
@@ -69,7 +70,7 @@ Termix, açık kaynaklı, sonsuza kadar ücretsiz, kendi sunucunuzda barındıra
|
|||||||
- **Komut Paleti** - Sol shift tuşuna iki kez basarak SSH bağlantılarına klavyenizle hızlıca erişin
|
- **Komut Paleti** - Sol shift tuşuna iki kez basarak SSH bağlantılarına klavyenizle hızlıca erişin
|
||||||
- **SSH Zengin Özellikler** - Atlama ana bilgisayarları, Warpgate, TOTP tabanlı bağlantılar, SOCKS5, ana bilgisayar anahtar doğrulama, otomatik şifre doldurma, [OPKSSH](https://github.com/openpubkey/opkssh) vb. destekler.
|
- **SSH Zengin Özellikler** - Atlama ana bilgisayarları, Warpgate, TOTP tabanlı bağlantılar, SOCKS5, ana bilgisayar anahtar doğrulama, otomatik şifre doldurma, [OPKSSH](https://github.com/openpubkey/opkssh) vb. destekler.
|
||||||
- **Ağ Grafiği** - Kontrol panelinizi, SSH bağlantılarınıza dayalı olarak ev laboratuvarınızı durum desteğiyle görselleştirmek için özelleştirin
|
- **Ağ Grafiği** - Kontrol panelinizi, SSH bağlantılarınıza dayalı olarak ev laboratuvarınızı durum desteğiyle görselleştirmek için özelleştirin
|
||||||
- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
|
- **Kalıcı Sekmeler** - Kullanıcı profilinde etkinleştirilmişse SSH oturumları ve sekmeler cihazlar/yenilemeler arasında açık kalır
|
||||||
|
|
||||||
# Planlanan Özellikler
|
# Planlanan Özellikler
|
||||||
|
|
||||||
@@ -115,10 +116,27 @@ services:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
depends_on:
|
||||||
|
- guacd
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
|
guacd:
|
||||||
|
image: guacamole/guacd:latest
|
||||||
|
container_name: guacd
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4822:4822"
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
termix-data:
|
termix-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
termix-net:
|
||||||
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
# Sponsorlar
|
# Sponsorlar
|
||||||
@@ -148,7 +166,7 @@ Lütfen sorununuzu mümkün olduğunca ayrıntılı yazın, tercihen İngilizce
|
|||||||
|
|
||||||
# Ekran Görüntüleri
|
# Ekran Görüntüleri
|
||||||
|
|
||||||
[](https://youtu.be/sjKIqfCK0NY)
|
[](https://www.youtube.com/@TermixSSH/videos)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||||
|
|||||||
+20
-2
@@ -49,6 +49,7 @@ Termix là nền tảng quản lý máy chủ tất cả trong một, mã nguồ
|
|||||||
# Tính Năng
|
# Tính Năng
|
||||||
|
|
||||||
- **Truy Cập Terminal SSH** - Terminal đầy đủ tính năng với hỗ trợ chia màn hình (lên đến 4 bảng) với hệ thống tab kiểu trình duyệt. Bao gồm hỗ trợ tùy chỉnh terminal bao gồm các chủ đề terminal phổ biến, phông chữ và các thành phần khác
|
- **Truy Cập Terminal SSH** - Terminal đầy đủ tính năng với hỗ trợ chia màn hình (lên đến 4 bảng) với hệ thống tab kiểu trình duyệt. Bao gồm hỗ trợ tùy chỉnh terminal bao gồm các chủ đề terminal phổ biến, phông chữ và các thành phần khác
|
||||||
|
- **Truy Cập Màn Hình Từ Xa** - Hỗ trợ RDP, VNC và Telnet qua trình duyệt với đầy đủ tùy chỉnh và chia màn hình
|
||||||
- **Quản Lý Đường Hầm SSH** - Tạo và quản lý đường hầm SSH với tự động kết nối lại và giám sát sức khỏe, hỗ trợ kết nối -l hoặc -r
|
- **Quản Lý Đường Hầm SSH** - Tạo và quản lý đường hầm SSH với tự động kết nối lại và giám sát sức khỏe, hỗ trợ kết nối -l hoặc -r
|
||||||
- **Trình Quản Lý Tệp Từ Xa** - Quản lý tệp trực tiếp trên máy chủ từ xa với hỗ trợ xem và chỉnh sửa mã, hình ảnh, âm thanh và video. Tải lên, tải xuống, đổi tên, xóa và di chuyển tệp liền mạch với hỗ trợ sudo.
|
- **Trình Quản Lý Tệp Từ Xa** - Quản lý tệp trực tiếp trên máy chủ từ xa với hỗ trợ xem và chỉnh sửa mã, hình ảnh, âm thanh và video. Tải lên, tải xuống, đổi tên, xóa và di chuyển tệp liền mạch với hỗ trợ sudo.
|
||||||
- **Quản Lý Docker** - Khởi động, dừng, tạm dừng, xóa container. Xem thống kê container. Điều khiển container bằng terminal docker exec. Không được tạo ra để thay thế Portainer hay Dockge mà đơn giản là để quản lý container của bạn thay vì tạo mới chúng.
|
- **Quản Lý Docker** - Khởi động, dừng, tạm dừng, xóa container. Xem thống kê container. Điều khiển container bằng terminal docker exec. Không được tạo ra để thay thế Portainer hay Dockge mà đơn giản là để quản lý container của bạn thay vì tạo mới chúng.
|
||||||
@@ -69,7 +70,7 @@ Termix là nền tảng quản lý máy chủ tất cả trong một, mã nguồ
|
|||||||
- **Bảng Lệnh** - Nhấn đúp phím shift trái để truy cập nhanh các kết nối SSH bằng bàn phím
|
- **Bảng Lệnh** - Nhấn đúp phím shift trái để truy cập nhanh các kết nối SSH bằng bàn phím
|
||||||
- **SSH Giàu Tính Năng** - Hỗ trợ jump host, Warpgate, kết nối dựa trên TOTP, SOCKS5, xác minh khóa máy chủ, tự động điền mật khẩu, [OPKSSH](https://github.com/openpubkey/opkssh), v.v.
|
- **SSH Giàu Tính Năng** - Hỗ trợ jump host, Warpgate, kết nối dựa trên TOTP, SOCKS5, xác minh khóa máy chủ, tự động điền mật khẩu, [OPKSSH](https://github.com/openpubkey/opkssh), v.v.
|
||||||
- **Biểu Đồ Mạng** - Tùy chỉnh Bảng Điều Khiển để trực quan hóa homelab của bạn dựa trên các kết nối SSH với hỗ trợ trạng thái
|
- **Biểu Đồ Mạng** - Tùy chỉnh Bảng Điều Khiển để trực quan hóa homelab của bạn dựa trên các kết nối SSH với hỗ trợ trạng thái
|
||||||
- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
|
- **Tab Liên Tục** - Các phiên SSH và tab vẫn mở trên các thiết bị/lần làm mới nếu được bật trong hồ sơ người dùng
|
||||||
|
|
||||||
# Tính Năng Dự Kiến
|
# Tính Năng Dự Kiến
|
||||||
|
|
||||||
@@ -115,10 +116,27 @@ services:
|
|||||||
- termix-data:/app/data
|
- termix-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
|
depends_on:
|
||||||
|
- guacd
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
|
guacd:
|
||||||
|
image: guacamole/guacd:latest
|
||||||
|
container_name: guacd
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4822:4822"
|
||||||
|
networks:
|
||||||
|
- termix-net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
termix-data:
|
termix-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
termix-net:
|
||||||
|
driver: bridge
|
||||||
```
|
```
|
||||||
|
|
||||||
# Nhà Tài Trợ
|
# Nhà Tài Trợ
|
||||||
@@ -148,7 +166,7 @@ Vui lòng mô tả vấn đề càng chi tiết càng tốt, ưu tiên viết b
|
|||||||
|
|
||||||
# Ảnh Chụp Màn Hình
|
# Ảnh Chụp Màn Hình
|
||||||
|
|
||||||
[](https://youtu.be/sjKIqfCK0NY)
|
[](https://www.youtube.com/@TermixSSH/videos)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
<img src="../repo-images/Image%201.png" width="400" alt="Termix Demo 1"/>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import cookieParser from "cookie-parser";
|
|||||||
import { getDb, DatabaseSaveTrigger } from "./database/db/index.js";
|
import { getDb, DatabaseSaveTrigger } from "./database/db/index.js";
|
||||||
import {
|
import {
|
||||||
recentActivity,
|
recentActivity,
|
||||||
sshData,
|
hosts,
|
||||||
hostAccess,
|
hostAccess,
|
||||||
dashboardPreferences,
|
dashboardPreferences,
|
||||||
} from "./database/db/schema.js";
|
} from "./database/db/schema.js";
|
||||||
@@ -176,7 +176,7 @@ app.get("/activity/recent", async (req, res) => {
|
|||||||
* properties:
|
* properties:
|
||||||
* type:
|
* type:
|
||||||
* type: string
|
* type: string
|
||||||
* enum: [terminal, file_manager, server_stats, tunnel, docker]
|
* enum: [terminal, file_manager, server_stats, tunnel, docker, telnet, vnc, rdp]
|
||||||
* hostId:
|
* hostId:
|
||||||
* type: integer
|
* type: integer
|
||||||
* hostName:
|
* hostName:
|
||||||
@@ -219,11 +219,14 @@ app.post("/activity/log", async (req, res) => {
|
|||||||
"server_stats",
|
"server_stats",
|
||||||
"tunnel",
|
"tunnel",
|
||||||
"docker",
|
"docker",
|
||||||
|
"telnet",
|
||||||
|
"vnc",
|
||||||
|
"rdp",
|
||||||
].includes(type)
|
].includes(type)
|
||||||
) {
|
) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error:
|
error:
|
||||||
"Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', or 'docker'",
|
"Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', 'docker', 'telnet', 'vnc', or 'rdp'",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,8 +255,8 @@ app.post("/activity/log", async (req, res) => {
|
|||||||
const ownedHosts = await SimpleDBOps.select(
|
const ownedHosts = await SimpleDBOps.select(
|
||||||
getDb()
|
getDb()
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import bodyParser from "body-parser";
|
|||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import userRoutes from "./routes/users.js";
|
import userRoutes from "./routes/users.js";
|
||||||
import sshRoutes from "./routes/ssh.js";
|
import hostRoutes from "./routes/host.js";
|
||||||
import alertRoutes from "./routes/alerts.js";
|
import alertRoutes from "./routes/alerts.js";
|
||||||
import credentialsRoutes from "./routes/credentials.js";
|
import credentialsRoutes from "./routes/credentials.js";
|
||||||
import snippetsRoutes from "./routes/snippets.js";
|
import snippetsRoutes from "./routes/snippets.js";
|
||||||
import terminalRoutes from "./routes/terminal.js";
|
import terminalRoutes from "./routes/terminal.js";
|
||||||
|
import guacamoleRoutes from "../guacamole/routes.js";
|
||||||
import networkTopologyRoutes from "./routes/network-topology.js";
|
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||||
import rbacRoutes from "./routes/rbac.js";
|
import rbacRoutes from "./routes/rbac.js";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
@@ -28,7 +29,7 @@ import { parseUserAgent } from "../utils/user-agent-parser.js";
|
|||||||
import { getProxyAgent } from "../utils/proxy-agent.js";
|
import { getProxyAgent } from "../utils/proxy-agent.js";
|
||||||
import {
|
import {
|
||||||
users,
|
users,
|
||||||
sshData,
|
hosts,
|
||||||
sshCredentials,
|
sshCredentials,
|
||||||
fileManagerRecent,
|
fileManagerRecent,
|
||||||
fileManagerPinned,
|
fileManagerPinned,
|
||||||
@@ -846,8 +847,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
|||||||
|
|
||||||
const sshHosts = await getDb()
|
const sshHosts = await getDb()
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(eq(sshData.userId, userId));
|
.where(eq(hosts.userId, userId));
|
||||||
const insertHost = exportDb.prepare(`
|
const insertHost = exportDb.prepare(`
|
||||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, stats_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, created_at, updated_at)
|
INSERT INTO ssh_data (id, user_id, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, stats_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
@@ -1267,13 +1268,13 @@ app.post(
|
|||||||
try {
|
try {
|
||||||
const existing = await mainDb
|
const existing = await mainDb
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(sshData.userId, userId),
|
eq(hosts.userId, userId),
|
||||||
eq(sshData.ip, host.ip),
|
eq(hosts.ip, host.ip),
|
||||||
eq(sshData.port, host.port),
|
eq(hosts.port, host.port),
|
||||||
eq(sshData.username, host.username),
|
eq(hosts.username, host.username),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1341,7 +1342,7 @@ app.post(
|
|||||||
userId,
|
userId,
|
||||||
userDataKey,
|
userDataKey,
|
||||||
);
|
);
|
||||||
await mainDb.insert(sshData).values(encrypted);
|
await mainDb.insert(hosts).values(encrypted);
|
||||||
result.summary.sshHostsImported++;
|
result.summary.sshHostsImported++;
|
||||||
} catch (hostError) {
|
} catch (hostError) {
|
||||||
result.summary.errors.push(
|
result.summary.errors.push(
|
||||||
@@ -1759,11 +1760,12 @@ app.post("/database/restore", requireAdmin, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.use("/users", userRoutes);
|
app.use("/users", userRoutes);
|
||||||
app.use("/ssh", sshRoutes);
|
app.use("/host", hostRoutes);
|
||||||
app.use("/alerts", alertRoutes);
|
app.use("/alerts", alertRoutes);
|
||||||
app.use("/credentials", credentialsRoutes);
|
app.use("/credentials", credentialsRoutes);
|
||||||
app.use("/snippets", snippetsRoutes);
|
app.use("/snippets", snippetsRoutes);
|
||||||
app.use("/terminal", terminalRoutes);
|
app.use("/terminal", terminalRoutes);
|
||||||
|
app.use("/guacamole", guacamoleRoutes);
|
||||||
app.use("/network-topology", networkTopologyRoutes);
|
app.use("/network-topology", networkTopologyRoutes);
|
||||||
app.use("/rbac", rbacRoutes);
|
app.use("/rbac", rbacRoutes);
|
||||||
|
|
||||||
|
|||||||
@@ -427,9 +427,7 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sqlite
|
sqlite.prepare("DELETE FROM sessions").run();
|
||||||
.prepare("DELETE FROM sessions WHERE expires_at < datetime('now')")
|
|
||||||
.run();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
databaseLogger.warn("Could not clear expired sessions on startup", {
|
databaseLogger.warn("Could not clear expired sessions on startup", {
|
||||||
operation: "db_init_session_cleanup_failed",
|
operation: "db_init_session_cleanup_failed",
|
||||||
@@ -474,6 +472,42 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
error: e,
|
error: e,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = sqlite
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
|
||||||
|
.get();
|
||||||
|
if (!row) {
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO settings (key, value) VALUES ('guac_enabled', 'true')",
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
databaseLogger.warn("Could not initialize guac_enabled setting", {
|
||||||
|
operation: "db_init",
|
||||||
|
error: e,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = sqlite
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||||
|
.get();
|
||||||
|
if (!row) {
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO settings (key, value) VALUES ('guac_url', 'guacd:4822')",
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
databaseLogger.warn("Could not initialize guac_url setting", {
|
||||||
|
operation: "db_init",
|
||||||
|
error: e,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const addColumnIfNotExists = (
|
const addColumnIfNotExists = (
|
||||||
@@ -591,6 +625,11 @@ const migrateSchema = () => {
|
|||||||
);
|
);
|
||||||
addColumnIfNotExists("ssh_data", "docker_config", "TEXT");
|
addColumnIfNotExists("ssh_data", "docker_config", "TEXT");
|
||||||
|
|
||||||
|
addColumnIfNotExists("ssh_data", "connection_type", 'TEXT NOT NULL DEFAULT "ssh"');
|
||||||
|
addColumnIfNotExists("ssh_data", "domain", "TEXT");
|
||||||
|
addColumnIfNotExists("ssh_data", "security", "TEXT");
|
||||||
|
addColumnIfNotExists("ssh_data", "ignore_cert", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
addColumnIfNotExists("ssh_data", "guacamole_config", "TEXT");
|
||||||
addColumnIfNotExists("ssh_data", "notes", "TEXT");
|
addColumnIfNotExists("ssh_data", "notes", "TEXT");
|
||||||
|
|
||||||
addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER");
|
addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER");
|
||||||
|
|||||||
@@ -64,11 +64,12 @@ export const trustedDevices = sqliteTable("trusted_devices", {
|
|||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sshData = sqliteTable("ssh_data", {
|
export const hosts = sqliteTable("ssh_data", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
connectionType: text("connection_type").notNull().default("ssh"),
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
ip: text("ip").notNull(),
|
ip: text("ip").notNull(),
|
||||||
port: integer("port").notNull(),
|
port: integer("port").notNull(),
|
||||||
@@ -124,9 +125,14 @@ export const sshData = sqliteTable("ssh_data", {
|
|||||||
.default(false),
|
.default(false),
|
||||||
defaultPath: text("default_path"),
|
defaultPath: text("default_path"),
|
||||||
statsConfig: text("stats_config"),
|
statsConfig: text("stats_config"),
|
||||||
|
dockerConfig: text("docker_config"),
|
||||||
terminalConfig: text("terminal_config"),
|
terminalConfig: text("terminal_config"),
|
||||||
quickActions: text("quick_actions"),
|
quickActions: text("quick_actions"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
|
domain: text("domain"),
|
||||||
|
security: text("security"),
|
||||||
|
ignoreCert: integer("ignore_cert", { mode: "boolean" }).default(false),
|
||||||
|
guacamoleConfig: text("guacamole_config"),
|
||||||
|
|
||||||
useSocks5: integer("use_socks5", { mode: "boolean" }),
|
useSocks5: integer("use_socks5", { mode: "boolean" }),
|
||||||
socks5Host: text("socks5_host"),
|
socks5Host: text("socks5_host"),
|
||||||
@@ -157,7 +163,7 @@ export const fileManagerRecent = sqliteTable("file_manager_recent", {
|
|||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
hostId: integer("host_id")
|
hostId: integer("host_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
path: text("path").notNull(),
|
path: text("path").notNull(),
|
||||||
lastOpened: text("last_opened")
|
lastOpened: text("last_opened")
|
||||||
@@ -172,7 +178,7 @@ export const fileManagerPinned = sqliteTable("file_manager_pinned", {
|
|||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
hostId: integer("host_id")
|
hostId: integer("host_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
path: text("path").notNull(),
|
path: text("path").notNull(),
|
||||||
pinnedAt: text("pinned_at")
|
pinnedAt: text("pinned_at")
|
||||||
@@ -187,7 +193,7 @@ export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", {
|
|||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
hostId: integer("host_id")
|
hostId: integer("host_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
path: text("path").notNull(),
|
path: text("path").notNull(),
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
@@ -246,7 +252,7 @@ export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
|
|||||||
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
hostId: integer("host_id")
|
hostId: integer("host_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
@@ -313,7 +319,7 @@ export const recentActivity = sqliteTable("recent_activity", {
|
|||||||
type: text("type").notNull(),
|
type: text("type").notNull(),
|
||||||
hostId: integer("host_id")
|
hostId: integer("host_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||||
hostName: text("host_name"),
|
hostName: text("host_name"),
|
||||||
timestamp: text("timestamp")
|
timestamp: text("timestamp")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -327,7 +333,7 @@ export const commandHistory = sqliteTable("command_history", {
|
|||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
hostId: integer("host_id")
|
hostId: integer("host_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||||
command: text("command").notNull(),
|
command: text("command").notNull(),
|
||||||
executedAt: text("executed_at")
|
executedAt: text("executed_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -367,7 +373,7 @@ export const hostAccess = sqliteTable("host_access", {
|
|||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
hostId: integer("host_id")
|
hostId: integer("host_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
@@ -492,7 +498,7 @@ export const sessionRecordings = sqliteTable("session_recordings", {
|
|||||||
|
|
||||||
hostId: integer("host_id")
|
hostId: integer("host_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
@@ -523,7 +529,7 @@ export const opksshTokens = sqliteTable("opkssh_tokens", {
|
|||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
hostId: integer("host_id")
|
hostId: integer("host_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||||
|
|
||||||
sshCert: text("ssh_cert", { length: 8192 }).notNull(),
|
sshCert: text("ssh_cert", { length: 8192 }).notNull(),
|
||||||
privateKey: text("private_key", { length: 8192 }).notNull(),
|
privateKey: text("private_key", { length: 8192 }).notNull(),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { db } from "../db/index.js";
|
|||||||
import {
|
import {
|
||||||
sshCredentials,
|
sshCredentials,
|
||||||
sshCredentialUsage,
|
sshCredentialUsage,
|
||||||
sshData,
|
hosts,
|
||||||
hostAccess,
|
hostAccess,
|
||||||
} from "../db/schema.js";
|
} from "../db/schema.js";
|
||||||
import { eq, and, desc, sql } from "drizzle-orm";
|
import { eq, and, desc, sql } from "drizzle-orm";
|
||||||
@@ -606,9 +606,8 @@ router.put(
|
|||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { SharedCredentialManager } = await import(
|
const { SharedCredentialManager } =
|
||||||
"../../utils/shared-credential-manager.js"
|
await import("../../utils/shared-credential-manager.js");
|
||||||
);
|
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||||
await sharedCredManager.updateSharedCredentialsForOriginal(
|
await sharedCredManager.updateSharedCredentialsForOriginal(
|
||||||
parseInt(id),
|
parseInt(id),
|
||||||
@@ -691,17 +690,14 @@ router.delete(
|
|||||||
|
|
||||||
const hostsUsingCredential = await db
|
const hostsUsingCredential = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(hosts.credentialId, parseInt(id)), eq(hosts.userId, userId)),
|
||||||
eq(sshData.credentialId, parseInt(id)),
|
|
||||||
eq(sshData.userId, userId),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hostsUsingCredential.length > 0) {
|
if (hostsUsingCredential.length > 0) {
|
||||||
await db
|
await db
|
||||||
.update(sshData)
|
.update(hosts)
|
||||||
.set({
|
.set({
|
||||||
credentialId: null,
|
credentialId: null,
|
||||||
password: null,
|
password: null,
|
||||||
@@ -710,10 +706,7 @@ router.delete(
|
|||||||
authType: "password",
|
authType: "password",
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(hosts.credentialId, parseInt(id)), eq(hosts.userId, userId)),
|
||||||
eq(sshData.credentialId, parseInt(id)),
|
|
||||||
eq(sshData.userId, userId),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const host of hostsUsingCredential) {
|
for (const host of hostsUsingCredential) {
|
||||||
@@ -737,9 +730,8 @@ router.delete(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { SharedCredentialManager } = await import(
|
const { SharedCredentialManager } =
|
||||||
"../../utils/shared-credential-manager.js"
|
await import("../../utils/shared-credential-manager.js");
|
||||||
);
|
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||||
await sharedCredManager.deleteSharedCredentialsForOriginal(parseInt(id));
|
await sharedCredManager.deleteSharedCredentialsForOriginal(parseInt(id));
|
||||||
|
|
||||||
@@ -837,7 +829,7 @@ router.post(
|
|||||||
const credential = credentials[0];
|
const credential = credentials[0];
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(sshData)
|
.update(hosts)
|
||||||
.set({
|
.set({
|
||||||
credentialId: parseInt(credentialId),
|
credentialId: parseInt(credentialId),
|
||||||
username: (credential.username as string) || "",
|
username: (credential.username as string) || "",
|
||||||
@@ -848,9 +840,7 @@ router.post(
|
|||||||
keyType: null,
|
keyType: null,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.where(
|
.where(and(eq(hosts.id, parseInt(hostId)), eq(hosts.userId, userId)));
|
||||||
and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
|
|
||||||
);
|
|
||||||
|
|
||||||
await db.insert(sshCredentialUsage).values({
|
await db.insert(sshCredentialUsage).values({
|
||||||
credentialId: parseInt(credentialId),
|
credentialId: parseInt(credentialId),
|
||||||
@@ -917,17 +907,17 @@ router.get(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hosts = await db
|
const hostsUsingCredential = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(sshData.credentialId, parseInt(credentialId)),
|
eq(hosts.credentialId, parseInt(credentialId)),
|
||||||
eq(sshData.userId, userId),
|
eq(hosts.userId, userId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json(hosts.map((host) => formatSSHHostOutput(host)));
|
res.json(hostsUsingCredential.map((host) => formatSSHHostOutput(host)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authLogger.error("Failed to fetch hosts using credential", err);
|
authLogger.error("Failed to fetch hosts using credential", err);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
@@ -1942,7 +1932,7 @@ router.post(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const targetHost = await SimpleDBOps.select(
|
const targetHost = await SimpleDBOps.select(
|
||||||
db.select().from(sshData).where(eq(sshData.id, targetHostId)).limit(1),
|
db.select().from(hosts).where(eq(hosts.id, targetHostId)).limit(1),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import { db } from "../db/index.js";
|
import { db } from "../db/index.js";
|
||||||
import {
|
import {
|
||||||
sshData,
|
hosts,
|
||||||
sshCredentials,
|
sshCredentials,
|
||||||
sshCredentialUsage,
|
sshCredentialUsage,
|
||||||
fileManagerRecent,
|
fileManagerRecent,
|
||||||
@@ -90,6 +90,12 @@ function transformHostResponse(
|
|||||||
socks5ProxyChain: host.socks5ProxyChain
|
socks5ProxyChain: host.socks5ProxyChain
|
||||||
? JSON.parse(host.socks5ProxyChain as string)
|
? JSON.parse(host.socks5ProxyChain as string)
|
||||||
: [],
|
: [],
|
||||||
|
domain: host.domain || undefined,
|
||||||
|
security: host.security || undefined,
|
||||||
|
ignoreCert: !!host.ignoreCert,
|
||||||
|
guacamoleConfig: host.guacamoleConfig
|
||||||
|
? JSON.parse(host.guacamoleConfig as string)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,12 +145,9 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const autostartHosts = await db
|
const autostartHosts = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(hosts.enableTunnel, true), isNotNull(hosts.tunnelConnections)),
|
||||||
eq(sshData.enableTunnel, true),
|
|
||||||
isNotNull(sshData.tunnelConnections),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = autostartHosts
|
const result = autostartHosts
|
||||||
@@ -235,7 +238,7 @@ router.get("/db/host/internal/all", async (req: Request, res: Response) => {
|
|||||||
.json({ error: "Invalid internal authentication token" });
|
.json({ error: "Invalid internal authentication token" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const allHosts = await db.select().from(sshData);
|
const allHosts = await db.select().from(hosts);
|
||||||
|
|
||||||
const result = allHosts.map((host) => {
|
const result = allHosts.map((host) => {
|
||||||
const tunnelConnections = host.tunnelConnections
|
const tunnelConnections = host.tunnelConnections
|
||||||
@@ -334,6 +337,7 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
connectionType,
|
||||||
name,
|
name,
|
||||||
folder,
|
folder,
|
||||||
tags,
|
tags,
|
||||||
@@ -363,8 +367,13 @@ router.post(
|
|||||||
jumpHosts,
|
jumpHosts,
|
||||||
quickActions,
|
quickActions,
|
||||||
statsConfig,
|
statsConfig,
|
||||||
|
dockerConfig,
|
||||||
terminalConfig,
|
terminalConfig,
|
||||||
forceKeyboardInteractive,
|
forceKeyboardInteractive,
|
||||||
|
domain,
|
||||||
|
security,
|
||||||
|
ignoreCert,
|
||||||
|
guacamoleConfig,
|
||||||
notes,
|
notes,
|
||||||
useSocks5,
|
useSocks5,
|
||||||
socks5Host,
|
socks5Host,
|
||||||
@@ -397,8 +406,10 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const effectiveAuthType = authType || authMethod;
|
const effectiveAuthType = authType || authMethod;
|
||||||
|
const effectiveConnectionType = connectionType || "ssh";
|
||||||
const sshDataObj: Record<string, unknown> = {
|
const sshDataObj: Record<string, unknown> = {
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
connectionType: effectiveConnectionType,
|
||||||
name,
|
name,
|
||||||
folder: folder || null,
|
folder: folder || null,
|
||||||
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
||||||
@@ -431,12 +442,21 @@ router.post(
|
|||||||
? statsConfig
|
? statsConfig
|
||||||
: JSON.stringify(statsConfig)
|
: JSON.stringify(statsConfig)
|
||||||
: null,
|
: null,
|
||||||
|
dockerConfig: dockerConfig
|
||||||
|
? typeof dockerConfig === "string"
|
||||||
|
? dockerConfig
|
||||||
|
: JSON.stringify(dockerConfig)
|
||||||
|
: null,
|
||||||
terminalConfig: terminalConfig
|
terminalConfig: terminalConfig
|
||||||
? typeof terminalConfig === "string"
|
? typeof terminalConfig === "string"
|
||||||
? terminalConfig
|
? terminalConfig
|
||||||
: JSON.stringify(terminalConfig)
|
: JSON.stringify(terminalConfig)
|
||||||
: null,
|
: null,
|
||||||
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
||||||
|
domain: domain || null,
|
||||||
|
security: security || null,
|
||||||
|
ignoreCert: ignoreCert ? 1 : 0,
|
||||||
|
guacamoleConfig: guacamoleConfig ? JSON.stringify(guacamoleConfig) : null,
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
sudoPassword: sudoPassword || null,
|
sudoPassword: sudoPassword || null,
|
||||||
useSocks5: useSocks5 ? 1 : 0,
|
useSocks5: useSocks5 ? 1 : 0,
|
||||||
@@ -449,7 +469,13 @@ router.post(
|
|||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (effectiveAuthType === "password") {
|
// For non-SSH hosts (RDP, VNC, Telnet), always save password if provided
|
||||||
|
if (effectiveConnectionType !== "ssh") {
|
||||||
|
sshDataObj.password = password || null;
|
||||||
|
sshDataObj.key = null;
|
||||||
|
sshDataObj.keyPassword = null;
|
||||||
|
sshDataObj.keyType = null;
|
||||||
|
} else if (effectiveAuthType === "password") {
|
||||||
sshDataObj.password = password || null;
|
sshDataObj.password = password || null;
|
||||||
sshDataObj.key = null;
|
sshDataObj.key = null;
|
||||||
sshDataObj.keyPassword = null;
|
sshDataObj.keyPassword = null;
|
||||||
@@ -501,7 +527,7 @@ router.post(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await SimpleDBOps.insert(
|
const result = await SimpleDBOps.insert(
|
||||||
sshData,
|
hosts,
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
sshDataObj,
|
sshDataObj,
|
||||||
userId,
|
userId,
|
||||||
@@ -532,7 +558,7 @@ router.post(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const axios = (await import("axios")).default;
|
const axios = (await import("axios")).default;
|
||||||
const statsPort = process.env.STATS_PORT || 30005;
|
const statsPort = 30005;
|
||||||
await axios.post(
|
await axios.post(
|
||||||
`http://localhost:${statsPort}/host-updated`,
|
`http://localhost:${statsPort}/host-updated`,
|
||||||
{ hostId: createdHost.id },
|
{ hostId: createdHost.id },
|
||||||
@@ -818,6 +844,7 @@ router.put(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
connectionType,
|
||||||
name,
|
name,
|
||||||
folder,
|
folder,
|
||||||
tags,
|
tags,
|
||||||
@@ -847,8 +874,13 @@ router.put(
|
|||||||
jumpHosts,
|
jumpHosts,
|
||||||
quickActions,
|
quickActions,
|
||||||
statsConfig,
|
statsConfig,
|
||||||
|
dockerConfig,
|
||||||
terminalConfig,
|
terminalConfig,
|
||||||
forceKeyboardInteractive,
|
forceKeyboardInteractive,
|
||||||
|
domain,
|
||||||
|
security,
|
||||||
|
ignoreCert,
|
||||||
|
guacamoleConfig,
|
||||||
notes,
|
notes,
|
||||||
useSocks5,
|
useSocks5,
|
||||||
socks5Host,
|
socks5Host,
|
||||||
@@ -884,6 +916,7 @@ router.put(
|
|||||||
|
|
||||||
const effectiveAuthType = authType || authMethod;
|
const effectiveAuthType = authType || authMethod;
|
||||||
const sshDataObj: Record<string, unknown> = {
|
const sshDataObj: Record<string, unknown> = {
|
||||||
|
connectionType: connectionType || "ssh",
|
||||||
name,
|
name,
|
||||||
folder,
|
folder,
|
||||||
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
||||||
@@ -916,12 +949,21 @@ router.put(
|
|||||||
? statsConfig
|
? statsConfig
|
||||||
: JSON.stringify(statsConfig)
|
: JSON.stringify(statsConfig)
|
||||||
: null,
|
: null,
|
||||||
|
dockerConfig: dockerConfig
|
||||||
|
? typeof dockerConfig === "string"
|
||||||
|
? dockerConfig
|
||||||
|
: JSON.stringify(dockerConfig)
|
||||||
|
: null,
|
||||||
terminalConfig: terminalConfig
|
terminalConfig: terminalConfig
|
||||||
? typeof terminalConfig === "string"
|
? typeof terminalConfig === "string"
|
||||||
? terminalConfig
|
? terminalConfig
|
||||||
: JSON.stringify(terminalConfig)
|
: JSON.stringify(terminalConfig)
|
||||||
: null,
|
: null,
|
||||||
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
||||||
|
domain: domain || null,
|
||||||
|
security: security || null,
|
||||||
|
ignoreCert: ignoreCert ? 1 : 0,
|
||||||
|
guacamoleConfig: guacamoleConfig ? JSON.stringify(guacamoleConfig) : null,
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
sudoPassword: sudoPassword || null,
|
sudoPassword: sudoPassword || null,
|
||||||
useSocks5: useSocks5 ? 1 : 0,
|
useSocks5: useSocks5 ? 1 : 0,
|
||||||
@@ -934,7 +976,15 @@ router.put(
|
|||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (effectiveAuthType === "password") {
|
// For non-SSH hosts (RDP, VNC, Telnet), always save password if provided
|
||||||
|
if ((connectionType || "ssh") !== "ssh") {
|
||||||
|
if (password) {
|
||||||
|
sshDataObj.password = password;
|
||||||
|
}
|
||||||
|
sshDataObj.key = null;
|
||||||
|
sshDataObj.keyPassword = null;
|
||||||
|
sshDataObj.keyType = null;
|
||||||
|
} else if (effectiveAuthType === "password") {
|
||||||
if (password) {
|
if (password) {
|
||||||
sshDataObj.password = password;
|
sshDataObj.password = password;
|
||||||
}
|
}
|
||||||
@@ -1021,12 +1071,12 @@ router.put(
|
|||||||
|
|
||||||
const hostRecord = await db
|
const hostRecord = await db
|
||||||
.select({
|
.select({
|
||||||
userId: sshData.userId,
|
userId: hosts.userId,
|
||||||
credentialId: sshData.credentialId,
|
credentialId: hosts.credentialId,
|
||||||
authType: sshData.authType,
|
authType: hosts.authType,
|
||||||
})
|
})
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(eq(sshData.id, Number(hostId)))
|
.where(eq(hosts.id, Number(hostId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (hostRecord.length === 0) {
|
if (hostRecord.length === 0) {
|
||||||
@@ -1072,9 +1122,9 @@ router.put(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await SimpleDBOps.update(
|
await SimpleDBOps.update(
|
||||||
sshData,
|
hosts,
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
eq(sshData.id, Number(hostId)),
|
eq(hosts.id, Number(hostId)),
|
||||||
sshDataObj,
|
sshDataObj,
|
||||||
ownerId,
|
ownerId,
|
||||||
);
|
);
|
||||||
@@ -1082,8 +1132,8 @@ router.put(
|
|||||||
const updatedHosts = await SimpleDBOps.select(
|
const updatedHosts = await SimpleDBOps.select(
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(eq(sshData.id, Number(hostId))),
|
.where(eq(hosts.id, Number(hostId))),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
ownerId,
|
ownerId,
|
||||||
);
|
);
|
||||||
@@ -1110,7 +1160,7 @@ router.put(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const axios = (await import("axios")).default;
|
const axios = (await import("axios")).default;
|
||||||
const statsPort = process.env.STATS_PORT || 30005;
|
const statsPort = 30005;
|
||||||
await axios.post(
|
await axios.post(
|
||||||
`http://localhost:${statsPort}/host-updated`,
|
`http://localhost:${statsPort}/host-updated`,
|
||||||
{ hostId: parseInt(hostId) },
|
{ hostId: parseInt(hostId) },
|
||||||
@@ -1186,62 +1236,67 @@ router.get(
|
|||||||
|
|
||||||
const rawData = await db
|
const rawData = await db
|
||||||
.select({
|
.select({
|
||||||
id: sshData.id,
|
id: hosts.id,
|
||||||
userId: sshData.userId,
|
userId: hosts.userId,
|
||||||
name: sshData.name,
|
connectionType: hosts.connectionType,
|
||||||
ip: sshData.ip,
|
name: hosts.name,
|
||||||
port: sshData.port,
|
ip: hosts.ip,
|
||||||
username: sshData.username,
|
port: hosts.port,
|
||||||
folder: sshData.folder,
|
username: hosts.username,
|
||||||
tags: sshData.tags,
|
folder: hosts.folder,
|
||||||
pin: sshData.pin,
|
tags: hosts.tags,
|
||||||
authType: sshData.authType,
|
pin: hosts.pin,
|
||||||
password: sshData.password,
|
authType: hosts.authType,
|
||||||
key: sshData.key,
|
password: hosts.password,
|
||||||
keyPassword: sshData.keyPassword,
|
key: hosts.key,
|
||||||
keyType: sshData.keyType,
|
keyPassword: hosts.keyPassword,
|
||||||
enableTerminal: sshData.enableTerminal,
|
keyType: hosts.keyType,
|
||||||
enableTunnel: sshData.enableTunnel,
|
enableTerminal: hosts.enableTerminal,
|
||||||
tunnelConnections: sshData.tunnelConnections,
|
enableTunnel: hosts.enableTunnel,
|
||||||
jumpHosts: sshData.jumpHosts,
|
tunnelConnections: hosts.tunnelConnections,
|
||||||
enableFileManager: sshData.enableFileManager,
|
jumpHosts: hosts.jumpHosts,
|
||||||
defaultPath: sshData.defaultPath,
|
enableFileManager: hosts.enableFileManager,
|
||||||
autostartPassword: sshData.autostartPassword,
|
defaultPath: hosts.defaultPath,
|
||||||
autostartKey: sshData.autostartKey,
|
autostartPassword: hosts.autostartPassword,
|
||||||
autostartKeyPassword: sshData.autostartKeyPassword,
|
autostartKey: hosts.autostartKey,
|
||||||
forceKeyboardInteractive: sshData.forceKeyboardInteractive,
|
autostartKeyPassword: hosts.autostartKeyPassword,
|
||||||
statsConfig: sshData.statsConfig,
|
forceKeyboardInteractive: hosts.forceKeyboardInteractive,
|
||||||
terminalConfig: sshData.terminalConfig,
|
statsConfig: hosts.statsConfig,
|
||||||
sudoPassword: sshData.sudoPassword,
|
terminalConfig: hosts.terminalConfig,
|
||||||
createdAt: sshData.createdAt,
|
sudoPassword: hosts.sudoPassword,
|
||||||
updatedAt: sshData.updatedAt,
|
createdAt: hosts.createdAt,
|
||||||
credentialId: sshData.credentialId,
|
updatedAt: hosts.updatedAt,
|
||||||
overrideCredentialUsername: sshData.overrideCredentialUsername,
|
credentialId: hosts.credentialId,
|
||||||
quickActions: sshData.quickActions,
|
overrideCredentialUsername: hosts.overrideCredentialUsername,
|
||||||
notes: sshData.notes,
|
quickActions: hosts.quickActions,
|
||||||
enableDocker: sshData.enableDocker,
|
notes: hosts.notes,
|
||||||
showTerminalInSidebar: sshData.showTerminalInSidebar,
|
enableDocker: hosts.enableDocker,
|
||||||
showFileManagerInSidebar: sshData.showFileManagerInSidebar,
|
showTerminalInSidebar: hosts.showTerminalInSidebar,
|
||||||
showTunnelInSidebar: sshData.showTunnelInSidebar,
|
showFileManagerInSidebar: hosts.showFileManagerInSidebar,
|
||||||
showDockerInSidebar: sshData.showDockerInSidebar,
|
showTunnelInSidebar: hosts.showTunnelInSidebar,
|
||||||
showServerStatsInSidebar: sshData.showServerStatsInSidebar,
|
showDockerInSidebar: hosts.showDockerInSidebar,
|
||||||
useSocks5: sshData.useSocks5,
|
showServerStatsInSidebar: hosts.showServerStatsInSidebar,
|
||||||
socks5Host: sshData.socks5Host,
|
useSocks5: hosts.useSocks5,
|
||||||
socks5Port: sshData.socks5Port,
|
socks5Host: hosts.socks5Host,
|
||||||
socks5Username: sshData.socks5Username,
|
socks5Port: hosts.socks5Port,
|
||||||
socks5Password: sshData.socks5Password,
|
socks5Username: hosts.socks5Username,
|
||||||
socks5ProxyChain: sshData.socks5ProxyChain,
|
socks5Password: hosts.socks5Password,
|
||||||
|
socks5ProxyChain: hosts.socks5ProxyChain,
|
||||||
|
domain: hosts.domain,
|
||||||
|
security: hosts.security,
|
||||||
|
ignoreCert: hosts.ignoreCert,
|
||||||
|
guacamoleConfig: hosts.guacamoleConfig,
|
||||||
|
|
||||||
ownerId: sshData.userId,
|
ownerId: hosts.userId,
|
||||||
isShared: sql<boolean>`${hostAccess.id} IS NOT NULL AND ${sshData.userId} != ${userId}`,
|
isShared: sql<boolean>`${hostAccess.id} IS NOT NULL AND ${hosts.userId} != ${userId}`,
|
||||||
permissionLevel: hostAccess.permissionLevel,
|
permissionLevel: hostAccess.permissionLevel,
|
||||||
expiresAt: hostAccess.expiresAt,
|
expiresAt: hostAccess.expiresAt,
|
||||||
})
|
})
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
hostAccess,
|
hostAccess,
|
||||||
and(
|
and(
|
||||||
eq(hostAccess.hostId, sshData.id),
|
eq(hostAccess.hostId, hosts.id),
|
||||||
or(
|
or(
|
||||||
eq(hostAccess.userId, userId),
|
eq(hostAccess.userId, userId),
|
||||||
roleIds.length > 0
|
roleIds.length > 0
|
||||||
@@ -1253,7 +1308,7 @@ router.get(
|
|||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
or(
|
or(
|
||||||
eq(sshData.userId, userId),
|
eq(hosts.userId, userId),
|
||||||
and(
|
and(
|
||||||
eq(hostAccess.userId, userId),
|
eq(hostAccess.userId, userId),
|
||||||
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
||||||
@@ -1361,10 +1416,14 @@ router.get(
|
|||||||
return res.status(400).json({ error: "Invalid userId or hostId" });
|
return res.status(400).json({ error: "Invalid userId or hostId" });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await db
|
const data = await SimpleDBOps.select(
|
||||||
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
|
.where(and(eq(hosts.id, Number(hostId)), eq(hosts.userId, userId))),
|
||||||
|
"ssh_data",
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
sshLogger.warn("SSH host not found", {
|
sshLogger.warn("SSH host not found", {
|
||||||
@@ -1429,37 +1488,36 @@ router.get(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hosts = await SimpleDBOps.select(
|
const hostResults = await SimpleDBOps.select(
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(
|
.where(and(eq(hosts.id, Number(hostId)), eq(hosts.userId, userId))),
|
||||||
and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)),
|
|
||||||
),
|
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hosts.length === 0) {
|
if (hostResults.length === 0) {
|
||||||
return res.status(404).json({ error: "SSH host not found" });
|
return res.status(404).json({ error: "SSH host not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = hosts[0];
|
const host = hostResults[0];
|
||||||
|
|
||||||
const resolvedHost = (await resolveHostCredentials(host, userId)) || host;
|
const resolvedHost = (await resolveHostCredentials(host, userId)) || host;
|
||||||
|
|
||||||
const exportData = {
|
const exportedConnectionType =
|
||||||
|
(resolvedHost.connectionType as string) || "ssh";
|
||||||
|
const isRemoteDesktop = ["rdp", "vnc", "telnet"].includes(
|
||||||
|
exportedConnectionType,
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseExportData = {
|
||||||
|
connectionType: exportedConnectionType,
|
||||||
name: resolvedHost.name,
|
name: resolvedHost.name,
|
||||||
ip: resolvedHost.ip,
|
ip: resolvedHost.ip,
|
||||||
port: resolvedHost.port,
|
port: resolvedHost.port,
|
||||||
username: resolvedHost.username,
|
username: resolvedHost.username,
|
||||||
authType: resolvedHost.authType,
|
|
||||||
password: resolvedHost.password || null,
|
password: resolvedHost.password || null,
|
||||||
key: resolvedHost.key || null,
|
|
||||||
keyPassword: resolvedHost.keyPassword || null,
|
|
||||||
keyType: resolvedHost.keyType || null,
|
|
||||||
credentialId: resolvedHost.credentialId || null,
|
|
||||||
overrideCredentialUsername: !!resolvedHost.overrideCredentialUsername,
|
|
||||||
folder: resolvedHost.folder,
|
folder: resolvedHost.folder,
|
||||||
tags:
|
tags:
|
||||||
typeof resolvedHost.tags === "string"
|
typeof resolvedHost.tags === "string"
|
||||||
@@ -1467,6 +1525,27 @@ router.get(
|
|||||||
: resolvedHost.tags || [],
|
: resolvedHost.tags || [],
|
||||||
pin: !!resolvedHost.pin,
|
pin: !!resolvedHost.pin,
|
||||||
notes: resolvedHost.notes || null,
|
notes: resolvedHost.notes || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportData = isRemoteDesktop
|
||||||
|
? {
|
||||||
|
...baseExportData,
|
||||||
|
domain: resolvedHost.domain || null,
|
||||||
|
security: resolvedHost.security || null,
|
||||||
|
ignoreCert: !!resolvedHost.ignoreCert,
|
||||||
|
guacamoleConfig: resolvedHost.guacamoleConfig
|
||||||
|
? JSON.parse(resolvedHost.guacamoleConfig as string)
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...baseExportData,
|
||||||
|
authType: resolvedHost.authType,
|
||||||
|
key: resolvedHost.key || null,
|
||||||
|
keyPassword: resolvedHost.keyPassword || null,
|
||||||
|
keyType: resolvedHost.keyType || null,
|
||||||
|
credentialId: resolvedHost.credentialId || null,
|
||||||
|
overrideCredentialUsername:
|
||||||
|
!!resolvedHost.overrideCredentialUsername,
|
||||||
enableTerminal: !!resolvedHost.enableTerminal,
|
enableTerminal: !!resolvedHost.enableTerminal,
|
||||||
enableTunnel: !!resolvedHost.enableTunnel,
|
enableTunnel: !!resolvedHost.enableTunnel,
|
||||||
enableFileManager: !!resolvedHost.enableFileManager,
|
enableFileManager: !!resolvedHost.enableFileManager,
|
||||||
@@ -1490,6 +1569,9 @@ router.get(
|
|||||||
statsConfig: resolvedHost.statsConfig
|
statsConfig: resolvedHost.statsConfig
|
||||||
? JSON.parse(resolvedHost.statsConfig as string)
|
? JSON.parse(resolvedHost.statsConfig as string)
|
||||||
: null,
|
: null,
|
||||||
|
dockerConfig: resolvedHost.dockerConfig
|
||||||
|
? JSON.parse(resolvedHost.dockerConfig as string)
|
||||||
|
: null,
|
||||||
terminalConfig: resolvedHost.terminalConfig
|
terminalConfig: resolvedHost.terminalConfig
|
||||||
? JSON.parse(resolvedHost.terminalConfig as string)
|
? JSON.parse(resolvedHost.terminalConfig as string)
|
||||||
: null,
|
: null,
|
||||||
@@ -1573,8 +1655,8 @@ router.delete(
|
|||||||
try {
|
try {
|
||||||
const hostToDelete = await db
|
const hostToDelete = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
|
.where(and(eq(hosts.id, Number(hostId)), eq(hosts.userId, userId)));
|
||||||
|
|
||||||
if (hostToDelete.length === 0) {
|
if (hostToDelete.length === 0) {
|
||||||
sshLogger.warn("SSH host not found for deletion", {
|
sshLogger.warn("SSH host not found for deletion", {
|
||||||
@@ -1618,8 +1700,8 @@ router.delete(
|
|||||||
.where(eq(sessionRecordings.hostId, numericHostId));
|
.where(eq(sessionRecordings.hostId, numericHostId));
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(sshData)
|
.delete(hosts)
|
||||||
.where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId)));
|
.where(and(eq(hosts.id, numericHostId), eq(hosts.userId, userId)));
|
||||||
|
|
||||||
databaseLogger.success("SSH host deleted", {
|
databaseLogger.success("SSH host deleted", {
|
||||||
operation: "host_delete_success",
|
operation: "host_delete_success",
|
||||||
@@ -1629,7 +1711,7 @@ router.delete(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const axios = (await import("axios")).default;
|
const axios = (await import("axios")).default;
|
||||||
const statsPort = process.env.STATS_PORT || 30005;
|
const statsPort = 30005;
|
||||||
await axios.post(
|
await axios.post(
|
||||||
`http://localhost:${statsPort}/host-deleted`,
|
`http://localhost:${statsPort}/host-deleted`,
|
||||||
{ hostId: numericHostId },
|
{ hostId: numericHostId },
|
||||||
@@ -2522,9 +2604,9 @@ router.put(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedHosts = await SimpleDBOps.update(
|
const updatedHosts = await SimpleDBOps.update(
|
||||||
sshData,
|
hosts,
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
and(eq(sshData.userId, userId), eq(sshData.folder, oldName)),
|
and(eq(hosts.userId, userId), eq(hosts.folder, oldName)),
|
||||||
{
|
{
|
||||||
folder: newName,
|
folder: newName,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
@@ -2748,8 +2830,8 @@ router.delete(
|
|||||||
try {
|
try {
|
||||||
const hostsToDelete = await db
|
const hostsToDelete = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
|
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
|
||||||
|
|
||||||
if (hostsToDelete.length === 0) {
|
if (hostsToDelete.length === 0) {
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -2793,8 +2875,8 @@ router.delete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(sshData)
|
.delete(hosts)
|
||||||
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
|
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(sshFolders)
|
.delete(sshFolders)
|
||||||
@@ -2806,7 +2888,7 @@ router.delete(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const axios = (await import("axios")).default;
|
const axios = (await import("axios")).default;
|
||||||
const statsPort = process.env.STATS_PORT || 30005;
|
const statsPort = 30005;
|
||||||
for (const host of hostsToDelete) {
|
for (const host of hostsToDelete) {
|
||||||
try {
|
try {
|
||||||
await axios.post(
|
await axios.post(
|
||||||
@@ -2935,9 +3017,9 @@ router.patch(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const ownedHosts = await db
|
const ownedHosts = await db
|
||||||
.select({ id: sshData.id, statsConfig: sshData.statsConfig })
|
.select({ id: hosts.id, statsConfig: hosts.statsConfig })
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(and(inArray(sshData.id, hostIds), eq(sshData.userId, userId)));
|
.where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)));
|
||||||
|
|
||||||
const ownedIds = ownedHosts.map((h) => h.id);
|
const ownedIds = ownedHosts.map((h) => h.id);
|
||||||
const unauthorizedIds = hostIds.filter(
|
const unauthorizedIds = hostIds.filter(
|
||||||
@@ -2968,11 +3050,9 @@ router.patch(
|
|||||||
|
|
||||||
if (Object.keys(simpleUpdates).length > 0) {
|
if (Object.keys(simpleUpdates).length > 0) {
|
||||||
await db
|
await db
|
||||||
.update(sshData)
|
.update(hosts)
|
||||||
.set(simpleUpdates)
|
.set(simpleUpdates)
|
||||||
.where(
|
.where(and(inArray(hosts.id, ownedIds), eq(hosts.userId, userId)));
|
||||||
and(inArray(sshData.id, ownedIds), eq(sshData.userId, userId)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updates.statsConfig && typeof updates.statsConfig === "object") {
|
if (updates.statsConfig && typeof updates.statsConfig === "object") {
|
||||||
@@ -2983,9 +3063,9 @@ router.patch(
|
|||||||
: {};
|
: {};
|
||||||
const merged = { ...existing, ...updates.statsConfig };
|
const merged = { ...existing, ...updates.statsConfig };
|
||||||
await db
|
await db
|
||||||
.update(sshData)
|
.update(hosts)
|
||||||
.set({ statsConfig: JSON.stringify(merged) })
|
.set({ statsConfig: JSON.stringify(merged) })
|
||||||
.where(and(eq(sshData.id, host.id), eq(sshData.userId, userId)));
|
.where(and(eq(hosts.id, host.id), eq(hosts.userId, userId)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errors.push(`Failed to update statsConfig for host ${host.id}`);
|
errors.push(`Failed to update statsConfig for host ${host.id}`);
|
||||||
}
|
}
|
||||||
@@ -3011,15 +3091,15 @@ router.post(
|
|||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
const { hosts, overwrite } = req.body;
|
const { hosts: hostsToImport, overwrite } = req.body;
|
||||||
|
|
||||||
if (!Array.isArray(hosts) || hosts.length === 0) {
|
if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) {
|
||||||
return res
|
return res
|
||||||
.status(400)
|
.status(400)
|
||||||
.json({ error: "Hosts array is required and must not be empty" });
|
.json({ error: "Hosts array is required and must not be empty" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hosts.length > 100) {
|
if (hostsToImport.length > 100) {
|
||||||
return res
|
return res
|
||||||
.status(400)
|
.status(400)
|
||||||
.json({ error: "Maximum 100 hosts allowed per import" });
|
.json({ error: "Maximum 100 hosts allowed per import" });
|
||||||
@@ -3037,7 +3117,7 @@ router.post(
|
|||||||
if (overwrite) {
|
if (overwrite) {
|
||||||
try {
|
try {
|
||||||
const allHosts = await SimpleDBOps.select<Record<string, unknown>>(
|
const allHosts = await SimpleDBOps.select<Record<string, unknown>>(
|
||||||
db.select().from(sshData).where(eq(sshData.userId, userId)),
|
db.select().from(hosts).where(eq(hosts.userId, userId)),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
@@ -3051,23 +3131,34 @@ router.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < hosts.length; i++) {
|
for (let i = 0; i < hostsToImport.length; i++) {
|
||||||
const hostData = hosts[i];
|
const hostData = hostsToImport[i];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (
|
const effectiveConnectionType = hostData.connectionType || "ssh";
|
||||||
!isNonEmptyString(hostData.ip) ||
|
|
||||||
!isValidPort(hostData.port) ||
|
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
|
||||||
!isNonEmptyString(hostData.username)
|
|
||||||
) {
|
|
||||||
results.failed++;
|
results.failed++;
|
||||||
results.errors.push(
|
results.errors.push(
|
||||||
`Host ${i + 1}: Missing required fields (ip, port, username)`,
|
`Host ${i + 1}: Missing required fields (ip, port)`,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
effectiveConnectionType === "ssh" &&
|
||||||
|
!isNonEmptyString(hostData.username)
|
||||||
|
) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(
|
||||||
|
`Host ${i + 1}: Username required for SSH connections`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
effectiveConnectionType === "ssh" &&
|
||||||
|
hostData.authType &&
|
||||||
!["password", "key", "credential", "none", "opkssh"].includes(
|
!["password", "key", "credential", "none", "opkssh"].includes(
|
||||||
hostData.authType,
|
hostData.authType,
|
||||||
)
|
)
|
||||||
@@ -3080,6 +3171,7 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
effectiveConnectionType === "ssh" &&
|
||||||
hostData.authType === "password" &&
|
hostData.authType === "password" &&
|
||||||
!isNonEmptyString(hostData.password)
|
!isNonEmptyString(hostData.password)
|
||||||
) {
|
) {
|
||||||
@@ -3090,7 +3182,11 @@ router.post(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hostData.authType === "key" && !isNonEmptyString(hostData.key)) {
|
if (
|
||||||
|
effectiveConnectionType === "ssh" &&
|
||||||
|
hostData.authType === "key" &&
|
||||||
|
!isNonEmptyString(hostData.key)
|
||||||
|
) {
|
||||||
results.failed++;
|
results.failed++;
|
||||||
results.errors.push(
|
results.errors.push(
|
||||||
`Host ${i + 1}: Key required for key authentication`,
|
`Host ${i + 1}: Key required for key authentication`,
|
||||||
@@ -3098,7 +3194,11 @@ router.post(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hostData.authType === "credential" && !hostData.credentialId) {
|
if (
|
||||||
|
effectiveConnectionType === "ssh" &&
|
||||||
|
hostData.authType === "credential" &&
|
||||||
|
!hostData.credentialId
|
||||||
|
) {
|
||||||
results.failed++;
|
results.failed++;
|
||||||
results.errors.push(
|
results.errors.push(
|
||||||
`Host ${i + 1}: credentialId required for credential authentication`,
|
`Host ${i + 1}: credentialId required for credential authentication`,
|
||||||
@@ -3108,21 +3208,13 @@ router.post(
|
|||||||
|
|
||||||
const sshDataObj: Record<string, unknown> = {
|
const sshDataObj: Record<string, unknown> = {
|
||||||
userId: userId,
|
userId: userId,
|
||||||
name: hostData.name || `${hostData.username}@${hostData.ip}`,
|
connectionType: effectiveConnectionType,
|
||||||
|
name: hostData.name || `${hostData.username || ""}@${hostData.ip}`,
|
||||||
folder: hostData.folder || "Default",
|
folder: hostData.folder || "Default",
|
||||||
tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "",
|
tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "",
|
||||||
ip: hostData.ip,
|
ip: hostData.ip,
|
||||||
port: hostData.port,
|
port: hostData.port,
|
||||||
username: hostData.username,
|
username: hostData.username || null,
|
||||||
password: hostData.authType === "password" ? hostData.password : null,
|
|
||||||
authType: hostData.authType,
|
|
||||||
credentialId:
|
|
||||||
hostData.authType === "credential" ? hostData.credentialId : null,
|
|
||||||
key: hostData.authType === "key" ? hostData.key : null,
|
|
||||||
keyPassword:
|
|
||||||
hostData.authType === "key" ? hostData.keyPassword || null : null,
|
|
||||||
keyType:
|
|
||||||
hostData.authType === "key" ? hostData.keyType || "auto" : null,
|
|
||||||
pin: hostData.pin || false,
|
pin: hostData.pin || false,
|
||||||
enableTerminal: hostData.enableTerminal !== false,
|
enableTerminal: hostData.enableTerminal !== false,
|
||||||
enableTunnel: hostData.enableTunnel !== false,
|
enableTunnel: hostData.enableTunnel !== false,
|
||||||
@@ -3147,6 +3239,9 @@ router.post(
|
|||||||
statsConfig: hostData.statsConfig
|
statsConfig: hostData.statsConfig
|
||||||
? JSON.stringify(hostData.statsConfig)
|
? JSON.stringify(hostData.statsConfig)
|
||||||
: null,
|
: null,
|
||||||
|
dockerConfig: hostData.dockerConfig
|
||||||
|
? JSON.stringify(hostData.dockerConfig)
|
||||||
|
: null,
|
||||||
terminalConfig: hostData.terminalConfig
|
terminalConfig: hostData.terminalConfig
|
||||||
? JSON.stringify(hostData.terminalConfig)
|
? JSON.stringify(hostData.terminalConfig)
|
||||||
: null,
|
: null,
|
||||||
@@ -3168,21 +3263,51 @@ router.post(
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (effectiveConnectionType !== "ssh") {
|
||||||
|
sshDataObj.password = hostData.password || null;
|
||||||
|
sshDataObj.authType = "password";
|
||||||
|
sshDataObj.credentialId = null;
|
||||||
|
sshDataObj.key = null;
|
||||||
|
sshDataObj.keyPassword = null;
|
||||||
|
sshDataObj.keyType = null;
|
||||||
|
sshDataObj.domain = hostData.domain || null;
|
||||||
|
sshDataObj.security = hostData.security || null;
|
||||||
|
sshDataObj.ignoreCert = hostData.ignoreCert ? 1 : 0;
|
||||||
|
sshDataObj.guacamoleConfig = hostData.guacamoleConfig
|
||||||
|
? JSON.stringify(hostData.guacamoleConfig)
|
||||||
|
: null;
|
||||||
|
} else {
|
||||||
|
sshDataObj.password =
|
||||||
|
hostData.authType === "password" ? hostData.password : null;
|
||||||
|
sshDataObj.authType = hostData.authType || "password";
|
||||||
|
sshDataObj.credentialId =
|
||||||
|
hostData.authType === "credential" ? hostData.credentialId : null;
|
||||||
|
sshDataObj.key = hostData.authType === "key" ? hostData.key : null;
|
||||||
|
sshDataObj.keyPassword =
|
||||||
|
hostData.authType === "key" ? hostData.keyPassword || null : null;
|
||||||
|
sshDataObj.keyType =
|
||||||
|
hostData.authType === "key" ? hostData.keyType || "auto" : null;
|
||||||
|
sshDataObj.domain = null;
|
||||||
|
sshDataObj.security = null;
|
||||||
|
sshDataObj.ignoreCert = 0;
|
||||||
|
sshDataObj.guacamoleConfig = null;
|
||||||
|
}
|
||||||
|
|
||||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||||
const existing = existingHostMap?.get(lookupKey);
|
const existing = existingHostMap?.get(lookupKey);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await SimpleDBOps.update(
|
await SimpleDBOps.update(
|
||||||
sshData,
|
hosts,
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
eq(sshData.id, existing.id),
|
eq(hosts.id, existing.id),
|
||||||
sshDataObj,
|
sshDataObj,
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
results.updated++;
|
results.updated++;
|
||||||
} else {
|
} else {
|
||||||
sshDataObj.createdAt = new Date().toISOString();
|
sshDataObj.createdAt = new Date().toISOString();
|
||||||
await SimpleDBOps.insert(sshData, "ssh_data", sshDataObj, userId);
|
await SimpleDBOps.insert(hosts, "ssh_data", sshDataObj, userId);
|
||||||
results.success++;
|
results.success++;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -3204,6 +3329,104 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /ssh/folders/{folderName}/hosts:
|
||||||
|
* delete:
|
||||||
|
* summary: Delete all hosts in a folder
|
||||||
|
* description: Deletes all hosts within a specific folder.
|
||||||
|
* tags:
|
||||||
|
* - SSH
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: folderName
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: All hosts deleted successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid folder name.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to delete hosts.
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
"/folders/:folderName/hosts",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const folderName = decodeURIComponent(
|
||||||
|
Array.isArray(req.params.folderName)
|
||||||
|
? req.params.folderName[0]
|
||||||
|
: req.params.folderName,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!folderName) {
|
||||||
|
return res.status(400).json({ error: "Folder name is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hostsToDelete = await db
|
||||||
|
.select({ id: hosts.id })
|
||||||
|
.from(hosts)
|
||||||
|
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
|
||||||
|
|
||||||
|
if (hostsToDelete.length === 0) {
|
||||||
|
return res.json({ deletedCount: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostIds = hostsToDelete.map((h) => h.id);
|
||||||
|
|
||||||
|
for (const hostId of hostIds) {
|
||||||
|
await db
|
||||||
|
.delete(fileManagerRecent)
|
||||||
|
.where(eq(fileManagerRecent.hostId, hostId));
|
||||||
|
await db
|
||||||
|
.delete(fileManagerPinned)
|
||||||
|
.where(eq(fileManagerPinned.hostId, hostId));
|
||||||
|
await db
|
||||||
|
.delete(fileManagerShortcuts)
|
||||||
|
.where(eq(fileManagerShortcuts.hostId, hostId));
|
||||||
|
await db
|
||||||
|
.delete(commandHistory)
|
||||||
|
.where(eq(commandHistory.hostId, hostId));
|
||||||
|
await db
|
||||||
|
.delete(sshCredentialUsage)
|
||||||
|
.where(eq(sshCredentialUsage.hostId, hostId));
|
||||||
|
await db
|
||||||
|
.delete(recentActivity)
|
||||||
|
.where(eq(recentActivity.hostId, hostId));
|
||||||
|
await db.delete(hostAccess).where(eq(hostAccess.hostId, hostId));
|
||||||
|
await db
|
||||||
|
.delete(sessionRecordings)
|
||||||
|
.where(eq(sessionRecordings.hostId, hostId));
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(hosts)
|
||||||
|
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
|
||||||
|
|
||||||
|
databaseLogger.success("All hosts in folder deleted", {
|
||||||
|
operation: "delete_folder_hosts",
|
||||||
|
userId,
|
||||||
|
folderName,
|
||||||
|
deletedCount: hostsToDelete.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ deletedCount: hostsToDelete.length });
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Failed to delete hosts in folder", error, {
|
||||||
|
operation: "delete_folder_hosts",
|
||||||
|
userId,
|
||||||
|
folderName,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to delete hosts in folder" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /ssh/autostart/enable:
|
* /ssh/autostart/enable:
|
||||||
@@ -3270,8 +3493,8 @@ router.post(
|
|||||||
|
|
||||||
const sshConfig = await db
|
const sshConfig = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(and(eq(sshData.id, sshConfigId), eq(sshData.userId, userId)));
|
.where(and(eq(hosts.id, sshConfigId), eq(hosts.userId, userId)));
|
||||||
|
|
||||||
if (sshConfig.length === 0) {
|
if (sshConfig.length === 0) {
|
||||||
sshLogger.warn("SSH config not found for autostart enable", {
|
sshLogger.warn("SSH config not found for autostart enable", {
|
||||||
@@ -3309,8 +3532,8 @@ router.post(
|
|||||||
) {
|
) {
|
||||||
const endpointHosts = await db
|
const endpointHosts = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(eq(sshData.userId, userId));
|
.where(eq(hosts.userId, userId));
|
||||||
|
|
||||||
const endpointHost = endpointHosts.find(
|
const endpointHost = endpointHosts.find(
|
||||||
(h) =>
|
(h) =>
|
||||||
@@ -3349,14 +3572,14 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(sshData)
|
.update(hosts)
|
||||||
.set({
|
.set({
|
||||||
autostartPassword: decryptedConfig.password || null,
|
autostartPassword: decryptedConfig.password || null,
|
||||||
autostartKey: decryptedConfig.key || null,
|
autostartKey: decryptedConfig.key || null,
|
||||||
autostartKeyPassword: decryptedConfig.keyPassword || null,
|
autostartKeyPassword: decryptedConfig.keyPassword || null,
|
||||||
tunnelConnections: updatedTunnelConnections,
|
tunnelConnections: updatedTunnelConnections,
|
||||||
})
|
})
|
||||||
.where(eq(sshData.id, sshConfigId));
|
.where(eq(hosts.id, sshConfigId));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await DatabaseSaveTrigger.triggerSave();
|
await DatabaseSaveTrigger.triggerSave();
|
||||||
@@ -3429,13 +3652,13 @@ router.delete(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await db
|
await db
|
||||||
.update(sshData)
|
.update(hosts)
|
||||||
.set({
|
.set({
|
||||||
autostartPassword: null,
|
autostartPassword: null,
|
||||||
autostartKey: null,
|
autostartKey: null,
|
||||||
autostartKeyPassword: null,
|
autostartKeyPassword: null,
|
||||||
})
|
})
|
||||||
.where(and(eq(sshData.id, sshConfigId), eq(sshData.userId, userId)));
|
.where(and(eq(hosts.id, sshConfigId), eq(hosts.userId, userId)));
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: "AutoStart disabled successfully",
|
message: "AutoStart disabled successfully",
|
||||||
@@ -3475,13 +3698,13 @@ router.get(
|
|||||||
try {
|
try {
|
||||||
const autostartConfigs = await db
|
const autostartConfigs = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(sshData.userId, userId),
|
eq(hosts.userId, userId),
|
||||||
or(
|
or(
|
||||||
isNotNull(sshData.autostartPassword),
|
isNotNull(hosts.autostartPassword),
|
||||||
isNotNull(sshData.autostartKey),
|
isNotNull(hosts.autostartKey),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -3,7 +3,7 @@ import express from "express";
|
|||||||
import { db } from "../db/index.js";
|
import { db } from "../db/index.js";
|
||||||
import {
|
import {
|
||||||
hostAccess,
|
hostAccess,
|
||||||
sshData,
|
hosts,
|
||||||
users,
|
users,
|
||||||
roles,
|
roles,
|
||||||
userRoles,
|
userRoles,
|
||||||
@@ -111,8 +111,8 @@ router.post(
|
|||||||
|
|
||||||
const host = await db
|
const host = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (host.length === 0) {
|
if (host.length === 0) {
|
||||||
@@ -201,9 +201,8 @@ router.post(
|
|||||||
.delete(sharedCredentials)
|
.delete(sharedCredentials)
|
||||||
.where(eq(sharedCredentials.hostAccessId, existing[0].id));
|
.where(eq(sharedCredentials.hostAccessId, existing[0].id));
|
||||||
|
|
||||||
const { SharedCredentialManager } = await import(
|
const { SharedCredentialManager } =
|
||||||
"../../utils/shared-credential-manager.js"
|
await import("../../utils/shared-credential-manager.js");
|
||||||
);
|
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||||
if (targetType === "user") {
|
if (targetType === "user") {
|
||||||
await sharedCredManager.createSharedCredentialForUser(
|
await sharedCredManager.createSharedCredentialForUser(
|
||||||
@@ -244,9 +243,8 @@ router.post(
|
|||||||
expiresAt,
|
expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { SharedCredentialManager } = await import(
|
const { SharedCredentialManager } =
|
||||||
"../../utils/shared-credential-manager.js"
|
await import("../../utils/shared-credential-manager.js");
|
||||||
);
|
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||||
|
|
||||||
if (targetType === "user") {
|
if (targetType === "user") {
|
||||||
@@ -336,8 +334,8 @@ router.delete(
|
|||||||
try {
|
try {
|
||||||
const host = await db
|
const host = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (host.length === 0) {
|
if (host.length === 0) {
|
||||||
@@ -404,8 +402,8 @@ router.get(
|
|||||||
try {
|
try {
|
||||||
const host = await db
|
const host = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (host.length === 0) {
|
if (host.length === 0) {
|
||||||
@@ -484,21 +482,21 @@ router.get(
|
|||||||
|
|
||||||
const sharedHosts = await db
|
const sharedHosts = await db
|
||||||
.select({
|
.select({
|
||||||
id: sshData.id,
|
id: hosts.id,
|
||||||
name: sshData.name,
|
name: hosts.name,
|
||||||
ip: sshData.ip,
|
ip: hosts.ip,
|
||||||
port: sshData.port,
|
port: hosts.port,
|
||||||
username: sshData.username,
|
username: hosts.username,
|
||||||
folder: sshData.folder,
|
folder: hosts.folder,
|
||||||
tags: sshData.tags,
|
tags: hosts.tags,
|
||||||
permissionLevel: hostAccess.permissionLevel,
|
permissionLevel: hostAccess.permissionLevel,
|
||||||
expiresAt: hostAccess.expiresAt,
|
expiresAt: hostAccess.expiresAt,
|
||||||
grantedBy: hostAccess.grantedBy,
|
grantedBy: hostAccess.grantedBy,
|
||||||
ownerUsername: users.username,
|
ownerUsername: users.username,
|
||||||
})
|
})
|
||||||
.from(hostAccess)
|
.from(hostAccess)
|
||||||
.innerJoin(sshData, eq(hostAccess.hostId, sshData.id))
|
.innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
|
||||||
.innerJoin(users, eq(sshData.userId, users.id))
|
.innerJoin(users, eq(hosts.userId, users.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(hostAccess.userId, userId),
|
eq(hostAccess.userId, userId),
|
||||||
@@ -978,12 +976,11 @@ router.post(
|
|||||||
const hostsSharedWithRole = await db
|
const hostsSharedWithRole = await db
|
||||||
.select()
|
.select()
|
||||||
.from(hostAccess)
|
.from(hostAccess)
|
||||||
.innerJoin(sshData, eq(hostAccess.hostId, sshData.id))
|
.innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
|
||||||
.where(eq(hostAccess.roleId, roleId));
|
.where(eq(hostAccess.roleId, roleId));
|
||||||
|
|
||||||
const { SharedCredentialManager } = await import(
|
const { SharedCredentialManager } =
|
||||||
"../../utils/shared-credential-manager.js"
|
await import("../../utils/shared-credential-manager.js");
|
||||||
);
|
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||||
|
|
||||||
for (const { host_access, ssh_data } of hostsSharedWithRole) {
|
for (const { host_access, ssh_data } of hostsSharedWithRole) {
|
||||||
|
|||||||
@@ -632,17 +632,15 @@ router.post(
|
|||||||
const snippet = snippetResult[0];
|
const snippet = snippetResult[0];
|
||||||
|
|
||||||
const { Client } = await import("ssh2");
|
const { Client } = await import("ssh2");
|
||||||
const { sshData, sshCredentials } = await import("../db/schema.js");
|
const { hosts, sshCredentials } = await import("../db/schema.js");
|
||||||
|
|
||||||
const { SimpleDBOps } = await import("../../utils/simple-db-ops.js");
|
const { SimpleDBOps } = await import("../../utils/simple-db-ops.js");
|
||||||
|
|
||||||
const hostResult = await SimpleDBOps.select(
|
const hostResult = await SimpleDBOps.select(
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(
|
.where(and(eq(hosts.id, parseInt(hostId)), eq(hosts.userId, userId))),
|
||||||
and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
|
|
||||||
),
|
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { restartGuacServer } from "../../guacamole/guacamole-server.js";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { db } from "../db/index.js";
|
import { db } from "../db/index.js";
|
||||||
import {
|
import {
|
||||||
users,
|
users,
|
||||||
sessions,
|
sessions,
|
||||||
sshData,
|
hosts,
|
||||||
sshCredentials,
|
sshCredentials,
|
||||||
fileManagerRecent,
|
fileManagerRecent,
|
||||||
fileManagerPinned,
|
fileManagerPinned,
|
||||||
@@ -265,7 +266,7 @@ async function deleteUserAndRelatedData(userId: string): Promise<void> {
|
|||||||
|
|
||||||
await db.delete(commandHistory).where(eq(commandHistory.userId, userId));
|
await db.delete(commandHistory).where(eq(commandHistory.userId, userId));
|
||||||
|
|
||||||
await db.delete(sshData).where(eq(sshData.userId, userId));
|
await db.delete(hosts).where(eq(hosts.userId, userId));
|
||||||
await db.delete(sshCredentials).where(eq(sshCredentials.userId, userId));
|
await db.delete(sshCredentials).where(eq(sshCredentials.userId, userId));
|
||||||
|
|
||||||
await db.delete(networkTopology).where(eq(networkTopology.userId, userId));
|
await db.delete(networkTopology).where(eq(networkTopology.userId, userId));
|
||||||
@@ -789,6 +790,12 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
|
|||||||
* description: Returns the OIDC authorization URL.
|
* description: Returns the OIDC authorization URL.
|
||||||
* tags:
|
* tags:
|
||||||
* - Users
|
* - Users
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: rememberMe
|
||||||
|
* schema:
|
||||||
|
* type: boolean
|
||||||
|
* description: Whether to extend the session to 30 days instead of 2 hours.
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: OIDC authorization URL.
|
* description: OIDC authorization URL.
|
||||||
@@ -799,29 +806,10 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.get("/oidc/authorize", async (req, res) => {
|
router.get("/oidc/authorize", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const { rememberMe } = req.query;
|
||||||
const origin = getRequestOriginWithForceHTTPS(req);
|
const origin = getRequestOriginWithForceHTTPS(req);
|
||||||
const backendCallbackUri = `${origin}/users/oidc/callback`;
|
const backendCallbackUri = `${origin}/users/oidc/callback`;
|
||||||
|
|
||||||
authLogger.info("OIDC authorize request headers", {
|
|
||||||
protocol: req.protocol,
|
|
||||||
host: req.get("Host"),
|
|
||||||
origin: req.get("Origin"),
|
|
||||||
referer: req.get("Referer"),
|
|
||||||
"x-forwarded-proto": req.get("X-Forwarded-Proto"),
|
|
||||||
"x-forwarded-host": req.get("X-Forwarded-Host"),
|
|
||||||
"x-forwarded-port": req.get("X-Forwarded-Port"),
|
|
||||||
secure: req.secure,
|
|
||||||
calculatedOrigin: origin,
|
|
||||||
backendCallbackUri: backendCallbackUri,
|
|
||||||
});
|
|
||||||
|
|
||||||
authLogger.info(
|
|
||||||
`\n${"=".repeat(68)}\n` +
|
|
||||||
` OIDC CALLBACK URL - Register this in your OAuth provider:\n` +
|
|
||||||
` ${backendCallbackUri}\n` +
|
|
||||||
`${"=".repeat(68)}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const envConfig = getOIDCConfigFromEnv();
|
const envConfig = getOIDCConfigFromEnv();
|
||||||
let config;
|
let config;
|
||||||
|
|
||||||
@@ -860,12 +848,12 @@ router.get("/oidc/authorize", async (req, res) => {
|
|||||||
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||||
.run(`oidc_frontend_origin_${state}`, frontendOrigin);
|
.run(`oidc_frontend_origin_${state}`, frontendOrigin);
|
||||||
|
|
||||||
authLogger.info("OIDC authorization initiated", {
|
db.$client
|
||||||
operation: "oidc_authorize",
|
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||||
backendCallbackUri,
|
.run(
|
||||||
frontendOrigin,
|
`oidc_remember_me_${state}`,
|
||||||
referer,
|
rememberMe === "true" ? "true" : "false",
|
||||||
});
|
);
|
||||||
|
|
||||||
const authUrl = new URL(config.authorization_url);
|
const authUrl = new URL(config.authorization_url);
|
||||||
authUrl.searchParams.set("client_id", config.client_id);
|
authUrl.searchParams.set("client_id", config.client_id);
|
||||||
@@ -898,10 +886,6 @@ router.get("/oidc/authorize", async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.get("/oidc/callback", async (req, res) => {
|
router.get("/oidc/callback", async (req, res) => {
|
||||||
const { code, state } = req.query;
|
const { code, state } = req.query;
|
||||||
authLogger.info("OIDC login callback received", {
|
|
||||||
operation: "oidc_login_request",
|
|
||||||
state,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isNonEmptyString(code) || !isNonEmptyString(state)) {
|
if (!isNonEmptyString(code) || !isNonEmptyString(state)) {
|
||||||
return res.status(400).json({ error: "Code and state are required" });
|
return res.status(400).json({ error: "Code and state are required" });
|
||||||
@@ -913,6 +897,9 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
const storedFrontendOriginRow = db.$client
|
const storedFrontendOriginRow = db.$client
|
||||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||||
.get(`oidc_frontend_origin_${state}`);
|
.get(`oidc_frontend_origin_${state}`);
|
||||||
|
const storedRememberMeRow = db.$client
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||||
|
.get(`oidc_remember_me_${state}`);
|
||||||
|
|
||||||
if (!storedBackendCallbackRow || !storedFrontendOriginRow) {
|
if (!storedBackendCallbackRow || !storedFrontendOriginRow) {
|
||||||
return res
|
return res
|
||||||
@@ -925,6 +912,8 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
).value as string;
|
).value as string;
|
||||||
const frontendOrigin = (storedFrontendOriginRow as Record<string, unknown>)
|
const frontendOrigin = (storedFrontendOriginRow as Record<string, unknown>)
|
||||||
.value as string;
|
.value as string;
|
||||||
|
const storedRememberMe =
|
||||||
|
(storedRememberMeRow as Record<string, unknown> | null)?.value === "true";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const storedNonce = db.$client
|
const storedNonce = db.$client
|
||||||
@@ -951,12 +940,6 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
authLogger.info("OIDC token exchange attempt", {
|
|
||||||
operation: "oidc_token_exchange",
|
|
||||||
backendCallbackUri,
|
|
||||||
frontendOrigin,
|
|
||||||
});
|
|
||||||
|
|
||||||
const tokenResponse = await fetch(config.token_url, {
|
const tokenResponse = await fetch(config.token_url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -998,6 +981,9 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
db.$client
|
db.$client
|
||||||
.prepare("DELETE FROM settings WHERE key = ?")
|
.prepare("DELETE FROM settings WHERE key = ?")
|
||||||
.run(`oidc_frontend_origin_${state}`);
|
.run(`oidc_frontend_origin_${state}`);
|
||||||
|
db.$client
|
||||||
|
.prepare("DELETE FROM settings WHERE key = ?")
|
||||||
|
.run(`oidc_remember_me_${state}`);
|
||||||
|
|
||||||
let userInfo: Record<string, unknown> = null;
|
let userInfo: Record<string, unknown> = null;
|
||||||
const userInfoUrls: string[] = [];
|
const userInfoUrls: string[] = [];
|
||||||
@@ -1320,6 +1306,7 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||||
deviceType: deviceInfo.type,
|
deviceType: deviceInfo.type,
|
||||||
deviceInfo: deviceInfo.deviceInfo,
|
deviceInfo: deviceInfo.deviceInfo,
|
||||||
|
rememberMe: storedRememberMe,
|
||||||
});
|
});
|
||||||
|
|
||||||
authLogger.success("OIDC login successful", {
|
authLogger.success("OIDC login successful", {
|
||||||
@@ -1333,6 +1320,8 @@ router.get("/oidc/callback", async (req, res) => {
|
|||||||
|
|
||||||
const maxAge =
|
const maxAge =
|
||||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||||
|
? 30 * 24 * 60 * 60 * 1000
|
||||||
|
: storedRememberMe
|
||||||
? 30 * 24 * 60 * 60 * 1000
|
? 30 * 24 * 60 * 60 * 1000
|
||||||
: 2 * 60 * 60 * 1000;
|
: 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
@@ -2488,7 +2477,7 @@ router.post("/complete-reset", async (req, res) => {
|
|||||||
.delete(dismissedAlerts)
|
.delete(dismissedAlerts)
|
||||||
.where(eq(dismissedAlerts.userId, userId));
|
.where(eq(dismissedAlerts.userId, userId));
|
||||||
await db.delete(snippets).where(eq(snippets.userId, userId));
|
await db.delete(snippets).where(eq(snippets.userId, userId));
|
||||||
await db.delete(sshData).where(eq(sshData.userId, userId));
|
await db.delete(hosts).where(eq(hosts.userId, userId));
|
||||||
await db
|
await db
|
||||||
.delete(sshCredentials)
|
.delete(sshCredentials)
|
||||||
.where(eq(sshCredentials.userId, userId));
|
.where(eq(sshCredentials.userId, userId));
|
||||||
@@ -4126,4 +4115,115 @@ router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/guacamole-settings:
|
||||||
|
* get:
|
||||||
|
* summary: Get Guacamole settings
|
||||||
|
* description: Returns current guacd enabled status and host:port URL. No authentication required.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Guacamole settings.
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* enabled:
|
||||||
|
* type: boolean
|
||||||
|
* url:
|
||||||
|
* type: string
|
||||||
|
* 500:
|
||||||
|
* description: Failed to get guacamole settings.
|
||||||
|
*/
|
||||||
|
router.get("/guacamole-settings", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const enabledRow = db.$client
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
const urlRow = db.$client
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
res.json({
|
||||||
|
enabled: enabledRow ? enabledRow.value !== "false" : true,
|
||||||
|
url: urlRow ? urlRow.value : "guacd:4822",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to get guacamole settings", err);
|
||||||
|
res.status(500).json({ error: "Failed to get guacamole settings" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/guacamole-settings:
|
||||||
|
* patch:
|
||||||
|
* summary: Update Guacamole settings
|
||||||
|
* description: Admin-only. Updates guacd enabled status and/or host:port URL.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* enabled:
|
||||||
|
* type: boolean
|
||||||
|
* url:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Guacamole settings updated.
|
||||||
|
* 403:
|
||||||
|
* description: Not authorized.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to update guacamole settings.
|
||||||
|
*/
|
||||||
|
router.patch("/guacamole-settings", authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||||
|
if (!user || user.length === 0 || !user[0].isAdmin) {
|
||||||
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
|
}
|
||||||
|
const { enabled, url } = req.body;
|
||||||
|
if (typeof enabled === "boolean") {
|
||||||
|
db.$client
|
||||||
|
.prepare(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_enabled', ?)",
|
||||||
|
)
|
||||||
|
.run(enabled ? "true" : "false");
|
||||||
|
}
|
||||||
|
if (typeof url === "string") {
|
||||||
|
db.$client
|
||||||
|
.prepare(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_url', ?)",
|
||||||
|
)
|
||||||
|
.run(url);
|
||||||
|
try {
|
||||||
|
await restartGuacServer();
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to restart guac server after URL update", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const enabledRow = db.$client
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
const urlRow = db.$client
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
res.json({
|
||||||
|
enabled: enabledRow ? enabledRow.value !== "false" : true,
|
||||||
|
url: urlRow ? urlRow.value : "guacd:4822",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to update guacamole settings", err);
|
||||||
|
res.status(500).json({ error: "Failed to update guacamole settings" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import GuacamoleLite from "guacamole-lite";
|
||||||
|
import { parse as parseUrl } from "url";
|
||||||
|
import { guacLogger } from "../utils/logger.js";
|
||||||
|
import { AuthManager } from "../utils/auth-manager.js";
|
||||||
|
import { GuacamoleTokenService } from "./token-service.js";
|
||||||
|
import { getDb } from "../database/db/index.js";
|
||||||
|
import type { IncomingMessage } from "http";
|
||||||
|
|
||||||
|
const authManager = AuthManager.getInstance();
|
||||||
|
const tokenService = GuacamoleTokenService.getInstance();
|
||||||
|
|
||||||
|
function parseGuacUrl(url: string): { host: string; port: number } {
|
||||||
|
const parts = url.split(":");
|
||||||
|
return {
|
||||||
|
host: parts[0] || "localhost",
|
||||||
|
port: parseInt(parts[1] || "4822", 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readGuacdOptions(): { host: string; port: number } {
|
||||||
|
let host = process.env.GUACD_HOST || "localhost";
|
||||||
|
let port = parseInt(process.env.GUACD_PORT || "4822", 10);
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const urlRow = db.$client
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
if (urlRow?.value) {
|
||||||
|
const parsed = parseGuacUrl(urlRow.value);
|
||||||
|
host = parsed.host;
|
||||||
|
port = parsed.port;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// DB not available yet, use env var defaults
|
||||||
|
}
|
||||||
|
return { host, port };
|
||||||
|
}
|
||||||
|
|
||||||
|
const GUAC_WS_PORT = 30008;
|
||||||
|
|
||||||
|
const websocketOptions = {
|
||||||
|
port: GUAC_WS_PORT,
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientOptions = {
|
||||||
|
crypt: {
|
||||||
|
cypher: "AES-256-CBC",
|
||||||
|
key: tokenService.getEncryptionKey(),
|
||||||
|
},
|
||||||
|
log: {
|
||||||
|
level: "ERRORS",
|
||||||
|
stdLog: (...args: unknown[]) => {
|
||||||
|
guacLogger.info(args.join(" "));
|
||||||
|
},
|
||||||
|
errorLog: (...args: unknown[]) => {
|
||||||
|
guacLogger.error(args.join(" "));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
allowedUnencryptedConnectionSettings: {
|
||||||
|
rdp: ["width", "height", "dpi"],
|
||||||
|
vnc: ["width", "height", "dpi"],
|
||||||
|
telnet: ["width", "height"],
|
||||||
|
},
|
||||||
|
connectionDefaultSettings: {
|
||||||
|
rdp: {
|
||||||
|
security: "any",
|
||||||
|
"ignore-cert": true,
|
||||||
|
"enable-wallpaper": false,
|
||||||
|
"enable-font-smoothing": true,
|
||||||
|
"enable-desktop-composition": false,
|
||||||
|
"disable-audio": false,
|
||||||
|
"enable-drive": false,
|
||||||
|
"resize-method": "display-update",
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
dpi: 96,
|
||||||
|
},
|
||||||
|
vnc: {
|
||||||
|
"swap-red-blue": false,
|
||||||
|
cursor: "remote",
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
},
|
||||||
|
telnet: {
|
||||||
|
"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);
|
||||||
|
};
|
||||||
|
|
||||||
|
function createGuacServer(): GuacamoleLite {
|
||||||
|
const guacdOptions = readGuacdOptions();
|
||||||
|
const server = new GuacamoleLite(
|
||||||
|
websocketOptions,
|
||||||
|
guacdOptions,
|
||||||
|
clientOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
server.on(
|
||||||
|
"open",
|
||||||
|
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
||||||
|
guacLogger.info("Guacamole connection opened", {
|
||||||
|
operation: "guac_connection_open",
|
||||||
|
type: clientConnection.connectionSettings?.type,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.on(
|
||||||
|
"close",
|
||||||
|
(clientConnection: { connectionSettings?: Record<string, unknown> }) => {
|
||||||
|
guacLogger.info("Guacamole connection closed", {
|
||||||
|
operation: "guac_connection_close",
|
||||||
|
type: clientConnection.connectionSettings?.type,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.on(
|
||||||
|
"error",
|
||||||
|
(
|
||||||
|
clientConnection: { connectionSettings?: Record<string, unknown> },
|
||||||
|
error: Error,
|
||||||
|
) => {
|
||||||
|
guacLogger.error("Guacamole connection error", error, {
|
||||||
|
operation: "guac_connection_error",
|
||||||
|
type: clientConnection.connectionSettings?.type,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
let guacServer = createGuacServer();
|
||||||
|
|
||||||
|
export async function restartGuacServer(): Promise<void> {
|
||||||
|
try {
|
||||||
|
guacServer.close();
|
||||||
|
} catch (err) {
|
||||||
|
guacLogger.error("Error closing guac server during restart", err as Error);
|
||||||
|
}
|
||||||
|
guacServer = createGuacServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { guacServer, tokenService };
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { GuacamoleTokenService } from "./token-service.js";
|
||||||
|
import { guacLogger } from "../utils/logger.js";
|
||||||
|
import { AuthManager } from "../utils/auth-manager.js";
|
||||||
|
import { PermissionManager } from "../utils/permission-manager.js";
|
||||||
|
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||||
|
import { getDb } from "../database/db/index.js";
|
||||||
|
import { hosts } from "../database/db/schema.js";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const tokenService = GuacamoleTokenService.getInstance();
|
||||||
|
const authManager = AuthManager.getInstance();
|
||||||
|
|
||||||
|
router.use(authManager.createAuthMiddleware());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /guacamole/token
|
||||||
|
* Generate an encrypted connection token for guacamole-lite
|
||||||
|
*
|
||||||
|
* Body: {
|
||||||
|
* type: "rdp" | "vnc" | "telnet",
|
||||||
|
* hostname: string,
|
||||||
|
* port?: number,
|
||||||
|
* username?: string,
|
||||||
|
* password?: string,
|
||||||
|
* domain?: string,
|
||||||
|
* // Additional protocol-specific options
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
router.post("/token", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const { type, hostname, port, username, password, domain, ...options } =
|
||||||
|
req.body;
|
||||||
|
|
||||||
|
if (!type || !hostname) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Missing required fields: type and hostname" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!["rdp", "vnc", "telnet"].includes(type)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Invalid connection type. Must be rdp, vnc, or telnet",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let token: string;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "rdp":
|
||||||
|
token = tokenService.createRdpToken(
|
||||||
|
hostname,
|
||||||
|
username || "",
|
||||||
|
password || "",
|
||||||
|
{
|
||||||
|
port: port || 3389,
|
||||||
|
domain,
|
||||||
|
...options,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "vnc":
|
||||||
|
token = tokenService.createVncToken(hostname, password, {
|
||||||
|
port: port || 5900,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "telnet":
|
||||||
|
token = tokenService.createTelnetToken(hostname, username, password, {
|
||||||
|
port: port || 23,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return res.status(400).json({ error: "Invalid connection type" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ token });
|
||||||
|
} catch (error) {
|
||||||
|
guacLogger.error("Failed to generate guacamole token", error, {
|
||||||
|
operation: "guac_token_error",
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to generate connection token" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /guacamole/connect-host/{hostId}:
|
||||||
|
* post:
|
||||||
|
* summary: Generate Guacamole connection token from host configuration
|
||||||
|
* description: Fetches host configuration from database and generates a connection token for RDP/VNC/Telnet
|
||||||
|
* tags:
|
||||||
|
* - Guacamole
|
||||||
|
* security:
|
||||||
|
* - bearerAuth: []
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: hostId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* description: Host ID to connect to
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Connection token generated successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* token:
|
||||||
|
* type: string
|
||||||
|
* description: Encrypted connection token
|
||||||
|
* 400:
|
||||||
|
* description: Invalid request or unsupported connection type
|
||||||
|
* 403:
|
||||||
|
* description: Access denied to host
|
||||||
|
* 404:
|
||||||
|
* description: Host not found
|
||||||
|
* 500:
|
||||||
|
* description: Server error
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/connect-host/:hostId",
|
||||||
|
async (req: express.Request, res: express.Response) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId!;
|
||||||
|
const hostId = parseInt(req.params.hostId, 10);
|
||||||
|
|
||||||
|
if (!hostId || isNaN(hostId)) {
|
||||||
|
return res.status(400).json({ error: "Invalid host ID" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostResults = await SimpleDBOps.select(
|
||||||
|
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
|
||||||
|
"ssh_data",
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hostResults.length === 0) {
|
||||||
|
return res.status(404).json({ error: "Host not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = hostResults[0];
|
||||||
|
|
||||||
|
if (host.userId !== userId) {
|
||||||
|
const permissionManager = PermissionManager.getInstance();
|
||||||
|
const accessInfo = await permissionManager.canAccessHost(
|
||||||
|
userId,
|
||||||
|
hostId,
|
||||||
|
"read",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!accessInfo.hasAccess) {
|
||||||
|
guacLogger.warn("User attempted to access host without permission", {
|
||||||
|
operation: "guac_access_denied",
|
||||||
|
userId,
|
||||||
|
hostId,
|
||||||
|
});
|
||||||
|
return res.status(403).json({ error: "Access denied to this host" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionType = (host.connectionType as string) || "ssh";
|
||||||
|
if (!["rdp", "vnc", "telnet"].includes(connectionType)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Connection type '${connectionType}' is not supported for remote desktop. Only RDP, VNC, and Telnet are supported.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let guacConfig: Record<string, unknown> = {};
|
||||||
|
if (host.guacamoleConfig) {
|
||||||
|
try {
|
||||||
|
guacConfig =
|
||||||
|
typeof host.guacamoleConfig === "string"
|
||||||
|
? JSON.parse(host.guacamoleConfig as string)
|
||||||
|
: (host.guacamoleConfig as Record<string, unknown>);
|
||||||
|
} catch (error) {
|
||||||
|
guacLogger.warn("Failed to parse guacamole config", {
|
||||||
|
operation: "guac_config_parse_error",
|
||||||
|
hostId,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let token: string;
|
||||||
|
const hostname = host.ip as string;
|
||||||
|
const port = host.port as number;
|
||||||
|
const username = (host.username as string) || "";
|
||||||
|
const password = (host.password as string) || "";
|
||||||
|
const domain = (host.domain as string) || "";
|
||||||
|
|
||||||
|
switch (connectionType) {
|
||||||
|
case "rdp":
|
||||||
|
token = tokenService.createRdpToken(hostname, username, password, {
|
||||||
|
port: port || 3389,
|
||||||
|
domain,
|
||||||
|
security: (host.security as string) || undefined,
|
||||||
|
"ignore-cert": (host.ignoreCert as boolean) || false,
|
||||||
|
...guacConfig,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "vnc":
|
||||||
|
token = tokenService.createVncToken(hostname, password, {
|
||||||
|
port: port || 5900,
|
||||||
|
...guacConfig,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "telnet":
|
||||||
|
token = tokenService.createTelnetToken(hostname, username, password, {
|
||||||
|
port: port || 23,
|
||||||
|
...guacConfig,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return res.status(400).json({ error: "Invalid connection type" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ token });
|
||||||
|
} catch (error) {
|
||||||
|
guacLogger.error("Failed to generate guacamole token for host", error, {
|
||||||
|
operation: "guac_host_token_error",
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to generate connection token" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /guacamole/status
|
||||||
|
* Check if guacd is reachable
|
||||||
|
*/
|
||||||
|
router.get("/status", async (req, res) => {
|
||||||
|
try {
|
||||||
|
let guacdHost = process.env.GUACD_HOST || "localhost";
|
||||||
|
let guacdPort = parseInt(process.env.GUACD_PORT || "4822", 10);
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const urlRow = db.$client
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
if (urlRow?.value) {
|
||||||
|
const parts = urlRow.value.split(":");
|
||||||
|
guacdHost = parts[0] || guacdHost;
|
||||||
|
guacdPort = parseInt(parts[1] || String(guacdPort), 10);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to env vars
|
||||||
|
}
|
||||||
|
|
||||||
|
const net = await import("net");
|
||||||
|
|
||||||
|
const checkConnection = (): Promise<boolean> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new net.Socket();
|
||||||
|
socket.setTimeout(3000);
|
||||||
|
|
||||||
|
socket.on("connect", () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("timeout", () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("error", () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.connect(guacdPort, guacdHost);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isConnected = await checkConnection();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
guacd: {
|
||||||
|
host: guacdHost,
|
||||||
|
port: guacdPort,
|
||||||
|
status: isConnected ? "connected" : "disconnected",
|
||||||
|
},
|
||||||
|
websocket: {
|
||||||
|
port: 30008,
|
||||||
|
status: "running",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
guacLogger.error("Failed to check guacamole status", error, {
|
||||||
|
operation: "guac_status_error",
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to check status" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import { guacLogger } from "../utils/logger.js";
|
||||||
|
|
||||||
|
export interface GuacamoleConnectionSettings {
|
||||||
|
type: "rdp" | "vnc" | "telnet";
|
||||||
|
settings: {
|
||||||
|
hostname: string;
|
||||||
|
port?: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
domain?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
dpi?: number;
|
||||||
|
security?: string;
|
||||||
|
"ignore-cert"?: boolean;
|
||||||
|
"enable-wallpaper"?: boolean;
|
||||||
|
"enable-drive"?: boolean;
|
||||||
|
"drive-path"?: string;
|
||||||
|
"create-drive-path"?: boolean;
|
||||||
|
"swap-red-blue"?: boolean;
|
||||||
|
cursor?: string;
|
||||||
|
"terminal-type"?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuacamoleToken {
|
||||||
|
connection: GuacamoleConnectionSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CIPHER = "aes-256-cbc";
|
||||||
|
const KEY_LENGTH = 32;
|
||||||
|
|
||||||
|
export class GuacamoleTokenService {
|
||||||
|
private static instance: GuacamoleTokenService;
|
||||||
|
private encryptionKey: Buffer;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
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) {
|
||||||
|
if (existingKey.length === 64 && /^[0-9a-fA-F]+$/.test(existingKey)) {
|
||||||
|
return Buffer.from(existingKey, "hex");
|
||||||
|
}
|
||||||
|
if (existingKey.length === KEY_LENGTH) {
|
||||||
|
return Buffer.from(existingKey, "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwtSecret = process.env.JWT_SECRET;
|
||||||
|
if (jwtSecret) {
|
||||||
|
return crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(jwtSecret + "_guacamole")
|
||||||
|
.digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
guacLogger.warn(
|
||||||
|
"No persistent encryption key found, generating random key",
|
||||||
|
{
|
||||||
|
operation: "guac_key_generation",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return crypto.randomBytes(KEY_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
getEncryptionKey(): Buffer {
|
||||||
|
return this.encryptionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptToken(tokenObject: GuacamoleToken): string {
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
const cipher = crypto.createCipheriv(CIPHER, this.encryptionKey, iv);
|
||||||
|
|
||||||
|
let encrypted = cipher.update(
|
||||||
|
JSON.stringify(tokenObject),
|
||||||
|
"utf8",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
encrypted += cipher.final("base64");
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
iv: iv.toString("base64"),
|
||||||
|
value: encrypted,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Buffer.from(JSON.stringify(data)).toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptToken(token: string): GuacamoleToken | null {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(Buffer.from(token, "base64").toString("utf8"));
|
||||||
|
const iv = Buffer.from(data.iv, "base64");
|
||||||
|
const decipher = crypto.createDecipheriv(CIPHER, this.encryptionKey, iv);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(data.value, "base64", "utf8");
|
||||||
|
decrypted += decipher.final("utf8");
|
||||||
|
|
||||||
|
return JSON.parse(decrypted) as GuacamoleToken;
|
||||||
|
} catch (error) {
|
||||||
|
guacLogger.error("Failed to decrypt guacamole token", error, {
|
||||||
|
operation: "guac_token_decrypt_error",
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createRdpToken(
|
||||||
|
hostname: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
|
||||||
|
): string {
|
||||||
|
const token: GuacamoleToken = {
|
||||||
|
connection: {
|
||||||
|
type: "rdp",
|
||||||
|
settings: {
|
||||||
|
hostname,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
port: 3389,
|
||||||
|
security: "nla",
|
||||||
|
"ignore-cert": true,
|
||||||
|
...options,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return this.encryptToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
createVncToken(
|
||||||
|
hostname: string,
|
||||||
|
password?: string,
|
||||||
|
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
|
||||||
|
): string {
|
||||||
|
const token: GuacamoleToken = {
|
||||||
|
connection: {
|
||||||
|
type: "vnc",
|
||||||
|
settings: {
|
||||||
|
hostname,
|
||||||
|
password,
|
||||||
|
port: 5900,
|
||||||
|
...options,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return this.encryptToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
createTelnetToken(
|
||||||
|
hostname: string,
|
||||||
|
username?: string,
|
||||||
|
password?: string,
|
||||||
|
options: Partial<GuacamoleConnectionSettings["settings"]> = {},
|
||||||
|
): string {
|
||||||
|
const token: GuacamoleToken = {
|
||||||
|
connection: {
|
||||||
|
type: "telnet",
|
||||||
|
settings: {
|
||||||
|
hostname,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
port: 23,
|
||||||
|
...options,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return this.encryptToken(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { Client as SSHClient } from "ssh2";
|
|||||||
import { WebSocketServer, WebSocket } from "ws";
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
import { parse as parseUrl } from "url";
|
import { parse as parseUrl } from "url";
|
||||||
import { AuthManager } from "../utils/auth-manager.js";
|
import { AuthManager } from "../utils/auth-manager.js";
|
||||||
import { sshData, sshCredentials } from "../database/db/schema.js";
|
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||||
@@ -24,7 +24,7 @@ const activeSessions = new Map<string, SSHSession>();
|
|||||||
|
|
||||||
const wss = new WebSocketServer({
|
const wss = new WebSocketServer({
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
port: 30008,
|
port: 30009,
|
||||||
verifyClient: async (info) => {
|
verifyClient: async (info) => {
|
||||||
try {
|
try {
|
||||||
const url = parseUrl(info.req.url || "", true);
|
const url = parseUrl(info.req.url || "", true);
|
||||||
@@ -107,8 +107,8 @@ async function createJumpHostChain(
|
|||||||
const jumpHostData = await SimpleDBOps.select(
|
const jumpHostData = await SimpleDBOps.select(
|
||||||
getDb()
|
getDb()
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(and(eq(sshData.id, jumpHostId), eq(sshData.userId, userId))),
|
.where(and(eq(hosts.id, jumpHostId), eq(hosts.userId, userId))),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|||||||
+26
-28
@@ -4,7 +4,7 @@ import cookieParser from "cookie-parser";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Client as SSHClient } from "ssh2";
|
import { Client as SSHClient } from "ssh2";
|
||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
import { sshData, sshCredentials } from "../database/db/schema.js";
|
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { logger } from "../utils/logger.js";
|
import { logger } from "../utils/logger.js";
|
||||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||||
@@ -136,20 +136,20 @@ async function resolveJumpHost(
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<JumpHostConfig | null> {
|
): Promise<JumpHostConfig | null> {
|
||||||
try {
|
try {
|
||||||
const hosts = await SimpleDBOps.select(
|
const hostResults = await SimpleDBOps.select(
|
||||||
getDb()
|
getDb()
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hosts.length === 0) {
|
if (hostResults.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = hosts[0];
|
const host = hostResults[0];
|
||||||
|
|
||||||
if (host.credentialId) {
|
if (host.credentialId) {
|
||||||
const credentials = await SimpleDBOps.select(
|
const credentials = await SimpleDBOps.select(
|
||||||
@@ -570,25 +570,24 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hosts = await SimpleDBOps.select(
|
const hostResults = await SimpleDBOps.select(
|
||||||
getDb().select().from(sshData).where(eq(sshData.id, hostId)),
|
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hosts.length === 0) {
|
if (hostResults.length === 0) {
|
||||||
connectionLogs.push(
|
connectionLogs.push(
|
||||||
createConnectionLog("error", "docker_connecting", "Host not found"),
|
createConnectionLog("error", "docker_connecting", "Host not found"),
|
||||||
);
|
);
|
||||||
return res.status(404).json({ error: "Host not found", connectionLogs });
|
return res.status(404).json({ error: "Host not found", connectionLogs });
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = hosts[0] as unknown as SSHHost;
|
const host = hostResults[0] as unknown as SSHHost;
|
||||||
|
|
||||||
if (host.userId !== userId) {
|
if (host.userId !== userId) {
|
||||||
const { PermissionManager } = await import(
|
const { PermissionManager } =
|
||||||
"../utils/permission-manager.js"
|
await import("../utils/permission-manager.js");
|
||||||
);
|
|
||||||
const permissionManager = PermissionManager.getInstance();
|
const permissionManager = PermissionManager.getInstance();
|
||||||
const accessInfo = await permissionManager.canAccessHost(
|
const accessInfo = await permissionManager.canAccessHost(
|
||||||
userId,
|
userId,
|
||||||
@@ -693,9 +692,8 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
|||||||
|
|
||||||
if (userId !== ownerId) {
|
if (userId !== ownerId) {
|
||||||
try {
|
try {
|
||||||
const { SharedCredentialManager } = await import(
|
const { SharedCredentialManager } =
|
||||||
"../utils/shared-credential-manager.js"
|
await import("../utils/shared-credential-manager.js");
|
||||||
);
|
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||||
host.id,
|
host.id,
|
||||||
@@ -1678,14 +1676,14 @@ app.post("/docker/ssh/connect-totp", async (req, res) => {
|
|||||||
if (session.hostId && session.userId) {
|
if (session.hostId && session.userId) {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const hosts = await SimpleDBOps.select(
|
const hostResults = await SimpleDBOps.select(
|
||||||
getDb()
|
getDb()
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(sshData.id, session.hostId!),
|
eq(hosts.id, session.hostId!),
|
||||||
eq(sshData.userId, session.userId!),
|
eq(hosts.userId, session.userId!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
@@ -1693,8 +1691,8 @@ app.post("/docker/ssh/connect-totp", async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hostName =
|
const hostName =
|
||||||
hosts.length > 0 && hosts[0].name
|
hostResults.length > 0 && hostResults[0].name
|
||||||
? hosts[0].name
|
? hostResults[0].name
|
||||||
: `${session.username}@${session.ip}:${session.port}`;
|
: `${session.username}@${session.ip}:${session.port}`;
|
||||||
|
|
||||||
await axios.post(
|
await axios.post(
|
||||||
@@ -1863,14 +1861,14 @@ app.post("/docker/ssh/connect-warpgate", async (req, res) => {
|
|||||||
if (session.hostId && session.userId) {
|
if (session.hostId && session.userId) {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const hosts = await SimpleDBOps.select(
|
const hostResults = await SimpleDBOps.select(
|
||||||
getDb()
|
getDb()
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(sshData.id, session.hostId!),
|
eq(hosts.id, session.hostId!),
|
||||||
eq(sshData.userId, session.userId!),
|
eq(hosts.userId, session.userId!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
@@ -1878,8 +1876,8 @@ app.post("/docker/ssh/connect-warpgate", async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hostName =
|
const hostName =
|
||||||
hosts.length > 0 && hosts[0].name
|
hostResults.length > 0 && hostResults[0].name
|
||||||
? hosts[0].name
|
? hostResults[0].name
|
||||||
: `${session.username}@${session.ip}:${session.port}`;
|
: `${session.username}@${session.ip}:${session.port}`;
|
||||||
|
|
||||||
await axios.post(
|
await axios.post(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import cookieParser from "cookie-parser";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Client as SSHClient } from "ssh2";
|
import { Client as SSHClient } from "ssh2";
|
||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
import { sshCredentials, sshData } from "../database/db/schema.js";
|
import { sshCredentials, hosts } from "../database/db/schema.js";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { fileLogger } from "../utils/logger.js";
|
import { fileLogger } from "../utils/logger.js";
|
||||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||||
@@ -179,20 +179,20 @@ async function resolveJumpHost(
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<JumpHostConfig | null> {
|
): Promise<JumpHostConfig | null> {
|
||||||
try {
|
try {
|
||||||
const hosts = await SimpleDBOps.select(
|
const hostResults = await SimpleDBOps.select(
|
||||||
getDb()
|
getDb()
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hosts.length === 0) {
|
if (hostResults.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = hosts[0];
|
const host = hostResults[0];
|
||||||
|
|
||||||
if (host.credentialId) {
|
if (host.credentialId) {
|
||||||
const credentials = await SimpleDBOps.select(
|
const credentials = await SimpleDBOps.select(
|
||||||
@@ -803,6 +803,69 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
|
|
||||||
let resolvedCredentials = { password, sshKey, keyPassword, authType };
|
let resolvedCredentials = { password, sshKey, keyPassword, authType };
|
||||||
if (credentialId && hostId && userId) {
|
if (credentialId && hostId && userId) {
|
||||||
|
const hostRow = await getDb()
|
||||||
|
.select({ userId: hosts.userId })
|
||||||
|
.from(hosts)
|
||||||
|
.where(eq(hosts.id, hostId))
|
||||||
|
.limit(1);
|
||||||
|
const ownerId = hostRow[0]?.userId ?? null;
|
||||||
|
|
||||||
|
if (ownerId && userId !== ownerId) {
|
||||||
|
try {
|
||||||
|
const { SharedCredentialManager } =
|
||||||
|
await import("../utils/shared-credential-manager.js");
|
||||||
|
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||||
|
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sharedCred) {
|
||||||
|
resolvedCredentials = {
|
||||||
|
password: sharedCred.password,
|
||||||
|
sshKey: sharedCred.key,
|
||||||
|
keyPassword: sharedCred.keyPassword,
|
||||||
|
authType: sharedCred.authType,
|
||||||
|
};
|
||||||
|
connectionLogs.push(
|
||||||
|
createConnectionLog(
|
||||||
|
"info",
|
||||||
|
"sftp_auth",
|
||||||
|
"Credentials resolved from shared credential store",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
fileLogger.warn(`No shared credentials found for host ${hostId}`, {
|
||||||
|
operation: "ssh_credentials",
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
connectionLogs.push(
|
||||||
|
createConnectionLog(
|
||||||
|
"warning",
|
||||||
|
"sftp_auth",
|
||||||
|
"No shared credentials found, using provided credentials",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fileLogger.warn(
|
||||||
|
`Failed to resolve shared credential for host ${hostId}`,
|
||||||
|
{
|
||||||
|
operation: "ssh_credentials",
|
||||||
|
hostId,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
connectionLogs.push(
|
||||||
|
createConnectionLog(
|
||||||
|
"warning",
|
||||||
|
"sftp_auth",
|
||||||
|
`Failed to resolve shared credentials: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (ownerId) {
|
||||||
try {
|
try {
|
||||||
const credentials = await SimpleDBOps.select(
|
const credentials = await SimpleDBOps.select(
|
||||||
getDb()
|
getDb()
|
||||||
@@ -811,11 +874,11 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(sshCredentials.id, credentialId),
|
eq(sshCredentials.id, credentialId),
|
||||||
eq(sshCredentials.userId, userId),
|
eq(sshCredentials.userId, ownerId),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
"ssh_credentials",
|
"ssh_credentials",
|
||||||
userId,
|
ownerId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (credentials.length > 0) {
|
if (credentials.length > 0) {
|
||||||
@@ -838,7 +901,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
operation: "ssh_credentials",
|
operation: "ssh_credentials",
|
||||||
hostId,
|
hostId,
|
||||||
credentialId,
|
credentialId,
|
||||||
userId,
|
userId: ownerId,
|
||||||
});
|
});
|
||||||
connectionLogs.push(
|
connectionLogs.push(
|
||||||
createConnectionLog(
|
createConnectionLog(
|
||||||
@@ -863,6 +926,16 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
fileLogger.warn(
|
||||||
|
"Missing userId for credential resolution in file manager",
|
||||||
|
{
|
||||||
|
operation: "ssh_credentials",
|
||||||
|
hostId,
|
||||||
|
credentialId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (credentialId && hostId) {
|
} else if (credentialId && hostId) {
|
||||||
fileLogger.warn(
|
fileLogger.warn(
|
||||||
"Missing userId for credential resolution in file manager",
|
"Missing userId for credential resolution in file manager",
|
||||||
@@ -1201,18 +1274,18 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
|||||||
if (hostId && userId) {
|
if (hostId && userId) {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const hosts = await SimpleDBOps.select(
|
const hostResults = await SimpleDBOps.select(
|
||||||
getDb()
|
getDb()
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const hostName =
|
const hostName =
|
||||||
hosts.length > 0 && hosts[0].name
|
hostResults.length > 0 && hostResults[0].name
|
||||||
? hosts[0].name
|
? hostResults[0].name
|
||||||
: `${username}@${ip}:${port}`;
|
: `${username}@${ip}:${port}`;
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
@@ -1844,14 +1917,14 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
|||||||
if (session.hostId && session.userId) {
|
if (session.hostId && session.userId) {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const hosts = await SimpleDBOps.select(
|
const hostResults = await SimpleDBOps.select(
|
||||||
getDb()
|
getDb()
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(sshData.id, session.hostId!),
|
eq(hosts.id, session.hostId!),
|
||||||
eq(sshData.userId, session.userId!),
|
eq(hosts.userId, session.userId!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
@@ -1859,8 +1932,8 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hostName =
|
const hostName =
|
||||||
hosts.length > 0 && hosts[0].name
|
hostResults.length > 0 && hostResults[0].name
|
||||||
? hosts[0].name
|
? hostResults[0].name
|
||||||
: `${session.username}@${session.ip}:${session.port}`;
|
: `${session.username}@${session.ip}:${session.port}`;
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
@@ -2045,14 +2118,14 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => {
|
|||||||
if (session.hostId && session.userId) {
|
if (session.hostId && session.userId) {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const hosts = await SimpleDBOps.select(
|
const hostResults = await SimpleDBOps.select(
|
||||||
getDb()
|
getDb()
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(sshData.id, session.hostId!),
|
eq(hosts.id, session.hostId!),
|
||||||
eq(sshData.userId, session.userId!),
|
eq(hosts.userId, session.userId!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
@@ -2060,8 +2133,8 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hostName =
|
const hostName =
|
||||||
hosts.length > 0 && hosts[0].name
|
hostResults.length > 0 && hostResults[0].name
|
||||||
? hosts[0].name
|
? hostResults[0].name
|
||||||
: `${session.username}@${session.ip}:${session.port}`;
|
: `${session.username}@${session.ip}:${session.port}`;
|
||||||
|
|
||||||
await axios.post(
|
await axios.post(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { WebSocket } from "ws";
|
import type { WebSocket } from "ws";
|
||||||
import { db } from "../database/db/index.js";
|
import { db } from "../database/db/index.js";
|
||||||
import { sshData } from "../database/db/schema.js";
|
import { hosts } from "../database/db/schema.js";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { sshLogger } from "../utils/logger.js";
|
import { sshLogger } from "../utils/logger.js";
|
||||||
|
|
||||||
@@ -21,17 +21,6 @@ interface VerificationResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class SSHHostKeyVerifier {
|
export class SSHHostKeyVerifier {
|
||||||
/**
|
|
||||||
* Creates a hostVerifier callback for ssh2 Client.connect()
|
|
||||||
*
|
|
||||||
* @param hostId - Database ID of the host (null for quick connect)
|
|
||||||
* @param ip - IP address or hostname
|
|
||||||
* @param port - SSH port
|
|
||||||
* @param ws - WebSocket for user prompts (null for non-interactive connections)
|
|
||||||
* @param userId - User ID for logging
|
|
||||||
* @param isJumpHost - If true, auto-accepts without prompting
|
|
||||||
* @returns async hostVerifier callback
|
|
||||||
*/
|
|
||||||
static async createHostVerifier(
|
static async createHostVerifier(
|
||||||
hostId: number | null,
|
hostId: number | null,
|
||||||
ip: string,
|
ip: string,
|
||||||
@@ -63,8 +52,8 @@ export class SSHHostKeyVerifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = await db.query.sshData.findFirst({
|
const host = await db.query.hosts.findFirst({
|
||||||
where: eq(sshData.id, hostId),
|
where: eq(hosts.id, hostId),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!host) {
|
if (!host) {
|
||||||
@@ -153,11 +142,11 @@ export class SSHHostKeyVerifier {
|
|||||||
|
|
||||||
if (host.hostKeyFingerprint === fingerprint) {
|
if (host.hostKeyFingerprint === fingerprint) {
|
||||||
await db
|
await db
|
||||||
.update(sshData)
|
.update(hosts)
|
||||||
.set({
|
.set({
|
||||||
hostKeyLastVerified: new Date().toISOString(),
|
hostKeyLastVerified: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.where(eq(sshData.id, hostId));
|
.where(eq(hosts.id, hostId));
|
||||||
|
|
||||||
sshLogger.info("Host key verified successfully", {
|
sshLogger.info("Host key verified successfully", {
|
||||||
operation: "host_key_verified",
|
operation: "host_key_verified",
|
||||||
@@ -287,7 +276,7 @@ export class SSHHostKeyVerifier {
|
|||||||
algorithm: string,
|
algorithm: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await db
|
await db
|
||||||
.update(sshData)
|
.update(hosts)
|
||||||
.set({
|
.set({
|
||||||
hostKeyFingerprint: fingerprint,
|
hostKeyFingerprint: fingerprint,
|
||||||
hostKeyType: keyType,
|
hostKeyType: keyType,
|
||||||
@@ -295,7 +284,7 @@ export class SSHHostKeyVerifier {
|
|||||||
hostKeyFirstSeen: new Date().toISOString(),
|
hostKeyFirstSeen: new Date().toISOString(),
|
||||||
hostKeyLastVerified: new Date().toISOString(),
|
hostKeyLastVerified: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.where(eq(sshData.id, hostId));
|
.where(eq(hosts.id, hostId));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async updateHostKey(
|
private static async updateHostKey(
|
||||||
@@ -306,7 +295,7 @@ export class SSHHostKeyVerifier {
|
|||||||
currentChangeCount: number,
|
currentChangeCount: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await db
|
await db
|
||||||
.update(sshData)
|
.update(hosts)
|
||||||
.set({
|
.set({
|
||||||
hostKeyFingerprint: fingerprint,
|
hostKeyFingerprint: fingerprint,
|
||||||
hostKeyType: keyType,
|
hostKeyType: keyType,
|
||||||
@@ -314,7 +303,7 @@ export class SSHHostKeyVerifier {
|
|||||||
hostKeyLastVerified: new Date().toISOString(),
|
hostKeyLastVerified: new Date().toISOString(),
|
||||||
hostKeyChangedCount: currentChangeCount + 1,
|
hostKeyChangedCount: currentChangeCount + 1,
|
||||||
})
|
})
|
||||||
.where(eq(sshData.id, hostId));
|
.where(eq(hosts.id, hostId));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async promptUserForNewKey(
|
private static async promptUserForNewKey(
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ async function checkOPKConfigExists(): Promise<{
|
|||||||
return {
|
return {
|
||||||
exists: false,
|
exists: false,
|
||||||
configPath,
|
configPath,
|
||||||
error: `OPKSSH configuration is missing 'redirect_uris' field. This field must contain the Termix callback URL that you registered with your OAuth provider (e.g., http://localhost:8080/ssh/opkssh-callback for Docker). The static callback route will internally redirect to the dynamic route for proper URL rewriting.`,
|
error: `OPKSSH configuration is missing 'redirect_uris' field. This field must contain the Termix callback URL that you registered with your OAuth provider (e.g., http://localhost:8080/host/opkssh-callback for Docker). The static callback route will internally redirect to the dynamic route for proper URL rewriting.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ export async function startOPKSSHAuth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requestId = randomUUID();
|
const requestId = randomUUID();
|
||||||
const remoteRedirectUri = `${requestOrigin}/ssh/opkssh-callback`;
|
const remoteRedirectUri = `${requestOrigin}/host/opkssh-callback`;
|
||||||
|
|
||||||
const session: Partial<OPKSSHAuthSession> = {
|
const session: Partial<OPKSSHAuthSession> = {
|
||||||
requestId,
|
requestId,
|
||||||
@@ -352,7 +352,7 @@ function handleOPKSSHOutput(requestId: string, output: string): void {
|
|||||||
/\/ssh\/opkssh-callback$/,
|
/\/ssh\/opkssh-callback$/,
|
||||||
"",
|
"",
|
||||||
);
|
);
|
||||||
const proxiedChooserUrl = `${baseUrl}/ssh/opkssh-chooser/${requestId}`;
|
const proxiedChooserUrl = `${baseUrl}/host/opkssh-chooser/${requestId}`;
|
||||||
|
|
||||||
session.status = "waiting_for_auth";
|
session.status = "waiting_for_auth";
|
||||||
session.ws.send(
|
session.ws.send(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import cors from "cors";
|
|||||||
import cookieParser from "cookie-parser";
|
import cookieParser from "cookie-parser";
|
||||||
import { Client, type ConnectConfig } from "ssh2";
|
import { Client, type ConnectConfig } from "ssh2";
|
||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
import { sshData, sshCredentials } from "../database/db/schema.js";
|
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { statsLogger } from "../utils/logger.js";
|
import { statsLogger } from "../utils/logger.js";
|
||||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||||
@@ -29,6 +29,15 @@ import {
|
|||||||
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
||||||
import { connectionPool, withConnection } from "./ssh-connection-pool.js";
|
import { connectionPool, withConnection } from "./ssh-connection-pool.js";
|
||||||
|
|
||||||
|
function supportsMetrics(host: SSHHostWithCredentials): boolean {
|
||||||
|
const connectionType = host.connectionType || "ssh";
|
||||||
|
return connectionType === "ssh";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTcpPingEnabled(statsConfig: StatsConfig): boolean {
|
||||||
|
return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing;
|
||||||
|
}
|
||||||
|
|
||||||
function createConnectionLog(
|
function createConnectionLog(
|
||||||
type: "info" | "success" | "warning" | "error",
|
type: "info" | "success" | "warning" | "error",
|
||||||
stage: ConnectionStage,
|
stage: ConnectionStage,
|
||||||
@@ -62,20 +71,20 @@ async function resolveJumpHost(
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<JumpHostConfig | null> {
|
): Promise<JumpHostConfig | null> {
|
||||||
try {
|
try {
|
||||||
const hosts = await SimpleDBOps.select(
|
const hostResults = await SimpleDBOps.select(
|
||||||
getDb()
|
getDb()
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hosts.length === 0) {
|
if (hostResults.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = hosts[0];
|
const host = hostResults[0];
|
||||||
|
|
||||||
if (host.credentialId) {
|
if (host.credentialId) {
|
||||||
const credentials = await SimpleDBOps.select(
|
const credentials = await SimpleDBOps.select(
|
||||||
@@ -644,6 +653,7 @@ interface SSHHostWithCredentials {
|
|||||||
socks5Username?: string;
|
socks5Username?: string;
|
||||||
socks5Password?: string;
|
socks5Password?: string;
|
||||||
socks5ProxyChain?: ProxyNode[];
|
socks5ProxyChain?: ProxyNode[];
|
||||||
|
connectionType?: "ssh" | "rdp" | "vnc" | "telnet";
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatusEntry = {
|
type StatusEntry = {
|
||||||
@@ -659,6 +669,7 @@ interface StatsConfig {
|
|||||||
metricsEnabled: boolean;
|
metricsEnabled: boolean;
|
||||||
metricsInterval: number;
|
metricsInterval: number;
|
||||||
useGlobalMetricsInterval?: boolean;
|
useGlobalMetricsInterval?: boolean;
|
||||||
|
disableTcpPing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_STATS_CONFIG: StatsConfig = {
|
const DEFAULT_STATS_CONFIG: StatsConfig = {
|
||||||
@@ -783,9 +794,13 @@ class PollingManager {
|
|||||||
const statusOnly = options?.statusOnly ?? false;
|
const statusOnly = options?.statusOnly ?? false;
|
||||||
const viewerUserId = options?.viewerUserId;
|
const viewerUserId = options?.viewerUserId;
|
||||||
|
|
||||||
|
const canCollectMetrics = supportsMetrics(host);
|
||||||
|
|
||||||
const enabledCollectors: string[] = [];
|
const enabledCollectors: string[] = [];
|
||||||
if (statsConfig.statusCheckEnabled) enabledCollectors.push("status");
|
if (isTcpPingEnabled(statsConfig)) {
|
||||||
if (!statusOnly && statsConfig.metricsEnabled) {
|
enabledCollectors.push("status");
|
||||||
|
}
|
||||||
|
if (!statusOnly && statsConfig.metricsEnabled && canCollectMetrics) {
|
||||||
enabledCollectors.push(
|
enabledCollectors.push(
|
||||||
"cpu",
|
"cpu",
|
||||||
"memory",
|
"memory",
|
||||||
@@ -810,7 +825,7 @@ class PollingManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!statsConfig.statusCheckEnabled && !statsConfig.metricsEnabled) {
|
if (!isTcpPingEnabled(statsConfig) && !statsConfig.metricsEnabled) {
|
||||||
this.pollingConfigs.delete(host.id);
|
this.pollingConfigs.delete(host.id);
|
||||||
this.statusStore.delete(host.id);
|
this.statusStore.delete(host.id);
|
||||||
this.metricsStore.delete(host.id);
|
this.metricsStore.delete(host.id);
|
||||||
@@ -823,14 +838,14 @@ class PollingManager {
|
|||||||
viewerUserId,
|
viewerUserId,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (statsConfig.statusCheckEnabled) {
|
if (isTcpPingEnabled(statsConfig)) {
|
||||||
const intervalMs = statsConfig.statusCheckInterval * 1000;
|
const intervalMs = statsConfig.statusCheckInterval * 1000;
|
||||||
|
|
||||||
this.pollHostStatus(host, viewerUserId);
|
this.pollHostStatus(host, viewerUserId);
|
||||||
|
|
||||||
config.statusTimer = setInterval(() => {
|
config.statusTimer = setInterval(() => {
|
||||||
const latestConfig = this.pollingConfigs.get(host.id);
|
const latestConfig = this.pollingConfigs.get(host.id);
|
||||||
if (latestConfig && latestConfig.statsConfig.statusCheckEnabled) {
|
if (latestConfig && isTcpPingEnabled(latestConfig.statsConfig)) {
|
||||||
this.pollHostStatus(latestConfig.host, latestConfig.viewerUserId);
|
this.pollHostStatus(latestConfig.host, latestConfig.viewerUserId);
|
||||||
}
|
}
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
@@ -838,14 +853,18 @@ class PollingManager {
|
|||||||
this.statusStore.delete(host.id);
|
this.statusStore.delete(host.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!statusOnly && statsConfig.metricsEnabled) {
|
if (!statusOnly && statsConfig.metricsEnabled && canCollectMetrics) {
|
||||||
const intervalMs = statsConfig.metricsInterval * 1000;
|
const intervalMs = statsConfig.metricsInterval * 1000;
|
||||||
|
|
||||||
await this.pollHostMetrics(host, viewerUserId);
|
await this.pollHostMetrics(host, viewerUserId);
|
||||||
|
|
||||||
config.metricsTimer = setInterval(() => {
|
config.metricsTimer = setInterval(() => {
|
||||||
const latestConfig = this.pollingConfigs.get(host.id);
|
const latestConfig = this.pollingConfigs.get(host.id);
|
||||||
if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
|
if (
|
||||||
|
latestConfig &&
|
||||||
|
latestConfig.statsConfig.metricsEnabled &&
|
||||||
|
supportsMetrics(latestConfig.host)
|
||||||
|
) {
|
||||||
this.pollHostMetrics(latestConfig.host, latestConfig.viewerUserId);
|
this.pollHostMetrics(latestConfig.host, latestConfig.viewerUserId);
|
||||||
}
|
}
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
@@ -896,6 +915,15 @@ class PollingManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!supportsMetrics(refreshedHost)) {
|
||||||
|
statsLogger.debug("Skipping metrics collection for non-SSH host", {
|
||||||
|
operation: "poll_host_metrics_skipped",
|
||||||
|
hostId: refreshedHost.id,
|
||||||
|
connectionType: refreshedHost.connectionType || "ssh",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const config = this.pollingConfigs.get(refreshedHost.id);
|
const config = this.pollingConfigs.get(refreshedHost.id);
|
||||||
if (!config || !config.statsConfig.metricsEnabled) {
|
if (!config || !config.statsConfig.metricsEnabled) {
|
||||||
return;
|
return;
|
||||||
@@ -1170,14 +1198,14 @@ async function fetchAllHosts(
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<SSHHostWithCredentials[]> {
|
): Promise<SSHHostWithCredentials[]> {
|
||||||
try {
|
try {
|
||||||
const hosts = await SimpleDBOps.select(
|
const hostResults = await SimpleDBOps.select(
|
||||||
getDb().select().from(sshData).where(eq(sshData.userId, userId)),
|
getDb().select().from(hosts).where(eq(hosts.userId, userId)),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const hostsWithCredentials: SSHHostWithCredentials[] = [];
|
const hostsWithCredentials: SSHHostWithCredentials[] = [];
|
||||||
for (const host of hosts) {
|
for (const host of hostResults) {
|
||||||
try {
|
try {
|
||||||
const hostWithCreds = await resolveHostCredentials(host, userId);
|
const hostWithCreds = await resolveHostCredentials(host, userId);
|
||||||
if (hostWithCreds) {
|
if (hostWithCreds) {
|
||||||
@@ -1221,17 +1249,17 @@ async function fetchHostById(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hosts = await SimpleDBOps.select(
|
const hostResults = await SimpleDBOps.select(
|
||||||
getDb().select().from(sshData).where(eq(sshData.id, id)),
|
getDb().select().from(hosts).where(eq(hosts.id, id)),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hosts.length === 0) {
|
if (hostResults.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = hosts[0];
|
const host = hostResults[0];
|
||||||
return await resolveHostCredentials(host, userId);
|
return await resolveHostCredentials(host, userId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statsLogger.error(`Failed to fetch host ${id}`, err);
|
statsLogger.error(`Failed to fetch host ${id}`, err);
|
||||||
@@ -1757,6 +1785,10 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
os: string | null;
|
os: string | null;
|
||||||
};
|
};
|
||||||
}> {
|
}> {
|
||||||
|
if (!supportsMetrics(host)) {
|
||||||
|
throw new Error("Metrics collection only supported for SSH hosts");
|
||||||
|
}
|
||||||
|
|
||||||
if (authFailureTracker.shouldSkip(host.id)) {
|
if (authFailureTracker.shouldSkip(host.id)) {
|
||||||
const reason = authFailureTracker.getSkipReason(host.id);
|
const reason = authFailureTracker.getSkipReason(host.id);
|
||||||
throw new Error(reason || "Authentication failed");
|
throw new Error(reason || "Authentication failed");
|
||||||
@@ -1935,7 +1967,8 @@ function tcpPing(
|
|||||||
|
|
||||||
socket.once("data", (data) => {
|
socket.once("data", (data) => {
|
||||||
clearTimeout(dataTimeout);
|
clearTimeout(dataTimeout);
|
||||||
if (data.toString().startsWith("SSH-")) {
|
const dataStr = data.toString("utf8");
|
||||||
|
if (dataStr.startsWith("SSH-")) {
|
||||||
try {
|
try {
|
||||||
socket.end("SSH-2.0-TermixHealthCheck\r\n");
|
socket.end("SSH-2.0-TermixHealthCheck\r\n");
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+61
-20
@@ -3,7 +3,7 @@ import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
|
|||||||
import { parse as parseUrl } from "url";
|
import { parse as parseUrl } from "url";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
import { sshCredentials, sshData } from "../database/db/schema.js";
|
import { sshCredentials, hosts } from "../database/db/schema.js";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { sshLogger, authLogger } from "../utils/logger.js";
|
import { sshLogger, authLogger } from "../utils/logger.js";
|
||||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||||
@@ -97,20 +97,20 @@ async function resolveJumpHost(
|
|||||||
hostId,
|
hostId,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const hosts = await SimpleDBOps.select(
|
const hostResults = await SimpleDBOps.select(
|
||||||
getDb()
|
getDb()
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hosts.length === 0) {
|
if (hostResults.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = hosts[0];
|
const host = hostResults[0];
|
||||||
|
|
||||||
if (host.credentialId) {
|
if (host.credentialId) {
|
||||||
const credentials = await SimpleDBOps.select(
|
const credentials = await SimpleDBOps.select(
|
||||||
@@ -814,8 +814,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const hostRow = await db
|
const hostRow = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(eq(sshData.id, opksshData.hostId))
|
.where(eq(hosts.id, opksshData.hostId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!hostRow || hostRow.length === 0) {
|
if (!hostRow || hostRow.length === 0) {
|
||||||
sshLogger.error(
|
sshLogger.error(
|
||||||
@@ -1057,7 +1057,48 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
authType,
|
authType,
|
||||||
};
|
};
|
||||||
const authMethodNotAvailable = false;
|
const authMethodNotAvailable = false;
|
||||||
if (credentialId && id && hostConfig.userId) {
|
if (credentialId && id) {
|
||||||
|
const hostRow = await getDb()
|
||||||
|
.select({ userId: hosts.userId })
|
||||||
|
.from(hosts)
|
||||||
|
.where(eq(hosts.id, id))
|
||||||
|
.limit(1);
|
||||||
|
const ownerId = hostRow[0]?.userId ?? null;
|
||||||
|
|
||||||
|
if (ownerId && userId !== ownerId) {
|
||||||
|
try {
|
||||||
|
const { SharedCredentialManager } =
|
||||||
|
await import("../utils/shared-credential-manager.js");
|
||||||
|
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||||
|
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sharedCred) {
|
||||||
|
resolvedCredentials = {
|
||||||
|
username: sharedCred.username || username,
|
||||||
|
password: sharedCred.password,
|
||||||
|
key: sharedCred.key,
|
||||||
|
keyPassword: sharedCred.keyPassword,
|
||||||
|
keyType: sharedCred.keyType,
|
||||||
|
authType: sharedCred.authType,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
sshLogger.warn(`No shared credentials found for host ${id}`, {
|
||||||
|
operation: "ssh_credentials",
|
||||||
|
userId,
|
||||||
|
hostId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.warn(`Failed to resolve shared credential for host ${id}`, {
|
||||||
|
operation: "ssh_credentials",
|
||||||
|
hostId: id,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (ownerId) {
|
||||||
try {
|
try {
|
||||||
const credentials = await SimpleDBOps.select(
|
const credentials = await SimpleDBOps.select(
|
||||||
getDb()
|
getDb()
|
||||||
@@ -1066,11 +1107,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(sshCredentials.id, credentialId),
|
eq(sshCredentials.id, credentialId),
|
||||||
eq(sshCredentials.userId, hostConfig.userId),
|
eq(sshCredentials.userId, ownerId),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
"ssh_credentials",
|
"ssh_credentials",
|
||||||
hostConfig.userId,
|
ownerId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (credentials.length > 0) {
|
if (credentials.length > 0) {
|
||||||
@@ -1088,7 +1129,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
operation: "ssh_credentials",
|
operation: "ssh_credentials",
|
||||||
hostId: id,
|
hostId: id,
|
||||||
credentialId,
|
credentialId,
|
||||||
userId: hostConfig.userId,
|
userId: ownerId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1099,14 +1140,14 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
error: error instanceof Error ? error.message : "Unknown error",
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (credentialId && id) {
|
} else {
|
||||||
sshLogger.warn("Missing userId for credential resolution in terminal", {
|
sshLogger.warn("Missing userId for credential resolution in terminal", {
|
||||||
operation: "ssh_credentials",
|
operation: "ssh_credentials",
|
||||||
hostId: id,
|
hostId: id,
|
||||||
credentialId,
|
credentialId,
|
||||||
hasUserId: !!hostConfig.userId,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sshConn.on("ready", () => {
|
sshConn.on("ready", () => {
|
||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
@@ -1400,14 +1441,14 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
if (id && hostConfig.userId) {
|
if (id && hostConfig.userId) {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const hosts = await SimpleDBOps.select(
|
const hostResults = await SimpleDBOps.select(
|
||||||
getDb()
|
getDb()
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(sshData.id, id),
|
eq(hosts.id, id),
|
||||||
eq(sshData.userId, hostConfig.userId!),
|
eq(hosts.userId, hostConfig.userId!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
"ssh_data",
|
"ssh_data",
|
||||||
@@ -1415,8 +1456,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hostName =
|
const hostName =
|
||||||
hosts.length > 0 && hosts[0].name
|
hostResults.length > 0 && hostResults[0].name
|
||||||
? hosts[0].name
|
? hostResults[0].name
|
||||||
: `${username}@${ip}:${port}`;
|
: `${username}@${ip}:${port}`;
|
||||||
|
|
||||||
await axios.post(
|
await axios.post(
|
||||||
|
|||||||
@@ -608,9 +608,8 @@ async function connectSSHTunnel(
|
|||||||
tunnelConfig.requestingUserId &&
|
tunnelConfig.requestingUserId &&
|
||||||
tunnelConfig.requestingUserId !== tunnelConfig.sourceUserId
|
tunnelConfig.requestingUserId !== tunnelConfig.sourceUserId
|
||||||
) {
|
) {
|
||||||
const { SharedCredentialManager } = await import(
|
const { SharedCredentialManager } =
|
||||||
"../utils/shared-credential-manager.js"
|
await import("../utils/shared-credential-manager.js");
|
||||||
);
|
|
||||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||||
|
|
||||||
if (tunnelConfig.sourceHostId) {
|
if (tunnelConfig.sourceHostId) {
|
||||||
@@ -1750,7 +1749,7 @@ app.post(
|
|||||||
const internalAuthToken = await systemCrypto.getInternalAuthToken();
|
const internalAuthToken = await systemCrypto.getInternalAuthToken();
|
||||||
|
|
||||||
const allHostsResponse = await axios.get(
|
const allHostsResponse = await axios.get(
|
||||||
"http://localhost:30001/ssh/db/host/internal/all",
|
"http://localhost:30001/host/db/host/internal/all",
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -2028,7 +2027,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
|||||||
const internalAuthToken = await systemCrypto.getInternalAuthToken();
|
const internalAuthToken = await systemCrypto.getInternalAuthToken();
|
||||||
|
|
||||||
const autostartResponse = await axios.get(
|
const autostartResponse = await axios.get(
|
||||||
"http://localhost:30001/ssh/db/host/internal",
|
"http://localhost:30001/host/db/host/internal",
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -2038,7 +2037,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const allHostsResponse = await axios.get(
|
const allHostsResponse = await axios.get(
|
||||||
"http://localhost:30001/ssh/db/host/internal/all",
|
"http://localhost:30001/host/db/host/internal/all",
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -2050,7 +2049,6 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
|||||||
const autostartHosts: SSHHost[] = autostartResponse.data || [];
|
const autostartHosts: SSHHost[] = autostartResponse.data || [];
|
||||||
const allHosts: SSHHost[] = allHostsResponse.data || [];
|
const allHosts: SSHHost[] = allHostsResponse.data || [];
|
||||||
const autoStartTunnels: TunnelConfig[] = [];
|
const autoStartTunnels: TunnelConfig[] = [];
|
||||||
|
|
||||||
tunnelLogger.info(
|
tunnelLogger.info(
|
||||||
`Found ${autostartHosts.length} autostart hosts and ${allHosts.length} total hosts for endpointHost resolution`,
|
`Found ${autostartHosts.length} autostart hosts and ${allHosts.length} total hosts for endpointHost resolution`,
|
||||||
);
|
);
|
||||||
|
|||||||
+29
-3
@@ -112,9 +112,8 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
|||||||
await authManager.initialize();
|
await authManager.initialize();
|
||||||
DataCrypto.initialize();
|
DataCrypto.initialize();
|
||||||
|
|
||||||
const { OPKSSHBinaryManager } = await import(
|
const { OPKSSHBinaryManager } =
|
||||||
"./utils/opkssh-binary-manager.js"
|
await import("./utils/opkssh-binary-manager.js");
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
await OPKSSHBinaryManager.ensureBinary();
|
await OPKSSHBinaryManager.ensureBinary();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -143,6 +142,33 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
|||||||
await import("./ssh/docker-console.js");
|
await import("./ssh/docker-console.js");
|
||||||
await import("./dashboard.js");
|
await import("./dashboard.js");
|
||||||
|
|
||||||
|
// Initialize Guacamole server for RDP/VNC/Telnet support
|
||||||
|
const { getDb: getDbForGuac } = await import("./database/db/index.js");
|
||||||
|
const guacDb = getDbForGuac();
|
||||||
|
const guacEnabledRow = guacDb.$client
|
||||||
|
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
const guacEnabled = guacEnabledRow
|
||||||
|
? guacEnabledRow.value !== "false"
|
||||||
|
: true;
|
||||||
|
|
||||||
|
if (process.env.ENABLE_GUACAMOLE !== "false" && guacEnabled) {
|
||||||
|
try {
|
||||||
|
await import("./guacamole/guacamole-server.js");
|
||||||
|
systemLogger.info("Guacamole server initialized", {
|
||||||
|
operation: "guac_init",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
systemLogger.warn(
|
||||||
|
"Failed to initialize Guacamole server (guacd may not be available)",
|
||||||
|
{
|
||||||
|
operation: "guac_init_skip",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
systemLogger.success("Termix backend started successfully", {
|
systemLogger.success("Termix backend started successfully", {
|
||||||
operation: "backend_init_complete",
|
operation: "backend_init_complete",
|
||||||
port: process.env.PORT || 4090,
|
port: process.env.PORT || 4090,
|
||||||
|
|||||||
@@ -254,5 +254,6 @@ export const authLogger = new Logger("AUTH", "🔐", "#ef4444");
|
|||||||
export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
|
export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
|
||||||
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
|
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
|
||||||
export const dashboardLogger = new Logger("DASHBOARD", "📊", "#ec4899");
|
export const dashboardLogger = new Logger("DASHBOARD", "📊", "#ec4899");
|
||||||
|
export const guacLogger = new Logger("GUACAMOLE", "🖼️", "#ff6b6b");
|
||||||
|
|
||||||
export const logger = systemLogger;
|
export const logger = systemLogger;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
hostAccess,
|
hostAccess,
|
||||||
roles,
|
roles,
|
||||||
userRoles,
|
userRoles,
|
||||||
sshData,
|
hosts,
|
||||||
users,
|
users,
|
||||||
} from "../database/db/schema.js";
|
} from "../database/db/schema.js";
|
||||||
import { eq, and, or, isNull, gte, sql } from "drizzle-orm";
|
import { eq, and, or, isNull, gte, sql } from "drizzle-orm";
|
||||||
@@ -167,8 +167,8 @@ class PermissionManager {
|
|||||||
try {
|
try {
|
||||||
const host = await db
|
const host = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (host.length > 0) {
|
if (host.length > 0) {
|
||||||
@@ -210,9 +210,9 @@ class PermissionManager {
|
|||||||
const access = sharedAccess[0];
|
const access = sharedAccess[0];
|
||||||
|
|
||||||
const hostOwnerCheck = await db
|
const hostOwnerCheck = await db
|
||||||
.select({ ownerId: sshData.userId })
|
.select({ ownerId: hosts.userId })
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(eq(sshData.id, hostId))
|
.where(eq(hosts.id, hostId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (hostOwnerCheck.length > 0 && hostOwnerCheck[0].ownerId === userId) {
|
if (hostOwnerCheck.length > 0 && hostOwnerCheck[0].ownerId === userId) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
sshCredentials,
|
sshCredentials,
|
||||||
hostAccess,
|
hostAccess,
|
||||||
userRoles,
|
userRoles,
|
||||||
sshData,
|
hosts,
|
||||||
} from "../database/db/schema.js";
|
} from "../database/db/schema.js";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { DataCrypto } from "./data-crypto.js";
|
import { DataCrypto } from "./data-crypto.js";
|
||||||
@@ -190,6 +190,15 @@ class SharedCredentialManager {
|
|||||||
const cred = sharedCred[0].shared_credentials;
|
const cred = sharedCred[0].shared_credentials;
|
||||||
|
|
||||||
if (cred.needsReEncryption) {
|
if (cred.needsReEncryption) {
|
||||||
|
await this.reEncryptSharedCredential(cred.id, userId);
|
||||||
|
|
||||||
|
const refreshed = await db
|
||||||
|
.select()
|
||||||
|
.from(sharedCredentials)
|
||||||
|
.where(eq(sharedCredentials.id, cred.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (refreshed.length === 0 || refreshed[0].needsReEncryption) {
|
||||||
databaseLogger.warn(
|
databaseLogger.warn(
|
||||||
"Shared credential needs re-encryption but cannot be accessed yet",
|
"Shared credential needs re-encryption but cannot be accessed yet",
|
||||||
{
|
{
|
||||||
@@ -201,6 +210,9 @@ class SharedCredentialManager {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.decryptSharedCredential(refreshed[0], userDEK);
|
||||||
|
}
|
||||||
|
|
||||||
return this.decryptSharedCredential(cred, userDEK);
|
return this.decryptSharedCredential(cred, userDEK);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to get shared credential", error, {
|
databaseLogger.error("Failed to get shared credential", error, {
|
||||||
@@ -587,7 +599,7 @@ class SharedCredentialManager {
|
|||||||
const access = await db
|
const access = await db
|
||||||
.select()
|
.select()
|
||||||
.from(hostAccess)
|
.from(hostAccess)
|
||||||
.innerJoin(sshData, eq(hostAccess.hostId, sshData.id))
|
.innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
|
||||||
.where(eq(hostAccess.id, cred.hostAccessId))
|
.where(eq(hostAccess.id, cred.hostAccessId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,18 @@ export function detectPlatform(req: Request): DeviceType {
|
|||||||
return "mobile";
|
return "mobile";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userAgent.includes("Android")) {
|
const isDesktopOS =
|
||||||
|
userAgent.includes("Windows") ||
|
||||||
|
userAgent.includes("Macintosh") ||
|
||||||
|
userAgent.includes("Mac OS X") ||
|
||||||
|
userAgent.includes("X11") ||
|
||||||
|
userAgent.includes("Linux x86_64");
|
||||||
|
|
||||||
|
if (
|
||||||
|
(userAgent.includes("Android") && !isDesktopOS) ||
|
||||||
|
userAgent.includes("iPhone") ||
|
||||||
|
userAgent.includes("iPad")
|
||||||
|
) {
|
||||||
return "mobile";
|
return "mobile";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -303,9 +303,8 @@ class UserCrypto {
|
|||||||
await this.storeKEKSalt(userId, newKekSalt);
|
await this.storeKEKSalt(userId, newKekSalt);
|
||||||
await this.storeEncryptedDEK(userId, newEncryptedDEK);
|
await this.storeEncryptedDEK(userId, newEncryptedDEK);
|
||||||
|
|
||||||
const { saveMemoryDatabaseToFile } = await import(
|
const { saveMemoryDatabaseToFile } =
|
||||||
"../database/db/index.js"
|
await import("../database/db/index.js");
|
||||||
);
|
|
||||||
await saveMemoryDatabaseToFile();
|
await saveMemoryDatabaseToFile();
|
||||||
|
|
||||||
oldKEK.fill(0);
|
oldKEK.fill(0);
|
||||||
@@ -341,9 +340,8 @@ class UserCrypto {
|
|||||||
await this.storeKEKSalt(userId, newKekSalt);
|
await this.storeKEKSalt(userId, newKekSalt);
|
||||||
await this.storeEncryptedDEK(userId, newEncryptedDEK);
|
await this.storeEncryptedDEK(userId, newEncryptedDEK);
|
||||||
|
|
||||||
const { saveMemoryDatabaseToFile } = await import(
|
const { saveMemoryDatabaseToFile } =
|
||||||
"../database/db/index.js"
|
await import("../database/db/index.js");
|
||||||
);
|
|
||||||
await saveMemoryDatabaseToFile();
|
await saveMemoryDatabaseToFile();
|
||||||
|
|
||||||
newKEK.fill(0);
|
newKEK.fill(0);
|
||||||
@@ -418,9 +416,8 @@ class UserCrypto {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { saveMemoryDatabaseToFile } = await import(
|
const { saveMemoryDatabaseToFile } =
|
||||||
"../database/db/index.js"
|
await import("../database/db/index.js");
|
||||||
);
|
|
||||||
await saveMemoryDatabaseToFile();
|
await saveMemoryDatabaseToFile();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to convert to OIDC encryption", error, {
|
databaseLogger.error("Failed to convert to OIDC encryption", error, {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
import {
|
import {
|
||||||
users,
|
users,
|
||||||
sshData,
|
hosts,
|
||||||
sshCredentials,
|
sshCredentials,
|
||||||
fileManagerRecent,
|
fileManagerRecent,
|
||||||
fileManagerPinned,
|
fileManagerPinned,
|
||||||
@@ -74,8 +74,8 @@ class UserDataExport {
|
|||||||
|
|
||||||
const sshHosts = await getDb()
|
const sshHosts = await getDb()
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(eq(sshData.userId, userId));
|
.where(eq(hosts.userId, userId));
|
||||||
const processedSshHosts =
|
const processedSshHosts =
|
||||||
format === "plaintext" && userDataKey
|
format === "plaintext" && userDataKey
|
||||||
? sshHosts.map((host) =>
|
? sshHosts.map((host) =>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
import {
|
import {
|
||||||
users,
|
users,
|
||||||
sshData,
|
hosts,
|
||||||
sshCredentials,
|
sshCredentials,
|
||||||
fileManagerRecent,
|
fileManagerRecent,
|
||||||
fileManagerPinned,
|
fileManagerPinned,
|
||||||
@@ -179,13 +179,13 @@ class UserDataImport {
|
|||||||
|
|
||||||
const existing = await getDb()
|
const existing = await getDb()
|
||||||
.select()
|
.select()
|
||||||
.from(sshData)
|
.from(hosts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(sshData.userId, targetUserId),
|
eq(hosts.userId, targetUserId),
|
||||||
eq(sshData.ip, host.ip as string),
|
eq(hosts.ip, host.ip as string),
|
||||||
eq(sshData.port, host.port as number),
|
eq(hosts.port, host.port as number),
|
||||||
eq(sshData.username, host.username as string),
|
eq(hosts.username, host.username as string),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -218,15 +218,13 @@ class UserDataImport {
|
|||||||
|
|
||||||
if (existing.length > 0 && options.replaceExisting) {
|
if (existing.length > 0 && options.replaceExisting) {
|
||||||
await getDb()
|
await getDb()
|
||||||
.update(sshData)
|
.update(hosts)
|
||||||
.set(processedHostData as unknown as typeof sshData.$inferInsert)
|
.set(processedHostData as unknown as typeof hosts.$inferInsert)
|
||||||
.where(eq(sshData.id, existing[0].id));
|
.where(eq(hosts.id, existing[0].id));
|
||||||
} else {
|
} else {
|
||||||
await getDb()
|
await getDb()
|
||||||
.insert(sshData)
|
.insert(hosts)
|
||||||
.values(
|
.values(processedHostData as unknown as typeof hosts.$inferInsert);
|
||||||
processedHostData as unknown as typeof sshData.$inferInsert,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
imported++;
|
imported++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -37,8 +37,7 @@ const buttonVariants = cva(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ComponentProps<"button">,
|
extends React.ComponentProps<"button">, VariantProps<typeof buttonVariants> {
|
||||||
VariantProps<typeof buttonVariants> {
|
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+126
-2
@@ -810,6 +810,15 @@
|
|||||||
"globalSettingsSaved": "Global monitoring settings saved",
|
"globalSettingsSaved": "Global monitoring settings saved",
|
||||||
"failedToSaveGlobalSettings": "Failed to save global monitoring settings",
|
"failedToSaveGlobalSettings": "Failed to save global monitoring settings",
|
||||||
"failedToLoadGlobalSettings": "Failed to load global monitoring settings",
|
"failedToLoadGlobalSettings": "Failed to load global monitoring settings",
|
||||||
|
"guacamoleIntegration": "Remote Desktop Integration (Guacamole)",
|
||||||
|
"guacamoleIntegrationDesc": "Enable RDP, VNC, and Telnet connections via guacd. Requires a guacd instance to be running.",
|
||||||
|
"enableGuacamole": "Enable RDP/VNC/Telnet support",
|
||||||
|
"guacdUrl": "guacd URL (host:port)",
|
||||||
|
"guacdUrlPlaceholder": "guacd:4822",
|
||||||
|
"guacdUrlNote": "Changes to the guacd host/port require a server restart to take effect.",
|
||||||
|
"guacamoleSettingsSaved": "Guacamole settings saved",
|
||||||
|
"failedToSaveGuacamoleSettings": "Failed to save guacamole settings",
|
||||||
|
"failedToLoadGuacamoleSettings": "Failed to load guacamole settings",
|
||||||
"clampedToValidRange": "was adjusted to valid range",
|
"clampedToValidRange": "was adjusted to valid range",
|
||||||
"sessionManagement": "Session Management",
|
"sessionManagement": "Session Management",
|
||||||
"loadingSessions": "Loading sessions...",
|
"loadingSessions": "Loading sessions...",
|
||||||
@@ -875,11 +884,21 @@
|
|||||||
"importError": "Import error",
|
"importError": "Import error",
|
||||||
"failedToImportJson": "Failed to import JSON file",
|
"failedToImportJson": "Failed to import JSON file",
|
||||||
"connectionDetails": "Connection Details",
|
"connectionDetails": "Connection Details",
|
||||||
|
"connectionType": "Connection Type",
|
||||||
|
"ssh": "SSH",
|
||||||
|
"rdp": "Remote Desktop (RDP)",
|
||||||
|
"vnc": "VNC",
|
||||||
|
"telnet": "Telnet",
|
||||||
|
"remoteDesktop": "Remote Desktop",
|
||||||
|
"guacamoleSettings": "Remote Desktop Settings",
|
||||||
"organization": "Organization",
|
"organization": "Organization",
|
||||||
"ipAddress": "IP Address or Hostname",
|
"ipAddress": "IP Address or Hostname",
|
||||||
|
"ipRequired": "IP address is required",
|
||||||
|
"portRequired": "Port is required",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
|
"usernameRequired": "Username is required (SSH/Telnet only)",
|
||||||
"folder": "Folder",
|
"folder": "Folder",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"pin": "Pin",
|
"pin": "Pin",
|
||||||
@@ -988,6 +1007,8 @@
|
|||||||
"tunnel": "Tunnel",
|
"tunnel": "Tunnel",
|
||||||
"fileManager": "File Manager",
|
"fileManager": "File Manager",
|
||||||
"serverStats": "Server Stats",
|
"serverStats": "Server Stats",
|
||||||
|
"status": "Status",
|
||||||
|
"statistics": "Statistics",
|
||||||
"hostViewer": "Host Viewer",
|
"hostViewer": "Host Viewer",
|
||||||
"enableServerStats": "Enable Server Stats",
|
"enableServerStats": "Enable Server Stats",
|
||||||
"enableServerStatsDesc": "Enable/disable server statistics collection for this host",
|
"enableServerStatsDesc": "Enable/disable server statistics collection for this host",
|
||||||
@@ -1049,13 +1070,18 @@
|
|||||||
"statusCheckEnabledDesc": "Check if the server is online or offline",
|
"statusCheckEnabledDesc": "Check if the server is online or offline",
|
||||||
"statusCheckInterval": "Status Check Interval",
|
"statusCheckInterval": "Status Check Interval",
|
||||||
"statusCheckIntervalDesc": "How often to check if host is online (5s - 1h)",
|
"statusCheckIntervalDesc": "How often to check if host is online (5s - 1h)",
|
||||||
|
"statusChecks": "Status Checks",
|
||||||
|
"disableTcpPing": "Disable TCP Ping",
|
||||||
|
"disableTcpPingDescription": "Turn off status checks (online/offline detection) for this host",
|
||||||
"useGlobalStatusInterval": "Use global default",
|
"useGlobalStatusInterval": "Use global default",
|
||||||
"useGlobalMetricsInterval": "Use global default",
|
"useGlobalMetricsInterval": "Use global default",
|
||||||
"usingGlobalDefault": "Using global default ({{value}}s)",
|
"usingGlobalDefault": "Using global default ({{value}}s)",
|
||||||
|
"metricsCollection": "Metrics Collection",
|
||||||
"metricsEnabled": "Enable Metrics Monitoring",
|
"metricsEnabled": "Enable Metrics Monitoring",
|
||||||
"metricsEnabledDesc": "Collect CPU, RAM, disk, and other system statistics",
|
"metricsEnabledDesc": "Collect CPU, RAM, disk, and other system statistics",
|
||||||
"metricsInterval": "Metrics Collection Interval",
|
"metricsInterval": "Metrics Collection Interval",
|
||||||
"metricsIntervalDesc": "How often to collect server statistics (5s - 1h)",
|
"metricsIntervalDesc": "How often to collect server statistics (5s - 1h)",
|
||||||
|
"metricsNotAvailableForConnectionType": "Server metrics are only available for SSH hosts. TCP ping status checks can still be enabled above.",
|
||||||
"intervalSeconds": "seconds",
|
"intervalSeconds": "seconds",
|
||||||
"intervalMinutes": "minutes",
|
"intervalMinutes": "minutes",
|
||||||
"intervalValidation": "Monitoring intervals must be between 5 seconds and 1 hour (3600 seconds)",
|
"intervalValidation": "Monitoring intervals must be between 5 seconds and 1 hour (3600 seconds)",
|
||||||
@@ -1243,6 +1269,7 @@
|
|||||||
"copyTunnelUrl": "Copy Tunnel URL",
|
"copyTunnelUrl": "Copy Tunnel URL",
|
||||||
"copyServerStatsUrl": "Copy Server Stats URL",
|
"copyServerStatsUrl": "Copy Server Stats URL",
|
||||||
"copyDockerUrl": "Copy Docker URL",
|
"copyDockerUrl": "Copy Docker URL",
|
||||||
|
"copyRemoteDesktopUrl": "Copy Remote Desktop URL",
|
||||||
"fullScreenUrlTooltip": "Copy URL to open this app in full-screen mode",
|
"fullScreenUrlTooltip": "Copy URL to open this app in full-screen mode",
|
||||||
"notEnabled": "Docker is not enabled for this host. Enable it in Host Settings to use Docker features.",
|
"notEnabled": "Docker is not enabled for this host. Enable it in Host Settings to use Docker features.",
|
||||||
"validating": "Validating Docker...",
|
"validating": "Validating Docker...",
|
||||||
@@ -1361,7 +1388,103 @@
|
|||||||
"selectAll": "Select all",
|
"selectAll": "Select all",
|
||||||
"deselectAll": "Deselect all",
|
"deselectAll": "Deselect all",
|
||||||
"useGlobalStatusDefault": "Use Global Default (Status)",
|
"useGlobalStatusDefault": "Use Global Default (Status)",
|
||||||
"useGlobalMetricsDefault": "Use Global Default (Metrics)"
|
"useGlobalMetricsDefault": "Use Global Default (Metrics)",
|
||||||
|
"connectionType": "Connection Type",
|
||||||
|
"rdp": "RDP",
|
||||||
|
"vnc": "VNC",
|
||||||
|
"telnet": "Telnet",
|
||||||
|
"ssh": "SSH",
|
||||||
|
"remoteDesktop": "Remote Desktop",
|
||||||
|
"remoteDesktopSettings": "Remote Desktop Settings",
|
||||||
|
"domain": "Domain",
|
||||||
|
"securityMode": "Security Mode",
|
||||||
|
"ignoreCert": "Ignore Certificate",
|
||||||
|
"ignoreCertDesc": "Skip TLS certificate verification for this connection",
|
||||||
|
"displaySettings": "Display Settings",
|
||||||
|
"colorDepth": "Color Depth",
|
||||||
|
"width": "Width",
|
||||||
|
"height": "Height",
|
||||||
|
"dpi": "DPI",
|
||||||
|
"resizeMethod": "Resize Method",
|
||||||
|
"forceLossless": "Force Lossless",
|
||||||
|
"audioSettings": "Audio Settings",
|
||||||
|
"disableAudio": "Disable Audio",
|
||||||
|
"enableAudioInput": "Enable Audio Input",
|
||||||
|
"rdpPerformance": "Performance",
|
||||||
|
"enableWallpaper": "Enable Wallpaper",
|
||||||
|
"enableTheming": "Enable Theming",
|
||||||
|
"enableFontSmoothing": "Enable Font Smoothing",
|
||||||
|
"enableFullWindowDrag": "Enable Full Window Drag",
|
||||||
|
"enableDesktopComposition": "Enable Desktop Composition",
|
||||||
|
"enableMenuAnimations": "Enable Menu Animations",
|
||||||
|
"disableBitmapCaching": "Disable Bitmap Caching",
|
||||||
|
"disableOffscreenCaching": "Disable Offscreen Caching",
|
||||||
|
"disableGlyphCaching": "Disable Glyph Caching",
|
||||||
|
"enableGfx": "Enable GFX",
|
||||||
|
"deviceRedirection": "Device Redirection",
|
||||||
|
"enablePrinting": "Enable Printing",
|
||||||
|
"enableDrive": "Enable Drive Redirection",
|
||||||
|
"driveName": "Drive Name",
|
||||||
|
"drivePath": "Drive Path",
|
||||||
|
"createDrivePath": "Create Drive Path",
|
||||||
|
"disableDownload": "Disable Download",
|
||||||
|
"disableUpload": "Disable Upload",
|
||||||
|
"enableTouch": "Enable Touch",
|
||||||
|
"rdpSession": "Session",
|
||||||
|
"clientName": "Client Name",
|
||||||
|
"consoleSession": "Console Session",
|
||||||
|
"initialProgram": "Initial Program",
|
||||||
|
"serverLayout": "Server Keyboard Layout",
|
||||||
|
"timezone": "Timezone",
|
||||||
|
"gatewaySettings": "Gateway",
|
||||||
|
"gatewayHostname": "Gateway Hostname",
|
||||||
|
"gatewayPort": "Gateway Port",
|
||||||
|
"gatewayUsername": "Gateway Username",
|
||||||
|
"gatewayPassword": "Gateway Password",
|
||||||
|
"gatewayDomain": "Gateway Domain",
|
||||||
|
"remoteApp": "RemoteApp",
|
||||||
|
"remoteAppProgram": "Remote Application",
|
||||||
|
"remoteAppDir": "Remote App Directory",
|
||||||
|
"remoteAppArgs": "Remote App Arguments",
|
||||||
|
"clipboardSettings": "Clipboard",
|
||||||
|
"normalizeClipboard": "Normalize Clipboard",
|
||||||
|
"disableCopy": "Disable Copy",
|
||||||
|
"disablePaste": "Disable Paste",
|
||||||
|
"vncSettings": "VNC Settings",
|
||||||
|
"cursorMode": "Cursor Mode",
|
||||||
|
"swapRedBlue": "Swap Red/Blue",
|
||||||
|
"readOnly": "Read Only",
|
||||||
|
"recordingSettings": "Recording",
|
||||||
|
"recordingPath": "Recording Path",
|
||||||
|
"recordingName": "Recording Name",
|
||||||
|
"createRecordingPath": "Create Recording Path",
|
||||||
|
"excludeOutput": "Exclude Output",
|
||||||
|
"excludeMouse": "Exclude Mouse",
|
||||||
|
"includeKeys": "Include Keys",
|
||||||
|
"wakeOnLan": "Wake-on-LAN",
|
||||||
|
"sendWolPacket": "Send WoL Packet",
|
||||||
|
"wolMacAddr": "MAC Address",
|
||||||
|
"wolBroadcastAddr": "Broadcast Address",
|
||||||
|
"wolUdpPort": "UDP Port",
|
||||||
|
"wolWaitTime": "Wait Time (seconds)",
|
||||||
|
"connectionSettings": "Connection Settings",
|
||||||
|
"rdpOnly": "RDP only",
|
||||||
|
"vncOnly": "VNC only",
|
||||||
|
"telnetTerminalSettings": "Terminal Settings",
|
||||||
|
"terminalType": "Terminal Type",
|
||||||
|
"guacFontName": "Font Name",
|
||||||
|
"guacFontSize": "Font Size",
|
||||||
|
"guacColorScheme": "Color Scheme",
|
||||||
|
"guacBackspaceKey": "Backspace Key"
|
||||||
|
},
|
||||||
|
"guacamole": {
|
||||||
|
"connecting": "Connecting to {{type}} session...",
|
||||||
|
"rdpConnecting": "Connecting to RDP server...",
|
||||||
|
"vncConnecting": "Connecting to VNC server...",
|
||||||
|
"telnetConnecting": "Connecting to Telnet server...",
|
||||||
|
"connectionError": "Connection error",
|
||||||
|
"connectionFailed": "Connection failed",
|
||||||
|
"failedToConnect": "Failed to get connection token"
|
||||||
},
|
},
|
||||||
"terminal": {
|
"terminal": {
|
||||||
"title": "Split Screen",
|
"title": "Split Screen",
|
||||||
@@ -1408,6 +1531,7 @@
|
|||||||
"connected": "Connected",
|
"connected": "Connected",
|
||||||
"clipboardWriteFailed": "Failed to copy to clipboard. Make sure the page is served over HTTPS or localhost.",
|
"clipboardWriteFailed": "Failed to copy to clipboard. Make sure the page is served over HTTPS or localhost.",
|
||||||
"clipboardReadFailed": "Failed to read from clipboard. Make sure clipboard permissions are granted.",
|
"clipboardReadFailed": "Failed to read from clipboard. Make sure clipboard permissions are granted.",
|
||||||
|
"clipboardHttpWarning": "Paste requires HTTPS. Use Ctrl+Shift+V or serve Termix over HTTPS.",
|
||||||
"sshConnected": "SSH connection established",
|
"sshConnected": "SSH connection established",
|
||||||
"authError": "Authentication failed: {{message}}",
|
"authError": "Authentication failed: {{message}}",
|
||||||
"unknownError": "Unknown error occurred",
|
"unknownError": "Unknown error occurred",
|
||||||
@@ -2196,7 +2320,7 @@
|
|||||||
"terminalSyntaxHighlightingDesc": "Automatically highlight commands, paths, IPs, and log levels in terminal output",
|
"terminalSyntaxHighlightingDesc": "Automatically highlight commands, paths, IPs, and log levels in terminal output",
|
||||||
"enableCommandPaletteShortcut": "Enable Command Palette Shortcut",
|
"enableCommandPaletteShortcut": "Enable Command Palette Shortcut",
|
||||||
"enableCommandPaletteShortcutDesc": "Double-tap left Shift to open the Command Palette for quick access to hosts",
|
"enableCommandPaletteShortcutDesc": "Double-tap left Shift to open the Command Palette for quick access to hosts",
|
||||||
"enableTerminalSessionPersistence": "Persistent Terminal Sessions",
|
"enableTerminalSessionPersistence": "Persistent Tabs/Sessions",
|
||||||
"enableTerminalSessionPersistenceDesc": "Maintain SSH connections when switching tabs or closing the browser (may be unstable)"
|
"enableTerminalSessionPersistenceDesc": "Maintain SSH connections when switching tabs or closing the browser (may be unstable)"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
|
|||||||
+6
-1
@@ -8,12 +8,13 @@ import { ThemeProvider } from "@/components/theme-provider";
|
|||||||
import { ElectronVersionCheck } from "@/ui/desktop/user/ElectronVersionCheck.tsx";
|
import { ElectronVersionCheck } from "@/ui/desktop/user/ElectronVersionCheck.tsx";
|
||||||
import "./i18n/i18n";
|
import "./i18n/i18n";
|
||||||
import { isElectron } from "./ui/main-axios.ts";
|
import { isElectron } from "./ui/main-axios.ts";
|
||||||
import HostManagerApp from "./ui/desktop/apps/HostManagerApp.tsx";
|
import HostManagerApp from "./ui/desktop/apps/host-manager/HostManagerApp.tsx";
|
||||||
import TerminalApp from "./ui/desktop/apps/features/terminal/TerminalApp.tsx";
|
import TerminalApp from "./ui/desktop/apps/features/terminal/TerminalApp.tsx";
|
||||||
import FileManagerApp from "./ui/desktop/apps/features/file-manager/FileManagerApp.tsx";
|
import FileManagerApp from "./ui/desktop/apps/features/file-manager/FileManagerApp.tsx";
|
||||||
import TunnelApp from "./ui/desktop/apps/features/tunnel/TunnelApp.tsx";
|
import TunnelApp from "./ui/desktop/apps/features/tunnel/TunnelApp.tsx";
|
||||||
import ServerStatsApp from "./ui/desktop/apps/features/server-stats/ServerStatsApp.tsx";
|
import ServerStatsApp from "./ui/desktop/apps/features/server-stats/ServerStatsApp.tsx";
|
||||||
import DockerApp from "./ui/desktop/apps/features/docker/DockerApp.tsx";
|
import DockerApp from "./ui/desktop/apps/features/docker/DockerApp.tsx";
|
||||||
|
import GuacamoleApp from "@/ui/desktop/apps/features/guacamole/GuacamoleApp.tsx";
|
||||||
|
|
||||||
const FullscreenApp: React.FC = () => {
|
const FullscreenApp: React.FC = () => {
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
@@ -33,6 +34,10 @@ const FullscreenApp: React.FC = () => {
|
|||||||
return <ServerStatsApp hostId={hostId || undefined} />;
|
return <ServerStatsApp hostId={hostId || undefined} />;
|
||||||
case "docker":
|
case "docker":
|
||||||
return <DockerApp hostId={hostId || undefined} />;
|
return <DockerApp hostId={hostId || undefined} />;
|
||||||
|
case "rdp":
|
||||||
|
case "vnc":
|
||||||
|
case "telnet":
|
||||||
|
return <GuacamoleApp hostId={hostId || undefined} />;
|
||||||
default:
|
default:
|
||||||
return <DesktopApp />;
|
return <DesktopApp />;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+108
@@ -0,0 +1,108 @@
|
|||||||
|
declare module "guacamole-common-js" {
|
||||||
|
namespace Guacamole {
|
||||||
|
class Client {
|
||||||
|
constructor(tunnel: Tunnel);
|
||||||
|
connect(data?: string): void;
|
||||||
|
disconnect(): void;
|
||||||
|
getDisplay(): Display;
|
||||||
|
sendKeyEvent(pressed: number, keysym: number): void;
|
||||||
|
sendMouseState(state: Mouse.State): void;
|
||||||
|
setClipboard(stream: OutputStream, mimetype: string): void;
|
||||||
|
createClipboardStream(mimetype: string): OutputStream;
|
||||||
|
onstatechange: ((state: number) => void) | null;
|
||||||
|
onerror: ((error: Status) => void) | null;
|
||||||
|
onclipboard: ((stream: InputStream, mimetype: string) => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Display {
|
||||||
|
getElement(): HTMLElement;
|
||||||
|
getWidth(): number;
|
||||||
|
getHeight(): number;
|
||||||
|
scale(scale: number): void;
|
||||||
|
onresize: (() => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Tunnel {
|
||||||
|
onerror: ((status: Status) => void) | null;
|
||||||
|
onstatechange: ((state: number) => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketTunnel extends Tunnel {
|
||||||
|
constructor(url: string);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Mouse {
|
||||||
|
constructor(element: HTMLElement);
|
||||||
|
onmousedown: ((state: Mouse.State) => void) | null;
|
||||||
|
onmouseup: ((state: Mouse.State) => void) | null;
|
||||||
|
onmousemove: ((state: Mouse.State) => void) | null;
|
||||||
|
onmouseout: ((state: Mouse.State) => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Mouse {
|
||||||
|
class State {
|
||||||
|
constructor(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
left?: boolean,
|
||||||
|
middle?: boolean,
|
||||||
|
right?: boolean,
|
||||||
|
up?: boolean,
|
||||||
|
down?: boolean,
|
||||||
|
);
|
||||||
|
constructor(state: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
left?: boolean;
|
||||||
|
middle?: boolean;
|
||||||
|
right?: boolean;
|
||||||
|
up?: boolean;
|
||||||
|
down?: boolean;
|
||||||
|
});
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
left: boolean;
|
||||||
|
middle: boolean;
|
||||||
|
right: boolean;
|
||||||
|
up: boolean;
|
||||||
|
down: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Keyboard {
|
||||||
|
constructor(element: Document | HTMLElement);
|
||||||
|
onkeydown: ((keysym: number) => void) | null;
|
||||||
|
onkeyup: ((keysym: number) => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Status {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
isError(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InputStream {
|
||||||
|
onblob: ((data: string) => void) | null;
|
||||||
|
onend: (() => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OutputStream {
|
||||||
|
sendBlob(data: string): void;
|
||||||
|
sendEnd(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class StringReader {
|
||||||
|
constructor(stream: InputStream);
|
||||||
|
ontext: ((text: string) => void) | null;
|
||||||
|
onend: (() => void) | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class StringWriter {
|
||||||
|
constructor(stream: OutputStream);
|
||||||
|
sendText(text: string): void;
|
||||||
|
sendEnd(): void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Guacamole;
|
||||||
|
}
|
||||||
+36
-4
@@ -2,9 +2,21 @@ import type { Client } from "ssh2";
|
|||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// SSH HOST TYPES
|
// HOST TYPES (SSH, RDP, VNC, Telnet)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ConnectionType = "ssh" | "rdp" | "vnc" | "telnet";
|
||||||
|
export type SSHAuthType = "password" | "key" | "credential" | "none" | "opkssh";
|
||||||
|
export type GuacamoleAuthType = "password" | "credential";
|
||||||
|
|
||||||
|
export interface HostFeatureFlags {
|
||||||
|
enableTerminal: boolean; // SSH, Telnet only
|
||||||
|
enableTunnel: boolean; // SSH only
|
||||||
|
enableFileManager: boolean; // SSH only
|
||||||
|
enableDocker: boolean; // SSH only
|
||||||
|
enableRemoteDesktop: boolean; // RDP, VNC only
|
||||||
|
}
|
||||||
|
|
||||||
export interface JumpHost {
|
export interface JumpHost {
|
||||||
hostId: number;
|
hostId: number;
|
||||||
}
|
}
|
||||||
@@ -14,7 +26,7 @@ export interface QuickAction {
|
|||||||
snippetId: number;
|
snippetId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHHost {
|
export interface Host {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
@@ -62,6 +74,12 @@ export interface SSHHost {
|
|||||||
socks5Password?: string;
|
socks5Password?: string;
|
||||||
socks5ProxyChain?: ProxyNode[];
|
socks5ProxyChain?: ProxyNode[];
|
||||||
|
|
||||||
|
connectionType?: "ssh" | "rdp" | "vnc" | "telnet";
|
||||||
|
domain?: string;
|
||||||
|
security?: string;
|
||||||
|
ignoreCert?: boolean;
|
||||||
|
guacamoleConfig?: string | Record<string, unknown>;
|
||||||
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|
||||||
@@ -87,7 +105,7 @@ export interface ProxyNode {
|
|||||||
password?: string;
|
password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHHostData {
|
export interface HostData {
|
||||||
name?: string;
|
name?: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -127,8 +145,18 @@ export interface SSHHostData {
|
|||||||
socks5Username?: string;
|
socks5Username?: string;
|
||||||
socks5Password?: string;
|
socks5Password?: string;
|
||||||
socks5ProxyChain?: ProxyNode[];
|
socks5ProxyChain?: ProxyNode[];
|
||||||
|
|
||||||
|
connectionType?: "ssh" | "rdp" | "vnc" | "telnet";
|
||||||
|
domain?: string;
|
||||||
|
security?: string;
|
||||||
|
ignoreCert?: boolean;
|
||||||
|
guacamoleConfig?: Record<string, unknown> | null;
|
||||||
|
dockerConfig?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SSHHost = Host;
|
||||||
|
export type SSHHostData = HostData;
|
||||||
|
|
||||||
export interface SSHFolder {
|
export interface SSHFolder {
|
||||||
id: number;
|
id: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -415,12 +443,16 @@ export interface TabContextTab {
|
|||||||
| "file_manager"
|
| "file_manager"
|
||||||
| "user_profile"
|
| "user_profile"
|
||||||
| "docker"
|
| "docker"
|
||||||
| "network_graph";
|
| "network_graph"
|
||||||
|
| "rdp"
|
||||||
|
| "vnc"
|
||||||
|
| "telnet";
|
||||||
title: string;
|
title: string;
|
||||||
hostConfig?: SSHHost;
|
hostConfig?: SSHHost;
|
||||||
terminalRef?: any;
|
terminalRef?: any;
|
||||||
initialTab?: string;
|
initialTab?: string;
|
||||||
_updateTimestamp?: number;
|
_updateTimestamp?: number;
|
||||||
|
connectionConfig?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid";
|
export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid";
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface StatsConfig {
|
|||||||
metricsEnabled: boolean;
|
metricsEnabled: boolean;
|
||||||
metricsInterval: number;
|
metricsInterval: number;
|
||||||
useGlobalMetricsInterval?: boolean;
|
useGlobalMetricsInterval?: boolean;
|
||||||
|
disableTcpPing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_STATS_CONFIG: StatsConfig = {
|
export const DEFAULT_STATS_CONFIG: StatsConfig = {
|
||||||
@@ -75,4 +76,5 @@ export const DEFAULT_STATS_CONFIG: StatsConfig = {
|
|||||||
metricsEnabled: true,
|
metricsEnabled: true,
|
||||||
metricsInterval: 30,
|
metricsInterval: 30,
|
||||||
useGlobalMetricsInterval: true,
|
useGlobalMetricsInterval: true,
|
||||||
|
disableTcpPing: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import React, {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useCallback,
|
useCallback,
|
||||||
useRef,
|
useRef,
|
||||||
|
useMemo,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { getAllServerStatuses, getSSHHosts } from "@/ui/main-axios";
|
import { getAllServerStatuses, getSSHHosts } from "@/ui/main-axios";
|
||||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||||
@@ -71,7 +72,13 @@ export function ServerStatusProvider({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setEnabledHostIds(enabled);
|
setEnabledHostIds((prev) => {
|
||||||
|
if (prev.size !== enabled.size) return enabled;
|
||||||
|
for (const id of enabled) {
|
||||||
|
if (!prev.has(id)) return enabled;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
return enabled;
|
return enabled;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Set<number>();
|
return new Set<number>();
|
||||||
@@ -125,14 +132,19 @@ export function ServerStatusProvider({
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
const stableEnabledHostIds = useMemo(
|
||||||
|
() => enabledHostIds,
|
||||||
|
[[...enabledHostIds].sort().join(",")],
|
||||||
|
);
|
||||||
|
|
||||||
const getStatus = useCallback(
|
const getStatus = useCallback(
|
||||||
(hostId: number): StatusValue => {
|
(hostId: number): StatusValue => {
|
||||||
if (!enabledHostIds.has(hostId)) {
|
if (!stableEnabledHostIds.has(hostId)) {
|
||||||
return "offline";
|
return "offline";
|
||||||
}
|
}
|
||||||
return statuses.get(hostId)?.status || "degraded";
|
return statuses.get(hostId)?.status || "degraded";
|
||||||
},
|
},
|
||||||
[statuses, enabledHostIds],
|
[statuses, stableEnabledHostIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -156,9 +156,8 @@ function AppContent({
|
|||||||
if (hostIdentifier) {
|
if (hostIdentifier) {
|
||||||
const openTerminal = async () => {
|
const openTerminal = async () => {
|
||||||
try {
|
try {
|
||||||
const { getSSHHostById, getSSHHosts } = await import(
|
const { getSSHHostById, getSSHHosts } =
|
||||||
"@/ui/main-axios.ts"
|
await import("@/ui/main-axios.ts");
|
||||||
);
|
|
||||||
let host = null;
|
let host = null;
|
||||||
|
|
||||||
if (/^\d+$/.test(hostIdentifier)) {
|
if (/^\d+$/.test(hostIdentifier)) {
|
||||||
@@ -282,6 +281,9 @@ function AppContent({
|
|||||||
currentTabData?.type === "terminal" ||
|
currentTabData?.type === "terminal" ||
|
||||||
currentTabData?.type === "server_stats" ||
|
currentTabData?.type === "server_stats" ||
|
||||||
currentTabData?.type === "file_manager" ||
|
currentTabData?.type === "file_manager" ||
|
||||||
|
currentTabData?.type === "rdp" ||
|
||||||
|
currentTabData?.type === "vnc" ||
|
||||||
|
currentTabData?.type === "telnet" ||
|
||||||
currentTabData?.type === "tunnel" ||
|
currentTabData?.type === "tunnel" ||
|
||||||
currentTabData?.type === "docker" ||
|
currentTabData?.type === "docker" ||
|
||||||
currentTabData?.type === "network_graph";
|
currentTabData?.type === "network_graph";
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ import {
|
|||||||
updatePasswordResetAllowed,
|
updatePasswordResetAllowed,
|
||||||
getGlobalMonitoringSettings,
|
getGlobalMonitoringSettings,
|
||||||
updateGlobalMonitoringSettings,
|
updateGlobalMonitoringSettings,
|
||||||
|
getGuacamoleSettings,
|
||||||
|
updateGuacamoleSettings,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
|
|
||||||
interface GeneralSettingsTabProps {
|
interface GeneralSettingsTabProps {
|
||||||
allowRegistration: boolean;
|
allowRegistration: boolean;
|
||||||
@@ -51,9 +54,10 @@ export function GeneralSettingsTab({
|
|||||||
const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false);
|
const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false);
|
||||||
const [passwordResetLoading, setPasswordResetLoading] = React.useState(false);
|
const [passwordResetLoading, setPasswordResetLoading] = React.useState(false);
|
||||||
|
|
||||||
// Global monitoring defaults
|
|
||||||
const [statusInterval, setStatusInterval] = React.useState(60);
|
const [statusInterval, setStatusInterval] = React.useState(60);
|
||||||
const [metricsInterval, setMetricsInterval] = React.useState(30);
|
const [metricsInterval, setMetricsInterval] = React.useState(30);
|
||||||
|
const [statusInputValue, setStatusInputValue] = React.useState("60");
|
||||||
|
const [metricsInputValue, setMetricsInputValue] = React.useState("30");
|
||||||
const [statusUnit, setStatusUnit] = React.useState<"seconds" | "minutes">(
|
const [statusUnit, setStatusUnit] = React.useState<"seconds" | "minutes">(
|
||||||
"seconds",
|
"seconds",
|
||||||
);
|
);
|
||||||
@@ -62,25 +66,58 @@ export function GeneralSettingsTab({
|
|||||||
);
|
);
|
||||||
const [monitoringLoading, setMonitoringLoading] = React.useState(false);
|
const [monitoringLoading, setMonitoringLoading] = React.useState(false);
|
||||||
|
|
||||||
|
const [guacEnabled, setGuacEnabled] = React.useState(true);
|
||||||
|
const [guacUrl, setGuacUrl] = React.useState("guacd:4822");
|
||||||
|
const [guacLoading, setGuacLoading] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
getGuacamoleSettings()
|
||||||
|
.then((data) => {
|
||||||
|
setGuacEnabled(data.enabled);
|
||||||
|
setGuacUrl(data.url);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(t("admin.failedToLoadGuacamoleSettings"));
|
||||||
|
});
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const saveGuacDebounce = React.useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const saveGuacSettings = React.useCallback(
|
||||||
|
(newEnabled: boolean, newUrl: string) => {
|
||||||
|
if (saveGuacDebounce.current) {
|
||||||
|
clearTimeout(saveGuacDebounce.current);
|
||||||
|
}
|
||||||
|
saveGuacDebounce.current = setTimeout(async () => {
|
||||||
|
setGuacLoading(true);
|
||||||
|
try {
|
||||||
|
await updateGuacamoleSettings({ enabled: newEnabled, url: newUrl });
|
||||||
|
toast.success(t("admin.guacamoleSettingsSaved"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("admin.failedToSaveGuacamoleSettings"));
|
||||||
|
} finally {
|
||||||
|
setGuacLoading(false);
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
getGlobalMonitoringSettings()
|
getGlobalMonitoringSettings()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setStatusInterval(data.statusCheckInterval);
|
setStatusInterval(data.statusCheckInterval);
|
||||||
setMetricsInterval(data.metricsInterval);
|
setMetricsInterval(data.metricsInterval);
|
||||||
|
setStatusInputValue(String(data.statusCheckInterval));
|
||||||
|
setMetricsInputValue(String(data.metricsInterval));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Use defaults silently
|
// Use defaults silently
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveMonitoringDebounce = React.useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
const saveMonitoringSettings = React.useCallback(
|
const saveMonitoringSettings = React.useCallback(
|
||||||
(newStatus: number, newMetrics: number) => {
|
async (newStatus: number, newMetrics: number) => {
|
||||||
if (saveMonitoringDebounce.current) {
|
|
||||||
clearTimeout(saveMonitoringDebounce.current);
|
|
||||||
}
|
|
||||||
saveMonitoringDebounce.current = setTimeout(async () => {
|
|
||||||
setMonitoringLoading(true);
|
setMonitoringLoading(true);
|
||||||
try {
|
try {
|
||||||
await updateGlobalMonitoringSettings({
|
await updateGlobalMonitoringSettings({
|
||||||
@@ -97,24 +134,33 @@ export function GeneralSettingsTab({
|
|||||||
} finally {
|
} finally {
|
||||||
setMonitoringLoading(false);
|
setMonitoringLoading(false);
|
||||||
}
|
}
|
||||||
}, 800);
|
|
||||||
},
|
},
|
||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStatusIntervalChange = (value: string) => {
|
const handleStatusBlur = () => {
|
||||||
const num = parseInt(value) || 0;
|
const num = parseInt(statusInputValue) || 0;
|
||||||
const seconds = statusUnit === "minutes" ? num * 60 : num;
|
const seconds = statusUnit === "minutes" ? num * 60 : num;
|
||||||
const clamped = Math.max(5, Math.min(3600, seconds));
|
const clamped = Math.max(5, Math.min(3600, seconds));
|
||||||
setStatusInterval(clamped);
|
setStatusInterval(clamped);
|
||||||
|
setStatusInputValue(
|
||||||
|
statusUnit === "minutes"
|
||||||
|
? String(Math.round(clamped / 60))
|
||||||
|
: String(clamped),
|
||||||
|
);
|
||||||
saveMonitoringSettings(clamped, metricsInterval);
|
saveMonitoringSettings(clamped, metricsInterval);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMetricsIntervalChange = (value: string) => {
|
const handleMetricsBlur = () => {
|
||||||
const num = parseInt(value) || 0;
|
const num = parseInt(metricsInputValue) || 0;
|
||||||
const seconds = metricsUnit === "minutes" ? num * 60 : num;
|
const seconds = metricsUnit === "minutes" ? num * 60 : num;
|
||||||
const clamped = Math.max(5, Math.min(3600, seconds));
|
const clamped = Math.max(5, Math.min(3600, seconds));
|
||||||
setMetricsInterval(clamped);
|
setMetricsInterval(clamped);
|
||||||
|
setMetricsInputValue(
|
||||||
|
metricsUnit === "minutes"
|
||||||
|
? String(Math.round(clamped / 60))
|
||||||
|
: String(clamped),
|
||||||
|
);
|
||||||
saveMonitoringSettings(statusInterval, clamped);
|
saveMonitoringSettings(statusInterval, clamped);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -244,12 +290,9 @@ export function GeneralSettingsTab({
|
|||||||
<div className="flex gap-2 mt-1">
|
<div className="flex gap-2 mt-1">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={
|
value={statusInputValue}
|
||||||
statusUnit === "minutes"
|
onChange={(e) => setStatusInputValue(e.target.value)}
|
||||||
? Math.round(statusInterval / 60)
|
onBlur={handleStatusBlur}
|
||||||
: statusInterval
|
|
||||||
}
|
|
||||||
onChange={(e) => handleStatusIntervalChange(e.target.value)}
|
|
||||||
disabled={monitoringLoading}
|
disabled={monitoringLoading}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
@@ -257,6 +300,11 @@ export function GeneralSettingsTab({
|
|||||||
value={statusUnit}
|
value={statusUnit}
|
||||||
onValueChange={(value: "seconds" | "minutes") => {
|
onValueChange={(value: "seconds" | "minutes") => {
|
||||||
setStatusUnit(value);
|
setStatusUnit(value);
|
||||||
|
setStatusInputValue(
|
||||||
|
value === "minutes"
|
||||||
|
? String(Math.round(statusInterval / 60))
|
||||||
|
: String(statusInterval),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[120px]">
|
<SelectTrigger className="w-[120px]">
|
||||||
@@ -280,12 +328,9 @@ export function GeneralSettingsTab({
|
|||||||
<div className="flex gap-2 mt-1">
|
<div className="flex gap-2 mt-1">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={
|
value={metricsInputValue}
|
||||||
metricsUnit === "minutes"
|
onChange={(e) => setMetricsInputValue(e.target.value)}
|
||||||
? Math.round(metricsInterval / 60)
|
onBlur={handleMetricsBlur}
|
||||||
: metricsInterval
|
|
||||||
}
|
|
||||||
onChange={(e) => handleMetricsIntervalChange(e.target.value)}
|
|
||||||
disabled={monitoringLoading}
|
disabled={monitoringLoading}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
@@ -293,6 +338,11 @@ export function GeneralSettingsTab({
|
|||||||
value={metricsUnit}
|
value={metricsUnit}
|
||||||
onValueChange={(value: "seconds" | "minutes") => {
|
onValueChange={(value: "seconds" | "minutes") => {
|
||||||
setMetricsUnit(value);
|
setMetricsUnit(value);
|
||||||
|
setMetricsInputValue(
|
||||||
|
value === "minutes"
|
||||||
|
? String(Math.round(metricsInterval / 60))
|
||||||
|
: String(metricsInterval),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[120px]">
|
<SelectTrigger className="w-[120px]">
|
||||||
@@ -311,6 +361,55 @@ export function GeneralSettingsTab({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{t("admin.guacamoleIntegration")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("admin.guacamoleIntegrationDesc")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-3 text-xs"
|
||||||
|
onClick={() =>
|
||||||
|
window.open("https://docs.termix.site/remote-desktop", "_blank")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("common.documentation")}
|
||||||
|
</Button>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={guacEnabled}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const val = checked === true;
|
||||||
|
setGuacEnabled(val);
|
||||||
|
saveGuacSettings(val, guacUrl);
|
||||||
|
}}
|
||||||
|
disabled={guacLoading}
|
||||||
|
/>
|
||||||
|
{t("admin.enableGuacamole")}
|
||||||
|
</label>
|
||||||
|
{guacEnabled && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">{t("admin.guacdUrl")}</label>
|
||||||
|
<Input
|
||||||
|
className="mt-1"
|
||||||
|
value={guacUrl}
|
||||||
|
placeholder={t("admin.guacdUrlPlaceholder")}
|
||||||
|
disabled={guacLoading}
|
||||||
|
onChange={(e) => {
|
||||||
|
setGuacUrl(e.target.value);
|
||||||
|
saveGuacSettings(guacEnabled, e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{t("admin.guacdUrlNote")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import {
|
|||||||
User,
|
User,
|
||||||
Github,
|
Github,
|
||||||
Terminal,
|
Terminal,
|
||||||
|
Monitor,
|
||||||
|
Eye,
|
||||||
|
MessagesSquare,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Pencil,
|
Pencil,
|
||||||
EllipsisVertical,
|
EllipsisVertical,
|
||||||
@@ -27,8 +30,14 @@ import { BiMoney, BiSupport } from "react-icons/bi";
|
|||||||
import { BsDiscord } from "react-icons/bs";
|
import { BsDiscord } from "react-icons/bs";
|
||||||
import { GrUpdate } from "react-icons/gr";
|
import { GrUpdate } from "react-icons/gr";
|
||||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||||
import { getRecentActivity, getSSHHosts } from "@/ui/main-axios.ts";
|
import {
|
||||||
|
getRecentActivity,
|
||||||
|
getSSHHosts,
|
||||||
|
getGuacamoleToken,
|
||||||
|
logActivity,
|
||||||
|
} from "@/ui/main-axios.ts";
|
||||||
import type { RecentActivityItem } from "@/ui/main-axios.ts";
|
import type { RecentActivityItem } from "@/ui/main-axios.ts";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -62,6 +71,16 @@ interface SSHHost {
|
|||||||
statsConfig?: string;
|
statsConfig?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
connectionType?: "ssh" | "rdp" | "vnc" | "telnet";
|
||||||
|
domain?: string;
|
||||||
|
security?: string;
|
||||||
|
ignoreCert?: boolean;
|
||||||
|
guacamoleConfig?: any;
|
||||||
|
showTerminalInSidebar?: boolean;
|
||||||
|
showFileManagerInSidebar?: boolean;
|
||||||
|
showTunnelInSidebar?: boolean;
|
||||||
|
showDockerInSidebar?: boolean;
|
||||||
|
showServerStatsInSidebar?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommandPalette({
|
export function CommandPalette({
|
||||||
@@ -204,10 +223,60 @@ export function CommandPalette({
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHostTerminalClick = (host: SSHHost) => {
|
const handleHostTerminalClick = async (host: SSHHost) => {
|
||||||
const title = host.name?.trim()
|
const title = host.name?.trim()
|
||||||
? host.name
|
? host.name
|
||||||
: `${host.username}@${host.ip}:${host.port}`;
|
: `${host.username}@${host.ip}:${host.port}`;
|
||||||
|
|
||||||
|
if (
|
||||||
|
host.connectionType === "rdp" ||
|
||||||
|
host.connectionType === "vnc" ||
|
||||||
|
host.connectionType === "telnet"
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const protocol = host.connectionType as "rdp" | "vnc" | "telnet";
|
||||||
|
const result = await getGuacamoleToken({
|
||||||
|
protocol,
|
||||||
|
hostname: host.ip,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
password: host.password,
|
||||||
|
domain: host.domain,
|
||||||
|
security: host.security,
|
||||||
|
ignoreCert: host.ignoreCert,
|
||||||
|
guacamoleConfig: host.guacamoleConfig as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
addTab({
|
||||||
|
type: protocol,
|
||||||
|
title,
|
||||||
|
hostConfig: host,
|
||||||
|
connectionConfig: {
|
||||||
|
token: result.token,
|
||||||
|
protocol,
|
||||||
|
type: protocol,
|
||||||
|
hostname: host.ip,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
password: host.password,
|
||||||
|
domain: host.domain,
|
||||||
|
security: host.security,
|
||||||
|
"ignore-cert": host.ignoreCert,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await logActivity(protocol, host.id, title);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Failed to log ${protocol} activity:`, err);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to get Guacamole token:", err);
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
addTab({ type: "terminal", title, hostConfig: host });
|
addTab({ type: "terminal", title, hostConfig: host });
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
@@ -340,6 +409,9 @@ export function CommandPalette({
|
|||||||
shouldShowMetrics = true;
|
shouldShowMetrics = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSSH =
|
||||||
|
!host.connectionType || host.connectionType === "ssh";
|
||||||
|
|
||||||
let hasTunnelConnections = false;
|
let hasTunnelConnections = false;
|
||||||
try {
|
try {
|
||||||
const tunnelConnections = Array.isArray(
|
const tunnelConnections = Array.isArray(
|
||||||
@@ -356,12 +428,17 @@ export function CommandPalette({
|
|||||||
|
|
||||||
const visibleButtons = [
|
const visibleButtons = [
|
||||||
host.enableTerminal && (host.showTerminalInSidebar ?? true),
|
host.enableTerminal && (host.showTerminalInSidebar ?? true),
|
||||||
|
isSSH &&
|
||||||
host.enableFileManager &&
|
host.enableFileManager &&
|
||||||
(host.showFileManagerInSidebar ?? false),
|
(host.showFileManagerInSidebar ?? false),
|
||||||
|
isSSH &&
|
||||||
host.enableTunnel &&
|
host.enableTunnel &&
|
||||||
hasTunnelConnections &&
|
hasTunnelConnections &&
|
||||||
(host.showTunnelInSidebar ?? false),
|
(host.showTunnelInSidebar ?? false),
|
||||||
host.enableDocker && (host.showDockerInSidebar ?? false),
|
isSSH &&
|
||||||
|
host.enableDocker &&
|
||||||
|
(host.showDockerInSidebar ?? false),
|
||||||
|
isSSH &&
|
||||||
shouldShowMetrics &&
|
shouldShowMetrics &&
|
||||||
(host.showServerStatsInSidebar ?? false),
|
(host.showServerStatsInSidebar ?? false),
|
||||||
].filter(Boolean).length;
|
].filter(Boolean).length;
|
||||||
@@ -395,11 +472,20 @@ export function CommandPalette({
|
|||||||
handleHostTerminalClick(host);
|
handleHostTerminalClick(host);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{host.connectionType === "rdp" ? (
|
||||||
|
<Monitor className="h-3 w-3" />
|
||||||
|
) : host.connectionType === "vnc" ? (
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
) : host.connectionType === "telnet" ? (
|
||||||
|
<MessagesSquare className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
<Terminal className="h-3 w-3" />
|
<Terminal className="h-3 w-3" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{host.enableFileManager &&
|
{isSSH &&
|
||||||
|
host.enableFileManager &&
|
||||||
(host.showFileManagerInSidebar ?? false) && (
|
(host.showFileManagerInSidebar ?? false) && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -413,7 +499,8 @@ export function CommandPalette({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{host.enableTunnel &&
|
{isSSH &&
|
||||||
|
host.enableTunnel &&
|
||||||
hasTunnelConnections &&
|
hasTunnelConnections &&
|
||||||
(host.showTunnelInSidebar ?? false) && (
|
(host.showTunnelInSidebar ?? false) && (
|
||||||
<Button
|
<Button
|
||||||
@@ -428,7 +515,8 @@ export function CommandPalette({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{host.enableDocker &&
|
{isSSH &&
|
||||||
|
host.enableDocker &&
|
||||||
(host.showDockerInSidebar ?? false) && (
|
(host.showDockerInSidebar ?? false) && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -442,7 +530,8 @@ export function CommandPalette({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{shouldShowMetrics &&
|
{isSSH &&
|
||||||
|
shouldShowMetrics &&
|
||||||
(host.showServerStatsInSidebar ?? false) && (
|
(host.showServerStatsInSidebar ?? false) && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -484,13 +573,22 @@ export function CommandPalette({
|
|||||||
}}
|
}}
|
||||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||||
>
|
>
|
||||||
|
{host.connectionType === "rdp" ? (
|
||||||
|
<Monitor className="h-4 w-4" />
|
||||||
|
) : host.connectionType === "vnc" ? (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
) : host.connectionType === "telnet" ? (
|
||||||
|
<MessagesSquare className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
<Terminal className="h-4 w-4" />
|
<Terminal className="h-4 w-4" />
|
||||||
|
)}
|
||||||
<span className="flex-1">
|
<span className="flex-1">
|
||||||
{t("hosts.openTerminal")}
|
{t("hosts.openTerminal")}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{shouldShowMetrics &&
|
{isSSH &&
|
||||||
|
shouldShowMetrics &&
|
||||||
!(host.showServerStatsInSidebar ?? false) && (
|
!(host.showServerStatsInSidebar ?? false) && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -505,7 +603,8 @@ export function CommandPalette({
|
|||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{host.enableFileManager &&
|
{isSSH &&
|
||||||
|
host.enableFileManager &&
|
||||||
!(host.showFileManagerInSidebar ?? false) && (
|
!(host.showFileManagerInSidebar ?? false) && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -520,7 +619,8 @@ export function CommandPalette({
|
|||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{host.enableTunnel &&
|
{isSSH &&
|
||||||
|
host.enableTunnel &&
|
||||||
hasTunnelConnections &&
|
hasTunnelConnections &&
|
||||||
!(host.showTunnelInSidebar ?? false) && (
|
!(host.showTunnelInSidebar ?? false) && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -536,7 +636,8 @@ export function CommandPalette({
|
|||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{host.enableDocker &&
|
{isSSH &&
|
||||||
|
host.enableDocker &&
|
||||||
!(host.showDockerInSidebar ?? false) && (
|
!(host.showDockerInSidebar ?? false) && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
getServerMetricsById,
|
getServerMetricsById,
|
||||||
registerMetricsViewer,
|
registerMetricsViewer,
|
||||||
sendMetricsHeartbeat,
|
sendMetricsHeartbeat,
|
||||||
|
getGuacamoleToken,
|
||||||
type RecentActivityItem,
|
type RecentActivityItem,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||||
@@ -386,6 +387,108 @@ export function Dashboard({
|
|||||||
title: item.hostName,
|
title: item.hostName,
|
||||||
hostConfig: host,
|
hostConfig: host,
|
||||||
});
|
});
|
||||||
|
} else if (item.type === "telnet") {
|
||||||
|
getGuacamoleToken({
|
||||||
|
protocol: "telnet",
|
||||||
|
hostname: host.ip,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
password: host.password,
|
||||||
|
domain: host.domain,
|
||||||
|
security: host.security,
|
||||||
|
ignoreCert: host.ignoreCert,
|
||||||
|
guacamoleConfig: host.guacamoleConfig as any,
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
addTab({
|
||||||
|
type: "telnet",
|
||||||
|
title: item.hostName,
|
||||||
|
hostConfig: host,
|
||||||
|
connectionConfig: {
|
||||||
|
token: result.token,
|
||||||
|
protocol: "telnet",
|
||||||
|
type: "telnet",
|
||||||
|
hostname: host.ip,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
password: host.password,
|
||||||
|
domain: host.domain,
|
||||||
|
security: host.security,
|
||||||
|
"ignore-cert": host.ignoreCert,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to get telnet token:", error);
|
||||||
|
});
|
||||||
|
} else if (item.type === "vnc") {
|
||||||
|
getGuacamoleToken({
|
||||||
|
protocol: "vnc",
|
||||||
|
hostname: host.ip,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
password: host.password,
|
||||||
|
domain: host.domain,
|
||||||
|
security: host.security,
|
||||||
|
ignoreCert: host.ignoreCert,
|
||||||
|
guacamoleConfig: host.guacamoleConfig as any,
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
addTab({
|
||||||
|
type: "vnc",
|
||||||
|
title: item.hostName,
|
||||||
|
hostConfig: host,
|
||||||
|
connectionConfig: {
|
||||||
|
token: result.token,
|
||||||
|
protocol: "vnc",
|
||||||
|
type: "vnc",
|
||||||
|
hostname: host.ip,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
password: host.password,
|
||||||
|
domain: host.domain,
|
||||||
|
security: host.security,
|
||||||
|
"ignore-cert": host.ignoreCert,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to get vnc token:", error);
|
||||||
|
});
|
||||||
|
} else if (item.type === "rdp") {
|
||||||
|
getGuacamoleToken({
|
||||||
|
protocol: "rdp",
|
||||||
|
hostname: host.ip,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
password: host.password,
|
||||||
|
domain: host.domain,
|
||||||
|
security: host.security,
|
||||||
|
ignoreCert: host.ignoreCert,
|
||||||
|
guacamoleConfig: host.guacamoleConfig as any,
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
addTab({
|
||||||
|
type: "rdp",
|
||||||
|
title: item.hostName,
|
||||||
|
hostConfig: host,
|
||||||
|
connectionConfig: {
|
||||||
|
token: result.token,
|
||||||
|
protocol: "rdp",
|
||||||
|
type: "rdp",
|
||||||
|
hostname: host.ip,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
password: host.password,
|
||||||
|
domain: host.domain,
|
||||||
|
security: host.security,
|
||||||
|
"ignore-cert": host.ignoreCert,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to get rdp token:", error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
ArrowDownUp,
|
ArrowDownUp,
|
||||||
Container,
|
Container,
|
||||||
|
Monitor,
|
||||||
|
Eye,
|
||||||
|
MessagesSquare,
|
||||||
|
Network,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { type RecentActivityItem } from "@/ui/main-axios";
|
import { type RecentActivityItem } from "@/ui/main-axios";
|
||||||
@@ -83,6 +87,12 @@ export function RecentActivityCard({
|
|||||||
<ArrowDownUp size={20} className="shrink-0" />
|
<ArrowDownUp size={20} className="shrink-0" />
|
||||||
) : item.type === "docker" ? (
|
) : item.type === "docker" ? (
|
||||||
<Container size={20} className="shrink-0" />
|
<Container size={20} className="shrink-0" />
|
||||||
|
) : item.type === "telnet" ? (
|
||||||
|
<MessagesSquare size={20} className="shrink-0" />
|
||||||
|
) : item.type === "vnc" ? (
|
||||||
|
<Eye size={20} className="shrink-0" />
|
||||||
|
) : item.type === "rdp" ? (
|
||||||
|
<Monitor size={20} className="shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<Terminal size={20} className="shrink-0" />
|
<Terminal size={20} className="shrink-0" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ export function ConsoleTerminal({
|
|||||||
window.location.port === "");
|
window.location.port === "");
|
||||||
|
|
||||||
const baseWsUrl = isDev
|
const baseWsUrl = isDev
|
||||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30008`
|
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`
|
||||||
: isElectronApp
|
: isElectronApp
|
||||||
? (() => {
|
? (() => {
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { GuacamoleDisplay } from "@/ui/desktop/apps/features/guacamole/GuacamoleDisplay.tsx";
|
||||||
|
import { FullScreenAppWrapper } from "@/ui/desktop/apps/FullScreenAppWrapper.tsx";
|
||||||
|
import { getGuacamoleTokenFromHost } from "@/ui/main-axios.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface GuacamoleAppProps {
|
||||||
|
hostId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
|
||||||
|
return (
|
||||||
|
<FullScreenAppWrapper hostId={hostId}>
|
||||||
|
{(hostConfig, loading) => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||||
|
<p className="text-muted-foreground">Loading host...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hostConfig) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-red-500 mb-4">Host not found</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GuacamoleAppInner
|
||||||
|
hostId={parseInt(hostId!, 10)}
|
||||||
|
hostConfig={hostConfig}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</FullScreenAppWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface GuacamoleAppInnerProps {
|
||||||
|
hostId: number;
|
||||||
|
hostConfig: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
|
||||||
|
hostId,
|
||||||
|
hostConfig,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getGuacamoleTokenFromHost(hostId)
|
||||||
|
.then((result) => setToken(result.token))
|
||||||
|
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
|
||||||
|
}, [hostId]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-red-500 mb-4">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t("guacamole.connecting", {
|
||||||
|
type: (hostConfig.connectionType || "remote").toUpperCase(),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = hostConfig.connectionType as "rdp" | "vnc" | "telnet";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full">
|
||||||
|
<GuacamoleDisplay
|
||||||
|
connectionConfig={{ token, protocol, type: protocol }}
|
||||||
|
isVisible={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GuacamoleApp;
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useImperativeHandle,
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
} from "react";
|
||||||
|
import Guacamole from "guacamole-common-js";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getCookie, isElectron } from "@/ui/main-axios.ts";
|
||||||
|
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||||
|
|
||||||
|
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
|
||||||
|
|
||||||
|
export interface GuacamoleConnectionConfig {
|
||||||
|
token?: string;
|
||||||
|
protocol?: GuacamoleConnectionType;
|
||||||
|
type?: GuacamoleConnectionType;
|
||||||
|
hostname?: string;
|
||||||
|
port?: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
domain?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
dpi?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuacamoleDisplayHandle {
|
||||||
|
disconnect: () => void;
|
||||||
|
sendKey: (keysym: number, pressed: boolean) => void;
|
||||||
|
sendMouse: (x: number, y: number, buttonMask: number) => void;
|
||||||
|
setClipboard: (data: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GuacamoleDisplayProps {
|
||||||
|
connectionConfig: GuacamoleConnectionConfig;
|
||||||
|
isVisible: boolean;
|
||||||
|
onConnect?: () => void;
|
||||||
|
onDisconnect?: () => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDev = import.meta.env.DEV;
|
||||||
|
|
||||||
|
export const GuacamoleDisplay = forwardRef<
|
||||||
|
GuacamoleDisplayHandle,
|
||||||
|
GuacamoleDisplayProps
|
||||||
|
>(function GuacamoleDisplay(
|
||||||
|
{ connectionConfig, isVisible, onConnect, onDisconnect, onError },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const displayRef = useRef<HTMLDivElement>(null);
|
||||||
|
const clientRef = useRef<Guacamole.Client | null>(null);
|
||||||
|
const scaleRef = useRef<number>(1);
|
||||||
|
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
disconnect: () => {
|
||||||
|
if (clientRef.current) {
|
||||||
|
clientRef.current.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sendKey: (keysym: number, pressed: boolean) => {
|
||||||
|
if (clientRef.current) {
|
||||||
|
clientRef.current.sendKeyEvent(pressed ? 1 : 0, keysym);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sendMouse: (x: number, y: number, buttonMask: number) => {
|
||||||
|
if (clientRef.current) {
|
||||||
|
clientRef.current.sendMouseState(
|
||||||
|
new Guacamole.Mouse.State({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
left: !!(buttonMask & 1),
|
||||||
|
middle: !!(buttonMask & 2),
|
||||||
|
right: !!(buttonMask & 4),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setClipboard: (data: string) => {
|
||||||
|
if (clientRef.current) {
|
||||||
|
const stream = clientRef.current.createClipboardStream("text/plain");
|
||||||
|
const writer = new Guacamole.StringWriter(stream);
|
||||||
|
writer.sendText(data);
|
||||||
|
writer.sendEnd();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getWebSocketUrl = useCallback(
|
||||||
|
async (
|
||||||
|
containerWidth: number,
|
||||||
|
containerHeight: number,
|
||||||
|
): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
let token: string;
|
||||||
|
|
||||||
|
if (connectionConfig.token) {
|
||||||
|
token = connectionConfig.token;
|
||||||
|
} else {
|
||||||
|
const jwtToken = getCookie("jwt");
|
||||||
|
if (!jwtToken) {
|
||||||
|
onError?.("Authentication required");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = isDev
|
||||||
|
? "http://localhost:30001"
|
||||||
|
: isElectron()
|
||||||
|
? (window as { configuredServerUrl?: string })
|
||||||
|
.configuredServerUrl || "http://127.0.0.1:30001"
|
||||||
|
: `${window.location.origin}`;
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/guacamole/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${jwtToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(connectionConfig),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.error || "Failed to get connection token");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
token = data.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = connectionConfig.width || containerWidth || 1280;
|
||||||
|
const height = connectionConfig.height || containerHeight || 720;
|
||||||
|
const dpi = connectionConfig.dpi || 96;
|
||||||
|
|
||||||
|
const wsBase = isDev
|
||||||
|
? `ws://localhost:30008`
|
||||||
|
: isElectron()
|
||||||
|
? (() => {
|
||||||
|
const base =
|
||||||
|
(window as { configuredServerUrl?: string })
|
||||||
|
.configuredServerUrl || "http://127.0.0.1:30001";
|
||||||
|
return `${base.startsWith("https://") ? "wss://" : "ws://"}${base.replace(/^https?:\/\//, "")}/guacamole/websocket/`;
|
||||||
|
})()
|
||||||
|
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/guacamole/websocket/`;
|
||||||
|
|
||||||
|
return `${wsBase}?token=${encodeURIComponent(token)}&width=${width}&height=${height}&dpi=${dpi}`;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Unknown error";
|
||||||
|
onError?.(errorMessage);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[connectionConfig, onError],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rescaleDisplay = useCallback((immediate: boolean = false) => {
|
||||||
|
if (!clientRef.current || !containerRef.current) return;
|
||||||
|
|
||||||
|
const performRescale = () => {
|
||||||
|
if (!clientRef.current || !containerRef.current) return;
|
||||||
|
|
||||||
|
const display = clientRef.current.getDisplay();
|
||||||
|
const cWidth = containerRef.current.clientWidth;
|
||||||
|
const cHeight = containerRef.current.clientHeight;
|
||||||
|
const displayWidth = display.getWidth();
|
||||||
|
const displayHeight = display.getHeight();
|
||||||
|
|
||||||
|
if (displayWidth > 0 && displayHeight > 0 && cWidth > 0 && cHeight > 0) {
|
||||||
|
const scale = Math.min(cWidth / displayWidth, cHeight / displayHeight);
|
||||||
|
scaleRef.current = scale;
|
||||||
|
display.scale(scale);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (immediate) {
|
||||||
|
performRescale();
|
||||||
|
} else {
|
||||||
|
if (resizeTimeoutRef.current) {
|
||||||
|
clearTimeout(resizeTimeoutRef.current);
|
||||||
|
}
|
||||||
|
resizeTimeoutRef.current = setTimeout(performRescale, 200);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const connect = useCallback(async () => {
|
||||||
|
if (isConnectingRef.current) return;
|
||||||
|
isConnectingRef.current = true;
|
||||||
|
setIsConnecting(true);
|
||||||
|
setIsReady(false);
|
||||||
|
|
||||||
|
let containerWidth = containerRef.current?.clientWidth || 0;
|
||||||
|
let containerHeight = containerRef.current?.clientHeight || 0;
|
||||||
|
|
||||||
|
if (containerWidth < 100 || containerHeight < 100) {
|
||||||
|
containerWidth = 1280;
|
||||||
|
containerHeight = 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsUrl = await getWebSocketUrl(containerWidth, containerHeight);
|
||||||
|
if (!wsUrl) {
|
||||||
|
isConnectingRef.current = false;
|
||||||
|
setIsConnecting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tunnel = new Guacamole.WebSocketTunnel(wsUrl);
|
||||||
|
const client = new Guacamole.Client(tunnel);
|
||||||
|
clientRef.current = client;
|
||||||
|
|
||||||
|
const display = client.getDisplay();
|
||||||
|
const displayElement = display.getElement();
|
||||||
|
|
||||||
|
if (displayRef.current) {
|
||||||
|
displayRef.current.innerHTML = "";
|
||||||
|
displayRef.current.appendChild(displayElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
display.onresize = () => {
|
||||||
|
rescaleDisplay(true);
|
||||||
|
setIsReady(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mouse = new Guacamole.Mouse(displayElement);
|
||||||
|
const sendMouseState = (state: Guacamole.Mouse.State) => {
|
||||||
|
const scale = scaleRef.current;
|
||||||
|
const adjustedX = Math.round(state.x / scale);
|
||||||
|
const adjustedY = Math.round(state.y / scale);
|
||||||
|
|
||||||
|
const adjustedState = new Guacamole.Mouse.State(
|
||||||
|
adjustedX,
|
||||||
|
adjustedY,
|
||||||
|
state.left,
|
||||||
|
state.middle,
|
||||||
|
state.right,
|
||||||
|
state.up,
|
||||||
|
state.down,
|
||||||
|
) as Guacamole.Mouse.State;
|
||||||
|
|
||||||
|
client.sendMouseState(adjustedState);
|
||||||
|
};
|
||||||
|
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState;
|
||||||
|
|
||||||
|
const keyboard = new Guacamole.Keyboard(document);
|
||||||
|
keyboard.onkeydown = (keysym: number) => {
|
||||||
|
client.sendKeyEvent(1, keysym);
|
||||||
|
};
|
||||||
|
keyboard.onkeyup = (keysym: number) => {
|
||||||
|
client.sendKeyEvent(0, keysym);
|
||||||
|
};
|
||||||
|
|
||||||
|
client.onstatechange = (state: number) => {
|
||||||
|
switch (state) {
|
||||||
|
case 0:
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
setIsConnecting(true);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
setIsConnecting(false);
|
||||||
|
onConnect?.();
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
setIsConnecting(false);
|
||||||
|
setIsReady(false);
|
||||||
|
keyboard.onkeydown = null;
|
||||||
|
keyboard.onkeyup = null;
|
||||||
|
onDisconnect?.();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
client.onerror = (error: Guacamole.Status) => {
|
||||||
|
const errorMessage = error.message || "Connection error";
|
||||||
|
setIsConnecting(false);
|
||||||
|
setIsReady(false);
|
||||||
|
onError?.(errorMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
client.onclipboard = (stream: Guacamole.InputStream, mimetype: string) => {
|
||||||
|
if (mimetype === "text/plain") {
|
||||||
|
const reader = new Guacamole.StringReader(stream);
|
||||||
|
let data = "";
|
||||||
|
reader.ontext = (text: string) => {
|
||||||
|
data += text;
|
||||||
|
};
|
||||||
|
reader.onend = () => {
|
||||||
|
navigator.clipboard.writeText(data).catch(() => {});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
client.connect();
|
||||||
|
}, [getWebSocketUrl, onConnect, onDisconnect, onError, rescaleDisplay]);
|
||||||
|
|
||||||
|
const hasInitiatedRef = useRef(false);
|
||||||
|
const isMountedRef = useRef(false);
|
||||||
|
const isConnectingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
|
||||||
|
if (isVisible && !hasInitiatedRef.current) {
|
||||||
|
hasInitiatedRef.current = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (isMountedRef.current) {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isVisible, connect]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
hasInitiatedRef.current = false;
|
||||||
|
isConnectingRef.current = false;
|
||||||
|
if (resizeTimeoutRef.current) {
|
||||||
|
clearTimeout(resizeTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (clientRef.current) {
|
||||||
|
clientRef.current.disconnect();
|
||||||
|
clientRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
rescaleDisplay(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
|
||||||
|
const initialTimeout = setTimeout(() => rescaleDisplay(true), 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
clearTimeout(initialTimeout);
|
||||||
|
};
|
||||||
|
}, [rescaleDisplay]);
|
||||||
|
|
||||||
|
const connectingMessage = t("guacamole.connecting", {
|
||||||
|
type: (
|
||||||
|
connectionConfig.protocol ||
|
||||||
|
connectionConfig.type ||
|
||||||
|
"remote"
|
||||||
|
).toUpperCase(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="absolute inset-0 overflow-hidden"
|
||||||
|
style={{ backgroundColor: "var(--bg-base)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={displayRef}
|
||||||
|
className="relative w-full h-full flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
cursor: isReady ? "none" : "default",
|
||||||
|
visibility: isReady ? "visible" : "hidden",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SimpleLoader visible={!isReady} message={connectingMessage} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { KeyRound } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
|
|
||||||
|
interface SudoPasswordPopupProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
hostPassword: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
onConfirm: (password: string) => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SudoPasswordPopup({
|
||||||
|
isOpen,
|
||||||
|
hostPassword,
|
||||||
|
backgroundColor,
|
||||||
|
onConfirm,
|
||||||
|
onDismiss,
|
||||||
|
}: SudoPasswordPopupProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
onConfirm(hostPassword);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
onDismiss();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown, true);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||||
|
}, [isOpen, onConfirm, onDismiss, hostPassword]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-4 right-4 z-50 backdrop-blur-sm border border-border rounded-lg shadow-lg p-4 min-w-[280px]"
|
||||||
|
style={{ backgroundColor: backgroundColor }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="p-2 bg-primary/10 rounded-full">
|
||||||
|
<KeyRound className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-sm">
|
||||||
|
{t("terminal.sudoPasswordPopupTitle", "Insert password?")}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"terminal.sudoPasswordPopupHint",
|
||||||
|
"Press Enter to insert, Esc to dismiss",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onDismiss}>
|
||||||
|
{t("terminal.sudoPasswordPopupDismiss", "Dismiss")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onConfirm(hostPassword)}
|
||||||
|
>
|
||||||
|
{t("terminal.sudoPasswordPopupConfirm", "Insert")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1499,7 +1499,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
return await navigator.clipboard.readText();
|
return await navigator.clipboard.readText();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("terminal.clipboardReadFailed"));
|
// fall through
|
||||||
|
}
|
||||||
|
if (window.location.protocol !== "https:" && !isElectron()) {
|
||||||
|
toast.error(t("terminal.clipboardHttpWarning"));
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -1679,7 +1682,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
if (!getUseRightClickCopyPaste()) return;
|
if (!getUseRightClickCopyPaste()) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
|
||||||
if (terminal.hasSelection()) {
|
if (terminal.hasSelection()) {
|
||||||
const selection = terminal.getSelection();
|
const selection = terminal.getSelection();
|
||||||
if (selection) {
|
if (selection) {
|
||||||
@@ -1687,11 +1689,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
terminal.clearSelection();
|
terminal.clearSelection();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const pasteText = await readTextFromClipboard();
|
const text = await readTextFromClipboard();
|
||||||
if (pasteText) terminal.paste(pasteText);
|
if (text) terminal.paste(text);
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Terminal operation failed:", error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
element?.addEventListener("contextmenu", handleContextMenu);
|
element?.addEventListener("contextmenu", handleContextMenu);
|
||||||
@@ -1832,6 +1831,21 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
e.ctrlKey &&
|
||||||
|
!e.shiftKey &&
|
||||||
|
!e.altKey &&
|
||||||
|
!e.metaKey &&
|
||||||
|
e.key.toLowerCase() === "v"
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
readTextFromClipboard().then((text) => {
|
||||||
|
if (text) terminal.paste(text);
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (e.ctrlKey && e.altKey && !e.metaKey && !e.shiftKey) {
|
if (e.ctrlKey && e.altKey && !e.metaKey && !e.shiftKey) {
|
||||||
const key = e.key.toLowerCase();
|
const key = e.key.toLowerCase();
|
||||||
const blockedKeys = ["w", "t", "n", "q"];
|
const blockedKeys = ["w", "t", "n", "q"];
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { HostManager } from "@/ui/desktop/apps/host-manager/hosts/HostManager";
|
import { HostManager } from "@/ui/desktop/apps/host-manager/hosts/HostManager.tsx";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
const HostManagerApp: React.FC = () => {
|
const HostManagerApp: React.FC = () => {
|
||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
revokeHostAccess,
|
revokeHostAccess,
|
||||||
getSSHHostById,
|
getSSHHostById,
|
||||||
notifyHostCreatedOrUpdated,
|
notifyHostCreatedOrUpdated,
|
||||||
|
getGuacamoleSettings,
|
||||||
type Role,
|
type Role,
|
||||||
type AccessRecord,
|
type AccessRecord,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
@@ -130,7 +131,9 @@ import { HostDockerTab } from "./tabs/HostDockerTab";
|
|||||||
import { HostTunnelTab } from "./tabs/HostTunnelTab";
|
import { HostTunnelTab } from "./tabs/HostTunnelTab";
|
||||||
import { HostFileManagerTab } from "./tabs/HostFileManagerTab";
|
import { HostFileManagerTab } from "./tabs/HostFileManagerTab";
|
||||||
import { HostStatisticsTab } from "./tabs/HostStatisticsTab";
|
import { HostStatisticsTab } from "./tabs/HostStatisticsTab";
|
||||||
|
import { HostStatusTab } from "./tabs/HostStatusTab";
|
||||||
import { HostSharingTab } from "./tabs/HostSharingTab";
|
import { HostSharingTab } from "./tabs/HostSharingTab";
|
||||||
|
import { HostRemoteDesktopTab } from "./tabs/HostRemoteDesktopTab";
|
||||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@@ -176,11 +179,18 @@ export function HostManagerEditor({
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState("general");
|
const [activeTab, setActiveTab] = useState("general");
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [guacEnabled, setGuacEnabled] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getGuacamoleSettings()
|
||||||
|
.then((data) => setGuacEnabled(data.enabled))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [statusIntervalUnit, setStatusIntervalUnit] = useState<
|
const [statusIntervalUnit, setStatusIntervalUnit] = useState<
|
||||||
"seconds" | "minutes"
|
"seconds" | "minutes"
|
||||||
>("seconds");
|
>("seconds");
|
||||||
@@ -265,10 +275,11 @@ export function HostManagerEditor({
|
|||||||
|
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
connectionType: z.enum(["ssh", "rdp", "vnc", "telnet"]).default("ssh"),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
ip: z.string().min(1),
|
ip: z.string().min(1, t("hosts.ipRequired", "IP address is required")),
|
||||||
port: z.coerce.number().min(1).max(65535),
|
port: z.coerce.number().min(1).max(65535),
|
||||||
username: z.string().min(1),
|
username: z.string().optional(),
|
||||||
folder: z.string().optional(),
|
folder: z.string().optional(),
|
||||||
tags: z.array(z.string().min(1)).default([]),
|
tags: z.array(z.string().min(1)).default([]),
|
||||||
pin: z.boolean().default(false),
|
pin: z.boolean().default(false),
|
||||||
@@ -350,6 +361,7 @@ export function HostManagerEditor({
|
|||||||
metricsEnabled: z.boolean().default(true),
|
metricsEnabled: z.boolean().default(true),
|
||||||
metricsInterval: z.number().min(5).max(3600).default(30),
|
metricsInterval: z.number().min(5).max(3600).default(30),
|
||||||
useGlobalMetricsInterval: z.boolean().default(true),
|
useGlobalMetricsInterval: z.boolean().default(true),
|
||||||
|
disableTcpPing: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
.default({
|
.default({
|
||||||
enabledWidgets: [
|
enabledWidgets: [
|
||||||
@@ -369,6 +381,7 @@ export function HostManagerEditor({
|
|||||||
metricsEnabled: true,
|
metricsEnabled: true,
|
||||||
metricsInterval: 30,
|
metricsInterval: 30,
|
||||||
useGlobalMetricsInterval: true,
|
useGlobalMetricsInterval: true,
|
||||||
|
disableTcpPing: false,
|
||||||
}),
|
}),
|
||||||
terminalConfig: z
|
terminalConfig: z
|
||||||
.object({
|
.object({
|
||||||
@@ -436,6 +449,10 @@ export function HostManagerEditor({
|
|||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
enableDocker: z.boolean().default(false),
|
enableDocker: z.boolean().default(false),
|
||||||
|
domain: z.string().optional(),
|
||||||
|
security: z.string().optional(),
|
||||||
|
ignoreCert: z.boolean().default(true),
|
||||||
|
guacamoleConfig: z.record(z.string(), z.unknown()).optional(),
|
||||||
showTerminalInSidebar: z.boolean().default(true),
|
showTerminalInSidebar: z.boolean().default(true),
|
||||||
showFileManagerInSidebar: z.boolean().default(false),
|
showFileManagerInSidebar: z.boolean().default(false),
|
||||||
showTunnelInSidebar: z.boolean().default(false),
|
showTunnelInSidebar: z.boolean().default(false),
|
||||||
@@ -443,6 +460,20 @@ export function HostManagerEditor({
|
|||||||
showServerStatsInSidebar: z.boolean().default(false),
|
showServerStatsInSidebar: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.connectionType !== "ssh") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.username || data.username.trim() === "") {
|
||||||
|
if (data.authType !== "credential") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t("hosts.usernameRequired", "Username is required"),
|
||||||
|
path: ["username"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (data.authType === "none") {
|
if (data.authType === "none") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -458,25 +489,27 @@ export function HostManagerEditor({
|
|||||||
) {
|
) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: t("hosts.passwordRequired"),
|
message: t("hosts.passwordRequired", "Password is required"),
|
||||||
path: ["password"],
|
path: ["password"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (data.authType === "key") {
|
} else if (data.authType === "key") {
|
||||||
if (
|
if (
|
||||||
!data.key ||
|
!data.key ||
|
||||||
(typeof data.key === "string" && data.key.trim() === "")
|
(typeof data.key === "string" &&
|
||||||
|
data.key.trim() === "" &&
|
||||||
|
data.key !== "existing_key")
|
||||||
) {
|
) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: t("hosts.sshKeyRequired"),
|
message: t("hosts.sshKeyRequired", "SSH key is required"),
|
||||||
path: ["key"],
|
path: ["key"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!data.keyType) {
|
if (!data.keyType) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: t("hosts.keyTypeRequired"),
|
message: t("hosts.keyTypeRequired", "Key type is required"),
|
||||||
path: ["keyType"],
|
path: ["keyType"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -484,7 +517,7 @@ export function HostManagerEditor({
|
|||||||
if (!data.credentialId) {
|
if (!data.credentialId) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: t("hosts.credentialRequired"),
|
message: t("hosts.credentialRequired", "Credential is required"),
|
||||||
path: ["credentialId"],
|
path: ["credentialId"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -510,6 +543,7 @@ export function HostManagerEditor({
|
|||||||
resolver: zodResolver(formSchema) as any,
|
resolver: zodResolver(formSchema) as any,
|
||||||
mode: "all",
|
mode: "all",
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
connectionType: "ssh" as const,
|
||||||
name: "",
|
name: "",
|
||||||
ip: "",
|
ip: "",
|
||||||
port: 22,
|
port: 22,
|
||||||
@@ -547,32 +581,80 @@ export function HostManagerEditor({
|
|||||||
socks5Password: "",
|
socks5Password: "",
|
||||||
socks5ProxyChain: [],
|
socks5ProxyChain: [],
|
||||||
enableDocker: false,
|
enableDocker: false,
|
||||||
|
domain: "",
|
||||||
|
security: "any",
|
||||||
|
ignoreCert: true,
|
||||||
|
guacamoleConfig: {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const watchedFields = form.watch();
|
const watchedFields = form.watch();
|
||||||
const formState = form.formState;
|
const formState = form.formState;
|
||||||
|
const watchedConnectionType = form.watch("connectionType") || "ssh";
|
||||||
|
|
||||||
|
const prevConnectionTypeRef = useRef<string>("ssh");
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = prevConnectionTypeRef.current;
|
||||||
|
const current = watchedConnectionType;
|
||||||
|
if (prev === current) return;
|
||||||
|
prevConnectionTypeRef.current = current;
|
||||||
|
|
||||||
|
const portDefaults: Record<string, number> = {
|
||||||
|
ssh: 22,
|
||||||
|
rdp: 3389,
|
||||||
|
vnc: 5900,
|
||||||
|
telnet: 23,
|
||||||
|
};
|
||||||
|
const currentPort = form.getValues("port");
|
||||||
|
const oldDefault = portDefaults[prev] || 22;
|
||||||
|
if (currentPort === oldDefault) {
|
||||||
|
form.setValue("port", portDefaults[current] || 22);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current !== "ssh") {
|
||||||
|
const currentStatsConfig = form.getValues("statsConfig");
|
||||||
|
form.setValue("statsConfig", {
|
||||||
|
...currentStatsConfig,
|
||||||
|
metricsEnabled: false,
|
||||||
|
disableTcpPing: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTab !== "general" && current !== "ssh") {
|
||||||
|
setActiveTab("general");
|
||||||
|
}
|
||||||
|
}, [watchedConnectionType]);
|
||||||
|
|
||||||
const isFormValid = React.useMemo(() => {
|
const isFormValid = React.useMemo(() => {
|
||||||
const values = form.getValues();
|
const errors = formState.errors;
|
||||||
|
|
||||||
if (!values.ip || !values.username || values.username.trim() === "")
|
if (!watchedFields.ip) return false;
|
||||||
|
|
||||||
|
if (watchedFields.connectionType !== "ssh") {
|
||||||
|
const port = Number(watchedFields.port);
|
||||||
|
return !errors.ip && port >= 1 && port <= 65535;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!watchedFields.username || watchedFields.username.trim() === "")
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if (authTab === "password") {
|
if (authTab === "password") {
|
||||||
return !!(values.password && values.password.trim() !== "");
|
if (!watchedFields.password || watchedFields.password.trim() === "")
|
||||||
|
return false;
|
||||||
} else if (authTab === "key") {
|
} else if (authTab === "key") {
|
||||||
return !!(values.key && values.keyType);
|
if (!watchedFields.key || !watchedFields.keyType) return false;
|
||||||
} else if (authTab === "credential") {
|
} else if (authTab === "credential") {
|
||||||
return !!values.credentialId;
|
if (!watchedFields.credentialId) return false;
|
||||||
} else if (authTab === "none") {
|
} else if (authTab === "none") {
|
||||||
return true;
|
// No auth required
|
||||||
} else if (authTab === "opkssh") {
|
} else if (authTab === "opkssh") {
|
||||||
return true;
|
// No auth required
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return Object.keys(errors).length === 0;
|
||||||
}, [watchedFields, authTab]);
|
}, [watchedFields, authTab, formState.errors]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateAuthFields = async () => {
|
const updateAuthFields = async () => {
|
||||||
@@ -605,7 +687,10 @@ export function HostManagerEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (authTab === "none") {
|
} else if (authTab === "none") {
|
||||||
|
const connectionType = form.getValues("connectionType");
|
||||||
|
if (connectionType === "ssh") {
|
||||||
form.setValue("password", "", { shouldValidate: true });
|
form.setValue("password", "", { shouldValidate: true });
|
||||||
|
}
|
||||||
form.setValue("key", null, { shouldValidate: true });
|
form.setValue("key", null, { shouldValidate: true });
|
||||||
form.setValue("keyPassword", "", { shouldValidate: true });
|
form.setValue("keyPassword", "", { shouldValidate: true });
|
||||||
form.setValue("keyType", "auto", { shouldValidate: true });
|
form.setValue("keyType", "auto", { shouldValidate: true });
|
||||||
@@ -628,6 +713,7 @@ export function HostManagerEditor({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingHost) {
|
if (editingHost) {
|
||||||
const cleanedHost = { ...editingHost };
|
const cleanedHost = { ...editingHost };
|
||||||
|
if ((cleanedHost as any).connectionType === "ssh") {
|
||||||
if (cleanedHost.credentialId && cleanedHost.key) {
|
if (cleanedHost.credentialId && cleanedHost.key) {
|
||||||
cleanedHost.key = undefined;
|
cleanedHost.key = undefined;
|
||||||
cleanedHost.keyPassword = undefined;
|
cleanedHost.keyPassword = undefined;
|
||||||
@@ -637,6 +723,7 @@ export function HostManagerEditor({
|
|||||||
} else if (cleanedHost.key && cleanedHost.password) {
|
} else if (cleanedHost.key && cleanedHost.password) {
|
||||||
cleanedHost.password = undefined;
|
cleanedHost.password = undefined;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const defaultAuthType = (cleanedHost.authType ||
|
const defaultAuthType = (cleanedHost.authType ||
|
||||||
(cleanedHost.credentialId
|
(cleanedHost.credentialId
|
||||||
@@ -669,6 +756,7 @@ export function HostManagerEditor({
|
|||||||
parsedStatsConfig = { ...DEFAULT_STATS_CONFIG, ...parsedStatsConfig };
|
parsedStatsConfig = { ...DEFAULT_STATS_CONFIG, ...parsedStatsConfig };
|
||||||
|
|
||||||
const formData: Partial<FormData> = {
|
const formData: Partial<FormData> = {
|
||||||
|
connectionType: (cleanedHost as any).connectionType || "ssh",
|
||||||
name: cleanedHost.name || "",
|
name: cleanedHost.name || "",
|
||||||
ip: cleanedHost.ip || "",
|
ip: cleanedHost.ip || "",
|
||||||
port: cleanedHost.port || 22,
|
port: cleanedHost.port || 22,
|
||||||
@@ -734,6 +822,21 @@ export function HostManagerEditor({
|
|||||||
? cleanedHost.socks5ProxyChain
|
? cleanedHost.socks5ProxyChain
|
||||||
: [],
|
: [],
|
||||||
enableDocker: Boolean(cleanedHost.enableDocker),
|
enableDocker: Boolean(cleanedHost.enableDocker),
|
||||||
|
domain: (cleanedHost as any).domain || "",
|
||||||
|
security: (cleanedHost as any).security || "any",
|
||||||
|
ignoreCert: (cleanedHost as any).ignoreCert ?? true,
|
||||||
|
guacamoleConfig: (() => {
|
||||||
|
const cfg = (cleanedHost as any).guacamoleConfig;
|
||||||
|
if (!cfg) return {};
|
||||||
|
if (typeof cfg === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(cfg);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg;
|
||||||
|
})(),
|
||||||
showTerminalInSidebar: cleanedHost.showTerminalInSidebar ?? true,
|
showTerminalInSidebar: cleanedHost.showTerminalInSidebar ?? true,
|
||||||
showFileManagerInSidebar: cleanedHost.showFileManagerInSidebar ?? false,
|
showFileManagerInSidebar: cleanedHost.showFileManagerInSidebar ?? false,
|
||||||
showTunnelInSidebar: cleanedHost.showTunnelInSidebar ?? false,
|
showTunnelInSidebar: cleanedHost.showTunnelInSidebar ?? false,
|
||||||
@@ -750,7 +853,11 @@ export function HostManagerEditor({
|
|||||||
setProxyMode("single");
|
setProxyMode("single");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (defaultAuthType === "password") {
|
if (cleanedHost.connectionType !== "ssh") {
|
||||||
|
if (cleanedHost.password) {
|
||||||
|
formData.password = cleanedHost.password;
|
||||||
|
}
|
||||||
|
} else if (defaultAuthType === "password") {
|
||||||
formData.password = cleanedHost.password || "";
|
formData.password = cleanedHost.password || "";
|
||||||
} else if (defaultAuthType === "key") {
|
} else if (defaultAuthType === "key") {
|
||||||
formData.key = editingHost.id ? "existing_key" : editingHost.key;
|
formData.key = editingHost.id ? "existing_key" : editingHost.key;
|
||||||
@@ -774,6 +881,7 @@ export function HostManagerEditor({
|
|||||||
} else {
|
} else {
|
||||||
setAuthTab("password");
|
setAuthTab("password");
|
||||||
const defaultFormData: Partial<FormData> = {
|
const defaultFormData: Partial<FormData> = {
|
||||||
|
connectionType: "ssh" as const,
|
||||||
name: "",
|
name: "",
|
||||||
ip: "",
|
ip: "",
|
||||||
port: 22,
|
port: 22,
|
||||||
@@ -799,6 +907,10 @@ export function HostManagerEditor({
|
|||||||
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
||||||
forceKeyboardInteractive: false,
|
forceKeyboardInteractive: false,
|
||||||
enableDocker: false,
|
enableDocker: false,
|
||||||
|
domain: "",
|
||||||
|
security: "any",
|
||||||
|
ignoreCert: true,
|
||||||
|
guacamoleConfig: {},
|
||||||
showTerminalInSidebar: true,
|
showTerminalInSidebar: true,
|
||||||
showFileManagerInSidebar: false,
|
showFileManagerInSidebar: false,
|
||||||
showTunnelInSidebar: false,
|
showTunnelInSidebar: false,
|
||||||
@@ -834,6 +946,28 @@ export function HostManagerEditor({
|
|||||||
...data,
|
...data,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
(submitData as any).connectionType = data.connectionType;
|
||||||
|
(submitData as any).domain = data.domain;
|
||||||
|
(submitData as any).security = data.security;
|
||||||
|
(submitData as any).ignoreCert = data.ignoreCert;
|
||||||
|
(submitData as any).guacamoleConfig = data.guacamoleConfig;
|
||||||
|
|
||||||
|
if (data.connectionType !== "ssh") {
|
||||||
|
submitData.authType = "none";
|
||||||
|
submitData.key = undefined;
|
||||||
|
submitData.keyPassword = undefined;
|
||||||
|
submitData.keyType = undefined;
|
||||||
|
submitData.credentialId = undefined;
|
||||||
|
submitData.tunnelConnections = [];
|
||||||
|
submitData.jumpHosts = [];
|
||||||
|
(submitData as any).useSocks5 = false;
|
||||||
|
(submitData as any).socks5ProxyChain = [];
|
||||||
|
submitData.forceKeyboardInteractive = false;
|
||||||
|
submitData.enableTunnel = false;
|
||||||
|
submitData.enableFileManager = false;
|
||||||
|
submitData.enableDocker = false;
|
||||||
|
submitData.enableTerminal = true;
|
||||||
|
} else {
|
||||||
if (
|
if (
|
||||||
data.terminalConfig?.sudoPasswordAutoFill &&
|
data.terminalConfig?.sudoPasswordAutoFill &&
|
||||||
data.terminalConfig?.sudoPassword
|
data.terminalConfig?.sudoPassword
|
||||||
@@ -852,6 +986,7 @@ export function HostManagerEditor({
|
|||||||
submitData.keyPassword = undefined;
|
submitData.keyPassword = undefined;
|
||||||
submitData.keyType = undefined;
|
submitData.keyType = undefined;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (data.authType === "key") {
|
if (data.authType === "key") {
|
||||||
if (data.key instanceof File) {
|
if (data.key instanceof File) {
|
||||||
@@ -955,6 +1090,11 @@ export function HostManagerEditor({
|
|||||||
enableTerminal: "terminal",
|
enableTerminal: "terminal",
|
||||||
terminalConfig: "terminal",
|
terminalConfig: "terminal",
|
||||||
enableDocker: "docker",
|
enableDocker: "docker",
|
||||||
|
domain: "general",
|
||||||
|
security: "general",
|
||||||
|
ignoreCert: "general",
|
||||||
|
guacamoleConfig: "remote_desktop",
|
||||||
|
connectionType: "general",
|
||||||
enableTunnel: "tunnel",
|
enableTunnel: "tunnel",
|
||||||
tunnelConnections: "tunnel",
|
tunnelConnections: "tunnel",
|
||||||
enableFileManager: "file_manager",
|
enableFileManager: "file_manager",
|
||||||
@@ -1194,6 +1334,50 @@ export function HostManagerEditor({
|
|||||||
: t("hosts.addHost")}
|
: t("hosts.addHost")}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
{guacEnabled && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="connectionType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mb-4">
|
||||||
|
<FormControl>
|
||||||
|
<Tabs
|
||||||
|
value={field.value || "ssh"}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<TabsList className="bg-button border border-edge-medium">
|
||||||
|
<TabsTrigger
|
||||||
|
value="ssh"
|
||||||
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
|
>
|
||||||
|
{t("hosts.ssh")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="rdp"
|
||||||
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
|
>
|
||||||
|
{t("hosts.rdp")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="vnc"
|
||||||
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
|
>
|
||||||
|
{t("hosts.vnc")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="telnet"
|
||||||
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
|
>
|
||||||
|
{t("hosts.telnet")}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={setActiveTab}
|
onValueChange={setActiveTab}
|
||||||
@@ -1206,6 +1390,8 @@ export function HostManagerEditor({
|
|||||||
>
|
>
|
||||||
{t("hosts.general")}
|
{t("hosts.general")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
{watchedConnectionType === "ssh" && (
|
||||||
|
<>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="terminal"
|
value="terminal"
|
||||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
@@ -1230,13 +1416,26 @@ export function HostManagerEditor({
|
|||||||
>
|
>
|
||||||
{t("hosts.fileManager")}
|
{t("hosts.fileManager")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="statistics"
|
value="statistics"
|
||||||
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
>
|
>
|
||||||
{t("hosts.statistics")}
|
{watchedConnectionType === "ssh"
|
||||||
|
? t("hosts.statistics")
|
||||||
|
: t("hosts.status")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{!editingHost?.isShared && (
|
{watchedConnectionType !== "ssh" && (
|
||||||
|
<TabsTrigger
|
||||||
|
value="remote_desktop"
|
||||||
|
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
|
||||||
|
>
|
||||||
|
{t("hosts.remoteDesktop")}
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
{watchedConnectionType === "ssh" &&
|
||||||
|
!editingHost?.isShared && (
|
||||||
<TabsTrigger value="sharing">
|
<TabsTrigger value="sharing">
|
||||||
{t("rbac.sharing")}
|
{t("rbac.sharing")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -1245,6 +1444,9 @@ export function HostManagerEditor({
|
|||||||
<TabsContent value="general" className="pt-2">
|
<TabsContent value="general" className="pt-2">
|
||||||
<HostGeneralTab
|
<HostGeneralTab
|
||||||
form={form}
|
form={form}
|
||||||
|
connectionType={
|
||||||
|
watchedConnectionType as "ssh" | "rdp" | "vnc" | "telnet"
|
||||||
|
}
|
||||||
authTab={authTab}
|
authTab={authTab}
|
||||||
setAuthTab={setAuthTab}
|
setAuthTab={setAuthTab}
|
||||||
keyInputMethod={keyInputMethod}
|
keyInputMethod={keyInputMethod}
|
||||||
@@ -1273,6 +1475,8 @@ export function HostManagerEditor({
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
{watchedConnectionType === "ssh" && (
|
||||||
|
<>
|
||||||
<TabsContent value="terminal" className="space-y-1">
|
<TabsContent value="terminal" className="space-y-1">
|
||||||
<HostTerminalTab form={form} snippets={snippets} t={t} />
|
<HostTerminalTab form={form} snippets={snippets} t={t} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -1294,7 +1498,10 @@ export function HostManagerEditor({
|
|||||||
<TabsContent value="file_manager">
|
<TabsContent value="file_manager">
|
||||||
<HostFileManagerTab form={form} t={t} />
|
<HostFileManagerTab form={form} t={t} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<TabsContent value="statistics" className="space-y-6">
|
<TabsContent value="statistics" className="space-y-6">
|
||||||
|
{watchedConnectionType === "ssh" ? (
|
||||||
<HostStatisticsTab
|
<HostStatisticsTab
|
||||||
form={form}
|
form={form}
|
||||||
statusIntervalUnit={statusIntervalUnit}
|
statusIntervalUnit={statusIntervalUnit}
|
||||||
@@ -1304,13 +1511,34 @@ export function HostManagerEditor({
|
|||||||
snippets={snippets}
|
snippets={snippets}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<HostStatusTab
|
||||||
|
form={form}
|
||||||
|
statusIntervalUnit={statusIntervalUnit}
|
||||||
|
setStatusIntervalUnit={setStatusIntervalUnit}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
{watchedConnectionType !== "ssh" && (
|
||||||
|
<TabsContent value="remote_desktop" className="space-y-4">
|
||||||
|
<HostRemoteDesktopTab
|
||||||
|
form={form}
|
||||||
|
connectionType={
|
||||||
|
watchedConnectionType as "rdp" | "vnc" | "telnet"
|
||||||
|
}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
{watchedConnectionType === "ssh" && (
|
||||||
<TabsContent value="sharing" className="space-y-6">
|
<TabsContent value="sharing" className="space-y-6">
|
||||||
<HostSharingTab
|
<HostSharingTab
|
||||||
hostId={editingHost?.id}
|
hostId={editingHost?.id}
|
||||||
isNewHost={!editingHost}
|
isNewHost={!editingHost}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ import {
|
|||||||
refreshServerPolling,
|
refreshServerPolling,
|
||||||
isElectron,
|
isElectron,
|
||||||
getConfiguredServerUrl,
|
getConfiguredServerUrl,
|
||||||
|
getGuacamoleTokenFromHost,
|
||||||
|
getGuacamoleToken,
|
||||||
|
logActivity,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import { useServerStatus } from "@/ui/contexts/ServerStatusContext";
|
import { useServerStatus } from "@/ui/contexts/ServerStatusContext";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -83,6 +86,9 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Monitor,
|
||||||
|
MessagesSquare,
|
||||||
|
Eye,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
SSHHost,
|
SSHHost,
|
||||||
@@ -587,6 +593,7 @@ export function HostManagerViewer({
|
|||||||
const getSampleData = () => ({
|
const getSampleData = () => ({
|
||||||
hosts: [
|
hosts: [
|
||||||
{
|
{
|
||||||
|
connectionType: "ssh",
|
||||||
name: t("interface.webServerProduction"),
|
name: t("interface.webServerProduction"),
|
||||||
ip: "192.168.1.100",
|
ip: "192.168.1.100",
|
||||||
port: 22,
|
port: 22,
|
||||||
@@ -607,6 +614,7 @@ export function HostManagerViewer({
|
|||||||
defaultPath: "/var/www",
|
defaultPath: "/var/www",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
connectionType: "ssh",
|
||||||
name: t("interface.databaseServer"),
|
name: t("interface.databaseServer"),
|
||||||
ip: "192.168.1.101",
|
ip: "192.168.1.101",
|
||||||
port: 22,
|
port: 22,
|
||||||
@@ -645,6 +653,7 @@ export function HostManagerViewer({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
connectionType: "ssh",
|
||||||
name: t("interface.developmentServer"),
|
name: t("interface.developmentServer"),
|
||||||
ip: "192.168.1.102",
|
ip: "192.168.1.102",
|
||||||
port: 2222,
|
port: 2222,
|
||||||
@@ -667,6 +676,66 @@ export function HostManagerViewer({
|
|||||||
sudoPassword: "dev_sudo_password",
|
sudoPassword: "dev_sudo_password",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
connectionType: "rdp",
|
||||||
|
name: "Windows Server 2022",
|
||||||
|
ip: "192.168.1.200",
|
||||||
|
port: 3389,
|
||||||
|
username: "Administrator",
|
||||||
|
password: "windows_password",
|
||||||
|
domain: "COMPANY",
|
||||||
|
security: "nla",
|
||||||
|
ignoreCert: false,
|
||||||
|
folder: "Remote Desktop",
|
||||||
|
tags: ["rdp", "windows", "production"],
|
||||||
|
pin: false,
|
||||||
|
notes: "Production Windows Server with RDP access",
|
||||||
|
guacamoleConfig: {
|
||||||
|
"enable-drive": true,
|
||||||
|
"drive-path": "/shared",
|
||||||
|
"create-drive-path": true,
|
||||||
|
"server-layout": "en-us-qwerty",
|
||||||
|
"resize-method": "display-update",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
connectionType: "vnc",
|
||||||
|
name: "Ubuntu Desktop",
|
||||||
|
ip: "192.168.1.201",
|
||||||
|
port: 5900,
|
||||||
|
username: "vncuser",
|
||||||
|
password: "vnc_password",
|
||||||
|
folder: "Remote Desktop",
|
||||||
|
tags: ["vnc", "linux", "desktop"],
|
||||||
|
pin: false,
|
||||||
|
notes: "Ubuntu desktop with VNC server",
|
||||||
|
guacamoleConfig: {
|
||||||
|
"color-depth": 24,
|
||||||
|
cursor: "remote",
|
||||||
|
"read-only": false,
|
||||||
|
"clipboard-encoding": "UTF-8",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
connectionType: "telnet",
|
||||||
|
name: "Network Switch",
|
||||||
|
ip: "192.168.1.254",
|
||||||
|
port: 23,
|
||||||
|
username: "admin",
|
||||||
|
password: "switch_password",
|
||||||
|
folder: "Infrastructure",
|
||||||
|
tags: ["telnet", "network", "switch"],
|
||||||
|
pin: false,
|
||||||
|
notes: "Legacy network switch with Telnet access",
|
||||||
|
guacamoleConfig: {
|
||||||
|
"color-scheme": "green-black",
|
||||||
|
"font-name": "monospace",
|
||||||
|
"font-size": 12,
|
||||||
|
scrollback: 1024,
|
||||||
|
backspace: 127,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
connectionType: "ssh",
|
||||||
name: "Jump Host Server",
|
name: "Jump Host Server",
|
||||||
ip: "10.0.0.50",
|
ip: "10.0.0.50",
|
||||||
port: 22,
|
port: 22,
|
||||||
@@ -693,6 +762,7 @@ export function HostManagerViewer({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
connectionType: "ssh",
|
||||||
name: "Server with SOCKS5 Proxy",
|
name: "Server with SOCKS5 Proxy",
|
||||||
ip: "10.10.10.100",
|
ip: "10.10.10.100",
|
||||||
port: 22,
|
port: 22,
|
||||||
@@ -713,6 +783,7 @@ export function HostManagerViewer({
|
|||||||
socks5Password: "proxypass",
|
socks5Password: "proxypass",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
connectionType: "ssh",
|
||||||
name: "Customized Terminal Server",
|
name: "Customized Terminal Server",
|
||||||
ip: "192.168.1.150",
|
ip: "192.168.1.150",
|
||||||
port: 22,
|
port: 22,
|
||||||
@@ -1388,34 +1459,47 @@ export function HostManagerViewer({
|
|||||||
)}
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
const statsConfig = (() => {
|
const statsConfig = (() => {
|
||||||
|
if (!host.statsConfig) {
|
||||||
|
return DEFAULT_STATS_CONFIG;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof host.statsConfig === "object"
|
||||||
|
) {
|
||||||
|
return host.statsConfig;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return host.statsConfig
|
return JSON.parse(host.statsConfig);
|
||||||
? JSON.parse(host.statsConfig)
|
} catch (e) {
|
||||||
: DEFAULT_STATS_CONFIG;
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_STATS_CONFIG;
|
return DEFAULT_STATS_CONFIG;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
const shouldShowStatus =
|
const shouldShowStatus = ![
|
||||||
statsConfig.statusCheckEnabled !==
|
false,
|
||||||
false;
|
"false",
|
||||||
const serverStatus = getStatus(host.id);
|
].includes(
|
||||||
|
statsConfig.statusCheckEnabled,
|
||||||
|
);
|
||||||
|
|
||||||
return shouldShowStatus ? (
|
if (!shouldShowStatus) return null;
|
||||||
|
|
||||||
|
const serverStatus = getStatus(host.id);
|
||||||
|
return (
|
||||||
<Status
|
<Status
|
||||||
status={serverStatus}
|
status={serverStatus}
|
||||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||||
>
|
>
|
||||||
<StatusIndicator />
|
<StatusIndicator />
|
||||||
</Status>
|
</Status>
|
||||||
) : null;
|
);
|
||||||
})()}
|
})()}
|
||||||
{host.pin && (
|
{host.pin && (
|
||||||
<Pin className="h-3 w-3 text-yellow-500 flex-shrink-0" />
|
<Pin className="h-3 w-3 text-yellow-500 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<h3 className="font-medium truncate text-sm">
|
<h3 className="font-medium truncate text-sm">
|
||||||
{host.name ||
|
{host.name ||
|
||||||
`${host.username}@${host.ip}`}
|
(host.username
|
||||||
|
? `${host.username}@${host.ip}`
|
||||||
|
: host.ip)}
|
||||||
</h3>
|
</h3>
|
||||||
{(host as any).isShared && (
|
{(host as any).isShared && (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -1429,9 +1513,11 @@ export function HostManagerViewer({
|
|||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{host.ip}:{host.port}
|
{host.ip}:{host.port}
|
||||||
</p>
|
</p>
|
||||||
|
{host.username && (
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{host.username}
|
{host.username}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
ID: {host.id}
|
ID: {host.id}
|
||||||
</p>
|
</p>
|
||||||
@@ -1569,6 +1655,41 @@ export function HostManagerViewer({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
{(() => {
|
||||||
|
const connType = (host as any)
|
||||||
|
.connectionType;
|
||||||
|
const isRemoteDesktop =
|
||||||
|
connType === "rdp" ||
|
||||||
|
connType === "vnc" ||
|
||||||
|
connType === "telnet";
|
||||||
|
|
||||||
|
if (isRemoteDesktop) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
copyFullScreenUrl(
|
||||||
|
host,
|
||||||
|
connType,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{connType === "rdp" ? (
|
||||||
|
<Monitor className="h-4 w-4 mr-2" />
|
||||||
|
) : connType === "vnc" ? (
|
||||||
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
) : (
|
||||||
|
<MessagesSquare className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{t(
|
||||||
|
"hosts.copyRemoteDesktopUrl",
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
{host.enableTerminal && (
|
{host.enableTerminal && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -1580,7 +1701,9 @@ export function HostManagerViewer({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Terminal className="h-4 w-4 mr-2" />
|
<Terminal className="h-4 w-4 mr-2" />
|
||||||
{t("hosts.copyTerminalUrl")}
|
{t(
|
||||||
|
"hosts.copyTerminalUrl",
|
||||||
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{host.enableFileManager && (
|
{host.enableFileManager && (
|
||||||
@@ -1594,7 +1717,9 @@ export function HostManagerViewer({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FolderOpen className="h-4 w-4 mr-2" />
|
<FolderOpen className="h-4 w-4 mr-2" />
|
||||||
{t("hosts.copyFileManagerUrl")}
|
{t(
|
||||||
|
"hosts.copyFileManagerUrl",
|
||||||
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{host.enableTunnel && (
|
{host.enableTunnel && (
|
||||||
@@ -1621,7 +1746,9 @@ export function HostManagerViewer({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Server className="h-4 w-4 mr-2" />
|
<Server className="h-4 w-4 mr-2" />
|
||||||
{t("hosts.copyServerStatsUrl")}
|
{t(
|
||||||
|
"hosts.copyServerStatsUrl",
|
||||||
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{host.enableDocker && (
|
{host.enableDocker && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -1637,6 +1764,9 @@ export function HostManagerViewer({
|
|||||||
{t("hosts.copyDockerUrl")}
|
{t("hosts.copyDockerUrl")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</>
|
</>
|
||||||
@@ -1671,6 +1801,44 @@ export function HostManagerViewer({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(() => {
|
||||||
|
const connType = (host as any)
|
||||||
|
.connectionType;
|
||||||
|
if (connType === "rdp") {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs px-1 py-0"
|
||||||
|
>
|
||||||
|
<Monitor className="h-2 w-2 mr-0.5" />
|
||||||
|
{t("hosts.rdp")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (connType === "vnc") {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs px-1 py-0"
|
||||||
|
>
|
||||||
|
<Eye className="h-2 w-2 mr-0.5" />
|
||||||
|
{t("hosts.vnc")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (connType === "telnet") {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs px-1 py-0"
|
||||||
|
>
|
||||||
|
<MessagesSquare className="h-2 w-2 mr-0.5" />
|
||||||
|
{t("hosts.telnet")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
{host.enableTerminal && (
|
{host.enableTerminal && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -1688,9 +1856,15 @@ export function HostManagerViewer({
|
|||||||
<Network className="h-2 w-2 mr-0.5" />
|
<Network className="h-2 w-2 mr-0.5" />
|
||||||
{t("hosts.tunnelBadge")}
|
{t("hosts.tunnelBadge")}
|
||||||
{host.tunnelConnections &&
|
{host.tunnelConnections &&
|
||||||
host.tunnelConnections.length > 0 && (
|
host.tunnelConnections.length >
|
||||||
|
0 && (
|
||||||
<span className="ml-0.5">
|
<span className="ml-0.5">
|
||||||
({host.tunnelConnections.length})
|
(
|
||||||
|
{
|
||||||
|
host.tunnelConnections
|
||||||
|
.length
|
||||||
|
}
|
||||||
|
)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -1713,10 +1887,111 @@ export function HostManagerViewer({
|
|||||||
{t("hosts.docker")}
|
{t("hosts.docker")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 pt-3 border-t border-border/50 flex items-center justify-center gap-1">
|
<div className="mt-3 pt-3 border-t border-border/50 flex items-center justify-center gap-1">
|
||||||
|
{(() => {
|
||||||
|
const connType = (host as any)
|
||||||
|
.connectionType;
|
||||||
|
const isRemoteDesktop =
|
||||||
|
connType === "rdp" ||
|
||||||
|
connType === "vnc" ||
|
||||||
|
connType === "telnet";
|
||||||
|
|
||||||
|
if (isRemoteDesktop) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const title = host.name?.trim()
|
||||||
|
? host.name
|
||||||
|
: `${host.ip}:${host.port}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const protocol = connType as
|
||||||
|
| "rdp"
|
||||||
|
| "vnc"
|
||||||
|
| "telnet";
|
||||||
|
const result =
|
||||||
|
await getGuacamoleToken({
|
||||||
|
protocol,
|
||||||
|
hostname: host.ip,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
password: host.password,
|
||||||
|
domain: host.domain,
|
||||||
|
security: host.security,
|
||||||
|
ignoreCert:
|
||||||
|
host.ignoreCert,
|
||||||
|
guacamoleConfig:
|
||||||
|
host.guacamoleConfig as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
addTab({
|
||||||
|
type: protocol,
|
||||||
|
title,
|
||||||
|
hostConfig: host,
|
||||||
|
connectionConfig: {
|
||||||
|
token: result.token,
|
||||||
|
protocol,
|
||||||
|
type: protocol,
|
||||||
|
hostname: host.ip,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
password: host.password,
|
||||||
|
domain: host.domain,
|
||||||
|
security: host.security,
|
||||||
|
"ignore-cert":
|
||||||
|
host.ignoreCert,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await logActivity(
|
||||||
|
protocol,
|
||||||
|
host.id,
|
||||||
|
title,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`Failed to log ${protocol} activity:`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
`Failed to connect: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-7 px-2 hover:bg-indigo-500/10 hover:border-indigo-500/50 flex-1"
|
||||||
|
>
|
||||||
|
{connType === "rdp" ? (
|
||||||
|
<Monitor className="h-3.5 w-3.5" />
|
||||||
|
) : connType === "vnc" ? (
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<MessagesSquare className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("hosts.remoteDesktop")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
{host.enableTerminal && (
|
{host.enableTerminal && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -1725,7 +2000,8 @@ export function HostManagerViewer({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const title = host.name?.trim()
|
const title =
|
||||||
|
host.name?.trim()
|
||||||
? host.name
|
? host.name
|
||||||
: `${host.username}@${host.ip}:${host.port}`;
|
: `${host.username}@${host.ip}:${host.port}`;
|
||||||
addTab({
|
addTab({
|
||||||
@@ -1752,7 +2028,8 @@ export function HostManagerViewer({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const title = host.name?.trim()
|
const title =
|
||||||
|
host.name?.trim()
|
||||||
? host.name
|
? host.name
|
||||||
: `${host.username}@${host.ip}:${host.port}`;
|
: `${host.username}@${host.ip}:${host.port}`;
|
||||||
addTab({
|
addTab({
|
||||||
@@ -1767,7 +2044,9 @@ export function HostManagerViewer({
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Open File Manager</p>
|
<p>
|
||||||
|
{t("hosts.openFileManager")}
|
||||||
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -1779,7 +2058,8 @@ export function HostManagerViewer({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const title = host.name?.trim()
|
const title =
|
||||||
|
host.name?.trim()
|
||||||
? host.name
|
? host.name
|
||||||
: `${host.username}@${host.ip}:${host.port}`;
|
: `${host.username}@${host.ip}:${host.port}`;
|
||||||
addTab({
|
addTab({
|
||||||
@@ -1794,7 +2074,7 @@ export function HostManagerViewer({
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Open Tunnels</p>
|
<p>{t("hosts.openTunnels")}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -1806,7 +2086,8 @@ export function HostManagerViewer({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const title = host.name?.trim()
|
const title =
|
||||||
|
host.name?.trim()
|
||||||
? host.name
|
? host.name
|
||||||
: `${host.username}@${host.ip}:${host.port}`;
|
: `${host.username}@${host.ip}:${host.port}`;
|
||||||
addTab({
|
addTab({
|
||||||
@@ -1847,9 +2128,12 @@ export function HostManagerViewer({
|
|||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Open Server Details</p>
|
<p>{t("hosts.openServerStats")}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { toast } from "sonner";
|
|||||||
|
|
||||||
export function HostGeneralTab({
|
export function HostGeneralTab({
|
||||||
form,
|
form,
|
||||||
|
connectionType,
|
||||||
authTab,
|
authTab,
|
||||||
setAuthTab,
|
setAuthTab,
|
||||||
keyInputMethod,
|
keyInputMethod,
|
||||||
@@ -213,7 +214,11 @@ export function HostGeneralTab({
|
|||||||
<FormItem className="col-span-1">
|
<FormItem className="col-span-1">
|
||||||
<FormLabel>{t("hosts.port")}</FormLabel>
|
<FormLabel>{t("hosts.port")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder={t("placeholders.port")} {...field} />
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder={t("placeholders.port")}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -430,6 +435,8 @@ export function HostGeneralTab({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{connectionType === "ssh" && (
|
||||||
|
<>
|
||||||
<FormLabel className="mb-3 mt-3 font-bold">
|
<FormLabel className="mb-3 mt-3 font-bold">
|
||||||
{t("hosts.authentication")}
|
{t("hosts.authentication")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
@@ -516,7 +523,9 @@ export function HostGeneralTab({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<TabsList className="inline-flex items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
|
<TabsList className="inline-flex items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
|
||||||
<TabsTrigger value="upload">{t("hosts.uploadFile")}</TabsTrigger>
|
<TabsTrigger value="upload">
|
||||||
|
{t("hosts.uploadFile")}
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="paste">{t("hosts.pasteKey")}</TabsTrigger>
|
<TabsTrigger value="paste">{t("hosts.pasteKey")}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="upload" className="mt-4">
|
<TabsContent value="upload" className="mt-4">
|
||||||
@@ -546,7 +555,8 @@ export function HostGeneralTab({
|
|||||||
<span
|
<span
|
||||||
className="truncate"
|
className="truncate"
|
||||||
title={
|
title={
|
||||||
(field.value as File)?.name || t("hosts.upload")
|
(field.value as File)?.name ||
|
||||||
|
t("hosts.upload")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{field.value === "existing_key"
|
{field.value === "existing_key"
|
||||||
@@ -634,10 +644,13 @@ export function HostGeneralTab({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full justify-start text-left rounded-md px-2 py-2 bg-canvas border border-input text-foreground"
|
className="w-full justify-start text-left rounded-md px-2 py-2 bg-canvas border border-input text-foreground"
|
||||||
onClick={() => setKeyTypeDropdownOpen((open) => !open)}
|
onClick={() =>
|
||||||
|
setKeyTypeDropdownOpen((open) => !open)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{keyTypeOptions.find((opt) => opt.value === field.value)
|
{keyTypeOptions.find(
|
||||||
?.label || t("hosts.autoDetect")}
|
(opt) => opt.value === field.value,
|
||||||
|
)?.label || t("hosts.autoDetect")}
|
||||||
</Button>
|
</Button>
|
||||||
{keyTypeDropdownOpen && (
|
{keyTypeDropdownOpen && (
|
||||||
<div
|
<div
|
||||||
@@ -741,7 +754,9 @@ export function HostGeneralTab({
|
|||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<strong>{t("hosts.noneAuthTitle")}</strong>
|
<strong>{t("hosts.noneAuthTitle")}</strong>
|
||||||
<div className="mt-2">{t("hosts.noneAuthDescription")}</div>
|
<div className="mt-2">{t("hosts.noneAuthDescription")}</div>
|
||||||
<div className="mt-2 text-sm">{t("hosts.noneAuthDetails")}</div>
|
<div className="mt-2 text-sm">
|
||||||
|
{t("hosts.noneAuthDetails")}
|
||||||
|
</div>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -767,7 +782,9 @@ export function HostGeneralTab({
|
|||||||
<Separator className="my-6" />
|
<Separator className="my-6" />
|
||||||
<Accordion type="multiple" className="w-full">
|
<Accordion type="multiple" className="w-full">
|
||||||
<AccordionItem value="sidebar-customization">
|
<AccordionItem value="sidebar-customization">
|
||||||
<AccordionTrigger>{t("hosts.sidebarCustomization")}</AccordionTrigger>
|
<AccordionTrigger>
|
||||||
|
{t("hosts.sidebarCustomization")}
|
||||||
|
</AccordionTrigger>
|
||||||
<AccordionContent className="space-y-4 pt-4">
|
<AccordionContent className="space-y-4 pt-4">
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
@@ -782,7 +799,9 @@ export function HostGeneralTab({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>{t("hosts.showTerminalInSidebar")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("hosts.showTerminalInSidebar")}
|
||||||
|
</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("hosts.showTerminalInSidebarDesc")}
|
{t("hosts.showTerminalInSidebarDesc")}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
@@ -830,7 +849,9 @@ export function HostGeneralTab({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>{t("hosts.showTunnelInSidebar")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("hosts.showTunnelInSidebar")}
|
||||||
|
</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("hosts.showTunnelInSidebarDesc")}
|
{t("hosts.showTunnelInSidebarDesc")}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
@@ -853,7 +874,9 @@ export function HostGeneralTab({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>{t("hosts.showDockerInSidebar")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("hosts.showDockerInSidebar")}
|
||||||
|
</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("hosts.showDockerInSidebarDesc")}
|
{t("hosts.showDockerInSidebarDesc")}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
@@ -875,7 +898,9 @@ export function HostGeneralTab({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>{t("hosts.showServerStatsInSidebar")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("hosts.showServerStatsInSidebar")}
|
||||||
|
</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("hosts.showServerStatsInSidebarDesc")}
|
{t("hosts.showServerStatsInSidebarDesc")}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
@@ -893,7 +918,9 @@ export function HostGeneralTab({
|
|||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
<AccordionItem value="advanced-auth">
|
<AccordionItem value="advanced-auth">
|
||||||
<AccordionTrigger>{t("hosts.advancedAuthSettings")}</AccordionTrigger>
|
<AccordionTrigger>
|
||||||
|
{t("hosts.advancedAuthSettings")}
|
||||||
|
</AccordionTrigger>
|
||||||
<AccordionContent className="space-y-4 pt-4">
|
<AccordionContent className="space-y-4 pt-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -901,7 +928,9 @@ export function HostGeneralTab({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>{t("hosts.forceKeyboardInteractive")}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("hosts.forceKeyboardInteractive")}
|
||||||
|
</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("hosts.forceKeyboardInteractiveDesc")}
|
{t("hosts.forceKeyboardInteractiveDesc")}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
@@ -968,7 +997,9 @@ export function HostGeneralTab({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>{t("hosts.jumpHostsOrder")}</FormDescription>
|
<FormDescription>
|
||||||
|
{t("hosts.jumpHostsOrder")}
|
||||||
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -1012,7 +1043,9 @@ export function HostGeneralTab({
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={proxyMode === "single" ? "default" : "outline"}
|
variant={
|
||||||
|
proxyMode === "single" ? "default" : "outline"
|
||||||
|
}
|
||||||
onClick={() => setProxyMode("single")}
|
onClick={() => setProxyMode("single")}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
@@ -1020,7 +1053,9 @@ export function HostGeneralTab({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={proxyMode === "chain" ? "default" : "outline"}
|
variant={
|
||||||
|
proxyMode === "chain" ? "default" : "outline"
|
||||||
|
}
|
||||||
onClick={() => setProxyMode("chain")}
|
onClick={() => setProxyMode("chain")}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
@@ -1066,7 +1101,9 @@ export function HostGeneralTab({
|
|||||||
placeholder={t("placeholders.socks5Port")}
|
placeholder={t("placeholders.socks5Port")}
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.onChange(parseInt(e.target.value) || 1080)
|
field.onChange(
|
||||||
|
parseInt(e.target.value) || 1080,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -1083,7 +1120,8 @@ export function HostGeneralTab({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("hosts.socks5Username")} {t("hosts.optional")}
|
{t("hosts.socks5Username")}{" "}
|
||||||
|
{t("hosts.optional")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
@@ -1105,7 +1143,8 @@ export function HostGeneralTab({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("hosts.socks5Password")} {t("hosts.optional")}
|
{t("hosts.socks5Password")}{" "}
|
||||||
|
{t("hosts.optional")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
@@ -1147,7 +1186,8 @@ export function HostGeneralTab({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(form.watch("socks5ProxyChain") || []).length === 0 && (
|
{(form.watch("socks5ProxyChain") || []).length ===
|
||||||
|
0 && (
|
||||||
<div className="text-sm text-muted-foreground text-center p-4 border rounded-lg border-dashed">
|
<div className="text-sm text-muted-foreground text-center p-4 border rounded-lg border-dashed">
|
||||||
{t("hosts.noProxyNodes")}
|
{t("hosts.noProxyNodes")}
|
||||||
</div>
|
</div>
|
||||||
@@ -1196,7 +1236,10 @@ export function HostGeneralTab({
|
|||||||
...newChain[index],
|
...newChain[index],
|
||||||
host: e.target.value,
|
host: e.target.value,
|
||||||
};
|
};
|
||||||
form.setValue("socks5ProxyChain", newChain);
|
form.setValue(
|
||||||
|
"socks5ProxyChain",
|
||||||
|
newChain,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const currentChain =
|
const currentChain =
|
||||||
@@ -1206,7 +1249,10 @@ export function HostGeneralTab({
|
|||||||
...newChain[index],
|
...newChain[index],
|
||||||
host: e.target.value.trim(),
|
host: e.target.value.trim(),
|
||||||
};
|
};
|
||||||
form.setValue("socks5ProxyChain", newChain);
|
form.setValue(
|
||||||
|
"socks5ProxyChain",
|
||||||
|
newChain,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1225,7 +1271,10 @@ export function HostGeneralTab({
|
|||||||
...newChain[index],
|
...newChain[index],
|
||||||
port: parseInt(e.target.value) || 1080,
|
port: parseInt(e.target.value) || 1080,
|
||||||
};
|
};
|
||||||
form.setValue("socks5ProxyChain", newChain);
|
form.setValue(
|
||||||
|
"socks5ProxyChain",
|
||||||
|
newChain,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1283,7 +1332,10 @@ export function HostGeneralTab({
|
|||||||
...newChain[index],
|
...newChain[index],
|
||||||
username: e.target.value,
|
username: e.target.value,
|
||||||
};
|
};
|
||||||
form.setValue("socks5ProxyChain", newChain);
|
form.setValue(
|
||||||
|
"socks5ProxyChain",
|
||||||
|
newChain,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const currentChain =
|
const currentChain =
|
||||||
@@ -1293,7 +1345,10 @@ export function HostGeneralTab({
|
|||||||
...newChain[index],
|
...newChain[index],
|
||||||
username: e.target.value.trim(),
|
username: e.target.value.trim(),
|
||||||
};
|
};
|
||||||
form.setValue("socks5ProxyChain", newChain);
|
form.setValue(
|
||||||
|
"socks5ProxyChain",
|
||||||
|
newChain,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1314,7 +1369,10 @@ export function HostGeneralTab({
|
|||||||
...newChain[index],
|
...newChain[index],
|
||||||
password: e.target.value,
|
password: e.target.value,
|
||||||
};
|
};
|
||||||
form.setValue("socks5ProxyChain", newChain);
|
form.setValue(
|
||||||
|
"socks5ProxyChain",
|
||||||
|
newChain,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1381,6 +1439,50 @@ export function HostGeneralTab({
|
|||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Simple password field for RDP/VNC/Telnet */}
|
||||||
|
{connectionType !== "ssh" && (
|
||||||
|
<div className="mt-3 space-y-4">
|
||||||
|
<FormLabel className="font-bold">
|
||||||
|
{t("hosts.authentication")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("hosts.password")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<PasswordInput
|
||||||
|
placeholder={t("placeholders.password")}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{connectionType === "rdp" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="domain"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("hosts.domain")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={t("hosts.domain")}
|
||||||
|
value={field.value || ""}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,797 @@
|
|||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from "@/components/ui/form.tsx";
|
||||||
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
|
import { Switch } from "@/components/ui/switch.tsx";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select.tsx";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion.tsx";
|
||||||
|
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||||
|
import type { HostRemoteDesktopTabProps } from "./shared/tab-types";
|
||||||
|
|
||||||
|
function GuacField({
|
||||||
|
form,
|
||||||
|
path,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
type = "text",
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
form: any;
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
type?: "text" | "number" | "password" | "switch";
|
||||||
|
t: (key: string) => string;
|
||||||
|
}) {
|
||||||
|
const fieldName = `guacamoleConfig.${path}` as any;
|
||||||
|
|
||||||
|
if (type === "switch") {
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={fieldName}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>{label}</FormLabel>
|
||||||
|
{description && <FormDescription>{description}</FormDescription>}
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={!!field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "password") {
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={fieldName}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{label}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<PasswordInput
|
||||||
|
value={field.value || ""}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{description && <FormDescription>{description}</FormDescription>}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={fieldName}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{label}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type={type}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(
|
||||||
|
type === "number"
|
||||||
|
? e.target.value === ""
|
||||||
|
? undefined
|
||||||
|
: parseInt(e.target.value)
|
||||||
|
: e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{description && <FormDescription>{description}</FormDescription>}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuacSelect({
|
||||||
|
form,
|
||||||
|
path,
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
form: any;
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const fieldName = `guacamoleConfig.${path}` as any;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={fieldName}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{label}</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value || "auto"}
|
||||||
|
onValueChange={(v) => field.onChange(v === "auto" ? "" : v)}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={placeholder || label} />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HostRemoteDesktopTab({
|
||||||
|
form,
|
||||||
|
connectionType,
|
||||||
|
t,
|
||||||
|
}: HostRemoteDesktopTabProps) {
|
||||||
|
const isRDP = connectionType === "rdp";
|
||||||
|
const isVNC = connectionType === "vnc";
|
||||||
|
const isTelnet = connectionType === "telnet";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-2 space-y-4">
|
||||||
|
<Accordion
|
||||||
|
type="multiple"
|
||||||
|
defaultValue={["connection", "display"]}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{/* Connection Settings */}
|
||||||
|
{isRDP && (
|
||||||
|
<AccordionItem value="connection">
|
||||||
|
<AccordionTrigger>{t("hosts.connectionSettings")}</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4 pt-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="security"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("hosts.securityMode")}</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value || "any"}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="any">Any</SelectItem>
|
||||||
|
<SelectItem value="nla">NLA</SelectItem>
|
||||||
|
<SelectItem value="nla-ext">NLA Extended</SelectItem>
|
||||||
|
<SelectItem value="tls">TLS</SelectItem>
|
||||||
|
<SelectItem value="vmconnect">VMConnect</SelectItem>
|
||||||
|
<SelectItem value="rdp">RDP</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="ignoreCert"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>{t("hosts.ignoreCert")}</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
{t("hosts.ignoreCertDesc")}
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={!!field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Display Settings */}
|
||||||
|
<AccordionItem value="display">
|
||||||
|
<AccordionTrigger>{t("hosts.displaySettings")}</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4 pt-4">
|
||||||
|
{!isTelnet && (
|
||||||
|
<GuacSelect
|
||||||
|
form={form}
|
||||||
|
path="color-depth"
|
||||||
|
label={t("hosts.colorDepth")}
|
||||||
|
options={[
|
||||||
|
{ value: "auto", label: "Auto" },
|
||||||
|
{ value: "8", label: "8-bit" },
|
||||||
|
{ value: "16", label: "16-bit" },
|
||||||
|
{ value: "24", label: "24-bit" },
|
||||||
|
{ value: "32", label: "32-bit" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="width"
|
||||||
|
label={t("hosts.width")}
|
||||||
|
type="number"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="height"
|
||||||
|
label={t("hosts.height")}
|
||||||
|
type="number"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isTelnet && (
|
||||||
|
<>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="dpi"
|
||||||
|
label={t("hosts.dpi")}
|
||||||
|
type="number"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacSelect
|
||||||
|
form={form}
|
||||||
|
path="resize-method"
|
||||||
|
label={t("hosts.resizeMethod")}
|
||||||
|
options={[
|
||||||
|
{ value: "auto", label: "Auto" },
|
||||||
|
{ value: "display-update", label: "Display Update" },
|
||||||
|
{ value: "reconnect", label: "Reconnect" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="force-lossless"
|
||||||
|
label={t("hosts.forceLossless")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* Telnet Terminal Settings */}
|
||||||
|
{isTelnet && (
|
||||||
|
<AccordionItem value="telnet-terminal">
|
||||||
|
<AccordionTrigger>
|
||||||
|
{t("hosts.telnetTerminalSettings")}
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4 pt-4">
|
||||||
|
<GuacSelect
|
||||||
|
form={form}
|
||||||
|
path="terminal-type"
|
||||||
|
label={t("hosts.terminalType")}
|
||||||
|
options={[
|
||||||
|
{ value: "auto", label: "Auto" },
|
||||||
|
{ value: "xterm", label: "xterm" },
|
||||||
|
{ value: "xterm-256color", label: "xterm-256color" },
|
||||||
|
{ value: "vt100", label: "VT100" },
|
||||||
|
{ value: "vt220", label: "VT220" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="font-name"
|
||||||
|
label={t("hosts.guacFontName")}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="font-size"
|
||||||
|
label={t("hosts.guacFontSize")}
|
||||||
|
type="number"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacSelect
|
||||||
|
form={form}
|
||||||
|
path="color-scheme"
|
||||||
|
label={t("hosts.guacColorScheme")}
|
||||||
|
options={[
|
||||||
|
{ value: "auto", label: "Auto" },
|
||||||
|
{ value: "black-white", label: "Black on White" },
|
||||||
|
{ value: "white-black", label: "White on Black" },
|
||||||
|
{ value: "gray-black", label: "Gray on Black" },
|
||||||
|
{ value: "green-black", label: "Green on Black" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<GuacSelect
|
||||||
|
form={form}
|
||||||
|
path="backspace"
|
||||||
|
label={t("hosts.guacBackspaceKey")}
|
||||||
|
options={[
|
||||||
|
{ value: "auto", label: "Auto" },
|
||||||
|
{ value: "127", label: "DEL (127)" },
|
||||||
|
{ value: "8", label: "BS (8)" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Audio Settings */}
|
||||||
|
{(isRDP || isVNC) && (
|
||||||
|
<AccordionItem value="audio">
|
||||||
|
<AccordionTrigger>{t("hosts.audioSettings")}</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4 pt-4">
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="disable-audio"
|
||||||
|
label={t("hosts.disableAudio")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
{isRDP && (
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="enable-audio-input"
|
||||||
|
label={t("hosts.enableAudioInput")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RDP Performance */}
|
||||||
|
{isRDP && (
|
||||||
|
<AccordionItem value="performance">
|
||||||
|
<AccordionTrigger>{t("hosts.rdpPerformance")}</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4 pt-4">
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="enable-wallpaper"
|
||||||
|
label={t("hosts.enableWallpaper")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="enable-theming"
|
||||||
|
label={t("hosts.enableTheming")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="enable-font-smoothing"
|
||||||
|
label={t("hosts.enableFontSmoothing")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="enable-full-window-drag"
|
||||||
|
label={t("hosts.enableFullWindowDrag")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="enable-desktop-composition"
|
||||||
|
label={t("hosts.enableDesktopComposition")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="enable-menu-animations"
|
||||||
|
label={t("hosts.enableMenuAnimations")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="disable-bitmap-caching"
|
||||||
|
label={t("hosts.disableBitmapCaching")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="disable-offscreen-caching"
|
||||||
|
label={t("hosts.disableOffscreenCaching")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="disable-glyph-caching"
|
||||||
|
label={t("hosts.disableGlyphCaching")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="enable-gfx"
|
||||||
|
label={t("hosts.enableGfx")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RDP Device Redirection */}
|
||||||
|
{isRDP && (
|
||||||
|
<AccordionItem value="device-redirection">
|
||||||
|
<AccordionTrigger>{t("hosts.deviceRedirection")}</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4 pt-4">
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="enable-printing"
|
||||||
|
label={t("hosts.enablePrinting")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="enable-drive"
|
||||||
|
label={t("hosts.enableDrive")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="drive-name"
|
||||||
|
label={t("hosts.driveName")}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="drive-path"
|
||||||
|
label={t("hosts.drivePath")}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="create-drive-path"
|
||||||
|
label={t("hosts.createDrivePath")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="disable-download"
|
||||||
|
label={t("hosts.disableDownload")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="disable-upload"
|
||||||
|
label={t("hosts.disableUpload")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="enable-touch"
|
||||||
|
label={t("hosts.enableTouch")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RDP Session */}
|
||||||
|
{isRDP && (
|
||||||
|
<AccordionItem value="session">
|
||||||
|
<AccordionTrigger>{t("hosts.rdpSession")}</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4 pt-4">
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="client-name"
|
||||||
|
label={t("hosts.clientName")}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="console"
|
||||||
|
label={t("hosts.consoleSession")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="initial-program"
|
||||||
|
label={t("hosts.initialProgram")}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacSelect
|
||||||
|
form={form}
|
||||||
|
path="server-layout"
|
||||||
|
label={t("hosts.serverLayout")}
|
||||||
|
options={[
|
||||||
|
{ value: "auto", label: "Auto" },
|
||||||
|
{ value: "en-us-qwerty", label: "English (US) QWERTY" },
|
||||||
|
{ value: "en-gb-qwerty", label: "English (UK) QWERTY" },
|
||||||
|
{ value: "de-de-qwertz", label: "German QWERTZ" },
|
||||||
|
{ value: "fr-fr-azerty", label: "French AZERTY" },
|
||||||
|
{ value: "it-it-qwerty", label: "Italian QWERTY" },
|
||||||
|
{ value: "sv-se-qwerty", label: "Swedish QWERTY" },
|
||||||
|
{ value: "ja-jp-qwerty", label: "Japanese QWERTY" },
|
||||||
|
{ value: "pt-br-qwerty", label: "Portuguese (BR) QWERTY" },
|
||||||
|
{ value: "es-es-qwerty", label: "Spanish QWERTY" },
|
||||||
|
{ value: "failsafe", label: "Failsafe (Unicode)" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="timezone"
|
||||||
|
label={t("hosts.timezone")}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RDP Gateway */}
|
||||||
|
{isRDP && (
|
||||||
|
<AccordionItem value="gateway">
|
||||||
|
<AccordionTrigger>{t("hosts.gatewaySettings")}</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4 pt-4">
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="gateway-hostname"
|
||||||
|
label={t("hosts.gatewayHostname")}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="gateway-port"
|
||||||
|
label={t("hosts.gatewayPort")}
|
||||||
|
type="number"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="gateway-username"
|
||||||
|
label={t("hosts.gatewayUsername")}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="gateway-password"
|
||||||
|
label={t("hosts.gatewayPassword")}
|
||||||
|
type="password"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="gateway-domain"
|
||||||
|
label={t("hosts.gatewayDomain")}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RDP RemoteApp */}
|
||||||
|
{isRDP && (
|
||||||
|
<AccordionItem value="remoteapp">
|
||||||
|
<AccordionTrigger>{t("hosts.remoteApp")}</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4 pt-4">
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="remote-app"
|
||||||
|
label={t("hosts.remoteAppProgram")}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="remote-app-dir"
|
||||||
|
label={t("hosts.remoteAppDir")}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="remote-app-args"
|
||||||
|
label={t("hosts.remoteAppArgs")}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Clipboard */}
|
||||||
|
<AccordionItem value="clipboard">
|
||||||
|
<AccordionTrigger>{t("hosts.clipboardSettings")}</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4 pt-4">
|
||||||
|
<GuacSelect
|
||||||
|
form={form}
|
||||||
|
path="normalize-clipboard"
|
||||||
|
label={t("hosts.normalizeClipboard")}
|
||||||
|
options={[
|
||||||
|
{ value: "auto", label: "Auto" },
|
||||||
|
{ value: "preserve", label: "Preserve" },
|
||||||
|
{ value: "unix", label: "Unix (LF)" },
|
||||||
|
{ value: "windows", label: "Windows (CRLF)" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="disable-copy"
|
||||||
|
label={t("hosts.disableCopy")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="disable-paste"
|
||||||
|
label={t("hosts.disablePaste")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* VNC Specific */}
|
||||||
|
{isVNC && (
|
||||||
|
<AccordionItem value="vnc-specific">
|
||||||
|
<AccordionTrigger>{t("hosts.vncSettings")}</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4 pt-4">
|
||||||
|
<GuacSelect
|
||||||
|
form={form}
|
||||||
|
path="cursor"
|
||||||
|
label={t("hosts.cursorMode")}
|
||||||
|
options={[
|
||||||
|
{ value: "auto", label: "Auto" },
|
||||||
|
{ value: "local", label: "Local" },
|
||||||
|
{ value: "remote", label: "Remote" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="swap-red-blue"
|
||||||
|
label={t("hosts.swapRedBlue")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="read-only"
|
||||||
|
label={t("hosts.readOnly")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recording */}
|
||||||
|
<AccordionItem value="recording">
|
||||||
|
<AccordionTrigger>{t("hosts.recordingSettings")}</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4 pt-4">
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="recording-path"
|
||||||
|
label={t("hosts.recordingPath")}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="recording-name"
|
||||||
|
label={t("hosts.recordingName")}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="create-recording-path"
|
||||||
|
label={t("hosts.createRecordingPath")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="recording-exclude-output"
|
||||||
|
label={t("hosts.excludeOutput")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="recording-exclude-mouse"
|
||||||
|
label={t("hosts.excludeMouse")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="recording-include-keys"
|
||||||
|
label={t("hosts.includeKeys")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* Wake-on-LAN */}
|
||||||
|
<AccordionItem value="wol">
|
||||||
|
<AccordionTrigger>{t("hosts.wakeOnLan")}</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4 pt-4">
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="wol-send-packet"
|
||||||
|
label={t("hosts.sendWolPacket")}
|
||||||
|
type="switch"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="wol-mac-addr"
|
||||||
|
label={t("hosts.wolMacAddr")}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="wol-broadcast-addr"
|
||||||
|
label={t("hosts.wolBroadcastAddr")}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="wol-udp-port"
|
||||||
|
label={t("hosts.wolUdpPort")}
|
||||||
|
type="number"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<GuacField
|
||||||
|
form={form}
|
||||||
|
path="wol-wait-time"
|
||||||
|
label={t("hosts.wolWaitTime")}
|
||||||
|
type="number"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from "@/components/ui/form.tsx";
|
||||||
|
import { Input } from "@/components/ui/input.tsx";
|
||||||
|
import { Switch } from "@/components/ui/switch.tsx";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select.tsx";
|
||||||
|
import type { HostStatisticsTabProps } from "./shared/tab-types";
|
||||||
|
|
||||||
|
export function HostStatusTab({
|
||||||
|
form,
|
||||||
|
statusIntervalUnit,
|
||||||
|
setStatusIntervalUnit,
|
||||||
|
t,
|
||||||
|
}: Pick<
|
||||||
|
HostStatisticsTabProps,
|
||||||
|
"form" | "statusIntervalUnit" | "setStatusIntervalUnit" | "t"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="statsConfig.statusCheckEnabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>{t("hosts.statusCheckEnabled")}</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
{t("hosts.statusCheckEnabledDesc")}
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{form.watch("statsConfig.statusCheckEnabled") && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="statsConfig.useGlobalStatusInterval"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value ?? true}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="font-normal">
|
||||||
|
{t("hosts.useGlobalStatusInterval")}
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{form.watch("statsConfig.useGlobalStatusInterval") === false && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="statsConfig.statusCheckInterval"
|
||||||
|
render={({ field }) => {
|
||||||
|
const displayValue =
|
||||||
|
statusIntervalUnit === "minutes"
|
||||||
|
? Math.round((field.value || 30) / 60)
|
||||||
|
: field.value || 30;
|
||||||
|
|
||||||
|
const handleIntervalChange = (value: string) => {
|
||||||
|
const numValue = parseInt(value) || 0;
|
||||||
|
const seconds =
|
||||||
|
statusIntervalUnit === "minutes"
|
||||||
|
? numValue * 60
|
||||||
|
: numValue;
|
||||||
|
field.onChange(seconds);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("hosts.statusCheckInterval")}</FormLabel>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={displayValue}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleIntervalChange(e.target.value)
|
||||||
|
}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<Select
|
||||||
|
value={statusIntervalUnit}
|
||||||
|
onValueChange={(value: "seconds" | "minutes") => {
|
||||||
|
setStatusIntervalUnit(value);
|
||||||
|
const currentSeconds = field.value || 30;
|
||||||
|
if (value === "minutes") {
|
||||||
|
const minutes = Math.round(currentSeconds / 60);
|
||||||
|
field.onChange(minutes * 60);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[120px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="seconds">
|
||||||
|
{t("hosts.intervalSeconds")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="minutes">
|
||||||
|
{t("hosts.intervalMinutes")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
{t("hosts.statusCheckIntervalDesc")}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { SSHHost, Credential } from "@/types";
|
|||||||
|
|
||||||
export interface HostGeneralTabProps {
|
export interface HostGeneralTabProps {
|
||||||
form: UseFormReturn<FormData>;
|
form: UseFormReturn<FormData>;
|
||||||
|
connectionType: "ssh" | "rdp" | "vnc" | "telnet";
|
||||||
authTab: "password" | "key" | "credential" | "none";
|
authTab: "password" | "key" | "credential" | "none";
|
||||||
setAuthTab: (value: "password" | "key" | "credential" | "none") => void;
|
setAuthTab: (value: "password" | "key" | "credential" | "none") => void;
|
||||||
keyInputMethod: "upload" | "paste";
|
keyInputMethod: "upload" | "paste";
|
||||||
@@ -75,6 +76,12 @@ export interface HostStatisticsTabProps {
|
|||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HostRemoteDesktopTabProps {
|
||||||
|
form: UseFormReturn<FormData>;
|
||||||
|
connectionType: "rdp" | "vnc" | "telnet";
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HostSharingTabProps {
|
export interface HostSharingTabProps {
|
||||||
hostId: number | undefined;
|
hostId: number | undefined;
|
||||||
isNewHost: boolean;
|
isNewHost: boolean;
|
||||||
|
|||||||
@@ -251,7 +251,10 @@ export function SSHToolsSidebar({
|
|||||||
tab.type === "server_stats" ||
|
tab.type === "server_stats" ||
|
||||||
tab.type === "file_manager" ||
|
tab.type === "file_manager" ||
|
||||||
tab.type === "tunnel" ||
|
tab.type === "tunnel" ||
|
||||||
tab.type === "docker",
|
tab.type === "docker" ||
|
||||||
|
tab.type === "rdp" ||
|
||||||
|
tab.type === "vnc" ||
|
||||||
|
tab.type === "telnet",
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -615,7 +615,7 @@ export function Auth({
|
|||||||
async function handleOIDCLogin() {
|
async function handleOIDCLogin() {
|
||||||
setOidcLoading(true);
|
setOidcLoading(true);
|
||||||
try {
|
try {
|
||||||
const authResponse = await getOIDCAuthorizeUrl();
|
const authResponse = await getOIDCAuthorizeUrl(rememberMe);
|
||||||
const { auth_url: authUrl } = authResponse;
|
const { auth_url: authUrl } = authResponse;
|
||||||
|
|
||||||
if (!authUrl || authUrl === "undefined") {
|
if (!authUrl || authUrl === "undefined") {
|
||||||
@@ -1236,6 +1236,19 @@ export function Auth({
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="rememberMeOIDC"
|
||||||
|
checked={rememberMe}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setRememberMe(checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="rememberMeOIDC">
|
||||||
|
{t("auth.rememberMe")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full h-11 mt-2 text-base font-semibold"
|
className="w-full h-11 mt-2 text-base font-semibold"
|
||||||
@@ -1246,6 +1259,7 @@ export function Auth({
|
|||||||
? Spinner
|
? Spinner
|
||||||
: t("auth.loginWithExternal")}
|
: t("auth.loginWithExternal")}
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import React, { useEffect, useRef, useState, useMemo } from "react";
|
|||||||
import { Terminal } from "@/ui/desktop/apps/features/terminal/Terminal.tsx";
|
import { Terminal } from "@/ui/desktop/apps/features/terminal/Terminal.tsx";
|
||||||
import { ServerStats as ServerView } from "@/ui/desktop/apps/features/server-stats/ServerStats.tsx";
|
import { ServerStats as ServerView } from "@/ui/desktop/apps/features/server-stats/ServerStats.tsx";
|
||||||
import { FileManager } from "@/ui/desktop/apps/features/file-manager/FileManager.tsx";
|
import { FileManager } from "@/ui/desktop/apps/features/file-manager/FileManager.tsx";
|
||||||
|
import {
|
||||||
|
GuacamoleDisplay,
|
||||||
|
type GuacamoleConnectionConfig,
|
||||||
|
} from "@/ui/desktop/apps/features/guacamole/GuacamoleDisplay.tsx";
|
||||||
import { TunnelManager } from "@/ui/desktop/apps/features/tunnel/TunnelManager.tsx";
|
import { TunnelManager } from "@/ui/desktop/apps/features/tunnel/TunnelManager.tsx";
|
||||||
import { DockerManager } from "@/ui/desktop/apps/features/docker/DockerManager.tsx";
|
import { DockerManager } from "@/ui/desktop/apps/features/docker/DockerManager.tsx";
|
||||||
import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
|
import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
|
||||||
@@ -14,6 +18,7 @@ import {
|
|||||||
import * as ResizablePrimitive from "react-resizable-panels";
|
import * as ResizablePrimitive from "react-resizable-panels";
|
||||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||||
import { RefreshCcw } from "lucide-react";
|
import { RefreshCcw } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button.tsx";
|
import { Button } from "@/components/ui/button.tsx";
|
||||||
import {
|
import {
|
||||||
TERMINAL_THEMES,
|
TERMINAL_THEMES,
|
||||||
@@ -34,6 +39,7 @@ interface TabData {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
hostConfig?: any;
|
hostConfig?: any;
|
||||||
|
connectionConfig?: GuacamoleConnectionConfig;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +111,9 @@ export function AppView({
|
|||||||
tab.type === "terminal" ||
|
tab.type === "terminal" ||
|
||||||
tab.type === "server_stats" ||
|
tab.type === "server_stats" ||
|
||||||
tab.type === "file_manager" ||
|
tab.type === "file_manager" ||
|
||||||
|
tab.type === "rdp" ||
|
||||||
|
tab.type === "vnc" ||
|
||||||
|
tab.type === "telnet" ||
|
||||||
tab.type === "tunnel" ||
|
tab.type === "tunnel" ||
|
||||||
tab.type === "docker" ||
|
tab.type === "docker" ||
|
||||||
tab.type === "network_graph",
|
tab.type === "network_graph",
|
||||||
@@ -376,6 +385,7 @@ export function AppView({
|
|||||||
>
|
>
|
||||||
{t.type === "terminal" ? (
|
{t.type === "terminal" ? (
|
||||||
<Terminal
|
<Terminal
|
||||||
|
key={`term-${t.id}-${t.instanceId || ""}`}
|
||||||
ref={t.terminalRef}
|
ref={t.terminalRef}
|
||||||
hostConfig={t.hostConfig}
|
hostConfig={t.hostConfig}
|
||||||
isVisible={effectiveVisible}
|
isVisible={effectiveVisible}
|
||||||
@@ -387,14 +397,35 @@ export function AppView({
|
|||||||
/>
|
/>
|
||||||
) : t.type === "server_stats" ? (
|
) : t.type === "server_stats" ? (
|
||||||
<ServerView
|
<ServerView
|
||||||
|
key={`stats-${t.id}-${t.instanceId || ""}`}
|
||||||
hostConfig={t.hostConfig}
|
hostConfig={t.hostConfig}
|
||||||
title={t.title}
|
title={t.title}
|
||||||
isVisible={effectiveVisible}
|
isVisible={effectiveVisible}
|
||||||
isTopbarOpen={isTopbarOpen}
|
isTopbarOpen={isTopbarOpen}
|
||||||
embedded
|
embedded
|
||||||
/>
|
/>
|
||||||
|
) : t.type === "rdp" ||
|
||||||
|
t.type === "vnc" ||
|
||||||
|
t.type === "telnet" ? (
|
||||||
|
t.connectionConfig ? (
|
||||||
|
<GuacamoleDisplay
|
||||||
|
key={`guac-${t.id}-${t.instanceId || ""}`}
|
||||||
|
connectionConfig={t.connectionConfig}
|
||||||
|
isVisible={effectiveVisible}
|
||||||
|
onDisconnect={() => removeTab(t.id)}
|
||||||
|
onError={(err) => {
|
||||||
|
toast.error(err);
|
||||||
|
removeTab(t.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-red-500">
|
||||||
|
Missing connection configuration
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : t.type === "network_graph" ? (
|
) : t.type === "network_graph" ? (
|
||||||
<NetworkGraphCard
|
<NetworkGraphCard
|
||||||
|
key={`netgraph-${t.id}-${t.instanceId || ""}`}
|
||||||
isTopbarOpen={isTopbarOpen}
|
isTopbarOpen={isTopbarOpen}
|
||||||
rightSidebarOpen={rightSidebarOpen}
|
rightSidebarOpen={rightSidebarOpen}
|
||||||
rightSidebarWidth={rightSidebarWidth}
|
rightSidebarWidth={rightSidebarWidth}
|
||||||
@@ -402,6 +433,7 @@ export function AppView({
|
|||||||
/>
|
/>
|
||||||
) : t.type === "tunnel" ? (
|
) : t.type === "tunnel" ? (
|
||||||
<TunnelManager
|
<TunnelManager
|
||||||
|
key={`tunnel-${t.id}-${t.instanceId || ""}`}
|
||||||
hostConfig={t.hostConfig}
|
hostConfig={t.hostConfig}
|
||||||
title={t.title}
|
title={t.title}
|
||||||
isVisible={effectiveVisible}
|
isVisible={effectiveVisible}
|
||||||
@@ -410,6 +442,7 @@ export function AppView({
|
|||||||
/>
|
/>
|
||||||
) : t.type === "docker" ? (
|
) : t.type === "docker" ? (
|
||||||
<DockerManager
|
<DockerManager
|
||||||
|
key={`docker-${t.id}-${t.instanceId || ""}`}
|
||||||
hostConfig={t.hostConfig}
|
hostConfig={t.hostConfig}
|
||||||
title={t.title}
|
title={t.title}
|
||||||
isVisible={effectiveVisible}
|
isVisible={effectiveVisible}
|
||||||
@@ -419,6 +452,7 @@ export function AppView({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FileManager
|
<FileManager
|
||||||
|
key={`filemgr-${t.id}-${t.instanceId || ""}`}
|
||||||
embedded
|
embedded
|
||||||
initialHost={t.hostConfig}
|
initialHost={t.hostConfig}
|
||||||
onClose={() => removeTab(t.id)}
|
onClose={() => removeTab(t.id)}
|
||||||
|
|||||||
@@ -36,30 +36,7 @@ import { Button } from "@/components/ui/button.tsx";
|
|||||||
import { FolderCard } from "@/ui/desktop/navigation/hosts/FolderCard.tsx";
|
import { FolderCard } from "@/ui/desktop/navigation/hosts/FolderCard.tsx";
|
||||||
import { getSSHHosts, getSSHFolders } from "@/ui/main-axios.ts";
|
import { getSSHHosts, getSSHFolders } from "@/ui/main-axios.ts";
|
||||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||||
import type { SSHFolder } from "@/types/index.ts";
|
import type { SSHFolder, SSHHost } from "@/types/index.ts";
|
||||||
|
|
||||||
interface SSHHost {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
username: string;
|
|
||||||
folder: string;
|
|
||||||
tags: string[];
|
|
||||||
pin: boolean;
|
|
||||||
authType: string;
|
|
||||||
password?: string;
|
|
||||||
key?: string;
|
|
||||||
keyPassword?: string;
|
|
||||||
keyType?: string;
|
|
||||||
enableTerminal: boolean;
|
|
||||||
enableTunnel: boolean;
|
|
||||||
enableFileManager: boolean;
|
|
||||||
defaultPath: string;
|
|
||||||
tunnelConnections: unknown[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -218,37 +195,41 @@ export function LeftSidebar({
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setHosts(newHosts);
|
setHosts(newHosts);
|
||||||
prevHostsRef.current = newHosts;
|
prevHostsRef.current = newHosts;
|
||||||
|
|
||||||
newHosts.forEach((newHost) => {
|
|
||||||
updateHostConfig(newHost.id, newHost);
|
|
||||||
});
|
|
||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setHostsError(t("leftSidebar.failedToLoadHosts"));
|
setHostsError(t("leftSidebar.failedToLoadHosts"));
|
||||||
}
|
}
|
||||||
}, [updateHostConfig]);
|
}, [t]);
|
||||||
|
|
||||||
|
const fetchHostsRef = React.useRef(fetchHosts);
|
||||||
|
const fetchFolderMetadataRef = React.useRef(fetchFolderMetadata);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
fetchHosts();
|
fetchHostsRef.current = fetchHosts;
|
||||||
fetchFolderMetadata();
|
fetchFolderMetadataRef.current = fetchFolderMetadata;
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetchHostsRef.current();
|
||||||
|
fetchFolderMetadataRef.current();
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
fetchHosts();
|
fetchHostsRef.current();
|
||||||
fetchFolderMetadata();
|
fetchFolderMetadataRef.current();
|
||||||
}, 300000);
|
}, 300000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchHosts, fetchFolderMetadata]);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleHostsChanged = () => {
|
const handleHostsChanged = () => {
|
||||||
fetchHosts();
|
fetchHostsRef.current();
|
||||||
fetchFolderMetadata();
|
fetchFolderMetadataRef.current();
|
||||||
};
|
};
|
||||||
const handleCredentialsChanged = () => {
|
const handleCredentialsChanged = () => {
|
||||||
fetchHosts();
|
fetchHostsRef.current();
|
||||||
};
|
};
|
||||||
const handleFoldersChanged = () => {
|
const handleFoldersChanged = () => {
|
||||||
fetchFolderMetadata();
|
fetchFolderMetadataRef.current();
|
||||||
};
|
};
|
||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
"ssh-hosts:changed",
|
"ssh-hosts:changed",
|
||||||
@@ -276,7 +257,7 @@ export function LeftSidebar({
|
|||||||
handleFoldersChanged as EventListener,
|
handleFoldersChanged as EventListener,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, [fetchHosts, fetchFolderMetadata]);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
||||||
@@ -549,7 +530,7 @@ export function LeftSidebar({
|
|||||||
const metadata = folderMetadata.get(folder);
|
const metadata = folderMetadata.get(folder);
|
||||||
return (
|
return (
|
||||||
<FolderCard
|
<FolderCard
|
||||||
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
|
key={`folder-${folder}`}
|
||||||
folderName={folder}
|
folderName={folder}
|
||||||
hosts={getSortedHosts(hostsByFolder[folder])}
|
hosts={getSortedHosts(hostsByFolder[folder])}
|
||||||
isFirst={idx === 0}
|
isFirst={idx === 0}
|
||||||
|
|||||||
@@ -376,6 +376,8 @@ export function TopNavbar({
|
|||||||
const isSshManager = tab.type === "ssh_manager";
|
const isSshManager = tab.type === "ssh_manager";
|
||||||
const isAdmin = tab.type === "admin";
|
const isAdmin = tab.type === "admin";
|
||||||
const isUserProfile = tab.type === "user_profile";
|
const isUserProfile = tab.type === "user_profile";
|
||||||
|
const isRdp = tab.type === "rdp";
|
||||||
|
const isVnc = tab.type === "vnc";
|
||||||
const isSplittable =
|
const isSplittable =
|
||||||
isTerminal || isServer || isFileManager || isTunnel || isDocker;
|
isTerminal || isServer || isFileManager || isTunnel || isDocker;
|
||||||
const disableSplit = !isSplittable;
|
const disableSplit = !isSplittable;
|
||||||
@@ -495,6 +497,8 @@ export function TopNavbar({
|
|||||||
isSshManager ||
|
isSshManager ||
|
||||||
isAdmin ||
|
isAdmin ||
|
||||||
isUserProfile ||
|
isUserProfile ||
|
||||||
|
isRdp ||
|
||||||
|
isVnc ||
|
||||||
tab.type === "network_graph"
|
tab.type === "network_graph"
|
||||||
? () => handleTabClose(tab.id)
|
? () => handleTabClose(tab.id)
|
||||||
: undefined
|
: undefined
|
||||||
@@ -512,6 +516,8 @@ export function TopNavbar({
|
|||||||
isSshManager ||
|
isSshManager ||
|
||||||
isAdmin ||
|
isAdmin ||
|
||||||
isUserProfile ||
|
isUserProfile ||
|
||||||
|
isRdp ||
|
||||||
|
isVnc ||
|
||||||
tab.type === "network_graph"
|
tab.type === "network_graph"
|
||||||
}
|
}
|
||||||
disableActivate={disableActivate}
|
disableActivate={disableActivate}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { ButtonGroup } from "@/components/ui/button-group";
|
|||||||
import {
|
import {
|
||||||
EllipsisVertical,
|
EllipsisVertical,
|
||||||
Terminal,
|
Terminal,
|
||||||
|
Monitor,
|
||||||
|
Eye,
|
||||||
|
MessagesSquare,
|
||||||
Server,
|
Server,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -18,7 +21,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
|
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
|
||||||
import { getSSHHosts } from "@/ui/main-axios";
|
import { getSSHHosts, getGuacamoleToken, logActivity } from "@/ui/main-axios";
|
||||||
import type { HostProps } from "../../../../types";
|
import type { HostProps } from "../../../../types";
|
||||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -44,10 +47,16 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
setHost(initialHost);
|
setHost(initialHost);
|
||||||
}, [initialHost]);
|
}, [initialHost]);
|
||||||
|
|
||||||
useEffect(() => {
|
const hostIdRef = React.useRef(host.id);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
hostIdRef.current = host.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
const handleHostsChanged = async () => {
|
const handleHostsChanged = async () => {
|
||||||
const hosts = await getSSHHosts();
|
const hosts = await getSSHHosts();
|
||||||
const updatedHost = hosts.find((h) => h.id === host.id);
|
const updatedHost = hosts.find((h) => h.id === hostIdRef.current);
|
||||||
if (updatedHost) {
|
if (updatedHost) {
|
||||||
setHost(updatedHost);
|
setHost(updatedHost);
|
||||||
}
|
}
|
||||||
@@ -56,7 +65,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||||
return () =>
|
return () =>
|
||||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||||
}, [host.id]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleShowTagsChanged = () => {
|
const handleShowTagsChanged = () => {
|
||||||
@@ -70,16 +79,21 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const statsConfig = useMemo(() => {
|
const statsConfig = useMemo(() => {
|
||||||
|
if (!host.statsConfig) {
|
||||||
|
return DEFAULT_STATS_CONFIG;
|
||||||
|
}
|
||||||
|
if (typeof host.statsConfig === "object") {
|
||||||
|
return host.statsConfig;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return host.statsConfig
|
return JSON.parse(host.statsConfig);
|
||||||
? JSON.parse(host.statsConfig)
|
} catch (e) {
|
||||||
: DEFAULT_STATS_CONFIG;
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_STATS_CONFIG;
|
return DEFAULT_STATS_CONFIG;
|
||||||
}
|
}
|
||||||
}, [host.statsConfig]);
|
}, [host.statsConfig]);
|
||||||
|
const shouldShowStatus = ![false, "false"].includes(
|
||||||
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
|
statsConfig.statusCheckEnabled,
|
||||||
|
);
|
||||||
const shouldShowMetrics = statsConfig.metricsEnabled !== false;
|
const shouldShowMetrics = statsConfig.metricsEnabled !== false;
|
||||||
|
|
||||||
const serverStatus = useHostStatus(host.id, shouldShowStatus);
|
const serverStatus = useHostStatus(host.id, shouldShowStatus);
|
||||||
@@ -96,18 +110,67 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [host.tunnelConnections]);
|
}, [host.tunnelConnections]);
|
||||||
|
|
||||||
const handleTerminalClick = () => {
|
const handleTerminalClick = async () => {
|
||||||
|
if (
|
||||||
|
host.connectionType === "rdp" ||
|
||||||
|
host.connectionType === "vnc" ||
|
||||||
|
host.connectionType === "telnet"
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const protocol = host.connectionType as "rdp" | "vnc" | "telnet";
|
||||||
|
const result = await getGuacamoleToken({
|
||||||
|
protocol,
|
||||||
|
hostname: host.ip,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
password: host.password,
|
||||||
|
domain: host.domain,
|
||||||
|
security: host.security,
|
||||||
|
ignoreCert: host.ignoreCert,
|
||||||
|
guacamoleConfig: host.guacamoleConfig as any,
|
||||||
|
});
|
||||||
|
addTab({
|
||||||
|
type: protocol,
|
||||||
|
title,
|
||||||
|
hostConfig: host,
|
||||||
|
connectionConfig: {
|
||||||
|
token: result.token,
|
||||||
|
protocol,
|
||||||
|
type: protocol,
|
||||||
|
hostname: host.ip,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
password: host.password,
|
||||||
|
domain: host.domain,
|
||||||
|
security: host.security,
|
||||||
|
"ignore-cert": host.ignoreCert,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await logActivity(protocol, host.id, title);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Failed to log ${protocol} activity:`, err);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to get Guacamole token:", err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
addTab({ type: "terminal", title, hostConfig: host });
|
addTab({ type: "terminal", title, hostConfig: host });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isSSH = !host.connectionType || host.connectionType === "ssh";
|
||||||
|
|
||||||
const visibleButtons = [
|
const visibleButtons = [
|
||||||
host.enableTerminal && (host.showTerminalInSidebar ?? true),
|
host.enableTerminal && (host.showTerminalInSidebar ?? true),
|
||||||
host.enableFileManager && (host.showFileManagerInSidebar ?? false),
|
isSSH && host.enableFileManager && (host.showFileManagerInSidebar ?? false),
|
||||||
|
isSSH &&
|
||||||
host.enableTunnel &&
|
host.enableTunnel &&
|
||||||
hasTunnelConnections &&
|
hasTunnelConnections &&
|
||||||
(host.showTunnelInSidebar ?? false),
|
(host.showTunnelInSidebar ?? false),
|
||||||
host.enableDocker && (host.showDockerInSidebar ?? false),
|
isSSH && host.enableDocker && (host.showDockerInSidebar ?? false),
|
||||||
shouldShowMetrics && (host.showServerStatsInSidebar ?? false),
|
isSSH && shouldShowMetrics && (host.showServerStatsInSidebar ?? false),
|
||||||
].filter(Boolean).length;
|
].filter(Boolean).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -133,11 +196,20 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
className="!px-2 border-1 border-edge"
|
className="!px-2 border-1 border-edge"
|
||||||
onClick={handleTerminalClick}
|
onClick={handleTerminalClick}
|
||||||
>
|
>
|
||||||
|
{host.connectionType === "rdp" ? (
|
||||||
|
<Monitor />
|
||||||
|
) : host.connectionType === "vnc" ? (
|
||||||
|
<Eye />
|
||||||
|
) : host.connectionType === "telnet" ? (
|
||||||
|
<MessagesSquare />
|
||||||
|
) : (
|
||||||
<Terminal />
|
<Terminal />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{host.enableFileManager &&
|
{isSSH &&
|
||||||
|
host.enableFileManager &&
|
||||||
(host.showFileManagerInSidebar ?? false) && (
|
(host.showFileManagerInSidebar ?? false) && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -150,7 +222,8 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{host.enableTunnel &&
|
{isSSH &&
|
||||||
|
host.enableTunnel &&
|
||||||
hasTunnelConnections &&
|
hasTunnelConnections &&
|
||||||
(host.showTunnelInSidebar ?? false) && (
|
(host.showTunnelInSidebar ?? false) && (
|
||||||
<Button
|
<Button
|
||||||
@@ -164,7 +237,9 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{host.enableDocker && (host.showDockerInSidebar ?? false) && (
|
{isSSH &&
|
||||||
|
host.enableDocker &&
|
||||||
|
(host.showDockerInSidebar ?? false) && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="!px-2 border-1 border-edge"
|
className="!px-2 border-1 border-edge"
|
||||||
@@ -176,7 +251,9 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{shouldShowMetrics && (host.showServerStatsInSidebar ?? false) && (
|
{isSSH &&
|
||||||
|
shouldShowMetrics &&
|
||||||
|
(host.showServerStatsInSidebar ?? false) && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="!px-2 border-1 border-edge"
|
className="!px-2 border-1 border-edge"
|
||||||
@@ -211,11 +288,20 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
onClick={handleTerminalClick}
|
onClick={handleTerminalClick}
|
||||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||||
>
|
>
|
||||||
|
{host.connectionType === "rdp" ? (
|
||||||
|
<Monitor className="h-4 w-4" />
|
||||||
|
) : host.connectionType === "vnc" ? (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
) : host.connectionType === "telnet" ? (
|
||||||
|
<MessagesSquare className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
<Terminal className="h-4 w-4" />
|
<Terminal className="h-4 w-4" />
|
||||||
|
)}
|
||||||
<span className="flex-1">{t("hosts.openTerminal")}</span>
|
<span className="flex-1">{t("hosts.openTerminal")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{shouldShowMetrics &&
|
{isSSH &&
|
||||||
|
shouldShowMetrics &&
|
||||||
!(host.showServerStatsInSidebar ?? false) && (
|
!(host.showServerStatsInSidebar ?? false) && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -227,7 +313,8 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
<span className="flex-1">{t("hosts.openServerStats")}</span>
|
<span className="flex-1">{t("hosts.openServerStats")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{host.enableFileManager &&
|
{isSSH &&
|
||||||
|
host.enableFileManager &&
|
||||||
!(host.showFileManagerInSidebar ?? false) && (
|
!(host.showFileManagerInSidebar ?? false) && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -239,7 +326,8 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
<span className="flex-1">{t("hosts.openFileManager")}</span>
|
<span className="flex-1">{t("hosts.openFileManager")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{host.enableTunnel &&
|
{isSSH &&
|
||||||
|
host.enableTunnel &&
|
||||||
hasTunnelConnections &&
|
hasTunnelConnections &&
|
||||||
!(host.showTunnelInSidebar ?? false) && (
|
!(host.showTunnelInSidebar ?? false) && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -252,7 +340,9 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
<span className="flex-1">{t("hosts.openTunnels")}</span>
|
<span className="flex-1">{t("hosts.openTunnels")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{host.enableDocker && !(host.showDockerInSidebar ?? false) && (
|
{isSSH &&
|
||||||
|
host.enableDocker &&
|
||||||
|
!(host.showDockerInSidebar ?? false) && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
addTab({ type: "docker", title, hostConfig: host })
|
addTab({ type: "docker", title, hostConfig: host })
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
Server as ServerIcon,
|
Server as ServerIcon,
|
||||||
Folder as FolderIcon,
|
Folder as FolderIcon,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
|
Monitor as MonitorIcon,
|
||||||
|
Eye as EyeIcon,
|
||||||
|
MessagesSquare as MessageSquareIcon,
|
||||||
Network,
|
Network,
|
||||||
ArrowDownUp as TunnelIcon,
|
ArrowDownUp as TunnelIcon,
|
||||||
Container as DockerIcon,
|
Container as DockerIcon,
|
||||||
@@ -172,6 +175,9 @@ export function Tab({
|
|||||||
tabType === "terminal" ||
|
tabType === "terminal" ||
|
||||||
tabType === "server_stats" ||
|
tabType === "server_stats" ||
|
||||||
tabType === "file_manager" ||
|
tabType === "file_manager" ||
|
||||||
|
tabType === "rdp" ||
|
||||||
|
tabType === "vnc" ||
|
||||||
|
tabType === "telnet" ||
|
||||||
tabType === "tunnel" ||
|
tabType === "tunnel" ||
|
||||||
tabType === "docker" ||
|
tabType === "docker" ||
|
||||||
tabType === "user_profile"
|
tabType === "user_profile"
|
||||||
@@ -181,7 +187,6 @@ export function Tab({
|
|||||||
const isTunnel = tabType === "tunnel";
|
const isTunnel = tabType === "tunnel";
|
||||||
const isDocker = tabType === "docker";
|
const isDocker = tabType === "docker";
|
||||||
const isUserProfile = tabType === "user_profile";
|
const isUserProfile = tabType === "user_profile";
|
||||||
|
|
||||||
const displayTitle =
|
const displayTitle =
|
||||||
title ||
|
title ||
|
||||||
(isServer
|
(isServer
|
||||||
@@ -194,6 +199,8 @@ export function Tab({
|
|||||||
? t("nav.docker")
|
? t("nav.docker")
|
||||||
: isUserProfile
|
: isUserProfile
|
||||||
? t("nav.userProfile")
|
? t("nav.userProfile")
|
||||||
|
: tabType === "rdp" || tabType === "vnc" || tabType === "telnet"
|
||||||
|
? tabType.toUpperCase()
|
||||||
: t("nav.terminal"));
|
: t("nav.terminal"));
|
||||||
|
|
||||||
const { base, suffix } = splitTitle(displayTitle);
|
const { base, suffix } = splitTitle(displayTitle);
|
||||||
@@ -219,6 +226,12 @@ export function Tab({
|
|||||||
<DockerIcon className="h-4 w-4 flex-shrink-0" />
|
<DockerIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
) : isUserProfile ? (
|
) : isUserProfile ? (
|
||||||
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
|
) : tabType === "rdp" ? (
|
||||||
|
<MonitorIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
|
) : tabType === "vnc" ? (
|
||||||
|
<EyeIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
|
) : tabType === "telnet" ? (
|
||||||
|
<MessageSquareIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<TerminalIcon className="h-4 w-4 flex-shrink-0" />
|
<TerminalIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -51,7 +53,6 @@ interface TabProviderProps {
|
|||||||
export function clearTermixSessionStorage() {
|
export function clearTermixSessionStorage() {
|
||||||
localStorage.removeItem("termix_tabs");
|
localStorage.removeItem("termix_tabs");
|
||||||
localStorage.removeItem("termix_currentTab");
|
localStorage.removeItem("termix_currentTab");
|
||||||
// Clear all session keys
|
|
||||||
const keysToRemove: string[] = [];
|
const keysToRemove: string[] = [];
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
const key = localStorage.key(i);
|
const key = localStorage.key(i);
|
||||||
@@ -65,7 +66,6 @@ export function clearTermixSessionStorage() {
|
|||||||
export function TabProvider({ children }: TabProviderProps) {
|
export function TabProvider({ children }: TabProviderProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [tabs, setTabs] = useState<Tab[]>(() => {
|
const [tabs, setTabs] = useState<Tab[]>(() => {
|
||||||
// Check if persistence is enabled (always enabled for mobile/Electron)
|
|
||||||
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
|
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
|
||||||
const isElectron =
|
const isElectron =
|
||||||
typeof window !== "undefined" && !!(window as any).electronAPI;
|
typeof window !== "undefined" && !!(window as any).electronAPI;
|
||||||
@@ -85,10 +85,9 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
let maxId = 1;
|
let maxId = 1;
|
||||||
for (const tab of parsed) {
|
for (const tab of parsed) {
|
||||||
if (tab.type === "home") continue;
|
if (tab.type === "home") continue;
|
||||||
// Preserve instanceId from saved tab - critical for session lookup
|
|
||||||
const restoredTab: Tab = {
|
const restoredTab: Tab = {
|
||||||
...tab,
|
...tab,
|
||||||
instanceId: tab.instanceId, // Preserve instanceId for session persistence
|
instanceId: tab.instanceId,
|
||||||
terminalRef:
|
terminalRef:
|
||||||
tab.type === "terminal"
|
tab.type === "terminal"
|
||||||
? React.createRef<{ disconnect?: () => void }>()
|
? React.createRef<{ disconnect?: () => void }>()
|
||||||
@@ -115,7 +114,6 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
const saved = localStorage.getItem("termix_currentTab");
|
const saved = localStorage.getItem("termix_currentTab");
|
||||||
if (saved) {
|
if (saved) {
|
||||||
const parsed = parseInt(saved, 10);
|
const parsed = parseInt(saved, 10);
|
||||||
// Validate against restored tabs
|
|
||||||
if (parsed && tabs.some((t) => t.id === parsed)) return parsed;
|
if (parsed && tabs.some((t) => t.id === parsed)) return parsed;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -133,9 +131,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
});
|
});
|
||||||
const nextTabId = useRef(initialMaxId);
|
const nextTabId = useRef(initialMaxId);
|
||||||
|
|
||||||
// Save tabs to localStorage on change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if persistence is enabled (always enabled for mobile/Electron)
|
|
||||||
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
|
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
|
||||||
const isElectron =
|
const isElectron =
|
||||||
typeof window !== "undefined" && !!(window as any).electronAPI;
|
typeof window !== "undefined" && !!(window as any).electronAPI;
|
||||||
@@ -144,14 +140,12 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
const shouldSave = isMobile || isElectron || persistenceEnabled;
|
const shouldSave = isMobile || isElectron || persistenceEnabled;
|
||||||
|
|
||||||
if (shouldSave) {
|
if (shouldSave) {
|
||||||
// Serialize tabs, preserving instanceId for session persistence
|
|
||||||
const serializable = tabs
|
const serializable = tabs
|
||||||
.filter((t) => t.type !== "home")
|
.filter((t) => t.type !== "home")
|
||||||
.map(({ terminalRef, ...rest }) => rest);
|
.map(({ terminalRef, ...rest }) => rest);
|
||||||
localStorage.setItem("termix_tabs", JSON.stringify(serializable));
|
localStorage.setItem("termix_tabs", JSON.stringify(serializable));
|
||||||
localStorage.setItem("termix_currentTab", String(currentTab));
|
localStorage.setItem("termix_currentTab", String(currentTab));
|
||||||
} else {
|
} else {
|
||||||
// Clear saved tabs when persistence is disabled
|
|
||||||
localStorage.removeItem("termix_tabs");
|
localStorage.removeItem("termix_tabs");
|
||||||
localStorage.removeItem("termix_currentTab");
|
localStorage.removeItem("termix_currentTab");
|
||||||
}
|
}
|
||||||
@@ -346,7 +340,8 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateHostConfig = (
|
const updateHostConfig = useCallback(
|
||||||
|
(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
newHostConfig: {
|
newHostConfig: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -364,7 +359,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
...tab,
|
...tab,
|
||||||
hostConfig: {
|
hostConfig: {
|
||||||
...newHostConfig,
|
...newHostConfig,
|
||||||
instanceId: tab.hostConfig.instanceId, // Preserve instanceId for session persistence
|
instanceId: tab.hostConfig.instanceId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -373,7 +368,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
...tab,
|
...tab,
|
||||||
hostConfig: {
|
hostConfig: {
|
||||||
...newHostConfig,
|
...newHostConfig,
|
||||||
instanceId: tab.hostConfig.instanceId, // Preserve instanceId for session persistence
|
instanceId: tab.hostConfig.instanceId,
|
||||||
},
|
},
|
||||||
title: newHostConfig.name?.trim()
|
title: newHostConfig.name?.trim()
|
||||||
? newHostConfig.name
|
? newHostConfig.name
|
||||||
@@ -387,9 +382,12 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
return tab;
|
return tab;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
const updateTab = (tabId: number, updates: Partial<Omit<Tab, "id">>) => {
|
const updateTab = useCallback(
|
||||||
|
(tabId: number, updates: Partial<Omit<Tab, "id">>) => {
|
||||||
setTabs((prev) =>
|
setTabs((prev) =>
|
||||||
prev.map((tab) =>
|
prev.map((tab) =>
|
||||||
tab.id === tabId
|
tab.id === tabId
|
||||||
@@ -397,9 +395,12 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
: tab,
|
: tab,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const value: TabContextType = {
|
const value: TabContextType = useMemo(
|
||||||
|
() => ({
|
||||||
tabs,
|
tabs,
|
||||||
currentTab,
|
currentTab,
|
||||||
allSplitScreenTab,
|
allSplitScreenTab,
|
||||||
@@ -411,7 +412,20 @@ export function TabProvider({ children }: TabProviderProps) {
|
|||||||
reorderTabs,
|
reorderTabs,
|
||||||
updateHostConfig,
|
updateHostConfig,
|
||||||
updateTab,
|
updateTab,
|
||||||
};
|
}),
|
||||||
|
[
|
||||||
|
tabs,
|
||||||
|
currentTab,
|
||||||
|
allSplitScreenTab,
|
||||||
|
addTab,
|
||||||
|
removeTab,
|
||||||
|
setSplitScreenTab,
|
||||||
|
getTab,
|
||||||
|
reorderTabs,
|
||||||
|
updateHostConfig,
|
||||||
|
updateTab,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return <TabContext.Provider value={value}>{children}</TabContext.Provider>;
|
return <TabContext.Provider value={value}>{children}</TabContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|||||||
+280
-32
@@ -731,8 +731,9 @@ function getApiUrl(path: string, defaultPort: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initializeApiInstances() {
|
function initializeApiInstances() {
|
||||||
// SSH Host Management API (port 30001)
|
// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
|
||||||
sshHostApi = createApiInstance(getApiUrl("/ssh", 30001), "SSH_HOST");
|
hostApi = createApiInstance(getApiUrl("/host", 30001), "HOST");
|
||||||
|
sshHostApi = hostApi;
|
||||||
|
|
||||||
// Tunnel Management API (port 30003)
|
// Tunnel Management API (port 30003)
|
||||||
tunnelApi = createApiInstance(getApiUrl("/ssh", 30003), "TUNNEL");
|
tunnelApi = createApiInstance(getApiUrl("/ssh", 30003), "TUNNEL");
|
||||||
@@ -759,7 +760,9 @@ function initializeApiInstances() {
|
|||||||
dockerApi = createApiInstance(getApiUrl("/docker", 30007), "DOCKER");
|
dockerApi = createApiInstance(getApiUrl("/docker", 30007), "DOCKER");
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSH Host Management API (port 30001)
|
// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
|
||||||
|
export let hostApi: AxiosInstance;
|
||||||
|
// Backward compatibility
|
||||||
export let sshHostApi: AxiosInstance;
|
export let sshHostApi: AxiosInstance;
|
||||||
|
|
||||||
// Tunnel Management API (port 30003)
|
// Tunnel Management API (port 30003)
|
||||||
@@ -1020,6 +1023,7 @@ export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
|
|||||||
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||||
try {
|
try {
|
||||||
const submitData = {
|
const submitData = {
|
||||||
|
connectionType: hostData.connectionType || "ssh",
|
||||||
name: hostData.name || "",
|
name: hostData.name || "",
|
||||||
ip: hostData.ip,
|
ip: hostData.ip,
|
||||||
port: parseInt(hostData.port.toString()) || 22,
|
port: parseInt(hostData.port.toString()) || 22,
|
||||||
@@ -1028,7 +1032,12 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
|||||||
tags: hostData.tags || [],
|
tags: hostData.tags || [],
|
||||||
pin: Boolean(hostData.pin),
|
pin: Boolean(hostData.pin),
|
||||||
authType: hostData.authType,
|
authType: hostData.authType,
|
||||||
password: hostData.authType === "password" ? hostData.password : null,
|
password:
|
||||||
|
hostData.connectionType !== "ssh"
|
||||||
|
? hostData.password || null
|
||||||
|
: hostData.authType === "password"
|
||||||
|
? hostData.password
|
||||||
|
: null,
|
||||||
key: hostData.authType === "key" ? hostData.key : null,
|
key: hostData.authType === "key" ? hostData.key : null,
|
||||||
keyPassword: hostData.authType === "key" ? hostData.keyPassword : null,
|
keyPassword: hostData.authType === "key" ? hostData.keyPassword : null,
|
||||||
keyType: hostData.authType === "key" ? hostData.keyType : null,
|
keyType: hostData.authType === "key" ? hostData.keyType : null,
|
||||||
@@ -1050,8 +1059,13 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
|||||||
quickActions: hostData.quickActions || [],
|
quickActions: hostData.quickActions || [],
|
||||||
sudoPassword: hostData.sudoPassword || null,
|
sudoPassword: hostData.sudoPassword || null,
|
||||||
statsConfig: hostData.statsConfig || null,
|
statsConfig: hostData.statsConfig || null,
|
||||||
|
dockerConfig: hostData.dockerConfig || null,
|
||||||
terminalConfig: hostData.terminalConfig || null,
|
terminalConfig: hostData.terminalConfig || null,
|
||||||
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
|
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
|
||||||
|
domain: hostData.domain || null,
|
||||||
|
security: hostData.security || null,
|
||||||
|
ignoreCert: Boolean(hostData.ignoreCert),
|
||||||
|
guacamoleConfig: hostData.guacamoleConfig || null,
|
||||||
notes: hostData.notes || "",
|
notes: hostData.notes || "",
|
||||||
useSocks5: Boolean(hostData.useSocks5),
|
useSocks5: Boolean(hostData.useSocks5),
|
||||||
socks5Host: hostData.socks5Host || null,
|
socks5Host: hostData.socks5Host || null,
|
||||||
@@ -1096,6 +1110,7 @@ export async function updateSSHHost(
|
|||||||
): Promise<SSHHost> {
|
): Promise<SSHHost> {
|
||||||
try {
|
try {
|
||||||
const submitData = {
|
const submitData = {
|
||||||
|
connectionType: hostData.connectionType || "ssh",
|
||||||
name: hostData.name || "",
|
name: hostData.name || "",
|
||||||
ip: hostData.ip,
|
ip: hostData.ip,
|
||||||
port: parseInt(hostData.port.toString()) || 22,
|
port: parseInt(hostData.port.toString()) || 22,
|
||||||
@@ -1104,7 +1119,12 @@ export async function updateSSHHost(
|
|||||||
tags: hostData.tags || [],
|
tags: hostData.tags || [],
|
||||||
pin: Boolean(hostData.pin),
|
pin: Boolean(hostData.pin),
|
||||||
authType: hostData.authType,
|
authType: hostData.authType,
|
||||||
password: hostData.authType === "password" ? hostData.password : null,
|
password:
|
||||||
|
hostData.connectionType !== "ssh"
|
||||||
|
? hostData.password || null
|
||||||
|
: hostData.authType === "password"
|
||||||
|
? hostData.password
|
||||||
|
: null,
|
||||||
key: hostData.authType === "key" ? hostData.key : null,
|
key: hostData.authType === "key" ? hostData.key : null,
|
||||||
keyPassword: hostData.authType === "key" ? hostData.keyPassword : null,
|
keyPassword: hostData.authType === "key" ? hostData.keyPassword : null,
|
||||||
keyType: hostData.authType === "key" ? hostData.keyType : null,
|
keyType: hostData.authType === "key" ? hostData.keyType : null,
|
||||||
@@ -1126,8 +1146,13 @@ export async function updateSSHHost(
|
|||||||
quickActions: hostData.quickActions || [],
|
quickActions: hostData.quickActions || [],
|
||||||
sudoPassword: hostData.sudoPassword || null,
|
sudoPassword: hostData.sudoPassword || null,
|
||||||
statsConfig: hostData.statsConfig || null,
|
statsConfig: hostData.statsConfig || null,
|
||||||
|
dockerConfig: hostData.dockerConfig || null,
|
||||||
terminalConfig: hostData.terminalConfig || null,
|
terminalConfig: hostData.terminalConfig || null,
|
||||||
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
|
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
|
||||||
|
domain: hostData.domain || null,
|
||||||
|
security: hostData.security || null,
|
||||||
|
ignoreCert: Boolean(hostData.ignoreCert),
|
||||||
|
guacamoleConfig: hostData.guacamoleConfig || null,
|
||||||
notes: hostData.notes || "",
|
notes: hostData.notes || "",
|
||||||
useSocks5: Boolean(hostData.useSocks5),
|
useSocks5: Boolean(hostData.useSocks5),
|
||||||
socks5Host: hostData.socks5Host || null,
|
socks5Host: hostData.socks5Host || null,
|
||||||
@@ -1511,7 +1536,6 @@ export async function connectSSH(
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Preserve connection logs from error response
|
|
||||||
if (error?.response?.data?.connectionLogs) {
|
if (error?.response?.data?.connectionLogs) {
|
||||||
const errorWithLogs = new Error(
|
const errorWithLogs = new Error(
|
||||||
error?.response?.data?.error ||
|
error?.response?.data?.error ||
|
||||||
@@ -1520,7 +1544,6 @@ export async function connectSSH(
|
|||||||
);
|
);
|
||||||
(errorWithLogs as any).connectionLogs =
|
(errorWithLogs as any).connectionLogs =
|
||||||
error.response.data.connectionLogs;
|
error.response.data.connectionLogs;
|
||||||
// Also preserve other fields like requires_totp
|
|
||||||
if (error.response.data.requires_totp) {
|
if (error.response.data.requires_totp) {
|
||||||
(errorWithLogs as any).requires_totp = true;
|
(errorWithLogs as any).requires_totp = true;
|
||||||
(errorWithLogs as any).sessionId = error.response.data.sessionId;
|
(errorWithLogs as any).sessionId = error.response.data.sessionId;
|
||||||
@@ -1653,7 +1676,7 @@ export async function quickConnect(
|
|||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
): Promise<SSHHost> {
|
): Promise<SSHHost> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post("/ssh/quick-connect", data);
|
const response = await authApi.post("/host/quick-connect", data);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "quick connect");
|
throw handleApiError(error, "quick connect");
|
||||||
@@ -2135,7 +2158,7 @@ export async function getRecentFiles(
|
|||||||
hostId: number,
|
hostId: number,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get("/ssh/file_manager/recent", {
|
const response = await authApi.get("/host/file_manager/recent", {
|
||||||
params: { hostId },
|
params: { hostId },
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -2151,7 +2174,7 @@ export async function addRecentFile(
|
|||||||
name?: string,
|
name?: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post("/ssh/file_manager/recent", {
|
const response = await authApi.post("/host/file_manager/recent", {
|
||||||
hostId,
|
hostId,
|
||||||
path,
|
path,
|
||||||
name,
|
name,
|
||||||
@@ -2168,7 +2191,7 @@ export async function removeRecentFile(
|
|||||||
path: string,
|
path: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.delete("/ssh/file_manager/recent", {
|
const response = await authApi.delete("/host/file_manager/recent", {
|
||||||
data: { hostId, path },
|
data: { hostId, path },
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -2182,7 +2205,7 @@ export async function getPinnedFiles(
|
|||||||
hostId: number,
|
hostId: number,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get("/ssh/file_manager/pinned", {
|
const response = await authApi.get("/host/file_manager/pinned", {
|
||||||
params: { hostId },
|
params: { hostId },
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -2198,7 +2221,7 @@ export async function addPinnedFile(
|
|||||||
name?: string,
|
name?: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post("/ssh/file_manager/pinned", {
|
const response = await authApi.post("/host/file_manager/pinned", {
|
||||||
hostId,
|
hostId,
|
||||||
path,
|
path,
|
||||||
name,
|
name,
|
||||||
@@ -2215,7 +2238,7 @@ export async function removePinnedFile(
|
|||||||
path: string,
|
path: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.delete("/ssh/file_manager/pinned", {
|
const response = await authApi.delete("/host/file_manager/pinned", {
|
||||||
data: { hostId, path },
|
data: { hostId, path },
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -2229,7 +2252,7 @@ export async function getFolderShortcuts(
|
|||||||
hostId: number,
|
hostId: number,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get("/ssh/file_manager/shortcuts", {
|
const response = await authApi.get("/host/file_manager/shortcuts", {
|
||||||
params: { hostId },
|
params: { hostId },
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -2245,7 +2268,7 @@ export async function addFolderShortcut(
|
|||||||
name?: string,
|
name?: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post("/ssh/file_manager/shortcuts", {
|
const response = await authApi.post("/host/file_manager/shortcuts", {
|
||||||
hostId,
|
hostId,
|
||||||
path,
|
path,
|
||||||
name,
|
name,
|
||||||
@@ -2262,7 +2285,7 @@ export async function removeFolderShortcut(
|
|||||||
path: string,
|
path: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.delete("/ssh/file_manager/shortcuts", {
|
const response = await authApi.delete("/host/file_manager/shortcuts", {
|
||||||
data: { hostId, path },
|
data: { hostId, path },
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -2319,7 +2342,6 @@ export async function startMetricsPolling(hostId: number): Promise<{
|
|||||||
const response = await statsApi.post(`/metrics/start/${hostId}`);
|
const response = await statsApi.post(`/metrics/start/${hostId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Preserve connection logs from error response
|
|
||||||
if (error?.response?.data?.connectionLogs) {
|
if (error?.response?.data?.connectionLogs) {
|
||||||
const errorWithLogs = new Error(
|
const errorWithLogs = new Error(
|
||||||
error?.response?.data?.error || error.message,
|
error?.response?.data?.error || error.message,
|
||||||
@@ -2449,6 +2471,33 @@ export async function updateGlobalMonitoringSettings(settings: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GUACAMOLE SETTINGS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getGuacamoleSettings(): Promise<{
|
||||||
|
enabled: boolean;
|
||||||
|
url: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get("/users/guacamole-settings");
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "fetch guacamole settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateGuacamoleSettings(settings: {
|
||||||
|
enabled?: boolean;
|
||||||
|
url?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
await authApi.patch("/users/guacamole-settings", settings);
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, "update guacamole settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// AUTHENTICATION
|
// AUTHENTICATION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2708,9 +2757,13 @@ export async function changePassword(oldPassword: string, newPassword: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOIDCAuthorizeUrl(): Promise<OIDCAuthorize> {
|
export async function getOIDCAuthorizeUrl(
|
||||||
|
rememberMe = false,
|
||||||
|
): Promise<OIDCAuthorize> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get("/users/oidc/authorize");
|
const response = await authApi.get("/users/oidc/authorize", {
|
||||||
|
params: { rememberMe },
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "get OIDC authorize URL");
|
handleApiError(error, "get OIDC authorize URL");
|
||||||
@@ -3201,7 +3254,7 @@ export async function migrateHostToCredential(
|
|||||||
|
|
||||||
export async function getFoldersWithStats(): Promise<Record<string, unknown>> {
|
export async function getFoldersWithStats(): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get("/ssh/db/folders/with-stats");
|
const response = await authApi.get("/host/db/folders/with-stats");
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, "fetch folders with statistics");
|
handleApiError(error, "fetch folders with statistics");
|
||||||
@@ -3213,7 +3266,7 @@ export async function renameFolder(
|
|||||||
newName: string,
|
newName: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.put("/ssh/folders/rename", {
|
const response = await authApi.put("/host/folders/rename", {
|
||||||
oldName,
|
oldName,
|
||||||
newName,
|
newName,
|
||||||
});
|
});
|
||||||
@@ -3229,7 +3282,7 @@ export async function getSSHFolders(): Promise<SSHFolder[]> {
|
|||||||
operation: "fetch_ssh_folders",
|
operation: "fetch_ssh_folders",
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await authApi.get("/ssh/folders");
|
const response = await authApi.get("/host/folders");
|
||||||
|
|
||||||
sshLogger.success("SSH folders fetched successfully", {
|
sshLogger.success("SSH folders fetched successfully", {
|
||||||
operation: "fetch_ssh_folders",
|
operation: "fetch_ssh_folders",
|
||||||
@@ -3259,7 +3312,7 @@ export async function updateFolderMetadata(
|
|||||||
icon,
|
icon,
|
||||||
});
|
});
|
||||||
|
|
||||||
await authApi.put("/ssh/folders/metadata", {
|
await authApi.put("/host/folders/metadata", {
|
||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
icon,
|
icon,
|
||||||
@@ -3289,7 +3342,7 @@ export async function deleteAllHostsInFolder(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const response = await authApi.delete(
|
const response = await authApi.delete(
|
||||||
`/ssh/folders/${encodeURIComponent(folderName)}/hosts`,
|
`/host/folders/${encodeURIComponent(folderName)}/hosts`,
|
||||||
);
|
);
|
||||||
|
|
||||||
sshLogger.success("All hosts in folder deleted successfully", {
|
sshLogger.success("All hosts in folder deleted successfully", {
|
||||||
@@ -3596,7 +3649,15 @@ export interface UptimeInfo {
|
|||||||
export interface RecentActivityItem {
|
export interface RecentActivityItem {
|
||||||
id: number;
|
id: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
type: "terminal" | "file_manager" | "server_stats" | "tunnel" | "docker";
|
type:
|
||||||
|
| "terminal"
|
||||||
|
| "file_manager"
|
||||||
|
| "server_stats"
|
||||||
|
| "tunnel"
|
||||||
|
| "docker"
|
||||||
|
| "telnet"
|
||||||
|
| "vnc"
|
||||||
|
| "rdp";
|
||||||
hostId: number;
|
hostId: number;
|
||||||
hostName: string;
|
hostName: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -3625,7 +3686,15 @@ export async function getRecentActivity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function logActivity(
|
export async function logActivity(
|
||||||
type: "terminal" | "file_manager" | "server_stats" | "tunnel" | "docker",
|
type:
|
||||||
|
| "terminal"
|
||||||
|
| "file_manager"
|
||||||
|
| "server_stats"
|
||||||
|
| "tunnel"
|
||||||
|
| "docker"
|
||||||
|
| "rdp"
|
||||||
|
| "vnc"
|
||||||
|
| "telnet",
|
||||||
hostId: number,
|
hostId: number,
|
||||||
hostName: string,
|
hostName: string,
|
||||||
): Promise<{ message: string; id: number | string }> {
|
): Promise<{ message: string; id: number | string }> {
|
||||||
@@ -3743,11 +3812,193 @@ export async function unlinkOIDCFromPasswordAccount(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GuacamoleTokenRequest {
|
||||||
|
protocol: "rdp" | "vnc" | "telnet";
|
||||||
|
hostname: string;
|
||||||
|
port?: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
domain?: string;
|
||||||
|
security?: string;
|
||||||
|
ignoreCert?: boolean;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuacamoleTokenResponse {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toGuacamoleParams(
|
||||||
|
config: GuacamoleTokenRequest["guacamoleConfig"],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!config) return {};
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
const mappings: Record<string, string> = {
|
||||||
|
colorDepth: "color-depth",
|
||||||
|
resizeMethod: "resize-method",
|
||||||
|
forceLossless: "force-lossless",
|
||||||
|
disableAudio: "disable-audio",
|
||||||
|
enableAudioInput: "enable-audio-input",
|
||||||
|
enableWallpaper: "enable-wallpaper",
|
||||||
|
enableTheming: "enable-theming",
|
||||||
|
enableFontSmoothing: "enable-font-smoothing",
|
||||||
|
enableFullWindowDrag: "enable-full-window-drag",
|
||||||
|
enableDesktopComposition: "enable-desktop-composition",
|
||||||
|
enableMenuAnimations: "enable-menu-animations",
|
||||||
|
disableBitmapCaching: "disable-bitmap-caching",
|
||||||
|
disableOffscreenCaching: "disable-offscreen-caching",
|
||||||
|
disableGlyphCaching: "disable-glyph-caching",
|
||||||
|
disableGfx: "disable-gfx",
|
||||||
|
enablePrinting: "enable-printing",
|
||||||
|
printerName: "printer-name",
|
||||||
|
enableDrive: "enable-drive",
|
||||||
|
driveName: "drive-name",
|
||||||
|
drivePath: "drive-path",
|
||||||
|
createDrivePath: "create-drive-path",
|
||||||
|
disableDownload: "disable-download",
|
||||||
|
disableUpload: "disable-upload",
|
||||||
|
enableTouch: "enable-touch",
|
||||||
|
clientName: "client-name",
|
||||||
|
initialProgram: "initial-program",
|
||||||
|
serverLayout: "server-layout",
|
||||||
|
gatewayHostname: "gateway-hostname",
|
||||||
|
gatewayPort: "gateway-port",
|
||||||
|
gatewayUsername: "gateway-username",
|
||||||
|
gatewayPassword: "gateway-password",
|
||||||
|
gatewayDomain: "gateway-domain",
|
||||||
|
remoteApp: "remote-app",
|
||||||
|
remoteAppDir: "remote-app-dir",
|
||||||
|
remoteAppArgs: "remote-app-args",
|
||||||
|
normalizeClipboard: "normalize-clipboard",
|
||||||
|
disableCopy: "disable-copy",
|
||||||
|
disablePaste: "disable-paste",
|
||||||
|
swapRedBlue: "swap-red-blue",
|
||||||
|
readOnly: "read-only",
|
||||||
|
recordingPath: "recording-path",
|
||||||
|
recordingName: "recording-name",
|
||||||
|
createRecordingPath: "create-recording-path",
|
||||||
|
recordingExcludeOutput: "recording-exclude-output",
|
||||||
|
recordingExcludeMouse: "recording-exclude-mouse",
|
||||||
|
recordingIncludeKeys: "recording-include-keys",
|
||||||
|
wolSendPacket: "wol-send-packet",
|
||||||
|
wolMacAddr: "wol-mac-addr",
|
||||||
|
wolBroadcastAddr: "wol-broadcast-addr",
|
||||||
|
wolUdpPort: "wol-udp-port",
|
||||||
|
wolWaitTime: "wol-wait-time",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
const paramName = mappings[key] || key;
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
params[paramName] = value ? "true" : "false";
|
||||||
|
} else {
|
||||||
|
params[paramName] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGuacamoleToken(
|
||||||
|
request: GuacamoleTokenRequest,
|
||||||
|
): Promise<GuacamoleTokenResponse> {
|
||||||
|
try {
|
||||||
|
const guacParams = toGuacamoleParams(request.guacamoleConfig);
|
||||||
|
|
||||||
|
const response = await authApi.post("/guacamole/token", {
|
||||||
|
type: request.protocol,
|
||||||
|
hostname: request.hostname,
|
||||||
|
port: request.port,
|
||||||
|
username: request.username,
|
||||||
|
password: request.password,
|
||||||
|
domain: request.domain,
|
||||||
|
security: request.security,
|
||||||
|
"ignore-cert": request.ignoreCert,
|
||||||
|
...guacParams,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "get guacamole token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGuacamoleTokenFromHost(
|
||||||
|
hostId: number,
|
||||||
|
): Promise<GuacamoleTokenResponse> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.post(`/guacamole/connect-host/${hostId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw handleApiError(error, "get guacamole token from host");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// RBAC MANAGEMENT
|
// RBAC MANAGEMENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// Role Management
|
|
||||||
export async function getRoles(): Promise<{ roles: Role[] }> {
|
export async function getRoles(): Promise<{ roles: Role[] }> {
|
||||||
try {
|
try {
|
||||||
const response = await rbacApi.get("/rbac/roles");
|
const response = await rbacApi.get("/rbac/roles");
|
||||||
@@ -3796,7 +4047,6 @@ export async function deleteRole(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// User-Role Management
|
|
||||||
export async function getUserRoles(
|
export async function getUserRoles(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<{ roles: UserRole[] }> {
|
): Promise<{ roles: UserRole[] }> {
|
||||||
@@ -3836,14 +4086,13 @@ export async function removeRoleFromUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Host Sharing Management
|
|
||||||
export async function shareHost(
|
export async function shareHost(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
shareData: {
|
shareData: {
|
||||||
targetType: "user" | "role";
|
targetType: "user" | "role";
|
||||||
targetUserId?: string;
|
targetUserId?: string;
|
||||||
targetRoleId?: number;
|
targetRoleId?: number;
|
||||||
permissionLevel: "view"; // Only view permission is supported
|
permissionLevel: "view";
|
||||||
durationHours?: number;
|
durationHours?: number;
|
||||||
},
|
},
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
@@ -3932,7 +4181,6 @@ export async function connectDockerSession(
|
|||||||
if (error.response?.data?.requires_warpgate) {
|
if (error.response?.data?.requires_warpgate) {
|
||||||
return error.response.data;
|
return error.response.data;
|
||||||
}
|
}
|
||||||
// Preserve connection logs from error response
|
|
||||||
if (error?.response?.data?.connectionLogs) {
|
if (error?.response?.data?.connectionLogs) {
|
||||||
const errorWithLogs = new Error(
|
const errorWithLogs = new Error(
|
||||||
error?.response?.data?.error ||
|
error?.response?.data?.error ||
|
||||||
|
|||||||
@@ -549,7 +549,7 @@ export function Auth({
|
|||||||
setError(null);
|
setError(null);
|
||||||
setOidcLoading(true);
|
setOidcLoading(true);
|
||||||
try {
|
try {
|
||||||
const authResponse = await getOIDCAuthorizeUrl();
|
const authResponse = await getOIDCAuthorizeUrl(rememberMe);
|
||||||
const { auth_url: authUrl } = authResponse;
|
const { auth_url: authUrl } = authResponse;
|
||||||
|
|
||||||
if (!authUrl || authUrl === "undefined") {
|
if (!authUrl || authUrl === "undefined") {
|
||||||
@@ -908,6 +908,18 @@ export function Auth({
|
|||||||
<div className="text-center text-muted-foreground mb-4">
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
<p>{t("auth.loginWithExternalDesc")}</p>
|
<p>{t("auth.loginWithExternalDesc")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="rememberMeOIDC"
|
||||||
|
checked={rememberMe}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setRememberMe(checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="rememberMeOIDC">
|
||||||
|
{t("auth.rememberMe")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full h-11 mt-2 text-base font-semibold"
|
className="w-full h-11 mt-2 text-base font-semibold"
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface SSHHost {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
pin: boolean;
|
pin: boolean;
|
||||||
authType: string;
|
authType: string;
|
||||||
|
connectionType: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: string;
|
key?: string;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
@@ -131,9 +132,10 @@ export function LeftSidebar({
|
|||||||
}, [search]);
|
}, [search]);
|
||||||
|
|
||||||
const filteredHosts = useMemo(() => {
|
const filteredHosts = useMemo(() => {
|
||||||
if (!debouncedSearch.trim()) return hosts;
|
const sshOnlyHosts = hosts.filter((h) => h.connectionType === "ssh");
|
||||||
|
if (!debouncedSearch.trim()) return sshOnlyHosts;
|
||||||
const q = debouncedSearch.trim().toLowerCase();
|
const q = debouncedSearch.trim().toLowerCase();
|
||||||
return hosts.filter((h) => {
|
return sshOnlyHosts.filter((h) => {
|
||||||
const searchableText = [
|
const searchableText = [
|
||||||
h.name || "",
|
h.name || "",
|
||||||
h.username,
|
h.username,
|
||||||
|
|||||||
Reference in New Issue
Block a user