mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
9de904dd4c
* 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>
3035 lines
86 KiB
TypeScript
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,
|
|
});
|
|
}
|