* 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:
Luke Gustafson
2026-04-22 16:55:23 -05:00
committed by GitHub
parent 9b1cc3dbf4
commit e3cb1f82af
103 changed files with 10356 additions and 5115 deletions
+43 -46
View File
@@ -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 });
+71 -99
View File
@@ -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 });
+31 -1
View File
@@ -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 {
+27
View File
@@ -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")
File diff suppressed because it is too large Load Diff
+367 -40
View File
@@ -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[];
}
+7 -15
View File
@@ -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",
+27 -1
View File
@@ -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
+245 -38
View File
@@ -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;
+2 -1
View File
@@ -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,
+118 -79
View File
@@ -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
View File
@@ -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
View File
@@ -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" });
}
+173
View File
@@ -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
View File
@@ -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>;
+236
View File
@@ -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
View File
@@ -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",
});
}
});
+2 -36
View File
@@ -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
View File
@@ -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;
+156
View File
@@ -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
View File
@@ -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
View File
@@ -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", {
+42 -3
View File
@@ -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);
}
}
+71
View File
@@ -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;
}
}
+28 -1
View File
@@ -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;
}
+2 -2
View File
@@ -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> = {
+3 -1
View File
@@ -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,
+71
View File
@@ -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"],
};
+46
View File
@@ -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();
});
});
});
}