mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
v2.3.2 (#874)
* fix: patch critical security vulnerabilities (GHSA-5fqh, GHSA-ccm8, GHSA-wqfw, GHSA-xmjh) - Remove passwordHash from /users/list API response - Require both password and TOTP code for MFA-critical operations - Restrict tunnel kill commands to tunnelMarker-only matching - Add session ownership middleware for file manager endpoints * fix: allow navigating away from split-view to non-pane tabs Show the normal view container on top of the split view when the active tab is not assigned to any pane, so users can switch to dashboard or other tabs while split mode is active. Closes #739 * fix: add inline quick-action buttons on host name row Show Terminal, Files, RDP, and VNC shortcut icons on the host name row on hover, so users can launch connections with a single click without expanding the full action tray. Closes #736 * fix: restore SSH keepalive interval to 30s to prevent random disconnects Revert keepalive defaults from 60s/5 to 30s/3 across terminal, tunnel, and server-stats SSH connections. The 60s interval introduced in 2.3.0 causes firewalls and NAT devices to drop idle connections before the next keepalive probe. Closes #733 * fix: apply guacamole-lite protocol patch in Docker builds The Dockerfile uses --ignore-scripts which skips the postinstall hook that patches guacamole-lite for guacd 1.6.0 protocol VERSION_1_5_0. Without this patch, the timezone handshake instruction is not sent for protocol versions above 1.1.0, causing VNC connections to fail immediately on connect. Closes #734 * fix: show correct icons for network interface types Detect interface type from name pattern and show appropriate icons: WiFi for wlan/wl*, Ethernet (Cable) for eth/en*, Container for docker/bridge/virtual, generic Network for others. Closes #720 * fix: resolve sudo password for shared host users The password endpoint required hosts.userId to match the requesting user, which fails for shared hosts. Now falls back to decrypting with the owner's key when the requesting user doesn't own the host. Closes #717 * fix: use jump hosts for online status check and metrics collection Status polling now pings the first jump host instead of the unreachable target when jump hosts are configured. The /metrics/start endpoint now tunnels through the jump host chain to reach the target host. Closes #716 * fix: broaden sudo prompt detection for newer distros Add patterns for 'password for <user>:' and bare 'Password:' prompts in addition to the existing [sudo] and sudo: patterns. Covers Ubuntu 26.04 and other distros that use different sudo prompt formats. Closes #718 * fix: recalculate terminal layout after web fonts load xterm.js measures character widths at open() time. If custom fonts haven't loaded yet, measurements use the fallback font and spacing becomes incorrect. Now refresh and re-fit the terminal once document.fonts.ready resolves. Closes #710 * fix: improve terminal cwd detection and initial directory command Remove '&& pwd' from initial directory command — the shell prompt shows the new directory naturally. Fixes PowerShell 5.1 which doesn't support '&&' as a statement separator. Prepend Ctrl+U to get_cwd command to clear any pending input before injecting the cwd probe, reducing interference with foreground programs. Closes #713, #714 * fix: decode base64 file content as UTF-8 in file manager Replace bare atob() with TextDecoder('utf-8') for base64 content decoding. atob() only handles Latin-1, so multi-byte UTF-8 characters like 'é' were decoded as 'é'. Closes #719 * fix: normalize lazy import default exports for iOS compatibility Wrap all lazy() imports with explicit .then(m => ({ default: m.default })) to ensure consistent module resolution across platforms. iOS Safari/WebView may handle bare lazy(() => import(...)) differently, returning the module object instead of extracting the default export. Closes #721 * fix: prevent RDP display from snapping back after container resize Remove immediate rescaleDisplay() from ResizeObserver callback. The display.onresize event already triggers rescaling when the RDP server responds with the new resolution. Calling rescaleDisplay before the server responds uses stale display dimensions, causing the bottom of the screen to be truncated. Closes #725 * fix: add portal Desktop DBus permission for Flatpak URL opening Flatpak sandbox blocks window.open() without the portal permission, causing terminal link clicks to open about:blank. Add talk-name for org.freedesktop.portal.Desktop to enable xdg-desktop-portal URL handling. Closes #704 * chore: remove unused code and fix PR checks (#851) * chore: remove unused frontend code * chore: prune unused theme exports * ci: fix pr check failures * chore: reduce lint warnings * feat(oidc): expose admin_group via OIDC_ADMIN_GROUP env var (#828) The admin-group OIDC sync added in 2.3.0 (#782) reads `config.admin_group` to sync the user's admin flag from OIDC group membership on each login. That field is only populated when the OIDC config is stored in the in-app DB — `getOIDCConfigFromEnv()` does not expose it, so deployments using the env-var config path (declarative IaC: Helm/Compose/Puppet) cannot enable the feature without abandoning env vars and pasting the client_secret into the admin UI. Add `admin_group: process.env.OIDC_ADMIN_GROUP || ""` to the env-config return type and object. Backward compatible: when unset, the existing `if (config.admin_group)` guard at users.ts:1336 keeps the sync block skipped, matching today's behavior. * chore: reduce explicit-any warnings * chore: reduce more explicit-any warnings * chore: reduce lint warnings * chore: silence intentional hook dependency warnings * chore: clean dependency tooling * chore: narrow frontend tsconfig scope * chore: reduce type assertion debt * refactor: split host manager components * refactor: split host editor sections * refactor: split api client modules * refactor: split more api clients * refactor: split user settings api clients * refactor: split tab and history api clients * refactor: split tunnel api clients * refactor: split server stats api client * refactor: split file manager data api * refactor: split ssh file operations api * refactor: split host editor general tab * refactor: split host editor guacamole tabs * refactor: split ssh host management api * refactor: split admin general settings sections * refactor: split admin database section * refactor: split admin management sections * refactor: split admin keys and dialogs * refactor: split system status api clients * refactor: split user route helpers * refactor: split host route helpers * refactor: split file manager ssh helpers * refactor: split file manager session helpers * refactor: split file manager listing routes * refactor: split host opkssh routes * refactor: split file manager content routes * refactor: split user api key routes * refactor: split host folder routes * refactor: split user settings routes * refactor: split user totp routes * refactor: split host file manager bookmark routes * refactor: split file manager operation routes * refactor: split server stats settings routes * refactor: split user session routes * refactor: split host command history routes * refactor: split server stats viewer routes * refactor: split docker container routes * refactor: split user oidc account routes * refactor: split host autostart routes * refactor: split host internal routes * refactor: split host network routes * refactor: split user password reset routes * refactor: split user admin routes * refactor: split user data access routes * refactor: split credential key routes * refactor: split credential deploy routes * refactor: split host bulk routes * refactor: split server stats connection helpers * refactor: split tunnel helpers * refactor: split file manager action routes * refactor: split terminal auth helpers * refactor: split terminal jump host helpers * refactor: split tunnel relay helpers * refactor: split tunnel socks relay helpers * refactor: split tunnel c2s relay handlers * refactor: split server stats session helpers * refactor: split terminal presentation helpers * refactor: split file manager presentation helpers * refactor: split file manager toolbar * fix(guacamole-lite): send name instruction for protocol >= 1.3.0 The Guacamole protocol added the `name` handshake instruction in 1.3.0 (an optional human-readable identifier for the joining user). guacd 1.6.0 began requiring it during the VNC handshake even when negotiating older protocol versions, causing connections to silently drop right after the "User joined" log line with no client-visible error. This patch extends scripts/patch-guacamole-lite.cjs with a third idempotent string-replacement that injects the `name` instruction send when guacamole-lite has negotiated protocol VERSION_1_3_0 or VERSION_1_5_0. Verified end-to-end: guacd debug logs now show `Processing instruction: name` and `Client is using protocol version "VERSION_1_5_0"` (previously stuck at VERSION_1_1_0). VNC session connects successfully against guacd 1.5.5 / macOS Tahoe target. Related: Termix-SSH/Support#567, #734 * fix: resolve recent support bugs * fix(admin): wire up OIDC-to-password link dialog submit + visibility The admin user-management UI already shipped a link icon and a "Link Account" dialog, but two things blocked the flow: 1. The submit button had no onClick handler and the username input was uncontrolled (no value/onChange). Clicking "Link Accounts" was a no-op — no network request, no console error, no toast. 2. The link icon's visibility condition was `user.isOidc && !user.passwordHash`, which hid the button on OIDC users that had been auto-provisioned with a passwordHash. Termix's OIDC provisioning sets a passwordHash by default, so the button was hidden on virtually every OIDC-provisioned user. This change: - Adds `linkOIDCToPasswordAccount` to the imports from `@/main-axios`. - Adds two pieces of dialog state: `linkAccountTargetUsername` and `linkAccountSubmitting`. - Makes the dialog's Input field a controlled component. - Wires the submit Button's onClick to call `linkOIDCToPasswordAccount`, emit success/error toasts, refresh the local user list, and close the dialog. - Loosens the visibility condition to `user.isOidc` (the backend handler already enforces all integrity checks). - Adds `linkAccountSuccess`, `linkAccountFailed`, and `linkAccountInProgress` translation keys to `en.json`. Verified locally: full Docker build via docker/Dockerfile passes; `tsc --noEmit` is clean; `prettier --check .` is clean; ESLint produces the same warning count as upstream (16 pre-existing `any`-type warnings, 0 errors). * fix: support native oidc callbacks (#856) * docs: add cloudflare tunnel guidance (#857) * fix: sync appearance preferences (#858) * fix: pass through terminal tab completion (#859) * fix: resolve terminal jump hosts server-side (#860) * fix(electron): auto-allow SSL certificates for private network hosts (#861) Add private network IP detection (RFC 1918, link-local, loopback, IPv6 ULA) to the Electron certificate-error handler so that connections to local/private servers like 192.168.x.x bypass SSL validation automatically. Also add an explicit "Allow invalid certificate" toggle in the server config UI for public HTTPS servers with self-signed certs. * fix: restore host password copy actions (#862) * feat: support single-host direct tunnels (ssh -L style) (#863) Add direct tunnel mode that uses a single SSH host for port forwarding, matching the behavior of ssh -L / ssh -R / ssh -D without requiring a second endpoint host in the Termix database. The Termix server creates a local TCP listener and forwards through the SSH channel directly. * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * fix: backend build errors (Type) * fix: mobile auth failing to login with webview * fix: mobile app geting incorrectly sent auth token * feat: commit existing frontend/backend e2e/unit tests (skipped tests containing private info like OIDC and real server testing) * feat: host-to-host file transfer via server relay * feat: removed host management from command palette, fixed command palette opening wrong protocol, export/import failing for ssh key hosts, docker ssh2 native crypto not compiled, persisted terminal tabs attempt SSh on RDP hosts after migration, improved layout for click to expand hosts, show ip/username without having to hover over hosts * fix: credentials not indexing into host manager until refresh * feat: update credentials lists to match hosts list UI/UX * feat: add rename folder UI * feat: improve transfer to host UI/UX * chore: increment ver * feat: improve transfer to host UI * feat: implement initial auto release system --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: nicodarge <43711429+nicodarge@users.noreply.github.com> Co-authored-by: Raman Gupta <7243222+raman325@users.noreply.github.com> Co-authored-by: luc <luc_cook@hotmail.co.uk>
This commit is contained in:
@@ -14,6 +14,16 @@ on:
|
||||
options:
|
||||
- Development
|
||||
- Production
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (e.g., 1.8.0)"
|
||||
required: true
|
||||
type: string
|
||||
build_type:
|
||||
description: "Build type (Development or Production)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -35,8 +45,8 @@ jobs:
|
||||
- name: Determine tags
|
||||
id: tags
|
||||
run: |
|
||||
VERSION=${{ github.event.inputs.version }}
|
||||
BUILD_TYPE=${{ github.event.inputs.build_type }}
|
||||
VERSION=${{ inputs.version }}
|
||||
BUILD_TYPE=${{ inputs.build_type }}
|
||||
|
||||
TAGS=()
|
||||
ALL_TAGS=()
|
||||
@@ -64,7 +74,7 @@ jobs:
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub (prod only)
|
||||
if: ${{ github.event.inputs.build_type == 'Production' }}
|
||||
if: ${{ inputs.build_type == 'Production' }}
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: bugattiguy527
|
||||
|
||||
@@ -23,11 +23,30 @@ on:
|
||||
- file
|
||||
- release
|
||||
- submit
|
||||
workflow_call:
|
||||
inputs:
|
||||
build_type:
|
||||
description: "Platform to build for (all, windows, linux, macos)"
|
||||
required: true
|
||||
type: string
|
||||
artifact_destination:
|
||||
description: "What to do with the built app (none, file, release, submit)"
|
||||
required: true
|
||||
type: string
|
||||
release_tag:
|
||||
description: "Explicit release tag to upload assets to (defaults to latest release when empty)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
outputs:
|
||||
macos_universal_dmg_sha256:
|
||||
description: "SHA256 of the universal macOS DMG (for Homebrew cask)"
|
||||
value: ${{ jobs.build-macos.outputs.dmg_sha256 }}
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == '') && github.event.inputs.artifact_destination != 'submit'
|
||||
if: (inputs.build_type == 'all' || inputs.build_type == 'windows' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -59,7 +78,7 @@ jobs:
|
||||
|
||||
- name: Upload Windows x64 NSIS Installer
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_windows_x64_nsis.exe') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('release/termix_windows_x64_nsis.exe') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_x64_nsis
|
||||
path: release/termix_windows_x64_nsis.exe
|
||||
@@ -67,7 +86,7 @@ jobs:
|
||||
|
||||
- name: Upload Windows ia32 NSIS Installer
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_windows_ia32_nsis.exe') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('release/termix_windows_ia32_nsis.exe') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_ia32_nsis
|
||||
path: release/termix_windows_ia32_nsis.exe
|
||||
@@ -75,7 +94,7 @@ jobs:
|
||||
|
||||
- name: Upload Windows x64 MSI Installer
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_windows_x64_msi.msi') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('release/termix_windows_x64_msi.msi') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_x64_msi
|
||||
path: release/termix_windows_x64_msi.msi
|
||||
@@ -83,7 +102,7 @@ jobs:
|
||||
|
||||
- name: Upload Windows ia32 MSI Installer
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_windows_ia32_msi.msi') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('release/termix_windows_ia32_msi.msi') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_ia32_msi
|
||||
path: release/termix_windows_ia32_msi.msi
|
||||
@@ -101,7 +120,7 @@ jobs:
|
||||
|
||||
- name: Upload Windows x64 Portable
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('termix_windows_x64_portable.zip') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('termix_windows_x64_portable.zip') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_x64_portable
|
||||
path: termix_windows_x64_portable.zip
|
||||
@@ -109,7 +128,7 @@ jobs:
|
||||
|
||||
- name: Upload Windows ia32 Portable
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('termix_windows_ia32_portable.zip') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('termix_windows_ia32_portable.zip') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_windows_ia32_portable
|
||||
path: termix_windows_ia32_portable.zip
|
||||
@@ -117,7 +136,7 @@ jobs:
|
||||
|
||||
build-linux:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
if: (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == '') && github.event.inputs.artifact_destination != 'submit'
|
||||
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -172,7 +191,7 @@ jobs:
|
||||
|
||||
- name: Upload Linux x64 AppImage
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_x64_appimage.AppImage') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('release/termix_linux_x64_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_x64_appimage
|
||||
path: release/termix_linux_x64_appimage.AppImage
|
||||
@@ -180,7 +199,7 @@ jobs:
|
||||
|
||||
- name: Upload Linux arm64 AppImage
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_arm64_appimage.AppImage') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('release/termix_linux_arm64_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_arm64_appimage
|
||||
path: release/termix_linux_arm64_appimage.AppImage
|
||||
@@ -188,7 +207,7 @@ jobs:
|
||||
|
||||
- name: Upload Linux armv7l AppImage
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_armv7l_appimage.AppImage') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('release/termix_linux_armv7l_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_armv7l_appimage
|
||||
path: release/termix_linux_armv7l_appimage.AppImage
|
||||
@@ -196,7 +215,7 @@ jobs:
|
||||
|
||||
- name: Upload Linux x64 DEB
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_x64_deb.deb') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('release/termix_linux_x64_deb.deb') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_x64_deb
|
||||
path: release/termix_linux_x64_deb.deb
|
||||
@@ -204,7 +223,7 @@ jobs:
|
||||
|
||||
- name: Upload Linux arm64 DEB
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_arm64_deb.deb') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('release/termix_linux_arm64_deb.deb') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_arm64_deb
|
||||
path: release/termix_linux_arm64_deb.deb
|
||||
@@ -212,7 +231,7 @@ jobs:
|
||||
|
||||
- name: Upload Linux armv7l DEB
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_armv7l_deb.deb') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('release/termix_linux_armv7l_deb.deb') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_armv7l_deb
|
||||
path: release/termix_linux_armv7l_deb.deb
|
||||
@@ -220,7 +239,7 @@ jobs:
|
||||
|
||||
- name: Upload Linux x64 tar.gz
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_x64_portable.tar.gz') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('release/termix_linux_x64_portable.tar.gz') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_x64_portable
|
||||
path: release/termix_linux_x64_portable.tar.gz
|
||||
@@ -228,7 +247,7 @@ jobs:
|
||||
|
||||
- name: Upload Linux arm64 tar.gz
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_arm64_portable.tar.gz') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('release/termix_linux_arm64_portable.tar.gz') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_arm64_portable
|
||||
path: release/termix_linux_arm64_portable.tar.gz
|
||||
@@ -236,7 +255,7 @@ jobs:
|
||||
|
||||
- name: Upload Linux armv7l tar.gz
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_armv7l_portable.tar.gz') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('release/termix_linux_armv7l_portable.tar.gz') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_armv7l_portable
|
||||
path: release/termix_linux_armv7l_portable.tar.gz
|
||||
@@ -308,7 +327,7 @@ jobs:
|
||||
|
||||
- name: Upload Flatpak bundle
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_linux_flatpak.flatpak') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('release/termix_linux_flatpak.flatpak') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_flatpak
|
||||
path: release/termix_linux_flatpak.flatpak
|
||||
@@ -316,7 +335,7 @@ jobs:
|
||||
|
||||
- name: Upload Flatpakref
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/com.karmaa.termix.flatpakref') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('release/com.karmaa.termix.flatpakref') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_linux_flatpakref
|
||||
path: release/com.karmaa.termix.flatpakref
|
||||
@@ -324,10 +343,12 @@ jobs:
|
||||
|
||||
build-macos:
|
||||
runs-on: blacksmith-6vcpu-macos-latest
|
||||
if: (github.event.inputs.build_type == 'macos' || github.event.inputs.build_type == 'all') && github.event.inputs.artifact_destination != 'submit'
|
||||
if: (inputs.build_type == 'macos' || inputs.build_type == 'all') && inputs.artifact_destination != 'submit'
|
||||
needs: []
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
dmg_sha256: ${{ steps.dmg-checksum.outputs.sha256 }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -458,7 +479,7 @@ jobs:
|
||||
npx electron-builder --mac dmg --universal --x64 --arm64 --publish never
|
||||
|
||||
- name: Upload macOS MAS PKG
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && hashFiles('release/termix_macos_universal_mas.pkg') != '' && (github.event.inputs.artifact_destination == 'file' || github.event.inputs.artifact_destination == 'release' || github.event.inputs.artifact_destination == 'submit')
|
||||
if: steps.check_certs.outputs.has_certs == 'true' && hashFiles('release/termix_macos_universal_mas.pkg') != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit')
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: termix_macos_universal_mas
|
||||
@@ -468,7 +489,7 @@ jobs:
|
||||
|
||||
- name: Upload macOS Universal DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_macos_universal_dmg
|
||||
path: release/termix_macos_universal_dmg.dmg
|
||||
@@ -476,7 +497,7 @@ jobs:
|
||||
|
||||
- name: Upload macOS x64 DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_macos_x64_dmg.dmg') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('release/termix_macos_x64_dmg.dmg') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_macos_x64_dmg
|
||||
path: release/termix_macos_x64_dmg.dmg
|
||||
@@ -484,7 +505,7 @@ jobs:
|
||||
|
||||
- name: Upload macOS arm64 DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('release/termix_macos_arm64_dmg.dmg') != '' && github.event.inputs.artifact_destination != 'none'
|
||||
if: hashFiles('release/termix_macos_arm64_dmg.dmg') != '' && inputs.artifact_destination != 'none'
|
||||
with:
|
||||
name: termix_macos_arm64_dmg
|
||||
path: release/termix_macos_arm64_dmg.dmg
|
||||
@@ -496,8 +517,15 @@ jobs:
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Compute universal DMG checksum
|
||||
id: dmg-checksum
|
||||
if: hashFiles('release/termix_macos_universal_dmg.dmg') != ''
|
||||
run: |
|
||||
CHECKSUM=$(shasum -a 256 "release/termix_macos_universal_dmg.dmg" | awk '{print $1}')
|
||||
echo "sha256=$CHECKSUM" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate Homebrew Cask
|
||||
if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && (github.event.inputs.artifact_destination == 'file' || github.event.inputs.artifact_destination == 'release')
|
||||
if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release')
|
||||
run: |
|
||||
VERSION="${{ steps.homebrew-version.outputs.version }}"
|
||||
DMG_PATH="release/termix_macos_universal_dmg.dmg"
|
||||
@@ -515,14 +543,14 @@ jobs:
|
||||
|
||||
- name: Upload Homebrew Cask as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
if: hashFiles('homebrew-generated/termix.rb') != '' && github.event.inputs.artifact_destination == 'file'
|
||||
if: hashFiles('homebrew-generated/termix.rb') != '' && inputs.artifact_destination == 'file'
|
||||
with:
|
||||
name: termix_macos_homebrew_cask
|
||||
path: homebrew-generated/termix.rb
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Homebrew Cask to release
|
||||
if: hashFiles('homebrew-generated/termix.rb') != '' && github.event.inputs.artifact_destination == 'release'
|
||||
if: hashFiles('homebrew-generated/termix.rb') != '' && inputs.artifact_destination == 'release'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
@@ -544,7 +572,7 @@ jobs:
|
||||
|
||||
submit-to-chocolatey:
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == '')
|
||||
if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'windows' || inputs.build_type == '')
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -649,7 +677,7 @@ jobs:
|
||||
|
||||
submit-to-flatpak:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == '')
|
||||
if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '')
|
||||
needs: []
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -736,7 +764,7 @@ jobs:
|
||||
|
||||
submit-to-homebrew:
|
||||
runs-on: blacksmith-6vcpu-macos-latest
|
||||
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'macos')
|
||||
if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'macos')
|
||||
needs: []
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -805,7 +833,7 @@ jobs:
|
||||
|
||||
upload-to-release:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
if: github.event.inputs.artifact_destination == 'release'
|
||||
if: inputs.artifact_destination == 'release'
|
||||
needs: [build-windows, build-linux, build-macos]
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -816,12 +844,17 @@ jobs:
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Get latest release tag
|
||||
- name: Resolve release tag
|
||||
id: get_release
|
||||
run: |
|
||||
echo "RELEASE_TAG=$(gh release list --repo ${{ github.repository }} --limit 1 --json tagName -q '.[0].tagName')" >> $GITHUB_ENV
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
INPUT_TAG: ${{ inputs.release_tag }}
|
||||
run: |
|
||||
if [ -n "$INPUT_TAG" ]; then
|
||||
echo "RELEASE_TAG=$INPUT_TAG" >> $GITHUB_ENV
|
||||
else
|
||||
echo "RELEASE_TAG=$(gh release list --repo ${{ github.repository }} --limit 1 --json tagName -q '.[0].tagName')" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Upload artifacts to latest release
|
||||
run: |
|
||||
@@ -841,7 +874,7 @@ jobs:
|
||||
|
||||
submit-to-testflight:
|
||||
runs-on: blacksmith-6vcpu-macos-latest
|
||||
if: github.event.inputs.artifact_destination == 'submit' && (github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'macos')
|
||||
if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'macos')
|
||||
needs: []
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
prep:
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
outputs:
|
||||
version: ${{ steps.info.outputs.version }}
|
||||
release_tag: ${{ steps.info.outputs.release_tag }}
|
||||
mobile_version: ${{ steps.info.outputs.mobile_version }}
|
||||
steps:
|
||||
- name: Guard branch
|
||||
if: github.ref != 'refs/heads/main'
|
||||
run: |
|
||||
echo "Releases must be run from main (got ${{ github.ref }})."
|
||||
exit 1
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Resolve versions
|
||||
id: info
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=release-$VERSION-tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
MOBILE_RAW=$(gh release view -R Termix-SSH/Mobile --json tagName -q .tagName)
|
||||
MOBILE_VERSION=$(echo "$MOBILE_RAW" | sed -E 's/^release-//; s/-tag$//')
|
||||
if [ -z "$MOBILE_VERSION" ]; then
|
||||
echo "Failed to resolve the latest Termix-SSH/Mobile release version."
|
||||
exit 1
|
||||
fi
|
||||
echo "mobile_version=$MOBILE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate release notes
|
||||
run: |
|
||||
node scripts/generate-release-body.cjs \
|
||||
--version "${{ steps.info.outputs.version }}" \
|
||||
--mobile-version "${{ steps.info.outputs.mobile_version }}" \
|
||||
--notes RELEASE_NOTES.md > /dev/null
|
||||
|
||||
docker:
|
||||
needs: [prep]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
version: ${{ needs.prep.outputs.version }}
|
||||
build_type: Production
|
||||
secrets: inherit
|
||||
|
||||
create-release:
|
||||
needs: [prep, docker]
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Generate release body
|
||||
run: |
|
||||
node scripts/generate-release-body.cjs \
|
||||
--version "${{ needs.prep.outputs.version }}" \
|
||||
--mobile-version "${{ needs.prep.outputs.mobile_version }}" \
|
||||
--notes RELEASE_NOTES.md > RELEASE_BODY.md
|
||||
|
||||
- name: Create or update GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
TAG="${{ needs.prep.outputs.release_tag }}"
|
||||
TITLE="release-${{ needs.prep.outputs.version }}"
|
||||
if gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then
|
||||
gh release edit "$TAG" --repo ${{ github.repository }} --title "$TITLE" --notes-file RELEASE_BODY.md
|
||||
else
|
||||
gh release create "$TAG" --repo ${{ github.repository }} --title "$TITLE" --notes-file RELEASE_BODY.md --target "${{ github.sha }}"
|
||||
fi
|
||||
|
||||
electron-release:
|
||||
needs: [prep, create-release]
|
||||
uses: ./.github/workflows/electron.yml
|
||||
with:
|
||||
build_type: all
|
||||
artifact_destination: release
|
||||
release_tag: ${{ needs.prep.outputs.release_tag }}
|
||||
secrets: inherit
|
||||
|
||||
electron-submit:
|
||||
needs: [electron-release]
|
||||
uses: ./.github/workflows/electron.yml
|
||||
with:
|
||||
build_type: all
|
||||
artifact_destination: submit
|
||||
secrets: inherit
|
||||
|
||||
cask-commit-back:
|
||||
needs: [prep, electron-release]
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 1
|
||||
token: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Bump and commit Homebrew cask
|
||||
env:
|
||||
VERSION: ${{ needs.prep.outputs.version }}
|
||||
DMG_SHA256: ${{ needs.electron-release.outputs.macos_universal_dmg_sha256 }}
|
||||
run: |
|
||||
if [ -z "$DMG_SHA256" ]; then
|
||||
echo "No universal DMG checksum available (macOS build unsigned or skipped); leaving cask unchanged."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sed -i "s|version \".*\"|version \"$VERSION\"|g" Casks/termix.rb
|
||||
sed -i "s|sha256 \".*\"|sha256 \"$DMG_SHA256\"|g" Casks/termix.rb
|
||||
|
||||
if git diff --quiet Casks/termix.rb; then
|
||||
echo "Cask already up to date."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "LukeGus"
|
||||
git config user.email "bugattiguy527@gmail.com"
|
||||
git add Casks/termix.rb
|
||||
git commit -m "chore: bump Homebrew cask to $VERSION"
|
||||
git push origin HEAD:main
|
||||
@@ -7,8 +7,10 @@ pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
src/mcp-server/node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
.vscode/
|
||||
|
||||
@@ -5,6 +5,7 @@ dist-ssr
|
||||
release
|
||||
|
||||
node_modules
|
||||
src/mcp-server/node_modules
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
@@ -80,7 +80,7 @@ Create and manage server-to-server SSH tunnels with automatic reconnection, heal
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**Remote File Manager:**
|
||||
Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly with sudo support.
|
||||
Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly with sudo support. Includes support for moving files from server to server.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<!-- SUMMARY -->
|
||||
|
||||
Bug fixes and new features including host-to-host file transfer, OIDC improvements, UI/UX updates, and numerous stability and security patches.
|
||||
|
||||
<!-- /SUMMARY -->
|
||||
|
||||
<!-- YOUTUBE -->
|
||||
|
||||
https://youtu.be/At8iDk6-Q_s
|
||||
|
||||
<!-- /YOUTUBE -->
|
||||
|
||||
<!-- UPDATE_LOG -->
|
||||
|
||||
- Added in-line buttons on host name row when using click to expand hosts in hosts list
|
||||
- Expose admin_group via OIDC_ADMIN_GROUP env var
|
||||
- Sync appearance preferences
|
||||
- Support native OIDC callbacks
|
||||
- Add portal Desktop DBUS permission for Flatpak URL opening
|
||||
- Restore host password copy
|
||||
- Support for single-host direct tunnels (ssh -L)
|
||||
- Host to host file transfer in file manager
|
||||
- Show ip/username without having to hover over hosts
|
||||
- Updated credential list UI to match UI/UX of host list
|
||||
- Restore rename host folder UI
|
||||
<!-- /UPDATE_LOG -->
|
||||
|
||||
<!-- BUG_FIXES -->
|
||||
|
||||
- Several security vulnerabilities
|
||||
- Allow navigating away from split-view to non-pane tabs
|
||||
- Restore SSH keepalive internal to use 30s to prevent random disconnects
|
||||
- Apply guacamole-lite protocol patch in Docker builds
|
||||
- Show correct icons for network interface types
|
||||
- Resolve sudo password for shared host users
|
||||
- Use jump hosts for online status check and metric collection
|
||||
- Broaden sudo prompt detection for newer distros
|
||||
- Recalculate terminal layout after web fonts load
|
||||
- Improve terminal cwd detection and initial directory command
|
||||
- Decode base64 file content as UTF-8 in file manager
|
||||
- Normalize lazy import default export for iOS compatibility
|
||||
- Prevent RDP display from snapping back after container resize
|
||||
- Removed unused code and fixed PR checks and lint warnings
|
||||
- Send name instruction for protocol >= 1.3.0 (guacd 1.5.0/1.1.0 errors)
|
||||
- Wire up OIDC to password link dialog with submit/visibility
|
||||
- Pass through command completion
|
||||
- Resolve terminal jump hosts server-side
|
||||
- Auto allow SSL certs for private network hosts
|
||||
- Removed deprecated host management button from command palette
|
||||
- Command palette opening wrong protocol
|
||||
- Export/import failing for ssh key hosts
|
||||
- Docker ssh2 native crypto not compiling in Docker
|
||||
- Persisted terminal tabs attempt SSH on RDP hosts afater migration
|
||||
- Credentials not appearing in host manager until refresh
|
||||
<!-- /BUG_FIXES -->
|
||||
+7
-1
@@ -8,7 +8,10 @@ COPY package*.json ./
|
||||
COPY .npmrc ./
|
||||
COPY vendor ./vendor
|
||||
|
||||
COPY scripts/patch-guacamole-lite.cjs ./scripts/
|
||||
|
||||
RUN npm ci --ignore-scripts && \
|
||||
node scripts/patch-guacamole-lite.cjs && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 2: Build frontend
|
||||
@@ -42,8 +45,11 @@ COPY package*.json ./
|
||||
COPY .npmrc ./
|
||||
COPY vendor ./vendor
|
||||
|
||||
COPY scripts/patch-guacamole-lite.cjs ./scripts/
|
||||
|
||||
RUN npm ci --omit=dev --ignore-scripts && \
|
||||
npm rebuild better-sqlite3 bcryptjs && \
|
||||
node scripts/patch-guacamole-lite.cjs && \
|
||||
npm rebuild better-sqlite3 bcryptjs ssh2 && \
|
||||
npm cache clean --force
|
||||
|
||||
# Stage 5: Final optimized image
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ http {
|
||||
|
||||
server {
|
||||
listen ${PORT};
|
||||
server_name localhost;
|
||||
server_name _;
|
||||
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
+92
-23
@@ -353,16 +353,83 @@ function isInsecureModeEnabled() {
|
||||
);
|
||||
}
|
||||
|
||||
function getTlsVerificationOptions() {
|
||||
function getServerConfigPath() {
|
||||
return path.join(app.getPath("userData"), "server-config.json");
|
||||
}
|
||||
|
||||
function getServerConfigSync() {
|
||||
try {
|
||||
const configPath = getServerConfigPath();
|
||||
if (!fs.existsSync(configPath)) return null;
|
||||
return JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getOrigin(url) {
|
||||
try {
|
||||
return new URL(url).origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivateNetworkHost(hostname) {
|
||||
if (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ipv4Match = hostname.match(
|
||||
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
||||
);
|
||||
if (ipv4Match) {
|
||||
const [, a, b] = ipv4Match.map(Number);
|
||||
return (
|
||||
a === 10 ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 169 && b === 254)
|
||||
);
|
||||
}
|
||||
|
||||
if (hostname.startsWith("fc") || hostname.startsWith("fd")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isInvalidCertificateAllowedForUrl(url) {
|
||||
if (isInsecureModeEnabled()) return true;
|
||||
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
if (isPrivateNetworkHost(hostname)) return true;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
const config = getServerConfigSync();
|
||||
if (!config?.allowInvalidCertificate || !config?.serverUrl) return false;
|
||||
|
||||
return getOrigin(url) === getOrigin(config.serverUrl);
|
||||
}
|
||||
|
||||
function getTlsVerificationOptions(url) {
|
||||
return {
|
||||
rejectUnauthorized: !isInsecureModeEnabled(),
|
||||
rejectUnauthorized: !isInvalidCertificateAllowedForUrl(url),
|
||||
};
|
||||
}
|
||||
|
||||
function getWebSocketOptions(url, options = {}) {
|
||||
return {
|
||||
...options,
|
||||
...(String(url).startsWith("wss:") ? getTlsVerificationOptions() : {}),
|
||||
...(String(url).startsWith("wss:") ? getTlsVerificationOptions(url) : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -376,7 +443,7 @@ function httpFetch(url, options = {}) {
|
||||
method: options.method || "GET",
|
||||
headers: options.headers || {},
|
||||
timeout: options.timeout || 10000,
|
||||
...(isHttps ? getTlsVerificationOptions() : {}),
|
||||
...(isHttps ? getTlsVerificationOptions(url) : {}),
|
||||
};
|
||||
|
||||
const req = client.request(url, requestOptions, (res) => {
|
||||
@@ -435,6 +502,25 @@ const electronCacheBuildPath = path.join(
|
||||
);
|
||||
const termixSessionPartition = "persist:termix";
|
||||
|
||||
app.on(
|
||||
"certificate-error",
|
||||
(event, _webContents, url, error, certificate, callback) => {
|
||||
if (isInvalidCertificateAllowedForUrl(url)) {
|
||||
event.preventDefault();
|
||||
logToFile("Allowed invalid certificate for configured server", {
|
||||
url,
|
||||
error,
|
||||
issuer: certificate?.issuerName,
|
||||
subject: certificate?.subjectName,
|
||||
});
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
callback(false);
|
||||
},
|
||||
);
|
||||
|
||||
function getElectronBuildTimestamp() {
|
||||
try {
|
||||
const buildInfo = require("./build-info.cjs");
|
||||
@@ -1153,14 +1239,7 @@ ipcMain.handle(
|
||||
|
||||
ipcMain.handle("get-server-config", () => {
|
||||
try {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const configPath = path.join(userDataPath, "server-config.json");
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
const configData = fs.readFileSync(configPath, "utf8");
|
||||
return JSON.parse(configData);
|
||||
}
|
||||
return null;
|
||||
return getServerConfigSync();
|
||||
} catch (error) {
|
||||
console.error("Error reading server config:", error);
|
||||
return null;
|
||||
@@ -1170,7 +1249,7 @@ ipcMain.handle("get-server-config", () => {
|
||||
ipcMain.handle("save-server-config", (event, config) => {
|
||||
try {
|
||||
const userDataPath = app.getPath("userData");
|
||||
const configPath = path.join(userDataPath, "server-config.json");
|
||||
const configPath = getServerConfigPath();
|
||||
|
||||
if (!fs.existsSync(userDataPath)) {
|
||||
fs.mkdirSync(userDataPath, { recursive: true });
|
||||
@@ -1319,16 +1398,6 @@ const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
|
||||
const C2S_WS_LOW_WATERMARK = 256 * 1024;
|
||||
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
|
||||
|
||||
function getServerConfigSync() {
|
||||
try {
|
||||
const configPath = path.join(app.getPath("userData"), "server-config.json");
|
||||
if (!fs.existsSync(configPath)) return null;
|
||||
return JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getC2SRelayUrl() {
|
||||
const config = getServerConfigSync();
|
||||
const serverUrl =
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import tseslint from "typescript-eslint";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(["dist", "release", "Mobile"]),
|
||||
globalIgnores(["dist", "release", "Mobile", "src/mcp-server/node_modules"]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
|
||||
@@ -18,6 +18,7 @@ finish-args:
|
||||
- --socket=ssh-auth
|
||||
- --socket=session-bus
|
||||
- --talk-name=org.freedesktop.secrets
|
||||
- --talk-name=org.freedesktop.portal.Desktop
|
||||
- --env=ELECTRON_TRASH=gio
|
||||
- --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons
|
||||
- --env=ELECTRON_OZONE_PLATFORM_HINT=auto
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@latest/schema.json",
|
||||
"entry": [
|
||||
"src/backend/starter.ts",
|
||||
"src/backend/swagger.ts",
|
||||
"scripts/**/*.cjs"
|
||||
],
|
||||
"project": ["src/**/*.{ts,tsx}", "electron/**/*.cjs", "scripts/**/*.cjs"],
|
||||
"ignoreBinaries": ["powershell"],
|
||||
"ignoreDependencies": [
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/search",
|
||||
"@codemirror/theme-one-dark",
|
||||
"@codemirror/view",
|
||||
"@electron/notarize",
|
||||
"@monaco-editor/react",
|
||||
"@radix-ui/react-accordion",
|
||||
"@radix-ui/react-alert-dialog",
|
||||
"@radix-ui/react-checkbox",
|
||||
"@radix-ui/react-dialog",
|
||||
"@radix-ui/react-dropdown-menu",
|
||||
"@radix-ui/react-label",
|
||||
"@radix-ui/react-popover",
|
||||
"@radix-ui/react-progress",
|
||||
"@radix-ui/react-scroll-area",
|
||||
"@radix-ui/react-select",
|
||||
"@radix-ui/react-separator",
|
||||
"@radix-ui/react-slider",
|
||||
"@radix-ui/react-slot",
|
||||
"@radix-ui/react-switch",
|
||||
"@radix-ui/react-tabs",
|
||||
"@radix-ui/react-tooltip",
|
||||
"@types/better-sqlite3",
|
||||
"@types/cookie-parser",
|
||||
"@types/cors",
|
||||
"@types/express",
|
||||
"@types/guacamole-common-js",
|
||||
"@types/js-yaml",
|
||||
"@types/jsonwebtoken",
|
||||
"@types/multer",
|
||||
"@types/qrcode",
|
||||
"@types/speakeasy",
|
||||
"@types/ssh2",
|
||||
"@uiw/codemirror-extensions-langs",
|
||||
"@uiw/codemirror-theme-github",
|
||||
"@uiw/react-codemirror",
|
||||
"@xterm/addon-clipboard",
|
||||
"@xterm/addon-fit",
|
||||
"@xterm/addon-unicode11",
|
||||
"@xterm/addon-web-links",
|
||||
"@xterm/xterm",
|
||||
"axios",
|
||||
"bcryptjs",
|
||||
"better-sqlite3",
|
||||
"body-parser",
|
||||
"chalk",
|
||||
"class-variance-authority",
|
||||
"clsx",
|
||||
"cmdk",
|
||||
"cookie-parser",
|
||||
"cors",
|
||||
"cytoscape",
|
||||
"drizzle-orm",
|
||||
"express",
|
||||
"guacamole-lite",
|
||||
"guacamole-common-js",
|
||||
"jose",
|
||||
"js-yaml",
|
||||
"jsonwebtoken",
|
||||
"jszip",
|
||||
"multer",
|
||||
"nanoid",
|
||||
"qrcode",
|
||||
"i18next-browser-languagedetector",
|
||||
"lucide-react",
|
||||
"motion",
|
||||
"radix-ui",
|
||||
"react-cytoscapejs",
|
||||
"react-h5-audio-player",
|
||||
"react-hook-form",
|
||||
"react-icons",
|
||||
"react-markdown",
|
||||
"react-pdf",
|
||||
"react-photo-view",
|
||||
"react-syntax-highlighter",
|
||||
"react-xtermjs",
|
||||
"remark-gfm",
|
||||
"socks",
|
||||
"speakeasy",
|
||||
"ssh2",
|
||||
"undici",
|
||||
"husky",
|
||||
"sharp",
|
||||
"sonner",
|
||||
"tailwind-merge"
|
||||
]
|
||||
}
|
||||
Generated
+1350
-103
File diff suppressed because it is too large
Load Diff
+14
-5
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"private": true,
|
||||
"version": "2.3.1",
|
||||
"version": "2.3.2",
|
||||
"description": "Self-hosted SSH and remote desktop management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -17,6 +17,10 @@
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"dev": "vite",
|
||||
"build": "vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\"",
|
||||
"build:backend": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\"",
|
||||
@@ -37,7 +41,6 @@
|
||||
"build:mac-dev": "npm run build && npm run electron:rebuild && npm run electron:patch-builder && electron-builder --mac dir --publish=never"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.26",
|
||||
"axios": "^1.15.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
@@ -49,7 +52,6 @@
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^5.2.1",
|
||||
"guacamole-lite": "^1.2.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"jose": "^6.2.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
@@ -61,7 +63,6 @@
|
||||
"socks": "^2.8.7",
|
||||
"speakeasy": "^2.0.0",
|
||||
"ssh2": "^1.17.0",
|
||||
"swagger-jsdoc": "^6.3.0",
|
||||
"undici": "^7.0.0",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
@@ -96,6 +97,10 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.19",
|
||||
@@ -115,6 +120,8 @@
|
||||
"@uiw/codemirror-theme-github": "^4.25.9",
|
||||
"@uiw/react-codemirror": "^4.25.9",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@vitest/ui": "^4.1.8",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
@@ -136,6 +143,7 @@
|
||||
"husky": "^9.1.7",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^17.0.5",
|
||||
"lucide-react": "^1.11.0",
|
||||
"prettier": "3.8.3",
|
||||
@@ -161,7 +169,8 @@
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.0",
|
||||
"vite": "^8.0.13",
|
||||
"vite-plugin-svgr": "^5.2.0"
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx}": [
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Host-to-host file transfer
|
||||
|
||||
This document describes the host-to-host copy/move feature. It is intended for operators and contributors. Direct host-to-host routing via SSH tunnels is **not implemented**; that is documented under [Future work](#future-work-direct-routing-via-tunnels).
|
||||
|
||||
## Overview
|
||||
|
||||
Host-to-host transfer copies or moves files from one SSH host to another through the **Termix server** as a relay. The UI lives in **File Manager**: right-click files or folders and choose **Copy to host…** or **Move to host…**.
|
||||
|
||||
Compared to copying via your laptop (`scp -3` or `ssh one 'cat …' | ssh two 'cat …'`), Termix keeps the job on the server so transfers continue if you close the browser, and you get integrated progress, cancel, retry, and cleanup.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Browser[Browser_UI]
|
||||
Termix[Termix_server]
|
||||
Src[Source_host]
|
||||
Dst[Destination_host]
|
||||
|
||||
Browser -->|start_transfer_API| Termix
|
||||
Termix -->|dedicated_SSH_xfer_src| Src
|
||||
Termix -->|dedicated_SSH_xfer_dst| Dst
|
||||
Termix -->|SFTP_read_then_write| Termix
|
||||
```
|
||||
|
||||
Data for remote-to-remote paths **always passes through the Termix process** (pipelined SFTP buffers, or a tar archive stream). There is no source→destination SSH tunnel for file bytes today.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Two hosts** with File Manager enabled, saved in Host Manager.
|
||||
2. **Both hosts reachable from the Termix server** on SSH (each host’s jump hosts and SOCKS5 proxy chain apply to the **Termix→host** connection, not host→host).
|
||||
3. **File Manager open** on the source host (browse session connected). The destination host should show **Ready** in the transfer dialog; if it shows authentication required, open File Manager on that host once first.
|
||||
4. For **multi-file or folder** transfers, pick a **destination directory** (not a file path).
|
||||
|
||||
The dialog shows a reminder: _“Both hosts must be reachable from the Termix server. Direct host-to-host routing is not supported.”_
|
||||
|
||||
## Using the UI
|
||||
|
||||
### Start a transfer
|
||||
|
||||
1. Open File Manager on the **source** host.
|
||||
2. Select one or more files or folders.
|
||||
3. Right-click → **Copy to host…** or **Move to host…** (sidebar tree supports the same context menu).
|
||||
4. In the dialog:
|
||||
- Choose **destination host** (other connected file-manager hosts).
|
||||
- Set **destination path** (type a path or use **Browse destination folders**).
|
||||
- Optionally pick a **recent destination** (collapsed by default).
|
||||
- For multi-item transfers, choose **transfer method** (Auto / Tar archive / Per-file SFTP); a preview explains what Auto will pick.
|
||||
- Pin folders with **Add to shortcuts** (per-destination host; uses normal file-manager shortcuts, not a separate favourites list).
|
||||
5. Confirm **Copy** or **Move**.
|
||||
|
||||
### During transfer
|
||||
|
||||
- A **progress toast** shows phase (compressing, transferring, extracting), bytes, speed, and **Cancel**.
|
||||
- **Transfer monitor** (desktop, when authenticated) picks up active transfers started in another tab or window.
|
||||
- Large **single files** use segmented SFTP (256 MiB segments) with **2 parallel lanes** by default (separate dedicated SSH session pairs per lane). The toast can show total speed and lane count when multiple lanes are active.
|
||||
|
||||
### After completion or failure
|
||||
|
||||
| Outcome | What happens |
|
||||
| ------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| **Success** | Toast success; destination path saved to **recent destinations** for that source host; file manager can refresh. |
|
||||
| **Partial** | Some paths failed; toast lists failed paths; source may be kept on move. |
|
||||
| **Error** | Toast with **Retry** when partial data on destination allows resume. |
|
||||
| **Cancelled** | Toast with optional **Clean up destination** to remove partial files. |
|
||||
|
||||
**Move** deletes source files only after a successful full transfer (same as copy, then source delete). Cancelled or partial moves may leave files on both sides.
|
||||
|
||||
### Metrics
|
||||
|
||||
On success, expanded timing details can include: prepare destination, compress (tar), per-hop throughput (source→server, server→dest), extract, source delete, total duration.
|
||||
|
||||
## Transfer methods
|
||||
|
||||
| Method | When used | Behavior |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| **Stream (single file)** | Exactly one file, copy or move | Pipelined SFTP read on source → write on destination; segmented above 32 MiB; parallel lanes for throughput. |
|
||||
| **Tar archive** | Multi-file/folder when Auto or user selects Tar, and both sides are Unix with `tar` | `tar -czf` on source → one archive streamed through Termix → `tar -xzf` on destination. |
|
||||
| **Per-file SFTP** | Windows involved, tar unavailable, or Auto chooses it | Each file copied sequentially over SFTP through Termix. |
|
||||
|
||||
**Auto** heuristics (see `src/backend/ssh/transfer-routing.ts`) consider file count, total size, largest file, and compressibility (e.g. many small files → tar; large incompressible sets → per-file SFTP).
|
||||
|
||||
**Method preview** is locked for the current source path set until you change Auto/Tar/Per-file preference, so changing only the destination host does not re-scan the source.
|
||||
|
||||
## Architecture (implementation)
|
||||
|
||||
| Component | Role |
|
||||
| ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| [`src/backend/ssh/host-transfer.ts`](../src/backend/ssh/host-transfer.ts) | Transfer engine: sessions, SFTP pipeline, tar path, retry, cancel, progress. |
|
||||
| [`src/backend/ssh/file-manager.ts`](../src/backend/ssh/file-manager.ts) | HTTP routes, `openDedicatedTransferSession`, jump/SOCKS connect. |
|
||||
| [`src/backend/ssh/transfer-routing.ts`](../src/backend/ssh/transfer-routing.ts) | Tar vs per-file SFTP selection. |
|
||||
| [`src/backend/ssh/transfer-paths.ts`](../src/backend/ssh/transfer-paths.ts) | Path normalization (Unix/Windows). |
|
||||
| [`src/ui/.../TransferToHostDialog.tsx`](../src/ui/desktop/apps/features/file-manager/components/TransferToHostDialog.tsx) | Transfer dialog. |
|
||||
| [`src/ui/.../transferProgressMonitor.tsx`](../src/ui/desktop/apps/features/file-manager/transferProgressMonitor.tsx) | Toasts, cancel, retry, cleanup. |
|
||||
| [`src/ui/.../TransferMonitor.tsx`](../src/ui/desktop/apps/features/file-manager/TransferMonitor.tsx) | Global active-transfer polling. |
|
||||
|
||||
**Sessions:** Browse sessions identify hosts. Each transfer opens **dedicated** SSH sessions (`xfer:{transferId}:src` / `:dst`) so browsing and transfers do not share channels. Parallel lanes add `xfer:{transferId}:src:pN` / `:dst:pN`.
|
||||
|
||||
**Special case:** If the destination host is the **same machine as Termix** (local SSH endpoint), writes use the local filesystem via `fastGet` instead of dest SFTP; data still originates from the remote source through Termix.
|
||||
|
||||
**Persistence:** Recent destinations are stored in `transfer_recent` (per user, per source host). Folder shortcuts use `file_manager_shortcuts` on the destination host.
|
||||
|
||||
## HTTP API (file manager service)
|
||||
|
||||
| Endpoint | Purpose |
|
||||
| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `POST /ssh/file_manager/ssh/transferMethodPreview` | Scan source; return resolved tar vs item_sftp and reason. |
|
||||
| `POST /ssh/file_manager/ssh/transferToHost` | Start transfer; body includes `sourceSessionId`, `destSessionId`, `sourcePaths`, `destPath`, `move`, `methodPreference`, optional `parallelSegmentCount` (1–8, default 2). |
|
||||
| `GET /ssh/file_manager/ssh/transferStatus/:transferId` | Poll progress. |
|
||||
| `GET /ssh/file_manager/ssh/activeTransfers` | List running transfers for user. |
|
||||
| `POST /ssh/file_manager/ssh/transferCancel/:transferId` | Request cancel. |
|
||||
| `POST /ssh/file_manager/ssh/transferCleanup/:transferId` | Remove partial destination artifacts after cancel/failure. |
|
||||
| `POST /ssh/file_manager/ssh/transferRetry/:transferId` | Retry with same snapshot (resume when possible). |
|
||||
|
||||
Database (main API): `GET/POST /host/transfer/recent` for recent destinations.
|
||||
|
||||
## Reliability features
|
||||
|
||||
- **Resume:** Destination file size is probed; SFTP write opens with resume when a partial file exists (per segment on large files).
|
||||
- **Retry:** Reconnects dedicated sessions; segment-level and full-copy retries with backoff; fresh SSH pairs after repeated failures (lane reset).
|
||||
- **Stall detection:** ~45 s without progress on a segment; hung transfer/reconnect probing on status polls.
|
||||
- **Cancel:** Aborts in-flight SFTP; user can clean up destination paths that were created or partially written.
|
||||
- **Overlap guard:** Refuses transfer when source and destination paths overlap in a destructive way.
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **No direct host-to-host data path** — Termix must reach **both** hosts independently (with each host’s jump/proxy settings).
|
||||
2. **Not the same as S2S SSH tunnels** — Tunnels in Host Manager forward TCP ports; they do not carry file-manager transfers today.
|
||||
3. **Throughput** — Remote-to-remote speed is bounded by Termix CPU/RAM and min(Termix↔source, Termix↔dest) links; very large files on a small Termix box may be slower than `scp -3` from a powerful desktop.
|
||||
4. **Parallel lanes** — Writes are out of order on disk; fine for copy, not for playing media from a partially written file. Default is 2 lanes; UI may not expose lane count (API default applies).
|
||||
5. **Tar** — Requires `tar` on both Unix hosts; temporary archive under `/tmp` on source during transfer.
|
||||
6. **Windows** — Tar path disabled; per-file SFTP only for Windows endpoints.
|
||||
7. **Jump hosts on S2S tunnels** — Server-to-server **tunnel** connect does not use jump hosts; only transfer/browse SSH does. A host reachable only via jump may work for transfer but not as an S2S tunnel source until tunnel code is aligned.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Things to check |
|
||||
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| No destination hosts listed | Open File Manager on another host; ensure host has File Manager enabled. |
|
||||
| Destination “Authentication required” | Connect File Manager on that host once in this session. |
|
||||
| Transfer fails immediately | SSH from Termix to both hosts (firewall, jump host, SOCKS5, credentials). |
|
||||
| Slow speed | Termix link to slower side; try off-peak; for single huge files, parallel lanes help if CPU/network allow. |
|
||||
| Stuck progress | Wait for stall/reconnect; cancel and retry; check server logs for `host_transfer` / `transfer_ssh_*`. |
|
||||
| Partial files after cancel | Use **Clean up destination** in the toast. |
|
||||
| 28 GB / 25 GB style progress | Usually parallel progress accounting; status polls use destination size probes. |
|
||||
|
||||
## Comparison to manual `scp` between remotes
|
||||
|
||||
See [Unix & Linux: scp from one remote server to another](https://unix.stackexchange.com/questions/85292/scp-from-one-remote-server-to-another-remote-server). Naive `scp one:file two:file` runs **from the first host** and fails unless that host can SSH to the second. `scp -3` relays through your workstation. **Termix relay** is analogous to `scp -3` through the **Termix server**, with richer lifecycle management, not analogous to direct `ssh source 'scp … dest'`.
|
||||
|
||||
---
|
||||
|
||||
## Future work: direct routing via tunnels
|
||||
|
||||
The following are **planned / discussed** enhancements, not shipped in the current build. They build on existing **S2S SSH tunnels** (`src/backend/ssh/tunnel.ts`), which already connect **source → endpoint** using `forwardOut` from the source host to the endpoint’s SSH port.
|
||||
|
||||
### Why tunnels matter
|
||||
|
||||
Many homelabs have a destination that is **only reachable from another host** (e.g. NAS on LAN behind a Pi), while Termix runs elsewhere. Today that destination cannot receive a dedicated `xfer:dst` session from Termix even if an S2S tunnel from Pi → NAS is configured and working.
|
||||
|
||||
### Possible routes (future)
|
||||
|
||||
| Route | Termix SSH legs | Data path | Benefit vs today |
|
||||
| --------------------------------------- | --------------- | --------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| **Relay (current)** | 2 | Termix buffers SFTP | Works when both hosts reachable from Termix. |
|
||||
| **Tunnel-bridged SFTP** | 1 (+ bridge) | Still through Termix memory | Dest reached via source `forwardOut`; fixes reachability; reuses most of current engine. |
|
||||
| **Direct remote (rsync/scp on source)** | 1 (control) | **Source → dest** bytes | Best throughput; Termix orchestrates `rsync`/`scp` on source when forward + tools allow. |
|
||||
|
||||
### Integration ideas (not implemented)
|
||||
|
||||
1. **`transfer-bridge` module** — Shared `forwardOut` probe and `connectDestThroughSource` (extracted from tunnel code); lookup matching `tunnel_connections` on the source host record.
|
||||
2. **Route resolver** — Auto-select relay vs bridged vs direct; expose route in method preview (“via Termix” vs “direct host-to-host”).
|
||||
3. **Reuse active S2S tunnel** — If Host Manager tunnel source→dest is already connected, reuse `endpointClient` instead of opening a second bridge.
|
||||
4. **Jump hosts on S2S tunnel source** — Align tunnel connect with file-manager jump chains so tunnel and transfer eligibility match.
|
||||
5. **Fallback** — Always fall back to current relay when probe or remote `rsync` fails (Windows, missing tools, forwarding denied).
|
||||
|
||||
### What would be preserved
|
||||
|
||||
Cancel, partial cleanup, retry/resume (rsync `--partial` on direct path; existing SFTP segment resume on relay/bridged), dedicated sessions, transfer monitor, recent destinations, folder shortcuts, and tar/per-file method selection (with direct-path variants for multi-file).
|
||||
|
||||
### References
|
||||
|
||||
- Internal plan: `.cursor/plans/host-to-host_direct_transfer_*.plan.md` (if present in your checkout).
|
||||
- Tunnel implementation: [`src/backend/ssh/tunnel.ts`](../src/backend/ssh/tunnel.ts) — `connectEndpointThroughSource`, `establishManagedS2STunnel`.
|
||||
- Transfer engine: [`src/backend/ssh/host-transfer.ts`](../src/backend/ssh/host-transfer.ts).
|
||||
|
||||
---
|
||||
@@ -0,0 +1,121 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg.startsWith("--")) {
|
||||
const key = arg.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined || next.startsWith("--")) {
|
||||
args[key] = true;
|
||||
} else {
|
||||
args[key] = next;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`generate-release-body: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function extractSection(notes, name) {
|
||||
const pattern = new RegExp(
|
||||
`<!--\\s*${name}\\s*-->([\\s\\S]*?)<!--\\s*/${name}\\s*-->`,
|
||||
);
|
||||
const match = notes.match(pattern);
|
||||
if (!match) {
|
||||
fail(`missing <!-- ${name} --> section in release notes`);
|
||||
}
|
||||
const value = match[1].trim();
|
||||
if (!value) {
|
||||
fail(`empty <!-- ${name} --> section in release notes`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function youtubeId(raw) {
|
||||
const value = raw.trim();
|
||||
let match = value.match(/[?&]v=([A-Za-z0-9_-]+)/);
|
||||
if (match) return match[1];
|
||||
match = value.match(/youtu\.be\/([A-Za-z0-9_-]+)/);
|
||||
if (match) return match[1];
|
||||
match = value.match(/embed\/([A-Za-z0-9_-]+)/);
|
||||
if (match) return match[1];
|
||||
if (/^[A-Za-z0-9_-]+$/.test(value)) return value;
|
||||
fail(`could not parse a YouTube video id from "${value}"`);
|
||||
}
|
||||
|
||||
function buildTable(version, mobileVersion) {
|
||||
const tag = `release-${version}-tag`;
|
||||
const base = `https://github.com/Termix-SSH/Termix/releases/download/${tag}`;
|
||||
const mobileBase = `https://github.com/Termix-SSH/Mobile/releases/download/release-${mobileVersion}-tag`;
|
||||
|
||||
const win = (file) => `${base}/${file}`;
|
||||
const linux = (file) => `${base}/${file}`;
|
||||
const mac = (file) => `${base}/${file}`;
|
||||
|
||||
return [
|
||||
`| Architecture | Windows | Linux | Mac | Android | iOS |`,
|
||||
`|------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|---------------------------------------------|-----------------------------------|`,
|
||||
`| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | — | — |`,
|
||||
`| **AArch64 (ARM64)** | — | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
|
||||
`| **ARMv7 (32-bit)** | — | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | — | — | — |`,
|
||||
`| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | — | — | — | — |`,
|
||||
`| **Universal** | [Chocolatey](https://docs.termix.site/install/connector/windows) | [Flatpak](https://docs.termix.site/install/connector/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/connector/macos) | — | — |`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const version = args.version;
|
||||
const mobileVersion = args["mobile-version"];
|
||||
const notesPath = args.notes || "RELEASE_NOTES.md";
|
||||
|
||||
if (!version || version === true) fail("--version is required");
|
||||
if (!mobileVersion || mobileVersion === true)
|
||||
fail("--mobile-version is required");
|
||||
|
||||
const resolvedNotes = path.resolve(notesPath);
|
||||
if (!fs.existsSync(resolvedNotes)) {
|
||||
fail(`release notes file not found: ${resolvedNotes}`);
|
||||
}
|
||||
|
||||
const notes = fs.readFileSync(resolvedNotes, "utf8");
|
||||
const summary = extractSection(notes, "SUMMARY");
|
||||
const youtube = extractSection(notes, "YOUTUBE");
|
||||
const updateLog = extractSection(notes, "UPDATE_LOG");
|
||||
const bugFixes = extractSection(notes, "BUG_FIXES");
|
||||
|
||||
const videoId = youtubeId(youtube);
|
||||
const embed = [
|
||||
`<a href="https://youtu.be/${videoId}">`,
|
||||
` <img src="./repo-images/YouTube.png" alt="YouTube" width="500">`,
|
||||
`</a>`,
|
||||
].join("\n");
|
||||
|
||||
const table = buildTable(version, mobileVersion);
|
||||
|
||||
const body = [
|
||||
summary,
|
||||
"",
|
||||
embed,
|
||||
"",
|
||||
table,
|
||||
"",
|
||||
"Update Log:",
|
||||
updateLog,
|
||||
"",
|
||||
"Bug Fixes:",
|
||||
bugFixes,
|
||||
].join("\n");
|
||||
|
||||
process.stdout.write(body + "\n");
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -26,11 +26,28 @@ const newVersionCheck =
|
||||
const oldTimezone = "if (protocolVersion === '1_1_0') {";
|
||||
const newTimezone = "if (protocolVersion !== '1_0_0') {";
|
||||
|
||||
// Patch 3: send the `name` handshake instruction for protocol >= 1.3.0.
|
||||
// The Guacamole protocol added the `name` instruction in 1.3.0 (an optional
|
||||
// human-readable identifier for the joining user). guacd 1.6.0 began requiring
|
||||
// it during the VNC handshake even when negotiating older protocol versions,
|
||||
// causing connections to silently drop right after "User joined". See
|
||||
// Termix-SSH/Support#567 and #734.
|
||||
const oldConnect =
|
||||
" this.sendInstruction(['connect'].concat(connectArgs));";
|
||||
const newConnect =
|
||||
" if (protocolVersion === '1_3_0' || protocolVersion === '1_5_0') {\n" +
|
||||
" this.sendInstruction(['name', this.connectionSettings.name || 'guacamole-lite']);\n" +
|
||||
" }\n" +
|
||||
"\n" +
|
||||
" this.sendInstruction(['connect'].concat(connectArgs));";
|
||||
|
||||
let patched = false;
|
||||
|
||||
if (!content.includes(newVersionCheck)) {
|
||||
if (!content.includes(oldVersionCheck)) {
|
||||
console.log("[patch-guacamole-lite] Version check target not found, skipping");
|
||||
console.log(
|
||||
"[patch-guacamole-lite] Version check target not found, skipping",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
content = content.replace(oldVersionCheck, newVersionCheck);
|
||||
@@ -46,6 +63,17 @@ if (!content.includes(newTimezone)) {
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!content.includes(newConnect)) {
|
||||
if (!content.includes(oldConnect)) {
|
||||
console.log(
|
||||
"[patch-guacamole-lite] Connect target not found, skipping name patch",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
content = content.replace(oldConnect, newConnect);
|
||||
patched = true;
|
||||
}
|
||||
|
||||
if (!patched) {
|
||||
console.log("[patch-guacamole-lite] Already patched");
|
||||
process.exit(0);
|
||||
@@ -53,5 +81,5 @@ if (!patched) {
|
||||
|
||||
fs.writeFileSync(filePath, content);
|
||||
console.log(
|
||||
"[patch-guacamole-lite] Patched to support protocol VERSION_1_3_0 and VERSION_1_5_0 with correct timezone handshake",
|
||||
"[patch-guacamole-lite] Patched to support protocol VERSION_1_3_0 and VERSION_1_5_0 with name handshake instruction",
|
||||
);
|
||||
|
||||
@@ -809,6 +809,16 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE transfer_recent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
source_host_id INTEGER NOT NULL,
|
||||
dest_host_id INTEGER NOT NULL,
|
||||
dest_path TEXT NOT NULL,
|
||||
dest_path_label TEXT NOT NULL,
|
||||
last_used TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE dismissed_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -1063,6 +1073,12 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
VALUES (?, ?)
|
||||
`);
|
||||
for (const setting of settingsData) {
|
||||
if (
|
||||
setting.key.startsWith("reset_code_") ||
|
||||
setting.key.startsWith("temp_reset_token_")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
insertSetting.run(setting.key, setting.value);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -268,6 +268,19 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transfer_recent (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
source_host_id INTEGER NOT NULL,
|
||||
dest_host_id INTEGER NOT NULL,
|
||||
dest_path TEXT NOT NULL,
|
||||
dest_path_label TEXT NOT NULL,
|
||||
last_used TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (source_host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (dest_host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dismissed_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -467,6 +480,10 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
reopen_tabs_on_login INTEGER NOT NULL DEFAULT 0,
|
||||
theme TEXT,
|
||||
font_size TEXT,
|
||||
accent_color TEXT,
|
||||
language TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -563,11 +580,12 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||
.get();
|
||||
if (!row) {
|
||||
const defaultGuacUrl = `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`;
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('guac_url', 'guacd:4822')",
|
||||
"INSERT INTO settings (key, value) VALUES ('guac_url', ?)",
|
||||
)
|
||||
.run();
|
||||
.run(defaultGuacUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize guac_url setting", {
|
||||
@@ -605,6 +623,11 @@ const addColumnIfNotExists = (
|
||||
};
|
||||
|
||||
const migrateSchema = () => {
|
||||
addColumnIfNotExists("user_preferences", "theme", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "language", "TEXT");
|
||||
|
||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
@@ -226,6 +226,24 @@ export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const transferRecent = sqliteTable("transfer_recent", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
sourceHostId: integer("source_host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
destHostId: integer("dest_host_id")
|
||||
.notNull()
|
||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
||||
destPath: text("dest_path").notNull(),
|
||||
destPathLabel: text("dest_path_label").notNull(),
|
||||
lastUsed: text("last_used")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const dismissedAlerts = sqliteTable("dismissed_alerts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
@@ -658,6 +676,10 @@ export const userPreferences = sqliteTable("user_preferences", {
|
||||
reopenTabsOnLogin: integer("reopen_tabs_on_login", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
theme: text("theme"),
|
||||
fontSize: text("font_size"),
|
||||
accentColor: text("accent_color"),
|
||||
language: text("language"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
import type {
|
||||
AuthenticatedRequest,
|
||||
CredentialBackend,
|
||||
} from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import ssh2Pkg from "ssh2";
|
||||
import { db } from "../db/index.js";
|
||||
import { hosts, sshCredentials } from "../db/schema.js";
|
||||
|
||||
const { Client } = ssh2Pkg;
|
||||
|
||||
async function deploySSHKeyToHost(
|
||||
hostConfig: Record<string, unknown>,
|
||||
credData: CredentialBackend,
|
||||
): Promise<{ success: boolean; message?: string; error?: string }> {
|
||||
const publicKey = credData.publicKey as string;
|
||||
return new Promise((resolve) => {
|
||||
const conn = new Client();
|
||||
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
conn.destroy();
|
||||
resolve({ success: false, error: "Connection timeout" });
|
||||
}, 120000);
|
||||
|
||||
conn.on("ready", async () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolveCmd, rejectCmd) => {
|
||||
const cmdTimeout = setTimeout(() => {
|
||||
rejectCmd(new Error("mkdir command timeout"));
|
||||
}, 10000);
|
||||
|
||||
conn.exec(
|
||||
"test -d ~/.ssh || mkdir -p ~/.ssh; chmod 700 ~/.ssh",
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(cmdTimeout);
|
||||
return rejectCmd(err);
|
||||
}
|
||||
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(cmdTimeout);
|
||||
if (code === 0) {
|
||||
resolveCmd();
|
||||
} else {
|
||||
rejectCmd(
|
||||
new Error(`mkdir command failed with code ${code}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("data", () => {
|
||||
// Ignore output
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const keyExists = await new Promise<boolean>(
|
||||
(resolveCheck, rejectCheck) => {
|
||||
const checkTimeout = setTimeout(() => {
|
||||
rejectCheck(new Error("Key check timeout"));
|
||||
}, 5000);
|
||||
|
||||
let actualPublicKey = publicKey;
|
||||
try {
|
||||
const parsed = JSON.parse(publicKey);
|
||||
if (parsed.data) {
|
||||
actualPublicKey = parsed.data;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
const keyParts = actualPublicKey.trim().split(" ");
|
||||
if (keyParts.length < 2) {
|
||||
clearTimeout(checkTimeout);
|
||||
return rejectCheck(
|
||||
new Error(
|
||||
"Invalid public key format - must contain at least 2 parts",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const keyPattern = keyParts[1];
|
||||
|
||||
conn.exec(
|
||||
`if [ -f ~/.ssh/authorized_keys ]; then grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?; else echo 1; fi`,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(checkTimeout);
|
||||
return rejectCheck(err);
|
||||
}
|
||||
|
||||
let output = "";
|
||||
stream.on("data", (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
clearTimeout(checkTimeout);
|
||||
const exists = output.trim() === "0";
|
||||
resolveCheck(exists);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (keyExists) {
|
||||
conn.end();
|
||||
resolve({ success: true, message: "SSH key already deployed" });
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolveAdd, rejectAdd) => {
|
||||
const addTimeout = setTimeout(() => {
|
||||
rejectAdd(new Error("Key add timeout"));
|
||||
}, 30000);
|
||||
|
||||
let actualPublicKey = publicKey;
|
||||
try {
|
||||
const parsed = JSON.parse(publicKey);
|
||||
if (parsed.data) {
|
||||
actualPublicKey = parsed.data;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
const escapedKey = actualPublicKey
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/'/g, "'\\''");
|
||||
const escapedName = credData.name
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/'/g, "'\\''");
|
||||
|
||||
conn.exec(
|
||||
`printf '%s\n' '${escapedKey} ${escapedName}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(addTimeout);
|
||||
return rejectAdd(err);
|
||||
}
|
||||
|
||||
stream.on("data", () => {
|
||||
// Consume output
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(addTimeout);
|
||||
if (code === 0) {
|
||||
resolveAdd();
|
||||
} else {
|
||||
rejectAdd(
|
||||
new Error(`Key deployment failed with code ${code}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const verifySuccess = await new Promise<boolean>(
|
||||
(resolveVerify, rejectVerify) => {
|
||||
const verifyTimeout = setTimeout(() => {
|
||||
rejectVerify(new Error("Key verification timeout"));
|
||||
}, 5000);
|
||||
|
||||
let actualPublicKey = publicKey;
|
||||
try {
|
||||
const parsed = JSON.parse(publicKey);
|
||||
if (parsed.data) {
|
||||
actualPublicKey = parsed.data;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
const keyParts = actualPublicKey.trim().split(" ");
|
||||
if (keyParts.length < 2) {
|
||||
clearTimeout(verifyTimeout);
|
||||
return rejectVerify(
|
||||
new Error(
|
||||
"Invalid public key format - must contain at least 2 parts",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const keyPattern = keyParts[1];
|
||||
conn.exec(
|
||||
`grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?`,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(verifyTimeout);
|
||||
return rejectVerify(err);
|
||||
}
|
||||
|
||||
let output = "";
|
||||
stream.on("data", (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
clearTimeout(verifyTimeout);
|
||||
const verified = output.trim() === "0";
|
||||
resolveVerify(verified);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
conn.end();
|
||||
|
||||
if (verifySuccess) {
|
||||
resolve({ success: true, message: "SSH key deployed successfully" });
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
error: "Key deployment verification failed",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
conn.end();
|
||||
resolve({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Deployment failed",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
conn.on("error", (err) => {
|
||||
clearTimeout(connectionTimeout);
|
||||
let errorMessage = err.message;
|
||||
|
||||
if (
|
||||
err.message.includes("All configured authentication methods failed")
|
||||
) {
|
||||
errorMessage =
|
||||
"Authentication failed. Please check your credentials and ensure the SSH service is running.";
|
||||
} else if (
|
||||
err.message.includes("ENOTFOUND") ||
|
||||
err.message.includes("ENOENT")
|
||||
) {
|
||||
errorMessage = "Could not resolve hostname or connect to server.";
|
||||
} else if (err.message.includes("ECONNREFUSED")) {
|
||||
errorMessage =
|
||||
"Connection refused. The server may not be running or the port may be incorrect.";
|
||||
} else if (err.message.includes("ETIMEDOUT")) {
|
||||
errorMessage =
|
||||
"Connection timed out. Check your network connection and server availability.";
|
||||
} else if (
|
||||
err.message.includes("authentication failed") ||
|
||||
err.message.includes("Permission denied")
|
||||
) {
|
||||
errorMessage =
|
||||
"Authentication failed. Please check your username and password/key.";
|
||||
}
|
||||
|
||||
resolve({ success: false, error: errorMessage });
|
||||
});
|
||||
|
||||
try {
|
||||
const connectionConfig: Record<string, unknown> = {
|
||||
host: hostConfig.ip,
|
||||
port: hostConfig.port || 22,
|
||||
username: hostConfig.username,
|
||||
readyTimeout: 60000,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"ecdh-sha2-nistp256",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp521",
|
||||
],
|
||||
cipher: [
|
||||
"aes128-ctr",
|
||||
"aes192-ctr",
|
||||
"aes256-ctr",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-cbc",
|
||||
"aes192-cbc",
|
||||
"aes256-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: [
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
|
||||
if (hostConfig.authType === "password" && hostConfig.password) {
|
||||
connectionConfig.password = hostConfig.password;
|
||||
} else if (hostConfig.authType === "key" && hostConfig.privateKey) {
|
||||
try {
|
||||
const privateKey = hostConfig.privateKey as string;
|
||||
if (
|
||||
!privateKey.includes("-----BEGIN") ||
|
||||
!privateKey.includes("-----END")
|
||||
) {
|
||||
throw new Error("Invalid private key format");
|
||||
}
|
||||
|
||||
const cleanKey = privateKey
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
|
||||
connectionConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
|
||||
if (hostConfig.keyPassword) {
|
||||
connectionConfig.passphrase = hostConfig.keyPassword;
|
||||
}
|
||||
} catch (keyError) {
|
||||
clearTimeout(connectionTimeout);
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Invalid SSH key format: ${keyError instanceof Error ? keyError.message : "Unknown error"}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
clearTimeout(connectionTimeout);
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Invalid authentication configuration. Auth type: ${hostConfig.authType}, has password: ${!!hostConfig.password}, has key: ${!!hostConfig.privateKey}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
conn.connect(connectionConfig);
|
||||
} catch (error) {
|
||||
clearTimeout(connectionTimeout);
|
||||
resolve({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Connection failed",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function registerCredentialDeployRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/{id}/deploy-to-host:
|
||||
* post:
|
||||
* summary: Deploy SSH key to a host
|
||||
* description: Deploys an SSH public key to a target host's authorized_keys file.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* targetHostId:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: SSH key deployed successfully.
|
||||
* 400:
|
||||
* description: Credential ID and target host ID are required.
|
||||
* 401:
|
||||
* description: Authentication required.
|
||||
* 404:
|
||||
* description: Credential or target host not found.
|
||||
* 500:
|
||||
* description: Failed to deploy SSH key.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/deploy-to-host",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const id = Array.isArray(req.params.id)
|
||||
? req.params.id[0]
|
||||
: req.params.id;
|
||||
const credentialId = parseInt(id);
|
||||
const { targetHostId } = req.body;
|
||||
|
||||
if (!credentialId || !targetHostId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Credential ID and target host ID are required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: "Authentication required",
|
||||
});
|
||||
}
|
||||
|
||||
const { SimpleDBOps } = await import("../../utils/simple-db-ops.js");
|
||||
const credential = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, credentialId))
|
||||
.limit(1),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (!credential || credential.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Credential not found",
|
||||
});
|
||||
}
|
||||
|
||||
const credData = credential[0] as unknown as CredentialBackend;
|
||||
|
||||
if (credData.authType !== "key") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Only SSH key-based credentials can be deployed",
|
||||
});
|
||||
}
|
||||
|
||||
const publicKey = credData.publicKey;
|
||||
if (!publicKey) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Public key is required for deployment",
|
||||
});
|
||||
}
|
||||
const targetHost = await SimpleDBOps.select(
|
||||
db.select().from(hosts).where(eq(hosts.id, targetHostId)).limit(1),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (!targetHost || targetHost.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Target host not found",
|
||||
});
|
||||
}
|
||||
|
||||
const hostData = targetHost[0];
|
||||
|
||||
const hostConfig = {
|
||||
ip: hostData.ip,
|
||||
port: hostData.port,
|
||||
username: hostData.username,
|
||||
authType: hostData.authType,
|
||||
password: hostData.password,
|
||||
privateKey: hostData.key,
|
||||
keyPassword: hostData.keyPassword,
|
||||
};
|
||||
|
||||
if (hostData.authType === "credential" && hostData.credentialId) {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!userId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Authentication required for credential resolution",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { SimpleDBOps } =
|
||||
await import("../../utils/simple-db-ops.js");
|
||||
const hostCredential = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, hostData.credentialId as number))
|
||||
.limit(1),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hostCredential && hostCredential.length > 0) {
|
||||
const cred = hostCredential[0];
|
||||
|
||||
hostConfig.authType = cred.authType;
|
||||
hostConfig.username = cred.username;
|
||||
|
||||
if (cred.authType === "password") {
|
||||
hostConfig.password = cred.password;
|
||||
} else if (cred.authType === "key") {
|
||||
hostConfig.privateKey = cred.privateKey || cred.key;
|
||||
hostConfig.keyPassword = cred.keyPassword;
|
||||
}
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Host credential not found",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to resolve host credentials",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const deployResult = await deploySSHKeyToHost(hostConfig, credData);
|
||||
|
||||
if (deployResult.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: deployResult.message || "SSH key deployed successfully",
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: deployResult.error || "Deployment failed",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to deploy SSH key",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import crypto from "crypto";
|
||||
import ssh2Pkg from "ssh2";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
parsePublicKey,
|
||||
parseSSHKey,
|
||||
validateKeyPair,
|
||||
} from "../../utils/ssh-key-utils.js";
|
||||
|
||||
const { utils: ssh2Utils } = ssh2Pkg;
|
||||
|
||||
function generateSSHKeyPair(
|
||||
keyType: string,
|
||||
keySize?: number,
|
||||
passphrase?: string,
|
||||
): {
|
||||
success: boolean;
|
||||
privateKey?: string;
|
||||
publicKey?: string;
|
||||
error?: string;
|
||||
} {
|
||||
try {
|
||||
let ssh2Type = keyType;
|
||||
const options: {
|
||||
bits?: number;
|
||||
passphrase?: string;
|
||||
cipher?: string;
|
||||
} = {};
|
||||
|
||||
if (keyType === "ssh-rsa") {
|
||||
ssh2Type = "rsa";
|
||||
options.bits = keySize || 2048;
|
||||
} else if (keyType === "ssh-ed25519") {
|
||||
ssh2Type = "ed25519";
|
||||
} else if (keyType === "ecdsa-sha2-nistp256") {
|
||||
ssh2Type = "ecdsa";
|
||||
options.bits = 256;
|
||||
}
|
||||
|
||||
if (passphrase && passphrase.trim()) {
|
||||
options.passphrase = passphrase;
|
||||
options.cipher = "aes128-cbc";
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
privateKey: keyPair.private,
|
||||
publicKey: keyPair.public,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "SSH key generation failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function registerCredentialKeyRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/detect-key-type:
|
||||
* post:
|
||||
* summary: Detect SSH key type
|
||||
* description: Detects the type of an SSH private key.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* privateKey:
|
||||
* type: string
|
||||
* keyPassword:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Key type detection result.
|
||||
* 400:
|
||||
* description: Private key is required.
|
||||
* 500:
|
||||
* description: Failed to detect key type.
|
||||
*/
|
||||
router.post(
|
||||
"/detect-key-type",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const { privateKey, keyPassword } = req.body;
|
||||
|
||||
if (!privateKey || typeof privateKey !== "string") {
|
||||
return res.status(400).json({ error: "Private key is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const keyInfo = parseSSHKey(privateKey, keyPassword);
|
||||
|
||||
const response = {
|
||||
success: keyInfo.success,
|
||||
keyType: keyInfo.keyType,
|
||||
detectedKeyType: keyInfo.keyType,
|
||||
hasPublicKey: !!keyInfo.publicKey,
|
||||
error: keyInfo.error || null,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to detect key type", error);
|
||||
res.status(500).json({
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to detect key type",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/detect-public-key-type:
|
||||
* post:
|
||||
* summary: Detect SSH public key type
|
||||
* description: Detects the type of an SSH public key.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* publicKey:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Key type detection result.
|
||||
* 400:
|
||||
* description: Public key is required.
|
||||
* 500:
|
||||
* description: Failed to detect public key type.
|
||||
*/
|
||||
router.post(
|
||||
"/detect-public-key-type",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const { publicKey } = req.body;
|
||||
|
||||
if (!publicKey || typeof publicKey !== "string") {
|
||||
return res.status(400).json({ error: "Public key is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const keyInfo = parsePublicKey(publicKey);
|
||||
|
||||
const response = {
|
||||
success: keyInfo.success,
|
||||
keyType: keyInfo.keyType,
|
||||
detectedKeyType: keyInfo.keyType,
|
||||
error: keyInfo.error || null,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to detect public key type", error);
|
||||
res.status(500).json({
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to detect public key type",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/validate-key-pair:
|
||||
* post:
|
||||
* summary: Validate SSH key pair
|
||||
* description: Validates if a given SSH private key and public key match.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* privateKey:
|
||||
* type: string
|
||||
* publicKey:
|
||||
* type: string
|
||||
* keyPassword:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Key pair validation result.
|
||||
* 400:
|
||||
* description: Private key and public key are required.
|
||||
* 500:
|
||||
* description: Failed to validate key pair.
|
||||
*/
|
||||
router.post(
|
||||
"/validate-key-pair",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const { privateKey, publicKey, keyPassword } = req.body;
|
||||
|
||||
if (!privateKey || typeof privateKey !== "string") {
|
||||
return res.status(400).json({ error: "Private key is required" });
|
||||
}
|
||||
|
||||
if (!publicKey || typeof publicKey !== "string") {
|
||||
return res.status(400).json({ error: "Public key is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const validationResult = validateKeyPair(
|
||||
privateKey,
|
||||
publicKey,
|
||||
keyPassword,
|
||||
);
|
||||
|
||||
const response = {
|
||||
isValid: validationResult.isValid,
|
||||
privateKeyType: validationResult.privateKeyType,
|
||||
publicKeyType: validationResult.publicKeyType,
|
||||
generatedPublicKey: validationResult.generatedPublicKey,
|
||||
error: validationResult.error || null,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to validate key pair", error);
|
||||
res.status(500).json({
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to validate key pair",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/generate-key-pair:
|
||||
* post:
|
||||
* summary: Generate new SSH key pair
|
||||
* description: Generates a new SSH key pair.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* keyType:
|
||||
* type: string
|
||||
* keySize:
|
||||
* type: integer
|
||||
* passphrase:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The new key pair.
|
||||
* 500:
|
||||
* description: Failed to generate SSH key pair.
|
||||
*/
|
||||
router.post(
|
||||
"/generate-key-pair",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body;
|
||||
|
||||
try {
|
||||
const result = generateSSHKeyPair(keyType, keySize, passphrase);
|
||||
|
||||
if (result.success && result.privateKey && result.publicKey) {
|
||||
const response = {
|
||||
success: true,
|
||||
privateKey: result.privateKey,
|
||||
publicKey: result.publicKey,
|
||||
keyType: keyType,
|
||||
format: "ssh",
|
||||
algorithm: keyType,
|
||||
keySize: keyType === "ssh-rsa" ? keySize : undefined,
|
||||
curve: keyType === "ecdsa-sha2-nistp256" ? "nistp256" : undefined,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: result.error || "Failed to generate SSH key pair",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to generate key pair", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to generate key pair",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /credentials/generate-public-key:
|
||||
* post:
|
||||
* summary: Generate public key from private key
|
||||
* description: Generates a public key from a given private key.
|
||||
* tags:
|
||||
* - Credentials
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* privateKey:
|
||||
* type: string
|
||||
* keyPassword:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The generated public key.
|
||||
* 400:
|
||||
* description: Private key is required.
|
||||
* 500:
|
||||
* description: Failed to generate public key.
|
||||
*/
|
||||
router.post(
|
||||
"/generate-public-key",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const { privateKey, keyPassword } = req.body;
|
||||
|
||||
if (!privateKey || typeof privateKey !== "string") {
|
||||
return res.status(400).json({ error: "Private key is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
let privateKeyObj;
|
||||
const parseAttempts = [];
|
||||
|
||||
try {
|
||||
privateKeyObj = crypto.createPrivateKey({
|
||||
key: privateKey,
|
||||
passphrase: keyPassword,
|
||||
});
|
||||
} catch (error) {
|
||||
parseAttempts.push(`Method 1 (with passphrase): ${error.message}`);
|
||||
}
|
||||
|
||||
if (!privateKeyObj) {
|
||||
try {
|
||||
privateKeyObj = crypto.createPrivateKey(privateKey);
|
||||
} catch (error) {
|
||||
parseAttempts.push(
|
||||
`Method 2 (without passphrase): ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!privateKeyObj) {
|
||||
try {
|
||||
privateKeyObj = crypto.createPrivateKey({
|
||||
key: privateKey,
|
||||
format: "pem",
|
||||
type: "pkcs8",
|
||||
});
|
||||
} catch (error) {
|
||||
parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!privateKeyObj &&
|
||||
privateKey.includes("-----BEGIN RSA PRIVATE KEY-----")
|
||||
) {
|
||||
try {
|
||||
privateKeyObj = crypto.createPrivateKey({
|
||||
key: privateKey,
|
||||
format: "pem",
|
||||
type: "pkcs1",
|
||||
});
|
||||
} catch (error) {
|
||||
parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!privateKeyObj &&
|
||||
privateKey.includes("-----BEGIN EC PRIVATE KEY-----")
|
||||
) {
|
||||
try {
|
||||
privateKeyObj = crypto.createPrivateKey({
|
||||
key: privateKey,
|
||||
format: "pem",
|
||||
type: "sec1",
|
||||
});
|
||||
} catch (error) {
|
||||
parseAttempts.push(`Method 5 (SEC1): ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!privateKeyObj) {
|
||||
try {
|
||||
const keyInfo = parseSSHKey(privateKey, keyPassword);
|
||||
|
||||
if (keyInfo.success && keyInfo.publicKey) {
|
||||
const publicKeyString = String(keyInfo.publicKey);
|
||||
return res.json({
|
||||
success: true,
|
||||
publicKey: publicKeyString,
|
||||
keyType: keyInfo.keyType,
|
||||
});
|
||||
} else {
|
||||
parseAttempts.push(
|
||||
`SSH2 fallback: ${keyInfo.error || "No public key generated"}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
parseAttempts.push(`SSH2 fallback exception: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!privateKeyObj) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Unable to parse private key. Tried multiple formats.",
|
||||
details: parseAttempts,
|
||||
});
|
||||
}
|
||||
|
||||
const publicKeyObj = crypto.createPublicKey(privateKeyObj);
|
||||
const publicKeyPem = publicKeyObj.export({
|
||||
type: "spki",
|
||||
format: "pem",
|
||||
});
|
||||
|
||||
const publicKeyString =
|
||||
typeof publicKeyPem === "string"
|
||||
? publicKeyPem
|
||||
: (publicKeyPem as Buffer).toString("utf8");
|
||||
|
||||
let keyType = "unknown";
|
||||
const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
|
||||
|
||||
if (asymmetricKeyType === "rsa") {
|
||||
keyType = "ssh-rsa";
|
||||
} else if (asymmetricKeyType === "ed25519") {
|
||||
keyType = "ssh-ed25519";
|
||||
} else if (asymmetricKeyType === "ec") {
|
||||
keyType = "ecdsa-sha2-nistp256";
|
||||
}
|
||||
|
||||
let finalPublicKey = publicKeyString;
|
||||
let formatType = "pem";
|
||||
|
||||
try {
|
||||
const ssh2PrivateKey = ssh2Utils.parseKey(privateKey, keyPassword);
|
||||
if (!(ssh2PrivateKey instanceof Error)) {
|
||||
const publicKeyBuffer = ssh2PrivateKey.getPublicSSH();
|
||||
const base64Data = publicKeyBuffer.toString("base64");
|
||||
finalPublicKey = `${keyType} ${base64Data}`;
|
||||
formatType = "ssh";
|
||||
}
|
||||
} catch {
|
||||
// Ignore validation errors
|
||||
}
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
publicKey: finalPublicKey,
|
||||
keyType: keyType,
|
||||
format: formatType,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to generate public key", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to generate public key",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { db } from "../db/index.js";
|
||||
import {
|
||||
auditLogs,
|
||||
commandHistory,
|
||||
dashboardPreferences,
|
||||
dismissedAlerts,
|
||||
fileManagerPinned,
|
||||
fileManagerRecent,
|
||||
fileManagerShortcuts,
|
||||
hostAccess,
|
||||
hosts,
|
||||
networkTopology,
|
||||
opksshTokens,
|
||||
recentActivity,
|
||||
sessionRecordings,
|
||||
sessions,
|
||||
sharedCredentials,
|
||||
snippetFolders,
|
||||
snippets,
|
||||
sshCredentialUsage,
|
||||
sshCredentials,
|
||||
sshFolders,
|
||||
transferRecent,
|
||||
userOpenTabs,
|
||||
userPreferences,
|
||||
userRoles,
|
||||
users,
|
||||
} from "../db/schema.js";
|
||||
|
||||
export async function deleteUserAndRelatedData(userId: string): Promise<void> {
|
||||
try {
|
||||
await db
|
||||
.delete(sharedCredentials)
|
||||
.where(eq(sharedCredentials.targetUserId, userId));
|
||||
|
||||
await db
|
||||
.delete(sessionRecordings)
|
||||
.where(eq(sessionRecordings.userId, userId));
|
||||
|
||||
await db.delete(hostAccess).where(eq(hostAccess.userId, userId));
|
||||
await db.delete(hostAccess).where(eq(hostAccess.grantedBy, userId));
|
||||
|
||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
||||
|
||||
await db.delete(userRoles).where(eq(userRoles.userId, userId));
|
||||
await db.delete(auditLogs).where(eq(auditLogs.userId, userId));
|
||||
|
||||
await db
|
||||
.delete(sshCredentialUsage)
|
||||
.where(eq(sshCredentialUsage.userId, userId));
|
||||
|
||||
await db
|
||||
.delete(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.userId, userId));
|
||||
await db
|
||||
.delete(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.userId, userId));
|
||||
await db
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.userId, userId));
|
||||
|
||||
await db.delete(transferRecent).where(eq(transferRecent.userId, userId));
|
||||
|
||||
await db.delete(recentActivity).where(eq(recentActivity.userId, userId));
|
||||
await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, userId));
|
||||
|
||||
await db.delete(snippets).where(eq(snippets.userId, userId));
|
||||
await db.delete(snippetFolders).where(eq(snippetFolders.userId, userId));
|
||||
|
||||
await db.delete(sshFolders).where(eq(sshFolders.userId, userId));
|
||||
|
||||
await db.delete(commandHistory).where(eq(commandHistory.userId, userId));
|
||||
|
||||
await db.delete(hosts).where(eq(hosts.userId, userId));
|
||||
await db.delete(sshCredentials).where(eq(sshCredentials.userId, userId));
|
||||
|
||||
await db.delete(networkTopology).where(eq(networkTopology.userId, userId));
|
||||
await db
|
||||
.delete(dashboardPreferences)
|
||||
.where(eq(dashboardPreferences.userId, userId));
|
||||
await db.delete(opksshTokens).where(eq(opksshTokens.userId, userId));
|
||||
await db.delete(userOpenTabs).where(eq(userOpenTabs.userId, userId));
|
||||
await db.delete(userPreferences).where(eq(userPreferences.userId, userId));
|
||||
|
||||
db.$client
|
||||
.prepare("DELETE FROM settings WHERE key LIKE ?")
|
||||
.run(`user_%_${userId}`);
|
||||
|
||||
await db.delete(users).where(eq(users.id, userId));
|
||||
|
||||
authLogger.success("User and all related data deleted successfully", {
|
||||
operation: "delete_user_and_related_data_complete",
|
||||
userId,
|
||||
});
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to delete user and related data", error, {
|
||||
operation: "delete_user_and_related_data_failed",
|
||||
userId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { and, eq, isNotNull, or } from "drizzle-orm";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { db, DatabaseSaveTrigger } from "../db/index.js";
|
||||
import { hosts } from "../db/schema.js";
|
||||
|
||||
type HostAutostartRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
requireDataAccess: RequestHandler;
|
||||
};
|
||||
|
||||
export function registerHostAutostartRoutes(
|
||||
router: Router,
|
||||
{ authenticateJWT, requireDataAccess }: HostAutostartRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/autostart/enable:
|
||||
* post:
|
||||
* summary: Enable autostart for SSH configuration
|
||||
* description: Enables autostart for a specific SSH configuration.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sshConfigId:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: AutoStart enabled successfully.
|
||||
* 400:
|
||||
* description: Valid sshConfigId is required.
|
||||
* 404:
|
||||
* description: SSH configuration not found.
|
||||
* 500:
|
||||
* description: Internal server error.
|
||||
*/
|
||||
router.post(
|
||||
"/autostart/enable",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { sshConfigId } = req.body;
|
||||
|
||||
if (!sshConfigId || typeof sshConfigId !== "number") {
|
||||
sshLogger.warn(
|
||||
"Missing or invalid sshConfigId in autostart enable request",
|
||||
{
|
||||
operation: "autostart_enable",
|
||||
userId,
|
||||
sshConfigId,
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "Valid sshConfigId is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) {
|
||||
sshLogger.warn(
|
||||
"User attempted to enable autostart without unlocked data",
|
||||
{
|
||||
operation: "autostart_enable_failed",
|
||||
userId,
|
||||
sshConfigId,
|
||||
reason: "data_locked",
|
||||
},
|
||||
);
|
||||
return res.status(400).json({
|
||||
error: "Failed to enable autostart. Ensure user data is unlocked.",
|
||||
});
|
||||
}
|
||||
|
||||
const sshConfig = await db
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, sshConfigId), eq(hosts.userId, userId)));
|
||||
|
||||
if (sshConfig.length === 0) {
|
||||
sshLogger.warn("SSH config not found for autostart enable", {
|
||||
operation: "autostart_enable_failed",
|
||||
userId,
|
||||
sshConfigId,
|
||||
reason: "config_not_found",
|
||||
});
|
||||
return res.status(404).json({
|
||||
error: "SSH configuration not found",
|
||||
});
|
||||
}
|
||||
|
||||
const config = sshConfig[0];
|
||||
|
||||
const decryptedConfig = DataCrypto.decryptRecord(
|
||||
"ssh_data",
|
||||
config,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
let updatedTunnelConnections = config.tunnelConnections;
|
||||
if (config.tunnelConnections) {
|
||||
try {
|
||||
const tunnelConnections = JSON.parse(config.tunnelConnections);
|
||||
|
||||
const resolvedConnections = await Promise.all(
|
||||
tunnelConnections.map(async (tunnel: Record<string, unknown>) => {
|
||||
if (
|
||||
tunnel.autoStart &&
|
||||
tunnel.endpointHost &&
|
||||
!tunnel.endpointPassword &&
|
||||
!tunnel.endpointKey
|
||||
) {
|
||||
const endpointHosts = await db
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(eq(hosts.userId, userId));
|
||||
|
||||
const endpointHost = endpointHosts.find(
|
||||
(h) =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost,
|
||||
);
|
||||
|
||||
if (endpointHost) {
|
||||
const decryptedEndpoint = DataCrypto.decryptRecord(
|
||||
"ssh_data",
|
||||
endpointHost,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
|
||||
return {
|
||||
...tunnel,
|
||||
endpointPassword: decryptedEndpoint.password || null,
|
||||
endpointKey: decryptedEndpoint.key || null,
|
||||
endpointKeyPassword:
|
||||
decryptedEndpoint.keyPassword || null,
|
||||
endpointAuthType: endpointHost.authType,
|
||||
};
|
||||
}
|
||||
}
|
||||
return tunnel;
|
||||
}),
|
||||
);
|
||||
|
||||
updatedTunnelConnections = JSON.stringify(resolvedConnections);
|
||||
} catch (error) {
|
||||
sshLogger.warn("Failed to update tunnel connections", {
|
||||
operation: "tunnel_connections_update_failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(hosts)
|
||||
.set({
|
||||
autostartPassword: decryptedConfig.password || null,
|
||||
autostartKey: decryptedConfig.key || null,
|
||||
autostartKeyPassword: decryptedConfig.keyPassword || null,
|
||||
tunnelConnections: updatedTunnelConnections,
|
||||
})
|
||||
.where(eq(hosts.id, sshConfigId));
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.triggerSave();
|
||||
} catch (saveError) {
|
||||
sshLogger.warn("Database save failed after autostart", {
|
||||
operation: "autostart_db_save_failed",
|
||||
error:
|
||||
saveError instanceof Error ? saveError.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: "AutoStart enabled successfully",
|
||||
sshConfigId,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Error enabling autostart", error, {
|
||||
operation: "autostart_enable_error",
|
||||
userId,
|
||||
sshConfigId,
|
||||
});
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/autostart/disable:
|
||||
* delete:
|
||||
* summary: Disable autostart for SSH configuration
|
||||
* description: Disables autostart for a specific SSH configuration.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sshConfigId:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: AutoStart disabled successfully.
|
||||
* 400:
|
||||
* description: Valid sshConfigId is required.
|
||||
* 500:
|
||||
* description: Internal server error.
|
||||
*/
|
||||
router.delete(
|
||||
"/autostart/disable",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { sshConfigId } = req.body;
|
||||
|
||||
if (!sshConfigId || typeof sshConfigId !== "number") {
|
||||
sshLogger.warn(
|
||||
"Missing or invalid sshConfigId in autostart disable request",
|
||||
{
|
||||
operation: "autostart_disable",
|
||||
userId,
|
||||
sshConfigId,
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "Valid sshConfigId is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(hosts)
|
||||
.set({
|
||||
autostartPassword: null,
|
||||
autostartKey: null,
|
||||
autostartKeyPassword: null,
|
||||
})
|
||||
.where(and(eq(hosts.id, sshConfigId), eq(hosts.userId, userId)));
|
||||
|
||||
res.json({
|
||||
message: "AutoStart disabled successfully",
|
||||
sshConfigId,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Error disabling autostart", error, {
|
||||
operation: "autostart_disable_error",
|
||||
userId,
|
||||
sshConfigId,
|
||||
});
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/autostart/status:
|
||||
* get:
|
||||
* summary: Get autostart status
|
||||
* description: Retrieves the autostart status for the user's SSH configurations.
|
||||
* tags:
|
||||
* - SSH
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of autostart configurations.
|
||||
* 500:
|
||||
* description: Internal server error.
|
||||
*/
|
||||
router.get(
|
||||
"/autostart/status",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
const autostartConfigs = await db
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(
|
||||
eq(hosts.userId, userId),
|
||||
or(
|
||||
isNotNull(hosts.autostartPassword),
|
||||
isNotNull(hosts.autostartKey),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const statusList = autostartConfigs.map((config) => ({
|
||||
sshConfigId: config.id,
|
||||
host: config.ip,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
authType: config.authType,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
autostart_configs: statusList,
|
||||
total_count: statusList.length,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Error getting autostart status", error, {
|
||||
operation: "autostart_status_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
|
||||
import { db, DatabaseSaveTrigger } from "../db/index.js";
|
||||
import { hosts, sshCredentials } from "../db/schema.js";
|
||||
import {
|
||||
isNonEmptyString,
|
||||
isValidPort,
|
||||
normalizeImportedHost,
|
||||
} from "./host-normalizers.js";
|
||||
|
||||
export function registerHostBulkRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/bulk-import:
|
||||
* post:
|
||||
* summary: Bulk import SSH hosts
|
||||
* description: Bulk imports multiple SSH hosts.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hosts:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Import completed.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /host/bulk-update:
|
||||
* patch:
|
||||
* summary: Bulk update partial fields on multiple SSH hosts
|
||||
* tags: [SSH]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostIds:
|
||||
* type: array
|
||||
* items:
|
||||
* type: number
|
||||
* updates:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Bulk update completed.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
*/
|
||||
router.patch(
|
||||
"/bulk-update",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostIds, updates } = req.body;
|
||||
|
||||
if (!Array.isArray(hostIds) || hostIds.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "hostIds array is required and must not be empty" });
|
||||
}
|
||||
|
||||
if (hostIds.length > 1000) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Maximum 1000 hosts allowed per bulk update" });
|
||||
}
|
||||
|
||||
if (
|
||||
!updates ||
|
||||
typeof updates !== "object" ||
|
||||
Object.keys(updates).length === 0
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"updates object is required and must contain at least one field",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const ownedHosts = await db
|
||||
.select({ id: hosts.id, statsConfig: hosts.statsConfig })
|
||||
.from(hosts)
|
||||
.where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)));
|
||||
|
||||
const ownedIds = ownedHosts.map((h) => h.id);
|
||||
const unauthorizedIds = hostIds.filter(
|
||||
(id: number) => !ownedIds.includes(id),
|
||||
);
|
||||
|
||||
if (ownedIds.length === 0) {
|
||||
return res.status(404).json({ error: "No matching hosts found" });
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
if (unauthorizedIds.length > 0) {
|
||||
errors.push(
|
||||
`${unauthorizedIds.length} host(s) not found or not owned`,
|
||||
);
|
||||
}
|
||||
|
||||
const simpleUpdates: Record<string, unknown> = {};
|
||||
if (typeof updates.pin === "boolean") simpleUpdates.pin = updates.pin;
|
||||
if (typeof updates.folder === "string")
|
||||
simpleUpdates.folder = updates.folder || null;
|
||||
if (typeof updates.enableTerminal === "boolean")
|
||||
simpleUpdates.enableTerminal = updates.enableTerminal;
|
||||
if (typeof updates.enableTunnel === "boolean")
|
||||
simpleUpdates.enableTunnel = updates.enableTunnel;
|
||||
if (typeof updates.enableFileManager === "boolean")
|
||||
simpleUpdates.enableFileManager = updates.enableFileManager;
|
||||
if (typeof updates.enableDocker === "boolean")
|
||||
simpleUpdates.enableDocker = updates.enableDocker;
|
||||
|
||||
if (Object.keys(simpleUpdates).length > 0) {
|
||||
await db
|
||||
.update(hosts)
|
||||
.set(simpleUpdates)
|
||||
.where(and(inArray(hosts.id, ownedIds), eq(hosts.userId, userId)));
|
||||
}
|
||||
|
||||
if (updates.statsConfig && typeof updates.statsConfig === "object") {
|
||||
for (const host of ownedHosts) {
|
||||
try {
|
||||
const existing = host.statsConfig
|
||||
? JSON.parse(host.statsConfig as string)
|
||||
: {};
|
||||
const merged = { ...existing, ...updates.statsConfig };
|
||||
await db
|
||||
.update(hosts)
|
||||
.set({ statsConfig: JSON.stringify(merged) })
|
||||
.where(and(eq(hosts.id, host.id), eq(hosts.userId, userId)));
|
||||
} catch {
|
||||
errors.push(`Failed to update statsConfig for host ${host.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("bulk_update");
|
||||
|
||||
return res.json({
|
||||
updated: ownedIds.length,
|
||||
failed: unauthorizedIds.length,
|
||||
errors,
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to bulk update hosts:", error);
|
||||
return res.status(500).json({ error: "Failed to bulk update hosts" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/bulk-import",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hosts: hostsToImport, overwrite } = req.body;
|
||||
|
||||
if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Hosts array is required and must not be empty" });
|
||||
}
|
||||
|
||||
if (hostsToImport.length > 100) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Maximum 100 hosts allowed per import" });
|
||||
}
|
||||
|
||||
const results = {
|
||||
success: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
let existingHostMap: Map<string, { id: number }> | undefined;
|
||||
if (overwrite) {
|
||||
try {
|
||||
const allHosts = await SimpleDBOps.select<Record<string, unknown>>(
|
||||
db.select().from(hosts).where(eq(hosts.userId, userId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
existingHostMap = new Map();
|
||||
for (const h of allHosts) {
|
||||
const key = `${h.ip}:${h.port}:${h.username}`;
|
||||
existingHostMap.set(key, { id: h.id as number });
|
||||
}
|
||||
} catch {
|
||||
existingHostMap = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < hostsToImport.length; i++) {
|
||||
const hostData = normalizeImportedHost(hostsToImport[i]);
|
||||
|
||||
try {
|
||||
const effectiveConnectionType = hostData.connectionType || "ssh";
|
||||
|
||||
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: Missing required fields (ip, port)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
!isNonEmptyString(hostData.username)
|
||||
) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: Username required for SSH connections`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
hostData.authType &&
|
||||
!["password", "key", "credential", "none", "opkssh"].includes(
|
||||
hostData.authType,
|
||||
)
|
||||
) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', or 'opkssh'`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
hostData.authType === "password" &&
|
||||
!isNonEmptyString(hostData.password)
|
||||
) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: Password required for password authentication`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
hostData.authType === "key" &&
|
||||
!isNonEmptyString(hostData.key)
|
||||
) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: Key required for key authentication`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
hostData.authType === "credential" &&
|
||||
!hostData.credentialId
|
||||
) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: credentialId required for credential authentication`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
hostData.authType === "credential" &&
|
||||
hostData.credentialId
|
||||
) {
|
||||
const cred = await db
|
||||
.select({ id: sshCredentials.id })
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, hostData.credentialId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (cred.length === 0) {
|
||||
const fallback = await db
|
||||
.select({ id: sshCredentials.id })
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (fallback.length > 0) {
|
||||
hostData.credentialId = fallback[0].id;
|
||||
} else {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: credentialId ${hostData.credentialId} not found and no fallback credential available`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sshDataObj: Record<string, unknown> = {
|
||||
userId: userId,
|
||||
connectionType: effectiveConnectionType,
|
||||
name: hostData.name || `${hostData.username || ""}@${hostData.ip}`,
|
||||
folder: hostData.folder || "Default",
|
||||
tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "",
|
||||
ip: hostData.ip,
|
||||
port: hostData.port,
|
||||
username: hostData.username || null,
|
||||
pin: hostData.pin || false,
|
||||
enableTerminal: hostData.enableTerminal !== false,
|
||||
enableTunnel: hostData.enableTunnel !== false,
|
||||
enableFileManager: hostData.enableFileManager !== false,
|
||||
enableDocker: hostData.enableDocker || false,
|
||||
showTerminalInSidebar: hostData.showTerminalInSidebar ? 1 : 0,
|
||||
showFileManagerInSidebar: hostData.showFileManagerInSidebar ? 1 : 0,
|
||||
showTunnelInSidebar: hostData.showTunnelInSidebar ? 1 : 0,
|
||||
showDockerInSidebar: hostData.showDockerInSidebar ? 1 : 0,
|
||||
showServerStatsInSidebar: hostData.showServerStatsInSidebar ? 1 : 0,
|
||||
defaultPath: hostData.defaultPath || "/",
|
||||
sudoPassword: hostData.sudoPassword || null,
|
||||
tunnelConnections: hostData.tunnelConnections
|
||||
? JSON.stringify(hostData.tunnelConnections)
|
||||
: "[]",
|
||||
jumpHosts: hostData.jumpHosts
|
||||
? JSON.stringify(hostData.jumpHosts)
|
||||
: null,
|
||||
quickActions: hostData.quickActions
|
||||
? JSON.stringify(hostData.quickActions)
|
||||
: null,
|
||||
statsConfig: hostData.statsConfig
|
||||
? JSON.stringify(hostData.statsConfig)
|
||||
: null,
|
||||
dockerConfig: hostData.dockerConfig
|
||||
? JSON.stringify(hostData.dockerConfig)
|
||||
: null,
|
||||
terminalConfig: hostData.terminalConfig
|
||||
? JSON.stringify(hostData.terminalConfig)
|
||||
: null,
|
||||
forceKeyboardInteractive: hostData.forceKeyboardInteractive
|
||||
? "true"
|
||||
: "false",
|
||||
notes: hostData.notes || null,
|
||||
useSocks5: hostData.useSocks5 ? 1 : 0,
|
||||
socks5Host: hostData.socks5Host || null,
|
||||
socks5Port: hostData.socks5Port || null,
|
||||
socks5Username: hostData.socks5Username || null,
|
||||
socks5Password: hostData.socks5Password || null,
|
||||
socks5ProxyChain: hostData.socks5ProxyChain
|
||||
? JSON.stringify(hostData.socks5ProxyChain)
|
||||
: null,
|
||||
portKnockSequence: hostData.portKnockSequence
|
||||
? JSON.stringify(hostData.portKnockSequence)
|
||||
: null,
|
||||
overrideCredentialUsername: hostData.overrideCredentialUsername
|
||||
? 1
|
||||
: 0,
|
||||
enableSsh: hostData.enableSsh ?? effectiveConnectionType === "ssh",
|
||||
enableRdp: hostData.enableRdp ?? false,
|
||||
enableVnc: hostData.enableVnc ?? false,
|
||||
enableTelnet: hostData.enableTelnet ?? false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (effectiveConnectionType !== "ssh") {
|
||||
sshDataObj.password = hostData.password || null;
|
||||
sshDataObj.authType = "password";
|
||||
sshDataObj.credentialId = null;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
sshDataObj.rdpUser = hostData.rdpUser || null;
|
||||
sshDataObj.rdpPassword = hostData.rdpPassword || null;
|
||||
sshDataObj.rdpDomain = hostData.rdpDomain || null;
|
||||
sshDataObj.rdpSecurity = hostData.rdpSecurity || null;
|
||||
sshDataObj.rdpIgnoreCert = hostData.rdpIgnoreCert ? 1 : 0;
|
||||
sshDataObj.rdpPort = hostData.rdpPort || 3389;
|
||||
sshDataObj.vncUser = hostData.vncUser || null;
|
||||
sshDataObj.vncPassword = hostData.vncPassword || null;
|
||||
sshDataObj.vncPort = hostData.vncPort || 5900;
|
||||
sshDataObj.telnetUser = hostData.telnetUser || null;
|
||||
sshDataObj.telnetPassword = hostData.telnetPassword || null;
|
||||
sshDataObj.telnetPort = hostData.telnetPort || 23;
|
||||
sshDataObj.enableRdp = hostData.enableRdp ? 1 : 0;
|
||||
sshDataObj.enableVnc = hostData.enableVnc ? 1 : 0;
|
||||
sshDataObj.enableTelnet = hostData.enableTelnet ? 1 : 0;
|
||||
sshDataObj.guacamoleConfig = hostData.guacamoleConfig
|
||||
? JSON.stringify(hostData.guacamoleConfig)
|
||||
: null;
|
||||
} else {
|
||||
sshDataObj.password =
|
||||
hostData.authType === "password" ? hostData.password : null;
|
||||
sshDataObj.authType = hostData.authType || "password";
|
||||
sshDataObj.credentialId =
|
||||
hostData.authType === "credential" ? hostData.credentialId : null;
|
||||
sshDataObj.key = hostData.authType === "key" ? hostData.key : null;
|
||||
sshDataObj.keyPassword =
|
||||
hostData.authType === "key" ? hostData.keyPassword || null : null;
|
||||
sshDataObj.keyType =
|
||||
hostData.authType === "key" ? hostData.keyType || "auto" : null;
|
||||
sshDataObj.domain = null;
|
||||
sshDataObj.security = null;
|
||||
sshDataObj.ignoreCert = 0;
|
||||
sshDataObj.guacamoleConfig = null;
|
||||
}
|
||||
|
||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||
const existing = existingHostMap?.get(lookupKey);
|
||||
|
||||
if (existing) {
|
||||
await SimpleDBOps.update(
|
||||
hosts,
|
||||
"ssh_data",
|
||||
eq(hosts.id, existing.id),
|
||||
sshDataObj,
|
||||
userId,
|
||||
);
|
||||
results.updated++;
|
||||
} else {
|
||||
sshDataObj.createdAt = new Date().toISOString();
|
||||
await SimpleDBOps.insert(hosts, "ssh_data", sshDataObj, userId);
|
||||
results.success++;
|
||||
}
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
|
||||
success: results.success,
|
||||
updated: results.updated,
|
||||
skipped: results.skipped,
|
||||
failed: results.failed,
|
||||
errors: results.errors,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { commandHistory } from "../db/schema.js";
|
||||
import { isNonEmptyString } from "./host-normalizers.js";
|
||||
|
||||
export function registerHostCommandHistoryRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/command-history/{hostId}:
|
||||
* get:
|
||||
* summary: Get command history
|
||||
* description: Retrieves the command history for a specific host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of commands.
|
||||
* 400:
|
||||
* description: Invalid userId or hostId.
|
||||
* 500:
|
||||
* description: Failed to fetch command history.
|
||||
*/
|
||||
router.get(
|
||||
"/command-history/:hostId",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostIdParam = Array.isArray(req.params.hostId)
|
||||
? req.params.hostId[0]
|
||||
: req.params.hostId;
|
||||
const hostId = parseInt(hostIdParam, 10);
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId) {
|
||||
sshLogger.warn("Invalid userId or hostId for command history fetch", {
|
||||
operation: "command_history_fetch",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid userId or hostId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const history = await db
|
||||
.select({
|
||||
id: commandHistory.id,
|
||||
command: commandHistory.command,
|
||||
})
|
||||
.from(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(commandHistory.executedAt))
|
||||
.limit(200);
|
||||
|
||||
res.json(history.map((h) => h.command));
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch command history from database", err, {
|
||||
operation: "command_history_fetch",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch command history" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/command-history:
|
||||
* delete:
|
||||
* summary: Delete command from history
|
||||
* description: Deletes a specific command from the history of a host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* command:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Command deleted from history.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to delete command.
|
||||
*/
|
||||
router.delete(
|
||||
"/command-history",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, command } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !command) {
|
||||
sshLogger.warn("Invalid data for command history deletion", {
|
||||
operation: "command_history_delete",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(commandHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(commandHistory.userId, userId),
|
||||
eq(commandHistory.hostId, hostId),
|
||||
eq(commandHistory.command, command),
|
||||
),
|
||||
);
|
||||
|
||||
res.json({ message: "Command deleted from history" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to delete command from history", err, {
|
||||
operation: "command_history_delete",
|
||||
hostId,
|
||||
userId,
|
||||
command,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete command" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { db } from "../db/index.js";
|
||||
import {
|
||||
fileManagerPinned,
|
||||
fileManagerRecent,
|
||||
fileManagerShortcuts,
|
||||
} from "../db/schema.js";
|
||||
import { isNonEmptyString } from "./host-normalizers.js";
|
||||
|
||||
export function registerHostFileManagerBookmarkRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/file_manager/recent:
|
||||
* get:
|
||||
* summary: Get recent files
|
||||
* description: Retrieves a list of recent files for a specific host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of recent files.
|
||||
* 400:
|
||||
* description: Invalid userId or hostId.
|
||||
* 500:
|
||||
* description: Failed to fetch recent files.
|
||||
*/
|
||||
router.get(
|
||||
"/file_manager/recent",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostIdQuery = Array.isArray(req.query.hostId)
|
||||
? req.query.hostId[0]
|
||||
: req.query.hostId;
|
||||
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
sshLogger.warn("Invalid userId for recent files fetch");
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
if (!hostId) {
|
||||
sshLogger.warn("Host ID is required for recent files fetch");
|
||||
return res.status(400).json({ error: "Host ID is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const recentFiles = await db
|
||||
.select()
|
||||
.from(fileManagerRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerRecent.userId, userId),
|
||||
eq(fileManagerRecent.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(fileManagerRecent.lastOpened))
|
||||
.limit(20);
|
||||
|
||||
res.json(recentFiles);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch recent files", err);
|
||||
res.status(500).json({ error: "Failed to fetch recent files" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/file_manager/recent:
|
||||
* post:
|
||||
* summary: Add recent file
|
||||
* description: Adds a file to the list of recent files for a host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* path:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Recent file added.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to add recent file.
|
||||
*/
|
||||
router.post(
|
||||
"/file_manager/recent",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, path, name } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
sshLogger.warn("Invalid data for recent file addition");
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(fileManagerRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerRecent.userId, userId),
|
||||
eq(fileManagerRecent.hostId, hostId),
|
||||
eq(fileManagerRecent.path, path),
|
||||
),
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(fileManagerRecent)
|
||||
.set({ lastOpened: new Date().toISOString() })
|
||||
.where(eq(fileManagerRecent.id, existing[0].id));
|
||||
} else {
|
||||
await db.insert(fileManagerRecent).values({
|
||||
userId,
|
||||
hostId,
|
||||
path,
|
||||
name: name || path.split("/").pop() || "Unknown",
|
||||
lastOpened: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: "Recent file added" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to add recent file", err);
|
||||
res.status(500).json({ error: "Failed to add recent file" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/file_manager/recent:
|
||||
* delete:
|
||||
* summary: Remove recent file
|
||||
* description: Removes a file from the list of recent files for a host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* path:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Recent file removed.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to remove recent file.
|
||||
*/
|
||||
router.delete(
|
||||
"/file_manager/recent",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, path } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
sshLogger.warn("Invalid data for recent file deletion");
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(fileManagerRecent)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerRecent.userId, userId),
|
||||
eq(fileManagerRecent.hostId, hostId),
|
||||
eq(fileManagerRecent.path, path),
|
||||
),
|
||||
);
|
||||
|
||||
res.json({ message: "Recent file removed" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to remove recent file", err);
|
||||
res.status(500).json({ error: "Failed to remove recent file" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/file_manager/pinned:
|
||||
* get:
|
||||
* summary: Get pinned files
|
||||
* description: Retrieves a list of pinned files for a specific host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of pinned files.
|
||||
* 400:
|
||||
* description: Invalid userId or hostId.
|
||||
* 500:
|
||||
* description: Failed to fetch pinned files.
|
||||
*/
|
||||
router.get(
|
||||
"/file_manager/pinned",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostIdQuery = Array.isArray(req.query.hostId)
|
||||
? req.query.hostId[0]
|
||||
: req.query.hostId;
|
||||
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
sshLogger.warn("Invalid userId for pinned files fetch");
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
if (!hostId) {
|
||||
sshLogger.warn("Host ID is required for pinned files fetch");
|
||||
return res.status(400).json({ error: "Host ID is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const pinnedFiles = await db
|
||||
.select()
|
||||
.from(fileManagerPinned)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerPinned.userId, userId),
|
||||
eq(fileManagerPinned.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(fileManagerPinned.pinnedAt));
|
||||
|
||||
res.json(pinnedFiles);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch pinned files", err);
|
||||
res.status(500).json({ error: "Failed to fetch pinned files" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/file_manager/pinned:
|
||||
* post:
|
||||
* summary: Add pinned file
|
||||
* description: Adds a file to the list of pinned files for a host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* path:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: File pinned.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 409:
|
||||
* description: File already pinned.
|
||||
* 500:
|
||||
* description: Failed to pin file.
|
||||
*/
|
||||
router.post(
|
||||
"/file_manager/pinned",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, path, name } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
sshLogger.warn("Invalid data for pinned file addition");
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(fileManagerPinned)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerPinned.userId, userId),
|
||||
eq(fileManagerPinned.hostId, hostId),
|
||||
eq(fileManagerPinned.path, path),
|
||||
),
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return res.status(409).json({ error: "File already pinned" });
|
||||
}
|
||||
|
||||
await db.insert(fileManagerPinned).values({
|
||||
userId,
|
||||
hostId,
|
||||
path,
|
||||
name: name || path.split("/").pop() || "Unknown",
|
||||
pinnedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.json({ message: "File pinned" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to pin file", err);
|
||||
res.status(500).json({ error: "Failed to pin file" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/file_manager/pinned:
|
||||
* delete:
|
||||
* summary: Remove pinned file
|
||||
* description: Removes a file from the list of pinned files for a host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* path:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Pinned file removed.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to remove pinned file.
|
||||
*/
|
||||
router.delete(
|
||||
"/file_manager/pinned",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, path } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
sshLogger.warn("Invalid data for pinned file deletion");
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(fileManagerPinned)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerPinned.userId, userId),
|
||||
eq(fileManagerPinned.hostId, hostId),
|
||||
eq(fileManagerPinned.path, path),
|
||||
),
|
||||
);
|
||||
|
||||
res.json({ message: "Pinned file removed" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to remove pinned file", err);
|
||||
res.status(500).json({ error: "Failed to remove pinned file" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/file_manager/shortcuts:
|
||||
* get:
|
||||
* summary: Get shortcuts
|
||||
* description: Retrieves a list of shortcuts for a specific host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of shortcuts.
|
||||
* 400:
|
||||
* description: Invalid userId or hostId.
|
||||
* 500:
|
||||
* description: Failed to fetch shortcuts.
|
||||
*/
|
||||
router.get(
|
||||
"/file_manager/shortcuts",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostIdQuery = Array.isArray(req.query.hostId)
|
||||
? req.query.hostId[0]
|
||||
: req.query.hostId;
|
||||
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
sshLogger.warn("Invalid userId for shortcuts fetch");
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
if (!hostId) {
|
||||
sshLogger.warn("Host ID is required for shortcuts fetch");
|
||||
return res.status(400).json({ error: "Host ID is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const shortcuts = await db
|
||||
.select()
|
||||
.from(fileManagerShortcuts)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerShortcuts.userId, userId),
|
||||
eq(fileManagerShortcuts.hostId, hostId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(fileManagerShortcuts.createdAt));
|
||||
|
||||
res.json(shortcuts);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch shortcuts", err);
|
||||
res.status(500).json({ error: "Failed to fetch shortcuts" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/file_manager/shortcuts:
|
||||
* post:
|
||||
* summary: Add shortcut
|
||||
* description: Adds a shortcut for a specific host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* path:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Shortcut added.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 409:
|
||||
* description: Shortcut already exists.
|
||||
* 500:
|
||||
* description: Failed to add shortcut.
|
||||
*/
|
||||
router.post(
|
||||
"/file_manager/shortcuts",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, path, name } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
sshLogger.warn("Invalid data for shortcut addition");
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(fileManagerShortcuts)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerShortcuts.userId, userId),
|
||||
eq(fileManagerShortcuts.hostId, hostId),
|
||||
eq(fileManagerShortcuts.path, path),
|
||||
),
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return res.status(409).json({ error: "Shortcut already exists" });
|
||||
}
|
||||
|
||||
await db.insert(fileManagerShortcuts).values({
|
||||
userId,
|
||||
hostId,
|
||||
path,
|
||||
name: name || path.split("/").pop() || "Unknown",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.json({ message: "Shortcut added" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to add shortcut", err);
|
||||
res.status(500).json({ error: "Failed to add shortcut" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/file_manager/shortcuts:
|
||||
* delete:
|
||||
* summary: Remove shortcut
|
||||
* description: Removes a shortcut for a specific host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* path:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Shortcut removed.
|
||||
* 400:
|
||||
* description: Invalid data.
|
||||
* 500:
|
||||
* description: Failed to remove shortcut.
|
||||
*/
|
||||
router.delete(
|
||||
"/file_manager/shortcuts",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, path } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
sshLogger.warn("Invalid data for shortcut deletion");
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(
|
||||
and(
|
||||
eq(fileManagerShortcuts.userId, userId),
|
||||
eq(fileManagerShortcuts.hostId, hostId),
|
||||
eq(fileManagerShortcuts.path, path),
|
||||
),
|
||||
);
|
||||
|
||||
res.json({ message: "Shortcut removed" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to remove shortcut", err);
|
||||
res.status(500).json({ error: "Failed to remove shortcut" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { and, eq, inArray, or } from "drizzle-orm";
|
||||
import { databaseLogger, sshLogger } from "../../utils/logger.js";
|
||||
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
|
||||
import { db, DatabaseSaveTrigger } from "../db/index.js";
|
||||
import {
|
||||
commandHistory,
|
||||
fileManagerPinned,
|
||||
fileManagerRecent,
|
||||
fileManagerShortcuts,
|
||||
hostAccess,
|
||||
hosts,
|
||||
recentActivity,
|
||||
sessionRecordings,
|
||||
sshCredentialUsage,
|
||||
sshCredentials,
|
||||
sshFolders,
|
||||
transferRecent,
|
||||
} from "../db/schema.js";
|
||||
import { isNonEmptyString } from "./host-normalizers.js";
|
||||
|
||||
type HostFolderRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
statsServerUrl: string;
|
||||
};
|
||||
|
||||
export function registerHostFolderRoutes(
|
||||
router: Router,
|
||||
{ authenticateJWT, statsServerUrl }: HostFolderRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/folders/rename:
|
||||
* put:
|
||||
* summary: Rename folder
|
||||
* description: Renames a folder for SSH hosts and credentials.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* oldName:
|
||||
* type: string
|
||||
* newName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Folder renamed successfully.
|
||||
* 400:
|
||||
* description: Old name and new name are required.
|
||||
* 500:
|
||||
* description: Failed to rename folder.
|
||||
*/
|
||||
router.put(
|
||||
"/folders/rename",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { oldName, newName } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !oldName || !newName) {
|
||||
sshLogger.warn("Invalid data for folder rename");
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Old name and new name are required" });
|
||||
}
|
||||
|
||||
if (oldName === newName) {
|
||||
return res.json({ message: "Folder name unchanged" });
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedHosts = await SimpleDBOps.update(
|
||||
hosts,
|
||||
"ssh_data",
|
||||
and(eq(hosts.userId, userId), eq(hosts.folder, oldName)),
|
||||
{
|
||||
folder: newName,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
userId,
|
||||
);
|
||||
|
||||
const updatedCredentials = await db
|
||||
.update(sshCredentials)
|
||||
.set({
|
||||
folder: newName,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
eq(sshCredentials.folder, oldName),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("folder_rename");
|
||||
|
||||
await db
|
||||
.update(sshFolders)
|
||||
.set({
|
||||
name: newName,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(
|
||||
and(eq(sshFolders.userId, userId), eq(sshFolders.name, oldName)),
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: "Folder renamed successfully",
|
||||
updatedHosts: updatedHosts.length,
|
||||
updatedCredentials: updatedCredentials.length,
|
||||
});
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to rename folder", err, {
|
||||
operation: "folder_rename",
|
||||
userId,
|
||||
oldName,
|
||||
newName,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to rename folder" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/folders:
|
||||
* get:
|
||||
* summary: Get all folders
|
||||
* description: Retrieves all folders for the authenticated user.
|
||||
* tags:
|
||||
* - SSH
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of folders.
|
||||
* 400:
|
||||
* description: Invalid user ID.
|
||||
* 500:
|
||||
* description: Failed to fetch folders.
|
||||
*/
|
||||
router.get(
|
||||
"/folders",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
return res.status(400).json({ error: "Invalid user ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const folders = await db
|
||||
.select()
|
||||
.from(sshFolders)
|
||||
.where(eq(sshFolders.userId, userId));
|
||||
|
||||
res.json(folders);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch folders", err, {
|
||||
operation: "fetch_folders",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch folders" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/folders/metadata:
|
||||
* put:
|
||||
* summary: Update folder metadata
|
||||
* description: Updates the metadata (color, icon) of a folder.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* color:
|
||||
* type: string
|
||||
* icon:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Folder metadata updated successfully.
|
||||
* 400:
|
||||
* description: Folder name is required.
|
||||
* 500:
|
||||
* description: Failed to update folder metadata.
|
||||
*/
|
||||
router.put(
|
||||
"/folders/metadata",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, color, icon } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !name) {
|
||||
return res.status(400).json({ error: "Folder name is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(sshFolders)
|
||||
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
databaseLogger.info("Updating SSH folder", {
|
||||
operation: "folder_update",
|
||||
userId,
|
||||
folderId: existing[0].id,
|
||||
});
|
||||
await db
|
||||
.update(sshFolders)
|
||||
.set({
|
||||
color,
|
||||
icon,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(
|
||||
and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)),
|
||||
);
|
||||
} else {
|
||||
databaseLogger.info("Creating SSH folder", {
|
||||
operation: "folder_create",
|
||||
userId,
|
||||
name,
|
||||
});
|
||||
await db.insert(sshFolders).values({
|
||||
userId,
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("folder_metadata_update");
|
||||
|
||||
res.json({ message: "Folder metadata updated successfully" });
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to update folder metadata", err, {
|
||||
operation: "update_folder_metadata",
|
||||
userId,
|
||||
name,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update folder metadata" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/folders/{name}/hosts:
|
||||
* delete:
|
||||
* summary: Delete all hosts in folder
|
||||
* description: Deletes all SSH hosts within a specific folder.
|
||||
* tags:
|
||||
* - SSH
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: name
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Hosts deleted successfully.
|
||||
* 400:
|
||||
* description: Invalid folder name.
|
||||
* 500:
|
||||
* description: Failed to delete hosts in folder.
|
||||
*/
|
||||
router.delete(
|
||||
"/folders/:name/hosts",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const folderName = Array.isArray(req.params.name)
|
||||
? req.params.name[0]
|
||||
: req.params.name;
|
||||
|
||||
if (!isNonEmptyString(userId) || !folderName) {
|
||||
return res.status(400).json({ error: "Invalid folder name" });
|
||||
}
|
||||
databaseLogger.info("Deleting SSH folder", {
|
||||
operation: "folder_delete",
|
||||
userId,
|
||||
folderId: folderName,
|
||||
});
|
||||
|
||||
try {
|
||||
const hostsToDelete = await db
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
|
||||
|
||||
if (hostsToDelete.length === 0) {
|
||||
return res.json({
|
||||
message: "No hosts found in folder",
|
||||
deletedCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const hostIds = hostsToDelete.map((host) => host.id);
|
||||
|
||||
if (hostIds.length > 0) {
|
||||
await db
|
||||
.delete(fileManagerRecent)
|
||||
.where(inArray(fileManagerRecent.hostId, hostIds));
|
||||
|
||||
await db
|
||||
.delete(fileManagerPinned)
|
||||
.where(inArray(fileManagerPinned.hostId, hostIds));
|
||||
|
||||
await db
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(inArray(fileManagerShortcuts.hostId, hostIds));
|
||||
|
||||
await db
|
||||
.delete(transferRecent)
|
||||
.where(
|
||||
or(
|
||||
inArray(transferRecent.sourceHostId, hostIds),
|
||||
inArray(transferRecent.destHostId, hostIds),
|
||||
),
|
||||
);
|
||||
|
||||
await db
|
||||
.delete(commandHistory)
|
||||
.where(inArray(commandHistory.hostId, hostIds));
|
||||
|
||||
await db
|
||||
.delete(sshCredentialUsage)
|
||||
.where(inArray(sshCredentialUsage.hostId, hostIds));
|
||||
|
||||
await db
|
||||
.delete(recentActivity)
|
||||
.where(inArray(recentActivity.hostId, hostIds));
|
||||
|
||||
await db
|
||||
.delete(hostAccess)
|
||||
.where(inArray(hostAccess.hostId, hostIds));
|
||||
|
||||
await db
|
||||
.delete(sessionRecordings)
|
||||
.where(inArray(sessionRecordings.hostId, hostIds));
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(hosts)
|
||||
.where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
|
||||
|
||||
await db
|
||||
.delete(sshFolders)
|
||||
.where(
|
||||
and(eq(sshFolders.userId, userId), eq(sshFolders.name, folderName)),
|
||||
);
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("folder_hosts_delete");
|
||||
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
for (const host of hostsToDelete) {
|
||||
try {
|
||||
await axios.post(
|
||||
`${statsServerUrl}/host-deleted`,
|
||||
{ hostId: host.id },
|
||||
{
|
||||
headers: {
|
||||
Authorization: req.headers.authorization || "",
|
||||
Cookie: req.headers.cookie || "",
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to notify stats server of host deletion", {
|
||||
operation: "folder_hosts_delete",
|
||||
hostId: host.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
sshLogger.warn("Failed to notify stats server of folder deletion", {
|
||||
operation: "folder_hosts_delete",
|
||||
folderName,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: "All hosts in folder deleted successfully",
|
||||
deletedCount: hostsToDelete.length,
|
||||
});
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to delete hosts in folder", err, {
|
||||
operation: "delete_folder_hosts",
|
||||
userId,
|
||||
folderName,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete hosts in folder" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import { and, eq, isNotNull } from "drizzle-orm";
|
||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { hosts } from "../db/schema.js";
|
||||
|
||||
export function registerHostInternalRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/db/host/internal:
|
||||
* get:
|
||||
* summary: Get internal SSH host data
|
||||
* description: Returns internal SSH host data for autostart tunnels. Requires internal auth token.
|
||||
* tags:
|
||||
* - SSH
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of autostart hosts.
|
||||
* 403:
|
||||
* description: Forbidden.
|
||||
* 500:
|
||||
* description: Failed to fetch autostart SSH data.
|
||||
*/
|
||||
router.get("/db/host/internal", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const internalToken = req.headers["x-internal-auth-token"];
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const expectedToken = await systemCrypto.getInternalAuthToken();
|
||||
|
||||
if (internalToken !== expectedToken) {
|
||||
sshLogger.warn(
|
||||
"Unauthorized attempt to access internal SSH host endpoint",
|
||||
{
|
||||
source: req.ip,
|
||||
userAgent: req.headers["user-agent"],
|
||||
providedToken: internalToken ? "present" : "missing",
|
||||
},
|
||||
);
|
||||
return res.status(403).json({ error: "Forbidden" });
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to validate internal auth token", error);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
|
||||
try {
|
||||
const autostartHosts = await db
|
||||
.select()
|
||||
.from(hosts)
|
||||
.where(
|
||||
and(eq(hosts.enableTunnel, true), isNotNull(hosts.tunnelConnections)),
|
||||
);
|
||||
|
||||
const result = autostartHosts
|
||||
.map((host) => {
|
||||
const tunnelConnections = host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections)
|
||||
: [];
|
||||
|
||||
const hasAutoStartTunnels = tunnelConnections.some(
|
||||
(tunnel: Record<string, unknown>) => tunnel.autoStart,
|
||||
);
|
||||
|
||||
if (!hasAutoStartTunnels) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: host.id,
|
||||
userId: host.userId,
|
||||
name: host.name || `autostart-${host.id}`,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
authType: host.authType,
|
||||
keyType: host.keyType,
|
||||
credentialId: host.credentialId,
|
||||
enableTunnel: true,
|
||||
tunnelConnections: tunnelConnections.filter(
|
||||
(tunnel: Record<string, unknown>) => tunnel.autoStart,
|
||||
),
|
||||
pin: !!host.pin,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableFileManager: !!host.enableFileManager,
|
||||
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
showDockerInSidebar: !!host.showDockerInSidebar,
|
||||
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
|
||||
tags: ["autostart"],
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch autostart SSH data", err);
|
||||
res.status(500).json({ error: "Failed to fetch autostart SSH data" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/db/host/internal/all:
|
||||
* get:
|
||||
* summary: Get all internal SSH host data
|
||||
* description: Returns all internal SSH host data. Requires internal auth token.
|
||||
* tags:
|
||||
* - SSH
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of all hosts.
|
||||
* 401:
|
||||
* description: Invalid or missing internal authentication token.
|
||||
* 500:
|
||||
* description: Failed to fetch all hosts.
|
||||
*/
|
||||
router.get("/db/host/internal/all", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const internalToken = req.headers["x-internal-auth-token"];
|
||||
if (!internalToken) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Internal authentication token required" });
|
||||
}
|
||||
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const expectedToken = await systemCrypto.getInternalAuthToken();
|
||||
|
||||
if (internalToken !== expectedToken) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Invalid internal authentication token" });
|
||||
}
|
||||
|
||||
const allHosts = await db.select().from(hosts);
|
||||
|
||||
const result = allHosts.map((host) => {
|
||||
const tunnelConnections = host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections)
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: host.id,
|
||||
userId: host.userId,
|
||||
name: host.name || `${host.username}@${host.ip}`,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
authType: host.authType,
|
||||
keyType: host.keyType,
|
||||
credentialId: host.credentialId,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
tunnelConnections: tunnelConnections,
|
||||
pin: !!host.pin,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableFileManager: !!host.enableFileManager,
|
||||
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
showDockerInSidebar: !!host.showDockerInSidebar,
|
||||
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
|
||||
defaultPath: host.defaultPath,
|
||||
createdAt: host.createdAt,
|
||||
updatedAt: host.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to fetch all hosts for internal use", err);
|
||||
res.status(500).json({ error: "Failed to fetch all hosts" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { sendWakeOnLan, isValidMac } from "../../utils/wake-on-lan.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { hosts } from "../db/schema.js";
|
||||
|
||||
interface HostNetworkRoutesDeps {
|
||||
authenticateJWT: RequestHandler;
|
||||
requireDataAccess: RequestHandler;
|
||||
}
|
||||
|
||||
export function registerHostNetworkRoutes(
|
||||
router: Router,
|
||||
{ authenticateJWT, requireDataAccess }: HostNetworkRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/db/proxy/test:
|
||||
* post:
|
||||
* summary: Test proxy connectivity
|
||||
* description: Tests connectivity through a proxy configuration to a target host.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* singleProxy:
|
||||
* type: object
|
||||
* properties:
|
||||
* host:
|
||||
* type: string
|
||||
* port:
|
||||
* type: number
|
||||
* type:
|
||||
* type: string
|
||||
* username:
|
||||
* type: string
|
||||
* password:
|
||||
* type: string
|
||||
* proxyChain:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* testTarget:
|
||||
* type: object
|
||||
* properties:
|
||||
* host:
|
||||
* type: string
|
||||
* port:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Test result
|
||||
* 500:
|
||||
* description: Proxy connection failed
|
||||
*/
|
||||
router.post(
|
||||
"/db/proxy/test",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { singleProxy, proxyChain, testTarget } = req.body;
|
||||
|
||||
const { testProxyConnectivity } =
|
||||
await import("../../utils/proxy-helper.js");
|
||||
|
||||
const result = await testProxyConnectivity({
|
||||
singleProxy,
|
||||
proxyChain,
|
||||
testTarget,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
sshLogger.error("Proxy connectivity test failed", error, {
|
||||
operation: "proxy_test",
|
||||
userId: req.userId,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/db/host/:id/wake",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const hostId = Number.parseInt(String(req.params.id), 10);
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
const host = await db
|
||||
.select({ macAddress: hosts.macAddress })
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
if (!host.macAddress || !isValidMac(host.macAddress)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "No valid MAC address configured" });
|
||||
}
|
||||
|
||||
await sendWakeOnLan(host.macAddress);
|
||||
|
||||
sshLogger.info("Wake-on-LAN packet sent", {
|
||||
operation: "wake_on_lan",
|
||||
userId,
|
||||
hostId,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
sshLogger.error("Wake-on-LAN failed", error, {
|
||||
operation: "wake_on_lan",
|
||||
userId,
|
||||
hostId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to send WoL packet",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isNonEmptyString,
|
||||
isValidPort,
|
||||
normalizeImportedHost,
|
||||
stripSensitiveFields,
|
||||
transformHostResponse,
|
||||
} from "./host-normalizers.js";
|
||||
|
||||
describe("isNonEmptyString", () => {
|
||||
it("accepts non-blank strings", () => {
|
||||
expect(isNonEmptyString("hello")).toBe(true);
|
||||
expect(isNonEmptyString(" x ")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects blank strings and non-strings", () => {
|
||||
expect(isNonEmptyString("")).toBe(false);
|
||||
expect(isNonEmptyString(" ")).toBe(false);
|
||||
expect(isNonEmptyString(123)).toBe(false);
|
||||
expect(isNonEmptyString(null)).toBe(false);
|
||||
expect(isNonEmptyString(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidPort", () => {
|
||||
it("accepts ports in range", () => {
|
||||
expect(isValidPort(1)).toBe(true);
|
||||
expect(isValidPort(22)).toBe(true);
|
||||
expect(isValidPort(65535)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects out-of-range or non-number ports", () => {
|
||||
expect(isValidPort(0)).toBe(false);
|
||||
expect(isValidPort(65536)).toBe(false);
|
||||
expect(isValidPort(-1)).toBe(false);
|
||||
expect(isValidPort("22")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeImportedHost", () => {
|
||||
it("defaults connectionType to ssh with port 22", () => {
|
||||
const host = normalizeImportedHost({ ip: "10.0.0.1" });
|
||||
expect(host.connectionType).toBe("ssh");
|
||||
expect(host.port).toBe(22);
|
||||
expect(host.enableSsh).toBe(true);
|
||||
expect(host.enableRdp).toBe(false);
|
||||
});
|
||||
|
||||
it("infers rdp from enableRdp and uses default rdp port", () => {
|
||||
const host = normalizeImportedHost({ enableRdp: true, ip: "10.0.0.2" });
|
||||
expect(host.connectionType).toBe("rdp");
|
||||
expect(host.port).toBe(3389);
|
||||
expect(host.enableRdp).toBe(true);
|
||||
});
|
||||
|
||||
it("honors an explicit port over protocol defaults", () => {
|
||||
const host = normalizeImportedHost({
|
||||
connectionType: "ssh",
|
||||
port: 2222,
|
||||
});
|
||||
expect(host.port).toBe(2222);
|
||||
});
|
||||
|
||||
it("resolves ip from common aliases", () => {
|
||||
expect(normalizeImportedHost({ address: "a.example" }).ip).toBe(
|
||||
"a.example",
|
||||
);
|
||||
expect(normalizeImportedHost({ hostname: "h.example" }).ip).toBe(
|
||||
"h.example",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes tags from a comma string", () => {
|
||||
const host = normalizeImportedHost({ tags: "prod, db , , web" });
|
||||
expect(host.tags).toEqual(["prod", "db", "web"]);
|
||||
});
|
||||
|
||||
it("normalizes tags from an array", () => {
|
||||
const host = normalizeImportedHost({ tags: ["a", " b ", "", "c"] });
|
||||
expect(host.tags).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("infers authType credential when credentialId present", () => {
|
||||
const host = normalizeImportedHost({ credentialId: 7 });
|
||||
expect(host.credentialId).toBe(7);
|
||||
expect(host.authType).toBe("credential");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripSensitiveFields", () => {
|
||||
it("removes secret fields and adds boolean presence flags", () => {
|
||||
const result = stripSensitiveFields({
|
||||
name: "web",
|
||||
password: "secret",
|
||||
key: "PRIVATE KEY",
|
||||
keyPassword: "kp",
|
||||
sudoPassword: "sp",
|
||||
});
|
||||
expect(result.password).toBeUndefined();
|
||||
expect(result.key).toBeUndefined();
|
||||
expect(result.keyPassword).toBeUndefined();
|
||||
expect(result.sudoPassword).toBeUndefined();
|
||||
expect(result.hasPassword).toBe(true);
|
||||
expect(result.hasKey).toBe(true);
|
||||
expect(result.hasKeyPassword).toBe(true);
|
||||
expect(result.hasSudoPassword).toBe(true);
|
||||
expect(result.name).toBe("web");
|
||||
});
|
||||
|
||||
it("marks presence flags false when secrets are absent", () => {
|
||||
const result = stripSensitiveFields({ name: "web" });
|
||||
expect(result.hasPassword).toBe(false);
|
||||
expect(result.hasKey).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformHostResponse", () => {
|
||||
it("parses tags and coerces enable flags to booleans", () => {
|
||||
const result = transformHostResponse({
|
||||
tags: "a,b,c",
|
||||
enableTerminal: 1,
|
||||
enableTunnel: 0,
|
||||
pin: 1,
|
||||
});
|
||||
expect(result.tags).toEqual(["a", "b", "c"]);
|
||||
expect(result.enableTerminal).toBe(true);
|
||||
expect(result.enableTunnel).toBe(false);
|
||||
expect(result.pin).toBe(true);
|
||||
});
|
||||
|
||||
it("parses JSON array fields and defaults them to []", () => {
|
||||
const result = transformHostResponse({
|
||||
tunnelConnections: '[{"sourcePort":8080}]',
|
||||
jumpHosts: null,
|
||||
});
|
||||
expect(result.tunnelConnections).toEqual([{ sourcePort: 8080 }]);
|
||||
expect(result.jumpHosts).toEqual([]);
|
||||
});
|
||||
|
||||
it("infers protocol flags for a migrated non-ssh host", () => {
|
||||
const result = transformHostResponse({
|
||||
connectionType: "rdp",
|
||||
enableSsh: true,
|
||||
});
|
||||
expect(result.enableSsh).toBe(false);
|
||||
expect(result.enableRdp).toBe(true);
|
||||
});
|
||||
|
||||
it("applies default protocol ports", () => {
|
||||
const result = transformHostResponse({ port: 22 });
|
||||
expect(result.sshPort).toBe(22);
|
||||
expect(result.rdpPort).toBe(3389);
|
||||
expect(result.vncPort).toBe(5900);
|
||||
expect(result.telnetPort).toBe(23);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
export function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
export function isValidPort(port: unknown): port is number {
|
||||
return typeof port === "number" && port > 0 && port <= 65535;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function asPort(value: unknown): number | undefined {
|
||||
const port =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Number.parseInt(value, 10)
|
||||
: NaN;
|
||||
|
||||
return isValidPort(port) ? port : undefined;
|
||||
}
|
||||
|
||||
function asInteger(value: unknown): number | undefined {
|
||||
const number =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Number.parseInt(value, 10)
|
||||
: NaN;
|
||||
|
||||
return Number.isInteger(number) ? number : undefined;
|
||||
}
|
||||
|
||||
function asBoolean(value: unknown, fallback = false): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value !== 0;
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (["true", "1", "yes", "on"].includes(normalized)) return true;
|
||||
if (["false", "0", "no", "off"].includes(normalized)) return false;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeImportTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((tag) => asString(tag))
|
||||
.filter((tag): tag is string => !!tag);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export type NormalizedImportedHost = Record<string, unknown> & {
|
||||
connectionType: string;
|
||||
name?: string;
|
||||
ip?: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
folder?: string;
|
||||
tags: string[];
|
||||
authType?: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
credentialId?: number;
|
||||
pin?: unknown;
|
||||
enableTerminal?: unknown;
|
||||
enableTunnel?: unknown;
|
||||
enableFileManager?: unknown;
|
||||
enableDocker?: unknown;
|
||||
showTerminalInSidebar?: unknown;
|
||||
showFileManagerInSidebar?: unknown;
|
||||
showTunnelInSidebar?: unknown;
|
||||
showDockerInSidebar?: unknown;
|
||||
showServerStatsInSidebar?: unknown;
|
||||
defaultPath?: unknown;
|
||||
sudoPassword?: unknown;
|
||||
tunnelConnections?: unknown;
|
||||
jumpHosts?: unknown;
|
||||
quickActions?: unknown;
|
||||
statsConfig?: unknown;
|
||||
dockerConfig?: unknown;
|
||||
terminalConfig?: unknown;
|
||||
forceKeyboardInteractive?: unknown;
|
||||
notes?: unknown;
|
||||
useSocks5?: unknown;
|
||||
socks5Host?: unknown;
|
||||
socks5Port?: unknown;
|
||||
socks5Username?: unknown;
|
||||
socks5Password?: unknown;
|
||||
socks5ProxyChain?: unknown;
|
||||
portKnockSequence?: unknown;
|
||||
overrideCredentialUsername?: unknown;
|
||||
domain?: unknown;
|
||||
security?: unknown;
|
||||
ignoreCert?: unknown;
|
||||
guacamoleConfig?: unknown;
|
||||
enableSsh: boolean;
|
||||
enableRdp: boolean;
|
||||
enableVnc: boolean;
|
||||
enableTelnet: boolean;
|
||||
};
|
||||
|
||||
export function normalizeImportedHost(
|
||||
hostData: Record<string, unknown>,
|
||||
): NormalizedImportedHost {
|
||||
const connectionType =
|
||||
asString(hostData.connectionType) ||
|
||||
(asBoolean(hostData.enableRdp)
|
||||
? "rdp"
|
||||
: asBoolean(hostData.enableVnc)
|
||||
? "vnc"
|
||||
: asBoolean(hostData.enableTelnet)
|
||||
? "telnet"
|
||||
: "ssh");
|
||||
|
||||
const port =
|
||||
asPort(hostData.port) ||
|
||||
(connectionType === "rdp"
|
||||
? asPort(hostData.rdpPort) || 3389
|
||||
: connectionType === "vnc"
|
||||
? asPort(hostData.vncPort) || 5900
|
||||
: connectionType === "telnet"
|
||||
? asPort(hostData.telnetPort) || 23
|
||||
: asPort(hostData.sshPort) || 22);
|
||||
|
||||
return {
|
||||
...hostData,
|
||||
connectionType,
|
||||
name: asString(hostData.name) || asString(hostData.label),
|
||||
ip:
|
||||
asString(hostData.ip) ||
|
||||
asString(hostData.address) ||
|
||||
asString(hostData.host) ||
|
||||
asString(hostData.hostname),
|
||||
port,
|
||||
username: asString(hostData.username) || asString(hostData.user),
|
||||
folder: asString(hostData.folder) || asString(hostData.group),
|
||||
tags: normalizeImportTags(hostData.tags),
|
||||
credentialId: asInteger(hostData.credentialId),
|
||||
authType:
|
||||
asString(hostData.authType) ||
|
||||
asString(hostData.authMethod) ||
|
||||
(hostData.credentialId ? "credential" : hostData.key ? "key" : undefined),
|
||||
enableSsh:
|
||||
hostData.enableSsh === undefined
|
||||
? connectionType === "ssh"
|
||||
: asBoolean(hostData.enableSsh),
|
||||
enableRdp:
|
||||
hostData.enableRdp === undefined
|
||||
? connectionType === "rdp"
|
||||
: asBoolean(hostData.enableRdp),
|
||||
enableVnc:
|
||||
hostData.enableVnc === undefined
|
||||
? connectionType === "vnc"
|
||||
: asBoolean(hostData.enableVnc),
|
||||
enableTelnet:
|
||||
hostData.enableTelnet === undefined
|
||||
? connectionType === "telnet"
|
||||
: asBoolean(hostData.enableTelnet),
|
||||
};
|
||||
}
|
||||
|
||||
const SENSITIVE_FIELDS = [
|
||||
"key",
|
||||
"keyPassword",
|
||||
"autostartKey",
|
||||
"autostartKeyPassword",
|
||||
"password",
|
||||
"sudoPassword",
|
||||
"socks5Password",
|
||||
"rdpPassword",
|
||||
"vncPassword",
|
||||
"telnetPassword",
|
||||
"autostartPassword",
|
||||
];
|
||||
|
||||
export function stripSensitiveFields(
|
||||
host: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const result = { ...host };
|
||||
result.hasKey = !!host.key;
|
||||
result.hasKeyPassword = !!host.keyPassword;
|
||||
result.hasPassword = !!host.password;
|
||||
result.hasSudoPassword = !!host.sudoPassword;
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
delete result[field];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function transformHostResponse(
|
||||
host: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...host,
|
||||
tags:
|
||||
typeof host.tags === "string"
|
||||
? host.tags
|
||||
? host.tags.split(",").filter(Boolean)
|
||||
: []
|
||||
: [],
|
||||
pin: !!host.pin,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
enableFileManager: !!host.enableFileManager,
|
||||
enableDocker: !!host.enableDocker,
|
||||
showTerminalInSidebar: !!host.showTerminalInSidebar,
|
||||
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
showDockerInSidebar: !!host.showDockerInSidebar,
|
||||
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
|
||||
// Old hosts only had connection_type set; the per-protocol enable flags didn't exist yet.
|
||||
// The schema defaults (enableSsh=true, others=false) wrongly mark every old host as SSH.
|
||||
// Detect this migration case: if no non-SSH protocol is explicitly enabled AND
|
||||
// connectionType is set to a non-SSH value, fall back to inferring from connectionType.
|
||||
...(() => {
|
||||
const ct = host.connectionType;
|
||||
const rdp = !!host.enableRdp;
|
||||
const vnc = !!host.enableVnc;
|
||||
const tel = !!host.enableTelnet;
|
||||
const isMigratedNonSsh = !rdp && !vnc && !tel && ct && ct !== "ssh";
|
||||
return {
|
||||
enableSsh: isMigratedNonSsh ? false : !!host.enableSsh,
|
||||
enableRdp: isMigratedNonSsh ? ct === "rdp" : rdp,
|
||||
enableVnc: isMigratedNonSsh ? ct === "vnc" : vnc,
|
||||
enableTelnet: isMigratedNonSsh ? ct === "telnet" : tel,
|
||||
};
|
||||
})(),
|
||||
sshPort: host.sshPort ?? host.port ?? 22,
|
||||
rdpPort: host.rdpPort ?? 3389,
|
||||
vncPort: host.vncPort ?? 5900,
|
||||
telnetPort: host.telnetPort ?? 23,
|
||||
rdpUser: host.rdpUser || undefined,
|
||||
rdpDomain: host.rdpDomain || undefined,
|
||||
rdpSecurity: host.rdpSecurity || undefined,
|
||||
rdpIgnoreCert: !!host.rdpIgnoreCert,
|
||||
vncUser: host.vncUser || undefined,
|
||||
telnetUser: host.telnetUser || undefined,
|
||||
tunnelConnections: host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections as string)
|
||||
: [],
|
||||
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [],
|
||||
quickActions: host.quickActions
|
||||
? JSON.parse(host.quickActions as string)
|
||||
: [],
|
||||
statsConfig: host.statsConfig
|
||||
? JSON.parse(host.statsConfig as string)
|
||||
: undefined,
|
||||
terminalConfig: host.terminalConfig
|
||||
? JSON.parse(host.terminalConfig as string)
|
||||
: undefined,
|
||||
dockerConfig: host.dockerConfig
|
||||
? JSON.parse(host.dockerConfig as string)
|
||||
: undefined,
|
||||
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
|
||||
socks5ProxyChain: host.socks5ProxyChain
|
||||
? JSON.parse(host.socks5ProxyChain as string)
|
||||
: [],
|
||||
portKnockSequence: host.portKnockSequence
|
||||
? JSON.parse(host.portKnockSequence as string)
|
||||
: [],
|
||||
domain: host.domain || undefined,
|
||||
security: host.security || undefined,
|
||||
ignoreCert: !!host.ignoreCert,
|
||||
guacamoleConfig: host.guacamoleConfig
|
||||
? JSON.parse(host.guacamoleConfig as string)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,841 @@
|
||||
import type { Router, Request, Response } from "express";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
normalizeSelectOpParam,
|
||||
renderOpksshErrorPage,
|
||||
rewriteOPKSSHHtml,
|
||||
} from "./opkssh-html.js";
|
||||
|
||||
export function registerHostOpksshRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/opkssh-chooser/{requestId}:
|
||||
* get:
|
||||
* summary: Proxy OPKSSH provider chooser page and all related resources
|
||||
* tags: [SSH]
|
||||
* parameters:
|
||||
* - name: requestId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Authentication request ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Chooser page content
|
||||
* 404:
|
||||
* description: Session not found
|
||||
* 500:
|
||||
* description: Proxy error
|
||||
*/
|
||||
|
||||
router.use(
|
||||
"/opkssh-chooser/:requestId",
|
||||
async (req: Request, res: Response) => {
|
||||
const requestId = Array.isArray(req.params.requestId)
|
||||
? req.params.requestId[0]
|
||||
: req.params.requestId;
|
||||
|
||||
const fullPath = req.originalUrl || req.url;
|
||||
const pathAfterRequestIdTemp =
|
||||
fullPath.split(`/host/opkssh-chooser/${requestId}`)[1] || "";
|
||||
|
||||
sshLogger.info("OPKSSH chooser proxy request", {
|
||||
operation: "opkssh_chooser_proxy_request",
|
||||
requestId,
|
||||
url: req.url,
|
||||
originalUrl: req.originalUrl,
|
||||
fullPath,
|
||||
pathAfterRequestId: pathAfterRequestIdTemp,
|
||||
method: req.method,
|
||||
});
|
||||
|
||||
try {
|
||||
const { getActiveAuthSession, registerOAuthState } =
|
||||
await import("../../ssh/opkssh-auth.js");
|
||||
const session = getActiveAuthSession(requestId);
|
||||
|
||||
if (!session) {
|
||||
sshLogger.error("Session not found for chooser request", {
|
||||
operation: "opkssh_chooser_session_not_found",
|
||||
requestId,
|
||||
});
|
||||
res.status(404).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "Session Not Found",
|
||||
heading: "Session Not Found",
|
||||
message: "This authentication session has expired or is invalid.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const axios = (await import("axios")).default;
|
||||
|
||||
const fullPath = req.originalUrl || req.url;
|
||||
const pathAfterRequestId =
|
||||
fullPath.split(`/host/opkssh-chooser/${requestId}`)[1] || "";
|
||||
const targetPath = pathAfterRequestId || "/chooser";
|
||||
|
||||
if (!session.localPort || session.localPort === 0) {
|
||||
sshLogger.error("OPKSSH session has no local port", {
|
||||
operation: "opkssh_chooser_proxy",
|
||||
requestId,
|
||||
sessionStatus: session.status,
|
||||
});
|
||||
res.status(500).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "Error",
|
||||
heading: "Authentication Error",
|
||||
message:
|
||||
"Failed to load authentication page. OPKSSH process may not be ready yet. Please try again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// /select on OPKSSH's chooser redirects (possibly via multiple local hops) to the
|
||||
// external OAuth provider URL. The hops we may see:
|
||||
// 1. /select -> /select/ (Go ServeMux canonicalization, same chooser port)
|
||||
// 2. /select/?op=ALIAS -> http://localhost:CALLBACK_PORT/login (OPKSSH's separate callback listener)
|
||||
// 3. /login on the callback listener -> https://<provider>/authorize?... (external OAuth URL)
|
||||
if (targetPath.startsWith("/select")) {
|
||||
const selectaxios = (await import("axios")).default;
|
||||
const rawQs = targetPath.includes("?")
|
||||
? targetPath.slice(targetPath.indexOf("?"))
|
||||
: "";
|
||||
|
||||
let qs = rawQs;
|
||||
let opMappedFrom: string | undefined;
|
||||
if (rawQs) {
|
||||
try {
|
||||
const params = new URLSearchParams(rawQs.replace(/^\?/, ""));
|
||||
const rawOp = params.get("op");
|
||||
if (rawOp) {
|
||||
const mappedOp = normalizeSelectOpParam(
|
||||
rawOp,
|
||||
session.providers || [],
|
||||
);
|
||||
if (mappedOp !== rawOp) {
|
||||
params.set("op", mappedOp);
|
||||
qs = `?${params.toString()}`;
|
||||
opMappedFrom = rawOp;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* keep rawQs if parsing fails */
|
||||
}
|
||||
}
|
||||
|
||||
const chooserHost = `127.0.0.1:${session.localPort}`;
|
||||
const startUrl = `http://${chooserHost}/select/${qs}`;
|
||||
|
||||
sshLogger.info("Proxying OPKSSH /select", {
|
||||
operation: "opkssh_select_proxy",
|
||||
requestId,
|
||||
targetUrl: startUrl,
|
||||
opMappedFrom,
|
||||
});
|
||||
|
||||
const isLocalHostname = (host: string): boolean => {
|
||||
const bare = host.split(":")[0];
|
||||
return (
|
||||
bare === "127.0.0.1" || bare === "localhost" || bare === "[::1]"
|
||||
);
|
||||
};
|
||||
|
||||
interface UpstreamResponse {
|
||||
status: number;
|
||||
location?: string;
|
||||
contentType: string;
|
||||
body: string;
|
||||
targetUrl: string;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
const fetchUpstream = async (
|
||||
url: string,
|
||||
): Promise<UpstreamResponse> => {
|
||||
const started = Date.now();
|
||||
let hostHeader = chooserHost;
|
||||
try {
|
||||
hostHeader = new URL(url).host;
|
||||
} catch {
|
||||
/* fall back to chooser host */
|
||||
}
|
||||
const r = await selectaxios({
|
||||
method: "GET",
|
||||
url,
|
||||
maxRedirects: 0,
|
||||
validateStatus: () => true,
|
||||
timeout: 10000,
|
||||
responseType: "text",
|
||||
transformResponse: (v) => v,
|
||||
headers: { host: hostHeader },
|
||||
});
|
||||
const locHeader = r.headers["location"];
|
||||
const location = Array.isArray(locHeader)
|
||||
? locHeader[0]
|
||||
: locHeader;
|
||||
const ctHeader = r.headers["content-type"];
|
||||
const ctRaw = Array.isArray(ctHeader) ? ctHeader[0] : ctHeader;
|
||||
const contentType = typeof ctRaw === "string" ? ctRaw : "";
|
||||
const body =
|
||||
typeof r.data === "string" ? r.data : String(r.data ?? "");
|
||||
return {
|
||||
status: r.status,
|
||||
location: typeof location === "string" ? location : undefined,
|
||||
contentType,
|
||||
body,
|
||||
targetUrl: url,
|
||||
elapsedMs: Date.now() - started,
|
||||
};
|
||||
};
|
||||
|
||||
const logResponse = (response: UpstreamResponse): void => {
|
||||
sshLogger.info("OPKSSH /select upstream response", {
|
||||
operation: "opkssh_select_upstream_response",
|
||||
requestId,
|
||||
targetUrl: response.targetUrl,
|
||||
status: response.status,
|
||||
location: response.location,
|
||||
contentType: response.contentType,
|
||||
elapsedMs: response.elapsedMs,
|
||||
bodyPreview: response.body.slice(0, 256),
|
||||
});
|
||||
};
|
||||
|
||||
const MAX_HOPS = 4;
|
||||
|
||||
try {
|
||||
let response = await fetchUpstream(startUrl);
|
||||
logResponse(response);
|
||||
|
||||
for (let hop = 0; hop < MAX_HOPS; hop++) {
|
||||
if (
|
||||
response.status < 300 ||
|
||||
response.status >= 400 ||
|
||||
!response.location
|
||||
) {
|
||||
break;
|
||||
}
|
||||
const loc = response.location;
|
||||
|
||||
// Relative path: resolve against the current upstream.
|
||||
if (loc.startsWith("/")) {
|
||||
let currentHost = chooserHost;
|
||||
try {
|
||||
currentHost = new URL(response.targetUrl).host;
|
||||
} catch {
|
||||
/* keep default */
|
||||
}
|
||||
response = await fetchUpstream(`http://${currentHost}${loc}`);
|
||||
logResponse(response);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Absolute URL: if it points to a localhost OPKSSH endpoint, capture
|
||||
// the port. Then redirect the BROWSER to the proxied path so that
|
||||
// Set-Cookie headers from OPKSSH's /login handler reach the browser
|
||||
// directly — following them server-side would swallow the cookie.
|
||||
if (/^https?:\/\//i.test(loc)) {
|
||||
try {
|
||||
const parsed = new URL(loc);
|
||||
if (isLocalHostname(parsed.host)) {
|
||||
// Capture callback listener port if not yet known.
|
||||
if (!session.callbackPort) {
|
||||
const port = parseInt(parsed.port, 10);
|
||||
if (!Number.isNaN(port)) {
|
||||
session.callbackPort = port;
|
||||
sshLogger.info(
|
||||
"Captured OPKSSH callback listener port from /select redirect",
|
||||
{
|
||||
operation: "opkssh_select_callback_port_detected",
|
||||
requestId,
|
||||
callbackPort: port,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
// Redirect browser through the chooser proxy so it can receive
|
||||
// the state cookie that OPKSSH sets on /login.
|
||||
const browserPath = `/host/opkssh-chooser/${requestId}${parsed.pathname}${parsed.search}`;
|
||||
sshLogger.info(
|
||||
"Redirecting browser to OPKSSH callback listener via proxy",
|
||||
{
|
||||
operation: "opkssh_select_browser_redirect_to_login",
|
||||
requestId,
|
||||
browserPath,
|
||||
callbackPort: session.callbackPort,
|
||||
},
|
||||
);
|
||||
res.redirect(302, browserPath);
|
||||
return;
|
||||
}
|
||||
// External OAuth provider URL — done, handled below.
|
||||
break;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
const isExternalRedirect =
|
||||
response.status >= 300 &&
|
||||
response.status < 400 &&
|
||||
!!response.location &&
|
||||
/^https?:\/\//i.test(response.location) &&
|
||||
(() => {
|
||||
try {
|
||||
return !isLocalHostname(
|
||||
new URL(response.location as string).host,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (isExternalRedirect) {
|
||||
const oauthUrl = response.location as string;
|
||||
try {
|
||||
const parsed = new URL(oauthUrl);
|
||||
const oauthState = parsed.searchParams.get("state");
|
||||
if (oauthState) registerOAuthState(oauthState, requestId);
|
||||
} catch {
|
||||
/* already validated above */
|
||||
}
|
||||
sshLogger.info(
|
||||
"OPKSSH /select redirecting browser to OAuth provider",
|
||||
{
|
||||
operation: "opkssh_select_redirect",
|
||||
requestId,
|
||||
oauthUrl,
|
||||
},
|
||||
);
|
||||
res.redirect(302, oauthUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const bodyPreview = response.body.slice(0, 512);
|
||||
const detailLines = [
|
||||
`Upstream: ${response.targetUrl}`,
|
||||
`Status: ${response.status}`,
|
||||
response.location ? `Location: ${response.location}` : undefined,
|
||||
`Content-Type: ${response.contentType || "(none)"}`,
|
||||
`Elapsed: ${response.elapsedMs}ms`,
|
||||
"",
|
||||
bodyPreview
|
||||
? `Body (first 512 chars):\n${bodyPreview}`
|
||||
: "Body: (empty)",
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
sshLogger.error(
|
||||
"OPKSSH /select did not produce an OAuth redirect",
|
||||
{
|
||||
operation: "opkssh_select_no_oauth_redirect",
|
||||
requestId,
|
||||
status: response.status,
|
||||
location: response.location,
|
||||
contentType: response.contentType,
|
||||
bodyPreview,
|
||||
},
|
||||
);
|
||||
|
||||
res.status(502).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "OPKSSH error",
|
||||
heading: "Failed to get OAuth redirect",
|
||||
message:
|
||||
"OPKSSH did not return an external OAuth provider URL. " +
|
||||
"This typically indicates a configuration mismatch between the provider's redirect_uris " +
|
||||
"and the Termix callback path. Check the server log for the OPKSSH response body.",
|
||||
details: detailLines.join("\n"),
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
sshLogger.error("Error proxying OPKSSH /select", err, {
|
||||
operation: "opkssh_select_proxy_error",
|
||||
requestId,
|
||||
targetUrl: startUrl,
|
||||
});
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
res.status(502).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "OPKSSH error",
|
||||
heading: "Failed to reach OPKSSH service",
|
||||
message:
|
||||
"Termix could not connect to the local OPKSSH authentication service. " +
|
||||
"The OPKSSH process may have exited or is not listening yet.",
|
||||
details: `Upstream: ${startUrl}\nError: ${errMsg}`,
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Paths served by the callback listener, not the chooser.
|
||||
// The browser is redirected here so it receives Set-Cookie from OPKSSH.
|
||||
const isCallbackListenerPath =
|
||||
targetPath === "/login" ||
|
||||
targetPath.startsWith("/login?") ||
|
||||
targetPath === "/login-callback" ||
|
||||
targetPath.startsWith("/login-callback?");
|
||||
|
||||
const upstreamPort =
|
||||
isCallbackListenerPath && session.callbackPort
|
||||
? session.callbackPort
|
||||
: session.localPort;
|
||||
|
||||
const targetUrl = `http://127.0.0.1:${upstreamPort}${targetPath}`;
|
||||
|
||||
sshLogger.info("Proxying to OPKSSH chooser", {
|
||||
operation: "opkssh_chooser_proxy_request_to_opkssh",
|
||||
requestId,
|
||||
targetUrl,
|
||||
upstreamPort,
|
||||
targetPath,
|
||||
});
|
||||
|
||||
const response = await axios({
|
||||
method: req.method,
|
||||
url: targetUrl,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: `127.0.0.1:${upstreamPort}`,
|
||||
},
|
||||
data: req.body,
|
||||
timeout: 10000,
|
||||
validateStatus: () => true,
|
||||
maxRedirects: 0,
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
|
||||
sshLogger.info("OPKSSH chooser response received", {
|
||||
operation: "opkssh_chooser_proxy_response",
|
||||
requestId,
|
||||
statusCode: response.status,
|
||||
contentType: response.headers["content-type"],
|
||||
contentLength: response.headers["content-length"],
|
||||
hasLocation: !!response.headers.location,
|
||||
});
|
||||
|
||||
Object.entries(response.headers).forEach(([key, value]) => {
|
||||
if (key.toLowerCase() === "transfer-encoding") {
|
||||
return;
|
||||
}
|
||||
if (key.toLowerCase() === "location") {
|
||||
const location = value as string;
|
||||
if (location.startsWith("/")) {
|
||||
res.setHeader(
|
||||
key,
|
||||
`/host/opkssh-chooser/${requestId}${location}`,
|
||||
);
|
||||
} else {
|
||||
const localhostMatch = location.match(
|
||||
/^http:\/\/(?:localhost|127\.0\.0\.1):(\d+)(\/.*)?$/,
|
||||
);
|
||||
if (localhostMatch) {
|
||||
const port = parseInt(localhostMatch[1], 10);
|
||||
const path = localhostMatch[2] || "/";
|
||||
if (session.callbackPort && port === session.callbackPort) {
|
||||
res.setHeader(
|
||||
key,
|
||||
`/host/opkssh-callback/${requestId}${path}`,
|
||||
);
|
||||
} else if (port === session.localPort) {
|
||||
res.setHeader(
|
||||
key,
|
||||
`/host/opkssh-chooser/${requestId}${path}`,
|
||||
);
|
||||
} else {
|
||||
const isCallback =
|
||||
path.includes("login") || path.includes("callback");
|
||||
const prefix = isCallback
|
||||
? "opkssh-callback"
|
||||
: "opkssh-chooser";
|
||||
res.setHeader(key, `/host/${prefix}/${requestId}${path}`);
|
||||
}
|
||||
} else {
|
||||
// External redirect (e.g. to OIDC provider) — capture OAuth state for session binding
|
||||
try {
|
||||
const redirectUrl = new URL(location);
|
||||
const oauthState = redirectUrl.searchParams.get("state");
|
||||
if (oauthState) {
|
||||
registerOAuthState(oauthState, requestId);
|
||||
}
|
||||
} catch {
|
||||
// Not a valid URL, skip state capture
|
||||
}
|
||||
res.setHeader(key, value as string);
|
||||
}
|
||||
}
|
||||
} else if (key.toLowerCase() === "set-cookie") {
|
||||
// Rewrite cookies from OPKSSH's internal listener so they are scoped
|
||||
// to the Termix proxy path instead of OPKSSH's internal path.
|
||||
// The state cookie set by /login must survive to /login-callback.
|
||||
const cookies = Array.isArray(value) ? value : [value as string];
|
||||
const rewritten = cookies.map((cookie) => {
|
||||
return cookie
|
||||
.replace(/;\s*domain=[^;]*/gi, "")
|
||||
.replace(/;\s*path=[^;]*/gi, "; Path=/host/opkssh-callback/")
|
||||
.concat(
|
||||
cookie.match(/;\s*path=/i)
|
||||
? ""
|
||||
: "; Path=/host/opkssh-callback/",
|
||||
);
|
||||
});
|
||||
res.setHeader(key, rewritten);
|
||||
} else {
|
||||
res.setHeader(key, value as string);
|
||||
}
|
||||
});
|
||||
|
||||
// Set a cookie to correlate this browser with the requestId.
|
||||
// OAuth state capture from Location headers only works for 3xx redirects;
|
||||
// if OPKSSH redirects via JavaScript, the state is never registered.
|
||||
// This cookie survives the OIDC round-trip and identifies the session on callback.
|
||||
res.cookie("opkssh_request_id", requestId, {
|
||||
path: "/host/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
maxAge: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const contentType = String(response.headers["content-type"] || "");
|
||||
if (contentType.includes("text/html")) {
|
||||
const html = rewriteOPKSSHHtml(
|
||||
response.data.toString("utf-8"),
|
||||
requestId,
|
||||
"opkssh-chooser",
|
||||
);
|
||||
res.status(response.status).send(html);
|
||||
} else {
|
||||
res.status(response.status).send(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error("Error proxying OPKSSH chooser", error, {
|
||||
operation: "opkssh_chooser_proxy_error",
|
||||
requestId,
|
||||
});
|
||||
res.status(500).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "Error",
|
||||
heading: "Error",
|
||||
message: "Failed to load authentication page. Please try again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/opkssh-callback:
|
||||
* get:
|
||||
* summary: Static OAuth callback from OIDC provider for OPKSSH authentication
|
||||
* tags: [SSH]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Callback processed successfully
|
||||
* 404:
|
||||
* description: No active authentication session found
|
||||
* 500:
|
||||
* description: Authentication failed
|
||||
*/
|
||||
router.get("/opkssh-callback", async (req: Request, res: Response) => {
|
||||
try {
|
||||
sshLogger.info("OAuth callback received", {
|
||||
operation: "opkssh_static_callback_received",
|
||||
host: req.headers.host,
|
||||
});
|
||||
|
||||
const {
|
||||
getUserIdFromRequest,
|
||||
getActiveSessionsForUser,
|
||||
getActiveAuthSession,
|
||||
getRequestIdByOAuthState,
|
||||
clearOAuthState,
|
||||
} = await import("../../ssh/opkssh-auth.js");
|
||||
|
||||
const userId = await getUserIdFromRequest({
|
||||
cookies: req.cookies,
|
||||
headers: req.headers as Record<string, string | undefined>,
|
||||
});
|
||||
|
||||
sshLogger.info("User ID resolved", {
|
||||
operation: "opkssh_callback_user_lookup",
|
||||
userId: userId || "null",
|
||||
hasCookies: !!req.cookies?.jwt,
|
||||
cookieKeys: Object.keys(req.cookies || {}),
|
||||
});
|
||||
|
||||
let userSessions: Awaited<ReturnType<typeof getActiveSessionsForUser>> =
|
||||
[];
|
||||
|
||||
if (userId) {
|
||||
userSessions = getActiveSessionsForUser(userId);
|
||||
} else {
|
||||
// No JWT cookie (e.g. OAuth redirect landed in external browser).
|
||||
// Try to find the correct session via the OAuth state parameter.
|
||||
const oauthState = req.query.state as string | undefined;
|
||||
|
||||
if (oauthState) {
|
||||
const mappedRequestId = getRequestIdByOAuthState(oauthState);
|
||||
if (mappedRequestId) {
|
||||
const mappedSession = getActiveAuthSession(mappedRequestId);
|
||||
if (mappedSession) {
|
||||
userSessions = [mappedSession];
|
||||
clearOAuthState(oauthState);
|
||||
sshLogger.info("Resolved session via OAuth state parameter", {
|
||||
operation: "opkssh_callback_state_lookup",
|
||||
requestId: mappedRequestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use the opkssh_request_id cookie set by the chooser proxy.
|
||||
// State capture only works for 3xx redirects; if OPKSSH redirects via
|
||||
// JavaScript in the HTML, the state is never registered in the map.
|
||||
if (userSessions.length === 0) {
|
||||
const cookieRequestId = req.cookies?.opkssh_request_id;
|
||||
if (cookieRequestId) {
|
||||
const cookieSession = getActiveAuthSession(cookieRequestId);
|
||||
if (cookieSession) {
|
||||
userSessions = [cookieSession];
|
||||
res.clearCookie("opkssh_request_id", { path: "/host/" });
|
||||
sshLogger.info("Resolved session via opkssh_request_id cookie", {
|
||||
operation: "opkssh_callback_cookie_lookup",
|
||||
requestId: cookieRequestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userSessions.length === 0) {
|
||||
sshLogger.warn(
|
||||
"OAuth callback with no JWT, no matching state, and no session cookie",
|
||||
{
|
||||
operation: "opkssh_callback_no_session_match",
|
||||
hasState: !!oauthState,
|
||||
hasCookie: !!req.cookies?.opkssh_request_id,
|
||||
},
|
||||
);
|
||||
res
|
||||
.status(401)
|
||||
.send("Authentication callback failed: unable to identify session");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sshLogger.info("Active sessions for user", {
|
||||
operation: "opkssh_callback_session_lookup",
|
||||
userId,
|
||||
sessionCount: userSessions.length,
|
||||
sessions: userSessions.map((s) => ({
|
||||
requestId: s.requestId,
|
||||
status: s.status,
|
||||
hasCallbackPort: !!s.callbackPort,
|
||||
callbackPort: s.callbackPort,
|
||||
hasLocalPort: !!s.localPort,
|
||||
localPort: s.localPort,
|
||||
})),
|
||||
});
|
||||
|
||||
if (userSessions.length === 0) {
|
||||
sshLogger.error("No active sessions for callback", {
|
||||
operation: "opkssh_callback_no_sessions",
|
||||
userId,
|
||||
});
|
||||
res.status(404).send("No active authentication session found");
|
||||
return;
|
||||
}
|
||||
|
||||
const session = userSessions[userSessions.length - 1];
|
||||
|
||||
if (!session.callbackPort) {
|
||||
sshLogger.error("Session callback port not ready", {
|
||||
operation: "opkssh_callback_port_not_ready",
|
||||
userId,
|
||||
requestId: session.requestId,
|
||||
sessionStatus: session.status,
|
||||
hasLocalPort: !!session.localPort,
|
||||
});
|
||||
res.status(503).send("OPKSSH callback listener not ready yet");
|
||||
return;
|
||||
}
|
||||
|
||||
const queryString = req.url.includes("?")
|
||||
? req.url.substring(req.url.indexOf("?"))
|
||||
: "";
|
||||
// OPKSSH's internal callback listener handles `/login-callback` regardless of the
|
||||
// path used in --remote-redirect-uri. The dynamic route below defaults to that path.
|
||||
const redirectUrl = `/host/opkssh-callback/${session.requestId}${queryString}`;
|
||||
|
||||
sshLogger.info("Redirecting OAuth callback to dynamic route", {
|
||||
operation: "opkssh_static_callback_redirect",
|
||||
userId,
|
||||
requestId: session.requestId,
|
||||
callbackPort: session.callbackPort,
|
||||
queryParams: Object.keys(req.query),
|
||||
redirectUrl,
|
||||
});
|
||||
|
||||
res.redirect(302, redirectUrl);
|
||||
} catch (error) {
|
||||
sshLogger.error("Error handling OPKSSH static callback", error, {
|
||||
operation: "opkssh_static_callback_error",
|
||||
url: req.url,
|
||||
originalUrl: req.originalUrl,
|
||||
});
|
||||
res.status(500).send("Authentication callback failed");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/opkssh-callback/{requestId}:
|
||||
* get:
|
||||
* summary: OAuth callback from OIDC provider for OPKSSH authentication (handles all sub-paths)
|
||||
* tags: [SSH]
|
||||
* parameters:
|
||||
* - name: requestId
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Authentication request ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Callback processed successfully
|
||||
* 404:
|
||||
* description: Invalid authentication session
|
||||
* 500:
|
||||
* description: Authentication failed
|
||||
*/
|
||||
router.use(
|
||||
"/opkssh-callback/:requestId",
|
||||
async (req: Request, res: Response) => {
|
||||
const requestId = Array.isArray(req.params.requestId)
|
||||
? req.params.requestId[0]
|
||||
: req.params.requestId;
|
||||
|
||||
try {
|
||||
const { getActiveAuthSession } =
|
||||
await import("../../ssh/opkssh-auth.js");
|
||||
const session = getActiveAuthSession(requestId);
|
||||
|
||||
if (!session) {
|
||||
res.status(404).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "Session Not Found",
|
||||
heading: "Session Not Found",
|
||||
message:
|
||||
"Authentication session expired or invalid. Please close this window and try again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const axios = (await import("axios")).default;
|
||||
const fullPath = req.originalUrl || req.url;
|
||||
const pathAfterRequestId =
|
||||
fullPath.split(`/host/opkssh-callback/${requestId}`)[1] || "";
|
||||
// pathAfterRequestId may be "", "?query=...", "/subpath", or "/subpath?query=..."
|
||||
// OPKSSH's internal listener serves /login-callback, so when no sub-path is present
|
||||
// (query-only or empty), prepend it.
|
||||
const targetPath =
|
||||
pathAfterRequestId === "" || pathAfterRequestId.startsWith("?")
|
||||
? `/login-callback${pathAfterRequestId}`
|
||||
: pathAfterRequestId;
|
||||
|
||||
if (!session.callbackPort || session.callbackPort === 0) {
|
||||
sshLogger.error("OPKSSH callback session has no callback port", {
|
||||
operation: "opkssh_callback_proxy",
|
||||
requestId,
|
||||
sessionStatus: session.status,
|
||||
});
|
||||
res.status(500).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "Error",
|
||||
heading: "Callback Error",
|
||||
message:
|
||||
"OPKSSH callback listener not ready. Please try authenticating again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUrl = `http://127.0.0.1:${session.callbackPort}${targetPath}`;
|
||||
|
||||
const response = await axios({
|
||||
method: req.method,
|
||||
url: targetUrl,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: `127.0.0.1:${session.callbackPort}`,
|
||||
},
|
||||
data: req.body,
|
||||
timeout: 10000,
|
||||
validateStatus: () => true,
|
||||
maxRedirects: 0,
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
|
||||
Object.entries(response.headers).forEach(([key, value]) => {
|
||||
if (key.toLowerCase() === "transfer-encoding") {
|
||||
return;
|
||||
}
|
||||
if (key.toLowerCase() === "location") {
|
||||
const location = value as string;
|
||||
if (location.startsWith("/")) {
|
||||
res.setHeader(
|
||||
key,
|
||||
`/host/opkssh-callback/${requestId}${location}`,
|
||||
);
|
||||
} else {
|
||||
res.setHeader(key, value as string);
|
||||
}
|
||||
} else {
|
||||
res.setHeader(key, value as string);
|
||||
}
|
||||
});
|
||||
|
||||
const contentType = String(response.headers["content-type"] || "");
|
||||
if (contentType.includes("text/html")) {
|
||||
const html = rewriteOPKSSHHtml(
|
||||
response.data.toString("utf-8"),
|
||||
requestId,
|
||||
"opkssh-callback",
|
||||
);
|
||||
res.status(response.status).send(html);
|
||||
} else {
|
||||
res.status(response.status).send(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error("Error handling OPKSSH OAuth callback", error, {
|
||||
operation: "opkssh_oauth_callback_error",
|
||||
requestId,
|
||||
});
|
||||
|
||||
res.status(500).send(
|
||||
renderOpksshErrorPage({
|
||||
title: "Error",
|
||||
heading: "Error",
|
||||
message: "An unexpected error occurred. Please try again.",
|
||||
requestId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
+107
-3457
File diff suppressed because it is too large
Load Diff
@@ -32,12 +32,20 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const tabs = db
|
||||
.select()
|
||||
.from(userOpenTabs)
|
||||
.where(and(eq(userOpenTabs.userId, userId), sql`${userOpenTabs.updatedAt} > ${cutoff}`))
|
||||
.where(
|
||||
and(
|
||||
eq(userOpenTabs.userId, userId),
|
||||
sql`${userOpenTabs.updatedAt} > ${cutoff}`,
|
||||
),
|
||||
)
|
||||
.orderBy(userOpenTabs.tabOrder)
|
||||
.all();
|
||||
return res.json(tabs);
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to get open tabs", e, { operation: "get_open_tabs", userId });
|
||||
databaseLogger.error("Failed to get open tabs", e, {
|
||||
operation: "get_open_tabs",
|
||||
userId,
|
||||
});
|
||||
return res.status(500).json({ error: "Failed to get open tabs" });
|
||||
}
|
||||
});
|
||||
@@ -77,7 +85,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
*/
|
||||
router.post("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { id, tabType, hostId, label, tabOrder, backendSessionId } = req.body as {
|
||||
const { id, tabType, hostId, label, tabOrder, backendSessionId } =
|
||||
req.body as {
|
||||
id: string;
|
||||
tabType: string;
|
||||
hostId?: number | null;
|
||||
@@ -87,25 +96,56 @@ router.post("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
};
|
||||
|
||||
if (!id || !tabType || !label) {
|
||||
return res.status(400).json({ error: "id, tabType, and label are required" });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "id, tabType, and label are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const existing = db.select().from(userOpenTabs).where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId))).all();
|
||||
const existing = db
|
||||
.select()
|
||||
.from(userOpenTabs)
|
||||
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
|
||||
.all();
|
||||
if (existing.length > 0) {
|
||||
// Preserve existing backendSessionId when not explicitly provided
|
||||
const sessionId = backendSessionId !== undefined ? backendSessionId : existing[0].backendSessionId;
|
||||
const sessionId =
|
||||
backendSessionId !== undefined
|
||||
? backendSessionId
|
||||
: existing[0].backendSessionId;
|
||||
db.update(userOpenTabs)
|
||||
.set({ tabType, hostId: hostId ?? null, label, tabOrder, backendSessionId: sessionId ?? null, updatedAt: now })
|
||||
.set({
|
||||
tabType,
|
||||
hostId: hostId ?? null,
|
||||
label,
|
||||
tabOrder,
|
||||
backendSessionId: sessionId ?? null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(userOpenTabs.id, id), eq(userOpenTabs.userId, userId)))
|
||||
.run();
|
||||
} else {
|
||||
db.insert(userOpenTabs).values({ id, userId, tabType, hostId: hostId ?? null, label, tabOrder, backendSessionId: backendSessionId ?? null, updatedAt: now }).run();
|
||||
db.insert(userOpenTabs)
|
||||
.values({
|
||||
id,
|
||||
userId,
|
||||
tabType,
|
||||
hostId: hostId ?? null,
|
||||
label,
|
||||
tabOrder,
|
||||
backendSessionId: backendSessionId ?? null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
return res.json({ success: true });
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to upsert open tab", e, { operation: "upsert_open_tab", userId, id });
|
||||
databaseLogger.error("Failed to upsert open tab", e, {
|
||||
operation: "upsert_open_tab",
|
||||
userId,
|
||||
id,
|
||||
});
|
||||
return res.status(500).json({ error: "Failed to upsert open tab" });
|
||||
}
|
||||
});
|
||||
@@ -151,7 +191,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
db.delete(userOpenTabs).where(eq(userOpenTabs.userId, userId)).run();
|
||||
if (tabs.length > 0) {
|
||||
const now = new Date().toISOString();
|
||||
db.insert(userOpenTabs).values(
|
||||
db.insert(userOpenTabs)
|
||||
.values(
|
||||
tabs.map((t) => ({
|
||||
id: t.id,
|
||||
userId,
|
||||
@@ -162,11 +203,15 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
backendSessionId: t.backendSessionId ?? null,
|
||||
updatedAt: now,
|
||||
})),
|
||||
).run();
|
||||
)
|
||||
.run();
|
||||
}
|
||||
return res.json({ success: true });
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to sync open tabs", e, { operation: "sync_open_tabs", userId });
|
||||
databaseLogger.error("Failed to sync open tabs", e, {
|
||||
operation: "sync_open_tabs",
|
||||
userId,
|
||||
});
|
||||
return res.status(500).json({ error: "Failed to sync open tabs" });
|
||||
}
|
||||
});
|
||||
@@ -211,7 +256,11 @@ router.patch("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
return res.json({ success: true });
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to update open tab", e, { operation: "update_open_tab", userId, id });
|
||||
databaseLogger.error("Failed to update open tab", e, {
|
||||
operation: "update_open_tab",
|
||||
userId,
|
||||
id,
|
||||
});
|
||||
return res.status(500).json({ error: "Failed to update open tab" });
|
||||
}
|
||||
});
|
||||
@@ -243,7 +292,11 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
.run();
|
||||
return res.json({ success: true });
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to delete open tab", e, { operation: "delete_open_tab", userId, id });
|
||||
databaseLogger.error("Failed to delete open tab", e, {
|
||||
operation: "delete_open_tab",
|
||||
userId,
|
||||
id,
|
||||
});
|
||||
return res.status(500).json({ error: "Failed to delete open tab" });
|
||||
}
|
||||
});
|
||||
@@ -279,7 +332,10 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* createdAt:
|
||||
* type: number
|
||||
*/
|
||||
router.get("/active-sessions", authenticateJWT, async (req: Request, res: Response) => {
|
||||
router.get(
|
||||
"/active-sessions",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const sessions = sessionManager.getUserSessions(userId);
|
||||
@@ -294,9 +350,13 @@ router.get("/active-sessions", authenticateJWT, async (req: Request, res: Respon
|
||||
})),
|
||||
);
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to get active sessions", e, { operation: "get_active_sessions", userId });
|
||||
databaseLogger.error("Failed to get active sessions", e, {
|
||||
operation: "get_active_sessions",
|
||||
userId,
|
||||
});
|
||||
return res.status(500).json({ error: "Failed to get active sessions" });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Replicates openpubkey's client/choosers/web_chooser.go IssuerToName().
|
||||
// OPKSSH's /select handler keys its providerMap by this derived name, NOT by the
|
||||
// `alias` field in config.yml. We need the same mapping so we can normalize any
|
||||
// `op=` query param we receive (which can be alias, issuer with protocol, or
|
||||
// issuer without protocol depending on client version) to what OPKSSH expects.
|
||||
function opksshIssuerToName(issuer: string): string | null {
|
||||
if (!issuer) return null;
|
||||
const withScheme =
|
||||
issuer.startsWith("http://") || issuer.startsWith("https://")
|
||||
? issuer
|
||||
: `https://${issuer}`;
|
||||
if (withScheme.startsWith("https://accounts.google.com")) return "google";
|
||||
if (withScheme.startsWith("https://login.microsoftonline.com"))
|
||||
return "azure";
|
||||
if (withScheme.startsWith("https://gitlab.com")) return "gitlab";
|
||||
if (withScheme.startsWith("https://issuer.hello.coop")) return "hello";
|
||||
if (withScheme.startsWith("https://")) {
|
||||
const host = withScheme.slice("https://".length).split("/")[0];
|
||||
return host || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeSelectOpParam(
|
||||
rawOp: string,
|
||||
providers: Array<{ alias: string; issuer: string }>,
|
||||
): string {
|
||||
if (!rawOp) return rawOp;
|
||||
const knownNames = new Set(
|
||||
providers
|
||||
.map((p) => opksshIssuerToName(p.issuer))
|
||||
.filter((n): n is string => typeof n === "string" && n.length > 0),
|
||||
);
|
||||
if (knownNames.has(rawOp)) return rawOp;
|
||||
|
||||
const derivedFromRaw = opksshIssuerToName(rawOp);
|
||||
if (derivedFromRaw && knownNames.has(derivedFromRaw)) return derivedFromRaw;
|
||||
|
||||
const matchByAlias = providers.find((p) => p.alias === rawOp);
|
||||
if (matchByAlias) {
|
||||
const name = opksshIssuerToName(matchByAlias.issuer);
|
||||
if (name) return name;
|
||||
}
|
||||
|
||||
return rawOp;
|
||||
}
|
||||
|
||||
type OpksshErrorPageOptions = {
|
||||
title: string;
|
||||
heading: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
requestId?: string;
|
||||
statusCode?: number;
|
||||
};
|
||||
|
||||
export function renderOpksshErrorPage(opts: OpksshErrorPageOptions): string {
|
||||
const title = escapeHtml(opts.title);
|
||||
const heading = escapeHtml(opts.heading);
|
||||
const message = escapeHtml(opts.message);
|
||||
const detailsBlock = opts.details
|
||||
? `<pre class="details">${escapeHtml(opts.details)}</pre>`
|
||||
: "";
|
||||
const requestIdBlock = opts.requestId
|
||||
? `<p class="request-id">Request ID: ${escapeHtml(opts.requestId)}</p>`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>${title}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: #18181b;
|
||||
color: #fafafa;
|
||||
padding: 1rem;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
background: #27272a;
|
||||
padding: 3rem 2rem;
|
||||
border-radius: 0.625rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
max-width: 720px;
|
||||
width: 100%;
|
||||
}
|
||||
h1 {
|
||||
color: #fafafa;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
p {
|
||||
color: #9ca3af;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
p + p { margin-top: 0.5rem; }
|
||||
.details {
|
||||
color: #d4d4d8;
|
||||
text-align: left;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
margin-top: 1.25rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: #0f0f11;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.5rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.request-id {
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>${heading}</h1>
|
||||
<p>${message}</p>
|
||||
${detailsBlock}
|
||||
${requestIdBlock}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function rewriteOPKSSHHtml(
|
||||
html: string,
|
||||
requestId: string,
|
||||
routePrefix: "opkssh-chooser" | "opkssh-callback",
|
||||
): string {
|
||||
const basePath = `/host/${routePrefix}/${requestId}`;
|
||||
const localHostPattern = "(?:localhost|127\\.0\\.0\\.1)";
|
||||
|
||||
const attrPatterns = ["action", "href", "src", "formaction"];
|
||||
for (const attr of attrPatterns) {
|
||||
html = html.replace(
|
||||
new RegExp(`${attr}="(/[^"]*)`, "g"),
|
||||
`${attr}="${basePath}$1`,
|
||||
);
|
||||
html = html.replace(
|
||||
new RegExp(`${attr}='(/[^']*)`, "g"),
|
||||
`${attr}='${basePath}$1`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const attr of ["href", "action", "src", "formaction"]) {
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`${attr}=["']?http:\\/\\/${localHostPattern}:\\d+\\/([^"'\\s]*)`,
|
||||
"g",
|
||||
),
|
||||
`${attr}="${basePath}/$1`,
|
||||
);
|
||||
}
|
||||
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(window\\.location\\.href\\s*=\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(window\\.location\\s*=\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(fetch\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(location\\.assign\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["']\\))`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(location\\.replace\\(["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["']\\))`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
// XMLHttpRequest.open("GET", "http://localhost:PORT/path", ...)
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(\\.open\\(["']\\w+["']\\s*,\\s*["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(<meta[^>]+http-equiv=["']refresh["'][^>]+content=["'][^;]+;\\s*url=)http:\\/\\/${localHostPattern}:\\d+\\/([^"']+)(["'][^>]*>)`,
|
||||
"gi",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
html = html.replace(
|
||||
new RegExp(
|
||||
`(data-[\\w-]+=["'])http:\\/\\/${localHostPattern}:\\d+\\/([^"']*)(["'])`,
|
||||
"g",
|
||||
),
|
||||
`$1${basePath}/$2$3`,
|
||||
);
|
||||
|
||||
const baseTag = `<base href="${basePath}/">`;
|
||||
|
||||
if (html.includes("<base")) {
|
||||
sshLogger.info("Replacing existing base tag", {
|
||||
operation: "opkssh_html_rewrite_base_tag",
|
||||
requestId,
|
||||
basePath,
|
||||
});
|
||||
html = html.replace(/<base[^>]*>/i, baseTag);
|
||||
} else if (html.includes("<head>")) {
|
||||
sshLogger.info("Inserting base tag into head", {
|
||||
operation: "opkssh_html_rewrite_base_tag_insert",
|
||||
requestId,
|
||||
basePath,
|
||||
});
|
||||
html = html.replace(/<head>/i, `<head>${baseTag}`);
|
||||
} else {
|
||||
sshLogger.warn("No <head> tag found, wrapping HTML", {
|
||||
operation: "opkssh_html_rewrite_no_head",
|
||||
requestId,
|
||||
htmlLength: html.length,
|
||||
htmlPreview: html.substring(0, 200),
|
||||
});
|
||||
html = `<!DOCTYPE html><html><head>${baseTag}</head><body>${html}</body></html>`;
|
||||
}
|
||||
|
||||
sshLogger.info("HTML rewrite complete", {
|
||||
operation: "opkssh_html_rewrite_complete",
|
||||
requestId,
|
||||
routePrefix,
|
||||
hasBaseTag: html.includes("<base href="),
|
||||
staticAssetCount: (html.match(/\/static\//g) || []).length,
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { users } from "../db/schema.js";
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
export function registerUserAdminRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/list:
|
||||
* get:
|
||||
* summary: List all users
|
||||
* description: Retrieves a list of all users in the system.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of users.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to list users.
|
||||
*/
|
||||
router.get("/list", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const allUsers = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
isAdmin: users.isAdmin,
|
||||
isOidc: users.isOidc,
|
||||
})
|
||||
.from(users);
|
||||
|
||||
res.json({ users: allUsers });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to list users", err);
|
||||
res.status(500).json({ error: "Failed to list users" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/make-admin:
|
||||
* post:
|
||||
* summary: Make user admin
|
||||
* description: Grants admin privileges to a user.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* 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: User ID or username is required, or the user is already an admin.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to make user admin.
|
||||
*/
|
||||
router.post("/make-admin", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { userId: targetUserId, username } = req.body;
|
||||
const resolvedUserId = isNonEmptyString(targetUserId)
|
||||
? targetUserId.trim()
|
||||
: null;
|
||||
const resolvedUsername = isNonEmptyString(username)
|
||||
? username.trim()
|
||||
: null;
|
||||
|
||||
if (!resolvedUserId && !resolvedUsername) {
|
||||
return res.status(400).json({ error: "User ID or username is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const adminUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
const targetUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.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" });
|
||||
}
|
||||
|
||||
if (targetUser[0].isAdmin) {
|
||||
return res.status(400).json({ error: "User is already an admin" });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ isAdmin: true })
|
||||
.where(
|
||||
resolvedUserId
|
||||
? eq(users.id, resolvedUserId)
|
||||
: eq(users.username, resolvedUsername!),
|
||||
);
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
authLogger.error(
|
||||
"Failed to persist admin promotion to disk",
|
||||
saveError,
|
||||
{
|
||||
operation: "make_admin_save_failed",
|
||||
userId: targetUser[0].id,
|
||||
username: targetUser[0].username,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
authLogger.info("Admin privileges granted", {
|
||||
operation: "admin_grant",
|
||||
adminId: userId,
|
||||
targetUserId: targetUser[0].id,
|
||||
targetUsername: targetUser[0].username,
|
||||
});
|
||||
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" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/remove-admin:
|
||||
* post:
|
||||
* summary: Remove admin status
|
||||
* description: Revokes admin privileges from a user.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* 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: User ID or username is required, or cannot remove your own admin status.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to remove admin status.
|
||||
*/
|
||||
router.post("/remove-admin", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { userId: targetUserId, username } = req.body;
|
||||
const resolvedUserId = isNonEmptyString(targetUserId)
|
||||
? targetUserId.trim()
|
||||
: null;
|
||||
const resolvedUsername = isNonEmptyString(username)
|
||||
? username.trim()
|
||||
: null;
|
||||
|
||||
if (!resolvedUserId && !resolvedUsername) {
|
||||
return res.status(400).json({ error: "User ID or username is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const adminUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
if (
|
||||
(resolvedUserId && adminUser[0].id === resolvedUserId) ||
|
||||
(resolvedUsername && adminUser[0].username === resolvedUsername)
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Cannot remove your own admin status" });
|
||||
}
|
||||
|
||||
const targetUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.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" });
|
||||
}
|
||||
|
||||
if (!targetUser[0].isAdmin) {
|
||||
return res.status(400).json({ error: "User is not an admin" });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ isAdmin: false })
|
||||
.where(
|
||||
resolvedUserId
|
||||
? eq(users.id, resolvedUserId)
|
||||
: eq(users.username, resolvedUsername!),
|
||||
);
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
authLogger.error("Failed to persist admin removal to disk", saveError, {
|
||||
operation: "remove_admin_save_failed",
|
||||
userId: targetUser[0].id,
|
||||
username: targetUser[0].username,
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.info("Admin privileges revoked", {
|
||||
operation: "admin_revoke",
|
||||
adminId: userId,
|
||||
targetUserId: targetUser[0].id,
|
||||
targetUsername: targetUser[0].username,
|
||||
});
|
||||
res.json({
|
||||
message: `Admin status removed from ${targetUser[0].username}`,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to remove admin status", err);
|
||||
res.status(500).json({ error: "Failed to remove admin status" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import crypto from "crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { nanoid } from "nanoid";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { apiKeys, users } from "../db/schema.js";
|
||||
|
||||
export function registerUserApiKeyRoutes(
|
||||
router: Router,
|
||||
requireAdmin: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/api-keys:
|
||||
* post:
|
||||
* summary: Create an API key (admin only)
|
||||
* description: Creates a new API key scoped to a specific user. The full token is returned only once.
|
||||
* tags:
|
||||
* - API Keys
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - name
|
||||
* - userId
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* description: Human-readable name for the key.
|
||||
* userId:
|
||||
* type: string
|
||||
* description: ID of the user this key is scoped to.
|
||||
* expiresAt:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: Optional expiration date. Null means the key never expires.
|
||||
* responses:
|
||||
* 201:
|
||||
* description: API key created. Contains the full token (shown only once).
|
||||
* 400:
|
||||
* description: Invalid input.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 404:
|
||||
* description: Target user not found.
|
||||
* 500:
|
||||
* description: Failed to create API key.
|
||||
*/
|
||||
router.post("/api-keys", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { name, userId: targetUserId, expiresAt } = req.body;
|
||||
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
return res.status(400).json({ error: "name is required" });
|
||||
}
|
||||
if (typeof targetUserId !== "string" || !targetUserId.trim()) {
|
||||
return res.status(400).json({ error: "userId is required" });
|
||||
}
|
||||
|
||||
const targetUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, targetUserId))
|
||||
.limit(1);
|
||||
if (targetUser.length === 0) {
|
||||
return res.status(404).json({ error: "Target user not found" });
|
||||
}
|
||||
|
||||
let expiresAtValue: string | null = null;
|
||||
if (expiresAt) {
|
||||
const parsed = new Date(expiresAt);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
return res.status(400).json({ error: "Invalid expiresAt date" });
|
||||
}
|
||||
if (parsed <= new Date()) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "expiresAt must be in the future" });
|
||||
}
|
||||
expiresAtValue = parsed.toISOString();
|
||||
}
|
||||
|
||||
const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex");
|
||||
const tokenPrefix = rawToken.substring(0, 12);
|
||||
const tokenHash = await bcrypt.hash(rawToken, 10);
|
||||
const keyId = nanoid();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await db.insert(apiKeys).values({
|
||||
id: keyId,
|
||||
userId: targetUserId,
|
||||
name: name.trim(),
|
||||
tokenHash,
|
||||
tokenPrefix,
|
||||
createdAt: now,
|
||||
expiresAt: expiresAtValue,
|
||||
lastUsedAt: null,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
return res.status(201).json({
|
||||
id: keyId,
|
||||
name: name.trim(),
|
||||
userId: targetUserId,
|
||||
username: targetUser[0].username,
|
||||
tokenPrefix,
|
||||
createdAt: now,
|
||||
expiresAt: expiresAtValue,
|
||||
token: rawToken,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to create API key", err);
|
||||
return res.status(500).json({ error: "Failed to create API key" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/api-keys:
|
||||
* get:
|
||||
* summary: List all API keys (admin only)
|
||||
* description: Returns all API keys with associated usernames. Token hashes are never returned.
|
||||
* tags:
|
||||
* - API Keys
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of API keys.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 500:
|
||||
* description: Failed to fetch API keys.
|
||||
*/
|
||||
router.get("/api-keys", requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
const keys = await db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
name: apiKeys.name,
|
||||
userId: apiKeys.userId,
|
||||
username: users.username,
|
||||
tokenPrefix: apiKeys.tokenPrefix,
|
||||
createdAt: apiKeys.createdAt,
|
||||
expiresAt: apiKeys.expiresAt,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
isActive: apiKeys.isActive,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.leftJoin(users, eq(apiKeys.userId, users.id))
|
||||
.orderBy(apiKeys.createdAt);
|
||||
|
||||
return res.json({ apiKeys: keys });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to list API keys", err);
|
||||
return res.status(500).json({ error: "Failed to fetch API keys" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/api-keys/{keyId}:
|
||||
* delete:
|
||||
* summary: Delete an API key (admin only)
|
||||
* description: Permanently deletes an API key. It can no longer be used to authenticate.
|
||||
* tags:
|
||||
* - API Keys
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: keyId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: The ID of the API key to delete.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: API key deleted.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 404:
|
||||
* description: API key not found.
|
||||
* 500:
|
||||
* description: Failed to delete API key.
|
||||
*/
|
||||
router.delete("/api-keys/:keyId", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const keyId = String(req.params.keyId);
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.id, keyId))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return res.status(404).json({ error: "API key not found" });
|
||||
}
|
||||
|
||||
await db.delete(apiKeys).where(eq(apiKeys.id, keyId));
|
||||
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete API key", err, {
|
||||
keyId: String(req.params.keyId),
|
||||
});
|
||||
return res.status(500).json({ error: "Failed to delete API key" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
|
||||
interface UserDataAccessRoutesDeps {
|
||||
authenticateJWT: RequestHandler;
|
||||
authManager: AuthManager;
|
||||
}
|
||||
|
||||
export function registerUserDataAccessRoutes(
|
||||
router: Router,
|
||||
{ authenticateJWT, authManager }: UserDataAccessRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/unlock-data:
|
||||
* post:
|
||||
* summary: Unlock user data
|
||||
* description: Re-authenticates user with password to unlock encrypted data.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* password:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Data unlocked successfully.
|
||||
* 400:
|
||||
* description: Password is required.
|
||||
* 401:
|
||||
* description: Invalid password.
|
||||
* 500:
|
||||
* description: Failed to unlock data.
|
||||
*/
|
||||
router.post("/unlock-data", authenticateJWT, async (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const userId = authReq.userId;
|
||||
const { password } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return res.status(400).json({ error: "Password is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const unlocked = await authManager.authenticateUser(userId, password);
|
||||
if (unlocked) {
|
||||
const refreshedSession =
|
||||
userId && authReq.sessionId
|
||||
? await authManager.refreshSessionToken(userId, authReq.sessionId)
|
||||
: null;
|
||||
|
||||
if (refreshedSession) {
|
||||
res.cookie(
|
||||
"jwt",
|
||||
refreshedSession.token,
|
||||
authManager.getSecureCookieOptions(req, refreshedSession.maxAge),
|
||||
);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Data unlocked successfully",
|
||||
});
|
||||
} else {
|
||||
authLogger.warn("Failed to unlock user data - invalid password", {
|
||||
operation: "user_data_unlock_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(401).json({ error: "Invalid password" });
|
||||
}
|
||||
} catch (err) {
|
||||
authLogger.error("Data unlock failed", err, {
|
||||
operation: "user_data_unlock_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to unlock data" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/data-status:
|
||||
* get:
|
||||
* summary: Check user data unlock status
|
||||
* description: Checks if user data is currently unlocked.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Data status returned.
|
||||
* 500:
|
||||
* description: Failed to check data status.
|
||||
*/
|
||||
router.get("/data-status", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
const unlocked = authManager.isUserUnlocked(userId);
|
||||
res.json({
|
||||
unlocked,
|
||||
message: unlocked ? "Data is unlocked" : "Data is locked",
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to check data status", err, {
|
||||
operation: "data_status_check_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to check data status" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
||||
|
||||
type UserOidcAccountRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
authManager: AuthManager;
|
||||
};
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
export function registerUserOidcAccountRoutes(
|
||||
router: Router,
|
||||
{ authenticateJWT, authManager }: UserOidcAccountRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/link-oidc-to-password:
|
||||
* post:
|
||||
* summary: Link OIDC user to password account
|
||||
* description: Merges an OIDC-only account into a password-based account (admin only).
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* oidcUserId:
|
||||
* type: string
|
||||
* targetUsername:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Accounts linked successfully.
|
||||
* 400:
|
||||
* description: Invalid request or incompatible accounts.
|
||||
* 403:
|
||||
* description: Admin access required.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to link accounts.
|
||||
*/
|
||||
router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
|
||||
const adminUserId = (req as AuthenticatedRequest).userId;
|
||||
const { oidcUserId, targetUsername } = req.body;
|
||||
|
||||
if (!isNonEmptyString(oidcUserId) || !isNonEmptyString(targetUsername)) {
|
||||
return res.status(400).json({
|
||||
error: "OIDC user ID and target username are required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const adminUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, adminUserId));
|
||||
if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
const oidcUserRecords = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, oidcUserId));
|
||||
if (!oidcUserRecords || oidcUserRecords.length === 0) {
|
||||
return res.status(404).json({ error: "OIDC user not found" });
|
||||
}
|
||||
|
||||
const oidcUser = oidcUserRecords[0];
|
||||
|
||||
if (!oidcUser.isOidc) {
|
||||
return res.status(400).json({
|
||||
error: "Source user is not an OIDC user",
|
||||
});
|
||||
}
|
||||
|
||||
const targetUserRecords = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, targetUsername));
|
||||
if (!targetUserRecords || targetUserRecords.length === 0) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Target password user not found" });
|
||||
}
|
||||
|
||||
const targetUser = targetUserRecords[0];
|
||||
|
||||
if (targetUser.isOidc || !targetUser.passwordHash) {
|
||||
return res.status(400).json({
|
||||
error: "Target user must be a password-based account",
|
||||
});
|
||||
}
|
||||
|
||||
if (targetUser.clientId && targetUser.oidcIdentifier) {
|
||||
return res.status(400).json({
|
||||
error: "Target user already has OIDC authentication configured",
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.info("Linking OIDC user to password account", {
|
||||
operation: "link_oidc_to_password",
|
||||
oidcUserId,
|
||||
oidcUsername: oidcUser.username,
|
||||
targetUserId: targetUser.id,
|
||||
targetUsername: targetUser.username,
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
isOidc: true,
|
||||
oidcIdentifier: oidcUser.oidcIdentifier,
|
||||
clientId: oidcUser.clientId,
|
||||
clientSecret: oidcUser.clientSecret,
|
||||
issuerUrl: oidcUser.issuerUrl,
|
||||
authorizationUrl: oidcUser.authorizationUrl,
|
||||
tokenUrl: oidcUser.tokenUrl,
|
||||
identifierPath: oidcUser.identifierPath,
|
||||
namePath: oidcUser.namePath,
|
||||
scopes: oidcUser.scopes || "openid email profile",
|
||||
})
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
try {
|
||||
await authManager.convertToOIDCEncryption(targetUser.id);
|
||||
} catch (encryptionError) {
|
||||
authLogger.error(
|
||||
"Failed to convert encryption to OIDC during linking",
|
||||
encryptionError,
|
||||
{
|
||||
operation: "link_convert_encryption_failed",
|
||||
userId: targetUser.id,
|
||||
},
|
||||
);
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
isOidc: false,
|
||||
oidcIdentifier: null,
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
issuerUrl: "",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "",
|
||||
identifierPath: "",
|
||||
namePath: "",
|
||||
scopes: "openid email profile",
|
||||
})
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
return res.status(500).json({
|
||||
error:
|
||||
"Failed to convert encryption for dual-auth. Please ensure the password account has encryption setup.",
|
||||
details:
|
||||
encryptionError instanceof Error
|
||||
? encryptionError.message
|
||||
: "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
await authManager.revokeAllUserSessions(oidcUserId);
|
||||
authManager.logoutUser(oidcUserId);
|
||||
|
||||
await deleteUserAndRelatedData(oidcUserId);
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
authLogger.error(
|
||||
"Failed to persist account linking to disk",
|
||||
saveError,
|
||||
{
|
||||
operation: "link_oidc_save_failed",
|
||||
oidcUserId,
|
||||
targetUserId: targetUser.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
authLogger.success(
|
||||
`OIDC user ${oidcUser.username} linked to password account ${targetUser.username}`,
|
||||
{
|
||||
operation: "link_oidc_to_password_success",
|
||||
oidcUserId,
|
||||
oidcUsername: oidcUser.username,
|
||||
targetUserId: targetUser.id,
|
||||
targetUsername: targetUser.username,
|
||||
adminUserId,
|
||||
},
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `OIDC user ${oidcUser.username} has been linked to ${targetUser.username}. The password account can now use both password and OIDC login.`,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to link OIDC user to password account", err, {
|
||||
operation: "link_oidc_to_password_failed",
|
||||
oidcUserId,
|
||||
targetUsername,
|
||||
adminUserId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to link accounts",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/unlink-oidc-from-password:
|
||||
* post:
|
||||
* summary: Unlink OIDC from password account
|
||||
* description: Removes OIDC authentication from a dual-auth account (admin only).
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* userId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OIDC unlinked successfully.
|
||||
* 400:
|
||||
* description: Invalid request or user doesn't have OIDC.
|
||||
* 403:
|
||||
* description: Admin privileges required.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to unlink OIDC.
|
||||
*/
|
||||
router.post(
|
||||
"/unlink-oidc-from-password",
|
||||
authenticateJWT,
|
||||
async (req, res) => {
|
||||
const adminUserId = (req as AuthenticatedRequest).userId;
|
||||
const { userId } = req.body;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({
|
||||
error: "User ID is required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const adminUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, adminUserId));
|
||||
|
||||
if (!adminUser || adminUser.length === 0 || !adminUser[0].isAdmin) {
|
||||
authLogger.warn("Non-admin attempted to unlink OIDC from password", {
|
||||
operation: "unlink_oidc_unauthorized",
|
||||
adminUserId,
|
||||
targetUserId: userId,
|
||||
});
|
||||
return res.status(403).json({
|
||||
error: "Admin privileges required",
|
||||
});
|
||||
}
|
||||
|
||||
const targetUserRecords = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
if (!targetUserRecords || targetUserRecords.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const targetUser = targetUserRecords[0];
|
||||
|
||||
if (!targetUser.isOidc) {
|
||||
return res.status(400).json({
|
||||
error: "User does not have OIDC authentication enabled",
|
||||
});
|
||||
}
|
||||
|
||||
if (!targetUser.passwordHash || targetUser.passwordHash === "") {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Cannot unlink OIDC from a user without password authentication. This would leave the user unable to login.",
|
||||
});
|
||||
}
|
||||
|
||||
authLogger.info("Unlinking OIDC from password account", {
|
||||
operation: "unlink_oidc_from_password_start",
|
||||
targetUserId: targetUser.id,
|
||||
targetUsername: targetUser.username,
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
isOidc: false,
|
||||
oidcIdentifier: null,
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
issuerUrl: "",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "",
|
||||
identifierPath: "",
|
||||
namePath: "",
|
||||
scopes: "openid email profile",
|
||||
})
|
||||
.where(eq(users.id, targetUser.id));
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
authLogger.error(
|
||||
"Failed to save database after unlinking OIDC",
|
||||
saveError,
|
||||
{
|
||||
operation: "unlink_oidc_save_failed",
|
||||
targetUserId: targetUser.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
authLogger.success("OIDC unlinked from password account successfully", {
|
||||
operation: "unlink_oidc_from_password_success",
|
||||
targetUserId: targetUser.id,
|
||||
targetUsername: targetUser.username,
|
||||
adminUserId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `OIDC authentication has been removed from ${targetUser.username}. User can now only login with password.`,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to unlink OIDC from password account", err, {
|
||||
operation: "unlink_oidc_from_password_failed",
|
||||
targetUserId: userId,
|
||||
adminUserId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to unlink OIDC",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// user-oidc-utils imports the logger; stub it so importing stays side-effect-free.
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
authLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { isOIDCUserAllowed, getOIDCConfigFromEnv } =
|
||||
await import("./user-oidc-utils.js");
|
||||
|
||||
describe("isOIDCUserAllowed", () => {
|
||||
it("allows everyone when the allow-list is empty", () => {
|
||||
expect(isOIDCUserAllowed("", "alice", "alice@x.com")).toBe(true);
|
||||
expect(isOIDCUserAllowed(" ", "alice")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows everyone with the '*' wildcard", () => {
|
||||
expect(isOIDCUserAllowed("*", "anyone", "anyone@x.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches an exact identifier (case-insensitive)", () => {
|
||||
expect(isOIDCUserAllowed("alice,bob", "alice")).toBe(true);
|
||||
expect(isOIDCUserAllowed("Alice", "alice")).toBe(true);
|
||||
expect(isOIDCUserAllowed("alice", "ALICE")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches against the email as well as the identifier", () => {
|
||||
expect(isOIDCUserAllowed("alice@x.com", "sub-123", "alice@x.com")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("matches an @domain suffix pattern", () => {
|
||||
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@company.com")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@COMPANY.COM")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("denies users not on the list", () => {
|
||||
expect(isOIDCUserAllowed("alice,bob", "charlie", "charlie@x.com")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@other.com")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores blank entries and surrounding whitespace in the list", () => {
|
||||
expect(isOIDCUserAllowed(" alice , , bob ", "bob")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match the email against an identifier-only pattern when email differs", () => {
|
||||
expect(isOIDCUserAllowed("alice", "sub-123", "alice@x.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOIDCConfigFromEnv", () => {
|
||||
const REQUIRED = [
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_ISSUER_URL",
|
||||
"OIDC_AUTHORIZATION_URL",
|
||||
"OIDC_TOKEN_URL",
|
||||
];
|
||||
const OPTIONAL = [
|
||||
"OIDC_USERINFO_URL",
|
||||
"OIDC_IDENTIFIER_PATH",
|
||||
"OIDC_NAME_PATH",
|
||||
"OIDC_SCOPES",
|
||||
"OIDC_ALLOWED_USERS",
|
||||
"OIDC_ADMIN_GROUP",
|
||||
];
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of [...REQUIRED, ...OPTIONAL]) {
|
||||
saved[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of [...REQUIRED, ...OPTIONAL]) {
|
||||
if (saved[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = saved[key];
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when any required variable is missing", () => {
|
||||
process.env.OIDC_CLIENT_ID = "id";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret";
|
||||
// issuer/authorization/token urls intentionally missing
|
||||
expect(getOIDCConfigFromEnv()).toBeNull();
|
||||
});
|
||||
|
||||
it("builds a config with defaults when all required vars are present", () => {
|
||||
process.env.OIDC_CLIENT_ID = "id";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret";
|
||||
process.env.OIDC_ISSUER_URL = "https://idp.example";
|
||||
process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
|
||||
process.env.OIDC_TOKEN_URL = "https://idp.example/token";
|
||||
|
||||
const config = getOIDCConfigFromEnv();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.client_id).toBe("id");
|
||||
expect(config?.identifier_path).toBe("sub");
|
||||
expect(config?.name_path).toBe("name");
|
||||
expect(config?.scopes).toBe("openid email profile");
|
||||
expect(config?.userinfo_url).toBe("");
|
||||
});
|
||||
|
||||
it("honors overrides for optional vars", () => {
|
||||
process.env.OIDC_CLIENT_ID = "id";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret";
|
||||
process.env.OIDC_ISSUER_URL = "https://idp.example";
|
||||
process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
|
||||
process.env.OIDC_TOKEN_URL = "https://idp.example/token";
|
||||
process.env.OIDC_IDENTIFIER_PATH = "email";
|
||||
process.env.OIDC_SCOPES = "openid";
|
||||
|
||||
const config = getOIDCConfigFromEnv();
|
||||
expect(config?.identifier_path).toBe("email");
|
||||
expect(config?.scopes).toBe("openid");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
|
||||
export type OIDCConfig = {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer_url: string;
|
||||
authorization_url: string;
|
||||
token_url: string;
|
||||
userinfo_url: string;
|
||||
identifier_path: string;
|
||||
name_path: string;
|
||||
scopes: string;
|
||||
allowed_users: string;
|
||||
admin_group: string;
|
||||
};
|
||||
|
||||
export function getOIDCConfigFromEnv(): OIDCConfig | null {
|
||||
const client_id = process.env.OIDC_CLIENT_ID;
|
||||
const client_secret = process.env.OIDC_CLIENT_SECRET;
|
||||
const issuer_url = process.env.OIDC_ISSUER_URL;
|
||||
const authorization_url = process.env.OIDC_AUTHORIZATION_URL;
|
||||
const token_url = process.env.OIDC_TOKEN_URL;
|
||||
|
||||
if (
|
||||
!client_id ||
|
||||
!client_secret ||
|
||||
!issuer_url ||
|
||||
!authorization_url ||
|
||||
!token_url
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
client_id,
|
||||
client_secret,
|
||||
issuer_url,
|
||||
authorization_url,
|
||||
token_url,
|
||||
userinfo_url: process.env.OIDC_USERINFO_URL || "",
|
||||
identifier_path: process.env.OIDC_IDENTIFIER_PATH || "sub",
|
||||
name_path: process.env.OIDC_NAME_PATH || "name",
|
||||
scopes: process.env.OIDC_SCOPES || "openid email profile",
|
||||
allowed_users: process.env.OIDC_ALLOWED_USERS || "",
|
||||
admin_group: process.env.OIDC_ADMIN_GROUP || "",
|
||||
};
|
||||
}
|
||||
|
||||
export function isOIDCUserAllowed(
|
||||
allowedUsers: string,
|
||||
identifier: string,
|
||||
email?: string,
|
||||
): boolean {
|
||||
if (!allowedUsers || !allowedUsers.trim()) return true;
|
||||
const patterns = allowedUsers
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (patterns.length === 0) return true;
|
||||
|
||||
const values = [
|
||||
identifier,
|
||||
...(email && email !== identifier ? [email] : []),
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
if (pattern === "*") return true;
|
||||
for (const value of values) {
|
||||
if (!value) continue;
|
||||
if (pattern.toLowerCase().startsWith("@")) {
|
||||
if (value.toLowerCase().endsWith(pattern.toLowerCase())) return true;
|
||||
} else {
|
||||
if (value.toLowerCase() === pattern.toLowerCase()) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function verifyOIDCToken(
|
||||
idToken: string,
|
||||
issuerUrl: string,
|
||||
clientId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const normalizedIssuerUrl = issuerUrl.endsWith("/")
|
||||
? issuerUrl.slice(0, -1)
|
||||
: issuerUrl;
|
||||
const possibleIssuers = [
|
||||
issuerUrl,
|
||||
normalizedIssuerUrl,
|
||||
issuerUrl.replace(/\/application\/o\/[^/]+$/, ""),
|
||||
normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, ""),
|
||||
];
|
||||
|
||||
const jwksUrls = [
|
||||
`${normalizedIssuerUrl}/.well-known/jwks.json`,
|
||||
`${normalizedIssuerUrl}/jwks/`,
|
||||
`${normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, "")}/.well-known/jwks.json`,
|
||||
];
|
||||
|
||||
try {
|
||||
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
|
||||
const discoveryResponse = await fetch(discoveryUrl);
|
||||
if (discoveryResponse.ok) {
|
||||
const discovery = (await discoveryResponse.json()) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (discovery.jwks_uri) {
|
||||
jwksUrls.unshift(discovery.jwks_uri as string);
|
||||
}
|
||||
}
|
||||
} catch (discoveryError) {
|
||||
authLogger.error(`OIDC discovery failed: ${discoveryError}`);
|
||||
}
|
||||
|
||||
let jwks: Record<string, unknown> | null = null;
|
||||
|
||||
for (const url of jwksUrls) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
const jwksData = (await response.json()) as Record<string, unknown>;
|
||||
if (jwksData && jwksData.keys && Array.isArray(jwksData.keys)) {
|
||||
jwks = jwksData;
|
||||
break;
|
||||
} else {
|
||||
authLogger.error(
|
||||
`Invalid JWKS structure from ${url}: ${JSON.stringify(jwksData)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// expected - non-ok response, try next URL
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!jwks) {
|
||||
throw new Error("Failed to fetch JWKS from any URL");
|
||||
}
|
||||
|
||||
if (!jwks.keys || !Array.isArray(jwks.keys)) {
|
||||
throw new Error(
|
||||
`Invalid JWKS response structure. Expected 'keys' array, got: ${JSON.stringify(jwks)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const header = JSON.parse(
|
||||
Buffer.from(idToken.split(".")[0], "base64").toString(),
|
||||
);
|
||||
const keyId = header.kid;
|
||||
|
||||
const publicKey = jwks.keys.find(
|
||||
(key: Record<string, unknown>) => key.kid === keyId,
|
||||
);
|
||||
if (!publicKey) {
|
||||
throw new Error(
|
||||
`No matching public key found for key ID: ${keyId}. Available keys: ${jwks.keys.map((k: Record<string, unknown>) => k.kid).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { importJWK, jwtVerify } = await import("jose");
|
||||
const key = await importJWK(publicKey);
|
||||
|
||||
const { payload } = await jwtVerify(idToken, key, {
|
||||
issuer: possibleIssuers,
|
||||
audience: clientId,
|
||||
});
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
import type { Router } from "express";
|
||||
import crypto from "crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { nanoid } from "nanoid";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
|
||||
import { db } from "../db/index.js";
|
||||
import {
|
||||
users,
|
||||
hosts,
|
||||
sshCredentials,
|
||||
fileManagerRecent,
|
||||
fileManagerPinned,
|
||||
fileManagerShortcuts,
|
||||
dismissedAlerts,
|
||||
sshCredentialUsage,
|
||||
recentActivity,
|
||||
snippets,
|
||||
} from "../db/schema.js";
|
||||
|
||||
interface UserPasswordResetRoutesDeps {
|
||||
authManager: AuthManager;
|
||||
}
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
export function registerUserPasswordResetRoutes(
|
||||
router: Router,
|
||||
{ authManager }: UserPasswordResetRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/initiate-reset:
|
||||
* post:
|
||||
* summary: Initiate password reset
|
||||
* description: Initiates the password reset process for a user.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* username:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Password reset code has been generated.
|
||||
* 400:
|
||||
* description: Username is required.
|
||||
* 403:
|
||||
* description: Password reset not available for external authentication users.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to initiate password reset.
|
||||
*/
|
||||
router.post("/initiate-reset", async (req, res) => {
|
||||
try {
|
||||
const row = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'allow_password_reset'",
|
||||
)
|
||||
.get();
|
||||
if (row && (row as { value: string }).value !== "true") {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Password reset is currently disabled" });
|
||||
}
|
||||
} catch (e) {
|
||||
authLogger.warn("Failed to check password reset status", {
|
||||
operation: "password_reset_check",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
const { username } = req.body;
|
||||
|
||||
if (!isNonEmptyString(username)) {
|
||||
return res.status(400).json({ error: "Username is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username));
|
||||
|
||||
if (!user || user.length === 0) {
|
||||
authLogger.warn(
|
||||
`Password reset attempted for non-existent user: ${username}`,
|
||||
);
|
||||
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.json({
|
||||
message:
|
||||
"If the user exists, a password reset code has been generated. Check docker logs for the code.",
|
||||
});
|
||||
}
|
||||
|
||||
const resetCode = crypto.randomInt(100000, 1000000).toString();
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
|
||||
|
||||
db.$client
|
||||
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||
.run(
|
||||
`reset_code_${username}`,
|
||||
JSON.stringify({
|
||||
code: resetCode,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
authLogger.info(
|
||||
`Password reset code generated for user ${username} (expires at ${expiresAt.toLocaleString()}). Check admin panel or database settings table for code.`,
|
||||
);
|
||||
|
||||
res.json({
|
||||
message:
|
||||
"Password reset code has been generated and logged. Check docker logs for the code.",
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to initiate password reset", err);
|
||||
res.status(500).json({ error: "Failed to initiate password reset" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/verify-reset-code:
|
||||
* post:
|
||||
* summary: Verify reset code
|
||||
* description: Verifies the password reset code.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* username:
|
||||
* type: string
|
||||
* resetCode:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Reset code verified.
|
||||
* 400:
|
||||
* description: Invalid or expired reset code.
|
||||
* 500:
|
||||
* description: Failed to verify reset code.
|
||||
*/
|
||||
router.post("/verify-reset-code", async (req, res) => {
|
||||
const { username, resetCode } = req.body;
|
||||
|
||||
if (!isNonEmptyString(username) || !isNonEmptyString(resetCode)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Username and reset code are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const lockStatus = loginRateLimiter.isResetCodeLocked(username);
|
||||
if (lockStatus.locked) {
|
||||
authLogger.warn(
|
||||
"Reset code verification blocked due to rate limiting",
|
||||
{
|
||||
operation: "reset_code_verify_blocked",
|
||||
username,
|
||||
remainingTime: lockStatus.remainingTime,
|
||||
},
|
||||
);
|
||||
return res.status(429).json({
|
||||
error: `Rate limited: Too many verification attempts. Please wait ${lockStatus.remainingTime} seconds before trying again.`,
|
||||
remainingTime: lockStatus.remainingTime,
|
||||
code: "RESET_CODE_RATE_LIMITED",
|
||||
});
|
||||
}
|
||||
|
||||
loginRateLimiter.recordResetCodeAttempt(username);
|
||||
|
||||
const resetDataRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||
.get(`reset_code_${username}`);
|
||||
if (!resetDataRow) {
|
||||
authLogger.warn("Reset code verification failed - no code found", {
|
||||
operation: "reset_code_verify_failed",
|
||||
username,
|
||||
remainingAttempts:
|
||||
loginRateLimiter.getRemainingResetCodeAttempts(username),
|
||||
});
|
||||
return res.status(400).json({
|
||||
error: "No reset code found for this user",
|
||||
remainingAttempts:
|
||||
loginRateLimiter.getRemainingResetCodeAttempts(username),
|
||||
});
|
||||
}
|
||||
|
||||
const resetData = JSON.parse(
|
||||
(resetDataRow as Record<string, unknown>).value as string,
|
||||
);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(resetData.expiresAt);
|
||||
|
||||
if (now > expiresAt) {
|
||||
db.$client
|
||||
.prepare("DELETE FROM settings WHERE key = ?")
|
||||
.run(`reset_code_${username}`);
|
||||
authLogger.warn("Reset code verification failed - code expired", {
|
||||
operation: "reset_code_verify_failed",
|
||||
username,
|
||||
remainingAttempts:
|
||||
loginRateLimiter.getRemainingResetCodeAttempts(username),
|
||||
});
|
||||
return res.status(400).json({
|
||||
error: "Reset code has expired",
|
||||
remainingAttempts:
|
||||
loginRateLimiter.getRemainingResetCodeAttempts(username),
|
||||
});
|
||||
}
|
||||
|
||||
if (resetData.code !== resetCode) {
|
||||
authLogger.warn("Reset code verification failed - invalid code", {
|
||||
operation: "reset_code_verify_failed",
|
||||
username,
|
||||
remainingAttempts:
|
||||
loginRateLimiter.getRemainingResetCodeAttempts(username),
|
||||
});
|
||||
return res.status(400).json({
|
||||
error: "Invalid reset code",
|
||||
remainingAttempts:
|
||||
loginRateLimiter.getRemainingResetCodeAttempts(username),
|
||||
});
|
||||
}
|
||||
|
||||
loginRateLimiter.resetResetCodeAttempts(username);
|
||||
|
||||
const tempToken = nanoid();
|
||||
const tempTokenExpiry = new Date(Date.now() + 10 * 60 * 1000);
|
||||
|
||||
db.$client
|
||||
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
||||
.run(
|
||||
`temp_reset_token_${username}`,
|
||||
JSON.stringify({
|
||||
token: tempToken,
|
||||
expiresAt: tempTokenExpiry.toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
res.json({ message: "Reset code verified", tempToken });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to verify reset code", err);
|
||||
res.status(500).json({ error: "Failed to verify reset code" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/complete-reset:
|
||||
* post:
|
||||
* summary: Complete password reset
|
||||
* description: Completes the password reset process with a new password.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* username:
|
||||
* type: string
|
||||
* tempToken:
|
||||
* type: string
|
||||
* newPassword:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Password has been successfully reset.
|
||||
* 400:
|
||||
* description: Invalid or expired temporary token.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to complete password reset.
|
||||
*/
|
||||
router.post("/complete-reset", async (req, res) => {
|
||||
const { username, tempToken, newPassword } = req.body;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(username) ||
|
||||
!isNonEmptyString(tempToken) ||
|
||||
!isNonEmptyString(newPassword)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "Username, temporary token, and new password are required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const tempTokenRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||
.get(`temp_reset_token_${username}`);
|
||||
if (!tempTokenRow) {
|
||||
return res.status(400).json({ error: "No temporary token found" });
|
||||
}
|
||||
|
||||
const tempTokenData = JSON.parse(
|
||||
(tempTokenRow as Record<string, unknown>).value as string,
|
||||
);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(tempTokenData.expiresAt);
|
||||
|
||||
if (now > expiresAt) {
|
||||
db.$client
|
||||
.prepare("DELETE FROM settings WHERE key = ?")
|
||||
.run(`temp_reset_token_${username}`);
|
||||
return res.status(400).json({ error: "Temporary token has expired" });
|
||||
}
|
||||
|
||||
if (tempTokenData.token !== tempToken) {
|
||||
return res.status(400).json({ error: "Invalid temporary token" });
|
||||
}
|
||||
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username));
|
||||
if (!user || user.length === 0) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
const userId = user[0].id;
|
||||
|
||||
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
||||
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
||||
|
||||
let userIdFromJwt: string | null = null;
|
||||
const cookie = req.cookies?.jwt;
|
||||
let header: string | undefined;
|
||||
if (req.headers?.authorization?.startsWith("Bearer ")) {
|
||||
header = req.headers?.authorization?.split(" ")[1];
|
||||
}
|
||||
const token = cookie || header;
|
||||
|
||||
if (token) {
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
if (payload) {
|
||||
userIdFromJwt = payload.userId;
|
||||
}
|
||||
}
|
||||
|
||||
if (userIdFromJwt === userId) {
|
||||
try {
|
||||
const success = await authManager.resetUserPasswordWithPreservedDEK(
|
||||
userId,
|
||||
newPassword,
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
throw new Error(
|
||||
"Failed to re-encrypt user data with new password.",
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ passwordHash: password_hash })
|
||||
.where(eq(users.id, userId));
|
||||
authManager.logoutUser(userId);
|
||||
authLogger.success(
|
||||
`Password reset (data preserved) for user: ${username}`,
|
||||
{
|
||||
operation: "password_reset_preserved",
|
||||
userId,
|
||||
username,
|
||||
},
|
||||
);
|
||||
} catch (encryptionError) {
|
||||
authLogger.error(
|
||||
"Failed to setup user data encryption after password reset",
|
||||
encryptionError,
|
||||
{
|
||||
operation: "password_reset_encryption_failed_preserved",
|
||||
userId,
|
||||
username,
|
||||
},
|
||||
);
|
||||
return res.status(500).json({
|
||||
error: "Password reset failed. Please contact administrator.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ passwordHash: password_hash })
|
||||
.where(eq(users.username, username));
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(sshCredentialUsage)
|
||||
.where(eq(sshCredentialUsage.userId, userId));
|
||||
await db
|
||||
.delete(fileManagerRecent)
|
||||
.where(eq(fileManagerRecent.userId, userId));
|
||||
await db
|
||||
.delete(fileManagerPinned)
|
||||
.where(eq(fileManagerPinned.userId, userId));
|
||||
await db
|
||||
.delete(fileManagerShortcuts)
|
||||
.where(eq(fileManagerShortcuts.userId, userId));
|
||||
await db
|
||||
.delete(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId));
|
||||
await db
|
||||
.delete(dismissedAlerts)
|
||||
.where(eq(dismissedAlerts.userId, userId));
|
||||
await db.delete(snippets).where(eq(snippets.userId, userId));
|
||||
await db.delete(hosts).where(eq(hosts.userId, userId));
|
||||
await db
|
||||
.delete(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId));
|
||||
|
||||
await authManager.registerUser(userId, newPassword);
|
||||
authManager.logoutUser(userId);
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
totpEnabled: false,
|
||||
totpSecret: null,
|
||||
totpBackupCodes: null,
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
authLogger.warn(
|
||||
`Password reset completed for user: ${username}. All encrypted data has been deleted due to lost encryption key.`,
|
||||
{
|
||||
operation: "password_reset_data_deleted",
|
||||
userId,
|
||||
username,
|
||||
},
|
||||
);
|
||||
} catch (encryptionError) {
|
||||
authLogger.error(
|
||||
"Failed to setup user data encryption after password reset",
|
||||
encryptionError,
|
||||
{
|
||||
operation: "password_reset_encryption_failed",
|
||||
userId,
|
||||
username,
|
||||
},
|
||||
);
|
||||
return res.status(500).json({
|
||||
error: "Password reset failed. Please contact administrator.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
authLogger.success(`Password successfully reset for user: ${username}`);
|
||||
|
||||
db.$client
|
||||
.prepare("DELETE FROM settings WHERE key = ?")
|
||||
.run(`reset_code_${username}`);
|
||||
db.$client
|
||||
.prepare("DELETE FROM settings WHERE key = ?")
|
||||
.run(`temp_reset_token_${username}`);
|
||||
|
||||
res.json({ message: "Password has been successfully reset" });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to complete password reset", err);
|
||||
res.status(500).json({ error: "Failed to complete password reset" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,14 @@ const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
const pickPreferences = (row?: typeof userPreferences.$inferSelect) => ({
|
||||
reopenTabsOnLogin: row?.reopenTabsOnLogin ?? false,
|
||||
theme: row?.theme ?? null,
|
||||
fontSize: row?.fontSize ?? null,
|
||||
accentColor: row?.accentColor ?? null,
|
||||
language: row?.language ?? null,
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /user-preferences:
|
||||
@@ -38,12 +46,12 @@ router.get("/", authenticateJWT, (req: Request, res: Response) => {
|
||||
.where(eq(userPreferences.userId, userId))
|
||||
.all();
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.json({ reopenTabsOnLogin: false });
|
||||
}
|
||||
return res.json({ reopenTabsOnLogin: rows[0].reopenTabsOnLogin });
|
||||
return res.json(pickPreferences(rows[0]));
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to get user preferences", e, { operation: "get_user_preferences", userId });
|
||||
databaseLogger.error("Failed to get user preferences", e, {
|
||||
operation: "get_user_preferences",
|
||||
userId,
|
||||
});
|
||||
return res.status(500).json({ error: "Failed to get user preferences" });
|
||||
}
|
||||
});
|
||||
@@ -70,10 +78,46 @@ router.get("/", authenticateJWT, (req: Request, res: Response) => {
|
||||
*/
|
||||
router.put("/", authenticateJWT, (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { reopenTabsOnLogin } = req.body as { reopenTabsOnLogin?: boolean };
|
||||
const { reopenTabsOnLogin, theme, fontSize, accentColor, language } =
|
||||
req.body as {
|
||||
reopenTabsOnLogin?: boolean;
|
||||
theme?: string | null;
|
||||
fontSize?: string | null;
|
||||
accentColor?: string | null;
|
||||
language?: string | null;
|
||||
};
|
||||
|
||||
const updates: Partial<typeof userPreferences.$inferInsert> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (reopenTabsOnLogin !== undefined) {
|
||||
if (typeof reopenTabsOnLogin !== "boolean") {
|
||||
return res.status(400).json({ error: "reopenTabsOnLogin must be a boolean" });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "reopenTabsOnLogin must be a boolean" });
|
||||
}
|
||||
updates.reopenTabsOnLogin = reopenTabsOnLogin;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries({
|
||||
theme,
|
||||
fontSize,
|
||||
accentColor,
|
||||
language,
|
||||
})) {
|
||||
if (value !== undefined && value !== null && typeof value !== "string") {
|
||||
return res.status(400).json({ error: `${key} must be a string` });
|
||||
}
|
||||
}
|
||||
|
||||
if (theme !== undefined) updates.theme = theme;
|
||||
if (fontSize !== undefined) updates.fontSize = fontSize;
|
||||
if (accentColor !== undefined) updates.accentColor = accentColor;
|
||||
if (language !== undefined) updates.language = language;
|
||||
|
||||
if (Object.keys(updates).length === 1) {
|
||||
return res.status(400).json({ error: "No preferences provided" });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -84,21 +128,25 @@ router.put("/", authenticateJWT, (req: Request, res: Response) => {
|
||||
.all();
|
||||
|
||||
if (existing.length === 0) {
|
||||
db.insert(userPreferences).values({
|
||||
db.insert(userPreferences)
|
||||
.values({
|
||||
userId,
|
||||
reopenTabsOnLogin,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).run();
|
||||
...updates,
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
db.update(userPreferences)
|
||||
.set({ reopenTabsOnLogin, updatedAt: new Date().toISOString() })
|
||||
.set(updates)
|
||||
.where(eq(userPreferences.userId, userId))
|
||||
.run();
|
||||
}
|
||||
|
||||
return res.json({ success: true, reopenTabsOnLogin });
|
||||
return res.json({ success: true, ...updates });
|
||||
} catch (e) {
|
||||
databaseLogger.error("Failed to update user preferences", e, { operation: "update_user_preferences", userId });
|
||||
databaseLogger.error("Failed to update user preferences", e, {
|
||||
operation: "update_user_preferences",
|
||||
userId,
|
||||
});
|
||||
return res.status(500).json({ error: "Failed to update user preferences" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { sessions, users } from "../db/schema.js";
|
||||
|
||||
type UserSessionRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
authManager: AuthManager;
|
||||
};
|
||||
|
||||
export function registerUserSessionRoutes(
|
||||
router: Router,
|
||||
{ authenticateJWT, authManager }: UserSessionRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sessions:
|
||||
* get:
|
||||
* summary: Get sessions
|
||||
* description: Retrieves all sessions for authenticated user (or all sessions for admins).
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Sessions list returned.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to get sessions.
|
||||
*/
|
||||
router.get("/sessions", authenticateJWT, async (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const userId = authReq.userId;
|
||||
const currentSessionId = authReq.sessionId;
|
||||
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
let sessionList;
|
||||
|
||||
if (userRecord.isAdmin) {
|
||||
sessionList = await authManager.getAllSessions();
|
||||
|
||||
const enrichedSessions = await Promise.all(
|
||||
sessionList.map(async (session) => {
|
||||
const sessionUser = await db
|
||||
.select({ username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, session.userId))
|
||||
.limit(1);
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
userId: session.userId,
|
||||
username: sessionUser[0]?.username || "Unknown",
|
||||
deviceType: session.deviceType,
|
||||
deviceInfo: session.deviceInfo,
|
||||
createdAt: session.createdAt,
|
||||
expiresAt: session.expiresAt,
|
||||
lastActiveAt: session.lastActiveAt,
|
||||
isRevoked: session.isRevoked,
|
||||
isCurrentSession: session.id === currentSessionId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return res.json({ sessions: enrichedSessions });
|
||||
} else {
|
||||
sessionList = await authManager.getUserSessions(userId);
|
||||
return res.json({
|
||||
sessions: sessionList.map((session) => ({
|
||||
id: session.id,
|
||||
userId: session.userId,
|
||||
deviceType: session.deviceType,
|
||||
deviceInfo: session.deviceInfo,
|
||||
createdAt: session.createdAt,
|
||||
expiresAt: session.expiresAt,
|
||||
lastActiveAt: session.lastActiveAt,
|
||||
isRevoked: session.isRevoked,
|
||||
isCurrentSession: session.id === currentSessionId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get sessions", err);
|
||||
res.status(500).json({ error: "Failed to get sessions" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sessions/{sessionId}:
|
||||
* delete:
|
||||
* summary: Revoke a specific session
|
||||
* description: Revokes a specific session by ID.
|
||||
* tags:
|
||||
* - Users
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: sessionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: The session ID to revoke
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Session revoked successfully.
|
||||
* 400:
|
||||
* description: Session ID is required.
|
||||
* 403:
|
||||
* description: Not authorized to revoke this session.
|
||||
* 404:
|
||||
* description: Session not found.
|
||||
* 500:
|
||||
* description: Failed to revoke session.
|
||||
*/
|
||||
router.delete("/sessions/:sessionId", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const sessionId = Array.isArray(req.params.sessionId)
|
||||
? req.params.sessionId[0]
|
||||
: req.params.sessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
const sessionRecords = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.id, sessionId))
|
||||
.limit(1);
|
||||
|
||||
if (sessionRecords.length === 0) {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
|
||||
const session = sessionRecords[0];
|
||||
|
||||
if (!userRecord.isAdmin && session.userId !== userId) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Not authorized to revoke this session" });
|
||||
}
|
||||
|
||||
const success = await authManager.revokeSession(sessionId);
|
||||
|
||||
if (success) {
|
||||
authLogger.success("Session revoked", {
|
||||
operation: "session_revoke",
|
||||
sessionId,
|
||||
revokedBy: userId,
|
||||
sessionUserId: session.userId,
|
||||
});
|
||||
res.json({ success: true, message: "Session revoked successfully" });
|
||||
} else {
|
||||
res.status(500).json({ error: "Failed to revoke session" });
|
||||
}
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to revoke session", err);
|
||||
res.status(500).json({ error: "Failed to revoke session" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sessions/revoke-all:
|
||||
* post:
|
||||
* summary: Revoke all sessions for a user
|
||||
* description: Revokes all sessions with option to exclude current session.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: false
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* targetUserId:
|
||||
* type: string
|
||||
* exceptCurrent:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Sessions revoked successfully.
|
||||
* 403:
|
||||
* description: Not authorized to revoke sessions for other users.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to revoke sessions.
|
||||
*/
|
||||
router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { targetUserId, exceptCurrent } = req.body;
|
||||
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
let revokeUserId = userId;
|
||||
if (targetUserId && userRecord.isAdmin) {
|
||||
revokeUserId = targetUserId;
|
||||
} else if (targetUserId && targetUserId !== userId) {
|
||||
return res.status(403).json({
|
||||
error: "Not authorized to revoke sessions for other users",
|
||||
});
|
||||
}
|
||||
|
||||
let currentSessionId: string | undefined;
|
||||
if (exceptCurrent) {
|
||||
currentSessionId = (req as AuthenticatedRequest).sessionId;
|
||||
}
|
||||
|
||||
const revokedCount = await authManager.revokeAllUserSessions(
|
||||
revokeUserId,
|
||||
currentSessionId,
|
||||
);
|
||||
|
||||
authLogger.success("User sessions revoked", {
|
||||
operation: "user_sessions_revoke_all",
|
||||
revokeUserId,
|
||||
revokedBy: userId,
|
||||
exceptCurrent,
|
||||
revokedCount,
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `${revokedCount} session(s) revoked successfully`,
|
||||
count: revokedCount,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to revoke user sessions", err);
|
||||
res.status(500).json({ error: "Failed to revoke sessions" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { restartGuacServer } from "../../guacamole/guacamole-server.js";
|
||||
import {
|
||||
authLogger,
|
||||
getGlobalLogLevel,
|
||||
setGlobalLogLevel,
|
||||
} from "../../utils/logger.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { users } from "../db/schema.js";
|
||||
|
||||
function getDefaultGuacUrl(): string {
|
||||
return `${process.env.GUACD_HOST || "localhost"}:${process.env.GUACD_PORT || "4822"}`;
|
||||
}
|
||||
|
||||
export function registerUserSettingsRoutes(
|
||||
router: Router,
|
||||
authenticateJWT: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/guacamole-settings:
|
||||
* get:
|
||||
* summary: Get Guacamole settings
|
||||
* description: Returns current guacd enabled status and host:port URL. No authentication required.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Guacamole settings.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* url:
|
||||
* type: string
|
||||
* 500:
|
||||
* description: Failed to get guacamole settings.
|
||||
*/
|
||||
router.get("/guacamole-settings", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
const enabledRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
|
||||
.get() as { value: string } | undefined;
|
||||
const urlRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||
.get() as { value: string } | undefined;
|
||||
res.json({
|
||||
enabled: enabledRow ? enabledRow.value !== "false" : true,
|
||||
url: urlRow ? urlRow.value : getDefaultGuacUrl(),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get guacamole settings", err);
|
||||
res.status(500).json({ error: "Failed to get guacamole settings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/guacamole-settings:
|
||||
* patch:
|
||||
* summary: Update Guacamole settings
|
||||
* description: Admin-only. Updates guacd enabled status and/or host:port URL.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* url:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Guacamole settings updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to update guacamole settings.
|
||||
*/
|
||||
router.patch("/guacamole-settings", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0 || !user[0].isAdmin) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const { enabled, url } = req.body;
|
||||
if (typeof enabled === "boolean") {
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_enabled', ?)",
|
||||
)
|
||||
.run(enabled ? "true" : "false");
|
||||
}
|
||||
if (typeof url === "string") {
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_url', ?)",
|
||||
)
|
||||
.run(url);
|
||||
try {
|
||||
await restartGuacServer();
|
||||
} catch (err) {
|
||||
authLogger.error(
|
||||
"Failed to restart guac server after URL update",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
const enabledRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
|
||||
.get() as { value: string } | undefined;
|
||||
const urlRow = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'guac_url'")
|
||||
.get() as { value: string } | undefined;
|
||||
res.json({
|
||||
enabled: enabledRow ? enabledRow.value !== "false" : true,
|
||||
url: urlRow ? urlRow.value : getDefaultGuacUrl(),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update guacamole settings", err);
|
||||
res.status(500).json({ error: "Failed to update guacamole settings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @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", authenticateJWT, 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", authenticateJWT, 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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, RequestHandler, Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
import QRCode from "qrcode";
|
||||
import speakeasy from "speakeasy";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
|
||||
import {
|
||||
generateDeviceFingerprint,
|
||||
parseUserAgent,
|
||||
} from "../../utils/user-agent-parser.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { sessions, trustedDevices, users } from "../db/schema.js";
|
||||
|
||||
type NativeAppRequestChecker = (req: Request) => boolean;
|
||||
|
||||
interface UserTotpRoutesDeps {
|
||||
authenticateJWT: RequestHandler;
|
||||
authManager: AuthManager;
|
||||
isNativeAppRequest: NativeAppRequestChecker;
|
||||
}
|
||||
|
||||
export function registerUserTotpRoutes(
|
||||
router: Router,
|
||||
{ authenticateJWT, authManager, isNativeAppRequest }: UserTotpRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/totp/setup:
|
||||
* post:
|
||||
* summary: Setup TOTP
|
||||
* description: Initiates TOTP setup by generating a secret and QR code.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: TOTP setup initiated with secret and QR code.
|
||||
* 400:
|
||||
* description: TOTP is already enabled.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to setup TOTP.
|
||||
*/
|
||||
router.post("/totp/setup", 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) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
if (userRecord.totpEnabled) {
|
||||
return res.status(400).json({ error: "TOTP is already enabled" });
|
||||
}
|
||||
|
||||
const secret = speakeasy.generateSecret({
|
||||
name: `Termix (${userRecord.username})`,
|
||||
length: 32,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ totpSecret: secret.base32 })
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url || "");
|
||||
|
||||
res.json({
|
||||
secret: secret.base32,
|
||||
qr_code: qrCodeUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to setup TOTP", err);
|
||||
res.status(500).json({ error: "Failed to setup TOTP" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/totp/enable:
|
||||
* post:
|
||||
* summary: Enable TOTP
|
||||
* description: Enables TOTP after verifying the initial code.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* totp_code:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: TOTP enabled successfully with backup codes.
|
||||
* 400:
|
||||
* description: TOTP code is required or TOTP already enabled.
|
||||
* 401:
|
||||
* description: Invalid TOTP code.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to enable TOTP.
|
||||
*/
|
||||
router.post("/totp/enable", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { totp_code } = req.body;
|
||||
|
||||
if (!totp_code) {
|
||||
return res.status(400).json({ error: "TOTP code is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
if (userRecord.totpEnabled) {
|
||||
return res.status(400).json({ error: "TOTP is already enabled" });
|
||||
}
|
||||
|
||||
if (!userRecord.totpSecret) {
|
||||
return res.status(400).json({ error: "TOTP setup not initiated" });
|
||||
}
|
||||
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: userRecord.totpSecret,
|
||||
encoding: "base32",
|
||||
token: totp_code,
|
||||
window: 2,
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
}
|
||||
|
||||
const backupCodes = Array.from({ length: 8 }, () =>
|
||||
Math.random().toString(36).substring(2, 10).toUpperCase(),
|
||||
);
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
totpEnabled: true,
|
||||
totpBackupCodes: JSON.stringify(backupCodes),
|
||||
})
|
||||
.where(eq(users.id, 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",
|
||||
backup_codes: backupCodes,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to enable TOTP", err);
|
||||
res.status(500).json({ error: "Failed to enable TOTP" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/totp/disable:
|
||||
* post:
|
||||
* summary: Disable TOTP
|
||||
* description: Disables TOTP for a user.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* password:
|
||||
* type: string
|
||||
* totp_code:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: TOTP disabled successfully.
|
||||
* 400:
|
||||
* description: Password or TOTP code is required.
|
||||
* 401:
|
||||
* description: Incorrect password or invalid TOTP code.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to disable TOTP.
|
||||
*/
|
||||
router.post("/totp/disable", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { password, totp_code } = req.body;
|
||||
|
||||
if (!password || !totp_code) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Both password and TOTP code are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
if (!userRecord.totpEnabled) {
|
||||
return res.status(400).json({ error: "TOTP is not enabled" });
|
||||
}
|
||||
|
||||
if (!userRecord.isOidc) {
|
||||
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
|
||||
if (!isMatch) {
|
||||
return res.status(401).json({ error: "Incorrect password" });
|
||||
}
|
||||
}
|
||||
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: userRecord.totpSecret!,
|
||||
encoding: "base32",
|
||||
token: totp_code,
|
||||
window: 2,
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
totpEnabled: false,
|
||||
totpSecret: null,
|
||||
totpBackupCodes: null,
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
authLogger.info("Two-factor authentication disabled", {
|
||||
operation: "totp_disable",
|
||||
userId,
|
||||
});
|
||||
|
||||
res.json({ message: "TOTP disabled successfully" });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to disable TOTP", err);
|
||||
res.status(500).json({ error: "Failed to disable TOTP" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/totp/backup-codes:
|
||||
* post:
|
||||
* summary: Generate new backup codes
|
||||
* description: Generates new TOTP backup codes.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* password:
|
||||
* type: string
|
||||
* totp_code:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: New backup codes generated.
|
||||
* 400:
|
||||
* description: Password or TOTP code is required.
|
||||
* 401:
|
||||
* description: Incorrect password or invalid TOTP code.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: Failed to generate backup codes.
|
||||
*/
|
||||
router.post("/totp/backup-codes", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { password, totp_code } = req.body;
|
||||
|
||||
if (!password || !totp_code) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Both password and TOTP code are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
if (!userRecord.totpEnabled) {
|
||||
return res.status(400).json({ error: "TOTP is not enabled" });
|
||||
}
|
||||
|
||||
if (!userRecord.isOidc) {
|
||||
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
|
||||
if (!isMatch) {
|
||||
return res.status(401).json({ error: "Incorrect password" });
|
||||
}
|
||||
}
|
||||
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: userRecord.totpSecret!,
|
||||
encoding: "base32",
|
||||
token: totp_code,
|
||||
window: 2,
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
}
|
||||
|
||||
const backupCodes = Array.from({ length: 8 }, () =>
|
||||
Math.random().toString(36).substring(2, 10).toUpperCase(),
|
||||
);
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ totpBackupCodes: JSON.stringify(backupCodes) })
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
res.json({ backup_codes: backupCodes });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to generate backup codes", err);
|
||||
res.status(500).json({ error: "Failed to generate backup codes" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/totp/verify-login:
|
||||
* post:
|
||||
* summary: Verify TOTP during login
|
||||
* description: Verifies the TOTP code during login.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* temp_token:
|
||||
* type: string
|
||||
* totp_code:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: TOTP verification successful.
|
||||
* 400:
|
||||
* description: Token and TOTP code are required.
|
||||
* 401:
|
||||
* description: Invalid temporary token or TOTP code.
|
||||
* 404:
|
||||
* description: User not found.
|
||||
* 500:
|
||||
* description: TOTP verification failed.
|
||||
*/
|
||||
router.post("/totp/verify-login", async (req, res) => {
|
||||
const { temp_token, totp_code, rememberMe } = req.body;
|
||||
|
||||
if (!temp_token || !totp_code) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Token and TOTP code are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = await authManager.verifyJWTToken(temp_token);
|
||||
if (!decoded || !decoded.pendingTOTP) {
|
||||
return res.status(401).json({ error: "Invalid temporary token" });
|
||||
}
|
||||
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, decoded.userId));
|
||||
if (!user || user.length === 0) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
const lockStatus = loginRateLimiter.isTOTPLocked(userRecord.id);
|
||||
if (lockStatus.locked) {
|
||||
authLogger.warn("TOTP verification blocked due to rate limiting", {
|
||||
operation: "totp_verify_blocked",
|
||||
userId: userRecord.id,
|
||||
remainingTime: lockStatus.remainingTime,
|
||||
});
|
||||
return res.status(429).json({
|
||||
error: `Rate limited: Too many TOTP verification attempts. Please wait ${lockStatus.remainingTime} seconds before trying again.`,
|
||||
remainingTime: lockStatus.remainingTime,
|
||||
code: "TOTP_RATE_LIMITED",
|
||||
});
|
||||
}
|
||||
|
||||
loginRateLimiter.recordFailedTOTPAttempt(userRecord.id);
|
||||
|
||||
if (!userRecord.totpEnabled || !userRecord.totpSecret) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "TOTP not enabled for this user" });
|
||||
}
|
||||
|
||||
const userDataKey = authManager.getUserDataKey(userRecord.id);
|
||||
if (!userDataKey) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const totpSecret = LazyFieldEncryption.safeGetFieldValue(
|
||||
userRecord.totpSecret,
|
||||
userDataKey,
|
||||
userRecord.id,
|
||||
"totp_secret",
|
||||
);
|
||||
|
||||
if (!totpSecret) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
totpEnabled: false,
|
||||
totpSecret: null,
|
||||
totpBackupCodes: null,
|
||||
})
|
||||
.where(eq(users.id, userRecord.id));
|
||||
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"TOTP has been disabled due to password reset. Please set up TOTP again.",
|
||||
});
|
||||
}
|
||||
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: totpSecret,
|
||||
encoding: "base32",
|
||||
token: totp_code,
|
||||
window: 2,
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
let backupCodes = [];
|
||||
try {
|
||||
backupCodes = userRecord.totpBackupCodes
|
||||
? JSON.parse(userRecord.totpBackupCodes)
|
||||
: [];
|
||||
} catch {
|
||||
backupCodes = [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(backupCodes)) {
|
||||
backupCodes = [];
|
||||
}
|
||||
|
||||
const backupIndex = backupCodes.indexOf(totp_code);
|
||||
|
||||
if (backupIndex === -1) {
|
||||
authLogger.warn("TOTP verification failed - invalid code", {
|
||||
operation: "totp_verify_failed",
|
||||
userId: userRecord.id,
|
||||
remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts(
|
||||
userRecord.id,
|
||||
),
|
||||
});
|
||||
return res.status(401).json({
|
||||
error: "Invalid TOTP code",
|
||||
remainingAttempts: loginRateLimiter.getRemainingTOTPAttempts(
|
||||
userRecord.id,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
backupCodes.splice(backupIndex, 1);
|
||||
await db
|
||||
.update(users)
|
||||
.set({ totpBackupCodes: JSON.stringify(backupCodes) })
|
||||
.where(eq(users.id, userRecord.id));
|
||||
}
|
||||
|
||||
loginRateLimiter.resetTOTPAttempts(userRecord.id);
|
||||
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
|
||||
if (rememberMe) {
|
||||
const deviceFingerprint = generateDeviceFingerprint(deviceInfo);
|
||||
await authManager.addTrustedDevice(
|
||||
userRecord.id,
|
||||
deviceFingerprint,
|
||||
deviceInfo.type,
|
||||
deviceInfo.deviceInfo,
|
||||
);
|
||||
authLogger.info("Device automatically trusted via Remember Me", {
|
||||
operation: "totp_auto_trust",
|
||||
userId: userRecord.id,
|
||||
deviceType: deviceInfo.type,
|
||||
});
|
||||
}
|
||||
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
rememberMe: !!rememberMe,
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
});
|
||||
|
||||
authLogger.success("TOTP verification successful", {
|
||||
operation: "totp_verify_success",
|
||||
userId: userRecord.id,
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
});
|
||||
|
||||
const response: Record<string, unknown> = {
|
||||
success: true,
|
||||
is_admin: !!userRecord.isAdmin,
|
||||
username: userRecord.username,
|
||||
userId: userRecord.id,
|
||||
is_oidc: !!userRecord.isOidc,
|
||||
totp_enabled: !!userRecord.totpEnabled,
|
||||
...(isNativeAppRequest(req) ? { token } : {}),
|
||||
};
|
||||
|
||||
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))
|
||||
.json(response);
|
||||
} catch (err) {
|
||||
authLogger.error("TOTP verification failed", err);
|
||||
return res.status(500).json({ error: "TOTP verification failed" });
|
||||
}
|
||||
});
|
||||
}
|
||||
+104
-2682
File diff suppressed because it is too large
Load Diff
@@ -232,7 +232,8 @@ router.post(
|
||||
const rdpRaw = !!host.enableRdp;
|
||||
const vncRaw = !!host.enableVnc;
|
||||
const telRaw = !!host.enableTelnet;
|
||||
const isMigratedNonSsh = !rdpRaw && !vncRaw && !telRaw && ct && ct !== "ssh";
|
||||
const isMigratedNonSsh =
|
||||
!rdpRaw && !vncRaw && !telRaw && ct && ct !== "ssh";
|
||||
const protocolEnabledMap: Record<string, boolean> = {
|
||||
rdp: isMigratedNonSsh ? ct === "rdp" : rdpRaw,
|
||||
vnc: isMigratedNonSsh ? ct === "vnc" : vncRaw,
|
||||
|
||||
@@ -297,7 +297,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
const authManagerInstance = AuthManager.getInstance();
|
||||
const payload = await authManagerInstance.verifyJWTToken(token);
|
||||
if (!payload || !payload.userId) {
|
||||
if (!payload?.userId || payload.pendingTOTP) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+16
-1053
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,541 @@
|
||||
import type { Express } from "express";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { fileLogger } from "../utils/logger.js";
|
||||
import { execChannel, type SSHSession } from "./file-manager-session.js";
|
||||
|
||||
type FileActionRoutesDeps = {
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
scheduleSessionCleanup: (sessionId: string) => void;
|
||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||
};
|
||||
|
||||
export function registerFileActionRoutes(
|
||||
app: Express,
|
||||
{
|
||||
sshSessions,
|
||||
scheduleSessionCleanup,
|
||||
verifySessionOwnership,
|
||||
}: FileActionRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/copyItem:
|
||||
* post:
|
||||
* summary: Copy a file or directory
|
||||
* description: Copies a file or directory on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* sourcePath:
|
||||
* type: string
|
||||
* targetDir:
|
||||
* type: string
|
||||
* hostId:
|
||||
* type: integer
|
||||
* userId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Item copied successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 500:
|
||||
* description: Failed to copy item.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
||||
const { sessionId, sourcePath, targetDir, hostId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId || !sourcePath || !targetDir) {
|
||||
return res.status(400).json({ error: "Missing required parameters" });
|
||||
}
|
||||
|
||||
const sshConn = sshSessions[sessionId];
|
||||
if (!sshConn || !sshConn.isConnected) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "SSH session not found or not connected" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const sourceName = sourcePath.split("/").pop() || "copied_item";
|
||||
|
||||
const timestamp = Date.now().toString().slice(-8);
|
||||
const uniqueName = `${sourceName}_copy_${timestamp}`;
|
||||
const targetPath = `${targetDir}/${uniqueName}`;
|
||||
|
||||
const escapedSource = sourcePath.replace(/'/g, "'\"'\"'");
|
||||
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const copyCommand = `cp '${escapedSource}' '${escapedTarget}' && echo "COPY_SUCCESS"`;
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
fileLogger.error("Copy command timed out after 60 seconds", {
|
||||
sourcePath,
|
||||
targetPath,
|
||||
command: copyCommand,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: "Copy operation timed out",
|
||||
toast: {
|
||||
type: "error",
|
||||
message:
|
||||
"Copy operation timed out. SSH connection may be unstable.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
execChannel(sshConn, copyCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH copyItem error:", err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let errorData = "";
|
||||
let stdoutData = "";
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
stdoutData += output;
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
errorData += output;
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
if (code !== 0) {
|
||||
const fullErrorInfo =
|
||||
errorData || stdoutData || "No error message available";
|
||||
fileLogger.error(`SSH copyItem command failed with code ${code}`, {
|
||||
operation: "file_copy_failed",
|
||||
sessionId,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
command: copyCommand,
|
||||
exitCode: code,
|
||||
errorData,
|
||||
stdoutData,
|
||||
fullErrorInfo,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: `Copy failed: ${fullErrorInfo}`,
|
||||
toast: {
|
||||
type: "error",
|
||||
message: `Copy failed: ${fullErrorInfo}`,
|
||||
},
|
||||
debug: {
|
||||
sourcePath,
|
||||
targetPath,
|
||||
exitCode: code,
|
||||
command: copyCommand,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const copySuccessful =
|
||||
stdoutData.includes("COPY_SUCCESS") || code === 0;
|
||||
|
||||
if (copySuccessful) {
|
||||
fileLogger.success("Item copied successfully", {
|
||||
operation: "file_copy",
|
||||
sessionId,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
uniqueName,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Item copied successfully",
|
||||
sourcePath,
|
||||
targetPath,
|
||||
uniqueName,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `Successfully copied to: ${uniqueName}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
fileLogger.warn("Copy completed but without success confirmation", {
|
||||
operation: "file_copy_uncertain",
|
||||
sessionId,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
code,
|
||||
stdoutData: stdoutData.substring(0, 200),
|
||||
});
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Copy may have completed",
|
||||
sourcePath,
|
||||
targetPath,
|
||||
uniqueName,
|
||||
toast: {
|
||||
type: "warning",
|
||||
message: `Copy completed but verification uncertain for: ${uniqueName}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH copyItem stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Stream error: ${streamErr.message}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/executeFile:
|
||||
* post:
|
||||
* summary: Execute a file
|
||||
* description: Executes a file on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* filePath:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: File execution result.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not available.
|
||||
* 500:
|
||||
* description: Failed to execute file.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||
const { sessionId, filePath } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sshConn || !sshConn.isConnected) {
|
||||
fileLogger.error(
|
||||
"SSH connection not found or not connected for executeFile",
|
||||
{
|
||||
operation: "execute_file",
|
||||
sessionId,
|
||||
hasConnection: !!sshConn,
|
||||
isConnected: sshConn?.isConnected,
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "SSH connection not available" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: "File path is required" });
|
||||
}
|
||||
|
||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`;
|
||||
|
||||
execChannel(sshConn, checkCommand, (checkErr, checkStream) => {
|
||||
if (checkErr) {
|
||||
fileLogger.error("SSH executeFile check error:", checkErr);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to check file executability" });
|
||||
}
|
||||
|
||||
let checkResult = "";
|
||||
checkStream.on("data", (data) => {
|
||||
checkResult += data.toString();
|
||||
});
|
||||
|
||||
checkStream.on("close", () => {
|
||||
if (!checkResult.includes("EXECUTABLE")) {
|
||||
return res.status(400).json({ error: "File is not executable" });
|
||||
}
|
||||
|
||||
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
|
||||
|
||||
execChannel(sshConn, executeCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH executeFile error:", err);
|
||||
return res.status(500).json({ error: "Failed to execute file" });
|
||||
}
|
||||
|
||||
let output = "";
|
||||
let errorOutput = "";
|
||||
|
||||
stream.on("data", (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/);
|
||||
const actualExitCode = exitCodeMatch
|
||||
? parseInt(exitCodeMatch[1])
|
||||
: code;
|
||||
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim();
|
||||
|
||||
fileLogger.info("File execution completed", {
|
||||
operation: "execute_file",
|
||||
sessionId,
|
||||
filePath,
|
||||
exitCode: actualExitCode,
|
||||
outputLength: cleanOutput.length,
|
||||
errorLength: errorOutput.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
exitCode: actualExitCode,
|
||||
output: cleanOutput,
|
||||
error: errorOutput,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
fileLogger.error("SSH executeFile stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Execution stream error" });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/changePermissions:
|
||||
* post:
|
||||
* summary: Change file permissions
|
||||
* description: Changes the permissions of a file on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* permissions:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Permissions changed successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not available.
|
||||
* 408:
|
||||
* description: Permission change timed out.
|
||||
* 500:
|
||||
* description: Failed to change permissions.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
|
||||
const { sessionId, path, permissions } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sshConn || !sshConn.isConnected) {
|
||||
fileLogger.error(
|
||||
"SSH connection not found or not connected for changePermissions",
|
||||
{
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
hasConnection: !!sshConn,
|
||||
isConnected: sshConn?.isConnected,
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "SSH connection not available" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return res.status(400).json({ error: "File path is required" });
|
||||
}
|
||||
|
||||
if (!permissions || !/^\d{3,4}$/.test(permissions)) {
|
||||
return res.status(400).json({
|
||||
error: "Valid permissions required (e.g., 755, 644)",
|
||||
});
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
const octalPerms = permissions.slice(-3);
|
||||
const escapedPath = path.replace(/'/g, "'\"'\"'");
|
||||
const command = `chmod ${octalPerms} '${escapedPath}' && echo "SUCCESS"`;
|
||||
|
||||
fileLogger.info("Changing file permissions", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
if (!res.headersSent) {
|
||||
fileLogger.error("changePermissions command timeout", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
res.status(408).json({
|
||||
error: "Permission change timed out. SSH connection may be unstable.",
|
||||
});
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
execChannel(sshConn, command, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH changePermissions exec error:", err, {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to change permissions" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorOutput = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
fileLogger.success("File permissions changed successfully", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Permissions changed successfully",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
fileLogger.error("chmod command failed", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
exitCode: code,
|
||||
error: errorOutput,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: errorOutput || "Failed to change permissions",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fileLogger.success("File permissions changed successfully", {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Permissions changed successfully",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH changePermissions stream error:", streamErr, {
|
||||
operation: "change_permissions",
|
||||
sessionId,
|
||||
path,
|
||||
permissions: octalPerms,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Stream error while changing permissions" });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
||||
import type { Express } from "express";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { fileLogger } from "../utils/logger.js";
|
||||
import { getMimeType } from "./file-manager-utils.js";
|
||||
import { getSessionSftp, type SSHSession } from "./file-manager-session.js";
|
||||
|
||||
type FileDownloadRoutesDeps = {
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
scheduleSessionCleanup: (sessionId: string) => void;
|
||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||
};
|
||||
|
||||
export function registerFileDownloadRoutes(
|
||||
app: Express,
|
||||
{
|
||||
sshSessions,
|
||||
scheduleSessionCleanup,
|
||||
verifySessionOwnership,
|
||||
}: FileDownloadRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/downloadFile:
|
||||
* post:
|
||||
* summary: Download a file
|
||||
* description: Downloads a file from the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* hostId:
|
||||
* type: integer
|
||||
* userId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The file content.
|
||||
* 400:
|
||||
* description: Missing required parameters or file too large.
|
||||
* 500:
|
||||
* description: Failed to download file.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
|
||||
const { sessionId, path: filePath, hostId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const downloadStartTime = Date.now();
|
||||
|
||||
if (!sessionId || !filePath) {
|
||||
fileLogger.warn("Missing download parameters", {
|
||||
operation: "file_download",
|
||||
sessionId,
|
||||
hasFilePath: !!filePath,
|
||||
});
|
||||
return res.status(400).json({ error: "Missing download parameters" });
|
||||
}
|
||||
|
||||
fileLogger.info("File download started", {
|
||||
operation: "file_download_start",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
});
|
||||
|
||||
const sshConn = sshSessions[sessionId];
|
||||
if (!sshConn || !sshConn.isConnected) {
|
||||
fileLogger.warn("SSH session not found or not connected for download", {
|
||||
operation: "file_download",
|
||||
sessionId,
|
||||
isConnected: sshConn?.isConnected,
|
||||
});
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "SSH session not found or not connected" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
fileLogger.info("Opening SFTP channel", {
|
||||
operation: "file_sftp_open",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
});
|
||||
|
||||
getSessionSftp(sshConn)
|
||||
.then((sftp) => {
|
||||
sftp.stat(filePath, (statErr, stats) => {
|
||||
if (statErr) {
|
||||
fileLogger.error("File stat failed for download:", statErr);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: `Cannot access file: ${statErr.message}` });
|
||||
}
|
||||
|
||||
if (!stats.isFile()) {
|
||||
fileLogger.warn("Attempted to download non-file", {
|
||||
operation: "file_download",
|
||||
sessionId,
|
||||
filePath,
|
||||
isFile: stats.isFile(),
|
||||
isDirectory: stats.isDirectory(),
|
||||
});
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Cannot download directories or special files" });
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024;
|
||||
if (stats.size > MAX_FILE_SIZE) {
|
||||
fileLogger.warn("File too large for download", {
|
||||
operation: "file_download",
|
||||
sessionId,
|
||||
filePath,
|
||||
fileSize: stats.size,
|
||||
maxSize: MAX_FILE_SIZE,
|
||||
});
|
||||
return res.status(400).json({
|
||||
error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB, file is ${(stats.size / 1024 / 1024).toFixed(2)}MB`,
|
||||
});
|
||||
}
|
||||
|
||||
sftp.readFile(filePath, (readErr, data) => {
|
||||
if (readErr) {
|
||||
fileLogger.error("File read failed for download:", readErr);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: `Failed to read file: ${readErr.message}` });
|
||||
}
|
||||
|
||||
const base64Content = data.toString("base64");
|
||||
const fileName = filePath.split("/").pop() || "download";
|
||||
fileLogger.success("File download completed", {
|
||||
operation: "file_download_complete",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
path: filePath,
|
||||
bytes: stats.size,
|
||||
duration: Date.now() - downloadStartTime,
|
||||
});
|
||||
|
||||
res.json({
|
||||
content: base64Content,
|
||||
fileName: fileName,
|
||||
size: stats.size,
|
||||
mimeType: getMimeType(fileName),
|
||||
path: filePath,
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
fileLogger.error("SFTP connection failed for download:", err);
|
||||
return res.status(500).json({ error: "SFTP connection failed" });
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/ssh/file_manager/ssh/downloadFileStream", async (req, res) => {
|
||||
const { sessionId, path: filePath } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId || !filePath) {
|
||||
return res.status(400).json({ error: "Missing download parameters" });
|
||||
}
|
||||
|
||||
const sshConn = sshSessions[sessionId];
|
||||
if (!sshConn?.isConnected) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "SSH session not found or not connected" });
|
||||
}
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
try {
|
||||
const sftp = await getSessionSftp(sshConn);
|
||||
const stats = await new Promise<{ size: number; isFile: () => boolean }>(
|
||||
(resolve, reject) => {
|
||||
sftp.stat(filePath, (err, s) => (err ? reject(err) : resolve(s)));
|
||||
},
|
||||
);
|
||||
|
||||
if (!stats.isFile()) {
|
||||
return res.status(400).json({ error: "Cannot download directories" });
|
||||
}
|
||||
|
||||
const fileName = filePath.split("/").pop() || "download";
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${encodeURIComponent(fileName)}"`,
|
||||
);
|
||||
res.setHeader("Content-Length", String(stats.size));
|
||||
|
||||
const readStream = sftp.createReadStream(filePath);
|
||||
readStream.on("error", (err) => {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Download failed: ${err.message}` });
|
||||
} else {
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
readStream.pipe(res);
|
||||
} catch (err) {
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Download failed: ${(err as Error).message}` });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import type { Express } from "express";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { fileLogger } from "../utils/logger.js";
|
||||
import {
|
||||
execChannel,
|
||||
getSessionSftp,
|
||||
type SSHSession,
|
||||
} from "./file-manager-session.js";
|
||||
import {
|
||||
formatMtime,
|
||||
isExecutableFile,
|
||||
modeToPermissions,
|
||||
} from "./file-manager-utils.js";
|
||||
|
||||
type FileListingRoutesDeps = {
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
activeListRequests: Record<string, boolean>;
|
||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||
};
|
||||
|
||||
export function registerFileListingRoutes(
|
||||
app: Express,
|
||||
{
|
||||
sshSessions,
|
||||
activeListRequests,
|
||||
verifySessionOwnership,
|
||||
}: FileListingRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/listFiles:
|
||||
* get:
|
||||
* summary: List files in a directory
|
||||
* description: Lists the files and directories in a given path on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: sessionId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A list of files and directories.
|
||||
* 400:
|
||||
* description: Session ID is required or SSH connection not established.
|
||||
* 500:
|
||||
* description: Failed to list files.
|
||||
*/
|
||||
app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
const sessionId = req.query.sessionId as string;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const sshPath = decodeURIComponent((req.query.path as string) || "/");
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
// Drop concurrent requests for the same session+path — each would open
|
||||
// a new SSH channel and can exceed the server's per-connection channel limit.
|
||||
const listKey = `${sessionId}:${sshPath}`;
|
||||
if (activeListRequests[listKey]) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "List request already in progress" });
|
||||
}
|
||||
activeListRequests[listKey] = true;
|
||||
res.on("finish", () => {
|
||||
delete activeListRequests[listKey];
|
||||
});
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
sshConn.activeOperations++;
|
||||
const trySFTP = () => {
|
||||
try {
|
||||
fileLogger.info("Opening SFTP channel", {
|
||||
operation: "file_sftp_open",
|
||||
sessionId,
|
||||
userId,
|
||||
path: sshPath,
|
||||
});
|
||||
getSessionSftp(sshConn)
|
||||
.then((sftp) => {
|
||||
sftp.readdir(sshPath, (readdirErr, list) => {
|
||||
if (readdirErr) {
|
||||
fileLogger.warn(
|
||||
`SFTP readdir failed, trying fallback: ${readdirErr.message}`,
|
||||
);
|
||||
tryFallbackMethod();
|
||||
return;
|
||||
}
|
||||
|
||||
const symlinks: Array<{ index: number; path: string }> = [];
|
||||
const files: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
size: number | undefined;
|
||||
modified: string;
|
||||
permissions: string;
|
||||
owner: string;
|
||||
group: string;
|
||||
linkTarget: string | undefined;
|
||||
path: string;
|
||||
executable: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const entry of list) {
|
||||
if (entry.filename === "." || entry.filename === "..") continue;
|
||||
|
||||
const attrs = entry.attrs;
|
||||
const permissions = modeToPermissions(attrs.mode);
|
||||
const isDirectory = attrs.isDirectory();
|
||||
const isLink = attrs.isSymbolicLink();
|
||||
|
||||
const fileEntry = {
|
||||
name: entry.filename,
|
||||
type: isDirectory ? "directory" : isLink ? "link" : "file",
|
||||
size: isDirectory ? undefined : attrs.size,
|
||||
modified: formatMtime(attrs.mtime),
|
||||
permissions,
|
||||
owner: String(attrs.uid),
|
||||
group: String(attrs.gid),
|
||||
linkTarget: undefined as string | undefined,
|
||||
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${entry.filename}`,
|
||||
executable:
|
||||
!isDirectory && !isLink
|
||||
? isExecutableFile(permissions, entry.filename)
|
||||
: false,
|
||||
};
|
||||
|
||||
if (isLink) {
|
||||
symlinks.push({ index: files.length, path: fileEntry.path });
|
||||
}
|
||||
|
||||
files.push(fileEntry);
|
||||
}
|
||||
|
||||
if (symlinks.length === 0) {
|
||||
sshConn.activeOperations--;
|
||||
return res.json({ files, path: sshPath });
|
||||
}
|
||||
|
||||
let resolved = 0;
|
||||
let responded = false;
|
||||
|
||||
const sendResponse = () => {
|
||||
if (responded) return;
|
||||
responded = true;
|
||||
sshConn.activeOperations--;
|
||||
res.json({ files, path: sshPath });
|
||||
};
|
||||
|
||||
const readlinkTimeout = setTimeout(sendResponse, 5000);
|
||||
|
||||
for (const link of symlinks) {
|
||||
sftp.readlink(link.path, (linkErr, target) => {
|
||||
resolved++;
|
||||
if (!linkErr && target) {
|
||||
files[link.index].linkTarget = target;
|
||||
}
|
||||
if (resolved === symlinks.length) {
|
||||
clearTimeout(readlinkTimeout);
|
||||
sendResponse();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
fileLogger.warn(
|
||||
`SFTP failed for listFiles, trying fallback: ${err.message}`,
|
||||
);
|
||||
const isChannelFailure =
|
||||
err.message.toLowerCase().includes("channel open failure") ||
|
||||
err.message.toLowerCase().includes("open failed");
|
||||
if (isChannelFailure) {
|
||||
sshConn.isConnected = false;
|
||||
sshConn.sftp = undefined;
|
||||
}
|
||||
tryFallbackMethod();
|
||||
});
|
||||
} catch (sftpErr: unknown) {
|
||||
const errMsg =
|
||||
sftpErr instanceof Error ? sftpErr.message : "Unknown error";
|
||||
fileLogger.warn(`SFTP connection error, trying fallback: ${errMsg}`);
|
||||
tryFallbackMethod();
|
||||
}
|
||||
};
|
||||
|
||||
const tryFallbackMethod = () => {
|
||||
if (!sshConn?.isConnected) {
|
||||
sshConn.activeOperations--;
|
||||
return res
|
||||
.status(503)
|
||||
.json({ error: "SSH session disconnected", disconnected: true });
|
||||
}
|
||||
try {
|
||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||
execChannel(
|
||||
sshConn,
|
||||
`command ls -la --color=never '${escapedPath}'`,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
sshConn.activeOperations--;
|
||||
fileLogger.error("SSH listFiles error:", err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
let data = "";
|
||||
let errorData = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
const isPermissionDenied =
|
||||
errorData.toLowerCase().includes("permission denied") ||
|
||||
errorData.toLowerCase().includes("access denied");
|
||||
|
||||
if (isPermissionDenied) {
|
||||
if (sshConn.sudoPassword) {
|
||||
fileLogger.info(
|
||||
`Permission denied for listFiles, retrying with sudo: ${sshPath}`,
|
||||
);
|
||||
tryWithSudo();
|
||||
return;
|
||||
}
|
||||
|
||||
sshConn.activeOperations--;
|
||||
fileLogger.warn(
|
||||
`Permission denied for listFiles, sudo required: ${sshPath}`,
|
||||
);
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot access ${sshPath}`,
|
||||
needsSudo: true,
|
||||
path: sshPath,
|
||||
});
|
||||
}
|
||||
|
||||
sshConn.activeOperations--;
|
||||
fileLogger.error(
|
||||
`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: `Command failed: ${errorData}` });
|
||||
}
|
||||
sshConn.activeOperations--;
|
||||
|
||||
const lines = data.split("\n").filter((line) => line.trim());
|
||||
const files = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 9) {
|
||||
const permissions = parts[0];
|
||||
const owner = parts[2];
|
||||
const group = parts[3];
|
||||
const size = parseInt(parts[4], 10);
|
||||
|
||||
let dateStr = "";
|
||||
const nameStartIndex = 8;
|
||||
|
||||
if (parts[5] && parts[6] && parts[7]) {
|
||||
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
|
||||
}
|
||||
|
||||
const name = parts.slice(nameStartIndex).join(" ");
|
||||
const isDirectory = permissions.startsWith("d");
|
||||
const isLink = permissions.startsWith("l");
|
||||
|
||||
if (name === "." || name === "..") continue;
|
||||
|
||||
let actualName = name;
|
||||
let linkTarget = undefined;
|
||||
if (isLink && name.includes(" -> ")) {
|
||||
const linkParts = name.split(" -> ");
|
||||
actualName = linkParts[0];
|
||||
linkTarget = linkParts[1];
|
||||
}
|
||||
|
||||
files.push({
|
||||
name: actualName,
|
||||
type: isDirectory ? "directory" : isLink ? "link" : "file",
|
||||
size: isDirectory ? undefined : size,
|
||||
modified: dateStr,
|
||||
permissions,
|
||||
owner,
|
||||
group,
|
||||
linkTarget,
|
||||
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
|
||||
executable:
|
||||
!isDirectory && !isLink
|
||||
? isExecutableFile(permissions, actualName)
|
||||
: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ files, path: sshPath });
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (execErr: unknown) {
|
||||
sshConn.activeOperations--;
|
||||
const errMsg =
|
||||
execErr instanceof Error ? execErr.message : "Unknown error";
|
||||
fileLogger.error(`Fallback listFiles exec failed: ${errMsg}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: errMsg });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const tryWithSudo = () => {
|
||||
try {
|
||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||
const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'");
|
||||
const sudoCommand = `echo '${escapedPassword}' | sudo -S /bin/ls -la --color=never '${escapedPath}' 2>&1`;
|
||||
|
||||
execChannel(sshConn, sudoCommand, (err, stream) => {
|
||||
if (err) {
|
||||
sshConn.activeOperations--;
|
||||
fileLogger.error("SSH sudo listFiles error:", err);
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
let data = "";
|
||||
let errorData = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
sshConn.activeOperations--;
|
||||
|
||||
data = data.replace(/\[sudo\] password for .+?:\s*/g, "");
|
||||
|
||||
if (
|
||||
data.toLowerCase().includes("sorry, try again") ||
|
||||
data.toLowerCase().includes("incorrect password") ||
|
||||
errorData.toLowerCase().includes("sorry, try again")
|
||||
) {
|
||||
sshConn.sudoPassword = undefined;
|
||||
return res.status(403).json({
|
||||
error: "Sudo authentication failed. Please try again.",
|
||||
needsSudo: true,
|
||||
sudoFailed: true,
|
||||
path: sshPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (code !== 0 && !data.trim()) {
|
||||
fileLogger.error(
|
||||
`SSH sudo listFiles failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: `Sudo command failed: ${errorData || data}` });
|
||||
}
|
||||
|
||||
const lines = data.split("\n").filter((line) => line.trim());
|
||||
const files: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
size: number | undefined;
|
||||
modified: string;
|
||||
permissions: string;
|
||||
owner: string;
|
||||
group: string;
|
||||
linkTarget: string | undefined;
|
||||
path: string;
|
||||
executable: boolean;
|
||||
}> = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 9) {
|
||||
const permissions = parts[0];
|
||||
const owner = parts[2];
|
||||
const group = parts[3];
|
||||
const size = parseInt(parts[4], 10);
|
||||
|
||||
let dateStr = "";
|
||||
const nameStartIndex = 8;
|
||||
|
||||
if (parts[5] && parts[6] && parts[7]) {
|
||||
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
|
||||
}
|
||||
|
||||
const name = parts.slice(nameStartIndex).join(" ");
|
||||
const isDirectory = permissions.startsWith("d");
|
||||
const isLink = permissions.startsWith("l");
|
||||
|
||||
if (name === "." || name === "..") continue;
|
||||
|
||||
let actualName = name;
|
||||
let linkTarget = undefined;
|
||||
if (isLink && name.includes(" -> ")) {
|
||||
const linkParts = name.split(" -> ");
|
||||
actualName = linkParts[0];
|
||||
linkTarget = linkParts[1];
|
||||
}
|
||||
|
||||
files.push({
|
||||
name: actualName,
|
||||
type: isDirectory ? "directory" : isLink ? "link" : "file",
|
||||
size: isDirectory ? undefined : size,
|
||||
modified: dateStr,
|
||||
permissions,
|
||||
owner,
|
||||
group,
|
||||
linkTarget,
|
||||
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
|
||||
executable:
|
||||
!isDirectory && !isLink
|
||||
? isExecutableFile(permissions, actualName)
|
||||
: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ files, path: sshPath });
|
||||
});
|
||||
});
|
||||
} catch (execErr: unknown) {
|
||||
sshConn.activeOperations--;
|
||||
const errMsg =
|
||||
execErr instanceof Error ? execErr.message : "Unknown error";
|
||||
fileLogger.error(`Sudo listFiles exec failed: ${errMsg}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: errMsg });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
trySFTP();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ConnectionStage, LogEntry } from "../../types/connection-log.js";
|
||||
|
||||
export function createConnectionLog(
|
||||
type: "info" | "success" | "warning" | "error",
|
||||
stage: ConnectionStage,
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
): Omit<LogEntry, "id" | "timestamp"> {
|
||||
return {
|
||||
type,
|
||||
stage,
|
||||
message,
|
||||
details,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,818 @@
|
||||
import type { Express } from "express";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { fileLogger } from "../utils/logger.js";
|
||||
import {
|
||||
execChannel,
|
||||
execWithSudo,
|
||||
type SSHSession,
|
||||
} from "./file-manager-session.js";
|
||||
|
||||
type FileOperationRoutesDeps = {
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||
};
|
||||
|
||||
export function registerFileOperationRoutes(
|
||||
app: Express,
|
||||
{ sshSessions, verifySessionOwnership }: FileOperationRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/createFile:
|
||||
* post:
|
||||
* summary: Create a file
|
||||
* description: Creates an empty file on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* fileName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: File created successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 500:
|
||||
* description: Failed to create file.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/createFile", async (req, res) => {
|
||||
const { sessionId, path: filePath, fileName } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!filePath || !fileName) {
|
||||
return res.status(400).json({ error: "File path and name are required" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
const fullPath = filePath.endsWith("/")
|
||||
? filePath + fileName
|
||||
: filePath + "/" + fileName;
|
||||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
execChannel(sshConn, createCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH createFile error:", err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorData = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes("Permission denied")) {
|
||||
fileLogger.error(`Permission denied creating file: ${fullPath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot create file ${fullPath}. Check directory permissions.`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "File created successfully",
|
||||
path: fullPath,
|
||||
toast: { type: "success", message: `File created: ${fullPath}` },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
fileLogger.error(
|
||||
`SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: `Command failed: ${errorData}`,
|
||||
toast: {
|
||||
type: "error",
|
||||
message: `File creation failed: ${errorData}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "File created successfully",
|
||||
path: fullPath,
|
||||
toast: { type: "success", message: `File created: ${fullPath}` },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
fileLogger.error("SSH createFile stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/createFolder:
|
||||
* post:
|
||||
* summary: Create a folder
|
||||
* description: Creates a new folder on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* folderName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Folder created successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 500:
|
||||
* description: Failed to create folder.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => {
|
||||
const { sessionId, path: folderPath, folderName } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!folderPath || !folderName) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Folder path and name are required" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
const fullPath = folderPath.endsWith("/")
|
||||
? folderPath + folderName
|
||||
: folderPath + "/" + folderName;
|
||||
fileLogger.info("Creating directory", {
|
||||
operation: "file_mkdir",
|
||||
sessionId,
|
||||
userId,
|
||||
path: fullPath,
|
||||
});
|
||||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const createCommand = `mkdir -p '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
execChannel(sshConn, createCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH createFolder error:", err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorData = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes("Permission denied")) {
|
||||
fileLogger.error(`Permission denied creating folder: ${fullPath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot create folder ${fullPath}. Check directory permissions.`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
fileLogger.success("Directory created successfully", {
|
||||
operation: "file_mkdir_success",
|
||||
sessionId,
|
||||
userId,
|
||||
path: fullPath,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Folder created successfully",
|
||||
path: fullPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `Folder created: ${fullPath}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
fileLogger.error(
|
||||
`SSH createFolder command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: `Command failed: ${errorData}`,
|
||||
toast: {
|
||||
type: "error",
|
||||
message: `Folder creation failed: ${errorData}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fileLogger.success("Directory created successfully", {
|
||||
operation: "file_mkdir_success",
|
||||
sessionId,
|
||||
userId,
|
||||
path: fullPath,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Folder created successfully",
|
||||
path: fullPath,
|
||||
toast: { type: "success", message: `Folder created: ${fullPath}` },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
fileLogger.error("SSH createFolder stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/deleteItem:
|
||||
* delete:
|
||||
* summary: Delete a file or directory
|
||||
* description: Deletes a file or directory on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* isDirectory:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Item deleted successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 500:
|
||||
* description: Failed to delete item.
|
||||
*/
|
||||
app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
|
||||
const { sessionId, path: itemPath, isDirectory } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!itemPath) {
|
||||
return res.status(400).json({ error: "Item path is required" });
|
||||
}
|
||||
|
||||
fileLogger.info("Deleting item", {
|
||||
operation: "file_delete",
|
||||
sessionId,
|
||||
userId,
|
||||
path: itemPath,
|
||||
type: isDirectory ? "directory" : "file",
|
||||
});
|
||||
sshConn.lastActive = Date.now();
|
||||
const escapedPath = itemPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const deleteCommand = isDirectory
|
||||
? `rm -rf '${escapedPath}'`
|
||||
: `rm -f '${escapedPath}'`;
|
||||
|
||||
const executeDelete = (useSudo: boolean): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
if (useSudo && sshConn.sudoPassword) {
|
||||
execWithSudo(sshConn, deleteCommand, sshConn.sudoPassword).then(
|
||||
(result) => {
|
||||
if (
|
||||
result.code === 0 ||
|
||||
(!result.stderr.includes("Permission denied") &&
|
||||
!result.stdout.includes("Permission denied"))
|
||||
) {
|
||||
res.json({
|
||||
message: "Item deleted successfully",
|
||||
path: itemPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: `Delete failed: ${result.stderr || result.stdout}`,
|
||||
});
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
execChannel(
|
||||
sshConn,
|
||||
`${deleteCommand} && echo "SUCCESS"`,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH deleteItem error:", err);
|
||||
res.status(500).json({ error: err.message });
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorData = "";
|
||||
let permissionDenied = false;
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
if (chunk.toString().includes("Permission denied")) {
|
||||
permissionDenied = true;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (permissionDenied) {
|
||||
if (sshConn.sudoPassword) {
|
||||
executeDelete(true).then(resolve);
|
||||
return;
|
||||
}
|
||||
fileLogger.error(`Permission denied deleting: ${itemPath}`);
|
||||
res.status(403).json({
|
||||
error: `Permission denied: Cannot delete ${itemPath}.`,
|
||||
needsSudo: true,
|
||||
});
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (outputData.includes("SUCCESS") || code === 0) {
|
||||
fileLogger.success("Item deleted successfully", {
|
||||
operation: "file_delete_success",
|
||||
sessionId,
|
||||
userId,
|
||||
path: itemPath,
|
||||
});
|
||||
res.json({
|
||||
message: "Item deleted successfully",
|
||||
path: itemPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: `Command failed: ${errorData}`,
|
||||
});
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
fileLogger.error("SSH deleteItem stream error:", streamErr);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Stream error: ${streamErr.message}` });
|
||||
resolve();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
await executeDelete(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/renameItem:
|
||||
* put:
|
||||
* summary: Rename a file or directory
|
||||
* description: Renames a file or directory on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* oldPath:
|
||||
* type: string
|
||||
* newName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Item renamed successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 500:
|
||||
* description: Failed to rename item.
|
||||
*/
|
||||
app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
|
||||
const { sessionId, oldPath, newName } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!oldPath || !newName) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Old path and new name are required" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
const oldDir = oldPath.substring(0, oldPath.lastIndexOf("/") + 1);
|
||||
const newPath = oldDir + newName;
|
||||
fileLogger.info("Renaming item", {
|
||||
operation: "file_rename",
|
||||
sessionId,
|
||||
userId,
|
||||
from: oldPath,
|
||||
to: newPath,
|
||||
});
|
||||
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
|
||||
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
execChannel(sshConn, renameCommand, (err, stream) => {
|
||||
if (err) {
|
||||
fileLogger.error("SSH renameItem error:", err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorData = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes("Permission denied")) {
|
||||
fileLogger.error(`Permission denied renaming: ${oldPath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot rename ${oldPath}. Check file permissions.`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
fileLogger.success("Item renamed successfully", {
|
||||
operation: "file_rename_success",
|
||||
sessionId,
|
||||
userId,
|
||||
from: oldPath,
|
||||
to: newPath,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Item renamed successfully",
|
||||
oldPath,
|
||||
newPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `Item renamed: ${oldPath} -> ${newPath}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
fileLogger.error(
|
||||
`SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: `Command failed: ${errorData}`,
|
||||
toast: { type: "error", message: `Rename failed: ${errorData}` },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fileLogger.success("Item renamed successfully", {
|
||||
operation: "file_rename_success",
|
||||
sessionId,
|
||||
userId,
|
||||
from: oldPath,
|
||||
to: newPath,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Item renamed successfully",
|
||||
oldPath,
|
||||
newPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `Item renamed: ${oldPath} -> ${newPath}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
fileLogger.error("SSH renameItem stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/moveItem:
|
||||
* put:
|
||||
* summary: Move a file or directory
|
||||
* description: Moves a file or directory on the remote host.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* oldPath:
|
||||
* type: string
|
||||
* newPath:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Item moved successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 403:
|
||||
* description: Permission denied.
|
||||
* 408:
|
||||
* description: Move operation timed out.
|
||||
* 500:
|
||||
* description: Failed to move item.
|
||||
*/
|
||||
app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
|
||||
const { sessionId, oldPath, newPath } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(400).json({ error: "Session ID is required" });
|
||||
}
|
||||
|
||||
if (!sshConn?.isConnected) {
|
||||
return res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
return res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
|
||||
if (!oldPath || !newPath) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Old path and new path are required" });
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
|
||||
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const moveCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
||||
|
||||
const commandTimeout = setTimeout(() => {
|
||||
if (!res.headersSent) {
|
||||
res.status(408).json({
|
||||
error: "Move operation timed out. SSH connection may be unstable.",
|
||||
toast: {
|
||||
type: "error",
|
||||
message:
|
||||
"Move operation timed out. SSH connection may be unstable.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
execChannel(sshConn, moveCommand, (err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH moveItem error:", err);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let outputData = "";
|
||||
let errorData = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
outputData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
errorData += chunk.toString();
|
||||
|
||||
if (chunk.toString().includes("Permission denied")) {
|
||||
fileLogger.error(`Permission denied moving: ${oldPath}`);
|
||||
if (!res.headersSent) {
|
||||
return res.status(403).json({
|
||||
error: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
|
||||
toast: {
|
||||
type: "error",
|
||||
message: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
clearTimeout(commandTimeout);
|
||||
if (outputData.includes("SUCCESS")) {
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Item moved successfully",
|
||||
oldPath,
|
||||
newPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `Item moved: ${oldPath} -> ${newPath}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
fileLogger.error(
|
||||
`SSH moveItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
error: `Command failed: ${errorData}`,
|
||||
toast: { type: "error", message: `Move failed: ${errorData}` },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.json({
|
||||
message: "Item moved successfully",
|
||||
oldPath,
|
||||
newPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `Item moved: ${oldPath} -> ${newPath}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
clearTimeout(commandTimeout);
|
||||
fileLogger.error("SSH moveItem stream error:", streamErr);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { Client as SSHClient } from "ssh2";
|
||||
|
||||
// Serializes SSH channel open requests so only one channel negotiation is
|
||||
// in-flight at a time per session. Once the channel is established the slot
|
||||
// is released immediately so the next open can proceed; the channels
|
||||
// themselves remain open concurrently.
|
||||
export class ChannelOpenSerializer {
|
||||
private tail: Promise<void> = Promise.resolve();
|
||||
|
||||
run<T>(action: () => Promise<T>): Promise<T> {
|
||||
const next = this.tail.then(
|
||||
() => action(),
|
||||
() => action(),
|
||||
);
|
||||
this.tail = next.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SSHSession {
|
||||
client: SSHClient;
|
||||
isConnected: boolean;
|
||||
lastActive: number;
|
||||
timeout?: NodeJS.Timeout;
|
||||
activeOperations: number;
|
||||
sudoPassword?: string;
|
||||
sftp?: import("ssh2").SFTPWrapper;
|
||||
sftpPending?: Promise<import("ssh2").SFTPWrapper>;
|
||||
channelOpener: ChannelOpenSerializer;
|
||||
poolKey?: string;
|
||||
userId?: string;
|
||||
hostId?: number;
|
||||
ip?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
transferDedicated?: boolean;
|
||||
transferId?: string;
|
||||
browseSessionId?: string;
|
||||
}
|
||||
|
||||
export interface PendingTOTPSession {
|
||||
client: SSHClient;
|
||||
finish: (responses: string[]) => void;
|
||||
config: import("ssh2").ConnectConfig;
|
||||
createdAt: number;
|
||||
sessionId: string;
|
||||
hostId?: number;
|
||||
ip?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
userId?: string;
|
||||
prompts?: Array<{ prompt: string; echo: boolean }>;
|
||||
totpPromptIndex?: number;
|
||||
resolvedPassword?: string;
|
||||
totpAttempts: number;
|
||||
isWarpgate?: boolean;
|
||||
}
|
||||
|
||||
export function execWithSudo(
|
||||
session: SSHSession,
|
||||
command: string,
|
||||
sudoPassword: string,
|
||||
): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
return execWithSudoBuffer(session, command, sudoPassword).then((result) => ({
|
||||
stdout: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr,
|
||||
code: result.code,
|
||||
}));
|
||||
}
|
||||
|
||||
export function execWithSudoBuffer(
|
||||
session: SSHSession,
|
||||
command: string,
|
||||
sudoPassword: string,
|
||||
): Promise<{ stdout: Buffer; stderr: string; code: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const escapedPassword = sudoPassword.replace(/'/g, "'\"'\"'");
|
||||
const sudoCommand = `echo '${escapedPassword}' | sudo -S ${command} 2>&1`;
|
||||
|
||||
execChannel(session, sudoCommand, (err, stream) => {
|
||||
if (err) {
|
||||
resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
let stderr = "";
|
||||
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
stdoutChunks.push(chunk);
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code: number) => {
|
||||
let stdout = Buffer.concat(stdoutChunks);
|
||||
const sudoPromptMatch = stdout
|
||||
.toString("utf8", 0, Math.min(stdout.length, 256))
|
||||
.match(/^\[sudo\] password for .+?:\s*/);
|
||||
if (sudoPromptMatch) {
|
||||
stdout = stdout.subarray(Buffer.byteLength(sudoPromptMatch[0]));
|
||||
}
|
||||
resolve({ stdout, stderr, code: code || 0 });
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr: Error) => {
|
||||
resolve({
|
||||
stdout: Buffer.concat(stdoutChunks),
|
||||
stderr: streamErr.message,
|
||||
code: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getSessionSftp(
|
||||
session: SSHSession,
|
||||
): Promise<import("ssh2").SFTPWrapper> {
|
||||
if (session.sftp) {
|
||||
return Promise.resolve(session.sftp);
|
||||
}
|
||||
|
||||
if (session.sftpPending) {
|
||||
return session.sftpPending;
|
||||
}
|
||||
|
||||
const openOnce = (): Promise<import("ssh2").SFTPWrapper> =>
|
||||
session.channelOpener.run(
|
||||
() =>
|
||||
new Promise<import("ssh2").SFTPWrapper>((resolve, reject) => {
|
||||
session.client.sftp((err, sftp) => {
|
||||
if (err) return reject(err);
|
||||
session.sftp = sftp;
|
||||
sftp.on("error", () => {
|
||||
session.sftp = undefined;
|
||||
});
|
||||
sftp.on("close", () => {
|
||||
session.sftp = undefined;
|
||||
});
|
||||
resolve(sftp);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
session.sftpPending = openOnce()
|
||||
.catch((err: Error) => {
|
||||
const isChannelFailure =
|
||||
err.message.toLowerCase().includes("channel open failure") ||
|
||||
err.message.toLowerCase().includes("open failed");
|
||||
if (isChannelFailure) {
|
||||
return new Promise<import("ssh2").SFTPWrapper>((resolve, reject) =>
|
||||
setTimeout(() => openOnce().then(resolve, reject), 500),
|
||||
);
|
||||
}
|
||||
return Promise.reject(err);
|
||||
})
|
||||
.finally(() => {
|
||||
session.sftpPending = undefined;
|
||||
});
|
||||
|
||||
return session.sftpPending;
|
||||
}
|
||||
|
||||
export function execChannel(
|
||||
session: SSHSession,
|
||||
command: string,
|
||||
callback: (
|
||||
err: Error | undefined,
|
||||
stream: import("ssh2").ClientChannel,
|
||||
) => void,
|
||||
): void {
|
||||
session.channelOpener
|
||||
.run(
|
||||
() =>
|
||||
new Promise<import("ssh2").ClientChannel>((resolve, reject) => {
|
||||
session.client.exec(command, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
resolve(stream);
|
||||
});
|
||||
}),
|
||||
)
|
||||
.then(
|
||||
(stream) => callback(undefined, stream),
|
||||
(err: Error) => callback(err, undefined as never),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isExecutableFile,
|
||||
modeToPermissions,
|
||||
formatMtime,
|
||||
getMimeType,
|
||||
detectBinary,
|
||||
} from "./file-manager-utils.js";
|
||||
|
||||
describe("isExecutableFile", () => {
|
||||
it("flags scripts with execute permission", () => {
|
||||
expect(isExecutableFile("-rwxr-xr-x", "deploy.sh")).toBe(true);
|
||||
expect(isExecutableFile("-rwxr-xr-x", "run.py")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags known executable extensions with execute permission", () => {
|
||||
expect(isExecutableFile("-rwxr-xr-x", "tool.bin")).toBe(true);
|
||||
expect(isExecutableFile("-rwxr-xr-x", "app.exe")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags extensionless files with execute permission", () => {
|
||||
expect(isExecutableFile("-rwxr-xr-x", "myprogram")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag files without execute permission", () => {
|
||||
expect(isExecutableFile("-rw-r--r--", "deploy.sh")).toBe(false);
|
||||
expect(isExecutableFile("-rw-r--r--", "myprogram")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag non-script data files even when executable", () => {
|
||||
expect(isExecutableFile("-rwxr-xr-x", "notes.txt")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modeToPermissions", () => {
|
||||
it("renders a regular file with rwxr-xr-x", () => {
|
||||
expect(modeToPermissions(0o100755)).toBe("-rwxr-xr-x");
|
||||
});
|
||||
|
||||
it("renders a directory prefix", () => {
|
||||
expect(modeToPermissions(0o040755)).toBe("drwxr-xr-x");
|
||||
});
|
||||
|
||||
it("renders a symlink prefix", () => {
|
||||
expect(modeToPermissions(0o120777)).toBe("lrwxrwxrwx");
|
||||
});
|
||||
|
||||
it("renders a read-only file", () => {
|
||||
expect(modeToPermissions(0o100444)).toBe("-r--r--r--");
|
||||
});
|
||||
|
||||
it("renders no permissions", () => {
|
||||
expect(modeToPermissions(0o100000)).toBe("----------");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMtime", () => {
|
||||
it("uses HH:MM format for recent timestamps", () => {
|
||||
// Within the last 6 months relative to now.
|
||||
const recent = Math.floor(Date.now() / 1000) - 60 * 60 * 24 * 5;
|
||||
const result = formatMtime(recent);
|
||||
expect(result).toMatch(/^[A-Z][a-z]{2} +\d{1,2} \d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it("uses the year for old timestamps", () => {
|
||||
// ~2 years ago is comfortably outside the 6-month window.
|
||||
const old = Math.floor(Date.now() / 1000) - 60 * 60 * 24 * 365 * 2;
|
||||
const result = formatMtime(old);
|
||||
expect(result).toMatch(/^[A-Z][a-z]{2} +\d{1,2} +\d{4}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMimeType", () => {
|
||||
it("maps known extensions", () => {
|
||||
expect(getMimeType("readme.txt")).toBe("text/plain");
|
||||
expect(getMimeType("data.json")).toBe("application/json");
|
||||
expect(getMimeType("photo.JPEG")).toBe("image/jpeg");
|
||||
expect(getMimeType("archive.zip")).toBe("application/zip");
|
||||
});
|
||||
|
||||
it("falls back to octet-stream for unknown or missing extensions", () => {
|
||||
expect(getMimeType("mystery.xyz")).toBe("application/octet-stream");
|
||||
expect(getMimeType("noextension")).toBe("application/octet-stream");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectBinary", () => {
|
||||
it("returns false for empty buffers", () => {
|
||||
expect(detectBinary(Buffer.from([]))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for plain UTF-8 text", () => {
|
||||
expect(detectBinary(Buffer.from("hello world\nsecond line\t tab"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true when null bytes are present", () => {
|
||||
expect(detectBinary(Buffer.from([0x48, 0x00, 0x49, 0x00]))).toBe(true);
|
||||
});
|
||||
|
||||
it("allows common whitespace control characters", () => {
|
||||
expect(detectBinary(Buffer.from("line1\r\nline2\tend"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
export function isExecutableFile(
|
||||
permissions: string,
|
||||
fileName: string,
|
||||
): boolean {
|
||||
const hasExecutePermission =
|
||||
permissions[3] === "x" || permissions[6] === "x" || permissions[9] === "x";
|
||||
|
||||
const scriptExtensions = [
|
||||
".sh",
|
||||
".py",
|
||||
".pl",
|
||||
".rb",
|
||||
".js",
|
||||
".php",
|
||||
".bash",
|
||||
".zsh",
|
||||
".fish",
|
||||
];
|
||||
const hasScriptExtension = scriptExtensions.some((ext) =>
|
||||
fileName.toLowerCase().endsWith(ext),
|
||||
);
|
||||
|
||||
const executableExtensions = [".bin", ".exe", ".out"];
|
||||
const hasExecutableExtension = executableExtensions.some((ext) =>
|
||||
fileName.toLowerCase().endsWith(ext),
|
||||
);
|
||||
|
||||
const hasNoExtension = !fileName.includes(".") && hasExecutePermission;
|
||||
|
||||
return (
|
||||
hasExecutePermission &&
|
||||
(hasScriptExtension || hasExecutableExtension || hasNoExtension)
|
||||
);
|
||||
}
|
||||
|
||||
export function modeToPermissions(mode: number): string {
|
||||
const S_IFDIR = 0o040000;
|
||||
const S_IFLNK = 0o120000;
|
||||
const S_IFMT = 0o170000;
|
||||
|
||||
const type = mode & S_IFMT;
|
||||
const prefix = type === S_IFDIR ? "d" : type === S_IFLNK ? "l" : "-";
|
||||
|
||||
const perms = [
|
||||
mode & 0o400 ? "r" : "-",
|
||||
mode & 0o200 ? "w" : "-",
|
||||
mode & 0o100 ? "x" : "-",
|
||||
mode & 0o040 ? "r" : "-",
|
||||
mode & 0o020 ? "w" : "-",
|
||||
mode & 0o010 ? "x" : "-",
|
||||
mode & 0o004 ? "r" : "-",
|
||||
mode & 0o002 ? "w" : "-",
|
||||
mode & 0o001 ? "x" : "-",
|
||||
].join("");
|
||||
|
||||
return prefix + perms;
|
||||
}
|
||||
|
||||
export function formatMtime(mtime: number): string {
|
||||
const date = new Date(mtime * 1000);
|
||||
const months = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
const month = months[date.getMonth()];
|
||||
const day = date.getDate().toString().padStart(2, " ");
|
||||
const now = new Date();
|
||||
const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000);
|
||||
|
||||
if (date > sixMonthsAgo) {
|
||||
const hours = date.getHours().toString().padStart(2, "0");
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
return `${month} ${day} ${hours}:${minutes}`;
|
||||
}
|
||||
return `${month} ${day} ${date.getFullYear()}`;
|
||||
}
|
||||
|
||||
export function getMimeType(fileName: string): string {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
txt: "text/plain",
|
||||
json: "application/json",
|
||||
js: "text/javascript",
|
||||
html: "text/html",
|
||||
css: "text/css",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
pdf: "application/pdf",
|
||||
zip: "application/zip",
|
||||
tar: "application/x-tar",
|
||||
gz: "application/gzip",
|
||||
};
|
||||
return mimeTypes[ext || ""] || "application/octet-stream";
|
||||
}
|
||||
|
||||
export function detectBinary(buffer: Buffer): boolean {
|
||||
if (buffer.length === 0) return false;
|
||||
|
||||
const sampleSize = Math.min(buffer.length, 8192);
|
||||
let nullBytes = 0;
|
||||
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const byte = buffer[i];
|
||||
|
||||
if (byte === 0) {
|
||||
nullBytes++;
|
||||
}
|
||||
|
||||
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
|
||||
if (++nullBytes > 1) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return nullBytes / sampleSize > 0.01;
|
||||
}
|
||||
+651
-3872
File diff suppressed because it is too large
Load Diff
@@ -148,12 +148,14 @@ export async function resolveHostById(
|
||||
return host as unknown as SSHHost;
|
||||
}
|
||||
} catch (e) {
|
||||
sshLogger.warn("Failed to get shared credential, falling back", {
|
||||
sshLogger.warn("Failed to get shared credential", {
|
||||
operation: "host_resolver_shared_credential",
|
||||
hostId,
|
||||
error: e instanceof Error ? e.message : "Unknown",
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const credentials = await SimpleDBOps.select(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,325 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { fileLogger } from "../utils/logger.js";
|
||||
import {
|
||||
createSocks5Connection,
|
||||
type SOCKS5Config,
|
||||
} from "../utils/socks5-helper.js";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
||||
|
||||
type JumpHostConfig = {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
credentialId?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
async function resolveJumpHost(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<JumpHostConfig | null> {
|
||||
try {
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hostResults.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = hostResults[0];
|
||||
const ownerId = (host.userId || userId) as string;
|
||||
|
||||
if (host.credentialId) {
|
||||
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) {
|
||||
return {
|
||||
...host,
|
||||
password: sharedCred.password,
|
||||
key: sharedCred.key,
|
||||
keyPassword: sharedCred.keyPassword,
|
||||
keyType: sharedCred.keyType,
|
||||
authType: sharedCred.key
|
||||
? "key"
|
||||
: sharedCred.password
|
||||
? "password"
|
||||
: "none",
|
||||
} as JumpHostConfig;
|
||||
}
|
||||
} catch {
|
||||
// fall through to owner credential lookup
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
ownerId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
return {
|
||||
...host,
|
||||
password: credential.password as string | undefined,
|
||||
key: (credential.key || credential.privateKey) as string | undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
keyType: credential.keyType as string | undefined,
|
||||
authType: credential.authType as string | undefined,
|
||||
} as JumpHostConfig;
|
||||
}
|
||||
}
|
||||
|
||||
return host as JumpHostConfig;
|
||||
} catch (error) {
|
||||
fileLogger.error("Failed to resolve jump host", error, {
|
||||
operation: "resolve_jump_host",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createJumpHostChain(
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
userId: string,
|
||||
socks5Config?: SOCKS5Config | null,
|
||||
): Promise<SSHClient | null> {
|
||||
if (!jumpHosts || jumpHosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentClient: SSHClient | null = null;
|
||||
const clients: SSHClient[] = [];
|
||||
|
||||
try {
|
||||
const jumpHostConfigs: Array<Awaited<ReturnType<typeof resolveJumpHost>>> =
|
||||
[];
|
||||
for (let i = 0; i < jumpHosts.length; i++) {
|
||||
const config = await resolveJumpHost(jumpHosts[i].hostId, userId);
|
||||
jumpHostConfigs.push(config);
|
||||
}
|
||||
|
||||
const totalHops = jumpHostConfigs.length;
|
||||
|
||||
for (let i = 0; i < jumpHostConfigs.length; i++) {
|
||||
if (!jumpHostConfigs[i]) {
|
||||
fileLogger.error(`Jump host ${i + 1} not found`, undefined, {
|
||||
operation: "jump_host_chain",
|
||||
hostId: jumpHosts[i].hostId,
|
||||
hopIndex: i,
|
||||
totalHops,
|
||||
});
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let proxySocket: import("net").Socket | null = null;
|
||||
if (socks5Config?.useSocks5) {
|
||||
const firstHop = jumpHostConfigs[0]!;
|
||||
proxySocket = await createSocks5Connection(
|
||||
firstHop.ip,
|
||||
firstHop.port || 22,
|
||||
socks5Config,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < jumpHostConfigs.length; i++) {
|
||||
const jumpHostConfig = jumpHostConfigs[i]!;
|
||||
|
||||
const jumpClient = new SSHClient();
|
||||
clients.push(jumpClient);
|
||||
|
||||
const jumpHostVerifier = await SSHHostKeyVerifier.createHostVerifier(
|
||||
jumpHostConfig.id,
|
||||
jumpHostConfig.ip,
|
||||
jumpHostConfig.port || 22,
|
||||
null,
|
||||
userId,
|
||||
true,
|
||||
);
|
||||
|
||||
const connected = await new Promise<boolean>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve(false);
|
||||
}, 30000);
|
||||
|
||||
jumpClient.on("ready", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
jumpClient.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
fileLogger.error(
|
||||
`Jump host ${i + 1}/${totalHops} connection failed`,
|
||||
err,
|
||||
{
|
||||
operation: "jump_host_connect",
|
||||
hostId: jumpHostConfig.id,
|
||||
ip: jumpHostConfig.ip,
|
||||
hopIndex: i,
|
||||
totalHops,
|
||||
previousHop:
|
||||
i > 0
|
||||
? jumpHostConfigs[i - 1]?.ip
|
||||
: proxySocket
|
||||
? "proxy"
|
||||
: "direct",
|
||||
usedProxySocket: i === 0 && !!proxySocket,
|
||||
},
|
||||
);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
const connectConfig: Record<string, unknown> = {
|
||||
host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
|
||||
port: jumpHostConfig.port || 22,
|
||||
username: jumpHostConfig.username,
|
||||
tryKeyboard: jumpHostConfig.authType !== "none",
|
||||
readyTimeout: 60000,
|
||||
hostVerifier: jumpHostVerifier,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group18-sha512",
|
||||
"diffie-hellman-group17-sha512",
|
||||
"diffie-hellman-group16-sha512",
|
||||
"diffie-hellman-group15-sha512",
|
||||
"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: SSH_ALGORITHMS.cipher,
|
||||
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"],
|
||||
},
|
||||
};
|
||||
|
||||
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
||||
connectConfig.password = jumpHostConfig.password;
|
||||
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
|
||||
const cleanKey = jumpHostConfig.key
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (jumpHostConfig.keyPassword) {
|
||||
connectConfig.passphrase = jumpHostConfig.keyPassword;
|
||||
}
|
||||
}
|
||||
|
||||
jumpClient.on(
|
||||
"keyboard-interactive",
|
||||
(
|
||||
_name: string,
|
||||
_instructions: string,
|
||||
_lang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && jumpHostConfig.password) {
|
||||
return jumpHostConfig.password as string;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
},
|
||||
);
|
||||
|
||||
if (currentClient) {
|
||||
currentClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
jumpHostConfig.ip,
|
||||
jumpHostConfig.port || 22,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
connectConfig.sock = stream;
|
||||
jumpClient.connect(connectConfig);
|
||||
},
|
||||
);
|
||||
} else if (proxySocket) {
|
||||
connectConfig.sock = proxySocket;
|
||||
jumpClient.connect(connectConfig);
|
||||
} else {
|
||||
jumpClient.connect(connectConfig);
|
||||
}
|
||||
});
|
||||
|
||||
if (!connected) {
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
|
||||
currentClient = jumpClient;
|
||||
}
|
||||
|
||||
return currentClient;
|
||||
} catch (error) {
|
||||
fileLogger.error("Failed to create jump host chain", error, {
|
||||
operation: "jump_host_chain",
|
||||
});
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -968,7 +968,8 @@ export async function getUserIdFromRequest(req: {
|
||||
}
|
||||
|
||||
const decoded = await authManager.verifyJWTToken(token);
|
||||
return decoded?.userId || null;
|
||||
if (!decoded?.userId || decoded.pendingTOTP) return null;
|
||||
return decoded.userId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
supportsMetrics,
|
||||
isTcpPingEnabled,
|
||||
createConnectionLog,
|
||||
} from "./server-stats-helpers.js";
|
||||
|
||||
describe("supportsMetrics", () => {
|
||||
it("supports plain ssh hosts", () => {
|
||||
expect(
|
||||
supportsMetrics({ connectionType: "ssh", authType: "password" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults missing connectionType to ssh", () => {
|
||||
expect(supportsMetrics({ authType: "key" })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-ssh connection types", () => {
|
||||
expect(supportsMetrics({ connectionType: "rdp" })).toBe(false);
|
||||
expect(supportsMetrics({ connectionType: "vnc" })).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects ssh hosts that cannot run shell commands", () => {
|
||||
expect(supportsMetrics({ connectionType: "ssh", authType: "none" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(supportsMetrics({ connectionType: "ssh", authType: "opkssh" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTcpPingEnabled", () => {
|
||||
it("is enabled when status checks are on and tcp ping is not disabled", () => {
|
||||
expect(
|
||||
isTcpPingEnabled({ statusCheckEnabled: true, disableTcpPing: false }),
|
||||
).toBe(true);
|
||||
expect(isTcpPingEnabled({ statusCheckEnabled: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("is disabled when status checks are off", () => {
|
||||
expect(isTcpPingEnabled({ statusCheckEnabled: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("is disabled when tcp ping is explicitly disabled", () => {
|
||||
expect(
|
||||
isTcpPingEnabled({ statusCheckEnabled: true, disableTcpPing: true }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createConnectionLog", () => {
|
||||
it("builds a log entry without id/timestamp", () => {
|
||||
const entry = createConnectionLog("info", "connection", "Connecting", {
|
||||
hostId: 1,
|
||||
});
|
||||
expect(entry).toEqual({
|
||||
type: "info",
|
||||
stage: "connection",
|
||||
message: "Connecting",
|
||||
details: { hostId: 1 },
|
||||
});
|
||||
expect("id" in entry).toBe(false);
|
||||
expect("timestamp" in entry).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { LogEntry, ConnectionStage } from "../../types/connection-log.js";
|
||||
|
||||
export type StatsCapableHost = {
|
||||
connectionType?: string;
|
||||
authType?: string;
|
||||
};
|
||||
|
||||
export type TcpPingStatsConfig = {
|
||||
statusCheckEnabled: boolean;
|
||||
disableTcpPing?: boolean;
|
||||
};
|
||||
|
||||
export function supportsMetrics(host: StatsCapableHost): boolean {
|
||||
const connectionType = host.connectionType || "ssh";
|
||||
if (connectionType !== "ssh") return false;
|
||||
if (host.authType === "none" || host.authType === "opkssh") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isTcpPingEnabled(statsConfig: TcpPingStatsConfig): boolean {
|
||||
return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing;
|
||||
}
|
||||
|
||||
export function createConnectionLog(
|
||||
type: "info" | "success" | "warning" | "error",
|
||||
stage: ConnectionStage,
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
): Omit<LogEntry, "id" | "timestamp"> {
|
||||
return {
|
||||
type,
|
||||
stage,
|
||||
message,
|
||||
details,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { Client } from "ssh2";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import {
|
||||
createSocks5Connection,
|
||||
type SOCKS5Config,
|
||||
} from "../utils/socks5-helper.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
||||
|
||||
interface JumpHostConfig {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
credentialId?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
async function resolveJumpHost(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<JumpHostConfig | null> {
|
||||
try {
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hostResults.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = hostResults[0];
|
||||
const ownerId = (host.userId || userId) as string;
|
||||
|
||||
if (host.credentialId) {
|
||||
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) {
|
||||
return {
|
||||
...host,
|
||||
password: sharedCred.password,
|
||||
key: sharedCred.key,
|
||||
keyPassword: sharedCred.keyPassword,
|
||||
keyType: sharedCred.keyType,
|
||||
authType: sharedCred.key
|
||||
? "key"
|
||||
: sharedCred.password
|
||||
? "password"
|
||||
: "none",
|
||||
} as JumpHostConfig;
|
||||
}
|
||||
} catch {
|
||||
// fall through to owner credential lookup
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
ownerId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
return {
|
||||
...host,
|
||||
password: credential.password as string | undefined,
|
||||
key: (credential.key || credential.privateKey) as string | undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
keyType: credential.keyType as string | undefined,
|
||||
authType: credential.authType as string | undefined,
|
||||
} as JumpHostConfig;
|
||||
}
|
||||
}
|
||||
|
||||
return host as JumpHostConfig;
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to resolve jump host", error, {
|
||||
operation: "resolve_jump_host",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createJumpHostChain(
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
userId: string,
|
||||
socks5Config?: SOCKS5Config | null,
|
||||
): Promise<Client | null> {
|
||||
if (!jumpHosts || jumpHosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentClient: Client | null = null;
|
||||
const clients: Client[] = [];
|
||||
|
||||
try {
|
||||
const jumpHostConfigs: Array<Awaited<ReturnType<typeof resolveJumpHost>>> =
|
||||
[];
|
||||
for (let i = 0; i < jumpHosts.length; i++) {
|
||||
const config = await resolveJumpHost(jumpHosts[i].hostId, userId);
|
||||
jumpHostConfigs.push(config);
|
||||
}
|
||||
|
||||
const totalHops = jumpHostConfigs.length;
|
||||
|
||||
for (let i = 0; i < jumpHostConfigs.length; i++) {
|
||||
if (!jumpHostConfigs[i]) {
|
||||
statsLogger.error(`Jump host ${i + 1} not found`, undefined, {
|
||||
operation: "jump_host_chain",
|
||||
hostId: jumpHosts[i].hostId,
|
||||
hopIndex: i,
|
||||
totalHops,
|
||||
});
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let proxySocket: import("net").Socket | null = null;
|
||||
if (socks5Config?.useSocks5) {
|
||||
const firstHop = jumpHostConfigs[0]!;
|
||||
proxySocket = await createSocks5Connection(
|
||||
firstHop.ip,
|
||||
firstHop.port || 22,
|
||||
socks5Config,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < jumpHostConfigs.length; i++) {
|
||||
const jumpHostConfig = jumpHostConfigs[i]!;
|
||||
|
||||
const jumpClient = new Client();
|
||||
clients.push(jumpClient);
|
||||
|
||||
const jumpHostVerifier = await SSHHostKeyVerifier.createHostVerifier(
|
||||
jumpHostConfig.id,
|
||||
jumpHostConfig.ip,
|
||||
jumpHostConfig.port || 22,
|
||||
null,
|
||||
userId,
|
||||
true,
|
||||
);
|
||||
|
||||
const connected = await new Promise<boolean>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve(false);
|
||||
}, 30000);
|
||||
|
||||
jumpClient.on("ready", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
jumpClient.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
statsLogger.error(
|
||||
`Jump host ${i + 1}/${totalHops} connection failed`,
|
||||
err,
|
||||
{
|
||||
operation: "jump_host_connect",
|
||||
hostId: jumpHostConfig.id,
|
||||
ip: jumpHostConfig.ip,
|
||||
hopIndex: i,
|
||||
totalHops,
|
||||
previousHop:
|
||||
i > 0
|
||||
? jumpHostConfigs[i - 1]?.ip
|
||||
: proxySocket
|
||||
? "proxy"
|
||||
: "direct",
|
||||
usedProxySocket: i === 0 && !!proxySocket,
|
||||
},
|
||||
);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
const connectConfig: Record<string, unknown> = {
|
||||
host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
|
||||
port: jumpHostConfig.port || 22,
|
||||
username: jumpHostConfig.username,
|
||||
tryKeyboard: jumpHostConfig.authType !== "none",
|
||||
readyTimeout: 60000,
|
||||
hostVerifier: jumpHostVerifier,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group18-sha512",
|
||||
"diffie-hellman-group17-sha512",
|
||||
"diffie-hellman-group16-sha512",
|
||||
"diffie-hellman-group15-sha512",
|
||||
"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: SSH_ALGORITHMS.cipher,
|
||||
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"],
|
||||
},
|
||||
};
|
||||
|
||||
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
||||
connectConfig.password = jumpHostConfig.password;
|
||||
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
|
||||
const cleanKey = jumpHostConfig.key
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (jumpHostConfig.keyPassword) {
|
||||
connectConfig.passphrase = jumpHostConfig.keyPassword;
|
||||
}
|
||||
}
|
||||
|
||||
jumpClient.on(
|
||||
"keyboard-interactive",
|
||||
(
|
||||
_name: string,
|
||||
_instructions: string,
|
||||
_lang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && jumpHostConfig.password) {
|
||||
return jumpHostConfig.password as string;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
},
|
||||
);
|
||||
|
||||
if (currentClient) {
|
||||
currentClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
jumpHostConfig.ip,
|
||||
jumpHostConfig.port || 22,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
connectConfig.sock = stream;
|
||||
jumpClient.connect(connectConfig);
|
||||
},
|
||||
);
|
||||
} else if (proxySocket) {
|
||||
connectConfig.sock = proxySocket;
|
||||
jumpClient.connect(connectConfig);
|
||||
} else {
|
||||
jumpClient.connect(connectConfig);
|
||||
}
|
||||
});
|
||||
|
||||
if (!connected) {
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
|
||||
currentClient = jumpClient;
|
||||
}
|
||||
|
||||
return currentClient;
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to create jump host chain", error, {
|
||||
operation: "jump_host_chain",
|
||||
});
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Client, ConnectConfig } from "ssh2";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
|
||||
export interface MetricsSession {
|
||||
client: Client;
|
||||
isConnected: boolean;
|
||||
lastActive: number;
|
||||
timeout?: NodeJS.Timeout;
|
||||
activeOperations: number;
|
||||
hostId: number;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface PendingTOTPSession {
|
||||
client: Client;
|
||||
finish: (responses: string[]) => void;
|
||||
config: ConnectConfig;
|
||||
createdAt: number;
|
||||
sessionId: string;
|
||||
hostId: number;
|
||||
userId: string;
|
||||
prompts?: Array<{ prompt: string; echo: boolean }>;
|
||||
totpPromptIndex?: number;
|
||||
resolvedPassword?: string;
|
||||
totpAttempts: number;
|
||||
}
|
||||
|
||||
export interface MetricsViewer {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
lastHeartbeat: number;
|
||||
}
|
||||
|
||||
export const metricsSessions: Record<string, MetricsSession> = {};
|
||||
export const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
|
||||
|
||||
export function cleanupMetricsSession(sessionId: string): void {
|
||||
const session = metricsSessions[sessionId];
|
||||
if (session) {
|
||||
if (session.activeOperations > 0) {
|
||||
statsLogger.warn(
|
||||
`Deferring metrics session cleanup - ${session.activeOperations} active operations`,
|
||||
{
|
||||
operation: "cleanup_deferred",
|
||||
sessionId,
|
||||
activeOperations: session.activeOperations,
|
||||
},
|
||||
);
|
||||
scheduleMetricsSessionCleanup(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
session.client.end();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
clearTimeout(session.timeout);
|
||||
delete metricsSessions[sessionId];
|
||||
}
|
||||
}
|
||||
|
||||
export function scheduleMetricsSessionCleanup(sessionId: string): void {
|
||||
const session = metricsSessions[sessionId];
|
||||
if (session) {
|
||||
if (session.timeout) clearTimeout(session.timeout);
|
||||
|
||||
session.timeout = setTimeout(
|
||||
() => {
|
||||
cleanupMetricsSession(sessionId);
|
||||
},
|
||||
30 * 60 * 1000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionKey(hostId: number, userId: string): string {
|
||||
return `${userId}:${hostId}`;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { Express, RequestHandler } from "express";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
|
||||
type ServerStatsSettingsConfig = {
|
||||
statusCheckInterval: number;
|
||||
metricsInterval: number;
|
||||
};
|
||||
|
||||
type ServerStatsSettingsRoutesDeps = {
|
||||
requireAdmin: RequestHandler;
|
||||
defaultStatsConfig: ServerStatsSettingsConfig;
|
||||
refreshAllPolling: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function registerServerStatsSettingsRoutes(
|
||||
app: Express,
|
||||
{
|
||||
requireAdmin,
|
||||
defaultStatsConfig: DEFAULT_STATS_CONFIG,
|
||||
refreshAllPolling,
|
||||
}: ServerStatsSettingsRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /global-settings:
|
||||
* get:
|
||||
* summary: Get global monitoring defaults
|
||||
* tags:
|
||||
* - Stats
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Global monitoring settings.
|
||||
* 403:
|
||||
* description: Requires admin privileges.
|
||||
*/
|
||||
app.get("/global-settings", requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
|
||||
try {
|
||||
db.$client.prepare("SELECT 1 FROM settings LIMIT 1").get();
|
||||
} catch (tableError) {
|
||||
statsLogger.warn("Settings table does not exist, using defaults", {
|
||||
operation: "global_settings_table_check",
|
||||
error:
|
||||
tableError instanceof Error
|
||||
? tableError.message
|
||||
: String(tableError),
|
||||
});
|
||||
return res.json({
|
||||
statusCheckInterval: DEFAULT_STATS_CONFIG.statusCheckInterval,
|
||||
metricsInterval: DEFAULT_STATS_CONFIG.metricsInterval,
|
||||
});
|
||||
}
|
||||
|
||||
const statusRow = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'global_status_check_interval'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
const metricsRow = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'global_metrics_interval'",
|
||||
)
|
||||
.get() as { value: string } | undefined;
|
||||
|
||||
res.json({
|
||||
statusCheckInterval: statusRow
|
||||
? parseInt(statusRow.value, 10)
|
||||
: DEFAULT_STATS_CONFIG.statusCheckInterval,
|
||||
metricsInterval: metricsRow
|
||||
? parseInt(metricsRow.value, 10)
|
||||
: DEFAULT_STATS_CONFIG.metricsInterval,
|
||||
});
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to fetch global settings", {
|
||||
operation: "global_settings_fetch_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch global settings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /global-settings:
|
||||
* post:
|
||||
* summary: Update global monitoring defaults
|
||||
* tags:
|
||||
* - Stats
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* statusCheckInterval:
|
||||
* type: integer
|
||||
* metricsInterval:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Settings saved.
|
||||
* 400:
|
||||
* description: Invalid parameters.
|
||||
* 403:
|
||||
* description: Requires admin privileges.
|
||||
*/
|
||||
app.post("/global-settings", requireAdmin, async (req, res) => {
|
||||
const { statusCheckInterval, metricsInterval } = req.body;
|
||||
|
||||
if (
|
||||
statusCheckInterval !== undefined &&
|
||||
(typeof statusCheckInterval !== "number" ||
|
||||
statusCheckInterval < 5 ||
|
||||
statusCheckInterval > 3600)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "statusCheckInterval must be between 5 and 3600 seconds",
|
||||
});
|
||||
}
|
||||
if (
|
||||
metricsInterval !== undefined &&
|
||||
(typeof metricsInterval !== "number" ||
|
||||
metricsInterval < 5 ||
|
||||
metricsInterval > 3600)
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "metricsInterval must be between 5 and 3600 seconds" });
|
||||
}
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
|
||||
try {
|
||||
db.$client.prepare("SELECT 1 FROM settings LIMIT 1").get();
|
||||
} catch (tableError) {
|
||||
statsLogger.error(
|
||||
"Settings table does not exist, cannot save settings",
|
||||
{
|
||||
operation: "global_settings_table_check",
|
||||
error:
|
||||
tableError instanceof Error
|
||||
? tableError.message
|
||||
: String(tableError),
|
||||
},
|
||||
);
|
||||
return res.status(500).json({
|
||||
error:
|
||||
"Database settings table is missing. Please check database initialization.",
|
||||
});
|
||||
}
|
||||
|
||||
if (statusCheckInterval !== undefined) {
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('global_status_check_interval', ?)",
|
||||
)
|
||||
.run(String(statusCheckInterval));
|
||||
}
|
||||
if (metricsInterval !== undefined) {
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('global_metrics_interval', ?)",
|
||||
)
|
||||
.run(String(metricsInterval));
|
||||
}
|
||||
|
||||
await refreshAllPolling();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Settings updated and polling refreshed",
|
||||
});
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to save global settings", {
|
||||
operation: "global_settings_save_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to save global settings",
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
class RequestQueue {
|
||||
private queues = new Map<number, Array<() => Promise<unknown>>>();
|
||||
private processing = new Set<number>();
|
||||
private requestTimeout = 60000;
|
||||
|
||||
async queueRequest<T>(hostId: number, request: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const wrappedRequest = async () => {
|
||||
try {
|
||||
const result = await Promise.race<T>([
|
||||
request(),
|
||||
new Promise<never>((_, rej) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
rej(
|
||||
new Error(
|
||||
`Request timeout after ${this.requestTimeout}ms for host ${hostId}`,
|
||||
),
|
||||
),
|
||||
this.requestTimeout,
|
||||
),
|
||||
),
|
||||
]);
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const queue = this.queues.get(hostId) || [];
|
||||
queue.push(wrappedRequest);
|
||||
this.queues.set(hostId, queue);
|
||||
this.processQueue(hostId);
|
||||
});
|
||||
}
|
||||
|
||||
private async processQueue(hostId: number): Promise<void> {
|
||||
if (this.processing.has(hostId)) return;
|
||||
|
||||
this.processing.add(hostId);
|
||||
const queue = this.queues.get(hostId) || [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const request = queue.shift();
|
||||
if (request) {
|
||||
try {
|
||||
await request();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.processing.delete(hostId);
|
||||
const currentQueue = this.queues.get(hostId);
|
||||
if (currentQueue && currentQueue.length > 0) {
|
||||
this.processQueue(hostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CachedMetrics {
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
class MetricsCache {
|
||||
private cache = new Map<number, CachedMetrics>();
|
||||
private ttl = 30000;
|
||||
|
||||
get(hostId: number): unknown | null {
|
||||
const cached = this.cache.get(hostId);
|
||||
if (cached && Date.now() - cached.timestamp < this.ttl) {
|
||||
return cached.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
set(hostId: number, data: unknown): void {
|
||||
this.cache.set(hostId, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
hostId,
|
||||
});
|
||||
}
|
||||
|
||||
clear(hostId?: number): void {
|
||||
if (hostId) {
|
||||
this.cache.delete(hostId);
|
||||
} else {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthFailureRecord {
|
||||
count: number;
|
||||
lastFailure: number;
|
||||
reason: "TOTP" | "AUTH" | "TIMEOUT";
|
||||
permanent: boolean;
|
||||
}
|
||||
|
||||
class AuthFailureTracker {
|
||||
private failures = new Map<number, AuthFailureRecord>();
|
||||
private maxRetries = 3;
|
||||
private backoffBase = 5000;
|
||||
|
||||
recordFailure(
|
||||
hostId: number,
|
||||
reason: "TOTP" | "AUTH" | "TIMEOUT",
|
||||
permanent = false,
|
||||
): void {
|
||||
const existing = this.failures.get(hostId);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
existing.lastFailure = Date.now();
|
||||
existing.reason = reason;
|
||||
if (permanent) existing.permanent = true;
|
||||
} else {
|
||||
this.failures.set(hostId, {
|
||||
count: 1,
|
||||
lastFailure: Date.now(),
|
||||
reason,
|
||||
permanent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
shouldSkip(hostId: number): boolean {
|
||||
const record = this.failures.get(hostId);
|
||||
if (!record) return false;
|
||||
|
||||
if (record.reason === "TOTP" || record.permanent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (record.count >= this.maxRetries) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
|
||||
const timeSinceFailure = Date.now() - record.lastFailure;
|
||||
|
||||
return timeSinceFailure < backoffTime;
|
||||
}
|
||||
|
||||
getSkipReason(hostId: number): string | null {
|
||||
const record = this.failures.get(hostId);
|
||||
if (!record) return null;
|
||||
|
||||
if (record.reason === "TOTP") {
|
||||
return "TOTP authentication required (metrics unavailable)";
|
||||
}
|
||||
|
||||
if (record.permanent) {
|
||||
return "Authentication permanently failed";
|
||||
}
|
||||
|
||||
if (record.count >= this.maxRetries) {
|
||||
return `Too many authentication failures (${record.count} attempts)`;
|
||||
}
|
||||
|
||||
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
|
||||
const timeSinceFailure = Date.now() - record.lastFailure;
|
||||
const remainingTime = Math.ceil((backoffTime - timeSinceFailure) / 1000);
|
||||
|
||||
if (timeSinceFailure < backoffTime) {
|
||||
return `Retry in ${remainingTime}s (attempt ${record.count}/${this.maxRetries})`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
reset(hostId: number): void {
|
||||
this.failures.delete(hostId);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
const maxAge = 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
for (const [hostId, record] of this.failures.entries()) {
|
||||
if (!record.permanent && now - record.lastFailure > maxAge) {
|
||||
this.failures.delete(hostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PollingBackoff {
|
||||
private failures = new Map<number, { count: number; nextRetry: number }>();
|
||||
private baseDelay = 30000;
|
||||
private maxDelay = 600000;
|
||||
private maxRetries = 5;
|
||||
|
||||
recordFailure(hostId: number): void {
|
||||
const existing = this.failures.get(hostId) || { count: 0, nextRetry: 0 };
|
||||
const delay = Math.min(
|
||||
this.baseDelay * Math.pow(2, existing.count),
|
||||
this.maxDelay,
|
||||
);
|
||||
this.failures.set(hostId, {
|
||||
count: existing.count + 1,
|
||||
nextRetry: Date.now() + delay,
|
||||
});
|
||||
}
|
||||
|
||||
shouldSkip(hostId: number): boolean {
|
||||
const backoff = this.failures.get(hostId);
|
||||
if (!backoff) return false;
|
||||
|
||||
if (backoff.count >= this.maxRetries) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Date.now() < backoff.nextRetry;
|
||||
}
|
||||
|
||||
getBackoffInfo(hostId: number): string | null {
|
||||
const backoff = this.failures.get(hostId);
|
||||
if (!backoff) return null;
|
||||
|
||||
if (backoff.count >= this.maxRetries) {
|
||||
return `Max retries exceeded (${backoff.count} failures) - polling suspended`;
|
||||
}
|
||||
|
||||
const remainingMs = backoff.nextRetry - Date.now();
|
||||
if (remainingMs > 0) {
|
||||
const remainingSec = Math.ceil(remainingMs / 1000);
|
||||
return `Retry in ${remainingSec}s (attempt ${backoff.count}/${this.maxRetries})`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
reset(hostId: number): void {
|
||||
this.failures.delete(hostId);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
const maxAge = 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
for (const [hostId, backoff] of this.failures.entries()) {
|
||||
if (backoff.count < this.maxRetries && now - backoff.nextRetry > maxAge) {
|
||||
this.failures.delete(hostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const requestQueue = new RequestQueue();
|
||||
export const metricsCache = new MetricsCache();
|
||||
export const authFailureTracker = new AuthFailureTracker();
|
||||
export const pollingBackoff = new PollingBackoff();
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { Express } from "express";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { statsLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
|
||||
type ViewerStatsConfig = {
|
||||
metricsEnabled: boolean;
|
||||
};
|
||||
|
||||
type ServerStatsViewerRoutesDeps<
|
||||
THost extends { statsConfig?: string | TStatsConfig },
|
||||
TStatsConfig extends ViewerStatsConfig,
|
||||
> = {
|
||||
fetchHostById: (hostId: number, userId: string) => Promise<THost>;
|
||||
supportsMetrics: (host: THost) => boolean;
|
||||
parseStatsConfig: (statsConfig: THost["statsConfig"]) => TStatsConfig;
|
||||
updateHeartbeat: (viewerSessionId: string) => boolean;
|
||||
registerViewer: (
|
||||
hostId: number,
|
||||
viewerSessionId: string,
|
||||
userId: string,
|
||||
) => void;
|
||||
unregisterViewer: (hostId: number, viewerSessionId: string) => void;
|
||||
};
|
||||
|
||||
export function registerServerStatsViewerRoutes<
|
||||
THost extends { statsConfig?: string | TStatsConfig },
|
||||
TStatsConfig extends ViewerStatsConfig,
|
||||
>(
|
||||
app: Express,
|
||||
{
|
||||
fetchHostById,
|
||||
supportsMetrics,
|
||||
parseStatsConfig,
|
||||
updateHeartbeat,
|
||||
registerViewer,
|
||||
unregisterViewer,
|
||||
}: ServerStatsViewerRoutesDeps<THost, TStatsConfig>,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /metrics/heartbeat:
|
||||
* post:
|
||||
* summary: Update viewer heartbeat
|
||||
* description: Updates the heartbeat timestamp for a metrics viewer session to keep it alive.
|
||||
* tags:
|
||||
* - Server Stats
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* viewerSessionId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Heartbeat updated successfully.
|
||||
* 400:
|
||||
* description: Invalid viewerSessionId.
|
||||
* 401:
|
||||
* description: Session expired - please log in again.
|
||||
* 404:
|
||||
* description: Viewer session not found.
|
||||
* 500:
|
||||
* description: Failed to update heartbeat.
|
||||
*/
|
||||
app.post("/metrics/heartbeat", async (req, res) => {
|
||||
const { viewerSessionId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
if (!viewerSessionId || typeof viewerSessionId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid viewerSessionId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const success = updateHeartbeat(viewerSessionId);
|
||||
if (success) {
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(404).json({ error: "Viewer session not found" });
|
||||
}
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to update heartbeat", {
|
||||
operation: "heartbeat_error",
|
||||
viewerSessionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update heartbeat" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /metrics/register-viewer:
|
||||
* post:
|
||||
* summary: Register metrics viewer
|
||||
* description: Registers a new viewer session for a host to track who is viewing metrics.
|
||||
* tags:
|
||||
* - Server Stats
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Viewer registered successfully.
|
||||
* 400:
|
||||
* description: Invalid hostId.
|
||||
* 401:
|
||||
* description: Session expired - please log in again.
|
||||
* 500:
|
||||
* description: Failed to register viewer.
|
||||
*/
|
||||
app.post("/metrics/register-viewer", async (req, res) => {
|
||||
const { hostId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
if (!hostId || typeof hostId !== "number") {
|
||||
return res.status(400).json({ error: "Invalid hostId" });
|
||||
}
|
||||
|
||||
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: THost | 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 = 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)}`;
|
||||
try {
|
||||
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", {
|
||||
operation: "register_viewer_error",
|
||||
hostId,
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
// 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /metrics/unregister-viewer:
|
||||
* post:
|
||||
* summary: Unregister metrics viewer
|
||||
* description: Unregisters a viewer session when they stop viewing metrics for a host.
|
||||
* tags:
|
||||
* - Server Stats
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* viewerSessionId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Viewer unregistered successfully.
|
||||
* 400:
|
||||
* description: Invalid hostId or viewerSessionId.
|
||||
* 401:
|
||||
* description: Session expired - please log in again.
|
||||
* 500:
|
||||
* description: Failed to unregister viewer.
|
||||
*/
|
||||
app.post("/metrics/unregister-viewer", async (req, res) => {
|
||||
const { hostId, viewerSessionId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
if (!hostId || typeof hostId !== "number") {
|
||||
return res.status(400).json({ error: "Invalid hostId" });
|
||||
}
|
||||
|
||||
if (!viewerSessionId || typeof viewerSessionId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid viewerSessionId" });
|
||||
}
|
||||
|
||||
try {
|
||||
unregisterViewer(hostId, viewerSessionId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to unregister viewer", {
|
||||
operation: "unregister_viewer_error",
|
||||
hostId,
|
||||
viewerSessionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to unregister viewer" });
|
||||
}
|
||||
});
|
||||
}
|
||||
+101
-1084
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
import dgram from "dgram";
|
||||
import net from "net";
|
||||
import ssh2Pkg, {
|
||||
type IdentityCallback,
|
||||
type ParsedKey,
|
||||
type SignCallback,
|
||||
type SigningRequestOptions,
|
||||
} from "ssh2";
|
||||
|
||||
const { BaseAgent } = ssh2Pkg;
|
||||
|
||||
export class MemoryAgent extends BaseAgent {
|
||||
private key: ParsedKey;
|
||||
|
||||
constructor(key: ParsedKey) {
|
||||
super();
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
getIdentities(cb: IdentityCallback<ParsedKey>): void {
|
||||
cb(null, [this.key]);
|
||||
}
|
||||
|
||||
sign(
|
||||
_pubKey: ParsedKey | Buffer | string,
|
||||
data: Buffer,
|
||||
optionsOrCb: SigningRequestOptions | SignCallback,
|
||||
cb?: SignCallback,
|
||||
): void {
|
||||
const callback = typeof optionsOrCb === "function" ? optionsOrCb : cb!;
|
||||
const options = typeof optionsOrCb === "function" ? {} : optionsOrCb;
|
||||
try {
|
||||
const algo =
|
||||
options.hash === "sha256"
|
||||
? "rsa-sha2-256"
|
||||
: options.hash === "sha512"
|
||||
? "rsa-sha2-512"
|
||||
: undefined;
|
||||
const signature = this.key.sign(data, algo);
|
||||
callback(null, signature);
|
||||
} catch (err) {
|
||||
callback(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import ssh2Pkg, { type Client as SSHClientType } from "ssh2";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import {
|
||||
createSocks5Connection,
|
||||
type SOCKS5Config,
|
||||
} from "../utils/socks5-helper.js";
|
||||
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
||||
|
||||
const { Client } = ssh2Pkg;
|
||||
|
||||
interface JumpHostConfig {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
credentialId?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
async function resolveJumpHost(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<JumpHostConfig | null> {
|
||||
sshLogger.info("Resolving jump host", {
|
||||
operation: "terminal_jumphost_resolve",
|
||||
userId,
|
||||
hostId,
|
||||
});
|
||||
try {
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hostResults.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = hostResults[0];
|
||||
const ownerId = (host.userId || userId) as string;
|
||||
|
||||
if (host.credentialId) {
|
||||
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) {
|
||||
return {
|
||||
...host,
|
||||
password: sharedCred.password,
|
||||
key: sharedCred.key,
|
||||
keyPassword: sharedCred.keyPassword,
|
||||
keyType: sharedCred.keyType,
|
||||
authType: sharedCred.key
|
||||
? "key"
|
||||
: sharedCred.password
|
||||
? "password"
|
||||
: "none",
|
||||
} as JumpHostConfig;
|
||||
}
|
||||
} catch {
|
||||
// fall through to owner credential lookup
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
ownerId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
return {
|
||||
...host,
|
||||
password: credential.password as string | undefined,
|
||||
key: (credential.key || credential.privateKey) as string | undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
keyType: credential.keyType as string | undefined,
|
||||
authType: credential.authType as string | undefined,
|
||||
} as JumpHostConfig;
|
||||
}
|
||||
}
|
||||
|
||||
return host as JumpHostConfig;
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to resolve jump host", error, {
|
||||
operation: "resolve_jump_host",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createJumpHostChain(
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
userId: string,
|
||||
socks5Config?: SOCKS5Config | null,
|
||||
): Promise<SSHClientType | null> {
|
||||
if (!jumpHosts || jumpHosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentClient: SSHClientType | null = null;
|
||||
const clients: SSHClientType[] = [];
|
||||
|
||||
try {
|
||||
const jumpHostConfigs = await Promise.all(
|
||||
jumpHosts.map((jh) => resolveJumpHost(jh.hostId, userId)),
|
||||
);
|
||||
|
||||
const totalHops = jumpHostConfigs.length;
|
||||
|
||||
for (let i = 0; i < jumpHostConfigs.length; i++) {
|
||||
if (!jumpHostConfigs[i]) {
|
||||
sshLogger.error(`Jump host ${i + 1} not found`, undefined, {
|
||||
operation: "jump_host_chain",
|
||||
hostId: jumpHosts[i].hostId,
|
||||
hopIndex: i,
|
||||
totalHops,
|
||||
});
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let proxySocket: import("net").Socket | null = null;
|
||||
if (socks5Config?.useSocks5) {
|
||||
const firstHop = jumpHostConfigs[0];
|
||||
proxySocket = await createSocks5Connection(
|
||||
firstHop.ip,
|
||||
firstHop.port || 22,
|
||||
socks5Config,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < jumpHostConfigs.length; i++) {
|
||||
const jumpHostConfig = jumpHostConfigs[i];
|
||||
|
||||
const jumpClient = new Client();
|
||||
clients.push(jumpClient);
|
||||
|
||||
const jumpHostVerifier = await SSHHostKeyVerifier.createHostVerifier(
|
||||
jumpHostConfig.id,
|
||||
jumpHostConfig.ip,
|
||||
jumpHostConfig.port || 22,
|
||||
null,
|
||||
userId,
|
||||
true,
|
||||
);
|
||||
|
||||
const connected = await new Promise<boolean>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve(false);
|
||||
}, 30000);
|
||||
|
||||
jumpClient.on("ready", () => {
|
||||
clearTimeout(timeout);
|
||||
sshLogger.success("Jump host connection established", {
|
||||
operation: "terminal_jumphost_connected",
|
||||
userId,
|
||||
hostId: jumpHostConfig.id,
|
||||
ip: jumpHostConfig.ip,
|
||||
depth: i,
|
||||
hopIndex: i,
|
||||
totalHops,
|
||||
usedProxySocket: i === 0 && !!proxySocket,
|
||||
});
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
jumpClient.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
sshLogger.error(
|
||||
`Jump host ${i + 1}/${totalHops} connection failed`,
|
||||
err,
|
||||
{
|
||||
operation: "jump_host_connect",
|
||||
hostId: jumpHostConfig.id,
|
||||
ip: jumpHostConfig.ip,
|
||||
hopIndex: i,
|
||||
totalHops,
|
||||
previousHop:
|
||||
i > 0
|
||||
? jumpHostConfigs[i - 1]?.ip
|
||||
: proxySocket
|
||||
? "proxy"
|
||||
: "direct",
|
||||
usedProxySocket: i === 0 && !!proxySocket,
|
||||
},
|
||||
);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
const connectConfig: Record<string, unknown> = {
|
||||
host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
|
||||
port: jumpHostConfig.port || 22,
|
||||
username: jumpHostConfig.username,
|
||||
tryKeyboard: jumpHostConfig.authType !== "none",
|
||||
readyTimeout: 60000,
|
||||
hostVerifier: jumpHostVerifier,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group18-sha512",
|
||||
"diffie-hellman-group17-sha512",
|
||||
"diffie-hellman-group16-sha512",
|
||||
"diffie-hellman-group15-sha512",
|
||||
"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: SSH_ALGORITHMS.cipher,
|
||||
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"],
|
||||
},
|
||||
};
|
||||
|
||||
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
||||
connectConfig.password = jumpHostConfig.password;
|
||||
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
|
||||
const cleanKey = jumpHostConfig.key
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (jumpHostConfig.keyPassword) {
|
||||
connectConfig.passphrase = jumpHostConfig.keyPassword;
|
||||
}
|
||||
}
|
||||
|
||||
jumpClient.on(
|
||||
"keyboard-interactive",
|
||||
(
|
||||
_name: string,
|
||||
_instructions: string,
|
||||
_lang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && jumpHostConfig.password) {
|
||||
return jumpHostConfig.password as string;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
},
|
||||
);
|
||||
|
||||
if (currentClient) {
|
||||
currentClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
jumpHostConfig.ip,
|
||||
jumpHostConfig.port || 22,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
connectConfig.sock = stream;
|
||||
jumpClient.connect(connectConfig);
|
||||
},
|
||||
);
|
||||
} else if (proxySocket) {
|
||||
connectConfig.sock = proxySocket;
|
||||
jumpClient.connect(connectConfig);
|
||||
} else {
|
||||
jumpClient.connect(connectConfig);
|
||||
}
|
||||
});
|
||||
|
||||
if (!connected) {
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
|
||||
currentClient = jumpClient;
|
||||
}
|
||||
|
||||
return currentClient;
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to create jump host chain", error, {
|
||||
operation: "jump_host_chain",
|
||||
});
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -86,12 +86,15 @@ class TerminalSessionManager {
|
||||
!existing.sshStream.destroyed;
|
||||
if (isLiveSession) {
|
||||
// Don't destroy a live session (even if detached) — the caller should attach instead
|
||||
sshLogger.warn("Tab instance has live session, skipping duplicate create", {
|
||||
sshLogger.warn(
|
||||
"Tab instance has live session, skipping duplicate create",
|
||||
{
|
||||
operation: "session_tab_duplicate_skip",
|
||||
existingSessionId: existing.id,
|
||||
tabInstanceId,
|
||||
hasAttachedWs: existing.attachedWs !== null,
|
||||
});
|
||||
},
|
||||
);
|
||||
return existing.id;
|
||||
}
|
||||
sshLogger.warn("Tab instance already has session, destroying old", {
|
||||
@@ -193,7 +196,9 @@ class TerminalSessionManager {
|
||||
|
||||
const isDetached =
|
||||
!session.attachedWs || session.attachedWs.readyState !== WebSocket.OPEN;
|
||||
const isOriginalTab = (session.attachedTabInstanceId ?? session.tabInstanceId) === tabInstanceId;
|
||||
const isOriginalTab =
|
||||
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
|
||||
tabInstanceId;
|
||||
|
||||
if (
|
||||
!isDetached &&
|
||||
|
||||
+102
-435
@@ -3,18 +3,12 @@ import ssh2Pkg, {
|
||||
type Client as SSHClientType,
|
||||
type ClientChannel,
|
||||
type PseudoTtyOptions,
|
||||
type ParsedKey,
|
||||
type SignCallback,
|
||||
type SigningRequestOptions,
|
||||
type IdentityCallback,
|
||||
} from "ssh2";
|
||||
const { Client, BaseAgent, utils: ssh2Utils } = ssh2Pkg;
|
||||
import net from "net";
|
||||
import dgram from "dgram";
|
||||
const { Client, utils: ssh2Utils } = ssh2Pkg;
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import axios from "axios";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshCredentials, hosts } from "../database/db/schema.js";
|
||||
import { hosts } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { sshLogger, authLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
@@ -27,82 +21,14 @@ import {
|
||||
import { SSHAuthManager } from "./auth-manager.js";
|
||||
import type { ProxyNode } from "../../types/index.js";
|
||||
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
||||
import { createJumpHostChain } from "./terminal-jump-hosts.js";
|
||||
import { sessionManager } from "./terminal-session-manager.js";
|
||||
import {
|
||||
detectTmux,
|
||||
attachOrCreateTmuxSession,
|
||||
waitForTmuxSession,
|
||||
} from "./tmux-helper.js";
|
||||
|
||||
class MemoryAgent extends BaseAgent {
|
||||
private key: ParsedKey;
|
||||
|
||||
constructor(key: ParsedKey) {
|
||||
super();
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
getIdentities(cb: IdentityCallback<ParsedKey>): void {
|
||||
cb(null, [this.key]);
|
||||
}
|
||||
|
||||
sign(
|
||||
pubKey: ParsedKey | Buffer | string,
|
||||
data: Buffer,
|
||||
optionsOrCb: SigningRequestOptions | SignCallback,
|
||||
cb?: SignCallback,
|
||||
): void {
|
||||
const callback = typeof optionsOrCb === "function" ? optionsOrCb : cb!;
|
||||
const options = typeof optionsOrCb === "function" ? {} : optionsOrCb;
|
||||
try {
|
||||
const algo =
|
||||
options.hash === "sha256"
|
||||
? "rsa-sha2-256"
|
||||
: options.hash === "sha512"
|
||||
? "rsa-sha2-512"
|
||||
: undefined;
|
||||
const signature = this.key.sign(data, algo);
|
||||
callback(null, signature);
|
||||
} catch (err) {
|
||||
callback(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
import { MemoryAgent, performPortKnocking } from "./terminal-auth-helpers.js";
|
||||
|
||||
interface ConnectToHostData {
|
||||
cols: number;
|
||||
@@ -164,331 +90,6 @@ const userCrypto = UserCrypto.getInstance();
|
||||
|
||||
const userConnections = new Map<string, Set<WebSocket>>();
|
||||
|
||||
interface JumpHostConfig {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
credentialId?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
async function resolveJumpHost(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<JumpHostConfig | null> {
|
||||
sshLogger.info("Resolving jump host", {
|
||||
operation: "terminal_jumphost_resolve",
|
||||
userId,
|
||||
hostId,
|
||||
});
|
||||
try {
|
||||
const hostResults = await SimpleDBOps.select(
|
||||
getDb().select().from(hosts).where(eq(hosts.id, hostId)),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (hostResults.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = hostResults[0];
|
||||
const ownerId = (host.userId || userId) as string;
|
||||
|
||||
if (host.credentialId) {
|
||||
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) {
|
||||
return {
|
||||
...host,
|
||||
password: sharedCred.password,
|
||||
key: sharedCred.key,
|
||||
keyPassword: sharedCred.keyPassword,
|
||||
keyType: sharedCred.keyType,
|
||||
authType: sharedCred.key
|
||||
? "key"
|
||||
: sharedCred.password
|
||||
? "password"
|
||||
: "none",
|
||||
} as JumpHostConfig;
|
||||
}
|
||||
} catch {
|
||||
// fall through to owner credential lookup
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId as number),
|
||||
eq(sshCredentials.userId, ownerId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
ownerId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
return {
|
||||
...host,
|
||||
password: credential.password as string | undefined,
|
||||
key: (credential.key || credential.privateKey) as string | undefined,
|
||||
keyPassword: credential.keyPassword as string | undefined,
|
||||
keyType: credential.keyType as string | undefined,
|
||||
authType: credential.authType as string | undefined,
|
||||
} as JumpHostConfig;
|
||||
}
|
||||
}
|
||||
|
||||
return host as JumpHostConfig;
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to resolve jump host", error, {
|
||||
operation: "resolve_jump_host",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function createJumpHostChain(
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
userId: string,
|
||||
socks5Config?: SOCKS5Config | null,
|
||||
): Promise<SSHClientType | null> {
|
||||
if (!jumpHosts || jumpHosts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentClient: SSHClientType | null = null;
|
||||
const clients: SSHClientType[] = [];
|
||||
|
||||
try {
|
||||
const jumpHostConfigs = await Promise.all(
|
||||
jumpHosts.map((jh) => resolveJumpHost(jh.hostId, userId)),
|
||||
);
|
||||
|
||||
const totalHops = jumpHostConfigs.length;
|
||||
|
||||
for (let i = 0; i < jumpHostConfigs.length; i++) {
|
||||
if (!jumpHostConfigs[i]) {
|
||||
sshLogger.error(`Jump host ${i + 1} not found`, undefined, {
|
||||
operation: "jump_host_chain",
|
||||
hostId: jumpHosts[i].hostId,
|
||||
hopIndex: i,
|
||||
totalHops,
|
||||
});
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let proxySocket: import("net").Socket | null = null;
|
||||
if (socks5Config?.useSocks5) {
|
||||
const firstHop = jumpHostConfigs[0];
|
||||
proxySocket = await createSocks5Connection(
|
||||
firstHop.ip,
|
||||
firstHop.port || 22,
|
||||
socks5Config,
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < jumpHostConfigs.length; i++) {
|
||||
const jumpHostConfig = jumpHostConfigs[i];
|
||||
|
||||
const jumpClient = new Client();
|
||||
clients.push(jumpClient);
|
||||
|
||||
const jumpHostVerifier = await SSHHostKeyVerifier.createHostVerifier(
|
||||
jumpHostConfig.id,
|
||||
jumpHostConfig.ip,
|
||||
jumpHostConfig.port || 22,
|
||||
null,
|
||||
userId,
|
||||
true,
|
||||
);
|
||||
|
||||
const connected = await new Promise<boolean>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve(false);
|
||||
}, 30000);
|
||||
|
||||
jumpClient.on("ready", () => {
|
||||
clearTimeout(timeout);
|
||||
sshLogger.success("Jump host connection established", {
|
||||
operation: "terminal_jumphost_connected",
|
||||
userId,
|
||||
hostId: jumpHostConfig.id,
|
||||
ip: jumpHostConfig.ip,
|
||||
depth: i,
|
||||
hopIndex: i,
|
||||
totalHops,
|
||||
usedProxySocket: i === 0 && !!proxySocket,
|
||||
});
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
jumpClient.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
sshLogger.error(
|
||||
`Jump host ${i + 1}/${totalHops} connection failed`,
|
||||
err,
|
||||
{
|
||||
operation: "jump_host_connect",
|
||||
hostId: jumpHostConfig.id,
|
||||
ip: jumpHostConfig.ip,
|
||||
hopIndex: i,
|
||||
totalHops,
|
||||
previousHop:
|
||||
i > 0
|
||||
? jumpHostConfigs[i - 1]?.ip
|
||||
: proxySocket
|
||||
? "proxy"
|
||||
: "direct",
|
||||
usedProxySocket: i === 0 && !!proxySocket,
|
||||
},
|
||||
);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
const connectConfig: Record<string, unknown> = {
|
||||
host: jumpHostConfig.ip?.replace(/^\[|\]$/g, "") || jumpHostConfig.ip,
|
||||
port: jumpHostConfig.port || 22,
|
||||
username: jumpHostConfig.username,
|
||||
tryKeyboard: jumpHostConfig.authType !== "none",
|
||||
readyTimeout: 60000,
|
||||
hostVerifier: jumpHostVerifier,
|
||||
algorithms: {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group18-sha512",
|
||||
"diffie-hellman-group17-sha512",
|
||||
"diffie-hellman-group16-sha512",
|
||||
"diffie-hellman-group15-sha512",
|
||||
"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: SSH_ALGORITHMS.cipher,
|
||||
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"],
|
||||
},
|
||||
};
|
||||
|
||||
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
||||
connectConfig.password = jumpHostConfig.password;
|
||||
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
|
||||
const cleanKey = jumpHostConfig.key
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (jumpHostConfig.keyPassword) {
|
||||
connectConfig.passphrase = jumpHostConfig.keyPassword;
|
||||
}
|
||||
}
|
||||
|
||||
jumpClient.on(
|
||||
"keyboard-interactive",
|
||||
(
|
||||
_name: string,
|
||||
_instructions: string,
|
||||
_lang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && jumpHostConfig.password) {
|
||||
return jumpHostConfig.password as string;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
},
|
||||
);
|
||||
|
||||
if (currentClient) {
|
||||
currentClient.forwardOut(
|
||||
"127.0.0.1",
|
||||
0,
|
||||
jumpHostConfig.ip,
|
||||
jumpHostConfig.port || 22,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
connectConfig.sock = stream;
|
||||
jumpClient.connect(connectConfig);
|
||||
},
|
||||
);
|
||||
} else if (proxySocket) {
|
||||
connectConfig.sock = proxySocket;
|
||||
jumpClient.connect(connectConfig);
|
||||
} else {
|
||||
jumpClient.connect(connectConfig);
|
||||
}
|
||||
});
|
||||
|
||||
if (!connected) {
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
|
||||
currentClient = jumpClient;
|
||||
}
|
||||
|
||||
return currentClient;
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to create jump host chain", error, {
|
||||
operation: "jump_host_chain",
|
||||
});
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
port: 30002,
|
||||
});
|
||||
@@ -525,7 +126,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
if (!payload) {
|
||||
if (!payload?.userId || payload.pendingTOTP) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
@@ -848,9 +449,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
cwdPending = true;
|
||||
cwdBuffer = "";
|
||||
// Split the sentinel across shell variables so the echoed command
|
||||
// itself never contains "TERMIX_CWD:" — only the output line does.
|
||||
activeStream.write('a=TERMIX_CWD; echo "$a:$(pwd)"\r');
|
||||
activeStream.write('\x15a=TERMIX_CWD; echo "$a:$(pwd)"\r');
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1227,8 +826,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const {
|
||||
id,
|
||||
ip: rawIp,
|
||||
port,
|
||||
username,
|
||||
port: clientPort,
|
||||
username: clientUsername,
|
||||
password,
|
||||
key,
|
||||
keyPassword,
|
||||
@@ -1236,7 +835,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
authType,
|
||||
credentialId,
|
||||
} = hostConfig;
|
||||
const ip = rawIp?.replace(/^\[|\]$/g, "").trim() || rawIp;
|
||||
const clientIp = rawIp?.replace(/^\[|\]$/g, "").trim() || rawIp;
|
||||
let ip = clientIp;
|
||||
let port = clientPort;
|
||||
let username = clientUsername;
|
||||
sshLogger.info("Resolving SSH host configuration", {
|
||||
operation: "terminal_host_resolve",
|
||||
sessionId,
|
||||
@@ -1339,6 +941,71 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
let resolvedHostData:
|
||||
| (Record<string, unknown> & {
|
||||
ip?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
jumpHosts?: Array<{ hostId: number }>;
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: unknown;
|
||||
terminalConfig?: ConnectToHostData["hostConfig"]["terminalConfig"];
|
||||
})
|
||||
| null = null;
|
||||
|
||||
if (id && userId) {
|
||||
try {
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
resolvedHostData = (await resolveHostById(
|
||||
id,
|
||||
userId,
|
||||
)) as unknown as typeof resolvedHostData;
|
||||
|
||||
if (resolvedHostData) {
|
||||
if (
|
||||
(!hostConfig.jumpHosts || hostConfig.jumpHosts.length === 0) &&
|
||||
resolvedHostData.jumpHosts &&
|
||||
resolvedHostData.jumpHosts.length > 0
|
||||
) {
|
||||
hostConfig.jumpHosts = resolvedHostData.jumpHosts;
|
||||
sendLog(
|
||||
"jump",
|
||||
"info",
|
||||
`Loaded ${resolvedHostData.jumpHosts.length} jump host(s) from server-side host data`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig.useSocks5 && resolvedHostData.useSocks5) {
|
||||
hostConfig.useSocks5 = resolvedHostData.useSocks5;
|
||||
hostConfig.socks5Host = resolvedHostData.socks5Host;
|
||||
hostConfig.socks5Port = resolvedHostData.socks5Port;
|
||||
hostConfig.socks5Username = resolvedHostData.socks5Username;
|
||||
hostConfig.socks5Password = resolvedHostData.socks5Password;
|
||||
hostConfig.socks5ProxyChain = resolvedHostData.socks5ProxyChain;
|
||||
}
|
||||
|
||||
if (!hostConfig.terminalConfig && resolvedHostData.terminalConfig) {
|
||||
hostConfig.terminalConfig = resolvedHostData.terminalConfig;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.warn(`Failed to resolve server-side host data for ${id}`, {
|
||||
operation: "ssh_host_data",
|
||||
hostId: id,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve credentials server-side when frontend doesn't provide them
|
||||
let resolvedCredentials = {
|
||||
username,
|
||||
@@ -1352,18 +1019,18 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const authMethodNotAvailable = false;
|
||||
if (id && userId && !password && !key) {
|
||||
try {
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
const resolvedHost = await resolveHostById(id, userId);
|
||||
if (resolvedHost) {
|
||||
if (resolvedHostData) {
|
||||
ip = resolvedHostData.ip || ip;
|
||||
port = resolvedHostData.port || port;
|
||||
username = resolvedHostData.username || username;
|
||||
resolvedCredentials = {
|
||||
username: resolvedHost.username || username,
|
||||
password: resolvedHost.password,
|
||||
key: resolvedHost.key,
|
||||
keyPassword: keyPassword || resolvedHost.keyPassword,
|
||||
keyType: resolvedHost.keyType,
|
||||
authType: resolvedHost.authType,
|
||||
certPublicKey: (resolvedHost as unknown as Record<string, unknown>)
|
||||
.certPublicKey as string | undefined,
|
||||
username: resolvedHostData.username || username,
|
||||
password: resolvedHostData.password,
|
||||
key: resolvedHostData.key,
|
||||
keyPassword: keyPassword || resolvedHostData.keyPassword,
|
||||
keyType: resolvedHostData.keyType,
|
||||
authType: resolvedHostData.authType,
|
||||
certPublicKey: resolvedHostData.certPublicKey as string | undefined,
|
||||
};
|
||||
sendLog(
|
||||
"auth",
|
||||
@@ -1380,19 +1047,19 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
} else if (credentialId && id && userId) {
|
||||
try {
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
const resolvedHost = await resolveHostById(id, userId);
|
||||
if (resolvedHost) {
|
||||
if (resolvedHostData) {
|
||||
ip = resolvedHostData.ip || ip;
|
||||
port = resolvedHostData.port || port;
|
||||
username = resolvedHostData.username || username;
|
||||
resolvedCredentials = {
|
||||
username: resolvedHost.username || username,
|
||||
password: resolvedHost.password,
|
||||
key: resolvedHost.key,
|
||||
username: resolvedHostData.username || username,
|
||||
password: resolvedHostData.password,
|
||||
key: resolvedHostData.key,
|
||||
// Preserve user-supplied keyPassword (e.g. from passphrase dialog) over the empty DB value
|
||||
keyPassword: keyPassword || resolvedHost.keyPassword,
|
||||
keyType: resolvedHost.keyType,
|
||||
authType: resolvedHost.authType,
|
||||
certPublicKey: (resolvedHost as unknown as Record<string, unknown>)
|
||||
.certPublicKey as string | undefined,
|
||||
keyPassword: keyPassword || resolvedHostData.keyPassword,
|
||||
keyType: resolvedHostData.keyType,
|
||||
authType: resolvedHostData.authType,
|
||||
certPublicKey: resolvedHostData.certPublicKey as string | undefined,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1787,7 +1454,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const runPostShellCommands = (delay: number) => {
|
||||
setTimeout(() => {
|
||||
if (initialPath && initialPath.trim() !== "") {
|
||||
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\r`;
|
||||
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}"\r`;
|
||||
stream.write(cdCommand);
|
||||
}
|
||||
if (executeCommand && executeCommand.trim() !== "") {
|
||||
@@ -2283,9 +1950,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
keepaliveInterval:
|
||||
typeof hostKeepaliveInterval === "number"
|
||||
? hostKeepaliveInterval * 1000
|
||||
: 60000,
|
||||
: 30000,
|
||||
keepaliveCountMax:
|
||||
typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 5,
|
||||
typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 3,
|
||||
readyTimeout: 120000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
export type TransferPlatform = "windows" | "unix";
|
||||
|
||||
/** OpenSSH SFTP on Windows commonly uses `/C:/...` or `C:/...` style paths. */
|
||||
export function isWindowsSftpPath(path: string): boolean {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
return /^[A-Za-z]:\//.test(normalized) || /^\/[A-Za-z]:\//.test(normalized);
|
||||
}
|
||||
|
||||
export function inferPlatformFromPath(path: string): TransferPlatform | null {
|
||||
if (isWindowsSftpPath(path)) return "windows";
|
||||
if (path.startsWith("/") && !/^\/[A-Za-z]:/.test(path.replace(/\\/g, "/"))) {
|
||||
return "unix";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeSftpPath(path: string): string {
|
||||
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
if (!normalized) return path.startsWith("/") ? "/" : ".";
|
||||
return normalized.replace(/([^:])\/+/g, "$1/");
|
||||
}
|
||||
|
||||
export function basename(path: string): string {
|
||||
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const idx = normalized.lastIndexOf("/");
|
||||
if (idx < 0) return normalized;
|
||||
return normalized.substring(idx + 1) || normalized;
|
||||
}
|
||||
|
||||
export function dirname(path: string): string {
|
||||
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
if (!normalized) return normalized;
|
||||
|
||||
const idx = normalized.lastIndexOf("/");
|
||||
if (idx < 0) return normalized;
|
||||
|
||||
const parent = normalized.substring(0, idx);
|
||||
if (!parent) return "/";
|
||||
if (/^\/[A-Za-z]:$/.test(parent) || /^[A-Za-z]:$/.test(parent)) {
|
||||
return parent;
|
||||
}
|
||||
return parent || "/";
|
||||
}
|
||||
|
||||
export function joinPath(base: string, ...parts: string[]): string {
|
||||
let result = base.replace(/\\/g, "/");
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
const segment = part.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
if (!segment) continue;
|
||||
if (result.endsWith("/")) {
|
||||
result += segment;
|
||||
} else {
|
||||
result = `${result}/${segment}`;
|
||||
}
|
||||
}
|
||||
return normalizeSftpPath(result);
|
||||
}
|
||||
|
||||
export interface PathSegments {
|
||||
/** Drive root prefix, e.g. `/C:` or `C:`; empty for Unix absolute paths. */
|
||||
root: string;
|
||||
/** Path segments below the root (may be empty). */
|
||||
segments: string[];
|
||||
}
|
||||
|
||||
export function splitPathSegments(path: string): PathSegments {
|
||||
const normalized = normalizeSftpPath(path);
|
||||
|
||||
const posixDrive = normalized.match(/^(\/[A-Za-z]:)(?:\/(.*))?$/);
|
||||
if (posixDrive) {
|
||||
return {
|
||||
root: posixDrive[1],
|
||||
segments: (posixDrive[2] ?? "").split("/").filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
const drive = normalized.match(/^([A-Za-z]:)(?:\/(.*))?$/);
|
||||
if (drive) {
|
||||
return {
|
||||
root: drive[1],
|
||||
segments: (drive[2] ?? "").split("/").filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalized.startsWith("/")) {
|
||||
return {
|
||||
root: "/",
|
||||
segments: normalized.split("/").filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
root: "",
|
||||
segments: normalized.split("/").filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPathFromSegments(
|
||||
root: string,
|
||||
segments: string[],
|
||||
count: number,
|
||||
): string {
|
||||
const slice = segments.slice(0, count);
|
||||
if (/^\/[A-Za-z]:$/.test(root)) {
|
||||
return slice.length ? `${root}/${slice.join("/")}` : root;
|
||||
}
|
||||
if (/^[A-Za-z]:$/.test(root)) {
|
||||
return slice.length ? `${root}/${slice.join("/")}` : root;
|
||||
}
|
||||
if (root === "/") {
|
||||
return slice.length ? `/${slice.join("/")}` : "/";
|
||||
}
|
||||
if (root === "") {
|
||||
return slice.join("/");
|
||||
}
|
||||
return slice.length ? `${root}/${slice.join("/")}` : root;
|
||||
}
|
||||
|
||||
export function pathsOverlap(source: string, dest: string): boolean {
|
||||
const norm = (p: string) => {
|
||||
const n = normalizeSftpPath(p).toLowerCase();
|
||||
return n || (isWindowsSftpPath(p) ? p : "/");
|
||||
};
|
||||
const s = norm(source);
|
||||
const d = norm(dest);
|
||||
const sep = "/";
|
||||
return s === d || s.startsWith(`${d}${sep}`) || d.startsWith(`${s}${sep}`);
|
||||
}
|
||||
|
||||
export function getWorkingDir(paths: string[]): string {
|
||||
if (paths.length === 0) return "/";
|
||||
const parents = paths.map((p) => dirname(p));
|
||||
const first = parents[0];
|
||||
if (parents.every((p) => p === first)) {
|
||||
return first;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
/** Convert an SFTP path to a local filesystem path when Termix runs on the dest host. */
|
||||
export function sftpPathToLocalPath(sftpPath: string): string {
|
||||
const normalized = sftpPath.replace(/\\/g, "/");
|
||||
if (/^\/[A-Za-z]:\//.test(normalized)) {
|
||||
return normalized.slice(1).replace(/\//g, "\\");
|
||||
}
|
||||
if (/^[A-Za-z]:\//.test(normalized)) {
|
||||
return normalized.replace(/\//g, "\\");
|
||||
}
|
||||
return sftpPath;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { TransferPlatform } from "./transfer-paths.js";
|
||||
|
||||
export type TransferMethodPreference = "auto" | "tar" | "item_sftp";
|
||||
|
||||
export interface TransferScanSummary {
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
largestFileBytes: number;
|
||||
/** Share of total bytes in likely incompressible file types (0–1). */
|
||||
incompressibleRatio: number;
|
||||
}
|
||||
|
||||
const INCOMPRESSIBLE_EXT =
|
||||
/\.(zip|gz|bz2|xz|7z|rar|tar|tgz|jpg|jpeg|png|gif|webp|mp3|mp4|mkv|avi|mov|wmv|iso|dmg|deb|rpm|pdf|db|sqlite|wasm|vmdk|qcow2)$/i;
|
||||
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
export function isLikelyIncompressiblePath(path: string): boolean {
|
||||
return INCOMPRESSIBLE_EXT.test(path);
|
||||
}
|
||||
|
||||
export function buildTransferScanSummary(
|
||||
items: Array<{ sourcePath: string; size: number }>,
|
||||
): TransferScanSummary {
|
||||
let totalBytes = 0;
|
||||
let largestFileBytes = 0;
|
||||
let incompressibleBytes = 0;
|
||||
|
||||
for (const item of items) {
|
||||
totalBytes += item.size;
|
||||
largestFileBytes = Math.max(largestFileBytes, item.size);
|
||||
if (isLikelyIncompressiblePath(item.sourcePath)) {
|
||||
incompressibleBytes += item.size;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fileCount: items.length,
|
||||
totalBytes,
|
||||
largestFileBytes,
|
||||
incompressibleRatio: totalBytes > 0 ? incompressibleBytes / totalBytes : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose tar vs per-item SFTP for directory / multi-file transfers.
|
||||
* Single-file stream transfers bypass this entirely.
|
||||
*/
|
||||
export function resolveArchiveTransferMethod(
|
||||
preference: TransferMethodPreference,
|
||||
summary: TransferScanSummary,
|
||||
sourcePlatform: TransferPlatform,
|
||||
destPlatform: TransferPlatform,
|
||||
sourceHasTar: boolean,
|
||||
destHasTar: boolean,
|
||||
): "tar" | "item_sftp" {
|
||||
if (sourcePlatform === "windows" || destPlatform === "windows") {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (preference === "item_sftp") {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (preference === "tar") {
|
||||
return sourceHasTar && destHasTar ? "tar" : "item_sftp";
|
||||
}
|
||||
|
||||
// auto
|
||||
if (!sourceHasTar || !destHasTar) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
const { fileCount, totalBytes, largestFileBytes, incompressibleRatio } =
|
||||
summary;
|
||||
|
||||
if (fileCount === 0) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (fileCount === 1 && largestFileBytes >= 2 * GB) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
// Multi-item sets with a large file: tar only when data is likely compressible
|
||||
// (mostly video/zip → per-file SFTP is simpler and avoids pack/unpack time).
|
||||
if (
|
||||
fileCount > 1 &&
|
||||
largestFileBytes >= 2 * GB &&
|
||||
totalBytes >= 500 * MB &&
|
||||
incompressibleRatio < 0.7
|
||||
) {
|
||||
return "tar";
|
||||
}
|
||||
|
||||
if (fileCount === 1 && largestFileBytes >= 5 * GB) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (totalBytes >= 10 * GB && incompressibleRatio >= 0.5) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (incompressibleRatio >= 0.85) {
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
if (fileCount >= 100) {
|
||||
return "tar";
|
||||
}
|
||||
|
||||
if (fileCount >= 20 && totalBytes >= 50 * MB && incompressibleRatio < 0.5) {
|
||||
return "tar";
|
||||
}
|
||||
|
||||
if (fileCount > 5 && incompressibleRatio < 0.7) {
|
||||
return "tar";
|
||||
}
|
||||
|
||||
return "item_sftp";
|
||||
}
|
||||
|
||||
export type ArchiveTransferReasonKey =
|
||||
| "user_item_sftp"
|
||||
| "user_tar"
|
||||
| "tar_unavailable"
|
||||
| "windows_host"
|
||||
| "auto_multi_large"
|
||||
| "auto_single_large_in_archive"
|
||||
| "auto_many_incompressible"
|
||||
| "auto_many_files"
|
||||
| "auto_default";
|
||||
|
||||
/** i18n key suffix under transfer.methodReason.* */
|
||||
export function getArchiveTransferReasonKey(
|
||||
preference: TransferMethodPreference,
|
||||
resolvedMethod: "tar" | "item_sftp",
|
||||
summary: TransferScanSummary,
|
||||
sourcePlatform: TransferPlatform,
|
||||
destPlatform: TransferPlatform,
|
||||
sourceHasTar: boolean,
|
||||
destHasTar: boolean,
|
||||
): ArchiveTransferReasonKey {
|
||||
if (preference === "item_sftp") return "user_item_sftp";
|
||||
if (preference === "tar") {
|
||||
return sourceHasTar && destHasTar ? "user_tar" : "tar_unavailable";
|
||||
}
|
||||
|
||||
if (sourcePlatform === "windows" || destPlatform === "windows") {
|
||||
return "windows_host";
|
||||
}
|
||||
if (!sourceHasTar || !destHasTar) {
|
||||
return "tar_unavailable";
|
||||
}
|
||||
|
||||
const { fileCount, totalBytes, largestFileBytes, incompressibleRatio } =
|
||||
summary;
|
||||
|
||||
if (
|
||||
fileCount > 1 &&
|
||||
largestFileBytes >= 2 * GB &&
|
||||
totalBytes >= 500 * MB &&
|
||||
incompressibleRatio < 0.7
|
||||
) {
|
||||
return "auto_multi_large";
|
||||
}
|
||||
|
||||
if (fileCount === 1 && largestFileBytes >= 2 * GB) {
|
||||
return "auto_single_large_in_archive";
|
||||
}
|
||||
|
||||
if (totalBytes >= 10 * GB && incompressibleRatio >= 0.5) {
|
||||
return "auto_many_incompressible";
|
||||
}
|
||||
if (incompressibleRatio >= 0.85) {
|
||||
return "auto_many_incompressible";
|
||||
}
|
||||
|
||||
if (
|
||||
fileCount >= 100 ||
|
||||
(fileCount >= 20 && totalBytes >= 50 * MB && incompressibleRatio < 0.5) ||
|
||||
(fileCount > 5 && incompressibleRatio < 0.7)
|
||||
) {
|
||||
return "auto_many_files";
|
||||
}
|
||||
|
||||
if (resolvedMethod === "tar") {
|
||||
return "auto_many_files";
|
||||
}
|
||||
|
||||
return "auto_default";
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { IncomingMessage } from "http";
|
||||
import type { Duplex } from "stream";
|
||||
import type { ClientChannel } from "ssh2";
|
||||
import type { WebSocket } from "ws";
|
||||
|
||||
const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
|
||||
const C2S_WS_LOW_WATERMARK = 256 * 1024;
|
||||
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
|
||||
|
||||
export function extractRequestToken(req: IncomingMessage): string | undefined {
|
||||
const cookieHeader = req.headers.cookie;
|
||||
if (cookieHeader) {
|
||||
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
|
||||
if (match) return decodeURIComponent(match[1]);
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
return authHeader.slice("Bearer ".length);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function sendC2SError(ws: WebSocket, message: string): void {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ type: "error", error: message }));
|
||||
}
|
||||
}
|
||||
|
||||
export function describeC2SRelayError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
if (
|
||||
lowerMessage.includes("administratively prohibited") ||
|
||||
lowerMessage.includes("forwarding disabled") ||
|
||||
lowerMessage.includes("open failed")
|
||||
) {
|
||||
return `SSH forwarding was rejected by the endpoint server: ${message}`;
|
||||
}
|
||||
if (
|
||||
lowerMessage.includes("address already in use") ||
|
||||
lowerMessage.includes("unable to bind") ||
|
||||
lowerMessage.includes("bind")
|
||||
) {
|
||||
return `Remote port is not available on the endpoint server: ${message}`;
|
||||
}
|
||||
if (
|
||||
lowerMessage.includes("name or service not known") ||
|
||||
lowerMessage.includes("enotfound") ||
|
||||
lowerMessage.includes("econnrefused")
|
||||
) {
|
||||
return `Tunnel target is not reachable from the endpoint host: ${message}`;
|
||||
}
|
||||
|
||||
return message || "Failed to open relay";
|
||||
}
|
||||
|
||||
function pauseSourceForC2SWebSocket(ws: WebSocket, source?: Duplex): void {
|
||||
if (!source) return;
|
||||
if (ws.bufferedAmount <= C2S_WS_HIGH_WATERMARK) return;
|
||||
|
||||
source.pause();
|
||||
const resumeTimer = setInterval(() => {
|
||||
if (
|
||||
ws.readyState !== 1 ||
|
||||
source.destroyed ||
|
||||
ws.bufferedAmount <= C2S_WS_LOW_WATERMARK
|
||||
) {
|
||||
clearInterval(resumeTimer);
|
||||
if (ws.readyState === 1 && !source.destroyed) {
|
||||
source.resume();
|
||||
}
|
||||
}
|
||||
}, 25);
|
||||
}
|
||||
|
||||
export function sendC2SMessage(
|
||||
ws: WebSocket,
|
||||
message: Record<string, unknown>,
|
||||
source?: Duplex,
|
||||
): void {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify(message), (error) => {
|
||||
if (error && source && !source.destroyed) {
|
||||
source.destroy(error);
|
||||
}
|
||||
});
|
||||
pauseSourceForC2SWebSocket(ws, source);
|
||||
}
|
||||
}
|
||||
|
||||
export function writeC2SRemoteChunk(
|
||||
target: ClientChannel,
|
||||
chunk: Buffer,
|
||||
ws: WebSocket,
|
||||
closeTarget: () => void,
|
||||
): void {
|
||||
if (!target || target.destroyed) return;
|
||||
|
||||
if (target.writableLength > C2S_STREAM_WRITE_LIMIT) {
|
||||
closeTarget();
|
||||
return;
|
||||
}
|
||||
|
||||
const canContinue = target.write(chunk);
|
||||
if (!canContinue) {
|
||||
ws.pause();
|
||||
target.once("drain", () => {
|
||||
if (ws.readyState === 1) {
|
||||
ws.resume();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import { Client, type ClientChannel } from "ssh2";
|
||||
import type { WebSocket } from "ws";
|
||||
import type { TunnelConfig } from "../../types/index.js";
|
||||
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
||||
import { tunnelLogger } from "../utils/logger.js";
|
||||
import { PermissionManager } from "../utils/permission-manager.js";
|
||||
import {
|
||||
applyAuthOptions,
|
||||
bindForwardIn,
|
||||
connectClient,
|
||||
forwardOut,
|
||||
getManagedTunnelAlgorithms,
|
||||
unbindForwardIn,
|
||||
} from "./tunnel-ssh-primitives.js";
|
||||
import {
|
||||
sendC2SMessage,
|
||||
writeC2SRemoteChunk,
|
||||
} from "./tunnel-c2s-relay-utils.js";
|
||||
import { getTunnelMode } from "./tunnel-utils.js";
|
||||
|
||||
export type C2SOpenMessage = {
|
||||
type: "open" | "test";
|
||||
tunnelConfig?: Partial<TunnelConfig>;
|
||||
targetHost?: string;
|
||||
targetPort?: number;
|
||||
};
|
||||
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
let c2sRemoteStreamCounter = 0;
|
||||
|
||||
async function resolveC2STunnelSource(
|
||||
tunnelConfig: Partial<TunnelConfig>,
|
||||
userId: string,
|
||||
): Promise<TunnelConfig> {
|
||||
if (!tunnelConfig.sourceHostId) {
|
||||
throw new Error("Endpoint SSH host is required");
|
||||
}
|
||||
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
tunnelConfig.sourceHostId,
|
||||
"read",
|
||||
);
|
||||
if (!accessInfo.hasAccess) {
|
||||
throw new Error("Access denied to this host");
|
||||
}
|
||||
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
const resolvedHost = await resolveHostById(tunnelConfig.sourceHostId, userId);
|
||||
if (!resolvedHost) {
|
||||
throw new Error("Endpoint SSH host not found");
|
||||
}
|
||||
|
||||
return {
|
||||
name: tunnelConfig.name || `c2s:${tunnelConfig.sourceHostId}`,
|
||||
scope: "c2s",
|
||||
mode: tunnelConfig.mode || "local",
|
||||
tunnelType:
|
||||
tunnelConfig.tunnelType ||
|
||||
(tunnelConfig.mode === "remote" ? "remote" : "local"),
|
||||
bindHost: tunnelConfig.bindHost,
|
||||
targetHost: tunnelConfig.targetHost || "127.0.0.1",
|
||||
sourceHostId: resolvedHost.id || tunnelConfig.sourceHostId,
|
||||
tunnelIndex: tunnelConfig.tunnelIndex || 0,
|
||||
requestingUserId: userId,
|
||||
hostName:
|
||||
resolvedHost.name || `${resolvedHost.username}@${resolvedHost.ip}`,
|
||||
sourceIP: resolvedHost.ip,
|
||||
sourceSSHPort: resolvedHost.port,
|
||||
sourceUsername: resolvedHost.username,
|
||||
sourcePassword: resolvedHost.password,
|
||||
sourceAuthMethod: resolvedHost.authType,
|
||||
sourceSSHKey: resolvedHost.key,
|
||||
sourceKeyPassword: resolvedHost.keyPassword,
|
||||
sourceKeyType: resolvedHost.keyType,
|
||||
sourceCredentialId: resolvedHost.credentialId,
|
||||
sourceUserId: resolvedHost.userId,
|
||||
endpointIP: tunnelConfig.endpointIP || resolvedHost.ip,
|
||||
endpointSSHPort: tunnelConfig.endpointSSHPort || resolvedHost.port,
|
||||
endpointUsername: resolvedHost.username,
|
||||
endpointHost:
|
||||
tunnelConfig.endpointHost || resolvedHost.name || resolvedHost.ip,
|
||||
endpointAuthMethod: resolvedHost.authType,
|
||||
endpointSSHKey: resolvedHost.key,
|
||||
endpointKeyPassword: resolvedHost.keyPassword,
|
||||
endpointKeyType: resolvedHost.keyType,
|
||||
endpointCredentialId: resolvedHost.credentialId,
|
||||
endpointUserId: resolvedHost.userId,
|
||||
sourcePort: Number(tunnelConfig.sourcePort) || 0,
|
||||
endpointPort: Number(tunnelConfig.endpointPort) || 0,
|
||||
maxRetries: Number(tunnelConfig.maxRetries) || 0,
|
||||
retryInterval: Number(tunnelConfig.retryInterval) || 0,
|
||||
autoStart: Boolean(tunnelConfig.autoStart),
|
||||
isPinned: Boolean(resolvedHost.pin),
|
||||
useSocks5: Boolean(resolvedHost.useSocks5),
|
||||
socks5Host: resolvedHost.socks5Host,
|
||||
socks5Port: resolvedHost.socks5Port,
|
||||
socks5Username: resolvedHost.socks5Username,
|
||||
socks5Password: resolvedHost.socks5Password,
|
||||
socks5ProxyChain: resolvedHost.socks5ProxyChain,
|
||||
keepaliveInterval:
|
||||
typeof resolvedHost.terminalConfig?.keepaliveInterval === "number"
|
||||
? resolvedHost.terminalConfig.keepaliveInterval * 1000
|
||||
: 60000,
|
||||
keepaliveCountMax:
|
||||
typeof resolvedHost.terminalConfig?.keepaliveCountMax === "number"
|
||||
? resolvedHost.terminalConfig.keepaliveCountMax
|
||||
: 5,
|
||||
};
|
||||
}
|
||||
|
||||
async function connectC2SSourceClient(
|
||||
tunnelConfig: TunnelConfig,
|
||||
): Promise<Client> {
|
||||
const connOptions: Record<string, unknown> = {
|
||||
host:
|
||||
tunnelConfig.sourceIP?.replace(/^\[|\]$/g, "") || tunnelConfig.sourceIP,
|
||||
port: tunnelConfig.sourceSSHPort,
|
||||
username: tunnelConfig.sourceUsername,
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: tunnelConfig.keepaliveInterval ?? 30000,
|
||||
keepaliveCountMax: tunnelConfig.keepaliveCountMax ?? 3,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
algorithms: getManagedTunnelAlgorithms(),
|
||||
};
|
||||
|
||||
applyAuthOptions(connOptions, {
|
||||
password: tunnelConfig.sourcePassword,
|
||||
sshKey: tunnelConfig.sourceSSHKey,
|
||||
keyPassword: tunnelConfig.sourceKeyPassword,
|
||||
keyType: tunnelConfig.sourceKeyType,
|
||||
authMethod: tunnelConfig.sourceAuthMethod,
|
||||
});
|
||||
|
||||
if (
|
||||
tunnelConfig.useSocks5 &&
|
||||
(tunnelConfig.socks5Host ||
|
||||
(tunnelConfig.socks5ProxyChain &&
|
||||
tunnelConfig.socks5ProxyChain.length > 0))
|
||||
) {
|
||||
const socks5Socket = await createSocks5Connection(
|
||||
tunnelConfig.sourceIP,
|
||||
tunnelConfig.sourceSSHPort,
|
||||
{
|
||||
useSocks5: tunnelConfig.useSocks5,
|
||||
socks5Host: tunnelConfig.socks5Host,
|
||||
socks5Port: tunnelConfig.socks5Port,
|
||||
socks5Username: tunnelConfig.socks5Username,
|
||||
socks5Password: tunnelConfig.socks5Password,
|
||||
socks5ProxyChain: tunnelConfig.socks5ProxyChain,
|
||||
},
|
||||
);
|
||||
if (socks5Socket) {
|
||||
connOptions.sock = socks5Socket;
|
||||
}
|
||||
}
|
||||
|
||||
return connectClient(connOptions, tunnelConfig.name, "source");
|
||||
}
|
||||
|
||||
async function handleC2SRemoteRelayOpen(
|
||||
ws: WebSocket,
|
||||
tunnelConfig: TunnelConfig,
|
||||
): Promise<void> {
|
||||
const tunnelName = tunnelConfig.name;
|
||||
const sourceClient = await connectC2SSourceClient(tunnelConfig);
|
||||
const bindHost = tunnelConfig.targetHost || "127.0.0.1";
|
||||
const bindPort = Number(tunnelConfig.sourcePort);
|
||||
let closed = false;
|
||||
|
||||
if (!Number.isInteger(bindPort) || bindPort < 1 || bindPort > 65535) {
|
||||
throw new Error("Invalid remote port");
|
||||
}
|
||||
|
||||
const actualPort = await bindForwardIn(sourceClient, bindHost, bindPort);
|
||||
const streams = new Map<string, ClientChannel>();
|
||||
|
||||
const closeStream = (streamId: string): void => {
|
||||
const stream = streams.get(streamId);
|
||||
if (!stream) return;
|
||||
streams.delete(streamId);
|
||||
try {
|
||||
stream.destroy();
|
||||
} catch {
|
||||
// expected during shutdown
|
||||
}
|
||||
};
|
||||
|
||||
const close = (): void => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const streamId of streams.keys()) {
|
||||
closeStream(streamId);
|
||||
}
|
||||
unbindForwardIn(sourceClient, bindHost, actualPort);
|
||||
try {
|
||||
sourceClient.end();
|
||||
} catch {
|
||||
// expected during shutdown
|
||||
}
|
||||
};
|
||||
|
||||
sourceClient.on("tcp connection", (info, accept, reject) => {
|
||||
if (info.destPort !== actualPort) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
|
||||
const inbound = accept();
|
||||
const streamId = `${Date.now()}-${++c2sRemoteStreamCounter}`;
|
||||
streams.set(streamId, inbound);
|
||||
|
||||
sendC2SMessage(ws, { type: "connection", streamId });
|
||||
|
||||
inbound.on("data", (chunk) => {
|
||||
sendC2SMessage(
|
||||
ws,
|
||||
{
|
||||
type: "data",
|
||||
streamId,
|
||||
data: chunk.toString("base64"),
|
||||
},
|
||||
inbound,
|
||||
);
|
||||
});
|
||||
inbound.on("close", () => {
|
||||
streams.delete(streamId);
|
||||
sendC2SMessage(ws, { type: "close", streamId });
|
||||
});
|
||||
inbound.on("error", (error) => {
|
||||
streams.delete(streamId);
|
||||
sendC2SMessage(ws, {
|
||||
type: "close",
|
||||
streamId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ws.on("message", (data, isBinary) => {
|
||||
if (isBinary) return;
|
||||
|
||||
try {
|
||||
const message = JSON.parse(data.toString()) as {
|
||||
type?: string;
|
||||
streamId?: string;
|
||||
data?: string;
|
||||
};
|
||||
if (!message.streamId) return;
|
||||
|
||||
if (message.type === "data" && message.data) {
|
||||
const stream = streams.get(message.streamId);
|
||||
if (stream) {
|
||||
writeC2SRemoteChunk(
|
||||
stream,
|
||||
Buffer.from(message.data, "base64"),
|
||||
ws,
|
||||
() => closeStream(message.streamId as string),
|
||||
);
|
||||
}
|
||||
} else if (message.type === "close") {
|
||||
closeStream(message.streamId);
|
||||
}
|
||||
} catch (error) {
|
||||
tunnelLogger.warn("Invalid C2S remote relay message", {
|
||||
operation: "c2s_remote_relay_invalid_message",
|
||||
tunnelName,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", close);
|
||||
ws.on("error", close);
|
||||
sourceClient.on("close", () => {
|
||||
if (ws.readyState === 1) ws.close();
|
||||
});
|
||||
sourceClient.on("error", (error) => {
|
||||
sendC2SMessage(ws, {
|
||||
type: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
if (ws.readyState === 1) ws.close();
|
||||
});
|
||||
|
||||
tunnelLogger.info("C2S remote tunnel ready", {
|
||||
operation: "c2s_remote_tunnel_ready",
|
||||
tunnelName,
|
||||
bindHost,
|
||||
bindPort: actualPort,
|
||||
endpointHost: tunnelConfig.endpointHost,
|
||||
});
|
||||
sendC2SMessage(ws, { type: "ready", bindHost, bindPort: actualPort });
|
||||
}
|
||||
|
||||
export async function handleC2SRelayOpen(
|
||||
ws: WebSocket,
|
||||
message: C2SOpenMessage,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const tunnelConfig = await resolveC2STunnelSource(
|
||||
message.tunnelConfig || {},
|
||||
userId,
|
||||
);
|
||||
const mode = getTunnelMode(tunnelConfig);
|
||||
if (mode === "remote") {
|
||||
await handleC2SRemoteRelayOpen(ws, tunnelConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetHost =
|
||||
mode === "dynamic"
|
||||
? message.targetHost
|
||||
: tunnelConfig.targetHost || "127.0.0.1";
|
||||
const targetPort =
|
||||
mode === "dynamic"
|
||||
? Number(message.targetPort)
|
||||
: Number(tunnelConfig.endpointPort);
|
||||
|
||||
if (!targetHost || !Number.isInteger(targetPort) || targetPort < 1) {
|
||||
throw new Error("Invalid client tunnel target");
|
||||
}
|
||||
|
||||
const sourceClient = await connectC2SSourceClient(tunnelConfig);
|
||||
const outbound = await forwardOut(sourceClient, targetHost, targetPort);
|
||||
|
||||
const close = () => {
|
||||
try {
|
||||
outbound.destroy();
|
||||
} catch {
|
||||
// expected during shutdown
|
||||
}
|
||||
try {
|
||||
sourceClient.end();
|
||||
} catch {
|
||||
// expected during shutdown
|
||||
}
|
||||
};
|
||||
|
||||
outbound.on("data", (chunk) => {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(chunk);
|
||||
}
|
||||
});
|
||||
outbound.on("close", () => {
|
||||
if (ws.readyState === 1) ws.close();
|
||||
});
|
||||
outbound.on("error", () => {
|
||||
if (ws.readyState === 1) ws.close();
|
||||
});
|
||||
ws.on("close", close);
|
||||
ws.on("error", close);
|
||||
ws.on("message", (data, isBinary) => {
|
||||
if (!isBinary) return;
|
||||
const chunk = Buffer.isBuffer(data)
|
||||
? data
|
||||
: Array.isArray(data)
|
||||
? Buffer.concat(data)
|
||||
: Buffer.from(data);
|
||||
outbound.write(chunk);
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({ type: "ready" }));
|
||||
}
|
||||
|
||||
export async function handleC2SRelayTest(
|
||||
ws: WebSocket,
|
||||
message: C2SOpenMessage,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const tunnelConfig = await resolveC2STunnelSource(
|
||||
message.tunnelConfig || {},
|
||||
userId,
|
||||
);
|
||||
const mode = getTunnelMode(tunnelConfig);
|
||||
const sourceClient = await connectC2SSourceClient(tunnelConfig);
|
||||
|
||||
try {
|
||||
if (mode === "remote") {
|
||||
const bindHost = tunnelConfig.targetHost || "127.0.0.1";
|
||||
const bindPort = Number(tunnelConfig.sourcePort);
|
||||
if (!Number.isInteger(bindPort) || bindPort < 1 || bindPort > 65535) {
|
||||
throw new Error("Invalid remote port");
|
||||
}
|
||||
|
||||
const actualPort = await bindForwardIn(sourceClient, bindHost, bindPort);
|
||||
unbindForwardIn(sourceClient, bindHost, actualPort);
|
||||
} else if (mode === "local") {
|
||||
const targetHost = tunnelConfig.targetHost || "127.0.0.1";
|
||||
const targetPort = Number(tunnelConfig.endpointPort);
|
||||
if (!Number.isInteger(targetPort) || targetPort < 1) {
|
||||
throw new Error("Invalid remote target port");
|
||||
}
|
||||
|
||||
const outbound = await forwardOut(sourceClient, targetHost, targetPort);
|
||||
outbound.destroy();
|
||||
}
|
||||
|
||||
sendC2SMessage(ws, { type: "ready" });
|
||||
} finally {
|
||||
try {
|
||||
sourceClient.end();
|
||||
} catch {
|
||||
// expected during shutdown
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Duplex } from "stream";
|
||||
import { tunnelLogger } from "../utils/logger.js";
|
||||
|
||||
function parseSocksAddress(buffer: Buffer): {
|
||||
address: string;
|
||||
port: number;
|
||||
bytesRead: number;
|
||||
} | null {
|
||||
if (buffer.length < 7 || buffer[0] !== 0x05 || buffer[1] !== 0x01) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const addressType = buffer[3];
|
||||
let offset = 4;
|
||||
let address: string;
|
||||
|
||||
if (addressType === 0x01) {
|
||||
if (buffer.length < offset + 4 + 2) return null;
|
||||
address = Array.from(buffer.subarray(offset, offset + 4)).join(".");
|
||||
offset += 4;
|
||||
} else if (addressType === 0x03) {
|
||||
const len = buffer[offset];
|
||||
offset += 1;
|
||||
if (buffer.length < offset + len + 2) return null;
|
||||
address = buffer.subarray(offset, offset + len).toString("utf8");
|
||||
offset += len;
|
||||
} else if (addressType === 0x04) {
|
||||
if (buffer.length < offset + 16 + 2) return null;
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < 16; i += 2) {
|
||||
parts.push(buffer.readUInt16BE(offset + i).toString(16));
|
||||
}
|
||||
address = parts.join(":");
|
||||
offset += 16;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const port = buffer.readUInt16BE(offset);
|
||||
return { address, port, bytesRead: offset + 2 };
|
||||
}
|
||||
|
||||
export function handleSocks5Connect(
|
||||
inbound: Duplex,
|
||||
openOutbound: (host: string, port: number) => Promise<Duplex>,
|
||||
tunnelName: string,
|
||||
): void {
|
||||
let buffer = Buffer.alloc(0);
|
||||
let stage: "greeting" | "connect" | "piping" = "greeting";
|
||||
|
||||
const fail = (code = 0x01) => {
|
||||
if (!inbound.destroyed) {
|
||||
inbound.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
|
||||
inbound.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
const onData = (chunk: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
|
||||
if (stage === "greeting") {
|
||||
if (buffer.length < 2) return;
|
||||
if (buffer[0] !== 0x05) {
|
||||
fail(0x01);
|
||||
return;
|
||||
}
|
||||
const methodsLength = buffer[1];
|
||||
if (buffer.length < 2 + methodsLength) return;
|
||||
inbound.write(Buffer.from([0x05, 0x00]));
|
||||
buffer = buffer.subarray(2 + methodsLength);
|
||||
stage = "connect";
|
||||
}
|
||||
|
||||
if (stage === "connect") {
|
||||
const parsed = parseSocksAddress(buffer);
|
||||
if (!parsed) return;
|
||||
stage = "piping";
|
||||
inbound.off("data", onData);
|
||||
const remainder = buffer.subarray(parsed.bytesRead);
|
||||
openOutbound(parsed.address, parsed.port)
|
||||
.then((outbound) => {
|
||||
inbound.write(
|
||||
Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]),
|
||||
);
|
||||
if (remainder.length > 0) {
|
||||
outbound.write(remainder);
|
||||
}
|
||||
inbound.pipe(outbound).pipe(inbound);
|
||||
inbound.on("error", () => outbound.destroy());
|
||||
outbound.on("error", () => inbound.destroy());
|
||||
})
|
||||
.catch((error) => {
|
||||
tunnelLogger.error("SOCKS5 tunnel connect failed", error, {
|
||||
operation: "managed_tunnel_socks_connect_failed",
|
||||
tunnelName,
|
||||
targetHost: parsed.address,
|
||||
targetPort: parsed.port,
|
||||
});
|
||||
fail(0x05);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
inbound.on("data", onData);
|
||||
inbound.on("error", () => inbound.destroy());
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { Client, type ClientChannel } from "ssh2";
|
||||
import type { Duplex } from "stream";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { tunnelLogger } from "../utils/logger.js";
|
||||
|
||||
export function getManagedTunnelAlgorithms() {
|
||||
return {
|
||||
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: SSH_ALGORITHMS.cipher,
|
||||
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"],
|
||||
};
|
||||
}
|
||||
|
||||
export function applyAuthOptions(
|
||||
connOptions: Record<string, unknown>,
|
||||
credentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authMethod?: string;
|
||||
},
|
||||
): void {
|
||||
if (credentials.authMethod === "key" && credentials.sshKey) {
|
||||
const cleanKey = credentials.sshKey
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
connOptions.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
if (credentials.keyPassword) {
|
||||
connOptions.passphrase = credentials.keyPassword;
|
||||
}
|
||||
if (credentials.keyType && credentials.keyType !== "auto") {
|
||||
connOptions.privateKeyType = credentials.keyType;
|
||||
}
|
||||
} else {
|
||||
connOptions.password = credentials.password;
|
||||
}
|
||||
}
|
||||
|
||||
export function connectClient(
|
||||
connOptions: Record<string, unknown>,
|
||||
tunnelName: string,
|
||||
role: "source" | "endpoint",
|
||||
): Promise<Client> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = new Client();
|
||||
let settled = false;
|
||||
client.once("ready", () => {
|
||||
settled = true;
|
||||
resolve(client);
|
||||
});
|
||||
client.once("error", (error) => {
|
||||
if (!settled) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
tunnelLogger.error("Managed tunnel SSH client error", error, {
|
||||
operation: "managed_tunnel_client_error",
|
||||
tunnelName,
|
||||
role,
|
||||
});
|
||||
});
|
||||
client.connect(connOptions);
|
||||
});
|
||||
}
|
||||
|
||||
export function forwardOut(
|
||||
client: Client,
|
||||
targetHost: string,
|
||||
targetPort: number,
|
||||
tunnelName?: string,
|
||||
): Promise<ClientChannel> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream) => {
|
||||
if (err) {
|
||||
if (tunnelName) {
|
||||
tunnelLogger.error("Managed tunnel forwardOut failed", err, {
|
||||
operation: "managed_tunnel_forward_out_failed",
|
||||
tunnelName,
|
||||
targetHost,
|
||||
targetPort,
|
||||
});
|
||||
}
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(stream);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function bindForwardIn(
|
||||
client: Client,
|
||||
bindHost: string,
|
||||
bindPort: number,
|
||||
): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.forwardIn(bindHost, bindPort, (err, actualPort) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(actualPort || bindPort);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function unbindForwardIn(
|
||||
client: Client,
|
||||
bindHost: string,
|
||||
bindPort: number,
|
||||
): void {
|
||||
try {
|
||||
client.unforwardIn(bindHost, bindPort, (err) => {
|
||||
if (err) {
|
||||
tunnelLogger.warn("Failed to unbind managed tunnel listener", {
|
||||
operation: "managed_tunnel_unforward_failed",
|
||||
bindHost,
|
||||
bindPort,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// The connection may already be gone.
|
||||
}
|
||||
}
|
||||
|
||||
export function pipeTunnelStreams(
|
||||
inbound: Duplex,
|
||||
outboundPromise: Promise<Duplex>,
|
||||
tunnelName: string,
|
||||
): void {
|
||||
outboundPromise
|
||||
.then((outbound) => {
|
||||
inbound.pipe(outbound).pipe(inbound);
|
||||
inbound.on("error", () => outbound.destroy());
|
||||
outbound.on("error", () => inbound.destroy());
|
||||
})
|
||||
.catch((error) => {
|
||||
tunnelLogger.error(
|
||||
"Failed to open managed tunnel outbound stream",
|
||||
error,
|
||||
{
|
||||
operation: "managed_tunnel_outbound_failed",
|
||||
tunnelName,
|
||||
},
|
||||
);
|
||||
inbound.destroy();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { ErrorType, TunnelConfig } from "../../types/index.js";
|
||||
import { tunnelLogger } from "../utils/logger.js";
|
||||
|
||||
export function classifyTunnelError(errorMessage: string): ErrorType {
|
||||
if (!errorMessage) return "UNKNOWN";
|
||||
|
||||
const message = errorMessage.toLowerCase();
|
||||
|
||||
if (
|
||||
message.includes("closed by remote host") ||
|
||||
message.includes("connection reset by peer") ||
|
||||
message.includes("connection refused") ||
|
||||
message.includes("broken pipe")
|
||||
) {
|
||||
return "NETWORK_ERROR";
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("authentication failed") ||
|
||||
message.includes("permission denied") ||
|
||||
message.includes("incorrect password")
|
||||
) {
|
||||
return "AUTHENTICATION_FAILED";
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("connect etimedout") ||
|
||||
message.includes("timeout") ||
|
||||
message.includes("timed out") ||
|
||||
message.includes("keepalive timeout")
|
||||
) {
|
||||
return "TIMEOUT";
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("bind: address already in use") ||
|
||||
message.includes("failed for listen port") ||
|
||||
message.includes("port forwarding failed")
|
||||
) {
|
||||
return "CONNECTION_FAILED";
|
||||
}
|
||||
|
||||
if (message.includes("permission") || message.includes("access denied")) {
|
||||
return "CONNECTION_FAILED";
|
||||
}
|
||||
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
export function getTunnelMarker(tunnelName: string): string {
|
||||
return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
||||
}
|
||||
|
||||
export function normalizeTunnelName(
|
||||
hostId: number,
|
||||
tunnelIndex: number,
|
||||
displayName: string,
|
||||
sourcePort: number,
|
||||
endpointHost: string,
|
||||
endpointPort: number,
|
||||
): string {
|
||||
return `${hostId}::${tunnelIndex}::${displayName}::${sourcePort}::${endpointHost}::${endpointPort}`;
|
||||
}
|
||||
|
||||
export function getTunnelMode(
|
||||
tunnelConfig: TunnelConfig,
|
||||
): "local" | "remote" | "dynamic" {
|
||||
return tunnelConfig.mode || tunnelConfig.tunnelType || "remote";
|
||||
}
|
||||
|
||||
export function getTunnelScope(tunnelConfig: TunnelConfig): "s2s" | "c2s" {
|
||||
return tunnelConfig.scope || "s2s";
|
||||
}
|
||||
|
||||
export function getTunnelBindHost(tunnelConfig: TunnelConfig): string {
|
||||
return tunnelConfig.bindHost || "127.0.0.1";
|
||||
}
|
||||
|
||||
export function parseTunnelName(tunnelName: string): {
|
||||
hostId?: number;
|
||||
tunnelIndex?: number;
|
||||
displayName: string;
|
||||
sourcePort: string;
|
||||
endpointHost: string;
|
||||
endpointPort: string;
|
||||
isLegacyFormat: boolean;
|
||||
} {
|
||||
const parts = tunnelName.split("::");
|
||||
|
||||
if (parts.length === 6) {
|
||||
return {
|
||||
hostId: parseInt(parts[0]),
|
||||
tunnelIndex: parseInt(parts[1]),
|
||||
displayName: parts[2],
|
||||
sourcePort: parts[3],
|
||||
endpointHost: parts[4],
|
||||
endpointPort: parts[5],
|
||||
isLegacyFormat: false,
|
||||
};
|
||||
}
|
||||
|
||||
tunnelLogger.warn(`Legacy tunnel name format: ${tunnelName}`);
|
||||
|
||||
const legacyParts = tunnelName.split("_");
|
||||
return {
|
||||
displayName: legacyParts[0] || "unknown",
|
||||
sourcePort: legacyParts[legacyParts.length - 3] || "0",
|
||||
endpointHost: legacyParts[legacyParts.length - 2] || "unknown",
|
||||
endpointPort: legacyParts[legacyParts.length - 1] || "0",
|
||||
isLegacyFormat: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateTunnelConfig(
|
||||
tunnelName: string,
|
||||
tunnelConfig: TunnelConfig,
|
||||
): boolean {
|
||||
const parsed = parseTunnelName(tunnelName);
|
||||
|
||||
if (parsed.isLegacyFormat) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
parsed.hostId === tunnelConfig.sourceHostId &&
|
||||
parsed.tunnelIndex === tunnelConfig.tunnelIndex &&
|
||||
String(parsed.sourcePort) === String(tunnelConfig.sourcePort) &&
|
||||
parsed.endpointHost === tunnelConfig.endpointHost &&
|
||||
String(parsed.endpointPort) === String(tunnelConfig.endpointPort)
|
||||
);
|
||||
}
|
||||
+197
-926
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { toFixedNum, kibToGiB } from "./common-utils.js";
|
||||
|
||||
describe("toFixedNum", () => {
|
||||
it("rounds to the requested digit count", () => {
|
||||
expect(toFixedNum(3.14159, 2)).toBe(3.14);
|
||||
expect(toFixedNum(3.14159, 0)).toBe(3);
|
||||
expect(toFixedNum(2.5, 0)).toBe(3);
|
||||
});
|
||||
|
||||
it("defaults to 2 digits", () => {
|
||||
expect(toFixedNum(1.23456)).toBe(1.23);
|
||||
});
|
||||
|
||||
it("returns null for non-finite or non-number input", () => {
|
||||
expect(toFixedNum(null)).toBeNull();
|
||||
expect(toFixedNum(undefined)).toBeNull();
|
||||
expect(toFixedNum(NaN)).toBeNull();
|
||||
expect(toFixedNum(Infinity)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("kibToGiB", () => {
|
||||
it("converts kibibytes to gibibytes", () => {
|
||||
expect(kibToGiB(1024 * 1024)).toBe(1);
|
||||
expect(kibToGiB(0)).toBe(0);
|
||||
expect(kibToGiB(2 * 1024 * 1024)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseCpuLine } from "./cpu-collector.js";
|
||||
|
||||
describe("parseCpuLine", () => {
|
||||
it("parses a standard /proc/stat cpu line", () => {
|
||||
// user nice system idle iowait irq softirq
|
||||
const result = parseCpuLine("cpu 100 0 50 800 30 0 20");
|
||||
expect(result).toBeDefined();
|
||||
// idle = idle(800) + iowait(30)
|
||||
expect(result?.idle).toBe(830);
|
||||
// total = sum of all fields
|
||||
expect(result?.total).toBe(100 + 0 + 50 + 800 + 30 + 0 + 20);
|
||||
});
|
||||
|
||||
it("tolerates leading/trailing whitespace", () => {
|
||||
const result = parseCpuLine(" cpu 1 2 3 4 ");
|
||||
expect(result?.total).toBe(10);
|
||||
expect(result?.idle).toBe(4);
|
||||
});
|
||||
|
||||
it("returns undefined for non-cpu lines", () => {
|
||||
expect(parseCpuLine("cpu0 1 2 3 4")).toBeUndefined();
|
||||
expect(parseCpuLine("intr 12345")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when there are fewer than 4 numeric fields", () => {
|
||||
expect(parseCpuLine("cpu 1 2 3")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand, toFixedNum } from "./common-utils.js";
|
||||
|
||||
function parseCpuLine(
|
||||
export function parseCpuLine(
|
||||
cpuLine: string,
|
||||
): { total: number; idle: number } | undefined {
|
||||
const parts = cpuLine.trim().split(/\s+/);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import swaggerJSDoc from "swagger-jsdoc";
|
||||
import swaggerJSDoc from "@deadendjs/swagger-jsdoc";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { promises as fs } from "fs";
|
||||
import { systemLogger } from "./utils/logger.js";
|
||||
|
||||
type SwaggerJSDocOptions = Parameters<typeof swaggerJSDoc>[0];
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const swaggerOptions: swaggerJSDoc.Options = {
|
||||
const swaggerOptions: SwaggerJSDocOptions = {
|
||||
definition: {
|
||||
openapi: "3.0.3",
|
||||
info: {
|
||||
@@ -129,7 +131,7 @@ async function generateOpenAPISpec() {
|
||||
operation: "openapi_generate_start",
|
||||
});
|
||||
|
||||
const swaggerSpec = swaggerJSDoc(swaggerOptions);
|
||||
const swaggerSpec = await swaggerJSDoc(swaggerOptions);
|
||||
|
||||
const outputPath = path.join(__dirname, "..", "..", "..", "openapi.json");
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import crypto from "crypto";
|
||||
import { FieldCrypto } from "./field-crypto.js";
|
||||
|
||||
const masterKey = crypto.randomBytes(32);
|
||||
|
||||
describe("FieldCrypto.encryptField / decryptField", () => {
|
||||
it("round-trips a plaintext value", () => {
|
||||
const encrypted = FieldCrypto.encryptField(
|
||||
"s3cr3t-password",
|
||||
masterKey,
|
||||
"record-1",
|
||||
"password",
|
||||
);
|
||||
const decrypted = FieldCrypto.decryptField(
|
||||
encrypted,
|
||||
masterKey,
|
||||
"record-1",
|
||||
"password",
|
||||
);
|
||||
expect(decrypted).toBe("s3cr3t-password");
|
||||
});
|
||||
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(FieldCrypto.encryptField("", masterKey, "r", "f")).toBe("");
|
||||
expect(FieldCrypto.decryptField("", masterKey, "r", "f")).toBe("");
|
||||
});
|
||||
|
||||
it("produces different ciphertext each time (random IV + salt)", () => {
|
||||
const a = FieldCrypto.encryptField("same", masterKey, "r", "f");
|
||||
const b = FieldCrypto.encryptField("same", masterKey, "r", "f");
|
||||
expect(a).not.toBe(b);
|
||||
expect(FieldCrypto.decryptField(a, masterKey, "r", "f")).toBe("same");
|
||||
expect(FieldCrypto.decryptField(b, masterKey, "r", "f")).toBe("same");
|
||||
});
|
||||
|
||||
it("fails to decrypt with the wrong master key", () => {
|
||||
const encrypted = FieldCrypto.encryptField("value", masterKey, "r", "f");
|
||||
const wrongKey = crypto.randomBytes(32);
|
||||
expect(() =>
|
||||
FieldCrypto.decryptField(encrypted, wrongKey, "r", "f"),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("fails to decrypt when the field name context differs", () => {
|
||||
const encrypted = FieldCrypto.encryptField(
|
||||
"value",
|
||||
masterKey,
|
||||
"r",
|
||||
"password",
|
||||
);
|
||||
expect(() =>
|
||||
FieldCrypto.decryptField(encrypted, masterKey, "r", "key"),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("detects tampering with the ciphertext (GCM auth tag)", () => {
|
||||
const encrypted = FieldCrypto.encryptField("value", masterKey, "r", "f");
|
||||
const parsed = JSON.parse(encrypted);
|
||||
// Flip a hex char in the encrypted data.
|
||||
parsed.data = (parsed.data[0] === "a" ? "b" : "a") + parsed.data.slice(1);
|
||||
const tampered = JSON.stringify(parsed);
|
||||
expect(() =>
|
||||
FieldCrypto.decryptField(tampered, masterKey, "r", "f"),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("throws when the encrypted payload is missing recordId context", () => {
|
||||
const encrypted = FieldCrypto.encryptField("value", masterKey, "r", "f");
|
||||
const parsed = JSON.parse(encrypted);
|
||||
delete parsed.recordId;
|
||||
expect(() =>
|
||||
FieldCrypto.decryptField(JSON.stringify(parsed), masterKey, "r", "f"),
|
||||
).toThrow(/recordId/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FieldCrypto.shouldEncryptField", () => {
|
||||
it("identifies encrypted fields per table", () => {
|
||||
expect(FieldCrypto.shouldEncryptField("users", "passwordHash")).toBe(true);
|
||||
expect(FieldCrypto.shouldEncryptField("ssh_data", "password")).toBe(true);
|
||||
expect(
|
||||
FieldCrypto.shouldEncryptField("ssh_credentials", "privateKey"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-encrypted fields and unknown tables", () => {
|
||||
expect(FieldCrypto.shouldEncryptField("users", "username")).toBe(false);
|
||||
expect(FieldCrypto.shouldEncryptField("unknown_table", "password")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { loginRateLimiter } from "./login-rate-limiter.js";
|
||||
|
||||
// The limiter is a shared singleton, so each test uses unique ip/username keys
|
||||
// and resets them to stay isolated.
|
||||
describe("loginRateLimiter login attempts", () => {
|
||||
let ip: string;
|
||||
let username: string;
|
||||
let counter = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
counter += 1;
|
||||
ip = `10.0.0.${counter}`;
|
||||
username = `user${counter}`;
|
||||
loginRateLimiter.resetAttempts(ip, username);
|
||||
});
|
||||
|
||||
it("starts unlocked with the full attempt budget", () => {
|
||||
expect(loginRateLimiter.isLocked(ip, username).locked).toBe(false);
|
||||
expect(loginRateLimiter.getRemainingAttempts(ip, username)).toBe(5);
|
||||
});
|
||||
|
||||
it("decrements remaining attempts on each failure", () => {
|
||||
loginRateLimiter.recordFailedAttempt(ip, username);
|
||||
expect(loginRateLimiter.getRemainingAttempts(ip, username)).toBe(4);
|
||||
loginRateLimiter.recordFailedAttempt(ip, username);
|
||||
expect(loginRateLimiter.getRemainingAttempts(ip, username)).toBe(3);
|
||||
});
|
||||
|
||||
it("locks the account after 5 failed attempts", () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
loginRateLimiter.recordFailedAttempt(ip, username);
|
||||
}
|
||||
const result = loginRateLimiter.isLocked(ip, username);
|
||||
expect(result.locked).toBe(true);
|
||||
expect(result.remainingTime).toBeGreaterThan(0);
|
||||
expect(loginRateLimiter.getRemainingAttempts(ip, username)).toBe(0);
|
||||
});
|
||||
|
||||
it("clears the lock and counters on reset (successful login)", () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
loginRateLimiter.recordFailedAttempt(ip, username);
|
||||
}
|
||||
expect(loginRateLimiter.isLocked(ip, username).locked).toBe(true);
|
||||
|
||||
loginRateLimiter.resetAttempts(ip, username);
|
||||
expect(loginRateLimiter.isLocked(ip, username).locked).toBe(false);
|
||||
expect(loginRateLimiter.getRemainingAttempts(ip, username)).toBe(5);
|
||||
});
|
||||
|
||||
it("locks by IP even without a username", () => {
|
||||
const soloIp = `192.168.1.${counter}`;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
loginRateLimiter.recordFailedAttempt(soloIp);
|
||||
}
|
||||
expect(loginRateLimiter.isLocked(soloIp).locked).toBe(true);
|
||||
loginRateLimiter.resetAttempts(soloIp);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loginRateLimiter TOTP attempts", () => {
|
||||
let userId: string;
|
||||
let counter = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
counter += 1;
|
||||
userId = `totp-user${counter}`;
|
||||
loginRateLimiter.resetTOTPAttempts(userId);
|
||||
});
|
||||
|
||||
it("locks TOTP after 5 failures and resets cleanly", () => {
|
||||
expect(loginRateLimiter.isTOTPLocked(userId).locked).toBe(false);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
loginRateLimiter.recordFailedTOTPAttempt(userId);
|
||||
}
|
||||
expect(loginRateLimiter.isTOTPLocked(userId).locked).toBe(true);
|
||||
expect(loginRateLimiter.getRemainingTOTPAttempts(userId)).toBe(0);
|
||||
|
||||
loginRateLimiter.resetTOTPAttempts(userId);
|
||||
expect(loginRateLimiter.isTOTPLocked(userId).locked).toBe(false);
|
||||
expect(loginRateLimiter.getRemainingTOTPAttempts(userId)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loginRateLimiter password-reset-code attempts", () => {
|
||||
let username: string;
|
||||
let counter = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
counter += 1;
|
||||
username = `reset-user${counter}`;
|
||||
loginRateLimiter.resetResetCodeAttempts(username);
|
||||
});
|
||||
|
||||
it("locks reset codes after 5 failures and resets cleanly", () => {
|
||||
expect(loginRateLimiter.isResetCodeLocked(username).locked).toBe(false);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
loginRateLimiter.recordResetCodeAttempt(username);
|
||||
}
|
||||
expect(loginRateLimiter.isResetCodeLocked(username).locked).toBe(true);
|
||||
|
||||
loginRateLimiter.resetResetCodeAttempts(username);
|
||||
expect(loginRateLimiter.isResetCodeLocked(username).locked).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// permission-manager imports the side-effectful DB barrel and the logger at the
|
||||
// top level. Stub both so importing the module does not spin up the real
|
||||
// database / encryption stack. We then drive hasPermission via a spied
|
||||
// getUserPermissions so we test the wildcard-matching logic in isolation.
|
||||
vi.mock("../database/db/index.js", () => ({ db: {} }));
|
||||
vi.mock("./logger.js", () => ({
|
||||
databaseLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { PermissionManager } = await import("./permission-manager.js");
|
||||
|
||||
type PermissionManagerInstance = ReturnType<
|
||||
typeof PermissionManager.getInstance
|
||||
>;
|
||||
|
||||
describe("PermissionManager.hasPermission wildcard matching", () => {
|
||||
let manager: PermissionManagerInstance;
|
||||
|
||||
function withPermissions(permissions: string[]) {
|
||||
vi.spyOn(manager, "getUserPermissions").mockResolvedValue(permissions);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
manager = PermissionManager.getInstance();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("grants everything for the global wildcard '*'", async () => {
|
||||
withPermissions(["*"]);
|
||||
expect(await manager.hasPermission("u1", "hosts.read")).toBe(true);
|
||||
expect(await manager.hasPermission("u1", "anything.at.all")).toBe(true);
|
||||
});
|
||||
|
||||
it("grants an exact permission match", async () => {
|
||||
withPermissions(["hosts.read", "hosts.write"]);
|
||||
expect(await manager.hasPermission("u1", "hosts.read")).toBe(true);
|
||||
});
|
||||
|
||||
it("grants via a prefix wildcard", async () => {
|
||||
withPermissions(["hosts.*"]);
|
||||
expect(await manager.hasPermission("u1", "hosts.read")).toBe(true);
|
||||
expect(await manager.hasPermission("u1", "hosts.write")).toBe(true);
|
||||
});
|
||||
|
||||
it("grants via a deep prefix wildcard", async () => {
|
||||
withPermissions(["admin.users.*"]);
|
||||
expect(await manager.hasPermission("u1", "admin.users.delete")).toBe(true);
|
||||
});
|
||||
|
||||
it("denies when no exact or wildcard permission matches", async () => {
|
||||
withPermissions(["hosts.read"]);
|
||||
expect(await manager.hasPermission("u1", "hosts.write")).toBe(false);
|
||||
expect(await manager.hasPermission("u1", "credentials.read")).toBe(false);
|
||||
});
|
||||
|
||||
it("denies when the user has no permissions", async () => {
|
||||
withPermissions([]);
|
||||
expect(await manager.hasPermission("u1", "hosts.read")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let a narrower wildcard grant a sibling branch", async () => {
|
||||
withPermissions(["hosts.read.*"]);
|
||||
expect(await manager.hasPermission("u1", "hosts.write")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,39 @@
|
||||
import { SocksClient } from "socks";
|
||||
import type { SocksClientOptions } from "socks";
|
||||
import net from "net";
|
||||
import dns from "dns/promises";
|
||||
import { sshLogger } from "./logger.js";
|
||||
import type { ProxyNode } from "../../types/index.js";
|
||||
|
||||
function isBlockedAddress(ip: string): boolean {
|
||||
if (ip === "0.0.0.0" || ip === "::1" || ip === "::") return true;
|
||||
|
||||
const parts = ip.split(".").map(Number);
|
||||
if (parts.length !== 4) return false;
|
||||
|
||||
if (parts[0] === 127) return true;
|
||||
if (parts[0] === 10) return true;
|
||||
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
||||
if (parts[0] === 192 && parts[1] === 168) return true;
|
||||
if (parts[0] === 169 && parts[1] === 254) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function validateHost(host: string): Promise<void> {
|
||||
if (net.isIP(host)) {
|
||||
if (isBlockedAddress(host)) {
|
||||
throw new Error("Proxy target address is not allowed");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { address } = await dns.lookup(host);
|
||||
if (isBlockedAddress(address)) {
|
||||
throw new Error("Proxy target address is not allowed");
|
||||
}
|
||||
}
|
||||
|
||||
export interface SOCKS5Config {
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
@@ -289,6 +319,17 @@ export async function testProxyConnectivity(options: {
|
||||
testTarget?: { host: string; port: number };
|
||||
}): Promise<{ success: boolean; latencyMs: number }> {
|
||||
const target = options.testTarget ?? { host: "google.com", port: 443 };
|
||||
|
||||
await validateHost(target.host);
|
||||
if (options.singleProxy) {
|
||||
await validateHost(options.singleProxy.host);
|
||||
}
|
||||
if (options.proxyChain) {
|
||||
for (const node of options.proxyChain) {
|
||||
await validateHost(node.host);
|
||||
}
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
let socket: net.Socket | null = null;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import crypto from "crypto";
|
||||
import { createRequire } from "module";
|
||||
import type { ConnectConfig, CipherAlgorithm } from "ssh2";
|
||||
|
||||
const nativeRequire = createRequire(import.meta.url);
|
||||
const availableCiphers = new Set(crypto.getCiphers());
|
||||
|
||||
// Maps SSH cipher names to their OpenSSL equivalents
|
||||
@@ -20,7 +22,7 @@ const SSH_CIPHER_SSL_NAME: Partial<Record<CipherAlgorithm, string>> = {
|
||||
// Check if ssh2's native crypto binding is available (needed for chacha20-poly1305)
|
||||
let ssh2BindingAvailable = false;
|
||||
try {
|
||||
require("ssh2/lib/protocol/crypto/build/Release/sshcrypto.node");
|
||||
nativeRequire("ssh2/lib/protocol/crypto/build/Release/sshcrypto.node");
|
||||
ssh2BindingAvailable = true;
|
||||
} catch {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseSSHKey,
|
||||
parsePublicKey,
|
||||
getFriendlyKeyTypeName,
|
||||
validateKeyPair,
|
||||
} from "./ssh-key-utils.js";
|
||||
|
||||
// A real OpenSSH ed25519 keypair generated solely for these tests. It grants no
|
||||
// access to anything and exists only so the ssh2 parsing path is exercised for
|
||||
// real rather than only via the text-fallback heuristics.
|
||||
const ED25519_PRIVATE = `-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBR/hJLX7eMinS4wJMfG2gWSttUiuSLvqDwVYT53x0qewAAAJihJo4koSaO
|
||||
JAAAAAtzc2gtZWQyNTUxOQAAACBR/hJLX7eMinS4wJMfG2gWSttUiuSLvqDwVYT53x0qew
|
||||
AAAEDLo85Twyg0v6V1zsJaeRaxq9KPQXkqGY0HiJtVMzCXEFH+Ektft4yKdLjAkx8baBZK
|
||||
21SK5Iu+oPBVhPnfHSp7AAAAEHRlc3RAdGVybWl4LnRlc3QBAgMEBQ==
|
||||
-----END OPENSSH PRIVATE KEY-----`;
|
||||
|
||||
const ED25519_PUBLIC =
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFH+Ektft4yKdLjAkx8baBZK21SK5Iu+oPBVhPnfHSp7 test@termix.test";
|
||||
|
||||
describe("parsePublicKey", () => {
|
||||
it("detects ssh-ed25519 public keys", () => {
|
||||
const info = parsePublicKey(ED25519_PUBLIC);
|
||||
expect(info.keyType).toBe("ssh-ed25519");
|
||||
expect(info.success).toBe(true);
|
||||
});
|
||||
|
||||
it("detects ssh-rsa public keys", () => {
|
||||
const info = parsePublicKey("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABFakeData");
|
||||
expect(info.keyType).toBe("ssh-rsa");
|
||||
expect(info.success).toBe(true);
|
||||
});
|
||||
|
||||
it("detects ecdsa public keys", () => {
|
||||
const info = parsePublicKey(
|
||||
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYData",
|
||||
);
|
||||
expect(info.keyType).toBe("ecdsa-sha2-nistp256");
|
||||
});
|
||||
|
||||
it("detects OpenSSH certificate types before plain types", () => {
|
||||
const info = parsePublicKey(
|
||||
"ssh-ed25519-cert-v01@openssh.com AAAAData comment",
|
||||
);
|
||||
expect(info.keyType).toBe("ssh-ed25519-cert-v01@openssh.com");
|
||||
});
|
||||
|
||||
it("returns unknown for unrecognized content", () => {
|
||||
const info = parsePublicKey("not-a-key");
|
||||
expect(info.keyType).toBe("unknown");
|
||||
expect(info.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSSHKey", () => {
|
||||
it("parses a valid ed25519 private key and derives its type", () => {
|
||||
const info = parseSSHKey(ED25519_PRIVATE);
|
||||
expect(info.success).toBe(true);
|
||||
expect(info.keyType).toContain("ed25519");
|
||||
// ssh2 successfully derives the public key for a real OpenSSH key.
|
||||
expect(info.publicKey).toContain("ssh-ed25519");
|
||||
});
|
||||
|
||||
it("reports failure for garbage input", () => {
|
||||
const info = parseSSHKey("definitely not a key");
|
||||
expect(info.success).toBe(false);
|
||||
expect(info.keyType).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFriendlyKeyTypeName", () => {
|
||||
it("maps known key types to friendly names", () => {
|
||||
expect(getFriendlyKeyTypeName("ssh-rsa")).toBe("RSA");
|
||||
expect(getFriendlyKeyTypeName("ssh-ed25519")).toBe("Ed25519");
|
||||
expect(getFriendlyKeyTypeName("ecdsa-sha2-nistp256")).toBe("ECDSA P-256");
|
||||
expect(getFriendlyKeyTypeName("ssh-dss")).toBe("DSA");
|
||||
});
|
||||
|
||||
it("passes unknown types through unchanged", () => {
|
||||
expect(getFriendlyKeyTypeName("some-future-type")).toBe("some-future-type");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateKeyPair", () => {
|
||||
it("validates a genuinely matching ed25519 key pair", () => {
|
||||
const result = validateKeyPair(ED25519_PRIVATE, ED25519_PUBLIC);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.privateKeyType).toContain("ed25519");
|
||||
expect(result.publicKeyType).toBe("ssh-ed25519");
|
||||
});
|
||||
|
||||
it("fails when private and public key types mismatch", () => {
|
||||
const result = validateKeyPair(
|
||||
ED25519_PRIVATE,
|
||||
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABFakeData",
|
||||
);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toMatch(/mismatch|match/i);
|
||||
});
|
||||
|
||||
it("fails when the public key is invalid", () => {
|
||||
const result = validateKeyPair(ED25519_PRIVATE, "garbage");
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("fails when the private key is invalid", () => {
|
||||
const result = validateKeyPair(
|
||||
"garbage",
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAData",
|
||||
);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.error).toMatch(/private key/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Request } from "express";
|
||||
import {
|
||||
detectPlatform,
|
||||
parseUserAgent,
|
||||
generateDeviceFingerprint,
|
||||
} from "./user-agent-parser.js";
|
||||
|
||||
function reqWith(headers: Record<string, string>): Request {
|
||||
return { headers } as unknown as Request;
|
||||
}
|
||||
|
||||
describe("detectPlatform", () => {
|
||||
it("detects desktop from the x-electron-app header", () => {
|
||||
expect(detectPlatform(reqWith({ "x-electron-app": "true" }))).toBe(
|
||||
"desktop",
|
||||
);
|
||||
});
|
||||
|
||||
it("detects desktop from a Termix-Desktop user agent", () => {
|
||||
expect(
|
||||
detectPlatform(reqWith({ "user-agent": "Termix-Desktop/1.0 (Windows)" })),
|
||||
).toBe("desktop");
|
||||
});
|
||||
|
||||
it("detects mobile from a Termix-Mobile user agent", () => {
|
||||
expect(
|
||||
detectPlatform(reqWith({ "user-agent": "Termix-Mobile/Android 1.0" })),
|
||||
).toBe("mobile");
|
||||
});
|
||||
|
||||
it("detects mobile phones/tablets", () => {
|
||||
expect(
|
||||
detectPlatform(reqWith({ "user-agent": "Mozilla/5.0 (iPhone; ...)" })),
|
||||
).toBe("mobile");
|
||||
});
|
||||
|
||||
it("treats Android-on-desktop-OS as web", () => {
|
||||
expect(
|
||||
detectPlatform(
|
||||
reqWith({ "user-agent": "Mozilla/5.0 (X11; Linux x86_64) Android" }),
|
||||
),
|
||||
).toBe("web");
|
||||
});
|
||||
|
||||
it("defaults to web for a desktop browser", () => {
|
||||
expect(
|
||||
detectPlatform(
|
||||
reqWith({
|
||||
"user-agent": "Mozilla/5.0 (Windows NT 10.0) Chrome/120.0",
|
||||
}),
|
||||
),
|
||||
).toBe("web");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseUserAgent", () => {
|
||||
it("parses a Chrome-on-Windows web client", () => {
|
||||
const info = parseUserAgent(
|
||||
reqWith({
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.71 Safari/537.36",
|
||||
}),
|
||||
);
|
||||
expect(info.type).toBe("web");
|
||||
expect(info.browser).toBe("Chrome");
|
||||
expect(info.version).toBe("120.0");
|
||||
expect(info.os).toBe("Windows 10/11");
|
||||
});
|
||||
|
||||
it("parses Edge distinctly from Chrome", () => {
|
||||
const info = parseUserAgent(
|
||||
reqWith({
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0",
|
||||
}),
|
||||
);
|
||||
expect(info.browser).toBe("Edge");
|
||||
});
|
||||
|
||||
it("parses a Termix desktop user agent", () => {
|
||||
const info = parseUserAgent(
|
||||
reqWith({ "user-agent": "Termix-Desktop/2.3.1 (macOS; arm64)" }),
|
||||
);
|
||||
expect(info.type).toBe("desktop");
|
||||
expect(info.browser).toBe("Termix Desktop");
|
||||
expect(info.version).toBe("2.3.1");
|
||||
expect(info.os).toBe("macOS");
|
||||
});
|
||||
|
||||
it("parses an iOS mobile user agent", () => {
|
||||
const info = parseUserAgent(
|
||||
reqWith({ "user-agent": "Termix-Mobile/iOS1.5 (OS 17_2)" }),
|
||||
);
|
||||
expect(info.type).toBe("mobile");
|
||||
expect(info.os).toContain("iOS");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateDeviceFingerprint", () => {
|
||||
it("is stable across minor browser version bumps on web", () => {
|
||||
const a = generateDeviceFingerprint({
|
||||
type: "web",
|
||||
browser: "Chrome",
|
||||
version: "120.5",
|
||||
os: "Windows 10/11",
|
||||
deviceInfo: "Chrome 120.5 on Windows 10/11",
|
||||
});
|
||||
const b = generateDeviceFingerprint({
|
||||
type: "web",
|
||||
browser: "Chrome",
|
||||
version: "120.9",
|
||||
os: "Windows 10/11",
|
||||
deviceInfo: "Chrome 120.9 on Windows 10/11",
|
||||
});
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("differs across major browser versions on web", () => {
|
||||
const a = generateDeviceFingerprint({
|
||||
type: "web",
|
||||
browser: "Chrome",
|
||||
version: "120.0",
|
||||
os: "Windows 10/11",
|
||||
deviceInfo: "",
|
||||
});
|
||||
const b = generateDeviceFingerprint({
|
||||
type: "web",
|
||||
browser: "Chrome",
|
||||
version: "121.0",
|
||||
os: "Windows 10/11",
|
||||
deviceInfo: "",
|
||||
});
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("produces a 64-char hex sha256 digest", () => {
|
||||
const fp = generateDeviceFingerprint({
|
||||
type: "desktop",
|
||||
browser: "Termix Desktop",
|
||||
version: "2.3.1",
|
||||
os: "macOS",
|
||||
deviceInfo: "",
|
||||
});
|
||||
expect(fp).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user