diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 6a176eff..4851ec5c 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -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
diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml
index 186a3e8e..c822dc14 100644
--- a/.github/workflows/electron.yml
+++ b/.github/workflows/electron.yml
@@ -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
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..94853c8b
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -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
diff --git a/.gitignore b/.gitignore
index 5121dc4f..b891f5f8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,8 +7,10 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
+src/mcp-server/node_modules
dist
dist-ssr
+coverage
*.local
.vscode/
diff --git a/.prettierignore b/.prettierignore
index 12156ff6..befe2e1f 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -5,6 +5,7 @@ dist-ssr
release
node_modules
+src/mcp-server/node_modules
package-lock.json
pnpm-lock.yaml
yarn.lock
diff --git a/README.md b/README.md
index fd52640b..60e2548c 100644
--- a/README.md
+++ b/README.md
@@ -80,7 +80,7 @@ Create and manage server-to-server SSH tunnels with automatic reconnection, heal
**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.
|
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
new file mode 100644
index 00000000..9c9b2a48
--- /dev/null
+++ b/RELEASE_NOTES.md
@@ -0,0 +1,55 @@
+
+
+Bug fixes and new features including host-to-host file transfer, OIDC improvements, UI/UX updates, and numerous stability and security patches.
+
+
+
+
+
+https://youtu.be/At8iDk6-Q_s
+
+
+
+
+
+- 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
+
+
+
+
+- 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
+
diff --git a/docker/Dockerfile b/docker/Dockerfile
index bb6236e0..b9a8a149 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -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
diff --git a/docker/nginx.conf b/docker/nginx.conf
index 35d7989d..36846d61 100644
--- a/docker/nginx.conf
+++ b/docker/nginx.conf
@@ -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;
diff --git a/electron/main.cjs b/electron/main.cjs
index 674a5d10..59dedcb3 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -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 =
diff --git a/eslint.config.mjs b/eslint.config.mjs
index a2cce5ee..ee87445c 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -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: [
diff --git a/flatpak/com.karmaa.termix.yml b/flatpak/com.karmaa.termix.yml
index 7b67c0e7..b0cde534 100644
--- a/flatpak/com.karmaa.termix.yml
+++ b/flatpak/com.karmaa.termix.yml
@@ -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
diff --git a/knip.json b/knip.json
new file mode 100644
index 00000000..c6669a43
--- /dev/null
+++ b/knip.json
@@ -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"
+ ]
+}
diff --git a/package-lock.json b/package-lock.json
index 5b667233..1adeec35 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,15 +1,14 @@
{
"name": "termix",
- "version": "2.3.1",
+ "version": "2.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "termix",
- "version": "2.3.1",
+ "version": "2.3.2",
"hasInstallScript": true,
"dependencies": {
- "@tanstack/react-virtual": "^3.13.26",
"axios": "^1.15.2",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.9.0",
@@ -21,7 +20,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",
@@ -33,7 +31,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"
},
@@ -68,6 +65,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",
@@ -87,6 +88,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",
@@ -108,6 +111,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",
@@ -133,17 +137,26 @@
"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"
},
"engines": {
"node": ">=22.12.0",
"npm": ">=11"
}
},
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz",
"integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@types/json-schema": "^7.0.15",
@@ -160,6 +173,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -169,12 +183,14 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
+ "dev": true,
"license": "MIT"
},
"node_modules/@apidevtools/swagger-parser": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz",
"integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"@apidevtools/json-schema-ref-parser": "14.0.1",
@@ -188,6 +204,57 @@
"openapi-types": ">=7"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.1.11",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
+ "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@csstools/css-calc": "^3.2.0",
+ "@csstools/css-color-parser": "^4.1.0",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
+ "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/generational-cache": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
+ "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -458,6 +525,29 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
"node_modules/@codemirror/autocomplete": {
"version": "6.20.2",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz",
@@ -1231,6 +1321,146 @@
}
}
},
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
+ "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
+ "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
+ "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.0.2",
+ "@csstools/css-calc": "^3.2.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz",
+ "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/@deadendjs/swagger-jsdoc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/@deadendjs/swagger-jsdoc/-/swagger-jsdoc-8.2.0.tgz",
@@ -1834,6 +2064,24 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
@@ -2517,6 +2765,7 @@
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
+ "dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
@@ -3172,6 +3421,13 @@
"url": "https://github.com/sponsors/Boshen"
}
},
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -5401,6 +5657,13 @@
"url": "https://ko-fi.com/dangreen"
}
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
@@ -5937,31 +6200,103 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
- "node_modules/@tanstack/react-virtual": {
- "version": "3.13.26",
- "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.26.tgz",
- "integrity": "sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ==",
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
"license": "MIT",
"dependencies": {
- "@tanstack/virtual-core": "3.16.0"
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
},
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/tannerlinsley"
- },
- "peerDependencies": {
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
- "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ "engines": {
+ "node": ">=18"
}
},
- "node_modules/@tanstack/virtual-core": {
- "version": "3.16.0",
- "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.16.0.tgz",
- "integrity": "sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A==",
+ "node_modules/@testing-library/dom/node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
"license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/tannerlinsley"
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@tybys/wasm-util": {
@@ -5975,6 +6310,13 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -5996,6 +6338,17 @@
"@types/node": "*"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -6036,6 +6389,13 @@
"@types/ms": "*"
}
},
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -6123,6 +6483,7 @@
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
@@ -6722,6 +7083,182 @@
}
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz",
+ "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.1.8",
+ "ast-v8-to-istanbul": "^1.0.0",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.2",
+ "obug": "^2.1.1",
+ "std-env": "^4.0.0-rc.1",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.1.8",
+ "vitest": "4.1.8"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
+ "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.8",
+ "@vitest/utils": "4.1.8",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
+ "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.8",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/mocker/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
+ "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
+ "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.8",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
+ "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.8",
+ "@vitest/utils": "4.1.8",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
+ "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/ui": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.8.tgz",
+ "integrity": "sha512-RUS2ZU2TsduVrI+9c12uTNaKrNUTsm6yFt3fueEUB9iKvyC2UP83F+sqIz00HQIah4UOL1TMoDAki8K0NjGvsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.8",
+ "fflate": "^0.8.2",
+ "flatted": "^3.4.2",
+ "pathe": "^2.0.3",
+ "sirv": "^3.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "vitest": "4.1.8"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
+ "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.8",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
@@ -6830,6 +7367,7 @@
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
@@ -6839,6 +7377,7 @@
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -6855,6 +7394,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
"integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
+ "dev": true,
"license": "MIT",
"peerDependencies": {
"ajv": "^8.5.0"
@@ -7102,6 +7642,16 @@
"node": ">=10"
}
},
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/array-ify": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz",
@@ -7129,6 +7679,45 @@
"node": ">=0.8"
}
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz",
+ "integrity": "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/astral-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
@@ -7239,6 +7828,7 @@
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
@@ -7407,6 +7997,16 @@
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
@@ -7444,6 +8044,7 @@
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -7665,6 +8266,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
+ "dev": true,
"license": "MIT"
},
"node_modules/callsites": {
@@ -7722,6 +8324,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@@ -8346,6 +8958,7 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -8356,6 +8969,27 @@
"node": ">= 8"
}
},
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -8373,6 +9007,20 @@
"node": ">=0.10"
}
},
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -8399,6 +9047,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -8633,17 +9288,12 @@
"license": "MIT",
"optional": true
},
- "node_modules/doctrine": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
- "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
- "license": "Apache-2.0",
- "dependencies": {
- "esutils": "^2.0.2"
- },
- "engines": {
- "node": ">=6.0.0"
- }
+ "node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/dot-case": {
"version": "3.0.4",
@@ -9135,6 +9785,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
+ "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
@@ -9499,6 +10156,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
@@ -9538,6 +10196,16 @@
"node": ">=6"
}
},
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/exponential-backoff": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
@@ -9640,6 +10308,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
"license": "MIT"
},
"node_modules/fast-fifo": {
@@ -9666,6 +10335,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
+ "dev": true,
"funding": [
{
"type": "github",
@@ -9720,6 +10390,13 @@
}
}
},
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -9862,6 +10539,7 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
@@ -10417,6 +11095,26 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
@@ -10476,6 +11174,7 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
@@ -10649,6 +11348,16 @@
"node": ">=0.8.19"
}
},
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -10800,6 +11509,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -10829,12 +11545,66 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
"license": "ISC"
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-report/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/jackspeak": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
+ "dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^9.0.0"
@@ -10909,6 +11679,57 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsdom": {
+ "version": "29.1.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
+ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.1.11",
+ "@asamuzakjp/dom-selector": "^7.1.1",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
+ "@exodus/bytes": "^1.15.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.3.5",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.1",
+ "undici": "^7.25.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/lru-cache": {
+ "version": "11.5.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
+ "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -10940,6 +11761,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
@@ -11555,12 +12377,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/lodash.mergewith": {
- "version": "4.6.2",
- "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
- "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==",
- "license": "MIT"
- },
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
@@ -11745,6 +12561,16 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -11755,6 +12581,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magicast": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
+ "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@babel/types": "^7.29.0",
+ "source-map-js": "^1.2.1"
+ }
+ },
"node_modules/make-cancellable-promise": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz",
@@ -11765,6 +12603,22 @@
"url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1"
}
},
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/make-event-props": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz",
@@ -12093,6 +12947,13 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@@ -12799,10 +13660,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
@@ -12827,6 +13699,7 @@
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -12892,6 +13765,16 @@
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
"license": "MIT"
},
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -13157,6 +14040,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -13257,6 +14151,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/pako": {
@@ -13324,6 +14219,32 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5/node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -13346,6 +14267,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -13355,6 +14277,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
+ "dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
@@ -13371,6 +14294,7 @@
"version": "11.5.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz",
"integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==",
+ "dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
@@ -13396,6 +14320,13 @@
"node": ">=8"
}
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
@@ -13588,6 +14519,51 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/pretty-format/node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
@@ -14449,6 +15425,20 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/refractor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
@@ -14549,6 +15539,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -14728,6 +15719,19 @@
"node": ">=11.0.0"
}
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -14859,6 +15863,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -14871,6 +15876,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -14961,10 +15967,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
@@ -15031,6 +16045,21 @@
"node": ">=10"
}
},
+ "node_modules/sirv": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
+ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/slice-ansi": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz",
@@ -15164,6 +16193,13 @@
"nan": "^2.23.0"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/stat-mode": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz",
@@ -15190,6 +16226,13 @@
"node": ">= 0.8"
}
},
+ "node_modules/std-env": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
+ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
@@ -15300,6 +16343,19 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -15376,67 +16432,12 @@
"dev": true,
"license": "MIT"
},
- "node_modules/swagger-jsdoc": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.3.0.tgz",
- "integrity": "sha512-I+iQjVGV3t28pOkQUJv2MncthvOtkEactOn8R76SvSYhxgtIn7FoqfDHwQaN+GBnQdXQLrhgDXseKitmJcHMsA==",
- "license": "MIT",
- "dependencies": {
- "@apidevtools/swagger-parser": "^12.1.0",
- "commander": "6.2.0",
- "doctrine": "3.0.0",
- "glob": "11.1.0",
- "lodash.mergewith": "^4.6.2",
- "yaml": "2.0.0-1"
- },
- "bin": {
- "swagger-jsdoc": "bin/swagger-jsdoc.js"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "node_modules/swagger-jsdoc/node_modules/commander": {
- "version": "6.2.0",
- "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
- "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==",
- "license": "MIT",
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/swagger-jsdoc/node_modules/glob": {
- "version": "11.1.0",
- "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
- "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
- "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
- "license": "BlueOak-1.0.0",
- "dependencies": {
- "foreground-child": "^3.3.1",
- "jackspeak": "^4.1.1",
- "minimatch": "^10.1.1",
- "minipass": "^7.1.2",
- "package-json-from-dist": "^1.0.0",
- "path-scurry": "^2.0.0"
- },
- "bin": {
- "glob": "dist/esm/bin.mjs"
- },
- "engines": {
- "node": "20 || >=22"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/swagger-jsdoc/node_modules/yaml": {
- "version": "2.0.0-1",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz",
- "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==",
- "license": "ISC",
- "engines": {
- "node": ">= 6"
- }
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "3.6.0",
@@ -15579,6 +16580,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tinyexec": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
@@ -15606,6 +16614,36 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.2",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz",
+ "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.2"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.2",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz",
+ "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tmp": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.6.tgz",
@@ -15635,6 +16673,42 @@
"node": ">=0.6"
}
},
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
+ "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
@@ -16205,6 +17279,96 @@
"vite": ">=3.0.0"
}
},
+ "node_modules/vitest": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
+ "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.8",
+ "@vitest/mocker": "4.1.8",
+ "@vitest/pretty-format": "4.1.8",
+ "@vitest/runner": "4.1.8",
+ "@vitest/snapshot": "4.1.8",
+ "@vitest/spy": "4.1.8",
+ "@vitest/utils": "4.1.8",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.8",
+ "@vitest/browser-preview": "4.1.8",
+ "@vitest/browser-webdriverio": "4.1.8",
+ "@vitest/coverage-istanbul": "4.1.8",
+ "@vitest/coverage-v8": "4.1.8",
+ "@vitest/ui": "4.1.8",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
@@ -16222,6 +17386,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
@@ -16232,10 +17409,46 @@
"loose-envify": "^1.0.0"
}
},
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -16253,6 +17466,23 @@
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -16338,6 +17568,16 @@
}
}
},
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
@@ -16348,6 +17588,13 @@
"node": ">=8.0"
}
},
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
diff --git a/package.json b/package.json
index 62889b18..88a8cc0b 100644
--- a/package.json
+++ b/package.json
@@ -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}": [
diff --git a/readme/HOST-TO-HOST-TRANSFER.md b/readme/HOST-TO-HOST-TRANSFER.md
new file mode 100644
index 00000000..d156e4e0
--- /dev/null
+++ b/readme/HOST-TO-HOST-TRANSFER.md
@@ -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).
+
+---
diff --git a/scripts/generate-release-body.cjs b/scripts/generate-release-body.cjs
new file mode 100644
index 00000000..d213015b
--- /dev/null
+++ b/scripts/generate-release-body.cjs
@@ -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\\S]*?)`,
+ );
+ const match = notes.match(pattern);
+ if (!match) {
+ fail(`missing section in release notes`);
+ }
+ const value = match[1].trim();
+ if (!value) {
+ fail(`empty 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 = [
+ ``,
+ `
`,
+ ``,
+ ].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();
diff --git a/scripts/patch-guacamole-lite.cjs b/scripts/patch-guacamole-lite.cjs
index 7d5de1be..f3889153 100644
--- a/scripts/patch-guacamole-lite.cjs
+++ b/scripts/patch-guacamole-lite.cjs
@@ -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",
);
diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts
index 3694bb04..292284ed 100644
--- a/src/backend/database/database.ts
+++ b/src/backend/database/database.ts
@@ -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 {
diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts
index 5a64dcf0..8d50f3de 100644
--- a/src/backend/database/db/index.ts
+++ b/src/backend/database/db/index.ts
@@ -268,6 +268,19 @@ async function initializeCompleteDatabase(): Promise {
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 {
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 {
.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");
diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts
index dc65fbe5..3907e341 100644
--- a/src/backend/database/db/schema.ts
+++ b/src/backend/database/db/schema.ts
@@ -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`),
diff --git a/src/backend/database/routes/credential-deploy-routes.ts b/src/backend/database/routes/credential-deploy-routes.ts
new file mode 100644
index 00000000..40db47e8
--- /dev/null
+++ b/src/backend/database/routes/credential-deploy-routes.ts
@@ -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,
+ 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((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(
+ (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((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(
+ (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 = {
+ 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",
+ });
+ }
+ },
+ );
+}
diff --git a/src/backend/database/routes/credential-key-routes.ts b/src/backend/database/routes/credential-key-routes.ts
new file mode 100644
index 00000000..3ff54967
--- /dev/null
+++ b/src/backend/database/routes/credential-key-routes.ts
@@ -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",
+ });
+ }
+ },
+ );
+}
diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts
index a04fbdc7..c81109b2 100644
--- a/src/backend/database/routes/credentials.ts
+++ b/src/backend/database/routes/credentials.ts
@@ -1,7 +1,4 @@
-import type {
- AuthenticatedRequest,
- CredentialBackend,
-} from "../../../types/index.js";
+import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import {
@@ -15,64 +12,9 @@ import type { Request, Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
import { AuthManager } from "../../utils/auth-manager.js";
-import {
- parseSSHKey,
- parsePublicKey,
- validateKeyPair,
-} from "../../utils/ssh-key-utils.js";
-import crypto from "crypto";
-import ssh2Pkg from "ssh2";
-const { utils: ssh2Utils, Client } = 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",
- };
- }
-}
+import { parseSSHKey } from "../../utils/ssh-key-utils.js";
+import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
+import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js";
const router = express.Router();
@@ -1058,981 +1000,8 @@ router.put(
},
);
-/**
- * @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;
+registerCredentialKeyRoutes(router, authenticateJWT);
- 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",
- });
- }
- },
-);
-
-async function deploySSHKeyToHost(
- hostConfig: Record,
- 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((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(
- (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((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(
- (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 = {
- 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",
- });
- }
- });
-}
-
-/**
- * @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",
- });
- }
- },
-);
+registerCredentialDeployRoutes(router, authenticateJWT);
export default router;
diff --git a/src/backend/database/routes/delete-user-data.ts b/src/backend/database/routes/delete-user-data.ts
new file mode 100644
index 00000000..8e99ef38
--- /dev/null
+++ b/src/backend/database/routes/delete-user-data.ts
@@ -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 {
+ 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;
+ }
+}
diff --git a/src/backend/database/routes/host-autostart-routes.ts b/src/backend/database/routes/host-autostart-routes.ts
new file mode 100644
index 00000000..e4466509
--- /dev/null
+++ b/src/backend/database/routes/host-autostart-routes.ts
@@ -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) => {
+ 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" });
+ }
+ },
+ );
+}
diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts
new file mode 100644
index 00000000..9edd751f
--- /dev/null
+++ b/src/backend/database/routes/host-bulk-routes.ts
@@ -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 = {};
+ 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 | undefined;
+ if (overwrite) {
+ try {
+ const allHosts = await SimpleDBOps.select>(
+ 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 = {
+ 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,
+ });
+ },
+ );
+}
diff --git a/src/backend/database/routes/host-command-history-routes.ts b/src/backend/database/routes/host-command-history-routes.ts
new file mode 100644
index 00000000..d120322b
--- /dev/null
+++ b/src/backend/database/routes/host-command-history-routes.ts
@@ -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" });
+ }
+ },
+ );
+}
diff --git a/src/backend/database/routes/host-file-manager-bookmark-routes.ts b/src/backend/database/routes/host-file-manager-bookmark-routes.ts
new file mode 100644
index 00000000..9260f51e
--- /dev/null
+++ b/src/backend/database/routes/host-file-manager-bookmark-routes.ts
@@ -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" });
+ }
+ },
+ );
+}
diff --git a/src/backend/database/routes/host-folder-routes.ts b/src/backend/database/routes/host-folder-routes.ts
new file mode 100644
index 00000000..2964745e
--- /dev/null
+++ b/src/backend/database/routes/host-folder-routes.ts
@@ -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" });
+ }
+ },
+ );
+}
diff --git a/src/backend/database/routes/host-internal-routes.ts b/src/backend/database/routes/host-internal-routes.ts
new file mode 100644
index 00000000..3bafed6a
--- /dev/null
+++ b/src/backend/database/routes/host-internal-routes.ts
@@ -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) => 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) => 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" });
+ }
+ });
+}
diff --git a/src/backend/database/routes/host-network-routes.ts b/src/backend/database/routes/host-network-routes.ts
new file mode 100644
index 00000000..1d98591e
--- /dev/null
+++ b/src/backend/database/routes/host-network-routes.ts
@@ -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",
+ });
+ }
+ },
+ );
+}
diff --git a/src/backend/database/routes/host-normalizers.test.ts b/src/backend/database/routes/host-normalizers.test.ts
new file mode 100644
index 00000000..4bae8a2f
--- /dev/null
+++ b/src/backend/database/routes/host-normalizers.test.ts
@@ -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);
+ });
+});
diff --git a/src/backend/database/routes/host-normalizers.ts b/src/backend/database/routes/host-normalizers.ts
new file mode 100644
index 00000000..bc2490cf
--- /dev/null
+++ b/src/backend/database/routes/host-normalizers.ts
@@ -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 & {
+ 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,
+): 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,
+): Record {
+ 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,
+): Record {
+ 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,
+ };
+}
diff --git a/src/backend/database/routes/host-opkssh-routes.ts b/src/backend/database/routes/host-opkssh-routes.ts
new file mode 100644
index 00000000..75ab5346
--- /dev/null
+++ b/src/backend/database/routes/host-opkssh-routes.ts
@@ -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:///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 => {
+ 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,
+ });
+
+ 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> =
+ [];
+
+ 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,
+ }),
+ );
+ }
+ },
+ );
+}
diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts
index a758dbba..211ed856 100644
--- a/src/backend/database/routes/host.ts
+++ b/src/backend/database/routes/host.ts
@@ -8,24 +8,14 @@ import {
fileManagerRecent,
fileManagerPinned,
fileManagerShortcuts,
- sshFolders,
+ transferRecent,
commandHistory,
recentActivity,
hostAccess,
userRoles,
sessionRecordings,
} from "../db/schema.js";
-import {
- eq,
- and,
- desc,
- isNotNull,
- or,
- isNull,
- gte,
- sql,
- inArray,
-} from "drizzle-orm";
+import { eq, and, or, isNull, gte, sql, inArray, desc } from "drizzle-orm";
import type { Request, Response } from "express";
import axios from "axios";
import multer from "multer";
@@ -34,10 +24,21 @@ import { SimpleDBOps } from "../../utils/simple-db-ops.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
-import { SystemCrypto } from "../../utils/system-crypto.js";
-import { DatabaseSaveTrigger } from "../db/index.js";
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
-import { sendWakeOnLan, isValidMac } from "../../utils/wake-on-lan.js";
+import {
+ isNonEmptyString,
+ isValidPort,
+ stripSensitiveFields,
+ transformHostResponse,
+} from "./host-normalizers.js";
+import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
+import { registerHostFolderRoutes } from "./host-folder-routes.js";
+import { registerHostFileManagerBookmarkRoutes } from "./host-file-manager-bookmark-routes.js";
+import { registerHostCommandHistoryRoutes } from "./host-command-history-routes.js";
+import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
+import { registerHostInternalRoutes } from "./host-internal-routes.js";
+import { registerHostNetworkRoutes } from "./host-network-routes.js";
+import { registerHostBulkRoutes } from "./host-bulk-routes.js";
const router = express.Router();
@@ -71,451 +72,12 @@ function notifyStatsHostUpdated(
});
}
-function isNonEmptyString(value: unknown): value is string {
- return typeof value === "string" && value.trim().length > 0;
-}
-
-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 [];
-}
-
-type NormalizedImportedHost = Record & {
- 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;
-};
-
-function normalizeImportedHost(
- hostData: Record,
-): 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",
-];
-
-function stripSensitiveFields(
- host: Record,
-): Record {
- const result = { ...host };
- result.hasKey = !!host.key;
- result.hasKeyPassword = !!host.keyPassword;
- for (const field of SENSITIVE_FIELDS) {
- delete result[field];
- }
- return result;
-}
-
-function transformHostResponse(
- host: Record,
-): Record {
- 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,
- };
-}
-
const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
-/**
- * @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) => 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) => 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" });
- }
-});
+registerHostInternalRoutes(router);
/**
* @openapi
@@ -1807,10 +1369,7 @@ router.get(
try {
const data = await SimpleDBOps.select(
- db
- .select()
- .from(hosts)
- .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
+ db.select().from(hosts).where(eq(hosts.id, hostId)),
"ssh_data",
userId,
);
@@ -2228,6 +1787,15 @@ router.delete(
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.hostId, numericHostId));
+ await db
+ .delete(transferRecent)
+ .where(
+ or(
+ eq(transferRecent.sourceHostId, numericHostId),
+ eq(transferRecent.destHostId, numericHostId),
+ ),
+ );
+
await db
.delete(commandHistory)
.where(eq(commandHistory.hostId, numericHostId));
@@ -2289,728 +1857,122 @@ router.delete(
},
);
-/**
- * @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.
- */
+registerHostFileManagerBookmarkRoutes(router, authenticateJWT);
+
router.get(
- "/file_manager/recent",
+ "/transfer/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;
+ const sourceHostIdQuery = Array.isArray(req.query.sourceHostId)
+ ? req.query.sourceHostId[0]
+ : req.query.sourceHostId;
+ const sourceHostId = sourceHostIdQuery
+ ? parseInt(sourceHostIdQuery 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" });
+ if (!sourceHostId) {
+ return res.status(400).json({ error: "Source host ID is required" });
}
try {
- const recentFiles = await db
+ const recent = await db
.select()
- .from(fileManagerRecent)
+ .from(transferRecent)
.where(
and(
- eq(fileManagerRecent.userId, userId),
- eq(fileManagerRecent.hostId, hostId),
+ eq(transferRecent.userId, userId),
+ eq(transferRecent.sourceHostId, sourceHostId),
),
)
- .orderBy(desc(fileManagerRecent.lastOpened))
- .limit(20);
+ .orderBy(desc(transferRecent.lastUsed))
+ .limit(10);
- res.json(recentFiles);
+ res.json(recent);
} catch (err) {
- sshLogger.error("Failed to fetch recent files", err);
- res.status(500).json({ error: "Failed to fetch recent files" });
+ sshLogger.error("Failed to fetch transfer recent destinations", err);
+ res.status(500).json({ error: "Failed to fetch recent destinations" });
}
},
);
-/**
- * @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",
+ "/transfer/recent",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
- const { hostId, path, name } = req.body;
+ const { sourceHostId, destHostId, destPath, destPathLabel } = req.body;
- if (!isNonEmptyString(userId) || !hostId || !path) {
- sshLogger.warn("Invalid data for recent file addition");
+ if (
+ !isNonEmptyString(userId) ||
+ !sourceHostId ||
+ !destHostId ||
+ !destPath
+ ) {
return res.status(400).json({ error: "Invalid data" });
}
try {
const existing = await db
.select()
- .from(fileManagerRecent)
+ .from(transferRecent)
.where(
and(
- eq(fileManagerRecent.userId, userId),
- eq(fileManagerRecent.hostId, hostId),
- eq(fileManagerRecent.path, path),
+ eq(transferRecent.userId, userId),
+ eq(transferRecent.sourceHostId, sourceHostId),
+ eq(transferRecent.destHostId, destHostId),
+ eq(transferRecent.destPath, destPath),
),
);
if (existing.length > 0) {
await db
- .update(fileManagerRecent)
- .set({ lastOpened: new Date().toISOString() })
- .where(eq(fileManagerRecent.id, existing[0].id));
+ .update(transferRecent)
+ .set({ lastUsed: new Date().toISOString() })
+ .where(eq(transferRecent.id, existing[0].id));
} else {
- await db.insert(fileManagerRecent).values({
+ await db.insert(transferRecent).values({
userId,
- hostId,
- path,
- name: name || path.split("/").pop() || "Unknown",
- lastOpened: new Date().toISOString(),
+ sourceHostId,
+ destHostId,
+ destPath,
+ destPathLabel: destPathLabel || destPath,
+ lastUsed: 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
+ const allRecent = await db
.select()
- .from(fileManagerPinned)
+ .from(transferRecent)
.where(
and(
- eq(fileManagerPinned.userId, userId),
- eq(fileManagerPinned.hostId, hostId),
+ eq(transferRecent.userId, userId),
+ eq(transferRecent.sourceHostId, sourceHostId),
),
)
- .orderBy(desc(fileManagerPinned.pinnedAt));
+ .orderBy(desc(transferRecent.lastUsed));
- 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" });
+ if (allRecent.length > 10) {
+ const toDelete = allRecent.slice(10);
+ for (const entry of toDelete) {
+ await db
+ .delete(transferRecent)
+ .where(eq(transferRecent.id, entry.id));
+ }
}
- await db.insert(fileManagerPinned).values({
- userId,
- hostId,
- path,
- name: name || path.split("/").pop() || "Unknown",
- pinnedAt: new Date().toISOString(),
- });
-
- res.json({ message: "File pinned" });
+ res.json({ message: "Recent destination saved" });
} 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" });
- }
- },
-);
-
-/**
- * @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" });
+ sshLogger.error("Failed to save transfer recent destination", err);
+ res.status(500).json({ error: "Failed to save recent destination" });
}
},
);
+registerHostCommandHistoryRoutes(router, authenticateJWT);
async function resolveHostCredentials(
host: Record,
@@ -3040,7 +2002,10 @@ async function resolveHostCredentials(
keyType: sharedCred.keyType,
};
- if (!host.overrideCredentialUsername) {
+ if (
+ !host.overrideCredentialUsername &&
+ isNonEmptyString(sharedCred.username)
+ ) {
resolvedHost.username = sharedCred.username;
}
@@ -3086,7 +2051,10 @@ async function resolveHostCredentials(
keyType: credential.keyType,
};
- if (!host.overrideCredentialUsername) {
+ if (
+ !host.overrideCredentialUsername &&
+ isNonEmptyString(credential.username)
+ ) {
resolvedHost.username = credential.username;
}
@@ -3103,830 +2071,12 @@ async function resolveHostCredentials(
}
}
-/**
- * @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",
+registerHostFolderRoutes(router, {
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" });
- }
+ statsServerUrl: STATS_SERVER_URL,
});
-/**
- * @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(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(
- `${STATS_SERVER_URL}/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" });
- }
- },
-);
-
-/**
- * @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 = {};
- 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 | undefined;
- if (overwrite) {
- try {
- const allHosts = await SimpleDBOps.select>(
- 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 = {
- 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 ?? false,
- 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,
- });
- },
-);
+registerHostBulkRoutes(router, authenticateJWT);
/**
* @openapi
@@ -3988,6 +2138,14 @@ router.delete(
await db
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.hostId, hostId));
+ await db
+ .delete(transferRecent)
+ .where(
+ or(
+ eq(transferRecent.sourceHostId, hostId),
+ eq(transferRecent.destHostId, hostId),
+ ),
+ );
await db
.delete(commandHistory)
.where(eq(commandHistory.hostId, hostId));
@@ -4026,309 +2184,10 @@ router.delete(
},
);
-/**
- * @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",
+registerHostAutostartRoutes(router, {
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) => {
- 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" });
- }
- },
-);
+});
/**
* @openapi
@@ -4481,1220 +2340,11 @@ router.delete(
},
);
-function escapeHtml(value: string): string {
- return value
- .replace(/&/g, "&")
- .replace(//g, ">")
- .replace(/"/g, """)
- .replace(/'/g, "'");
-}
+registerHostOpksshRoutes(router);
-// 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;
-}
-
-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;
-}
-
-interface OpksshErrorPageOptions {
- title: string;
- heading: string;
- message: string;
- details?: string;
- requestId?: string;
- statusCode?: number;
-}
-
-function renderOpksshErrorPage(opts: OpksshErrorPageOptions): string {
- const title = escapeHtml(opts.title);
- const heading = escapeHtml(opts.heading);
- const message = escapeHtml(opts.message);
- const detailsBlock = opts.details
- ? `${escapeHtml(opts.details)}`
- : "";
- const requestIdBlock = opts.requestId
- ? `Request ID: ${escapeHtml(opts.requestId)}
`
- : "";
-
- return `
-
-
- ${title}
-
-
-
-
-
-
${heading}
-
${message}
- ${detailsBlock}
- ${requestIdBlock}
-
-
-`;
-}
-
-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(
- `(]+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 = ``;
-
- if (html.includes("]*>/i, baseTag);
- } else if (html.includes("")) {
- sshLogger.info("Inserting base tag into head", {
- operation: "opkssh_html_rewrite_base_tag_insert",
- requestId,
- basePath,
- });
- html = html.replace(//i, `${baseTag}`);
- } else {
- sshLogger.warn("No tag found, wrapping HTML", {
- operation: "opkssh_html_rewrite_no_head",
- requestId,
- htmlLength: html.length,
- htmlPreview: html.substring(0, 200),
- });
- html = `${baseTag}${html}`;
- }
-
- sshLogger.info("HTML rewrite complete", {
- operation: "opkssh_html_rewrite_complete",
- requestId,
- routePrefix,
- hasBaseTag: html.includes(" {
- 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:///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 => {
- 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,
- });
-
- 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> = [];
-
- 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");
- }
+registerHostNetworkRoutes(router, {
+ authenticateJWT,
+ requireDataAccess,
});
-/**
- * @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,
- }),
- );
- }
- },
-);
-
-/**
- * @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",
- });
- }
- },
-);
-
export default router;
diff --git a/src/backend/database/routes/open-tabs.ts b/src/backend/database/routes/open-tabs.ts
index 24b0df8c..70c9c444 100644
--- a/src/backend/database/routes/open-tabs.ts
+++ b/src/backend/database/routes/open-tabs.ts
@@ -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,35 +85,67 @@ 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 {
- id: string;
- tabType: string;
- hostId?: number | null;
- label: string;
- tabOrder: number;
- backendSessionId?: string | null;
- };
+ const { id, tabType, hostId, label, tabOrder, backendSessionId } =
+ req.body as {
+ id: string;
+ tabType: string;
+ hostId?: number | null;
+ label: string;
+ tabOrder: number;
+ backendSessionId?: string | null;
+ };
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,22 +191,27 @@ 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(
- tabs.map((t) => ({
- id: t.id,
- userId,
- tabType: t.tabType,
- hostId: t.hostId ?? null,
- label: t.label,
- tabOrder: t.tabOrder,
- backendSessionId: t.backendSessionId ?? null,
- updatedAt: now,
- })),
- ).run();
+ db.insert(userOpenTabs)
+ .values(
+ tabs.map((t) => ({
+ id: t.id,
+ userId,
+ tabType: t.tabType,
+ hostId: t.hostId ?? null,
+ label: t.label,
+ tabOrder: t.tabOrder,
+ backendSessionId: t.backendSessionId ?? null,
+ updatedAt: now,
+ })),
+ )
+ .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,24 +332,31 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
* createdAt:
* type: number
*/
-router.get("/active-sessions", authenticateJWT, async (req: Request, res: Response) => {
- const userId = (req as AuthenticatedRequest).userId;
- try {
- const sessions = sessionManager.getUserSessions(userId);
- return res.json(
- sessions.map((s) => ({
- sessionId: s.id,
- hostId: s.hostId,
- hostName: s.hostName,
- tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
- isConnected: s.isConnected,
- createdAt: s.createdAt,
- })),
- );
- } catch (e) {
- databaseLogger.error("Failed to get active sessions", e, { operation: "get_active_sessions", userId });
- return res.status(500).json({ error: "Failed to get active sessions" });
- }
-});
+router.get(
+ "/active-sessions",
+ authenticateJWT,
+ async (req: Request, res: Response) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ try {
+ const sessions = sessionManager.getUserSessions(userId);
+ return res.json(
+ sessions.map((s) => ({
+ sessionId: s.id,
+ hostId: s.hostId,
+ hostName: s.hostName,
+ tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
+ isConnected: s.isConnected,
+ createdAt: s.createdAt,
+ })),
+ );
+ } catch (e) {
+ 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;
diff --git a/src/backend/database/routes/opkssh-html.ts b/src/backend/database/routes/opkssh-html.ts
new file mode 100644
index 00000000..fa94ac3a
--- /dev/null
+++ b/src/backend/database/routes/opkssh-html.ts
@@ -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, "'");
+}
+
+// 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
+ ? `${escapeHtml(opts.details)}`
+ : "";
+ const requestIdBlock = opts.requestId
+ ? `Request ID: ${escapeHtml(opts.requestId)}
`
+ : "";
+
+ return `
+
+
+ ${title}
+
+
+
+
+
+
${heading}
+
${message}
+ ${detailsBlock}
+ ${requestIdBlock}
+
+
+`;
+}
+
+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(
+ `(]+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 = ``;
+
+ if (html.includes("]*>/i, baseTag);
+ } else if (html.includes("")) {
+ sshLogger.info("Inserting base tag into head", {
+ operation: "opkssh_html_rewrite_base_tag_insert",
+ requestId,
+ basePath,
+ });
+ html = html.replace(//i, `${baseTag}`);
+ } else {
+ sshLogger.warn("No tag found, wrapping HTML", {
+ operation: "opkssh_html_rewrite_no_head",
+ requestId,
+ htmlLength: html.length,
+ htmlPreview: html.substring(0, 200),
+ });
+ html = `${baseTag}${html}`;
+ }
+
+ sshLogger.info("HTML rewrite complete", {
+ operation: "opkssh_html_rewrite_complete",
+ requestId,
+ routePrefix,
+ hasBaseTag: html.includes(" 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" });
+ }
+ });
+}
diff --git a/src/backend/database/routes/user-api-key-routes.ts b/src/backend/database/routes/user-api-key-routes.ts
new file mode 100644
index 00000000..f683b0bf
--- /dev/null
+++ b/src/backend/database/routes/user-api-key-routes.ts
@@ -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" });
+ }
+ });
+}
diff --git a/src/backend/database/routes/user-data-access-routes.ts b/src/backend/database/routes/user-data-access-routes.ts
new file mode 100644
index 00000000..7240a05e
--- /dev/null
+++ b/src/backend/database/routes/user-data-access-routes.ts
@@ -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" });
+ }
+ });
+}
diff --git a/src/backend/database/routes/user-oidc-account-routes.ts b/src/backend/database/routes/user-oidc-account-routes.ts
new file mode 100644
index 00000000..5151a1e7
--- /dev/null
+++ b/src/backend/database/routes/user-oidc-account-routes.ts
@@ -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",
+ });
+ }
+ },
+ );
+}
diff --git a/src/backend/database/routes/user-oidc-utils.test.ts b/src/backend/database/routes/user-oidc-utils.test.ts
new file mode 100644
index 00000000..ab9455f9
--- /dev/null
+++ b/src/backend/database/routes/user-oidc-utils.test.ts
@@ -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 = {};
+
+ 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");
+ });
+});
diff --git a/src/backend/database/routes/user-oidc-utils.ts b/src/backend/database/routes/user-oidc-utils.ts
new file mode 100644
index 00000000..d3a887e3
--- /dev/null
+++ b/src/backend/database/routes/user-oidc-utils.ts
@@ -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> {
+ 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 | null = null;
+
+ for (const url of jwksUrls) {
+ try {
+ const response = await fetch(url);
+ if (response.ok) {
+ const jwksData = (await response.json()) as Record;
+ 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) => key.kid === keyId,
+ );
+ if (!publicKey) {
+ throw new Error(
+ `No matching public key found for key ID: ${keyId}. Available keys: ${jwks.keys.map((k: Record) => 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;
+}
diff --git a/src/backend/database/routes/user-password-reset-routes.ts b/src/backend/database/routes/user-password-reset-routes.ts
new file mode 100644
index 00000000..c7257e04
--- /dev/null
+++ b/src/backend/database/routes/user-password-reset-routes.ts
@@ -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).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).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" });
+ }
+ });
+}
diff --git a/src/backend/database/routes/user-preferences.ts b/src/backend/database/routes/user-preferences.ts
index 0f10bbcd..203418ff 100644
--- a/src/backend/database/routes/user-preferences.ts
+++ b/src/backend/database/routes/user-preferences.ts
@@ -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;
+ };
- if (typeof reopenTabsOnLogin !== "boolean") {
- return res.status(400).json({ error: "reopenTabsOnLogin must be a boolean" });
+ const updates: Partial = {
+ updatedAt: new Date().toISOString(),
+ };
+
+ if (reopenTabsOnLogin !== undefined) {
+ if (typeof reopenTabsOnLogin !== "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({
- userId,
- reopenTabsOnLogin,
- updatedAt: new Date().toISOString(),
- }).run();
+ db.insert(userPreferences)
+ .values({
+ userId,
+ ...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" });
}
});
diff --git a/src/backend/database/routes/user-session-routes.ts b/src/backend/database/routes/user-session-routes.ts
new file mode 100644
index 00000000..3726ffa0
--- /dev/null
+++ b/src/backend/database/routes/user-session-routes.ts
@@ -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" });
+ }
+ });
+}
diff --git a/src/backend/database/routes/user-settings-routes.ts b/src/backend/database/routes/user-settings-routes.ts
new file mode 100644
index 00000000..0cd37274
--- /dev/null
+++ b/src/backend/database/routes/user-settings-routes.ts
@@ -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" });
+ }
+ });
+}
diff --git a/src/backend/database/routes/user-totp-routes.ts b/src/backend/database/routes/user-totp-routes.ts
new file mode 100644
index 00000000..71afae13
--- /dev/null
+++ b/src/backend/database/routes/user-totp-routes.ts
@@ -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 = {
+ 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" });
+ }
+ });
+}
diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts
index 64f3ee1d..4a5a6b14 100644
--- a/src/backend/database/routes/users.ts
+++ b/src/backend/database/routes/users.ts
@@ -1,225 +1,37 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
-import { restartGuacServer } from "../../guacamole/guacamole-server.js";
-import { setGlobalLogLevel, getGlobalLogLevel } from "../../utils/logger.js";
-import crypto from "crypto";
import { db } from "../db/index.js";
-import {
- users,
- sessions,
- trustedDevices,
- hosts,
- sshCredentials,
- fileManagerRecent,
- fileManagerPinned,
- fileManagerShortcuts,
- dismissedAlerts,
- settings,
- sshCredentialUsage,
- recentActivity,
- snippets,
- snippetFolders,
- sshFolders,
- commandHistory,
- roles,
- userRoles,
- hostAccess,
- sharedCredentials,
- auditLogs,
- sessionRecordings,
- networkTopology,
- dashboardPreferences,
- opksshTokens,
- apiKeys,
- userOpenTabs,
- userPreferences,
-} from "../db/schema.js";
+import { users, settings, roles, userRoles } from "../db/schema.js";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
-import speakeasy from "speakeasy";
-import QRCode from "qrcode";
import type { Request, Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
-import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
import {
parseUserAgent,
generateDeviceFingerprint,
} from "../../utils/user-agent-parser.js";
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
import { getRequestOriginWithForceHTTPS } from "../../utils/request-origin.js";
+import { deleteUserAndRelatedData } from "./delete-user-data.js";
+import {
+ getOIDCConfigFromEnv,
+ isOIDCUserAllowed,
+ verifyOIDCToken,
+} from "./user-oidc-utils.js";
+import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
+import { registerUserSettingsRoutes } from "./user-settings-routes.js";
+import { registerUserTotpRoutes } from "./user-totp-routes.js";
+import { registerUserSessionRoutes } from "./user-session-routes.js";
+import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js";
+import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
+import { registerUserAdminRoutes } from "./user-admin-routes.js";
+import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
const authManager = AuthManager.getInstance();
-function getOIDCConfigFromEnv(): {
- 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;
-} | 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 || "",
- };
-}
-
-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;
-}
-
-async function verifyOIDCToken(
- idToken: string,
- issuerUrl: string,
- clientId: string,
-): Promise> {
- 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 | null = null;
-
- for (const url of jwksUrls) {
- try {
- const response = await fetch(url);
- if (response.ok) {
- const jwksData = (await response.json()) as Record;
- 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) => key.kid === keyId,
- );
- if (!publicKey) {
- throw new Error(
- `No matching public key found for key ID: ${keyId}. Available keys: ${jwks.keys.map((k: Record) => 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;
-}
-
const router = express.Router();
function isNonEmptyString(val: unknown): val is string {
@@ -236,78 +48,6 @@ function isNativeAppRequest(req: Request): boolean {
const authenticateJWT = authManager.createAuthMiddleware();
const requireAdmin = authManager.createAdminMiddleware();
-async function deleteUserAndRelatedData(userId: string): Promise {
- 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(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;
- }
-}
-
/**
* @openapi
* /users/create:
@@ -390,33 +130,39 @@ router.post("/create", async (req, res) => {
return res.status(409).json({ error: "Username already exists" });
}
- let isFirstUser = false;
- const countResult = db.$client
- .prepare("SELECT COUNT(*) as count FROM users")
- .get();
- isFirstUser = ((countResult as { count?: number })?.count || 0) === 0;
-
const saltRounds = parseInt(process.env.SALT || "10", 10);
const password_hash = await bcrypt.hash(password, saltRounds);
const id = nanoid();
- await db.insert(users).values({
- id,
- username,
- passwordHash: password_hash,
- isAdmin: isFirstUser,
- isOidc: false,
- clientId: "",
- clientSecret: "",
- issuerUrl: "",
- authorizationUrl: "",
- tokenUrl: "",
- identifierPath: "",
- namePath: "",
- scopes: "openid email profile",
- totpSecret: null,
- totpEnabled: false,
- totpBackupCodes: null,
- });
+
+ const isFirstUser = db.$client.transaction(() => {
+ const countResult = db.$client
+ .prepare("SELECT COUNT(*) as count FROM users")
+ .get() as { count?: number };
+ const first = (countResult?.count || 0) === 0;
+ db.$client
+ .prepare(
+ "INSERT INTO users (id, username, password_hash, is_admin, is_oidc, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes, totp_secret, totp_enabled, totp_backup_codes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ )
+ .run(
+ id,
+ username,
+ password_hash,
+ first ? 1 : 0,
+ 0,
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "openid email profile",
+ null,
+ 0,
+ null,
+ );
+ return first;
+ })();
try {
const defaultRoleName = isFirstUser ? "admin" : "user";
@@ -823,7 +569,7 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
*/
router.get("/oidc/authorize", async (req, res) => {
try {
- const { rememberMe, desktopCallbackPort } = req.query;
+ const { rememberMe, desktopCallbackPort, appCallbackUrl } = req.query;
const origin = getRequestOriginWithForceHTTPS(req);
const backendCallbackUri = `${origin}/users/oidc/callback`;
@@ -848,6 +594,17 @@ router.get("/oidc/authorize", async (req, res) => {
let frontendOrigin;
if (desktopCallbackPort) {
frontendOrigin = `http://127.0.0.1:${desktopCallbackPort}/oidc-callback`;
+ } else if (typeof appCallbackUrl === "string" && appCallbackUrl) {
+ let callbackUrl: URL;
+ try {
+ callbackUrl = new URL(appCallbackUrl);
+ } catch {
+ return res.status(400).json({ error: "Invalid app callback URL" });
+ }
+ if (callbackUrl.protocol !== "termix-mobile:") {
+ return res.status(400).json({ error: "Unsupported app callback URL" });
+ }
+ frontendOrigin = callbackUrl.toString();
} else if (referer) {
const refererUrl = new URL(referer);
frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`;
@@ -1151,10 +908,10 @@ router.get("/oidc/callback", async (req, res) => {
let isFirstUser = false;
if (!user || user.length === 0) {
- const countResult = db.$client
+ const preCheckCount = db.$client
.prepare("SELECT COUNT(*) as count FROM users")
.get();
- isFirstUser = ((countResult as { count?: number })?.count || 0) === 0;
+ isFirstUser = ((preCheckCount as { count?: number })?.count || 0) === 0;
if (!isFirstUser && config.allowed_users) {
const email = userInfo.email as string | undefined;
@@ -1222,22 +979,33 @@ router.get("/oidc/callback", async (req, res) => {
}
const id = nanoid();
- await db.insert(users).values({
- id,
- username: name,
- passwordHash: "",
- isAdmin: isFirstUser,
- isOidc: true,
- oidcIdentifier: identifier,
- clientId: String(config.client_id),
- clientSecret: String(config.client_secret),
- issuerUrl: String(config.issuer_url),
- authorizationUrl: String(config.authorization_url),
- tokenUrl: String(config.token_url),
- identifierPath: String(config.identifier_path),
- namePath: String(config.name_path),
- scopes: String(config.scopes),
- });
+ isFirstUser = db.$client.transaction(() => {
+ const countResult = db.$client
+ .prepare("SELECT COUNT(*) as count FROM users")
+ .get() as { count?: number };
+ const first = (countResult?.count || 0) === 0;
+ db.$client
+ .prepare(
+ "INSERT INTO users (id, username, password_hash, is_admin, is_oidc, oidc_identifier, client_id, client_secret, issuer_url, authorization_url, token_url, identifier_path, name_path, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ )
+ .run(
+ id,
+ name,
+ "",
+ first ? 1 : 0,
+ 1,
+ identifier,
+ String(config.client_id),
+ String(config.client_secret),
+ String(config.issuer_url),
+ String(config.authorization_url),
+ String(config.token_url),
+ String(config.identifier_path),
+ String(config.name_path),
+ String(config.scopes),
+ );
+ return first;
+ })();
try {
const defaultRoleName = isFirstUser ? "admin" : "user";
@@ -1389,7 +1157,9 @@ router.get("/oidc/callback", async (req, res) => {
const redirectUrl = new URL(frontendOrigin);
redirectUrl.searchParams.set("success", "true");
- const isDesktopCallback = frontendOrigin.startsWith("http://127.0.0.1:");
+ const isTokenCallback =
+ frontendOrigin.startsWith("http://127.0.0.1:") ||
+ frontendOrigin.startsWith("termix-mobile:");
const maxAge =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
@@ -1400,7 +1170,7 @@ router.get("/oidc/callback", async (req, res) => {
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
- if (isDesktopCallback) {
+ if (isTokenCallback) {
redirectUrl.searchParams.set("token", token);
return res.redirect(redirectUrl.toString());
}
@@ -2239,450 +2009,7 @@ router.delete("/delete-account", authenticateJWT, async (req, res) => {
}
});
-/**
- * @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).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).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" });
- }
-});
+registerUserPasswordResetRoutes(router, { authManager });
/**
* @openapi
@@ -2773,794 +2100,12 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
res.json({ message: "Password changed successfully. Please log in again." });
});
-/**
- * @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,
- passwordHash: users.passwordHash,
- })
- .from(users);
+registerUserAdminRoutes(router, authenticateJWT);
- 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" });
- }
-});
-
-/**
- * @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: "Password or 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 not enabled" });
- }
-
- if (password && !userRecord.isOidc) {
- const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
- if (!isMatch) {
- return res.status(401).json({ error: "Incorrect password" });
- }
- } else if (totp_code) {
- 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" });
- }
- } else {
- return res.status(400).json({ error: "Authentication required" });
- }
-
- 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: "Password or 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 not enabled" });
- }
-
- if (password && !userRecord.isOidc) {
- const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
- if (!isMatch) {
- return res.status(401).json({ error: "Incorrect password" });
- }
- } else if (totp_code) {
- 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" });
- }
- } else {
- return res.status(400).json({ error: "Authentication required" });
- }
-
- 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 = {
- 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" });
- }
+registerUserTotpRoutes(router, {
+ authenticateJWT,
+ authManager,
+ isNativeAppRequest,
});
/**
@@ -3658,1146 +2203,23 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => {
}
});
-/**
- * @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" });
- }
+registerUserDataAccessRoutes(router, {
+ authenticateJWT,
+ authManager,
});
-/**
- * @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" });
- }
+registerUserSessionRoutes(router, {
+ authenticateJWT,
+ authManager,
});
-/**
- * @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" });
- }
+registerUserOidcAccountRoutes(router, {
+ authenticateJWT,
+ authManager,
});
-/**
- * @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;
+registerUserSettingsRoutes(router, authenticateJWT);
- 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" });
- }
-});
-
-/**
- * @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",
- });
- }
-});
-
-/**
- * @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 : "guacd:4822",
- });
- } catch (err) {
- authLogger.error("Failed to get guacamole settings", err);
- res.status(500).json({ error: "Failed to get guacamole settings" });
- }
-});
-
-/**
- * @openapi
- * /users/guacamole-settings:
- * patch:
- * summary: Update Guacamole settings
- * description: Admin-only. Updates guacd enabled status and/or host:port URL.
- * tags:
- * - Users
- * requestBody:
- * required: true
- * content:
- * application/json:
- * schema:
- * type: object
- * properties:
- * enabled:
- * type: boolean
- * url:
- * type: string
- * responses:
- * 200:
- * description: Guacamole settings updated.
- * 403:
- * description: Not authorized.
- * 500:
- * description: Failed to update guacamole settings.
- */
-router.patch("/guacamole-settings", authenticateJWT, async (req, res) => {
- const userId = (req as AuthenticatedRequest).userId;
- try {
- const user = await db.select().from(users).where(eq(users.id, userId));
- if (!user || user.length === 0 || !user[0].isAdmin) {
- return res.status(403).json({ error: "Not authorized" });
- }
- const { enabled, url } = req.body;
- if (typeof enabled === "boolean") {
- db.$client
- .prepare(
- "INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_enabled', ?)",
- )
- .run(enabled ? "true" : "false");
- }
- if (typeof url === "string") {
- db.$client
- .prepare(
- "INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_url', ?)",
- )
- .run(url);
- try {
- await restartGuacServer();
- } catch (err) {
- authLogger.error("Failed to restart guac server after URL update", err);
- }
- }
- const enabledRow = db.$client
- .prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
- .get() as { value: string } | undefined;
- const urlRow = db.$client
- .prepare("SELECT value FROM settings WHERE key = 'guac_url'")
- .get() as { value: string } | undefined;
- res.json({
- enabled: enabledRow ? enabledRow.value !== "false" : true,
- url: urlRow ? urlRow.value : "guacd:4822",
- });
- } catch (err) {
- authLogger.error("Failed to update guacamole settings", err);
- res.status(500).json({ error: "Failed to update guacamole settings" });
- }
-});
-
-/**
- * @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" });
- }
-});
-
-/**
- * @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" });
- }
-});
+registerUserApiKeyRoutes(router, requireAdmin);
export default router;
diff --git a/src/backend/guacamole/routes.ts b/src/backend/guacamole/routes.ts
index b0867f03..b7ac567a 100644
--- a/src/backend/guacamole/routes.ts
+++ b/src/backend/guacamole/routes.ts
@@ -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 = {
rdp: isMigratedNonSsh ? ct === "rdp" : rdpRaw,
vnc: isMigratedNonSsh ? ct === "vnc" : vncRaw,
diff --git a/src/backend/ssh/docker-console.ts b/src/backend/ssh/docker-console.ts
index 4016f8c6..2af5dc14 100644
--- a/src/backend/ssh/docker-console.ts
+++ b/src/backend/ssh/docker-console.ts
@@ -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;
}
diff --git a/src/backend/ssh/docker-container-routes.ts b/src/backend/ssh/docker-container-routes.ts
new file mode 100644
index 00000000..f9c71e50
--- /dev/null
+++ b/src/backend/ssh/docker-container-routes.ts
@@ -0,0 +1,1093 @@
+import type express from "express";
+import { logger } from "../utils/logger.js";
+
+const sshLogger = logger;
+
+type DockerSession = {
+ isConnected: boolean;
+ lastActive: number;
+ activeOperations: number;
+ hostId?: number;
+};
+
+type PendingDockerTotpSession = unknown;
+
+type ExecuteDockerCommand = (
+ session: DockerSession,
+ command: string,
+ sessionId: string,
+ userId: string,
+ hostId?: number,
+) => Promise;
+
+type DockerContainerRoutesDeps = {
+ sshSessions: Record;
+ pendingTOTPSessions: Record;
+ getRequestUserId: (req: express.Request) => string | undefined;
+ executeDockerCommand: ExecuteDockerCommand;
+ dockerTimestampPattern: RegExp;
+};
+
+export function registerDockerContainerRoutes(
+ app: express.Express,
+ {
+ sshSessions,
+ pendingTOTPSessions,
+ getRequestUserId,
+ executeDockerCommand,
+ dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
+ }: DockerContainerRoutesDeps,
+): void {
+ /**
+ * @openapi
+ * /docker/containers/{sessionId}:
+ * get:
+ * summary: List all containers
+ * description: Lists all Docker containers on the host.
+ * tags:
+ * - Docker
+ * parameters:
+ * - in: path
+ * name: sessionId
+ * required: true
+ * schema:
+ * type: string
+ * - in: query
+ * name: all
+ * schema:
+ * type: boolean
+ * responses:
+ * 200:
+ * description: A list of containers.
+ * 400:
+ * description: SSH session not found or not connected.
+ * 500:
+ * description: Failed to list containers.
+ */
+ app.get("/docker/containers/:sessionId", async (req, res) => {
+ const { sessionId } = req.params;
+ const all = req.query.all !== "false";
+ const userId = getRequestUserId(req);
+
+ if (!userId) {
+ return res.status(401).json({ error: "Authentication required" });
+ }
+
+ if (pendingTOTPSessions[sessionId]) {
+ return res.status(400).json({
+ error: "Connection pending authentication",
+ code: "AUTH_PENDING",
+ });
+ }
+
+ const session = sshSessions[sessionId];
+
+ if (!session || !session.isConnected) {
+ return res.status(400).json({
+ error: "SSH session not found or not connected",
+ });
+ }
+
+ session.lastActive = Date.now();
+ session.activeOperations++;
+
+ try {
+ const allFlag = all ? "-a " : "";
+ const command = `docker ps ${allFlag}--format '{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}'`;
+
+ const output = await executeDockerCommand(
+ session,
+ command,
+ sessionId,
+ userId,
+ session.hostId,
+ );
+
+ const containers = output
+ .split("\n")
+ .filter((line) => line.trim())
+ .map((line) => {
+ try {
+ return JSON.parse(line);
+ } catch {
+ sshLogger.warn("Failed to parse container line", {
+ operation: "parse_container",
+ line,
+ });
+ return null;
+ }
+ })
+ .filter((c) => c !== null);
+
+ session.activeOperations--;
+
+ res.json(containers);
+ } catch (error) {
+ session.activeOperations--;
+ sshLogger.error("Failed to list Docker containers", error, {
+ operation: "list_containers",
+ sessionId,
+ userId,
+ });
+
+ res.status(500).json({
+ error:
+ error instanceof Error ? error.message : "Failed to list containers",
+ });
+ }
+ });
+
+ /**
+ * @openapi
+ * /docker/containers/{sessionId}/{containerId}:
+ * get:
+ * summary: Get container details
+ * description: Retrieves detailed information about a specific container.
+ * tags:
+ * - Docker
+ * parameters:
+ * - in: path
+ * name: sessionId
+ * required: true
+ * schema:
+ * type: string
+ * - in: path
+ * name: containerId
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: Container details.
+ * 400:
+ * description: SSH session not found or not connected.
+ * 404:
+ * description: Container not found.
+ * 500:
+ * description: Failed to get container details.
+ */
+ app.get("/docker/containers/:sessionId/:containerId", async (req, res) => {
+ const { sessionId, containerId } = req.params;
+ const userId = getRequestUserId(req);
+
+ if (!userId) {
+ return res.status(401).json({ error: "Authentication required" });
+ }
+
+ const session = sshSessions[sessionId];
+
+ if (!session || !session.isConnected) {
+ return res.status(400).json({
+ error: "SSH session not found or not connected",
+ });
+ }
+
+ session.lastActive = Date.now();
+ session.activeOperations++;
+
+ try {
+ const command = `docker inspect ${containerId}`;
+ const output = await executeDockerCommand(
+ session,
+ command,
+ sessionId,
+ userId,
+ session.hostId,
+ );
+ const details = JSON.parse(output);
+
+ session.activeOperations--;
+
+ if (details && details.length > 0) {
+ res.json(details[0]);
+ } else {
+ res.status(404).json({
+ error: "Container not found",
+ code: "CONTAINER_NOT_FOUND",
+ });
+ }
+ } catch (error) {
+ session.activeOperations--;
+
+ const errorMsg = error instanceof Error ? error.message : "";
+ if (errorMsg.includes("No such container")) {
+ return res.status(404).json({
+ error: "Container not found",
+ code: "CONTAINER_NOT_FOUND",
+ });
+ }
+
+ sshLogger.error("Failed to get container details", error, {
+ operation: "get_container_details",
+ sessionId,
+ containerId,
+ userId,
+ });
+
+ res.status(500).json({
+ error: errorMsg || "Failed to get container details",
+ });
+ }
+ });
+
+ /**
+ * @openapi
+ * /docker/containers/{sessionId}/{containerId}/start:
+ * post:
+ * summary: Start container
+ * description: Starts a specific container.
+ * tags:
+ * - Docker
+ * parameters:
+ * - in: path
+ * name: sessionId
+ * required: true
+ * schema:
+ * type: string
+ * - in: path
+ * name: containerId
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: Container started successfully.
+ * 400:
+ * description: SSH session not found or not connected.
+ * 404:
+ * description: Container not found.
+ * 500:
+ * description: Failed to start container.
+ */
+ app.post(
+ "/docker/containers/:sessionId/:containerId/start",
+ async (req, res) => {
+ const { sessionId, containerId } = req.params;
+ const userId = getRequestUserId(req);
+
+ if (!userId) {
+ return res.status(401).json({ error: "Authentication required" });
+ }
+
+ const session = sshSessions[sessionId];
+
+ if (!session || !session.isConnected) {
+ return res.status(400).json({
+ error: "SSH session not found or not connected",
+ });
+ }
+
+ session.lastActive = Date.now();
+ session.activeOperations++;
+
+ try {
+ sshLogger.info("Docker container operation", {
+ operation: "docker_container_op",
+ sessionId,
+ userId,
+ hostId: session.hostId,
+ containerId,
+ action: "start",
+ });
+ await executeDockerCommand(
+ session,
+ `docker start ${containerId}`,
+ sessionId,
+ userId,
+ session.hostId,
+ );
+
+ session.activeOperations--;
+
+ res.json({
+ success: true,
+ message: "Container started successfully",
+ });
+ } catch (error) {
+ session.activeOperations--;
+
+ const errorMsg = error instanceof Error ? error.message : "";
+ if (errorMsg.includes("No such container")) {
+ return res.status(404).json({
+ success: false,
+ error: "Container not found",
+ code: "CONTAINER_NOT_FOUND",
+ });
+ }
+
+ sshLogger.error("Failed to start container", error, {
+ operation: "start_container",
+ sessionId,
+ containerId,
+ userId,
+ });
+
+ res.status(500).json({
+ success: false,
+ error: errorMsg || "Failed to start container",
+ });
+ }
+ },
+ );
+
+ /**
+ * @openapi
+ * /docker/containers/{sessionId}/{containerId}/stop:
+ * post:
+ * summary: Stop container
+ * description: Stops a specific container.
+ * tags:
+ * - Docker
+ * parameters:
+ * - in: path
+ * name: sessionId
+ * required: true
+ * schema:
+ * type: string
+ * - in: path
+ * name: containerId
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: Container stopped successfully.
+ * 400:
+ * description: SSH session not found or not connected.
+ * 404:
+ * description: Container not found.
+ * 500:
+ * description: Failed to stop container.
+ */
+ app.post(
+ "/docker/containers/:sessionId/:containerId/stop",
+ async (req, res) => {
+ const { sessionId, containerId } = req.params;
+ const userId = getRequestUserId(req);
+
+ if (!userId) {
+ return res.status(401).json({ error: "Authentication required" });
+ }
+
+ const session = sshSessions[sessionId];
+
+ if (!session || !session.isConnected) {
+ return res.status(400).json({
+ error: "SSH session not found or not connected",
+ });
+ }
+
+ session.lastActive = Date.now();
+ session.activeOperations++;
+
+ try {
+ sshLogger.info("Docker container operation", {
+ operation: "docker_container_op",
+ sessionId,
+ userId,
+ hostId: session.hostId,
+ containerId,
+ action: "stop",
+ });
+ await executeDockerCommand(
+ session,
+ `docker stop ${containerId}`,
+ sessionId,
+ userId,
+ session.hostId,
+ );
+
+ session.activeOperations--;
+
+ res.json({
+ success: true,
+ message: "Container stopped successfully",
+ });
+ } catch (error) {
+ session.activeOperations--;
+
+ const errorMsg = error instanceof Error ? error.message : "";
+ if (errorMsg.includes("No such container")) {
+ return res.status(404).json({
+ success: false,
+ error: "Container not found",
+ code: "CONTAINER_NOT_FOUND",
+ });
+ }
+
+ sshLogger.error("Failed to stop container", error, {
+ operation: "stop_container",
+ sessionId,
+ containerId,
+ userId,
+ });
+
+ res.status(500).json({
+ success: false,
+ error: errorMsg || "Failed to stop container",
+ });
+ }
+ },
+ );
+
+ /**
+ * @openapi
+ * /docker/containers/{sessionId}/{containerId}/restart:
+ * post:
+ * summary: Restart container
+ * description: Restarts a specific container.
+ * tags:
+ * - Docker
+ * parameters:
+ * - in: path
+ * name: sessionId
+ * required: true
+ * schema:
+ * type: string
+ * - in: path
+ * name: containerId
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: Container restarted successfully.
+ * 400:
+ * description: SSH session not found or not connected.
+ * 404:
+ * description: Container not found.
+ * 500:
+ * description: Failed to restart container.
+ */
+ app.post(
+ "/docker/containers/:sessionId/:containerId/restart",
+ async (req, res) => {
+ const { sessionId, containerId } = req.params;
+ const userId = getRequestUserId(req);
+
+ if (!userId) {
+ return res.status(401).json({ error: "Authentication required" });
+ }
+
+ const session = sshSessions[sessionId];
+
+ if (!session || !session.isConnected) {
+ return res.status(400).json({
+ error: "SSH session not found or not connected",
+ });
+ }
+
+ session.lastActive = Date.now();
+ session.activeOperations++;
+
+ try {
+ sshLogger.info("Docker container operation", {
+ operation: "docker_container_op",
+ sessionId,
+ userId,
+ hostId: session.hostId,
+ containerId,
+ action: "restart",
+ });
+ await executeDockerCommand(
+ session,
+ `docker restart ${containerId}`,
+ sessionId,
+ userId,
+ session.hostId,
+ );
+
+ session.activeOperations--;
+
+ res.json({
+ success: true,
+ message: "Container restarted successfully",
+ });
+ } catch (error) {
+ session.activeOperations--;
+
+ const errorMsg = error instanceof Error ? error.message : "";
+ if (errorMsg.includes("No such container")) {
+ return res.status(404).json({
+ success: false,
+ error: "Container not found",
+ code: "CONTAINER_NOT_FOUND",
+ });
+ }
+
+ sshLogger.error("Failed to restart container", error, {
+ operation: "restart_container",
+ sessionId,
+ containerId,
+ userId,
+ });
+
+ res.status(500).json({
+ success: false,
+ error: errorMsg || "Failed to restart container",
+ });
+ }
+ },
+ );
+
+ /**
+ * @openapi
+ * /docker/containers/{sessionId}/{containerId}/pause:
+ * post:
+ * summary: Pause container
+ * description: Pauses a specific container.
+ * tags:
+ * - Docker
+ * parameters:
+ * - in: path
+ * name: sessionId
+ * required: true
+ * schema:
+ * type: string
+ * - in: path
+ * name: containerId
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: Container paused successfully.
+ * 400:
+ * description: SSH session not found or not connected.
+ * 404:
+ * description: Container not found.
+ * 500:
+ * description: Failed to pause container.
+ */
+ app.post(
+ "/docker/containers/:sessionId/:containerId/pause",
+ async (req, res) => {
+ const { sessionId, containerId } = req.params;
+ const userId = getRequestUserId(req);
+
+ if (!userId) {
+ return res.status(401).json({ error: "Authentication required" });
+ }
+
+ const session = sshSessions[sessionId];
+
+ if (!session || !session.isConnected) {
+ return res.status(400).json({
+ error: "SSH session not found or not connected",
+ });
+ }
+
+ session.lastActive = Date.now();
+ session.activeOperations++;
+
+ try {
+ sshLogger.info("Docker container operation", {
+ operation: "docker_container_op",
+ sessionId,
+ userId,
+ hostId: session.hostId,
+ containerId,
+ action: "pause",
+ });
+ await executeDockerCommand(
+ session,
+ `docker pause ${containerId}`,
+ sessionId,
+ userId,
+ session.hostId,
+ );
+
+ session.activeOperations--;
+
+ res.json({
+ success: true,
+ message: "Container paused successfully",
+ });
+ } catch (error) {
+ session.activeOperations--;
+
+ const errorMsg = error instanceof Error ? error.message : "";
+ if (errorMsg.includes("No such container")) {
+ return res.status(404).json({
+ success: false,
+ error: "Container not found",
+ code: "CONTAINER_NOT_FOUND",
+ });
+ }
+
+ sshLogger.error("Failed to pause container", error, {
+ operation: "pause_container",
+ sessionId,
+ containerId,
+ userId,
+ });
+
+ res.status(500).json({
+ success: false,
+ error: errorMsg || "Failed to pause container",
+ });
+ }
+ },
+ );
+
+ /**
+ * @openapi
+ * /docker/containers/{sessionId}/{containerId}/unpause:
+ * post:
+ * summary: Unpause container
+ * description: Unpauses a specific container.
+ * tags:
+ * - Docker
+ * parameters:
+ * - in: path
+ * name: sessionId
+ * required: true
+ * schema:
+ * type: string
+ * - in: path
+ * name: containerId
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: Container unpaused successfully.
+ * 400:
+ * description: SSH session not found or not connected.
+ * 404:
+ * description: Container not found.
+ * 500:
+ * description: Failed to unpause container.
+ */
+ app.post(
+ "/docker/containers/:sessionId/:containerId/unpause",
+ async (req, res) => {
+ const { sessionId, containerId } = req.params;
+ const userId = getRequestUserId(req);
+
+ if (!userId) {
+ return res.status(401).json({ error: "Authentication required" });
+ }
+
+ const session = sshSessions[sessionId];
+
+ if (!session || !session.isConnected) {
+ return res.status(400).json({
+ error: "SSH session not found or not connected",
+ });
+ }
+
+ session.lastActive = Date.now();
+ session.activeOperations++;
+
+ try {
+ sshLogger.info("Docker container operation", {
+ operation: "docker_container_op",
+ sessionId,
+ userId,
+ hostId: session.hostId,
+ containerId,
+ action: "unpause",
+ });
+ await executeDockerCommand(
+ session,
+ `docker unpause ${containerId}`,
+ sessionId,
+ userId,
+ session.hostId,
+ );
+
+ session.activeOperations--;
+
+ res.json({
+ success: true,
+ message: "Container unpaused successfully",
+ });
+ } catch (error) {
+ session.activeOperations--;
+
+ const errorMsg = error instanceof Error ? error.message : "";
+ if (errorMsg.includes("No such container")) {
+ return res.status(404).json({
+ success: false,
+ error: "Container not found",
+ code: "CONTAINER_NOT_FOUND",
+ });
+ }
+
+ sshLogger.error("Failed to unpause container", error, {
+ operation: "unpause_container",
+ sessionId,
+ containerId,
+ userId,
+ });
+
+ res.status(500).json({
+ success: false,
+ error: errorMsg || "Failed to unpause container",
+ });
+ }
+ },
+ );
+
+ /**
+ * @openapi
+ * /docker/containers/{sessionId}/{containerId}/remove:
+ * delete:
+ * summary: Remove container
+ * description: Removes a specific container.
+ * tags:
+ * - Docker
+ * parameters:
+ * - in: path
+ * name: sessionId
+ * required: true
+ * schema:
+ * type: string
+ * - in: path
+ * name: containerId
+ * required: true
+ * schema:
+ * type: string
+ * - in: query
+ * name: force
+ * schema:
+ * type: boolean
+ * responses:
+ * 200:
+ * description: Container removed successfully.
+ * 400:
+ * description: SSH session not found or not connected, or cannot remove a running container.
+ * 404:
+ * description: Container not found.
+ * 500:
+ * description: Failed to remove container.
+ */
+ app.delete(
+ "/docker/containers/:sessionId/:containerId/remove",
+ async (req, res) => {
+ const { sessionId, containerId } = req.params;
+ const force = req.query.force === "true";
+ const userId = getRequestUserId(req);
+
+ if (!userId) {
+ return res.status(401).json({ error: "Authentication required" });
+ }
+
+ const session = sshSessions[sessionId];
+
+ if (!session || !session.isConnected) {
+ return res.status(400).json({
+ error: "SSH session not found or not connected",
+ });
+ }
+
+ session.lastActive = Date.now();
+ session.activeOperations++;
+
+ try {
+ sshLogger.info("Docker container operation", {
+ operation: "docker_container_op",
+ sessionId,
+ userId,
+ hostId: session.hostId,
+ containerId,
+ action: "remove",
+ });
+ const forceFlag = force ? "-f " : "";
+ await executeDockerCommand(
+ session,
+ `docker rm ${forceFlag}${containerId}`,
+ sessionId,
+ userId,
+ session.hostId,
+ );
+
+ session.activeOperations--;
+
+ res.json({
+ success: true,
+ message: "Container removed successfully",
+ });
+ } catch (error) {
+ session.activeOperations--;
+
+ const errorMsg = error instanceof Error ? error.message : "";
+ if (errorMsg.includes("No such container")) {
+ return res.status(404).json({
+ success: false,
+ error: "Container not found",
+ code: "CONTAINER_NOT_FOUND",
+ });
+ }
+
+ if (errorMsg.includes("cannot remove a running container")) {
+ return res.status(400).json({
+ success: false,
+ error:
+ "Cannot remove a running container. Stop it first or use force.",
+ code: "CONTAINER_RUNNING",
+ });
+ }
+
+ sshLogger.error("Failed to remove container", error, {
+ operation: "remove_container",
+ sessionId,
+ containerId,
+ userId,
+ });
+
+ res.status(500).json({
+ success: false,
+ error: errorMsg || "Failed to remove container",
+ });
+ }
+ },
+ );
+
+ /**
+ * @openapi
+ * /docker/containers/{sessionId}/{containerId}/logs:
+ * get:
+ * summary: Get container logs
+ * description: Retrieves logs for a specific container.
+ * tags:
+ * - Docker
+ * parameters:
+ * - in: path
+ * name: sessionId
+ * required: true
+ * schema:
+ * type: string
+ * - in: path
+ * name: containerId
+ * required: true
+ * schema:
+ * type: string
+ * - in: query
+ * name: tail
+ * schema:
+ * type: integer
+ * - in: query
+ * name: timestamps
+ * schema:
+ * type: boolean
+ * - in: query
+ * name: since
+ * schema:
+ * type: string
+ * - in: query
+ * name: until
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: Container logs.
+ * 400:
+ * description: SSH session not found or not connected.
+ * 404:
+ * description: Container not found.
+ * 500:
+ * description: Failed to get container logs.
+ */
+ app.get(
+ "/docker/containers/:sessionId/:containerId/logs",
+ async (req, res) => {
+ const { sessionId, containerId } = req.params;
+ const tail = req.query.tail ? parseInt(req.query.tail as string) : 100;
+ const timestamps = req.query.timestamps === "true";
+ const since = req.query.since as string;
+ const until = req.query.until as string;
+ const userId = getRequestUserId(req);
+
+ if (!userId) {
+ return res.status(401).json({ error: "Authentication required" });
+ }
+
+ const session = sshSessions[sessionId];
+
+ if (!session || !session.isConnected) {
+ return res.status(400).json({
+ error: "SSH session not found or not connected",
+ });
+ }
+
+ session.lastActive = Date.now();
+ session.activeOperations++;
+
+ try {
+ let command = `docker logs ${containerId} 2>&1`;
+
+ if (tail && tail > 0) {
+ command += ` --tail ${Math.floor(tail)}`;
+ }
+
+ if (timestamps) {
+ command += " --timestamps";
+ }
+
+ if (since && DOCKER_TIMESTAMP_RE.test(since)) {
+ command += ` --since ${since}`;
+ }
+
+ if (until && DOCKER_TIMESTAMP_RE.test(until)) {
+ command += ` --until ${until}`;
+ }
+
+ const logs = await executeDockerCommand(
+ session,
+ command,
+ sessionId,
+ userId,
+ session.hostId,
+ );
+
+ session.activeOperations--;
+
+ res.json({
+ success: true,
+ logs,
+ });
+ } catch (error) {
+ session.activeOperations--;
+
+ const errorMsg = error instanceof Error ? error.message : "";
+ if (errorMsg.includes("No such container")) {
+ return res.status(404).json({
+ success: false,
+ error: "Container not found",
+ code: "CONTAINER_NOT_FOUND",
+ });
+ }
+
+ sshLogger.error("Failed to get container logs", error, {
+ operation: "get_logs",
+ sessionId,
+ containerId,
+ userId,
+ });
+
+ res.status(500).json({
+ success: false,
+ error: errorMsg || "Failed to get container logs",
+ });
+ }
+ },
+ );
+
+ /**
+ * @openapi
+ * /docker/containers/{sessionId}/{containerId}/stats:
+ * get:
+ * summary: Get container stats
+ * description: Retrieves stats for a specific container.
+ * tags:
+ * - Docker
+ * parameters:
+ * - in: path
+ * name: sessionId
+ * required: true
+ * schema:
+ * type: string
+ * - in: path
+ * name: containerId
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: Container stats.
+ * 400:
+ * description: SSH session not found or not connected.
+ * 404:
+ * description: Container not found.
+ * 500:
+ * description: Failed to get container stats.
+ */
+ app.get(
+ "/docker/containers/:sessionId/:containerId/stats",
+ async (req, res) => {
+ const { sessionId, containerId } = req.params;
+ const userId = getRequestUserId(req);
+
+ if (!userId) {
+ return res.status(401).json({ error: "Authentication required" });
+ }
+
+ const session = sshSessions[sessionId];
+
+ if (!session || !session.isConnected) {
+ return res.status(400).json({
+ error: "SSH session not found or not connected",
+ });
+ }
+
+ session.lastActive = Date.now();
+ session.activeOperations++;
+
+ try {
+ const command = `docker stats ${containerId} --no-stream --format '{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}'`;
+
+ const output = await executeDockerCommand(
+ session,
+ command,
+ sessionId,
+ userId,
+ session.hostId,
+ );
+ const rawStats = JSON.parse(output.trim());
+
+ const memoryParts = rawStats.memory.split(" / ");
+ const memoryUsed = memoryParts[0]?.trim() || "0B";
+ const memoryLimit = memoryParts[1]?.trim() || "0B";
+
+ const netIOParts = rawStats.netIO.split(" / ");
+ const netInput = netIOParts[0]?.trim() || "0B";
+ const netOutput = netIOParts[1]?.trim() || "0B";
+
+ const blockIOParts = rawStats.blockIO.split(" / ");
+ const blockRead = blockIOParts[0]?.trim() || "0B";
+ const blockWrite = blockIOParts[1]?.trim() || "0B";
+
+ const stats = {
+ cpu: rawStats.cpu,
+ memoryUsed,
+ memoryLimit,
+ memoryPercent: rawStats.memoryPercent,
+ netInput,
+ netOutput,
+ blockRead,
+ blockWrite,
+ pids: rawStats.pids,
+ };
+
+ session.activeOperations--;
+
+ res.json(stats);
+ } catch (error) {
+ session.activeOperations--;
+
+ const errorMsg = error instanceof Error ? error.message : "";
+ if (errorMsg.includes("No such container")) {
+ return res.status(404).json({
+ success: false,
+ error: "Container not found",
+ code: "CONTAINER_NOT_FOUND",
+ });
+ }
+
+ sshLogger.error("Failed to get container stats", error, {
+ operation: "get_stats",
+ sessionId,
+ containerId,
+ userId,
+ });
+
+ res.status(500).json({
+ success: false,
+ error: errorMsg || "Failed to get container stats",
+ });
+ }
+ },
+ );
+}
diff --git a/src/backend/ssh/docker.ts b/src/backend/ssh/docker.ts
index c28500b8..dd335f9a 100644
--- a/src/backend/ssh/docker.ts
+++ b/src/backend/ssh/docker.ts
@@ -18,6 +18,7 @@ import {
import type { SSHHost, ProxyNode } from "../../types/index.js";
import type { LogEntry, ConnectionStage } from "../../types/connection-log.js";
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
+import { registerDockerContainerRoutes } from "./docker-container-routes.js";
const sshLogger = logger;
@@ -526,6 +527,10 @@ app.use(authManager.createAuthMiddleware());
const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/;
+function getRequestUserId(req: express.Request): string | undefined {
+ return (req as AuthenticatedRequest).userId;
+}
+
app.param("containerId", (req, res, next, value) => {
if (!CONTAINER_ID_RE.test(value)) {
return res.status(400).json({ error: "Invalid container ID" });
@@ -575,7 +580,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
socks5Password,
socks5ProxyChain,
} = req.body;
- const userId = (req as unknown as { userId: string }).userId;
+ const userId = getRequestUserId(req);
const connectionLogs: Array> = [];
@@ -1629,7 +1634,7 @@ app.post("/docker/ssh/disconnect", async (req, res) => {
*/
app.post("/docker/ssh/connect-totp", async (req, res) => {
const { sessionId, totpCode } = req.body;
- const userId = (req as unknown as { userId: string }).userId;
+ const userId = getRequestUserId(req);
if (!userId) {
sshLogger.error("TOTP verification rejected: no authenticated user", {
@@ -1821,7 +1826,7 @@ app.post("/docker/ssh/connect-totp", async (req, res) => {
*/
app.post("/docker/ssh/connect-warpgate", async (req, res) => {
const { sessionId } = req.body;
- const userId = (req as unknown as { userId: string }).userId;
+ const userId = getRequestUserId(req);
if (!userId) {
sshLogger.error("Warpgate verification rejected: no authenticated user", {
@@ -2004,7 +2009,7 @@ app.post("/docker/ssh/connect-warpgate", async (req, res) => {
*/
app.post("/docker/ssh/keepalive", async (req, res) => {
const { sessionId } = req.body;
- const userId = (req as AuthenticatedRequest).userId;
+ const userId = getRequestUserId(req);
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
@@ -2090,7 +2095,7 @@ app.get("/docker/ssh/status", async (req, res) => {
*/
app.get("/docker/validate/:sessionId", async (req, res) => {
const { sessionId } = req.params;
- const userId = (req as unknown as { userId: string }).userId;
+ const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
@@ -2193,1056 +2198,14 @@ app.get("/docker/validate/:sessionId", async (req, res) => {
}
});
-/**
- * @openapi
- * /docker/containers/{sessionId}:
- * get:
- * summary: List all containers
- * description: Lists all Docker containers on the host.
- * tags:
- * - Docker
- * parameters:
- * - in: path
- * name: sessionId
- * required: true
- * schema:
- * type: string
- * - in: query
- * name: all
- * schema:
- * type: boolean
- * responses:
- * 200:
- * description: A list of containers.
- * 400:
- * description: SSH session not found or not connected.
- * 500:
- * description: Failed to list containers.
- */
-app.get("/docker/containers/:sessionId", async (req, res) => {
- const { sessionId } = req.params;
- const all = req.query.all !== "false";
- const userId = (req as unknown as { userId: string }).userId;
-
- if (!userId) {
- return res.status(401).json({ error: "Authentication required" });
- }
-
- if (pendingTOTPSessions[sessionId]) {
- return res.status(400).json({
- error: "Connection pending authentication",
- code: "AUTH_PENDING",
- });
- }
-
- const session = sshSessions[sessionId];
-
- if (!session || !session.isConnected) {
- return res.status(400).json({
- error: "SSH session not found or not connected",
- });
- }
-
- session.lastActive = Date.now();
- session.activeOperations++;
-
- try {
- const allFlag = all ? "-a " : "";
- const command = `docker ps ${allFlag}--format '{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}'`;
-
- const output = await executeDockerCommand(
- session,
- command,
- sessionId,
- userId,
- session.hostId,
- );
-
- const containers = output
- .split("\n")
- .filter((line) => line.trim())
- .map((line) => {
- try {
- return JSON.parse(line);
- } catch {
- sshLogger.warn("Failed to parse container line", {
- operation: "parse_container",
- line,
- });
- return null;
- }
- })
- .filter((c) => c !== null);
-
- session.activeOperations--;
-
- res.json(containers);
- } catch (error) {
- session.activeOperations--;
- sshLogger.error("Failed to list Docker containers", error, {
- operation: "list_containers",
- sessionId,
- userId,
- });
-
- res.status(500).json({
- error:
- error instanceof Error ? error.message : "Failed to list containers",
- });
- }
+registerDockerContainerRoutes(app, {
+ sshSessions,
+ pendingTOTPSessions,
+ getRequestUserId,
+ executeDockerCommand,
+ dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
});
-/**
- * @openapi
- * /docker/containers/{sessionId}/{containerId}:
- * get:
- * summary: Get container details
- * description: Retrieves detailed information about a specific container.
- * tags:
- * - Docker
- * parameters:
- * - in: path
- * name: sessionId
- * required: true
- * schema:
- * type: string
- * - in: path
- * name: containerId
- * required: true
- * schema:
- * type: string
- * responses:
- * 200:
- * description: Container details.
- * 400:
- * description: SSH session not found or not connected.
- * 404:
- * description: Container not found.
- * 500:
- * description: Failed to get container details.
- */
-app.get("/docker/containers/:sessionId/:containerId", async (req, res) => {
- const { sessionId, containerId } = req.params;
- const userId = (req as unknown as { userId: string }).userId;
-
- if (!userId) {
- return res.status(401).json({ error: "Authentication required" });
- }
-
- const session = sshSessions[sessionId];
-
- if (!session || !session.isConnected) {
- return res.status(400).json({
- error: "SSH session not found or not connected",
- });
- }
-
- session.lastActive = Date.now();
- session.activeOperations++;
-
- try {
- const command = `docker inspect ${containerId}`;
- const output = await executeDockerCommand(
- session,
- command,
- sessionId,
- userId,
- session.hostId,
- );
- const details = JSON.parse(output);
-
- session.activeOperations--;
-
- if (details && details.length > 0) {
- res.json(details[0]);
- } else {
- res.status(404).json({
- error: "Container not found",
- code: "CONTAINER_NOT_FOUND",
- });
- }
- } catch (error) {
- session.activeOperations--;
-
- const errorMsg = error instanceof Error ? error.message : "";
- if (errorMsg.includes("No such container")) {
- return res.status(404).json({
- error: "Container not found",
- code: "CONTAINER_NOT_FOUND",
- });
- }
-
- sshLogger.error("Failed to get container details", error, {
- operation: "get_container_details",
- sessionId,
- containerId,
- userId,
- });
-
- res.status(500).json({
- error: errorMsg || "Failed to get container details",
- });
- }
-});
-
-/**
- * @openapi
- * /docker/containers/{sessionId}/{containerId}/start:
- * post:
- * summary: Start container
- * description: Starts a specific container.
- * tags:
- * - Docker
- * parameters:
- * - in: path
- * name: sessionId
- * required: true
- * schema:
- * type: string
- * - in: path
- * name: containerId
- * required: true
- * schema:
- * type: string
- * responses:
- * 200:
- * description: Container started successfully.
- * 400:
- * description: SSH session not found or not connected.
- * 404:
- * description: Container not found.
- * 500:
- * description: Failed to start container.
- */
-app.post(
- "/docker/containers/:sessionId/:containerId/start",
- async (req, res) => {
- const { sessionId, containerId } = req.params;
- const userId = (req as unknown as { userId: string }).userId;
-
- if (!userId) {
- return res.status(401).json({ error: "Authentication required" });
- }
-
- const session = sshSessions[sessionId];
-
- if (!session || !session.isConnected) {
- return res.status(400).json({
- error: "SSH session not found or not connected",
- });
- }
-
- session.lastActive = Date.now();
- session.activeOperations++;
-
- try {
- sshLogger.info("Docker container operation", {
- operation: "docker_container_op",
- sessionId,
- userId,
- hostId: session.hostId,
- containerId,
- action: "start",
- });
- await executeDockerCommand(
- session,
- `docker start ${containerId}`,
- sessionId,
- userId,
- session.hostId,
- );
-
- session.activeOperations--;
-
- res.json({
- success: true,
- message: "Container started successfully",
- });
- } catch (error) {
- session.activeOperations--;
-
- const errorMsg = error instanceof Error ? error.message : "";
- if (errorMsg.includes("No such container")) {
- return res.status(404).json({
- success: false,
- error: "Container not found",
- code: "CONTAINER_NOT_FOUND",
- });
- }
-
- sshLogger.error("Failed to start container", error, {
- operation: "start_container",
- sessionId,
- containerId,
- userId,
- });
-
- res.status(500).json({
- success: false,
- error: errorMsg || "Failed to start container",
- });
- }
- },
-);
-
-/**
- * @openapi
- * /docker/containers/{sessionId}/{containerId}/stop:
- * post:
- * summary: Stop container
- * description: Stops a specific container.
- * tags:
- * - Docker
- * parameters:
- * - in: path
- * name: sessionId
- * required: true
- * schema:
- * type: string
- * - in: path
- * name: containerId
- * required: true
- * schema:
- * type: string
- * responses:
- * 200:
- * description: Container stopped successfully.
- * 400:
- * description: SSH session not found or not connected.
- * 404:
- * description: Container not found.
- * 500:
- * description: Failed to stop container.
- */
-app.post(
- "/docker/containers/:sessionId/:containerId/stop",
- async (req, res) => {
- const { sessionId, containerId } = req.params;
- const userId = (req as unknown as { userId: string }).userId;
-
- if (!userId) {
- return res.status(401).json({ error: "Authentication required" });
- }
-
- const session = sshSessions[sessionId];
-
- if (!session || !session.isConnected) {
- return res.status(400).json({
- error: "SSH session not found or not connected",
- });
- }
-
- session.lastActive = Date.now();
- session.activeOperations++;
-
- try {
- sshLogger.info("Docker container operation", {
- operation: "docker_container_op",
- sessionId,
- userId,
- hostId: session.hostId,
- containerId,
- action: "stop",
- });
- await executeDockerCommand(
- session,
- `docker stop ${containerId}`,
- sessionId,
- userId,
- session.hostId,
- );
-
- session.activeOperations--;
-
- res.json({
- success: true,
- message: "Container stopped successfully",
- });
- } catch (error) {
- session.activeOperations--;
-
- const errorMsg = error instanceof Error ? error.message : "";
- if (errorMsg.includes("No such container")) {
- return res.status(404).json({
- success: false,
- error: "Container not found",
- code: "CONTAINER_NOT_FOUND",
- });
- }
-
- sshLogger.error("Failed to stop container", error, {
- operation: "stop_container",
- sessionId,
- containerId,
- userId,
- });
-
- res.status(500).json({
- success: false,
- error: errorMsg || "Failed to stop container",
- });
- }
- },
-);
-
-/**
- * @openapi
- * /docker/containers/{sessionId}/{containerId}/restart:
- * post:
- * summary: Restart container
- * description: Restarts a specific container.
- * tags:
- * - Docker
- * parameters:
- * - in: path
- * name: sessionId
- * required: true
- * schema:
- * type: string
- * - in: path
- * name: containerId
- * required: true
- * schema:
- * type: string
- * responses:
- * 200:
- * description: Container restarted successfully.
- * 400:
- * description: SSH session not found or not connected.
- * 404:
- * description: Container not found.
- * 500:
- * description: Failed to restart container.
- */
-app.post(
- "/docker/containers/:sessionId/:containerId/restart",
- async (req, res) => {
- const { sessionId, containerId } = req.params;
- const userId = (req as unknown as { userId: string }).userId;
-
- if (!userId) {
- return res.status(401).json({ error: "Authentication required" });
- }
-
- const session = sshSessions[sessionId];
-
- if (!session || !session.isConnected) {
- return res.status(400).json({
- error: "SSH session not found or not connected",
- });
- }
-
- session.lastActive = Date.now();
- session.activeOperations++;
-
- try {
- sshLogger.info("Docker container operation", {
- operation: "docker_container_op",
- sessionId,
- userId,
- hostId: session.hostId,
- containerId,
- action: "restart",
- });
- await executeDockerCommand(
- session,
- `docker restart ${containerId}`,
- sessionId,
- userId,
- session.hostId,
- );
-
- session.activeOperations--;
-
- res.json({
- success: true,
- message: "Container restarted successfully",
- });
- } catch (error) {
- session.activeOperations--;
-
- const errorMsg = error instanceof Error ? error.message : "";
- if (errorMsg.includes("No such container")) {
- return res.status(404).json({
- success: false,
- error: "Container not found",
- code: "CONTAINER_NOT_FOUND",
- });
- }
-
- sshLogger.error("Failed to restart container", error, {
- operation: "restart_container",
- sessionId,
- containerId,
- userId,
- });
-
- res.status(500).json({
- success: false,
- error: errorMsg || "Failed to restart container",
- });
- }
- },
-);
-
-/**
- * @openapi
- * /docker/containers/{sessionId}/{containerId}/pause:
- * post:
- * summary: Pause container
- * description: Pauses a specific container.
- * tags:
- * - Docker
- * parameters:
- * - in: path
- * name: sessionId
- * required: true
- * schema:
- * type: string
- * - in: path
- * name: containerId
- * required: true
- * schema:
- * type: string
- * responses:
- * 200:
- * description: Container paused successfully.
- * 400:
- * description: SSH session not found or not connected.
- * 404:
- * description: Container not found.
- * 500:
- * description: Failed to pause container.
- */
-app.post(
- "/docker/containers/:sessionId/:containerId/pause",
- async (req, res) => {
- const { sessionId, containerId } = req.params;
- const userId = (req as unknown as { userId: string }).userId;
-
- if (!userId) {
- return res.status(401).json({ error: "Authentication required" });
- }
-
- const session = sshSessions[sessionId];
-
- if (!session || !session.isConnected) {
- return res.status(400).json({
- error: "SSH session not found or not connected",
- });
- }
-
- session.lastActive = Date.now();
- session.activeOperations++;
-
- try {
- sshLogger.info("Docker container operation", {
- operation: "docker_container_op",
- sessionId,
- userId,
- hostId: session.hostId,
- containerId,
- action: "pause",
- });
- await executeDockerCommand(
- session,
- `docker pause ${containerId}`,
- sessionId,
- userId,
- session.hostId,
- );
-
- session.activeOperations--;
-
- res.json({
- success: true,
- message: "Container paused successfully",
- });
- } catch (error) {
- session.activeOperations--;
-
- const errorMsg = error instanceof Error ? error.message : "";
- if (errorMsg.includes("No such container")) {
- return res.status(404).json({
- success: false,
- error: "Container not found",
- code: "CONTAINER_NOT_FOUND",
- });
- }
-
- sshLogger.error("Failed to pause container", error, {
- operation: "pause_container",
- sessionId,
- containerId,
- userId,
- });
-
- res.status(500).json({
- success: false,
- error: errorMsg || "Failed to pause container",
- });
- }
- },
-);
-
-/**
- * @openapi
- * /docker/containers/{sessionId}/{containerId}/unpause:
- * post:
- * summary: Unpause container
- * description: Unpauses a specific container.
- * tags:
- * - Docker
- * parameters:
- * - in: path
- * name: sessionId
- * required: true
- * schema:
- * type: string
- * - in: path
- * name: containerId
- * required: true
- * schema:
- * type: string
- * responses:
- * 200:
- * description: Container unpaused successfully.
- * 400:
- * description: SSH session not found or not connected.
- * 404:
- * description: Container not found.
- * 500:
- * description: Failed to unpause container.
- */
-app.post(
- "/docker/containers/:sessionId/:containerId/unpause",
- async (req, res) => {
- const { sessionId, containerId } = req.params;
- const userId = (req as unknown as { userId: string }).userId;
-
- if (!userId) {
- return res.status(401).json({ error: "Authentication required" });
- }
-
- const session = sshSessions[sessionId];
-
- if (!session || !session.isConnected) {
- return res.status(400).json({
- error: "SSH session not found or not connected",
- });
- }
-
- session.lastActive = Date.now();
- session.activeOperations++;
-
- try {
- sshLogger.info("Docker container operation", {
- operation: "docker_container_op",
- sessionId,
- userId,
- hostId: session.hostId,
- containerId,
- action: "unpause",
- });
- await executeDockerCommand(
- session,
- `docker unpause ${containerId}`,
- sessionId,
- userId,
- session.hostId,
- );
-
- session.activeOperations--;
-
- res.json({
- success: true,
- message: "Container unpaused successfully",
- });
- } catch (error) {
- session.activeOperations--;
-
- const errorMsg = error instanceof Error ? error.message : "";
- if (errorMsg.includes("No such container")) {
- return res.status(404).json({
- success: false,
- error: "Container not found",
- code: "CONTAINER_NOT_FOUND",
- });
- }
-
- sshLogger.error("Failed to unpause container", error, {
- operation: "unpause_container",
- sessionId,
- containerId,
- userId,
- });
-
- res.status(500).json({
- success: false,
- error: errorMsg || "Failed to unpause container",
- });
- }
- },
-);
-
-/**
- * @openapi
- * /docker/containers/{sessionId}/{containerId}/remove:
- * delete:
- * summary: Remove container
- * description: Removes a specific container.
- * tags:
- * - Docker
- * parameters:
- * - in: path
- * name: sessionId
- * required: true
- * schema:
- * type: string
- * - in: path
- * name: containerId
- * required: true
- * schema:
- * type: string
- * - in: query
- * name: force
- * schema:
- * type: boolean
- * responses:
- * 200:
- * description: Container removed successfully.
- * 400:
- * description: SSH session not found or not connected, or cannot remove a running container.
- * 404:
- * description: Container not found.
- * 500:
- * description: Failed to remove container.
- */
-app.delete(
- "/docker/containers/:sessionId/:containerId/remove",
- async (req, res) => {
- const { sessionId, containerId } = req.params;
- const force = req.query.force === "true";
- const userId = (req as unknown as { userId: string }).userId;
-
- if (!userId) {
- return res.status(401).json({ error: "Authentication required" });
- }
-
- const session = sshSessions[sessionId];
-
- if (!session || !session.isConnected) {
- return res.status(400).json({
- error: "SSH session not found or not connected",
- });
- }
-
- session.lastActive = Date.now();
- session.activeOperations++;
-
- try {
- sshLogger.info("Docker container operation", {
- operation: "docker_container_op",
- sessionId,
- userId,
- hostId: session.hostId,
- containerId,
- action: "remove",
- });
- const forceFlag = force ? "-f " : "";
- await executeDockerCommand(
- session,
- `docker rm ${forceFlag}${containerId}`,
- sessionId,
- userId,
- session.hostId,
- );
-
- session.activeOperations--;
-
- res.json({
- success: true,
- message: "Container removed successfully",
- });
- } catch (error) {
- session.activeOperations--;
-
- const errorMsg = error instanceof Error ? error.message : "";
- if (errorMsg.includes("No such container")) {
- return res.status(404).json({
- success: false,
- error: "Container not found",
- code: "CONTAINER_NOT_FOUND",
- });
- }
-
- if (errorMsg.includes("cannot remove a running container")) {
- return res.status(400).json({
- success: false,
- error:
- "Cannot remove a running container. Stop it first or use force.",
- code: "CONTAINER_RUNNING",
- });
- }
-
- sshLogger.error("Failed to remove container", error, {
- operation: "remove_container",
- sessionId,
- containerId,
- userId,
- });
-
- res.status(500).json({
- success: false,
- error: errorMsg || "Failed to remove container",
- });
- }
- },
-);
-
-/**
- * @openapi
- * /docker/containers/{sessionId}/{containerId}/logs:
- * get:
- * summary: Get container logs
- * description: Retrieves logs for a specific container.
- * tags:
- * - Docker
- * parameters:
- * - in: path
- * name: sessionId
- * required: true
- * schema:
- * type: string
- * - in: path
- * name: containerId
- * required: true
- * schema:
- * type: string
- * - in: query
- * name: tail
- * schema:
- * type: integer
- * - in: query
- * name: timestamps
- * schema:
- * type: boolean
- * - in: query
- * name: since
- * schema:
- * type: string
- * - in: query
- * name: until
- * schema:
- * type: string
- * responses:
- * 200:
- * description: Container logs.
- * 400:
- * description: SSH session not found or not connected.
- * 404:
- * description: Container not found.
- * 500:
- * description: Failed to get container logs.
- */
-app.get("/docker/containers/:sessionId/:containerId/logs", async (req, res) => {
- const { sessionId, containerId } = req.params;
- const tail = req.query.tail ? parseInt(req.query.tail as string) : 100;
- const timestamps = req.query.timestamps === "true";
- const since = req.query.since as string;
- const until = req.query.until as string;
- const userId = (req as unknown as { userId: string }).userId;
-
- if (!userId) {
- return res.status(401).json({ error: "Authentication required" });
- }
-
- const session = sshSessions[sessionId];
-
- if (!session || !session.isConnected) {
- return res.status(400).json({
- error: "SSH session not found or not connected",
- });
- }
-
- session.lastActive = Date.now();
- session.activeOperations++;
-
- try {
- let command = `docker logs ${containerId} 2>&1`;
-
- if (tail && tail > 0) {
- command += ` --tail ${Math.floor(tail)}`;
- }
-
- if (timestamps) {
- command += " --timestamps";
- }
-
- if (since && DOCKER_TIMESTAMP_RE.test(since)) {
- command += ` --since ${since}`;
- }
-
- if (until && DOCKER_TIMESTAMP_RE.test(until)) {
- command += ` --until ${until}`;
- }
-
- const logs = await executeDockerCommand(
- session,
- command,
- sessionId,
- userId,
- session.hostId,
- );
-
- session.activeOperations--;
-
- res.json({
- success: true,
- logs,
- });
- } catch (error) {
- session.activeOperations--;
-
- const errorMsg = error instanceof Error ? error.message : "";
- if (errorMsg.includes("No such container")) {
- return res.status(404).json({
- success: false,
- error: "Container not found",
- code: "CONTAINER_NOT_FOUND",
- });
- }
-
- sshLogger.error("Failed to get container logs", error, {
- operation: "get_logs",
- sessionId,
- containerId,
- userId,
- });
-
- res.status(500).json({
- success: false,
- error: errorMsg || "Failed to get container logs",
- });
- }
-});
-
-/**
- * @openapi
- * /docker/containers/{sessionId}/{containerId}/stats:
- * get:
- * summary: Get container stats
- * description: Retrieves stats for a specific container.
- * tags:
- * - Docker
- * parameters:
- * - in: path
- * name: sessionId
- * required: true
- * schema:
- * type: string
- * - in: path
- * name: containerId
- * required: true
- * schema:
- * type: string
- * responses:
- * 200:
- * description: Container stats.
- * 400:
- * description: SSH session not found or not connected.
- * 404:
- * description: Container not found.
- * 500:
- * description: Failed to get container stats.
- */
-app.get(
- "/docker/containers/:sessionId/:containerId/stats",
- async (req, res) => {
- const { sessionId, containerId } = req.params;
- const userId = (req as unknown as { userId: string }).userId;
-
- if (!userId) {
- return res.status(401).json({ error: "Authentication required" });
- }
-
- const session = sshSessions[sessionId];
-
- if (!session || !session.isConnected) {
- return res.status(400).json({
- error: "SSH session not found or not connected",
- });
- }
-
- session.lastActive = Date.now();
- session.activeOperations++;
-
- try {
- const command = `docker stats ${containerId} --no-stream --format '{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}'`;
-
- const output = await executeDockerCommand(
- session,
- command,
- sessionId,
- userId,
- session.hostId,
- );
- const rawStats = JSON.parse(output.trim());
-
- const memoryParts = rawStats.memory.split(" / ");
- const memoryUsed = memoryParts[0]?.trim() || "0B";
- const memoryLimit = memoryParts[1]?.trim() || "0B";
-
- const netIOParts = rawStats.netIO.split(" / ");
- const netInput = netIOParts[0]?.trim() || "0B";
- const netOutput = netIOParts[1]?.trim() || "0B";
-
- const blockIOParts = rawStats.blockIO.split(" / ");
- const blockRead = blockIOParts[0]?.trim() || "0B";
- const blockWrite = blockIOParts[1]?.trim() || "0B";
-
- const stats = {
- cpu: rawStats.cpu,
- memoryUsed,
- memoryLimit,
- memoryPercent: rawStats.memoryPercent,
- netInput,
- netOutput,
- blockRead,
- blockWrite,
- pids: rawStats.pids,
- };
-
- session.activeOperations--;
-
- res.json(stats);
- } catch (error) {
- session.activeOperations--;
-
- const errorMsg = error instanceof Error ? error.message : "";
- if (errorMsg.includes("No such container")) {
- return res.status(404).json({
- success: false,
- error: "Container not found",
- code: "CONTAINER_NOT_FOUND",
- });
- }
-
- sshLogger.error("Failed to get container stats", error, {
- operation: "get_stats",
- sessionId,
- containerId,
- userId,
- });
-
- res.status(500).json({
- success: false,
- error: errorMsg || "Failed to get container stats",
- });
- }
- },
-);
-
const PORT = 30007;
app.listen(PORT, async () => {
diff --git a/src/backend/ssh/file-manager-action-routes.ts b/src/backend/ssh/file-manager-action-routes.ts
new file mode 100644
index 00000000..18a610cb
--- /dev/null
+++ b/src/backend/ssh/file-manager-action-routes.ts
@@ -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;
+ 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" });
+ }
+ });
+ });
+ });
+}
diff --git a/src/backend/ssh/file-manager-content-routes.ts b/src/backend/ssh/file-manager-content-routes.ts
new file mode 100644
index 00000000..0c18f877
--- /dev/null
+++ b/src/backend/ssh/file-manager-content-routes.ts
@@ -0,0 +1,1305 @@
+import type { Express } from "express";
+import type { AuthenticatedRequest } from "../../types/index.js";
+import { fileLogger } from "../utils/logger.js";
+import {
+ execChannel,
+ execWithSudo,
+ execWithSudoBuffer,
+ getSessionSftp,
+ type SSHSession,
+} from "./file-manager-session.js";
+import { detectBinary } from "./file-manager-utils.js";
+
+type FileContentRoutesDeps = {
+ sshSessions: Record;
+ verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
+};
+
+export function registerFileContentRoutes(
+ app: Express,
+ { sshSessions, verifySessionOwnership }: FileContentRoutesDeps,
+): void {
+ /**
+ * @openapi
+ * /ssh/file_manager/ssh/identifySymlink:
+ * get:
+ * summary: Identify symbolic link
+ * description: Identifies the target of a symbolic link.
+ * 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: Symbolic link information.
+ * 400:
+ * description: Missing required parameters or SSH connection not established.
+ * 500:
+ * description: Failed to identify symbolic link.
+ */
+ app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
+ const sessionId = req.query.sessionId as string;
+ const sshConn = sshSessions[sessionId];
+ const linkPath = 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" });
+ }
+
+ if (!linkPath) {
+ return res.status(400).json({ error: "Link path is required" });
+ }
+
+ sshConn.lastActive = Date.now();
+
+ const escapedPath = linkPath.replace(/'/g, "'\"'\"'");
+ const command = `stat -L -c "%F" '${escapedPath}' && readlink -f '${escapedPath}'`;
+
+ execChannel(sshConn, command, (err, stream) => {
+ if (err) {
+ fileLogger.error("SSH identifySymlink 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) {
+ fileLogger.error(
+ `SSH identifySymlink command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
+ );
+ return res
+ .status(500)
+ .json({ error: `Command failed: ${errorData}` });
+ }
+
+ const [fileType, target] = data.trim().split("\n");
+
+ res.json({
+ path: linkPath,
+ target: target,
+ type: fileType.toLowerCase().includes("directory")
+ ? "directory"
+ : "file",
+ });
+ });
+
+ stream.on("error", (streamErr) => {
+ fileLogger.error("SSH identifySymlink stream error:", streamErr);
+ if (!res.headersSent) {
+ res.status(500).json({ error: `Stream error: ${streamErr.message}` });
+ }
+ });
+ });
+ });
+
+ /**
+ * @openapi
+ * /ssh/file_manager/ssh/resolvePath:
+ * get:
+ * summary: Resolve a path with environment variables
+ * description: Expands environment variables and ~ in a path via the SSH session.
+ * 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: The resolved absolute path.
+ * 400:
+ * description: Missing required parameters.
+ * 500:
+ * description: Failed to resolve path.
+ */
+ app.get("/ssh/file_manager/ssh/resolvePath", (req, res) => {
+ const sessionId = req.query.sessionId as string;
+ const sshConn = sshSessions[sessionId];
+ const rawPath = 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" });
+ }
+
+ if (!rawPath) {
+ return res.status(400).json({ error: "Path is required" });
+ }
+
+ sshConn.lastActive = Date.now();
+
+ let command: string;
+ if (rawPath.startsWith("~")) {
+ const rest = rawPath.substring(1).replace(/'/g, "'\"'\"'");
+ command = `echo ~'${rest}'`;
+ } else {
+ const escapedPath = rawPath.replace(/'/g, "'\"'\"'");
+ command = `echo '${escapedPath}'`;
+ }
+
+ execChannel(sshConn, command, (err, stream) => {
+ if (err) {
+ fileLogger.error("SSH resolvePath 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) {
+ fileLogger.error(
+ `SSH resolvePath command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
+ );
+ return res.json({ resolvedPath: rawPath });
+ }
+
+ const resolved = data.trim();
+ res.json({ resolvedPath: resolved || rawPath });
+ });
+
+ stream.on("error", (streamErr) => {
+ fileLogger.error("SSH resolvePath stream error:", streamErr);
+ if (!res.headersSent) {
+ res.json({ resolvedPath: rawPath });
+ }
+ });
+ });
+ });
+
+ /**
+ * @openapi
+ * /ssh/file_manager/ssh/readFile:
+ * get:
+ * summary: Read a file
+ * description: Reads the content of a file from 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: The content of the file.
+ * 400:
+ * description: Missing required parameters or file too large.
+ * 404:
+ * description: File not found.
+ * 500:
+ * description: Failed to read file.
+ */
+ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
+ const sessionId = req.query.sessionId as string;
+ const sshConn = sshSessions[sessionId];
+ const filePath = 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" });
+ }
+
+ if (!filePath) {
+ return res.status(400).json({ error: "File path is required" });
+ }
+
+ fileLogger.info("Reading file", {
+ operation: "file_read",
+ sessionId,
+ userId,
+ path: filePath,
+ });
+ sshConn.lastActive = Date.now();
+
+ const MAX_READ_SIZE = 500 * 1024 * 1024;
+ const escapedPath = filePath.replace(/'/g, "'\"'\"'");
+
+ execChannel(
+ sshConn,
+ `stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`,
+ (sizeErr, sizeStream) => {
+ if (sizeErr) {
+ fileLogger.error("SSH file size check error:", sizeErr);
+ return res.status(500).json({ error: sizeErr.message });
+ }
+
+ let sizeData = "";
+ let sizeErrorData = "";
+
+ sizeStream.on("data", (chunk: Buffer) => {
+ sizeData += chunk.toString();
+ });
+
+ sizeStream.stderr.on("data", (chunk: Buffer) => {
+ sizeErrorData += chunk.toString();
+ });
+
+ sizeStream.on("close", (sizeCode) => {
+ if (sizeCode !== 0) {
+ const errorLower = sizeErrorData.toLowerCase();
+ const isFileNotFound =
+ errorLower.includes("no such file or directory") ||
+ errorLower.includes("cannot access") ||
+ errorLower.includes("not found") ||
+ errorLower.includes("resource not found");
+
+ fileLogger.error(`File size check failed: ${sizeErrorData}`);
+ return res.status(isFileNotFound ? 404 : 500).json({
+ error: `Cannot check file size: ${sizeErrorData}`,
+ fileNotFound: isFileNotFound,
+ });
+ }
+
+ const fileSize = parseInt(sizeData.trim(), 10);
+
+ if (isNaN(fileSize)) {
+ fileLogger.error("Invalid file size response:", sizeData);
+ return res
+ .status(500)
+ .json({ error: "Cannot determine file size" });
+ }
+
+ if (fileSize > MAX_READ_SIZE) {
+ fileLogger.warn("File too large for reading", {
+ operation: "file_read",
+ sessionId,
+ filePath,
+ fileSize,
+ maxSize: MAX_READ_SIZE,
+ });
+ return res.status(400).json({
+ error: `File too large to open in editor. Maximum size is ${MAX_READ_SIZE / 1024 / 1024}MB, file is ${(fileSize / 1024 / 1024).toFixed(2)}MB. Use download instead.`,
+ fileSize,
+ maxSize: MAX_READ_SIZE,
+ tooLarge: true,
+ });
+ }
+
+ execChannel(sshConn, `cat '${escapedPath}'`, (err, stream) => {
+ if (err) {
+ fileLogger.error("SSH readFile error:", err);
+ return res.status(500).json({ error: err.message });
+ }
+
+ let binaryData = Buffer.alloc(0);
+ let errorData = "";
+
+ stream.on("data", (chunk: Buffer) => {
+ binaryData = Buffer.concat([binaryData, chunk]);
+ });
+
+ stream.stderr.on("data", (chunk: Buffer) => {
+ errorData += chunk.toString();
+ });
+
+ stream.on("close", (code) => {
+ if (code !== 0) {
+ const isPermissionDenied = errorData
+ .toLowerCase()
+ .includes("permission denied");
+
+ if (isPermissionDenied && sshConn.sudoPassword) {
+ execWithSudoBuffer(
+ sshConn,
+ `cat '${escapedPath}'`,
+ sshConn.sudoPassword,
+ )
+ .then((result) => {
+ if (result.code !== 0) {
+ return res.status(403).json({
+ error: `Permission denied: ${result.stderr || result.stdout.toString("utf8")}`,
+ needsSudo: true,
+ });
+ }
+
+ const sudoData = result.stdout;
+ const isBinary = detectBinary(sudoData);
+ res.json({
+ content: isBinary
+ ? sudoData.toString("base64")
+ : sudoData.toString("utf8"),
+ isBinary,
+ size: sudoData.length,
+ });
+ })
+ .catch(() => {
+ res
+ .status(403)
+ .json({ error: "Permission denied", needsSudo: true });
+ });
+ return;
+ }
+
+ fileLogger.error(
+ `SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
+ );
+
+ const isFileNotFound =
+ errorData.includes("No such file or directory") ||
+ errorData.includes("cannot access") ||
+ errorData.includes("not found");
+
+ return res.status(isFileNotFound ? 404 : 500).json({
+ error: `Command failed: ${errorData}`,
+ fileNotFound: isFileNotFound,
+ });
+ }
+
+ const isBinary = detectBinary(binaryData);
+ fileLogger.success("File read successfully", {
+ operation: "file_read_success",
+ sessionId,
+ userId,
+ path: filePath,
+ bytes: binaryData.length,
+ });
+
+ if (isBinary) {
+ const base64Content = binaryData.toString("base64");
+ res.json({
+ content: base64Content,
+ path: filePath,
+ encoding: "base64",
+ });
+ } else {
+ const textContent = binaryData.toString("utf8");
+ res.json({
+ content: textContent,
+ path: filePath,
+ encoding: "utf8",
+ });
+ }
+ });
+ });
+ });
+ },
+ );
+ });
+
+ /**
+ * @openapi
+ * /ssh/file_manager/ssh/writeFile:
+ * post:
+ * summary: Write to a file
+ * description: Writes content to a file on the remote host and preserves the existing permissions when the file already exists.
+ * tags:
+ * - File Manager
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * sessionId:
+ * type: string
+ * path:
+ * type: string
+ * content:
+ * type: string
+ * responses:
+ * 200:
+ * description: File written successfully.
+ * 400:
+ * description: Missing required parameters or SSH connection not established.
+ * 500:
+ * description: Failed to write file.
+ */
+ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
+ const { sessionId, path: filePath, content } = 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) {
+ return res.status(400).json({ error: "File path is required" });
+ }
+
+ if (content === undefined) {
+ return res.status(400).json({ error: "File content is required" });
+ }
+
+ const contentLength =
+ typeof content === "string" ? content.length : Buffer.byteLength(content);
+ fileLogger.info("Writing file", {
+ operation: "file_write",
+ sessionId,
+ userId,
+ path: filePath,
+ bytes: contentLength,
+ });
+ sshConn.lastActive = Date.now();
+
+ let preservedMode: number | undefined;
+
+ const restoreOriginalMode = (
+ sftp: import("ssh2").SFTPWrapper | null,
+ onComplete: () => void,
+ ) => {
+ if (preservedMode === undefined) {
+ onComplete();
+ return;
+ }
+
+ const permissions = preservedMode.toString(8);
+
+ if (sftp) {
+ sftp.chmod(filePath, preservedMode, (chmodErr) => {
+ if (chmodErr) {
+ fileLogger.warn("Failed to restore file permissions after save", {
+ operation: "file_write_restore_permissions",
+ sessionId,
+ userId,
+ path: filePath,
+ permissions,
+ error: chmodErr.message,
+ });
+ } else {
+ fileLogger.info("Restored file permissions after save", {
+ operation: "file_write_restore_permissions",
+ sessionId,
+ userId,
+ path: filePath,
+ permissions,
+ });
+ }
+
+ onComplete();
+ });
+ return;
+ }
+
+ const escapedPath = filePath.replace(/'/g, "'\"'\"'");
+ const chmodCommand = `chmod ${permissions} '${escapedPath}' && echo "SUCCESS"`;
+
+ execChannel(sshConn, chmodCommand, (err, stream) => {
+ if (err) {
+ fileLogger.warn("Failed to restore file permissions after save", {
+ operation: "file_write_restore_permissions",
+ sessionId,
+ userId,
+ path: filePath,
+ permissions,
+ error: err.message,
+ });
+ onComplete();
+ return;
+ }
+
+ let outputData = "";
+ let errorData = "";
+
+ stream.on("data", (chunk: Buffer) => {
+ outputData += chunk.toString();
+ });
+
+ stream.stderr.on("data", (chunk: Buffer) => {
+ errorData += chunk.toString();
+ });
+
+ stream.on("close", (code) => {
+ if (outputData.includes("SUCCESS")) {
+ fileLogger.info("Restored file permissions after save", {
+ operation: "file_write_restore_permissions",
+ sessionId,
+ userId,
+ path: filePath,
+ permissions,
+ });
+ } else {
+ fileLogger.warn("Failed to restore file permissions after save", {
+ operation: "file_write_restore_permissions",
+ sessionId,
+ userId,
+ path: filePath,
+ permissions,
+ exitCode: code,
+ error:
+ errorData ||
+ "Permission restore command did not report success",
+ });
+ }
+
+ onComplete();
+ });
+
+ stream.on("error", (streamErr) => {
+ fileLogger.warn("Failed to restore file permissions after save", {
+ operation: "file_write_restore_permissions",
+ sessionId,
+ userId,
+ path: filePath,
+ permissions,
+ error: streamErr.message,
+ });
+ onComplete();
+ });
+ });
+ };
+
+ const trySFTP = () => {
+ try {
+ fileLogger.info("Opening SFTP channel", {
+ operation: "file_sftp_open",
+ sessionId,
+ userId,
+ path: filePath,
+ });
+ getSessionSftp(sshConn)
+ .then((sftp) => {
+ let fileBuffer;
+ try {
+ if (typeof content === "string") {
+ try {
+ const testBuffer = Buffer.from(content, "base64");
+ if (testBuffer.toString("base64") === content) {
+ fileBuffer = testBuffer;
+ } else {
+ fileBuffer = Buffer.from(content, "utf8");
+ }
+ } catch {
+ fileBuffer = Buffer.from(content, "utf8");
+ }
+ } else if (Buffer.isBuffer(content)) {
+ fileBuffer = content;
+ } else {
+ fileBuffer = Buffer.from(content);
+ }
+ } catch (bufferErr) {
+ fileLogger.error("Buffer conversion error:", bufferErr);
+ if (!res.headersSent) {
+ return res
+ .status(500)
+ .json({ error: "Invalid file content format" });
+ }
+ return;
+ }
+
+ sftp.stat(filePath, (statErr, stats) => {
+ try {
+ if (statErr) {
+ fileLogger.warn(
+ "Failed to read existing file permissions before save",
+ {
+ operation: "file_write_stat",
+ sessionId,
+ userId,
+ path: filePath,
+ error: statErr.message,
+ },
+ );
+ } else if (stats.isFile()) {
+ preservedMode = stats.mode & 0o7777;
+ }
+
+ const writeStream = sftp.createWriteStream(filePath);
+
+ let hasError = false;
+ let hasFinished = false;
+ let isFinalizing = false;
+
+ const finalizeSuccess = () => {
+ if (hasError || hasFinished) return;
+ hasFinished = true;
+ isFinalizing = false;
+ fileLogger.success("File written successfully", {
+ operation: "file_write_success",
+ sessionId,
+ userId,
+ path: filePath,
+ bytes: fileBuffer.length,
+ });
+ if (!res.headersSent) {
+ res.json({
+ message: "File written successfully",
+ path: filePath,
+ toast: {
+ type: "success",
+ message: `File written: ${filePath}`,
+ },
+ });
+ }
+ };
+
+ writeStream.on("error", (streamErr) => {
+ if (hasError || hasFinished || isFinalizing) return;
+ hasError = true;
+ isFinalizing = false;
+ fileLogger.warn(
+ `SFTP write failed, trying fallback method: ${streamErr.message}`,
+ );
+ tryFallbackMethod();
+ });
+
+ const finishWrite = () => {
+ if (hasError || hasFinished || isFinalizing) return;
+ isFinalizing = true;
+ restoreOriginalMode(sftp, finalizeSuccess);
+ };
+
+ writeStream.on("finish", () => {
+ finishWrite();
+ });
+
+ writeStream.on("close", () => {
+ finishWrite();
+ });
+
+ try {
+ writeStream.write(fileBuffer);
+ writeStream.end();
+ } catch (writeErr) {
+ if (hasError || hasFinished) return;
+ hasError = true;
+ isFinalizing = false;
+ fileLogger.warn(
+ `SFTP write operation failed, trying fallback method: ${(writeErr as Error).message}`,
+ );
+ tryFallbackMethod();
+ }
+ } catch (callbackErr) {
+ fileLogger.warn(
+ `SFTP stat callback error, trying fallback method: ${(callbackErr as Error).message}`,
+ );
+ tryFallbackMethod();
+ }
+ });
+ })
+ .catch((err: Error) => {
+ fileLogger.warn(
+ `SFTP failed, trying fallback method: ${err.message}`,
+ );
+ tryFallbackMethod();
+ });
+ } catch (sftpErr) {
+ fileLogger.warn(
+ `SFTP connection error, trying fallback method: ${(sftpErr as Error).message}`,
+ );
+ tryFallbackMethod();
+ }
+ };
+
+ const tryFallbackMethod = () => {
+ if (!sshConn?.isConnected) {
+ if (!res.headersSent) {
+ return res.status(500).json({ error: "SSH session disconnected" });
+ }
+ return;
+ }
+ try {
+ let contentBuffer: Buffer;
+ if (typeof content === "string") {
+ try {
+ contentBuffer = Buffer.from(content, "base64");
+ if (contentBuffer.toString("base64") !== content) {
+ contentBuffer = Buffer.from(content, "utf8");
+ }
+ } catch {
+ contentBuffer = Buffer.from(content, "utf8");
+ }
+ } else if (Buffer.isBuffer(content)) {
+ contentBuffer = content;
+ } else {
+ contentBuffer = Buffer.from(content);
+ }
+ const base64Content = contentBuffer.toString("base64");
+ const escapedPath = filePath.replace(/'/g, "'\"'\"'");
+
+ const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
+
+ execChannel(sshConn, writeCommand, (err, stream) => {
+ if (err) {
+ fileLogger.error("Fallback write command failed:", err);
+ if (!res.headersSent) {
+ return res.status(500).json({
+ error: `Write failed: ${err.message}`,
+ toast: {
+ type: "error",
+ message: `Write failed: ${err.message}`,
+ },
+ });
+ }
+ return;
+ }
+
+ let outputData = "";
+ let errorData = "";
+
+ stream.on("data", (chunk: Buffer) => {
+ outputData += chunk.toString();
+ });
+
+ stream.stderr.on("data", (chunk: Buffer) => {
+ errorData += chunk.toString();
+ });
+
+ stream.stderr.on("error", (stderrErr) => {
+ fileLogger.error("Fallback write stderr error:", stderrErr);
+ });
+
+ stream.on("close", (code) => {
+ if (outputData.includes("SUCCESS")) {
+ restoreOriginalMode(null, () => {
+ if (!res.headersSent) {
+ res.json({
+ message: "File written successfully",
+ path: filePath,
+ toast: {
+ type: "success",
+ message: `File written: ${filePath}`,
+ },
+ });
+ }
+ });
+ } else {
+ const isPermDenied = errorData
+ .toLowerCase()
+ .includes("permission denied");
+ if (isPermDenied && sshConn.sudoPassword) {
+ execWithSudo(
+ sshConn,
+ `bash -c "echo '${base64Content}' | base64 -d > '${escapedPath}' && echo SUCCESS"`,
+ sshConn.sudoPassword,
+ )
+ .then(({ stdout, code: sudoCode }) => {
+ if (sudoCode === 0 && stdout.includes("SUCCESS")) {
+ restoreOriginalMode(null, () => {
+ if (!res.headersSent) {
+ res.json({
+ message: "File written successfully",
+ path: filePath,
+ });
+ }
+ });
+ } else if (!res.headersSent) {
+ res
+ .status(403)
+ .json({ error: "Permission denied", needsSudo: true });
+ }
+ })
+ .catch(() => {
+ if (!res.headersSent) {
+ res
+ .status(403)
+ .json({ error: "Permission denied", needsSudo: true });
+ }
+ });
+ return;
+ }
+ fileLogger.error(
+ `Fallback write failed with code ${code}: ${errorData}`,
+ );
+ if (!res.headersSent) {
+ res.status(500).json({
+ error: `Write failed: ${errorData}`,
+ needsSudo: isPermDenied,
+ toast: {
+ type: "error",
+ message: `Write failed: ${errorData}`,
+ },
+ });
+ }
+ }
+ });
+
+ stream.on("error", (streamErr) => {
+ fileLogger.error("Fallback write stream error:", streamErr);
+ if (!res.headersSent) {
+ res
+ .status(500)
+ .json({ error: `Write stream error: ${streamErr.message}` });
+ }
+ });
+ });
+ } catch (fallbackErr) {
+ fileLogger.error("Fallback method failed:", fallbackErr);
+ if (!res.headersSent) {
+ res.status(500).json({
+ error: `All write methods failed: ${(fallbackErr as Error).message}`,
+ });
+ }
+ }
+ };
+
+ trySFTP();
+ });
+
+ /**
+ * @openapi
+ * /ssh/file_manager/ssh/uploadFile:
+ * post:
+ * summary: Upload a file
+ * description: Uploads a file to the remote host.
+ * tags:
+ * - File Manager
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * sessionId:
+ * type: string
+ * path:
+ * type: string
+ * content:
+ * type: string
+ * fileName:
+ * type: string
+ * responses:
+ * 200:
+ * description: File uploaded successfully.
+ * 400:
+ * description: Missing required parameters or SSH connection not established.
+ * 500:
+ * description: Failed to upload file.
+ */
+ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
+ const { sessionId, path: filePath, content, 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 || content === undefined) {
+ return res
+ .status(400)
+ .json({ error: "File path, name, and content are required" });
+ }
+
+ sshConn.lastActive = Date.now();
+
+ const contentSize =
+ typeof content === "string"
+ ? Buffer.byteLength(content, "utf8")
+ : content.length;
+
+ const fullPath = filePath.endsWith("/")
+ ? filePath + fileName
+ : filePath + "/" + fileName;
+ const uploadStartTime = Date.now();
+ fileLogger.info("File upload started", {
+ operation: "file_upload_start",
+ sessionId,
+ userId,
+ path: fullPath,
+ bytes: contentSize,
+ });
+
+ const trySFTP = () => {
+ try {
+ fileLogger.info("Opening SFTP channel", {
+ operation: "file_sftp_open",
+ sessionId,
+ userId,
+ path: fullPath,
+ });
+ getSessionSftp(sshConn)
+ .then((sftp) => {
+ let fileBuffer;
+ try {
+ if (typeof content === "string") {
+ fileBuffer = Buffer.from(content, "base64");
+ } else if (Buffer.isBuffer(content)) {
+ fileBuffer = content;
+ } else {
+ fileBuffer = Buffer.from(content);
+ }
+ } catch (bufferErr) {
+ fileLogger.error("Buffer conversion error:", bufferErr);
+ if (!res.headersSent) {
+ return res
+ .status(500)
+ .json({ error: "Invalid file content format" });
+ }
+ return;
+ }
+
+ const writeStream = sftp.createWriteStream(fullPath);
+
+ let hasError = false;
+ let hasFinished = false;
+
+ writeStream.on("error", (streamErr) => {
+ if (hasError || hasFinished) return;
+ hasError = true;
+ fileLogger.warn(
+ `SFTP write failed, trying fallback method: ${streamErr.message}`,
+ {
+ operation: "file_upload",
+ sessionId,
+ fileName,
+ fileSize: contentSize,
+ error: streamErr.message,
+ },
+ );
+ tryFallbackMethod();
+ });
+
+ writeStream.on("finish", () => {
+ if (hasError || hasFinished) return;
+ hasFinished = true;
+ fileLogger.success("File upload completed", {
+ operation: "file_upload_complete",
+ sessionId,
+ userId,
+ path: fullPath,
+ bytes: fileBuffer.length,
+ duration: Date.now() - uploadStartTime,
+ });
+ if (!res.headersSent) {
+ res.json({
+ message: "File uploaded successfully",
+ path: fullPath,
+ toast: {
+ type: "success",
+ message: `File uploaded: ${fullPath}`,
+ },
+ });
+ }
+ });
+
+ writeStream.on("close", () => {
+ if (hasError || hasFinished) return;
+ hasFinished = true;
+ fileLogger.success("File upload completed", {
+ operation: "file_upload_complete",
+ sessionId,
+ userId,
+ path: fullPath,
+ bytes: fileBuffer.length,
+ duration: Date.now() - uploadStartTime,
+ });
+ if (!res.headersSent) {
+ res.json({
+ message: "File uploaded successfully",
+ path: fullPath,
+ toast: {
+ type: "success",
+ message: `File uploaded: ${fullPath}`,
+ },
+ });
+ }
+ });
+
+ try {
+ writeStream.write(fileBuffer);
+ writeStream.end();
+ } catch (writeErr) {
+ if (hasError || hasFinished) return;
+ hasError = true;
+ fileLogger.warn(
+ `SFTP write operation failed, trying fallback method: ${(writeErr as Error).message}`,
+ );
+ tryFallbackMethod();
+ }
+ })
+ .catch((err: Error) => {
+ fileLogger.warn(
+ `SFTP failed, trying fallback method: ${err.message}`,
+ );
+ tryFallbackMethod();
+ });
+ } catch (sftpErr) {
+ fileLogger.warn(
+ `SFTP connection error, trying fallback method: ${(sftpErr as Error).message}`,
+ );
+ tryFallbackMethod();
+ }
+ };
+
+ const tryFallbackMethod = () => {
+ if (!sshConn?.isConnected) {
+ if (!res.headersSent) {
+ return res.status(500).json({ error: "SSH session disconnected" });
+ }
+ return;
+ }
+ try {
+ let contentBuffer: Buffer;
+ if (typeof content === "string") {
+ try {
+ contentBuffer = Buffer.from(content, "base64");
+ if (contentBuffer.toString("base64") !== content) {
+ contentBuffer = Buffer.from(content, "utf8");
+ }
+ } catch {
+ contentBuffer = Buffer.from(content, "utf8");
+ }
+ } else if (Buffer.isBuffer(content)) {
+ contentBuffer = content;
+ } else {
+ contentBuffer = Buffer.from(content);
+ }
+ const base64Content = contentBuffer.toString("base64");
+ const chunkSize = 1000000;
+ const chunks = [];
+
+ for (let i = 0; i < base64Content.length; i += chunkSize) {
+ chunks.push(base64Content.slice(i, i + chunkSize));
+ }
+
+ if (!sshConn?.isConnected) {
+ fileLogger.error("SSH connection lost before fallback upload", {
+ operation: "file_upload_fallback",
+ sessionId,
+ path: fullPath,
+ });
+ if (!res.headersSent) {
+ return res
+ .status(500)
+ .json({ error: "SSH connection lost during upload" });
+ }
+ return;
+ }
+
+ if (chunks.length === 1) {
+ const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
+
+ const writeCommand = `echo '${chunks[0]}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
+
+ execChannel(sshConn, writeCommand, (err, stream) => {
+ if (err) {
+ fileLogger.error("Fallback upload command failed:", err);
+ if (!res.headersSent) {
+ return res
+ .status(500)
+ .json({ error: `Upload failed: ${err.message}` });
+ }
+ return;
+ }
+
+ let outputData = "";
+ let errorData = "";
+
+ stream.on("data", (chunk: Buffer) => {
+ outputData += chunk.toString();
+ });
+
+ stream.stderr.on("data", (chunk: Buffer) => {
+ errorData += chunk.toString();
+ });
+
+ stream.stderr.on("error", (stderrErr) => {
+ fileLogger.error("Fallback upload stderr error:", stderrErr);
+ });
+
+ stream.on("close", (code) => {
+ if (outputData.includes("SUCCESS")) {
+ if (!res.headersSent) {
+ res.json({
+ message: "File uploaded successfully",
+ path: fullPath,
+ toast: {
+ type: "success",
+ message: `File uploaded: ${fullPath}`,
+ },
+ });
+ }
+ } else {
+ fileLogger.error(
+ `Fallback upload failed with code ${code}: ${errorData}`,
+ );
+ if (!res.headersSent) {
+ res.status(500).json({
+ error: `Upload failed: ${errorData}`,
+ toast: {
+ type: "error",
+ message: `Upload failed: ${errorData}`,
+ },
+ });
+ }
+ }
+ });
+
+ stream.on("error", (streamErr) => {
+ fileLogger.error("Fallback upload stream error:", streamErr);
+ if (!res.headersSent) {
+ res
+ .status(500)
+ .json({ error: `Upload stream error: ${streamErr.message}` });
+ }
+ });
+ });
+ } else {
+ const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
+
+ let writeCommand = `> '${escapedPath}'`;
+
+ chunks.forEach((chunk) => {
+ writeCommand += ` && echo '${chunk}' | base64 -d >> '${escapedPath}'`;
+ });
+
+ writeCommand += ` && echo "SUCCESS"`;
+
+ execChannel(sshConn, writeCommand, (err, stream) => {
+ if (err) {
+ fileLogger.error("Chunked fallback upload failed:", err);
+ if (!res.headersSent) {
+ return res
+ .status(500)
+ .json({ error: `Chunked upload failed: ${err.message}` });
+ }
+ return;
+ }
+
+ let outputData = "";
+ let errorData = "";
+
+ stream.on("data", (chunk: Buffer) => {
+ outputData += chunk.toString();
+ });
+
+ stream.stderr.on("data", (chunk: Buffer) => {
+ errorData += chunk.toString();
+ });
+
+ stream.stderr.on("error", (stderrErr) => {
+ fileLogger.error(
+ "Chunked fallback upload stderr error:",
+ stderrErr,
+ );
+ });
+
+ stream.on("close", (code) => {
+ if (outputData.includes("SUCCESS")) {
+ if (!res.headersSent) {
+ res.json({
+ message: "File uploaded successfully",
+ path: fullPath,
+ toast: {
+ type: "success",
+ message: `File uploaded: ${fullPath}`,
+ },
+ });
+ }
+ } else {
+ fileLogger.error(
+ `Chunked fallback upload failed with code ${code}: ${errorData}`,
+ );
+ if (!res.headersSent) {
+ res.status(500).json({
+ error: `Chunked upload failed: ${errorData}`,
+ toast: {
+ type: "error",
+ message: `Chunked upload failed: ${errorData}`,
+ },
+ });
+ }
+ }
+ });
+
+ stream.on("error", (streamErr) => {
+ fileLogger.error(
+ "Chunked fallback upload stream error:",
+ streamErr,
+ );
+ if (!res.headersSent) {
+ res.status(500).json({
+ error: `Chunked upload stream error: ${streamErr.message}`,
+ });
+ }
+ });
+ });
+ }
+ } catch (fallbackErr) {
+ fileLogger.error("Fallback method failed:", fallbackErr);
+ if (!res.headersSent) {
+ res.status(500).json({
+ error: `All upload methods failed: ${fallbackErr.message}`,
+ });
+ }
+ }
+ };
+
+ trySFTP();
+ });
+}
diff --git a/src/backend/ssh/file-manager-download-routes.ts b/src/backend/ssh/file-manager-download-routes.ts
new file mode 100644
index 00000000..75a80638
--- /dev/null
+++ b/src/backend/ssh/file-manager-download-routes.ts
@@ -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;
+ 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}` });
+ }
+ }
+ });
+}
diff --git a/src/backend/ssh/file-manager-list-routes.ts b/src/backend/ssh/file-manager-list-routes.ts
new file mode 100644
index 00000000..b095ce21
--- /dev/null
+++ b/src/backend/ssh/file-manager-list-routes.ts
@@ -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;
+ activeListRequests: Record;
+ 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();
+ });
+}
diff --git a/src/backend/ssh/file-manager-log.ts b/src/backend/ssh/file-manager-log.ts
new file mode 100644
index 00000000..e2126e78
--- /dev/null
+++ b/src/backend/ssh/file-manager-log.ts
@@ -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,
+): Omit {
+ return {
+ type,
+ stage,
+ message,
+ details,
+ };
+}
diff --git a/src/backend/ssh/file-manager-operation-routes.ts b/src/backend/ssh/file-manager-operation-routes.ts
new file mode 100644
index 00000000..23c1e0da
--- /dev/null
+++ b/src/backend/ssh/file-manager-operation-routes.ts
@@ -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;
+ 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 => {
+ 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}` });
+ }
+ });
+ });
+ });
+}
diff --git a/src/backend/ssh/file-manager-session.ts b/src/backend/ssh/file-manager-session.ts
new file mode 100644
index 00000000..1bbebf22
--- /dev/null
+++ b/src/backend/ssh/file-manager-session.ts
@@ -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 = Promise.resolve();
+
+ run(action: () => Promise): Promise {
+ 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;
+ 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 {
+ if (session.sftp) {
+ return Promise.resolve(session.sftp);
+ }
+
+ if (session.sftpPending) {
+ return session.sftpPending;
+ }
+
+ const openOnce = (): Promise =>
+ session.channelOpener.run(
+ () =>
+ new Promise((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((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((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),
+ );
+}
diff --git a/src/backend/ssh/file-manager-utils.test.ts b/src/backend/ssh/file-manager-utils.test.ts
new file mode 100644
index 00000000..85eddb08
--- /dev/null
+++ b/src/backend/ssh/file-manager-utils.test.ts
@@ -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);
+ });
+});
diff --git a/src/backend/ssh/file-manager-utils.ts b/src/backend/ssh/file-manager-utils.ts
new file mode 100644
index 00000000..b3809a18
--- /dev/null
+++ b/src/backend/ssh/file-manager-utils.ts
@@ -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 = {
+ 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;
+}
diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts
index 733a6b0b..3a33debe 100644
--- a/src/backend/ssh/file-manager.ts
+++ b/src/backend/ssh/file-manager.ts
@@ -5,7 +5,7 @@ import axios from "axios";
import { Client as SSHClient } from "ssh2";
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
import { getDb } from "../database/db/index.js";
-import { sshCredentials, hosts } from "../database/db/schema.js";
+import { hosts } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { fileLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
@@ -17,104 +17,33 @@ import {
} from "../utils/socks5-helper.js";
import type { LogEntry, ConnectionStage } from "../../types/connection-log.js";
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
-
-function createConnectionLog(
- type: "info" | "success" | "warning" | "error",
- stage: ConnectionStage,
- message: string,
- details?: Record,
-): Omit {
- return {
- type,
- stage,
- message,
- details,
- };
-}
-
-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)
- );
-}
-
-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;
-}
-
-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()}`;
-}
+import { resolveHostById } from "./host-resolver.js";
+import type { SSHHost } from "../../types/index.js";
+import {
+ startHostTransfer,
+ getTransferStatus,
+ listActiveTransfers,
+ probeHungStreamTransfers,
+ requestTransferCancel,
+ cleanupCancelledTransfer,
+ retryHostTransfer,
+ previewArchiveTransferMethod,
+ type HostTransferDeps,
+} from "./host-transfer.js";
+import { registerFileContentRoutes } from "./file-manager-content-routes.js";
+import { createConnectionLog } from "./file-manager-log.js";
+import { createJumpHostChain } from "./jump-host-chain.js";
+import {
+ ChannelOpenSerializer,
+ execChannel,
+ getSessionSftp,
+ type PendingTOTPSession,
+ type SSHSession,
+} from "./file-manager-session.js";
+import { registerFileListingRoutes } from "./file-manager-list-routes.js";
+import { registerFileOperationRoutes } from "./file-manager-operation-routes.js";
+import { registerFileDownloadRoutes } from "./file-manager-download-routes.js";
+import { registerFileActionRoutes } from "./file-manager-action-routes.js";
const app = express();
@@ -131,519 +60,11 @@ app.use((_req, res, next) => {
const authManager = AuthManager.getInstance();
app.use(authManager.createAuthMiddleware());
-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 {
- 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;
- }
-}
-
-async function createJumpHostChain(
- jumpHosts: Array<{ hostId: number }>,
- userId: string,
- socks5Config?: SOCKS5Config | null,
-): Promise {
- if (!jumpHosts || jumpHosts.length === 0) {
- return null;
- }
-
- let currentClient: SSHClient | null = null;
- const clients: SSHClient[] = [];
-
- try {
- const jumpHostConfigs: Array>> =
- [];
- 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((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 = {
- 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;
- }
-}
-
-// 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 (one exec per command is short-lived,
-// the SFTP channel is long-lived but only opened once).
-class ChannelOpenSerializer {
- private tail: Promise = Promise.resolve();
-
- // Enqueue an action that opens a channel. The action runs after the previous
- // one completes (success or failure). Returns a promise that resolves with
- // the action's result.
- run(action: () => Promise): Promise {
- const next = this.tail.then(
- () => action(),
- () => action(), // run even if the previous open failed
- );
- // Advance tail past this slot (swallow result so the chain keeps going)
- this.tail = next.then(
- () => {},
- () => {},
- );
- return next;
- }
-}
-
-interface SSHSession {
- client: SSHClient;
- isConnected: boolean;
- lastActive: number;
- timeout?: NodeJS.Timeout;
- activeOperations: number;
- sudoPassword?: string;
- sftp?: import("ssh2").SFTPWrapper;
- sftpPending?: Promise;
- channelOpener: ChannelOpenSerializer;
- poolKey?: string;
- userId?: string;
-}
-
-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;
-}
-
const sshSessions: Record = {};
const pendingTOTPSessions: Record = {};
// Keyed by "sessionId:path" to prevent concurrent requests for the same path
const activeListRequests: Record = {};
-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,
- }));
-}
-
-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,
- });
- });
- });
- });
-}
-
-function getSessionSftp(
- session: SSHSession,
-): Promise {
- if (session.sftp) {
- return Promise.resolve(session.sftp);
- }
-
- // Serialization: if a channel open is already in flight, join it
- if (session.sftpPending) {
- return session.sftpPending;
- }
-
- const openOnce = (): Promise =>
- session.channelOpener.run(
- () =>
- new Promise((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) {
- // Single retry after 500ms for transient server-side rate limiting
- return new Promise((resolve, reject) =>
- setTimeout(() => openOnce().then(resolve, reject), 500),
- );
- }
- return Promise.reject(err);
- })
- .finally(() => {
- session.sftpPending = undefined;
- });
-
- return session.sftpPending;
-}
-
-// Wraps client.exec through the channel serializer so only one SSH channel
-// negotiation is in-flight at a time. The serializer slot is released as soon
-// as the channel is established (not when it closes), so channels run
-// concurrently once open — we only serialize the *open handshake*.
-function execChannel(
- session: SSHSession,
- command: string,
- callback: (
- err: Error | undefined,
- stream: import("ssh2").ClientChannel,
- ) => void,
-): void {
- session.channelOpener
- .run(
- () =>
- new Promise((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),
- );
-}
-
function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
@@ -696,47 +117,372 @@ function verifySessionOwnership(session: SSHSession, userId: string): boolean {
return !session.userId || session.userId === userId;
}
-function getMimeType(fileName: string): string {
- const ext = fileName.split(".").pop()?.toLowerCase();
- const mimeTypes: Record = {
- 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";
+function resolveBrowseHostId(
+ browseSessionId: string,
+ browseSession: SSHSession,
+): number | undefined {
+ if (browseSession.hostId) return browseSession.hostId;
+ const parsed = Number.parseInt(browseSessionId, 10);
+ return Number.isFinite(parsed) ? parsed : undefined;
}
-function detectBinary(buffer: Buffer): boolean {
- if (buffer.length === 0) return false;
+async function buildDedicatedTransferConnectConfig(
+ host: SSHHost,
+ userId: string,
+ client: SSHClient,
+): Promise> {
+ const { ip, port, username } = host;
+ const config: Record = {
+ host: ip?.replace(/^\[|\]$/g, "") || ip,
+ port,
+ username,
+ tryKeyboard: true,
+ keepaliveInterval: 30000,
+ keepaliveCountMax: 120,
+ readyTimeout: 60000,
+ tcpKeepAlive: true,
+ tcpKeepAliveInitialDelay: 5000,
+ hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
+ host.id,
+ ip,
+ port,
+ null,
+ userId,
+ false,
+ ),
+ env: {
+ TERM: "xterm-256color",
+ LANG: "en_US.UTF-8",
+ LC_ALL: "en_US.UTF-8",
+ },
+ algorithms: {
+ kex: [
+ "curve25519-sha256",
+ "curve25519-sha256@libssh.org",
+ "ecdh-sha2-nistp521",
+ "ecdh-sha2-nistp384",
+ "ecdh-sha2-nistp256",
+ "diffie-hellman-group-exchange-sha256",
+ "diffie-hellman-group14-sha256",
+ "diffie-hellman-group14-sha1",
+ "diffie-hellman-group-exchange-sha1",
+ "diffie-hellman-group1-sha1",
+ ],
+ serverHostKey: [
+ "ssh-ed25519",
+ "ecdsa-sha2-nistp521",
+ "ecdsa-sha2-nistp384",
+ "ecdsa-sha2-nistp256",
+ "rsa-sha2-512",
+ "rsa-sha2-256",
+ "ssh-rsa",
+ "ssh-dss",
+ ],
+ cipher: 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"],
+ },
+ };
- const sampleSize = Math.min(buffer.length, 8192);
- let nullBytes = 0;
+ const authType = host.authType;
- for (let i = 0; i < sampleSize; i++) {
- const byte = buffer[i];
+ if (authType === "key" && host.key?.trim()) {
+ const cleanKey = host.key
+ .trim()
+ .replace(/\r\n/g, "\n")
+ .replace(/\r/g, "\n");
+ config.privateKey = Buffer.from(cleanKey, "utf8");
+ if (host.keyPassword) config.passphrase = host.keyPassword;
+ } else if (authType === "password") {
+ if (!host.password) {
+ throw new Error("Password required for transfer connection");
+ }
+ config.password = host.password;
+ } else if (authType === "opkssh") {
+ const { getOPKSSHToken } = await import("./opkssh-auth.js");
+ const token = await getOPKSSHToken(userId, host.id);
+ if (!token) {
+ throw new Error(
+ "OPKSSH authentication required. Open a Terminal connection to this host first.",
+ );
+ }
+ const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
+ await setupOPKSSHCertAuth(
+ config as import("ssh2").ConnectConfig,
+ client,
+ token,
+ username,
+ );
+ } else if (authType !== "none") {
+ throw new Error(`Unsupported auth type for transfer: ${authType}`);
+ }
- if (byte === 0) {
- nullBytes++;
+ return config;
+}
+
+function attachDedicatedKeyboardInteractive(
+ client: SSHClient,
+ host: SSHHost,
+): void {
+ client.on(
+ "keyboard-interactive",
+ (
+ _name: string,
+ _instructions: string,
+ _instructionsLang: string,
+ prompts: Array<{ prompt: string; echo: boolean }>,
+ finish: (responses: string[]) => void,
+ ) => {
+ const responses = prompts.map((p) => {
+ if (/password/i.test(p.prompt) && host.password) {
+ return host.password;
+ }
+ return "";
+ });
+ finish(responses);
+ },
+ );
+}
+
+async function startDedicatedTransferConnect(
+ client: SSHClient,
+ config: Record,
+ host: SSHHost,
+ userId: string,
+): Promise {
+ const proxyConfig: SOCKS5Config | null =
+ host.useSocks5 &&
+ (host.socks5Host ||
+ (host.socks5ProxyChain && host.socks5ProxyChain.length > 0))
+ ? {
+ useSocks5: host.useSocks5,
+ socks5Host: host.socks5Host,
+ socks5Port: host.socks5Port,
+ socks5Username: host.socks5Username,
+ socks5Password: host.socks5Password,
+ socks5ProxyChain: host.socks5ProxyChain,
+ }
+ : null;
+
+ const jumpHosts = host.jumpHosts;
+ const hasJumpHosts = jumpHosts && jumpHosts.length > 0;
+
+ if (hasJumpHosts) {
+ const jumpClient = await createJumpHostChain(
+ jumpHosts,
+ userId,
+ proxyConfig,
+ );
+ if (!jumpClient) {
+ throw new Error("Failed to connect through jump hosts for transfer");
}
- if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
- if (++nullBytes > 1) return true;
+ await new Promise((resolve, reject) => {
+ jumpClient.forwardOut(
+ "127.0.0.1",
+ 0,
+ host.ip,
+ host.port,
+ (err, stream) => {
+ if (err) {
+ jumpClient.end();
+ reject(
+ new Error(
+ `Failed to forward through jump host for transfer: ${err.message}`,
+ ),
+ );
+ return;
+ }
+ config.sock = stream;
+ client.connect(config);
+ resolve();
+ },
+ );
+ });
+ return;
+ }
+
+ if (proxyConfig) {
+ const proxySocket = await createSocks5Connection(
+ host.ip,
+ host.port,
+ proxyConfig,
+ );
+ if (proxySocket) {
+ config.sock = proxySocket;
}
}
- return nullBytes / sampleSize > 0.01;
+ client.connect(config);
}
+async function openDedicatedTransferSession(
+ browseSessionId: string,
+ dedicatedSessionId: string,
+ userId: string,
+ transferId: string,
+ options?: { allowBrowseDisconnected?: boolean },
+): Promise {
+ const browseSession = sshSessions[browseSessionId];
+ if (!options?.allowBrowseDisconnected && !browseSession?.isConnected) {
+ throw new Error("Browse SSH session not connected");
+ }
+ if (browseSession && !verifySessionOwnership(browseSession, userId)) {
+ throw new Error("Session access denied");
+ }
+
+ const hostId = browseSession
+ ? resolveBrowseHostId(browseSessionId, browseSession)
+ : (() => {
+ const parsed = Number.parseInt(browseSessionId, 10);
+ return Number.isFinite(parsed) ? parsed : undefined;
+ })();
+ if (!hostId) {
+ throw new Error("Cannot open transfer connection: unknown host");
+ }
+
+ const host = await resolveHostById(hostId, userId);
+ if (!host) {
+ throw new Error("Host not found for transfer connection");
+ }
+
+ if (sshSessions[dedicatedSessionId]?.isConnected) {
+ closeDedicatedTransferSession(dedicatedSessionId);
+ }
+
+ const client = new SSHClient();
+ attachDedicatedKeyboardInteractive(client, host);
+ const config = await buildDedicatedTransferConnectConfig(
+ host,
+ userId,
+ client,
+ );
+
+ fileLogger.info("Opening dedicated transfer SSH session", {
+ operation: "transfer_ssh_connect",
+ transferId,
+ browseSessionId,
+ dedicatedSessionId,
+ hostId,
+ ip: host.ip,
+ port: host.port,
+ username: host.username,
+ });
+
+ await new Promise((resolve, reject) => {
+ const connectTimeout = setTimeout(() => {
+ client.end();
+ reject(new Error("Transfer SSH connection timed out"));
+ }, 60000);
+
+ const fail = (err: Error) => {
+ clearTimeout(connectTimeout);
+ reject(err);
+ };
+
+ client.once("ready", () => {
+ clearTimeout(connectTimeout);
+ resolve();
+ });
+ client.once("error", fail);
+
+ void startDedicatedTransferConnect(client, config, host, userId).catch(
+ fail,
+ );
+ });
+
+ const session: SSHSession = {
+ client,
+ isConnected: true,
+ lastActive: Date.now(),
+ activeOperations: 0,
+ channelOpener: new ChannelOpenSerializer(),
+ userId,
+ ip: host.ip,
+ port: host.port,
+ hostId: host.id,
+ username: host.username,
+ transferDedicated: true,
+ transferId,
+ browseSessionId,
+ };
+
+ client.on("close", () => {
+ fileLogger.info("Dedicated transfer SSH connection closed", {
+ operation: "transfer_ssh_disconnected",
+ transferId,
+ dedicatedSessionId,
+ browseSessionId,
+ hostId,
+ });
+ const existing = sshSessions[dedicatedSessionId];
+ if (existing) {
+ existing.isConnected = false;
+ closeDedicatedTransferSession(dedicatedSessionId);
+ }
+ });
+
+ sshSessions[dedicatedSessionId] = session;
+ return session;
+}
+
+function closeDedicatedTransferSession(sessionId: string): void {
+ const session = sshSessions[sessionId];
+ if (!session?.transferDedicated) return;
+
+ fileLogger.info("Closing dedicated transfer SSH session", {
+ operation: "transfer_ssh_close",
+ sessionId,
+ transferId: session.transferId,
+ browseSessionId: session.browseSessionId,
+ });
+
+ try {
+ if (session.sftp) {
+ session.sftp.end();
+ session.sftp = undefined;
+ }
+ } catch {
+ // expected
+ }
+ session.sftpPending = undefined;
+
+ try {
+ session.client.end();
+ } catch {
+ // expected
+ }
+
+ clearTimeout(session.timeout);
+ delete sshSessions[sessionId];
+}
+
+app.use("/ssh/file_manager/ssh", (req, res, next) => {
+ if (
+ req.path === "/connect" ||
+ req.path === "/connect-totp" ||
+ req.path === "/connect-warpgate"
+ ) {
+ return next();
+ }
+ const sessionId = (req.query.sessionId as string) || req.body?.sessionId;
+ if (!sessionId) return next();
+ const session = sshSessions[sessionId];
+ if (!session) return next();
+ const userId = (req as AuthenticatedRequest).userId;
+ if (!verifySessionOwnership(session, userId)) {
+ return res.status(403).json({ error: "Session access denied" });
+ }
+ next();
+});
+
/**
* @openapi
* /ssh/file_manager/ssh/connect:
@@ -976,11 +722,17 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
};
let hostKeepaliveInterval: number | undefined;
let hostKeepaliveCountMax: number | undefined;
+ let resolvedIp = ip;
+ let resolvedPort = port;
+ let resolvedUsername = username;
if (hostId && userId && !password && !sshKey) {
try {
const { resolveHostById } = await import("./host-resolver.js");
const resolvedHost = await resolveHostById(hostId, userId);
if (resolvedHost) {
+ resolvedIp = resolvedHost.ip;
+ resolvedPort = resolvedHost.port;
+ resolvedUsername = resolvedHost.username;
resolvedCredentials = {
password: resolvedHost.password,
sshKey: resolvedHost.key,
@@ -1011,6 +763,9 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
const { resolveHostById } = await import("./host-resolver.js");
const resolvedHost = await resolveHostById(hostId, userId);
if (resolvedHost) {
+ resolvedIp = resolvedHost.ip;
+ resolvedPort = resolvedHost.port;
+ resolvedUsername = resolvedHost.username;
resolvedCredentials = {
password: resolvedHost.password,
sshKey: resolvedHost.key,
@@ -1039,9 +794,9 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
}
const config: Record = {
- host: ip?.replace(/^\[|\]$/g, "") || ip,
- port,
- username,
+ host: resolvedIp?.replace(/^\[|\]$/g, "") || resolvedIp,
+ port: resolvedPort,
+ username: resolvedUsername,
tryKeyboard: true,
keepaliveInterval:
typeof hostKeepaliveInterval === "number"
@@ -1054,8 +809,8 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
tcpKeepAliveInitialDelay: 30000,
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
hostId,
- ip,
- port,
+ resolvedIp,
+ resolvedPort,
null,
userId,
false,
@@ -1317,6 +1072,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
activeOperations: 0,
channelOpener: new ChannelOpenSerializer(),
userId,
+ ip,
+ port,
+ hostId,
+ username,
sudoPassword: resolvedCredentials.sudoPassword,
};
scheduleSessionCleanup(sessionId);
@@ -1963,6 +1722,10 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
activeOperations: 0,
channelOpener: new ChannelOpenSerializer(),
userId,
+ ip: session.ip,
+ port: session.port,
+ hostId: session.hostId,
+ username: session.username,
};
scheduleSessionCleanup(sessionId);
@@ -2166,6 +1929,10 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => {
activeOperations: 0,
channelOpener: new ChannelOpenSerializer(),
userId,
+ ip: session.ip,
+ port: session.port,
+ hostId: session.hostId,
+ username: session.username,
};
scheduleSessionCleanup(sessionId);
@@ -2408,3236 +2175,32 @@ app.post("/ssh/file_manager/ssh/keepalive", async (req, res) => {
});
});
-/**
- * @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();
+registerFileListingRoutes(app, {
+ sshSessions,
+ activeListRequests,
+ verifySessionOwnership,
});
-/**
- * @openapi
- * /ssh/file_manager/ssh/identifySymlink:
- * get:
- * summary: Identify symbolic link
- * description: Identifies the target of a symbolic link.
- * 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: Symbolic link information.
- * 400:
- * description: Missing required parameters or SSH connection not established.
- * 500:
- * description: Failed to identify symbolic link.
- */
-app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {
- const sessionId = req.query.sessionId as string;
- const sshConn = sshSessions[sessionId];
- const linkPath = 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" });
- }
-
- if (!linkPath) {
- return res.status(400).json({ error: "Link path is required" });
- }
-
- sshConn.lastActive = Date.now();
-
- const escapedPath = linkPath.replace(/'/g, "'\"'\"'");
- const command = `stat -L -c "%F" '${escapedPath}' && readlink -f '${escapedPath}'`;
-
- execChannel(sshConn, command, (err, stream) => {
- if (err) {
- fileLogger.error("SSH identifySymlink 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) {
- fileLogger.error(
- `SSH identifySymlink command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
- );
- return res.status(500).json({ error: `Command failed: ${errorData}` });
- }
-
- const [fileType, target] = data.trim().split("\n");
-
- res.json({
- path: linkPath,
- target: target,
- type: fileType.toLowerCase().includes("directory")
- ? "directory"
- : "file",
- });
- });
-
- stream.on("error", (streamErr) => {
- fileLogger.error("SSH identifySymlink stream error:", streamErr);
- if (!res.headersSent) {
- res.status(500).json({ error: `Stream error: ${streamErr.message}` });
- }
- });
- });
+registerFileContentRoutes(app, {
+ sshSessions,
+ verifySessionOwnership,
});
-/**
- * @openapi
- * /ssh/file_manager/ssh/resolvePath:
- * get:
- * summary: Resolve a path with environment variables
- * description: Expands environment variables and ~ in a path via the SSH session.
- * 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: The resolved absolute path.
- * 400:
- * description: Missing required parameters.
- * 500:
- * description: Failed to resolve path.
- */
-app.get("/ssh/file_manager/ssh/resolvePath", (req, res) => {
- const sessionId = req.query.sessionId as string;
- const sshConn = sshSessions[sessionId];
- const rawPath = 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" });
- }
-
- if (!rawPath) {
- return res.status(400).json({ error: "Path is required" });
- }
-
- sshConn.lastActive = Date.now();
-
- let command: string;
- if (rawPath.startsWith("~")) {
- const rest = rawPath.substring(1).replace(/'/g, "'\"'\"'");
- command = `echo ~'${rest}'`;
- } else {
- const escapedPath = rawPath.replace(/'/g, "'\"'\"'");
- command = `echo '${escapedPath}'`;
- }
-
- execChannel(sshConn, command, (err, stream) => {
- if (err) {
- fileLogger.error("SSH resolvePath 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) {
- fileLogger.error(
- `SSH resolvePath command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
- );
- return res.json({ resolvedPath: rawPath });
- }
-
- const resolved = data.trim();
- res.json({ resolvedPath: resolved || rawPath });
- });
-
- stream.on("error", (streamErr) => {
- fileLogger.error("SSH resolvePath stream error:", streamErr);
- if (!res.headersSent) {
- res.json({ resolvedPath: rawPath });
- }
- });
- });
+registerFileOperationRoutes(app, {
+ sshSessions,
+ verifySessionOwnership,
});
-/**
- * @openapi
- * /ssh/file_manager/ssh/readFile:
- * get:
- * summary: Read a file
- * description: Reads the content of a file from 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: The content of the file.
- * 400:
- * description: Missing required parameters or file too large.
- * 404:
- * description: File not found.
- * 500:
- * description: Failed to read file.
- */
-app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
- const sessionId = req.query.sessionId as string;
- const sshConn = sshSessions[sessionId];
- const filePath = 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" });
- }
-
- if (!filePath) {
- return res.status(400).json({ error: "File path is required" });
- }
-
- fileLogger.info("Reading file", {
- operation: "file_read",
- sessionId,
- userId,
- path: filePath,
- });
- sshConn.lastActive = Date.now();
-
- const MAX_READ_SIZE = 500 * 1024 * 1024;
- const escapedPath = filePath.replace(/'/g, "'\"'\"'");
-
- execChannel(
- sshConn,
- `stat -c%s '${escapedPath}' 2>/dev/null || wc -c < '${escapedPath}'`,
- (sizeErr, sizeStream) => {
- if (sizeErr) {
- fileLogger.error("SSH file size check error:", sizeErr);
- return res.status(500).json({ error: sizeErr.message });
- }
-
- let sizeData = "";
- let sizeErrorData = "";
-
- sizeStream.on("data", (chunk: Buffer) => {
- sizeData += chunk.toString();
- });
-
- sizeStream.stderr.on("data", (chunk: Buffer) => {
- sizeErrorData += chunk.toString();
- });
-
- sizeStream.on("close", (sizeCode) => {
- if (sizeCode !== 0) {
- const errorLower = sizeErrorData.toLowerCase();
- const isFileNotFound =
- errorLower.includes("no such file or directory") ||
- errorLower.includes("cannot access") ||
- errorLower.includes("not found") ||
- errorLower.includes("resource not found");
-
- fileLogger.error(`File size check failed: ${sizeErrorData}`);
- return res.status(isFileNotFound ? 404 : 500).json({
- error: `Cannot check file size: ${sizeErrorData}`,
- fileNotFound: isFileNotFound,
- });
- }
-
- const fileSize = parseInt(sizeData.trim(), 10);
-
- if (isNaN(fileSize)) {
- fileLogger.error("Invalid file size response:", sizeData);
- return res.status(500).json({ error: "Cannot determine file size" });
- }
-
- if (fileSize > MAX_READ_SIZE) {
- fileLogger.warn("File too large for reading", {
- operation: "file_read",
- sessionId,
- filePath,
- fileSize,
- maxSize: MAX_READ_SIZE,
- });
- return res.status(400).json({
- error: `File too large to open in editor. Maximum size is ${MAX_READ_SIZE / 1024 / 1024}MB, file is ${(fileSize / 1024 / 1024).toFixed(2)}MB. Use download instead.`,
- fileSize,
- maxSize: MAX_READ_SIZE,
- tooLarge: true,
- });
- }
-
- execChannel(sshConn, `cat '${escapedPath}'`, (err, stream) => {
- if (err) {
- fileLogger.error("SSH readFile error:", err);
- return res.status(500).json({ error: err.message });
- }
-
- let binaryData = Buffer.alloc(0);
- let errorData = "";
-
- stream.on("data", (chunk: Buffer) => {
- binaryData = Buffer.concat([binaryData, chunk]);
- });
-
- stream.stderr.on("data", (chunk: Buffer) => {
- errorData += chunk.toString();
- });
-
- stream.on("close", (code) => {
- if (code !== 0) {
- const isPermissionDenied = errorData
- .toLowerCase()
- .includes("permission denied");
-
- if (isPermissionDenied && sshConn.sudoPassword) {
- execWithSudoBuffer(
- sshConn,
- `cat '${escapedPath}'`,
- sshConn.sudoPassword,
- )
- .then((result) => {
- if (result.code !== 0) {
- return res.status(403).json({
- error: `Permission denied: ${result.stderr || result.stdout.toString("utf8")}`,
- needsSudo: true,
- });
- }
-
- const sudoData = result.stdout;
- const isBinary = detectBinary(sudoData);
- res.json({
- content: isBinary
- ? sudoData.toString("base64")
- : sudoData.toString("utf8"),
- isBinary,
- size: sudoData.length,
- });
- })
- .catch(() => {
- res
- .status(403)
- .json({ error: "Permission denied", needsSudo: true });
- });
- return;
- }
-
- fileLogger.error(
- `SSH readFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
- );
-
- const isFileNotFound =
- errorData.includes("No such file or directory") ||
- errorData.includes("cannot access") ||
- errorData.includes("not found");
-
- return res.status(isFileNotFound ? 404 : 500).json({
- error: `Command failed: ${errorData}`,
- fileNotFound: isFileNotFound,
- });
- }
-
- const isBinary = detectBinary(binaryData);
- fileLogger.success("File read successfully", {
- operation: "file_read_success",
- sessionId,
- userId,
- path: filePath,
- bytes: binaryData.length,
- });
-
- if (isBinary) {
- const base64Content = binaryData.toString("base64");
- res.json({
- content: base64Content,
- path: filePath,
- encoding: "base64",
- });
- } else {
- const textContent = binaryData.toString("utf8");
- res.json({
- content: textContent,
- path: filePath,
- encoding: "utf8",
- });
- }
- });
- });
- });
- },
- );
+registerFileDownloadRoutes(app, {
+ sshSessions,
+ scheduleSessionCleanup,
+ verifySessionOwnership,
});
-/**
- * @openapi
- * /ssh/file_manager/ssh/writeFile:
- * post:
- * summary: Write to a file
- * description: Writes content to a file on the remote host and preserves the existing permissions when the file already exists.
- * tags:
- * - File Manager
- * requestBody:
- * required: true
- * content:
- * application/json:
- * schema:
- * type: object
- * properties:
- * sessionId:
- * type: string
- * path:
- * type: string
- * content:
- * type: string
- * responses:
- * 200:
- * description: File written successfully.
- * 400:
- * description: Missing required parameters or SSH connection not established.
- * 500:
- * description: Failed to write file.
- */
-app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
- const { sessionId, path: filePath, content } = 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) {
- return res.status(400).json({ error: "File path is required" });
- }
-
- if (content === undefined) {
- return res.status(400).json({ error: "File content is required" });
- }
-
- const contentLength =
- typeof content === "string" ? content.length : Buffer.byteLength(content);
- fileLogger.info("Writing file", {
- operation: "file_write",
- sessionId,
- userId,
- path: filePath,
- bytes: contentLength,
- });
- sshConn.lastActive = Date.now();
-
- let preservedMode: number | undefined;
-
- const restoreOriginalMode = (
- sftp: import("ssh2").SFTPWrapper | null,
- onComplete: () => void,
- ) => {
- if (preservedMode === undefined) {
- onComplete();
- return;
- }
-
- const permissions = preservedMode.toString(8);
-
- if (sftp) {
- sftp.chmod(filePath, preservedMode, (chmodErr) => {
- if (chmodErr) {
- fileLogger.warn("Failed to restore file permissions after save", {
- operation: "file_write_restore_permissions",
- sessionId,
- userId,
- path: filePath,
- permissions,
- error: chmodErr.message,
- });
- } else {
- fileLogger.info("Restored file permissions after save", {
- operation: "file_write_restore_permissions",
- sessionId,
- userId,
- path: filePath,
- permissions,
- });
- }
-
- onComplete();
- });
- return;
- }
-
- const escapedPath = filePath.replace(/'/g, "'\"'\"'");
- const chmodCommand = `chmod ${permissions} '${escapedPath}' && echo "SUCCESS"`;
-
- execChannel(sshConn, chmodCommand, (err, stream) => {
- if (err) {
- fileLogger.warn("Failed to restore file permissions after save", {
- operation: "file_write_restore_permissions",
- sessionId,
- userId,
- path: filePath,
- permissions,
- error: err.message,
- });
- onComplete();
- return;
- }
-
- let outputData = "";
- let errorData = "";
-
- stream.on("data", (chunk: Buffer) => {
- outputData += chunk.toString();
- });
-
- stream.stderr.on("data", (chunk: Buffer) => {
- errorData += chunk.toString();
- });
-
- stream.on("close", (code) => {
- if (outputData.includes("SUCCESS")) {
- fileLogger.info("Restored file permissions after save", {
- operation: "file_write_restore_permissions",
- sessionId,
- userId,
- path: filePath,
- permissions,
- });
- } else {
- fileLogger.warn("Failed to restore file permissions after save", {
- operation: "file_write_restore_permissions",
- sessionId,
- userId,
- path: filePath,
- permissions,
- exitCode: code,
- error:
- errorData || "Permission restore command did not report success",
- });
- }
-
- onComplete();
- });
-
- stream.on("error", (streamErr) => {
- fileLogger.warn("Failed to restore file permissions after save", {
- operation: "file_write_restore_permissions",
- sessionId,
- userId,
- path: filePath,
- permissions,
- error: streamErr.message,
- });
- onComplete();
- });
- });
- };
-
- const trySFTP = () => {
- try {
- fileLogger.info("Opening SFTP channel", {
- operation: "file_sftp_open",
- sessionId,
- userId,
- path: filePath,
- });
- getSessionSftp(sshConn)
- .then((sftp) => {
- let fileBuffer;
- try {
- if (typeof content === "string") {
- try {
- const testBuffer = Buffer.from(content, "base64");
- if (testBuffer.toString("base64") === content) {
- fileBuffer = testBuffer;
- } else {
- fileBuffer = Buffer.from(content, "utf8");
- }
- } catch {
- fileBuffer = Buffer.from(content, "utf8");
- }
- } else if (Buffer.isBuffer(content)) {
- fileBuffer = content;
- } else {
- fileBuffer = Buffer.from(content);
- }
- } catch (bufferErr) {
- fileLogger.error("Buffer conversion error:", bufferErr);
- if (!res.headersSent) {
- return res
- .status(500)
- .json({ error: "Invalid file content format" });
- }
- return;
- }
-
- sftp.stat(filePath, (statErr, stats) => {
- try {
- if (statErr) {
- fileLogger.warn(
- "Failed to read existing file permissions before save",
- {
- operation: "file_write_stat",
- sessionId,
- userId,
- path: filePath,
- error: statErr.message,
- },
- );
- } else if (stats.isFile()) {
- preservedMode = stats.mode & 0o7777;
- }
-
- const writeStream = sftp.createWriteStream(filePath);
-
- let hasError = false;
- let hasFinished = false;
- let isFinalizing = false;
-
- const finalizeSuccess = () => {
- if (hasError || hasFinished) return;
- hasFinished = true;
- isFinalizing = false;
- fileLogger.success("File written successfully", {
- operation: "file_write_success",
- sessionId,
- userId,
- path: filePath,
- bytes: fileBuffer.length,
- });
- if (!res.headersSent) {
- res.json({
- message: "File written successfully",
- path: filePath,
- toast: {
- type: "success",
- message: `File written: ${filePath}`,
- },
- });
- }
- };
-
- writeStream.on("error", (streamErr) => {
- if (hasError || hasFinished || isFinalizing) return;
- hasError = true;
- isFinalizing = false;
- fileLogger.warn(
- `SFTP write failed, trying fallback method: ${streamErr.message}`,
- );
- tryFallbackMethod();
- });
-
- const finishWrite = () => {
- if (hasError || hasFinished || isFinalizing) return;
- isFinalizing = true;
- restoreOriginalMode(sftp, finalizeSuccess);
- };
-
- writeStream.on("finish", () => {
- finishWrite();
- });
-
- writeStream.on("close", () => {
- finishWrite();
- });
-
- try {
- writeStream.write(fileBuffer);
- writeStream.end();
- } catch (writeErr) {
- if (hasError || hasFinished) return;
- hasError = true;
- isFinalizing = false;
- fileLogger.warn(
- `SFTP write operation failed, trying fallback method: ${(writeErr as Error).message}`,
- );
- tryFallbackMethod();
- }
- } catch (callbackErr) {
- fileLogger.warn(
- `SFTP stat callback error, trying fallback method: ${(callbackErr as Error).message}`,
- );
- tryFallbackMethod();
- }
- });
- })
- .catch((err: Error) => {
- fileLogger.warn(
- `SFTP failed, trying fallback method: ${err.message}`,
- );
- tryFallbackMethod();
- });
- } catch (sftpErr) {
- fileLogger.warn(
- `SFTP connection error, trying fallback method: ${(sftpErr as Error).message}`,
- );
- tryFallbackMethod();
- }
- };
-
- const tryFallbackMethod = () => {
- if (!sshConn?.isConnected) {
- if (!res.headersSent) {
- return res.status(500).json({ error: "SSH session disconnected" });
- }
- return;
- }
- try {
- let contentBuffer: Buffer;
- if (typeof content === "string") {
- try {
- contentBuffer = Buffer.from(content, "base64");
- if (contentBuffer.toString("base64") !== content) {
- contentBuffer = Buffer.from(content, "utf8");
- }
- } catch {
- contentBuffer = Buffer.from(content, "utf8");
- }
- } else if (Buffer.isBuffer(content)) {
- contentBuffer = content;
- } else {
- contentBuffer = Buffer.from(content);
- }
- const base64Content = contentBuffer.toString("base64");
- const escapedPath = filePath.replace(/'/g, "'\"'\"'");
-
- const writeCommand = `echo '${base64Content}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
-
- execChannel(sshConn, writeCommand, (err, stream) => {
- if (err) {
- fileLogger.error("Fallback write command failed:", err);
- if (!res.headersSent) {
- return res.status(500).json({
- error: `Write failed: ${err.message}`,
- toast: {
- type: "error",
- message: `Write failed: ${err.message}`,
- },
- });
- }
- return;
- }
-
- let outputData = "";
- let errorData = "";
-
- stream.on("data", (chunk: Buffer) => {
- outputData += chunk.toString();
- });
-
- stream.stderr.on("data", (chunk: Buffer) => {
- errorData += chunk.toString();
- });
-
- stream.stderr.on("error", (stderrErr) => {
- fileLogger.error("Fallback write stderr error:", stderrErr);
- });
-
- stream.on("close", (code) => {
- if (outputData.includes("SUCCESS")) {
- restoreOriginalMode(null, () => {
- if (!res.headersSent) {
- res.json({
- message: "File written successfully",
- path: filePath,
- toast: {
- type: "success",
- message: `File written: ${filePath}`,
- },
- });
- }
- });
- } else {
- const isPermDenied = errorData
- .toLowerCase()
- .includes("permission denied");
- if (isPermDenied && sshConn.sudoPassword) {
- execWithSudo(
- sshConn,
- `bash -c "echo '${base64Content}' | base64 -d > '${escapedPath}' && echo SUCCESS"`,
- sshConn.sudoPassword,
- )
- .then(({ stdout, code: sudoCode }) => {
- if (sudoCode === 0 && stdout.includes("SUCCESS")) {
- restoreOriginalMode(null, () => {
- if (!res.headersSent) {
- res.json({
- message: "File written successfully",
- path: filePath,
- });
- }
- });
- } else if (!res.headersSent) {
- res
- .status(403)
- .json({ error: "Permission denied", needsSudo: true });
- }
- })
- .catch(() => {
- if (!res.headersSent) {
- res
- .status(403)
- .json({ error: "Permission denied", needsSudo: true });
- }
- });
- return;
- }
- fileLogger.error(
- `Fallback write failed with code ${code}: ${errorData}`,
- );
- if (!res.headersSent) {
- res.status(500).json({
- error: `Write failed: ${errorData}`,
- needsSudo: isPermDenied,
- toast: { type: "error", message: `Write failed: ${errorData}` },
- });
- }
- }
- });
-
- stream.on("error", (streamErr) => {
- fileLogger.error("Fallback write stream error:", streamErr);
- if (!res.headersSent) {
- res
- .status(500)
- .json({ error: `Write stream error: ${streamErr.message}` });
- }
- });
- });
- } catch (fallbackErr) {
- fileLogger.error("Fallback method failed:", fallbackErr);
- if (!res.headersSent) {
- res.status(500).json({
- error: `All write methods failed: ${(fallbackErr as Error).message}`,
- });
- }
- }
- };
-
- trySFTP();
-});
-
-/**
- * @openapi
- * /ssh/file_manager/ssh/uploadFile:
- * post:
- * summary: Upload a file
- * description: Uploads a file to the remote host.
- * tags:
- * - File Manager
- * requestBody:
- * required: true
- * content:
- * application/json:
- * schema:
- * type: object
- * properties:
- * sessionId:
- * type: string
- * path:
- * type: string
- * content:
- * type: string
- * fileName:
- * type: string
- * responses:
- * 200:
- * description: File uploaded successfully.
- * 400:
- * description: Missing required parameters or SSH connection not established.
- * 500:
- * description: Failed to upload file.
- */
-app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
- const { sessionId, path: filePath, content, 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 || content === undefined) {
- return res
- .status(400)
- .json({ error: "File path, name, and content are required" });
- }
-
- sshConn.lastActive = Date.now();
-
- const contentSize =
- typeof content === "string"
- ? Buffer.byteLength(content, "utf8")
- : content.length;
-
- const fullPath = filePath.endsWith("/")
- ? filePath + fileName
- : filePath + "/" + fileName;
- const uploadStartTime = Date.now();
- fileLogger.info("File upload started", {
- operation: "file_upload_start",
- sessionId,
- userId,
- path: fullPath,
- bytes: contentSize,
- });
-
- const trySFTP = () => {
- try {
- fileLogger.info("Opening SFTP channel", {
- operation: "file_sftp_open",
- sessionId,
- userId,
- path: fullPath,
- });
- getSessionSftp(sshConn)
- .then((sftp) => {
- let fileBuffer;
- try {
- if (typeof content === "string") {
- fileBuffer = Buffer.from(content, "base64");
- } else if (Buffer.isBuffer(content)) {
- fileBuffer = content;
- } else {
- fileBuffer = Buffer.from(content);
- }
- } catch (bufferErr) {
- fileLogger.error("Buffer conversion error:", bufferErr);
- if (!res.headersSent) {
- return res
- .status(500)
- .json({ error: "Invalid file content format" });
- }
- return;
- }
-
- const writeStream = sftp.createWriteStream(fullPath);
-
- let hasError = false;
- let hasFinished = false;
-
- writeStream.on("error", (streamErr) => {
- if (hasError || hasFinished) return;
- hasError = true;
- fileLogger.warn(
- `SFTP write failed, trying fallback method: ${streamErr.message}`,
- {
- operation: "file_upload",
- sessionId,
- fileName,
- fileSize: contentSize,
- error: streamErr.message,
- },
- );
- tryFallbackMethod();
- });
-
- writeStream.on("finish", () => {
- if (hasError || hasFinished) return;
- hasFinished = true;
- fileLogger.success("File upload completed", {
- operation: "file_upload_complete",
- sessionId,
- userId,
- path: fullPath,
- bytes: fileBuffer.length,
- duration: Date.now() - uploadStartTime,
- });
- if (!res.headersSent) {
- res.json({
- message: "File uploaded successfully",
- path: fullPath,
- toast: {
- type: "success",
- message: `File uploaded: ${fullPath}`,
- },
- });
- }
- });
-
- writeStream.on("close", () => {
- if (hasError || hasFinished) return;
- hasFinished = true;
- fileLogger.success("File upload completed", {
- operation: "file_upload_complete",
- sessionId,
- userId,
- path: fullPath,
- bytes: fileBuffer.length,
- duration: Date.now() - uploadStartTime,
- });
- if (!res.headersSent) {
- res.json({
- message: "File uploaded successfully",
- path: fullPath,
- toast: {
- type: "success",
- message: `File uploaded: ${fullPath}`,
- },
- });
- }
- });
-
- try {
- writeStream.write(fileBuffer);
- writeStream.end();
- } catch (writeErr) {
- if (hasError || hasFinished) return;
- hasError = true;
- fileLogger.warn(
- `SFTP write operation failed, trying fallback method: ${(writeErr as Error).message}`,
- );
- tryFallbackMethod();
- }
- })
- .catch((err: Error) => {
- fileLogger.warn(
- `SFTP failed, trying fallback method: ${err.message}`,
- );
- tryFallbackMethod();
- });
- } catch (sftpErr) {
- fileLogger.warn(
- `SFTP connection error, trying fallback method: ${(sftpErr as Error).message}`,
- );
- tryFallbackMethod();
- }
- };
-
- const tryFallbackMethod = () => {
- if (!sshConn?.isConnected) {
- if (!res.headersSent) {
- return res.status(500).json({ error: "SSH session disconnected" });
- }
- return;
- }
- try {
- let contentBuffer: Buffer;
- if (typeof content === "string") {
- try {
- contentBuffer = Buffer.from(content, "base64");
- if (contentBuffer.toString("base64") !== content) {
- contentBuffer = Buffer.from(content, "utf8");
- }
- } catch {
- contentBuffer = Buffer.from(content, "utf8");
- }
- } else if (Buffer.isBuffer(content)) {
- contentBuffer = content;
- } else {
- contentBuffer = Buffer.from(content);
- }
- const base64Content = contentBuffer.toString("base64");
- const chunkSize = 1000000;
- const chunks = [];
-
- for (let i = 0; i < base64Content.length; i += chunkSize) {
- chunks.push(base64Content.slice(i, i + chunkSize));
- }
-
- if (!sshConn?.isConnected) {
- fileLogger.error("SSH connection lost before fallback upload", {
- operation: "file_upload_fallback",
- sessionId,
- path: fullPath,
- });
- if (!res.headersSent) {
- return res
- .status(500)
- .json({ error: "SSH connection lost during upload" });
- }
- return;
- }
-
- if (chunks.length === 1) {
- const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
-
- const writeCommand = `echo '${chunks[0]}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
-
- execChannel(sshConn, writeCommand, (err, stream) => {
- if (err) {
- fileLogger.error("Fallback upload command failed:", err);
- if (!res.headersSent) {
- return res
- .status(500)
- .json({ error: `Upload failed: ${err.message}` });
- }
- return;
- }
-
- let outputData = "";
- let errorData = "";
-
- stream.on("data", (chunk: Buffer) => {
- outputData += chunk.toString();
- });
-
- stream.stderr.on("data", (chunk: Buffer) => {
- errorData += chunk.toString();
- });
-
- stream.stderr.on("error", (stderrErr) => {
- fileLogger.error("Fallback upload stderr error:", stderrErr);
- });
-
- stream.on("close", (code) => {
- if (outputData.includes("SUCCESS")) {
- if (!res.headersSent) {
- res.json({
- message: "File uploaded successfully",
- path: fullPath,
- toast: {
- type: "success",
- message: `File uploaded: ${fullPath}`,
- },
- });
- }
- } else {
- fileLogger.error(
- `Fallback upload failed with code ${code}: ${errorData}`,
- );
- if (!res.headersSent) {
- res.status(500).json({
- error: `Upload failed: ${errorData}`,
- toast: {
- type: "error",
- message: `Upload failed: ${errorData}`,
- },
- });
- }
- }
- });
-
- stream.on("error", (streamErr) => {
- fileLogger.error("Fallback upload stream error:", streamErr);
- if (!res.headersSent) {
- res
- .status(500)
- .json({ error: `Upload stream error: ${streamErr.message}` });
- }
- });
- });
- } else {
- const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
-
- let writeCommand = `> '${escapedPath}'`;
-
- chunks.forEach((chunk) => {
- writeCommand += ` && echo '${chunk}' | base64 -d >> '${escapedPath}'`;
- });
-
- writeCommand += ` && echo "SUCCESS"`;
-
- execChannel(sshConn, writeCommand, (err, stream) => {
- if (err) {
- fileLogger.error("Chunked fallback upload failed:", err);
- if (!res.headersSent) {
- return res
- .status(500)
- .json({ error: `Chunked upload failed: ${err.message}` });
- }
- return;
- }
-
- let outputData = "";
- let errorData = "";
-
- stream.on("data", (chunk: Buffer) => {
- outputData += chunk.toString();
- });
-
- stream.stderr.on("data", (chunk: Buffer) => {
- errorData += chunk.toString();
- });
-
- stream.stderr.on("error", (stderrErr) => {
- fileLogger.error(
- "Chunked fallback upload stderr error:",
- stderrErr,
- );
- });
-
- stream.on("close", (code) => {
- if (outputData.includes("SUCCESS")) {
- if (!res.headersSent) {
- res.json({
- message: "File uploaded successfully",
- path: fullPath,
- toast: {
- type: "success",
- message: `File uploaded: ${fullPath}`,
- },
- });
- }
- } else {
- fileLogger.error(
- `Chunked fallback upload failed with code ${code}: ${errorData}`,
- );
- if (!res.headersSent) {
- res.status(500).json({
- error: `Chunked upload failed: ${errorData}`,
- toast: {
- type: "error",
- message: `Chunked upload failed: ${errorData}`,
- },
- });
- }
- }
- });
-
- stream.on("error", (streamErr) => {
- fileLogger.error(
- "Chunked fallback upload stream error:",
- streamErr,
- );
- if (!res.headersSent) {
- res.status(500).json({
- error: `Chunked upload stream error: ${streamErr.message}`,
- });
- }
- });
- });
- }
- } catch (fallbackErr) {
- fileLogger.error("Fallback method failed:", fallbackErr);
- if (!res.headersSent) {
- res
- .status(500)
- .json({ error: `All upload methods failed: ${fallbackErr.message}` });
- }
- }
- };
-
- trySFTP();
-});
-
-/**
- * @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 => {
- 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}` });
- }
- });
- });
-});
-
-/**
- * @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}` });
- }
- }
-});
-
-/**
- * @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" });
- }
- });
- });
+registerFileActionRoutes(app, {
+ sshSessions,
+ scheduleSessionCleanup,
+ verifySessionOwnership,
});
/**
@@ -5937,7 +2500,7 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
const fileNames = paths
.map((p) => {
const name = p.split("/").pop();
- return `'${escapeShell(name || "")}'`;
+ return `'./${escapeShell(name || "")}'`;
})
.join(" ");
@@ -5954,17 +2517,17 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
const escapedArchive = escapeShell(archivePath);
if (compressionFormat === "zip") {
- compressCommand = `cd '${escapedDir}' && zip -r '${escapedArchive}' ${fileNames}`;
+ compressCommand = `cd '${escapedDir}' && zip -r '${escapedArchive}' -- ${fileNames}`;
} else if (compressionFormat === "tar.gz" || compressionFormat === "tgz") {
- compressCommand = `cd '${escapedDir}' && tar -czf '${escapedArchive}' ${fileNames}`;
+ compressCommand = `cd '${escapedDir}' && tar -czf '${escapedArchive}' -- ${fileNames}`;
} else if (compressionFormat === "tar.bz2" || compressionFormat === "tbz2") {
- compressCommand = `cd '${escapedDir}' && tar -cjf '${escapedArchive}' ${fileNames}`;
+ compressCommand = `cd '${escapedDir}' && tar -cjf '${escapedArchive}' -- ${fileNames}`;
} else if (compressionFormat === "tar.xz") {
- compressCommand = `cd '${escapedDir}' && tar -cJf '${escapedArchive}' ${fileNames}`;
+ compressCommand = `cd '${escapedDir}' && tar -cJf '${escapedArchive}' -- ${fileNames}`;
} else if (compressionFormat === "tar") {
- compressCommand = `cd '${escapedDir}' && tar -cf '${escapedArchive}' ${fileNames}`;
+ compressCommand = `cd '${escapedDir}' && tar -cf '${escapedArchive}' -- ${fileNames}`;
} else if (compressionFormat === "7z") {
- compressCommand = `cd '${escapedDir}' && 7z a '${escapedArchive}' ${fileNames}`;
+ compressCommand = `cd '${escapedDir}' && 7z a '${escapedArchive}' -- ${fileNames}`;
} else {
return res.status(400).json({ error: "Unsupported compression format" });
}
@@ -6081,6 +2644,222 @@ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
});
});
+const hostTransferDeps: HostTransferDeps = {
+ sshSessions,
+ getSessionSftp,
+ execChannel,
+ verifySessionOwnership,
+ openDedicatedTransferSession,
+ closeDedicatedTransferSession,
+};
+
+app.post("/ssh/file_manager/ssh/transferMethodPreview", async (req, res) => {
+ const {
+ sourceSessionId,
+ destSessionId,
+ sourcePaths,
+ destPath,
+ methodPreference,
+ } = req.body;
+ const userId = (req as AuthenticatedRequest).userId;
+
+ if (
+ !sourceSessionId ||
+ !destSessionId ||
+ !destPath ||
+ !sourcePaths ||
+ !Array.isArray(sourcePaths) ||
+ sourcePaths.length === 0
+ ) {
+ return res.status(400).json({ error: "Missing required parameters" });
+ }
+
+ try {
+ const preview = await previewArchiveTransferMethod(hostTransferDeps, {
+ sourceSessionId,
+ destSessionId,
+ sourcePaths,
+ destPath,
+ methodPreference:
+ methodPreference === "tar" || methodPreference === "item_sftp"
+ ? methodPreference
+ : "auto",
+ userId,
+ });
+ res.json(preview);
+ } catch (err) {
+ fileLogger.error("Failed to preview transfer method", err, {
+ operation: "host_transfer",
+ sourceSessionId,
+ destSessionId,
+ sourcePaths,
+ });
+ res.status(500).json({
+ error: err instanceof Error ? err.message : "Failed to preview method",
+ });
+ }
+});
+
+app.post("/ssh/file_manager/ssh/transferToHost", async (req, res) => {
+ const {
+ sourceSessionId,
+ sourcePaths,
+ destSessionId,
+ destPath,
+ move,
+ methodPreference,
+ parallelSegmentCount: parallelSegmentCountRaw,
+ } = req.body;
+ const userId = (req as AuthenticatedRequest).userId;
+
+ if (
+ !sourceSessionId ||
+ !destSessionId ||
+ !destPath ||
+ !sourcePaths ||
+ !Array.isArray(sourcePaths) ||
+ sourcePaths.length === 0
+ ) {
+ return res.status(400).json({ error: "Missing required parameters" });
+ }
+
+ const sourceSession = sshSessions[sourceSessionId];
+ const destSession = sshSessions[destSessionId];
+
+ if (!sourceSession?.isConnected || !destSession?.isConnected) {
+ return res.status(400).json({ error: "SSH session not connected" });
+ }
+
+ if (
+ !verifySessionOwnership(sourceSession, userId) ||
+ !verifySessionOwnership(destSession, userId)
+ ) {
+ return res.status(403).json({ error: "Session access denied" });
+ }
+
+ sourceSession.lastActive = Date.now();
+ destSession.lastActive = Date.now();
+ scheduleSessionCleanup(sourceSessionId);
+ scheduleSessionCleanup(destSessionId);
+
+ try {
+ const rawParallel = Number(parallelSegmentCountRaw);
+ const parallelSegmentCount = Number.isFinite(rawParallel)
+ ? Math.max(1, Math.min(8, Math.floor(rawParallel)))
+ : 2;
+
+ const { transferId } = startHostTransfer(hostTransferDeps, {
+ sourceSessionId,
+ sourcePaths,
+ destSessionId,
+ destPath,
+ move: !!move,
+ userId,
+ methodPreference:
+ methodPreference === "tar" || methodPreference === "item_sftp"
+ ? methodPreference
+ : "auto",
+ parallelSegmentCount,
+ });
+
+ res.json({ transferId });
+ } catch (err) {
+ fileLogger.error("Failed to start host transfer", err, {
+ operation: "host_transfer",
+ sourceSessionId,
+ destSessionId,
+ sourcePaths,
+ });
+ res.status(500).json({ error: "Failed to start transfer" });
+ }
+});
+
+app.get("/ssh/file_manager/ssh/activeTransfers", async (req, res) => {
+ const userId = (req as unknown as AuthenticatedRequest).userId;
+ if (!userId) {
+ return res.status(401).json({ error: "Authentication required" });
+ }
+
+ await probeHungStreamTransfers(hostTransferDeps);
+ res.json({ transfers: listActiveTransfers(userId) });
+});
+
+app.get(
+ "/ssh/file_manager/ssh/transferStatus/:transferId",
+ async (req, res) => {
+ const userId = (req as unknown as AuthenticatedRequest).userId;
+ const transferId = req.params.transferId;
+
+ if (!userId) {
+ return res.status(401).json({ error: "Authentication required" });
+ }
+
+ await probeHungStreamTransfers(hostTransferDeps);
+ const status = getTransferStatus(transferId, userId);
+ if (!status) {
+ return res.status(404).json({ error: "Transfer not found" });
+ }
+
+ res.json(status);
+ },
+);
+
+app.post("/ssh/file_manager/ssh/transferCancel/:transferId", (req, res) => {
+ const userId = (req as unknown as AuthenticatedRequest).userId;
+ const transferId = req.params.transferId;
+
+ const cancelled = requestTransferCancel(transferId, userId);
+ if (!cancelled) {
+ return res.status(404).json({ error: "Transfer not found or not running" });
+ }
+
+ res.json({ ok: true });
+});
+
+app.post(
+ "/ssh/file_manager/ssh/transferCleanup/:transferId",
+ async (req, res) => {
+ const userId = (req as unknown as AuthenticatedRequest).userId;
+ const transferId = req.params.transferId;
+
+ if (!userId) {
+ return res.status(401).json({ error: "Authentication required" });
+ }
+
+ try {
+ const result = await cleanupCancelledTransfer(
+ hostTransferDeps,
+ transferId,
+ userId,
+ );
+ res.json(result);
+ } catch (err) {
+ const message =
+ err instanceof Error ? err.message : "Failed to clean up transfer";
+ const status = message === "Transfer not found" ? 404 : 400;
+ res.status(status).json({ error: message });
+ }
+ },
+);
+
+app.post("/ssh/file_manager/ssh/transferRetry/:transferId", (req, res) => {
+ const userId = (req as unknown as AuthenticatedRequest).userId;
+ const transferId = req.params.transferId;
+
+ if (!userId) {
+ return res.status(401).json({ error: "Authentication required" });
+ }
+
+ const retried = retryHostTransfer(hostTransferDeps, transferId, userId);
+ if (!retried) {
+ return res
+ .status(404)
+ .json({ error: "Transfer not found or not retryable" });
+ }
+
+ res.json({ ok: true, transferId });
+});
+
process.on("SIGINT", () => {
Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0);
diff --git a/src/backend/ssh/host-resolver.ts b/src/backend/ssh/host-resolver.ts
index b741794a..da2dc99b 100644
--- a/src/backend/ssh/host-resolver.ts
+++ b/src/backend/ssh/host-resolver.ts
@@ -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(
diff --git a/src/backend/ssh/host-transfer.ts b/src/backend/ssh/host-transfer.ts
new file mode 100644
index 00000000..ef8aa0d0
--- /dev/null
+++ b/src/backend/ssh/host-transfer.ts
@@ -0,0 +1,3445 @@
+import { randomUUID } from "crypto";
+import { networkInterfaces } from "os";
+import { performance } from "node:perf_hooks";
+import type { ClientChannel } from "ssh2";
+import { fileLogger } from "../utils/logger.js";
+import {
+ basename,
+ buildPathFromSegments,
+ dirname,
+ getWorkingDir,
+ inferPlatformFromPath,
+ joinPath,
+ normalizeSftpPath,
+ pathsOverlap,
+ sftpPathToLocalPath,
+ splitPathSegments,
+ type TransferPlatform,
+} from "./transfer-paths.js";
+import {
+ buildTransferScanSummary,
+ getArchiveTransferReasonKey,
+ resolveArchiveTransferMethod,
+ type TransferMethodPreference,
+ type TransferScanSummary,
+} from "./transfer-routing.js";
+
+export type {
+ TransferMethodPreference,
+ TransferScanSummary,
+} from "./transfer-routing.js";
+
+export interface TransferMethodPreview {
+ methodPreference: TransferMethodPreference;
+ resolvedMethod: "tar" | "item_sftp";
+ reasonKey: ReturnType;
+ sourcePlatform: TransferPlatform;
+ destPlatform: TransferPlatform;
+ sourceHasTar: boolean;
+ destHasTar: boolean;
+ summary: TransferScanSummary;
+}
+
+type SFTPWrapper = import("ssh2").SFTPWrapper;
+
+export interface SSHSessionLike {
+ client: import("ssh2").Client;
+ isConnected: boolean;
+ lastActive: number;
+ activeOperations: number;
+ sftp?: SFTPWrapper;
+ sftpPending?: Promise;
+ channelOpener: { run: (fn: () => Promise) => Promise };
+ userId?: string;
+ ip?: string;
+ port?: number;
+ transferPlatform?: TransferPlatform;
+}
+
+export interface HostTransferDeps {
+ sshSessions: Record;
+ getSessionSftp: (session: SSHSessionLike) => Promise;
+ execChannel: (
+ session: SSHSessionLike,
+ command: string,
+ callback: (err: Error | undefined, stream: ClientChannel) => void,
+ ) => void;
+ verifySessionOwnership: (session: SSHSessionLike, userId: string) => boolean;
+ openDedicatedTransferSession: (
+ browseSessionId: string,
+ dedicatedSessionId: string,
+ userId: string,
+ transferId: string,
+ options?: { allowBrowseDisconnected?: boolean },
+ ) => Promise;
+ closeDedicatedTransferSession: (sessionId: string) => void;
+}
+
+export type TransferPhase =
+ | "compressing"
+ | "transferring"
+ | "extracting"
+ | "reconnecting";
+export type TransferStatus =
+ | "running"
+ | "success"
+ | "partial"
+ | "error"
+ | "cancelled";
+export type TransferMethod = "stream" | "tar" | "item_sftp";
+
+export type TransferHopId =
+ | "source_read"
+ | "dest_sftp_write"
+ | "dest_local_write";
+
+export interface TransferHopMetrics {
+ id: TransferHopId;
+ bytes: number;
+ /** Wall-clock span from first I/O on this hop to last I/O complete. */
+ spanMs: number;
+ mbPerSec: number;
+}
+
+export interface TransferTimings {
+ prepareDestMs?: number;
+ compressMs?: number;
+ transferMs?: number;
+ extractMs?: number;
+ sourceDeleteMs?: number;
+ totalMs?: number;
+ transferBytes?: number;
+ endToEndMbPerSec?: number;
+ hops?: TransferHopMetrics[];
+}
+
+export interface TransferProgress {
+ transferId: string;
+ status: TransferStatus;
+ phase: TransferPhase;
+ bytesTransferred?: number;
+ totalBytes?: number;
+ itemsCompleted?: number;
+ totalItems?: number;
+ failedPaths?: string[];
+ message?: string;
+ method?: TransferMethod;
+ sourcePaths?: string[];
+ destPath?: string;
+ userId?: string;
+ sourceSessionId?: string;
+ destSessionId?: string;
+ dedicatedSourceSessionId?: string;
+ dedicatedDestSessionId?: string;
+ startedAt?: number;
+ lastActivityAt?: number;
+ reconnectingAt?: number;
+ timings?: TransferTimings;
+ sourceDeleted?: boolean;
+ moveRequested?: boolean;
+ methodPreference?: TransferMethodPreference;
+ parallelSegmentCount?: number;
+ /** Destination paths created or partially written before cancel. */
+ destArtifacts?: string[];
+ tempArchivePath?: string;
+ partialDestRemaining?: boolean;
+ cleanupCompleted?: boolean;
+ retryable?: boolean;
+ requestSnapshot?: {
+ sourceSessionId: string;
+ sourcePaths: string[];
+ destSessionId: string;
+ destPath: string;
+ move?: boolean;
+ methodPreference?: TransferMethodPreference;
+ parallelSegmentCount?: number;
+ };
+}
+
+export interface TransferRequest {
+ sourceSessionId: string;
+ sourcePaths: string[];
+ destSessionId: string;
+ destPath: string;
+ move?: boolean;
+ userId: string;
+ methodPreference?: TransferMethodPreference;
+ /** Parallel 256 MiB segment lanes for single-file SFTP copy (default 2). */
+ parallelSegmentCount?: number;
+}
+
+const activeTransfers = new Map();
+const cancelRequestedTransfers = new Set();
+
+/** In-flight pipelined SFTP reads; force-closed when the user cancels. */
+interface ActiveXferControl {
+ abort: (err: Error) => void;
+ closeResources: () => Promise;
+}
+const activeXferControls = new Map();
+const cancelWatchdogs = new Map>();
+
+class TransferCancelledError extends Error {
+ constructor() {
+ super("Transfer cancelled");
+ this.name = "TransferCancelledError";
+ }
+}
+
+class TransferStalledError extends Error {
+ readonly byteOffset?: number;
+ readonly segmentIndex?: number;
+
+ constructor(byteOffset?: number, segmentIndex?: number) {
+ const pos = byteOffset !== undefined ? ` at byte offset ${byteOffset}` : "";
+ const seg = segmentIndex !== undefined ? ` (segment ${segmentIndex})` : "";
+ super(`Transfer stalled — no data moved for 45 seconds${pos}${seg}`);
+ this.name = "TransferStalledError";
+ this.byteOffset = byteOffset;
+ this.segmentIndex = segmentIndex;
+ }
+}
+
+class TransferConnectionLostError extends Error {
+ constructor(message = "Transfer SSH connection lost") {
+ super(message);
+ this.name = "TransferConnectionLostError";
+ }
+}
+
+function throwIfCancelled(transferId: string): void {
+ if (cancelRequestedTransfers.has(transferId)) {
+ throw new TransferCancelledError();
+ }
+}
+
+function createTransferShouldAbort(transferId: string): () => boolean {
+ return () => {
+ if (cancelRequestedTransfers.has(transferId)) {
+ return true;
+ }
+ const progress = activeTransfers.get(transferId);
+ return progress !== undefined && progress.status !== "running";
+ };
+}
+
+export function requestTransferCancel(
+ transferId: string,
+ userId: string,
+): boolean {
+ const progress = activeTransfers.get(transferId);
+ if (
+ !progress ||
+ progress.userId !== userId ||
+ progress.status !== "running"
+ ) {
+ return false;
+ }
+ cancelRequestedTransfers.add(transferId);
+ updateTransfer(transferId, { message: "Cancellation requested" });
+ void forceAbortActiveXfer(transferId);
+
+ if (!cancelWatchdogs.has(transferId)) {
+ cancelWatchdogs.set(
+ transferId,
+ setTimeout(() => {
+ cancelWatchdogs.delete(transferId);
+ const current = activeTransfers.get(transferId);
+ if (
+ current?.status === "running" &&
+ cancelRequestedTransfers.has(transferId)
+ ) {
+ void forceAbortActiveXfer(transferId);
+ finalizeTransfer(
+ transferId,
+ cancelledProgressPatch(current, {
+ status: "cancelled",
+ phase: current.phase ?? "transferring",
+ message: "Transfer cancelled by user",
+ method: current.method,
+ sourcePaths: current.sourcePaths,
+ destPath: current.destPath,
+ bytesTransferred: current.bytesTransferred,
+ totalBytes: current.totalBytes,
+ itemsCompleted: current.itemsCompleted,
+ totalItems: current.totalItems,
+ moveRequested: current.moveRequested,
+ sourceDeleted: false,
+ }),
+ );
+ fileLogger.warn("Force-finalized stuck transfer after cancel", {
+ operation: "host_transfer",
+ transferId,
+ });
+ }
+ }, 8000),
+ );
+ }
+
+ return true;
+}
+
+async function forceAbortActiveXfer(transferId: string): Promise {
+ const control = activeXferControls.get(transferId);
+ if (!control) return;
+ control.abort(new TransferCancelledError());
+ await control.closeResources().catch(() => {});
+}
+const SMALL_FILE_SYNC_THRESHOLD = 10 * 1024 * 1024;
+/** OpenSSH SFTP max packet payload is ~256 KiB; larger chunks cut round-trips. */
+const SFTP_XFER_CHUNK_SIZE = 256 * 1024;
+/** Pipelined in-flight READ requests per leg (ssh2 fastGet/fastPut default is 64). */
+const SFTP_XFER_CONCURRENCY = 32;
+/** Reset pipelined scheduler every segment to avoid long-run deadlocks at GiB boundaries. */
+const SFTP_XFER_SEGMENT_SIZE = 256 * 1024 * 1024;
+/** Files above this size use segmented copy; smaller files use a single scheduler run. */
+const SFTP_XFER_SEGMENT_THRESHOLD = 32 * 1024 * 1024;
+/** Per-segment attempts before giving up (sequential and parallel). */
+const SFTP_SEQUENTIAL_SEGMENT_MAX_ATTEMPTS = 4;
+/** Per-lane attempts in parallel mode (each opens a fresh lane SSH pair quickly). */
+const SFTP_PARALLEL_SEGMENT_MAX_ATTEMPTS = 4;
+/** Full copy restarts after segment exhaustion. */
+const SFTP_SEQUENTIAL_COPY_MAX_ATTEMPTS = 2;
+const SFTP_PARALLEL_COPY_MAX_ATTEMPTS = 2;
+/** Short backoff before opening fresh dedicated SSH sessions. */
+const TRANSFER_SESSION_RESET_DELAYS_MS = [1000, 2000, 3000];
+const DEFAULT_PARALLEL_SEGMENT_COUNT = 2;
+const MAX_PARALLEL_SEGMENT_COUNT = 8;
+const TRANSFER_HANDLE_CLOSE_TIMEOUT_MS = 2500;
+const HUNG_TRANSFER_MS = 90_000;
+const HUNG_RECONNECTING_MS = 180_000;
+const TRANSFER_PROGRESS_INTERVAL_MS = 200;
+const SFTP_OPEN_READ = 0x00000001;
+/** WRITE | CREATE | TRUNCATE — new file or overwrite from start. */
+const SFTP_OPEN_WRITE = 0x00000002 | 0x00000008 | 0x00000010;
+/** READ | WRITE | CREATE — resume into an existing partial file without truncating. */
+const SFTP_OPEN_WRITE_RESUME = 0x00000001 | 0x00000002 | 0x00000008;
+
+interface TransferReconnectContext {
+ deps: HostTransferDeps;
+ userId: string;
+ browseSourceSessionId: string;
+ browseDestSessionId: string;
+ dedicatedSourceSessionId: string;
+ dedicatedDestSessionId: string;
+ transferId: string;
+}
+
+type TransferReconnectMeta = Omit<
+ TransferReconnectContext,
+ "deps" | "transferId"
+>;
+
+function buildTransferReconnectContext(
+ deps: HostTransferDeps,
+ transferId: string,
+ meta: TransferReconnectMeta,
+): TransferReconnectContext {
+ return { deps, transferId, ...meta };
+}
+
+function isRecoverableTransferConnectionError(err: unknown): boolean {
+ if (!(err instanceof Error)) return false;
+ const msg = err.message.toLowerCase();
+ return (
+ msg.includes("no response from server") ||
+ msg.includes("connection lost") ||
+ msg.includes("not connected") ||
+ msg.includes("econnreset") ||
+ msg.includes("econnrefused") ||
+ msg.includes("etimedout") ||
+ msg.includes("socket hang up") ||
+ msg.includes("protocol error") ||
+ msg.includes("connection closed") ||
+ msg.includes("channel open failure")
+ );
+}
+
+function isRecoverableTransferError(err: unknown): boolean {
+ return (
+ err instanceof TransferStalledError ||
+ err instanceof TransferConnectionLostError ||
+ isRecoverableTransferConnectionError(err)
+ );
+}
+
+async function probeDestResumeOffset(
+ destSftp: SFTPWrapper,
+ destPath: string,
+ fileSize: number,
+): Promise {
+ try {
+ const destStat = await promisifySftpStat(destSftp, destPath);
+ return Math.min(destStat.size, fileSize);
+ } catch {
+ return 0;
+ }
+}
+
+function buildStreamTransferTimings(
+ transferId: string,
+ fileSize: number,
+ prepareDestMs?: number,
+): TransferTimings {
+ const progress = activeTransfers.get(transferId);
+ const wallStart = progress?.startedAt ?? Date.now();
+ const totalMs = elapsedMs(wallStart);
+ const prepare = prepareDestMs ?? progress?.timings?.prepareDestMs ?? 0;
+ const dataMs = Math.max(1, totalMs - prepare);
+
+ return {
+ ...progress?.timings,
+ prepareDestMs: prepare > 0 ? prepare : undefined,
+ transferMs: dataMs,
+ transferBytes: fileSize,
+ endToEndMbPerSec: computeTransferMbPerSec(fileSize, dataMs),
+ totalMs,
+ };
+}
+
+async function finalizeStreamTransferIfDestAtSize(
+ transferId: string,
+ destSftp: SFTPWrapper,
+ destPath: string,
+ expectedSize: number,
+ extra: Partial = {},
+): Promise {
+ try {
+ const destSize = await probeDestResumeOffset(
+ destSftp,
+ destPath,
+ expectedSize,
+ );
+ if (destSize < expectedSize) {
+ return false;
+ }
+
+ fileLogger.info("Destination file complete — finalizing transfer", {
+ operation: "host_transfer_dest_complete",
+ transferId,
+ destSize,
+ expectedSize,
+ });
+
+ finalizeTransfer(transferId, {
+ status: "success",
+ phase: "transferring",
+ method: "stream",
+ bytesTransferred: expectedSize,
+ totalBytes: expectedSize,
+ destPath,
+ timings: buildStreamTransferTimings(transferId, expectedSize),
+ ...extra,
+ });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function tryFinalizeStreamTransferIfDestComplete(
+ deps: HostTransferDeps,
+ transferId: string,
+ destSession: SSHSessionLike,
+ destPath: string,
+ expectedSize: number,
+ extra: Partial = {},
+): Promise {
+ try {
+ const destSftp = await deps.getSessionSftp(destSession);
+ return finalizeStreamTransferIfDestAtSize(
+ transferId,
+ destSftp,
+ destPath,
+ expectedSize,
+ extra,
+ );
+ } catch {
+ return false;
+ }
+}
+
+async function reconnectDedicatedTransferSessions(
+ ctx: TransferReconnectContext,
+ parallelWorkers = 0,
+): Promise<{ sourceSession: SSHSessionLike; destSession: SSHSessionLike }> {
+ fileLogger.warn("Reconnecting dedicated transfer SSH sessions", {
+ operation: "transfer_ssh_reconnect",
+ transferId: ctx.transferId,
+ parallelWorkers,
+ });
+
+ closeAllTransferSessions(ctx.deps, ctx, parallelWorkers);
+
+ const [sourceSession, destSession] = await Promise.all([
+ ctx.deps.openDedicatedTransferSession(
+ ctx.browseSourceSessionId,
+ ctx.dedicatedSourceSessionId,
+ ctx.userId,
+ ctx.transferId,
+ { allowBrowseDisconnected: true },
+ ),
+ ctx.deps.openDedicatedTransferSession(
+ ctx.browseDestSessionId,
+ ctx.dedicatedDestSessionId,
+ ctx.userId,
+ ctx.transferId,
+ { allowBrowseDisconnected: true },
+ ),
+ ]);
+
+ return { sourceSession, destSession };
+}
+
+async function resetDedicatedTransferSessions(
+ ctx: TransferReconnectContext,
+ attempt: number,
+ reason: string,
+): Promise<{
+ sourceSession: SSHSessionLike;
+ destSession: SSHSessionLike;
+ sourceSftp: SFTPWrapper;
+ destSftp: SFTPWrapper;
+}> {
+ fileLogger.warn("Resetting transfer SSH sessions (fresh connection)", {
+ operation: "transfer_session_reset",
+ transferId: ctx.transferId,
+ attempt,
+ reason,
+ });
+
+ closeAllTransferSessions(ctx.deps, ctx, 0);
+
+ const delayMs =
+ TRANSFER_SESSION_RESET_DELAYS_MS[
+ Math.min(attempt - 1, TRANSFER_SESSION_RESET_DELAYS_MS.length - 1)
+ ] ?? 3000;
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+
+ const { sourceSession, destSession } =
+ await reconnectDedicatedTransferSessions(ctx, 0);
+ const sourceSftp = await ctx.deps.getSessionSftp(sourceSession);
+ const destSftp = await ctx.deps.getSessionSftp(destSession);
+
+ if (ctx.transferId) {
+ updateTransfer(ctx.transferId, { lastActivityAt: Date.now() });
+ }
+
+ return { sourceSession, destSession, sourceSftp, destSftp };
+}
+
+let cachedLocalAddresses: Set | null = null;
+
+function normalizeHostAddress(host: string): string {
+ const trimmed = host.trim().toLowerCase();
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
+ return trimmed.slice(1, -1);
+ }
+ return trimmed.split(":")[0] ?? trimmed;
+}
+
+function getLocalAddresses(): Set {
+ if (cachedLocalAddresses) return cachedLocalAddresses;
+
+ const addresses = new Set(["127.0.0.1", "::1", "localhost"]);
+ for (const ifaces of Object.values(networkInterfaces())) {
+ if (!ifaces) continue;
+ for (const iface of ifaces) {
+ if (!iface.internal && iface.family === "IPv4") {
+ addresses.add(iface.address.toLowerCase());
+ }
+ }
+ }
+ cachedLocalAddresses = addresses;
+ return addresses;
+}
+
+export function isLocalSshEndpoint(ip?: string): boolean {
+ if (!ip) return false;
+ const bare = normalizeHostAddress(ip);
+ if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1") {
+ return true;
+ }
+ return getLocalAddresses().has(bare);
+}
+
+function createThrottledProgress(onProgress?: (bytes: number) => void) {
+ let pending = 0;
+ let lastFlush = 0;
+
+ const flush = () => {
+ if (pending > 0) {
+ onProgress?.(pending);
+ pending = 0;
+ lastFlush = Date.now();
+ }
+ };
+
+ return {
+ add(bytes: number) {
+ pending += bytes;
+ const now = Date.now();
+ if (now - lastFlush >= TRANSFER_PROGRESS_INTERVAL_MS) {
+ flush();
+ }
+ },
+ flush,
+ };
+}
+
+function escapeShell(s: string): string {
+ return s.replace(/'/g, "'\"'\"'");
+}
+
+async function detectTransferPlatform(
+ deps: HostTransferDeps,
+ session: SSHSessionLike,
+ pathHints: string[] = [],
+): Promise {
+ if (session.transferPlatform) return session.transferPlatform;
+
+ for (const path of pathHints) {
+ const inferred = inferPlatformFromPath(path);
+ if (inferred === "windows") {
+ session.transferPlatform = "windows";
+ return "windows";
+ }
+ }
+
+ try {
+ const { code } = await execCommand(deps, session, "uname -s");
+ if (code === 0) {
+ session.transferPlatform = "unix";
+ return "unix";
+ }
+ } catch {
+ /* not unix */
+ }
+
+ try {
+ const { code } = await execCommand(
+ deps,
+ session,
+ 'cmd /c "if defined OS (exit 0) else (exit 1)"',
+ );
+ if (code === 0) {
+ session.transferPlatform = "windows";
+ return "windows";
+ }
+ } catch {
+ /* not cmd */
+ }
+
+ try {
+ const { code } = await execCommand(
+ deps,
+ session,
+ "powershell -NoProfile -Command \"if ($IsWindows -or $env:OS -like 'Windows*') { exit 0 } else { exit 1 }\"",
+ );
+ if (code === 0) {
+ session.transferPlatform = "windows";
+ return "windows";
+ }
+ } catch {
+ /* not powershell */
+ }
+
+ session.transferPlatform = "unix";
+ return "unix";
+}
+
+function isRootOnlyPath(path: string): boolean {
+ const normalized = normalizeSftpPath(path);
+ return (
+ normalized === "/" ||
+ /^\/[A-Za-z]:$/.test(normalized) ||
+ /^[A-Za-z]:$/.test(normalized)
+ );
+}
+
+function isPermissionError(err: Error): boolean {
+ const msg = err.message.toLowerCase();
+ return (
+ msg.includes("permission denied") ||
+ msg.includes("eacces") ||
+ msg.includes("access denied")
+ );
+}
+
+function promisifySftpStat(
+ sftp: SFTPWrapper,
+ path: string,
+): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.stat(path, (err, stats) => {
+ if (err) reject(err);
+ else resolve(stats);
+ });
+ });
+}
+
+function promisifySftpUnlink(sftp: SFTPWrapper, path: string): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.unlink(path, (err) => {
+ if (err) reject(err);
+ else resolve();
+ });
+ });
+}
+
+function promisifySftpRmdir(sftp: SFTPWrapper, path: string): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.rmdir(path, (err) => {
+ if (err) reject(err);
+ else resolve();
+ });
+ });
+}
+
+async function ensureDirectoryTreeSftp(
+ sftp: SFTPWrapper,
+ dirPath: string,
+ created: Set = new Set(),
+): Promise {
+ const normalized = normalizeSftpPath(dirPath);
+ if (!normalized || isRootOnlyPath(normalized)) return;
+
+ const { root, segments } = splitPathSegments(normalized);
+ if (segments.length === 0) return;
+
+ for (let i = 0; i < segments.length; i++) {
+ const current = buildPathFromSegments(root, segments, i + 1);
+ if (created.has(current)) continue;
+
+ try {
+ await promisifySftpMkdir(sftp, current, 0o755);
+ } catch (err) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code !== "EEXIST") {
+ try {
+ const stats = await promisifySftpStat(sftp, current);
+ if (!stats.isDirectory()) throw err;
+ } catch {
+ throw err;
+ }
+ }
+ }
+ created.add(current);
+ }
+}
+
+async function deletePathSftp(sftp: SFTPWrapper, path: string): Promise {
+ let stats: import("ssh2").Stats;
+ try {
+ stats = await promisifySftpStat(sftp, path);
+ } catch {
+ return;
+ }
+
+ if (stats.isDirectory()) {
+ const entries = await promisifySftpReaddir(sftp, path);
+ for (const entry of entries) {
+ if (entry.filename === "." || entry.filename === "..") continue;
+ await deletePathSftp(sftp, joinPath(path, entry.filename));
+ }
+ await promisifySftpRmdir(sftp, path);
+ return;
+ }
+
+ if (stats.isFile()) {
+ await promisifySftpUnlink(sftp, path);
+ }
+}
+
+function promisifySftpMkdir(
+ sftp: SFTPWrapper,
+ path: string,
+ mode: number,
+): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.mkdir(path, { mode }, (err) => {
+ if (err && (err as NodeJS.ErrnoException).code !== "EEXIST") {
+ reject(err);
+ } else {
+ resolve();
+ }
+ });
+ });
+}
+
+function promisifySftpChmod(
+ sftp: SFTPWrapper,
+ path: string,
+ mode: number,
+): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.chmod(path, mode, (err) => {
+ if (err) reject(err);
+ else resolve();
+ });
+ });
+}
+
+function promisifySftpReaddir(
+ sftp: SFTPWrapper,
+ path: string,
+): Promise> {
+ return new Promise((resolve, reject) => {
+ sftp.readdir(path, (err, list) => {
+ if (err) reject(err);
+ else resolve(list);
+ });
+ });
+}
+
+function execCommand(
+ deps: HostTransferDeps,
+ session: SSHSessionLike,
+ command: string,
+): Promise<{ code: number; stderr: string }> {
+ return new Promise((resolve, reject) => {
+ deps.execChannel(session, command, (err, stream) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ let stderr = "";
+ stream.on("data", () => {
+ /* consume stdout */
+ });
+ stream.stderr.on("data", (data: Buffer) => {
+ stderr += data.toString();
+ });
+ stream.on("close", (code: number) => {
+ resolve({ code: code ?? 0, stderr });
+ });
+ stream.on("error", (streamErr: Error) => {
+ reject(streamErr);
+ });
+ });
+ });
+}
+
+async function checkTarAvailable(
+ deps: HostTransferDeps,
+ session: SSHSessionLike,
+): Promise {
+ const platform = await detectTransferPlatform(deps, session);
+ if (platform === "windows") return false;
+
+ try {
+ const { code } = await execCommand(deps, session, "command -v tar");
+ return code === 0;
+ } catch {
+ return false;
+ }
+}
+
+async function ensureDestDirectory(
+ deps: HostTransferDeps,
+ destSession: SSHSessionLike,
+ dirPath: string,
+ created: Set = new Set(),
+): Promise {
+ const destSftp = await deps.getSessionSftp(destSession);
+ await ensureDirectoryTreeSftp(destSftp, dirPath, created);
+}
+
+function updateTransfer(
+ transferId: string,
+ patch: Partial,
+): void {
+ const current = activeTransfers.get(transferId);
+ if (current) {
+ const next: TransferProgress = { ...current, ...patch };
+ if (
+ patch.bytesTransferred !== undefined &&
+ current.totalBytes !== undefined &&
+ current.totalBytes > 0
+ ) {
+ next.bytesTransferred = Math.min(
+ patch.bytesTransferred,
+ current.totalBytes,
+ );
+ }
+ if (
+ patch.bytesTransferred !== undefined &&
+ patch.bytesTransferred !== current.bytesTransferred
+ ) {
+ next.lastActivityAt = Date.now();
+ }
+ activeTransfers.set(transferId, next);
+ }
+}
+
+async function closeTransferHandlesSafe(
+ handles: TransferFileHandles | null | undefined,
+): Promise {
+ if (!handles) return;
+ await Promise.race([
+ handles.close(),
+ new Promise((resolve) =>
+ setTimeout(resolve, TRANSFER_HANDLE_CLOSE_TIMEOUT_MS),
+ ),
+ ]).catch(() => {});
+}
+
+function checkHungTransfers(): void {
+ const now = Date.now();
+ for (const [transferId, progress] of activeTransfers) {
+ if (progress.status !== "running") continue;
+
+ if (
+ progress.method === "stream" &&
+ progress.totalBytes &&
+ progress.totalBytes > 0 &&
+ progress.bytesTransferred !== undefined &&
+ progress.bytesTransferred >= progress.totalBytes
+ ) {
+ fileLogger.info("Transfer progress at 100% — marking complete", {
+ operation: "host_transfer_progress_complete",
+ transferId,
+ bytesTransferred: progress.bytesTransferred,
+ totalBytes: progress.totalBytes,
+ });
+ finalizeTransfer(transferId, {
+ status: "success",
+ phase: "transferring",
+ method: "stream",
+ bytesTransferred: progress.totalBytes,
+ totalBytes: progress.totalBytes,
+ sourcePaths: progress.sourcePaths,
+ destPath: progress.destPath,
+ moveRequested: progress.moveRequested,
+ timings: buildStreamTransferTimings(transferId, progress.totalBytes),
+ });
+ continue;
+ }
+
+ if (progress.phase === "reconnecting") {
+ const reconnectStarted =
+ progress.reconnectingAt ?? progress.lastActivityAt ?? now;
+ if (now - reconnectStarted < HUNG_RECONNECTING_MS) continue;
+ }
+
+ const lastActivity = progress.lastActivityAt ?? progress.startedAt ?? now;
+ if (now - lastActivity < HUNG_TRANSFER_MS) continue;
+
+ fileLogger.error("Force-finalizing unresponsive transfer", {
+ operation: "host_transfer_hung",
+ transferId,
+ lastActivityAt: lastActivity,
+ bytesTransferred: progress.bytesTransferred,
+ });
+
+ finalizeTransfer(
+ transferId,
+ failedProgressPatch(progress, {
+ status: "error",
+ phase:
+ progress.phase === "reconnecting" ? "transferring" : progress.phase,
+ message: "Transfer stopped responding",
+ method: progress.method,
+ sourcePaths: progress.sourcePaths,
+ destPath: progress.destPath,
+ bytesTransferred: Math.min(
+ progress.bytesTransferred ?? 0,
+ progress.totalBytes ?? Number.MAX_SAFE_INTEGER,
+ ),
+ totalBytes: progress.totalBytes,
+ itemsCompleted: progress.itemsCompleted,
+ totalItems: progress.totalItems,
+ moveRequested: progress.moveRequested,
+ }),
+ );
+ }
+}
+
+/** Before marking a stalled stream transfer as failed, verify the destination file size. */
+export async function probeHungStreamTransfers(
+ deps: HostTransferDeps,
+): Promise {
+ const now = Date.now();
+ for (const [transferId, progress] of activeTransfers) {
+ if (progress.status !== "running" || progress.method !== "stream") {
+ continue;
+ }
+ if (!progress.destPath || !progress.totalBytes || !progress.userId) {
+ continue;
+ }
+
+ const lastActivity = progress.lastActivityAt ?? progress.startedAt ?? now;
+ if (now - lastActivity < HUNG_TRANSFER_MS) {
+ continue;
+ }
+
+ const browseDestId = progress.destSessionId;
+ if (!browseDestId) {
+ continue;
+ }
+
+ const destId = progress.dedicatedDestSessionId ?? `xfer:${transferId}:dst`;
+ let destSession = deps.sshSessions[destId];
+ if (!destSession?.isConnected) {
+ try {
+ destSession = await deps.openDedicatedTransferSession(
+ browseDestId,
+ destId,
+ progress.userId,
+ transferId,
+ { allowBrowseDisconnected: true },
+ );
+ } catch {
+ continue;
+ }
+ }
+
+ await tryFinalizeStreamTransferIfDestComplete(
+ deps,
+ transferId,
+ destSession,
+ progress.destPath,
+ progress.totalBytes,
+ {
+ sourcePaths: progress.sourcePaths,
+ moveRequested: progress.moveRequested,
+ },
+ );
+ }
+}
+
+function trackDestArtifact(transferId: string, path: string): void {
+ const normalized = normalizeSftpPath(path);
+ const current = activeTransfers.get(transferId);
+ if (!current) return;
+ const existing = current.destArtifacts ?? [];
+ if (existing.includes(normalized)) return;
+ updateTransfer(transferId, { destArtifacts: [...existing, normalized] });
+}
+
+function buildCleanupPaths(progress: TransferProgress): string[] {
+ const paths = new Set(progress.destArtifacts ?? []);
+
+ if (progress.tempArchivePath) {
+ paths.add(normalizeSftpPath(progress.tempArchivePath));
+ }
+
+ if (paths.size === 0 && progress.destPath) {
+ if (progress.method === "stream") {
+ paths.add(normalizeSftpPath(progress.destPath));
+ } else if (progress.sourcePaths?.length) {
+ for (const sourcePath of progress.sourcePaths) {
+ paths.add(
+ normalizeSftpPath(joinPath(progress.destPath, basename(sourcePath))),
+ );
+ }
+ }
+ }
+
+ return [...paths];
+}
+
+function partialDestWasWritten(progress: TransferProgress): boolean {
+ return (
+ (progress.bytesTransferred ?? 0) > 0 ||
+ (progress.itemsCompleted ?? 0) > 0 ||
+ (progress.destArtifacts?.length ?? 0) > 0
+ );
+}
+
+function failedProgressPatch(
+ current: TransferProgress | undefined,
+ patch: Partial,
+): Partial {
+ const merged = { ...current, ...patch } as TransferProgress;
+ const hasPartial = partialDestWasWritten(merged);
+ return {
+ ...patch,
+ partialDestRemaining: hasPartial,
+ retryable: hasPartial && !!current?.requestSnapshot,
+ };
+}
+
+function cancelledProgressPatch(
+ current: TransferProgress | undefined,
+ patch: Partial,
+): Partial {
+ const merged = { ...current, ...patch } as TransferProgress;
+ return {
+ ...patch,
+ partialDestRemaining: partialDestWasWritten(merged),
+ };
+}
+
+function finalizeTransfer(
+ transferId: string,
+ patch: Partial,
+): TransferProgress {
+ cancelRequestedTransfers.delete(transferId);
+ const watchdog = cancelWatchdogs.get(transferId);
+ if (watchdog) {
+ clearTimeout(watchdog);
+ cancelWatchdogs.delete(transferId);
+ }
+ const current = activeTransfers.get(transferId);
+ const result: TransferProgress = {
+ transferId,
+ status: "success",
+ phase: "transferring",
+ ...current,
+ ...patch,
+ };
+ activeTransfers.set(transferId, result);
+ return result;
+}
+
+function elapsedMs(start: number): number {
+ return Date.now() - start;
+}
+
+export function computeTransferMbPerSec(
+ bytes: number,
+ ms: number,
+): number | undefined {
+ if (ms <= 0 || bytes <= 0) return undefined;
+ return ((bytes / ms) * 1000) / (1024 * 1024);
+}
+
+interface HopWallClock {
+ firstAt: number | null;
+ lastAt: number | null;
+}
+
+function createHopWallClock(): HopWallClock {
+ return { firstAt: null, lastAt: null };
+}
+
+function noteHopStart(
+ clock: HopWallClock,
+ t: number = performance.now(),
+): void {
+ if (clock.firstAt === null) clock.firstAt = t;
+}
+
+function noteHopEnd(clock: HopWallClock, t: number = performance.now()): void {
+ clock.lastAt = t;
+}
+
+function hopSpanMs(clock: HopWallClock): number {
+ if (clock.firstAt === null || clock.lastAt === null) return 0;
+ return Math.max(0, clock.lastAt - clock.firstAt);
+}
+
+interface PipelinedXferStats {
+ bytes: number;
+ sourceReadSpanMs: number;
+ destWriteSpanMs: number;
+ destWriteKind: "sftp" | "local";
+}
+
+function createEmptyXferStats(): PipelinedXferStats {
+ return {
+ bytes: 0,
+ sourceReadSpanMs: 0,
+ destWriteSpanMs: 0,
+ destWriteKind: "sftp",
+ };
+}
+
+function mergeXferStats(
+ target: PipelinedXferStats,
+ source: PipelinedXferStats,
+): void {
+ target.bytes += source.bytes;
+ target.sourceReadSpanMs += source.sourceReadSpanMs;
+ target.destWriteSpanMs += source.destWriteSpanMs;
+ target.destWriteKind = source.destWriteKind;
+}
+
+function buildTransferHopTimings(
+ stats: PipelinedXferStats,
+ transferMs: number,
+): Pick {
+ const hops: TransferHopMetrics[] = [];
+
+ const sourceRate = computeTransferMbPerSec(
+ stats.bytes,
+ stats.sourceReadSpanMs,
+ );
+ if (sourceRate !== undefined) {
+ hops.push({
+ id: "source_read",
+ bytes: stats.bytes,
+ spanMs: stats.sourceReadSpanMs,
+ mbPerSec: sourceRate,
+ });
+ }
+
+ const destHopId: TransferHopId =
+ stats.destWriteKind === "local" ? "dest_local_write" : "dest_sftp_write";
+ const destRate = computeTransferMbPerSec(stats.bytes, stats.destWriteSpanMs);
+ if (destRate !== undefined) {
+ hops.push({
+ id: destHopId,
+ bytes: stats.bytes,
+ spanMs: stats.destWriteSpanMs,
+ mbPerSec: destRate,
+ });
+ }
+
+ return {
+ transferBytes: stats.bytes,
+ endToEndMbPerSec: computeTransferMbPerSec(stats.bytes, transferMs),
+ hops,
+ };
+}
+
+async function deleteSourcePathsAfterSuccess(
+ deps: HostTransferDeps,
+ transferId: string,
+ sourceSession: SSHSessionLike,
+ sourcePaths: string[],
+): Promise {
+ const deleteStart = Date.now();
+ await deleteSourcePaths(deps, sourceSession, sourcePaths);
+ const sourceDeleteMs = elapsedMs(deleteStart);
+ updateTransfer(transferId, {
+ sourceDeleted: true,
+ timings: {
+ ...activeTransfers.get(transferId)?.timings,
+ sourceDeleteMs,
+ },
+ });
+ return sourceDeleteMs;
+}
+
+async function ensureDestParentForFile(
+ deps: HostTransferDeps,
+ destSession: SSHSessionLike,
+ filePath: string,
+): Promise {
+ const parent = dirname(filePath);
+ if (!parent || isRootOnlyPath(parent)) return;
+ await ensureDestDirectory(deps, destSession, parent);
+}
+
+interface FileWorkItem {
+ sourcePath: string;
+ destPath: string;
+ mode: number;
+ size: number;
+}
+
+async function collectFileWorkItems(
+ sftp: SFTPWrapper,
+ sourcePath: string,
+ destRoot: string,
+ destBaseName?: string,
+): Promise {
+ const stats = await promisifySftpStat(sftp, sourcePath);
+ const name = destBaseName ?? basename(sourcePath);
+ const destPath = joinPath(destRoot, name);
+
+ if (stats.isFile()) {
+ return [
+ {
+ sourcePath,
+ destPath,
+ mode: stats.mode & 0o7777,
+ size: stats.size,
+ },
+ ];
+ }
+
+ if (!stats.isDirectory()) {
+ return [];
+ }
+
+ const items: FileWorkItem[] = [];
+ const walk = async (srcDir: string, dstDir: string) => {
+ const entries = await promisifySftpReaddir(sftp, srcDir);
+ for (const entry of entries) {
+ if (entry.filename === "." || entry.filename === "..") continue;
+ const srcChild = joinPath(srcDir, entry.filename);
+ const dstChild = joinPath(dstDir, entry.filename);
+
+ if (entry.attrs.isDirectory()) {
+ await walk(srcChild, dstChild);
+ } else if (entry.attrs.isFile()) {
+ items.push({
+ sourcePath: srcChild,
+ destPath: dstChild,
+ mode: entry.attrs.mode & 0o7777,
+ size: entry.attrs.size,
+ });
+ }
+ }
+ };
+
+ await walk(sourcePath, destPath);
+ return items;
+}
+
+async function scanSourcePathsForRouting(
+ sftp: SFTPWrapper,
+ sourcePaths: string[],
+ transferId: string,
+) {
+ const scanItems: Array<{ sourcePath: string; size: number }> = [];
+ for (const sourcePath of sourcePaths) {
+ throwIfCancelled(transferId);
+ const work = await collectFileWorkItems(sftp, sourcePath, "/");
+ scanItems.push(
+ ...work.map((w) => ({ sourcePath: w.sourcePath, size: w.size })),
+ );
+ }
+ return buildTransferScanSummary(scanItems);
+}
+
+function promisifySftpOpen(
+ sftp: SFTPWrapper,
+ path: string,
+ flags: number,
+ mode: number,
+): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.open(path, flags, mode, (err, handle) => {
+ if (err) reject(err);
+ else resolve(handle);
+ });
+ });
+}
+
+function promisifySftpClose(sftp: SFTPWrapper, handle: Buffer): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.close(handle, (err) => {
+ if (err) reject(err);
+ else resolve();
+ });
+ });
+}
+
+function promisifySftpFstat(
+ sftp: SFTPWrapper,
+ handle: Buffer,
+): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.fstat(handle, (err, stats) => {
+ if (err) reject(err);
+ else resolve(stats);
+ });
+ });
+}
+
+function promisifySftpWrite(
+ sftp: SFTPWrapper,
+ handle: Buffer,
+ buffer: Buffer,
+ offset: number,
+ length: number,
+ position: number,
+): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.write(handle, buffer, offset, length, position, (err) => {
+ if (err) reject(err);
+ else resolve();
+ });
+ });
+}
+
+interface PipelinedXferOptions {
+ fileSize?: number;
+ initialOffset?: number;
+ parallelSegmentCount?: number;
+ onProgress?: (bytes: number) => void;
+ shouldAbort?: () => boolean;
+ transferId?: string;
+ segmentIndex?: number;
+ reconnect?: TransferReconnectContext;
+ onResumeOffset?: (offset: number) => void;
+}
+
+interface SegmentCopyJob {
+ offset: number;
+ length: number;
+ segmentIndex: number;
+}
+
+function clampParallelSegmentCount(value?: number): number {
+ const n = value ?? DEFAULT_PARALLEL_SEGMENT_COUNT;
+ return Math.max(1, Math.min(MAX_PARALLEL_SEGMENT_COUNT, Math.floor(n)));
+}
+
+function buildSegmentCopyJobs(
+ fileSize: number,
+ initialOffset: number,
+ destResumeSize: number,
+): SegmentCopyJob[] {
+ const jobs: SegmentCopyJob[] = [];
+ for (
+ let offset = initialOffset;
+ offset < fileSize;
+ offset += SFTP_XFER_SEGMENT_SIZE
+ ) {
+ const length = Math.min(SFTP_XFER_SEGMENT_SIZE, fileSize - offset);
+ const segmentIndex = Math.floor(offset / SFTP_XFER_SEGMENT_SIZE);
+ if (destResumeSize >= offset + length) {
+ continue;
+ }
+ const start = destResumeSize > offset ? destResumeSize : offset;
+ jobs.push({
+ offset: start,
+ length: offset + length - start,
+ segmentIndex,
+ });
+ }
+ return jobs;
+}
+
+function closeAllTransferSessions(
+ deps: HostTransferDeps,
+ ctx: TransferReconnectContext,
+ parallelWorkers: number,
+): void {
+ deps.closeDedicatedTransferSession(ctx.dedicatedSourceSessionId);
+ deps.closeDedicatedTransferSession(ctx.dedicatedDestSessionId);
+ for (let i = 0; i < parallelWorkers; i++) {
+ deps.closeDedicatedTransferSession(`${ctx.dedicatedSourceSessionId}:p${i}`);
+ deps.closeDedicatedTransferSession(`${ctx.dedicatedDestSessionId}:p${i}`);
+ }
+}
+
+interface TransferFileHandles {
+ sourceSftp: SFTPWrapper;
+ destSftp: SFTPWrapper;
+ srcHandle: Buffer;
+ dstHandle: Buffer;
+ close: () => Promise;
+}
+
+async function openTransferFileHandles(
+ sourceSftp: SFTPWrapper,
+ destSftp: SFTPWrapper,
+ sourcePath: string,
+ destPath: string,
+ resume: boolean,
+): Promise {
+ const srcHandle = await promisifySftpOpen(
+ sourceSftp,
+ sourcePath,
+ SFTP_OPEN_READ,
+ 0o666,
+ );
+ const dstHandle = await promisifySftpOpen(
+ destSftp,
+ destPath,
+ resume ? SFTP_OPEN_WRITE_RESUME : SFTP_OPEN_WRITE,
+ 0o666,
+ );
+
+ return {
+ sourceSftp,
+ destSftp,
+ srcHandle,
+ dstHandle,
+ close: async () => {
+ await promisifySftpClose(sourceSftp, srcHandle).catch(() => {});
+ await promisifySftpClose(destSftp, dstHandle).catch(() => {});
+ },
+ };
+}
+
+/**
+ * Parallel SFTP copy using the same scheduling model as ssh2's fastXfer.
+ * Scoped to [fileBaseOffset, fileBaseOffset + byteLength) so callers can reset
+ * scheduler state between segments on large files.
+ */
+async function runFastSftpCopy(
+ sourceSftp: SFTPWrapper,
+ srcHandle: Buffer,
+ destSftp: SFTPWrapper,
+ dstHandle: Buffer,
+ byteLength: number,
+ fileBaseOffset: number,
+ options: PipelinedXferOptions,
+ sourceReadClock: ReturnType,
+ destWriteClock: ReturnType,
+): Promise {
+ let concurrency = SFTP_XFER_CONCURRENCY;
+ let chunkSize = SFTP_XFER_CHUNK_SIZE;
+ let bufsize = chunkSize * concurrency;
+ while (bufsize > byteLength && concurrency > 1) {
+ bufsize -= chunkSize;
+ concurrency -= 1;
+ }
+ if (byteLength <= chunkSize) {
+ chunkSize = byteLength;
+ concurrency = 1;
+ bufsize = byteLength;
+ }
+
+ const readbuf = Buffer.alloc(bufsize);
+ const progress = createThrottledProgress(options.onProgress);
+ let lastActivity = Date.now();
+ let stallTimer: ReturnType | undefined;
+
+ await new Promise((resolve, reject) => {
+ let finished = false;
+ let hadError = false;
+ let pdst = 0;
+ let total = 0;
+
+ const fail = (err: Error) => {
+ if (hadError) return;
+ hadError = true;
+ finished = true;
+ progress.flush();
+ reject(err);
+ };
+
+ const succeed = () => {
+ if (finished) return;
+ finished = true;
+ progress.flush();
+ resolve();
+ };
+
+ stallTimer = setInterval(() => {
+ if (finished) return;
+ if (options.shouldAbort?.()) {
+ fail(new TransferCancelledError());
+ return;
+ }
+ if (Date.now() - lastActivity > 45000) {
+ fail(
+ new TransferStalledError(
+ fileBaseOffset + total,
+ options.segmentIndex,
+ ),
+ );
+ }
+ }, 10000);
+
+ const onread = (
+ err: Error | undefined,
+ nb: number,
+ _data: Buffer | undefined,
+ localDstPos: number,
+ datapos: number,
+ origChunkLen: number,
+ ) => {
+ if (err) {
+ fail(err);
+ return;
+ }
+ if (options.shouldAbort?.()) {
+ fail(new TransferCancelledError());
+ return;
+ }
+
+ noteHopStart(destWriteClock);
+ destSftp.write(
+ dstHandle,
+ readbuf,
+ datapos,
+ nb,
+ fileBaseOffset + localDstPos,
+ (writeErr: Error | undefined) => {
+ noteHopEnd(destWriteClock);
+ if (writeErr) {
+ fail(writeErr);
+ return;
+ }
+
+ total += nb;
+ progress.add(nb);
+ lastActivity = Date.now();
+
+ if (options.shouldAbort?.()) {
+ fail(new TransferCancelledError());
+ return;
+ }
+
+ if (nb < origChunkLen) {
+ singleRead(datapos, localDstPos + nb, origChunkLen - nb);
+ return;
+ }
+
+ if (total >= byteLength) {
+ succeed();
+ return;
+ }
+
+ if (pdst >= byteLength) {
+ return;
+ }
+
+ const chunk = Math.min(chunkSize, byteLength - pdst);
+ singleRead(datapos, pdst, chunk);
+ pdst += chunk;
+ },
+ );
+ };
+
+ const makeCb =
+ (psrc: number, localFilePos: number, chunk: number) =>
+ (err: Error | undefined, nb?: number, data?: Buffer) => {
+ onread(err, nb ?? 0, data, localFilePos, psrc, chunk);
+ };
+
+ const singleRead = (psrc: number, localFilePos: number, chunk: number) => {
+ if (options.shouldAbort?.()) {
+ fail(new TransferCancelledError());
+ return;
+ }
+ noteHopStart(sourceReadClock);
+ sourceSftp.read(
+ srcHandle,
+ readbuf,
+ psrc,
+ chunk,
+ fileBaseOffset + localFilePos,
+ (err, nb, data) => {
+ noteHopEnd(sourceReadClock);
+ makeCb(psrc, localFilePos, chunk)(err, nb, data);
+ },
+ );
+ };
+
+ const startReads = () => {
+ let reads = 0;
+ let psrc = 0;
+ while (pdst < byteLength && reads < concurrency) {
+ const chunk = Math.min(chunkSize, byteLength - pdst);
+ singleRead(psrc, pdst, chunk);
+ psrc += chunk;
+ pdst += chunk;
+ reads += 1;
+ }
+ };
+
+ startReads();
+ }).finally(() => {
+ if (stallTimer) clearInterval(stallTimer);
+ });
+}
+
+async function runFastSftpCopySegmented(
+ handles: TransferFileHandles,
+ fileSize: number,
+ options: PipelinedXferOptions,
+ sourceReadClock: ReturnType,
+ destWriteClock: ReturnType,
+ sourcePath: string,
+ destPath: string,
+): Promise {
+ const parallel = clampParallelSegmentCount(options.parallelSegmentCount);
+ if (parallel > 1 && options.reconnect) {
+ return runFastSftpCopySegmentedParallel(
+ handles,
+ fileSize,
+ { ...options, parallelSegmentCount: parallel },
+ sourceReadClock,
+ destWriteClock,
+ sourcePath,
+ destPath,
+ );
+ }
+ return runFastSftpCopySegmentedSequential(
+ handles,
+ fileSize,
+ options,
+ sourceReadClock,
+ destWriteClock,
+ sourcePath,
+ destPath,
+ );
+}
+
+async function runFastSftpCopySegmentedSequential(
+ handles: TransferFileHandles,
+ fileSize: number,
+ options: PipelinedXferOptions,
+ sourceReadClock: ReturnType,
+ destWriteClock: ReturnType,
+ sourcePath: string,
+ destPath: string,
+): Promise {
+ const transferId = options.transferId;
+ const segmentCount = Math.ceil(fileSize / SFTP_XFER_SEGMENT_SIZE);
+ let offset = options.initialOffset ?? 0;
+ let currentHandles = handles;
+
+ while (offset < fileSize) {
+ if (options.shouldAbort?.()) {
+ throw new TransferCancelledError();
+ }
+
+ let segLen = Math.min(SFTP_XFER_SEGMENT_SIZE, fileSize - offset);
+ const segmentIndex = Math.floor(offset / SFTP_XFER_SEGMENT_SIZE);
+ let attempts = 0;
+
+ while (true) {
+ fileLogger.info("Transfer segment started", {
+ operation: "host_transfer_segment",
+ transferId,
+ segmentIndex,
+ segmentCount,
+ fileBaseOffset: offset,
+ byteLength: segLen,
+ attempt: attempts + 1,
+ });
+
+ try {
+ await runFastSftpCopy(
+ currentHandles.sourceSftp,
+ currentHandles.srcHandle,
+ currentHandles.destSftp,
+ currentHandles.dstHandle,
+ segLen,
+ offset,
+ { ...options, segmentIndex },
+ sourceReadClock,
+ destWriteClock,
+ );
+
+ fileLogger.info("Transfer segment completed", {
+ operation: "host_transfer_segment",
+ transferId,
+ segmentIndex,
+ segmentCount,
+ fileBaseOffset: offset,
+ byteLength: segLen,
+ });
+
+ offset += segLen;
+ break;
+ } catch (err) {
+ if (err instanceof TransferCancelledError) {
+ throw err;
+ }
+
+ const message =
+ err instanceof Error ? err.message : "Segment transfer failed";
+ const recoverable =
+ options.reconnect &&
+ isRecoverableTransferError(err) &&
+ attempts < SFTP_SEQUENTIAL_SEGMENT_MAX_ATTEMPTS;
+
+ fileLogger.error("Transfer segment failed", err, {
+ operation: "host_transfer_segment",
+ transferId,
+ segmentIndex,
+ fileBaseOffset: offset,
+ byteLength: segLen,
+ attempt: attempts + 1,
+ maxAttempts: SFTP_SEQUENTIAL_SEGMENT_MAX_ATTEMPTS,
+ recoverable,
+ });
+
+ if (!recoverable || !options.reconnect) {
+ throw new Error(
+ `Segment ${segmentIndex} failed at offset ${offset}: ${message}`,
+ );
+ }
+
+ attempts += 1;
+ await closeTransferHandlesSafe(currentHandles);
+
+ const { sourceSftp, destSftp } = await resetDedicatedTransferSessions(
+ options.reconnect,
+ attempts,
+ message,
+ );
+
+ let resumeOffset = offset;
+ try {
+ resumeOffset = await probeDestResumeOffset(
+ destSftp,
+ destPath,
+ fileSize,
+ );
+ } catch {
+ /* use last known offset */
+ }
+
+ if (resumeOffset > offset) {
+ fileLogger.info("Resuming transfer from destination size", {
+ operation: "host_transfer_resume",
+ transferId,
+ previousOffset: offset,
+ resumeOffset,
+ fileSize,
+ });
+ offset = resumeOffset;
+ options.onResumeOffset?.(offset);
+ }
+
+ if (offset >= fileSize) {
+ break;
+ }
+
+ segLen = Math.min(SFTP_XFER_SEGMENT_SIZE, fileSize - offset);
+ currentHandles = await openTransferFileHandles(
+ sourceSftp,
+ destSftp,
+ sourcePath,
+ destPath,
+ offset > 0,
+ );
+ }
+ }
+ }
+
+ return currentHandles;
+}
+
+interface WorkerSessionCache {
+ sourceSession: SSHSessionLike;
+ destSession: SSHSessionLike;
+ sourceSftp: SFTPWrapper;
+ destSftp: SFTPWrapper;
+}
+
+async function runFastSftpCopySegmentedParallel(
+ handles: TransferFileHandles,
+ fileSize: number,
+ options: PipelinedXferOptions,
+ sourceReadClock: ReturnType,
+ destWriteClock: ReturnType,
+ sourcePath: string,
+ destPath: string,
+): Promise {
+ const transferId = options.transferId;
+ const parallel = clampParallelSegmentCount(options.parallelSegmentCount);
+ const initialOffset = options.initialOffset ?? 0;
+ const reconnect = options.reconnect!;
+
+ let destResumeSize = initialOffset;
+ try {
+ destResumeSize = await probeDestResumeOffset(
+ handles.destSftp,
+ destPath,
+ fileSize,
+ );
+ } catch {
+ /* use initial offset */
+ }
+
+ const jobs = buildSegmentCopyJobs(fileSize, initialOffset, destResumeSize);
+ if (jobs.length === 0) {
+ options.onResumeOffset?.(fileSize);
+ return handles;
+ }
+
+ fileLogger.info("Starting parallel segment transfer", {
+ operation: "host_transfer_parallel",
+ transferId,
+ parallel,
+ segmentJobs: jobs.length,
+ fileSize,
+ destResumeSize,
+ });
+
+ updateTransfer(transferId ?? "", {
+ parallelSegmentCount: parallel,
+ bytesTransferred: destResumeSize,
+ });
+ options.onResumeOffset?.(destResumeSize);
+
+ let aggregateBytes = destResumeSize;
+ let lastDestProbeAt = 0;
+ const DEST_PROGRESS_PROBE_MS = 1500;
+
+ const refreshDestProgress = async (force = false): Promise => {
+ const now = Date.now();
+ if (!force && now - lastDestProbeAt < DEST_PROGRESS_PROBE_MS) {
+ return aggregateBytes;
+ }
+ lastDestProbeAt = now;
+ try {
+ const size = await probeDestResumeOffset(
+ handles.destSftp,
+ destPath,
+ fileSize,
+ );
+ if (size > aggregateBytes) {
+ aggregateBytes = size;
+ } else if (force) {
+ aggregateBytes = size;
+ }
+ options.onResumeOffset?.(aggregateBytes);
+ if (transferId) {
+ updateTransfer(transferId, { bytesTransferred: aggregateBytes });
+ }
+ } catch {
+ /* keep last known progress */
+ }
+ return aggregateBytes;
+ };
+
+ /** Parallel lanes share one dest file — use dest size, not summed segment deltas. */
+ const reportDelta = (_delta: number) => {
+ void refreshDestProgress(false);
+ };
+
+ let nextJobIndex = 0;
+ const workerCaches = new Map();
+
+ const resetParallelLane = async (
+ workerId: number,
+ attempt: number,
+ reason: string,
+ ): Promise => {
+ fileLogger.warn("Resetting parallel transfer lane (fresh SSH session)", {
+ operation: "transfer_lane_reset",
+ transferId: reconnect.transferId,
+ parallelLane: workerId,
+ attempt,
+ reason,
+ });
+ workerCaches.delete(workerId);
+ reconnect.deps.closeDedicatedTransferSession(
+ `${reconnect.dedicatedSourceSessionId}:p${workerId}`,
+ );
+ reconnect.deps.closeDedicatedTransferSession(
+ `${reconnect.dedicatedDestSessionId}:p${workerId}`,
+ );
+ const delayMs =
+ TRANSFER_SESSION_RESET_DELAYS_MS[
+ Math.min(attempt - 1, TRANSFER_SESSION_RESET_DELAYS_MS.length - 1)
+ ] ?? 3000;
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+ };
+
+ const ensureWorkerSessions = async (
+ workerId: number,
+ ): Promise => {
+ const cached = workerCaches.get(workerId);
+ if (cached) return cached;
+
+ const srcId = `${reconnect.dedicatedSourceSessionId}:p${workerId}`;
+ const dstId = `${reconnect.dedicatedDestSessionId}:p${workerId}`;
+ const sourceSession = await reconnect.deps.openDedicatedTransferSession(
+ reconnect.browseSourceSessionId,
+ srcId,
+ reconnect.userId,
+ reconnect.transferId,
+ { allowBrowseDisconnected: true },
+ );
+ const destSession = await reconnect.deps.openDedicatedTransferSession(
+ reconnect.browseDestSessionId,
+ dstId,
+ reconnect.userId,
+ reconnect.transferId,
+ { allowBrowseDisconnected: true },
+ );
+ const sourceSftp = await reconnect.deps.getSessionSftp(sourceSession);
+ const destSftp = await reconnect.deps.getSessionSftp(destSession);
+ const entry: WorkerSessionCache = {
+ sourceSession,
+ destSession,
+ sourceSftp,
+ destSftp,
+ };
+ workerCaches.set(workerId, entry);
+ return entry;
+ };
+
+ const runSegmentJob = async (
+ job: SegmentCopyJob,
+ workerId: number,
+ ): Promise => {
+ let attempts = 0;
+
+ while (true) {
+ if (options.shouldAbort?.()) {
+ throw new TransferCancelledError();
+ }
+
+ fileLogger.info("Transfer segment started", {
+ operation: "host_transfer_segment",
+ transferId,
+ segmentIndex: job.segmentIndex,
+ fileBaseOffset: job.offset,
+ byteLength: job.length,
+ attempt: attempts + 1,
+ parallelLane: workerId,
+ });
+
+ const { sourceSftp, destSftp } = await ensureWorkerSessions(workerId);
+ const laneHandles = await openTransferFileHandles(
+ sourceSftp,
+ destSftp,
+ sourcePath,
+ destPath,
+ job.offset > 0,
+ );
+
+ try {
+ await runFastSftpCopy(
+ laneHandles.sourceSftp,
+ laneHandles.srcHandle,
+ laneHandles.destSftp,
+ laneHandles.dstHandle,
+ job.length,
+ job.offset,
+ {
+ ...options,
+ segmentIndex: job.segmentIndex,
+ onProgress: reportDelta,
+ },
+ sourceReadClock,
+ destWriteClock,
+ );
+
+ fileLogger.info("Transfer segment completed", {
+ operation: "host_transfer_segment",
+ transferId,
+ segmentIndex: job.segmentIndex,
+ fileBaseOffset: job.offset,
+ byteLength: job.length,
+ parallelLane: workerId,
+ });
+
+ await closeTransferHandlesSafe(laneHandles);
+ await refreshDestProgress(true);
+ return;
+ } catch (err) {
+ await closeTransferHandlesSafe(laneHandles);
+
+ if (err instanceof TransferCancelledError) {
+ throw err;
+ }
+
+ const message =
+ err instanceof Error ? err.message : "Segment transfer failed";
+ const recoverable =
+ isRecoverableTransferError(err) &&
+ attempts < SFTP_PARALLEL_SEGMENT_MAX_ATTEMPTS;
+
+ fileLogger.error("Transfer segment failed", err, {
+ operation: "host_transfer_segment",
+ transferId,
+ segmentIndex: job.segmentIndex,
+ fileBaseOffset: job.offset,
+ byteLength: job.length,
+ attempt: attempts + 1,
+ maxAttempts: SFTP_PARALLEL_SEGMENT_MAX_ATTEMPTS,
+ recoverable,
+ parallelLane: workerId,
+ });
+
+ if (!recoverable) {
+ if (
+ transferId &&
+ (await finalizeStreamTransferIfDestAtSize(
+ transferId,
+ handles.destSftp,
+ destPath,
+ fileSize,
+ ))
+ ) {
+ return;
+ }
+ throw new Error(
+ `Segment ${job.segmentIndex} failed at offset ${job.offset}: ${message}`,
+ );
+ }
+
+ attempts += 1;
+ await resetParallelLane(workerId, attempts, message);
+ await refreshDestProgress(true);
+ }
+ }
+ };
+
+ const runWorker = async (workerId: number) => {
+ while (true) {
+ if (options.shouldAbort?.()) {
+ throw new TransferCancelledError();
+ }
+
+ const jobIndex = nextJobIndex;
+ nextJobIndex += 1;
+ if (jobIndex >= jobs.length) {
+ return;
+ }
+
+ await runSegmentJob(jobs[jobIndex], workerId);
+ }
+ };
+
+ try {
+ await Promise.all(
+ Array.from({ length: parallel }, (_, workerId) => runWorker(workerId)),
+ );
+ await refreshDestProgress(true);
+ } finally {
+ workerCaches.clear();
+ for (let i = 0; i < parallel; i++) {
+ reconnect.deps.closeDedicatedTransferSession(
+ `${reconnect.dedicatedSourceSessionId}:p${i}`,
+ );
+ reconnect.deps.closeDedicatedTransferSession(
+ `${reconnect.dedicatedDestSessionId}:p${i}`,
+ );
+ }
+ }
+
+ options.onResumeOffset?.(fileSize);
+ return handles;
+}
+
+function promisifyFastGet(
+ sftp: SFTPWrapper,
+ remotePath: string,
+ localPath: string,
+ opts: {
+ concurrency?: number;
+ chunkSize?: number;
+ fileSize?: number;
+ step?: (totalTransferred: number, chunk: number, total: number) => void;
+ },
+): Promise {
+ return new Promise((resolve, reject) => {
+ sftp.fastGet(remotePath, localPath, opts, (err) => {
+ if (err) reject(err);
+ else resolve();
+ });
+ });
+}
+
+async function pipelinedSftpFile(
+ sourceSftp: SFTPWrapper,
+ destSftp: SFTPWrapper,
+ sourcePath: string,
+ destPath: string,
+ options: PipelinedXferOptions = {},
+): Promise {
+ let fileSize = options.fileSize ?? 0;
+ const stats = createEmptyXferStats();
+ stats.destWriteKind = "sftp";
+
+ let handles: TransferFileHandles | null = null;
+ const transferId = options.transferId;
+
+ try {
+ if (!fileSize) {
+ const srcProbe = await promisifySftpOpen(
+ sourceSftp,
+ sourcePath,
+ SFTP_OPEN_READ,
+ 0o666,
+ );
+ try {
+ const fstats = await promisifySftpFstat(sourceSftp, srcProbe);
+ fileSize = fstats.size;
+ } finally {
+ await promisifySftpClose(sourceSftp, srcProbe).catch(() => {});
+ }
+ }
+ if (fileSize <= 0) return stats;
+
+ stats.bytes = fileSize;
+
+ const sourceReadClock = createHopWallClock();
+ const destWriteClock = createHopWallClock();
+
+ const runCopyWithRetry = async (): Promise => {
+ let attempts = 0;
+ let sftpSource = sourceSftp;
+ let sftpDest = destSftp;
+ const parallelLanes = clampParallelSegmentCount(
+ options.parallelSegmentCount,
+ );
+ const isParallelCopy = parallelLanes > 1;
+ const maxCopyAttempts = isParallelCopy
+ ? SFTP_PARALLEL_COPY_MAX_ATTEMPTS
+ : SFTP_SEQUENTIAL_COPY_MAX_ATTEMPTS;
+
+ while (true) {
+ const resumeOffset = await probeDestResumeOffset(
+ sftpDest,
+ destPath,
+ fileSize,
+ );
+ if (resumeOffset >= fileSize) {
+ options.onResumeOffset?.(resumeOffset);
+ return;
+ }
+ if (resumeOffset > 0) {
+ options.onResumeOffset?.(resumeOffset);
+ if (attempts === 0) {
+ fileLogger.info("Resuming transfer from destination size", {
+ operation: "host_transfer_resume",
+ transferId,
+ resumeOffset,
+ fileSize,
+ });
+ }
+ }
+
+ try {
+ if (handles) {
+ await handles.close().catch(() => {});
+ }
+ handles = await openTransferFileHandles(
+ sftpSource,
+ sftpDest,
+ sourcePath,
+ destPath,
+ resumeOffset > 0,
+ );
+
+ if (fileSize <= SFTP_XFER_SEGMENT_THRESHOLD) {
+ await runFastSftpCopy(
+ handles.sourceSftp,
+ handles.srcHandle,
+ handles.destSftp,
+ handles.dstHandle,
+ fileSize - resumeOffset,
+ resumeOffset,
+ options,
+ sourceReadClock,
+ destWriteClock,
+ );
+ } else {
+ handles = await runFastSftpCopySegmented(
+ handles,
+ fileSize,
+ { ...options, initialOffset: resumeOffset },
+ sourceReadClock,
+ destWriteClock,
+ sourcePath,
+ destPath,
+ );
+ }
+ return;
+ } catch (err) {
+ if (err instanceof TransferCancelledError) {
+ throw err;
+ }
+
+ const recoverable =
+ options.reconnect &&
+ isRecoverableTransferError(err) &&
+ attempts < maxCopyAttempts;
+
+ fileLogger.error("Transfer copy attempt failed", err, {
+ operation: "host_transfer_copy",
+ transferId,
+ attempt: attempts + 1,
+ maxAttempts: maxCopyAttempts,
+ recoverable,
+ parallelLanes: isParallelCopy ? parallelLanes : undefined,
+ });
+
+ if (!recoverable || !options.reconnect) {
+ if (
+ transferId &&
+ (await finalizeStreamTransferIfDestAtSize(
+ transferId,
+ sftpDest,
+ destPath,
+ fileSize,
+ ))
+ ) {
+ return;
+ }
+ throw err;
+ }
+
+ attempts += 1;
+ await closeTransferHandlesSafe(handles);
+
+ if (isParallelCopy) {
+ closeAllTransferSessions(
+ options.reconnect.deps,
+ options.reconnect,
+ parallelLanes,
+ );
+ const delayMs =
+ TRANSFER_SESSION_RESET_DELAYS_MS[
+ Math.min(
+ attempts - 1,
+ TRANSFER_SESSION_RESET_DELAYS_MS.length - 1,
+ )
+ ] ?? 3000;
+ fileLogger.warn(
+ "Restarting parallel copy after segment exhaustion",
+ {
+ operation: "host_transfer_parallel_restart",
+ transferId,
+ attempt: attempts,
+ delayMs,
+ },
+ );
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+ } else {
+ const reset = await resetDedicatedTransferSessions(
+ options.reconnect,
+ attempts,
+ err instanceof Error ? err.message : "copy failed",
+ );
+ sftpSource = reset.sourceSftp;
+ sftpDest = reset.destSftp;
+ }
+ }
+ }
+ };
+
+ if (transferId) {
+ let abortFn: ((err: Error) => void) | null = null;
+ const control: ActiveXferControl = {
+ abort: (err) => abortFn?.(err),
+ closeResources: async () => {
+ await closeTransferHandlesSafe(handles);
+ },
+ };
+ activeXferControls.set(transferId, control);
+
+ try {
+ await new Promise((resolve, reject) => {
+ abortFn = reject;
+ runCopyWithRetry().then(resolve).catch(reject);
+ });
+ } finally {
+ activeXferControls.delete(transferId);
+ }
+ } else {
+ await runCopyWithRetry();
+ }
+
+ stats.sourceReadSpanMs = hopSpanMs(sourceReadClock);
+ stats.destWriteSpanMs = hopSpanMs(destWriteClock);
+ } finally {
+ if (handles) {
+ await handles.close().catch(() => {});
+ }
+ }
+
+ return stats;
+}
+
+/** Source SFTP → local filesystem (skips SFTP write when dest SSH target is this host). */
+async function pipelinedSftpToLocalFile(
+ sourceSftp: SFTPWrapper,
+ sourcePath: string,
+ destPath: string,
+ options: PipelinedXferOptions = {},
+): Promise {
+ let fileSize = options.fileSize ?? 0;
+ const stats = createEmptyXferStats();
+ stats.destWriteKind = "local";
+
+ if (!fileSize) {
+ const srcHandle = await promisifySftpOpen(
+ sourceSftp,
+ sourcePath,
+ SFTP_OPEN_READ,
+ 0o666,
+ );
+ try {
+ const fstats = await promisifySftpFstat(sourceSftp, srcHandle);
+ fileSize = fstats.size;
+ } finally {
+ await promisifySftpClose(sourceSftp, srcHandle).catch(() => {});
+ }
+ }
+ if (fileSize <= 0) return stats;
+
+ stats.bytes = fileSize;
+ const localPath = sftpPathToLocalPath(destPath);
+ const progress = createThrottledProgress(options.onProgress);
+ const transferStart = Date.now();
+
+ await promisifyFastGet(sourceSftp, sourcePath, localPath, {
+ concurrency: SFTP_XFER_CONCURRENCY,
+ chunkSize: SFTP_XFER_CHUNK_SIZE,
+ fileSize,
+ step: (_total, chunk) => {
+ if (options.shouldAbort?.()) {
+ throw new TransferCancelledError();
+ }
+ progress.add(chunk);
+ },
+ });
+
+ progress.flush();
+ stats.sourceReadSpanMs = elapsedMs(transferStart);
+ stats.destWriteSpanMs = stats.sourceReadSpanMs;
+ return stats;
+}
+
+async function transferFileData(
+ sourceSftp: SFTPWrapper,
+ destSftp: SFTPWrapper,
+ destSession: SSHSessionLike,
+ sourcePath: string,
+ destPath: string,
+ fileSize: number,
+ onProgress?: (bytes: number) => void,
+ shouldAbort?: () => boolean,
+ transferId?: string,
+ reconnect?: TransferReconnectContext,
+ onResumeOffset?: (offset: number) => void,
+ parallelSegmentCount?: number,
+): Promise {
+ const pipeOptions: PipelinedXferOptions = {
+ fileSize,
+ onProgress,
+ shouldAbort,
+ transferId,
+ reconnect,
+ onResumeOffset,
+ parallelSegmentCount,
+ };
+
+ if (isLocalSshEndpoint(destSession.ip)) {
+ return pipelinedSftpToLocalFile(
+ sourceSftp,
+ sourcePath,
+ destPath,
+ pipeOptions,
+ );
+ }
+
+ return pipelinedSftpFile(
+ sourceSftp,
+ destSftp,
+ sourcePath,
+ destPath,
+ pipeOptions,
+ );
+}
+
+async function transferSingleFile(
+ deps: HostTransferDeps,
+ transferId: string,
+ sourceSession: SSHSessionLike,
+ destSession: SSHSessionLike,
+ sourcePath: string,
+ destPath: string,
+ move: boolean,
+ reconnectMeta: TransferReconnectMeta,
+): Promise {
+ const sourceSftp = await deps.getSessionSftp(sourceSession);
+ const destSftp = await deps.getSessionSftp(destSession);
+ const shouldAbort = createTransferShouldAbort(transferId);
+
+ const reconnect = buildTransferReconnectContext(
+ deps,
+ transferId,
+ reconnectMeta,
+ );
+
+ const stats = await promisifySftpStat(sourceSftp, sourcePath);
+ if (!stats.isFile()) {
+ throw new Error(`Source is not a regular file: ${sourcePath}`);
+ }
+
+ updateTransfer(transferId, {
+ method: "stream",
+ phase: "transferring",
+ totalBytes: stats.size,
+ });
+
+ let bytesTransferred = 0;
+ try {
+ const destStat = await promisifySftpStat(destSftp, destPath);
+ bytesTransferred = Math.min(destStat.size, stats.size);
+ } catch {
+ /* destination file does not exist yet */
+ }
+
+ updateTransfer(transferId, { bytesTransferred });
+ const prepareStart = Date.now();
+ await ensureDestParentForFile(deps, destSession, destPath);
+ const prepareDestMs = elapsedMs(prepareStart);
+ updateTransfer(transferId, { timings: { prepareDestMs } });
+
+ const syncProgress = (absoluteOffset: number) => {
+ bytesTransferred = absoluteOffset;
+ updateTransfer(transferId, { bytesTransferred: absoluteOffset });
+ };
+
+ const parallelLanes = clampParallelSegmentCount(
+ activeTransfers.get(transferId)?.parallelSegmentCount,
+ );
+ const useAbsoluteProgressOnly = parallelLanes > 1;
+
+ throwIfCancelled(transferId);
+ trackDestArtifact(transferId, destPath);
+ await transferFileData(
+ sourceSftp,
+ destSftp,
+ destSession,
+ sourcePath,
+ destPath,
+ stats.size,
+ useAbsoluteProgressOnly
+ ? undefined
+ : (n) => {
+ bytesTransferred += n;
+ updateTransfer(transferId, { bytesTransferred });
+ },
+ shouldAbort,
+ transferId,
+ reconnect,
+ syncProgress,
+ parallelLanes,
+ );
+
+ if (move) {
+ await deleteSourcePathsAfterSuccess(deps, transferId, sourceSession, [
+ sourcePath,
+ ]);
+ }
+
+ return finalizeTransfer(transferId, {
+ status: "success",
+ phase: "transferring",
+ method: "stream",
+ bytesTransferred: stats.size,
+ totalBytes: stats.size,
+ sourcePaths: [sourcePath],
+ destPath,
+ sourceDeleted: move,
+ moveRequested: move,
+ timings: buildStreamTransferTimings(transferId, stats.size, prepareDestMs),
+ });
+}
+
+async function cleanupDestItems(
+ deps: HostTransferDeps,
+ destSession: SSHSessionLike,
+ destDir: string,
+ basenames: string[],
+): Promise {
+ const destSftp = await deps.getSessionSftp(destSession);
+ for (const name of basenames) {
+ const target = joinPath(destDir, name);
+ try {
+ await deletePathSftp(destSftp, target);
+ } catch {
+ /* best effort */
+ }
+ }
+}
+
+async function deleteSourcePaths(
+ deps: HostTransferDeps,
+ sourceSession: SSHSessionLike,
+ sourcePaths: string[],
+): Promise {
+ const sourceSftp = await deps.getSessionSftp(sourceSession);
+ for (const path of sourcePaths) {
+ try {
+ await deletePathSftp(sourceSftp, path);
+ } catch {
+ /* best effort */
+ }
+ }
+}
+
+async function transferViaTar(
+ deps: HostTransferDeps,
+ transferId: string,
+ sourceSession: SSHSessionLike,
+ destSession: SSHSessionLike,
+ sourcePaths: string[],
+ destPath: string,
+ move: boolean,
+ reconnectMeta: TransferReconnectMeta,
+): Promise {
+ const sourceSftp = await deps.getSessionSftp(sourceSession);
+ const destSftp = await deps.getSessionSftp(destSession);
+ const reconnect = buildTransferReconnectContext(
+ deps,
+ transferId,
+ reconnectMeta,
+ );
+ const archiveId = randomUUID();
+ const tempArchive = `/tmp/termix-transfer-${archiveId}.tar.gz`;
+ updateTransfer(transferId, { tempArchivePath: tempArchive });
+ trackDestArtifact(transferId, tempArchive);
+ const workingDir = getWorkingDir(sourcePaths);
+ const basenames = sourcePaths.map((p) => basename(p));
+ const escapedNames = basenames.map((n) => `'${escapeShell(n)}'`).join(" ");
+ const escapedDir = escapeShell(workingDir);
+ const escapedArchive = escapeShell(tempArchive);
+ const escapedDest = escapeShell(destPath);
+
+ await ensureDestDirectory(deps, destSession, destPath);
+
+ updateTransfer(transferId, {
+ method: "tar",
+ phase: "compressing",
+ });
+
+ throwIfCancelled(transferId);
+ const compressStart = Date.now();
+ const compressCmd = `cd '${escapedDir}' && tar -czf '${escapedArchive}' ${escapedNames}`;
+ const compressResult = await execCommand(deps, sourceSession, compressCmd);
+ const compressMs = elapsedMs(compressStart);
+ updateTransfer(transferId, { timings: { compressMs } });
+ if (compressResult.code !== 0) {
+ throw new Error(
+ compressResult.stderr || "Failed to compress on source host",
+ );
+ }
+
+ throwIfCancelled(transferId);
+ let archiveSize = 0;
+ try {
+ const archiveStats = await promisifySftpStat(sourceSftp, tempArchive);
+ archiveSize = archiveStats.size;
+ } catch {
+ /* size unknown */
+ }
+
+ updateTransfer(transferId, {
+ phase: "transferring",
+ totalBytes: archiveSize,
+ bytesTransferred: 0,
+ });
+
+ let bytesTransferred = 0;
+ const transferStart = Date.now();
+ const xferStats = createEmptyXferStats();
+ const syncProgress = (absoluteOffset: number) => {
+ bytesTransferred = absoluteOffset;
+ updateTransfer(transferId, { bytesTransferred: absoluteOffset });
+ };
+ try {
+ const fileStats = await transferFileData(
+ sourceSftp,
+ destSftp,
+ destSession,
+ tempArchive,
+ tempArchive,
+ archiveSize,
+ (n) => {
+ bytesTransferred += n;
+ updateTransfer(transferId, { bytesTransferred });
+ },
+ () => cancelRequestedTransfers.has(transferId),
+ transferId,
+ reconnect,
+ syncProgress,
+ );
+ mergeXferStats(xferStats, fileStats);
+ } catch (err) {
+ if (!(err instanceof TransferCancelledError)) {
+ await cleanupDestItems(deps, destSession, destPath, basenames);
+ }
+ throw err;
+ } finally {
+ await promisifySftpUnlink(sourceSftp, tempArchive);
+ }
+ throwIfCancelled(transferId);
+ const transferMs = elapsedMs(transferStart);
+ const hopTimings = buildTransferHopTimings(xferStats, transferMs);
+ updateTransfer(transferId, {
+ timings: {
+ ...activeTransfers.get(transferId)?.timings,
+ transferMs,
+ ...hopTimings,
+ },
+ });
+
+ updateTransfer(transferId, { phase: "extracting" });
+
+ throwIfCancelled(transferId);
+ const extractStart = Date.now();
+ const extractCmd = `tar -xzf '${escapedArchive}' -C '${escapedDest}'`;
+ const extractResult = await execCommand(deps, destSession, extractCmd);
+ const extractMs = elapsedMs(extractStart);
+
+ await promisifySftpUnlink(destSftp, tempArchive);
+
+ if (extractResult.code !== 0) {
+ await cleanupDestItems(deps, destSession, destPath, basenames);
+ throw new Error(
+ extractResult.stderr || "Failed to extract on destination host",
+ );
+ }
+
+ if (move) {
+ await deleteSourcePathsAfterSuccess(
+ deps,
+ transferId,
+ sourceSession,
+ sourcePaths,
+ );
+ }
+
+ return finalizeTransfer(transferId, {
+ status: "success",
+ phase: "extracting",
+ method: "tar",
+ bytesTransferred,
+ totalBytes: archiveSize,
+ sourcePaths,
+ destPath,
+ sourceDeleted: move,
+ moveRequested: move,
+ timings: {
+ ...activeTransfers.get(transferId)?.timings,
+ extractMs,
+ ...hopTimings,
+ },
+ });
+}
+
+async function transferViaItemSftp(
+ deps: HostTransferDeps,
+ transferId: string,
+ sourceSession: SSHSessionLike,
+ destSession: SSHSessionLike,
+ sourcePaths: string[],
+ destPath: string,
+ move: boolean,
+ destPlatform: TransferPlatform,
+ reconnectMeta: TransferReconnectMeta,
+): Promise {
+ const sourceSftp = await deps.getSessionSftp(sourceSession);
+ const destSftp = await deps.getSessionSftp(destSession);
+ const reconnect = buildTransferReconnectContext(
+ deps,
+ transferId,
+ reconnectMeta,
+ );
+
+ const createdDirs = new Set();
+ await ensureDirectoryTreeSftp(destSftp, destPath, createdDirs);
+
+ const allWork: FileWorkItem[] = [];
+ for (const sourcePath of sourcePaths) {
+ const items = await collectFileWorkItems(sourceSftp, sourcePath, destPath);
+ allWork.push(...items);
+ }
+
+ allWork.sort((a, b) => b.size - a.size);
+
+ const totalBytes = allWork.reduce((sum, item) => sum + item.size, 0);
+
+ updateTransfer(transferId, {
+ method: "item_sftp",
+ phase: "transferring",
+ totalItems: allWork.length,
+ itemsCompleted: 0,
+ totalBytes,
+ bytesTransferred: 0,
+ });
+
+ const failedPaths: string[] = [];
+ const createdFiles: string[] = [];
+ let itemsCompleted = 0;
+ let bytesTransferred = 0;
+ const transferStart = Date.now();
+ const xferStats = createEmptyXferStats();
+
+ for (const item of allWork) {
+ throwIfCancelled(transferId);
+ trackDestArtifact(transferId, item.destPath);
+ const bytesBeforeItem = bytesTransferred;
+ try {
+ const parentDir = dirname(item.destPath);
+ if (parentDir && !isRootOnlyPath(parentDir)) {
+ await ensureDirectoryTreeSftp(destSftp, parentDir, createdDirs);
+ }
+ const itemStats = await promisifySftpStat(sourceSftp, item.sourcePath);
+ const itemXferStats = await transferFileData(
+ sourceSftp,
+ destSftp,
+ destSession,
+ item.sourcePath,
+ item.destPath,
+ itemStats.size,
+ (n) => {
+ bytesTransferred += n;
+ updateTransfer(transferId, { bytesTransferred });
+ },
+ () => cancelRequestedTransfers.has(transferId),
+ transferId,
+ reconnect,
+ (absoluteOffset) => {
+ bytesTransferred = bytesBeforeItem + absoluteOffset;
+ updateTransfer(transferId, { bytesTransferred });
+ },
+ );
+ mergeXferStats(xferStats, itemXferStats);
+ if (destPlatform !== "windows") {
+ await promisifySftpChmod(destSftp, item.destPath, item.mode);
+ }
+ createdFiles.push(item.destPath);
+ itemsCompleted++;
+ updateTransfer(transferId, { itemsCompleted, bytesTransferred });
+ } catch (err) {
+ if (isPermissionError(err as Error)) {
+ failedPaths.push(item.sourcePath);
+ itemsCompleted++;
+ updateTransfer(transferId, { itemsCompleted, bytesTransferred });
+ continue;
+ }
+ if (!(err instanceof TransferCancelledError)) {
+ for (const created of [...createdFiles].reverse()) {
+ await deletePathSftp(destSftp, created).catch(() => {});
+ }
+ }
+ throw err;
+ }
+ }
+
+ const transferMs = elapsedMs(transferStart);
+ const hopTimings = buildTransferHopTimings(xferStats, transferMs);
+
+ const status: TransferStatus = failedPaths.length > 0 ? "partial" : "success";
+
+ // Move only after every file succeeded — never delete source on partial transfer
+ if (status === "success" && move) {
+ await deleteSourcePathsAfterSuccess(
+ deps,
+ transferId,
+ sourceSession,
+ sourcePaths,
+ );
+ }
+
+ return finalizeTransfer(transferId, {
+ status,
+ phase: "transferring",
+ method: "item_sftp",
+ itemsCompleted,
+ totalItems: allWork.length,
+ failedPaths: failedPaths.length > 0 ? failedPaths : undefined,
+ sourcePaths,
+ destPath,
+ sourceDeleted: status === "success" && move,
+ moveRequested: move,
+ timings: {
+ ...activeTransfers.get(transferId)?.timings,
+ transferMs,
+ ...hopTimings,
+ },
+ });
+}
+
+async function runTransfer(
+ deps: HostTransferDeps,
+ transferId: string,
+ request: TransferRequest,
+): Promise {
+ const {
+ sourceSessionId: browseSourceSessionId,
+ sourcePaths,
+ destSessionId: browseDestSessionId,
+ destPath,
+ move = false,
+ userId,
+ methodPreference = "auto",
+ parallelSegmentCount:
+ requestParallelSegments = DEFAULT_PARALLEL_SEGMENT_COUNT,
+ } = request;
+
+ const parallelSegmentCount = clampParallelSegmentCount(
+ requestParallelSegments,
+ );
+
+ const dedicatedSourceSessionId = `xfer:${transferId}:src`;
+ const dedicatedDestSessionId = `xfer:${transferId}:dst`;
+ const runStart = Date.now();
+ const reconnectMeta: TransferReconnectMeta = {
+ userId,
+ browseSourceSessionId,
+ browseDestSessionId,
+ dedicatedSourceSessionId,
+ dedicatedDestSessionId,
+ };
+
+ try {
+ const sourceSession = await deps.openDedicatedTransferSession(
+ browseSourceSessionId,
+ dedicatedSourceSessionId,
+ userId,
+ transferId,
+ );
+ const destSession = await deps.openDedicatedTransferSession(
+ browseDestSessionId,
+ dedicatedDestSessionId,
+ userId,
+ transferId,
+ );
+
+ sourceSession.lastActive = Date.now();
+ destSession.lastActive = Date.now();
+
+ updateTransfer(transferId, {
+ parallelSegmentCount,
+ dedicatedSourceSessionId,
+ dedicatedDestSessionId,
+ });
+
+ const pathHints = [destPath, ...sourcePaths];
+ const sourcePlatform = await detectTransferPlatform(
+ deps,
+ sourceSession,
+ pathHints,
+ );
+ const destPlatform = await detectTransferPlatform(
+ deps,
+ destSession,
+ pathHints,
+ );
+
+ const sourceSftp = await deps.getSessionSftp(sourceSession);
+ const destSftp = await deps.getSessionSftp(destSession);
+
+ for (const path of sourcePaths) {
+ try {
+ await promisifySftpStat(sourceSftp, path);
+ } catch {
+ throw new Error(`Source not found: ${path}`);
+ }
+ }
+
+ if (browseSourceSessionId === browseDestSessionId) {
+ for (const src of sourcePaths) {
+ if (pathsOverlap(src, destPath)) {
+ throw new Error("Source and destination paths overlap");
+ }
+ }
+ }
+
+ let useArchive = sourcePaths.length > 1;
+
+ if (sourcePaths.length === 1) {
+ const stats = await promisifySftpStat(sourceSftp, sourcePaths[0]);
+ useArchive = stats.isDirectory();
+ }
+
+ if (!useArchive) {
+ await transferSingleFile(
+ deps,
+ transferId,
+ sourceSession,
+ destSession,
+ sourcePaths[0],
+ destPath,
+ move,
+ reconnectMeta,
+ );
+ } else {
+ const prepareStart = Date.now();
+ await ensureDestDirectory(deps, destSession, destPath);
+ updateTransfer(transferId, {
+ timings: { prepareDestMs: elapsedMs(prepareStart) },
+ });
+
+ const sourceHasTar =
+ sourcePlatform === "unix" &&
+ (await checkTarAvailable(deps, sourceSession));
+ const destHasTar =
+ destPlatform === "unix" && (await checkTarAvailable(deps, destSession));
+
+ throwIfCancelled(transferId);
+ const scanSummary = await scanSourcePathsForRouting(
+ sourceSftp,
+ sourcePaths,
+ transferId,
+ );
+ const archiveMethod = resolveArchiveTransferMethod(
+ methodPreference,
+ scanSummary,
+ sourcePlatform,
+ destPlatform,
+ sourceHasTar,
+ destHasTar,
+ );
+
+ fileLogger.info("Resolved archive transfer method", {
+ operation: "host_transfer",
+ transferId,
+ methodPreference,
+ archiveMethod,
+ fileCount: scanSummary.fileCount,
+ totalBytes: scanSummary.totalBytes,
+ incompressibleRatio: scanSummary.incompressibleRatio,
+ });
+
+ if (archiveMethod === "tar") {
+ await transferViaTar(
+ deps,
+ transferId,
+ sourceSession,
+ destSession,
+ sourcePaths,
+ destPath,
+ move,
+ reconnectMeta,
+ );
+ } else {
+ await transferViaItemSftp(
+ deps,
+ transferId,
+ sourceSession,
+ destSession,
+ sourcePaths,
+ destPath,
+ move,
+ destPlatform,
+ reconnectMeta,
+ );
+ }
+ }
+
+ fileLogger.success("Host transfer completed", {
+ operation: "host_transfer",
+ transferId,
+ browseSourceSessionId,
+ browseDestSessionId,
+ sourcePaths,
+ destPath,
+ move,
+ timings: {
+ ...activeTransfers.get(transferId)?.timings,
+ totalMs: elapsedMs(runStart),
+ },
+ });
+ updateTransfer(transferId, {
+ moveRequested: move,
+ timings: {
+ ...activeTransfers.get(transferId)?.timings,
+ totalMs: elapsedMs(runStart),
+ },
+ });
+ } catch (err) {
+ if (err instanceof TransferCancelledError) {
+ const current = activeTransfers.get(transferId);
+ finalizeTransfer(
+ transferId,
+ cancelledProgressPatch(current, {
+ status: "cancelled",
+ phase: current?.phase ?? "transferring",
+ message: "Transfer cancelled by user",
+ method: current?.method,
+ sourcePaths,
+ destPath,
+ bytesTransferred: current?.bytesTransferred,
+ totalBytes: current?.totalBytes,
+ itemsCompleted: current?.itemsCompleted,
+ totalItems: current?.totalItems,
+ moveRequested: move,
+ sourceDeleted: false,
+ timings: {
+ ...current?.timings,
+ totalMs: elapsedMs(runStart),
+ },
+ }),
+ );
+ fileLogger.info("Host transfer cancelled", {
+ operation: "host_transfer",
+ transferId,
+ sourcePaths,
+ destPath,
+ });
+ return;
+ }
+
+ const current = activeTransfers.get(transferId);
+ const message = err instanceof Error ? err.message : "Transfer failed";
+
+ if (
+ sourcePaths.length === 1 &&
+ current?.method === "stream" &&
+ current.destPath &&
+ current.totalBytes
+ ) {
+ try {
+ const destSession = await deps.openDedicatedTransferSession(
+ browseDestSessionId,
+ dedicatedDestSessionId,
+ userId,
+ transferId,
+ { allowBrowseDisconnected: true },
+ );
+ const finalized = await tryFinalizeStreamTransferIfDestComplete(
+ deps,
+ transferId,
+ destSession,
+ current.destPath,
+ current.totalBytes,
+ {
+ sourcePaths,
+ moveRequested: move,
+ sourceDeleted: current.sourceDeleted,
+ },
+ );
+ if (finalized) {
+ fileLogger.info("Host transfer succeeded after destination verify", {
+ operation: "host_transfer",
+ transferId,
+ sourcePaths,
+ destPath,
+ });
+ return;
+ }
+ } catch {
+ /* fall through to error */
+ }
+ }
+
+ finalizeTransfer(
+ transferId,
+ failedProgressPatch(current, {
+ status: "error",
+ phase: current?.phase ?? "transferring",
+ message,
+ method: current?.method,
+ sourcePaths,
+ destPath,
+ bytesTransferred: current?.bytesTransferred,
+ totalBytes: current?.totalBytes,
+ itemsCompleted: current?.itemsCompleted,
+ totalItems: current?.totalItems,
+ moveRequested: move,
+ timings: {
+ ...current?.timings,
+ totalMs: elapsedMs(runStart),
+ },
+ }),
+ );
+ fileLogger.error(
+ err instanceof TransferStalledError
+ ? "Host transfer stalled"
+ : "Host transfer failed",
+ err,
+ {
+ operation: "host_transfer",
+ transferId,
+ sourcePaths,
+ destPath,
+ },
+ );
+ } finally {
+ deps.closeDedicatedTransferSession(dedicatedSourceSessionId);
+ deps.closeDedicatedTransferSession(dedicatedDestSessionId);
+ }
+}
+
+export function getTransferStatus(
+ transferId: string,
+ userId: string,
+): TransferProgress | null {
+ checkHungTransfers();
+ const progress = activeTransfers.get(transferId);
+ if (!progress || progress.userId !== userId) return null;
+ return progress;
+}
+
+export function listActiveTransfers(userId: string): TransferProgress[] {
+ return Array.from(activeTransfers.values()).filter(
+ (progress) => progress.userId === userId && progress.status === "running",
+ );
+}
+
+export async function cleanupCancelledTransfer(
+ deps: HostTransferDeps,
+ transferId: string,
+ userId: string,
+): Promise<{ removedPaths: string[]; failedPaths: string[] }> {
+ const progress = activeTransfers.get(transferId);
+ if (!progress || progress.userId !== userId) {
+ throw new Error("Transfer not found");
+ }
+ if (progress.status !== "cancelled") {
+ throw new Error("Transfer is not cancelled");
+ }
+ if (progress.cleanupCompleted) {
+ return { removedPaths: [], failedPaths: [] };
+ }
+ if (!progress.destSessionId) {
+ throw new Error("Transfer has no destination session");
+ }
+
+ const pathsToRemove = buildCleanupPaths(progress);
+ if (pathsToRemove.length === 0) {
+ updateTransfer(transferId, { cleanupCompleted: true });
+ return { removedPaths: [], failedPaths: [] };
+ }
+
+ const cleanupSessionId = `xfer:cleanup:${transferId}:dst`;
+ const removedPaths: string[] = [];
+ const failedPaths: string[] = [];
+
+ try {
+ const destSession = await deps.openDedicatedTransferSession(
+ progress.destSessionId,
+ cleanupSessionId,
+ userId,
+ transferId,
+ { allowBrowseDisconnected: true },
+ );
+ const destSftp = await deps.getSessionSftp(destSession);
+
+ for (const path of pathsToRemove) {
+ try {
+ await deletePathSftp(destSftp, path);
+ removedPaths.push(path);
+ } catch {
+ failedPaths.push(path);
+ }
+ }
+ } finally {
+ deps.closeDedicatedTransferSession(cleanupSessionId);
+ }
+
+ updateTransfer(transferId, { cleanupCompleted: true });
+ return { removedPaths, failedPaths };
+}
+
+export function retryHostTransfer(
+ deps: HostTransferDeps,
+ transferId: string,
+ userId: string,
+): boolean {
+ const progress = activeTransfers.get(transferId);
+ if (
+ !progress ||
+ progress.userId !== userId ||
+ progress.status !== "error" ||
+ !progress.retryable ||
+ !progress.requestSnapshot
+ ) {
+ return false;
+ }
+
+ void (async () => {
+ if (
+ progress.method === "stream" &&
+ progress.destPath &&
+ progress.totalBytes &&
+ progress.destSessionId
+ ) {
+ const destId =
+ progress.dedicatedDestSessionId ?? `xfer:${transferId}:dst`;
+ try {
+ const destSession = await deps.openDedicatedTransferSession(
+ progress.destSessionId,
+ destId,
+ userId,
+ transferId,
+ { allowBrowseDisconnected: true },
+ );
+ const done = await tryFinalizeStreamTransferIfDestComplete(
+ deps,
+ transferId,
+ destSession,
+ progress.destPath,
+ progress.totalBytes,
+ {
+ sourcePaths: progress.sourcePaths,
+ moveRequested: progress.moveRequested,
+ },
+ );
+ if (done) {
+ return;
+ }
+ } catch {
+ /* destination not ready — restart transfer below */
+ }
+ }
+
+ const latest = activeTransfers.get(transferId);
+ if (!latest || latest.status !== "error" || !latest.requestSnapshot) {
+ return;
+ }
+
+ updateTransfer(transferId, {
+ status: "running",
+ phase: "transferring",
+ message: undefined,
+ retryable: false,
+ parallelSegmentCount:
+ latest.requestSnapshot?.parallelSegmentCount ??
+ latest.parallelSegmentCount,
+ });
+
+ await runTransfer(deps, transferId, {
+ ...latest.requestSnapshot,
+ userId,
+ parallelSegmentCount:
+ latest.requestSnapshot?.parallelSegmentCount ??
+ latest.parallelSegmentCount ??
+ DEFAULT_PARALLEL_SEGMENT_COUNT,
+ });
+ })();
+
+ return true;
+}
+
+export async function previewArchiveTransferMethod(
+ deps: HostTransferDeps,
+ request: {
+ sourceSessionId: string;
+ destSessionId: string;
+ sourcePaths: string[];
+ destPath: string;
+ methodPreference?: TransferMethodPreference;
+ userId: string;
+ },
+): Promise {
+ const {
+ sourceSessionId,
+ destSessionId,
+ sourcePaths,
+ destPath,
+ methodPreference = "auto",
+ userId,
+ } = request;
+
+ const sourceSession = deps.sshSessions[sourceSessionId];
+ const destSession = deps.sshSessions[destSessionId];
+
+ if (!sourceSession?.isConnected || !destSession?.isConnected) {
+ throw new Error("SSH session not connected");
+ }
+
+ if (
+ !deps.verifySessionOwnership(sourceSession, userId) ||
+ !deps.verifySessionOwnership(destSession, userId)
+ ) {
+ throw new Error("Session access denied");
+ }
+
+ const pathHints = [destPath, ...sourcePaths];
+ const sourcePlatform = await detectTransferPlatform(
+ deps,
+ sourceSession,
+ pathHints,
+ );
+ const destPlatform = await detectTransferPlatform(
+ deps,
+ destSession,
+ pathHints,
+ );
+
+ const sourceSftp = await deps.getSessionSftp(sourceSession);
+ const scanItems: Array<{ sourcePath: string; size: number }> = [];
+ for (const sourcePath of sourcePaths) {
+ const work = await collectFileWorkItems(sourceSftp, sourcePath, "/");
+ scanItems.push(
+ ...work.map((w) => ({ sourcePath: w.sourcePath, size: w.size })),
+ );
+ }
+ const scanSummary = buildTransferScanSummary(scanItems);
+
+ const sourceHasTar =
+ sourcePlatform === "unix" && (await checkTarAvailable(deps, sourceSession));
+ const destHasTar =
+ destPlatform === "unix" && (await checkTarAvailable(deps, destSession));
+
+ const resolvedMethod = resolveArchiveTransferMethod(
+ methodPreference,
+ scanSummary,
+ sourcePlatform,
+ destPlatform,
+ sourceHasTar,
+ destHasTar,
+ );
+
+ const reasonKey = getArchiveTransferReasonKey(
+ methodPreference,
+ resolvedMethod,
+ scanSummary,
+ sourcePlatform,
+ destPlatform,
+ sourceHasTar,
+ destHasTar,
+ );
+
+ return {
+ methodPreference,
+ resolvedMethod,
+ reasonKey,
+ sourcePlatform,
+ destPlatform,
+ sourceHasTar,
+ destHasTar,
+ summary: scanSummary,
+ };
+}
+
+export function startHostTransfer(
+ deps: HostTransferDeps,
+ request: TransferRequest,
+): { transferId: string; syncResult?: TransferProgress } {
+ const transferId = randomUUID();
+
+ activeTransfers.set(transferId, {
+ transferId,
+ status: "running",
+ phase: "transferring",
+ sourcePaths: request.sourcePaths,
+ destPath: request.destPath,
+ userId: request.userId,
+ sourceSessionId: request.sourceSessionId,
+ destSessionId: request.destSessionId,
+ startedAt: Date.now(),
+ moveRequested: request.move ?? false,
+ methodPreference: request.methodPreference ?? "auto",
+ parallelSegmentCount: clampParallelSegmentCount(
+ request.parallelSegmentCount,
+ ),
+ requestSnapshot: {
+ sourceSessionId: request.sourceSessionId,
+ sourcePaths: request.sourcePaths,
+ destSessionId: request.destSessionId,
+ destPath: request.destPath,
+ move: request.move,
+ methodPreference: request.methodPreference,
+ parallelSegmentCount: clampParallelSegmentCount(
+ request.parallelSegmentCount,
+ ),
+ },
+ });
+
+ const isSingleSmallFile = request.sourcePaths.length === 1 && !request.move;
+
+ if (isSingleSmallFile) {
+ const sourceSession = deps.sshSessions[request.sourceSessionId];
+ if (sourceSession?.sftp || sourceSession?.isConnected) {
+ void (async () => {
+ try {
+ const sftp = await deps.getSessionSftp(sourceSession);
+ const stats = await promisifySftpStat(sftp, request.sourcePaths[0]);
+ if (stats.isFile() && stats.size <= SMALL_FILE_SYNC_THRESHOLD) {
+ await runTransfer(deps, transferId, request);
+ return;
+ }
+ } catch {
+ /* fall through to async */
+ }
+ void runTransfer(deps, transferId, request);
+ })();
+ return { transferId };
+ }
+ }
+
+ void runTransfer(deps, transferId, request);
+ return { transferId };
+}
+
+export function cleanupOldTransfers(maxAgeMs = 60 * 60 * 1000): void {
+ const now = Date.now();
+ for (const [id, progress] of activeTransfers.entries()) {
+ if (
+ progress.status !== "running" &&
+ now - (progress as TransferProgress & { _ts?: number })._ts! > maxAgeMs
+ ) {
+ activeTransfers.delete(id);
+ }
+ }
+}
diff --git a/src/backend/ssh/jump-host-chain.ts b/src/backend/ssh/jump-host-chain.ts
new file mode 100644
index 00000000..c2fd46fc
--- /dev/null
+++ b/src/backend/ssh/jump-host-chain.ts
@@ -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 {
+ 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 {
+ if (!jumpHosts || jumpHosts.length === 0) {
+ return null;
+ }
+
+ let currentClient: SSHClient | null = null;
+ const clients: SSHClient[] = [];
+
+ try {
+ const jumpHostConfigs: Array>> =
+ [];
+ 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((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 = {
+ 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;
+ }
+}
diff --git a/src/backend/ssh/opkssh-auth.ts b/src/backend/ssh/opkssh-auth.ts
index 5b00239a..03a0c3c4 100644
--- a/src/backend/ssh/opkssh-auth.ts
+++ b/src/backend/ssh/opkssh-auth.ts
@@ -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;
}
diff --git a/src/backend/ssh/server-stats-helpers.test.ts b/src/backend/ssh/server-stats-helpers.test.ts
new file mode 100644
index 00000000..c59c4612
--- /dev/null
+++ b/src/backend/ssh/server-stats-helpers.test.ts
@@ -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);
+ });
+});
diff --git a/src/backend/ssh/server-stats-helpers.ts b/src/backend/ssh/server-stats-helpers.ts
new file mode 100644
index 00000000..e9977413
--- /dev/null
+++ b/src/backend/ssh/server-stats-helpers.ts
@@ -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,
+): Omit {
+ return {
+ type,
+ stage,
+ message,
+ details,
+ };
+}
diff --git a/src/backend/ssh/server-stats-jump-hosts.ts b/src/backend/ssh/server-stats-jump-hosts.ts
new file mode 100644
index 00000000..cfc88f6b
--- /dev/null
+++ b/src/backend/ssh/server-stats-jump-hosts.ts
@@ -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 {
+ 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 {
+ if (!jumpHosts || jumpHosts.length === 0) {
+ return null;
+ }
+
+ let currentClient: Client | null = null;
+ const clients: Client[] = [];
+
+ try {
+ const jumpHostConfigs: Array>> =
+ [];
+ 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((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 = {
+ 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;
+ }
+}
diff --git a/src/backend/ssh/server-stats-sessions.ts b/src/backend/ssh/server-stats-sessions.ts
new file mode 100644
index 00000000..fb9aa71f
--- /dev/null
+++ b/src/backend/ssh/server-stats-sessions.ts
@@ -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 = {};
+export const pendingTOTPSessions: Record = {};
+
+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}`;
+}
diff --git a/src/backend/ssh/server-stats-settings-routes.ts b/src/backend/ssh/server-stats-settings-routes.ts
new file mode 100644
index 00000000..a7a4e5ae
--- /dev/null
+++ b/src/backend/ssh/server-stats-settings-routes.ts
@@ -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;
+};
+
+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),
+ });
+ }
+ });
+}
diff --git a/src/backend/ssh/server-stats-state.ts b/src/backend/ssh/server-stats-state.ts
new file mode 100644
index 00000000..f1140bd3
--- /dev/null
+++ b/src/backend/ssh/server-stats-state.ts
@@ -0,0 +1,256 @@
+class RequestQueue {
+ private queues = new Map Promise>>();
+ private processing = new Set();
+ private requestTimeout = 60000;
+
+ async queueRequest(hostId: number, request: () => Promise): Promise {
+ return new Promise((resolve, reject) => {
+ const wrappedRequest = async () => {
+ try {
+ const result = await Promise.race([
+ request(),
+ new Promise((_, 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 {
+ 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();
+ 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();
+ 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();
+ 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();
diff --git a/src/backend/ssh/server-stats-viewer-routes.ts b/src/backend/ssh/server-stats-viewer-routes.ts
new file mode 100644
index 00000000..7e5a36dc
--- /dev/null
+++ b/src/backend/ssh/server-stats-viewer-routes.ts
@@ -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;
+ 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,
+): 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" });
+ }
+ });
+}
diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts
index 81fe337e..b821ce8a 100644
--- a/src/backend/ssh/server-stats.ts
+++ b/src/backend/ssh/server-stats.ts
@@ -6,7 +6,7 @@ import { Client, type ConnectConfig } from "ssh2";
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
import { getDb } from "../database/db/index.js";
import { hosts, sshCredentials } from "../database/db/schema.js";
-import { eq, and } from "drizzle-orm";
+import { eq } from "drizzle-orm";
import { statsLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
@@ -29,679 +29,29 @@ import {
} from "../utils/socks5-helper.js";
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
import { connectionPool, withConnection } from "./ssh-connection-pool.js";
+import { registerServerStatsSettingsRoutes } from "./server-stats-settings-routes.js";
+import { registerServerStatsViewerRoutes } from "./server-stats-viewer-routes.js";
+import { createJumpHostChain } from "./server-stats-jump-hosts.js";
+import {
+ createConnectionLog,
+ isTcpPingEnabled,
+ supportsMetrics,
+} from "./server-stats-helpers.js";
+import {
+ cleanupMetricsSession,
+ getSessionKey,
+ metricsSessions,
+ pendingTOTPSessions,
+ scheduleMetricsSessionCleanup,
+ type MetricsViewer,
+} from "./server-stats-sessions.js";
+import {
+ authFailureTracker,
+ metricsCache,
+ pollingBackoff,
+ requestQueue,
+} from "./server-stats-state.js";
-function supportsMetrics(host: SSHHostWithCredentials): boolean {
- const connectionType = host.connectionType || "ssh";
- if (connectionType !== "ssh") return false;
- if (host.authType === "none" || host.authType === "opkssh") return false;
- return true;
-}
-
-function isTcpPingEnabled(statsConfig: StatsConfig): boolean {
- return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing;
-}
-
-function createConnectionLog(
- type: "info" | "success" | "warning" | "error",
- stage: ConnectionStage,
- message: string,
- details?: Record,
-): Omit {
- return {
- type,
- stage,
- message,
- details,
- };
-}
-
-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 {
- 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;
- }
-}
-
-async function createJumpHostChain(
- jumpHosts: Array<{ hostId: number }>,
- userId: string,
- socks5Config?: SOCKS5Config | null,
-): Promise {
- if (!jumpHosts || jumpHosts.length === 0) {
- return null;
- }
-
- let currentClient: Client | null = null;
- const clients: Client[] = [];
-
- try {
- const jumpHostConfigs: Array>> =
- [];
- 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((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 = {
- 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;
- }
-}
-
-interface MetricsSession {
- client: Client;
- isConnected: boolean;
- lastActive: number;
- timeout?: NodeJS.Timeout;
- activeOperations: number;
- hostId: number;
- userId: string;
-}
-
-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;
-}
-
-interface MetricsViewer {
- sessionId: string;
- userId: string;
- hostId: number;
- lastHeartbeat: number;
-}
-
-const metricsSessions: Record = {};
-const pendingTOTPSessions: Record = {};
-
-function cleanupMetricsSession(sessionId: string) {
- 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];
- }
-}
-
-function scheduleMetricsSessionCleanup(sessionId: string) {
- const session = metricsSessions[sessionId];
- if (session) {
- if (session.timeout) clearTimeout(session.timeout);
-
- session.timeout = setTimeout(
- () => {
- cleanupMetricsSession(sessionId);
- },
- 30 * 60 * 1000,
- );
- }
-}
-
-function getSessionKey(hostId: number, userId: string): string {
- return `${userId}:${hostId}`;
-}
-
-class RequestQueue {
- private queues = new Map Promise>>();
- private processing = new Set