Files
Termix/src/backend/ssh/file-manager.ts
T
Luke Gustafson 9de904dd4c release-2.5.0 (#994)
* feat(sshid) - sshid.io equivalent for termix (#919)

* feat(ssh-id): database schema, migrations and field encryption

Adds ssh_identities, ssh_identity_keys and ssh_identity_ca tables (public keys
stored plaintext for the unauthenticated resolver; CA private key registered
for per-user field encryption), with UNIQUE(user_id), an index on
ssh_identity_keys(identity_id), and idempotent CREATE TABLE migrations.

* feat(ssh-id): backend API — resolver, key management, CA and certificates

Mounts /sshid (nginx route added). Public text/plain authorized_keys resolver
(+ exact /:algo filter, HTML viewer) and CA public-key endpoint; no-store +
noindex headers on every resolver response including early 404s. Authenticated
management: claim/rename/delete handle, add/import/generate/enable/delete keys,
and a per-user CA (create/rotate/delete) with pure-Node OpenSSH certificate
issuance. Audit logging on all mutations; UNIQUE races map to a precise 409.
Unit tests for key parsing and certificate signing (ssh-keygen-validated).

* feat(ssh-id): frontend panel, API client and i18n

SSH ID panel wired into the app rail and AppShell: claim handle, resolver URL +
curl one-liner, key list, generate, paste/import, CA enable/rotate/remove with
server trust command, and per-key certificate issuance. API client re-exported
through main-axios.ts; all strings i18n'd.

* style(ssh-id): align panel and resolver page with Termix theme

- Rebuild the SSH ID sidebar panel with the theme's square components
  (SectionCard / SettingRow / FakeSwitch) instead of rounded ad-hoc cards;
  use accent-brand and destructive tokens rather than raw red/green.
- Fix panel scrolling: move overflow to a block scroll container so the
  cards keep their natural height instead of being clipped.
- Restyle the public resolver HTML page (/sshid/u/:handle) to the Termix
  dark theme: square corners, #18181b/#303032 palette, #f59145 accent,
  uppercase section labels.
- Tidy copy: 'Save To Credentials' label, drop the redundant generate intro,
  and correct the generate tooltip (the key is stored when saving to vault).

* feat: rename to Termix ID, improve UI, backend inconsistencies, and general bug fixes

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* ci(deps): bump actions/checkout from 6 to 7 in the github-actions group (#922)

Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 11 updates (#923)

Bumps the dev-patch-updates group with 11 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.0` | `6.7.1` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.8` | `4.1.9` |
| [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.8` | `4.1.9` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` |
| [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` |
| [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` |
| [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.2` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` |


Updates `@codemirror/search` from 6.7.0 to 6.7.1
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)

Updates `@codemirror/view` from 6.43.0 to 6.43.1
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-vite)

Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8)

Updates `@vitest/ui` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/ui)

Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.2...v0.5.3)

Updates `lint-staged` from 17.0.7 to 17.0.8
- [Release notes](https://github.com/lint-staged/lint-staged/releases)
- [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md)
- [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8)

Updates `prettier` from 3.8.3 to 3.8.4
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4)

Updates `sharp` from 0.35.1 to 0.35.2
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.2)

Updates `tailwindcss` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss)

Updates `vitest` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest)

---
updated-dependencies:
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitest/ui"
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: lint-staged
  dependency-version: 17.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.8.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: sharp
  dependency-version: 0.35.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tailwindcss
  dependency-version: 4.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: vitest
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump nanoid in the prod-patch-updates group (#925)

Bumps the prod-patch-updates group with 1 update: [nanoid](https://github.com/ai/nanoid).


Updates `nanoid` from 5.1.11 to 5.1.15
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/5.1.11...5.1.15)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-version: 5.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the major-updates group with 5 updates (#926)

Bumps the major-updates group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.0.0` |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `26.0.0` |
| [concurrently](https://github.com/open-cli-tools/concurrently) | `9.2.1` | `10.0.3` |
| [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.5.0` |


Updates `js-yaml` from 4.2.0 to 5.0.0
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0)

Updates `@eslint/js` from 9.39.4 to 10.0.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

Updates `@types/node` from 25.9.2 to 26.0.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `concurrently` from 9.2.1 to 10.0.3
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3)

Updates `eslint` from 9.39.4 to 10.5.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.5.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: "@types/node"
  dependency-version: 26.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: concurrently
  dependency-version: 10.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: eslint
  dependency-version: 10.5.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat(ssh): add HashiCorp Vault SSH signer authentication

* fix: small fixes to vault feature to align with Termix codebase

* chore: add view docs links for vault/termix id

* fix: file upload fails with 400 and missing schema migrations on upgrade (#929)

Two bugs introduced in v2.4.1:

1. uploadFileStream uses fileManagerApi.post() which triggers axios's
   transformRequest to JSON-serialize the FormData because the instance
   default Content-Type is application/json. Change to postForm() which
   sets Content-Type: multipart/form-data so the browser XHR sends the
   correct multipart body with boundary.

2. Two schema items added to schema.ts were not included in migrateSchema()
   in db/index.ts, causing 500 errors on existing installations upgrading
   from v2.4.0:
   - user_preferences.status_color_scheme (no such column)
   - dashboard_service_links table (no such table)

Fixes #928

Co-authored-by: sash <sash@fominykh.io>

* fix: support PuTTY PPK ssh keys (#930)

* fix: chunk large file manager uploads (#932)

* fix: route dashboard hosts by protocol (#934)

* fix: resolve tunnel endpoints reliably (#935)

* Fix Electron OIDC browser auth failures (#936)

* Allow RDP connections without stored credentials (#937)

* Sync role credential shares for OIDC users (#938)

* Fix terminal link dialog layering (#940)

* Confirm large files before opening editor (#942)

* Confirm closing active host connections (#943)

* Preserve file path case in file manager UI (#941)

* fix: preserve unicode guacamole tokens (#933)

* Persist VNC authentication settings (#944)

* Fix Guacamole websocket base path (#946)

* Promote file manager terminals to tabs (#939)

* Guard Guacamole disconnect during startup (#945)

* chore: increment ver

* feat: bitwarden ssh agent integration

* feat: serial connections support

* fix: various small bug fixes

* feat: open all sessions in a folder and terminal custom theme color support

* feat: cross host file manager clipboard and several small bug fixes

* feat: tailscale/wireguard support and added a new status state for when backend is checking status

* feat: grafana like server stats history, new alert system, ntfy/webhook support

* feat: new grid and widget based homepage function

* feat: new donate button in dashboard

* fix: alert ui incorrectly using termix css and fixed issue with alert system not loading

* chore: fix ci checks (#966)

* Fix dashboard service link creation (#950)

* Fix jump host SOCKS5 proxy selection (#951)

* Fix jump host SOCKS5 proxy selection

* fix: type jump host socks proxy config

* Fix tmux detection path handling (#949)

* Support GUACD_URL environment config (#952)

* Retry autostart tunnel host fetches (#953)

* Retry autostart tunnel host fetches

* chore: format tunnel route

* Fix PUID html ownership in Docker entrypoint (#954)

* feat: allow custom tunnel endpoints (#977)

* fix: skip metrics start for non-ssh hosts (#976)

* Fix Proxmox import auth fallback (#956)

* Fix Proxmox import auth fallback

* chore: format proxmox import auth

* Fix SSH heading syntax highlighting (#955)

* Fix SSH heading syntax highlighting

* chore: format terminal highlighter

* Initialize auth before fullscreen terminal routes (#957)

* Add WebAuthn passkey authentication (#959)

* chore: add Biome tooling (#965)

* chore: add biome tooling

* chore: support tailwind syntax in biome

* Fix VNC required argument handshake (#968)

* Prioritize host results in command palette search (#969)

* Prevent sidebar host hover layout shift (#970)

* Add terminal font zoom with mouse wheel (#971)

* Add host temperature metrics card (#972)

* Make file downloads reliable in desktop app (#973)

* Add app rail hover expansion setting (#974)

* fix: use correct translation key for nav.close (#964)

* fix(tunnel): skip endpoint credential validation for direct tunnels (#963)

* fix: SSH port connection bug (#975)

* fix: chunked upload for files >=1.5GB to bypass browser ArrayBuffer limit (#948)

* feat: support SSH agent auth across SSH features (#960)

* feat: add Podman container runtime support (#958)

* chore: update readme and release notes

* fix: stabilize Windows app icon (#978)

* Add safe host sharing export (#979)

* Add external editor support for file manager (#985)

* Rework SSH credential password handling (#984)

* Support password fallback for SSH key credentials

* Complete SSH credential password fallback

* Fix runtime base path for auth callbacks (#982)

* Fix TUI terminal output highlighting (#983)

Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* Add app fullscreen mode (#993)

* Fix host metrics startup polling (#986)

* chore: update release notes

* fix: line chart text overlap

* chore: update RELEASE_NOTES.md

* chore: add donation goal to readme

* chore: update readme

* fix: hide full-screen button in electron app

* fix: failed unit tests

* chore: lint, format, and bump version to 2.5.0

* chore: remove timeout from crowdin translate

* chore: sync Crowdin translations for 2.5.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: DivByZero <mr.oplus@yahoo.fr>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com>
Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com>
Co-authored-by: sash <sash@fominykh.io>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
2026-06-29 13:28:26 -05:00

3035 lines
86 KiB
TypeScript

import express from "express";
import { createCorsMiddleware } from "../utils/cors-config.js";
import cookieParser from "cookie-parser";
import axios from "axios";
import { Client as SSHClient } from "ssh2";
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
import { getDb } from "../database/db/index.js";
import { 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";
import { AuthManager } from "../utils/auth-manager.js";
import type { AuthenticatedRequest, ProxyNode } from "../../types/index.js";
import {
createSocks5Connection,
type SOCKS5Config,
} from "../utils/socks5-helper.js";
import type { LogEntry, ConnectionStage } from "../../types/connection-log.js";
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
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 { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.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";
import { applyAgentAuth } from "./terminal-auth-helpers.js";
const app = express();
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
app.use(cookieParser());
app.use(express.json({ limit: "1gb" }));
app.use(express.urlencoded({ limit: "1gb", extended: true }));
app.use(express.raw({ limit: "5gb", type: "application/octet-stream" }));
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
const authManager = AuthManager.getInstance();
app.use(authManager.createAuthMiddleware());
const sshSessions: Record<string, SSHSession> = {};
const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
// Keyed by "sessionId:path" to prevent concurrent requests for the same path
const activeListRequests: Record<string, boolean> = {};
function cleanupSession(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
if (session.activeOperations > 0) {
fileLogger.warn(
`Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`,
{
operation: "cleanup_deferred",
sessionId,
activeOperations: session.activeOperations,
},
);
scheduleSessionCleanup(sessionId);
return;
}
try {
if (session.sftp) {
session.sftp.end();
session.sftp = undefined;
}
} catch {
// expected
}
try {
session.client.end();
} catch {
// expected
}
clearTimeout(session.timeout);
delete sshSessions[sessionId];
}
}
function scheduleSessionCleanup(sessionId: string) {
const session = sshSessions[sessionId];
if (session) {
if (session.timeout) clearTimeout(session.timeout);
session.timeout = setTimeout(
() => {
cleanupSession(sessionId);
},
30 * 60 * 1000,
);
}
}
function verifySessionOwnership(session: SSHSession, userId: string): boolean {
return !session.userId || session.userId === userId;
}
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;
}
async function buildDedicatedTransferConnectConfig(
host: SSHHost,
userId: string,
client: SSHClient,
): Promise<Record<string, unknown>> {
const { ip, port, username } = host;
const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(host.id);
const config: Record<string, unknown> = {
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,
preloadedHostData,
),
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 authType = host.authType;
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 === "agent") {
const result = await applyAgentAuth(
config,
host.terminalConfig as unknown as Record<string, unknown> | undefined,
);
if ("error" in result) {
throw new Error(result.error);
}
} else if (authType !== "none" && authType !== "tailscale") {
throw new Error(`Unsupported auth type for transfer: ${authType}`);
}
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<string, unknown>,
host: SSHHost,
userId: string,
): Promise<void> {
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");
}
await new Promise<void>((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;
}
}
client.connect(config);
}
async function openDedicatedTransferSession(
browseSessionId: string,
dedicatedSessionId: string,
userId: string,
transferId: string,
options?: { allowBrowseDisconnected?: boolean },
): Promise<SSHSession> {
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<void>((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:
* post:
* summary: Connect to SSH for file management
* description: Establishes an SSH/SFTP connection for file manager operations. Supports password, key-based, and keyboard-interactive authentication, as well as jump hosts and SOCKS5 proxies.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - sessionId
* - ip
* - port
* - username
* properties:
* sessionId:
* type: string
* description: Unique session identifier
* hostId:
* type: number
* description: Host ID from database
* ip:
* type: string
* description: SSH server IP address
* port:
* type: number
* description: SSH server port
* username:
* type: string
* description: SSH username
* password:
* type: string
* description: SSH password (for password auth)
* sshKey:
* type: string
* description: SSH private key (for key-based auth)
* keyPassword:
* type: string
* description: Private key passphrase
* authType:
* type: string
* enum: [password, key, none]
* description: Authentication method
* credentialId:
* type: number
* description: Credential ID to use from database
* userProvidedPassword:
* type: string
* description: User-provided password for keyboard-interactive auth
* forceKeyboardInteractive:
* type: boolean
* description: Force keyboard-interactive authentication
* jumpHosts:
* type: array
* description: Jump host configuration
* items:
* type: object
* properties:
* hostId:
* type: number
* useSocks5:
* type: boolean
* description: Use SOCKS5 proxy
* socks5Host:
* type: string
* description: SOCKS5 proxy host
* socks5Port:
* type: number
* description: SOCKS5 proxy port
* socks5Username:
* type: string
* description: SOCKS5 proxy username
* socks5Password:
* type: string
* description: SOCKS5 proxy password
* socks5ProxyChain:
* type: array
* description: Chain of SOCKS5 proxies
* responses:
* 200:
* description: SSH connection established successfully, or requires TOTP/Warpgate authentication.
* content:
* application/json:
* schema:
* oneOf:
* - type: object
* properties:
* status:
* type: string
* example: success
* message:
* type: string
* connectionLogs:
* type: array
* - type: object
* properties:
* requires_totp:
* type: boolean
* sessionId:
* type: string
* prompt:
* type: string
* connectionLogs:
* type: array
* - type: object
* properties:
* requires_warpgate:
* type: boolean
* sessionId:
* type: string
* url:
* type: string
* securityKey:
* type: string
* connectionLogs:
* type: array
* - type: object
* properties:
* status:
* type: string
* example: auth_required
* reason:
* type: string
* connectionLogs:
* type: array
* 400:
* description: Missing required parameters or invalid SSH key format.
* 401:
* description: Authentication required.
* 500:
* description: SSH connection failed.
*/
app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
const {
sessionId,
hostId,
ip,
port,
username,
password,
sshKey,
keyPassword,
authType,
credentialId,
jumpHosts,
useSocks5,
socks5Host,
socks5Port,
socks5Username,
socks5Password,
socks5ProxyChain,
} = req.body;
const userId = (req as AuthenticatedRequest).userId;
const connectionLogs: Array<Omit<LogEntry, "id" | "timestamp">> = [];
connectionLogs.push(
createConnectionLog(
"info",
"sftp_connecting",
`Initiating SFTP connection to ${username}@${ip}:${port}`,
),
);
if (!userId) {
fileLogger.error("SSH connection rejected: no authenticated user", {
operation: "file_connect_auth",
sessionId,
});
connectionLogs.push(
createConnectionLog(
"error",
"sftp_auth",
"Authentication required - no user session",
),
);
return res
.status(401)
.json({ error: "Authentication required", connectionLogs });
}
if (!sessionId || !ip || !username || !port) {
fileLogger.warn("Missing SSH connection parameters for file manager", {
operation: "file_connect",
sessionId,
hasIp: !!ip,
hasUsername: !!username,
hasPort: !!port,
});
connectionLogs.push(
createConnectionLog(
"error",
"sftp_connecting",
"Missing required connection parameters",
),
);
return res
.status(400)
.json({ error: "Missing SSH connection parameters", connectionLogs });
}
if (sshSessions[sessionId]?.isConnected) {
cleanupSession(sessionId);
}
if (pendingTOTPSessions[sessionId]) {
try {
pendingTOTPSessions[sessionId].client.end();
} catch {
// expected
}
delete pendingTOTPSessions[sessionId];
}
const client = new SSHClient();
connectionLogs.push(
createConnectionLog(
"info",
"sftp_auth",
"Resolving authentication credentials",
),
);
// Resolve credentials server-side when frontend doesn't provide them
let resolvedCredentials = {
password,
sshKey,
keyPassword,
authType,
sudoPassword: undefined as string | undefined,
};
let hostKeepaliveInterval: number | undefined;
let hostKeepaliveCountMax: number | undefined;
let resolvedIp = ip;
let resolvedPort = port;
let resolvedUsername = username;
let resolvedTerminalConfig: Record<string, unknown> | undefined;
let resolvedJumpHosts = jumpHosts;
let resolvedScpLegacy = false;
let resolvedUseSocks5 = useSocks5;
let resolvedSocks5Host = socks5Host;
let resolvedSocks5Port = socks5Port;
let resolvedSocks5Username = socks5Username;
let resolvedSocks5Password = socks5Password;
let resolvedSocks5ProxyChain = socks5ProxyChain;
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,
keyPassword: resolvedHost.keyPassword,
authType: resolvedHost.authType,
sudoPassword: resolvedHost.sudoPassword as string | undefined,
};
resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as
| Record<string, unknown>
| undefined;
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax;
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
if (resolvedHost.useSocks5) {
resolvedUseSocks5 = resolvedHost.useSocks5;
resolvedSocks5Host = resolvedHost.socks5Host;
resolvedSocks5Port = resolvedHost.socks5Port;
resolvedSocks5Username = resolvedHost.socks5Username;
resolvedSocks5Password = resolvedHost.socks5Password;
resolvedSocks5ProxyChain = resolvedHost.socks5ProxyChain;
}
if (
(!resolvedJumpHosts || resolvedJumpHosts.length === 0) &&
resolvedHost.jumpHosts &&
resolvedHost.jumpHosts.length > 0
) {
resolvedJumpHosts = resolvedHost.jumpHosts;
connectionLogs.push(
createConnectionLog(
"info",
"jump",
`Loaded ${resolvedHost.jumpHosts.length} jump host(s) from server-side host data`,
),
);
}
connectionLogs.push(
createConnectionLog(
"info",
"sftp_auth",
"Credentials resolved from server-side host data",
),
);
}
} catch (error) {
fileLogger.warn(`Failed to resolve host credentials for ${hostId}`, {
operation: "ssh_credentials",
hostId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
} else if (credentialId && hostId && userId) {
// Legacy: credential resolution from credentialId
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,
keyPassword: resolvedHost.keyPassword,
authType: resolvedHost.authType,
sudoPassword: resolvedHost.sudoPassword as string | undefined,
};
resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as
| Record<string, unknown>
| undefined;
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax;
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
if (resolvedHost.useSocks5) {
resolvedUseSocks5 = resolvedHost.useSocks5;
resolvedSocks5Host = resolvedHost.socks5Host;
resolvedSocks5Port = resolvedHost.socks5Port;
resolvedSocks5Username = resolvedHost.socks5Username;
resolvedSocks5Password = resolvedHost.socks5Password;
resolvedSocks5ProxyChain = resolvedHost.socks5ProxyChain;
}
if (
(!resolvedJumpHosts || resolvedJumpHosts.length === 0) &&
resolvedHost.jumpHosts &&
resolvedHost.jumpHosts.length > 0
) {
resolvedJumpHosts = resolvedHost.jumpHosts;
connectionLogs.push(
createConnectionLog(
"info",
"jump",
`Loaded ${resolvedHost.jumpHosts.length} jump host(s) from server-side host data`,
),
);
}
connectionLogs.push(
createConnectionLog(
"info",
"sftp_auth",
"Credentials resolved from credential store",
),
);
}
} catch (error) {
fileLogger.warn(`Failed to resolve credentials for host ${hostId}`, {
operation: "ssh_credentials",
hostId,
credentialId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(hostId);
const config: Record<string, unknown> = {
host: resolvedIp?.replace(/^\[|\]$/g, "") || resolvedIp,
port: resolvedPort,
username: resolvedUsername,
tryKeyboard: true,
keepaliveInterval:
typeof hostKeepaliveInterval === "number"
? Math.max(5000, hostKeepaliveInterval * 1000)
: 60000,
keepaliveCountMax:
typeof hostKeepaliveCountMax === "number"
? Math.max(1, hostKeepaliveCountMax)
: 5,
readyTimeout: 60000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
hostVerifier: await SSHHostKeyVerifier.createHostVerifier(
hostId,
resolvedIp,
resolvedPort,
null,
userId,
false,
preloadedHostData,
),
env: {
TERM: "xterm-256color",
LANG: "en_US.UTF-8",
LC_ALL: "en_US.UTF-8",
LC_CTYPE: "en_US.UTF-8",
LC_MESSAGES: "en_US.UTF-8",
LC_MONETARY: "en_US.UTF-8",
LC_NUMERIC: "en_US.UTF-8",
LC_TIME: "en_US.UTF-8",
LC_COLLATE: "en_US.UTF-8",
COLORTERM: "truecolor",
},
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"],
},
};
if (
resolvedCredentials.authType === "key" &&
resolvedCredentials.sshKey &&
resolvedCredentials.sshKey.trim()
) {
try {
config.privateKey = preparePrivateKeyForSSH2(
resolvedCredentials.sshKey,
resolvedCredentials.keyPassword,
);
if (resolvedCredentials.keyPassword)
config.passphrase = resolvedCredentials.keyPassword;
connectionLogs.push(
createConnectionLog(
"info",
"sftp_auth",
"Using SSH key authentication",
),
);
} catch (keyError) {
fileLogger.error("SSH key format error for file manager", {
operation: "file_connect",
sessionId,
hostId,
error: keyError.message,
});
connectionLogs.push(
createConnectionLog(
"error",
"sftp_auth",
`Invalid SSH key format: ${keyError.message}`,
),
);
return res
.status(400)
.json({ error: "Invalid SSH key format", connectionLogs });
}
} else if (resolvedCredentials.authType === "password") {
if (!resolvedCredentials.password) {
connectionLogs.push(
createConnectionLog(
"error",
"sftp_auth",
"Password required for password authentication",
),
);
return res.status(400).json({
error: "Password required for password authentication",
connectionLogs,
});
}
config.password = resolvedCredentials.password;
connectionLogs.push(
createConnectionLog("info", "sftp_auth", "Using password authentication"),
);
} else if (resolvedCredentials.authType === "opkssh") {
try {
const { getOPKSSHToken } = await import("./opkssh-auth.js");
const token = await getOPKSSHToken(userId, hostId);
if (!token) {
connectionLogs.push(
createConnectionLog(
"error",
"sftp_auth",
"OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.",
),
);
return res.status(401).json({
error:
"OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.",
requiresOPKSSHAuth: true,
connectionLogs,
});
}
const { setupOPKSSHCertAuth } = await import("./opkssh-cert-auth.js");
await setupOPKSSHCertAuth(
config as import("ssh2").ConnectConfig,
client,
token,
username,
);
connectionLogs.push(
createConnectionLog(
"info",
"sftp_auth",
"Using OPKSSH certificate authentication",
),
);
} catch (opksshError) {
fileLogger.error("OPKSSH authentication error for file manager", {
operation: "file_connect",
sessionId,
hostId,
error:
opksshError instanceof Error ? opksshError.message : "Unknown error",
});
connectionLogs.push(
createConnectionLog(
"error",
"sftp_auth",
`OPKSSH authentication failed: ${opksshError instanceof Error ? opksshError.message : "Unknown error"}`,
),
);
return res.status(500).json({
error: "OPKSSH authentication failed",
connectionLogs,
});
}
} else if (resolvedCredentials.authType === "agent") {
const result = await applyAgentAuth(config, resolvedTerminalConfig);
if ("error" in result) {
connectionLogs.push(
createConnectionLog("error", "sftp_auth", result.error),
);
return res.status(400).json({ error: result.error, connectionLogs });
}
connectionLogs.push(
createConnectionLog(
"info",
"sftp_auth",
"Using SSH agent authentication",
),
);
} else if (
resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale" ||
resolvedCredentials.authType === "warpgate"
) {
connectionLogs.push(
createConnectionLog(
"info",
"sftp_auth",
"Using keyboard-interactive authentication",
),
);
} else {
fileLogger.warn(
"No valid authentication method provided for file manager",
{
operation: "file_connect",
sessionId,
hostId,
authType: resolvedCredentials.authType,
hasPassword: !!resolvedCredentials.password,
hasKey: !!resolvedCredentials.sshKey,
},
);
connectionLogs.push(
createConnectionLog(
"error",
"sftp_auth",
"No valid authentication method provided",
),
);
return res.status(400).json({
error: "Either password or SSH key must be provided",
connectionLogs,
});
}
let responseSent = false;
connectionLogs.push(
createConnectionLog("info", "dns", `Resolving DNS for ${ip}`),
);
connectionLogs.push(
createConnectionLog("info", "tcp", `Connecting to ${ip}:${port}`),
);
connectionLogs.push(
createConnectionLog("info", "handshake", "Initiating SSH handshake"),
);
connectionLogs.push(
createConnectionLog(
"info",
"sftp_connecting",
"Establishing SSH connection...",
),
);
client.on("ready", () => {
if (responseSent) return;
responseSent = true;
fileLogger.info("File manager SSH connection established", {
operation: "file_ssh_connected",
sessionId,
userId,
hostId,
ip,
port,
username,
});
connectionLogs.push(
createConnectionLog(
"success",
"connected",
"SSH connection established successfully",
),
);
connectionLogs.push(
createConnectionLog(
"success",
"sftp_connected",
"SFTP session established successfully",
),
);
sshSessions[sessionId] = {
client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
channelOpener: new ChannelOpenSerializer(),
userId,
ip,
port,
hostId,
username,
sudoPassword: resolvedCredentials.sudoPassword,
scpLegacy: resolvedScpLegacy,
};
scheduleSessionCleanup(sessionId);
res.json({
status: "success",
message: "SSH connection established",
connectionLogs,
});
if (hostId && userId) {
(async () => {
try {
const hostResults = await SimpleDBOps.select(
getDb()
.select()
.from(hosts)
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
const hostName =
hostResults.length > 0 && hostResults[0].name
? hostResults[0].name
: `${username}@${ip}:${port}`;
const authManager = AuthManager.getInstance();
await axios.post(
"http://localhost:30006/activity/log",
{
type: "file_manager",
hostId,
hostName,
},
{
headers: {
Authorization: `Bearer ${await authManager.generateJWTToken(userId)}`,
},
},
);
} catch (error) {
fileLogger.warn("Failed to log file manager activity", {
operation: "activity_log_error",
userId,
hostId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
})();
}
});
client.on("error", (err) => {
if (responseSent) return;
responseSent = true;
fileLogger.error("SSH connection failed for file manager", {
operation: "file_connect",
sessionId,
hostId,
ip,
port,
username,
error: err.message,
});
let errorStage: ConnectionStage;
if (
err.message.includes("ENOTFOUND") ||
err.message.includes("getaddrinfo")
) {
errorStage = "dns";
connectionLogs.push(
createConnectionLog(
"error",
errorStage,
`DNS resolution failed: ${err.message}`,
{
errorCode: (err as unknown as Record<string, unknown>).code,
errorLevel: (err as unknown as Record<string, unknown>).level,
},
),
);
} else if (
err.message.includes("ECONNREFUSED") ||
err.message.includes("ETIMEDOUT")
) {
errorStage = "tcp";
connectionLogs.push(
createConnectionLog(
"error",
errorStage,
`TCP connection failed: ${err.message}`,
{
errorCode: (err as unknown as Record<string, unknown>).code,
errorLevel: (err as unknown as Record<string, unknown>).level,
},
),
);
} else if (
err.message.includes("handshake") ||
err.message.includes("key exchange")
) {
errorStage = "handshake";
connectionLogs.push(
createConnectionLog(
"error",
errorStage,
`SSH handshake failed: ${err.message}`,
{
errorCode: (err as unknown as Record<string, unknown>).code,
errorLevel: (err as unknown as Record<string, unknown>).level,
},
),
);
} else if (
err.message.includes("authentication") ||
err.message.includes("Authentication")
) {
errorStage = "auth";
connectionLogs.push(
createConnectionLog(
"error",
errorStage,
`Authentication failed: ${err.message}`,
{
errorCode: (err as unknown as Record<string, unknown>).code,
errorLevel: (err as unknown as Record<string, unknown>).level,
},
),
);
} else if (err.message.includes("verification failed")) {
errorStage = "handshake";
connectionLogs.push(
createConnectionLog(
"error",
errorStage,
`SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.`,
{
errorCode: (err as unknown as Record<string, unknown>).code,
errorLevel: (err as unknown as Record<string, unknown>).level,
},
),
);
} else {
connectionLogs.push(
createConnectionLog(
"error",
"error",
`SSH connection failed: ${err.message}`,
{
errorCode: (err as unknown as Record<string, unknown>).code,
errorLevel: (err as unknown as Record<string, unknown>).level,
},
),
);
}
if (
err.message.includes("Cannot parse privateKey") &&
err.message.includes("no passphrase")
) {
res.json({
status: "passphrase_required",
connectionLogs,
});
} else if (
(resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale") &&
(err.message.includes("authentication") ||
err.message.includes("All configured authentication methods failed"))
) {
res.json({
status: "auth_required",
reason: "no_keyboard",
connectionLogs,
});
} else {
res
.status(500)
.json({ status: "error", message: err.message, connectionLogs });
}
});
client.on("close", () => {
fileLogger.info("File manager SSH connection closed", {
operation: "file_ssh_disconnected",
sessionId,
userId,
hostId,
});
if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false;
cleanupSession(sessionId);
});
client.on(
"keyboard-interactive",
(
name: string,
instructions: string,
instructionsLang: string,
prompts: Array<{ prompt: string; echo: boolean }>,
finish: (responses: string[]) => void,
) => {
const promptTexts = prompts.map((p) => p.prompt);
const warpgatePattern = /warpgate\s+authentication/i;
const isWarpgate =
warpgatePattern.test(name) ||
warpgatePattern.test(instructions) ||
promptTexts.some((p) => warpgatePattern.test(p));
if (isWarpgate) {
const fullText = `${name}\n${instructions}\n${promptTexts.join("\n")}`;
const urlMatch = fullText.match(/https?:\/\/[^\s\n]+/i);
const keyMatch = fullText.match(
/security key[:\s]+([a-z0-9](?:\s+[a-z0-9]){3}|[a-z0-9]{4})/i,
);
if (urlMatch) {
if (responseSent) return;
responseSent = true;
connectionLogs.push(
createConnectionLog(
"info",
"sftp_auth",
"Warpgate authentication required",
{ url: urlMatch[0] },
),
);
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId,
ip,
port,
username,
userId,
prompts,
totpPromptIndex: -1,
resolvedPassword: resolvedCredentials.password,
totpAttempts: 0,
isWarpgate: true,
};
res.json({
requires_warpgate: true,
sessionId,
url: urlMatch[0],
securityKey: keyMatch ? keyMatch[1] : "N/A",
connectionLogs,
});
return;
}
}
const totpPromptIndex = prompts.findIndex((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt,
),
);
if (totpPromptIndex !== -1) {
if (responseSent) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
responseSent = true;
if (pendingTOTPSessions[sessionId]) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
connectionLogs.push(
createConnectionLog(
"info",
"sftp_auth",
"TOTP verification required",
{ prompt: prompts[totpPromptIndex].prompt },
),
);
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId,
ip,
port,
username,
userId,
prompts,
totpPromptIndex,
resolvedPassword: resolvedCredentials.password,
totpAttempts: 0,
};
res.json({
requires_totp: true,
sessionId,
prompt: prompts[totpPromptIndex].prompt,
connectionLogs,
});
} else {
const hasStoredPassword =
resolvedCredentials.password &&
resolvedCredentials.authType !== "none" &&
resolvedCredentials.authType !== "tailscale" &&
resolvedCredentials.authType !== "warpgate";
const passwordPromptIndex = prompts.findIndex((p) =>
/password/i.test(p.prompt),
);
if (resolvedCredentials.authType === "warpgate") {
finish(prompts.map(() => ""));
return;
}
if (
(resolvedCredentials.authType === "none" ||
resolvedCredentials.authType === "tailscale") &&
passwordPromptIndex !== -1
) {
if (responseSent) return;
responseSent = true;
client.end();
res.json({
status: "auth_required",
reason: "no_keyboard",
});
return;
}
if (!hasStoredPassword && passwordPromptIndex !== -1) {
if (responseSent) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
responseSent = true;
if (pendingTOTPSessions[sessionId]) {
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
return;
}
pendingTOTPSessions[sessionId] = {
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId,
ip,
port,
username,
userId,
prompts,
totpPromptIndex: passwordPromptIndex,
resolvedPassword: resolvedCredentials.password,
totpAttempts: 0,
};
res.json({
requires_totp: true,
sessionId,
prompt: prompts[passwordPromptIndex].prompt,
isPassword: true,
});
return;
}
const responses = prompts.map((p) => {
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
return resolvedCredentials.password;
}
return "";
});
finish(responses);
}
},
);
const proxyConfig: SOCKS5Config | null =
resolvedUseSocks5 &&
(resolvedSocks5Host ||
(resolvedSocks5ProxyChain &&
(resolvedSocks5ProxyChain as ProxyNode[]).length > 0))
? {
useSocks5: resolvedUseSocks5,
socks5Host: resolvedSocks5Host,
socks5Port: resolvedSocks5Port,
socks5Username: resolvedSocks5Username,
socks5Password: resolvedSocks5Password,
socks5ProxyChain: resolvedSocks5ProxyChain as ProxyNode[],
}
: null;
const hasJumpHosts =
resolvedJumpHosts && resolvedJumpHosts.length > 0 && userId;
if (hasJumpHosts) {
try {
if (proxyConfig) {
connectionLogs.push(
createConnectionLog(
"info",
"proxy",
"Connecting via proxy + jump hosts",
),
);
}
connectionLogs.push(
createConnectionLog(
"info",
"jump",
`Connecting via ${resolvedJumpHosts.length} jump host(s)`,
),
);
const jumpClient = await createJumpHostChain(
resolvedJumpHosts,
userId,
proxyConfig,
);
if (!jumpClient) {
fileLogger.error("Failed to establish jump host chain", {
operation: "file_jump_chain",
sessionId,
hostId,
});
connectionLogs.push(
createConnectionLog(
"error",
"jump",
"Failed to establish jump host chain",
),
);
return res.status(500).json({
error: "Failed to connect through jump hosts",
connectionLogs,
});
}
let forwardOutDone = false;
const forwardOutTimeout = setTimeout(() => {
if (!forwardOutDone) {
forwardOutDone = true;
fileLogger.error("Timeout waiting for jump host forwardOut", {
operation: "file_jump_forward_timeout",
sessionId,
hostId,
ip,
port,
});
connectionLogs.push(
createConnectionLog(
"error",
"jump",
"Timed out waiting for jump host tunnel to target host",
),
);
jumpClient.end();
res.status(500).json({
error: "Jump host tunnel timed out",
connectionLogs,
});
}
}, 30000);
jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => {
if (forwardOutDone) return;
forwardOutDone = true;
clearTimeout(forwardOutTimeout);
if (err) {
fileLogger.error("Failed to forward through jump host", err, {
operation: "file_jump_forward",
sessionId,
hostId,
ip,
port,
});
connectionLogs.push(
createConnectionLog(
"error",
"jump",
`Failed to forward through jump host: ${err.message}`,
),
);
jumpClient.end();
return res.status(500).json({
error: "Failed to forward through jump host: " + err.message,
connectionLogs,
});
}
config.sock = stream;
client.connect(config);
});
} catch (error) {
fileLogger.error("Jump host error", error, {
operation: "file_jump_host",
sessionId,
hostId,
});
connectionLogs.push(
createConnectionLog(
"error",
"jump",
`Jump host error: ${error instanceof Error ? error.message : "Unknown error"}`,
),
);
return res.status(500).json({
error: "Failed to connect through jump hosts",
connectionLogs,
});
}
} else if (proxyConfig) {
connectionLogs.push(
createConnectionLog("info", "proxy", "Connecting via proxy", {
proxyHost: socks5Host,
proxyPort: socks5Port || 1080,
}),
);
try {
const proxySocket = await createSocks5Connection(ip, port, proxyConfig);
if (proxySocket) {
connectionLogs.push(
createConnectionLog(
"success",
"proxy",
"Proxy connected successfully",
),
);
config.sock = proxySocket;
}
client.connect(config);
} catch (proxyError) {
fileLogger.error("Proxy connection failed", proxyError, {
operation: "proxy_connect",
sessionId,
hostId,
proxyHost: socks5Host,
proxyPort: socks5Port || 1080,
});
connectionLogs.push(
createConnectionLog(
"error",
"proxy",
`Proxy connection failed: ${proxyError instanceof Error ? proxyError.message : "Unknown error"}`,
),
);
return res.status(500).json({
error:
"Proxy connection failed: " +
(proxyError instanceof Error ? proxyError.message : "Unknown error"),
connectionLogs,
});
}
} else {
client.connect(config);
}
});
/**
* @openapi
* /ssh/file_manager/ssh/connect-totp:
* post:
* summary: Verify TOTP and complete connection
* description: Verifies the TOTP code and completes the SSH connection for file manager.
* tags:
* - File Manager
* responses:
* 200:
* description: TOTP verified, SSH connection established.
* 400:
* description: Session ID and TOTP code required.
* 401:
* description: Invalid TOTP code or authentication required.
* 404:
* description: TOTP session expired.
* 408:
* description: TOTP session timeout.
*/
app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
const { sessionId, totpCode } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
fileLogger.error("TOTP verification rejected: no authenticated user", {
operation: "file_totp_auth",
sessionId,
});
return res.status(401).json({ error: "Authentication required" });
}
if (!sessionId || !totpCode) {
return res.status(400).json({ error: "Session ID and TOTP code required" });
}
const session = pendingTOTPSessions[sessionId];
if (!session) {
fileLogger.warn("TOTP session not found or expired", {
operation: "file_totp_verify",
sessionId,
userId,
availableSessions: Object.keys(pendingTOTPSessions),
});
return res
.status(404)
.json({ error: "TOTP session expired. Please reconnect." });
}
if (Date.now() - session.createdAt > 180000) {
delete pendingTOTPSessions[sessionId];
try {
session.client.end();
} catch {
// expected
}
fileLogger.warn("TOTP session timeout before code submission", {
operation: "file_totp_verify",
sessionId,
userId,
age: Date.now() - session.createdAt,
});
return res
.status(408)
.json({ error: "TOTP session timeout. Please reconnect." });
}
const responses = (session.prompts || []).map((p, index) => {
if (index === session.totpPromptIndex) {
return totpCode;
}
if (/password/i.test(p.prompt) && session.resolvedPassword) {
return session.resolvedPassword;
}
return "";
});
let responseSent = false;
session.client.once("ready", () => {
if (responseSent) return;
responseSent = true;
clearTimeout(responseTimeout);
delete pendingTOTPSessions[sessionId];
setTimeout(() => {
sshSessions[sessionId] = {
client: session.client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
channelOpener: new ChannelOpenSerializer(),
userId,
ip: session.ip,
port: session.port,
hostId: session.hostId,
username: session.username,
};
scheduleSessionCleanup(sessionId);
res.json({
status: "success",
message: "TOTP verified, SSH connection established",
});
if (session.hostId && session.userId) {
(async () => {
try {
const hostResults = await SimpleDBOps.select(
getDb()
.select()
.from(hosts)
.where(
and(
eq(hosts.id, session.hostId!),
eq(hosts.userId, session.userId!),
),
),
"ssh_data",
session.userId!,
);
const hostName =
hostResults.length > 0 && hostResults[0].name
? hostResults[0].name
: `${session.username}@${session.ip}:${session.port}`;
const authManager = AuthManager.getInstance();
await axios.post(
"http://localhost:30006/activity/log",
{
type: "file_manager",
hostId: session.hostId,
hostName,
},
{
headers: {
Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`,
},
},
);
} catch (error) {
fileLogger.warn("Failed to log file manager activity (TOTP)", {
operation: "activity_log_error",
userId: session.userId,
hostId: session.hostId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
})();
}
}, 200);
});
session.client.once("error", (err) => {
if (responseSent) return;
responseSent = true;
clearTimeout(responseTimeout);
delete pendingTOTPSessions[sessionId];
fileLogger.error("TOTP verification failed", {
operation: "file_totp_verify",
sessionId,
userId,
error: err.message,
});
res.status(401).json({ status: "error", message: "Invalid TOTP code" });
});
const responseTimeout = setTimeout(() => {
if (!responseSent) {
responseSent = true;
delete pendingTOTPSessions[sessionId];
fileLogger.warn("TOTP verification timeout", {
operation: "file_totp_verify",
sessionId,
userId,
});
res.status(408).json({ error: "TOTP verification timeout" });
}
}, 60000);
session.finish(responses);
});
/**
* @openapi
* /ssh/file_manager/ssh/connect-warpgate:
* post:
* summary: Complete Warpgate authentication
* description: Submits empty response to complete Warpgate authentication after user completes browser auth.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - sessionId
* properties:
* sessionId:
* type: string
* description: Session ID from initial connection attempt
* responses:
* 200:
* description: Warpgate authentication completed successfully.
* 401:
* description: Authentication failed or unauthorized.
* 404:
* description: Warpgate session expired.
* 408:
* description: Warpgate session timeout.
*/
app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => {
const { sessionId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!userId) {
fileLogger.error("Warpgate verification rejected: no authenticated user", {
operation: "file_warpgate_auth",
sessionId,
});
return res.status(401).json({ error: "Authentication required" });
}
if (!sessionId) {
return res.status(400).json({ error: "Session ID required" });
}
const session = pendingTOTPSessions[sessionId];
if (!session) {
fileLogger.warn("Warpgate session not found or expired", {
operation: "file_warpgate_verify",
sessionId,
userId,
availableSessions: Object.keys(pendingTOTPSessions),
});
return res
.status(404)
.json({ error: "Warpgate session expired. Please reconnect." });
}
if (!session.isWarpgate) {
return res.status(400).json({ error: "Session is not a Warpgate session" });
}
if (Date.now() - session.createdAt > 300000) {
delete pendingTOTPSessions[sessionId];
try {
session.client.end();
} catch {
// expected
}
fileLogger.warn("Warpgate session timeout before completion", {
operation: "file_warpgate_verify",
sessionId,
userId,
age: Date.now() - session.createdAt,
});
return res
.status(408)
.json({ error: "Warpgate session timeout. Please reconnect." });
}
let responseSent = false;
const responseTimeout = setTimeout(() => {
if (!responseSent) {
responseSent = true;
delete pendingTOTPSessions[sessionId];
fileLogger.warn("Warpgate verification timeout", {
operation: "file_warpgate_verify",
sessionId,
userId,
});
res.status(408).json({ error: "Warpgate verification timeout" });
}
}, 60000);
session.client.once("ready", () => {
if (responseSent) return;
responseSent = true;
clearTimeout(responseTimeout);
delete pendingTOTPSessions[sessionId];
setTimeout(() => {
sshSessions[sessionId] = {
client: session.client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
channelOpener: new ChannelOpenSerializer(),
userId,
ip: session.ip,
port: session.port,
hostId: session.hostId,
username: session.username,
};
scheduleSessionCleanup(sessionId);
res.json({
status: "success",
message: "Warpgate verified, SSH connection established",
});
if (session.hostId && session.userId) {
(async () => {
try {
const hostResults = await SimpleDBOps.select(
getDb()
.select()
.from(hosts)
.where(
and(
eq(hosts.id, session.hostId!),
eq(hosts.userId, session.userId!),
),
),
"ssh_data",
session.userId!,
);
const hostName =
hostResults.length > 0 && hostResults[0].name
? hostResults[0].name
: `${session.username}@${session.ip}:${session.port}`;
await axios.post(
"http://localhost:30006/activity/log",
{
type: "file_manager",
hostId: session.hostId,
hostName,
},
{
headers: {
Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`,
},
},
);
} catch (error) {
fileLogger.warn("Failed to log file manager activity", {
operation: "activity_log_error",
userId: session.userId,
hostId: session.hostId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
})();
}
}, 200);
});
session.client.once("error", (err) => {
if (responseSent) return;
responseSent = true;
clearTimeout(responseTimeout);
delete pendingTOTPSessions[sessionId];
fileLogger.error("Warpgate verification failed", {
operation: "file_warpgate_verify",
sessionId,
userId,
error: err.message,
});
res
.status(401)
.json({ status: "error", message: "Warpgate authentication failed" });
});
session.finish([""]);
});
/**
* @openapi
* /ssh/file_manager/ssh/disconnect:
* post:
* summary: Disconnect from SSH
* description: Closes an active SSH connection for file manager.
* tags:
* - File Manager
* responses:
* 200:
* description: SSH connection disconnected.
*/
app.post("/ssh/file_manager/ssh/disconnect", (req, res) => {
const { sessionId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
const session = sshSessions[sessionId];
if (session && !verifySessionOwnership(session, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
fileLogger.info("File manager disconnection requested", {
operation: "file_disconnect_request",
sessionId,
userId,
});
cleanupSession(sessionId);
res.json({ status: "success", message: "SSH connection disconnected" });
});
/**
* @openapi
* /ssh/file_manager/sudo-password:
* post:
* summary: Set sudo password for session
* description: Stores sudo password temporarily in session for elevated operations.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* password:
* type: string
* responses:
* 200:
* description: Sudo password set successfully.
* 400:
* description: Invalid session.
*/
app.post("/ssh/file_manager/sudo-password", (req, res) => {
const { sessionId, password } = req.body;
const userId = (req as AuthenticatedRequest).userId;
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({ error: "Invalid or disconnected session" });
}
if (!verifySessionOwnership(session, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
session.sudoPassword = password;
session.lastActive = Date.now();
res.json({ status: "success", message: "Sudo password set" });
});
/**
* @openapi
* /ssh/file_manager/ssh/status:
* get:
* summary: Get SSH connection status
* description: Checks the status of an SSH connection for file manager.
* tags:
* - File Manager
* parameters:
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: SSH connection status.
*/
app.get("/ssh/file_manager/ssh/status", (req, res) => {
const sessionId = req.query.sessionId as string;
const userId = (req as AuthenticatedRequest).userId;
const session = sshSessions[sessionId];
if (session && !verifySessionOwnership(session, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
const isConnected = !!session?.isConnected;
res.json({ status: "success", connected: isConnected });
});
/**
* @openapi
* /ssh/file_manager/ssh/keepalive:
* post:
* summary: Keep SSH session alive
* description: Keeps an active SSH session for file manager alive.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* responses:
* 200:
* description: Session keepalive successful.
* 400:
* description: Session ID is required or session not found.
*/
app.post("/ssh/file_manager/ssh/keepalive", async (req, res) => {
const { sessionId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({
error: "SSH session not found or not connected",
connected: false,
});
}
if (!verifySessionOwnership(session, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
// Probe the cached SFTP channel. If stale, clear it so the next operation
// opens a fresh one via the serialized getSessionSftp.
if (session.sftp && !session.sftpPending) {
try {
await new Promise<void>((resolve, reject) => {
session.sftp!.stat("/", (err) => (err ? reject(err) : resolve()));
});
} catch {
session.sftp = undefined;
}
}
res.json({
status: "success",
connected: true,
message: "Session keepalive successful",
lastActive: session.lastActive,
});
});
registerFileListingRoutes(app, {
sshSessions,
activeListRequests,
verifySessionOwnership,
});
registerFileContentRoutes(app, {
sshSessions,
verifySessionOwnership,
});
registerFileOperationRoutes(app, {
sshSessions,
verifySessionOwnership,
});
registerFileDownloadRoutes(app, {
sshSessions,
scheduleSessionCleanup,
verifySessionOwnership,
});
registerFileActionRoutes(app, {
sshSessions,
scheduleSessionCleanup,
verifySessionOwnership,
});
/**
* @openapi
* /ssh/file_manager/ssh/extractArchive:
* post:
* summary: Extract archive file
* description: Extracts an archive file (.tar, .tar.gz, .tgz, .zip, .tar.bz2, .tbz2, .tar.xz, .txz) to a specified or default location on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - sessionId
* - archivePath
* properties:
* sessionId:
* type: string
* description: SSH session ID
* archivePath:
* type: string
* description: Path to the archive file on remote host
* extractPath:
* type: string
* description: Optional custom extraction path (defaults to same directory as archive)
* responses:
* 200:
* description: Archive extracted successfully.
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: success
* message:
* type: string
* extractPath:
* type: string
* 400:
* description: Missing required parameters, SSH connection not established, or unsupported archive format.
* 403:
* description: Permission denied.
* 500:
* description: Failed to extract archive.
*/
app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => {
const { sessionId, archivePath, extractPath } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId || !archivePath) {
return res.status(400).json({ error: "Missing required parameters" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({ error: "SSH session not connected" });
}
if (!verifySessionOwnership(session, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const fileName = archivePath.split("/").pop() || "";
const fileExt = fileName.toLowerCase();
let extractCommand: string;
const targetPath =
extractPath || archivePath.substring(0, archivePath.lastIndexOf("/"));
const escapedArchive = archivePath.replace(/'/g, "'\"'\"'");
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
const escapedDecompressed = archivePath
.replace(/\.gz$/, "")
.replace(/'/g, "'\"'\"'");
if (fileExt.endsWith(".tar.gz") || fileExt.endsWith(".tgz")) {
extractCommand = `tar -xzf '${escapedArchive}' -C '${escapedTarget}'`;
} else if (fileExt.endsWith(".tar.bz2") || fileExt.endsWith(".tbz2")) {
extractCommand = `tar -xjf '${escapedArchive}' -C '${escapedTarget}'`;
} else if (fileExt.endsWith(".tar.xz")) {
extractCommand = `tar -xJf '${escapedArchive}' -C '${escapedTarget}'`;
} else if (fileExt.endsWith(".tar")) {
extractCommand = `tar -xf '${escapedArchive}' -C '${escapedTarget}'`;
} else if (fileExt.endsWith(".zip")) {
extractCommand = `unzip -o '${escapedArchive}' -d '${escapedTarget}'`;
} else if (fileExt.endsWith(".gz") && !fileExt.endsWith(".tar.gz")) {
extractCommand = `gunzip -c '${escapedArchive}' > '${escapedDecompressed}'`;
} else if (fileExt.endsWith(".bz2") && !fileExt.endsWith(".tar.bz2")) {
extractCommand = `bunzip2 -k '${escapedArchive}'`;
} else if (fileExt.endsWith(".xz") && !fileExt.endsWith(".tar.xz")) {
extractCommand = `unxz -k '${escapedArchive}'`;
} else if (fileExt.endsWith(".7z")) {
extractCommand = `7z x '${escapedArchive}' -o'${escapedTarget}'`;
} else if (fileExt.endsWith(".rar")) {
extractCommand = `unrar x '${escapedArchive}' '${escapedTarget}/'`;
} else {
return res.status(400).json({ error: "Unsupported archive format" });
}
fileLogger.info("Extracting archive", {
operation: "extract_archive",
sessionId,
archivePath,
extractPath: targetPath,
command: extractCommand,
});
execChannel(session, extractCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH exec error during extract:", err, {
operation: "extract_archive",
sessionId,
archivePath,
});
return res
.status(500)
.json({ error: "Failed to execute extract command" });
}
let errorOutput = "";
stream.on("data", () => {
/* consume stdout */
});
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
});
stream.on("close", (code: number) => {
if (code !== 0) {
fileLogger.error("Extract command failed", {
operation: "extract_archive",
sessionId,
archivePath,
exitCode: code,
error: errorOutput,
});
let friendlyError = errorOutput || "Failed to extract archive";
if (
errorOutput.includes("command not found") ||
errorOutput.includes("not found")
) {
let missingCmd = "";
let installHint = "";
if (fileExt.endsWith(".zip")) {
missingCmd = "unzip";
installHint =
"apt install unzip / yum install unzip / brew install unzip";
} else if (
fileExt.endsWith(".tar.gz") ||
fileExt.endsWith(".tgz") ||
fileExt.endsWith(".tar.bz2") ||
fileExt.endsWith(".tbz2") ||
fileExt.endsWith(".tar.xz") ||
fileExt.endsWith(".tar")
) {
missingCmd = "tar";
installHint = "Usually pre-installed on Linux/Unix systems";
} else if (fileExt.endsWith(".gz")) {
missingCmd = "gunzip";
installHint =
"apt install gzip / yum install gzip / Usually pre-installed";
} else if (fileExt.endsWith(".bz2")) {
missingCmd = "bunzip2";
installHint =
"apt install bzip2 / yum install bzip2 / brew install bzip2";
} else if (fileExt.endsWith(".xz")) {
missingCmd = "unxz";
installHint =
"apt install xz-utils / yum install xz / brew install xz";
} else if (fileExt.endsWith(".7z")) {
missingCmd = "7z";
installHint =
"apt install p7zip-full / yum install p7zip / brew install p7zip";
} else if (fileExt.endsWith(".rar")) {
missingCmd = "unrar";
installHint =
"apt install unrar / yum install unrar / brew install unrar";
}
if (missingCmd) {
friendlyError = `Command '${missingCmd}' not found on remote server. Please install it first: ${installHint}`;
}
}
return res.status(500).json({ error: friendlyError });
}
fileLogger.success("Archive extracted successfully", {
operation: "extract_archive",
sessionId,
archivePath,
extractPath: targetPath,
});
res.json({
success: true,
message: "Archive extracted successfully",
extractPath: targetPath,
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH extractArchive stream error:", streamErr, {
operation: "extract_archive",
sessionId,
archivePath,
});
if (!res.headersSent) {
res
.status(500)
.json({ error: "Stream error while extracting archive" });
}
});
});
});
/**
* @openapi
* /ssh/file_manager/ssh/compressFiles:
* post:
* summary: Compress files
* description: Compresses files and/or directories on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* paths:
* type: array
* items:
* type: string
* archiveName:
* type: string
* format:
* type: string
* responses:
* 200:
* description: Files compressed successfully.
* 400:
* description: Missing required parameters or unsupported compression format.
* 500:
* description: Failed to compress files.
*/
app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => {
const { sessionId, paths, archiveName, format } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (
!sessionId ||
!paths ||
!Array.isArray(paths) ||
paths.length === 0 ||
!archiveName
) {
return res.status(400).json({ error: "Missing required parameters" });
}
const session = sshSessions[sessionId];
if (!session || !session.isConnected) {
return res.status(400).json({ error: "SSH session not connected" });
}
if (!verifySessionOwnership(session, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
session.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const compressionFormat = format || "zip";
let compressCommand: string;
const firstPath = paths[0];
const workingDir = firstPath.substring(0, firstPath.lastIndexOf("/")) || "/";
const escapeShell = (s: string) => s.replace(/'/g, "'\"'\"'");
const fileNames = paths
.map((p) => {
const name = p.split("/").pop();
return `'./${escapeShell(name || "")}'`;
})
.join(" ");
let archivePath = "";
if (archiveName.includes("/")) {
archivePath = archiveName;
} else {
archivePath = workingDir.endsWith("/")
? `${workingDir}${archiveName}`
: `${workingDir}/${archiveName}`;
}
const escapedDir = escapeShell(workingDir);
const escapedArchive = escapeShell(archivePath);
if (compressionFormat === "zip") {
compressCommand = `cd '${escapedDir}' && zip -r '${escapedArchive}' -- ${fileNames}`;
} else if (compressionFormat === "tar.gz" || compressionFormat === "tgz") {
compressCommand = `cd '${escapedDir}' && tar -czf '${escapedArchive}' -- ${fileNames}`;
} else if (compressionFormat === "tar.bz2" || compressionFormat === "tbz2") {
compressCommand = `cd '${escapedDir}' && tar -cjf '${escapedArchive}' -- ${fileNames}`;
} else if (compressionFormat === "tar.xz") {
compressCommand = `cd '${escapedDir}' && tar -cJf '${escapedArchive}' -- ${fileNames}`;
} else if (compressionFormat === "tar") {
compressCommand = `cd '${escapedDir}' && tar -cf '${escapedArchive}' -- ${fileNames}`;
} else if (compressionFormat === "7z") {
compressCommand = `cd '${escapedDir}' && 7z a '${escapedArchive}' -- ${fileNames}`;
} else {
return res.status(400).json({ error: "Unsupported compression format" });
}
fileLogger.info("Compressing files", {
operation: "compress_files",
sessionId,
paths,
archivePath,
format: compressionFormat,
command: compressCommand,
});
execChannel(session, compressCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH exec error during compress:", err, {
operation: "compress_files",
sessionId,
paths,
});
return res
.status(500)
.json({ error: "Failed to execute compress command" });
}
let errorOutput = "";
stream.on("data", () => {
/* consume stdout */
});
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
});
stream.on("close", (code: number) => {
if (code !== 0) {
fileLogger.error("Compress command failed", {
operation: "compress_files",
sessionId,
paths,
archivePath,
exitCode: code,
error: errorOutput,
});
let friendlyError = errorOutput || "Failed to compress files";
if (
errorOutput.includes("command not found") ||
errorOutput.includes("not found")
) {
const commandMap: Record<string, { cmd: string; install: string }> = {
zip: {
cmd: "zip",
install: "apt install zip / yum install zip / brew install zip",
},
"tar.gz": {
cmd: "tar",
install: "Usually pre-installed on Linux/Unix systems",
},
"tar.bz2": {
cmd: "tar",
install: "Usually pre-installed on Linux/Unix systems",
},
"tar.xz": {
cmd: "tar",
install: "Usually pre-installed on Linux/Unix systems",
},
tar: {
cmd: "tar",
install: "Usually pre-installed on Linux/Unix systems",
},
"7z": {
cmd: "7z",
install:
"apt install p7zip-full / yum install p7zip / brew install p7zip",
},
};
const info = commandMap[compressionFormat];
if (info) {
friendlyError = `Command '${info.cmd}' not found on remote server. Please install it first: ${info.install}`;
}
}
return res.status(500).json({ error: friendlyError });
}
fileLogger.success("Files compressed successfully", {
operation: "compress_files",
sessionId,
paths,
archivePath,
format: compressionFormat,
});
res.json({
success: true,
message: "Files compressed successfully",
archivePath: archivePath,
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH compressFiles stream error:", streamErr, {
operation: "compress_files",
sessionId,
paths,
});
if (!res.headersSent) {
res.status(500).json({ error: "Stream error while compressing files" });
}
});
});
});
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);
});
process.on("SIGTERM", () => {
Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0);
});
const PORT = 30004;
try {
const server = app.listen(PORT, async () => {
try {
await authManager.initialize();
} catch (err) {
fileLogger.error("Failed to initialize AuthManager", err, {
operation: "auth_init_error",
});
}
});
server.on("error", (err) => {
fileLogger.error("File Manager server error", err, {
operation: "file_manager_server_error",
port: PORT,
});
});
} catch (err) {
fileLogger.error("Failed to start File Manager server", err, {
operation: "file_manager_server_start_failed",
port: PORT,
});
}