mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
v2.1.0 (#711)
* feat: enhance terminal theme preview and persistence (#637) - Refactor Terminal.tsx to optimize theme update logic and eliminate flashes - Implement localStorage persistence for terminal themes per host - Fix hover preview to redraw buffer content instantly - Ensure theme preferences survive cookie clears Co-authored-by: Gemini CLI <gemini@cli.local> * fix: update darwin platform identifier to osx (#626) * feat: implement SSH algorithms mapping and refactor cipher usage across SSH modules (#627) * feat: enhance WebSocket connection handling for embedded mode (#628) * fix(auth): pass JWT token via URL param for Electron/mobile OIDC callback (#630) The OIDC callback redirect did not include the JWT token as a URL parameter for desktop/mobile device types, causing Electron and React Native webviews to have jwt = undefined after login. Closes Termix-SSH/Support#562 * fix: remove hardcoded version number from dashboard (#632) The version text was initialized to "v1.8.0" which displayed incorrect version on the dashboard before the API response. Changed to empty string so it shows nothing until the real version is fetched. Closes Termix-SSH/Support#550 * fix: admin role toggle showing incorrect state after update (#633) After successfully toggling admin status, onSuccess() closes the dialog and clears the user reference, but onOpenChange(true) then reopens the dialog with null user, causing isAdmin state to not sync properly. Removed the redundant dialog reopen after success - let onSuccess handle the cleanup normally. Closes Termix-SSH/Support#549 * fix: allow disabling password login when OIDC is configured via env vars (#634) The admin OIDC config endpoint only checked the database for OIDC configuration, ignoring environment variables. This caused the frontend to incorrectly block disabling password login when OIDC was configured via OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, etc. Now falls back to getOIDCConfigFromEnv() when no database config exists, matching the behavior of the public /oidc-config endpoint. Closes Termix-SSH/Support#561 * fix: sync snippet selected terminals count when tabs are closed (#635) selectedSnippetTabIds was not cleaned up when terminal tabs were closed, causing the snippet dialog to show stale terminal count. Added useEffect to filter out IDs of closed tabs. Closes Termix-SSH/Support#534 * feature: toggle history globally (#636) * Fix RDP audio output and dynamic session resize (#625) Co-authored-by: AllX <contact@alexmaftei.com> * fix: check connection state before fallback exec in file manager (#644) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: check connection state before fallback exec in file manager When SFTP operations fail and tryFallbackMethod is called, the SSH client may already be disconnected. Calling client.exec() on a disconnected client throws an unhandled exception that crashes the backend process. Added connection state check at the start of all three tryFallbackMethod closures (listFiles, writeFile, uploadFile). Closes Termix-SSH/Support#451 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: restrict postMessage targetOrigin to prevent JWT leakage (#645) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: restrict postMessage targetOrigin to prevent JWT leakage Multiple postMessage calls used wildcard "*" as targetOrigin, allowing any parent window to intercept JWT tokens if Termix is embedded in an iframe. Changes: - main-axios.ts: Only send postMessage in Electron iframe context (added isElectron() check), use window.location.origin as target - Auth.tsx: Replace "*" with window.location.origin for all three AUTH_SUCCESS postMessage calls (already gated by isInElectronWebView) - ElectronLoginForm.tsx: Use server URL origin for iframe postMessage, fall back to "*" only if origin parsing fails --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: stats monitoring resolves SSH key from credential privateKey field (#643) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: stats monitoring resolves SSH key from credential privateKey field When loading credentials for status monitoring, only the `key` field was checked but not `privateKey`. The ssh_credentials table has both fields and some credentials store the key in `privateKey`. This caused stats polling to fail with auth errors for key-based credentials. Closes Termix-SSH/Support#429 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * feat: add collapse/expand all button for host manager folders (#642) * Update sha256 value for v2.0.0 universal dmg (#629) * feat: add collapse/expand all button for host manager folders All folders were always auto-expanded with no way to collapse them all at once. Added a toggle button in the toolbar that collapses or expands all folder accordions. Icon switches between ChevronsDownUp (collapse) and ChevronsUpDown (expand) to indicate current action. Closes Termix-SSH/Support#488 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: prevent invalid SSH key from crashing stats polling loop (#640) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: prevent invalid SSH key from crashing stats polling loop Two fixes: 1. Add .catch() to pollHostMetrics() call inside setInterval to prevent unhandled promise rejections from crashing the process 2. Add "Invalid SSH key format" to the auth failure error patterns in collectMetrics so it's properly tracked instead of re-thrown Closes Termix-SSH/Support#478 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: revoke all sessions when password is changed or reset (#647) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: revoke all sessions when password is changed or reset logoutUser() without sessionId only cleared in-memory crypto state but did not delete session records from the database. This meant old JWT tokens remained valid after password change/reset. Now deletes all session records for the user when no specific sessionId is provided, which is the code path used by password reset and password change handlers. --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: isolate RDP keyboard input to active tab (#663) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: disable RDP keyboard input when tab is not visible --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * Fix clipboard paste browser popup (#667) * feat: switch to adjacent tab when closing current tab (#661) * Update sha256 value for v2.0.0 universal dmg (#629) * feat: switch to adjacent tab when closing current tab Previously closing the current tab always switched to the first remaining tab (often Dashboard). Now switches to the adjacent tab — the next one in order, or the previous if the closed tab was last. This matches the tab-close behavior of browsers and IDEs. Closes Termix-SSH/Support#606 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: add auth token to database export/import in Electron app (#664) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: add Bearer token to database export/import requests in Electron --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * Fix WebSocket reconnection and add connection lost overlay (#668) * fix: skip metrics collection for hosts with authType none or opkssh (#639) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: skip metrics collection for hosts with authType none or opkssh supportsMetrics() only checked connectionType but ignored authType. Hosts configured with Authentication: None (e.g. Tailscale SSH) or opkssh would trigger SSH metrics polling, causing repeated auth failures since no credentials are available. Closes Termix-SSH/Support#515 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: align cookie maxAge with JWT expiration to prevent early logout (#658) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: align cookie maxAge with JWT expiration to prevent early logout The JWT cookie maxAge for regular (non-rememberMe) logins was set to 2 hours, while the JWT token itself was valid for 24 hours. After 2 hours the cookie expired and the user was logged out, even during active SSH sessions. Changed cookie maxAge from 2h to 24h for regular logins to match the JWT expiration. Affects both password login and OIDC login paths. Closes Termix-SSH/Support#595 Closes Termix-SSH/Support#583 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: remove sensitive data from log output (#649) - Password reset: stop logging the 6-digit reset code in plaintext. The code is still stored in the settings table for retrieval. - Password reset: return identical response for non-existent users and OIDC users to prevent username enumeration. - OPKSSH callback: remove URL, query params, and forwarded headers from log output to prevent token/code leakage. * feat: display file owner and group in file manager list view (#654) * Update sha256 value for v2.0.0 universal dmg (#629) * feat: display file owner and group in file manager list view Added an Owner column to the file manager list view showing owner:group for each file. The data was already returned by the backend (SFTP returns uid/gid, ls fallback returns usernames) but not displayed. Column is hidden on small screens (md:block) to avoid crowding. Closes Termix-SSH/Support#603 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: prevent file manager from showing stale directory contents (#655) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: prevent file manager from showing stale directory contents Two issues caused the file browser to get stuck showing outdated directory contents after folder operations: 1. handleRefreshDirectory could be blocked by a lingering isLoading state from a previous request. Now force-resets loading state before initiating the refresh. 2. debouncedLoadDirectory skipped requests when the path hadn't changed (path === lastPathChangeRef), but after create/move/delete operations the path stays the same while contents change. Added force parameter to bypass the path equality check. Closes Termix-SSH/Support#599 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: fallback to default layout when dashboard preferences lack cards (#652) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: fallback to default layout when dashboard preferences lack cards getDashboardPreferences may return null, empty object, or an object without a cards array (e.g. when behind a reverse proxy that alters the response, or on first load for a new user). This caused layout.cards.filter() to throw, leaving the dashboard as a black screen after login. Now validates that the response has a cards array before using it, falling back to DEFAULT_LAYOUT otherwise. --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: bind SSH sessions to userId and verify ownership on access (#650) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: bind SSH sessions to userId and verify ownership on access SSHSession objects in file-manager and docker did not store userId, allowing any authenticated user to operate on another user's session if they knew the sessionId. Changes: - Added userId field to SSHSession interface in both modules - Store userId when creating sessions (connect, TOTP, Warpgate paths) - Added verifySessionOwnership() helper in file-manager - Applied ownership checks to sudo-password, status, keepalive, listFiles endpoints in file-manager - Applied ownership check to keepalive endpoint in docker - Session creation in docker now stores userId in all 3 paths --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: use cookie-based auth for WebSocket instead of URL token (#646) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: use cookie-based auth for WebSocket instead of URL token JWT tokens in WebSocket URL query strings are exposed in nginx access logs, browser history, and proxy logs. Backend: terminal and docker-console WebSocket servers now read JWT from the cookie header as fallback when no URL token is provided. Frontend: desktop terminal and docker console no longer append token to WebSocket URL, relying on cookies sent automatically by the browser. Mobile and Guacamole WebSocket connections are unchanged as they may not have cookie access. --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: prevent long Docker container names from overflowing card bounds (#653) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: prevent long Docker container names from overflowing card bounds Container names were not constrained to the card width, causing long names to overlay adjacent container cards. Added overflow-hidden and min-w-0 to the Card root element so the existing truncate class on CardTitle takes effect within the grid layout. Closes Termix-SSH/Support#601 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: preserve original timestamps in SSH login statistics (#657) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: preserve original timestamps in SSH login statistics Failed login attempts showed the current time instead of the actual attempt time. Two issues: 1. auth.log dates (e.g. "Mar 15 10:23:45") were parsed with a format that could fail on some platforms, falling back to new Date() which gives the current time. Changed to a more reliable format ("Mar 15, 2026 10:23:45") and fall back to the raw string instead of the current time. 2. Dates from previous years (e.g. December logs viewed in January) were assigned the current year, producing future dates. Now checks if the parsed date is in the future and subtracts a year. Also fixed the same fallback issue for successful login timestamps. Closes Termix-SSH/Support#570 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: remove plaintext credentials from internal host API responses (#651) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: remove plaintext credentials from internal host API responses /db/host/internal and /db/host/internal/all returned password, key, keyPassword, and autostart credentials in plaintext, protected only by a static INTERNAL_AUTH_TOKEN. If the token leaked, all SSH credentials would be exposed. Changes: - Stripped password, key, keyPassword, autostartPassword, autostartKey, autostartKeyPassword from both internal API responses - Only return hostId, userId, and non-sensitive connection metadata - Updated tunnel.ts endpoint resolution to use resolveHostById() for credentials instead of reading from HTTP response - Autostart tunnel initialization no longer receives credentials from internal API, relying on server-side DB resolution at connect time --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: default keyType to auto instead of blocking host update (#641) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: default keyType to auto instead of blocking host update When editing a host with key authentication, missing keyType value caused form validation to fail silently, preventing the Update Host button from saving changes. Now defaults keyType to "auto" instead of raising a validation error. Closes Termix-SSH/Support#510 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: downgrade credential migration errors to warnings (#659) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: downgrade credential migration errors to warnings Credential migration failures during login (e.g. corrupted encrypted data from older versions) were logged at ERROR level, causing alarm in Docker logs. The migration is non-blocking — login succeeds regardless — so these should be warnings. Changed individual credential decryption failures and overall migration failures from error to warn level. Also improved log messages to be more descriptive. Closes Termix-SSH/Support#541 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * feat: add Select All / Deselect All buttons for snippet terminal selection (#660) * Update sha256 value for v2.0.0 universal dmg (#629) * feat: add Select All / Deselect All buttons for snippet terminal selection When running snippets on many terminals, users had to click each terminal individually. Added Select All and Deselect All buttons above the terminal list for batch selection. Closes Termix-SSH/Support#535 --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: admin user list not reading OIDC and admin status correctly (#665) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: align admin user list field names with API response (camelCase) --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: enable clipboard paste from host to RDP session (#666) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: sync host clipboard to RDP session on tab focus and mouse enter --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * fix: validate containerId and timestamp params to prevent command injection (#648) * Update sha256 value for v2.0.0 universal dmg (#629) * fix: validate containerId and timestamp params to prevent command injection Docker API endpoints passed containerId, since, and until parameters directly into shell commands via SSH exec without validation. An authenticated user with Docker access could inject arbitrary shell commands on the remote host. Added Express param middleware to validate containerId against ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ for all 9 endpoints. Also validate since/until timestamps in the logs endpoint against a strict regex. --------- Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> * Merge commit from fork * Merge commit from fork Replace double-quoted shell string interpolation with single-quoted escaping in extractArchive and compressFiles endpoints. Double quotes allow $(command) substitution, enabling arbitrary command execution on the remote SSH host via crafted archive paths or file names. Now uses the same single-quote escaping pattern used by all other file manager operations in this file. * Merge commit from fork CORS: Replace permissive origin checks (any http/https) across all 6 microservices with a shared cors-config module that only allows: - Same-origin requests (derived from Host header) - Configured origins via CORS_ALLOWED_ORIGINS env var - Dev origins (localhost:5173) Docker console: Validate containerId against ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ and restrict shell to allowlist [bash, sh, ash, zsh] to prevent command injection via WebSocket messages. * Merge commit from fork * refactor: add shared host-resolver for server-side credential resolution Creates resolveHostById() utility that loads a host from DB and resolves its credentials entirely server-side. This will be used by connection modules to avoid receiving credentials from the frontend. Also adds checkHostAccess() for permission validation. * fix: strip sensitive credentials from host API responses Remove password, key, keyPassword, sudoPassword, and other credential fields from GET /db/host and GET /db/host/:id responses. Add boolean indicators (hasPassword, hasKey, hasSudoPassword) so the frontend knows capabilities without seeing actual values. Add GET /db/host/:id/password endpoint for the copy-password feature to fetch a specific password on demand. * refactor: docker-console resolves credentials server-side by hostId Instead of receiving the full hostConfig with credentials from the frontend WebSocket message, docker-console now extracts hostId and uses resolveHostById() to load credentials from the database. Also validates containerId format and restricts shell to allowlist. * feat: add getHostPassword API and update copy-password to use it Add getHostPassword() frontend function that calls the new server-side password endpoint instead of reading from the host object. Update Tab component to use boolean indicators (hasPassword, hasKey, hasSudoPassword) from the sanitized API response, with backward compatibility for the old response format. Add boolean indicator fields to Host type definition. * refactor: file-manager resolves credentials server-side via host-resolver When frontend doesn't provide password/sshKey (due to API stripping), file-manager now uses resolveHostById() to load credentials from DB. Falls back to provided credentials for backward compatibility. * refactor: terminal resolves credentials server-side via host-resolver When frontend doesn't provide password/key (due to API stripping), terminal now uses resolveHostById() to load credentials from DB. Preserves backward compatibility with reconnect_with_credentials where user provides credentials interactively. * refactor: tunnel resolves source credentials server-side via host-resolver When frontend doesn't provide sourcePassword/sourceSSHKey (due to API stripping), tunnel now uses resolveHostById() to load credentials from DB for both the connect and cleanup paths. * fix: terminal sudo auto-fill fetches password from server on demand After credentials are stripped from API responses, hostConfig.password is no longer available. Sudo auto-fill now checks boolean indicators to show the prompt, then fetches the actual password via getHostPassword API only when the user confirms the auto-fill action. * fix: host editor fetches full credentials via export API for editing After credentials are stripped from the host list API, the editor would show empty password/key fields. Now uses exportSSHHostWithCredentials() to fetch the full host data with credentials when opening the editor. Applies to all paths: direct edit, sidebar click, and external navigation. --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: update jsdoc comments for /host instead of /ssh * fix: align OIDC login cookie maxAge with JWT expiration (2h → 24h) (#671) * fix: persist OIDC JWT token to localStorage in Electron app (#672) * fix: add error toast for empty file download and remove stray prop in tab bar (#674) * fix: prevent server status failure from blocking host list loading (#673) * fix: show server config dialog on first launch instead of auto-selecting embedded (#675) * fix: remove unnecessary registration disabled toast on login page (#670) * fix: add clipboard fallback and toast feedback for Copy Password button (#669) Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: allow file origin for packaged Electron desktop app (#676) * Add AWS logo to README * fix: allow file origin for packaged Electron desktop app --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: backend compliation errors * feat: remove theme selector from nav bar * fix: validate and fallback credentialId during JSON host bulk import (#677) * Add AWS logo to README * fix: validate and fallback credentialId during JSON host bulk import --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: await tunnel cleanup to prevent new connection from being killed (#678) * Add AWS logo to README * fix: await tunnel cleanup before creating new connection to prevent race condition --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: disable keyboard-interactive when host auth is set to None (#682) * Add AWS logo to README * fix: disable keyboard-interactive auth when host authType is none --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: use carriage return for mobile startup snippet execution (#680) * Add AWS logo to README * fix: use carriage return for mobile startup snippet execution --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: prevent file upload from crashing backend on permission denied (#681) * Add AWS logo to README * fix: add stderr error handlers and connection check to prevent upload crash --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: add missing stream error handlers in Docker console (#684) * Add AWS logo to README * fix: add missing stream error handlers in Docker console to prevent crashes --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: use carriage return for snippet execution to support PowerShell (#679) * Add AWS logo to README * fix: use carriage return instead of line feed for snippet and command execution --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: restrict remaining postMessage targetOrigin from wildcard to origin (#685) * Add AWS logo to README * fix: restrict postMessage targetOrigin to prevent JWT token leakage --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add SESSION_TIMEOUT_HOURS environment variable for customizable session duration (#662) * feat: add SESSION_TIMEOUT_HOURS environment variable for session duration Session timeout was hardcoded to 24h (JWT) and 2h (cookie). Now both are configurable via SESSION_TIMEOUT_HOURS env var (default: 24). Set in docker-compose.yml: environment: SESSION_TIMEOUT_HOURS: "72" Also fixes the cookie maxAge mismatch (was 2h, now matches JWT). Remember Me sessions remain at 30 days regardless of this setting. Closes Termix-SSH/Support#609 Closes Termix-SSH/Support#595 * refactor: move session timeout from env var to Admin Settings Replace SESSION_TIMEOUT_HOURS environment variable with a database-backed setting configurable from Admin Settings UI. Default remains 24 hours. --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add port knocking support for SSH connections (#694) * Add AWS logo to README * feat: add port knocking support for SSH connections Send TCP/UDP knock packets to a configurable port sequence before establishing SSH connections. Configured per-host in the host editor under a new Port Knocking accordion section. Supports custom protocol (TCP/UDP) and delay between knocks. Knocking failures don't block the connection attempt. Closes Termix-SSH/Support#524 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add Wake-on-LAN support for hosts (#696) * Add AWS logo to README * feat: add Wake-on-LAN support for hosts --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add export all hosts as JSON (#688) * Add AWS logo to README * feat: add export all hosts as JSON Add GET /ssh/db/hosts/export endpoint and Export All button in the host manager toolbar. Exported format is compatible with existing bulk import. Includes sensitive data warning confirmation before download. Closes Termix-SSH/Support#582 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add snippet sharing with users and roles (#691) * Add AWS logo to README * feat: add snippet sharing with users and roles Add snippetAccess table and RBAC routes for sharing snippets, following the same pattern as host sharing. Users can share snippets with other users or roles via a share dialog. Shared snippets appear in a dedicated section in the snippets sidebar as read-only with copy support. Closes Termix-SSH/Support#474 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: auth errors and ws connection errors in dev env * feat: add opt-in tmux integration for persistent terminal sessions (#683) * Add backend tmux integration with native scrollback Detect tmux on remote hosts via SSH exec channel, auto-attach or create sessions with mouse mode, history-limit 50000, set-clipboard on, and allow-passthrough on for native scrollback, OSC 52 clipboard sync, and safe paste handling. Use && exit so the shell only closes if tmux started successfully. Query session name after auto-creation. * Add frontend tmux session handling and picker dialog Desktop: handle tmux WebSocket messages, show session picker with window count, attached clients, and last activity when multiple sessions exist. Toast warning when Auto-tmux is enabled but tmux is missing on remote. Mobile: auto-attach to first available session. All user-facing strings are localized via i18n. * Add Auto-tmux toggle in host settings and i18n strings Per-host opt-in toggle following the existing autoMosh pattern. English i18n strings for all tmux-related UI elements. * Show a toast hint on first drag inside a tmux session When the user drags the mouse inside a tmux-wrapped terminal, show a localized toast ("Adjust selection and press Enter to copy") once per tab session. Purely frontend so the hint is i18n-ready and doesn't pollute the tmux status bar. * chore: increment ver * feat: add right-click context menu in terminal to open file manager (#695) * Add AWS logo to README * feat: add right-click context menu in terminal to open file manager --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: LukeGus <lukegustafson06@gmail.com> * Fix/desktop guac connect flow (#687) * fix: use direct guacamole websocket port in embedded electron mode * Fix desktop remote token flow for redacted hosts --------- Co-authored-by: LukeGus <lukegustafson06@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: allow file:// origin in shared cors middleware (#686) Co-authored-by: LukeGus <lukegustafson06@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add command history toggle and sensitive command filtering (#693) * Add AWS logo to README * feat: add command history toggle and sensitive command filtering Add on/off toggle for command history recording in User Profile settings. Commands matching sensitive patterns (passwords, secrets, tokens, API keys) are automatically filtered on both frontend and backend, never stored. Closes Termix-SSH/Support#461 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: LukeGus <lukegustafson06@gmail.com> * OPKSSH proxy, certificate auth, and inline provider selection (#692) * fix: OPKSSH proxy integration for remote deployments Migrate proxy routes from /ssh/ to /host/ prefix. Use session's remote redirect URI for callback path instead of hardcoded /login-callback. Add OAuth callback fallback for external browser redirects with state parameter binding to prevent cross-session mixup. Reject cookie-less callbacks that can't be identified. * fix: implement OPKSSH certificate authentication for ssh2 Extract OPKSSH certificate auth into shared module that works around ssh2's lack of native certificate support: grafts cert blob onto parsed key, wraps ECDSA sign() for DER-to-SSH conversion, and patches Protocol.authPK for correct algorithm. Applied across terminal, file-manager, docker, and server-stats. Removes legacy temp file approach in favor of in-memory keys. * feat: inline OPKSSH provider selection in dialog Parse OIDC provider aliases and issuers from config.yml using js-yaml and send them to the frontend via the WebSocket message. The dialog renders a "Sign in with {Provider}" button per provider, opening the browser directly to the OAuth flow and skipping the external chooser page. Falls back to the existing "Open in Browser" behavior when providers aren't available. --------- Co-authored-by: LukeGus <lukegustafson06@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add configurable log level via Admin Settings (#690) * Add AWS logo to README * feat: add configurable log level via Admin Settings Add log verbosity control (debug/info/warn/error) through Admin Settings UI and LOG_LEVEL environment variable. Database setting takes precedence. Changes take effect immediately without restart. Closes Termix-SSH/Support#499 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: LukeGus <lukegustafson06@gmail.com> * feat: add reconnect button for disconnected SSH sessions (#689) * Add AWS logo to README * feat: add reconnect button for disconnected SSH sessions When an SSH connection drops, show a reconnect overlay instead of closing the tab. Users can click Reconnect to re-establish the connection or Close to dismiss. Also triggers after auto-reconnect attempts are exhausted. Closes Termix-SSH/Support#596 Closes Termix-SSH/Support#542 Closes Termix-SSH/Support#604 --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: LukeGus <lukegustafson06@gmail.com> * feat: fix port knocking and mac address not saving to backend * fix: fix snippets table not being created * fix: command history logic error, snippet sharing failing and improved UI for it * Reset stale trust state when TOTP is enabled (#697) * Add AWS logo to README * Reset stale trust state when TOTP is enabled Enablement now updates the user record, revokes existing sessions, clears trusted devices, and persists the result using the existing route flow. The change stays narrow and avoids introducing a one-off auth-manager wrapper or changing the save helper contract. Constraint: Keep the change close to the existing auth route and avoid extra abstractions Rejected: Keep the dedicated auth-manager helper | it was single-use and widened the surface area Confidence: high Scope-risk: narrow Directive: If this behavior changes again, keep the reset logic at the route boundary unless another caller appears Tested: tsc -p tsconfig.node.json --pretty false, git diff --check Not-tested: full frontend build --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: version disabling in user profile not properly disabling and mac address not saving in host manager * feat: improved reconnect ui for terminals * feat: improve right click copy/paste * fix: some themes not including all the needed colors * chore: remove donate button * fix: schema errors, password logic errors, sswapped line order in host.ts * fix: cors being too strict * fix: passphrase erorr and tmux error * fix: guacd improvements, ui bugs, connection problems, etc * fix: shrink image, opkssh fixes, desktop ui changes * feat: dont require password to export and fixed export failures * feat: opkssh fixes, guacamole ui fixes, update readme for release * fix: tabs closing fast causiung no tab to be active and electron header persistance issue * fix: guacd params getting malformed * fix: desktop app header persistance * fix: desktop app header persistance * feat: desktop app not logging in * feat: improve okpkssh implementation and fix redirect uri bug * fix: opkssh redirect * fix: backend hang (ongoing) * fix: tunnels not being able to be saved * fix: c2s networking stability (activity/log, metrics, status) (#701) - /activity/log: the trim-over-100 path called SimpleDBOps.delete with a userId instead of a where clause and 500'd every call. Use inArray on the actual ids, best-effort (trim failures don't fail the log). - /metrics/register-viewer: now a graceful 200 no-op when the host can't be found, metrics are disabled, or the connection type doesn't support metrics. Any internal error is reported as skipped instead of a 500, and the fire-and-forget startMetricsForHost can no longer leak an unhandled rejection. - /metrics/🆔 treat 404 as "no metrics yet / disabled" rather than an error. Dashboard skips hosts known to be offline before asking for metrics. - /status: retry with 2s/5s/8s timeouts and 3s/5s pauses (23s worst case, fits in the 30s poll cycle) before surfacing a network error; intermediate attempts stay silent. - Replace the blocking "connection lost" overlay with a persistent, non-dismissible toast ("Unstable server connection, recovering…") carrying a Reload action. Users keep full access to the UI; if they try to connect to a host and it fails, that's on them. The toast clears to the usual "Server connection restored" success toast on the next healthy API response. The toast triggers on any of ERR_NETWORK / ECONNREFUSED / ECONNABORTED / ECONNRESET / ETIMEDOUT / ERR_CANCELED, "Request aborted"/timeout messages, or database/drizzle/sqlite errors. * fix(guacamole): honor host RDP DPI in client and tab params (#703) * fix(file-manager): preserve remote file mode after SFTP write (#704) * fix(admin): target admin toggle APIs by user id (#705) * fix(terminal): resolve Electron SSH websocket URL from server config (#706) * fix(snippets): accept snippets or legacy updates in reorder API (#707) * fix(admin): fetch users list when users tab is opened (#708) * fix(docker): improve list layout and overflow for container cards (#709) * fix(guacamole): gate keyboard capture on focus and visibility (#710) * fix: remove snippets test file * chore: run linter * fix: increase macos memory for building * Potential fix for pull request finding 'Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --------- Co-authored-by: Will Moore <will@clevercode.ca> Co-authored-by: Gemini CLI <gemini@cli.local> Co-authored-by: Chakyiu <49145984+Chakyiu@users.noreply.github.com> Co-authored-by: Jozef Rebjak <jozefrebjak@icloud.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Daniel Quinan <68088383+DanielQuinan@users.noreply.github.com> Co-authored-by: allxm4 <77125344+allxm4@users.noreply.github.com> Co-authored-by: AllX <contact@alexmaftei.com> Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com> Co-authored-by: Dylan Ysmal <Xenthys@users.noreply.github.com> Co-authored-by: vvbbnn00 <vvbbnn00@foxmail.com> Co-authored-by: Lbubeer <Lbubeer1@gmail.com> Co-authored-by: Dominik <DL6ER@users.noreply.github.com> Co-authored-by: LukeGus <lukegustafson06@gmail.com> Co-authored-by: TerrifiedBug <35064668+TerrifiedBug@users.noreply.github.com> Co-authored-by: JIHUN <asdfgl98@naver.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
This commit is contained in:
+43
-46
@@ -1,6 +1,6 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { createCorsMiddleware } from "./utils/cors-config.js";
|
||||
import { getDb, DatabaseSaveTrigger } from "./database/db/index.js";
|
||||
import {
|
||||
recentActivity,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
hostAccess,
|
||||
dashboardPreferences,
|
||||
} from "./database/db/schema.js";
|
||||
import { eq, and, desc, sql } from "drizzle-orm";
|
||||
import { eq, and, desc, sql, inArray } from "drizzle-orm";
|
||||
import { dashboardLogger } from "./utils/logger.js";
|
||||
import { SimpleDBOps } from "./utils/simple-db-ops.js";
|
||||
import { AuthManager } from "./utils/auth-manager.js";
|
||||
@@ -22,37 +22,7 @@ const serverStartTime = Date.now();
|
||||
const activityRateLimiter = new Map<string, number>();
|
||||
const RATE_LIMIT_MS = 1000;
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
const allowedOrigins = ["http://localhost:5173", "http://127.0.0.1:5173"];
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("http://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
],
|
||||
}),
|
||||
);
|
||||
app.use(createCorsMiddleware());
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use((_req, res, next) => {
|
||||
@@ -288,21 +258,48 @@ app.post("/activity/log", async (req, res) => {
|
||||
userId,
|
||||
)) as unknown as { id: number };
|
||||
|
||||
const allActivities = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.orderBy(desc(recentActivity.timestamp)),
|
||||
"recent_activity",
|
||||
userId,
|
||||
);
|
||||
// Best-effort trim of old activity entries; failures here should not
|
||||
// cause the primary /activity/log request to 500.
|
||||
try {
|
||||
const allActivities = await SimpleDBOps.select<{
|
||||
id: number;
|
||||
timestamp: string;
|
||||
}>(
|
||||
getDb()
|
||||
.select({
|
||||
id: recentActivity.id,
|
||||
timestamp: recentActivity.timestamp,
|
||||
})
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.orderBy(desc(recentActivity.timestamp)),
|
||||
"recent_activity",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (allActivities.length > 100) {
|
||||
const toDelete = allActivities.slice(100);
|
||||
for (let i = 0; i < toDelete.length; i++) {
|
||||
await SimpleDBOps.delete(recentActivity, "recent_activity", userId);
|
||||
if (allActivities.length > 100) {
|
||||
const idsToDelete = allActivities
|
||||
.slice(100)
|
||||
.map((a) => a.id)
|
||||
.filter((id) => typeof id === "number");
|
||||
|
||||
if (idsToDelete.length > 0) {
|
||||
await SimpleDBOps.delete(
|
||||
recentActivity,
|
||||
"recent_activity",
|
||||
and(
|
||||
eq(recentActivity.userId, userId),
|
||||
inArray(recentActivity.id, idsToDelete),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (trimErr) {
|
||||
dashboardLogger.warn("Failed to trim recent_activity (non-fatal)", {
|
||||
operation: "trim_recent_activity",
|
||||
userId,
|
||||
error: trimErr instanceof Error ? trimErr.message : String(trimErr),
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: "Activity logged", id: result.id });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import express from "express";
|
||||
import http from "http";
|
||||
import bodyParser from "body-parser";
|
||||
import multer from "multer";
|
||||
import cookieParser from "cookie-parser";
|
||||
@@ -11,7 +12,7 @@ import terminalRoutes from "./routes/terminal.js";
|
||||
import guacamoleRoutes from "../guacamole/routes.js";
|
||||
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import cors from "cors";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import fetch from "node-fetch";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
@@ -58,39 +59,7 @@ app.set("trust proxy", true);
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireAdmin = authManager.createAdminMiddleware();
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
const allowedOrigins = ["http://localhost:5173", "http://127.0.0.1:5173"];
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("http://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
"Accept",
|
||||
"Origin",
|
||||
],
|
||||
}),
|
||||
);
|
||||
app.use(createCorsMiddleware());
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
@@ -307,6 +276,10 @@ app.get("/version", authenticateJWT, async (req, res) => {
|
||||
return res.status(404).send("Local Version Not Set");
|
||||
}
|
||||
|
||||
if (req.query.checkRemote === "false") {
|
||||
return res.json({ localVersion, status: "update_check_disabled" });
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = "latest_release";
|
||||
const releaseData = await fetchGitHubAPI<GitHubRelease>(
|
||||
@@ -603,7 +576,6 @@ app.post("/encryption/regenerate-jwt", requireAdmin, async (req, res) => {
|
||||
app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { password } = req.body;
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
|
||||
const user = await getDb().select().from(users).where(eq(users.id, userId));
|
||||
@@ -613,30 +585,20 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
|
||||
const isOidcUser = !!user[0].isOidc;
|
||||
|
||||
if (!isOidcUser) {
|
||||
if (!password) {
|
||||
return res.status(400).json({
|
||||
error: "Password required for export",
|
||||
code: "PASSWORD_REQUIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const unlocked = await authManager.authenticateUser(
|
||||
userId,
|
||||
password,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!unlocked) {
|
||||
return res.status(401).json({ error: "Invalid password" });
|
||||
}
|
||||
} else if (!DataCrypto.getUserDataKey(userId)) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!oidcUnlocked) {
|
||||
if (!DataCrypto.getUserDataKey(userId)) {
|
||||
if (isOidcUser) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!oidcUnlocked) {
|
||||
return res.status(403).json({
|
||||
error: "Failed to unlock user data with SSO credentials",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return res.status(403).json({
|
||||
error: "Failed to unlock user data with SSO credentials",
|
||||
error: "User data is locked. Please log in again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -651,10 +613,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
throw new Error("User data not unlocked");
|
||||
}
|
||||
|
||||
const tempDir =
|
||||
process.env.NODE_ENV === "production"
|
||||
? path.join(process.env.DATA_DIR || "./db/data", ".temp", "exports")
|
||||
: path.join(os.tmpdir(), "termix-exports");
|
||||
const tempDir = path.join(os.tmpdir(), "termix-exports");
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
@@ -710,6 +669,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
connection_type TEXT NOT NULL DEFAULT 'ssh',
|
||||
name TEXT,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
@@ -742,6 +702,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0,
|
||||
default_path TEXT,
|
||||
stats_config TEXT,
|
||||
docker_config TEXT,
|
||||
terminal_config TEXT,
|
||||
quick_actions TEXT,
|
||||
notes TEXT,
|
||||
@@ -751,6 +712,12 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
socks5_username TEXT,
|
||||
socks5_password TEXT,
|
||||
socks5_proxy_chain TEXT,
|
||||
domain TEXT,
|
||||
security TEXT,
|
||||
ignore_cert INTEGER NOT NULL DEFAULT 0,
|
||||
guacamole_config TEXT,
|
||||
mac_address TEXT,
|
||||
port_knock_sequence TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -763,7 +730,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
folder TEXT,
|
||||
tags TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
key TEXT,
|
||||
private_key TEXT,
|
||||
@@ -850,8 +817,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO ssh_data (id, user_id, connection_type, 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, docker_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, security, ignore_cert, guacamole_config, mac_address, port_knock_sequence, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const host of sshHosts) {
|
||||
@@ -864,6 +831,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
insertHost.run(
|
||||
decrypted.id,
|
||||
decrypted.userId,
|
||||
decrypted.connectionType || "ssh",
|
||||
decrypted.name || null,
|
||||
decrypted.ip,
|
||||
decrypted.port,
|
||||
@@ -896,6 +864,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
decrypted.showServerStatsInSidebar ? 1 : 0,
|
||||
decrypted.defaultPath || null,
|
||||
decrypted.statsConfig || null,
|
||||
decrypted.dockerConfig || null,
|
||||
decrypted.terminalConfig || null,
|
||||
decrypted.quickActions || null,
|
||||
decrypted.notes || null,
|
||||
@@ -905,6 +874,12 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
decrypted.socks5Username || null,
|
||||
decrypted.socks5Password || null,
|
||||
decrypted.socks5ProxyChain || null,
|
||||
decrypted.domain || null,
|
||||
decrypted.security || null,
|
||||
decrypted.ignoreCert ? 1 : 0,
|
||||
decrypted.guacamoleConfig || null,
|
||||
decrypted.macAddress || null,
|
||||
decrypted.portKnockSequence || null,
|
||||
decrypted.createdAt,
|
||||
decrypted.updatedAt,
|
||||
);
|
||||
@@ -1146,7 +1121,6 @@ app.post(
|
||||
}
|
||||
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { password } = req.body;
|
||||
const mainDb = getDb();
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
|
||||
@@ -1161,30 +1135,20 @@ app.post(
|
||||
|
||||
const isOidcUser = !!userRecords[0].isOidc;
|
||||
|
||||
if (!isOidcUser) {
|
||||
if (!password) {
|
||||
return res.status(400).json({
|
||||
error: "Password required for import",
|
||||
code: "PASSWORD_REQUIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const unlocked = await authManager.authenticateUser(
|
||||
userId,
|
||||
password,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!unlocked) {
|
||||
return res.status(401).json({ error: "Invalid password" });
|
||||
}
|
||||
} else if (!DataCrypto.getUserDataKey(userId)) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!oidcUnlocked) {
|
||||
if (!DataCrypto.getUserDataKey(userId)) {
|
||||
if (isOidcUser) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (!oidcUnlocked) {
|
||||
return res.status(403).json({
|
||||
error: "Failed to unlock user data with SSO credentials",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return res.status(403).json({
|
||||
error: "Failed to unlock user data with SSO credentials",
|
||||
error: "User data is locked. Please log in again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1198,15 +1162,6 @@ app.post(
|
||||
});
|
||||
|
||||
let userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey && isOidcUser) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(
|
||||
userId,
|
||||
deviceInfo.type,
|
||||
);
|
||||
if (oidcUnlocked) {
|
||||
userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
}
|
||||
}
|
||||
if (!userDataKey) {
|
||||
throw new Error("User data not unlocked");
|
||||
}
|
||||
@@ -1982,7 +1937,24 @@ app.get(
|
||||
},
|
||||
);
|
||||
|
||||
app.listen(HTTP_PORT, async () => {
|
||||
const httpServer = http.createServer(app);
|
||||
|
||||
httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
databaseLogger.error(
|
||||
`Port ${HTTP_PORT} is already in use. Kill the existing process and retry.`,
|
||||
err,
|
||||
{
|
||||
operation: "http_server_port_conflict",
|
||||
port: HTTP_PORT,
|
||||
},
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
httpServer.listen(HTTP_PORT, async () => {
|
||||
const uploadsDir = path.join(process.cwd(), "uploads");
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
|
||||
@@ -775,6 +775,34 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM snippet_access LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS snippet_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snippet_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
role_id INTEGER,
|
||||
granted_by TEXT NOT NULL,
|
||||
permission_level TEXT NOT NULL DEFAULT 'view',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (snippet_id) REFERENCES snippets (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create snippet_access table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite
|
||||
.prepare("SELECT id FROM sessions LIMIT 1")
|
||||
@@ -955,6 +983,8 @@ const migrateSchema = () => {
|
||||
{ column: "host_key_first_seen", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_first_seen TEXT" },
|
||||
{ column: "host_key_last_verified", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_last_verified TEXT" },
|
||||
{ column: "host_key_changed_count", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_changed_count INTEGER NOT NULL DEFAULT 0" },
|
||||
{ column: "mac_address", sql: "ALTER TABLE ssh_data ADD COLUMN mac_address TEXT" },
|
||||
{ column: "port_knock_sequence", sql: "ALTER TABLE ssh_data ADD COLUMN port_knock_sequence TEXT" },
|
||||
];
|
||||
|
||||
for (const migration of sshDataMigrations) {
|
||||
@@ -1255,7 +1285,7 @@ const migrateSchema = () => {
|
||||
});
|
||||
};
|
||||
|
||||
async function saveMemoryDatabaseToFile() {
|
||||
async function saveMemoryDatabaseToFile(): Promise<void> {
|
||||
if (!memoryDatabase) return;
|
||||
|
||||
try {
|
||||
|
||||
@@ -141,6 +141,9 @@ export const hosts = sqliteTable("ssh_data", {
|
||||
socks5Password: text("socks5_password"),
|
||||
socks5ProxyChain: text("socks5_proxy_chain"),
|
||||
|
||||
macAddress: text("mac_address"),
|
||||
portKnockSequence: text("port_knock_sequence"),
|
||||
|
||||
hostKeyFingerprint: text("host_key_fingerprint"),
|
||||
hostKeyType: text("host_key_type"),
|
||||
hostKeyAlgorithm: text("host_key_algorithm").default("sha256"),
|
||||
@@ -295,6 +298,30 @@ export const snippetFolders = sqliteTable("snippet_folders", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const snippetAccess = sqliteTable("snippet_access", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
snippetId: integer("snippet_id")
|
||||
.notNull()
|
||||
.references(() => snippets.id, { onDelete: "cascade" }),
|
||||
|
||||
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
|
||||
roleId: integer("role_id").references(() => roles.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
|
||||
grantedBy: text("granted_by")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
permissionLevel: text("permission_level").notNull().default("view"),
|
||||
|
||||
expiresAt: text("expires_at"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshFolders = sqliteTable("ssh_folders", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
|
||||
+1059
-423
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ import {
|
||||
roles,
|
||||
userRoles,
|
||||
sharedCredentials,
|
||||
snippets,
|
||||
snippetAccess,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm";
|
||||
import type { Response } from "express";
|
||||
@@ -516,46 +518,6 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/roles:
|
||||
* get:
|
||||
* summary: Get all roles
|
||||
* description: Retrieves a list of all roles.
|
||||
* tags:
|
||||
* - RBAC
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of roles.
|
||||
* 500:
|
||||
* description: Failed to get roles.
|
||||
*/
|
||||
router.get(
|
||||
"/roles",
|
||||
authenticateJWT,
|
||||
permissionManager.requireAdmin(),
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const allRoles = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.orderBy(roles.isSystem, roles.name);
|
||||
|
||||
const rolesWithParsedPermissions = allRoles.map((role) => ({
|
||||
...role,
|
||||
permissions: JSON.parse(role.permissions),
|
||||
}));
|
||||
|
||||
res.json({ roles: rolesWithParsedPermissions });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get roles", error, {
|
||||
operation: "get_roles",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get roles" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/roles:
|
||||
@@ -1193,4 +1155,369 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// SNIPPET SHARING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/snippet/{id}/share:
|
||||
* post:
|
||||
* summary: Share a snippet
|
||||
* description: Shares a snippet with a user or role.
|
||||
* tags:
|
||||
* - RBAC
|
||||
*/
|
||||
router.post(
|
||||
"/snippet/:id/share",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const snippetId = parseInt(id, 10);
|
||||
const userId = req.userId!;
|
||||
|
||||
if (isNaN(snippetId)) {
|
||||
return res.status(400).json({ error: "Invalid snippet ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
targetType = "user",
|
||||
targetUserId,
|
||||
targetRoleId,
|
||||
durationHours,
|
||||
} = req.body;
|
||||
|
||||
if (!["user", "role"].includes(targetType)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid target type. Must be 'user' or 'role'" });
|
||||
}
|
||||
|
||||
if (targetType === "user" && !isNonEmptyString(targetUserId)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Target user ID is required when sharing with user" });
|
||||
}
|
||||
if (targetType === "role" && !targetRoleId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Target role ID is required when sharing with role" });
|
||||
}
|
||||
|
||||
const snippet = await db
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (snippet.length === 0) {
|
||||
return res.status(403).json({ error: "Not snippet owner" });
|
||||
}
|
||||
|
||||
if (targetType === "user") {
|
||||
const targetUser = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.id, targetUserId))
|
||||
.limit(1);
|
||||
if (targetUser.length === 0) {
|
||||
return res.status(404).json({ error: "Target user not found" });
|
||||
}
|
||||
} else {
|
||||
const targetRole = await db
|
||||
.select({ id: roles.id })
|
||||
.from(roles)
|
||||
.where(eq(roles.id, targetRoleId))
|
||||
.limit(1);
|
||||
if (targetRole.length === 0) {
|
||||
return res.status(404).json({ error: "Target role not found" });
|
||||
}
|
||||
}
|
||||
|
||||
let expiresAt: string | null = null;
|
||||
if (
|
||||
durationHours &&
|
||||
typeof durationHours === "number" &&
|
||||
durationHours > 0
|
||||
) {
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setHours(expiryDate.getHours() + durationHours);
|
||||
expiresAt = expiryDate.toISOString();
|
||||
}
|
||||
|
||||
const whereConditions = [eq(snippetAccess.snippetId, snippetId)];
|
||||
if (targetType === "user") {
|
||||
whereConditions.push(eq(snippetAccess.userId, targetUserId));
|
||||
} else {
|
||||
whereConditions.push(eq(snippetAccess.roleId, targetRoleId));
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(snippetAccess)
|
||||
.where(and(...whereConditions))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(snippetAccess)
|
||||
.set({ expiresAt })
|
||||
.where(eq(snippetAccess.id, existing[0].id));
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "Snippet access updated",
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
await db.insert(snippetAccess).values({
|
||||
snippetId,
|
||||
userId: targetType === "user" ? targetUserId : null,
|
||||
roleId: targetType === "role" ? targetRoleId : null,
|
||||
grantedBy: userId,
|
||||
permissionLevel: "view",
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
databaseLogger.success("Snippet shared successfully", {
|
||||
operation: "rbac_snippet_share",
|
||||
userId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Snippet shared successfully with ${targetType}`,
|
||||
expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to share snippet", error, {
|
||||
operation: "share_snippet",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to share snippet" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/snippet/{id}/access/{accessId}:
|
||||
* delete:
|
||||
* summary: Revoke snippet access
|
||||
* description: Revokes a user's or role's access to a snippet.
|
||||
* tags:
|
||||
* - RBAC
|
||||
*/
|
||||
router.delete(
|
||||
"/snippet/:id/access/:accessId",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const accessIdParam = Array.isArray(req.params.accessId)
|
||||
? req.params.accessId[0]
|
||||
: req.params.accessId;
|
||||
const snippetId = parseInt(id, 10);
|
||||
const accessId = parseInt(accessIdParam, 10);
|
||||
const userId = req.userId!;
|
||||
|
||||
if (isNaN(snippetId) || isNaN(accessId)) {
|
||||
return res.status(400).json({ error: "Invalid ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const snippet = await db
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (snippet.length === 0) {
|
||||
return res.status(403).json({ error: "Not snippet owner" });
|
||||
}
|
||||
|
||||
await db.delete(snippetAccess).where(eq(snippetAccess.id, accessId));
|
||||
|
||||
res.json({ success: true, message: "Snippet access revoked" });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to revoke snippet access", error, {
|
||||
operation: "revoke_snippet_access",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to revoke access" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/snippet/{id}/access:
|
||||
* get:
|
||||
* summary: Get snippet access list
|
||||
* description: Retrieves the list of users and roles with access to a snippet.
|
||||
* tags:
|
||||
* - RBAC
|
||||
*/
|
||||
router.get(
|
||||
"/snippet/:id/access",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const snippetId = parseInt(id, 10);
|
||||
const userId = req.userId!;
|
||||
|
||||
if (isNaN(snippetId)) {
|
||||
return res.status(400).json({ error: "Invalid snippet ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const snippet = await db
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (snippet.length === 0) {
|
||||
return res.status(403).json({ error: "Not snippet owner" });
|
||||
}
|
||||
|
||||
const rawAccessList = await db
|
||||
.select({
|
||||
id: snippetAccess.id,
|
||||
userId: snippetAccess.userId,
|
||||
roleId: snippetAccess.roleId,
|
||||
username: users.username,
|
||||
roleName: roles.name,
|
||||
roleDisplayName: roles.displayName,
|
||||
grantedBy: snippetAccess.grantedBy,
|
||||
grantedByUsername: sql<string>`(SELECT username FROM users WHERE id = ${snippetAccess.grantedBy})`,
|
||||
permissionLevel: snippetAccess.permissionLevel,
|
||||
expiresAt: snippetAccess.expiresAt,
|
||||
createdAt: snippetAccess.createdAt,
|
||||
})
|
||||
.from(snippetAccess)
|
||||
.leftJoin(users, eq(snippetAccess.userId, users.id))
|
||||
.leftJoin(roles, eq(snippetAccess.roleId, roles.id))
|
||||
.where(eq(snippetAccess.snippetId, snippetId))
|
||||
.orderBy(desc(snippetAccess.createdAt));
|
||||
|
||||
const accessList = rawAccessList.map((access) => ({
|
||||
id: access.id,
|
||||
targetType: access.userId ? "user" : "role",
|
||||
userId: access.userId,
|
||||
roleId: access.roleId,
|
||||
username: access.username,
|
||||
roleName: access.roleName,
|
||||
roleDisplayName: access.roleDisplayName,
|
||||
grantedBy: access.grantedBy,
|
||||
grantedByUsername: access.grantedByUsername,
|
||||
permissionLevel: access.permissionLevel,
|
||||
expiresAt: access.expiresAt,
|
||||
createdAt: access.createdAt,
|
||||
}));
|
||||
|
||||
res.json({ accessList });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get snippet access list", error, {
|
||||
operation: "get_snippet_access_list",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get access list" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /rbac/shared-snippets:
|
||||
* get:
|
||||
* summary: Get shared snippets
|
||||
* description: Retrieves snippets shared with the current user.
|
||||
* tags:
|
||||
* - RBAC
|
||||
*/
|
||||
router.get(
|
||||
"/shared-snippets",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userId = req.userId!;
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const directShared = await db
|
||||
.select({
|
||||
id: snippets.id,
|
||||
name: snippets.name,
|
||||
content: snippets.content,
|
||||
description: snippets.description,
|
||||
folder: snippets.folder,
|
||||
ownerUsername: users.username,
|
||||
permissionLevel: snippetAccess.permissionLevel,
|
||||
expiresAt: snippetAccess.expiresAt,
|
||||
})
|
||||
.from(snippetAccess)
|
||||
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
|
||||
.innerJoin(users, eq(snippets.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(snippetAccess.userId, userId),
|
||||
or(
|
||||
isNull(snippetAccess.expiresAt),
|
||||
gte(snippetAccess.expiresAt, now),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const userRoleRows = await db
|
||||
.select({ roleId: userRoles.roleId })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.userId, userId));
|
||||
const roleIds = userRoleRows.map((r) => r.roleId);
|
||||
|
||||
let roleShared: typeof directShared = [];
|
||||
if (roleIds.length > 0) {
|
||||
const directIds = directShared.map((s) => s.id);
|
||||
const roleResults = await db
|
||||
.select({
|
||||
id: snippets.id,
|
||||
name: snippets.name,
|
||||
content: snippets.content,
|
||||
description: snippets.description,
|
||||
folder: snippets.folder,
|
||||
ownerUsername: users.username,
|
||||
permissionLevel: snippetAccess.permissionLevel,
|
||||
expiresAt: snippetAccess.expiresAt,
|
||||
})
|
||||
.from(snippetAccess)
|
||||
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
|
||||
.innerJoin(users, eq(snippets.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
isNull(snippetAccess.expiresAt),
|
||||
gte(snippetAccess.expiresAt, now),
|
||||
),
|
||||
sql`${snippetAccess.roleId} IN (${sql.join(
|
||||
roleIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
),
|
||||
);
|
||||
|
||||
roleShared = roleResults.filter((s) => !directIds.includes(s.id));
|
||||
}
|
||||
|
||||
res.json({ sharedSnippets: [...directShared, ...roleShared] });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get shared snippets", error, {
|
||||
operation: "get_shared_snippets",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get shared snippets" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface SnippetReorderUpdate {
|
||||
id: number;
|
||||
order: number;
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
type SnippetReorderRequestBody = {
|
||||
snippets?: unknown;
|
||||
updates?: unknown;
|
||||
};
|
||||
|
||||
export function extractSnippetReorderUpdates(
|
||||
body: unknown,
|
||||
): SnippetReorderUpdate[] | null {
|
||||
if (!body || typeof body !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = body as SnippetReorderRequestBody;
|
||||
// Keep accepting the legacy `updates` key so older clients do not break
|
||||
// while the web and desktop helpers converge on `snippets`.
|
||||
const snippetsUpdates = Array.isArray(payload.snippets)
|
||||
? payload.snippets
|
||||
: Array.isArray(payload.updates)
|
||||
? payload.updates
|
||||
: null;
|
||||
|
||||
if (!snippetsUpdates) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return snippetsUpdates as SnippetReorderUpdate[];
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { eq, and, desc, asc, sql } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import { authLogger, databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
||||
import { extractSnippetReorderUpdates } from "./snippets-reorder.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -472,7 +474,8 @@ router.delete(
|
||||
* /snippets/reorder:
|
||||
* put:
|
||||
* summary: Reorder snippets
|
||||
* description: Bulk updates the order and folder of snippets.
|
||||
* description: Bulk updates the order and folder of snippets. Accepts
|
||||
* `snippets` and the legacy `updates` payload key.
|
||||
* tags:
|
||||
* - Snippets
|
||||
* requestBody:
|
||||
@@ -507,14 +510,14 @@ router.put(
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { snippets: snippetUpdates } = req.body;
|
||||
const snippetUpdates = extractSnippetReorderUpdates(req.body);
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
authLogger.warn("Invalid userId for snippet reorder");
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
if (!Array.isArray(snippetUpdates) || snippetUpdates.length === 0) {
|
||||
if (!snippetUpdates || snippetUpdates.length === 0) {
|
||||
authLogger.warn("Invalid snippet reorder data", {
|
||||
operation: "snippet_reorder",
|
||||
userId,
|
||||
@@ -775,18 +778,7 @@ router.post(
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: [
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
cipher: SSH_ALGORITHMS.cipher,
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
|
||||
@@ -62,11 +62,37 @@ router.post(
|
||||
return res.status(400).json({ error: "Missing required parameters" });
|
||||
}
|
||||
|
||||
const sensitivePatterns = [
|
||||
/passw(or)?d/i,
|
||||
/\bsecret\b/i,
|
||||
/\btoken\b/i,
|
||||
/\bapi.?key\b/i,
|
||||
/PASS(WORD)?=/i,
|
||||
/AWS_SECRET/i,
|
||||
/mysql\b.*-p/i,
|
||||
/sudo\s+-S\b/,
|
||||
/htpasswd/i,
|
||||
/sshpass/i,
|
||||
/curl\b.*-u\s/i,
|
||||
/export\b.*(?:PASSWORD|SECRET|TOKEN|KEY)=/i,
|
||||
];
|
||||
|
||||
const trimmedCommand = command.trim();
|
||||
if (sensitivePatterns.some((p: RegExp) => p.test(trimmedCommand))) {
|
||||
return res.status(201).json({
|
||||
id: 0,
|
||||
userId,
|
||||
hostId: parseInt(hostId, 10),
|
||||
command: trimmedCommand,
|
||||
executedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const insertData = {
|
||||
userId,
|
||||
hostId: parseInt(hostId, 10),
|
||||
command: command.trim(),
|
||||
command: trimmedCommand,
|
||||
};
|
||||
|
||||
const result = await db
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { restartGuacServer } from "../../guacamole/guacamole-server.js";
|
||||
import { setGlobalLogLevel, getGlobalLogLevel } from "../../utils/logger.js";
|
||||
import crypto from "crypto";
|
||||
import { db } from "../db/index.js";
|
||||
import {
|
||||
users,
|
||||
sessions,
|
||||
trustedDevices,
|
||||
hosts,
|
||||
sshCredentials,
|
||||
fileManagerRecent,
|
||||
@@ -734,7 +736,8 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
|
||||
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
|
||||
.get();
|
||||
if (!row) {
|
||||
return res.json(null);
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
return res.json(envConfig);
|
||||
}
|
||||
|
||||
let config = JSON.parse((row as Record<string, unknown>).value as string);
|
||||
@@ -1226,7 +1229,7 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
const sessionDurationMs =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 2 * 60 * 60 * 1000;
|
||||
: 24 * 60 * 60 * 1000;
|
||||
await authManager.registerOIDCUser(id, sessionDurationMs);
|
||||
} catch (encryptionError) {
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
@@ -1318,12 +1321,16 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
const redirectUrl = new URL(frontendOrigin);
|
||||
redirectUrl.searchParams.set("success", "true");
|
||||
|
||||
if (deviceInfo.type === "desktop" || deviceInfo.type === "mobile") {
|
||||
redirectUrl.searchParams.set("token", token);
|
||||
}
|
||||
|
||||
const maxAge =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: storedRememberMe
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: 2 * 60 * 60 * 1000;
|
||||
: 24 * 60 * 60 * 1000;
|
||||
|
||||
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
|
||||
|
||||
@@ -1577,7 +1584,13 @@ router.post("/login", async (req, res) => {
|
||||
response.token = token;
|
||||
}
|
||||
|
||||
const maxAge = rememberMe ? 30 * 24 * 60 * 60 * 1000 : 2 * 60 * 60 * 1000;
|
||||
const timeoutRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
|
||||
.get() as { value: string } | undefined;
|
||||
const timeoutHours = timeoutRow ? parseInt(timeoutRow.value, 10) || 24 : 24;
|
||||
const maxAge = rememberMe
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: timeoutHours * 60 * 60 * 1000;
|
||||
|
||||
return res
|
||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
|
||||
@@ -2155,12 +2168,16 @@ router.post("/initiate-reset", async (req, res) => {
|
||||
authLogger.warn(
|
||||
`Password reset attempted for non-existent user: ${username}`,
|
||||
);
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
return res.json({
|
||||
message:
|
||||
"If the user exists, a password reset code has been generated. Check docker logs for the code.",
|
||||
});
|
||||
}
|
||||
|
||||
if (user[0].isOidc) {
|
||||
return res.status(403).json({
|
||||
error: "Password reset not available for external authentication users",
|
||||
return res.json({
|
||||
message:
|
||||
"If the user exists, a password reset code has been generated. Check docker logs for the code.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2175,7 +2192,7 @@ router.post("/initiate-reset", async (req, res) => {
|
||||
);
|
||||
|
||||
authLogger.info(
|
||||
`Password reset code for user ${username}: ${resetCode} (expires at ${expiresAt.toLocaleString()})`,
|
||||
`Password reset code generated for user ${username} (expires at ${expiresAt.toLocaleString()}). Check admin panel or database settings table for code.`,
|
||||
);
|
||||
|
||||
res.json({
|
||||
@@ -2640,13 +2657,7 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
|
||||
* description: Failed to list users.
|
||||
*/
|
||||
router.get("/list", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0 || !user[0].isAdmin) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
const allUsers = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
@@ -2679,13 +2690,17 @@ router.get("/list", authenticateJWT, async (req, res) => {
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* userId:
|
||||
* type: string
|
||||
* description: Preferred unique user identifier.
|
||||
* username:
|
||||
* type: string
|
||||
* description: Legacy fallback identifier.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: User is now an admin.
|
||||
* 400:
|
||||
* description: Username is required or user is already an admin.
|
||||
* description: User ID or username is required, or the user is already an admin.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 404:
|
||||
@@ -2695,10 +2710,14 @@ router.get("/list", authenticateJWT, async (req, res) => {
|
||||
*/
|
||||
router.post("/make-admin", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { username } = req.body;
|
||||
const { userId: targetUserId, username } = req.body;
|
||||
const resolvedUserId = isNonEmptyString(targetUserId)
|
||||
? targetUserId.trim()
|
||||
: null;
|
||||
const resolvedUsername = isNonEmptyString(username) ? username.trim() : null;
|
||||
|
||||
if (!isNonEmptyString(username)) {
|
||||
return res.status(400).json({ error: "Username is required" });
|
||||
if (!resolvedUserId && !resolvedUsername) {
|
||||
return res.status(400).json({ error: "User ID or username is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -2710,7 +2729,12 @@ router.post("/make-admin", authenticateJWT, async (req, res) => {
|
||||
const targetUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username));
|
||||
.where(
|
||||
resolvedUserId
|
||||
? eq(users.id, resolvedUserId)
|
||||
: eq(users.username, resolvedUsername!),
|
||||
)
|
||||
.limit(1);
|
||||
if (!targetUser || targetUser.length === 0) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
@@ -2722,7 +2746,11 @@ router.post("/make-admin", authenticateJWT, async (req, res) => {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ isAdmin: true })
|
||||
.where(eq(users.username, username));
|
||||
.where(
|
||||
resolvedUserId
|
||||
? eq(users.id, resolvedUserId)
|
||||
: eq(users.username, resolvedUsername!),
|
||||
);
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
@@ -2730,7 +2758,8 @@ router.post("/make-admin", authenticateJWT, async (req, res) => {
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist admin promotion to disk", saveError, {
|
||||
operation: "make_admin_save_failed",
|
||||
username,
|
||||
userId: targetUser[0].id,
|
||||
username: targetUser[0].username,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2738,9 +2767,9 @@ router.post("/make-admin", authenticateJWT, async (req, res) => {
|
||||
operation: "admin_grant",
|
||||
adminId: userId,
|
||||
targetUserId: targetUser[0].id,
|
||||
targetUsername: username,
|
||||
targetUsername: targetUser[0].username,
|
||||
});
|
||||
res.json({ message: `User ${username} is now an admin` });
|
||||
res.json({ message: `User ${targetUser[0].username} is now an admin` });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to make user admin", err);
|
||||
res.status(500).json({ error: "Failed to make user admin" });
|
||||
@@ -2762,13 +2791,17 @@ router.post("/make-admin", authenticateJWT, async (req, res) => {
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* userId:
|
||||
* type: string
|
||||
* description: Preferred unique user identifier.
|
||||
* username:
|
||||
* type: string
|
||||
* description: Legacy fallback identifier.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Admin status removed from user.
|
||||
* 400:
|
||||
* description: Username is required or cannot remove your own admin status.
|
||||
* description: User ID or username is required, or cannot remove your own admin status.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 404:
|
||||
@@ -2778,10 +2811,14 @@ router.post("/make-admin", authenticateJWT, async (req, res) => {
|
||||
*/
|
||||
router.post("/remove-admin", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { username } = req.body;
|
||||
const { userId: targetUserId, username } = req.body;
|
||||
const resolvedUserId = isNonEmptyString(targetUserId)
|
||||
? targetUserId.trim()
|
||||
: null;
|
||||
const resolvedUsername = isNonEmptyString(username) ? username.trim() : null;
|
||||
|
||||
if (!isNonEmptyString(username)) {
|
||||
return res.status(400).json({ error: "Username is required" });
|
||||
if (!resolvedUserId && !resolvedUsername) {
|
||||
return res.status(400).json({ error: "User ID or username is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -2790,7 +2827,10 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
if (adminUser[0].username === username) {
|
||||
if (
|
||||
(resolvedUserId && adminUser[0].id === resolvedUserId) ||
|
||||
(resolvedUsername && adminUser[0].username === resolvedUsername)
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Cannot remove your own admin status" });
|
||||
@@ -2799,7 +2839,12 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => {
|
||||
const targetUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username));
|
||||
.where(
|
||||
resolvedUserId
|
||||
? eq(users.id, resolvedUserId)
|
||||
: eq(users.username, resolvedUsername!),
|
||||
)
|
||||
.limit(1);
|
||||
if (!targetUser || targetUser.length === 0) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
@@ -2811,7 +2856,11 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ isAdmin: false })
|
||||
.where(eq(users.username, username));
|
||||
.where(
|
||||
resolvedUserId
|
||||
? eq(users.id, resolvedUserId)
|
||||
: eq(users.username, resolvedUsername!),
|
||||
);
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
@@ -2819,7 +2868,8 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => {
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist admin removal to disk", saveError, {
|
||||
operation: "remove_admin_save_failed",
|
||||
username,
|
||||
userId: targetUser[0].id,
|
||||
username: targetUser[0].username,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2827,9 +2877,11 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => {
|
||||
operation: "admin_revoke",
|
||||
adminId: userId,
|
||||
targetUserId: targetUser[0].id,
|
||||
targetUsername: username,
|
||||
targetUsername: targetUser[0].username,
|
||||
});
|
||||
res.json({
|
||||
message: `Admin status removed from ${targetUser[0].username}`,
|
||||
});
|
||||
res.json({ message: `Admin status removed from ${username}` });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to remove admin status", err);
|
||||
res.status(500).json({ error: "Failed to remove admin status" });
|
||||
@@ -2966,10 +3018,19 @@ router.post("/totp/enable", authenticateJWT, async (req, res) => {
|
||||
totpBackupCodes: JSON.stringify(backupCodes),
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
authLogger.info("Two-factor authentication enabled", {
|
||||
operation: "totp_enable",
|
||||
userId,
|
||||
});
|
||||
|
||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
||||
await db.delete(trustedDevices).where(eq(trustedDevices.userId, userId));
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist TOTP enablement to disk", saveError, {
|
||||
operation: "totp_enable_db_save_failed",
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: "TOTP enabled successfully",
|
||||
@@ -3359,7 +3420,13 @@ router.post("/totp/verify-login", async (req, res) => {
|
||||
response.token = token;
|
||||
}
|
||||
|
||||
const maxAge = rememberMe ? 30 * 24 * 60 * 60 * 1000 : 2 * 60 * 60 * 1000;
|
||||
const timeoutRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
|
||||
.get() as { value: string } | undefined;
|
||||
const timeoutHours = timeoutRow ? parseInt(timeoutRow.value, 10) || 24 : 24;
|
||||
const maxAge = rememberMe
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
: timeoutHours * 60 * 60 * 1000;
|
||||
|
||||
return res
|
||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
|
||||
@@ -4226,4 +4293,144 @@ router.patch("/guacamole-settings", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/log-level:
|
||||
* get:
|
||||
* summary: Get log level setting
|
||||
* description: Returns the configured log verbosity level.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Current log level.
|
||||
*/
|
||||
router.get("/log-level", async (_req, res) => {
|
||||
try {
|
||||
const row = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'log_level'")
|
||||
.get() as { value: string } | undefined;
|
||||
res.json({
|
||||
level: row ? row.value : getGlobalLogLevel(),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get log level", err);
|
||||
res.status(500).json({ error: "Failed to get log level" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/log-level:
|
||||
* patch:
|
||||
* summary: Update log level setting (admin only)
|
||||
* description: Sets the log verbosity level.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Log level updated.
|
||||
* 400:
|
||||
* description: Invalid log level.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
*/
|
||||
router.patch("/log-level", 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 { level } = req.body;
|
||||
const validLevels = ["debug", "info", "warn", "error"];
|
||||
if (typeof level !== "string" || !validLevels.includes(level)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "level must be one of: debug, info, warn, error" });
|
||||
}
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('log_level', ?)",
|
||||
)
|
||||
.run(level);
|
||||
setGlobalLogLevel(level);
|
||||
res.json({ level });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to set log level", err);
|
||||
res.status(500).json({ error: "Failed to set log level" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/session-timeout:
|
||||
* get:
|
||||
* summary: Get session timeout setting
|
||||
* description: Returns the configured session timeout in hours.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Current session timeout hours.
|
||||
*/
|
||||
router.get("/session-timeout", async (_req, res) => {
|
||||
try {
|
||||
const row = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
|
||||
.get() as { value: string } | undefined;
|
||||
res.json({
|
||||
timeoutHours: row ? parseInt(row.value, 10) : 24,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get session timeout", err);
|
||||
res.status(500).json({ error: "Failed to get session timeout" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/session-timeout:
|
||||
* patch:
|
||||
* summary: Update session timeout setting (admin only)
|
||||
* description: Sets the session timeout in hours.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session timeout updated.
|
||||
* 400:
|
||||
* description: Invalid value.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
*/
|
||||
router.patch("/session-timeout", 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 { timeoutHours } = req.body;
|
||||
if (
|
||||
typeof timeoutHours !== "number" ||
|
||||
timeoutHours < 1 ||
|
||||
timeoutHours > 720
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "timeoutHours must be between 1 and 720" });
|
||||
}
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('session_timeout_hours', ?)",
|
||||
)
|
||||
.run(String(timeoutHours));
|
||||
res.json({ timeoutHours });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to set session timeout", err);
|
||||
res.status(500).json({ error: "Failed to set session timeout" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -58,7 +58,7 @@ const clientOptions = {
|
||||
},
|
||||
allowedUnencryptedConnectionSettings: {
|
||||
rdp: ["width", "height", "dpi"],
|
||||
vnc: ["width", "height", "dpi"],
|
||||
vnc: ["width", "height"],
|
||||
telnet: ["width", "height"],
|
||||
},
|
||||
connectionDefaultSettings: {
|
||||
@@ -74,6 +74,7 @@ const clientOptions = {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
dpi: 96,
|
||||
audio: ["audio/L16"],
|
||||
},
|
||||
vnc: {
|
||||
"swap-red-blue": false,
|
||||
|
||||
@@ -28,7 +28,15 @@ const wss = new WebSocketServer({
|
||||
verifyClient: async (info) => {
|
||||
try {
|
||||
const url = parseUrl(info.req.url || "", true);
|
||||
const token = url.query.token as string;
|
||||
let token = url.query.token as string;
|
||||
|
||||
if (!token) {
|
||||
const cookieHeader = info.req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return false;
|
||||
@@ -75,8 +83,10 @@ async function detectShell(
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", () => {
|
||||
// Ignore stderr
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
stream.on("error", (streamErr) => {
|
||||
reject(streamErr);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -229,7 +239,30 @@ async function createJumpHostChain(
|
||||
}
|
||||
|
||||
wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const userId = (req as unknown as { userId: string }).userId;
|
||||
const url = parseUrl(req.url || "", true);
|
||||
let token = url.query.token as string;
|
||||
|
||||
if (!token) {
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const authManagerInstance = AuthManager.getInstance();
|
||||
const payload = await authManagerInstance.verifyJWTToken(token);
|
||||
if (!payload || !payload.userId) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = payload.userId;
|
||||
const sessionId = `docker-console-${Date.now()}-${Math.random()}`;
|
||||
sshLogger.info("Docker console WebSocket connected", {
|
||||
operation: "docker_console_connect",
|
||||
@@ -260,21 +293,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
rows?: number;
|
||||
};
|
||||
|
||||
if (
|
||||
typeof hostConfig.jumpHosts === "string" &&
|
||||
hostConfig.jumpHosts
|
||||
) {
|
||||
try {
|
||||
hostConfig.jumpHosts = JSON.parse(hostConfig.jumpHosts);
|
||||
} catch (e) {
|
||||
sshLogger.error("Failed to parse jump hosts", e, {
|
||||
hostId: hostConfig.id,
|
||||
});
|
||||
hostConfig.jumpHosts = [];
|
||||
}
|
||||
}
|
||||
const hostId = hostConfig?.id;
|
||||
|
||||
if (!hostConfig || !containerId) {
|
||||
if (!hostId || !containerId) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
@@ -284,62 +305,69 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(containerId)) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Invalid container ID",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedShells = ["bash", "sh", "ash", "zsh"];
|
||||
if (shell && !allowedShells.includes(shell)) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Invalid shell",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hostConfig.enableDocker) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Docker is not enabled for this host. Enable it in Host Settings.",
|
||||
message: "Docker is not enabled on this host",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let resolvedCredentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
authType?: string;
|
||||
} = {
|
||||
password: hostConfig.password,
|
||||
sshKey: hostConfig.key,
|
||||
keyPassword: hostConfig.keyPassword,
|
||||
authType: hostConfig.authType,
|
||||
};
|
||||
// Resolve host with credentials server-side
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
const resolvedHost = await resolveHostById(hostId, userId);
|
||||
|
||||
if (hostConfig.credentialId) {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, hostConfig.credentialId as number),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
if (!resolvedHost) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Host not found",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
password: credential.password as string | undefined,
|
||||
sshKey: credential.privateKey as string | undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
authType: credential.authType as string | undefined,
|
||||
};
|
||||
}
|
||||
if (!resolvedHost.enableDocker) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Docker is not enabled for this host. Enable it in Host Settings.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new SSHClient();
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
host: hostConfig.ip?.replace(/^\[|\]$/g, "") || hostConfig.ip,
|
||||
port: hostConfig.port || 22,
|
||||
username: hostConfig.username,
|
||||
host: resolvedHost.ip?.replace(/^\[|\]$/g, "") || resolvedHost.ip,
|
||||
port: resolvedHost.port || 22,
|
||||
username: resolvedHost.username,
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
@@ -348,28 +376,22 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
};
|
||||
|
||||
if (
|
||||
resolvedCredentials.authType === "password" &&
|
||||
resolvedCredentials.password
|
||||
) {
|
||||
config.password = resolvedCredentials.password;
|
||||
} else if (
|
||||
resolvedCredentials.authType === "key" &&
|
||||
resolvedCredentials.sshKey
|
||||
) {
|
||||
const cleanKey = resolvedCredentials.sshKey
|
||||
if (resolvedHost.authType === "password" && resolvedHost.password) {
|
||||
config.password = resolvedHost.password;
|
||||
} else if (resolvedHost.authType === "key" && resolvedHost.key) {
|
||||
const cleanKey = resolvedHost.key
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
config.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (resolvedCredentials.keyPassword) {
|
||||
config.passphrase = resolvedCredentials.keyPassword;
|
||||
if (resolvedHost.keyPassword) {
|
||||
config.passphrase = resolvedHost.keyPassword;
|
||||
}
|
||||
}
|
||||
|
||||
if (hostConfig.jumpHosts && hostConfig.jumpHosts.length > 0) {
|
||||
if (resolvedHost.jumpHosts && resolvedHost.jumpHosts.length > 0) {
|
||||
const jumpClient = await createJumpHostChain(
|
||||
hostConfig.jumpHosts,
|
||||
resolvedHost.jumpHosts,
|
||||
userId,
|
||||
);
|
||||
if (jumpClient) {
|
||||
@@ -378,8 +400,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
jumpClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
hostConfig.ip,
|
||||
hostConfig.port || 22,
|
||||
resolvedHost.ip,
|
||||
resolvedHost.port || 22,
|
||||
(err, stream) => {
|
||||
if (err) return reject(err);
|
||||
resolve(stream);
|
||||
@@ -402,7 +424,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
stream: null,
|
||||
isConnected: true,
|
||||
containerId,
|
||||
hostId: hostConfig.id,
|
||||
hostId: resolvedHost.id,
|
||||
};
|
||||
|
||||
activeSessions.set(sessionId, sshSession);
|
||||
@@ -430,8 +452,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", () => {
|
||||
// Ignore stderr
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
stream.on("error", (streamErr) => {
|
||||
reject(streamErr);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -459,7 +483,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
operation: "docker_attach",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId: hostConfig.id,
|
||||
hostId: resolvedHost.id,
|
||||
containerId,
|
||||
});
|
||||
|
||||
@@ -494,7 +518,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
operation: "docker_attach_success",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId: hostConfig.id,
|
||||
hostId: resolvedHost.id,
|
||||
containerId,
|
||||
});
|
||||
|
||||
@@ -509,8 +533,23 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", () => {
|
||||
// stderr output ignored
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
sshLogger.error("Docker console stream error", streamErr, {
|
||||
operation: "docker_console_stream_error",
|
||||
sessionId,
|
||||
containerId,
|
||||
});
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: `Console error: ${streamErr.message}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
|
||||
+32
-75
@@ -1,5 +1,5 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import cookieParser from "cookie-parser";
|
||||
import axios from "axios";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
@@ -9,6 +9,7 @@ import { eq, and } from "drizzle-orm";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import {
|
||||
createSocks5Connection,
|
||||
type SOCKS5Config,
|
||||
@@ -40,6 +41,7 @@ interface SSHSession {
|
||||
timeout?: NodeJS.Timeout;
|
||||
activeOperations: number;
|
||||
hostId?: number;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface PendingTOTPSession {
|
||||
@@ -424,39 +426,7 @@ async function executeDockerCommand(
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("http://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
const allowedOrigins = ["http://localhost:5173", "http://127.0.0.1:5173"];
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
return callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
],
|
||||
}),
|
||||
);
|
||||
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
||||
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: "100mb" }));
|
||||
@@ -469,6 +439,16 @@ app.use((_req, res, next) => {
|
||||
const authManager = AuthManager.getInstance();
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
|
||||
const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||
const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/;
|
||||
|
||||
app.param("containerId", (req, res, next, value) => {
|
||||
if (!CONTAINER_ID_RE.test(value)) {
|
||||
return res.status(400).json({ error: "Invalid container ID" });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /docker/ssh/connect:
|
||||
@@ -791,18 +771,13 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { promises: fs } = await import("fs");
|
||||
const path = await import("path");
|
||||
const os = await import("os");
|
||||
|
||||
const tempDir = os.tmpdir();
|
||||
const keyPath = path.join(tempDir, `opkssh-docker-${userId}-${hostId}`);
|
||||
const certPath = `${keyPath}-cert.pub`;
|
||||
|
||||
await fs.writeFile(keyPath, token.privateKey, { mode: 0o600 });
|
||||
await fs.writeFile(certPath, token.sshCert, { mode: 0o600 });
|
||||
|
||||
config.privateKey = await fs.readFile(keyPath);
|
||||
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(
|
||||
config as import("ssh2").ConnectConfig,
|
||||
client,
|
||||
token,
|
||||
host.username,
|
||||
);
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"info",
|
||||
@@ -810,32 +785,6 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
"Using OPKSSH certificate authentication",
|
||||
),
|
||||
);
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const cleanupResults = await Promise.allSettled([
|
||||
fs.unlink(keyPath),
|
||||
fs.unlink(certPath),
|
||||
]);
|
||||
|
||||
cleanupResults.forEach((result, index) => {
|
||||
if (result.status === "rejected") {
|
||||
sshLogger.warn(`Failed to cleanup OPKSSH temp file`, {
|
||||
operation: "opkssh_temp_cleanup_failed",
|
||||
file: index === 0 ? "keyPath" : "certPath",
|
||||
sessionId,
|
||||
error: result.reason,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to cleanup OPKSSH temp files", {
|
||||
operation: "opkssh_temp_cleanup_error",
|
||||
sessionId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}, 60000);
|
||||
} catch (opksshError) {
|
||||
sshLogger.error("OPKSSH authentication error for Docker", {
|
||||
operation: "docker_connect",
|
||||
@@ -983,6 +932,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
hostId,
|
||||
userId,
|
||||
};
|
||||
|
||||
scheduleSessionCleanup(sessionId);
|
||||
@@ -1665,6 +1615,7 @@ app.post("/docker/ssh/connect-totp", async (req, res) => {
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
hostId: session.hostId,
|
||||
userId,
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
@@ -1850,6 +1801,7 @@ app.post("/docker/ssh/connect-warpgate", async (req, res) => {
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
hostId: session.hostId,
|
||||
userId,
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
@@ -1953,6 +1905,7 @@ app.post("/docker/ssh/connect-warpgate", async (req, res) => {
|
||||
*/
|
||||
app.post("/docker/ssh/keepalive", async (req, res) => {
|
||||
const { sessionId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
@@ -1967,6 +1920,10 @@ app.post("/docker/ssh/keepalive", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (session.userId && session.userId !== userId) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
session.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
@@ -3016,18 +2973,18 @@ app.get("/docker/containers/:sessionId/:containerId/logs", async (req, res) => {
|
||||
let command = `docker logs ${containerId}`;
|
||||
|
||||
if (tail && tail > 0) {
|
||||
command += ` --tail ${tail}`;
|
||||
command += ` --tail ${Math.floor(tail)}`;
|
||||
}
|
||||
|
||||
if (timestamps) {
|
||||
command += " --timestamps";
|
||||
}
|
||||
|
||||
if (since) {
|
||||
if (since && DOCKER_TIMESTAMP_RE.test(since)) {
|
||||
command += ` --since ${since}`;
|
||||
}
|
||||
|
||||
if (until) {
|
||||
if (until && DOCKER_TIMESTAMP_RE.test(until)) {
|
||||
command += ` --until ${until}`;
|
||||
}
|
||||
|
||||
|
||||
+345
-310
@@ -1,8 +1,9 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import cookieParser from "cookie-parser";
|
||||
import axios from "axios";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshCredentials, hosts } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
@@ -117,37 +118,7 @@ function formatMtime(mtime: number): string {
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
const allowedOrigins = ["http://localhost:5173", "http://127.0.0.1:5173"];
|
||||
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("http://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
],
|
||||
}),
|
||||
);
|
||||
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: "1gb" }));
|
||||
app.use(express.urlencoded({ limit: "1gb", extended: true }));
|
||||
@@ -399,6 +370,7 @@ interface SSHSession {
|
||||
sudoPassword?: string;
|
||||
sftp?: import("ssh2").SFTPWrapper;
|
||||
poolKey?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface PendingTOTPSession {
|
||||
@@ -531,6 +503,10 @@ function scheduleSessionCleanup(sessionId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function verifySessionOwnership(session: SSHSession, userId: string): boolean {
|
||||
return !session.userId || session.userId === userId;
|
||||
}
|
||||
|
||||
function getMimeType(fileName: string): string {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
@@ -801,151 +777,62 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
),
|
||||
);
|
||||
|
||||
// Resolve credentials server-side when frontend doesn't provide them
|
||||
let resolvedCredentials = { password, sshKey, keyPassword, authType };
|
||||
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",
|
||||
},
|
||||
);
|
||||
if (hostId && userId && !password && !sshKey) {
|
||||
try {
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
const resolvedHost = await resolveHostById(hostId, userId);
|
||||
if (resolvedHost) {
|
||||
resolvedCredentials = {
|
||||
password: resolvedHost.password,
|
||||
sshKey: resolvedHost.key,
|
||||
keyPassword: resolvedHost.keyPassword,
|
||||
authType: resolvedHost.authType,
|
||||
};
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"warning",
|
||||
"info",
|
||||
"sftp_auth",
|
||||
`Failed to resolve shared credentials: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
"Credentials resolved from server-side host data",
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (ownerId) {
|
||||
try {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
ownerId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
password: credential.password,
|
||||
sshKey: credential.privateKey,
|
||||
keyPassword: credential.keyPassword,
|
||||
authType: credential.authType,
|
||||
};
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"info",
|
||||
"sftp_auth",
|
||||
"Credentials resolved from credential store",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
fileLogger.warn(`No credentials found for host ${hostId}`, {
|
||||
operation: "ssh_credentials",
|
||||
hostId,
|
||||
credentialId,
|
||||
userId: ownerId,
|
||||
});
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"warning",
|
||||
"sftp_auth",
|
||||
"No stored credentials found, using provided credentials",
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
fileLogger.warn(`Failed to resolve credentials for host ${hostId}`, {
|
||||
operation: "ssh_credentials",
|
||||
hostId,
|
||||
credentialId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"warning",
|
||||
"sftp_auth",
|
||||
`Failed to resolve credentials: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
fileLogger.warn(
|
||||
"Missing userId for credential resolution in file manager",
|
||||
{
|
||||
operation: "ssh_credentials",
|
||||
hostId,
|
||||
credentialId,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
fileLogger.warn(`Failed to resolve host credentials for ${hostId}`, {
|
||||
operation: "ssh_credentials",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
} else if (credentialId && hostId) {
|
||||
fileLogger.warn(
|
||||
"Missing userId for credential resolution in file manager",
|
||||
{
|
||||
} else if (credentialId && hostId && userId) {
|
||||
// Legacy: credential resolution from credentialId
|
||||
try {
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
const resolvedHost = await resolveHostById(hostId, userId);
|
||||
if (resolvedHost) {
|
||||
resolvedCredentials = {
|
||||
password: resolvedHost.password,
|
||||
sshKey: resolvedHost.key,
|
||||
keyPassword: resolvedHost.keyPassword,
|
||||
authType: resolvedHost.authType,
|
||||
};
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"info",
|
||||
"sftp_auth",
|
||||
"Credentials resolved from credential store",
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
fileLogger.warn(`Failed to resolve credentials for host ${hostId}`, {
|
||||
operation: "ssh_credentials",
|
||||
hostId,
|
||||
credentialId,
|
||||
hasUserId: !!userId,
|
||||
},
|
||||
);
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
@@ -1001,18 +888,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: [
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
cipher: SSH_ALGORITHMS.cipher,
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
@@ -1112,18 +988,13 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { promises: fs } = await import("fs");
|
||||
const path = await import("path");
|
||||
const os = await import("os");
|
||||
|
||||
const tempDir = os.tmpdir();
|
||||
const keyPath = path.join(tempDir, `opkssh-fm-${userId}-${hostId}`);
|
||||
const certPath = `${keyPath}-cert.pub`;
|
||||
|
||||
await fs.writeFile(keyPath, token.privateKey, { mode: 0o600 });
|
||||
await fs.writeFile(certPath, token.sshCert, { mode: 0o600 });
|
||||
|
||||
config.privateKey = await fs.readFile(keyPath);
|
||||
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(
|
||||
config as import("ssh2").ConnectConfig,
|
||||
client,
|
||||
token,
|
||||
username,
|
||||
);
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"info",
|
||||
@@ -1131,32 +1002,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
"Using OPKSSH certificate authentication",
|
||||
),
|
||||
);
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const cleanupResults = await Promise.allSettled([
|
||||
fs.unlink(keyPath),
|
||||
fs.unlink(certPath),
|
||||
]);
|
||||
|
||||
cleanupResults.forEach((result, index) => {
|
||||
if (result.status === "rejected") {
|
||||
fileLogger.warn(`Failed to cleanup OPKSSH temp file`, {
|
||||
operation: "opkssh_temp_cleanup_failed",
|
||||
file: index === 0 ? "keyPath" : "certPath",
|
||||
sessionId,
|
||||
error: result.reason,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
fileLogger.error("Failed to cleanup OPKSSH temp files", {
|
||||
operation: "opkssh_temp_cleanup_error",
|
||||
sessionId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}, 60000);
|
||||
} catch (opksshError) {
|
||||
fileLogger.error("OPKSSH authentication error for file manager", {
|
||||
operation: "file_connect",
|
||||
@@ -1263,6 +1108,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
isConnected: true,
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
userId,
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
res.json({
|
||||
@@ -1906,6 +1752,7 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
isConnected: true,
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
userId,
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
@@ -2107,6 +1954,7 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => {
|
||||
isConnected: true,
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
userId,
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
@@ -2236,10 +2084,14 @@ app.post("/ssh/file_manager/ssh/disconnect", (req, res) => {
|
||||
*/
|
||||
app.post("/ssh/file_manager/sudo-password", (req, res) => {
|
||||
const { sessionId, password } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const session = sshSessions[sessionId];
|
||||
if (!session || !session.isConnected) {
|
||||
return res.status(400).json({ error: "Invalid or disconnected session" });
|
||||
}
|
||||
if (!verifySessionOwnership(session, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
session.sudoPassword = password;
|
||||
session.lastActive = Date.now();
|
||||
res.json({ status: "success", message: "Sudo password set" });
|
||||
@@ -2265,7 +2117,12 @@ app.post("/ssh/file_manager/sudo-password", (req, res) => {
|
||||
*/
|
||||
app.get("/ssh/file_manager/ssh/status", (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const isConnected = !!sshSessions[sessionId]?.isConnected;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const session = sshSessions[sessionId];
|
||||
if (session && !verifySessionOwnership(session, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
const isConnected = !!session?.isConnected;
|
||||
res.json({ status: "success", connected: isConnected });
|
||||
});
|
||||
|
||||
@@ -2294,6 +2151,7 @@ app.get("/ssh/file_manager/ssh/status", (req, res) => {
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
|
||||
const { sessionId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
@@ -2308,6 +2166,10 @@ app.post("/ssh/file_manager/ssh/keepalive", (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(session, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
session.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
@@ -2360,6 +2222,10 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
sshConn.activeOperations++;
|
||||
const trySFTP = () => {
|
||||
@@ -2472,6 +2338,10 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
};
|
||||
|
||||
const tryFallbackMethod = () => {
|
||||
if (!sshConn?.isConnected) {
|
||||
sshConn.activeOperations--;
|
||||
return res.status(500).json({ error: "SSH session disconnected" });
|
||||
}
|
||||
try {
|
||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||
sshConn.client.exec(
|
||||
@@ -3099,7 +2969,7 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
||||
* /ssh/file_manager/ssh/writeFile:
|
||||
* post:
|
||||
* summary: Write to a file
|
||||
* description: Writes content to a file on the remote host.
|
||||
* description: Writes content to a file on the remote host and preserves the existing permissions when the file already exists.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
@@ -3155,6 +3025,112 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
||||
});
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
let preservedMode: number | undefined;
|
||||
|
||||
const restoreOriginalMode = (
|
||||
sftp: import("ssh2").SFTPWrapper | null,
|
||||
onComplete: () => void,
|
||||
) => {
|
||||
if (preservedMode === undefined) {
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
const permissions = preservedMode.toString(8);
|
||||
|
||||
if (sftp) {
|
||||
sftp.chmod(filePath, preservedMode, (chmodErr) => {
|
||||
if (chmodErr) {
|
||||
fileLogger.warn("Failed to restore file permissions after save", {
|
||||
operation: "file_write_restore_permissions",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
permissions,
|
||||
error: chmodErr.message,
|
||||
});
|
||||
} else {
|
||||
fileLogger.info("Restored file permissions after save", {
|
||||
operation: "file_write_restore_permissions",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
permissions,
|
||||
});
|
||||
}
|
||||
|
||||
onComplete();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||
const chmodCommand = `chmod ${permissions} '${escapedPath}' && echo "SUCCESS"`;
|
||||
|
||||
sshConn.client.exec(chmodCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.warn("Failed to restore file permissions after save", {
|
||||
operation: "file_write_restore_permissions",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
permissions,
|
||||
error: err.message,
|
||||
});
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorData = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
fileLogger.info("Restored file permissions after save", {
|
||||
operation: "file_write_restore_permissions",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
permissions,
|
||||
});
|
||||
} else {
|
||||
fileLogger.warn("Failed to restore file permissions after save", {
|
||||
operation: "file_write_restore_permissions",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
permissions,
|
||||
exitCode: code,
|
||||
error:
|
||||
errorData || "Permission restore command did not report success",
|
||||
});
|
||||
}
|
||||
|
||||
onComplete();
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
fileLogger.warn("Failed to restore file permissions after save", {
|
||||
operation: "file_write_restore_permissions",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
permissions,
|
||||
error: streamErr.message,
|
||||
});
|
||||
onComplete();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const trySFTP = () => {
|
||||
try {
|
||||
fileLogger.info("Opening SFTP channel", {
|
||||
@@ -3193,75 +3169,88 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const writeStream = sftp.createWriteStream(filePath);
|
||||
|
||||
let hasError = false;
|
||||
let hasFinished = false;
|
||||
|
||||
writeStream.on("error", (streamErr) => {
|
||||
if (hasError || hasFinished) return;
|
||||
hasError = true;
|
||||
fileLogger.warn(
|
||||
`SFTP write failed, trying fallback method: ${streamErr.message}`,
|
||||
);
|
||||
tryFallbackMethod();
|
||||
});
|
||||
|
||||
writeStream.on("finish", () => {
|
||||
if (hasError || hasFinished) return;
|
||||
hasFinished = true;
|
||||
fileLogger.success("File written successfully", {
|
||||
operation: "file_write_success",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
bytes: fileBuffer.length,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "File written successfully",
|
||||
path: filePath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `File written: ${filePath}`,
|
||||
sftp.stat(filePath, (statErr, stats) => {
|
||||
if (statErr) {
|
||||
fileLogger.warn(
|
||||
"Failed to read existing file permissions before save",
|
||||
{
|
||||
operation: "file_write_stat",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
error: statErr.message,
|
||||
},
|
||||
);
|
||||
} else if (stats.isFile()) {
|
||||
preservedMode = stats.mode & 0o7777;
|
||||
}
|
||||
|
||||
const writeStream = sftp.createWriteStream(filePath);
|
||||
|
||||
let hasError = false;
|
||||
let hasFinished = false;
|
||||
let isFinalizing = false;
|
||||
|
||||
const finalizeSuccess = () => {
|
||||
if (hasError || hasFinished) return;
|
||||
hasFinished = true;
|
||||
isFinalizing = false;
|
||||
fileLogger.success("File written successfully", {
|
||||
operation: "file_write_success",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
bytes: fileBuffer.length,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "File written successfully",
|
||||
path: filePath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `File written: ${filePath}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
writeStream.on("error", (streamErr) => {
|
||||
if (hasError || hasFinished || isFinalizing) return;
|
||||
hasError = true;
|
||||
isFinalizing = false;
|
||||
fileLogger.warn(
|
||||
`SFTP write failed, trying fallback method: ${streamErr.message}`,
|
||||
);
|
||||
tryFallbackMethod();
|
||||
});
|
||||
|
||||
const finishWrite = () => {
|
||||
if (hasError || hasFinished || isFinalizing) return;
|
||||
isFinalizing = true;
|
||||
restoreOriginalMode(sftp, finalizeSuccess);
|
||||
};
|
||||
|
||||
writeStream.on("finish", () => {
|
||||
finishWrite();
|
||||
});
|
||||
|
||||
writeStream.on("close", () => {
|
||||
finishWrite();
|
||||
});
|
||||
|
||||
try {
|
||||
writeStream.write(fileBuffer);
|
||||
writeStream.end();
|
||||
} catch (writeErr) {
|
||||
if (hasError || hasFinished) return;
|
||||
hasError = true;
|
||||
isFinalizing = false;
|
||||
fileLogger.warn(
|
||||
`SFTP write operation failed, trying fallback method: ${writeErr.message}`,
|
||||
);
|
||||
tryFallbackMethod();
|
||||
}
|
||||
});
|
||||
|
||||
writeStream.on("close", () => {
|
||||
if (hasError || hasFinished) return;
|
||||
hasFinished = true;
|
||||
fileLogger.success("File written successfully", {
|
||||
operation: "file_write_success",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
bytes: fileBuffer.length,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "File written successfully",
|
||||
path: filePath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `File written: ${filePath}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
writeStream.write(fileBuffer);
|
||||
writeStream.end();
|
||||
} catch (writeErr) {
|
||||
if (hasError || hasFinished) return;
|
||||
hasError = true;
|
||||
fileLogger.warn(
|
||||
`SFTP write operation failed, trying fallback method: ${writeErr.message}`,
|
||||
);
|
||||
tryFallbackMethod();
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
fileLogger.warn(
|
||||
@@ -3278,6 +3267,10 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
||||
};
|
||||
|
||||
const tryFallbackMethod = () => {
|
||||
if (!sshConn?.isConnected) {
|
||||
sshConn.activeOperations--;
|
||||
return res.status(500).json({ error: "SSH session disconnected" });
|
||||
}
|
||||
try {
|
||||
let contentBuffer: Buffer;
|
||||
if (typeof content === "string") {
|
||||
@@ -3327,16 +3320,18 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "File written successfully",
|
||||
path: filePath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `File written: ${filePath}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
restoreOriginalMode(null, () => {
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "File written successfully",
|
||||
path: filePath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `File written: ${filePath}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
fileLogger.error(
|
||||
`Fallback write failed with code ${code}: ${errorData}`,
|
||||
@@ -3564,6 +3559,10 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
||||
};
|
||||
|
||||
const tryFallbackMethod = () => {
|
||||
if (!sshConn?.isConnected) {
|
||||
sshConn.activeOperations--;
|
||||
return res.status(500).json({ error: "SSH session disconnected" });
|
||||
}
|
||||
try {
|
||||
let contentBuffer: Buffer;
|
||||
if (typeof content === "string") {
|
||||
@@ -3588,6 +3587,20 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
||||
chunks.push(base64Content.slice(i, i + chunkSize));
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
fileLogger.error("SSH connection lost before fallback upload", {
|
||||
operation: "file_upload_fallback",
|
||||
sessionId,
|
||||
path: fullPath,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "SSH connection lost during upload" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (chunks.length === 1) {
|
||||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
@@ -3615,6 +3628,10 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
||||
errorData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("error", (stderrErr) => {
|
||||
fileLogger.error("Fallback upload stderr error:", stderrErr);
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
if (!res.headersSent) {
|
||||
@@ -3685,6 +3702,13 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
||||
errorData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("error", (stderrErr) => {
|
||||
fileLogger.error(
|
||||
"Chunked fallback upload stderr error:",
|
||||
stderrErr,
|
||||
);
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
if (!res.headersSent) {
|
||||
@@ -5228,26 +5252,32 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
|
||||
const targetPath =
|
||||
extractPath || archivePath.substring(0, archivePath.lastIndexOf("/"));
|
||||
|
||||
const escapedArchive = archivePath.replace(/'/g, "'\"'\"'");
|
||||
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
|
||||
const escapedDecompressed = archivePath
|
||||
.replace(/\.gz$/, "")
|
||||
.replace(/'/g, "'\"'\"'");
|
||||
|
||||
if (fileExt.endsWith(".tar.gz") || fileExt.endsWith(".tgz")) {
|
||||
extractCommand = `tar -xzf "${archivePath}" -C "${targetPath}"`;
|
||||
extractCommand = `tar -xzf '${escapedArchive}' -C '${escapedTarget}'`;
|
||||
} else if (fileExt.endsWith(".tar.bz2") || fileExt.endsWith(".tbz2")) {
|
||||
extractCommand = `tar -xjf "${archivePath}" -C "${targetPath}"`;
|
||||
extractCommand = `tar -xjf '${escapedArchive}' -C '${escapedTarget}'`;
|
||||
} else if (fileExt.endsWith(".tar.xz")) {
|
||||
extractCommand = `tar -xJf "${archivePath}" -C "${targetPath}"`;
|
||||
extractCommand = `tar -xJf '${escapedArchive}' -C '${escapedTarget}'`;
|
||||
} else if (fileExt.endsWith(".tar")) {
|
||||
extractCommand = `tar -xf "${archivePath}" -C "${targetPath}"`;
|
||||
extractCommand = `tar -xf '${escapedArchive}' -C '${escapedTarget}'`;
|
||||
} else if (fileExt.endsWith(".zip")) {
|
||||
extractCommand = `unzip -o "${archivePath}" -d "${targetPath}"`;
|
||||
extractCommand = `unzip -o '${escapedArchive}' -d '${escapedTarget}'`;
|
||||
} else if (fileExt.endsWith(".gz") && !fileExt.endsWith(".tar.gz")) {
|
||||
extractCommand = `gunzip -c "${archivePath}" > "${archivePath.replace(/\.gz$/, "")}"`;
|
||||
extractCommand = `gunzip -c '${escapedArchive}' > '${escapedDecompressed}'`;
|
||||
} else if (fileExt.endsWith(".bz2") && !fileExt.endsWith(".tar.bz2")) {
|
||||
extractCommand = `bunzip2 -k "${archivePath}"`;
|
||||
extractCommand = `bunzip2 -k '${escapedArchive}'`;
|
||||
} else if (fileExt.endsWith(".xz") && !fileExt.endsWith(".tar.xz")) {
|
||||
extractCommand = `unxz -k "${archivePath}"`;
|
||||
extractCommand = `unxz -k '${escapedArchive}'`;
|
||||
} else if (fileExt.endsWith(".7z")) {
|
||||
extractCommand = `7z x "${archivePath}" -o"${targetPath}"`;
|
||||
extractCommand = `7z x '${escapedArchive}' -o'${escapedTarget}'`;
|
||||
} else if (fileExt.endsWith(".rar")) {
|
||||
extractCommand = `unrar x "${archivePath}" "${targetPath}/"`;
|
||||
extractCommand = `unrar x '${escapedArchive}' '${escapedTarget}/'`;
|
||||
} else {
|
||||
return res.status(400).json({ error: "Unsupported archive format" });
|
||||
}
|
||||
@@ -5433,10 +5463,12 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
|
||||
const firstPath = paths[0];
|
||||
const workingDir = firstPath.substring(0, firstPath.lastIndexOf("/")) || "/";
|
||||
|
||||
const escapeShell = (s: string) => s.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const fileNames = paths
|
||||
.map((p) => {
|
||||
const name = p.split("/").pop();
|
||||
return `"${name}"`;
|
||||
return `'${escapeShell(name || "")}'`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
@@ -5449,18 +5481,21 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
|
||||
: `${workingDir}/${archiveName}`;
|
||||
}
|
||||
|
||||
const escapedDir = escapeShell(workingDir);
|
||||
const escapedArchive = escapeShell(archivePath);
|
||||
|
||||
if (compressionFormat === "zip") {
|
||||
compressCommand = `cd "${workingDir}" && zip -r "${archivePath}" ${fileNames}`;
|
||||
compressCommand = `cd '${escapedDir}' && zip -r '${escapedArchive}' ${fileNames}`;
|
||||
} else if (compressionFormat === "tar.gz" || compressionFormat === "tgz") {
|
||||
compressCommand = `cd "${workingDir}" && tar -czf "${archivePath}" ${fileNames}`;
|
||||
compressCommand = `cd '${escapedDir}' && tar -czf '${escapedArchive}' ${fileNames}`;
|
||||
} else if (compressionFormat === "tar.bz2" || compressionFormat === "tbz2") {
|
||||
compressCommand = `cd "${workingDir}" && tar -cjf "${archivePath}" ${fileNames}`;
|
||||
compressCommand = `cd '${escapedDir}' && tar -cjf '${escapedArchive}' ${fileNames}`;
|
||||
} else if (compressionFormat === "tar.xz") {
|
||||
compressCommand = `cd "${workingDir}" && tar -cJf "${archivePath}" ${fileNames}`;
|
||||
compressCommand = `cd '${escapedDir}' && tar -cJf '${escapedArchive}' ${fileNames}`;
|
||||
} else if (compressionFormat === "tar") {
|
||||
compressCommand = `cd "${workingDir}" && tar -cf "${archivePath}" ${fileNames}`;
|
||||
compressCommand = `cd '${escapedDir}' && tar -cf '${escapedArchive}' ${fileNames}`;
|
||||
} else if (compressionFormat === "7z") {
|
||||
compressCommand = `cd "${workingDir}" && 7z a "${archivePath}" ${fileNames}`;
|
||||
compressCommand = `cd '${escapedDir}' && 7z a '${escapedArchive}' ${fileNames}`;
|
||||
} else {
|
||||
return res.status(400).json({ error: "Unsupported compression format" });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import type { SSHHost } from "../../types/index.js";
|
||||
|
||||
const sshLogger = logger;
|
||||
|
||||
/**
|
||||
* Resolve a host with its credentials server-side by hostId.
|
||||
* This avoids passing credentials through the frontend.
|
||||
*/
|
||||
export async function resolveHostById(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<SSHHost | null> {
|
||||
const db = getDb();
|
||||
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
db.select().from(hosts).where(eq(hosts.id, hostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hostResults.length === 0) return null;
|
||||
|
||||
const host = hostResults[0] as Record<string, unknown>;
|
||||
|
||||
// Parse JSON fields
|
||||
if (typeof host.jumpHosts === "string" && host.jumpHosts) {
|
||||
try {
|
||||
host.jumpHosts = JSON.parse(host.jumpHosts as string);
|
||||
} catch {
|
||||
host.jumpHosts = [];
|
||||
}
|
||||
}
|
||||
if (typeof host.tunnelConnections === "string") {
|
||||
try {
|
||||
host.tunnelConnections = JSON.parse(host.tunnelConnections as string);
|
||||
} catch {
|
||||
host.tunnelConnections = [];
|
||||
}
|
||||
}
|
||||
if (typeof host.statsConfig === "string" && host.statsConfig) {
|
||||
try {
|
||||
host.statsConfig = JSON.parse(host.statsConfig as string);
|
||||
} catch {
|
||||
host.statsConfig = undefined;
|
||||
}
|
||||
}
|
||||
if (typeof host.terminalConfig === "string" && host.terminalConfig) {
|
||||
try {
|
||||
host.terminalConfig = JSON.parse(host.terminalConfig as string);
|
||||
} catch {
|
||||
host.terminalConfig = undefined;
|
||||
}
|
||||
}
|
||||
if (typeof host.socks5ProxyChain === "string" && host.socks5ProxyChain) {
|
||||
try {
|
||||
host.socks5ProxyChain = JSON.parse(host.socks5ProxyChain as string);
|
||||
} catch {
|
||||
host.socks5ProxyChain = [];
|
||||
}
|
||||
}
|
||||
if (typeof host.quickActions === "string" && host.quickActions) {
|
||||
try {
|
||||
host.quickActions = JSON.parse(host.quickActions as string);
|
||||
} catch {
|
||||
host.quickActions = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve credential if using credential-based auth
|
||||
if (host.credentialId) {
|
||||
const ownerId = (host.userId || userId) as string;
|
||||
try {
|
||||
// Try shared credential first for non-owner users
|
||||
if (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) {
|
||||
host.password = sharedCred.password;
|
||||
host.key = sharedCred.key;
|
||||
host.keyPassword = sharedCred.keyPassword;
|
||||
host.keyType = sharedCred.keyType;
|
||||
if (!host.overrideCredentialUsername) {
|
||||
host.username = sharedCred.username;
|
||||
}
|
||||
host.authType = sharedCred.key
|
||||
? "key"
|
||||
: sharedCred.password
|
||||
? "password"
|
||||
: "none";
|
||||
return host as unknown as SSHHost;
|
||||
}
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to get shared credential, falling back", {
|
||||
operation: "host_resolver_shared_credential",
|
||||
hostId,
|
||||
error: e instanceof Error ? e.message : "Unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
ownerId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const cred = credentials[0] as Record<string, unknown>;
|
||||
host.password = cred.password;
|
||||
host.key = cred.key;
|
||||
host.keyPassword = cred.keyPassword;
|
||||
host.keyType = cred.keyType;
|
||||
if (!host.overrideCredentialUsername) {
|
||||
host.username = cred.username;
|
||||
}
|
||||
host.authType = cred.key ? "key" : cred.password ? "password" : "none";
|
||||
}
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to resolve credential for host", {
|
||||
operation: "host_resolver_credential",
|
||||
hostId,
|
||||
error: e instanceof Error ? e.message : "Unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return host as unknown as SSHHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has access to a host (owner or shared access).
|
||||
*/
|
||||
export async function checkHostAccess(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
hostUserId: string,
|
||||
requiredPermission: "read" | "execute" = "execute",
|
||||
): Promise<boolean> {
|
||||
if (userId === hostUserId) return true;
|
||||
|
||||
try {
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
requiredPermission,
|
||||
);
|
||||
return accessInfo.hasAccess;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+237
-35
@@ -12,10 +12,13 @@ import { FieldCrypto } from "../utils/field-crypto.js";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import axios from "axios";
|
||||
import yaml from "js-yaml";
|
||||
import { getRequestOrigin } from "../utils/request-origin.js";
|
||||
|
||||
const AUTH_TIMEOUT = 60 * 1000;
|
||||
|
||||
export const OPKSSH_CALLBACK_PATH = "/host/opkssh-callback";
|
||||
|
||||
interface OPKSSHAuthSession {
|
||||
requestId: string;
|
||||
userId: string;
|
||||
@@ -25,6 +28,7 @@ interface OPKSSHAuthSession {
|
||||
localPort: number;
|
||||
callbackPort: number;
|
||||
remoteRedirectUri: string;
|
||||
providers: Array<{ alias: string; issuer: string }>;
|
||||
status:
|
||||
| "starting"
|
||||
| "waiting_for_auth"
|
||||
@@ -47,6 +51,7 @@ interface OPKSSHAuthSession {
|
||||
}
|
||||
|
||||
const activeAuthSessions = new Map<string, OPKSSHAuthSession>();
|
||||
const oauthStateToRequestId = new Map<string, string>();
|
||||
const cleanupInProgress = new Set<string>();
|
||||
|
||||
function getOPKConfigPath(): string {
|
||||
@@ -78,10 +83,17 @@ async function createTemplateConfig(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
interface ProviderRedirectInfo {
|
||||
alias: string;
|
||||
issuer: string;
|
||||
redirectUris: string[];
|
||||
}
|
||||
|
||||
async function checkOPKConfigExists(): Promise<{
|
||||
exists: boolean;
|
||||
error?: string;
|
||||
configPath?: string;
|
||||
providers?: ProviderRedirectInfo[];
|
||||
}> {
|
||||
const configPath = getOPKConfigPath();
|
||||
const isDocker =
|
||||
@@ -119,15 +131,44 @@ async function checkOPKConfigExists(): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
if (!content.includes("redirect_uris:")) {
|
||||
return {
|
||||
exists: false,
|
||||
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/host/opkssh-callback for Docker). The static callback route will internally redirect to the dynamic route for proper URL rewriting.`,
|
||||
let providers: ProviderRedirectInfo[] = [];
|
||||
try {
|
||||
const parsed = yaml.load(content) as {
|
||||
providers?: Array<{
|
||||
alias?: string;
|
||||
issuer?: string;
|
||||
redirect_uris?: string[];
|
||||
}>;
|
||||
};
|
||||
if (parsed?.providers && Array.isArray(parsed.providers)) {
|
||||
providers = parsed.providers
|
||||
.filter(
|
||||
(
|
||||
p,
|
||||
): p is {
|
||||
alias: string;
|
||||
issuer: string;
|
||||
redirect_uris?: string[];
|
||||
} => typeof p.alias === "string" && typeof p.issuer === "string",
|
||||
)
|
||||
.map((p) => ({
|
||||
alias: p.alias,
|
||||
issuer: p.issuer.replace(/^https?:\/\//, ""),
|
||||
redirectUris: Array.isArray(p.redirect_uris)
|
||||
? p.redirect_uris.filter(
|
||||
(u): u is string => typeof u === "string",
|
||||
)
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to parse OPKSSH config for providers", {
|
||||
operation: "opkssh_config_parse_providers_error",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
return { exists: true, configPath };
|
||||
return { exists: true, configPath, providers };
|
||||
} catch {
|
||||
await createTemplateConfig();
|
||||
return {
|
||||
@@ -138,6 +179,63 @@ async function checkOPKConfigExists(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// OPKSSH's `redirect_uris` field lists candidate LOCAL ports for the callback listener
|
||||
// that OPKSSH binds on the host running the binary. The openpubkey library enforces these
|
||||
// must be localhost, a non-localhost entry causes ECONNRESET on /select/ at runtime.
|
||||
// The publicly registered OAuth redirect URI is what Termix passes via --remote-redirect-uri
|
||||
// (derived from request origin); users do NOT put that URL in this config field.
|
||||
function validateRedirectUrisAreLocalhost(
|
||||
providers: ProviderRedirectInfo[],
|
||||
): { ok: true } | { ok: false; message: string } {
|
||||
const isLocalHost = (host: string): boolean => {
|
||||
const bare = host.replace(/^\[|\]$/g, "");
|
||||
return (
|
||||
bare === "localhost" ||
|
||||
bare === "127.0.0.1" ||
|
||||
bare === "::1" ||
|
||||
bare === "0:0:0:0:0:0:0:1" ||
|
||||
bare.startsWith("localhost:") ||
|
||||
bare.startsWith("127.0.0.1:")
|
||||
);
|
||||
};
|
||||
|
||||
const issues: string[] = [];
|
||||
for (const p of providers) {
|
||||
const uris = p.redirectUris || [];
|
||||
if (uris.length === 0) continue;
|
||||
const nonLocal = uris.filter((u) => {
|
||||
try {
|
||||
return !isLocalHost(new URL(u).hostname);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
if (nonLocal.length > 0) {
|
||||
issues.push(
|
||||
`Provider '${p.alias}': non-localhost entries in redirect_uris: ${nonLocal.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
`OPKSSH configuration error: 'redirect_uris' must only contain localhost URLs.\n\n` +
|
||||
`${issues.join("\n")}\n\n` +
|
||||
`This field is OPKSSH's local callback listener, it must be localhost (or omitted to use ` +
|
||||
`the defaults http://localhost:3000/login-callback, :10001, :11110). ` +
|
||||
`The public Termix callback URL is supplied automatically by Termix via --remote-redirect-uri; ` +
|
||||
`you do not put it here. Register the PUBLIC Termix URL with your OAuth provider instead ` +
|
||||
`(e.g. https://your-domain${OPKSSH_CALLBACK_PATH}).\n\n` +
|
||||
`Fix: remove the non-localhost entries above, or delete the whole 'redirect_uris' block to use defaults.\n\n` +
|
||||
`Docs: https://docs.termix.site/opkssh`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function startOPKSSHAuth(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
@@ -178,8 +276,37 @@ export async function startOPKSSHAuth(
|
||||
return "";
|
||||
}
|
||||
|
||||
const redirectValidation = validateRedirectUrisAreLocalhost(
|
||||
configCheck.providers || [],
|
||||
);
|
||||
if (redirectValidation.ok === false) {
|
||||
sshLogger.warn("OPKSSH config redirect_uris validation failed", {
|
||||
operation: "opkssh_config_redirect_uris_not_localhost",
|
||||
configPath: configCheck.configPath,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_config_error",
|
||||
requestId: "",
|
||||
error: redirectValidation.message,
|
||||
instructions: redirectValidation.message,
|
||||
}),
|
||||
);
|
||||
return "";
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
const remoteRedirectUri = `${requestOrigin}/host/opkssh-callback`;
|
||||
const remoteRedirectUri = `${requestOrigin}${OPKSSH_CALLBACK_PATH}`;
|
||||
|
||||
sshLogger.info("Starting OPKSSH auth session", {
|
||||
operation: "opkssh_start_auth_remote_redirect_uri",
|
||||
requestId,
|
||||
userId,
|
||||
hostId,
|
||||
requestOrigin,
|
||||
remoteRedirectUri,
|
||||
providerAliases: (configCheck.providers || []).map((p) => p.alias),
|
||||
});
|
||||
|
||||
const session: Partial<OPKSSHAuthSession> = {
|
||||
requestId,
|
||||
@@ -189,6 +316,7 @@ export async function startOPKSSHAuth(
|
||||
localPort: 0,
|
||||
callbackPort: 0,
|
||||
remoteRedirectUri,
|
||||
providers: configCheck.providers || [],
|
||||
status: "starting",
|
||||
ws,
|
||||
stdoutBuffer: "",
|
||||
@@ -261,7 +389,80 @@ export async function startOPKSSHAuth(
|
||||
handleOPKSSHOutput(requestId, stderr);
|
||||
}
|
||||
|
||||
if (stderr.includes("provider not found") || stderr.includes("config")) {
|
||||
const lowerStderr = stderr.toLowerCase();
|
||||
|
||||
// OPKSSH's openpubkey library rejects non-localhost `redirect_uris` at runtime
|
||||
// with the distinctive message "redirectURI must be localhost". Surface that
|
||||
// directly with actionable guidance.
|
||||
if (lowerStderr.includes("redirecturi must be localhost")) {
|
||||
sshLogger.warn("OPKSSH rejected non-localhost entry in redirect_uris", {
|
||||
operation: "opkssh_stderr_redirect_uris_not_localhost",
|
||||
requestId,
|
||||
remoteRedirectUri,
|
||||
stderrSnippet: stderr.slice(0, 500),
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_config_error",
|
||||
requestId,
|
||||
error:
|
||||
`OPKSSH rejected the local callback URI: every entry in 'redirect_uris' must be localhost.\n\n` +
|
||||
`OPKSSH output:\n${stderr.trim()}\n\n` +
|
||||
`The 'redirect_uris' config field is OPKSSH's LOCAL listener — it is not the public Termix callback. ` +
|
||||
`Remove any non-localhost entries from redirect_uris (or delete the whole block to use OPKSSH's ` +
|
||||
`defaults of :3000, :10001, :11110). Register the public Termix callback URL with your OAuth ` +
|
||||
`provider instead, Termix passes it to OPKSSH automatically via --remote-redirect-uri.`,
|
||||
instructions: "See documentation: https://docs.termix.site/opkssh",
|
||||
}),
|
||||
);
|
||||
await cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
// Generic redirect-uri/mismatch errors (OAuth provider side, OPKSSH config side, etc.)
|
||||
const genericRedirectIndicators = [
|
||||
"redirect_uri",
|
||||
"redirect uri",
|
||||
"invalid redirect",
|
||||
"no matching redirect",
|
||||
"allowed redirect",
|
||||
"mismatching redirection",
|
||||
];
|
||||
const hasGenericRedirectError = genericRedirectIndicators.some((s) =>
|
||||
lowerStderr.includes(s),
|
||||
);
|
||||
|
||||
if (hasGenericRedirectError) {
|
||||
sshLogger.warn("OPKSSH stderr reported redirect_uri error", {
|
||||
operation: "opkssh_stderr_redirect_uri_error",
|
||||
requestId,
|
||||
remoteRedirectUri,
|
||||
stderrSnippet: stderr.slice(0, 500),
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_config_error",
|
||||
requestId,
|
||||
error:
|
||||
`OPKSSH or the OAuth provider rejected the redirect URI.\n\n` +
|
||||
`Computed Termix callback URI (sent to provider): ${remoteRedirectUri}\n\n` +
|
||||
`OPKSSH output:\n${stderr.trim()}\n\n` +
|
||||
`Register '${remoteRedirectUri}' as an authorized redirect URI with your OAuth provider ` +
|
||||
`(e.g. in Google Cloud Console → OAuth client). ` +
|
||||
`Also confirm any 'redirect_uris' in your OPKSSH config contain ONLY localhost URLs.`,
|
||||
instructions: "See documentation: https://docs.termix.site/opkssh",
|
||||
}),
|
||||
);
|
||||
await cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
stderr.includes("provider not found") ||
|
||||
stderr.includes("config error") ||
|
||||
stderr.includes("invalid config") ||
|
||||
stderr.includes("config not found")
|
||||
) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_config_error",
|
||||
@@ -340,18 +541,18 @@ function handleOPKSSHOutput(requestId: string, output: string): void {
|
||||
session.stdoutBuffer += output;
|
||||
|
||||
const chooserUrlMatch = session.stdoutBuffer.match(
|
||||
/(?:Opening browser to|Open your browser to:)\s*http:\/\/localhost:(\d+)\/chooser/,
|
||||
/(?:Opening browser to|Open your browser to:)\s*http:\/\/(?:localhost|127\.0\.0\.1):(\d+)\/chooser/,
|
||||
);
|
||||
if (chooserUrlMatch && session.status === "starting") {
|
||||
const actualPort = parseInt(chooserUrlMatch[1], 10);
|
||||
const localChooserUrl = `http://localhost:${actualPort}/chooser`;
|
||||
const localChooserUrl = `http://127.0.0.1:${actualPort}/chooser`;
|
||||
|
||||
session.localPort = actualPort;
|
||||
|
||||
const baseUrl = session.remoteRedirectUri.replace(
|
||||
/\/ssh\/opkssh-callback$/,
|
||||
"",
|
||||
);
|
||||
const baseUrl = session.remoteRedirectUri
|
||||
.replace(/\/host\/opkssh-callback$/, "")
|
||||
// In direct dev mode the WS server (30002) is separate from the HTTP API (30001)
|
||||
.replace(/:30002\b/, ":30001");
|
||||
const proxiedChooserUrl = `${baseUrl}/host/opkssh-chooser/${requestId}`;
|
||||
|
||||
session.status = "waiting_for_auth";
|
||||
@@ -361,6 +562,7 @@ function handleOPKSSHOutput(requestId: string, output: string): void {
|
||||
requestId,
|
||||
stage: "chooser",
|
||||
url: proxiedChooserUrl,
|
||||
providers: session.providers,
|
||||
localUrl: localChooserUrl,
|
||||
message: "Please authenticate in your browser",
|
||||
}),
|
||||
@@ -368,7 +570,7 @@ function handleOPKSSHOutput(requestId: string, output: string): void {
|
||||
}
|
||||
|
||||
const callbackPortMatch = session.stdoutBuffer.match(
|
||||
/listening on http:\/\/127\.0\.0\.1:(\d+)\//,
|
||||
/listening on http:\/\/(?:127\.0\.0\.1|localhost):(\d+)\//,
|
||||
);
|
||||
if (callbackPortMatch && !session.callbackPort) {
|
||||
session.callbackPort = parseInt(callbackPortMatch[1], 10);
|
||||
@@ -509,25 +711,6 @@ async function storeOPKSSHToken(session: OPKSSHAuthSession): Promise<void> {
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
"http://localhost:30006/activity/log",
|
||||
{
|
||||
type: "opkssh_authentication",
|
||||
hostId: session.hostId,
|
||||
hostName: session.hostname,
|
||||
status: "approved",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.INTERNAL_AUTH_TOKEN}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (activityError) {
|
||||
sshLogger.warn("Failed to log OPKSSH activity", activityError);
|
||||
}
|
||||
|
||||
await session.cleanup();
|
||||
} catch (error) {
|
||||
sshLogger.error(
|
||||
@@ -657,7 +840,7 @@ export async function handleOAuthCallback(
|
||||
}
|
||||
|
||||
try {
|
||||
const callbackUrl = `http://localhost:${session.localPort}/login-callback?${queryString}`;
|
||||
const callbackUrl = `http://127.0.0.1:${session.localPort}/login-callback?${queryString}`;
|
||||
await axios.get(callbackUrl, {
|
||||
timeout: 10000,
|
||||
validateStatus: () => true,
|
||||
@@ -720,6 +903,13 @@ async function cleanupAuthSession(requestId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any OAuth state mappings for this session
|
||||
for (const [state, reqId] of oauthStateToRequestId.entries()) {
|
||||
if (reqId === requestId) {
|
||||
oauthStateToRequestId.delete(state);
|
||||
}
|
||||
}
|
||||
|
||||
activeAuthSessions.delete(requestId);
|
||||
} finally {
|
||||
cleanupInProgress.delete(requestId);
|
||||
@@ -753,6 +943,18 @@ export function getActiveSessionsAll(): OPKSSHAuthSession[] {
|
||||
return Array.from(activeAuthSessions.values());
|
||||
}
|
||||
|
||||
export function registerOAuthState(state: string, requestId: string): void {
|
||||
oauthStateToRequestId.set(state, requestId);
|
||||
}
|
||||
|
||||
export function getRequestIdByOAuthState(state: string): string | undefined {
|
||||
return oauthStateToRequestId.get(state);
|
||||
}
|
||||
|
||||
export function clearOAuthState(state: string): void {
|
||||
oauthStateToRequestId.delete(state);
|
||||
}
|
||||
|
||||
export async function getUserIdFromRequest(req: {
|
||||
cookies?: Record<string, string>;
|
||||
headers: Record<string, string | undefined>;
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// OPKSSH certificate authentication workarounds for ssh2.
|
||||
// ssh2 doesn't support OpenSSH cert auth natively — this module grafts
|
||||
// the certificate onto the parsed key, wraps ECDSA signing to convert
|
||||
// DER → SSH wire format, and patches Protocol.authPK to use the base
|
||||
// algorithm in the signature wrapper (required by OpenSSH's sshkey_check_sigtype).
|
||||
|
||||
import type { Client, ConnectConfig } from "ssh2";
|
||||
|
||||
interface OPKSSHToken {
|
||||
privateKey: string;
|
||||
sshCert: string;
|
||||
}
|
||||
export async function setupOPKSSHCertAuth(
|
||||
config: ConnectConfig,
|
||||
client: Client,
|
||||
token: OPKSSHToken,
|
||||
username: string,
|
||||
): Promise<void> {
|
||||
const { createRequire } = await import("node:module");
|
||||
const esmRequire = createRequire(import.meta.url);
|
||||
const {
|
||||
utils: { parseKey },
|
||||
} = esmRequire("ssh2");
|
||||
|
||||
const parsed = parseKey(Buffer.from(token.privateKey));
|
||||
if (parsed instanceof Error || !parsed) {
|
||||
throw new Error("Failed to parse OPKSSH private key");
|
||||
}
|
||||
const privKey: any = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
|
||||
// Extract cert type and blob from the stored certificate
|
||||
const certParts = token.sshCert.trim().split(/\s+/);
|
||||
const certType = certParts[0];
|
||||
privKey.type = certType;
|
||||
const certBlob = Buffer.from(certParts[1], "base64");
|
||||
|
||||
// Replace the internal public SSH blob with the full certificate
|
||||
const pubSSHSym = Object.getOwnPropertySymbols(privKey).find(
|
||||
(s) => String(s) === "Symbol(Public key SSH)",
|
||||
);
|
||||
if (!pubSSHSym) {
|
||||
throw new Error(
|
||||
"Cannot find public SSH symbol on parsed key, ssh2 internals may have changed",
|
||||
);
|
||||
}
|
||||
privKey[pubSSHSym] = certBlob;
|
||||
|
||||
// Wrap sign() for ECDSA cert keys
|
||||
if (privKey.type.startsWith("ecdsa-")) {
|
||||
const origSign = privKey.sign.bind(privKey);
|
||||
privKey.sign = (data: Buffer, algo?: string) => {
|
||||
const sigAlgo = algo?.includes("-cert-")
|
||||
? algo.replace(/-cert-v\d+@openssh\.com$/, "")
|
||||
: algo;
|
||||
const sig = origSign(data, sigAlgo);
|
||||
if (sig instanceof Error || sig[0] !== 0x30) return sig;
|
||||
// Convert DER-encoded ECDSA signature to SSH wire format
|
||||
try {
|
||||
let pos = 2;
|
||||
if (sig[1] & 0x80) pos += sig[1] & 0x7f;
|
||||
pos++;
|
||||
const rLen = sig[pos++];
|
||||
const r = sig.subarray(pos, pos + rLen);
|
||||
pos += rLen + 1;
|
||||
const sLen = sig[pos++];
|
||||
const s = sig.subarray(pos, pos + sLen);
|
||||
const out = Buffer.allocUnsafe(4 + r.length + 4 + s.length);
|
||||
out.writeUInt32BE(r.length, 0);
|
||||
r.copy(out, 4);
|
||||
out.writeUInt32BE(s.length, 4 + r.length);
|
||||
s.copy(out, 4 + r.length + 4);
|
||||
return out;
|
||||
} catch {
|
||||
return sig;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Set up authHandler to bypass ssh2's cert type rejection
|
||||
let certAuthAttempted = false;
|
||||
config.authHandler = (
|
||||
methodsLeft: string[],
|
||||
_partialSuccess: boolean,
|
||||
callback: (authInfo: any) => void,
|
||||
) => {
|
||||
if (
|
||||
!certAuthAttempted &&
|
||||
(!methodsLeft || methodsLeft.includes("publickey"))
|
||||
) {
|
||||
certAuthAttempted = true;
|
||||
callback({ type: "publickey", username, key: privKey });
|
||||
} else {
|
||||
callback(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Monkey-patch Protocol.authPK after connect() to fix the signature
|
||||
// wrapper algorithm for cert types.
|
||||
const baseAlgo = certType.replace(/-cert-v\d+@openssh\.com$/, "");
|
||||
const origConnect = client.connect.bind(client);
|
||||
(client as any).connect = (cfg: any) => {
|
||||
origConnect(cfg);
|
||||
const proto = (client as any)._protocol;
|
||||
if (!proto) return;
|
||||
const origAuthPK = proto.authPK.bind(proto);
|
||||
proto.authPK = (
|
||||
user: string,
|
||||
pubKey: any,
|
||||
keyAlgo: string | undefined,
|
||||
cbSign?: Function,
|
||||
) => {
|
||||
const isCertAuth = !!cbSign && pubKey?.type?.includes("-cert-");
|
||||
if (!isCertAuth) {
|
||||
return origAuthPK(user, pubKey, keyAlgo, cbSign);
|
||||
}
|
||||
|
||||
// Signed auth with cert type: rebuild packet with base algo in
|
||||
// the signature wrapper. keyAlgo may be undefined for ECDSA.
|
||||
const certAlgo = keyAlgo || pubKey.type;
|
||||
const pubSSH = pubKey.getPublicSSH();
|
||||
const sessionID = proto._kex.sessionID;
|
||||
const sesLen = sessionID.length;
|
||||
const userLen = Buffer.byteLength(user);
|
||||
const certAlgoLen = Buffer.byteLength(certAlgo);
|
||||
const baseAlgoLen = Buffer.byteLength(baseAlgo);
|
||||
const pubKeyLen = pubSSH.length;
|
||||
|
||||
// Build data to sign (uses cert algo — matches server verification)
|
||||
const sigDataLen =
|
||||
4 +
|
||||
sesLen +
|
||||
1 +
|
||||
4 +
|
||||
userLen +
|
||||
4 +
|
||||
14 +
|
||||
4 +
|
||||
9 +
|
||||
1 +
|
||||
4 +
|
||||
certAlgoLen +
|
||||
4 +
|
||||
pubKeyLen;
|
||||
const sigData = Buffer.allocUnsafe(sigDataLen);
|
||||
let sp = 0;
|
||||
sigData.writeUInt32BE(sesLen, sp);
|
||||
sp += 4;
|
||||
sessionID.copy(sigData, sp);
|
||||
sp += sesLen;
|
||||
sigData[sp++] = 50; // SSH_MSG_USERAUTH_REQUEST
|
||||
sigData.writeUInt32BE(userLen, sp);
|
||||
sp += 4;
|
||||
sigData.write(user, sp, userLen, "utf8");
|
||||
sp += userLen;
|
||||
sigData.writeUInt32BE(14, sp);
|
||||
sp += 4;
|
||||
sigData.write("ssh-connection", sp, 14, "utf8");
|
||||
sp += 14;
|
||||
sigData.writeUInt32BE(9, sp);
|
||||
sp += 4;
|
||||
sigData.write("publickey", sp, 9, "utf8");
|
||||
sp += 9;
|
||||
sigData[sp++] = 1; // TRUE
|
||||
sigData.writeUInt32BE(certAlgoLen, sp);
|
||||
sp += 4;
|
||||
sigData.write(certAlgo, sp, certAlgoLen, "utf8");
|
||||
sp += certAlgoLen;
|
||||
sigData.writeUInt32BE(pubKeyLen, sp);
|
||||
sp += 4;
|
||||
pubSSH.copy(sigData, sp);
|
||||
|
||||
cbSign(sigData, (signature: Buffer) => {
|
||||
const sigLen = signature.length;
|
||||
const payloadLen =
|
||||
1 +
|
||||
4 +
|
||||
userLen +
|
||||
4 +
|
||||
14 +
|
||||
4 +
|
||||
9 +
|
||||
1 +
|
||||
4 +
|
||||
certAlgoLen +
|
||||
4 +
|
||||
pubKeyLen +
|
||||
4 +
|
||||
4 +
|
||||
baseAlgoLen +
|
||||
4 +
|
||||
sigLen;
|
||||
const packet = proto._packetRW.write.alloc(payloadLen);
|
||||
let pp = proto._packetRW.write.allocStart;
|
||||
packet[pp] = 50; // SSH_MSG_USERAUTH_REQUEST
|
||||
packet.writeUInt32BE(userLen, ++pp);
|
||||
pp += 4;
|
||||
packet.write(user, pp, userLen, "utf8");
|
||||
pp += userLen;
|
||||
packet.writeUInt32BE(14, pp);
|
||||
pp += 4;
|
||||
packet.write("ssh-connection", pp, 14, "utf8");
|
||||
pp += 14;
|
||||
packet.writeUInt32BE(9, pp);
|
||||
pp += 4;
|
||||
packet.write("publickey", pp, 9, "utf8");
|
||||
pp += 9;
|
||||
packet[pp++] = 1; // TRUE
|
||||
// Header: cert type
|
||||
packet.writeUInt32BE(certAlgoLen, pp);
|
||||
pp += 4;
|
||||
packet.write(certAlgo, pp, certAlgoLen, "utf8");
|
||||
pp += certAlgoLen;
|
||||
// Public key blob
|
||||
packet.writeUInt32BE(pubKeyLen, pp);
|
||||
pp += 4;
|
||||
pubSSH.copy(packet, pp);
|
||||
pp += pubKeyLen;
|
||||
// Signature wrapper: base algo (NOT cert type)
|
||||
packet.writeUInt32BE(4 + baseAlgoLen + 4 + sigLen, pp);
|
||||
pp += 4;
|
||||
packet.writeUInt32BE(baseAlgoLen, pp);
|
||||
pp += 4;
|
||||
packet.write(baseAlgo, pp, baseAlgoLen, "utf8");
|
||||
pp += baseAlgoLen;
|
||||
packet.writeUInt32BE(sigLen, pp);
|
||||
pp += 4;
|
||||
signature.copy(packet, pp);
|
||||
|
||||
proto._authsQueue.push("publickey");
|
||||
proto._debug?.("Outbound: Sending USERAUTH_REQUEST (publickey)");
|
||||
const finalized = proto._packetRW.write.finalize(packet);
|
||||
proto._cipher.encrypt(finalized);
|
||||
});
|
||||
};
|
||||
};
|
||||
}
|
||||
+176
-61
@@ -1,8 +1,9 @@
|
||||
import express from "express";
|
||||
import net from "net";
|
||||
import cors from "cors";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { Client, type ConnectConfig } from "ssh2";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
@@ -31,7 +32,9 @@ import { connectionPool, withConnection } from "./ssh-connection-pool.js";
|
||||
|
||||
function supportsMetrics(host: SSHHostWithCredentials): boolean {
|
||||
const connectionType = host.connectionType || "ssh";
|
||||
return connectionType === "ssh";
|
||||
if (connectionType !== "ssh") return false;
|
||||
if (host.authType === "none" || host.authType === "opkssh") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isTcpPingEnabled(statsConfig: StatsConfig): boolean {
|
||||
@@ -865,7 +868,15 @@ class PollingManager {
|
||||
latestConfig.statsConfig.metricsEnabled &&
|
||||
supportsMetrics(latestConfig.host)
|
||||
) {
|
||||
this.pollHostMetrics(latestConfig.host, latestConfig.viewerUserId);
|
||||
this.pollHostMetrics(
|
||||
latestConfig.host,
|
||||
latestConfig.viewerUserId,
|
||||
).catch((err) => {
|
||||
statsLogger.error("Metrics polling failed", err, {
|
||||
operation: "metrics_poll_unhandled",
|
||||
hostId: host.id,
|
||||
});
|
||||
});
|
||||
}
|
||||
}, intervalMs);
|
||||
} else {
|
||||
@@ -929,9 +940,11 @@ class PollingManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasExistingMetrics = this.metricsStore.has(refreshedHost.id);
|
||||
if (authFailureTracker.shouldSkip(host.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasExistingMetrics && pollingBackoff.shouldSkip(host.id)) {
|
||||
if (pollingBackoff.shouldSkip(host.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -942,16 +955,38 @@ class PollingManager {
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
pollingBackoff.reset(refreshedHost.id);
|
||||
authFailureTracker.reset(refreshedHost.id);
|
||||
} catch (error) {
|
||||
const isAuthError =
|
||||
error instanceof Error &&
|
||||
(error.message.includes("authentication") ||
|
||||
error.message.includes("Authentication") ||
|
||||
error.message.includes("permission denied") ||
|
||||
error.message.includes("Permission denied"));
|
||||
|
||||
if (isAuthError) {
|
||||
// authFailureTracker already handles auth errors inside collectMetrics;
|
||||
// only log on the first occurrence to avoid repeated spam
|
||||
const alreadyTracked = authFailureTracker.shouldSkip(host.id);
|
||||
if (!alreadyTracked) {
|
||||
statsLogger.error("Stats collector connection failed", error, {
|
||||
operation: "stats_connect_failed",
|
||||
hostId: refreshedHost.id,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pollingBackoff.recordFailure(refreshedHost.id);
|
||||
|
||||
const latestConfig = this.pollingConfigs.get(refreshedHost.id);
|
||||
if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
|
||||
const backoffInfo = pollingBackoff.getBackoffInfo(refreshedHost.id);
|
||||
// Only log when a new retry window opens, not on every skipped poll
|
||||
const backoff = pollingBackoff.getBackoffInfo(refreshedHost.id);
|
||||
const isNewFailure =
|
||||
backoff !== null && !backoff.includes("polling suspended");
|
||||
if (isNewFailure) {
|
||||
statsLogger.error("Stats collector connection failed", error, {
|
||||
operation: "stats_connect_failed",
|
||||
hostId: refreshedHost.id,
|
||||
retryInfo: backoffInfo,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1071,7 +1106,18 @@ class PollingManager {
|
||||
});
|
||||
|
||||
if (this.activeViewers.get(hostId)!.size === 1) {
|
||||
this.startMetricsForHost(hostId, userId);
|
||||
// Fire-and-forget: never let background metrics start-up failures
|
||||
// propagate up to the HTTP handler that registered the viewer.
|
||||
Promise.resolve()
|
||||
.then(() => this.startMetricsForHost(hostId, userId))
|
||||
.catch((err) => {
|
||||
statsLogger.warn("startMetricsForHost rejected (non-fatal)", {
|
||||
operation: "start_metrics_unhandled",
|
||||
hostId,
|
||||
userId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1153,37 +1199,7 @@ function validateHostId(
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
const allowedOrigins = ["http://localhost:5173", "http://127.0.0.1:5173"];
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("http://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
],
|
||||
}),
|
||||
);
|
||||
app.use(createCorsMiddleware());
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use((_req, res, next) => {
|
||||
@@ -1315,8 +1331,9 @@ async function resolveHostCredentials(
|
||||
const isSharedHost = userId !== ownerId;
|
||||
|
||||
if (isSharedHost) {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../utils/shared-credential-manager.js");
|
||||
const { SharedCredentialManager } = await import(
|
||||
"../utils/shared-credential-manager.js"
|
||||
);
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||
host.id as number,
|
||||
@@ -1373,8 +1390,13 @@ async function resolveHostCredentials(
|
||||
if (credential.password) {
|
||||
baseHost.password = credential.password;
|
||||
}
|
||||
if (credential.key) {
|
||||
baseHost.key = credential.key;
|
||||
if (
|
||||
credential.key ||
|
||||
(credential as Record<string, unknown>).privateKey
|
||||
) {
|
||||
baseHost.key =
|
||||
credential.key ||
|
||||
((credential as Record<string, unknown>).privateKey as string);
|
||||
}
|
||||
if (credential.keyPassword) {
|
||||
baseHost.keyPassword = credential.keyPassword;
|
||||
@@ -1485,18 +1507,7 @@ async function buildSshConfig(
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: [
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
cipher: SSH_ALGORITHMS.cipher,
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
@@ -1546,7 +1557,7 @@ async function buildSshConfig(
|
||||
} else if (host.authType === "none") {
|
||||
// no credentials needed
|
||||
} else if (host.authType === "opkssh") {
|
||||
// handled externally
|
||||
// cert auth setup happens in createSshFactory (needs client instance)
|
||||
} else if (host.authType === "credential") {
|
||||
if (host.password) {
|
||||
base.password = host.password;
|
||||
@@ -1586,6 +1597,19 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
|
||||
const config = await buildSshConfig(host);
|
||||
const client = new Client();
|
||||
|
||||
// Set up OPKSSH cert auth if needed (requires client instance)
|
||||
if (host.authType === "opkssh" && host.userId) {
|
||||
const { getOPKSSHToken } = await import("./opkssh-auth.js");
|
||||
const token = await getOPKSSHToken(host.userId, host.id);
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"OPKSSH authentication required. Please open a Terminal connection first.",
|
||||
);
|
||||
}
|
||||
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(config, client, token, host.username);
|
||||
}
|
||||
|
||||
const proxyConfig: SOCKS5Config | null =
|
||||
host.useSocks5 &&
|
||||
(host.socks5Host ||
|
||||
@@ -1913,7 +1937,8 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
} else if (
|
||||
error.message.includes("No password available") ||
|
||||
error.message.includes("Unsupported authentication type") ||
|
||||
error.message.includes("No SSH key available")
|
||||
error.message.includes("No SSH key available") ||
|
||||
error.message.includes("Invalid SSH key format")
|
||||
) {
|
||||
authFailureTracker.recordFailure(host.id, "AUTH", true);
|
||||
} else if (
|
||||
@@ -2413,6 +2438,27 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
|
||||
const config = await buildSshConfig(host);
|
||||
const client = new Client();
|
||||
|
||||
if (host.authType === "opkssh" && host.userId) {
|
||||
const { getOPKSSHToken } = await import("./opkssh-auth.js");
|
||||
const token = await getOPKSSHToken(host.userId, host.id);
|
||||
if (!token) {
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"error",
|
||||
"auth",
|
||||
"OPKSSH authentication required. Please open a Terminal connection first.",
|
||||
),
|
||||
);
|
||||
return res.status(401).json({
|
||||
error: "OPKSSH authentication required",
|
||||
requiresOPKSSHAuth: true,
|
||||
connectionLogs,
|
||||
});
|
||||
}
|
||||
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(config, client, token, host.username);
|
||||
}
|
||||
|
||||
const connectionPromise = new Promise<{
|
||||
success: boolean;
|
||||
requires_totp?: boolean;
|
||||
@@ -3028,8 +3074,70 @@ app.post("/metrics/register-viewer", async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Graceful no-op if host is inaccessible, metrics disabled, or host type
|
||||
// does not support metrics. The client may call this speculatively, so
|
||||
// avoid returning 5xx for expected "no metrics available" scenarios.
|
||||
let host: SSHHostWithCredentials | undefined;
|
||||
try {
|
||||
host = await fetchHostById(hostId, userId);
|
||||
} catch (lookupErr) {
|
||||
statsLogger.warn(
|
||||
"register-viewer host lookup failed (treating as no-op)",
|
||||
{
|
||||
operation: "register_viewer_lookup",
|
||||
hostId,
|
||||
userId,
|
||||
error:
|
||||
lookupErr instanceof Error ? lookupErr.message : String(lookupErr),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
return res.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: "host_not_found",
|
||||
});
|
||||
}
|
||||
|
||||
if (!supportsMetrics(host)) {
|
||||
return res.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: "metrics_unsupported",
|
||||
});
|
||||
}
|
||||
|
||||
const statsConfig = pollingManager.parseStatsConfig(host.statsConfig);
|
||||
if (!statsConfig.metricsEnabled) {
|
||||
return res.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: "metrics_disabled",
|
||||
});
|
||||
}
|
||||
|
||||
const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
pollingManager.registerViewer(hostId, viewerSessionId, userId);
|
||||
try {
|
||||
pollingManager.registerViewer(hostId, viewerSessionId, userId);
|
||||
} catch (regErr) {
|
||||
statsLogger.warn(
|
||||
"pollingManager.registerViewer threw (treating as no-op)",
|
||||
{
|
||||
operation: "register_viewer_internal",
|
||||
hostId,
|
||||
userId,
|
||||
error: regErr instanceof Error ? regErr.message : String(regErr),
|
||||
},
|
||||
);
|
||||
return res.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: "register_failed_noop",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, viewerSessionId });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to register viewer", {
|
||||
@@ -3038,7 +3146,14 @@ app.post("/metrics/register-viewer", async (req, res) => {
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to register viewer" });
|
||||
// Even on unexpected errors we prefer a graceful client experience: the
|
||||
// viewer-registration is purely an optimization and should never break
|
||||
// the UI. Report success:false but HTTP 200 so the client can decide.
|
||||
res.status(200).json({
|
||||
success: false,
|
||||
skipped: true,
|
||||
reason: "internal_error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface TerminalSession {
|
||||
sshConn: Client | null;
|
||||
sshStream: ClientChannel | null;
|
||||
jumpClient: Client | null;
|
||||
opksshTempFiles: { keyPath: string; certPath: string } | null;
|
||||
|
||||
cols: number;
|
||||
rows: number;
|
||||
@@ -32,6 +31,7 @@ export interface TerminalSession {
|
||||
|
||||
outputBuffer: string[];
|
||||
outputBufferBytes: number;
|
||||
tmuxSessionName: string | null;
|
||||
}
|
||||
|
||||
class TerminalSessionManager {
|
||||
@@ -99,7 +99,6 @@ class TerminalSessionManager {
|
||||
sshConn: null,
|
||||
sshStream: null,
|
||||
jumpClient: null,
|
||||
opksshTempFiles: null,
|
||||
cols,
|
||||
rows,
|
||||
isConnected: false,
|
||||
@@ -109,6 +108,7 @@ class TerminalSessionManager {
|
||||
detachTimeout: null,
|
||||
outputBuffer: [],
|
||||
outputBufferBytes: 0,
|
||||
tmuxSessionName: null,
|
||||
};
|
||||
this.sessions.set(id, session);
|
||||
|
||||
@@ -132,7 +132,6 @@ class TerminalSessionManager {
|
||||
conn: Client,
|
||||
stream: ClientChannel,
|
||||
jumpClient?: Client | null,
|
||||
opksshTempFiles?: { keyPath: string; certPath: string } | null,
|
||||
): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
@@ -140,7 +139,6 @@ class TerminalSessionManager {
|
||||
session.sshConn = conn;
|
||||
session.sshStream = stream;
|
||||
session.jumpClient = jumpClient ?? null;
|
||||
session.opksshTempFiles = opksshTempFiles ?? null;
|
||||
session.isConnected = true;
|
||||
}
|
||||
|
||||
@@ -326,12 +324,6 @@ class TerminalSessionManager {
|
||||
session.jumpClient = null;
|
||||
}
|
||||
|
||||
if (session.opksshTempFiles) {
|
||||
const tempFiles = session.opksshTempFiles;
|
||||
session.opksshTempFiles = null;
|
||||
this.cleanupOpksshFiles(tempFiles);
|
||||
}
|
||||
|
||||
session.isConnected = false;
|
||||
session.outputBuffer = [];
|
||||
session.outputBufferBytes = 0;
|
||||
@@ -449,32 +441,6 @@ class TerminalSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupOpksshFiles(tempFiles: {
|
||||
keyPath: string;
|
||||
certPath: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const { promises: fs } = await import("fs");
|
||||
const results = await Promise.allSettled([
|
||||
fs.unlink(tempFiles.keyPath),
|
||||
fs.unlink(tempFiles.certPath),
|
||||
]);
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === "rejected") {
|
||||
sshLogger.warn("Failed to cleanup OPKSSH temp file", {
|
||||
operation: "opkssh_temp_cleanup_failed",
|
||||
file: index === 0 ? "keyPath" : "certPath",
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to cleanup OPKSSH temp files", {
|
||||
operation: "opkssh_temp_cleanup_error",
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
destroyAll(): void {
|
||||
for (const id of [...this.sessions.keys()]) {
|
||||
this.destroySession(id);
|
||||
|
||||
+305
-123
@@ -1,5 +1,8 @@
|
||||
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
||||
import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
|
||||
import net from "net";
|
||||
import dgram from "dgram";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { parse as parseUrl } from "url";
|
||||
import axios from "axios";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
@@ -17,6 +20,46 @@ import { SSHAuthManager } from "./auth-manager.js";
|
||||
import type { ProxyNode } from "../../types/index.js";
|
||||
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
||||
import { sessionManager } from "./terminal-session-manager.js";
|
||||
import {
|
||||
detectTmux,
|
||||
attachOrCreateTmuxSession,
|
||||
queryNewestTmuxSession,
|
||||
} from "./tmux-helper.js";
|
||||
|
||||
async function performPortKnocking(
|
||||
host: string,
|
||||
sequence: Array<{ port: number; protocol?: string; delay?: number }>,
|
||||
): Promise<void> {
|
||||
for (const knock of sequence) {
|
||||
const protocol = knock.protocol || "tcp";
|
||||
const delay = knock.delay ?? 100;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
if (protocol === "udp") {
|
||||
const client = dgram.createSocket("udp4");
|
||||
client.send(Buffer.alloc(0), knock.port, host, () => {
|
||||
client.close();
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
const socket = new net.Socket();
|
||||
socket.once("connect", () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.once("error", () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
socket.connect(knock.port, host);
|
||||
}
|
||||
});
|
||||
|
||||
if (delay > 0) {
|
||||
await new Promise<void>((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ConnectToHostData {
|
||||
cols: number;
|
||||
@@ -42,6 +85,11 @@ interface ConnectToHostData {
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: unknown;
|
||||
portKnockSequence?: Array<{
|
||||
port: number;
|
||||
protocol?: "tcp" | "udp";
|
||||
delay?: number;
|
||||
}>;
|
||||
terminalConfig?: {
|
||||
keepaliveInterval?: number;
|
||||
keepaliveCountMax?: number;
|
||||
@@ -255,7 +303,7 @@ async function createJumpHostChain(
|
||||
host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
|
||||
port: jumpHostConfig.port || 22,
|
||||
username: jumpHostConfig.username,
|
||||
tryKeyboard: true,
|
||||
tryKeyboard: jumpHostConfig.authType !== "none",
|
||||
readyTimeout: 30000,
|
||||
hostVerifier: jumpHostVerifier,
|
||||
};
|
||||
@@ -320,7 +368,15 @@ const wss = new WebSocketServer({
|
||||
verifyClient: async (info) => {
|
||||
try {
|
||||
const url = parseUrl(info.req.url!, true);
|
||||
const token = url.query.token as string;
|
||||
let token = url.query.token as string;
|
||||
|
||||
if (!token) {
|
||||
const cookieHeader = info.req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return false;
|
||||
@@ -338,7 +394,7 @@ const wss = new WebSocketServer({
|
||||
|
||||
const existingConnections = userConnections.get(payload.userId);
|
||||
|
||||
if (existingConnections && existingConnections.size >= 3) {
|
||||
if (existingConnections && existingConnections.size >= 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -359,7 +415,15 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
try {
|
||||
const url = parseUrl(req.url!, true);
|
||||
const token = url.query.token as string;
|
||||
let token = url.query.token as string;
|
||||
|
||||
if (!token) {
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) token = decodeURIComponent(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
ws.close(1008, "Authentication required");
|
||||
@@ -427,10 +491,28 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
let warpgateAuthPromptSent = false;
|
||||
let warpgateAuthTimeout: NodeJS.Timeout | null = null;
|
||||
let isAwaitingAuthCredentials = false;
|
||||
let opksshTempFiles: { keyPath: string; certPath: string } | null = null;
|
||||
|
||||
let wsAlive = true;
|
||||
|
||||
ws.on("pong", () => {
|
||||
wsAlive = true;
|
||||
});
|
||||
|
||||
const wsPingInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
if (!wsAlive) {
|
||||
sshLogger.warn(
|
||||
"WebSocket pong timeout - terminating zombie connection",
|
||||
{
|
||||
operation: "ws_pong_timeout",
|
||||
userId,
|
||||
sessionId: currentSessionId,
|
||||
},
|
||||
);
|
||||
ws.terminate();
|
||||
return;
|
||||
}
|
||||
wsAlive = false;
|
||||
ws.ping();
|
||||
}
|
||||
}, 30000);
|
||||
@@ -624,6 +706,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
hostName: s.hostName,
|
||||
createdAt: s.createdAt,
|
||||
lastDetachedAt: s.lastDetachedAt,
|
||||
tmuxSessionName: s.tmuxSessionName,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
@@ -678,6 +761,52 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
|
||||
case "tmux_attach": {
|
||||
const tmuxData = data as { sessionName: string };
|
||||
const session = currentSessionId
|
||||
? sessionManager.getSession(currentSessionId)
|
||||
: null;
|
||||
if (session?.sshStream) {
|
||||
const existingName = tmuxData.sessionName || undefined;
|
||||
attachOrCreateTmuxSession(session.sshStream, existingName);
|
||||
if (existingName) {
|
||||
session.tmuxSessionName = existingName;
|
||||
sshLogger.info("User selected tmux session to attach", {
|
||||
operation: "tmux_user_attach",
|
||||
sessionName: existingName,
|
||||
hostId: session.hostId,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_session_attached",
|
||||
sessionName: existingName,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// New session from picker -- query name after startup
|
||||
const sshConn = session.sshConn;
|
||||
setTimeout(async () => {
|
||||
const sessionName = sshConn
|
||||
? await queryNewestTmuxSession(sshConn)
|
||||
: null;
|
||||
session.tmuxSessionName = sessionName;
|
||||
sshLogger.info("User requested new tmux session", {
|
||||
operation: "tmux_user_create",
|
||||
sessionName,
|
||||
hostId: session.hostId,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_session_created",
|
||||
sessionName,
|
||||
}),
|
||||
);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "totp_response": {
|
||||
const totpData = data as TOTPResponseData;
|
||||
if (keyboardInteractiveFinish && totpData?.code) {
|
||||
@@ -1048,6 +1177,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
// Resolve credentials server-side when frontend doesn't provide them
|
||||
let resolvedCredentials = {
|
||||
username,
|
||||
password,
|
||||
@@ -1057,94 +1187,52 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
authType,
|
||||
};
|
||||
const authMethodNotAvailable = false;
|
||||
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 (id && userId && !password && !key) {
|
||||
try {
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
const resolvedHost = await resolveHostById(id, userId);
|
||||
if (resolvedHost) {
|
||||
resolvedCredentials = {
|
||||
username: resolvedHost.username || username,
|
||||
password: resolvedHost.password,
|
||||
key: resolvedHost.key,
|
||||
keyPassword: resolvedHost.keyPassword,
|
||||
keyType: resolvedHost.keyType,
|
||||
authType: resolvedHost.authType,
|
||||
};
|
||||
sendLog(
|
||||
"auth",
|
||||
"info",
|
||||
"Credentials resolved from server-side host data",
|
||||
);
|
||||
|
||||
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 {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
ownerId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
username: (credential.username as string | undefined) || username,
|
||||
password: credential.password as string | undefined,
|
||||
key: credential.privateKey as string | undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
keyType: credential.keyType as string | undefined,
|
||||
authType: credential.authType as string | undefined,
|
||||
};
|
||||
} else {
|
||||
sshLogger.warn(`No credentials found for host ${id}`, {
|
||||
operation: "ssh_credentials",
|
||||
hostId: id,
|
||||
credentialId,
|
||||
userId: ownerId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.warn(`Failed to resolve credentials for host ${id}`, {
|
||||
operation: "ssh_credentials",
|
||||
hostId: id,
|
||||
credentialId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.warn(`Failed to resolve host credentials for ${id}`, {
|
||||
operation: "ssh_credentials",
|
||||
hostId: id,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
} else if (credentialId && id && userId) {
|
||||
try {
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
const resolvedHost = await resolveHostById(id, userId);
|
||||
if (resolvedHost) {
|
||||
resolvedCredentials = {
|
||||
username: resolvedHost.username || username,
|
||||
password: resolvedHost.password,
|
||||
key: resolvedHost.key,
|
||||
keyPassword: resolvedHost.keyPassword,
|
||||
keyType: resolvedHost.keyType,
|
||||
authType: resolvedHost.authType,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
sshLogger.warn("Missing userId for credential resolution in terminal", {
|
||||
} catch (error) {
|
||||
sshLogger.warn(`Failed to resolve credentials for host ${id}`, {
|
||||
operation: "ssh_credentials",
|
||||
hostId: id,
|
||||
credentialId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1331,7 +1419,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
sshConn!,
|
||||
stream,
|
||||
lastJumpClient,
|
||||
opksshTempFiles,
|
||||
);
|
||||
sessionManager.attachWs(currentSessionId, userId, ws);
|
||||
|
||||
@@ -1422,16 +1509,115 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
});
|
||||
|
||||
if (initialPath && initialPath.trim() !== "") {
|
||||
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
|
||||
stream.write(cdCommand);
|
||||
}
|
||||
const autoTmux = hostConfig.terminalConfig?.autoTmux === true;
|
||||
|
||||
if (executeCommand && executeCommand.trim() !== "") {
|
||||
// Helper to run initialPath/executeCommand after the shell
|
||||
// (or tmux session) is ready
|
||||
const runPostShellCommands = (delay: number) => {
|
||||
setTimeout(() => {
|
||||
const command = `${executeCommand}\n`;
|
||||
stream.write(command);
|
||||
}, 500);
|
||||
if (initialPath && initialPath.trim() !== "") {
|
||||
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\r`;
|
||||
stream.write(cdCommand);
|
||||
}
|
||||
if (executeCommand && executeCommand.trim() !== "") {
|
||||
setTimeout(() => {
|
||||
stream.write(`${executeCommand}\r`);
|
||||
}, 300);
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
|
||||
if (autoTmux && conn) {
|
||||
(async () => {
|
||||
try {
|
||||
const detection = await detectTmux(conn);
|
||||
if (!detection.available) {
|
||||
sshLogger.warn("tmux not found on remote host", {
|
||||
operation: "tmux_detection",
|
||||
hostId: id,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_unavailable",
|
||||
message:
|
||||
"tmux is not installed on the remote host. Falling back to standard shell.",
|
||||
}),
|
||||
);
|
||||
// tmux unavailable, run commands in plain shell
|
||||
runPostShellCommands(0);
|
||||
} else if (detection.sessions.length === 0) {
|
||||
attachOrCreateTmuxSession(stream);
|
||||
// Query the name tmux assigned after a short delay
|
||||
setTimeout(async () => {
|
||||
const sessionName = await queryNewestTmuxSession(conn);
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session) {
|
||||
session.tmuxSessionName = sessionName;
|
||||
}
|
||||
sshLogger.info("Created new tmux session", {
|
||||
operation: "tmux_new_session",
|
||||
sessionName,
|
||||
hostId: id,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_session_created",
|
||||
sessionName,
|
||||
}),
|
||||
);
|
||||
}, 500);
|
||||
// Wait for tmux to start before running commands inside it
|
||||
runPostShellCommands(500);
|
||||
} else if (detection.sessions.length === 1) {
|
||||
attachOrCreateTmuxSession(stream, detection.sessions[0].name);
|
||||
const sessionName = detection.sessions[0].name;
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session) {
|
||||
session.tmuxSessionName = sessionName;
|
||||
}
|
||||
sshLogger.info("Auto-attached to existing tmux session", {
|
||||
operation: "tmux_auto_attach",
|
||||
sessionName,
|
||||
hostId: id,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_session_attached",
|
||||
sessionName,
|
||||
}),
|
||||
);
|
||||
// Reattaching to existing session -- don't re-run
|
||||
// initialPath/executeCommand since the session already
|
||||
// has its own state
|
||||
} else {
|
||||
sshLogger.info(
|
||||
"Multiple tmux sessions found, sending list to frontend",
|
||||
{
|
||||
operation: "tmux_sessions_available",
|
||||
sessions: detection.sessions,
|
||||
hostId: id,
|
||||
},
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_sessions_available",
|
||||
sessions: detection.sessions,
|
||||
}),
|
||||
);
|
||||
// Commands deferred until user picks a session
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error("tmux detection failed", error, {
|
||||
operation: "tmux_detection_error",
|
||||
hostId: id,
|
||||
});
|
||||
// Fallback: run commands in plain shell
|
||||
runPostShellCommands(0);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
// No tmux -- run commands directly as before
|
||||
runPostShellCommands(0);
|
||||
}
|
||||
|
||||
ws.send(
|
||||
@@ -1801,7 +1987,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
host: ip,
|
||||
port,
|
||||
username,
|
||||
tryKeyboard: true,
|
||||
tryKeyboard: resolvedCredentials.authType !== "none",
|
||||
keepaliveInterval:
|
||||
typeof hostKeepaliveInterval === "number"
|
||||
? hostKeepaliveInterval
|
||||
@@ -1859,18 +2045,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: [
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
cipher: SSH_ALGORITHMS.cipher,
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
@@ -1974,19 +2149,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
sendLog("auth", "info", "Using cached OPKSSH certificate");
|
||||
|
||||
const { promises: fs } = await import("fs");
|
||||
const path = await import("path");
|
||||
const os = await import("os");
|
||||
|
||||
const tempDir = os.tmpdir();
|
||||
const keyPath = path.join(tempDir, `opkssh-${userId}-${id}`);
|
||||
const certPath = `${keyPath}-cert.pub`;
|
||||
|
||||
await fs.writeFile(keyPath, token.privateKey, { mode: 0o600 });
|
||||
await fs.writeFile(certPath, token.sshCert, { mode: 0o600 });
|
||||
|
||||
opksshTempFiles = { keyPath, certPath };
|
||||
connectConfig.privateKey = await fs.readFile(keyPath);
|
||||
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
|
||||
await setupOPKSSHCertAuth(connectConfig, sshConn, token, username);
|
||||
} catch (opksshError) {
|
||||
sshLogger.error("OPKSSH authentication error", opksshError, {
|
||||
operation: "opkssh_auth_error",
|
||||
@@ -2017,6 +2181,24 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
hostConfig.portKnockSequence &&
|
||||
hostConfig.portKnockSequence.length > 0
|
||||
) {
|
||||
try {
|
||||
sshLogger.info(
|
||||
`Port knocking ${hostConfig.ip} (${hostConfig.portKnockSequence.length} ports)`,
|
||||
{ operation: "port_knock", hostId: hostConfig.id },
|
||||
);
|
||||
await performPortKnocking(hostConfig.ip, hostConfig.portKnockSequence);
|
||||
} catch (err) {
|
||||
sshLogger.warn("Port knocking failed, attempting connection anyway", {
|
||||
operation: "port_knock",
|
||||
hostId: hostConfig.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const proxyConfig: SOCKS5Config | null =
|
||||
hostConfig.useSocks5 &&
|
||||
(hostConfig.socks5Host ||
|
||||
@@ -2171,6 +2353,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
} else {
|
||||
sendLog("handshake", "info", "Starting SSH session");
|
||||
sendLog("auth", "info", `Authenticating as ${username}`);
|
||||
|
||||
sshLogger.info("Initiating SSH connection", {
|
||||
operation: "terminal_ssh_connect_attempt",
|
||||
sessionId,
|
||||
@@ -2219,7 +2402,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
sshStream = null;
|
||||
sshConn = null;
|
||||
lastJumpClient = null;
|
||||
opksshTempFiles = null;
|
||||
|
||||
resetConnectionState();
|
||||
isCleaningUp = false;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { Client, ClientChannel } from "ssh2";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
|
||||
export interface TmuxSessionInfo {
|
||||
name: string;
|
||||
created: number;
|
||||
lastActivity: number;
|
||||
windows: number;
|
||||
attachedClients: number;
|
||||
}
|
||||
|
||||
export interface TmuxDetectionResult {
|
||||
available: boolean;
|
||||
sessions: TmuxSessionInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command on the remote host via a separate exec channel.
|
||||
* Returns stdout as a string. Does not pollute the interactive shell.
|
||||
*/
|
||||
export function execCommand(conn: Client, command: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
conn.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
stream.on("data", (data: Buffer) => {
|
||||
stdout += data.toString("utf-8");
|
||||
});
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString("utf-8");
|
||||
});
|
||||
stream.on("error", (err: Error) => {
|
||||
reject(err);
|
||||
});
|
||||
stream.on("close", (code: number) => {
|
||||
if (code !== 0 && stdout === "") {
|
||||
reject(
|
||||
new Error(stderr.trim() || `Command exited with code ${code}`),
|
||||
);
|
||||
} else {
|
||||
resolve(stdout.trim());
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether tmux is installed and list all existing sessions with details.
|
||||
*/
|
||||
export async function detectTmux(conn: Client): Promise<TmuxDetectionResult> {
|
||||
try {
|
||||
await execCommand(conn, "command -v tmux");
|
||||
} catch {
|
||||
return { available: false, sessions: [] };
|
||||
}
|
||||
|
||||
let sessions: TmuxSessionInfo[] = [];
|
||||
try {
|
||||
const output = await execCommand(
|
||||
conn,
|
||||
`tmux list-sessions -F "#{session_name}|#{session_created}|#{session_activity}|#{session_windows}|#{session_attached}" 2>/dev/null`,
|
||||
);
|
||||
if (output) {
|
||||
sessions = output
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
const [name, created, activity, windows, attached] = line.split("|");
|
||||
return {
|
||||
name,
|
||||
created: parseInt(created, 10) || 0,
|
||||
lastActivity: parseInt(activity, 10) || 0,
|
||||
windows: parseInt(windows, 10) || 0,
|
||||
attachedClients: parseInt(attached, 10) || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// tmux server not running yet -- no sessions exist
|
||||
}
|
||||
|
||||
return { available: true, sessions };
|
||||
}
|
||||
|
||||
// tmux options applied on every attach/create:
|
||||
// - mouse on: enables mouse wheel / touch scrollback through tmux history
|
||||
// - history-limit: deep scrollback buffer on the remote host
|
||||
// - set-clipboard on: use OSC 52 to sync tmux selections to the client clipboard
|
||||
// - mode-keys vi: use vi-style keys in copy mode
|
||||
// - MouseDragEnd: stop the selection but keep it highlighted so the user can
|
||||
// adjust and press Enter to copy (or drag again)
|
||||
// - Enter: copy the (possibly adjusted) selection and exit copy mode
|
||||
// - pane-mode-changed hook: on copy-mode entry, show a brief hint so users
|
||||
// know to press Enter to copy the selection
|
||||
// Using -q on set/set-hook to suppress errors on older tmux versions that don't support
|
||||
// a particular option (e.g. set-clipboard on tmux < 2.5). Note: set-hook doesn't support -q.
|
||||
const TMUX_OPTS =
|
||||
`set -gq mouse on` +
|
||||
` \\; set -gq history-limit 50000` +
|
||||
` \\; set -gq set-clipboard on` +
|
||||
` \\; set -gq mode-keys vi` +
|
||||
` \\; bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X stop-selection` +
|
||||
` \\; bind-key -T copy-mode-vi Enter send-keys -X copy-selection-and-cancel` +
|
||||
` \\; set-hook -g pane-mode-changed` +
|
||||
` 'if -F "#{pane_in_mode}"` +
|
||||
` "display-message -d 2500 \\"Adjust selection and press Enter to copy\\""'`;
|
||||
|
||||
/**
|
||||
* Write tmux attach or new-session command to the interactive shell stream.
|
||||
* Uses && exit so the shell only closes if tmux started successfully.
|
||||
*/
|
||||
export function attachOrCreateTmuxSession(
|
||||
stream: ClientChannel,
|
||||
existingSessionName?: string,
|
||||
): void {
|
||||
let command: string;
|
||||
if (existingSessionName) {
|
||||
command = `tmux ${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)} && exit\r`;
|
||||
} else {
|
||||
command = `tmux ${TMUX_OPTS} \\; new-session && exit\r`;
|
||||
}
|
||||
|
||||
sshLogger.info("Writing tmux command to shell", {
|
||||
operation: "tmux_attach_or_create",
|
||||
sessionName: existingSessionName || "(auto)",
|
||||
isReattach: !!existingSessionName,
|
||||
});
|
||||
|
||||
stream.write(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the name of the most recently created tmux session via exec channel.
|
||||
*/
|
||||
export async function queryNewestTmuxSession(
|
||||
conn: Client,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const output = await execCommand(
|
||||
conn,
|
||||
`tmux list-sessions -F "#{session_created}:#{session_name}" 2>/dev/null | sort -rn | head -1 | cut -d: -f2-`,
|
||||
);
|
||||
return output || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shellEscape(s: string): string {
|
||||
return "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
+99
-153
@@ -1,7 +1,8 @@
|
||||
import express, { type Response } from "express";
|
||||
import cors from "cors";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { Client } from "ssh2";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { ChildProcess } from "child_process";
|
||||
import axios from "axios";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
@@ -26,40 +27,7 @@ import { PermissionManager } from "../utils/permission-manager.js";
|
||||
import { withConnection } from "./ssh-connection-pool.js";
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
const allowedOrigins = ["http://localhost:5173", "http://127.0.0.1:5173"];
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("http://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Origin",
|
||||
"X-Requested-With",
|
||||
"Content-Type",
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
],
|
||||
}),
|
||||
);
|
||||
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
||||
app.use(cookieParser());
|
||||
app.use(express.json());
|
||||
app.use((_req, res, next) => {
|
||||
@@ -546,7 +514,7 @@ async function connectSSHTunnel(
|
||||
|
||||
tunnelConnecting.add(tunnelName);
|
||||
|
||||
cleanupTunnelResources(tunnelName, true);
|
||||
await cleanupTunnelResources(tunnelName, true);
|
||||
|
||||
if (retryAttempt === 0) {
|
||||
retryExhaustedTunnels.delete(tunnelName);
|
||||
@@ -602,70 +570,53 @@ async function connectSSHTunnel(
|
||||
const effectiveUserId =
|
||||
tunnelConfig.requestingUserId || tunnelConfig.sourceUserId;
|
||||
|
||||
if (tunnelConfig.sourceCredentialId && effectiveUserId) {
|
||||
// Resolve source credentials server-side when not provided by frontend
|
||||
if (
|
||||
tunnelConfig.sourceHostId &&
|
||||
effectiveUserId &&
|
||||
!tunnelConfig.sourcePassword &&
|
||||
!tunnelConfig.sourceSSHKey
|
||||
) {
|
||||
try {
|
||||
if (
|
||||
tunnelConfig.requestingUserId &&
|
||||
tunnelConfig.requestingUserId !== tunnelConfig.sourceUserId
|
||||
) {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
|
||||
if (tunnelConfig.sourceHostId) {
|
||||
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
|
||||
tunnelConfig.sourceHostId,
|
||||
tunnelConfig.requestingUserId,
|
||||
);
|
||||
|
||||
if (sharedCred) {
|
||||
resolvedSourceCredentials = {
|
||||
password: sharedCred.password,
|
||||
sshKey: sharedCred.key,
|
||||
keyPassword: sharedCred.keyPassword,
|
||||
keyType: sharedCred.keyType,
|
||||
authMethod: sharedCred.authType,
|
||||
};
|
||||
} else {
|
||||
const errorMessage = `Cannot connect tunnel '${tunnelName}': shared credentials not available`;
|
||||
tunnelLogger.error(errorMessage, undefined, {
|
||||
operation: "tunnel_shared_credentials_unavailable",
|
||||
tunnelName,
|
||||
requestingUserId: tunnelConfig.requestingUserId,
|
||||
sourceUserId: tunnelConfig.sourceUserId,
|
||||
sourceHostId: tunnelConfig.sourceHostId,
|
||||
});
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: errorMessage,
|
||||
});
|
||||
tunnelConnecting.delete(tunnelName);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const userDataKey = DataCrypto.getUserDataKey(effectiveUserId);
|
||||
if (userDataKey) {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, tunnelConfig.sourceCredentialId)),
|
||||
"ssh_credentials",
|
||||
effectiveUserId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedSourceCredentials = {
|
||||
password: credential.password as string | undefined,
|
||||
sshKey: credential.privateKey as string | undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
keyType: credential.keyType as string | undefined,
|
||||
authMethod: credential.authType as string,
|
||||
};
|
||||
}
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
const resolvedHost = await resolveHostById(
|
||||
tunnelConfig.sourceHostId,
|
||||
effectiveUserId,
|
||||
);
|
||||
if (resolvedHost) {
|
||||
resolvedSourceCredentials = {
|
||||
password: resolvedHost.password,
|
||||
sshKey: resolvedHost.key,
|
||||
keyPassword: resolvedHost.keyPassword,
|
||||
keyType: resolvedHost.keyType,
|
||||
authMethod: resolvedHost.authType,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
tunnelLogger.warn("Failed to resolve source host credentials", {
|
||||
operation: "tunnel_connect",
|
||||
tunnelName,
|
||||
sourceHostId: tunnelConfig.sourceHostId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
} else if (tunnelConfig.sourceCredentialId && effectiveUserId) {
|
||||
// Legacy: credential resolution from credentialId
|
||||
try {
|
||||
if (tunnelConfig.sourceHostId) {
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
const resolvedHost = await resolveHostById(
|
||||
tunnelConfig.sourceHostId,
|
||||
effectiveUserId,
|
||||
);
|
||||
if (resolvedHost) {
|
||||
resolvedSourceCredentials = {
|
||||
password: resolvedHost.password,
|
||||
sshKey: resolvedHost.key,
|
||||
keyPassword: resolvedHost.keyPassword,
|
||||
keyType: resolvedHost.keyType,
|
||||
authMethod: resolvedHost.authType,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1152,18 +1103,7 @@ async function connectSSHTunnel(
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: [
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
cipher: SSH_ALGORITHMS.cipher,
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
@@ -1328,34 +1268,31 @@ async function killRemoteTunnelByMarker(
|
||||
authMethod: tunnelConfig.sourceAuthMethod,
|
||||
};
|
||||
|
||||
if (tunnelConfig.sourceCredentialId && tunnelConfig.sourceUserId) {
|
||||
if (
|
||||
tunnelConfig.sourceHostId &&
|
||||
tunnelConfig.sourceUserId &&
|
||||
!tunnelConfig.sourcePassword &&
|
||||
!tunnelConfig.sourceSSHKey
|
||||
) {
|
||||
try {
|
||||
const userDataKey = DataCrypto.getUserDataKey(tunnelConfig.sourceUserId);
|
||||
if (userDataKey) {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, tunnelConfig.sourceCredentialId)),
|
||||
"ssh_credentials",
|
||||
tunnelConfig.sourceUserId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedSourceCredentials = {
|
||||
password: credential.password as string | undefined,
|
||||
sshKey: credential.privateKey as string | undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
keyType: credential.keyType as string | undefined,
|
||||
authMethod: credential.authType as string,
|
||||
};
|
||||
}
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
const resolvedHost = await resolveHostById(
|
||||
tunnelConfig.sourceHostId,
|
||||
tunnelConfig.sourceUserId,
|
||||
);
|
||||
if (resolvedHost) {
|
||||
resolvedSourceCredentials = {
|
||||
password: resolvedHost.password,
|
||||
sshKey: resolvedHost.key,
|
||||
keyPassword: resolvedHost.keyPassword,
|
||||
keyType: resolvedHost.keyType,
|
||||
authMethod: resolvedHost.authType,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
tunnelLogger.warn("Failed to resolve source credentials for cleanup", {
|
||||
tunnelName,
|
||||
credentialId: tunnelConfig.sourceCredentialId,
|
||||
sourceHostId: tunnelConfig.sourceHostId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
@@ -1774,13 +1711,38 @@ app.post(
|
||||
tunnelConfig.endpointIP = endpointHost.ip;
|
||||
tunnelConfig.endpointSSHPort = endpointHost.port;
|
||||
tunnelConfig.endpointUsername = endpointHost.username;
|
||||
tunnelConfig.endpointPassword = endpointHost.password;
|
||||
tunnelConfig.endpointAuthMethod = endpointHost.authType;
|
||||
tunnelConfig.endpointSSHKey = endpointHost.key;
|
||||
tunnelConfig.endpointKeyPassword = endpointHost.keyPassword;
|
||||
tunnelConfig.endpointKeyType = endpointHost.keyType;
|
||||
tunnelConfig.endpointCredentialId = endpointHost.credentialId;
|
||||
tunnelConfig.endpointUserId = endpointHost.userId;
|
||||
|
||||
// Resolve credentials server-side instead of from HTTP response
|
||||
if (endpointHost.id && endpointHost.userId) {
|
||||
try {
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
const resolved = await resolveHostById(
|
||||
endpointHost.id,
|
||||
endpointHost.userId,
|
||||
);
|
||||
if (resolved) {
|
||||
tunnelConfig.endpointPassword = resolved.password;
|
||||
tunnelConfig.endpointSSHKey = resolved.key;
|
||||
tunnelConfig.endpointKeyPassword = resolved.keyPassword;
|
||||
}
|
||||
} catch (credError) {
|
||||
tunnelLogger.warn(
|
||||
"Failed to resolve endpoint credentials from DB",
|
||||
{
|
||||
operation: "tunnel_endpoint_credential_resolve",
|
||||
endpointHostId: endpointHost.id,
|
||||
error:
|
||||
credError instanceof Error
|
||||
? credError.message
|
||||
: "Unknown",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (resolveError) {
|
||||
tunnelLogger.error(
|
||||
"Failed to resolve endpoint host",
|
||||
@@ -2082,11 +2044,7 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
sourceIP: host.ip,
|
||||
sourceSSHPort: host.port,
|
||||
sourceUsername: host.username,
|
||||
sourcePassword: host.autostartPassword || host.password,
|
||||
sourceAuthMethod: host.authType,
|
||||
sourceSSHKey: host.autostartKey || host.key,
|
||||
sourceKeyPassword:
|
||||
host.autostartKeyPassword || host.keyPassword,
|
||||
sourceKeyType: host.keyType,
|
||||
sourceCredentialId: host.credentialId,
|
||||
sourceUserId: host.userId,
|
||||
@@ -2094,20 +2052,8 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
endpointSSHPort: endpointHost.port,
|
||||
endpointUsername: endpointHost.username,
|
||||
endpointHost: tunnelConnection.endpointHost,
|
||||
endpointPassword:
|
||||
tunnelConnection.endpointPassword ||
|
||||
endpointHost.autostartPassword ||
|
||||
endpointHost.password,
|
||||
endpointAuthMethod:
|
||||
tunnelConnection.endpointAuthType || endpointHost.authType,
|
||||
endpointSSHKey:
|
||||
tunnelConnection.endpointKey ||
|
||||
endpointHost.autostartKey ||
|
||||
endpointHost.key,
|
||||
endpointKeyPassword:
|
||||
tunnelConnection.endpointKeyPassword ||
|
||||
endpointHost.autostartKeyPassword ||
|
||||
endpointHost.keyPassword,
|
||||
endpointKeyType:
|
||||
tunnelConnection.endpointKeyType || endpointHost.keyType,
|
||||
endpointCredentialId: endpointHost.credentialId,
|
||||
|
||||
@@ -50,10 +50,10 @@ export async function collectLoginStats(client: Client): Promise<LoginStats> {
|
||||
try {
|
||||
const date = new Date(timeStr);
|
||||
parsedTime = isNaN(date.getTime())
|
||||
? new Date().toISOString()
|
||||
? timeStr || "unknown"
|
||||
: date.toISOString();
|
||||
} catch {
|
||||
parsedTime = new Date().toISOString();
|
||||
parsedTime = timeStr || "unknown";
|
||||
}
|
||||
|
||||
recentLogins.push({
|
||||
@@ -99,21 +99,30 @@ export async function collectLoginStats(client: Client): Promise<LoginStats> {
|
||||
ip = ipMatch[1];
|
||||
}
|
||||
|
||||
const dateMatch = line.match(/^(\w+\s+\d+\s+\d+:\d+:\d+)/);
|
||||
const dateMatch = line.match(/^(\w+)\s+(\d+)\s+(\d+:\d+:\d+)/);
|
||||
if (dateMatch) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
timeStr = `${currentYear} ${dateMatch[1]}`;
|
||||
const [, month, day, time] = dateMatch;
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const candidate = new Date(`${month} ${day}, ${currentYear} ${time}`);
|
||||
if (!isNaN(candidate.getTime()) && candidate > now) {
|
||||
// If parsed date is in the future, it's from last year
|
||||
timeStr = `${month} ${day}, ${currentYear - 1} ${time}`;
|
||||
} else {
|
||||
timeStr = `${month} ${day}, ${currentYear} ${time}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (user && ip) {
|
||||
let parsedTime: string;
|
||||
try {
|
||||
const date = timeStr ? new Date(timeStr) : new Date();
|
||||
parsedTime = isNaN(date.getTime())
|
||||
? new Date().toISOString()
|
||||
: date.toISOString();
|
||||
const date = timeStr ? new Date(timeStr) : null;
|
||||
parsedTime =
|
||||
date && !isNaN(date.getTime())
|
||||
? date.toISOString()
|
||||
: timeStr || "unknown";
|
||||
} catch {
|
||||
parsedTime = new Date().toISOString();
|
||||
parsedTime = timeStr || "unknown";
|
||||
}
|
||||
|
||||
failedLogins.push({
|
||||
|
||||
+51
-34
@@ -7,7 +7,11 @@ import { AutoSSLSetup } from "./utils/auto-ssl-setup.js";
|
||||
import { AuthManager } from "./utils/auth-manager.js";
|
||||
import { DataCrypto } from "./utils/data-crypto.js";
|
||||
import { SystemCrypto } from "./utils/system-crypto.js";
|
||||
import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
import {
|
||||
systemLogger,
|
||||
versionLogger,
|
||||
setGlobalLogLevel,
|
||||
} from "./utils/logger.js";
|
||||
|
||||
(async () => {
|
||||
const initStartTime = Date.now();
|
||||
@@ -112,28 +116,27 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
await authManager.initialize();
|
||||
DataCrypto.initialize();
|
||||
|
||||
const { OPKSSHBinaryManager } =
|
||||
await import("./utils/opkssh-binary-manager.js");
|
||||
try {
|
||||
await OPKSSHBinaryManager.ensureBinary();
|
||||
} catch (error) {
|
||||
const dataDir =
|
||||
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
|
||||
systemLogger.warn(
|
||||
"Failed to initialize OPKSSH binary - OPKSSH authentication will not be available",
|
||||
{
|
||||
operation: "opkssh_binary_init_failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
dataDir,
|
||||
},
|
||||
);
|
||||
}
|
||||
import("./utils/opkssh-binary-manager.js").then(
|
||||
({ OPKSSHBinaryManager }) => {
|
||||
OPKSSHBinaryManager.ensureBinary().catch((error) => {
|
||||
const dataDir =
|
||||
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
|
||||
systemLogger.warn(
|
||||
"Failed to initialize OPKSSH binary - OPKSSH authentication will not be available",
|
||||
{
|
||||
operation: "opkssh_binary_init_failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
dataDir,
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await import("./database/database.js");
|
||||
|
||||
await import("./ssh/terminal.js");
|
||||
await import("./ssh/tunnel.js");
|
||||
await import("./ssh/file-manager.js");
|
||||
@@ -142,6 +145,19 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
await import("./ssh/docker-console.js");
|
||||
await import("./dashboard.js");
|
||||
|
||||
// Initialize log level from database settings
|
||||
const { getDb: getDbForSettings } = await import("./database/db/index.js");
|
||||
const settingsDb = getDbForSettings();
|
||||
const logLevelRow = settingsDb.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'log_level'")
|
||||
.get() as { value: string } | undefined;
|
||||
if (logLevelRow) {
|
||||
setGlobalLogLevel(logLevelRow.value);
|
||||
systemLogger.info(`Log level set to: ${logLevelRow.value}`, {
|
||||
operation: "log_level_init",
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize Guacamole server for RDP/VNC/Telnet support
|
||||
const { getDb: getDbForGuac } = await import("./database/db/index.js");
|
||||
const guacDb = getDbForGuac();
|
||||
@@ -153,20 +169,21 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
: true;
|
||||
|
||||
if (process.env.ENABLE_GUACAMOLE !== "false" && guacEnabled) {
|
||||
try {
|
||||
await import("./guacamole/guacamole-server.js");
|
||||
systemLogger.info("Guacamole server initialized", {
|
||||
operation: "guac_init",
|
||||
import("./guacamole/guacamole-server.js")
|
||||
.then(() => {
|
||||
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",
|
||||
},
|
||||
);
|
||||
});
|
||||
} 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", {
|
||||
|
||||
@@ -4,7 +4,11 @@ import { SystemCrypto } from "./system-crypto.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { databaseLogger, authLogger } from "./logger.js";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { db } from "../database/db/index.js";
|
||||
import {
|
||||
db,
|
||||
getSqlite,
|
||||
saveMemoryDatabaseToFile,
|
||||
} from "../database/db/index.js";
|
||||
import { sessions, trustedDevices } from "../database/db/schema.js";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
@@ -206,15 +210,20 @@ class AuthManager {
|
||||
): Promise<string> {
|
||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||
|
||||
const timeoutRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'session_timeout_hours'")
|
||||
.get() as { value: string } | undefined;
|
||||
const defaultExpiry = `${timeoutRow ? parseInt(timeoutRow.value, 10) || 24 : 24}h`;
|
||||
|
||||
let expiresIn = options.expiresIn;
|
||||
if (!expiresIn && !options.pendingTOTP) {
|
||||
if (options.rememberMe) {
|
||||
expiresIn = "30d";
|
||||
} else {
|
||||
expiresIn = "24h";
|
||||
expiresIn = defaultExpiry;
|
||||
}
|
||||
} else if (!expiresIn) {
|
||||
expiresIn = "24h";
|
||||
expiresIn = defaultExpiry;
|
||||
}
|
||||
|
||||
const payload: JWTPayload = { userId };
|
||||
@@ -567,6 +576,13 @@ class AuthManager {
|
||||
return res.status(401).json({ error: "Invalid token" });
|
||||
}
|
||||
|
||||
if (payload.pendingTOTP) {
|
||||
return res.status(401).json({
|
||||
error: "TOTP verification required",
|
||||
code: "TOTP_REQUIRED",
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.sessionId) {
|
||||
try {
|
||||
const sessionRecords = await db
|
||||
@@ -708,6 +724,13 @@ class AuthManager {
|
||||
return res.status(401).json({ error: "Invalid token" });
|
||||
}
|
||||
|
||||
if (payload.pendingTOTP) {
|
||||
return res.status(401).json({
|
||||
error: "TOTP verification required",
|
||||
code: "TOTP_REQUIRED",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { db } = await import("../database/db/index.js");
|
||||
const { users } = await import("../database/db/schema.js");
|
||||
@@ -785,6 +808,22 @@ class AuthManager {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } =
|
||||
await import("../database/db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to revoke all sessions on logout", error, {
|
||||
operation: "session_revoke_all_failed",
|
||||
userId,
|
||||
});
|
||||
}
|
||||
this.userCrypto.logoutUser(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import cors from "cors";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { getRequestOrigin } from "./request-origin.js";
|
||||
|
||||
const DEV_ORIGINS = ["http://localhost:5173", "http://127.0.0.1:5173"];
|
||||
const ELECTRON_FILE_ORIGIN = "file://";
|
||||
|
||||
function getAllowedOrigins(): string[] {
|
||||
const envOrigins = process.env.CORS_ALLOWED_ORIGINS;
|
||||
if (!envOrigins) return [];
|
||||
return envOrigins
|
||||
.split(",")
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isLocalRequest(req: Request): boolean {
|
||||
const remoteAddr = req.socket?.remoteAddress || req.ip || "";
|
||||
return (
|
||||
remoteAddr === "127.0.0.1" ||
|
||||
remoteAddr === "::1" ||
|
||||
remoteAddr === "::ffff:127.0.0.1"
|
||||
);
|
||||
}
|
||||
|
||||
export function createCorsMiddleware(
|
||||
methods: string[] = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
extraHeaders: string[] = [],
|
||||
) {
|
||||
const allowedHeaders = [
|
||||
"Origin",
|
||||
"X-Requested-With",
|
||||
"Content-Type",
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
"Cache-Control",
|
||||
...extraHeaders,
|
||||
];
|
||||
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const handler = cors({
|
||||
origin: (origin, callback) => {
|
||||
// No origin = same-origin or non-browser request (curl, internal service calls)
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
// Requests coming from localhost (nginx proxy, internal service calls)
|
||||
if (isLocalRequest(req)) return callback(null, true);
|
||||
|
||||
if (DEV_ORIGINS.includes(origin)) return callback(null, true);
|
||||
if (origin.startsWith(ELECTRON_FILE_ORIGIN))
|
||||
return callback(null, true);
|
||||
|
||||
const configured = getAllowedOrigins();
|
||||
if (configured.length === 0) return callback(null, true);
|
||||
if (configured.includes("*") || configured.includes(origin))
|
||||
return callback(null, true);
|
||||
|
||||
const sameOrigin = getRequestOrigin(req);
|
||||
if (origin === sameOrigin) return callback(null, true);
|
||||
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
methods,
|
||||
allowedHeaders,
|
||||
});
|
||||
handler(req, res, next);
|
||||
};
|
||||
}
|
||||
@@ -107,24 +107,24 @@ export class CredentialSystemEncryptionMigration {
|
||||
|
||||
migrated++;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to migrate credential", error, {
|
||||
credentialId: cred.id,
|
||||
userId,
|
||||
});
|
||||
databaseLogger.warn(
|
||||
`Skipping credential migration for credential ${cred.id}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
{
|
||||
operation: "credential_migration_skip",
|
||||
credentialId: cred.id,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
return { migrated, failed, skipped };
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Credential system encryption migration failed",
|
||||
error,
|
||||
{
|
||||
operation: "credential_migration_failed",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
);
|
||||
databaseLogger.warn("Credential system encryption migration incomplete", {
|
||||
operation: "credential_migration_incomplete",
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,33 @@ import chalk from "chalk";
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
|
||||
|
||||
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
success: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
let globalLogLevel: LogLevel = "info";
|
||||
|
||||
export function setGlobalLogLevel(level: string): void {
|
||||
const normalized = level.toLowerCase();
|
||||
if (normalized in LOG_LEVEL_PRIORITY) {
|
||||
globalLogLevel = normalized as LogLevel;
|
||||
}
|
||||
}
|
||||
|
||||
export function getGlobalLogLevel(): LogLevel {
|
||||
return globalLogLevel;
|
||||
}
|
||||
|
||||
// Initialize from environment variable if set
|
||||
const envLogLevel = process.env.LOG_LEVEL?.toLowerCase();
|
||||
if (envLogLevel && envLogLevel in LOG_LEVEL_PRIORITY) {
|
||||
globalLogLevel = envLogLevel as LogLevel;
|
||||
}
|
||||
|
||||
export interface LogContext {
|
||||
service?: string;
|
||||
operation?: string;
|
||||
@@ -140,7 +167,7 @@ export class Logger {
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel, message: string): boolean {
|
||||
if (level === "debug" && process.env.NODE_ENV === "production") {
|
||||
if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[globalLogLevel]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ export class OPKSSHBinaryManager {
|
||||
const osMap: Record<string, string> = {
|
||||
win32: "windows",
|
||||
linux: "linux",
|
||||
darwin: "darwin",
|
||||
darwin: "osx",
|
||||
};
|
||||
|
||||
const archMap: Record<string, string> = {
|
||||
@@ -209,7 +209,7 @@ export class OPKSSHBinaryManager {
|
||||
const osMap: Record<string, string> = {
|
||||
win32: "windows",
|
||||
linux: "linux",
|
||||
darwin: "darwin",
|
||||
darwin: "osx",
|
||||
};
|
||||
|
||||
const archMap: Record<string, string> = {
|
||||
|
||||
@@ -6,10 +6,12 @@ export function getRequestOrigin(req: Request | IncomingMessage): string {
|
||||
const protoHeader = req.headers["x-forwarded-proto"];
|
||||
|
||||
if (protoHeader) {
|
||||
protocol =
|
||||
const raw =
|
||||
typeof protoHeader === "string"
|
||||
? protoHeader.split(",")[0].trim()
|
||||
: protoHeader[0];
|
||||
// Normalize WebSocket protocols to their HTTP equivalents
|
||||
protocol = raw === "wss" ? "https" : raw === "ws" ? "http" : raw;
|
||||
} else if ("protocol" in req && req.protocol) {
|
||||
protocol = req.protocol;
|
||||
} else {
|
||||
|
||||
@@ -379,7 +379,7 @@ class SharedCredentialManager {
|
||||
cred.keyPassword,
|
||||
ownerDEK,
|
||||
credentialId,
|
||||
"key_password",
|
||||
"keyPassword",
|
||||
)
|
||||
: undefined,
|
||||
keyType: cred.keyType,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import crypto from "crypto";
|
||||
import type { ConnectConfig, CipherAlgorithm } from "ssh2";
|
||||
|
||||
// Maps SSH cipher names to their OpenSSL equivalents (as used by ssh2 internally)
|
||||
const SSH_CIPHER_SSL_NAME: Partial<Record<CipherAlgorithm, string>> = {
|
||||
"chacha20-poly1305@openssh.com": "chacha20",
|
||||
"aes256-gcm@openssh.com": "aes-256-gcm",
|
||||
"aes128-gcm@openssh.com": "aes-128-gcm",
|
||||
"aes256-ctr": "aes-256-ctr",
|
||||
"aes192-ctr": "aes-192-ctr",
|
||||
"aes128-ctr": "aes-128-ctr",
|
||||
"aes256-cbc": "aes-256-cbc",
|
||||
"aes192-cbc": "aes-192-cbc",
|
||||
"aes128-cbc": "aes-128-cbc",
|
||||
"3des-cbc": "des-ede3-cbc",
|
||||
};
|
||||
|
||||
const availableCiphers = new Set(crypto.getCiphers());
|
||||
|
||||
function filterCiphers(list: CipherAlgorithm[]): CipherAlgorithm[] {
|
||||
return list.filter((name) => {
|
||||
const sslName = SSH_CIPHER_SSL_NAME[name];
|
||||
return !sslName || availableCiphers.has(sslName);
|
||||
});
|
||||
}
|
||||
|
||||
export const SSH_ALGORITHMS: NonNullable<ConnectConfig["algorithms"]> = {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: filterCiphers([
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
]),
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import dgram from "dgram";
|
||||
|
||||
const MAC_REGEX = /^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/;
|
||||
|
||||
function parseMac(mac: string): Buffer {
|
||||
return Buffer.from(mac.replace(/[:-]/g, ""), "hex");
|
||||
}
|
||||
|
||||
function buildMagicPacket(mac: string): Buffer {
|
||||
const macBytes = parseMac(mac);
|
||||
const packet = Buffer.alloc(102);
|
||||
packet.fill(0xff, 0, 6);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
macBytes.copy(packet, 6 + i * 6);
|
||||
}
|
||||
return packet;
|
||||
}
|
||||
|
||||
export function isValidMac(mac: string): boolean {
|
||||
return MAC_REGEX.test(mac);
|
||||
}
|
||||
|
||||
export function sendWakeOnLan(mac: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isValidMac(mac)) {
|
||||
return reject(new Error("Invalid MAC address"));
|
||||
}
|
||||
|
||||
const packet = buildMagicPacket(mac);
|
||||
const socket = dgram.createSocket("udp4");
|
||||
|
||||
socket.once("error", (err) => {
|
||||
socket.close();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
socket.bind(() => {
|
||||
socket.setBroadcast(true);
|
||||
socket.send(packet, 0, packet.length, 9, "255.255.255.255", (err) => {
|
||||
socket.close();
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "dark" | "light" | "system";
|
||||
type Theme =
|
||||
| "dark"
|
||||
| "light"
|
||||
| "system"
|
||||
| "dracula"
|
||||
| "gentlemansChoice"
|
||||
| "midnightEspresso"
|
||||
| "catppuccinMocha";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -12,11 +19,13 @@ type ThemeProviderProps = {
|
||||
type ThemeProviderState = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
setThemePreview: (theme: Theme | null) => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
setThemePreview: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
@@ -30,13 +39,23 @@ export function ThemeProvider({
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
|
||||
);
|
||||
const [previewTheme, setPreviewTheme] = useState<Theme | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.remove(
|
||||
"light",
|
||||
"dark",
|
||||
"dracula",
|
||||
"gentlemansChoice",
|
||||
"midnightEspresso",
|
||||
"catppuccinMocha",
|
||||
);
|
||||
|
||||
if (theme === "system") {
|
||||
const activeTheme = previewTheme || theme;
|
||||
|
||||
if (activeTheme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
@@ -46,8 +65,18 @@ export function ThemeProvider({
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
root.classList.add(activeTheme);
|
||||
|
||||
const darkCustomThemes: Theme[] = [
|
||||
"dracula",
|
||||
"gentlemansChoice",
|
||||
"midnightEspresso",
|
||||
"catppuccinMocha",
|
||||
];
|
||||
if (darkCustomThemes.includes(activeTheme)) {
|
||||
root.classList.add("dark");
|
||||
}
|
||||
}, [theme, previewTheme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
@@ -55,6 +84,9 @@ export function ThemeProvider({
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
setThemePreview: (theme: Theme | null) => {
|
||||
setPreviewTheme(theme);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -39,9 +39,19 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
message: rateLimitedToast,
|
||||
});
|
||||
|
||||
const darkCustomThemes = [
|
||||
"dracula",
|
||||
"gentlemansChoice",
|
||||
"midnightEspresso",
|
||||
"catppuccinMocha",
|
||||
];
|
||||
const sonnerTheme: ToasterProps["theme"] = darkCustomThemes.includes(theme)
|
||||
? "dark"
|
||||
: (theme as ToasterProps["theme"]);
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
theme={sonnerTheme}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
|
||||
@@ -671,6 +671,62 @@ export const TERMINAL_THEMES: Record<string, TerminalTheme> = {
|
||||
brightWhite: "#a6adc8",
|
||||
},
|
||||
},
|
||||
|
||||
gentlemansChoice: {
|
||||
name: "Gentleman's Choice",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1a1c1a",
|
||||
foreground: "#d1c7a3",
|
||||
cursor: "#d1c7a3",
|
||||
cursorAccent: "#1a1c1a",
|
||||
selectionBackground: "#3e4437",
|
||||
black: "#1a1c1a",
|
||||
red: "#9d3a3a",
|
||||
green: "#5a7a3a",
|
||||
yellow: "#b39a3a",
|
||||
blue: "#3a5a7a",
|
||||
magenta: "#7a3a5a",
|
||||
cyan: "#3a7a7a",
|
||||
white: "#d1c7a3",
|
||||
brightBlack: "#3e4437",
|
||||
brightRed: "#bf4a4a",
|
||||
brightGreen: "#7a9a4a",
|
||||
brightYellow: "#d1b34a",
|
||||
brightBlue: "#4a7abf",
|
||||
brightMagenta: "#9a4abf",
|
||||
brightCyan: "#4abfbf",
|
||||
brightWhite: "#e3dbc3",
|
||||
},
|
||||
},
|
||||
|
||||
midnightEspresso: {
|
||||
name: "Midnight Espresso",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#120f0d",
|
||||
foreground: "#ceb195",
|
||||
cursor: "#ceb195",
|
||||
cursorAccent: "#120f0d",
|
||||
selectionBackground: "#3d2b1f",
|
||||
black: "#120f0d",
|
||||
red: "#a05a4a",
|
||||
green: "#7a8a5a",
|
||||
yellow: "#b08a4a",
|
||||
blue: "#5a7a9a",
|
||||
magenta: "#8a5a7a",
|
||||
cyan: "#5a8a8a",
|
||||
white: "#ceb195",
|
||||
brightBlack: "#3d2b1f",
|
||||
brightRed: "#c07a6a",
|
||||
brightGreen: "#9aaa7a",
|
||||
brightYellow: "#d0aa6a",
|
||||
brightBlue: "#7a9aba",
|
||||
brightMagenta: "#aa7aba",
|
||||
brightCyan: "#7ababa",
|
||||
brightWhite: "#e0cbb5",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TERMINAL_FONTS = [
|
||||
@@ -764,6 +820,7 @@ export const DEFAULT_TERMINAL_CONFIG = {
|
||||
sudoPasswordAutoFill: false,
|
||||
keepaliveInterval: undefined as number | undefined,
|
||||
keepaliveCountMax: undefined as number | undefined,
|
||||
autoTmux: false,
|
||||
};
|
||||
|
||||
export type TerminalConfigType = typeof DEFAULT_TERMINAL_CONFIG;
|
||||
|
||||
+260
@@ -233,6 +233,266 @@
|
||||
--bg-overlay: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.dracula {
|
||||
--background: #282a36;
|
||||
--foreground: #ffffff;
|
||||
--card: #343746;
|
||||
--card-foreground: #ffffff;
|
||||
--popover: #343746;
|
||||
--popover-foreground: #ffffff;
|
||||
--primary: #ffffff;
|
||||
--primary-foreground: #282a36;
|
||||
--secondary: #44475a;
|
||||
--secondary-foreground: #ffffff;
|
||||
--muted: #343746;
|
||||
--muted-foreground: #a6accd;
|
||||
--accent: #44475a;
|
||||
--accent-foreground: #ffffff;
|
||||
--destructive: #ff5555;
|
||||
--border: #44475a;
|
||||
--input: #343746;
|
||||
--ring: #8be9fd;
|
||||
--chart-1: #8be9fd;
|
||||
--chart-2: #50fa7b;
|
||||
--chart-3: #ffb86c;
|
||||
--chart-4: #bd93f9;
|
||||
--chart-5: #ff79c6;
|
||||
--sidebar: #1e1f29;
|
||||
--sidebar-foreground: #ffffff;
|
||||
--sidebar-primary: #8be9fd;
|
||||
--sidebar-primary-foreground: #1e1f29;
|
||||
--sidebar-accent: #44475a;
|
||||
--sidebar-accent-foreground: #ffffff;
|
||||
--sidebar-border: #44475a;
|
||||
--sidebar-ring: #8be9fd;
|
||||
|
||||
--bg-base: #282a36;
|
||||
--bg-elevated: #343746;
|
||||
--bg-surface: #343746;
|
||||
--bg-surface-hover: #44475a;
|
||||
--bg-input: #343746;
|
||||
--bg-deepest: #1e1f29;
|
||||
--bg-header: #1e1f29;
|
||||
--bg-button: #343746;
|
||||
--bg-active: #44475a;
|
||||
--bg-light: #343746;
|
||||
--bg-subtle: #1e1f29;
|
||||
--bg-interact: #44475a;
|
||||
--border-base: #44475a;
|
||||
--border-panel: #1e1f29;
|
||||
--border-subtle: #44475a;
|
||||
--border-medium: #44475a;
|
||||
--bg-hover: #44475a;
|
||||
--bg-hover-alt: #44475a;
|
||||
--bg-pressed: #1e1f29;
|
||||
--border-hover: #6272a4;
|
||||
--border-active: #8be9fd;
|
||||
|
||||
--foreground-secondary: #a6accd;
|
||||
--foreground-subtle: #6272a4;
|
||||
|
||||
--scrollbar-thumb: #44475a;
|
||||
--scrollbar-thumb-hover: #6272a4;
|
||||
--scrollbar-track: #282a36;
|
||||
|
||||
--bg-overlay: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.gentlemansChoice {
|
||||
--background: #1a1c1a;
|
||||
--foreground: #d1c7a3;
|
||||
--card: #2a2c2a;
|
||||
--card-foreground: #d1c7a3;
|
||||
--popover: #2a2c2a;
|
||||
--popover-foreground: #d1c7a3;
|
||||
--primary: #d1c7a3;
|
||||
--primary-foreground: #1a1c1a;
|
||||
--secondary: #3e4437;
|
||||
--secondary-foreground: #d1c7a3;
|
||||
--muted: #2a2c2a;
|
||||
--muted-foreground: #8e8463;
|
||||
--accent: #3e4437;
|
||||
--accent-foreground: #d1c7a3;
|
||||
--destructive: #9d3a3a;
|
||||
--border: #3e4437;
|
||||
--input: #2a2c2a;
|
||||
--ring: #5a7a3a;
|
||||
--chart-1: #5a7a3a;
|
||||
--chart-2: #3a5a7a;
|
||||
--chart-3: #b39a3a;
|
||||
--chart-4: #7a3a5a;
|
||||
--chart-5: #3a7a7a;
|
||||
--sidebar: #151715;
|
||||
--sidebar-foreground: #d1c7a3;
|
||||
--sidebar-primary: #5a7a3a;
|
||||
--sidebar-primary-foreground: #151715;
|
||||
--sidebar-accent: #3e4437;
|
||||
--sidebar-accent-foreground: #d1c7a3;
|
||||
--sidebar-border: #3e4437;
|
||||
--sidebar-ring: #5a7a3a;
|
||||
|
||||
--bg-base: #1a1c1a;
|
||||
--bg-elevated: #2a2c2a;
|
||||
--bg-surface: #2a2c2a;
|
||||
--bg-surface-hover: #3e4437;
|
||||
--bg-input: #2a2c2a;
|
||||
--bg-deepest: #151715;
|
||||
--bg-header: #151715;
|
||||
--bg-button: #2a2c2a;
|
||||
--bg-active: #3e4437;
|
||||
--bg-light: #2a2c2a;
|
||||
--bg-subtle: #151715;
|
||||
--bg-interact: #3e4437;
|
||||
--border-base: #3e4437;
|
||||
--border-panel: #151715;
|
||||
--border-subtle: #3e4437;
|
||||
--border-medium: #3e4437;
|
||||
--bg-hover: #3e4437;
|
||||
--bg-hover-alt: #3e4437;
|
||||
--bg-pressed: #151715;
|
||||
--border-hover: #5a7a3a;
|
||||
--border-active: #d1c7a3;
|
||||
|
||||
--foreground-secondary: #8e8463;
|
||||
--foreground-subtle: #5e5841;
|
||||
|
||||
--scrollbar-thumb: #3e4437;
|
||||
--scrollbar-thumb-hover: #5e5841;
|
||||
--scrollbar-track: #1a1c1a;
|
||||
|
||||
--bg-overlay: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.midnightEspresso {
|
||||
--background: #120f0d;
|
||||
--foreground: #ceb195;
|
||||
--card: #1f1a17;
|
||||
--card-foreground: #ceb195;
|
||||
--popover: #1f1a17;
|
||||
--popover-foreground: #ceb195;
|
||||
--primary: #ceb195;
|
||||
--primary-foreground: #120f0d;
|
||||
--secondary: #3d2b1f;
|
||||
--secondary-foreground: #ceb195;
|
||||
--muted: #1f1a17;
|
||||
--muted-foreground: #9a8a7a;
|
||||
--accent: #3d2b1f;
|
||||
--accent-foreground: #ceb195;
|
||||
--destructive: #a05a4a;
|
||||
--border: #3d2b1f;
|
||||
--input: #1f1a17;
|
||||
--ring: #7a8a5a;
|
||||
--chart-1: #7a8a5a;
|
||||
--chart-2: #5a7a9a;
|
||||
--chart-3: #b08a4a;
|
||||
--chart-4: #8a5a7a;
|
||||
--chart-5: #5a8a8a;
|
||||
--sidebar: #0d0b0a;
|
||||
--sidebar-foreground: #ceb195;
|
||||
--sidebar-primary: #7a8a5a;
|
||||
--sidebar-primary-foreground: #0d0b0a;
|
||||
--sidebar-accent: #3d2b1f;
|
||||
--sidebar-accent-foreground: #ceb195;
|
||||
--sidebar-border: #3d2b1f;
|
||||
--sidebar-ring: #7a8a5a;
|
||||
|
||||
--bg-base: #120f0d;
|
||||
--bg-elevated: #1f1a17;
|
||||
--bg-surface: #1f1a17;
|
||||
--bg-surface-hover: #3d2b1f;
|
||||
--bg-input: #1f1a17;
|
||||
--bg-deepest: #0d0b0a;
|
||||
--bg-header: #0d0b0a;
|
||||
--bg-button: #1f1a17;
|
||||
--bg-active: #3d2b1f;
|
||||
--bg-light: #1f1a17;
|
||||
--bg-subtle: #0d0b0a;
|
||||
--bg-interact: #3d2b1f;
|
||||
--border-base: #3d2b1f;
|
||||
--border-panel: #0d0b0a;
|
||||
--border-subtle: #3d2b1f;
|
||||
--border-medium: #3d2b1f;
|
||||
--bg-hover: #3d2b1f;
|
||||
--bg-hover-alt: #3d2b1f;
|
||||
--bg-pressed: #0d0b0a;
|
||||
--border-hover: #7a8a5a;
|
||||
--border-active: #ceb195;
|
||||
|
||||
--foreground-secondary: #9a8a7a;
|
||||
--foreground-subtle: #6a5a4a;
|
||||
|
||||
--scrollbar-thumb: #3d2b1f;
|
||||
--scrollbar-thumb-hover: #6a5a4a;
|
||||
--scrollbar-track: #120f0d;
|
||||
|
||||
--bg-overlay: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.catppuccinMocha {
|
||||
--background: #1e1e2e;
|
||||
--foreground: #cdd6f4;
|
||||
--card: #181825;
|
||||
--card-foreground: #cdd6f4;
|
||||
--popover: #181825;
|
||||
--popover-foreground: #cdd6f4;
|
||||
--primary: #cdd6f4;
|
||||
--primary-foreground: #1e1e2e;
|
||||
--secondary: #313244;
|
||||
--secondary-foreground: #cdd6f4;
|
||||
--muted: #181825;
|
||||
--muted-foreground: #a6adc8;
|
||||
--accent: #313244;
|
||||
--accent-foreground: #cdd6f4;
|
||||
--destructive: #f38ba8;
|
||||
--border: #313244;
|
||||
--input: #181825;
|
||||
--ring: #89b4fa;
|
||||
--chart-1: #89b4fa;
|
||||
--chart-2: #a6e3a1;
|
||||
--chart-3: #f9e2af;
|
||||
--chart-4: #f5c2e7;
|
||||
--chart-5: #94e2d5;
|
||||
--sidebar: #11111b;
|
||||
--sidebar-foreground: #cdd6f4;
|
||||
--sidebar-primary: #89b4fa;
|
||||
--sidebar-primary-foreground: #11111b;
|
||||
--sidebar-accent: #313244;
|
||||
--sidebar-accent-foreground: #cdd6f4;
|
||||
--sidebar-border: #313244;
|
||||
--sidebar-ring: #89b4fa;
|
||||
|
||||
--bg-base: #1e1e2e;
|
||||
--bg-elevated: #181825;
|
||||
--bg-surface: #181825;
|
||||
--bg-surface-hover: #313244;
|
||||
--bg-input: #181825;
|
||||
--bg-deepest: #11111b;
|
||||
--bg-header: #11111b;
|
||||
--bg-button: #181825;
|
||||
--bg-active: #313244;
|
||||
--bg-light: #181825;
|
||||
--bg-subtle: #11111b;
|
||||
--bg-interact: #313244;
|
||||
--border-base: #313244;
|
||||
--border-panel: #11111b;
|
||||
--border-subtle: #313244;
|
||||
--border-medium: #313244;
|
||||
--bg-hover: #313244;
|
||||
--bg-hover-alt: #313244;
|
||||
--bg-pressed: #11111b;
|
||||
--border-hover: #89b4fa;
|
||||
--border-active: #cdd6f4;
|
||||
|
||||
--foreground-secondary: #a6adc8;
|
||||
--foreground-subtle: #7f849c;
|
||||
|
||||
--scrollbar-thumb: #313244;
|
||||
--scrollbar-thumb-hover: #45475a;
|
||||
--scrollbar-track: #1e1e2e;
|
||||
|
||||
--bg-overlay: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body {
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
/**
|
||||
* DatabaseHealthMonitor
|
||||
*
|
||||
* Non-blocking health tracker for backend/database connectivity. The
|
||||
* monitor no longer gates the whole UI: there is no full-screen overlay.
|
||||
* When a transient failure is observed we emit a "degraded" event so the
|
||||
* UI can surface a persistent but non-intrusive toast. A success from any
|
||||
* API request clears the state. Session-expired events are also relayed
|
||||
* to the UI.
|
||||
*
|
||||
* The previous "database-connection-lost" / "database-connection-restored"
|
||||
* events have been retired along with the overlay. Listeners should use
|
||||
* "database-connection-degraded" / "database-connection-degraded-cleared"
|
||||
* to reflect the current UX contract: users can keep working regardless
|
||||
* of backend hiccups and are simply informed via a toast.
|
||||
*/
|
||||
type EventListener = (...args: any[]) => void;
|
||||
|
||||
class DatabaseHealthMonitor {
|
||||
private static instance: DatabaseHealthMonitor;
|
||||
private dbHealthy: boolean = true;
|
||||
private lastCheckTime: number = 0;
|
||||
private checkInProgress: boolean = false;
|
||||
private listeners: Map<string, EventListener[]> = new Map();
|
||||
private consecutiveErrorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private confirmedUnhealthy: boolean = false;
|
||||
private degradedActive: boolean = false;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -49,71 +61,56 @@ class DatabaseHealthMonitor {
|
||||
reportDatabaseError(error: any, _wasAuthenticated: boolean = false) {
|
||||
const errorMessage = error?.response?.data?.error || error?.message || "";
|
||||
const errorCode = error?.response?.data?.code || error?.code;
|
||||
const lowerMessage = errorMessage.toLowerCase();
|
||||
|
||||
const isDatabaseError =
|
||||
errorMessage.toLowerCase().includes("database") ||
|
||||
errorMessage.toLowerCase().includes("sqlite") ||
|
||||
errorMessage.toLowerCase().includes("drizzle") ||
|
||||
lowerMessage.includes("database") ||
|
||||
lowerMessage.includes("sqlite") ||
|
||||
lowerMessage.includes("drizzle") ||
|
||||
errorCode === "DATABASE_ERROR" ||
|
||||
errorCode === "DB_CONNECTION_FAILED";
|
||||
|
||||
const isBackendUnreachable =
|
||||
errorCode === "ERR_NETWORK" ||
|
||||
errorCode === "ECONNREFUSED" ||
|
||||
(errorMessage.toLowerCase().includes("network error") &&
|
||||
error?.response === undefined);
|
||||
errorCode === "ECONNABORTED" ||
|
||||
errorCode === "ECONNRESET" ||
|
||||
errorCode === "ETIMEDOUT" ||
|
||||
errorCode === "ERR_CANCELED" ||
|
||||
(lowerMessage.includes("network error") &&
|
||||
error?.response === undefined) ||
|
||||
lowerMessage.includes("request aborted") ||
|
||||
lowerMessage.includes("timeout");
|
||||
|
||||
if (!(isDatabaseError || isBackendUnreachable)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.dbHealthy && !this.consecutiveErrorTimer) {
|
||||
this.consecutiveErrorTimer = setTimeout(() => {
|
||||
this.consecutiveErrorTimer = null;
|
||||
if (this.dbHealthy) {
|
||||
this.dbHealthy = false;
|
||||
this.confirmedUnhealthy = true;
|
||||
this.emit("database-connection-lost", {
|
||||
error: errorMessage || "Backend server unreachable",
|
||||
code: errorCode,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}, 10000);
|
||||
if (!this.degradedActive) {
|
||||
this.degradedActive = true;
|
||||
this.emit("database-connection-degraded", {
|
||||
error: errorMessage || "Background request failed",
|
||||
code: errorCode,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
reportDatabaseSuccess() {
|
||||
this.clearErrorTimer();
|
||||
|
||||
if (this.confirmedUnhealthy) {
|
||||
this.dbHealthy = true;
|
||||
this.confirmedUnhealthy = false;
|
||||
this.emit("database-connection-restored", {
|
||||
if (this.degradedActive) {
|
||||
this.degradedActive = false;
|
||||
this.emit("database-connection-degraded-cleared", {
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else if (!this.dbHealthy) {
|
||||
this.dbHealthy = true;
|
||||
}
|
||||
}
|
||||
|
||||
private clearErrorTimer(): void {
|
||||
if (this.consecutiveErrorTimer !== null) {
|
||||
clearTimeout(this.consecutiveErrorTimer);
|
||||
this.consecutiveErrorTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
isDatabaseHealthy(): boolean {
|
||||
return this.dbHealthy;
|
||||
isDegraded(): boolean {
|
||||
return this.degradedActive;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.dbHealthy = true;
|
||||
this.confirmedUnhealthy = false;
|
||||
this.lastCheckTime = 0;
|
||||
this.checkInProgress = false;
|
||||
this.clearErrorTimer();
|
||||
this.degradedActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+78
-4
@@ -277,6 +277,19 @@
|
||||
"copyTooltip": "Copy snippet to clipboard",
|
||||
"editTooltip": "Edit this snippet",
|
||||
"deleteTooltip": "Delete this snippet",
|
||||
"shareTooltip": "Share this snippet",
|
||||
"sharedWithYou": "Shared",
|
||||
"sharedBy": "Shared by {{username}}",
|
||||
"shareSnippet": "Share Snippet",
|
||||
"user": "User",
|
||||
"role": "Role",
|
||||
"selectTarget": "Select...",
|
||||
"currentAccess": "Current Access",
|
||||
"shareSuccess": "Snippet shared successfully",
|
||||
"shareFailed": "Failed to share snippet",
|
||||
"revokeSuccess": "Access revoked",
|
||||
"revokeFailed": "Failed to revoke access",
|
||||
"failedToLoadShareData": "Failed to load sharing data",
|
||||
"newFolder": "New Folder",
|
||||
"reorderSameFolder": "Can only reorder snippets within the same folder",
|
||||
"reorderSuccess": "Snippets reordered successfully",
|
||||
@@ -323,7 +336,9 @@
|
||||
"authRequiredRefresh": "Authentication required. Please refresh the page.",
|
||||
"dataAccessLockedReauth": "Data access locked. Please re-authenticate.",
|
||||
"loading": "Loading command history...",
|
||||
"error": "Error Loading History"
|
||||
"error": "Error Loading History",
|
||||
"disabledTitle": "Command History is Disabled",
|
||||
"disabledDescription": "Enable Command History Tracking in your profile under Appearance settings."
|
||||
},
|
||||
"splitScreen": {
|
||||
"title": "Split Screen",
|
||||
@@ -510,6 +525,8 @@
|
||||
"checkingDatabase": "Checking database connection...",
|
||||
"checkingAuthentication": "Checking authentication...",
|
||||
"backendReconnected": "Server connection restored",
|
||||
"connectionDegraded": "Server connection lost, recovering…",
|
||||
"reload": "Reload",
|
||||
"actions": "Actions",
|
||||
"remove": "Remove",
|
||||
"revoke": "Revoke",
|
||||
@@ -541,7 +558,8 @@
|
||||
"copySudoPassword": "Copy Sudo Password",
|
||||
"passwordCopied": "Password copied to clipboard",
|
||||
"sudoPasswordCopied": "Sudo password copied to clipboard",
|
||||
"noPasswordAvailable": "No password available"
|
||||
"noPasswordAvailable": "No password available",
|
||||
"failedToCopyPassword": "Failed to copy password"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin Settings",
|
||||
@@ -810,6 +828,13 @@
|
||||
"globalSettingsSaved": "Global monitoring settings saved",
|
||||
"failedToSaveGlobalSettings": "Failed to save global monitoring settings",
|
||||
"failedToLoadGlobalSettings": "Failed to load global monitoring settings",
|
||||
"sessionTimeout": "Session Timeout",
|
||||
"sessionTimeoutDesc": "Configure how long user sessions last before requiring re-authentication. 'Remember Me' sessions are unaffected (always 30 days).",
|
||||
"sessionTimeoutHours": "Timeout Duration",
|
||||
"hours": "hours",
|
||||
"sessionTimeoutNote": "Valid range: 1–720 hours. Changes apply to new sessions only.",
|
||||
"sessionTimeoutSaved": "Session timeout saved",
|
||||
"failedToSaveSessionTimeout": "Failed to save session timeout",
|
||||
"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",
|
||||
@@ -819,6 +844,12 @@
|
||||
"guacamoleSettingsSaved": "Guacamole settings saved",
|
||||
"failedToSaveGuacamoleSettings": "Failed to save guacamole settings",
|
||||
"failedToLoadGuacamoleSettings": "Failed to load guacamole settings",
|
||||
"logLevel": "Log Level",
|
||||
"logLevelDesc": "Control the verbosity of server logs. Higher levels reduce log output. Can also be set via the LOG_LEVEL environment variable.",
|
||||
"logVerbosity": "Verbosity",
|
||||
"logLevelNote": "Changes take effect immediately. Debug level may impact performance.",
|
||||
"logLevelSaved": "Log level saved",
|
||||
"failedToSaveLogLevel": "Failed to save log level",
|
||||
"clampedToValidRange": "was adjusted to valid range",
|
||||
"sessionManagement": "Session Management",
|
||||
"loadingSessions": "Loading sessions...",
|
||||
@@ -861,6 +892,11 @@
|
||||
"hostsCount": "{{count}} hosts",
|
||||
"importJson": "Import JSON",
|
||||
"importing": "Importing...",
|
||||
"exportAllJson": "Export All",
|
||||
"exporting": "Exporting...",
|
||||
"exportedAllHosts": "Exported {{count}} hosts",
|
||||
"failedToExportAllHosts": "Failed to export hosts. Please ensure you're logged in and have access to the host data.",
|
||||
"exportAllSensitiveWarning": "The exported file will contain sensitive authentication data (passwords/SSH keys) in plaintext. Please keep the file secure and delete it after use.",
|
||||
"importJsonTitle": "Import SSH Hosts from JSON",
|
||||
"importJsonDesc": "Upload a JSON file to bulk import multiple SSH hosts (max 100).",
|
||||
"downloadSample": "Download Sample",
|
||||
@@ -893,6 +929,11 @@
|
||||
"guacamoleSettings": "Remote Desktop Settings",
|
||||
"organization": "Organization",
|
||||
"ipAddress": "IP Address or Hostname",
|
||||
"macAddress": "MAC Address",
|
||||
"macAddressDesc": "For Wake-on-LAN. Format: AA:BB:CC:DD:EE:FF",
|
||||
"wakeOnLan": "Wake on LAN",
|
||||
"wolSent": "Wake-on-LAN packet sent",
|
||||
"wolFailed": "Failed to send Wake-on-LAN packet",
|
||||
"ipRequired": "IP address is required",
|
||||
"portRequired": "Port is required",
|
||||
"port": "Port",
|
||||
@@ -1055,6 +1096,9 @@
|
||||
"dragToMoveBetweenFolders": "Drag to move between folders",
|
||||
"exportedHostConfig": "Exported host configuration for {{name}}",
|
||||
"openTerminal": "Open Terminal",
|
||||
"openRdp": "Open RDP",
|
||||
"openVnc": "Open VNC",
|
||||
"openTelnet": "Open Telnet",
|
||||
"openFileManager": "Open File Manager",
|
||||
"openTunnels": "Open Tunnels",
|
||||
"openServerDetails": "Open Server Details",
|
||||
@@ -1170,6 +1214,10 @@
|
||||
"noServerFound": "No server found",
|
||||
"jumpHostsOrder": "Connections will be made in order: Jump Host 1 → Jump Host 2 → ... → Target Server",
|
||||
"socks5Proxy": "SOCKS5 Proxy",
|
||||
"portKnocking": "Port Knocking",
|
||||
"portKnockingDesc": "Send a sequence of connection attempts to specified ports before connecting. Used to open firewall rules dynamically.",
|
||||
"addKnock": "Add Port",
|
||||
"delayMs": "Delay",
|
||||
"socks5Description": "Configure SOCKS5 proxy for SSH connection. All traffic will be routed through the specified proxy server.",
|
||||
"enableSocks5": "Enable SOCKS5 Proxy",
|
||||
"enableSocks5Description": "Use SOCKS5 proxy for this SSH connection",
|
||||
@@ -1254,6 +1302,8 @@
|
||||
"autoMoshDesc": "Automatically run MOSH command on connect",
|
||||
"moshCommand": "MOSH Command",
|
||||
"moshCommandDesc": "The MOSH command to execute",
|
||||
"autoTmux": "Auto-tmux",
|
||||
"autoTmuxDesc": "Automatically attach to an existing (or start a new) tmux session for persistent sessions and cross-device continuity",
|
||||
"environmentVariables": "Environment Variables",
|
||||
"environmentVariablesDesc": "Set custom environment variables for the terminal session",
|
||||
"variableName": "Variable name",
|
||||
@@ -1540,7 +1590,27 @@
|
||||
"connecting": "Connecting...",
|
||||
"reconnecting": "Reconnecting... ({{attempt}}/{{max}})",
|
||||
"reconnected": "Reconnected successfully",
|
||||
"tmuxSessionCreated": "tmux session created: {{name}}",
|
||||
"tmuxSessionAttached": "tmux session attached: {{name}}",
|
||||
"tmuxUnavailable": "tmux is not installed on the remote host, falling back to standard shell",
|
||||
"tmuxSessionPickerTitle": "tmux Sessions",
|
||||
"tmuxSessionPickerDesc": "Existing tmux sessions found on this host. Select one to reattach or create a new session.",
|
||||
"tmuxWindows": "Windows",
|
||||
"tmuxWindowCount": "{{count}} window",
|
||||
"tmuxWindowCount_other": "{{count}} windows",
|
||||
"tmuxAttached": "Attached clients",
|
||||
"tmuxAttachedCount": "{{count}} attached",
|
||||
"tmuxLastActivity": "Last activity",
|
||||
"tmuxTimeJustNow": "just now",
|
||||
"tmuxTimeMinutes": "{{count}}m ago",
|
||||
"tmuxTimeHours": "{{count}}h ago",
|
||||
"tmuxTimeDays": "{{count}}d ago",
|
||||
"tmuxCreateNew": "Start new session",
|
||||
"tmuxCopyHint": "Adjust selection and press Enter to copy to clipboard",
|
||||
"maxReconnectAttemptsReached": "Maximum reconnection attempts reached",
|
||||
"connectionLost": "Connection lost",
|
||||
"reconnect": "Reconnect",
|
||||
"closeTab": "Close",
|
||||
"connectionTimeout": "Connection timeout",
|
||||
"terminalTitle": "Terminal - {{host}}",
|
||||
"terminalWithPath": "Terminal - {{host}}:{{path}}",
|
||||
@@ -1564,6 +1634,7 @@
|
||||
"opksshTimeout": "Authentication timed out. Please try again.",
|
||||
"opksshAuthFailed": "Authentication failed. Please check your credentials and try again.",
|
||||
"opksshConfigMissing": "OPKSSH configuration not found. Please create ~/.opk/config.yml with your OIDC provider settings. See documentation: https://github.com/openpubkey/opkssh#configuration",
|
||||
"opksshSignInWith": "Sign in with {{provider}}",
|
||||
"sudoPasswordPopupTitle": "Insert Password?",
|
||||
"sudoPasswordPopupHint": "Press Enter to insert, Esc to dismiss",
|
||||
"sudoPasswordPopupConfirm": "Insert",
|
||||
@@ -1588,7 +1659,8 @@
|
||||
"connectionRejected": "Connection rejected by server. Please check your authentication and network configuration.",
|
||||
"hostKeyRejected": "SSH host key verification rejected. Connection cancelled.",
|
||||
"sessionTakenOver": "Session was opened in another tab. Reconnecting...",
|
||||
"sessionAttachTimeout": "Session attachment timed out. Creating new connection..."
|
||||
"sessionAttachTimeout": "Session attachment timed out. Creating new connection...",
|
||||
"openFileManagerHere": "Open File Manager Here"
|
||||
},
|
||||
"fileManager": {
|
||||
"title": "File Manager",
|
||||
@@ -2292,6 +2364,8 @@
|
||||
"fileColorCodingDesc": "Color-code files by type: folders (red), files (blue), symlinks (green)",
|
||||
"commandAutocomplete": "Command Autocomplete",
|
||||
"commandAutocompleteDesc": "Enable Tab key autocomplete suggestions for terminal commands based on your command history",
|
||||
"commandHistoryTracking": "Save Command History",
|
||||
"commandHistoryTrackingDesc": "Store terminal commands in history for autocomplete and sidebar. Disabled by default for privacy.",
|
||||
"defaultSnippetFoldersCollapsed": "Collapse Snippet Folders by Default",
|
||||
"defaultSnippetFoldersCollapsedDesc": "When enabled, all snippet folders will be collapsed when you open the snippets tab",
|
||||
"terminalSyntaxHighlighting": "Terminal Syntax Highlighting",
|
||||
@@ -2530,7 +2604,7 @@
|
||||
"database": "Database",
|
||||
"healthy": "Healthy",
|
||||
"error": "Error",
|
||||
"totalServers": "Total Servers",
|
||||
"totalHosts": "Total Hosts",
|
||||
"totalTunnels": "Total Tunnels",
|
||||
"totalCredentials": "Total Credentials",
|
||||
"recentActivity": "Recent Activity",
|
||||
|
||||
Vendored
+10
@@ -7,11 +7,21 @@ declare module "guacamole-common-js" {
|
||||
getDisplay(): Display;
|
||||
sendKeyEvent(pressed: number, keysym: number): void;
|
||||
sendMouseState(state: Mouse.State): void;
|
||||
sendSize(width: number, height: number): 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;
|
||||
onaudio: ((stream: InputStream, mimetype: string) => void) | null;
|
||||
}
|
||||
|
||||
class AudioPlayer {
|
||||
static getInstance(
|
||||
stream: InputStream,
|
||||
mimetype: string,
|
||||
): AudioPlayer | null;
|
||||
sync(): void;
|
||||
}
|
||||
|
||||
class Display {
|
||||
|
||||
@@ -74,6 +74,13 @@ export interface Host {
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: ProxyNode[];
|
||||
|
||||
macAddress?: string;
|
||||
portKnockSequence?: Array<{
|
||||
port: number;
|
||||
protocol?: "tcp" | "udp";
|
||||
delay?: number;
|
||||
}>;
|
||||
|
||||
connectionType?: "ssh" | "rdp" | "vnc" | "telnet";
|
||||
domain?: string;
|
||||
security?: string;
|
||||
@@ -83,6 +90,10 @@ export interface Host {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
hasPassword?: boolean;
|
||||
hasKey?: boolean;
|
||||
hasSudoPassword?: boolean;
|
||||
|
||||
isShared?: boolean;
|
||||
permissionLevel?: "view";
|
||||
sharedExpiresAt?: string;
|
||||
@@ -146,6 +157,13 @@ export interface HostData {
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: ProxyNode[];
|
||||
|
||||
macAddress?: string;
|
||||
portKnockSequence?: Array<{
|
||||
port: number;
|
||||
protocol?: "tcp" | "udp";
|
||||
delay?: number;
|
||||
}>;
|
||||
|
||||
connectionType?: "ssh" | "rdp" | "vnc" | "telnet";
|
||||
domain?: string;
|
||||
security?: string;
|
||||
@@ -425,6 +443,7 @@ export interface TerminalConfig {
|
||||
sudoPasswordAutoFill: boolean;
|
||||
keepaliveInterval?: number;
|
||||
keepaliveCountMax?: number;
|
||||
autoTmux: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -52,10 +52,13 @@ function AppContent({
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
|
||||
const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
|
||||
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
|
||||
|
||||
const isDarkMode =
|
||||
theme === "dark" ||
|
||||
theme === "dracula" ||
|
||||
theme === "gentlemansChoice" ||
|
||||
theme === "midnightEspresso" ||
|
||||
theme === "catppuccinMocha" ||
|
||||
(theme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const lineColor = isDarkMode ? "#151517" : "#f9f9f9";
|
||||
@@ -65,12 +68,29 @@ function AppContent({
|
||||
const lastAltPressTime = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDatabaseConnectionLost = () => {
|
||||
setDbConnectionFailed(true);
|
||||
const DEGRADED_TOAST_ID = "db-connection-degraded";
|
||||
|
||||
const handleDatabaseConnectionDegraded = () => {
|
||||
// Non-blocking, non-dismissible status toast that stays visible until
|
||||
// connectivity is recovered. A Reload action lets users force-refresh
|
||||
// the page if they want to, but the app itself remains fully usable.
|
||||
toast.loading(
|
||||
t("common.connectionDegraded", "Server connection lost, recovering…"),
|
||||
{
|
||||
id: DEGRADED_TOAST_ID,
|
||||
duration: Infinity,
|
||||
dismissible: false,
|
||||
closeButton: false,
|
||||
action: {
|
||||
label: t("common.reload", "Reload"),
|
||||
onClick: () => window.location.reload(),
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDatabaseConnectionRestored = () => {
|
||||
setDbConnectionFailed(false);
|
||||
const handleDatabaseConnectionDegradedCleared = () => {
|
||||
toast.dismiss(DEGRADED_TOAST_ID);
|
||||
toast.success(t("common.backendReconnected"));
|
||||
};
|
||||
|
||||
@@ -79,27 +99,28 @@ function AppContent({
|
||||
};
|
||||
|
||||
dbHealthMonitor.on(
|
||||
"database-connection-lost",
|
||||
handleDatabaseConnectionLost,
|
||||
"database-connection-degraded",
|
||||
handleDatabaseConnectionDegraded,
|
||||
);
|
||||
dbHealthMonitor.on(
|
||||
"database-connection-restored",
|
||||
handleDatabaseConnectionRestored,
|
||||
"database-connection-degraded-cleared",
|
||||
handleDatabaseConnectionDegradedCleared,
|
||||
);
|
||||
dbHealthMonitor.on("session-expired", handleSessionExpired);
|
||||
|
||||
return () => {
|
||||
dbHealthMonitor.off(
|
||||
"database-connection-lost",
|
||||
handleDatabaseConnectionLost,
|
||||
"database-connection-degraded",
|
||||
handleDatabaseConnectionDegraded,
|
||||
);
|
||||
dbHealthMonitor.off(
|
||||
"database-connection-restored",
|
||||
handleDatabaseConnectionRestored,
|
||||
"database-connection-degraded-cleared",
|
||||
handleDatabaseConnectionDegradedCleared,
|
||||
);
|
||||
dbHealthMonitor.off("session-expired", handleSessionExpired);
|
||||
toast.dismiss(DEGRADED_TOAST_ID);
|
||||
};
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -156,8 +177,9 @@ function AppContent({
|
||||
if (hostIdentifier) {
|
||||
const openTerminal = async () => {
|
||||
try {
|
||||
const { getSSHHostById, getSSHHosts } =
|
||||
await import("@/ui/main-axios.ts");
|
||||
const { getSSHHostById, getSSHHosts } = await import(
|
||||
"@/ui/main-axios.ts"
|
||||
);
|
||||
let host = null;
|
||||
|
||||
if (/^\d+$/.test(hostIdentifier)) {
|
||||
@@ -188,8 +210,12 @@ function AppContent({
|
||||
}
|
||||
}, [addTab]);
|
||||
|
||||
const isCheckingAuth = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
if (isCheckingAuth.current) return;
|
||||
isCheckingAuth.current = true;
|
||||
setAuthLoading(true);
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
@@ -197,7 +223,6 @@ function AppContent({
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
localStorage.removeItem("jwt");
|
||||
} else {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
@@ -209,8 +234,6 @@ function AppContent({
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
const errorCode = err?.response?.data?.code;
|
||||
if (errorCode === "SESSION_EXPIRED") {
|
||||
console.warn("Session expired - please log in again");
|
||||
@@ -218,6 +241,7 @@ function AppContent({
|
||||
})
|
||||
.finally(() => {
|
||||
setAuthLoading(false);
|
||||
isCheckingAuth.current = false;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -292,7 +316,7 @@ function AppContent({
|
||||
const showAdmin = currentTabData?.type === "admin";
|
||||
const showProfile = currentTabData?.type === "user_profile";
|
||||
|
||||
if (authLoading && !dbConnectionFailed) {
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center"
|
||||
@@ -321,30 +345,6 @@ function AppContent({
|
||||
);
|
||||
}
|
||||
|
||||
if (dbConnectionFailed) {
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden bg-background">
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-background">
|
||||
<Dashboard
|
||||
isAuthenticated={false}
|
||||
authLoading={false}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
onSelectView={() => {}}
|
||||
initialDbError="Database connection failed"
|
||||
/>
|
||||
</div>
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
richColors={false}
|
||||
closeButton
|
||||
duration={5000}
|
||||
offset={20}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden bg-background">
|
||||
<CommandPalette
|
||||
|
||||
@@ -68,9 +68,9 @@ export function AdminSettings({
|
||||
Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
password_hash?: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
}>
|
||||
>([]);
|
||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||
@@ -80,9 +80,9 @@ export function AdminSettings({
|
||||
const [selectedUserForEdit, setSelectedUserForEdit] = React.useState<{
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
password_hash?: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
} | null>(null);
|
||||
|
||||
const [currentUser, setCurrentUser] = React.useState<{
|
||||
@@ -148,7 +148,6 @@ export function AdminSettings({
|
||||
console.warn("Failed to fetch current user info", err);
|
||||
}
|
||||
});
|
||||
fetchUsers();
|
||||
fetchSessions();
|
||||
}, []);
|
||||
|
||||
@@ -340,7 +339,15 @@ export function AdminSettings({
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="px-6 py-4 overflow-auto thin-scrollbar">
|
||||
<Tabs defaultValue="registration" className="w-full">
|
||||
<Tabs
|
||||
defaultValue="registration"
|
||||
onValueChange={(value) => {
|
||||
if (value === "users") {
|
||||
fetchUsers();
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="mb-4 bg-elevated border-2 border-edge">
|
||||
<TabsTrigger
|
||||
value="registration"
|
||||
|
||||
@@ -42,9 +42,9 @@ import {
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
password_hash?: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
}
|
||||
|
||||
interface UserEditDialogProps {
|
||||
@@ -81,7 +81,7 @@ export function UserEditDialog({
|
||||
|
||||
useEffect(() => {
|
||||
if (open && user) {
|
||||
setIsAdmin(user.is_admin);
|
||||
setIsAdmin(user.isAdmin);
|
||||
loadRoles();
|
||||
}
|
||||
}, [open, user]);
|
||||
@@ -135,19 +135,18 @@ export function UserEditDialog({
|
||||
setAdminLoading(true);
|
||||
try {
|
||||
if (checked) {
|
||||
await makeUserAdmin(userToUpdate.username);
|
||||
await makeUserAdmin(userToUpdate.id);
|
||||
toast.success(
|
||||
t("admin.userIsNowAdmin", { username: userToUpdate.username }),
|
||||
);
|
||||
} else {
|
||||
await removeAdminStatus(userToUpdate.username);
|
||||
await removeAdminStatus(userToUpdate.id);
|
||||
toast.success(
|
||||
t("admin.adminStatusRemoved", { username: userToUpdate.username }),
|
||||
);
|
||||
}
|
||||
setIsAdmin(checked);
|
||||
onSuccess();
|
||||
onOpenChange(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle admin status:", error);
|
||||
toast.error(
|
||||
@@ -332,9 +331,9 @@ export function UserEditDialog({
|
||||
|
||||
const getAuthTypeDisplay = (): string => {
|
||||
if (!user) return "";
|
||||
if (user.is_oidc && user.password_hash) {
|
||||
if (user.isOidc && user.passwordHash) {
|
||||
return t("admin.dualAuth");
|
||||
} else if (user.is_oidc) {
|
||||
} else if (user.isOidc) {
|
||||
return t("admin.externalOIDC");
|
||||
} else {
|
||||
return t("admin.localPassword");
|
||||
@@ -344,7 +343,7 @@ export function UserEditDialog({
|
||||
if (!user) return null;
|
||||
|
||||
const showPasswordReset =
|
||||
allowPasswordLogin && (user.password_hash || !user.is_oidc);
|
||||
allowPasswordLogin && (user.passwordHash || !user.isOidc);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Download, Upload } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -22,33 +20,8 @@ export function DatabaseSecurityTab({
|
||||
const [exportLoading, setExportLoading] = React.useState(false);
|
||||
const [importLoading, setImportLoading] = React.useState(false);
|
||||
const [importFile, setImportFile] = React.useState<File | null>(null);
|
||||
const [exportPassword, setExportPassword] = React.useState("");
|
||||
const [showPasswordInput, setShowPasswordInput] = React.useState(false);
|
||||
const [importPassword, setImportPassword] = React.useState("");
|
||||
|
||||
const requiresImportPassword = React.useMemo(
|
||||
() => !currentUser?.is_oidc,
|
||||
[currentUser?.is_oidc],
|
||||
);
|
||||
|
||||
const requiresExportPassword = React.useMemo(
|
||||
() => !currentUser?.is_oidc,
|
||||
[currentUser?.is_oidc],
|
||||
);
|
||||
|
||||
const handleExportDatabase = async () => {
|
||||
if (requiresExportPassword) {
|
||||
if (!showPasswordInput) {
|
||||
setShowPasswordInput(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!exportPassword.trim()) {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const isDev =
|
||||
@@ -66,15 +39,21 @@ export function DatabaseSecurityTab({
|
||||
? `http://localhost:30001/database/export`
|
||||
: `${window.location.protocol}//${window.location.host}${getBasePath()}/database/export`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (isElectron()) {
|
||||
const token = localStorage.getItem("jwt");
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers,
|
||||
credentials: "include",
|
||||
body: JSON.stringify(
|
||||
requiresExportPassword ? { password: exportPassword } : {},
|
||||
),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -94,15 +73,9 @@ export function DatabaseSecurityTab({
|
||||
document.body.removeChild(a);
|
||||
|
||||
toast.success(t("admin.databaseExportedSuccessfully"));
|
||||
setExportPassword("");
|
||||
setShowPasswordInput(false);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
if (error.code === "PASSWORD_REQUIRED") {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
} else {
|
||||
toast.error(error.error || t("admin.databaseExportFailed"));
|
||||
}
|
||||
toast.error(error.error || t("admin.databaseExportFailed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.databaseExportFailed"));
|
||||
@@ -117,11 +90,6 @@ export function DatabaseSecurityTab({
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiresImportPassword && !importPassword.trim()) {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setImportLoading(true);
|
||||
try {
|
||||
const isDev =
|
||||
@@ -141,12 +109,18 @@ export function DatabaseSecurityTab({
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", importFile);
|
||||
if (requiresImportPassword) {
|
||||
formData.append("password", importPassword);
|
||||
|
||||
const importHeaders: Record<string, string> = {};
|
||||
if (isElectron()) {
|
||||
const token = localStorage.getItem("jwt");
|
||||
if (token) {
|
||||
importHeaders["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: importHeaders,
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
@@ -181,7 +155,6 @@ export function DatabaseSecurityTab({
|
||||
`Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(", ")})` : ""}, ${skipped} items skipped`,
|
||||
);
|
||||
setImportFile(null);
|
||||
setImportPassword("");
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
@@ -193,11 +166,7 @@ export function DatabaseSecurityTab({
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
if (error.code === "PASSWORD_REQUIRED") {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
} else {
|
||||
toast.error(error.error || t("admin.databaseImportFailed"));
|
||||
}
|
||||
toast.error(error.error || t("admin.databaseImportFailed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.databaseImportFailed"));
|
||||
@@ -220,45 +189,13 @@ export function DatabaseSecurityTab({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.exportDescription")}
|
||||
</p>
|
||||
{showPasswordInput && requiresExportPassword && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="export-password">Password</Label>
|
||||
<PasswordInput
|
||||
id="export-password"
|
||||
value={exportPassword}
|
||||
onChange={(e) => setExportPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleExportDatabase();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleExportDatabase}
|
||||
disabled={exportLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{exportLoading
|
||||
? t("admin.exporting")
|
||||
: showPasswordInput && requiresExportPassword
|
||||
? t("admin.confirmExport")
|
||||
: t("admin.export")}
|
||||
{exportLoading ? t("admin.exporting") : t("admin.export")}
|
||||
</Button>
|
||||
{showPasswordInput && requiresExportPassword && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowPasswordInput(false);
|
||||
setExportPassword("");
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -294,29 +231,9 @@ export function DatabaseSecurityTab({
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{importFile && requiresImportPassword && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="import-password">Password</Label>
|
||||
<PasswordInput
|
||||
id="import-password"
|
||||
value={importPassword}
|
||||
onChange={(e) => setImportPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleImportDatabase();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleImportDatabase}
|
||||
disabled={
|
||||
importLoading ||
|
||||
!importFile ||
|
||||
(requiresImportPassword && !importPassword.trim())
|
||||
}
|
||||
disabled={importLoading || !importFile}
|
||||
className="w-full"
|
||||
>
|
||||
{importLoading ? t("admin.importing") : t("admin.import")}
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
updateGlobalMonitoringSettings,
|
||||
getGuacamoleSettings,
|
||||
updateGuacamoleSettings,
|
||||
getLogLevel,
|
||||
updateLogLevel,
|
||||
getSessionTimeout,
|
||||
updateSessionTimeout,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
|
||||
@@ -66,10 +70,64 @@ export function GeneralSettingsTab({
|
||||
);
|
||||
const [monitoringLoading, setMonitoringLoading] = React.useState(false);
|
||||
|
||||
const [logLevel, setLogLevel] = React.useState("info");
|
||||
const [logLevelLoading, setLogLevelLoading] = React.useState(false);
|
||||
|
||||
const [sessionTimeoutHours, setSessionTimeoutHours] = React.useState(24);
|
||||
const [sessionTimeoutInput, setSessionTimeoutInput] = React.useState("24");
|
||||
const [sessionTimeoutLoading, setSessionTimeoutLoading] =
|
||||
React.useState(false);
|
||||
|
||||
const [guacEnabled, setGuacEnabled] = React.useState(true);
|
||||
const [guacUrl, setGuacUrl] = React.useState("guacd:4822");
|
||||
const [guacLoading, setGuacLoading] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
getLogLevel()
|
||||
.then((data) => {
|
||||
setLogLevel(data.level);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
getSessionTimeout()
|
||||
.then((data) => {
|
||||
setSessionTimeoutHours(data.timeoutHours);
|
||||
setSessionTimeoutInput(String(data.timeoutHours));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleLogLevelChange = async (value: string) => {
|
||||
setLogLevel(value);
|
||||
setLogLevelLoading(true);
|
||||
try {
|
||||
await updateLogLevel(value);
|
||||
toast.success(t("admin.logLevelSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.failedToSaveLogLevel"));
|
||||
} finally {
|
||||
setLogLevelLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSessionTimeoutBlur = async () => {
|
||||
const num = parseInt(sessionTimeoutInput) || 24;
|
||||
const clamped = Math.max(1, Math.min(720, num));
|
||||
setSessionTimeoutHours(clamped);
|
||||
setSessionTimeoutInput(String(clamped));
|
||||
setSessionTimeoutLoading(true);
|
||||
try {
|
||||
await updateSessionTimeout(clamped);
|
||||
toast.success(t("admin.sessionTimeoutSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.failedToSaveSessionTimeout"));
|
||||
} finally {
|
||||
setSessionTimeoutLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
getGuacamoleSettings()
|
||||
.then((data) => {
|
||||
@@ -275,6 +333,34 @@ export function GeneralSettingsTab({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t("admin.sessionTimeout")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.sessionTimeoutDesc")}
|
||||
</p>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
{t("admin.sessionTimeoutHours")}
|
||||
</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={720}
|
||||
value={sessionTimeoutInput}
|
||||
onChange={(e) => setSessionTimeoutInput(e.target.value)}
|
||||
onBlur={handleSessionTimeoutBlur}
|
||||
disabled={sessionTimeoutLoading}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm font-medium py-2">{t("admin.hours")}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("admin.sessionTimeoutNote")}
|
||||
</p>
|
||||
</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.monitoringDefaults")}
|
||||
@@ -410,6 +496,36 @@ export function GeneralSettingsTab({
|
||||
</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.logLevel")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.logLevelDesc")}
|
||||
</p>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
{t("admin.logVerbosity")}
|
||||
</label>
|
||||
<Select
|
||||
value={logLevel}
|
||||
onValueChange={handleLogLevelChange}
|
||||
disabled={logLevelLoading}
|
||||
>
|
||||
<SelectTrigger className="w-[200px] mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="debug">Debug</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
<SelectItem value="warn">Warning</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("admin.logLevelNote")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ import { deleteUser } from "@/ui/main-axios.ts";
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
password_hash?: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
}
|
||||
|
||||
interface UserManagementTabProps {
|
||||
@@ -47,9 +47,9 @@ export function UserManagementTab({
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const getAuthTypeDisplay = (user: User): string => {
|
||||
if (user.is_oidc && user.password_hash) {
|
||||
if (user.isOidc && user.passwordHash) {
|
||||
return t("admin.dualAuth");
|
||||
} else if (user.is_oidc) {
|
||||
} else if (user.isOidc) {
|
||||
return t("admin.externalOIDC");
|
||||
} else {
|
||||
return t("admin.localPassword");
|
||||
@@ -111,7 +111,7 @@ export function UserManagementTab({
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">
|
||||
{user.username}
|
||||
{user.is_admin && (
|
||||
{user.isAdmin && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
{t("admin.adminBadge")}
|
||||
</span>
|
||||
@@ -129,7 +129,7 @@ export function UserManagementTab({
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{user.is_oidc && !user.password_hash && (
|
||||
{user.isOidc && !user.passwordHash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -145,7 +145,7 @@ export function UserManagementTab({
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{user.is_oidc && user.password_hash && (
|
||||
{user.isOidc && user.passwordHash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -161,7 +161,7 @@ export function UserManagementTab({
|
||||
size="sm"
|
||||
onClick={() => handleDeleteUserQuick(user.username)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={user.is_admin}
|
||||
disabled={user.isAdmin}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -33,7 +33,8 @@ import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import {
|
||||
getRecentActivity,
|
||||
getSSHHosts,
|
||||
getGuacamoleToken,
|
||||
getGuacamoleDpi,
|
||||
getGuacamoleTokenFromHost,
|
||||
logActivity,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import type { RecentActivityItem } from "@/ui/main-axios.ts";
|
||||
@@ -235,17 +236,7 @@ export function CommandPalette({
|
||||
) {
|
||||
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,
|
||||
});
|
||||
const result = await getGuacamoleTokenFromHost(host.id);
|
||||
|
||||
addTab({
|
||||
type: protocol,
|
||||
@@ -262,6 +253,7 @@ export function CommandPalette({
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(host),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@ import {
|
||||
getCredentials,
|
||||
getRecentActivity,
|
||||
resetRecentActivity,
|
||||
getAllServerStatuses,
|
||||
getServerMetricsById,
|
||||
registerMetricsViewer,
|
||||
sendMetricsHeartbeat,
|
||||
getGuacamoleToken,
|
||||
getGuacamoleDpi,
|
||||
getGuacamoleTokenFromHost,
|
||||
type RecentActivityItem,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
@@ -68,7 +70,7 @@ export function Dashboard({
|
||||
const [versionStatus, setVersionStatus] = useState<
|
||||
"up_to_date" | "requires_update"
|
||||
>("up_to_date");
|
||||
const [versionText, setVersionText] = useState<string>("v1.8.0");
|
||||
const [versionText, setVersionText] = useState<string>("");
|
||||
const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy");
|
||||
const [totalServers, setTotalServers] = useState<number>(0);
|
||||
const [totalTunnels, setTotalTunnels] = useState<number>(0);
|
||||
@@ -223,72 +225,108 @@ export function Dashboard({
|
||||
setRecentActivityLoading(false);
|
||||
|
||||
setServerStatsLoading(true);
|
||||
|
||||
// Fetch current host statuses once so we can skip offline hosts
|
||||
// before issuing per-host register-viewer / metrics requests.
|
||||
let hostStatuses: Record<number, { status?: string }> = {};
|
||||
try {
|
||||
hostStatuses = (await getAllServerStatuses()) as Record<
|
||||
number,
|
||||
{ status?: string }
|
||||
>;
|
||||
} catch {
|
||||
// Best-effort: if the status endpoint is unavailable, fall back
|
||||
// to the previous behavior and still attempt each host.
|
||||
hostStatuses = {};
|
||||
}
|
||||
|
||||
const newViewerSessions = new Map<number, string>();
|
||||
const serversWithStats = await Promise.all(
|
||||
hosts
|
||||
.slice(0, 50)
|
||||
.map(
|
||||
async (host: {
|
||||
id: number;
|
||||
name: string;
|
||||
authType?: string;
|
||||
statsConfig?: string | { metricsEnabled?: boolean };
|
||||
}) => {
|
||||
try {
|
||||
let statsConfig: { metricsEnabled?: boolean } = {
|
||||
metricsEnabled: true,
|
||||
hosts.slice(0, 50).map(
|
||||
async (host: {
|
||||
id: number;
|
||||
name: string;
|
||||
authType?: string;
|
||||
statsConfig?:
|
||||
| string
|
||||
| {
|
||||
metricsEnabled?: boolean;
|
||||
statusCheckEnabled?: boolean;
|
||||
};
|
||||
if (host.statsConfig) {
|
||||
if (typeof host.statsConfig === "string") {
|
||||
statsConfig = JSON.parse(host.statsConfig);
|
||||
} else {
|
||||
statsConfig = host.statsConfig;
|
||||
}
|
||||
}
|
||||
|
||||
if (statsConfig.metricsEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host.authType === "none") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host.authType === "opkssh") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingSession = viewerSessions.get(host.id);
|
||||
let sessionId = existingSession;
|
||||
|
||||
if (!existingSession) {
|
||||
try {
|
||||
const viewerResult = await registerMetricsViewer(host.id);
|
||||
if (
|
||||
viewerResult.success &&
|
||||
viewerResult.viewerSessionId
|
||||
) {
|
||||
sessionId = viewerResult.viewerSessionId;
|
||||
newViewerSessions.set(host.id, sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to register viewer for host ${host.id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}) => {
|
||||
try {
|
||||
let statsConfig: {
|
||||
metricsEnabled?: boolean;
|
||||
statusCheckEnabled?: boolean;
|
||||
} = {
|
||||
metricsEnabled: true,
|
||||
statusCheckEnabled: true,
|
||||
};
|
||||
if (host.statsConfig) {
|
||||
if (typeof host.statsConfig === "string") {
|
||||
statsConfig = JSON.parse(host.statsConfig);
|
||||
} else {
|
||||
newViewerSessions.set(host.id, existingSession);
|
||||
statsConfig = host.statsConfig;
|
||||
}
|
||||
}
|
||||
|
||||
const metrics = await getServerMetricsById(host.id);
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: metrics.cpu.percent,
|
||||
ram: metrics.memory.percent,
|
||||
};
|
||||
} catch {
|
||||
if (statsConfig.metricsEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host.authType === "none") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host.authType === "opkssh") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip hosts that are known to be offline: no metrics can
|
||||
// possibly exist for them, and hitting /metrics/:id would
|
||||
// just 404. If the status is unknown (e.g. no entry yet
|
||||
// or statusCheckEnabled === false) we still attempt.
|
||||
if (statsConfig.statusCheckEnabled !== false) {
|
||||
const knownStatus = hostStatuses?.[host.id]?.status;
|
||||
if (knownStatus === "offline") {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const existingSession = viewerSessions.get(host.id);
|
||||
let sessionId = existingSession;
|
||||
let registrationSkipped = false;
|
||||
|
||||
if (!existingSession) {
|
||||
try {
|
||||
const viewerResult = await registerMetricsViewer(host.id);
|
||||
if (viewerResult.skipped) {
|
||||
// Metrics disabled/unsupported on this host; don't
|
||||
// poll and don't surface this as an error.
|
||||
registrationSkipped = true;
|
||||
} else if (
|
||||
viewerResult.success &&
|
||||
viewerResult.viewerSessionId
|
||||
) {
|
||||
sessionId = viewerResult.viewerSessionId;
|
||||
newViewerSessions.set(host.id, sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to register viewer for host ${host.id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
newViewerSessions.set(host.id, existingSession);
|
||||
}
|
||||
|
||||
if (registrationSkipped) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metrics = await getServerMetricsById(host.id);
|
||||
if (!metrics) {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
@@ -296,8 +334,22 @@ export function Dashboard({
|
||||
ram: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
),
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: metrics.cpu?.percent ?? null,
|
||||
ram: metrics.memory?.percent ?? null,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
setViewerSessions(newViewerSessions);
|
||||
const validServerStats = serversWithStats.filter(
|
||||
@@ -388,17 +440,7 @@ export function Dashboard({
|
||||
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,
|
||||
})
|
||||
getGuacamoleTokenFromHost(host.id)
|
||||
.then((result) => {
|
||||
addTab({
|
||||
type: "telnet",
|
||||
@@ -415,6 +457,7 @@ export function Dashboard({
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(host),
|
||||
},
|
||||
});
|
||||
})
|
||||
@@ -422,17 +465,7 @@ export function Dashboard({
|
||||
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,
|
||||
})
|
||||
getGuacamoleTokenFromHost(host.id)
|
||||
.then((result) => {
|
||||
addTab({
|
||||
type: "vnc",
|
||||
@@ -449,6 +482,7 @@ export function Dashboard({
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(host),
|
||||
},
|
||||
});
|
||||
})
|
||||
@@ -456,17 +490,7 @@ export function Dashboard({
|
||||
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,
|
||||
})
|
||||
getGuacamoleTokenFromHost(host.id)
|
||||
.then((result) => {
|
||||
addTab({
|
||||
type: "rdp",
|
||||
@@ -483,6 +507,7 @@ export function Dashboard({
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(host),
|
||||
},
|
||||
});
|
||||
})
|
||||
@@ -650,15 +675,6 @@ export function Dashboard({
|
||||
>
|
||||
{t("dashboard.discord")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold shrink-0 !bg-canvas"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open("https://github.com/sponsors/LukeGus", "_blank")
|
||||
}
|
||||
>
|
||||
{t("dashboard.donate")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold shrink-0 !bg-canvas"
|
||||
variant="outline"
|
||||
|
||||
@@ -110,7 +110,7 @@ export function ServerOverviewCard({
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Server size={16} className="mr-3 shrink-0" />
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalServers")}
|
||||
{t("dashboard.totalHosts")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
|
||||
@@ -30,7 +30,11 @@ export function useDashboardPreferences(enabled: boolean = true) {
|
||||
const fetchPreferences = async () => {
|
||||
try {
|
||||
const preferences = await getDashboardPreferences();
|
||||
setLayout(preferences);
|
||||
if (preferences?.cards && Array.isArray(preferences.cards)) {
|
||||
setLayout(preferences);
|
||||
} else {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
}
|
||||
} catch (error) {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
} finally {
|
||||
|
||||
@@ -661,7 +661,7 @@ function DockerManagerInner({
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0 relative">
|
||||
{viewMode === "list" ? (
|
||||
<div className="h-full px-4 py-4">
|
||||
<div className="h-full min-h-0 px-4 py-4">
|
||||
{sessionId ? (
|
||||
isLoadingContainers && containers.length === 0 ? (
|
||||
<SimpleLoader
|
||||
|
||||
@@ -263,8 +263,7 @@ export function ConsoleTerminal({
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
|
||||
|
||||
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(token)}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const ws = new WebSocket(baseWsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
const cols = terminal.cols || 80;
|
||||
|
||||
@@ -246,7 +246,7 @@ export function ContainerCard({
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
className={`cursor-pointer transition-all hover:shadow-lg ${
|
||||
className={`cursor-pointer transition-all hover:shadow-lg overflow-hidden min-w-0 ${
|
||||
isSelected
|
||||
? "ring-2 ring-primary border-primary"
|
||||
: `border-2 ${colors.border}`
|
||||
@@ -267,27 +267,27 @@ export function ContainerCard({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-4 pb-3">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-muted-foreground min-w-[50px] text-xs">
|
||||
{t("docker.image")}
|
||||
</span>
|
||||
<span className="truncate text-foreground text-xs">
|
||||
<span className="flex-1 min-w-0 truncate text-foreground text-xs">
|
||||
{container.image}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-muted-foreground min-w-[50px] text-xs">
|
||||
{t("docker.idLabel")}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-foreground">
|
||||
<span className="flex-1 min-w-0 truncate font-mono text-xs text-foreground">
|
||||
{container.id.substring(0, 12)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<span className="text-muted-foreground min-w-[50px] text-xs shrink-0">
|
||||
{t("docker.ports")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="flex flex-1 min-w-0 flex-wrap gap-1">
|
||||
{portsList.length > 0 ? (
|
||||
portsList.map((port, idx) => (
|
||||
<Badge
|
||||
@@ -308,11 +308,11 @@ export function ContainerCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-muted-foreground min-w-[50px] text-xs">
|
||||
{t("docker.created")}
|
||||
</span>
|
||||
<span className="text-foreground text-xs">
|
||||
<span className="flex-1 min-w-0 truncate text-foreground text-xs">
|
||||
{formatCreatedDate(container.created)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,7 @@ export function ContainerList({
|
||||
|
||||
if (containers.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex items-center justify-center h-full min-h-0">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-muted-foreground text-lg">
|
||||
{t("docker.noContainersFound")}
|
||||
@@ -69,7 +69,7 @@ export function ContainerList({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
<div className="flex flex-col h-full min-h-0 gap-3">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
@@ -106,7 +106,7 @@ export function ContainerList({
|
||||
</div>
|
||||
|
||||
{filteredContainers.length === 0 ? (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<div className="flex items-center justify-center flex-1 min-h-0">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-muted-foreground">
|
||||
{t("docker.noContainersMatchFilters")}
|
||||
@@ -117,17 +117,19 @@ export function ContainerList({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-3 overflow-auto thin-scrollbar pb-2">
|
||||
{filteredContainers.map((container) => (
|
||||
<ContainerCard
|
||||
key={container.id}
|
||||
container={container}
|
||||
sessionId={sessionId}
|
||||
onSelect={() => onSelectContainer(container.id)}
|
||||
isSelected={selectedContainerId === container.id}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
))}
|
||||
<div className="min-h-0 flex-1 overflow-auto thin-scrollbar pr-1">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full pb-2">
|
||||
{filteredContainers.map((container) => (
|
||||
<ContainerCard
|
||||
key={container.id}
|
||||
container={container}
|
||||
sessionId={sessionId}
|
||||
onSelect={() => onSelectContainer(container.id)}
|
||||
isSelected={selectedContainerId === container.id}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -599,13 +599,13 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
);
|
||||
|
||||
const debouncedLoadDirectory = useCallback(
|
||||
(path: string) => {
|
||||
(path: string, force?: boolean) => {
|
||||
if (pathChangeTimerRef.current) {
|
||||
clearTimeout(pathChangeTimerRef.current);
|
||||
}
|
||||
|
||||
pathChangeTimerRef.current = setTimeout(() => {
|
||||
if (path !== lastPathChangeRef.current && sshSessionId) {
|
||||
if ((force || path !== lastPathChangeRef.current) && sshSessionId) {
|
||||
lastPathChangeRef.current = path;
|
||||
loadDirectory(path);
|
||||
}
|
||||
@@ -641,6 +641,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
|
||||
setLastRefreshTime(now);
|
||||
// Force reset loading state to ensure refresh is not blocked
|
||||
setIsLoading(false);
|
||||
currentLoadingPathRef.current = "";
|
||||
loadDirectory(currentPath);
|
||||
}, [currentPath, lastRefreshTime, loadDirectory]);
|
||||
|
||||
@@ -778,6 +781,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
toast.success(
|
||||
t("fileManager.fileDownloadedSuccessfully", { name: file.name }),
|
||||
);
|
||||
} else {
|
||||
toast.error(t("fileManager.failedToDownloadFile"));
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
|
||||
@@ -1177,6 +1177,9 @@ export function FileManagerGrid({
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-24 text-right hidden md:block">
|
||||
{t("fileManager.owner", "Owner")}
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-20 text-right">
|
||||
{t("fileManager.permissions")}
|
||||
</div>
|
||||
@@ -1275,6 +1278,14 @@ export function FileManagerGrid({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 text-right w-24 hidden md:block">
|
||||
{file.owner && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{file.owner}
|
||||
{file.group ? `:${file.group}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-right w-20">
|
||||
{file.permissions && (
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "react";
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getCookie, isElectron } from "@/ui/main-axios.ts";
|
||||
import { getCookie, isElectron, isEmbeddedMode } from "@/ui/main-axios.ts";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
|
||||
@@ -55,9 +55,15 @@ export const GuacamoleDisplay = forwardRef<
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const displayRef = useRef<HTMLDivElement>(null);
|
||||
const displayElementRef = useRef<HTMLElement | null>(null);
|
||||
const clientRef = useRef<Guacamole.Client | null>(null);
|
||||
const keyboardRef = useRef<Guacamole.Keyboard | null>(null);
|
||||
const scaleRef = useRef<number>(1);
|
||||
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasKeyboardFocusRef = useRef(false);
|
||||
const windowFocusedRef = useRef(
|
||||
typeof document === "undefined" ? true : document.hasFocus(),
|
||||
);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
@@ -138,22 +144,44 @@ export const GuacamoleDisplay = forwardRef<
|
||||
token = data.token;
|
||||
}
|
||||
|
||||
const width = connectionConfig.width || containerWidth || 1280;
|
||||
const height = connectionConfig.height || containerHeight || 720;
|
||||
const dpi = connectionConfig.dpi || 96;
|
||||
const width = connectionConfig.width ?? containerWidth ?? 1280;
|
||||
const height = connectionConfig.height ?? containerHeight ?? 720;
|
||||
const protocol = connectionConfig.protocol ?? connectionConfig.type;
|
||||
const dpi = protocol === "rdp" ? (connectionConfig.dpi ?? 96) : null;
|
||||
|
||||
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/`;
|
||||
const configuredUrl = (
|
||||
window as { configuredServerUrl?: string }
|
||||
).configuredServerUrl;
|
||||
|
||||
// Embedded mode or no configured remote server: connect directly
|
||||
// to the local guacamole websocket service.
|
||||
if (isEmbeddedMode() || !configuredUrl) {
|
||||
return "ws://127.0.0.1:30008";
|
||||
}
|
||||
|
||||
const wsProtocol = configuredUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = configuredUrl
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "");
|
||||
return `${wsProtocol}${wsHost}/guacamole/websocket/`;
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/guacamole/websocket/`;
|
||||
|
||||
return `${wsBase}?token=${encodeURIComponent(token)}&width=${width}&height=${height}&dpi=${dpi}`;
|
||||
const params = new URLSearchParams({
|
||||
token,
|
||||
width: String(width),
|
||||
height: String(height),
|
||||
});
|
||||
if (dpi !== null && dpi !== undefined) {
|
||||
params.set("dpi", String(dpi));
|
||||
}
|
||||
return `${wsBase}?${params.toString()}`;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
@@ -164,6 +192,63 @@ export const GuacamoleDisplay = forwardRef<
|
||||
[connectionConfig, onError],
|
||||
);
|
||||
|
||||
const refreshKeyboardHandlers = useCallback(() => {
|
||||
const keyboard = keyboardRef.current;
|
||||
const client = clientRef.current;
|
||||
const displayElement = displayElementRef.current;
|
||||
|
||||
if (!keyboard) return;
|
||||
|
||||
const documentVisible =
|
||||
typeof document === "undefined" || document.visibilityState === "visible";
|
||||
const displayIsFocused =
|
||||
!!displayElement &&
|
||||
typeof document !== "undefined" &&
|
||||
document.activeElement === displayElement;
|
||||
const shouldCaptureInput =
|
||||
!!client &&
|
||||
!!displayElement &&
|
||||
isVisible &&
|
||||
documentVisible &&
|
||||
windowFocusedRef.current &&
|
||||
(hasKeyboardFocusRef.current || displayIsFocused);
|
||||
|
||||
if (!shouldCaptureInput) {
|
||||
keyboard.onkeydown = null;
|
||||
keyboard.onkeyup = null;
|
||||
keyboard.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
keyboard.onkeydown = (keysym: number) => {
|
||||
if (!clientRef.current) return;
|
||||
if (!isVisible || !windowFocusedRef.current) return;
|
||||
|
||||
const activeDisplay = displayElementRef.current;
|
||||
const stillFocused =
|
||||
!!activeDisplay &&
|
||||
typeof document !== "undefined" &&
|
||||
document.activeElement === activeDisplay;
|
||||
|
||||
if (!hasKeyboardFocusRef.current && !stillFocused) return;
|
||||
clientRef.current.sendKeyEvent(1, keysym);
|
||||
};
|
||||
|
||||
keyboard.onkeyup = (keysym: number) => {
|
||||
if (!clientRef.current) return;
|
||||
if (!isVisible || !windowFocusedRef.current) return;
|
||||
|
||||
const activeDisplay = displayElementRef.current;
|
||||
const stillFocused =
|
||||
!!activeDisplay &&
|
||||
typeof document !== "undefined" &&
|
||||
document.activeElement === activeDisplay;
|
||||
|
||||
if (!hasKeyboardFocusRef.current && !stillFocused) return;
|
||||
clientRef.current.sendKeyEvent(0, keysym);
|
||||
};
|
||||
}, [isVisible]);
|
||||
|
||||
const rescaleDisplay = useCallback((immediate: boolean = false) => {
|
||||
if (!clientRef.current || !containerRef.current) return;
|
||||
|
||||
@@ -220,12 +305,16 @@ export const GuacamoleDisplay = forwardRef<
|
||||
|
||||
const display = client.getDisplay();
|
||||
const displayElement = display.getElement();
|
||||
displayElementRef.current = displayElement;
|
||||
|
||||
if (displayRef.current) {
|
||||
displayRef.current.innerHTML = "";
|
||||
displayRef.current.appendChild(displayElement);
|
||||
}
|
||||
|
||||
displayElement.setAttribute("tabindex", "0");
|
||||
displayElement.style.outline = "none";
|
||||
|
||||
display.onresize = () => {
|
||||
rescaleDisplay(true);
|
||||
setIsReady(true);
|
||||
@@ -233,6 +322,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
|
||||
const mouse = new Guacamole.Mouse(displayElement);
|
||||
const sendMouseState = (state: Guacamole.Mouse.State) => {
|
||||
displayElement.focus({ preventScroll: true });
|
||||
const scale = scaleRef.current;
|
||||
const adjustedX = Math.round(state.x / scale);
|
||||
const adjustedY = Math.round(state.y / scale);
|
||||
@@ -251,14 +341,24 @@ export const GuacamoleDisplay = forwardRef<
|
||||
};
|
||||
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState;
|
||||
|
||||
const keyboard = new Guacamole.Keyboard(document);
|
||||
keyboard.onkeydown = (keysym: number) => {
|
||||
client.sendKeyEvent(1, keysym);
|
||||
const keyboard = new Guacamole.Keyboard(displayElement);
|
||||
keyboardRef.current = keyboard;
|
||||
|
||||
const handleDisplayFocus = () => {
|
||||
hasKeyboardFocusRef.current = true;
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
keyboard.onkeyup = (keysym: number) => {
|
||||
client.sendKeyEvent(0, keysym);
|
||||
|
||||
const handleDisplayBlur = () => {
|
||||
hasKeyboardFocusRef.current = false;
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
|
||||
displayElement.addEventListener("focus", handleDisplayFocus);
|
||||
displayElement.addEventListener("blur", handleDisplayBlur);
|
||||
displayElement.addEventListener("mousedown", handleDisplayFocus);
|
||||
refreshKeyboardHandlers();
|
||||
|
||||
client.onstatechange = (state: number) => {
|
||||
switch (state) {
|
||||
case 0:
|
||||
@@ -270,6 +370,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
break;
|
||||
case 3:
|
||||
setIsConnecting(false);
|
||||
setIsReady(true);
|
||||
onConnect?.();
|
||||
break;
|
||||
case 4:
|
||||
@@ -277,8 +378,8 @@ export const GuacamoleDisplay = forwardRef<
|
||||
case 5:
|
||||
setIsConnecting(false);
|
||||
setIsReady(false);
|
||||
keyboard.onkeydown = null;
|
||||
keyboard.onkeyup = null;
|
||||
hasKeyboardFocusRef.current = false;
|
||||
refreshKeyboardHandlers();
|
||||
onDisconnect?.();
|
||||
break;
|
||||
}
|
||||
@@ -304,8 +405,19 @@ export const GuacamoleDisplay = forwardRef<
|
||||
}
|
||||
};
|
||||
|
||||
client.onaudio = (stream: Guacamole.InputStream, mimetype: string) => {
|
||||
Guacamole.AudioPlayer.getInstance(stream, mimetype);
|
||||
};
|
||||
|
||||
client.connect();
|
||||
}, [getWebSocketUrl, onConnect, onDisconnect, onError, rescaleDisplay]);
|
||||
}, [
|
||||
getWebSocketUrl,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onError,
|
||||
refreshKeyboardHandlers,
|
||||
rescaleDisplay,
|
||||
]);
|
||||
|
||||
const hasInitiatedRef = useRef(false);
|
||||
const isMountedRef = useRef(false);
|
||||
@@ -324,6 +436,46 @@ export const GuacamoleDisplay = forwardRef<
|
||||
}
|
||||
}, [isVisible, connect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
hasKeyboardFocusRef.current = false;
|
||||
}
|
||||
|
||||
refreshKeyboardHandlers();
|
||||
}, [isVisible, refreshKeyboardHandlers]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowFocus = () => {
|
||||
windowFocusedRef.current = true;
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
windowFocusedRef.current = false;
|
||||
hasKeyboardFocusRef.current = false;
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
windowFocusedRef.current =
|
||||
document.visibilityState === "visible" && document.hasFocus();
|
||||
if (document.visibilityState !== "visible") {
|
||||
hasKeyboardFocusRef.current = false;
|
||||
}
|
||||
refreshKeyboardHandlers();
|
||||
};
|
||||
|
||||
window.addEventListener("focus", handleWindowFocus);
|
||||
window.addEventListener("blur", handleWindowBlur);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleWindowFocus);
|
||||
window.removeEventListener("blur", handleWindowBlur);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [refreshKeyboardHandlers]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
@@ -336,6 +488,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
clientRef.current.disconnect();
|
||||
clientRef.current = null;
|
||||
}
|
||||
displayElementRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -344,6 +497,14 @@ export const GuacamoleDisplay = forwardRef<
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
rescaleDisplay(false);
|
||||
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current);
|
||||
resizeTimeoutRef.current = setTimeout(() => {
|
||||
if (clientRef.current && containerRef.current) {
|
||||
const w = containerRef.current.clientWidth;
|
||||
const h = containerRef.current.clientHeight;
|
||||
if (w > 0 && h > 0) clientRef.current.sendSize(w, h);
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
|
||||
resizeObserver.observe(containerRef.current);
|
||||
@@ -356,6 +517,40 @@ export const GuacamoleDisplay = forwardRef<
|
||||
};
|
||||
}, [rescaleDisplay]);
|
||||
|
||||
const syncClipboard = useCallback(() => {
|
||||
const client = clientRef.current;
|
||||
if (!client) return;
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then((text) => {
|
||||
if (text) {
|
||||
const stream = client.createClipboardStream("text/plain");
|
||||
const writer = new Guacamole.StringWriter(stream);
|
||||
writer.sendText(text);
|
||||
writer.sendEnd();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && isReady) {
|
||||
syncClipboard();
|
||||
}
|
||||
}, [isVisible, isReady, syncClipboard]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !isReady) return;
|
||||
|
||||
const handleFocus = () => syncClipboard();
|
||||
container.addEventListener("mouseenter", handleFocus);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("mouseenter", handleFocus);
|
||||
};
|
||||
}, [isReady, syncClipboard]);
|
||||
|
||||
const connectingMessage = t("guacamole.connecting", {
|
||||
type: (
|
||||
connectionConfig.protocol ||
|
||||
|
||||
@@ -479,7 +479,7 @@ function ServerStatsInner({
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
if (!cancelled) {
|
||||
if (!cancelled && data) {
|
||||
setMetrics(data);
|
||||
setMetricsHistory((prev) => {
|
||||
const newHistory = [...prev, data];
|
||||
@@ -636,7 +636,9 @@ function ServerStatsInner({
|
||||
const data = await getServerMetricsById(
|
||||
currentHostConfig.id,
|
||||
);
|
||||
setMetrics(data);
|
||||
if (data) {
|
||||
setMetrics(data);
|
||||
}
|
||||
setShowStatsUI(true);
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
|
||||
@@ -17,16 +17,20 @@ import { getBasePath } from "@/lib/base-path";
|
||||
import {
|
||||
getCookie,
|
||||
isElectron,
|
||||
isEmbeddedMode,
|
||||
logActivity,
|
||||
getSnippets,
|
||||
deleteCommandFromHistory,
|
||||
getCommandHistory,
|
||||
getHostPassword,
|
||||
getServerConfig,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { TOTPDialog } from "@/ui/desktop/navigation/dialogs/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ui/desktop/navigation/dialogs/SSHAuthDialog.tsx";
|
||||
import { WarpgateDialog } from "@/ui/desktop/navigation/dialogs/WarpgateDialog.tsx";
|
||||
import { OPKSSHDialog } from "@/ui/desktop/navigation/dialogs/OPKSSHDialog.tsx";
|
||||
import { HostKeyVerificationDialog } from "@/ui/desktop/navigation/dialogs/HostKeyVerificationDialog.tsx";
|
||||
import { TmuxSessionPicker } from "@/ui/desktop/navigation/dialogs/TmuxSessionPicker.tsx";
|
||||
import {
|
||||
TERMINAL_THEMES,
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
@@ -47,6 +51,7 @@ import {
|
||||
} from "@/ui/desktop/navigation/connection-log/ConnectionLogContext.tsx";
|
||||
import { ConnectionLog } from "@/ui/desktop/navigation/connection-log/ConnectionLog.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface HostConfig {
|
||||
id?: number;
|
||||
@@ -66,6 +71,7 @@ interface HostConfig {
|
||||
|
||||
interface TerminalHandle {
|
||||
disconnect: () => void;
|
||||
reconnect: () => void;
|
||||
fit: () => void;
|
||||
sendInput: (data: string) => void;
|
||||
notifyResize: () => void;
|
||||
@@ -82,6 +88,110 @@ interface SSHTerminalProps {
|
||||
onTitleChange?: (title: string) => void;
|
||||
initialPath?: string;
|
||||
executeCommand?: string;
|
||||
onOpenFileManager?: () => void;
|
||||
previewTheme?: string | null;
|
||||
}
|
||||
|
||||
function TerminalContextMenu({
|
||||
x,
|
||||
y,
|
||||
hasSelection,
|
||||
showCopyPaste,
|
||||
showOpenFileManager,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onOpenFileManager,
|
||||
onClose,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
hasSelection: boolean;
|
||||
showCopyPaste: boolean;
|
||||
showOpenFileManager: boolean;
|
||||
onCopy: () => void;
|
||||
onPaste: () => void;
|
||||
onOpenFileManager: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const menuX = x + 180 > window.innerWidth ? window.innerWidth - 190 : x;
|
||||
const menuY = y + 150 > window.innerHeight ? window.innerHeight - 160 : y;
|
||||
|
||||
useEffect(() => {
|
||||
let cleanup: (() => void) | null = null;
|
||||
const timeoutId = setTimeout(() => {
|
||||
const handleClose = (e: MouseEvent) => {
|
||||
if (!menuRef.current?.contains(e.target as Element)) onClose();
|
||||
};
|
||||
const handleRightClick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("mousedown", handleClose, true);
|
||||
document.addEventListener("contextmenu", handleRightClick);
|
||||
document.addEventListener("keydown", handleKey);
|
||||
window.addEventListener("blur", onClose);
|
||||
|
||||
cleanup = () => {
|
||||
document.removeEventListener("mousedown", handleClose, true);
|
||||
document.removeEventListener("contextmenu", handleRightClick);
|
||||
document.removeEventListener("keydown", handleKey);
|
||||
window.removeEventListener("blur", onClose);
|
||||
};
|
||||
}, 50);
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
cleanup?.();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const items: { label: string; action: () => void; disabled?: boolean }[] = [];
|
||||
|
||||
if (showCopyPaste) {
|
||||
items.push(
|
||||
{ label: t("terminal.copy"), action: onCopy, disabled: !hasSelection },
|
||||
{ label: t("terminal.paste"), action: onPaste },
|
||||
);
|
||||
}
|
||||
|
||||
if (showOpenFileManager) {
|
||||
items.push({
|
||||
label: t("terminal.openFileManagerHere"),
|
||||
action: onOpenFileManager,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99990]" />
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed bg-canvas border border-edge rounded-lg shadow-xl min-w-[180px] z-[99995] overflow-hidden"
|
||||
style={{ left: menuX, top: menuY }}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`w-full px-3 py-2 text-left text-sm flex items-center hover:bg-hover transition-colors first:rounded-t-lg last:rounded-b-lg ${item.disabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
if (!item.disabled) {
|
||||
item.action();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
@@ -94,6 +204,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
onTitleChange,
|
||||
initialPath,
|
||||
executeCommand,
|
||||
onOpenFileManager,
|
||||
previewTheme,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -114,21 +226,37 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const { theme: appTheme } = useTheme();
|
||||
const { addLog, isExpanded: isConnectionLogExpanded } = useConnectionLog();
|
||||
|
||||
const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig };
|
||||
const savedTheme = localStorage.getItem(
|
||||
`terminal_theme_host_${hostConfig.id}`,
|
||||
);
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...hostConfig.terminalConfig,
|
||||
theme:
|
||||
savedTheme ||
|
||||
hostConfig.terminalConfig?.theme ||
|
||||
DEFAULT_TERMINAL_CONFIG.theme,
|
||||
};
|
||||
|
||||
const isDarkMode =
|
||||
appTheme === "dark" ||
|
||||
appTheme === "dracula" ||
|
||||
appTheme === "gentlemansChoice" ||
|
||||
appTheme === "midnightEspresso" ||
|
||||
appTheme === "catppuccinMocha" ||
|
||||
(appTheme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
|
||||
let themeColors;
|
||||
if (config.theme === "termix") {
|
||||
const activeTheme = previewTheme || config.theme;
|
||||
|
||||
if (activeTheme === "termix") {
|
||||
themeColors = isDarkMode
|
||||
? TERMINAL_THEMES.termixDark.colors
|
||||
: TERMINAL_THEMES.termixLight.colors;
|
||||
} else {
|
||||
themeColors =
|
||||
TERMINAL_THEMES[config.theme]?.colors ||
|
||||
TERMINAL_THEMES[activeTheme]?.colors ||
|
||||
TERMINAL_THEMES.termixDark.colors;
|
||||
}
|
||||
const backgroundColor = themeColors.background;
|
||||
@@ -137,11 +265,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const wasDisconnectedBySSH = useRef(false);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const pongReceivedRef = useRef(true);
|
||||
const pongTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isFitted, setIsFitted] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const connectionErrorRef = useRef<string | null>(null);
|
||||
const [showDisconnectedOverlay, setShowDisconnectedOverlay] =
|
||||
useState(false);
|
||||
|
||||
const updateConnectionError = useCallback((error: string | null) => {
|
||||
connectionErrorRef.current = error;
|
||||
@@ -169,8 +301,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
requestId: string;
|
||||
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
|
||||
error?: string;
|
||||
providers?: Array<{ alias: string; issuer: string }>;
|
||||
} | null>(null);
|
||||
const opksshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
hasSelection: boolean;
|
||||
} | null>(null);
|
||||
const opksshFailedRef = useRef(false);
|
||||
const currentHostIdRef = useRef<number | null>(null);
|
||||
const currentHostConfigRef = useRef<any>(null);
|
||||
@@ -183,12 +322,23 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const isAttachingSessionRef = useRef<boolean>(false);
|
||||
const [tmuxSessionPicker, setTmuxSessionPicker] = useState<{
|
||||
sessions: Array<{
|
||||
name: string;
|
||||
created: number;
|
||||
lastActivity: number;
|
||||
windows: number;
|
||||
attachedClients: number;
|
||||
}>;
|
||||
} | null>(null);
|
||||
const tmuxSessionNameRef = useRef<string | null>(null);
|
||||
const tmuxCopyModeHintShownRef = useRef(false);
|
||||
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
const isFittingRef = useRef(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttempts = useRef(0);
|
||||
const maxReconnectAttempts = 3;
|
||||
const maxReconnectAttempts = 8;
|
||||
const isUnmountingRef = useRef(false);
|
||||
const shouldNotReconnectRef = useRef(false);
|
||||
const isReconnectingRef = useRef(false);
|
||||
@@ -211,11 +361,35 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const activityLoggedRef = useRef(false);
|
||||
const keyHandlerAttachedRef = useRef(false);
|
||||
const [commandHistoryTrackingEnabled, setCommandHistoryTrackingEnabled] =
|
||||
useState<boolean>(
|
||||
() => localStorage.getItem("commandHistoryTracking") === "true",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCommandHistoryTrackingChanged = () => {
|
||||
setCommandHistoryTrackingEnabled(
|
||||
localStorage.getItem("commandHistoryTracking") === "true",
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
"commandHistoryTrackingChanged",
|
||||
handleCommandHistoryTrackingChanged,
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"commandHistoryTrackingChanged",
|
||||
handleCommandHistoryTrackingChanged,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { trackInput, getCurrentCommand, updateCurrentCommand } =
|
||||
useCommandTracker({
|
||||
hostId: hostConfig.id,
|
||||
enabled: true,
|
||||
enabled: commandHistoryTrackingEnabled,
|
||||
onCommandExecuted: (command) => {
|
||||
if (!autocompleteHistory.current.includes(command)) {
|
||||
autocompleteHistory.current = [
|
||||
@@ -558,6 +732,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
if (pongTimeoutRef.current) {
|
||||
clearTimeout(pongTimeoutRef.current);
|
||||
pongTimeoutRef.current = null;
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
@@ -584,6 +762,23 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
},
|
||||
reconnect: () => {
|
||||
isUnmountingRef.current = false;
|
||||
shouldNotReconnectRef.current = false;
|
||||
isReconnectingRef.current = false;
|
||||
isConnectingRef.current = false;
|
||||
reconnectAttempts.current = 0;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
wasConnectedRef.current = false;
|
||||
updateConnectionError(null);
|
||||
setShowDisconnectedOverlay(false);
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
connectToHost(cols, rows);
|
||||
}
|
||||
},
|
||||
fit: () => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
@@ -628,9 +823,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
if (reconnectAttempts.current >= maxReconnectAttempts) {
|
||||
updateConnectionError(t("terminal.maxReconnectAttemptsReached"));
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
setShowDisconnectedOverlay(true);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
@@ -706,7 +901,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function connectToHost(cols: number, rows: number) {
|
||||
async function connectToHost(cols: number, rows: number) {
|
||||
if (isConnectingRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -738,20 +933,52 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
const baseWsUrl = isDev
|
||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
|
||||
: isElectron()
|
||||
? (() => {
|
||||
const baseUrl =
|
||||
(window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl || "http://127.0.0.1:30001";
|
||||
const wsProtocol = baseUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||
return `${wsProtocol}${wsHost}/ssh/websocket/`;
|
||||
})()
|
||||
: `${getBasePath()}/ssh/websocket/`;
|
||||
let baseWsUrl: string;
|
||||
|
||||
if (isDev) {
|
||||
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
|
||||
} else if (isElectron()) {
|
||||
let configuredUrl = (window as { configuredServerUrl?: string | null })
|
||||
.configuredServerUrl;
|
||||
|
||||
if (!configuredUrl && !isEmbeddedMode()) {
|
||||
try {
|
||||
const serverConfig = await getServerConfig();
|
||||
configuredUrl = serverConfig?.serverUrl || null;
|
||||
if (configuredUrl) {
|
||||
(
|
||||
window as Window &
|
||||
typeof globalThis & {
|
||||
configuredServerUrl?: string | null;
|
||||
}
|
||||
).configuredServerUrl = configuredUrl;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve Electron server URL:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (isEmbeddedMode()) {
|
||||
baseWsUrl = "ws://127.0.0.1:30002";
|
||||
} else if (!configuredUrl) {
|
||||
console.error("No configured server URL available for Electron SSH");
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
updateConnectionError(t("errors.failedToLoadServer"));
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
} else {
|
||||
const wsProtocol = configuredUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = configuredUrl
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "");
|
||||
baseWsUrl = `${wsProtocol}${wsHost}/ssh/websocket/`;
|
||||
}
|
||||
} else {
|
||||
baseWsUrl = `${getBasePath()}/ssh/websocket/`;
|
||||
}
|
||||
|
||||
if (
|
||||
webSocketRef.current &&
|
||||
@@ -769,9 +996,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const ws = new WebSocket(baseWsUrl);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
updateConnectionError(null);
|
||||
@@ -857,8 +1082,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
});
|
||||
|
||||
pongReceivedRef.current = true;
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
if (!pongReceivedRef.current) {
|
||||
console.warn(
|
||||
"[WebSocket] Pong timeout - connection appears dead, closing",
|
||||
);
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
pongReceivedRef.current = false;
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}, 30000);
|
||||
@@ -867,6 +1101,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
ws.addEventListener("message", (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "pong") {
|
||||
pongReceivedRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (msg.type === "data") {
|
||||
if (typeof msg.data === "string") {
|
||||
const syntaxHighlightingEnabled =
|
||||
@@ -879,19 +1117,36 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
terminal.write(outputData);
|
||||
const sudoPasswordPattern =
|
||||
/(?:\[sudo\][^\n]*:\s*$|sudo:[^\n]*password[^\n]*required)/i;
|
||||
const passwordToFill =
|
||||
hostConfig.terminalConfig?.sudoPassword || hostConfig.password;
|
||||
const hasSudoPw =
|
||||
hostConfig.terminalConfig?.sudoPassword ||
|
||||
hostConfig.password ||
|
||||
hostConfig.hasSudoPassword ||
|
||||
hostConfig.hasPassword;
|
||||
if (
|
||||
config.sudoPasswordAutoFill &&
|
||||
sudoPasswordPattern.test(msg.data) &&
|
||||
passwordToFill &&
|
||||
hasSudoPw &&
|
||||
!sudoPromptShownRef.current
|
||||
) {
|
||||
sudoPromptShownRef.current = true;
|
||||
confirmWithToast(
|
||||
t("terminal.sudoPasswordPopupTitle"),
|
||||
async () => {
|
||||
// Fetch password on-demand from server
|
||||
let passwordToFill =
|
||||
hostConfig.terminalConfig?.sudoPassword ||
|
||||
hostConfig.password;
|
||||
if (!passwordToFill && hostConfig.id) {
|
||||
passwordToFill =
|
||||
(await getHostPassword(
|
||||
hostConfig.id,
|
||||
"sudoPassword",
|
||||
)) ||
|
||||
(await getHostPassword(hostConfig.id, "password")) ||
|
||||
undefined;
|
||||
}
|
||||
if (
|
||||
passwordToFill &&
|
||||
webSocketRef.current &&
|
||||
webSocketRef.current.readyState === WebSocket.OPEN
|
||||
) {
|
||||
@@ -1052,19 +1307,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
} else if (msg.type === "disconnected") {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
setIsConnected(false);
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
setIsConnecting(false);
|
||||
if (wasConnectedRef.current) {
|
||||
wasConnectedRef.current = false;
|
||||
if (
|
||||
onClose &&
|
||||
!connectionErrorRef.current &&
|
||||
!opksshFailedRef.current
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
setShowDisconnectedOverlay(true);
|
||||
} else if (!connectionErrorRef.current) {
|
||||
updateConnectionError(
|
||||
msg.message || t("terminal.connectionRejected"),
|
||||
@@ -1158,6 +1404,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
authUrl: msg.url || "",
|
||||
requestId: msg.requestId || "",
|
||||
stage: "chooser",
|
||||
providers: msg.providers,
|
||||
});
|
||||
if (opksshTimeoutRef.current) {
|
||||
clearTimeout(opksshTimeoutRef.current);
|
||||
@@ -1345,6 +1592,40 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const cols = terminal?.cols || 80;
|
||||
const rows = terminal?.rows || 24;
|
||||
connectToHost(cols, rows);
|
||||
} else if (msg.type === "tmux_sessions_available") {
|
||||
setTmuxSessionPicker({
|
||||
sessions: msg.sessions,
|
||||
});
|
||||
} else if (
|
||||
msg.type === "tmux_session_created" ||
|
||||
msg.type === "tmux_session_attached"
|
||||
) {
|
||||
const sessionName =
|
||||
typeof msg.sessionName === "string" ? msg.sessionName : "";
|
||||
tmuxSessionNameRef.current = sessionName || "(active)";
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "connection",
|
||||
message:
|
||||
msg.type === "tmux_session_created"
|
||||
? t("terminal.tmuxSessionCreated", {
|
||||
name: sessionName || "new",
|
||||
})
|
||||
: t("terminal.tmuxSessionAttached", {
|
||||
name: sessionName,
|
||||
}),
|
||||
});
|
||||
} else if (msg.type === "tmux_unavailable") {
|
||||
setTimeout(() => {
|
||||
toast.warning(t("terminal.tmuxUnavailable"), {
|
||||
duration: 8000,
|
||||
});
|
||||
}, 500);
|
||||
addLog({
|
||||
type: "warning",
|
||||
stage: "connection",
|
||||
message: t("terminal.tmuxUnavailable"),
|
||||
});
|
||||
} else if (msg.type === "connection_log") {
|
||||
if (msg.data) {
|
||||
addLog({
|
||||
@@ -1369,8 +1650,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
setIsConnected(false);
|
||||
isConnectingRef.current = false;
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
|
||||
if (pongTimeoutRef.current) {
|
||||
clearTimeout(pongTimeoutRef.current);
|
||||
pongTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (totpTimeoutRef.current) {
|
||||
@@ -1379,17 +1662,21 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
if (event.code === 1006) {
|
||||
console.error(
|
||||
"[WebSocket] Abnormal closure detected - possible HTTPS/proxy issue",
|
||||
console.warn(
|
||||
"[WebSocket] Abnormal closure detected - attempting reconnection",
|
||||
);
|
||||
addLog({
|
||||
type: "error",
|
||||
type: "warning",
|
||||
stage: "connection",
|
||||
message: t("terminal.websocketAbnormalClose"),
|
||||
});
|
||||
updateConnectionError(t("terminal.websocketAbnormalClose"));
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
|
||||
if (wasConnectedRef.current) {
|
||||
attemptReconnection();
|
||||
} else {
|
||||
updateConnectionError(t("terminal.websocketAbnormalClose"));
|
||||
setIsConnecting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1406,10 +1693,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1581,22 +1864,25 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
commandHistoryContext.setOnDeleteCommand(handleDeleteCommand);
|
||||
}, [handleDeleteCommand]);
|
||||
|
||||
// Separate theme and options updates to avoid terminal re-initialization flashes
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current) return;
|
||||
if (!terminal) return;
|
||||
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...hostConfig.terminalConfig,
|
||||
...(hostConfig.terminalConfig as any),
|
||||
};
|
||||
|
||||
let themeColors;
|
||||
if (config.theme === "termix") {
|
||||
const activeTheme = previewTheme || config.theme;
|
||||
|
||||
if (activeTheme === "termix") {
|
||||
themeColors = isDarkMode
|
||||
? TERMINAL_THEMES.termixDark.colors
|
||||
: TERMINAL_THEMES.termixLight.colors;
|
||||
} else {
|
||||
themeColors =
|
||||
TERMINAL_THEMES[config.theme]?.colors ||
|
||||
TERMINAL_THEMES[activeTheme]?.colors ||
|
||||
TERMINAL_THEMES.termixDark.colors;
|
||||
}
|
||||
|
||||
@@ -1605,13 +1891,98 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
);
|
||||
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
|
||||
|
||||
// Update terminal options individually to avoid re-initialization flashes
|
||||
terminal.options.cursorBlink = config.cursorBlink;
|
||||
terminal.options.cursorStyle = config.cursorStyle;
|
||||
terminal.options.scrollback = config.scrollback;
|
||||
terminal.options.fontSize = config.fontSize;
|
||||
terminal.options.fontFamily = fontFamily;
|
||||
terminal.options.rightClickSelectsWord = config.rightClickSelectsWord;
|
||||
terminal.options.fastScrollModifier = config.fastScrollModifier;
|
||||
terminal.options.fastScrollSensitivity = config.fastScrollSensitivity;
|
||||
terminal.options.minimumContrastRatio = config.minimumContrastRatio;
|
||||
terminal.options.letterSpacing = config.letterSpacing;
|
||||
terminal.options.lineHeight = config.lineHeight;
|
||||
terminal.options.bellStyle = config.bellStyle as
|
||||
| "none"
|
||||
| "sound"
|
||||
| "visual"
|
||||
| "both";
|
||||
|
||||
terminal.options.theme = {
|
||||
background: themeColors.background,
|
||||
foreground: themeColors.foreground,
|
||||
cursor: themeColors.cursor,
|
||||
cursorAccent: themeColors.cursorAccent,
|
||||
selectionBackground: themeColors.selectionBackground,
|
||||
selectionForeground: themeColors.selectionForeground,
|
||||
black: themeColors.black,
|
||||
red: themeColors.red,
|
||||
green: themeColors.green,
|
||||
yellow: themeColors.yellow,
|
||||
blue: themeColors.blue,
|
||||
magenta: themeColors.magenta,
|
||||
cyan: themeColors.cyan,
|
||||
white: themeColors.white,
|
||||
brightBlack: themeColors.brightBlack,
|
||||
brightRed: themeColors.brightRed,
|
||||
brightGreen: themeColors.brightGreen,
|
||||
brightYellow: themeColors.brightYellow,
|
||||
brightBlue: themeColors.brightBlue,
|
||||
brightMagenta: themeColors.brightMagenta,
|
||||
brightCyan: themeColors.brightCyan,
|
||||
brightWhite: themeColors.brightWhite,
|
||||
};
|
||||
|
||||
// Ensure terminal is correctly fitted if font-related options change
|
||||
if (fitAddonRef.current && isFitted) {
|
||||
performFit();
|
||||
}
|
||||
|
||||
// Refresh terminal to apply new theme colors to existing buffer content
|
||||
hardRefresh();
|
||||
}, [
|
||||
terminal,
|
||||
hostConfig.terminalConfig,
|
||||
previewTheme,
|
||||
isDarkMode,
|
||||
isFitted,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current) return;
|
||||
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...(hostConfig.terminalConfig as any),
|
||||
};
|
||||
|
||||
const fontConfig = TERMINAL_FONTS.find(
|
||||
(f) => f.value === config.fontFamily,
|
||||
);
|
||||
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
|
||||
|
||||
let themeColors;
|
||||
const activeTheme = previewTheme || config.theme;
|
||||
|
||||
if (activeTheme === "termix") {
|
||||
themeColors = isDarkMode
|
||||
? TERMINAL_THEMES.termixDark.colors
|
||||
: TERMINAL_THEMES.termixLight.colors;
|
||||
} else {
|
||||
themeColors =
|
||||
TERMINAL_THEMES[activeTheme]?.colors ||
|
||||
TERMINAL_THEMES.termixDark.colors;
|
||||
}
|
||||
|
||||
// Set initial options before opening the terminal
|
||||
terminal.options = {
|
||||
cursorBlink: config.cursorBlink,
|
||||
cursorStyle: config.cursorStyle,
|
||||
scrollback: config.scrollback,
|
||||
fontSize: config.fontSize,
|
||||
fontFamily,
|
||||
allowTransparency: true,
|
||||
allowTransparency: true, // MUST be set before open()
|
||||
convertEol: false,
|
||||
windowsMode: false,
|
||||
macOptionIsMeta: false,
|
||||
@@ -1624,7 +1995,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
letterSpacing: config.letterSpacing,
|
||||
lineHeight: config.lineHeight,
|
||||
bellStyle: config.bellStyle as "none" | "sound" | "visual" | "both",
|
||||
|
||||
theme: {
|
||||
background: themeColors.background,
|
||||
foreground: themeColors.foreground,
|
||||
@@ -1678,26 +2048,71 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
const element = xtermRef.current;
|
||||
const handleContextMenu = async (e: MouseEvent) => {
|
||||
if (!getUseRightClickCopyPaste()) return;
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
if (getUseRightClickCopyPaste()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (terminal.hasSelection()) {
|
||||
const text = terminal.getSelection();
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => terminal.clearSelection());
|
||||
} else {
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
if (text) terminal.paste(text);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!onOpenFileManager) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (terminal.hasSelection()) {
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
await writeTextToClipboard(selection);
|
||||
terminal.clearSelection();
|
||||
}
|
||||
} else {
|
||||
const text = await readTextFromClipboard();
|
||||
if (text) terminal.paste(text);
|
||||
}
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
hasSelection: terminal.hasSelection(),
|
||||
});
|
||||
};
|
||||
element?.addEventListener("contextmenu", handleContextMenu);
|
||||
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const text = e.clipboardData?.getData("text");
|
||||
if (text) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
terminal.paste(text);
|
||||
}
|
||||
};
|
||||
element?.addEventListener("paste", handlePaste);
|
||||
|
||||
let tmuxDragTracking = false;
|
||||
const handleTmuxDragStart = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
if (!tmuxSessionNameRef.current) return;
|
||||
tmuxDragTracking = true;
|
||||
};
|
||||
const handleTmuxDragMove = () => {
|
||||
if (!tmuxDragTracking) return;
|
||||
tmuxDragTracking = false;
|
||||
if (tmuxCopyModeHintShownRef.current) return;
|
||||
tmuxCopyModeHintShownRef.current = true;
|
||||
toast.info(t("terminal.tmuxCopyHint"), { duration: 5000 });
|
||||
};
|
||||
const handleTmuxDragEnd = () => {
|
||||
tmuxDragTracking = false;
|
||||
};
|
||||
element?.addEventListener("mousedown", handleTmuxDragStart);
|
||||
element?.addEventListener("mousemove", handleTmuxDragMove);
|
||||
element?.addEventListener("mouseup", handleTmuxDragEnd);
|
||||
|
||||
const handleBackspaceMode = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Backspace") return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...(hostConfig.terminalConfig as any),
|
||||
};
|
||||
if (config.backspaceMode !== "control-h") return;
|
||||
|
||||
e.preventDefault();
|
||||
@@ -1729,11 +2144,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
resizeObserver.disconnect();
|
||||
clipboardProvider.dispose();
|
||||
element?.removeEventListener("contextmenu", handleContextMenu);
|
||||
element?.removeEventListener("paste", handlePaste);
|
||||
element?.removeEventListener("mousedown", handleTmuxDragStart);
|
||||
element?.removeEventListener("mousemove", handleTmuxDragMove);
|
||||
element?.removeEventListener("mouseup", handleTmuxDragEnd);
|
||||
element?.removeEventListener("keydown", handleBackspaceMode, true);
|
||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
};
|
||||
}, [xtermRef, terminal, hostConfig, isDarkMode]);
|
||||
}, [xtermRef, terminal]);
|
||||
|
||||
const isMountedRef = useRef(false);
|
||||
|
||||
@@ -1765,6 +2184,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
if (pongTimeoutRef.current) {
|
||||
clearTimeout(pongTimeoutRef.current);
|
||||
pongTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
const persistenceEnabled =
|
||||
localStorage.getItem("enableTerminalSessionPersistence") === "true";
|
||||
@@ -1838,11 +2261,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
!e.metaKey &&
|
||||
e.key.toLowerCase() === "v"
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
readTextFromClipboard().then((text) => {
|
||||
if (text) terminal.paste(text);
|
||||
});
|
||||
// Let the browser handle Ctrl+V natively, the paste event
|
||||
// listener will intercept the result without triggering the
|
||||
// clipboard permission popup
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2145,6 +2566,40 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
|
||||
{showDisconnectedOverlay && !isConnecting && (
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col items-center justify-center gap-4 z-10"
|
||||
style={{ backgroundColor }}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowDisconnectedOverlay(false);
|
||||
isUnmountingRef.current = false;
|
||||
shouldNotReconnectRef.current = false;
|
||||
isReconnectingRef.current = false;
|
||||
isConnectingRef.current = false;
|
||||
reconnectAttempts.current = 0;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
wasConnectedRef.current = false;
|
||||
updateConnectionError(null);
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
connectToHost(terminal.cols, terminal.rows);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("terminal.reconnect")}
|
||||
</Button>
|
||||
{onClose && (
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
{t("terminal.closeTab")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConnectionLog
|
||||
isConnecting={isConnecting}
|
||||
isConnected={isConnected}
|
||||
@@ -2191,6 +2646,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
requestId={opksshDialog.requestId}
|
||||
stage={opksshDialog.stage}
|
||||
error={opksshDialog.error}
|
||||
providers={opksshDialog.providers}
|
||||
onCancel={() => {
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.send(
|
||||
@@ -2217,6 +2673,22 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
);
|
||||
}
|
||||
}}
|
||||
onSelectProvider={(alias) => {
|
||||
if (!opksshDialog.authUrl) return;
|
||||
const selectUrl = `${opksshDialog.authUrl}/select?op=${encodeURIComponent(alias)}`;
|
||||
window.open(selectUrl, "_blank");
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "opkssh_browser_opened",
|
||||
data: { requestId: opksshDialog.requestId },
|
||||
}),
|
||||
);
|
||||
}
|
||||
setOpksshDialog((prev) =>
|
||||
prev ? { ...prev, stage: "waiting" } : null,
|
||||
);
|
||||
}}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
)}
|
||||
@@ -2254,6 +2726,37 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
/>
|
||||
)}
|
||||
|
||||
{tmuxSessionPicker && (
|
||||
<TmuxSessionPicker
|
||||
isOpen={true}
|
||||
sessions={tmuxSessionPicker.sessions}
|
||||
onSelect={(sessionName) => {
|
||||
setTmuxSessionPicker(null);
|
||||
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_attach",
|
||||
data: { sessionName },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onCreateNew={() => {
|
||||
setTmuxSessionPicker(null);
|
||||
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_attach",
|
||||
data: { sessionName: "" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onCancel={() => setTmuxSessionPicker(null)}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CommandAutocomplete
|
||||
visible={showAutocomplete}
|
||||
suggestions={autocompleteSuggestions}
|
||||
@@ -2261,6 +2764,29 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
position={autocompletePosition}
|
||||
onSelect={handleAutocompleteSelect}
|
||||
/>
|
||||
|
||||
{contextMenu && (
|
||||
<TerminalContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
hasSelection={contextMenu.hasSelection}
|
||||
showCopyPaste={getUseRightClickCopyPaste()}
|
||||
showOpenFileManager={!!onOpenFileManager}
|
||||
onCopy={async () => {
|
||||
const selection = terminal?.getSelection();
|
||||
if (selection) {
|
||||
await writeTextToClipboard(selection);
|
||||
terminal?.clearSelection();
|
||||
}
|
||||
}}
|
||||
onPaste={async () => {
|
||||
const text = await readTextFromClipboard();
|
||||
if (text) terminal?.paste(text);
|
||||
}}
|
||||
onOpenFileManager={() => onOpenFileManager?.()}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CredentialsManager } from "@/ui/desktop/apps/host-manager/credentials/C
|
||||
import { CredentialEditor } from "@/ui/desktop/apps/host-manager/credentials/CredentialEditor.tsx";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { exportSSHHostWithCredentials } from "@/ui/main-axios.ts";
|
||||
import type { SSHHost, HostManagerProps } from "../../../types/index";
|
||||
|
||||
export function HostManager({
|
||||
@@ -93,9 +94,13 @@ export function HostManager({
|
||||
}
|
||||
|
||||
if (hostConfig && hostConfig.id !== lastProcessedHostIdRef.current) {
|
||||
setEditingHost(hostConfig);
|
||||
setIsAddingHost(false);
|
||||
lastProcessedHostIdRef.current = hostConfig.id;
|
||||
setIsAddingHost(false);
|
||||
exportSSHHostWithCredentials(hostConfig.id)
|
||||
.then((fullHost) =>
|
||||
setEditingHost({ ...hostConfig, ...fullHost } as SSHHost),
|
||||
)
|
||||
.catch(() => setEditingHost(hostConfig));
|
||||
} else if (!hostConfig && editingHost) {
|
||||
setEditingHost(null);
|
||||
setIsAddingHost(false);
|
||||
@@ -111,17 +116,26 @@ export function HostManager({
|
||||
setActiveTab(normalizedTab);
|
||||
}
|
||||
if (hostConfig && hostConfig.id !== lastProcessedHostIdRef.current) {
|
||||
setEditingHost(hostConfig);
|
||||
setIsAddingHost(false);
|
||||
lastProcessedHostIdRef.current = hostConfig.id;
|
||||
setIsAddingHost(false);
|
||||
exportSSHHostWithCredentials(hostConfig.id)
|
||||
.then((fullHost) =>
|
||||
setEditingHost({ ...hostConfig, ...fullHost } as SSHHost),
|
||||
)
|
||||
.catch(() => setEditingHost(hostConfig));
|
||||
}
|
||||
}
|
||||
}, [_updateTimestamp, initialTab, hostConfig]);
|
||||
|
||||
const handleEditHost = (host: SSHHost) => {
|
||||
setEditingHost(host);
|
||||
const handleEditHost = async (host: SSHHost) => {
|
||||
setIsAddingHost(false);
|
||||
lastProcessedHostIdRef.current = host.id;
|
||||
try {
|
||||
const fullHost = await exportSSHHostWithCredentials(host.id);
|
||||
setEditingHost({ ...host, ...fullHost } as SSHHost);
|
||||
} catch {
|
||||
setEditingHost(host);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddHost = () => {
|
||||
|
||||
@@ -413,6 +413,7 @@ export function HostManagerEditor({
|
||||
sudoPassword: z.string().optional(),
|
||||
keepaliveInterval: z.number().min(0).max(300000).optional(),
|
||||
keepaliveCountMax: z.number().min(0).max(100).optional(),
|
||||
autoTmux: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
forceKeyboardInteractive: z.boolean().optional(),
|
||||
@@ -448,6 +449,16 @@ export function HostManagerEditor({
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
macAddress: z.string().optional(),
|
||||
portKnockSequence: z
|
||||
.array(
|
||||
z.object({
|
||||
port: z.coerce.number().min(1).max(65535),
|
||||
protocol: z.enum(["tcp", "udp"]).default("tcp"),
|
||||
delay: z.coerce.number().min(0).max(60000).default(100),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
enableDocker: z.boolean().default(false),
|
||||
domain: z.string().optional(),
|
||||
security: z.string().optional(),
|
||||
@@ -507,11 +518,7 @@ export function HostManagerEditor({
|
||||
});
|
||||
}
|
||||
if (!data.keyType) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("hosts.keyTypeRequired", "Key type is required"),
|
||||
path: ["keyType"],
|
||||
});
|
||||
data.keyType = "auto";
|
||||
}
|
||||
} else if (data.authType === "credential") {
|
||||
if (!data.credentialId) {
|
||||
@@ -522,19 +529,6 @@ export function HostManagerEditor({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
data.tunnelConnections.forEach((connection, index) => {
|
||||
if (
|
||||
connection.endpointHost &&
|
||||
!sshConfigurations.includes(connection.endpointHost)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("hosts.mustSelectValidSshConfig"),
|
||||
path: ["tunnelConnections", index, "endpointHost"],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
@@ -580,6 +574,8 @@ export function HostManagerEditor({
|
||||
socks5Username: "",
|
||||
socks5Password: "",
|
||||
socks5ProxyChain: [],
|
||||
macAddress: "",
|
||||
portKnockSequence: [],
|
||||
enableDocker: false,
|
||||
domain: "",
|
||||
security: "any",
|
||||
@@ -821,6 +817,10 @@ export function HostManagerEditor({
|
||||
socks5ProxyChain: Array.isArray(cleanedHost.socks5ProxyChain)
|
||||
? cleanedHost.socks5ProxyChain
|
||||
: [],
|
||||
macAddress: cleanedHost.macAddress || "",
|
||||
portKnockSequence: Array.isArray(cleanedHost.portKnockSequence)
|
||||
? cleanedHost.portKnockSequence
|
||||
: [],
|
||||
enableDocker: Boolean(cleanedHost.enableDocker),
|
||||
domain: (cleanedHost as any).domain || "",
|
||||
security: (cleanedHost as any).security || "any",
|
||||
@@ -1086,6 +1086,7 @@ export function HostManagerEditor({
|
||||
socks5Username: "general",
|
||||
socks5Password: "general",
|
||||
socks5ProxyChain: "general",
|
||||
portKnockSequence: "general",
|
||||
quickActions: "general",
|
||||
enableTerminal: "terminal",
|
||||
terminalConfig: "terminal",
|
||||
@@ -1248,7 +1249,10 @@ export function HostManagerEditor({
|
||||
};
|
||||
|
||||
const handleSshConfigClick = (config: string, index: number) => {
|
||||
form.setValue(`tunnelConnections.${index}.endpointHost`, config);
|
||||
form.setValue(`tunnelConnections.${index}.endpointHost`, config, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
setSshConfigDropdownOpen((prev) => ({ ...prev, [index]: false }));
|
||||
};
|
||||
|
||||
|
||||
@@ -35,14 +35,15 @@ import {
|
||||
updateSSHHost,
|
||||
renameFolder,
|
||||
exportSSHHostWithCredentials,
|
||||
exportAllSSHHosts,
|
||||
getSSHFolders,
|
||||
updateFolderMetadata,
|
||||
deleteAllHostsInFolder,
|
||||
refreshServerPolling,
|
||||
isElectron,
|
||||
getConfiguredServerUrl,
|
||||
getGuacamoleDpi,
|
||||
getGuacamoleTokenFromHost,
|
||||
getGuacamoleToken,
|
||||
logActivity,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useServerStatus } from "@/ui/contexts/ServerStatusContext";
|
||||
@@ -89,6 +90,8 @@ import {
|
||||
Monitor,
|
||||
MessagesSquare,
|
||||
Eye,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
SSHHost,
|
||||
@@ -843,6 +846,39 @@ export function HostManagerViewer({
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const handleExportAll = () => {
|
||||
confirmWithToast(
|
||||
t("hosts.exportAllSensitiveWarning"),
|
||||
async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const data = await exportAllSSHHosts();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `termix-hosts-export-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(
|
||||
t("hosts.exportedAllHosts", { count: data.hosts.length }),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToExportAllHosts"));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const handleJsonImport = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
@@ -1160,6 +1196,15 @@ export function HostManagerViewer({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={exporting || hosts.length === 0}
|
||||
onClick={handleExportAll}
|
||||
>
|
||||
{exporting ? t("hosts.exporting") : t("hosts.exportAllJson")}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleDownloadSample}>
|
||||
{t("hosts.downloadSample")}
|
||||
</Button>
|
||||
@@ -1215,6 +1260,28 @@ export function HostManagerViewer({
|
||||
<ListChecks className="h-4 w-4 mr-2" />
|
||||
{selectionMode ? t("hosts.exitSelectMode") : t("hosts.selectMode")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9"
|
||||
onClick={() => {
|
||||
if (openAccordions.length > 0) {
|
||||
setOpenAccordions([]);
|
||||
} else {
|
||||
setOpenAccordions(folderKeys);
|
||||
}
|
||||
}}
|
||||
title={
|
||||
openAccordions.length > 0
|
||||
? t("hosts.collapseAll", "Collapse All")
|
||||
: t("hosts.expandAll", "Expand All")
|
||||
}
|
||||
>
|
||||
{openAccordions.length > 0 ? (
|
||||
<ChevronsDownUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
@@ -1921,19 +1988,9 @@ export function HostManagerViewer({
|
||||
| "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,
|
||||
});
|
||||
await getGuacamoleTokenFromHost(
|
||||
host.id,
|
||||
);
|
||||
|
||||
addTab({
|
||||
type: protocol,
|
||||
@@ -1951,6 +2008,9 @@ export function HostManagerViewer({
|
||||
security: host.security,
|
||||
"ignore-cert":
|
||||
host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(
|
||||
host,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -224,49 +224,73 @@ export function HostGeneralTab({
|
||||
)}
|
||||
/>
|
||||
|
||||
{connectionType !== "vnc" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => {
|
||||
const isCredentialAuth = authTab === "credential";
|
||||
const credentialId = form.watch("credentialId");
|
||||
const overrideEnabled = form.watch("overrideCredentialUsername");
|
||||
const selectedCredential = credentials.find(
|
||||
(c) => c.id === credentialId,
|
||||
);
|
||||
const shouldDisable =
|
||||
isCredentialAuth &&
|
||||
selectedCredential?.username &&
|
||||
!overrideEnabled;
|
||||
|
||||
return (
|
||||
<FormItem className="col-span-6">
|
||||
<FormLabel>{t("hosts.username")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.username")}
|
||||
disabled={shouldDisable}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
if (
|
||||
isCredentialAuth &&
|
||||
selectedCredential &&
|
||||
!selectedCredential.username &&
|
||||
e.target.value.trim() !== ""
|
||||
) {
|
||||
form.setValue("overrideCredentialUsername", true);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4 mt-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => {
|
||||
const isCredentialAuth = authTab === "credential";
|
||||
const credentialId = form.watch("credentialId");
|
||||
const overrideEnabled = form.watch("overrideCredentialUsername");
|
||||
const selectedCredential = credentials.find(
|
||||
(c) => c.id === credentialId,
|
||||
);
|
||||
const shouldDisable =
|
||||
isCredentialAuth &&
|
||||
selectedCredential?.username &&
|
||||
!overrideEnabled;
|
||||
|
||||
return (
|
||||
<FormItem className="col-span-6">
|
||||
<FormLabel>{t("hosts.username")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.username")}
|
||||
disabled={shouldDisable}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
if (
|
||||
isCredentialAuth &&
|
||||
selectedCredential &&
|
||||
!selectedCredential.username &&
|
||||
e.target.value.trim() !== ""
|
||||
) {
|
||||
form.setValue("overrideCredentialUsername", true);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
name="macAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-5">
|
||||
<FormLabel>{t("hosts.macAddress")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="AA:BB:CC:DD:EE:FF"
|
||||
{...field}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t("hosts.macAddressDesc")}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormLabel className="mb-3 mt-3 font-bold">
|
||||
@@ -1438,6 +1462,120 @@ export function HostGeneralTab({
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="port-knocking">
|
||||
<AccordionTrigger>{t("hosts.portKnocking")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3 pt-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("hosts.portKnockingDesc")}
|
||||
</p>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="portKnockSequence"
|
||||
render={({ field }) => {
|
||||
const sequence = field.value || [];
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{sequence.map(
|
||||
(
|
||||
knock: {
|
||||
port: number;
|
||||
protocol?: string;
|
||||
delay?: number;
|
||||
},
|
||||
index: number,
|
||||
) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("hosts.port")}
|
||||
value={knock.port || ""}
|
||||
onChange={(e) => {
|
||||
const updated = [...sequence];
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
port: parseInt(e.target.value) || 0,
|
||||
};
|
||||
field.onChange(updated);
|
||||
}}
|
||||
className="w-24"
|
||||
/>
|
||||
<Select
|
||||
value={knock.protocol || "tcp"}
|
||||
onValueChange={(v) => {
|
||||
const updated = [...sequence];
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
protocol: v,
|
||||
};
|
||||
field.onChange(updated);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tcp">TCP</SelectItem>
|
||||
<SelectItem value="udp">UDP</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("hosts.delayMs")}
|
||||
value={knock.delay ?? 100}
|
||||
onChange={(e) => {
|
||||
const updated = [...sequence];
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
delay: parseInt(e.target.value) || 0,
|
||||
};
|
||||
field.onChange(updated);
|
||||
}}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
ms
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
field.onChange(
|
||||
sequence.filter(
|
||||
(_: unknown, i: number) => i !== index,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
field.onChange([
|
||||
...sequence,
|
||||
{ port: 0, protocol: "tcp", delay: 100 },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("hosts.addKnock")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -47,9 +47,11 @@ import {
|
||||
import { TerminalPreview } from "@/ui/desktop/apps/features/terminal/TerminalPreview.tsx";
|
||||
import type { HostTerminalTabProps } from "./shared/tab-types";
|
||||
import React from "react";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
|
||||
export function HostTerminalTab({ form, snippets, t }: HostTerminalTabProps) {
|
||||
const [snippetPopoverOpen, setSnippetPopoverOpen] = React.useState(false);
|
||||
const { setPreviewTerminalTheme } = useTabs() as any;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<FormField
|
||||
@@ -103,9 +105,15 @@ export function HostTerminalTab({ form, snippets, t }: HostTerminalTabProps) {
|
||||
<SelectValue placeholder={t("hosts.selectTheme")} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectContent
|
||||
onMouseLeave={() => setPreviewTerminalTheme(null)}
|
||||
>
|
||||
{Object.entries(TERMINAL_THEMES).map(([key, theme]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
<SelectItem
|
||||
key={key}
|
||||
value={key}
|
||||
onMouseEnter={() => setPreviewTerminalTheme(key)}
|
||||
>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -637,6 +645,25 @@ export function HostTerminalTab({ form, snippets, t }: HostTerminalTabProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.autoTmux"
|
||||
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.autoTmux")}</FormLabel>
|
||||
<FormDescription>{t("hosts.autoTmuxDesc")}</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.sudoPasswordAutoFill"
|
||||
|
||||
@@ -60,6 +60,8 @@ import {
|
||||
Archive,
|
||||
HardDrive,
|
||||
Globe,
|
||||
Share2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -79,6 +81,13 @@ import {
|
||||
renameSnippetFolder,
|
||||
deleteSnippetFolder,
|
||||
reorderSnippets,
|
||||
getSharedSnippets,
|
||||
shareSnippet,
|
||||
getSnippetAccess,
|
||||
revokeSnippetAccess,
|
||||
getRoles,
|
||||
getUserList,
|
||||
type AccessRecord,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import type { Snippet, SnippetData, SnippetFolder } from "../../../../types";
|
||||
@@ -180,6 +189,30 @@ export function SSHToolsSidebar({
|
||||
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||
const [snippetFolders, setSnippetFolders] = useState<SnippetFolder[]>([]);
|
||||
const [sharedSnippetsList, setSharedSnippetsList] = useState<
|
||||
Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
description: string | null;
|
||||
folder: string | null;
|
||||
ownerUsername: string;
|
||||
}>
|
||||
>([]);
|
||||
const [shareDialogSnippet, setShareDialogSnippet] = useState<Snippet | null>(
|
||||
null,
|
||||
);
|
||||
const [shareTargetType, setShareTargetType] = useState<"user" | "role">(
|
||||
"user",
|
||||
);
|
||||
const [shareUsers, setShareUsers] = useState<
|
||||
Array<{ id: string; username: string }>
|
||||
>([]);
|
||||
const [shareRoles, setShareRoles] = useState<
|
||||
Array<{ id: number; name: string; displayName?: string }>
|
||||
>([]);
|
||||
const [shareTargetId, setShareTargetId] = useState("");
|
||||
const [shareAccessList, setShareAccessList] = useState<AccessRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null);
|
||||
@@ -222,6 +255,9 @@ export function SSHToolsSidebar({
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [historyRefreshCounter, setHistoryRefreshCounter] = useState(0);
|
||||
const commandHistoryScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState<boolean>(
|
||||
() => localStorage.getItem("commandHistoryTracking") === "true",
|
||||
);
|
||||
|
||||
const [splitMode, setSplitMode] = useState<
|
||||
"none" | "2" | "3" | "4" | "5" | "6"
|
||||
@@ -240,6 +276,14 @@ export function SSHToolsSidebar({
|
||||
const startWidthRef = React.useRef<number>(sidebarWidth);
|
||||
|
||||
const terminalTabs = tabs.filter((tab: TabData) => tab.type === "terminal");
|
||||
|
||||
useEffect(() => {
|
||||
const terminalIds = new Set(terminalTabs.map((t) => t.id));
|
||||
setSelectedSnippetTabIds((prev) =>
|
||||
prev.filter((id) => terminalIds.has(id)),
|
||||
);
|
||||
}, [terminalTabs.length]);
|
||||
|
||||
const activeUiTab = tabs.find((tab) => tab.id === currentTab);
|
||||
const activeTerminal =
|
||||
activeUiTab?.type === "terminal" ? activeUiTab : undefined;
|
||||
@@ -327,6 +371,17 @@ export function SSHToolsSidebar({
|
||||
}
|
||||
}, [isOpen, activeTab, activeTerminalHostId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleChange = () => {
|
||||
setCommandHistoryEnabled(
|
||||
localStorage.getItem("commandHistoryTracking") === "true",
|
||||
);
|
||||
};
|
||||
window.addEventListener("commandHistoryTrackingChanged", handleChange);
|
||||
return () =>
|
||||
window.removeEventListener("commandHistoryTrackingChanged", handleChange);
|
||||
}, []);
|
||||
|
||||
const filteredCommands = searchQuery
|
||||
? commandHistory.filter((cmd) =>
|
||||
cmd.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
@@ -625,16 +680,19 @@ export function SSHToolsSidebar({
|
||||
const fetchSnippets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [snippetsData, foldersData] = await Promise.all([
|
||||
const [snippetsData, foldersData, sharedData] = await Promise.all([
|
||||
getSnippets(),
|
||||
getSnippetFolders(),
|
||||
getSharedSnippets().catch(() => ({ sharedSnippets: [] })),
|
||||
]);
|
||||
setSnippets(Array.isArray(snippetsData) ? snippetsData : []);
|
||||
setSnippetFolders(Array.isArray(foldersData) ? foldersData : []);
|
||||
setSharedSnippetsList(sharedData.sharedSnippets || []);
|
||||
} catch {
|
||||
toast.error(t("snippets.failedToFetch"));
|
||||
setSnippets([]);
|
||||
setSnippetFolders([]);
|
||||
setSharedSnippetsList([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -675,6 +733,63 @@ export function SSHToolsSidebar({
|
||||
);
|
||||
};
|
||||
|
||||
const handleOpenShareDialog = async (snippet: Snippet) => {
|
||||
setShareDialogSnippet(snippet);
|
||||
setShareTargetId("");
|
||||
setShareTargetType("user");
|
||||
try {
|
||||
const [usersData, rolesData, accessData] = await Promise.all([
|
||||
getUserList(),
|
||||
getRoles(),
|
||||
getSnippetAccess(snippet.id),
|
||||
]);
|
||||
setShareUsers(
|
||||
(usersData?.users || []).map((u: Record<string, unknown>) => ({
|
||||
id: u.id as string,
|
||||
username: u.username as string,
|
||||
})),
|
||||
);
|
||||
setShareRoles(
|
||||
(rolesData?.roles || []).map(
|
||||
(r: { id: number; name: string; displayName?: string }) => r,
|
||||
),
|
||||
);
|
||||
setShareAccessList(accessData.accessList || []);
|
||||
} catch {
|
||||
toast.error(t("snippets.failedToLoadShareData"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!shareDialogSnippet || !shareTargetId) return;
|
||||
try {
|
||||
await shareSnippet(shareDialogSnippet.id, {
|
||||
targetType: shareTargetType,
|
||||
targetUserId: shareTargetType === "user" ? shareTargetId : undefined,
|
||||
targetRoleId:
|
||||
shareTargetType === "role" ? parseInt(shareTargetId) : undefined,
|
||||
});
|
||||
toast.success(t("snippets.shareSuccess"));
|
||||
const accessData = await getSnippetAccess(shareDialogSnippet.id);
|
||||
setShareAccessList(accessData.accessList || []);
|
||||
setShareTargetId("");
|
||||
} catch {
|
||||
toast.error(t("snippets.shareFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeSnippetAccess = async (accessId: number) => {
|
||||
if (!shareDialogSnippet) return;
|
||||
try {
|
||||
await revokeSnippetAccess(shareDialogSnippet.id, accessId);
|
||||
toast.success(t("snippets.revokeSuccess"));
|
||||
const accessData = await getSnippetAccess(shareDialogSnippet.id);
|
||||
setShareAccessList(accessData.accessList || []);
|
||||
} catch {
|
||||
toast.error(t("snippets.revokeFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const errors = {
|
||||
name: !formData.name.trim(),
|
||||
@@ -736,7 +851,7 @@ export function SSHToolsSidebar({
|
||||
selectedSnippetTabIds.forEach((tabId) => {
|
||||
const tab = tabs.find((t: TabData) => t.id === tabId);
|
||||
if (tab?.terminalRef?.current?.sendInput) {
|
||||
tab.terminalRef.current.sendInput(snippet.content + "\n");
|
||||
tab.terminalRef.current.sendInput(snippet.content + "\r");
|
||||
}
|
||||
});
|
||||
toast.success(
|
||||
@@ -1296,6 +1411,30 @@ export function SSHToolsSidebar({
|
||||
})
|
||||
: t("snippets.executeOnCurrent")}
|
||||
</p>
|
||||
<div className="flex gap-2 mb-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs h-6 px-2"
|
||||
onClick={() =>
|
||||
setSelectedSnippetTabIds(
|
||||
terminalTabs.map((t) => t.id),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("snippets.selectAll", "Select All")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs h-6 px-2"
|
||||
onClick={() => setSelectedSnippetTabIds([])}
|
||||
>
|
||||
{t("snippets.deselectAll", "Deselect All")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto thin-scrollbar">
|
||||
{terminalTabs.map((tab) => (
|
||||
<Button
|
||||
@@ -1375,6 +1514,118 @@ export function SSHToolsSidebar({
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<div className="space-y-3 overflow-y-auto flex-1 min-h-0 thin-scrollbar">
|
||||
{sharedSnippetsList.length > 0 &&
|
||||
(() => {
|
||||
const isCollapsed =
|
||||
collapsedFolders.has("__shared__");
|
||||
const FolderIcon = getFolderIcon("__shared__");
|
||||
return (
|
||||
<div key="__shared__">
|
||||
<div className="flex items-center gap-2 mb-2 hover:bg-hover-alt p-2 rounded-lg transition-colors group/folder">
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer"
|
||||
onClick={() => toggleFolder("__shared__")}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
<span className="text-sm font-semibold">
|
||||
{t("snippets.sharedWithYou")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{sharedSnippetsList.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 pointer-events-none">
|
||||
<div className="h-6 w-6" />
|
||||
<div className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="space-y-2 ml-6">
|
||||
{sharedSnippetsList.map((snippet) => (
|
||||
<div
|
||||
key={`shared-${snippet.id}`}
|
||||
className="bg-field border border-input rounded-lg hover:shadow-lg hover:border-edge-hover hover:bg-hover-alt transition-all duration-200 p-3 group"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">
|
||||
{snippet.name}
|
||||
</h3>
|
||||
{snippet.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{snippet.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("snippets.sharedBy", {
|
||||
username:
|
||||
snippet.ownerUsername,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded p-2 mb-3">
|
||||
<code className="text-xs font-mono break-all line-clamp-2 text-muted-foreground">
|
||||
{snippet.content}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="flex-1"
|
||||
onClick={() =>
|
||||
handleExecute(
|
||||
snippet as unknown as Snippet,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Play className="w-3 h-3 mr-1" />
|
||||
{t("snippets.run")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t("snippets.runTooltip")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
handleCopy(
|
||||
snippet as unknown as Snippet,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t("snippets.copyTooltip")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{Array.from(groupSnippetsByFolder()).map(
|
||||
([folderName, folderSnippets]) => {
|
||||
const folderMetadata = snippetFolders.find(
|
||||
@@ -1584,6 +1835,27 @@ export function SSHToolsSidebar({
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
handleOpenShareDialog(
|
||||
snippet,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Share2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t("snippets.shareTooltip")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1602,131 +1874,151 @@ export function SSHToolsSidebar({
|
||||
value="command-history"
|
||||
className="flex flex-col flex-1 overflow-hidden"
|
||||
>
|
||||
<div className="space-y-2 flex-shrink-0 mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("commandHistory.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
}}
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{!commandHistoryEnabled ? (
|
||||
<div className="flex flex-col items-center justify-center flex-1 py-8 text-center px-4">
|
||||
<div className="bg-muted/40 border rounded-lg p-5 space-y-2">
|
||||
<p className="font-medium text-sm">
|
||||
{t("commandHistory.disabledTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("commandHistory.disabledDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground bg-muted/30 px-2 py-1.5 rounded">
|
||||
{t("commandHistory.tabHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
{historyError ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-4 mb-4">
|
||||
<p className="text-destructive font-medium mb-2">
|
||||
{t("commandHistory.error")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{historyError}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2 flex-shrink-0 mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"commandHistory.searchPlaceholder",
|
||||
)}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
}}
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setHistoryRefreshCounter((prev) => prev + 1)
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : !activeTerminal ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Terminal className="h-12 w-12 mb-4 opacity-20 mx-auto" />
|
||||
<p className="mb-2 font-medium">
|
||||
{t("commandHistory.noTerminal")}{" "}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t("commandHistory.noTerminalHint")}
|
||||
<p className="text-xs text-muted-foreground bg-muted/30 px-2 py-1.5 rounded">
|
||||
{t("commandHistory.tabHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : isHistoryLoading && commandHistory.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Loader2 className="h-12 w-12 mb-4 opacity-20 mx-auto animate-spin" />
|
||||
<p className="mb-2 font-medium">
|
||||
{t("commandHistory.loading")}{" "}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredCommands.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
{searchQuery ? (
|
||||
<>
|
||||
<Search className="h-12 w-12 mb-2 opacity-20 mx-auto" />
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
{historyError ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-4 mb-4">
|
||||
<p className="text-destructive font-medium mb-2">
|
||||
{t("commandHistory.error")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{historyError}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setHistoryRefreshCounter((prev) => prev + 1)
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : !activeTerminal ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Terminal className="h-12 w-12 mb-4 opacity-20 mx-auto" />
|
||||
<p className="mb-2 font-medium">
|
||||
{t("commandHistory.noResults")}
|
||||
{t("commandHistory.noTerminal")}{" "}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t("commandHistory.noResultsHint", {
|
||||
query: searchQuery,
|
||||
})}
|
||||
{t("commandHistory.noTerminalHint")}
|
||||
</p>
|
||||
</>
|
||||
</div>
|
||||
) : isHistoryLoading &&
|
||||
commandHistory.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Loader2 className="h-12 w-12 mb-4 opacity-20 mx-auto animate-spin" />
|
||||
<p className="mb-2 font-medium">
|
||||
{t("commandHistory.loading")}{" "}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredCommands.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
{searchQuery ? (
|
||||
<>
|
||||
<Search className="h-12 w-12 mb-2 opacity-20 mx-auto" />
|
||||
<p className="mb-2 font-medium">
|
||||
{t("commandHistory.noResults")}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t("commandHistory.noResultsHint", {
|
||||
query: searchQuery,
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 font-medium">
|
||||
{t("commandHistory.empty")}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t("commandHistory.emptyHint")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 font-medium">
|
||||
{t("commandHistory.empty")}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t("commandHistory.emptyHint")}
|
||||
</p>
|
||||
</>
|
||||
<div
|
||||
ref={commandHistoryScrollRef}
|
||||
className="space-y-2 overflow-y-auto h-full thin-scrollbar"
|
||||
>
|
||||
{filteredCommands.map((command, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-canvas border-2 border-edge rounded-md px-3 py-2.5 hover:bg-hover-alt hover:border-edge-hover transition-all duration-200 group h-12 flex items-center"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 w-full min-w-0">
|
||||
<span
|
||||
className="flex-1 font-mono text-sm cursor-pointer text-foreground truncate"
|
||||
onClick={() =>
|
||||
handleCommandSelect(command)
|
||||
}
|
||||
title={command}
|
||||
>
|
||||
{command}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400 flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCommandDelete(command);
|
||||
}}
|
||||
title={t("commandHistory.deleteTooltip")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={commandHistoryScrollRef}
|
||||
className="space-y-2 overflow-y-auto h-full thin-scrollbar"
|
||||
>
|
||||
{filteredCommands.map((command, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-canvas border-2 border-edge rounded-md px-3 py-2.5 hover:bg-hover-alt hover:border-edge-hover transition-all duration-200 group h-12 flex items-center"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 w-full min-w-0">
|
||||
<span
|
||||
className="flex-1 font-mono text-sm cursor-pointer text-foreground truncate"
|
||||
onClick={() => handleCommandSelect(command)}
|
||||
title={command}
|
||||
>
|
||||
{command}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400 flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCommandDelete(command);
|
||||
}}
|
||||
title={t("commandHistory.deleteTooltip")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
@@ -2238,6 +2530,104 @@ export function SSHToolsSidebar({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shareDialogSnippet && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
<div className="bg-card border-2 border-border rounded-lg p-6 w-full max-w-md space-y-4 max-h-[80vh] overflow-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("snippets.shareSnippet")}
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShareDialogSnippet(null)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{shareDialogSnippet.name}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={shareTargetType}
|
||||
onValueChange={(v: "user" | "role") => {
|
||||
setShareTargetType(v);
|
||||
setShareTargetId("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">{t("snippets.user")}</SelectItem>
|
||||
<SelectItem value="role">{t("snippets.role")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={shareTargetId} onValueChange={setShareTargetId}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder={t("snippets.selectTarget")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{shareTargetType === "user"
|
||||
? shareUsers.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.username}
|
||||
</SelectItem>
|
||||
))
|
||||
: shareRoles.map((r) => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>
|
||||
{r.displayName || r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
onClick={handleShare}
|
||||
disabled={!shareTargetId}
|
||||
size="sm"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shareAccessList.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-semibold">
|
||||
{t("snippets.currentAccess")}
|
||||
</span>
|
||||
{shareAccessList.map((access) => (
|
||||
<div
|
||||
key={access.id}
|
||||
className="flex items-center justify-between rounded-md border p-2 text-sm"
|
||||
>
|
||||
<span>
|
||||
{access.username ||
|
||||
access.roleDisplayName ||
|
||||
access.roleName}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={() => handleRevokeSnippetAccess(access.id)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
getOIDCAuthorizeUrl,
|
||||
verifyTOTPLogin,
|
||||
getServerConfig,
|
||||
saveServerConfig,
|
||||
isElectron,
|
||||
getEmbeddedServerStatus,
|
||||
isEmbeddedMode,
|
||||
@@ -56,6 +57,7 @@ interface AuthProps extends React.ComponentProps<"div"> {
|
||||
loggedIn: boolean;
|
||||
authLoading: boolean;
|
||||
setDbError: (error: string | null) => void;
|
||||
dbError?: string | null;
|
||||
onAuthSuccess: (authData: {
|
||||
isAdmin: boolean;
|
||||
username: string | null;
|
||||
@@ -72,6 +74,7 @@ export function Auth({
|
||||
loggedIn,
|
||||
authLoading,
|
||||
setDbError,
|
||||
dbError: _dbError,
|
||||
onAuthSuccess,
|
||||
...props
|
||||
}: AuthProps) {
|
||||
@@ -80,6 +83,10 @@ export function Auth({
|
||||
|
||||
const isDarkMode =
|
||||
theme === "dark" ||
|
||||
theme === "dracula" ||
|
||||
theme === "gentlemansChoice" ||
|
||||
theme === "midnightEspresso" ||
|
||||
theme === "catppuccinMocha" ||
|
||||
(theme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const lineColor = isDarkMode ? "#151517" : "#f9f9f9";
|
||||
@@ -93,7 +100,7 @@ export function Auth({
|
||||
return true;
|
||||
}
|
||||
} catch (_e) {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -147,7 +154,24 @@ export function Auth({
|
||||
|
||||
const handleElectronAuthSuccess = useCallback(async () => {
|
||||
try {
|
||||
const meRes = await getUserInfo();
|
||||
let retries = 5;
|
||||
let meRes = null;
|
||||
while (retries-- > 0) {
|
||||
try {
|
||||
meRes = await getUserInfo();
|
||||
break;
|
||||
} catch (err: any) {
|
||||
const isNoServer =
|
||||
err?.code === "NO_SERVER_CONFIGURED" ||
|
||||
err?.message?.includes("no-server-configured");
|
||||
if (isNoServer && retries > 0) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!meRes) throw new Error("Failed to get user info");
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
@@ -255,12 +279,6 @@ export function Auth({
|
||||
});
|
||||
}, [setDbError, firstUserToastShown, showServerConfig, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!registrationAllowed && !internalLoggedIn) {
|
||||
toast.warning(t("messages.registrationDisabled"));
|
||||
}
|
||||
}, [registrationAllowed, internalLoggedIn, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!passwordLoginAllowed && oidcConfigured && tab !== "external") {
|
||||
setTab("external");
|
||||
@@ -658,6 +676,11 @@ export function Auth({
|
||||
if (success) {
|
||||
setOidcLoading(true);
|
||||
|
||||
const urlToken = urlParams.get("token");
|
||||
if (urlToken && (isElectron() || isInElectronWebView())) {
|
||||
localStorage.setItem("jwt", urlToken);
|
||||
}
|
||||
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
if (isInElectronWebView()) {
|
||||
@@ -683,6 +706,13 @@ export function Auth({
|
||||
}
|
||||
}
|
||||
|
||||
if (isElectron()) {
|
||||
const token = getCookie("jwt");
|
||||
if (token) {
|
||||
localStorage.setItem("jwt", token);
|
||||
}
|
||||
}
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
@@ -772,7 +802,12 @@ export function Auth({
|
||||
getEmbeddedServerStatus(),
|
||||
]);
|
||||
|
||||
if (status?.embedded && status?.running && !config?.serverUrl) {
|
||||
if (
|
||||
status?.embedded &&
|
||||
status?.running &&
|
||||
config &&
|
||||
!config.serverUrl
|
||||
) {
|
||||
setCurrentServerUrl("");
|
||||
setShowServerConfig(false);
|
||||
return;
|
||||
@@ -816,7 +851,11 @@ export function Auth({
|
||||
onServerConfigured={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
onUseEmbedded={() => {
|
||||
onUseEmbedded={async () => {
|
||||
await saveServerConfig({
|
||||
serverUrl: "",
|
||||
lastUpdated: new Date().toISOString(),
|
||||
});
|
||||
setShowServerConfig(false);
|
||||
setCurrentServerUrl("");
|
||||
}}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Loader2, ArrowLeft, RefreshCw } from "lucide-react";
|
||||
import { getCookie } from "@/ui/main-axios.ts";
|
||||
import { setCookie } from "@/ui/main-axios.ts";
|
||||
|
||||
interface ElectronLoginFormProps {
|
||||
serverUrl: string;
|
||||
@@ -20,64 +19,75 @@ export function ElectronLoginForm({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||
const isAuthenticatingRef = useRef(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const hasAuthenticatedRef = useRef(false);
|
||||
const [currentUrl, setCurrentUrl] = useState(serverUrl);
|
||||
const hasLoadedOnce = useRef(false);
|
||||
|
||||
const onAuthSuccessRef = useRef(onAuthSuccess);
|
||||
useEffect(() => {
|
||||
localStorage.removeItem("jwt");
|
||||
}, []);
|
||||
onAuthSuccessRef.current = onAuthSuccess;
|
||||
}, [onAuthSuccess]);
|
||||
|
||||
const handleAuthToken = useCallback(
|
||||
async (token: string) => {
|
||||
if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return;
|
||||
hasAuthenticatedRef.current = true;
|
||||
isAuthenticatingRef.current = true;
|
||||
setIsAuthenticating(true);
|
||||
|
||||
try {
|
||||
setCookie("jwt", token);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
onAuthSuccessRef.current();
|
||||
} catch (_err) {
|
||||
setError(t("errors.authTokenSaveFailed"));
|
||||
isAuthenticatingRef.current = false;
|
||||
setIsAuthenticating(false);
|
||||
hasAuthenticatedRef.current = false;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// postMessage from server's Auth.tsx (works if server has postMessage code)
|
||||
useEffect(() => {
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
try {
|
||||
const serverOrigin = new URL(serverUrl).origin;
|
||||
if (event.origin !== serverOrigin) {
|
||||
return;
|
||||
if (!event.data || typeof event.data !== "object") return;
|
||||
const { type, token, platform } = event.data;
|
||||
if (type === "AUTH_SUCCESS" && token && platform === "desktop") {
|
||||
await handleAuthToken(token);
|
||||
}
|
||||
|
||||
if (event.data && typeof event.data === "object") {
|
||||
const data = event.data;
|
||||
|
||||
if (
|
||||
data.type === "AUTH_SUCCESS" &&
|
||||
data.token &&
|
||||
!hasAuthenticatedRef.current &&
|
||||
!isAuthenticating
|
||||
) {
|
||||
hasAuthenticatedRef.current = true;
|
||||
setIsAuthenticating(true);
|
||||
|
||||
try {
|
||||
localStorage.setItem("jwt", data.token);
|
||||
|
||||
const savedToken = localStorage.getItem("jwt");
|
||||
if (!savedToken) {
|
||||
throw new Error("Failed to save JWT to localStorage");
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
onAuthSuccess();
|
||||
} catch (err) {
|
||||
setError(t("errors.authTokenSaveFailed"));
|
||||
setIsAuthenticating(false);
|
||||
hasAuthenticatedRef.current = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Authentication operation failed:", err);
|
||||
} catch (_err) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [handleAuthToken]);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [serverUrl, isAuthenticating, onAuthSuccess, t]);
|
||||
// Poll iframe localStorage via main process executeJavaScriptInFrame (bypasses cross-origin)
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const electronAPI = (window as any).electronAPI;
|
||||
if (!electronAPI?.invoke) return;
|
||||
|
||||
const poll = setInterval(async () => {
|
||||
if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return;
|
||||
try {
|
||||
const token = await electronAPI.invoke("get-iframe-jwt");
|
||||
if (token && token.length > 20) {
|
||||
await handleAuthToken(token);
|
||||
}
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(poll);
|
||||
}, [loading, handleAuthToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
@@ -92,116 +102,9 @@ export function ElectronLoginForm({
|
||||
if (iframe.contentWindow) {
|
||||
setCurrentUrl(iframe.contentWindow.location.href);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
setCurrentUrl(serverUrl);
|
||||
}
|
||||
|
||||
try {
|
||||
const injectedScript = `
|
||||
(function() {
|
||||
let hasNotified = false;
|
||||
|
||||
function postJWTToParent(token, source) {
|
||||
if (hasNotified) {
|
||||
return;
|
||||
}
|
||||
hasNotified = true;
|
||||
|
||||
try {
|
||||
window.parent.postMessage({
|
||||
type: 'AUTH_SUCCESS',
|
||||
token: token,
|
||||
source: source,
|
||||
platform: 'desktop',
|
||||
timestamp: Date.now()
|
||||
}, '*');
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
function checkAuth() {
|
||||
try {
|
||||
const localToken = localStorage.getItem('jwt');
|
||||
if (localToken && localToken.length > 20) {
|
||||
postJWTToParent(localToken, 'localStorage');
|
||||
return true;
|
||||
}
|
||||
|
||||
const sessionToken = sessionStorage.getItem('jwt');
|
||||
if (sessionToken && sessionToken.length > 20) {
|
||||
postJWTToParent(sessionToken, 'sessionStorage');
|
||||
return true;
|
||||
}
|
||||
|
||||
const cookies = document.cookie;
|
||||
if (cookies && cookies.length > 0) {
|
||||
const cookieArray = cookies.split('; ');
|
||||
const tokenCookie = cookieArray.find(row => row.startsWith('jwt='));
|
||||
|
||||
if (tokenCookie) {
|
||||
const token = tokenCookie.split('=')[1];
|
||||
if (token && token.length > 20) {
|
||||
postJWTToParent(token, 'cookie');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const originalSetItem = localStorage.setItem;
|
||||
localStorage.setItem = function(key, value) {
|
||||
originalSetItem.apply(this, arguments);
|
||||
if (key === 'jwt' && value && value.length > 20 && !hasNotified) {
|
||||
setTimeout(() => checkAuth(), 100);
|
||||
}
|
||||
};
|
||||
|
||||
const originalSessionSetItem = sessionStorage.setItem;
|
||||
sessionStorage.setItem = function(key, value) {
|
||||
originalSessionSetItem.apply(this, arguments);
|
||||
if (key === 'jwt' && value && value.length > 20 && !hasNotified) {
|
||||
setTimeout(() => checkAuth(), 100);
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
if (hasNotified) {
|
||||
clearInterval(intervalId);
|
||||
return;
|
||||
}
|
||||
if (checkAuth()) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(intervalId);
|
||||
}, 300000);
|
||||
|
||||
setTimeout(() => checkAuth(), 500);
|
||||
})();
|
||||
`;
|
||||
|
||||
try {
|
||||
if (iframe.contentWindow) {
|
||||
try {
|
||||
iframe.contentWindow.eval(injectedScript);
|
||||
} catch (evalError) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{ type: "INJECT_SCRIPT", script: injectedScript },
|
||||
"*",
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Authentication operation failed:", err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Authentication operation failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
@@ -213,12 +116,11 @@ export function ElectronLoginForm({
|
||||
|
||||
iframe.addEventListener("load", handleLoad);
|
||||
iframe.addEventListener("error", handleError);
|
||||
|
||||
return () => {
|
||||
iframe.removeEventListener("load", handleLoad);
|
||||
iframe.removeEventListener("error", handleError);
|
||||
};
|
||||
}, [t]);
|
||||
}, [serverUrl, t]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (iframeRef.current) {
|
||||
@@ -228,44 +130,47 @@ export function ElectronLoginForm({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
onChangeServer();
|
||||
};
|
||||
|
||||
const displayUrl = currentUrl.replace(/^https?:\/\//, "");
|
||||
const isEmbeddedServer = serverUrl.includes("localhost:30001");
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 w-screen h-screen bg-canvas flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 bg-canvas border-b border-edge">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-2 text-foreground hover:text-primary transition-colors"
|
||||
disabled={isAuthenticating}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span className="text-base font-medium">
|
||||
{t("serverConfig.changeServer")}
|
||||
</span>
|
||||
</button>
|
||||
{!isEmbeddedServer && (
|
||||
<div className="flex-1 mx-4 text-center">
|
||||
<span className="text-muted-foreground text-sm truncate block">
|
||||
{displayUrl}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isEmbeddedServer && <div className="flex-1" />}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-2 text-foreground hover:text-primary transition-colors"
|
||||
disabled={loading || isAuthenticating}
|
||||
>
|
||||
<RefreshCw className={`h-5 w-5 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
{isAuthenticating && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-canvas z-50">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
{!isAuthenticating && (
|
||||
<div className="flex items-center justify-between p-4 bg-canvas border-b border-edge">
|
||||
<button
|
||||
onClick={onChangeServer}
|
||||
className="flex items-center gap-2 text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span className="text-base font-medium">
|
||||
{t("serverConfig.changeServer")}
|
||||
</span>
|
||||
</button>
|
||||
{!isEmbeddedServer && (
|
||||
<div className="flex-1 mx-4 text-center">
|
||||
<span className="text-muted-foreground text-sm truncate block">
|
||||
{displayUrl}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isEmbeddedServer && <div className="flex-1" />}
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-2 text-foreground hover:text-primary transition-colors"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`h-5 w-5 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isAuthenticating && (
|
||||
<div className="absolute top-20 left-1/2 transform -translate-x-1/2 z-50 w-full max-w-md px-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
@@ -275,7 +180,7 @@ export function ElectronLoginForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
{loading && !isAuthenticating && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center bg-canvas z-40"
|
||||
style={{ marginTop: "60px" }}
|
||||
@@ -289,7 +194,10 @@ export function ElectronLoginForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div
|
||||
className="flex-1 overflow-hidden"
|
||||
style={{ visibility: isAuthenticating ? "hidden" : "visible" }}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={serverUrl}
|
||||
|
||||
@@ -39,6 +39,10 @@ export function ElectronServerConfig({
|
||||
|
||||
const isDarkMode =
|
||||
theme === "dark" ||
|
||||
theme === "dracula" ||
|
||||
theme === "gentlemansChoice" ||
|
||||
theme === "midnightEspresso" ||
|
||||
theme === "catppuccinMocha" ||
|
||||
(theme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const lineColor = isDarkMode ? "#151517" : "#f9f9f9";
|
||||
|
||||
@@ -87,20 +87,34 @@ export function AppView({
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
}: TerminalViewProps): React.ReactElement {
|
||||
const { tabs, currentTab, allSplitScreenTab, removeTab, updateTab } =
|
||||
useTabs() as {
|
||||
tabs: TabData[];
|
||||
currentTab: number;
|
||||
allSplitScreenTab: number[];
|
||||
removeTab: (id: number) => void;
|
||||
updateTab: (tabId: number, updates: Partial<Omit<TabData, "id">>) => void;
|
||||
};
|
||||
const {
|
||||
tabs,
|
||||
currentTab,
|
||||
allSplitScreenTab,
|
||||
removeTab,
|
||||
updateTab,
|
||||
addTab,
|
||||
previewTerminalTheme,
|
||||
} = useTabs() as {
|
||||
tabs: TabData[];
|
||||
currentTab: number;
|
||||
allSplitScreenTab: number[];
|
||||
removeTab: (id: number) => void;
|
||||
updateTab: (tabId: number, updates: Partial<Omit<TabData, "id">>) => void;
|
||||
addTab: (tab: Omit<TabData, "id">) => number;
|
||||
previewTerminalTheme: string | null;
|
||||
};
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const { theme: appTheme } = useTheme();
|
||||
|
||||
const isDarkMode = useMemo(() => {
|
||||
if (appTheme === "dark") return true;
|
||||
if (appTheme === "dracula") return true;
|
||||
if (appTheme === "gentlemansChoice") return true;
|
||||
if (appTheme === "midnightEspresso") return true;
|
||||
if (appTheme === "catppuccinMocha") return true;
|
||||
if (appTheme === "light") return false;
|
||||
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}, [appTheme]);
|
||||
|
||||
@@ -362,13 +376,18 @@ export function AppView({
|
||||
};
|
||||
|
||||
let themeColors;
|
||||
if (terminalConfig.theme === "termix") {
|
||||
const activeTerminalTheme =
|
||||
t.id === currentTab && previewTerminalTheme
|
||||
? previewTerminalTheme
|
||||
: terminalConfig.theme;
|
||||
|
||||
if (activeTerminalTheme === "termix") {
|
||||
themeColors = isDarkMode
|
||||
? TERMINAL_THEMES.termixDark.colors
|
||||
: TERMINAL_THEMES.termixLight.colors;
|
||||
} else {
|
||||
themeColors =
|
||||
TERMINAL_THEMES[terminalConfig.theme]?.colors ||
|
||||
TERMINAL_THEMES[activeTerminalTheme]?.colors ||
|
||||
TERMINAL_THEMES.termixDark.colors;
|
||||
}
|
||||
const backgroundColor = themeColors.background;
|
||||
@@ -394,6 +413,19 @@ export function AppView({
|
||||
splitScreen={allSplitScreenTab.length > 0}
|
||||
onClose={() => removeTab(t.id)}
|
||||
onTitleChange={(title) => updateTab(t.id, { title })}
|
||||
onOpenFileManager={
|
||||
(t.hostConfig as any)?.enableFileManager
|
||||
? () =>
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title: t.title,
|
||||
hostConfig: t.hostConfig,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
previewTheme={
|
||||
t.id === currentTab ? previewTerminalTheme : null
|
||||
}
|
||||
/>
|
||||
) : t.type === "server_stats" ? (
|
||||
<ServerView
|
||||
|
||||
@@ -10,6 +10,23 @@ import { TabDropdown } from "@/ui/desktop/navigation/tabs/TabDropdown.tsx";
|
||||
import { SSHToolsSidebar } from "@/ui/desktop/apps/tools/SSHToolsSidebar.tsx";
|
||||
import { useCommandHistory } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||
import { QuickConnectDialog } from "@/ui/desktop/navigation/dialogs/QuickConnectDialog.tsx";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu.tsx";
|
||||
import {
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
Palette,
|
||||
Terminal as TerminalIcon,
|
||||
} from "lucide-react";
|
||||
import { TERMINAL_THEMES } from "@/constants/terminal-themes.ts";
|
||||
|
||||
interface TabData {
|
||||
id: number;
|
||||
@@ -45,15 +62,10 @@ export function TopNavbar({
|
||||
removeTab,
|
||||
allSplitScreenTab,
|
||||
reorderTabs,
|
||||
} = useTabs() as {
|
||||
tabs: TabData[];
|
||||
currentTab: number;
|
||||
setCurrentTab: (id: number) => void;
|
||||
setSplitScreenTab: (id: number) => void;
|
||||
removeTab: (id: number) => void;
|
||||
allSplitScreenTab: number[];
|
||||
reorderTabs: (fromIndex: number, toIndex: number) => void;
|
||||
};
|
||||
updateTab,
|
||||
previewTerminalTheme,
|
||||
setPreviewTerminalTheme,
|
||||
} = useTabs() as any;
|
||||
const leftPosition =
|
||||
state === "collapsed" ? "26px" : "calc(var(--sidebar-width) + 8px)";
|
||||
const { t } = useTranslation();
|
||||
@@ -146,7 +158,7 @@ export function TopNavbar({
|
||||
const handleSnippetExecute = (content: string) => {
|
||||
const tab = tabs.find((t: TabData) => t.id === currentTab);
|
||||
if (tab?.terminalRef?.current?.sendInput) {
|
||||
tab.terminalRef.current.sendInput(content + "\n");
|
||||
tab.terminalRef.current.sendInput(content + "\r");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -378,6 +390,7 @@ export function TopNavbar({
|
||||
const isUserProfile = tab.type === "user_profile";
|
||||
const isRdp = tab.type === "rdp";
|
||||
const isVnc = tab.type === "vnc";
|
||||
const isTelnet = tab.type === "telnet";
|
||||
const isSplittable =
|
||||
isTerminal || isServer || isFileManager || isTunnel || isDocker;
|
||||
const disableSplit = !isSplittable;
|
||||
@@ -456,7 +469,6 @@ export function TopNavbar({
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
e
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1 && !disableClose) {
|
||||
e.preventDefault();
|
||||
@@ -499,6 +511,7 @@ export function TopNavbar({
|
||||
isUserProfile ||
|
||||
isRdp ||
|
||||
isVnc ||
|
||||
isTelnet ||
|
||||
tab.type === "network_graph"
|
||||
? () => handleTabClose(tab.id)
|
||||
: undefined
|
||||
@@ -518,6 +531,7 @@ export function TopNavbar({
|
||||
isUserProfile ||
|
||||
isRdp ||
|
||||
isVnc ||
|
||||
isTelnet ||
|
||||
tab.type === "network_graph"
|
||||
}
|
||||
disableActivate={disableActivate}
|
||||
@@ -535,6 +549,73 @@ export function TopNavbar({
|
||||
<div className="flex items-center justify-center gap-2 flex-1 px-2">
|
||||
<TabDropdown />
|
||||
|
||||
{/* Terminal Theme Switcher */}
|
||||
{(() => {
|
||||
const activeTab = tabs.find((t: any) => t.id === currentTab);
|
||||
if (activeTab?.type !== "terminal") return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[30px] h-[30px] border-edge"
|
||||
title={t("hosts.selectTheme")}
|
||||
>
|
||||
<TerminalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="bg-canvas border-edge text-foreground max-h-[400px] overflow-y-auto thin-scrollbar"
|
||||
onMouseLeave={() => setPreviewTerminalTheme(null)}
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs opacity-70">
|
||||
Terminal Themes
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{Object.entries(TERMINAL_THEMES).map(([key, theme]) => (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
onClick={() => {
|
||||
const activeTab = tabs.find(
|
||||
(t: any) => t.id === currentTab,
|
||||
);
|
||||
if (activeTab?.hostConfig) {
|
||||
const updatedConfig = {
|
||||
...activeTab.hostConfig.terminalConfig,
|
||||
theme: key,
|
||||
};
|
||||
|
||||
// Persist terminal theme selection to localStorage
|
||||
localStorage.setItem(
|
||||
`terminal_theme_host_${activeTab.hostConfig.id}`,
|
||||
key,
|
||||
);
|
||||
|
||||
updateTab(currentTab, {
|
||||
hostConfig: {
|
||||
...activeTab.hostConfig,
|
||||
terminalConfig: updatedConfig,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setPreviewTerminalTheme(key)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full border border-edge"
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
/>
|
||||
<span>{theme.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
})()}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setToolsSidebarOpen(!toolsSidebarOpen)}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Shield, Copy, ExternalLink, Loader2, AlertCircle } from "lucide-react";
|
||||
import { Shield, ExternalLink, Loader2, AlertCircle } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface OPKSSHDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -12,8 +9,10 @@ interface OPKSSHDialogProps {
|
||||
requestId: string;
|
||||
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
|
||||
error?: string;
|
||||
providers?: Array<{ alias: string; issuer: string }>;
|
||||
onCancel: () => void;
|
||||
onOpenUrl: () => void;
|
||||
onSelectProvider?: (alias: string) => void;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
@@ -23,26 +22,15 @@ export function OPKSSHDialog({
|
||||
requestId,
|
||||
stage,
|
||||
error,
|
||||
providers,
|
||||
onCancel,
|
||||
onOpenUrl,
|
||||
onSelectProvider,
|
||||
backgroundColor,
|
||||
}: OPKSSHDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasOpenedRef = React.useRef(false);
|
||||
|
||||
useEffect(() => {}, [isOpen, authUrl, stage, onOpenUrl]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(authUrl);
|
||||
toast.success(t("common.copied"));
|
||||
} catch (error) {
|
||||
toast.error(t("common.copyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
<div
|
||||
@@ -58,47 +46,54 @@ export function OPKSSHDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{stage === "chooser" && authUrl && (
|
||||
{stage === "chooser" && (
|
||||
<>
|
||||
<p className="text-muted-foreground">
|
||||
{t("terminal.opksshAuthDescription")}
|
||||
</p>
|
||||
<div>
|
||||
<Label htmlFor="opksshUrl" className="text-base font-semibold">
|
||||
{t("terminal.opksshAuthUrl")}
|
||||
</Label>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Input
|
||||
id="opksshUrl"
|
||||
type="text"
|
||||
value={authUrl}
|
||||
readOnly
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
{providers && providers.length > 0 && onSelectProvider ? (
|
||||
<div className="space-y-2">
|
||||
{providers.map((provider) => (
|
||||
<Button
|
||||
key={provider.alias}
|
||||
type="button"
|
||||
onClick={() => onSelectProvider(provider.alias)}
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t("terminal.opksshSignInWith", {
|
||||
provider:
|
||||
provider.alias.charAt(0).toUpperCase() +
|
||||
provider.alias.slice(1),
|
||||
})}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyUrl}
|
||||
title={t("common.copy")}
|
||||
className="w-full"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onOpenUrl}
|
||||
className="flex-1 flex items-center justify-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t("terminal.opksshOpenBrowser")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : authUrl ? (
|
||||
<div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onOpenUrl}
|
||||
className="flex-1 flex items-center justify-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t("terminal.opksshOpenBrowser")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -121,7 +116,9 @@ export function OPKSSHDialog({
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{t("common.error")}
|
||||
</p>
|
||||
<p className="text-sm text-destructive/90 mt-1">{error}</p>
|
||||
<p className="text-sm text-destructive/90 mt-1 whitespace-pre-wrap break-words">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Terminal, Monitor, Users, Clock } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TmuxSessionInfo {
|
||||
name: string;
|
||||
created: number;
|
||||
lastActivity: number;
|
||||
windows: number;
|
||||
attachedClients: number;
|
||||
}
|
||||
|
||||
interface TmuxSessionPickerProps {
|
||||
isOpen: boolean;
|
||||
sessions: TmuxSessionInfo[];
|
||||
onSelect: (sessionName: string) => void;
|
||||
onCreateNew: () => void;
|
||||
onCancel: () => void;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
function formatTimestamp(
|
||||
unix: number,
|
||||
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
if (!unix) return "---";
|
||||
const date = new Date(unix * 1000);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDays = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffMin < 1) return t("terminal.tmuxTimeJustNow");
|
||||
if (diffMin < 60) return t("terminal.tmuxTimeMinutes", { count: diffMin });
|
||||
if (diffHr < 24) return t("terminal.tmuxTimeHours", { count: diffHr });
|
||||
if (diffDays < 7) return t("terminal.tmuxTimeDays", { count: diffDays });
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function TmuxSessionPicker({
|
||||
isOpen,
|
||||
sessions,
|
||||
onSelect,
|
||||
onCreateNew,
|
||||
onCancel,
|
||||
backgroundColor,
|
||||
}: TmuxSessionPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
<div
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-elevated border-2 border-edge rounded-lg p-6 max-w-md w-full mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("terminal.tmuxSessionPickerTitle")}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("terminal.tmuxSessionPickerDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2 mb-4 max-h-60 overflow-y-auto">
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.name}
|
||||
onClick={() => onSelect(session.name)}
|
||||
className="w-full text-left px-3 py-3 rounded-md border border-edge hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="font-mono text-sm font-medium">
|
||||
{session.name}
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-muted-foreground">
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t("terminal.tmuxWindows")}
|
||||
>
|
||||
<Monitor className="w-3 h-3" />
|
||||
{t("terminal.tmuxWindowCount", { count: session.windows })}
|
||||
</span>
|
||||
{session.attachedClients > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t("terminal.tmuxAttached")}
|
||||
>
|
||||
<Users className="w-3 h-3" />
|
||||
{t("terminal.tmuxAttachedCount", {
|
||||
count: session.attachedClients,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t("terminal.tmuxLastActivity")}
|
||||
>
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatTimestamp(session.lastActivity, t)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onCreateNew} variant="outline" className="flex-1">
|
||||
{t("terminal.tmuxCreateNew")}
|
||||
</Button>
|
||||
<Button onClick={onCancel} variant="outline" className="flex-1">
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Pencil,
|
||||
ArrowDownUp,
|
||||
Container,
|
||||
Power,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -21,12 +22,20 @@ import {
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
|
||||
import { getSSHHosts, getGuacamoleToken, logActivity } from "@/ui/main-axios";
|
||||
import {
|
||||
getSSHHosts,
|
||||
getGuacamoleToken,
|
||||
getGuacamoleDpi,
|
||||
getGuacamoleTokenFromHost,
|
||||
logActivity,
|
||||
wakeOnLan,
|
||||
} from "@/ui/main-axios";
|
||||
import type { HostProps } from "../../../../types";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHostStatus } from "@/ui/contexts/ServerStatusContext";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
const { addTab } = useTabs();
|
||||
@@ -118,17 +127,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
) {
|
||||
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,
|
||||
});
|
||||
const result = await getGuacamoleTokenFromHost(host.id);
|
||||
addTab({
|
||||
type: protocol,
|
||||
title,
|
||||
@@ -144,6 +143,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(host),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -297,7 +297,15 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
) : (
|
||||
<Terminal className="h-4 w-4" />
|
||||
)}
|
||||
<span className="flex-1">{t("hosts.openTerminal")}</span>
|
||||
<span className="flex-1">
|
||||
{host.connectionType === "rdp"
|
||||
? t("hosts.openRdp")
|
||||
: host.connectionType === "vnc"
|
||||
? t("hosts.openVnc")
|
||||
: host.connectionType === "telnet"
|
||||
? t("hosts.openTelnet")
|
||||
: t("hosts.openTerminal")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isSSH &&
|
||||
@@ -353,6 +361,22 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
<span className="flex-1">{t("hosts.openDocker")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.macAddress && (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
try {
|
||||
await wakeOnLan(host.id);
|
||||
toast.success(t("hosts.wolSent"));
|
||||
} catch {
|
||||
toast.error(t("hosts.wolFailed"));
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.wakeOnLan")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getHostPassword } from "@/ui/main-axios.ts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Home,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
Container as DockerIcon,
|
||||
Key,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
interface TabProps {
|
||||
@@ -66,42 +68,61 @@ export function Tab({
|
||||
|
||||
if (!hostConfig) return;
|
||||
|
||||
const hasSshPassword =
|
||||
hostConfig.authType === "password" && hostConfig.password;
|
||||
const hasSudoPassword = hostConfig.sudoPassword;
|
||||
const hasSshPw =
|
||||
hostConfig.authType === "password" &&
|
||||
(hostConfig.hasPassword || hostConfig.password);
|
||||
const hasSudoPw = hostConfig.hasSudoPassword || hostConfig.sudoPassword;
|
||||
|
||||
if (!hasSshPassword && !hasSudoPassword) {
|
||||
return;
|
||||
if (!hasSshPw && !hasSudoPw) return;
|
||||
|
||||
let passwordToCopy = "";
|
||||
|
||||
if (hasSshPw) {
|
||||
passwordToCopy = hostConfig.password || "";
|
||||
} else if (hasSudoPw) {
|
||||
passwordToCopy = hostConfig.sudoPassword;
|
||||
}
|
||||
|
||||
if (!passwordToCopy) return;
|
||||
|
||||
try {
|
||||
let passwordToCopy = "";
|
||||
|
||||
if (hasSshPassword) {
|
||||
passwordToCopy = hostConfig.password || "";
|
||||
} else if (hasSudoPassword) {
|
||||
passwordToCopy = hostConfig.sudoPassword;
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(passwordToCopy);
|
||||
} catch {}
|
||||
toast.success(t("nav.passwordCopied"));
|
||||
} catch {
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = passwordToCopy;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
toast.success(t("nav.passwordCopied"));
|
||||
} catch {
|
||||
toast.error(t("nav.failedToCopyPassword"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const hasPassword =
|
||||
hostConfig &&
|
||||
((hostConfig.authType === "password" && hostConfig.password) ||
|
||||
((hostConfig.authType === "password" &&
|
||||
(hostConfig.hasPassword || hostConfig.password)) ||
|
||||
hostConfig.hasSudoPassword ||
|
||||
hostConfig.sudoPassword);
|
||||
|
||||
const getPasswordButtonTitle = () => {
|
||||
if (!hostConfig) return "";
|
||||
|
||||
const hasSshPassword =
|
||||
hostConfig.authType === "password" && hostConfig.password;
|
||||
const hasSudoPassword = hostConfig.sudoPassword;
|
||||
const hasSshPw =
|
||||
hostConfig.authType === "password" &&
|
||||
(hostConfig.hasPassword || hostConfig.password);
|
||||
const hasSudoPw = hostConfig.hasSudoPassword || hostConfig.sudoPassword;
|
||||
|
||||
if (hasSshPassword) {
|
||||
if (hasSshPw) {
|
||||
return t("nav.copyPassword");
|
||||
} else if (hasSudoPassword) {
|
||||
} else if (hasSudoPw) {
|
||||
return t("nav.copySudoPassword");
|
||||
}
|
||||
return t("nav.noPasswordAvailable");
|
||||
|
||||
@@ -34,6 +34,8 @@ interface TabContextType {
|
||||
},
|
||||
) => void;
|
||||
updateTab: (tabId: number, updates: Partial<Omit<Tab, "id">>) => void;
|
||||
previewTerminalTheme: string | null;
|
||||
setPreviewTerminalTheme: (theme: string | null) => void;
|
||||
}
|
||||
|
||||
const TabContext = createContext<TabContextType | undefined>(undefined);
|
||||
@@ -122,6 +124,9 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
return 1;
|
||||
});
|
||||
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
|
||||
const [previewTerminalTheme, setPreviewTerminalTheme] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [initialMaxId] = useState(() => {
|
||||
let maxId = 1;
|
||||
tabs.forEach((tab) => {
|
||||
@@ -151,6 +156,13 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
}
|
||||
}, [tabs, currentTab]);
|
||||
|
||||
// Safety net: if currentTab points to a tab that no longer exists, fall back to home
|
||||
useEffect(() => {
|
||||
if (tabs.length > 0 && !tabs.some((t) => t.id === currentTab)) {
|
||||
setCurrentTab(1);
|
||||
}
|
||||
}, [tabs, currentTab]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) =>
|
||||
@@ -264,6 +276,8 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
return id;
|
||||
};
|
||||
|
||||
const pendingCurrentTabRef = useRef<number | null>(null);
|
||||
|
||||
const removeTab = (tabId: number) => {
|
||||
const tab = tabs.find((t) => t.id === tabId);
|
||||
if (
|
||||
@@ -274,31 +288,33 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
tab.terminalRef.current.disconnect();
|
||||
}
|
||||
|
||||
setTabs((prev) => prev.filter((tab) => tab.id !== tabId));
|
||||
setTabs((prev) => {
|
||||
const closedIndex = prev.findIndex((t) => t.id === tabId);
|
||||
const filtered = prev.filter((t) => t.id !== tabId);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
pendingCurrentTabRef.current = 1;
|
||||
return [{ id: 1, type: "home", title: t("nav.home") }];
|
||||
}
|
||||
|
||||
// If the closed tab was active, compute the next tab to activate
|
||||
// using the latest prev so rapid closes don't use stale data
|
||||
const nextIndex =
|
||||
closedIndex < filtered.length ? closedIndex : filtered.length - 1;
|
||||
pendingCurrentTabRef.current = filtered[Math.max(0, nextIndex)]?.id ?? 1;
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
setAllSplitScreenTab((prev) => {
|
||||
const newSplits = prev.filter((id) => id !== tabId);
|
||||
if (newSplits.length <= 1) {
|
||||
return [];
|
||||
}
|
||||
return newSplits;
|
||||
return newSplits.length <= 1 ? [] : newSplits;
|
||||
});
|
||||
|
||||
if (currentTab === tabId) {
|
||||
const remainingTabs = tabs.filter((tab) => tab.id !== tabId);
|
||||
if (remainingTabs.length > 0) {
|
||||
const remainingSplitTabs = allSplitScreenTab.filter(
|
||||
(id) => id !== tabId,
|
||||
);
|
||||
if (remainingSplitTabs.length > 0) {
|
||||
setCurrentTab(remainingSplitTabs[0]);
|
||||
} else {
|
||||
setCurrentTab(remainingTabs[0].id);
|
||||
}
|
||||
} else {
|
||||
setCurrentTab(1);
|
||||
}
|
||||
}
|
||||
setCurrentTab((prevCurrentTab) => {
|
||||
if (prevCurrentTab !== tabId) return prevCurrentTab;
|
||||
return pendingCurrentTabRef.current ?? 1;
|
||||
});
|
||||
};
|
||||
|
||||
const setSplitScreenTab = (tabId: number) => {
|
||||
@@ -412,6 +428,8 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
reorderTabs,
|
||||
updateHostConfig,
|
||||
updateTab,
|
||||
previewTerminalTheme,
|
||||
setPreviewTerminalTheme,
|
||||
}),
|
||||
[
|
||||
tabs,
|
||||
@@ -424,6 +442,8 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
reorderTabs,
|
||||
updateHostConfig,
|
||||
updateTab,
|
||||
previewTerminalTheme,
|
||||
setPreviewTerminalTheme,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ export function ElectronVersionCheck({
|
||||
|
||||
const isDarkMode =
|
||||
theme === "dark" ||
|
||||
theme === "dracula" ||
|
||||
theme === "gentlemansChoice" ||
|
||||
theme === "midnightEspresso" ||
|
||||
theme === "catppuccinMocha" ||
|
||||
(theme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const lineColor = isDarkMode ? "#151517" : "#f9f9f9";
|
||||
|
||||
@@ -97,7 +97,7 @@ export function UserProfile({
|
||||
}: UserProfileProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { theme, setTheme, setThemePreview } = useTheme();
|
||||
const [userInfo, setUserInfo] = useState<{
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
@@ -121,6 +121,9 @@ export function UserProfile({
|
||||
const [commandAutocomplete, setCommandAutocomplete] = useState<boolean>(
|
||||
localStorage.getItem("commandAutocomplete") === "true",
|
||||
);
|
||||
const [commandHistoryTracking, setCommandHistoryTracking] = useState<boolean>(
|
||||
() => localStorage.getItem("commandHistoryTracking") === "true",
|
||||
);
|
||||
const [terminalSyntaxHighlighting, setTerminalSyntaxHighlighting] =
|
||||
useState<boolean>(
|
||||
() => localStorage.getItem("terminalSyntaxHighlighting") === "true",
|
||||
@@ -162,7 +165,7 @@ export function UserProfile({
|
||||
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const info = await getVersionInfo();
|
||||
const info = await getVersionInfo(!disableUpdateCheck);
|
||||
setVersionInfo({ version: info.localVersion });
|
||||
} catch {
|
||||
toast.error(t("user.failedToLoadVersionInfo"));
|
||||
@@ -214,6 +217,12 @@ export function UserProfile({
|
||||
localStorage.setItem("commandAutocomplete", enabled.toString());
|
||||
};
|
||||
|
||||
const handleCommandHistoryTrackingToggle = (enabled: boolean) => {
|
||||
setCommandHistoryTracking(enabled);
|
||||
localStorage.setItem("commandHistoryTracking", enabled.toString());
|
||||
window.dispatchEvent(new Event("commandHistoryTrackingChanged"));
|
||||
};
|
||||
|
||||
const handleTerminalSyntaxHighlightingToggle = (enabled: boolean) => {
|
||||
setTerminalSyntaxHighlighting(enabled);
|
||||
localStorage.setItem("terminalSyntaxHighlighting", enabled.toString());
|
||||
@@ -525,20 +534,73 @@ export function UserProfile({
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">
|
||||
<SelectContent
|
||||
onMouseLeave={() => setThemePreview(null)}
|
||||
>
|
||||
<SelectItem
|
||||
value="light"
|
||||
onMouseEnter={() => setThemePreview("light")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sun className="w-4 h-4" />
|
||||
{t("profile.themeLight")}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="dark">
|
||||
<SelectItem
|
||||
value="dark"
|
||||
onMouseEnter={() => setThemePreview("dark")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Moon className="w-4 h-4" />
|
||||
{t("profile.themeDark")}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="system">
|
||||
<SelectItem
|
||||
value="dracula"
|
||||
onMouseEnter={() => setThemePreview("dracula")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4" />
|
||||
Dracula
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="gentlemansChoice"
|
||||
onMouseEnter={() =>
|
||||
setThemePreview("gentlemansChoice")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4" />
|
||||
Gentleman's Choice
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="midnightEspresso"
|
||||
onMouseEnter={() =>
|
||||
setThemePreview("midnightEspresso")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4" />
|
||||
Midnight Espresso
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="catppuccinMocha"
|
||||
onMouseEnter={() =>
|
||||
setThemePreview("catppuccinMocha")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4" />
|
||||
Catppuccin Mocha
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="system"
|
||||
onMouseEnter={() => setThemePreview("system")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
{t("profile.themeSystem")}
|
||||
@@ -591,6 +653,20 @@ export function UserProfile({
|
||||
onCheckedChange={handleCommandAutocompleteToggle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.commandHistoryTracking")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.commandHistoryTrackingDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={commandHistoryTracking}
|
||||
onCheckedChange={handleCommandHistoryTrackingToggle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { useRef, useCallback } from "react";
|
||||
import { saveCommandToHistory } from "@/ui/main-axios.ts";
|
||||
|
||||
const SENSITIVE_PATTERNS = [
|
||||
/\bpassw(or)?d\b/i,
|
||||
/\bsecret\b/i,
|
||||
/\btoken\b/i,
|
||||
/\bapi.?key\b/i,
|
||||
/\bPASS(WORD)?=/i,
|
||||
/\bAWS_SECRET/i,
|
||||
/\bmysql\b.*-p/i,
|
||||
/\bsudo\s+-S\b/,
|
||||
/\bhtpasswd\b/i,
|
||||
/\bsshpass\b/i,
|
||||
/\bcurl\b.*-u\s/i,
|
||||
/\bexport\b.*(?:PASSWORD|SECRET|TOKEN|KEY)=/i,
|
||||
];
|
||||
|
||||
interface UseCommandTrackerOptions {
|
||||
hostId?: number;
|
||||
enabled?: boolean;
|
||||
@@ -52,9 +67,13 @@ export function useCommandTracker({
|
||||
const command = currentCommandRef.current.trim();
|
||||
|
||||
if (command.length > 0) {
|
||||
saveCommandToHistory(hostId, command).catch((error) => {
|
||||
console.error("Failed to save command to history:", error);
|
||||
});
|
||||
const isSensitive = SENSITIVE_PATTERNS.some((p) => p.test(command));
|
||||
|
||||
if (!isSensitive) {
|
||||
saveCommandToHistory(hostId, command).catch((error) => {
|
||||
console.error("Failed to save command to history:", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (onCommandExecuted) {
|
||||
onCommandExecuted(command);
|
||||
|
||||
+389
-63
@@ -149,28 +149,12 @@ interface OIDCAuthorize {
|
||||
// ============================================================================
|
||||
|
||||
export function isElectron(): boolean {
|
||||
const hasISElectron =
|
||||
(
|
||||
window as Window &
|
||||
typeof globalThis & {
|
||||
IS_ELECTRON?: boolean;
|
||||
electronAPI?: unknown;
|
||||
configuredServerUrl?: string;
|
||||
}
|
||||
).IS_ELECTRON === true;
|
||||
const win = window as any;
|
||||
const hasISElectron = win.IS_ELECTRON === true;
|
||||
const hasElectronAPI = !!win.electronAPI;
|
||||
const isElectronProp = win.electronAPI?.isElectron === true;
|
||||
|
||||
const hasElectronAPI = !!(
|
||||
window as Window &
|
||||
typeof globalThis & {
|
||||
IS_ELECTRON?: boolean;
|
||||
electronAPI?: unknown;
|
||||
configuredServerUrl?: string;
|
||||
}
|
||||
).electronAPI;
|
||||
|
||||
const result = hasISElectron || hasElectronAPI;
|
||||
|
||||
return result;
|
||||
return hasISElectron || hasElectronAPI || isElectronProp;
|
||||
}
|
||||
|
||||
function getLoggerForService(serviceName: string) {
|
||||
@@ -199,20 +183,22 @@ const electronSettingsCache = new Map<string, string>();
|
||||
if (isElectron()) {
|
||||
(async () => {
|
||||
try {
|
||||
const electronAPI = (
|
||||
window as Window &
|
||||
typeof globalThis & {
|
||||
electronAPI?: any;
|
||||
}
|
||||
).electronAPI;
|
||||
const electronAPI = (window as any).electronAPI;
|
||||
|
||||
if (electronAPI?.getSetting) {
|
||||
const settingsToLoad = ["rightClickCopyPaste", "jwt"];
|
||||
for (const key of settingsToLoad) {
|
||||
const value = await electronAPI.getSetting(key);
|
||||
if (value !== null && value !== undefined) {
|
||||
electronSettingsCache.set(key, value);
|
||||
localStorage.setItem(key, value);
|
||||
// Only populate if not already set to prevent overwriting new values during login
|
||||
if (!localStorage.getItem(key)) {
|
||||
electronSettingsCache.set(key, value);
|
||||
localStorage.setItem(key, value);
|
||||
console.log(`[Electron] Loaded setting ${key} from main process`);
|
||||
} else {
|
||||
// Even if we don't overwrite localStorage, update the cache
|
||||
electronSettingsCache.set(key, localStorage.getItem(key)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,16 +298,27 @@ function createApiInstance(
|
||||
|
||||
const logger = getLoggerForService(serviceName);
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
const requestBaseURL = config.baseURL || "";
|
||||
const isDevMode = process.env.NODE_ENV === "development";
|
||||
|
||||
if (isDevMode) {
|
||||
logger.requestStart(method, fullUrl, context);
|
||||
}
|
||||
|
||||
if (isElectron()) {
|
||||
config.headers["X-Electron-App"] = "true";
|
||||
if (config.headers.set) {
|
||||
config.headers.set("X-Electron-App", "true");
|
||||
} else {
|
||||
config.headers["X-Electron-App"] = "true";
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("jwt");
|
||||
if (token) {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
if (config.headers.set) {
|
||||
config.headers.set("Authorization", `Bearer ${token}`);
|
||||
} else {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
userWasAuthenticated = true;
|
||||
}
|
||||
}
|
||||
@@ -339,15 +336,42 @@ function createApiInstance(
|
||||
platform = "iOS";
|
||||
}
|
||||
}
|
||||
config.headers["User-Agent"] = `Termix-Mobile/${platform}`;
|
||||
if (config.headers.set) {
|
||||
config.headers.set("User-Agent", `Termix-Mobile/${platform}`);
|
||||
} else {
|
||||
config.headers["User-Agent"] = `Termix-Mobile/${platform}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isElectron()) {
|
||||
const token = document.cookie
|
||||
const tokenCookie = document.cookie
|
||||
.split("; ")
|
||||
.find((row) => row.startsWith("jwt="));
|
||||
if (token) {
|
||||
userWasAuthenticated = true;
|
||||
|
||||
if (tokenCookie) {
|
||||
const tokenValue = tokenCookie.split("=")[1];
|
||||
if (tokenValue) {
|
||||
// Always add Authorization header as fallback if token is present,
|
||||
// especially important for cross-origin requests where cookies might be blocked
|
||||
const decodedToken = decodeURIComponent(tokenValue);
|
||||
if (config.headers.set) {
|
||||
config.headers.set("Authorization", `Bearer ${decodedToken}`);
|
||||
} else {
|
||||
config.headers["Authorization"] = `Bearer ${decodedToken}`;
|
||||
}
|
||||
userWasAuthenticated = true;
|
||||
}
|
||||
} else {
|
||||
// Check localStorage as fallback even in browser mode
|
||||
const localToken = localStorage.getItem("jwt");
|
||||
if (localToken) {
|
||||
if (config.headers.set) {
|
||||
config.headers.set("Authorization", `Bearer ${localToken}`);
|
||||
} else {
|
||||
config.headers["Authorization"] = `Bearer ${localToken}`;
|
||||
}
|
||||
userWasAuthenticated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,8 +450,11 @@ function createApiInstance(
|
||||
};
|
||||
|
||||
const logger = getLoggerForService(serviceName);
|
||||
// A caller can mark a request as a silent retry (see progressive /status
|
||||
// retry) so we don't spam error logs / health events on each attempt.
|
||||
const isSilentRetry = !!(error.config as any)?.__silentRetry;
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
if (process.env.NODE_ENV === "development" && !isSilentRetry) {
|
||||
if (status === 401) {
|
||||
logger.authError(method, fullUrl, context);
|
||||
} else if (status === 0 || !status) {
|
||||
@@ -457,7 +484,24 @@ function createApiInstance(
|
||||
errorMessage === "Authentication required" ||
|
||||
errorMessage === "Missing authentication token";
|
||||
|
||||
if (isSessionExpired || isSessionNotFound || isInvalidToken) {
|
||||
const headers = error.config?.headers;
|
||||
let hasAuthHeader = false;
|
||||
if (headers) {
|
||||
if (typeof headers.get === "function") {
|
||||
hasAuthHeader = !!(
|
||||
headers.get("Authorization") || headers.get("authorization")
|
||||
);
|
||||
} else {
|
||||
hasAuthHeader = !!(
|
||||
headers["Authorization"] || headers["authorization"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(isSessionExpired || isSessionNotFound || isInvalidToken) &&
|
||||
hasAuthHeader
|
||||
) {
|
||||
const wasAuthenticated = userWasAuthenticated;
|
||||
|
||||
localStorage.removeItem("jwt");
|
||||
@@ -488,7 +532,7 @@ function createApiInstance(
|
||||
|
||||
userWasAuthenticated = false;
|
||||
}
|
||||
} else {
|
||||
} else if (!isSilentRetry) {
|
||||
const wasAuthenticated = !!localStorage.getItem("jwt");
|
||||
dbHealthMonitor.reportDatabaseError(error, wasAuthenticated);
|
||||
}
|
||||
@@ -786,6 +830,9 @@ export let rbacApi: AxiosInstance;
|
||||
// Docker Management API (port 30007)
|
||||
export let dockerApi: AxiosInstance;
|
||||
|
||||
// Pre-initialize with default values to avoid undefined errors during early mounting
|
||||
initializeApiInstances();
|
||||
|
||||
function initializeApp() {
|
||||
if (isElectron()) {
|
||||
Promise.all([getServerConfig(), getEmbeddedServerStatus()])
|
||||
@@ -1006,10 +1053,16 @@ function handleApiError(error: unknown, operation: string): never {
|
||||
export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
|
||||
try {
|
||||
const hostsResponse = await sshHostApi.get("/db/host");
|
||||
const hosts: SSHHost[] = hostsResponse.data;
|
||||
const hosts: SSHHost[] = Array.isArray(hostsResponse.data)
|
||||
? hostsResponse.data
|
||||
: [];
|
||||
|
||||
const statusesResponse = await getAllServerStatuses();
|
||||
const statuses = statusesResponse || {};
|
||||
let statuses: Record<number, ServerStatus> = {};
|
||||
try {
|
||||
statuses = (await getAllServerStatuses()) || {};
|
||||
} catch {
|
||||
// Status fetch failure should not prevent host list from loading
|
||||
}
|
||||
|
||||
return hosts.map((host) => ({
|
||||
...host,
|
||||
@@ -1073,6 +1126,8 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
socks5Username: hostData.socks5Username || null,
|
||||
socks5Password: hostData.socks5Password || null,
|
||||
socks5ProxyChain: hostData.socks5ProxyChain || null,
|
||||
macAddress: hostData.macAddress || null,
|
||||
portKnockSequence: hostData.portKnockSequence || null,
|
||||
};
|
||||
|
||||
if (!submitData.enableTunnel) {
|
||||
@@ -1160,6 +1215,8 @@ export async function updateSSHHost(
|
||||
socks5Username: hostData.socks5Username || null,
|
||||
socks5Password: hostData.socks5Password || null,
|
||||
socks5ProxyChain: hostData.socks5ProxyChain || null,
|
||||
macAddress: hostData.macAddress || null,
|
||||
portKnockSequence: hostData.portKnockSequence || null,
|
||||
};
|
||||
|
||||
if (!submitData.enableTunnel) {
|
||||
@@ -1190,6 +1247,15 @@ export async function updateSSHHost(
|
||||
}
|
||||
}
|
||||
|
||||
export async function wakeOnLan(hostId: number): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await sshHostApi.post(`/db/host/${hostId}/wake`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "wake on LAN");
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkImportSSHHosts(
|
||||
hosts: SSHHostData[],
|
||||
overwrite = false,
|
||||
@@ -1258,6 +1324,17 @@ export async function exportSSHHostWithCredentials(
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportAllSSHHosts(): Promise<{
|
||||
hosts: SSHHost[];
|
||||
}> {
|
||||
try {
|
||||
const response = await sshHostApi.get("/db/hosts/export");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "export all SSH hosts");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSH AUTOSTART MANAGEMENT
|
||||
// ============================================================================
|
||||
@@ -2299,15 +2376,81 @@ export async function removeFolderShortcut(
|
||||
// SERVER STATISTICS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Progressive retry schedule for the background /status poll.
|
||||
*
|
||||
* Each entry describes one attempt's per-request timeout and the pause to
|
||||
* observe before the next attempt. The pause on the last entry is `null`:
|
||||
* after that final failure we surface the network error, which flows
|
||||
* through the response interceptor + dbHealthMonitor (which decides
|
||||
* between the degraded toast and the full-outage overlay based on whether
|
||||
* any WebSocket is still alive).
|
||||
*
|
||||
* Sequence: try(2s) -> wait 3s -> try(5s) -> wait 5s -> try(8s) -> fail.
|
||||
* Worst-case wall-clock = 23s, which fits inside the 30s ServerStatusContext
|
||||
* poll cadence, so the next tick acts as the next retry without overlap.
|
||||
*/
|
||||
const STATUS_RETRY_SCHEDULE: ReadonlyArray<{
|
||||
timeoutMs: number;
|
||||
pauseAfterMs: number | null;
|
||||
}> = [
|
||||
{ timeoutMs: 2000, pauseAfterMs: 3000 },
|
||||
{ timeoutMs: 5000, pauseAfterMs: 5000 },
|
||||
{ timeoutMs: 8000, pauseAfterMs: null },
|
||||
];
|
||||
|
||||
function isTransientStatusError(error: unknown): boolean {
|
||||
if (!axios.isAxiosError(error)) return false;
|
||||
if (error.response) {
|
||||
// Definitive server response (even 5xx) is not something more retries
|
||||
// will fix in a useful timeframe; bail out and report it normally.
|
||||
return false;
|
||||
}
|
||||
const code = error.code;
|
||||
if (!code) {
|
||||
// No code + no response means classic network error (offline / DNS / TCP)
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
code === "ECONNABORTED" ||
|
||||
code === "ETIMEDOUT" ||
|
||||
code === "ERR_NETWORK" ||
|
||||
code === "ECONNREFUSED" ||
|
||||
code === "ECONNRESET"
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAllServerStatuses(): Promise<
|
||||
Record<number, ServerStatus>
|
||||
> {
|
||||
try {
|
||||
const response = await statsApi.get("/status");
|
||||
return response.data || {};
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch server statuses");
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
||||
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
|
||||
const isFinalAttempt = i === STATUS_RETRY_SCHEDULE.length - 1;
|
||||
|
||||
try {
|
||||
const response = await statsApi.get("/status", {
|
||||
timeout: timeoutMs,
|
||||
// Silence per-attempt interceptor logging & health-monitor side
|
||||
// effects on all attempts except the final one, so background
|
||||
// blips don't look like real outages.
|
||||
__silentRetry: !isFinalAttempt,
|
||||
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
||||
return response.data || {};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isTransientStatusError(error)) {
|
||||
break;
|
||||
}
|
||||
if (pauseAfterMs === null) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pauseAfterMs));
|
||||
}
|
||||
}
|
||||
|
||||
handleApiError(lastError, "fetch server statuses");
|
||||
}
|
||||
|
||||
export async function getServerStatusById(id: number): Promise<ServerStatus> {
|
||||
@@ -2320,11 +2463,25 @@ export async function getServerStatusById(id: number): Promise<ServerStatus> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getServerMetricsById(id: number): Promise<ServerMetrics> {
|
||||
export async function getServerMetricsById(
|
||||
id: number,
|
||||
): Promise<ServerMetrics | null> {
|
||||
try {
|
||||
const response = await statsApi.get(`/metrics/${id}`);
|
||||
const response = await statsApi.get(`/metrics/${id}`, {
|
||||
// Treat 404 as an expected "no metrics yet / disabled" signal rather
|
||||
// than an error so we don't spam warn logs on the client.
|
||||
validateStatus: (status) => status === 200 || status === 404,
|
||||
});
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// If a 404 still slips through (e.g. intercepted before reaching here),
|
||||
// swallow it quietly; everything else still flows through handleApiError.
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
handleApiError(error, "fetch server metrics");
|
||||
throw error;
|
||||
}
|
||||
@@ -2378,9 +2535,12 @@ export async function sendMetricsHeartbeat(
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerMetricsViewer(
|
||||
hostId: number,
|
||||
): Promise<{ success: boolean; viewerSessionId: string }> {
|
||||
export async function registerMetricsViewer(hostId: number): Promise<{
|
||||
success: boolean;
|
||||
viewerSessionId?: string;
|
||||
skipped?: boolean;
|
||||
reason?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await statsApi.post("/metrics/register-viewer", {
|
||||
hostId,
|
||||
@@ -2471,6 +2631,50 @@ export async function updateGlobalMonitoringSettings(settings: {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LOG LEVEL SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export async function getLogLevel(): Promise<{ level: string }> {
|
||||
try {
|
||||
const response = await authApi.get("/users/log-level");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch log level");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLogLevel(level: string): Promise<void> {
|
||||
try {
|
||||
await authApi.patch("/users/log-level", { level });
|
||||
} catch (error) {
|
||||
handleApiError(error, "update log level");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SESSION TIMEOUT SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export async function getSessionTimeout(): Promise<{ timeoutHours: number }> {
|
||||
try {
|
||||
const response = await authApi.get("/users/session-timeout");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch session timeout");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSessionTimeout(
|
||||
timeoutHours: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await authApi.patch("/users/session-timeout", { timeoutHours });
|
||||
} catch (error) {
|
||||
handleApiError(error, "update session timeout");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GUACAMOLE SETTINGS
|
||||
// ============================================================================
|
||||
@@ -2538,7 +2742,7 @@ export async function loginUser(
|
||||
const isInIframe =
|
||||
typeof window !== "undefined" && window.self !== window.top;
|
||||
|
||||
if (isInIframe && hasToken) {
|
||||
if (isInIframe && isElectron() && hasToken) {
|
||||
localStorage.setItem("jwt", response.data.token);
|
||||
|
||||
try {
|
||||
@@ -2550,7 +2754,7 @@ export async function loginUser(
|
||||
platform: "desktop",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
"*",
|
||||
window.location.origin,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("[main-axios] Error posting message to parent:", e);
|
||||
@@ -2831,10 +3035,10 @@ export async function revokeAllUserSessions(
|
||||
}
|
||||
|
||||
export async function makeUserAdmin(
|
||||
username: string,
|
||||
userId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/users/make-admin", { username });
|
||||
const response = await authApi.post("/users/make-admin", { userId });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "make user admin");
|
||||
@@ -2842,10 +3046,10 @@ export async function makeUserAdmin(
|
||||
}
|
||||
|
||||
export async function removeAdminStatus(
|
||||
username: string,
|
||||
userId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/users/remove-admin", { username });
|
||||
const response = await authApi.post("/users/remove-admin", { userId });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "remove admin status");
|
||||
@@ -3012,7 +3216,7 @@ export async function verifyTOTPLogin(
|
||||
const isInIframe =
|
||||
typeof window !== "undefined" && window.self !== window.top;
|
||||
|
||||
if (isInIframe && hasToken) {
|
||||
if (isInIframe && isElectron() && hasToken) {
|
||||
localStorage.setItem("jwt", response.data.token);
|
||||
|
||||
try {
|
||||
@@ -3024,7 +3228,7 @@ export async function verifyTOTPLogin(
|
||||
platform: "desktop",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
"*",
|
||||
window.location.origin,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("[main-axios] Error posting message to parent:", e);
|
||||
@@ -3091,9 +3295,13 @@ export async function getReleasesRSS(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getVersionInfo(): Promise<Record<string, unknown>> {
|
||||
export async function getVersionInfo(
|
||||
checkRemote = true,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get("/version");
|
||||
const response = await authApi.get(
|
||||
`/version${checkRemote ? "" : "?checkRemote=false"}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch version info");
|
||||
@@ -3207,6 +3415,20 @@ export async function getSSHHostWithCredentials(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHostPassword(
|
||||
hostId: number,
|
||||
field: "password" | "sudoPassword" = "password",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
`/db/host/${hostId}/password?field=${field}`,
|
||||
);
|
||||
return response.data?.value || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyCredentialToHost(
|
||||
hostId: number,
|
||||
credentialId: number,
|
||||
@@ -3629,7 +3851,9 @@ export async function reorderSnippets(
|
||||
updates: Array<{ id: number; order: number; folder?: string }>,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.post("/snippets/reorder", { updates });
|
||||
const response = await authApi.post("/snippets/reorder", {
|
||||
snippets: updates,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "reorder snippets");
|
||||
@@ -3886,6 +4110,39 @@ export interface GuacamoleTokenResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
type GuacamoleConfigSource = {
|
||||
guacamoleConfig?: string | Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function getGuacamoleDpi(
|
||||
source?: GuacamoleConfigSource,
|
||||
): number | undefined {
|
||||
const config = source?.guacamoleConfig;
|
||||
if (!config) return undefined;
|
||||
|
||||
let dpi: unknown;
|
||||
if (typeof config === "string") {
|
||||
try {
|
||||
dpi = JSON.parse(config).dpi;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
dpi = config.dpi;
|
||||
}
|
||||
|
||||
const parsedDpi = typeof dpi === "string" ? Number(dpi) : dpi;
|
||||
if (
|
||||
typeof parsedDpi !== "number" ||
|
||||
!Number.isFinite(parsedDpi) ||
|
||||
parsedDpi <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Math.trunc(parsedDpi);
|
||||
}
|
||||
|
||||
function toGuacamoleParams(
|
||||
config: GuacamoleTokenRequest["guacamoleConfig"],
|
||||
): Record<string, unknown> {
|
||||
@@ -4132,6 +4389,75 @@ export async function revokeHostAccess(
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SNIPPET SHARING
|
||||
// ============================================================================
|
||||
|
||||
export async function shareSnippet(
|
||||
snippetId: number,
|
||||
shareData: {
|
||||
targetType: "user" | "role";
|
||||
targetUserId?: string;
|
||||
targetRoleId?: number;
|
||||
durationHours?: number;
|
||||
},
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await rbacApi.post(
|
||||
`/rbac/snippet/${snippetId}/share`,
|
||||
shareData,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "share snippet");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSnippetAccess(
|
||||
snippetId: number,
|
||||
): Promise<{ accessList: AccessRecord[] }> {
|
||||
try {
|
||||
const response = await rbacApi.get(`/rbac/snippet/${snippetId}/access`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch snippet access");
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeSnippetAccess(
|
||||
snippetId: number,
|
||||
accessId: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await rbacApi.delete(
|
||||
`/rbac/snippet/${snippetId}/access/${accessId}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "revoke snippet access");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSharedSnippets(): Promise<{
|
||||
sharedSnippets: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
description: string | null;
|
||||
folder: string | null;
|
||||
ownerUsername: string;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
}>;
|
||||
}> {
|
||||
try {
|
||||
const response = await rbacApi.get("/rbac/shared-snippets");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch shared snippets");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DOCKER MANAGEMENT API
|
||||
// ============================================================================
|
||||
|
||||
@@ -13,7 +13,13 @@ import { RobustClipboardProvider } from "@/lib/clipboard-provider";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isElectron, getCookie, getSnippets } from "@/ui/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
isElectron,
|
||||
isEmbeddedMode,
|
||||
getCookie,
|
||||
getSnippets,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import {
|
||||
@@ -121,7 +127,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const isFittingRef = useRef(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttempts = useRef(0);
|
||||
const maxReconnectAttempts = 3;
|
||||
const maxReconnectAttempts = 8;
|
||||
const isUnmountingRef = useRef(false);
|
||||
const shouldNotReconnectRef = useRef(false);
|
||||
const isReconnectingRef = useRef(false);
|
||||
@@ -506,13 +512,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
|
||||
: isElectron()
|
||||
? (() => {
|
||||
const baseUrl =
|
||||
(window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl || "http://127.0.0.1:30001";
|
||||
const wsProtocol = baseUrl.startsWith("https://")
|
||||
const configuredUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (isEmbeddedMode() || !configuredUrl) {
|
||||
return "ws://127.0.0.1:30002";
|
||||
}
|
||||
const wsProtocol = configuredUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||
const wsHost = configuredUrl
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/$/, "");
|
||||
return `${wsProtocol}${wsHost}/ssh/websocket/`;
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/ssh/websocket/`;
|
||||
@@ -709,7 +719,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "input",
|
||||
data: snippet.content + "\n",
|
||||
data: snippet.content + "\r",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -800,6 +810,46 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
} else if (msg.type === "tmux_sessions_available") {
|
||||
// On mobile, auto-attach to the first available session
|
||||
const sessions = msg.sessions as Array<{ name: string }>;
|
||||
if (sessions.length > 0 && ws.readyState === 1) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_attach",
|
||||
data: { sessionName: sessions[0].name },
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
msg.type === "tmux_session_created" ||
|
||||
msg.type === "tmux_session_attached"
|
||||
) {
|
||||
const sessionName =
|
||||
typeof msg.sessionName === "string" ? msg.sessionName : "";
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "connection",
|
||||
message:
|
||||
msg.type === "tmux_session_created"
|
||||
? t("terminal.tmuxSessionCreated", {
|
||||
name: sessionName || "new",
|
||||
})
|
||||
: t("terminal.tmuxSessionAttached", {
|
||||
name: sessionName,
|
||||
}),
|
||||
});
|
||||
} else if (msg.type === "tmux_unavailable") {
|
||||
setTimeout(() => {
|
||||
toast.warning(t("terminal.tmuxUnavailable"), {
|
||||
duration: 8000,
|
||||
});
|
||||
}, 500);
|
||||
addLog({
|
||||
type: "warning",
|
||||
stage: "connection",
|
||||
message: t("terminal.tmuxUnavailable"),
|
||||
});
|
||||
} else if (msg.type === "connection_log") {
|
||||
if (msg.data) {
|
||||
addLog({
|
||||
@@ -834,17 +884,21 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
if (event.code === 1006) {
|
||||
console.error(
|
||||
"[WebSocket] Abnormal closure detected - possible HTTPS/proxy issue",
|
||||
console.warn(
|
||||
"[WebSocket] Abnormal closure detected - attempting reconnection",
|
||||
);
|
||||
addLog({
|
||||
type: "error",
|
||||
type: "warning",
|
||||
stage: "connection",
|
||||
message: t("terminal.websocketAbnormalClose"),
|
||||
});
|
||||
updateConnectionError(t("terminal.websocketAbnormalClose"));
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
|
||||
if (wasConnectedRef.current) {
|
||||
attemptReconnection();
|
||||
} else {
|
||||
updateConnectionError(t("terminal.websocketAbnormalClose"));
|
||||
setIsConnecting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -861,10 +915,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -994,6 +1044,16 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
terminal.open(xtermRef.current);
|
||||
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const text = e.clipboardData?.getData("text");
|
||||
if (text) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
terminal.paste(text);
|
||||
}
|
||||
};
|
||||
xtermRef.current.addEventListener("paste", handlePaste);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
resizeTimeout.current = setTimeout(() => {
|
||||
@@ -1046,9 +1106,12 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
});
|
||||
});
|
||||
|
||||
const currentElement = xtermRef.current;
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
clipboardProvider.dispose();
|
||||
currentElement?.removeEventListener("paste", handlePaste);
|
||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
if (pingIntervalRef.current) {
|
||||
|
||||
@@ -165,12 +165,6 @@ export function Auth({
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!registrationAllowed && !internalLoggedIn) {
|
||||
toast.warning(t("messages.registrationDisabled"));
|
||||
}
|
||||
}, [registrationAllowed, internalLoggedIn, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!passwordLoginAllowed && oidcConfigured && tab !== "external") {
|
||||
setTab("external");
|
||||
@@ -595,6 +589,11 @@ export function Auth({
|
||||
setOidcLoading(true);
|
||||
setError(null);
|
||||
|
||||
const urlToken = urlParams.get("token");
|
||||
if (urlToken && (isElectron() || isReactNativeWebView())) {
|
||||
localStorage.setItem("jwt", urlToken);
|
||||
}
|
||||
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
Reference in New Issue
Block a user