fix: finish 2.9.0 plugin moves and get every check green

This commit is contained in:
LukeGus committed 2026-09-24 23:35:18 -05:00
1 parent 8c1e02af89
commit 6489b62ece
335 files changed
+29878 -17749

No files matched your search

@@ -1,6 +1,6 @@
/**
* The SSH auth types core always provides: password, key, credential, agent
* and none. Everything else is a plugin (or, in 2.9.0, a legacy provider).
* and none. Everything else is a plugin, through ctx.auth.
*/
import ssh2 from "ssh2";
+1 -1
View File
@@ -152,7 +152,7 @@ export interface SshAuthField {
export interface SshAuthProvider {
/** Stored in ssh_data.auth_type. */
type: string;
/** "core" for built-ins and the legacy providers A8 keeps in core. */
/** "core" for the built-ins, otherwise the registering plugin's id. */
pluginId: string;
labelKey: string;
descriptionKey?: string;
@@ -1,541 +0,0 @@
import type { Express } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { fileLogger } from "../../utils/logger.js";
import { execChannel, type SSHSession } from "./session.js";
type FileActionRoutesDeps = {
sshSessions: Record<string, SSHSession>;
scheduleSessionCleanup: (sessionId: string) => void;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
export function registerFileActionRoutes(
app: Express,
{
sshSessions,
scheduleSessionCleanup,
verifySessionOwnership,
}: FileActionRoutesDeps,
): void {
/**
* @openapi
* /ssh/file_manager/ssh/copyItem:
* post:
* summary: Copy a file or directory
* description: Copies a file or directory on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* sourcePath:
* type: string
* targetDir:
* type: string
* hostId:
* type: integer
* userId:
* type: string
* responses:
* 200:
* description: Item copied successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 500:
* description: Failed to copy item.
*/
app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
const { sessionId, sourcePath, targetDir, hostId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId || !sourcePath || !targetDir) {
return res.status(400).json({ error: "Missing required parameters" });
}
const sshConn = sshSessions[sessionId];
if (!sshConn || !sshConn.isConnected) {
return res
.status(400)
.json({ error: "SSH session not found or not connected" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const sourceName = sourcePath.split("/").pop() || "copied_item";
const timestamp = Date.now().toString().slice(-8);
const uniqueName = `${sourceName}_copy_${timestamp}`;
const targetPath = `${targetDir}/${uniqueName}`;
const escapedSource = sourcePath.replace(/'/g, "'\"'\"'");
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
const copyCommand = `cp '${escapedSource}' '${escapedTarget}' && echo "COPY_SUCCESS"`;
const commandTimeout = setTimeout(() => {
fileLogger.error("Copy command timed out after 60 seconds", {
sourcePath,
targetPath,
command: copyCommand,
});
if (!res.headersSent) {
res.status(500).json({
error: "Copy operation timed out",
toast: {
type: "error",
message:
"Copy operation timed out. SSH connection may be unstable.",
},
});
}
}, 60000);
execChannel(sshConn, copyCommand, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
fileLogger.error("SSH copyItem error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let errorData = "";
let stdoutData = "";
stream.on("data", (data: Buffer) => {
const output = data.toString();
stdoutData += output;
stream.stderr.on("data", (data: Buffer) => {
const output = data.toString();
errorData += output;
});
stream.on("close", (code) => {
clearTimeout(commandTimeout);
if (code !== 0) {
const fullErrorInfo =
errorData || stdoutData || "No error message available";
fileLogger.error(`SSH copyItem command failed with code ${code}`, {
operation: "file_copy_failed",
sessionId,
sourcePath,
targetPath,
command: copyCommand,
exitCode: code,
errorData,
stdoutData,
fullErrorInfo,
});
if (!res.headersSent) {
return res.status(500).json({
error: `Copy failed: ${fullErrorInfo}`,
toast: {
type: "error",
message: `Copy failed: ${fullErrorInfo}`,
},
debug: {
sourcePath,
targetPath,
exitCode: code,
command: copyCommand,
},
});
}
return;
}
const copySuccessful =
stdoutData.includes("COPY_SUCCESS") || code === 0;
if (copySuccessful) {
fileLogger.success("Item copied successfully", {
operation: "file_copy",
sessionId,
sourcePath,
targetPath,
uniqueName,
hostId,
userId,
});
if (!res.headersSent) {
res.json({
message: "Item copied successfully",
sourcePath,
targetPath,
uniqueName,
toast: {
type: "success",
message: `Successfully copied to: ${uniqueName}`,
},
});
}
} else {
fileLogger.warn("Copy completed but without success confirmation", {
operation: "file_copy_uncertain",
sessionId,
sourcePath,
targetPath,
code,
stdoutData: stdoutData.substring(0, 200),
});
if (!res.headersSent) {
res.json({
message: "Copy may have completed",
sourcePath,
targetPath,
uniqueName,
toast: {
type: "warning",
message: `Copy completed but verification uncertain for: ${uniqueName}`,
},
});
}
}
});
stream.on("error", (streamErr) => {
clearTimeout(commandTimeout);
fileLogger.error("SSH copyItem stream error:", streamErr);
if (!res.headersSent) {
res
.status(500)
.json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
});
/**
* @openapi
* /ssh/file_manager/ssh/executeFile:
* post:
* summary: Execute a file
* description: Executes a file on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* filePath:
* type: string
* responses:
* 200:
* description: File execution result.
* 400:
* description: Missing required parameters or SSH connection not available.
* 500:
* description: Failed to execute file.
*/
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
const { sessionId, filePath } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sshConn || !sshConn.isConnected) {
fileLogger.error(
"SSH connection not found or not connected for executeFile",
{
operation: "execute_file",
sessionId,
hasConnection: !!sshConn,
isConnected: sshConn?.isConnected,
},
);
return res.status(400).json({ error: "SSH connection not available" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!filePath) {
return res.status(400).json({ error: "File path is required" });
}
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`;
execChannel(sshConn, checkCommand, (checkErr, checkStream) => {
if (checkErr) {
fileLogger.error("SSH executeFile check error:", checkErr);
return res
.status(500)
.json({ error: "Failed to check file executability" });
}
let checkResult = "";
checkStream.on("data", (data) => {
checkResult += data.toString();
});
checkStream.on("close", () => {
if (!checkResult.includes("EXECUTABLE")) {
return res.status(400).json({ error: "File is not executable" });
}
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
execChannel(sshConn, executeCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH executeFile error:", err);
return res.status(500).json({ error: "Failed to execute file" });
}
let output = "";
let errorOutput = "";
stream.on("data", (data) => {
output += data.toString();
});
stream.stderr.on("data", (data) => {
errorOutput += data.toString();
});
stream.on("close", (code) => {
const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/);
const actualExitCode = exitCodeMatch
? parseInt(exitCodeMatch[1])
: code;
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim();
fileLogger.info("File execution completed", {
operation: "execute_file",
sessionId,
filePath,
exitCode: actualExitCode,
outputLength: cleanOutput.length,
errorLength: errorOutput.length,
});
res.json({
success: true,
exitCode: actualExitCode,
output: cleanOutput,
error: errorOutput,
timestamp: new Date().toISOString(),
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH executeFile stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: "Execution stream error" });
}
});
});
});
});
});
/**
* @openapi
* /ssh/file_manager/ssh/changePermissions:
* post:
* summary: Change file permissions
* description: Changes the permissions of a file on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* permissions:
* type: string
* responses:
* 200:
* description: Permissions changed successfully.
* 400:
* description: Missing required parameters or SSH connection not available.
* 408:
* description: Permission change timed out.
* 500:
* description: Failed to change permissions.
*/
app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
const { sessionId, path, permissions } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sshConn || !sshConn.isConnected) {
fileLogger.error(
"SSH connection not found or not connected for changePermissions",
{
operation: "change_permissions",
sessionId,
hasConnection: !!sshConn,
isConnected: sshConn?.isConnected,
},
);
return res.status(400).json({ error: "SSH connection not available" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!path) {
return res.status(400).json({ error: "File path is required" });
}
if (!permissions || !/^\d{3,4}$/.test(permissions)) {
return res.status(400).json({
error: "Valid permissions required (e.g., 755, 644)",
});
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const octalPerms = permissions.slice(-3);
const escapedPath = path.replace(/'/g, "'\"'\"'");
const command = `chmod ${octalPerms} '${escapedPath}' && echo "SUCCESS"`;
fileLogger.info("Changing file permissions", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
const commandTimeout = setTimeout(() => {
if (!res.headersSent) {
fileLogger.error("changePermissions command timeout", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
res.status(408).json({
error: "Permission change timed out. SSH connection may be unstable.",
});
}
}, 10000);
execChannel(sshConn, command, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
fileLogger.error("SSH changePermissions exec error:", err, {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
if (!res.headersSent) {
return res
.status(500)
.json({ error: "Failed to change permissions" });
}
return;
}
let outputData = "";
let errorOutput = "";
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
});
stream.on("close", (code) => {
clearTimeout(commandTimeout);
if (outputData.includes("SUCCESS")) {
fileLogger.success("File permissions changed successfully", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
if (!res.headersSent) {
res.json({
success: true,
message: "Permissions changed successfully",
});
}
return;
}
if (code !== 0) {
fileLogger.error("chmod command failed", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
exitCode: code,
error: errorOutput,
});
if (!res.headersSent) {
return res.status(500).json({
error: errorOutput || "Failed to change permissions",
});
}
return;
}
fileLogger.success("File permissions changed successfully", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
if (!res.headersSent) {
res.json({
success: true,
message: "Permissions changed successfully",
});
}
});
stream.on("error", (streamErr) => {
clearTimeout(commandTimeout);
fileLogger.error("SSH changePermissions stream error:", streamErr, {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
if (!res.headersSent) {
res
.status(500)
.json({ error: "Stream error while changing permissions" });
}
});
});
});
}
File diff suppressed because it is too large. Load diff
@@ -1,72 +0,0 @@
export interface DirectTransferEndpoint {
host: string;
port: number;
username: string;
}
export const DIRECT_TRANSFER_MIN_IMPROVEMENT = 0.2;
export const DIRECT_TRANSFER_MIN_BYTES = 32 * 1024 * 1024;
export function shouldBenchmarkDirectTransfer(totalBytes: number): boolean {
return totalBytes >= DIRECT_TRANSFER_MIN_BYTES;
}
export function quoteShell(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function formatHost(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
export function buildDirectSshCommand(
endpoint: DirectTransferEndpoint,
): string {
const target = `${endpoint.username}@${formatHost(endpoint.host)}`;
return [
"ssh",
"-o BatchMode=yes",
"-o ConnectTimeout=5",
"-o StrictHostKeyChecking=yes",
`-p ${endpoint.port}`,
quoteShell(target),
].join(" ");
}
export function buildDirectProbeCommand(
endpoint: DirectTransferEndpoint,
): string {
return `${buildDirectSshCommand(endpoint)} ${quoteShell("command -v rsync >/dev/null")}`;
}
export function buildDirectRsyncCommand(
endpoint: DirectTransferEndpoint,
sourcePaths: string[],
destPath: string,
destIsDirectory: boolean,
): string {
const sshTransport = buildDirectSshCommand(endpoint).replace(/ '[^']+'$/, "");
const targetHost = `${endpoint.username}@${formatHost(endpoint.host)}`;
const targetPath = destIsDirectory
? `${destPath.replace(/\/+$/, "") || "/"}/`
: destPath;
const sources = sourcePaths.map(quoteShell).join(" ");
const target = quoteShell(`${targetHost}:${targetPath}`);
return [
"rsync -a --partial --append-verify --protect-args --info=progress2",
`-e ${quoteShell(sshTransport)}`,
"--",
sources,
target,
].join(" ");
}
export function shouldUseDirectTransfer(
directMs: number,
relayMs: number,
minImprovement = DIRECT_TRANSFER_MIN_IMPROVEMENT,
): boolean {
if (directMs <= 0 || relayMs <= 0) return false;
return directMs <= relayMs * (1 - minImprovement);
}
@@ -1,356 +0,0 @@
import type { Express } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { fileLogger } from "../../utils/logger.js";
import { getMimeType } from "./utils.js";
import { execChannel, getSessionSftp, type SSHSession } from "./session.js";
type FileDownloadRoutesDeps = {
sshSessions: Record<string, SSHSession>;
scheduleSessionCleanup: (sessionId: string) => void;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
function escapeSingleQuotes(path: string): string {
return path.replace(/'/g, "'\"'\"'");
}
function downloadViaExec(
session: SSHSession,
filePath: string,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const escaped = escapeSingleQuotes(filePath);
execChannel(session, `cat '${escaped}'`, (err, stream) => {
if (err) return reject(err);
const chunks: Buffer[] = [];
let stderr = "";
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
stream.on("close", (code: number) => {
if (code !== 0) {
return reject(
new Error(stderr.trim() || `cat exited with code ${code}`),
);
}
resolve(Buffer.concat(chunks));
});
stream.on("error", reject);
});
});
}
export function registerFileDownloadRoutes(
app: Express,
{
sshSessions,
scheduleSessionCleanup,
verifySessionOwnership,
}: FileDownloadRoutesDeps,
): void {
/**
* @openapi
* /ssh/file_manager/ssh/downloadFile:
* post:
* summary: Download a file
* description: Downloads a file from the remote host. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* hostId:
* type: integer
* userId:
* type: string
* responses:
* 200:
* description: The file content.
* 400:
* description: Missing required parameters or file too large.
* 500:
* description: Failed to download file.
*/
app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => {
const { sessionId, path: filePath, hostId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
const downloadStartTime = Date.now();
if (!sessionId || !filePath) {
fileLogger.warn("Missing download parameters", {
operation: "file_download",
sessionId,
hasFilePath: !!filePath,
});
return res.status(400).json({ error: "Missing download parameters" });
}
fileLogger.info("File download started", {
operation: "file_download_start",
sessionId,
userId,
path: filePath,
});
const sshConn = sshSessions[sessionId];
if (!sshConn || !sshConn.isConnected) {
fileLogger.warn("SSH session not found or not connected for download", {
operation: "file_download",
sessionId,
isConnected: sshConn?.isConnected,
});
return res
.status(400)
.json({ error: "SSH session not found or not connected" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
if (sshConn.scpLegacy) {
fileLogger.info(
"Downloading file via legacy exec/cat (SCP legacy mode)",
{
operation: "file_download_legacy",
sessionId,
userId,
path: filePath,
},
);
try {
const data = await downloadViaExec(sshConn, filePath);
const fileName = filePath.split("/").pop() || "download";
fileLogger.success("File download completed (legacy mode)", {
operation: "file_download_complete",
sessionId,
userId,
hostId,
path: filePath,
bytes: data.length,
duration: Date.now() - downloadStartTime,
});
return res.json({
content: data.toString("base64"),
fileName,
size: data.length,
mimeType: getMimeType(fileName),
path: filePath,
});
} catch (err) {
fileLogger.error("Legacy exec/cat download failed:", err);
return res.status(500).json({
error: `Failed to download file: ${(err as Error).message}`,
});
}
}
fileLogger.info("Opening SFTP channel", {
operation: "file_sftp_open",
sessionId,
userId,
path: filePath,
});
getSessionSftp(sshConn)
.then((sftp) => {
sftp.stat(filePath, (statErr, stats) => {
if (statErr) {
fileLogger.error("File stat failed for download:", statErr);
return res
.status(500)
.json({ error: `Cannot access file: ${statErr.message}` });
}
if (!stats.isFile()) {
fileLogger.warn("Attempted to download non-file", {
operation: "file_download",
sessionId,
filePath,
isFile: stats.isFile(),
isDirectory: stats.isDirectory(),
});
return res
.status(400)
.json({ error: "Cannot download directories or special files" });
}
const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024;
if (stats.size > MAX_FILE_SIZE) {
fileLogger.warn("File too large for download", {
operation: "file_download",
sessionId,
filePath,
fileSize: stats.size,
maxSize: MAX_FILE_SIZE,
});
return res.status(400).json({
error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB, file is ${(stats.size / 1024 / 1024).toFixed(2)}MB`,
});
}
sftp.readFile(filePath, (readErr, data) => {
if (readErr) {
fileLogger.error("File read failed for download:", readErr);
return res
.status(500)
.json({ error: `Failed to read file: ${readErr.message}` });
}
const base64Content = data.toString("base64");
const fileName = filePath.split("/").pop() || "download";
fileLogger.success("File download completed", {
operation: "file_download_complete",
sessionId,
userId,
hostId,
path: filePath,
bytes: stats.size,
duration: Date.now() - downloadStartTime,
});
res.json({
content: base64Content,
fileName: fileName,
size: stats.size,
mimeType: getMimeType(fileName),
path: filePath,
});
});
});
})
.catch((err) => {
fileLogger.error("SFTP connection failed for download:", err);
return res.status(500).json({ error: "SFTP connection failed" });
});
});
/**
* @openapi
* /ssh/file_manager/ssh/downloadFileStream:
* post:
* summary: Stream-download a file
* description: Downloads a file as a binary stream. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* responses:
* 200:
* description: Binary file stream.
* 400:
* description: Missing required parameters.
* 500:
* description: Download failed.
*/
app.post("/ssh/file_manager/ssh/downloadFileStream", async (req, res) => {
const { sessionId, path: filePath } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId || !filePath) {
return res.status(400).json({ error: "Missing download parameters" });
}
const sshConn = sshSessions[sessionId];
if (!sshConn?.isConnected) {
return res
.status(400)
.json({ error: "SSH session not found or not connected" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
sshConn.lastActive = Date.now();
const fileName = filePath.split("/").pop() || "download";
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(fileName)}"`,
);
if (sshConn.scpLegacy) {
fileLogger.info("Streaming file via legacy exec/cat (SCP legacy mode)", {
operation: "file_download_stream_legacy",
sessionId,
userId,
path: filePath,
});
const escaped = escapeSingleQuotes(filePath);
execChannel(sshConn, `cat '${escaped}'`, (err, stream) => {
if (err) {
if (!res.headersSent) {
res.status(500).json({ error: `Download failed: ${err.message}` });
}
return;
}
stream.on("error", (streamErr: Error) => {
if (!res.headersSent) {
res
.status(500)
.json({ error: `Download failed: ${streamErr.message}` });
} else {
res.destroy();
}
});
stream.pipe(res);
});
return;
}
try {
const sftp = await getSessionSftp(sshConn);
const stats = await new Promise<{ size: number; isFile: () => boolean }>(
(resolve, reject) => {
sftp.stat(filePath, (err, s) => (err ? reject(err) : resolve(s)));
},
);
if (!stats.isFile()) {
return res.status(400).json({ error: "Cannot download directories" });
}
res.setHeader("Content-Length", String(stats.size));
const readStream = sftp.createReadStream(filePath);
readStream.on("error", (err) => {
if (!res.headersSent) {
res.status(500).json({ error: `Download failed: ${err.message}` });
} else {
res.destroy();
}
});
readStream.pipe(res);
} catch (err) {
if (!res.headersSent) {
res
.status(500)
.json({ error: `Download failed: ${(err as Error).message}` });
}
}
});
}
File diff suppressed because it is too large. Load diff
@@ -1,467 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { Express } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { fileLogger } from "../../utils/logger.js";
import { execChannel, getSessionSftp, type SSHSession } from "./session.js";
import {
formatMtime,
isExecutableFile,
modeToPermissions,
parseLsDateToTimestamp,
} from "./utils.js";
type FileListingRoutesDeps = {
sshSessions: Record<string, SSHSession>;
activeListRequests: Record<string, boolean>;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
export function registerFileListingRoutes(
app: Express,
{
sshSessions,
activeListRequests,
verifySessionOwnership,
}: FileListingRoutesDeps,
): void {
/**
* @openapi
* /ssh/file_manager/ssh/listFiles:
* get:
* summary: List files in a directory
* description: Lists the files and directories in a given path on the remote host.
* tags:
* - File Manager
* parameters:
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* - in: query
* name: path
* required: true
* schema:
* type: string
* responses:
* 200:
* description: A list of files and directories.
* 400:
* description: Session ID is required or SSH connection not established.
* 500:
* description: Failed to list files.
*/
app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
const sshPath = (req.query.path as string) || "/";
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
// Drop concurrent requests for the same session+path — each would open
// a new SSH channel and can exceed the server's per-connection channel limit.
const listKey = `${sessionId}:${sshPath}`;
if (activeListRequests[listKey]) {
return res
.status(409)
.json({ error: "List request already in progress" });
}
activeListRequests[listKey] = true;
res.on("finish", () => {
delete activeListRequests[listKey];
});
sshConn.lastActive = Date.now();
sshConn.activeOperations++;
const trySFTP = () => {
try {
fileLogger.info("Opening SFTP channel", {
operation: "file_sftp_open",
sessionId,
userId,
path: sshPath,
});
getSessionSftp(sshConn)
.then((sftp) => {
sftp.readdir(sshPath, (readdirErr, list) => {
if (readdirErr) {
fileLogger.warn(
`SFTP readdir failed, trying fallback: ${readdirErr.message}`,
);
tryFallbackMethod();
return;
}
const symlinks: Array<{ index: number; path: string }> = [];
const files: Array<{
name: string;
type: string;
size: number | undefined;
modified: string;
modifiedTimestamp: number;
permissions: string;
owner: string;
group: string;
linkTarget: string | undefined;
path: string;
executable: boolean;
}> = [];
for (const entry of list) {
if (entry.filename === "." || entry.filename === "..") continue;
const attrs = entry.attrs;
const permissions = modeToPermissions(attrs.mode);
const isDirectory = attrs.isDirectory();
const isLink = attrs.isSymbolicLink();
const fileEntry = {
name: entry.filename,
type: isDirectory ? "directory" : isLink ? "link" : "file",
size: isDirectory ? undefined : attrs.size,
modified: formatMtime(attrs.mtime),
modifiedTimestamp: attrs.mtime,
permissions,
owner: String(attrs.uid),
group: String(attrs.gid),
linkTarget: undefined as string | undefined,
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${entry.filename}`,
executable:
!isDirectory && !isLink
? isExecutableFile(permissions, entry.filename)
: false,
};
if (isLink) {
symlinks.push({ index: files.length, path: fileEntry.path });
}
files.push(fileEntry);
}
if (symlinks.length === 0) {
sshConn.activeOperations--;
return res.json({ files, path: sshPath });
}
let resolved = 0;
let responded = false;
const sendResponse = () => {
if (responded) return;
responded = true;
sshConn.activeOperations--;
res.json({ files, path: sshPath });
};
const readlinkTimeout = setTimeout(sendResponse, 5000);
for (const link of symlinks) {
sftp.readlink(link.path, (linkErr, target) => {
resolved++;
if (!linkErr && target) {
files[link.index].linkTarget = target;
}
if (resolved === symlinks.length) {
clearTimeout(readlinkTimeout);
sendResponse();
}
});
}
});
})
.catch((err: Error) => {
fileLogger.warn(
`SFTP failed for listFiles, trying fallback: ${err.message}`,
);
const isChannelFailure =
err.message.toLowerCase().includes("channel open failure") ||
err.message.toLowerCase().includes("open failed");
if (isChannelFailure) {
sshConn.isConnected = false;
sshConn.sftp = undefined;
}
tryFallbackMethod();
});
} catch (sftpErr: unknown) {
const errMsg = getErrorMessage(sftpErr);
fileLogger.warn(`SFTP connection error, trying fallback: ${errMsg}`);
tryFallbackMethod();
}
};
const tryFallbackMethod = () => {
if (!sshConn?.isConnected) {
sshConn.activeOperations--;
return res
.status(503)
.json({ error: "SSH session disconnected", disconnected: true });
}
try {
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
execChannel(
sshConn,
`command ls -la --color=never '${escapedPath}'`,
(err, stream) => {
if (err) {
sshConn.activeOperations--;
fileLogger.error("SSH listFiles error:", err);
return res.status(500).json({ error: err.message });
}
let data = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on("close", (code) => {
if (code !== 0) {
const isPermissionDenied =
errorData.toLowerCase().includes("permission denied") ||
errorData.toLowerCase().includes("access denied");
if (isPermissionDenied) {
if (sshConn.sudoPassword) {
fileLogger.info(
`Permission denied for listFiles, retrying with sudo: ${sshPath}`,
);
tryWithSudo();
return;
}
sshConn.activeOperations--;
fileLogger.warn(
`Permission denied for listFiles, sudo required: ${sshPath}`,
);
return res.status(403).json({
error: `Permission denied: Cannot access ${sshPath}`,
needsSudo: true,
path: sshPath,
});
}
sshConn.activeOperations--;
fileLogger.error(
`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
return res
.status(500)
.json({ error: `Command failed: ${errorData}` });
}
sshConn.activeOperations--;
const lines = data.split("\n").filter((line) => line.trim());
const files = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const parts = line.split(/\s+/);
if (parts.length >= 9) {
const permissions = parts[0];
const owner = parts[2];
const group = parts[3];
const size = parseInt(parts[4], 10);
let dateStr = "";
const nameStartIndex = 8;
if (parts[5] && parts[6] && parts[7]) {
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
}
const name = parts.slice(nameStartIndex).join(" ");
const isDirectory = permissions.startsWith("d");
const isLink = permissions.startsWith("l");
if (name === "." || name === "..") continue;
let actualName = name;
let linkTarget = undefined;
if (isLink && name.includes(" -> ")) {
const linkParts = name.split(" -> ");
actualName = linkParts[0];
linkTarget = linkParts[1];
}
files.push({
name: actualName,
type: isDirectory ? "directory" : isLink ? "link" : "file",
size: isDirectory ? undefined : size,
modified: dateStr,
modifiedTimestamp: parseLsDateToTimestamp(dateStr),
permissions,
owner,
group,
linkTarget,
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
executable:
!isDirectory && !isLink
? isExecutableFile(permissions, actualName)
: false,
});
}
}
res.json({ files, path: sshPath });
});
},
);
} catch (execErr: unknown) {
sshConn.activeOperations--;
const errMsg = getErrorMessage(execErr);
fileLogger.error(`Fallback listFiles exec failed: ${errMsg}`);
if (!res.headersSent) {
return res.status(500).json({ error: errMsg });
}
}
};
const tryWithSudo = () => {
try {
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'");
const sudoCommand = `echo '${escapedPassword}' | sudo -S /bin/ls -la --color=never '${escapedPath}' 2>&1`;
execChannel(sshConn, sudoCommand, (err, stream) => {
if (err) {
sshConn.activeOperations--;
fileLogger.error("SSH sudo listFiles error:", err);
return res.status(500).json({ error: err.message });
}
let data = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on("close", (code) => {
sshConn.activeOperations--;
data = data.replace(/\[sudo\] password for .+?:\s*/g, "");
if (
data.toLowerCase().includes("sorry, try again") ||
data.toLowerCase().includes("incorrect password") ||
errorData.toLowerCase().includes("sorry, try again")
) {
sshConn.sudoPassword = undefined;
return res.status(403).json({
error: "Sudo authentication failed. Please try again.",
needsSudo: true,
sudoFailed: true,
path: sshPath,
});
}
if (code !== 0 && !data.trim()) {
fileLogger.error(
`SSH sudo listFiles failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
return res
.status(500)
.json({ error: `Sudo command failed: ${errorData || data}` });
}
const lines = data.split("\n").filter((line) => line.trim());
const files: Array<{
name: string;
type: string;
size: number | undefined;
modified: string;
modifiedTimestamp: number;
permissions: string;
owner: string;
group: string;
linkTarget: string | undefined;
path: string;
executable: boolean;
}> = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const parts = line.split(/\s+/);
if (parts.length >= 9) {
const permissions = parts[0];
const owner = parts[2];
const group = parts[3];
const size = parseInt(parts[4], 10);
let dateStr = "";
const nameStartIndex = 8;
if (parts[5] && parts[6] && parts[7]) {
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
}
const name = parts.slice(nameStartIndex).join(" ");
const isDirectory = permissions.startsWith("d");
const isLink = permissions.startsWith("l");
if (name === "." || name === "..") continue;
let actualName = name;
let linkTarget = undefined;
if (isLink && name.includes(" -> ")) {
const linkParts = name.split(" -> ");
actualName = linkParts[0];
linkTarget = linkParts[1];
}
files.push({
name: actualName,
type: isDirectory ? "directory" : isLink ? "link" : "file",
size: isDirectory ? undefined : size,
modified: dateStr,
modifiedTimestamp: parseLsDateToTimestamp(dateStr),
permissions,
owner,
group,
linkTarget,
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
executable:
!isDirectory && !isLink
? isExecutableFile(permissions, actualName)
: false,
});
}
}
res.json({ files, path: sshPath });
});
});
} catch (execErr: unknown) {
sshConn.activeOperations--;
const errMsg = getErrorMessage(execErr);
fileLogger.error(`Sudo listFiles exec failed: ${errMsg}`);
if (!res.headersSent) {
return res.status(500).json({ error: errMsg });
}
}
};
trySFTP();
});
}
@@ -1,39 +0,0 @@
import { isWindowsSftpPath, sftpPathToLocalPath } from "../transfer-paths.js";
export interface DeleteCommand {
command: string;
commandWithSuccess: string;
}
function quotePosixPath(path: string): string {
return `'${path.replace(/'/g, "'\"'\"'")}'`;
}
function quotePowerShellLiteral(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
export function buildDeleteCommand(
itemPath: string,
isDirectory: boolean,
): DeleteCommand {
if (isWindowsSftpPath(itemPath)) {
const path = quotePowerShellLiteral(sftpPathToLocalPath(itemPath));
const command = isDirectory
? `Remove-Item -LiteralPath ${path} -Recurse -Force -ErrorAction Stop`
: `Remove-Item -LiteralPath ${path} -Force -ErrorAction Stop`;
return {
command,
commandWithSuccess: `${command}; if ($?) { Write-Output "SUCCESS" }`,
};
}
const path = quotePosixPath(itemPath);
const command = isDirectory ? `rm -rf ${path}` : `rm -f ${path}`;
return {
command,
commandWithSuccess: `${command} && echo "SUCCESS"`,
};
}
@@ -1,913 +0,0 @@
import type { Express } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { fileLogger } from "../../utils/logger.js";
import {
execChannel,
execWithSudo,
getSessionSftp,
type SSHSession,
} from "./session.js";
import { ensureDirectoryTreeSftp } from "./transfer-sftp-dir.js";
import { buildDeleteCommand } from "./operation-commands.js";
import {
emptyTrash,
listTrash,
moveToTrash,
permanentlyDeleteTrashItem,
restoreTrashItem,
} from "./trash-service.js";
import {
createCurrentSettingsRepository,
getCurrentSettingValue,
} from "../../database/repositories/factory.js";
import { PermissionManager } from "../../utils/permission-manager.js";
type FileOperationRoutesDeps = {
sshSessions: Record<string, SSHSession>;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
export function registerFileOperationRoutes(
app: Express,
{ sshSessions, verifySessionOwnership }: FileOperationRoutesDeps,
): void {
const permissionManager = PermissionManager.getInstance();
const getTrashRetentionDays = () => {
try {
const value = Number(
getCurrentSettingValue("file_manager_trash_retention_days"),
);
return Number.isInteger(value) && value >= 1 && value <= 3650 ? value : 7;
} catch {
return 7;
}
};
async function ownedSession(
req: AuthenticatedRequest,
res: import("express").Response,
) {
const sessionId = String(req.body?.sessionId ?? req.query?.sessionId ?? "");
const session = sshSessions[sessionId];
if (!sessionId || !session?.isConnected) {
res.status(400).json({ error: "SSH connection not established" });
return null;
}
if (!verifySessionOwnership(session, req.userId)) {
res.status(403).json({ error: "Session access denied" });
return null;
}
session.lastActive = Date.now();
return session;
}
app.get("/ssh/file_manager/ssh/trash", async (req, res) => {
const session = await ownedSession(
req as unknown as AuthenticatedRequest,
res,
);
if (!session) return;
try {
res.json({
items: await listTrash(
await getSessionSftp(session),
getTrashRetentionDays(),
),
retentionDays: getTrashRetentionDays(),
canManageRetention: await permissionManager.isAdmin(
(req as AuthenticatedRequest).userId,
),
});
} catch (error) {
fileLogger.error("Failed to list trash", error);
res.status(500).json({ error: (error as Error).message });
}
});
app.post("/ssh/file_manager/ssh/trash/:id/restore", async (req, res) => {
const session = await ownedSession(
req as unknown as AuthenticatedRequest,
res,
);
if (!session) return;
try {
res.json({
item: await restoreTrashItem(
await getSessionSftp(session),
req.params.id,
),
});
} catch (error) {
const message = (error as Error).message;
res
.status(message.includes("already exists") ? 409 : 500)
.json({ error: message });
}
});
app.delete("/ssh/file_manager/ssh/trash/:id", async (req, res) => {
const session = await ownedSession(
req as unknown as AuthenticatedRequest,
res,
);
if (!session) return;
try {
await permanentlyDeleteTrashItem(
await getSessionSftp(session),
req.params.id,
);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
app.delete("/ssh/file_manager/ssh/trash", async (req, res) => {
const session = await ownedSession(req as AuthenticatedRequest, res);
if (!session) return;
try {
res.json({ deleted: await emptyTrash(await getSessionSftp(session)) });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
app.put("/ssh/file_manager/ssh/trash-retention", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
if (!(await permissionManager.isAdmin(userId))) {
return res.status(403).json({ error: "Admin access required" });
}
const retentionDays = Number(req.body?.retentionDays);
if (
!Number.isInteger(retentionDays) ||
retentionDays < 1 ||
retentionDays > 3650
) {
return res
.status(400)
.json({ error: "Retention must be between 1 and 3650 days" });
}
await createCurrentSettingsRepository().upsert(
"file_manager_trash_retention_days",
String(retentionDays),
);
return res.json({ retentionDays });
});
/**
* @openapi
* /ssh/file_manager/ssh/createFile:
* post:
* summary: Create a file
* description: Creates an empty file on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* fileName:
* type: string
* responses:
* 200:
* description: File created successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 500:
* description: Failed to create file.
*/
app.post("/ssh/file_manager/ssh/createFile", async (req, res) => {
const { sessionId, path: filePath, fileName } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!filePath || !fileName) {
return res.status(400).json({ error: "File path and name are required" });
}
sshConn.lastActive = Date.now();
const fullPath = filePath.endsWith("/")
? filePath + fileName
: filePath + "/" + fileName;
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`;
execChannel(sshConn, createCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH createFile error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let outputData = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
fileLogger.error(`Permission denied creating file: ${fullPath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot create file ${fullPath}. Check directory permissions.`,
});
}
return;
}
});
stream.on("close", (code) => {
if (outputData.includes("SUCCESS")) {
if (!res.headersSent) {
res.json({
message: "File created successfully",
path: fullPath,
toast: { type: "success", message: `File created: ${fullPath}` },
});
}
return;
}
if (code !== 0) {
fileLogger.error(
`SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
if (!res.headersSent) {
return res.status(500).json({
error: `Command failed: ${errorData}`,
toast: {
type: "error",
message: `File creation failed: ${errorData}`,
},
});
}
return;
}
if (!res.headersSent) {
res.json({
message: "File created successfully",
path: fullPath,
toast: { type: "success", message: `File created: ${fullPath}` },
});
}
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH createFile stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
/**
* @openapi
* /ssh/file_manager/ssh/createFolder:
* post:
* summary: Create a folder
* description: Creates a new folder on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* folderName:
* type: string
* responses:
* 200:
* description: Folder created successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 500:
* description: Failed to create folder.
*/
app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => {
const { sessionId, path: folderPath, folderName } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!folderPath || !folderName) {
return res
.status(400)
.json({ error: "Folder path and name are required" });
}
sshConn.lastActive = Date.now();
const fullPath = folderPath.endsWith("/")
? folderPath + folderName
: folderPath + "/" + folderName;
fileLogger.info("Creating directory", {
operation: "file_mkdir",
sessionId,
userId,
path: fullPath,
});
try {
await ensureDirectoryTreeSftp(await getSessionSftp(sshConn), fullPath);
res.json({
message: "Folder created successfully",
path: fullPath,
toast: { type: "success", message: `Folder created: ${fullPath}` },
});
} catch (error) {
fileLogger.error("SFTP createFolder failed", error);
const code = (error as { code?: string | number }).code;
res.status(code === 3 || code === "EACCES" ? 403 : 500).json({
error: (error as Error).message,
});
}
});
/**
* @openapi
* /ssh/file_manager/ssh/deleteItem:
* delete:
* summary: Delete a file or directory
* description: Deletes a file or directory on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* isDirectory:
* type: boolean
* responses:
* 200:
* description: Item deleted successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 500:
* description: Failed to delete item.
*/
app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
const { sessionId, path: itemPath, isDirectory, permanent } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!itemPath) {
return res.status(400).json({ error: "Item path is required" });
}
fileLogger.info("Deleting item", {
operation: "file_delete",
sessionId,
userId,
path: itemPath,
type: isDirectory ? "directory" : "file",
});
sshConn.lastActive = Date.now();
if (!permanent) {
try {
const sftp = await getSessionSftp(sshConn);
await listTrash(sftp, getTrashRetentionDays());
const item = await moveToTrash(sftp, itemPath);
fileLogger.success("Item moved to trash", {
operation: "file_trash_success",
sessionId,
userId,
path: itemPath,
trashId: item.id,
});
return res.json({
message: "Item moved to trash",
path: itemPath,
trashItem: item,
});
} catch (error) {
fileLogger.error("Failed to move item to trash", error, {
operation: "file_trash_failed",
sessionId,
userId,
path: itemPath,
});
return res.status(409).json({
error: (error as Error).message,
trashUnavailable: true,
});
}
}
const { command: deleteCommand, commandWithSuccess } = buildDeleteCommand(
itemPath,
Boolean(isDirectory),
);
const executeDelete = (useSudo: boolean): Promise<void> => {
return new Promise((resolve) => {
if (useSudo && sshConn.sudoPassword) {
execWithSudo(sshConn, deleteCommand, sshConn.sudoPassword).then(
(result) => {
if (
result.code === 0 ||
(!result.stderr.includes("Permission denied") &&
!result.stdout.includes("Permission denied"))
) {
res.json({
message: "Item deleted successfully",
path: itemPath,
toast: {
type: "success",
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
},
});
} else {
res.status(500).json({
error: `Delete failed: ${result.stderr || result.stdout}`,
});
}
resolve();
},
);
return;
}
execChannel(sshConn, commandWithSuccess, (err, stream) => {
if (err) {
fileLogger.error("SSH deleteItem error:", err);
res.status(500).json({ error: err.message });
resolve();
return;
}
let outputData = "";
let errorData = "";
let permissionDenied = false;
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
permissionDenied = true;
}
});
stream.on("close", (code) => {
if (permissionDenied) {
if (sshConn.sudoPassword) {
executeDelete(true).then(resolve);
return;
}
fileLogger.error(`Permission denied deleting: ${itemPath}`);
res.status(403).json({
error: `Permission denied: Cannot delete ${itemPath}.`,
needsSudo: true,
});
resolve();
return;
}
if (outputData.includes("SUCCESS")) {
fileLogger.success("Item deleted successfully", {
operation: "file_delete_success",
sessionId,
userId,
path: itemPath,
});
res.json({
message: "Item deleted successfully",
path: itemPath,
toast: {
type: "success",
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
},
});
} else {
const detail =
errorData.trim() ||
outputData.trim() ||
`command exited with code ${code} and produced no output (the remote shell may not support the delete command)`;
fileLogger.error(`Delete failed for ${itemPath}: ${detail}`, {
operation: "file_delete_failed",
sessionId,
userId,
path: itemPath,
});
res.status(500).json({
error: `Delete failed: ${detail}`,
});
}
resolve();
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH deleteItem stream error:", streamErr);
res
.status(500)
.json({ error: `Stream error: ${streamErr.message}` });
resolve();
});
});
});
};
await executeDelete(false);
});
/**
* @openapi
* /ssh/file_manager/ssh/renameItem:
* put:
* summary: Rename a file or directory
* description: Renames a file or directory on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* oldPath:
* type: string
* newName:
* type: string
* responses:
* 200:
* description: Item renamed successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 500:
* description: Failed to rename item.
*/
app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
const { sessionId, oldPath, newName } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!oldPath || !newName) {
return res
.status(400)
.json({ error: "Old path and new name are required" });
}
sshConn.lastActive = Date.now();
const oldDir = oldPath.substring(0, oldPath.lastIndexOf("/") + 1);
const newPath = oldDir + newName;
fileLogger.info("Renaming item", {
operation: "file_rename",
sessionId,
userId,
from: oldPath,
to: newPath,
});
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
execChannel(sshConn, renameCommand, (err, stream) => {
if (err) {
fileLogger.error("SSH renameItem error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let outputData = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
fileLogger.error(`Permission denied renaming: ${oldPath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot rename ${oldPath}. Check file permissions.`,
});
}
return;
}
});
stream.on("close", (code) => {
if (outputData.includes("SUCCESS")) {
fileLogger.success("Item renamed successfully", {
operation: "file_rename_success",
sessionId,
userId,
from: oldPath,
to: newPath,
});
if (!res.headersSent) {
res.json({
message: "Item renamed successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item renamed: ${oldPath} -> ${newPath}`,
},
});
}
return;
}
if (code !== 0) {
fileLogger.error(
`SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
if (!res.headersSent) {
return res.status(500).json({
error: `Command failed: ${errorData}`,
toast: { type: "error", message: `Rename failed: ${errorData}` },
});
}
return;
}
fileLogger.success("Item renamed successfully", {
operation: "file_rename_success",
sessionId,
userId,
from: oldPath,
to: newPath,
});
if (!res.headersSent) {
res.json({
message: "Item renamed successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item renamed: ${oldPath} -> ${newPath}`,
},
});
}
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH renameItem stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
/**
* @openapi
* /ssh/file_manager/ssh/moveItem:
* put:
* summary: Move a file or directory
* description: Moves a file or directory on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* oldPath:
* type: string
* newPath:
* type: string
* responses:
* 200:
* description: Item moved successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 408:
* description: Move operation timed out.
* 500:
* description: Failed to move item.
*/
app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
const { sessionId, oldPath, newPath } = req.body;
const sshConn = sshSessions[sessionId];
const userId = (req as AuthenticatedRequest).userId;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!oldPath || !newPath) {
return res
.status(400)
.json({ error: "Old path and new path are required" });
}
sshConn.lastActive = Date.now();
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
const moveCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
const commandTimeout = setTimeout(() => {
if (!res.headersSent) {
res.status(408).json({
error: "Move operation timed out. SSH connection may be unstable.",
toast: {
type: "error",
message:
"Move operation timed out. SSH connection may be unstable.",
},
});
}
}, 60000);
execChannel(sshConn, moveCommand, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
fileLogger.error("SSH moveItem error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let outputData = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
fileLogger.error(`Permission denied moving: ${oldPath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
toast: {
type: "error",
message: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
},
});
}
return;
}
});
stream.on("close", (code) => {
clearTimeout(commandTimeout);
if (outputData.includes("SUCCESS")) {
if (!res.headersSent) {
res.json({
message: "Item moved successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item moved: ${oldPath} -> ${newPath}`,
},
});
}
return;
}
if (code !== 0) {
fileLogger.error(
`SSH moveItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
if (!res.headersSent) {
return res.status(500).json({
error: `Command failed: ${errorData}`,
toast: { type: "error", message: `Move failed: ${errorData}` },
});
}
return;
}
if (!res.headersSent) {
res.json({
message: "Item moved successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item moved: ${oldPath} -> ${newPath}`,
},
});
}
});
stream.on("error", (streamErr) => {
clearTimeout(commandTimeout);
fileLogger.error("SSH moveItem stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
}
-282
View File
@@ -1,282 +0,0 @@
import type { Client as SSHClient } from "ssh2";
// Serializes SSH channel open requests so only one channel negotiation is
// in-flight at a time per session. Once the channel is established the slot
// is released immediately so the next open can proceed; the channels
// themselves remain open concurrently.
export class ChannelOpenSerializer {
private tail: Promise<void> = Promise.resolve();
run<T>(action: () => Promise<T>): Promise<T> {
const next = this.tail.then(
() => action(),
() => action(),
);
this.tail = next.then(
() => {},
() => {},
);
return next;
}
}
export interface SSHSession {
client: SSHClient;
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
activeOperations: number;
sudoPassword?: string;
sftp?: import("ssh2").SFTPWrapper;
sftpPending?: Promise<import("ssh2").SFTPWrapper>;
channelOpener: ChannelOpenSerializer;
poolKey?: string;
userId?: string;
hostId?: number;
ip?: string;
port?: number;
username?: string;
transferDedicated?: boolean;
transferId?: string;
browseSessionId?: string;
scpLegacy?: boolean;
}
export interface PendingTOTPSession {
client: SSHClient;
finish: (responses: string[]) => void;
config: import("ssh2").ConnectConfig;
createdAt: number;
sessionId: string;
hostId?: number;
ip?: string;
port?: number;
username?: string;
userId?: string;
prompts?: Array<{ prompt: string; echo: boolean }>;
totpPromptIndex?: number;
resolvedPassword?: string;
totpAttempts: number;
isWarpgate?: boolean;
}
export function execWithSudo(
session: SSHSession,
command: string,
sudoPassword: string,
): Promise<{ stdout: string; stderr: string; code: number }> {
return execWithSudoBuffer(session, command, sudoPassword).then((result) => ({
stdout: result.stdout.toString("utf8"),
stderr: result.stderr,
code: result.code,
}));
}
export function execWithSudoBuffer(
session: SSHSession,
command: string,
sudoPassword: string,
maxStdoutBytes?: number,
): Promise<{
stdout: Buffer;
stderr: string;
code: number;
exceededLimit?: boolean;
}> {
return new Promise((resolve) => {
const sudoCommand = `sudo -S -p '' ${command} 2>&1`;
execChannel(session, sudoCommand, (err, stream) => {
if (err) {
resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 });
return;
}
const stdoutChunks: Buffer[] = [];
let stdoutBytes = 0;
let stderr = "";
let settled = false;
const finish = (
code: number,
extra: { exceededLimit?: boolean } = {},
) => {
if (settled) return;
settled = true;
resolve({
stdout: Buffer.concat(stdoutChunks),
stderr,
code,
...extra,
});
};
stream.on("data", (chunk: Buffer) => {
if (settled) return;
stdoutBytes += chunk.length;
if (maxStdoutBytes !== undefined && stdoutBytes > maxStdoutBytes) {
stderr = `Command output exceeds ${maxStdoutBytes} bytes`;
finish(1, { exceededLimit: true });
stream.close();
return;
}
stdoutChunks.push(chunk);
});
stream.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
stream.on("close", (code: number) => {
if (settled) return;
let stdout = Buffer.concat(stdoutChunks);
const sudoPromptMatch = stdout
.toString("utf8", 0, Math.min(stdout.length, 256))
.match(/^\[sudo\] password for .+?:\s*/);
if (sudoPromptMatch) {
stdout = stdout.subarray(Buffer.byteLength(sudoPromptMatch[0]));
}
settled = true;
resolve({ stdout, stderr, code: code || 0 });
});
stream.on("error", (streamErr: Error) => {
stderr = streamErr.message;
finish(1);
});
stream.write(`${sudoPassword}\n`);
});
});
}
export function execBuffer(
session: SSHSession,
command: string,
maxStdoutBytes?: number,
): Promise<{
stdout: Buffer;
stderr: string;
code: number;
exceededLimit?: boolean;
}> {
return new Promise((resolve) => {
execChannel(session, command, (err, stream) => {
if (err) {
resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 });
return;
}
const stdoutChunks: Buffer[] = [];
let stdoutBytes = 0;
let stderr = "";
let settled = false;
const finish = (
code: number,
extra: { exceededLimit?: boolean } = {},
) => {
if (settled) return;
settled = true;
resolve({
stdout: Buffer.concat(stdoutChunks),
stderr,
code,
...extra,
});
};
stream.on("data", (chunk: Buffer) => {
if (settled) return;
stdoutBytes += chunk.length;
if (maxStdoutBytes !== undefined && stdoutBytes > maxStdoutBytes) {
stderr = `Command output exceeds ${maxStdoutBytes} bytes`;
finish(1, { exceededLimit: true });
stream.close();
return;
}
stdoutChunks.push(chunk);
});
stream.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
stream.on("close", (code: number) => finish(code || 0));
stream.on("error", (streamErr: Error) => {
stderr = stderr || streamErr.message;
finish(1);
});
});
});
}
export function getSessionSftp(
session: SSHSession,
): Promise<import("ssh2").SFTPWrapper> {
if (session.sftp) {
return Promise.resolve(session.sftp);
}
if (session.sftpPending) {
return session.sftpPending;
}
const openOnce = (): Promise<import("ssh2").SFTPWrapper> =>
session.channelOpener.run(
() =>
new Promise<import("ssh2").SFTPWrapper>((resolve, reject) => {
session.client.sftp((err, sftp) => {
if (err) return reject(err);
session.sftp = sftp;
sftp.on("error", () => {
session.sftp = undefined;
});
sftp.on("close", () => {
session.sftp = undefined;
});
resolve(sftp);
});
}),
);
session.sftpPending = openOnce()
.catch((err: Error) => {
const isChannelFailure =
err.message.toLowerCase().includes("channel open failure") ||
err.message.toLowerCase().includes("open failed");
if (isChannelFailure) {
return new Promise<import("ssh2").SFTPWrapper>((resolve, reject) =>
setTimeout(() => openOnce().then(resolve, reject), 500),
);
}
return Promise.reject(err);
})
.finally(() => {
session.sftpPending = undefined;
});
return session.sftpPending;
}
export function execChannel(
session: SSHSession,
command: string,
callback: (
err: Error | undefined,
stream: import("ssh2").ClientChannel,
) => void,
): void {
session.channelOpener
.run(
() =>
new Promise<import("ssh2").ClientChannel>((resolve, reject) => {
session.client.exec(command, (err, stream) => {
if (err) return reject(err);
resolve(stream);
});
}),
)
.then(
(stream) => callback(undefined, stream),
(err: Error) => callback(err, undefined as never),
);
}
@@ -1,120 +0,0 @@
type SFTPWrapper = import("ssh2").SFTPWrapper;
export const SFTP_OPEN_READ = 0x00000001;
export const SFTP_OPEN_WRITE = 0x00000002 | 0x00000008 | 0x00000010;
export const SFTP_OPEN_WRITE_RESUME = 0x00000001 | 0x00000002 | 0x00000008;
export function promisifySftpStat(
sftp: SFTPWrapper,
path: string,
): Promise<import("ssh2").Stats> {
return new Promise((resolve, reject) => {
sftp.stat(path, (err, stats) => {
if (err) reject(err);
else resolve(stats);
});
});
}
export function promisifySftpUnlink(
sftp: SFTPWrapper,
path: string,
): Promise<void> {
return new Promise((resolve, reject) => {
sftp.unlink(path, (err) => {
if (err) reject(err);
else resolve();
});
});
}
export function promisifySftpRmdir(
sftp: SFTPWrapper,
path: string,
): Promise<void> {
return new Promise((resolve, reject) => {
sftp.rmdir(path, (err) => {
if (err) reject(err);
else resolve();
});
});
}
export function promisifySftpMkdir(
sftp: SFTPWrapper,
path: string,
mode: number,
): Promise<void> {
return new Promise((resolve, reject) => {
sftp.mkdir(path, { mode }, (err) => {
if (err && (err as NodeJS.ErrnoException).code !== "EEXIST") {
reject(err);
} else {
resolve();
}
});
});
}
export function promisifySftpChmod(
sftp: SFTPWrapper,
path: string,
mode: number,
): Promise<void> {
return new Promise((resolve, reject) => {
sftp.chmod(path, mode, (err) => {
if (err) reject(err);
else resolve();
});
});
}
export function promisifySftpReaddir(
sftp: SFTPWrapper,
path: string,
): Promise<Array<{ filename: string; attrs: import("ssh2").Stats }>> {
return new Promise((resolve, reject) => {
sftp.readdir(path, (err, list) => {
if (err) reject(err);
else resolve(list);
});
});
}
export function promisifySftpOpen(
sftp: SFTPWrapper,
path: string,
flags: number,
mode: number,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
sftp.open(path, flags, mode, (err, handle) => {
if (err) reject(err);
else resolve(handle);
});
});
}
export function promisifySftpClose(
sftp: SFTPWrapper,
handle: Buffer,
): Promise<void> {
return new Promise((resolve, reject) => {
sftp.close(handle, (err) => {
if (err) reject(err);
else resolve();
});
});
}
export function promisifySftpFstat(
sftp: SFTPWrapper,
handle: Buffer,
): Promise<import("ssh2").Stats> {
return new Promise((resolve, reject) => {
sftp.fstat(handle, (err, stats) => {
if (err) reject(err);
else resolve(stats);
});
});
}
File diff suppressed because it is too large. Load diff
@@ -1,52 +0,0 @@
export class TransferCancelledError extends Error {
constructor() {
super("Transfer cancelled");
this.name = "TransferCancelledError";
}
}
export class TransferStalledError extends Error {
readonly byteOffset?: number;
readonly segmentIndex?: number;
constructor(byteOffset?: number, segmentIndex?: number) {
const pos = byteOffset !== undefined ? ` at byte offset ${byteOffset}` : "";
const seg = segmentIndex !== undefined ? ` (segment ${segmentIndex})` : "";
super(`Transfer stalled — no data moved for 45 seconds${pos}${seg}`);
this.name = "TransferStalledError";
this.byteOffset = byteOffset;
this.segmentIndex = segmentIndex;
}
}
export class TransferConnectionLostError extends Error {
constructor(message = "Transfer SSH connection lost") {
super(message);
this.name = "TransferConnectionLostError";
}
}
export function isRecoverableTransferConnectionError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("no response from server") ||
msg.includes("connection lost") ||
msg.includes("not connected") ||
msg.includes("econnreset") ||
msg.includes("econnrefused") ||
msg.includes("etimedout") ||
msg.includes("socket hang up") ||
msg.includes("protocol error") ||
msg.includes("connection closed") ||
msg.includes("channel open failure")
);
}
export function isRecoverableTransferError(err: unknown): boolean {
return (
err instanceof TransferStalledError ||
err instanceof TransferConnectionLostError ||
isRecoverableTransferConnectionError(err)
);
}
@@ -1,59 +0,0 @@
import { networkInterfaces } from "os";
import { normalizeSftpPath } from "../transfer-paths.js";
let cachedLocalAddresses: Set<string> | null = null;
export function normalizeHostAddress(host: string): string {
const trimmed = host.trim().toLowerCase();
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
return trimmed.slice(1, -1);
}
return trimmed.split(":")[0] ?? trimmed;
}
function getLocalAddresses(): Set<string> {
if (cachedLocalAddresses) return cachedLocalAddresses;
const addresses = new Set(["127.0.0.1", "::1", "localhost"]);
for (const ifaces of Object.values(networkInterfaces())) {
if (!ifaces) continue;
for (const iface of ifaces) {
if (!iface.internal && iface.family === "IPv4") {
addresses.add(iface.address.toLowerCase());
}
}
}
cachedLocalAddresses = addresses;
return addresses;
}
export function isLocalSshEndpoint(ip?: string): boolean {
if (!ip) return false;
const bare = normalizeHostAddress(ip);
if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1") {
return true;
}
return getLocalAddresses().has(bare);
}
export function escapeShell(s: string): string {
return s.replace(/'/g, "'\"'\"'");
}
export function isRootOnlyPath(path: string): boolean {
const normalized = normalizeSftpPath(path);
return (
normalized === "/" ||
/^\/[A-Za-z]:$/.test(normalized) ||
/^[A-Za-z]:$/.test(normalized)
);
}
export function isPermissionError(err: Error): boolean {
const msg = err.message.toLowerCase();
return (
msg.includes("permission denied") ||
msg.includes("eacces") ||
msg.includes("access denied")
);
}
@@ -1,54 +0,0 @@
import { createHash } from "node:crypto";
type SFTPWrapper = import("ssh2").SFTPWrapper;
export interface TransferIntegrityResult {
algorithm: "sha256";
digest: string;
}
export async function hashSftpFile(
sftp: SFTPWrapper,
path: string,
shouldAbort: () => boolean = () => false,
createAbortError: () => Error = () => new Error("Transfer cancelled"),
): Promise<string> {
const stream = sftp.createReadStream(path);
const hash = createHash("sha256");
try {
for await (const chunk of stream) {
if (shouldAbort()) {
const error = createAbortError();
stream.destroy(error);
throw error;
}
hash.update(chunk);
}
} finally {
stream.destroy();
}
if (shouldAbort()) throw createAbortError();
return hash.digest("hex");
}
export async function verifySftpFileIntegrity(
sourceSftp: SFTPWrapper,
destSftp: SFTPWrapper,
sourcePath: string,
destPath: string,
shouldAbort: () => boolean = () => false,
createAbortError: () => Error = () => new Error("Transfer cancelled"),
): Promise<TransferIntegrityResult> {
const [sourceDigest, destDigest] = await Promise.all([
hashSftpFile(sourceSftp, sourcePath, shouldAbort, createAbortError),
hashSftpFile(destSftp, destPath, shouldAbort, createAbortError),
]);
if (sourceDigest !== destDigest) {
throw new Error(`SHA-256 verification failed for ${sourcePath}`);
}
return { algorithm: "sha256", digest: sourceDigest };
}
@@ -1,205 +0,0 @@
import type { TransferPlatform } from "../transfer-paths.js";
import { deflateRawSync } from "node:zlib";
export type TransferMethodPreference = "auto" | "tar" | "item_sftp";
export interface TransferScanSummary {
fileCount: number;
totalBytes: number;
largestFileBytes: number;
/** Share of total bytes in likely incompressible file types (0–1). */
incompressibleRatio: number;
/** Share inferred from bounded content samples, when available. */
sampledIncompressibleRatio?: number;
}
const INCOMPRESSIBLE_EXT =
/\.(zip|gz|bz2|xz|7z|rar|tar|tgz|jpg|jpeg|png|gif|webp|mp3|mp4|mkv|avi|mov|wmv|iso|dmg|deb|rpm|pdf|db|sqlite|wasm|vmdk|qcow2)$/i;
const GB = 1024 * 1024 * 1024;
const MB = 1024 * 1024;
export function isLikelyIncompressiblePath(path: string): boolean {
return INCOMPRESSIBLE_EXT.test(path);
}
export function buildTransferScanSummary(
items: Array<{ sourcePath: string; size: number }>,
): TransferScanSummary {
let totalBytes = 0;
let largestFileBytes = 0;
let incompressibleBytes = 0;
for (const item of items) {
totalBytes += item.size;
largestFileBytes = Math.max(largestFileBytes, item.size);
if (isLikelyIncompressiblePath(item.sourcePath)) {
incompressibleBytes += item.size;
}
}
return {
fileCount: items.length,
totalBytes,
largestFileBytes,
incompressibleRatio: totalBytes > 0 ? incompressibleBytes / totalBytes : 0,
};
}
export function estimateIncompressibleSample(data: Buffer): boolean {
if (data.length === 0) return false;
return deflateRawSync(data, { level: 1 }).length / data.length >= 0.9;
}
function effectiveIncompressibleRatio(summary: TransferScanSummary): number {
return summary.sampledIncompressibleRatio ?? summary.incompressibleRatio;
}
/**
* Choose tar vs per-item SFTP for directory / multi-file transfers.
* Single-file stream transfers bypass this entirely.
*/
export function resolveArchiveTransferMethod(
preference: TransferMethodPreference,
summary: TransferScanSummary,
sourcePlatform: TransferPlatform,
destPlatform: TransferPlatform,
sourceHasTar: boolean,
destHasTar: boolean,
): "tar" | "item_sftp" {
if (sourcePlatform === "windows" || destPlatform === "windows") {
return "item_sftp";
}
if (preference === "item_sftp") {
return "item_sftp";
}
if (preference === "tar") {
return sourceHasTar && destHasTar ? "tar" : "item_sftp";
}
// auto
if (!sourceHasTar || !destHasTar) {
return "item_sftp";
}
const { fileCount, totalBytes, largestFileBytes } = summary;
const incompressibleRatio = effectiveIncompressibleRatio(summary);
if (fileCount === 0) {
return "item_sftp";
}
if (fileCount === 1 && largestFileBytes >= 2 * GB) {
return "item_sftp";
}
// Multi-item sets with a large file: tar only when data is likely compressible
// (mostly video/zip → per-file SFTP is simpler and avoids pack/unpack time).
if (
fileCount > 1 &&
largestFileBytes >= 2 * GB &&
totalBytes >= 500 * MB &&
incompressibleRatio < 0.7
) {
return "tar";
}
if (fileCount === 1 && largestFileBytes >= 5 * GB) {
return "item_sftp";
}
if (totalBytes >= 10 * GB && incompressibleRatio >= 0.5) {
return "item_sftp";
}
if (incompressibleRatio >= 0.85) {
return "item_sftp";
}
if (fileCount >= 100) {
return "tar";
}
if (fileCount >= 20 && totalBytes >= 50 * MB && incompressibleRatio < 0.5) {
return "tar";
}
if (fileCount > 5 && incompressibleRatio < 0.7) {
return "tar";
}
return "item_sftp";
}
export type ArchiveTransferReasonKey =
| "user_item_sftp"
| "user_tar"
| "tar_unavailable"
| "windows_host"
| "auto_multi_large"
| "auto_single_large_in_archive"
| "auto_many_incompressible"
| "auto_many_files"
| "auto_default";
/** i18n key suffix under transfer.methodReason.* */
export function getArchiveTransferReasonKey(
preference: TransferMethodPreference,
resolvedMethod: "tar" | "item_sftp",
summary: TransferScanSummary,
sourcePlatform: TransferPlatform,
destPlatform: TransferPlatform,
sourceHasTar: boolean,
destHasTar: boolean,
): ArchiveTransferReasonKey {
if (preference === "item_sftp") return "user_item_sftp";
if (preference === "tar") {
return sourceHasTar && destHasTar ? "user_tar" : "tar_unavailable";
}
if (sourcePlatform === "windows" || destPlatform === "windows") {
return "windows_host";
}
if (!sourceHasTar || !destHasTar) {
return "tar_unavailable";
}
const { fileCount, totalBytes, largestFileBytes } = summary;
const incompressibleRatio = effectiveIncompressibleRatio(summary);
if (
fileCount > 1 &&
largestFileBytes >= 2 * GB &&
totalBytes >= 500 * MB &&
incompressibleRatio < 0.7
) {
return "auto_multi_large";
}
if (fileCount === 1 && largestFileBytes >= 2 * GB) {
return "auto_single_large_in_archive";
}
if (totalBytes >= 10 * GB && incompressibleRatio >= 0.5) {
return "auto_many_incompressible";
}
if (incompressibleRatio >= 0.85) {
return "auto_many_incompressible";
}
if (
fileCount >= 100 ||
(fileCount >= 20 && totalBytes >= 50 * MB && incompressibleRatio < 0.5) ||
(fileCount > 5 && incompressibleRatio < 0.7)
) {
return "auto_many_files";
}
if (resolvedMethod === "tar") {
return "auto_many_files";
}
return "auto_default";
}
@@ -1,89 +0,0 @@
import { basename, joinPath } from "../transfer-paths.js";
import {
SFTP_OPEN_READ,
promisifySftpClose,
promisifySftpOpen,
promisifySftpReaddir,
promisifySftpStat,
} from "./sftp-promisify.js";
type SFTPWrapper = import("ssh2").SFTPWrapper;
export interface FileWorkItem {
sourcePath: string;
destPath: string;
mode: number;
size: number;
}
export async function collectFileWorkItems(
sftp: SFTPWrapper,
sourcePath: string,
destRoot: string,
destBaseName?: string,
): Promise<FileWorkItem[]> {
const stats = await promisifySftpStat(sftp, sourcePath);
const name = destBaseName ?? basename(sourcePath);
const destPath = joinPath(destRoot, name);
if (stats.isFile()) {
return [
{
sourcePath,
destPath,
mode: stats.mode & 0o7777,
size: stats.size,
},
];
}
if (!stats.isDirectory()) {
return [];
}
const items: FileWorkItem[] = [];
const walk = async (srcDir: string, dstDir: string) => {
const entries = await promisifySftpReaddir(sftp, srcDir);
for (const entry of entries) {
if (entry.filename === "." || entry.filename === "..") continue;
const srcChild = joinPath(srcDir, entry.filename);
const dstChild = joinPath(dstDir, entry.filename);
if (entry.attrs.isDirectory()) {
await walk(srcChild, dstChild);
} else if (entry.attrs.isFile()) {
items.push({
sourcePath: srcChild,
destPath: dstChild,
mode: entry.attrs.mode & 0o7777,
size: entry.attrs.size,
});
}
}
};
await walk(sourcePath, destPath);
return items;
}
export async function readSftpSample(
sftp: SFTPWrapper,
path: string,
fileSize: number,
): Promise<Buffer> {
const sampleSize = Math.min(64 * 1024, fileSize);
const position = Math.max(0, Math.floor((fileSize - sampleSize) / 2));
const handle = await promisifySftpOpen(sftp, path, SFTP_OPEN_READ, 0o666);
try {
const buffer = Buffer.alloc(sampleSize);
const bytesRead = await new Promise<number>((resolve, reject) => {
sftp.read(handle, buffer, 0, sampleSize, position, (err, count) => {
if (err) reject(err);
else resolve(count);
});
});
return buffer.subarray(0, bytesRead);
} finally {
await promisifySftpClose(sftp, handle).catch(() => {});
}
}
@@ -1,40 +0,0 @@
export const SFTP_XFER_SEGMENT_SIZE = 256 * 1024 * 1024;
export const DEFAULT_PARALLEL_SEGMENT_COUNT = 2;
export const MAX_PARALLEL_SEGMENT_COUNT = 8;
export interface SegmentCopyJob {
offset: number;
length: number;
segmentIndex: number;
}
export function clampParallelSegmentCount(value?: number): number {
const n = value ?? DEFAULT_PARALLEL_SEGMENT_COUNT;
return Math.max(1, Math.min(MAX_PARALLEL_SEGMENT_COUNT, Math.floor(n)));
}
export function buildSegmentCopyJobs(
fileSize: number,
initialOffset: number,
destResumeSize: number,
): SegmentCopyJob[] {
const jobs: SegmentCopyJob[] = [];
for (
let offset = initialOffset;
offset < fileSize;
offset += SFTP_XFER_SEGMENT_SIZE
) {
const length = Math.min(SFTP_XFER_SEGMENT_SIZE, fileSize - offset);
const segmentIndex = Math.floor(offset / SFTP_XFER_SEGMENT_SIZE);
if (destResumeSize >= offset + length) {
continue;
}
const start = destResumeSize > offset ? destResumeSize : offset;
jobs.push({
offset: start,
length: offset + length - start,
segmentIndex,
});
}
return jobs;
}
@@ -1,74 +0,0 @@
import {
buildPathFromSegments,
joinPath,
normalizeSftpPath,
splitPathSegments,
} from "../transfer-paths.js";
import { isRootOnlyPath } from "./transfer-host-utils.js";
import {
promisifySftpMkdir,
promisifySftpReaddir,
promisifySftpRmdir,
promisifySftpStat,
promisifySftpUnlink,
} from "./sftp-promisify.js";
type SFTPWrapper = import("ssh2").SFTPWrapper;
export async function ensureDirectoryTreeSftp(
sftp: SFTPWrapper,
dirPath: string,
created: Set<string> = new Set(),
): Promise<void> {
const normalized = normalizeSftpPath(dirPath);
if (!normalized || isRootOnlyPath(normalized)) return;
const { root, segments } = splitPathSegments(normalized);
if (segments.length === 0) return;
for (let i = 0; i < segments.length; i++) {
const current = buildPathFromSegments(root, segments, i + 1);
if (created.has(current)) continue;
try {
await promisifySftpMkdir(sftp, current, 0o755);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== "EEXIST") {
try {
const stats = await promisifySftpStat(sftp, current);
if (!stats.isDirectory()) throw err;
} catch {
throw err;
}
}
}
created.add(current);
}
}
export async function deletePathSftp(
sftp: SFTPWrapper,
path: string,
): Promise<void> {
let stats: import("ssh2").Stats;
try {
stats = await promisifySftpStat(sftp, path);
} catch {
return;
}
if (stats.isDirectory()) {
const entries = await promisifySftpReaddir(sftp, path);
for (const entry of entries) {
if (entry.filename === "." || entry.filename === "..") continue;
await deletePathSftp(sftp, joinPath(path, entry.filename));
}
await promisifySftpRmdir(sftp, path);
return;
}
if (stats.isFile()) {
await promisifySftpUnlink(sftp, path);
}
}
@@ -1,157 +0,0 @@
import { performance } from "node:perf_hooks";
const TRANSFER_PROGRESS_INTERVAL_MS = 200;
export type TransferHopId =
"source_read" | "dest_sftp_write" | "dest_local_write";
export interface TransferHopMetrics {
id: TransferHopId;
bytes: number;
/** Wall-clock span from first I/O on this hop to last I/O complete. */
spanMs: number;
mbPerSec: number;
}
export interface TransferTimings {
prepareDestMs?: number;
compressMs?: number;
transferMs?: number;
extractMs?: number;
verifyMs?: number;
directBenchmarkMs?: number;
relayBenchmarkMs?: number;
sourceDeleteMs?: number;
totalMs?: number;
transferBytes?: number;
endToEndMbPerSec?: number;
hops?: TransferHopMetrics[];
}
export function elapsedMs(start: number): number {
return Date.now() - start;
}
export function computeTransferMbPerSec(
bytes: number,
ms: number,
): number | undefined {
if (ms <= 0 || bytes <= 0) return undefined;
return ((bytes / ms) * 1000) / (1024 * 1024);
}
export interface HopWallClock {
firstAt: number | null;
lastAt: number | null;
}
export function createHopWallClock(): HopWallClock {
return { firstAt: null, lastAt: null };
}
export function noteHopStart(
clock: HopWallClock,
t: number = performance.now(),
): void {
if (clock.firstAt === null) clock.firstAt = t;
}
export function noteHopEnd(
clock: HopWallClock,
t: number = performance.now(),
): void {
clock.lastAt = t;
}
export function hopSpanMs(clock: HopWallClock): number {
if (clock.firstAt === null || clock.lastAt === null) return 0;
return Math.max(0, clock.lastAt - clock.firstAt);
}
export function createThrottledProgress(onProgress?: (bytes: number) => void) {
let pending = 0;
let lastFlush = 0;
const flush = () => {
if (pending > 0) {
onProgress?.(pending);
pending = 0;
lastFlush = Date.now();
}
};
return {
add(bytes: number) {
pending += bytes;
const now = Date.now();
if (now - lastFlush >= TRANSFER_PROGRESS_INTERVAL_MS) {
flush();
}
},
flush,
};
}
export interface PipelinedXferStats {
bytes: number;
sourceReadSpanMs: number;
destWriteSpanMs: number;
destWriteKind: "sftp" | "local";
}
export function createEmptyXferStats(): PipelinedXferStats {
return {
bytes: 0,
sourceReadSpanMs: 0,
destWriteSpanMs: 0,
destWriteKind: "sftp",
};
}
export function mergeXferStats(
target: PipelinedXferStats,
source: PipelinedXferStats,
): void {
target.bytes += source.bytes;
target.sourceReadSpanMs += source.sourceReadSpanMs;
target.destWriteSpanMs += source.destWriteSpanMs;
target.destWriteKind = source.destWriteKind;
}
export function buildTransferHopTimings(
stats: PipelinedXferStats,
transferMs: number,
): Pick<TransferTimings, "transferBytes" | "endToEndMbPerSec" | "hops"> {
const hops: TransferHopMetrics[] = [];
const sourceRate = computeTransferMbPerSec(
stats.bytes,
stats.sourceReadSpanMs,
);
if (sourceRate !== undefined) {
hops.push({
id: "source_read",
bytes: stats.bytes,
spanMs: stats.sourceReadSpanMs,
mbPerSec: sourceRate,
});
}
const destHopId: TransferHopId =
stats.destWriteKind === "local" ? "dest_local_write" : "dest_sftp_write";
const destRate = computeTransferMbPerSec(stats.bytes, stats.destWriteSpanMs);
if (destRate !== undefined) {
hops.push({
id: destHopId,
bytes: stats.bytes,
spanMs: stats.destWriteSpanMs,
mbPerSec: destRate,
});
}
return {
transferBytes: stats.bytes,
endToEndMbPerSec: computeTransferMbPerSec(stats.bytes, transferMs),
hops,
};
}
@@ -1,387 +0,0 @@
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
export interface TransferPerformanceProfile {
throughputBps: number;
failureRate: number;
samples: number;
preferredLanes: number;
pipelineConcurrency: number;
updatedAt: number;
}
export interface TransferTuning {
parallelSegmentCount: number;
pipelineConcurrency: number;
}
export interface DirectRouteProfile {
directMs: number;
relayMs: number;
failureRate: number;
benchmarkSamples: number;
outcomeSamples: number;
benchmarkedAt: number;
cooldownUntil?: number;
updatedAt: number;
}
const MB = 1024 * 1024;
const PROFILE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_PROFILES = 128;
const STORE_VERSION = 1;
const STORE_FILENAME = "adaptive-transfer-profiles.json";
const profiles = new Map<string, TransferPerformanceProfile>();
const directRoutes = new Map<string, DirectRouteProfile>();
let loadedPath: string | undefined;
let loadPromise: Promise<void> | undefined;
let persistTimer: ReturnType<typeof setTimeout> | undefined;
let persistPromise = Promise.resolve();
interface PersistedProfiles {
version: typeof STORE_VERSION;
profiles: Record<string, TransferPerformanceProfile>;
directRoutes: Record<string, DirectRouteProfile>;
}
function validDirectRoute(value: unknown): value is DirectRouteProfile {
if (!value || typeof value !== "object") return false;
const profile = value as Partial<DirectRouteProfile>;
return (
Number.isFinite(profile.directMs) &&
Number(profile.directMs) > 0 &&
Number.isFinite(profile.relayMs) &&
Number(profile.relayMs) > 0 &&
Number.isFinite(profile.failureRate) &&
Number(profile.failureRate) >= 0 &&
Number(profile.failureRate) <= 1 &&
Number.isFinite(profile.benchmarkSamples) &&
Number(profile.benchmarkSamples) > 0 &&
Number.isFinite(profile.outcomeSamples) &&
Number(profile.outcomeSamples) >= 0 &&
Number.isFinite(profile.benchmarkedAt) &&
(profile.cooldownUntil === undefined ||
Number.isFinite(profile.cooldownUntil)) &&
Number.isFinite(profile.updatedAt)
);
}
function storePath(): string {
const dataDir =
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
return path.join(dataDir, STORE_FILENAME);
}
function profileId(key: string): string {
return createHash("sha256").update(key).digest("hex");
}
function validProfile(value: unknown): value is TransferPerformanceProfile {
if (!value || typeof value !== "object") return false;
const profile = value as Partial<TransferPerformanceProfile>;
return (
Number.isFinite(profile.throughputBps) &&
Number(profile.throughputBps) >= 0 &&
Number.isFinite(profile.failureRate) &&
Number(profile.failureRate) >= 0 &&
Number(profile.failureRate) <= 1 &&
Number.isFinite(profile.samples) &&
Number(profile.samples) > 0 &&
Number.isFinite(profile.preferredLanes) &&
Number.isFinite(profile.pipelineConcurrency) &&
Number.isFinite(profile.updatedAt)
);
}
function trimProfiles(now = Date.now()): void {
for (const [key, profile] of profiles) {
if (now - profile.updatedAt > PROFILE_TTL_MS) profiles.delete(key);
}
const recent = [...profiles.entries()]
.sort(([, a], [, b]) => b.updatedAt - a.updatedAt)
.slice(0, MAX_PROFILES);
profiles.clear();
for (const [key, profile] of recent) profiles.set(key, profile);
for (const [key, profile] of directRoutes) {
if (now - profile.updatedAt > PROFILE_TTL_MS) directRoutes.delete(key);
}
const recentRoutes = [...directRoutes.entries()]
.sort(([, a], [, b]) => b.updatedAt - a.updatedAt)
.slice(0, MAX_PROFILES);
directRoutes.clear();
for (const [key, profile] of recentRoutes) directRoutes.set(key, profile);
}
async function persistProfiles(): Promise<void> {
trimProfiles();
const target = storePath();
const temporary = `${target}.${process.pid}.tmp`;
const payload: PersistedProfiles = {
version: STORE_VERSION,
profiles: Object.fromEntries(profiles),
directRoutes: Object.fromEntries(directRoutes),
};
try {
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(temporary, JSON.stringify(payload), {
encoding: "utf8",
mode: 0o600,
});
await fs.rename(temporary, target);
} catch {
await fs.rm(temporary, { force: true }).catch(() => {});
}
}
function queuePersist(): void {
if (persistTimer) clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
persistTimer = undefined;
persistPromise = persistPromise.then(persistProfiles);
}, 250);
persistTimer.unref?.();
}
export async function initializeTransferProfiles(): Promise<void> {
const target = storePath();
if (loadedPath === target) return loadPromise;
loadedPath = target;
loadPromise = (async () => {
profiles.clear();
directRoutes.clear();
try {
const parsed = JSON.parse(
await fs.readFile(target, "utf8"),
) as Partial<PersistedProfiles>;
if (parsed.version !== STORE_VERSION || !parsed.profiles) return;
for (const [key, profile] of Object.entries(parsed.profiles)) {
if (/^[a-f0-9]{64}$/.test(key) && validProfile(profile)) {
profiles.set(key, profile);
}
}
for (const [key, profile] of Object.entries(parsed.directRoutes ?? {})) {
if (/^[a-f0-9]{64}$/.test(key) && validDirectRoute(profile)) {
directRoutes.set(key, profile);
}
}
trimProfiles();
} catch {
// Missing or malformed local learning data must not affect transfers.
}
})();
return loadPromise;
}
export async function flushTransferProfiles(): Promise<void> {
if (persistTimer) {
clearTimeout(persistTimer);
persistTimer = undefined;
}
persistPromise = persistPromise.then(persistProfiles);
await persistPromise;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, Math.floor(value)));
}
export function selectTransferTuning(
fileSize: number,
profile?: TransferPerformanceProfile,
requestedLanes?: number,
): TransferTuning {
if (fileSize < 32 * MB) {
return { parallelSegmentCount: 1, pipelineConcurrency: 8 };
}
let lanes = fileSize >= 1024 * MB ? 4 : 2;
let pipelineConcurrency = fileSize >= 256 * MB ? 32 : 16;
if (profile) {
lanes = profile.preferredLanes;
pipelineConcurrency = profile.pipelineConcurrency;
if (profile.failureRate >= 0.2) {
lanes = Math.min(lanes, 2);
pipelineConcurrency = Math.min(pipelineConcurrency, 16);
}
}
if (requestedLanes !== undefined) lanes = requestedLanes;
const segmentCapacity = Math.max(1, Math.ceil(fileSize / (256 * MB)));
return {
parallelSegmentCount: clamp(lanes, 1, Math.min(8, segmentCapacity)),
pipelineConcurrency: clamp(pipelineConcurrency, 8, 64),
};
}
export function updateTransferProfile(
previous: TransferPerformanceProfile | undefined,
observation: {
bytes: number;
durationMs: number;
lanes: number;
pipelineConcurrency: number;
failed: boolean;
now?: number;
},
): TransferPerformanceProfile {
const now = observation.now ?? Date.now();
const throughputBps =
observation.durationMs > 0
? (observation.bytes * 1000) / observation.durationMs
: 0;
const weight = previous ? 0.25 : 1;
const smoothedThroughput = previous
? previous.throughputBps * (1 - weight) + throughputBps * weight
: throughputBps;
const failure = observation.failed ? 1 : 0;
const failureRate = previous
? previous.failureRate * (1 - weight) + failure * weight
: failure;
let preferredLanes = observation.lanes;
let pipelineConcurrency = observation.pipelineConcurrency;
if (failureRate >= 0.2) {
preferredLanes = Math.max(1, Math.floor(preferredLanes / 2));
pipelineConcurrency = Math.max(8, Math.floor(pipelineConcurrency / 2));
} else if (
!observation.failed &&
previous &&
previous.samples >= 2 &&
throughputBps > previous.throughputBps * 1.25
) {
preferredLanes = Math.min(8, preferredLanes + 1);
pipelineConcurrency = Math.min(64, pipelineConcurrency + 8);
}
return {
throughputBps: smoothedThroughput,
failureRate,
samples: (previous?.samples ?? 0) + 1,
preferredLanes,
pipelineConcurrency,
updatedAt: now,
};
}
export function getTransferProfile(
key: string,
now = Date.now(),
): TransferPerformanceProfile | undefined {
const id = profileId(key);
const profile = profiles.get(id);
if (!profile) return undefined;
if (now - profile.updatedAt <= PROFILE_TTL_MS) return profile;
profiles.delete(id);
queuePersist();
return undefined;
}
export function recordTransferProfile(
key: string,
observation: Parameters<typeof updateTransferProfile>[1],
): TransferPerformanceProfile {
const profile = updateTransferProfile(getTransferProfile(key), observation);
profiles.set(profileId(key), profile);
trimProfiles(observation.now);
queuePersist();
return profile;
}
export function getDirectRouteProfile(
key: string,
now = Date.now(),
): DirectRouteProfile | undefined {
const id = profileId(key);
const profile = directRoutes.get(id);
if (!profile) return undefined;
if (now - profile.updatedAt <= PROFILE_TTL_MS) return profile;
directRoutes.delete(id);
queuePersist();
return undefined;
}
export function recordDirectRouteBenchmark(
key: string,
directMs: number,
relayMs: number,
now = Date.now(),
): DirectRouteProfile {
const previous = getDirectRouteProfile(key, now);
const weight = previous ? 0.25 : 1;
const profile: DirectRouteProfile = {
directMs: previous
? previous.directMs * (1 - weight) + directMs * weight
: directMs,
relayMs: previous
? previous.relayMs * (1 - weight) + relayMs * weight
: relayMs,
failureRate: previous?.failureRate ?? 0,
benchmarkSamples: (previous?.benchmarkSamples ?? 0) + 1,
outcomeSamples: previous?.outcomeSamples ?? 0,
benchmarkedAt: now,
cooldownUntil: previous?.cooldownUntil,
updatedAt: now,
};
directRoutes.set(profileId(key), profile);
trimProfiles(now);
queuePersist();
return profile;
}
export function recordDirectRouteOutcome(
key: string,
failed: boolean,
now = Date.now(),
cooldownMs = 10 * 60 * 1000,
): DirectRouteProfile | undefined {
const previous = getDirectRouteProfile(key, now);
if (!previous) return undefined;
const failure = failed ? 1 : 0;
const weight = previous.outcomeSamples > 0 ? 0.25 : 1;
const profile = {
...previous,
failureRate: previous.failureRate * (1 - weight) + failure * weight,
outcomeSamples: previous.outcomeSamples + 1,
cooldownUntil: failed ? now + cooldownMs : undefined,
updatedAt: now,
};
directRoutes.set(profileId(key), profile);
queuePersist();
return profile;
}
export function getRecentDirectRouteDecision(
key: string,
maxAgeMs: number,
now = Date.now(),
): { useDirect: boolean; directMs: number; relayMs: number } | undefined {
const profile = getDirectRouteProfile(key, now);
if (!profile) return undefined;
if ((profile.cooldownUntil ?? 0) > now) {
return {
useDirect: false,
directMs: profile.directMs,
relayMs: profile.relayMs,
};
}
if (now - profile.benchmarkedAt > maxAgeMs) return undefined;
return {
useDirect:
profile.failureRate < 0.2 && profile.directMs <= profile.relayMs * 0.8,
directMs: profile.directMs,
relayMs: profile.relayMs,
};
}
export function clearTransferProfiles(): void {
if (persistTimer) clearTimeout(persistTimer);
persistTimer = undefined;
loadedPath = undefined;
loadPromise = undefined;
profiles.clear();
directRoutes.clear();
}
@@ -1,234 +0,0 @@
import crypto from "node:crypto";
import path from "node:path";
import type { SFTPWrapper, Stats } from "ssh2";
export interface TrashItem {
id: string;
name: string;
originalPath: string;
isDirectory: boolean;
deletedAt: string;
size: number;
}
type StoredTrashItem = TrashItem & { trashPath: string };
const TRASH_DIR = ".termix-trash";
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
function call<T>(
run: (done: (error: Error | undefined, value: T) => void) => void,
) {
return new Promise<T>((resolve, reject) => {
run((error, value) => (error ? reject(error) : resolve(value)));
});
}
function stat(sftp: SFTPWrapper, target: string) {
return call<Stats>((done) => sftp.stat(target, done));
}
function lstat(sftp: SFTPWrapper, target: string) {
return call<Stats>((done) => sftp.lstat(target, done));
}
function readdir(sftp: SFTPWrapper, target: string) {
return call<import("ssh2").FileEntry[]>((done) => sftp.readdir(target, done));
}
function readFile(sftp: SFTPWrapper, target: string) {
return call<Buffer>((done) => sftp.readFile(target, done));
}
function writeFile(sftp: SFTPWrapper, target: string, data: string) {
return call<void>((done) => sftp.writeFile(target, data, done));
}
function rename(sftp: SFTPWrapper, from: string, to: string) {
return call<void>((done) => sftp.rename(from, to, done));
}
function unlink(sftp: SFTPWrapper, target: string) {
return call<void>((done) => sftp.unlink(target, done));
}
function mkdir(sftp: SFTPWrapper, target: string) {
return call<void>((done) => sftp.mkdir(target, done));
}
function rmdir(sftp: SFTPWrapper, target: string) {
return call<void>((done) => sftp.rmdir(target, done));
}
async function exists(sftp: SFTPWrapper, target: string) {
try {
await stat(sftp, target);
return true;
} catch {
return false;
}
}
async function ensureDirectory(sftp: SFTPWrapper, target: string) {
const normalized = target.replace(/\\/g, "/");
const root = normalized.startsWith("/") ? "/" : "";
const parts = normalized.split("/").filter(Boolean);
let current = root;
for (const part of parts) {
current =
current === "/" ? `/${part}` : current ? `${current}/${part}` : part;
if (await exists(sftp, current)) continue;
await mkdir(sftp, current);
}
}
async function removeTree(sftp: SFTPWrapper, target: string): Promise<void> {
const targetStat = await lstat(sftp, target);
if (!targetStat.isDirectory()) {
await unlink(sftp, target);
return;
}
for (const entry of await readdir(sftp, target)) {
if (entry.filename === "." || entry.filename === "..") continue;
await removeTree(sftp, path.posix.join(target, entry.filename));
}
await rmdir(sftp, target);
}
async function trashPaths(sftp: SFTPWrapper) {
const home = await call<string>((done) => sftp.realpath(".", done));
const root = path.posix.join(home.replace(/\\/g, "/"), TRASH_DIR);
const files = path.posix.join(root, "files");
const info = path.posix.join(root, "info");
await ensureDirectory(sftp, files);
await ensureDirectory(sftp, info);
return { root, files, info };
}
export function isSafeTrashSource(itemPath: string, trashRoot: string) {
const normalized = path.posix.normalize(itemPath.replace(/\\/g, "/"));
const root = path.posix.normalize(trashRoot);
return (
normalized !== "." &&
normalized !== "/" &&
!/^[A-Za-z]:\/?$/.test(normalized) &&
normalized !== root &&
!normalized.startsWith(`${root}/`)
);
}
function publicItem(item: StoredTrashItem): TrashItem {
const { trashPath: _trashPath, ...result } = item;
return result;
}
async function readStoredItem(
sftp: SFTPWrapper,
dirs: { root: string; files: string; info: string },
id: string,
): Promise<StoredTrashItem> {
if (!ID_PATTERN.test(id)) throw new Error("Invalid trash item id");
const parsed = JSON.parse(
(await readFile(sftp, path.posix.join(dirs.info, `${id}.json`))).toString(
"utf8",
),
) as StoredTrashItem;
if (
parsed.id !== id ||
parsed.trashPath !== path.posix.join(dirs.files, id) ||
!isSafeTrashSource(parsed.originalPath, dirs.root)
) {
throw new Error("Invalid trash metadata");
}
return parsed;
}
export async function moveToTrash(
sftp: SFTPWrapper,
itemPath: string,
): Promise<TrashItem> {
const dirs = await trashPaths(sftp);
if (!isSafeTrashSource(itemPath, dirs.root)) {
throw new Error("This path cannot be moved to trash");
}
const itemStat = await lstat(sftp, itemPath);
const id = crypto.randomUUID();
const trashPath = path.posix.join(dirs.files, id);
const item: StoredTrashItem = {
id,
name: path.posix.basename(itemPath.replace(/\\/g, "/")),
originalPath: itemPath,
trashPath,
isDirectory: itemStat.isDirectory(),
deletedAt: new Date().toISOString(),
size: itemStat.size,
};
await rename(sftp, itemPath, trashPath);
try {
await writeFile(
sftp,
path.posix.join(dirs.info, `${id}.json`),
JSON.stringify(item),
);
} catch (error) {
await rename(sftp, trashPath, itemPath).catch(() => {});
throw error;
}
return publicItem(item);
}
export async function listTrash(
sftp: SFTPWrapper,
retentionDays: number,
): Promise<TrashItem[]> {
const dirs = await trashPaths(sftp);
const cutoff = Date.now() - retentionDays * 86_400_000;
const items: TrashItem[] = [];
for (const entry of await readdir(sftp, dirs.info)) {
if (!entry.filename.endsWith(".json")) continue;
const id = entry.filename.slice(0, -5);
try {
const item = await readStoredItem(sftp, dirs, id);
if (new Date(item.deletedAt).getTime() < cutoff) {
if (await exists(sftp, item.trashPath))
await removeTree(sftp, item.trashPath);
await unlink(sftp, path.posix.join(dirs.info, entry.filename));
continue;
}
if (await exists(sftp, item.trashPath)) items.push(publicItem(item));
else await unlink(sftp, path.posix.join(dirs.info, entry.filename));
} catch {
// Ignore corrupt metadata without exposing arbitrary paths to deletion.
}
}
return items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
}
export async function restoreTrashItem(sftp: SFTPWrapper, id: string) {
const dirs = await trashPaths(sftp);
const item = await readStoredItem(sftp, dirs, id);
if (await exists(sftp, item.originalPath)) {
throw new Error("A file already exists at the original path");
}
await rename(sftp, item.trashPath, item.originalPath);
await unlink(sftp, path.posix.join(dirs.info, `${id}.json`));
return publicItem(item);
}
export async function permanentlyDeleteTrashItem(
sftp: SFTPWrapper,
id: string,
) {
const dirs = await trashPaths(sftp);
const item = await readStoredItem(sftp, dirs, id);
if (await exists(sftp, item.trashPath))
await removeTree(sftp, item.trashPath);
await unlink(sftp, path.posix.join(dirs.info, `${id}.json`));
}
export async function emptyTrash(sftp: SFTPWrapper) {
const items = await listTrash(sftp, 365_000);
for (const item of items) await permanentlyDeleteTrashItem(sftp, item.id);
return items.length;
}
-164
View File
@@ -1,164 +0,0 @@
export function isExecutableFile(
permissions: string,
fileName: string,
): boolean {
const hasExecutePermission =
permissions[3] === "x" || permissions[6] === "x" || permissions[9] === "x";
const scriptExtensions = [
".sh",
".py",
".pl",
".rb",
".js",
".php",
".bash",
".zsh",
".fish",
];
const hasScriptExtension = scriptExtensions.some((ext) =>
fileName.toLowerCase().endsWith(ext),
);
const executableExtensions = [".bin", ".exe", ".out"];
const hasExecutableExtension = executableExtensions.some((ext) =>
fileName.toLowerCase().endsWith(ext),
);
const hasNoExtension = !fileName.includes(".") && hasExecutePermission;
return (
hasExecutePermission &&
(hasScriptExtension || hasExecutableExtension || hasNoExtension)
);
}
export function modeToPermissions(mode: number): string {
const S_IFDIR = 0o040000;
const S_IFLNK = 0o120000;
const S_IFMT = 0o170000;
const type = mode & S_IFMT;
const prefix = type === S_IFDIR ? "d" : type === S_IFLNK ? "l" : "-";
const perms = [
mode & 0o400 ? "r" : "-",
mode & 0o200 ? "w" : "-",
mode & 0o100 ? "x" : "-",
mode & 0o040 ? "r" : "-",
mode & 0o020 ? "w" : "-",
mode & 0o010 ? "x" : "-",
mode & 0o004 ? "r" : "-",
mode & 0o002 ? "w" : "-",
mode & 0o001 ? "x" : "-",
].join("");
return prefix + perms;
}
export function parseLsDateToTimestamp(dateStr: string): number {
const parts = dateStr.trim().split(/\s+/);
if (parts.length < 3) return 0;
const [month, day, timeOrYear] = parts;
const monthIndex = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
].indexOf(month);
if (monthIndex === -1) return 0;
const dayNum = parseInt(day, 10);
const now = new Date();
if (timeOrYear.includes(":")) {
const [hours, minutes] = timeOrYear.split(":").map(Number);
let year = now.getFullYear();
const candidate = new Date(year, monthIndex, dayNum, hours, minutes);
if (candidate > now) year -= 1;
return new Date(year, monthIndex, dayNum, hours, minutes).getTime() / 1000;
}
const year = parseInt(timeOrYear, 10);
if (isNaN(year)) return 0;
return new Date(year, monthIndex, dayNum).getTime() / 1000;
}
export function formatMtime(mtime: number): string {
const date = new Date(mtime * 1000);
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const month = months[date.getMonth()];
const day = date.getDate().toString().padStart(2, " ");
const now = new Date();
const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000);
if (date > sixMonthsAgo) {
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${month} ${day} ${hours}:${minutes}`;
}
return `${month} ${day} ${date.getFullYear()}`;
}
export function getMimeType(fileName: string): string {
const ext = fileName.split(".").pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
txt: "text/plain",
json: "application/json",
js: "text/javascript",
html: "text/html",
css: "text/css",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
pdf: "application/pdf",
zip: "application/zip",
tar: "application/x-tar",
gz: "application/gzip",
};
return mimeTypes[ext || ""] || "application/octet-stream";
}
export function detectBinary(buffer: Buffer): boolean {
if (buffer.length === 0) return false;
const sampleSize = Math.min(buffer.length, 8192);
let nullBytes = 0;
for (let i = 0; i < sampleSize; i++) {
const byte = buffer[i];
if (byte === 0) {
nullBytes++;
}
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
if (++nullBytes > 1) return true;
}
}
return nullBytes / sampleSize > 0.01;
}
-7
View File
@@ -109,13 +109,6 @@ export async function resolveHostById(
host.jumpHosts = [];
}
}
if (typeof host.tunnelConnections === "string") {
try {
host.tunnelConnections = JSON.parse(host.tunnelConnections as string);
} catch {
host.tunnelConnections = [];
}
}
if (typeof host.terminalConfig === "string" && host.terminalConfig) {
try {
host.terminalConfig = JSON.parse(host.terminalConfig as string);
@@ -68,6 +68,8 @@ export interface HostStatusDeps {
userId?: string;
hostIds?: number[];
}) => Promise<StatusTarget[]>;
/** Hosts other users shared with this one, directly or through a role. */
loadSharedHostIds?: (userId: string) => Promise<number[]>;
ping: (host: string, port: number) => Promise<boolean>;
pingThroughJumpHosts: (
target: StatusTarget,
@@ -110,6 +112,17 @@ const defaultDeps: HostStatusDeps = {
(
await createCurrentHostResolutionRepository().listStatusTargets(filter)
).map(toStatusTarget),
loadSharedHostIds: async (userId) => {
const { createCurrentRbacAccessRepository, createCurrentRoleRepository } =
await import("../../database/repositories/factory.js");
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
const entries =
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
userId,
roleIds,
);
return [...new Set(entries.map((entry) => entry.hostId))];
},
ping: (host, port) => tcpPing(host, port, 5000),
pingThroughJumpHosts: async (target, port) => {
const { createJumpHostChain } = await import("../jump-host-chain.js");
@@ -266,6 +279,35 @@ export class HostStatusService {
for (const target of targets) {
if (!this.polled.has(target.id)) this.startPolling(target);
}
await this.startShared(userId, null);
}
/**
* Hosts shared with the user are checked as their owners, even when no
* owner has asked for statuses since the server started.
*/
private async startShared(
userId: string,
allowed: Set<number> | null,
): Promise<void> {
if (!this.deps.loadSharedHostIds) return;
try {
const shared = (await this.deps.loadSharedHostIds(userId)).filter(
(hostId) =>
!this.polled.has(hostId) && (allowed === null || allowed.has(hostId)),
);
if (shared.length === 0) return;
const targets = await this.deps.loadTargets({ hostIds: shared });
for (const target of targets) {
if (!this.polled.has(target.id)) this.startPolling(target);
}
} catch (error) {
sshLogger.warn("Could not start status checks for shared hosts", {
operation: "host_status_shared",
userId,
error: error instanceof Error ? error.message : String(error),
});
}
}
private async reconcile(userId: string, allowed: Set<number>): Promise<void> {
@@ -278,6 +320,7 @@ export class HostStatusService {
}
if (!this.polled.has(target.id)) this.startPolling(target);
}
await this.startShared(userId, allowed);
}
private async reload(hostId: number): Promise<void> {