mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
2375 lines
71 KiB
TypeScript
2375 lines
71 KiB
TypeScript
import express, { type Router } from "express";
|
|
import cookieParser from "cookie-parser";
|
|
import { Client as SSHClient } from "ssh2";
|
|
import type {
|
|
PluginContext,
|
|
PluginSshHost,
|
|
PluginSshPromptChannel,
|
|
} from "@termix/plugin-sdk/backend";
|
|
import { getErrorMessage } from "./error-message.js";
|
|
import { setPluginSsh, pluginSsh } from "./ssh.js";
|
|
import { setPluginCtx } from "./plugin-ctx.js";
|
|
import {
|
|
flushTransferProfiles,
|
|
setTransferProfileStore,
|
|
} from "./transfer-tuning.js";
|
|
|
|
const TRANSFER_PROFILES_KEY = "transfer-profiles";
|
|
import {
|
|
createConnectionLog,
|
|
createFileLogger,
|
|
hostAddressMismatch,
|
|
HostAddressMismatchError,
|
|
HostNotOnThisServerError,
|
|
resolveServerHostId,
|
|
type ConnectionStage,
|
|
type FileLogger,
|
|
type LogEntry,
|
|
} from "./host-identity.js";
|
|
import {
|
|
ChannelOpenSerializer,
|
|
execChannel,
|
|
getSessionSftp,
|
|
type PendingTOTPSession,
|
|
type SSHSession,
|
|
} from "./session.js";
|
|
import { registerFileListingRoutes } from "./list-routes.js";
|
|
import { registerFileContentRoutes } from "./content-routes.js";
|
|
import { registerFileOperationRoutes } from "./operation-routes.js";
|
|
import { registerFileDownloadRoutes } from "./download-routes.js";
|
|
import { registerFileActionRoutes } from "./action-routes.js";
|
|
import { registerBookmarkRoutes } from "./bookmark-routes.js";
|
|
import {
|
|
startHostTransfer,
|
|
getTransferStatus,
|
|
listActiveTransfers,
|
|
probeHungStreamTransfers,
|
|
requestTransferCancel,
|
|
cleanupCancelledTransfer,
|
|
retryHostTransfer,
|
|
previewArchiveTransferMethod,
|
|
type HostTransferDeps,
|
|
} from "./transfer-engine.js";
|
|
import { createFilesService } from "./service.js";
|
|
import { tables } from "./tables.js";
|
|
|
|
let fileLogger: FileLogger = createFileLogger(console);
|
|
|
|
// Shape of a resolved host as this route reads it; core fills more fields
|
|
// than PluginSshHost names.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
type LooseHost = PluginSshHost & Record<string, any>;
|
|
|
|
// Core's jump chain failure, matched by name so no core class is imported.
|
|
function isJumpHostChainError(error: unknown): boolean {
|
|
return error instanceof Error && error.name === "JumpHostChainError";
|
|
}
|
|
|
|
/** Cosmetic only: whether the proxy step is worth a connection-log line. */
|
|
function hasSocks5Config(host: PluginSshHost): boolean {
|
|
const chain = Array.isArray(host.socks5ProxyChain)
|
|
? host.socks5ProxyChain
|
|
: [];
|
|
return !!host.useSocks5 && (!!host.socks5Host || chain.length > 0);
|
|
}
|
|
|
|
/**
|
|
* The host id came from whichever database the client is displaying. If this
|
|
* server has a different machine under that id, refuse rather than browse,
|
|
* edit or delete files on the wrong host.
|
|
*/
|
|
function assertResolvedHost(
|
|
clientIp: unknown,
|
|
hostSyncId: string | null | undefined,
|
|
resolvedHost: { ip?: string } | null | undefined,
|
|
hostId: number,
|
|
userId: string,
|
|
): void {
|
|
if (hostSyncId) {
|
|
if (resolvedHost) return;
|
|
fileLogger.error(
|
|
"Refusing SFTP connection: host is not known to this server",
|
|
undefined,
|
|
{ operation: "file_manager_host_sync_id_unknown", hostId, userId },
|
|
);
|
|
throw new HostNotOnThisServerError();
|
|
}
|
|
|
|
if (!hostAddressMismatch(clientIp, resolvedHost?.ip)) return;
|
|
|
|
fileLogger.error(
|
|
"Refusing SFTP connection: host id resolves to a different address here",
|
|
undefined,
|
|
{
|
|
operation: "file_manager_host_id_mismatch",
|
|
hostId,
|
|
userId,
|
|
clientIp,
|
|
resolvedIp: resolvedHost?.ip,
|
|
},
|
|
);
|
|
throw new HostAddressMismatchError();
|
|
}
|
|
|
|
export async function activate(ctx: PluginContext) {
|
|
fileLogger = createFileLogger(ctx.log);
|
|
setPluginSsh(ctx.ssh);
|
|
setPluginCtx(ctx);
|
|
ctx.disposables.add(() => setPluginSsh(null));
|
|
ctx.disposables.add(() => setPluginCtx(null));
|
|
setTransferProfileStore({
|
|
read: () => ctx.kv.get(TRANSFER_PROFILES_KEY),
|
|
write: (value) => ctx.kv.set(TRANSFER_PROFILES_KEY, value),
|
|
});
|
|
ctx.disposables.add(async () => {
|
|
await flushTransferProfiles();
|
|
setTransferProfileStore(null);
|
|
});
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
// ctx.db.define hands back an untyped table object; typed at this module's
|
|
// edge, the same way plugins/workspaces/src/backend/repository.ts does.
|
|
const table: any = await ctx.db.define(tables[0]);
|
|
const pinnedTable: any = await ctx.db.define(tables[1]);
|
|
const shortcutsTable: any = await ctx.db.define(tables[2]);
|
|
const transferRecentTable: any = await ctx.db.define(tables[3]);
|
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
|
|
// The user row survives a password-reset data wipe (the DEK could not be
|
|
// recovered, not that the account is gone), so a refUser() cascade never
|
|
// fires for it. Core asks every plugin holding user data to drop its own
|
|
// rows on this topic instead.
|
|
ctx.events.on("user.data_wiped", (payload) => {
|
|
const userId = (payload as { userId?: string } | undefined)?.userId;
|
|
if (!userId) return;
|
|
void (async () => {
|
|
try {
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
const drizzle = await ctx.db.client<any>();
|
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
const { eq } = await import("drizzle-orm");
|
|
await drizzle.delete(table).where(eq(table.userId, userId));
|
|
await drizzle.delete(pinnedTable).where(eq(pinnedTable.userId, userId));
|
|
await drizzle
|
|
.delete(shortcutsTable)
|
|
.where(eq(shortcutsTable.userId, userId));
|
|
await drizzle
|
|
.delete(transferRecentTable)
|
|
.where(eq(transferRecentTable.userId, userId));
|
|
await ctx.db.persist();
|
|
} catch (error) {
|
|
ctx.log.error(
|
|
"Failed to wipe file manager data for user",
|
|
error as Error,
|
|
);
|
|
}
|
|
})();
|
|
});
|
|
|
|
const sshSessions: Record<string, SSHSession> = {};
|
|
const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
|
|
const activeListRequests: Record<string, boolean> = {};
|
|
|
|
function cleanupSession(sessionId: string) {
|
|
const session = sshSessions[sessionId];
|
|
if (!session) return;
|
|
if (session.activeOperations > 0) {
|
|
fileLogger.warn(
|
|
`Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`,
|
|
{
|
|
operation: "cleanup_deferred",
|
|
sessionId,
|
|
activeOperations: session.activeOperations,
|
|
},
|
|
);
|
|
scheduleSessionCleanup(sessionId);
|
|
return;
|
|
}
|
|
try {
|
|
if (session.sftp) {
|
|
session.sftp.end();
|
|
session.sftp = undefined;
|
|
}
|
|
} catch {
|
|
// expected
|
|
}
|
|
try {
|
|
session.client.end();
|
|
} catch {
|
|
// expected
|
|
}
|
|
clearTimeout(session.timeout);
|
|
delete sshSessions[sessionId];
|
|
}
|
|
|
|
function scheduleSessionCleanup(sessionId: string) {
|
|
const session = sshSessions[sessionId];
|
|
if (!session) return;
|
|
if (session.timeout) clearTimeout(session.timeout);
|
|
session.timeout = setTimeout(
|
|
() => cleanupSession(sessionId),
|
|
30 * 60 * 1000,
|
|
);
|
|
}
|
|
|
|
// Pending TOTP and browser sign-in parking sessions expire on their own; sweep stale
|
|
// ones so a session left mid-handshake doesn't leak an open ssh2 Client.
|
|
const pendingSweep = setInterval(() => {
|
|
const now = Date.now();
|
|
for (const sessionId of Object.keys(pendingTOTPSessions)) {
|
|
const session = pendingTOTPSessions[sessionId];
|
|
if (now - session.createdAt > 300000) {
|
|
try {
|
|
session.client.end();
|
|
} catch {
|
|
// expected
|
|
}
|
|
delete pendingTOTPSessions[sessionId];
|
|
}
|
|
}
|
|
}, 60000);
|
|
ctx.disposables.add(() => clearInterval(pendingSweep));
|
|
ctx.disposables.add(() => {
|
|
for (const sessionId of Object.keys(sshSessions)) cleanupSession(sessionId);
|
|
});
|
|
|
|
function verifySessionOwnership(
|
|
session: SSHSession,
|
|
userId: string,
|
|
): boolean {
|
|
return !session.userId || session.userId === userId;
|
|
}
|
|
|
|
function resolveBrowseHostId(
|
|
browseSessionId: string,
|
|
browseSession: SSHSession,
|
|
): number | undefined {
|
|
if (browseSession.hostId) return browseSession.hostId;
|
|
const parsed = Number.parseInt(browseSessionId, 10);
|
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
}
|
|
|
|
async function openDedicatedTransferSession(
|
|
browseSessionId: string,
|
|
dedicatedSessionId: string,
|
|
userId: string,
|
|
transferId: string,
|
|
options?: { allowBrowseDisconnected?: boolean },
|
|
): Promise<SSHSession> {
|
|
const browseSession = sshSessions[browseSessionId];
|
|
if (!options?.allowBrowseDisconnected && !browseSession?.isConnected) {
|
|
throw new Error("Browse SSH session not connected");
|
|
}
|
|
if (browseSession && !verifySessionOwnership(browseSession, userId)) {
|
|
throw new Error("Session access denied");
|
|
}
|
|
|
|
const hostId = browseSession
|
|
? resolveBrowseHostId(browseSessionId, browseSession)
|
|
: (() => {
|
|
const parsed = Number.parseInt(browseSessionId, 10);
|
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
})();
|
|
if (!hostId) {
|
|
throw new Error("Cannot open transfer connection: unknown host");
|
|
}
|
|
|
|
const host = await ctx.asUser(userId, () => ctx.ssh.resolveHost(hostId));
|
|
if (!host) {
|
|
throw new Error("Host not found for transfer connection");
|
|
}
|
|
|
|
const existingSession = sshSessions[dedicatedSessionId];
|
|
if (
|
|
existingSession?.isConnected &&
|
|
verifySessionOwnership(existingSession, userId)
|
|
) {
|
|
return existingSession;
|
|
}
|
|
|
|
fileLogger.info("Opening dedicated transfer SSH session", {
|
|
operation: "transfer_ssh_connect",
|
|
transferId,
|
|
browseSessionId,
|
|
dedicatedSessionId,
|
|
hostId,
|
|
ip: host.ip,
|
|
port: host.port,
|
|
username: host.username,
|
|
});
|
|
|
|
const connection = await ctx.ssh.connect<SSHClient>(
|
|
host as unknown as PluginSshHost,
|
|
{
|
|
purpose: "file-transfer",
|
|
profile: "stream",
|
|
timeoutMs: 60000,
|
|
overrides: { tcpKeepAliveInitialDelay: 5000 },
|
|
},
|
|
);
|
|
const client = connection.client;
|
|
|
|
const session: SSHSession = {
|
|
client,
|
|
isConnected: true,
|
|
lastActive: Date.now(),
|
|
activeOperations: 0,
|
|
channelOpener: new ChannelOpenSerializer(),
|
|
userId,
|
|
ip: host.ip,
|
|
port: host.port,
|
|
hostId: host.id,
|
|
username: host.username,
|
|
transferDedicated: true,
|
|
transferId,
|
|
browseSessionId,
|
|
};
|
|
|
|
client.on("close", () => {
|
|
fileLogger.info("Dedicated transfer SSH connection closed", {
|
|
operation: "transfer_ssh_disconnected",
|
|
transferId,
|
|
dedicatedSessionId,
|
|
browseSessionId,
|
|
hostId,
|
|
});
|
|
const existing = sshSessions[dedicatedSessionId];
|
|
if (existing) {
|
|
existing.isConnected = false;
|
|
closeDedicatedTransferSession(dedicatedSessionId);
|
|
}
|
|
});
|
|
|
|
sshSessions[dedicatedSessionId] = session;
|
|
return session;
|
|
}
|
|
|
|
function closeDedicatedTransferSession(sessionId: string): void {
|
|
const session = sshSessions[sessionId];
|
|
if (!session?.transferDedicated) return;
|
|
|
|
fileLogger.info("Closing dedicated transfer SSH session", {
|
|
operation: "transfer_ssh_close",
|
|
sessionId,
|
|
transferId: session.transferId,
|
|
browseSessionId: session.browseSessionId,
|
|
});
|
|
|
|
try {
|
|
if (session.sftp) {
|
|
session.sftp.end();
|
|
session.sftp = undefined;
|
|
}
|
|
} catch {
|
|
// expected
|
|
}
|
|
session.sftpPending = undefined;
|
|
|
|
try {
|
|
session.client.end();
|
|
} catch {
|
|
// expected
|
|
}
|
|
|
|
clearTimeout(session.timeout);
|
|
delete sshSessions[sessionId];
|
|
}
|
|
|
|
const app = express();
|
|
app.use(ctx.rbac.require("use") as never);
|
|
app.use(cookieParser());
|
|
// uploadFileChunk streams its octet-stream body straight into SFTP; letting
|
|
// the JSON/urlencoded parsers run is harmless (they ignore a body they do
|
|
// not claim), but the raw parser only applies to that one route so a chunk
|
|
// isn't buffered whole in memory first.
|
|
const rawBodyParser = express.raw({
|
|
limit: "5gb",
|
|
type: "application/octet-stream",
|
|
});
|
|
app.use((req, res, next) => {
|
|
if (req.path === "/uploadFileChunk") return rawBodyParser(req, res, next);
|
|
next();
|
|
});
|
|
app.use((_req, res, next) => {
|
|
res.setHeader("Cache-Control", "no-store");
|
|
next();
|
|
});
|
|
|
|
app.use((req, res, next) => {
|
|
if (
|
|
req.path === "/connect" ||
|
|
req.path === "/connect-totp" ||
|
|
req.path === "/connect-browser-sign-in"
|
|
) {
|
|
return next();
|
|
}
|
|
const sessionId = (req.query.sessionId as string) || req.body?.sessionId;
|
|
if (!sessionId) return next();
|
|
const session = sshSessions[sessionId];
|
|
if (!session) return next();
|
|
const userId = ctx.currentActor();
|
|
if (!userId || !verifySessionOwnership(session, userId)) {
|
|
return res.status(403).json({ error: "Session access denied" });
|
|
}
|
|
next();
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/file-manager/connect:
|
|
* post:
|
|
* summary: Connect to SSH for file management
|
|
* description: Establishes an SSH/SFTP connection for file manager operations. Supports password, key-based, and keyboard-interactive authentication, as well as jump hosts and SOCKS5 proxies.
|
|
* tags:
|
|
* - File Manager
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - sessionId
|
|
* - ip
|
|
* - port
|
|
* - username
|
|
* properties:
|
|
* sessionId:
|
|
* type: string
|
|
* hostId:
|
|
* type: number
|
|
* ip:
|
|
* type: string
|
|
* port:
|
|
* type: number
|
|
* username:
|
|
* type: string
|
|
* password:
|
|
* type: string
|
|
* sshKey:
|
|
* type: string
|
|
* keyPassword:
|
|
* type: string
|
|
* authType:
|
|
* type: string
|
|
* enum: [password, key, none]
|
|
* credentialId:
|
|
* type: number
|
|
* jumpHosts:
|
|
* type: array
|
|
* items:
|
|
* type: object
|
|
* properties:
|
|
* hostId:
|
|
* type: number
|
|
* useSocks5:
|
|
* type: boolean
|
|
* socks5Host:
|
|
* type: string
|
|
* socks5Port:
|
|
* type: number
|
|
* socks5Username:
|
|
* type: string
|
|
* socks5Password:
|
|
* type: string
|
|
* socks5ProxyChain:
|
|
* type: array
|
|
* responses:
|
|
* 200:
|
|
* description: SSH connection established, or requires TOTP or a browser sign-in.
|
|
* 400:
|
|
* description: Missing required parameters or invalid SSH key format.
|
|
* 401:
|
|
* description: Authentication required.
|
|
* 500:
|
|
* description: SSH connection failed.
|
|
*/
|
|
app.post("/connect", async (req, res) => {
|
|
const {
|
|
sessionId,
|
|
hostId,
|
|
syncId: hostSyncId,
|
|
ip,
|
|
port,
|
|
username,
|
|
password,
|
|
sshKey,
|
|
keyPassword,
|
|
authType,
|
|
credentialId,
|
|
jumpHosts,
|
|
useSocks5,
|
|
socks5Host,
|
|
socks5Port,
|
|
socks5Username,
|
|
socks5Password,
|
|
socks5ProxyChain,
|
|
} = req.body;
|
|
|
|
const userId = ctx.currentActor();
|
|
const connectionLogs: Array<Omit<LogEntry, "id" | "timestamp">> = [];
|
|
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"info",
|
|
"sftp_connecting",
|
|
`Initiating SFTP connection to ${username}@${ip}:${port}`,
|
|
),
|
|
);
|
|
|
|
if (!userId) {
|
|
fileLogger.error("SSH connection rejected: no authenticated user", {
|
|
operation: "file_connect_auth",
|
|
sessionId,
|
|
});
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"error",
|
|
"sftp_auth",
|
|
"Authentication required - no user session",
|
|
),
|
|
);
|
|
return res
|
|
.status(401)
|
|
.json({ error: "Authentication required", connectionLogs });
|
|
}
|
|
|
|
if (!sessionId || !ip || !username || !port) {
|
|
fileLogger.warn("Missing SSH connection parameters for file manager", {
|
|
operation: "file_connect",
|
|
sessionId,
|
|
hasIp: !!ip,
|
|
hasUsername: !!username,
|
|
hasPort: !!port,
|
|
});
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"error",
|
|
"sftp_connecting",
|
|
"Missing required connection parameters",
|
|
),
|
|
);
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Missing SSH connection parameters", connectionLogs });
|
|
}
|
|
|
|
if (sshSessions[sessionId]?.isConnected) {
|
|
cleanupSession(sessionId);
|
|
}
|
|
|
|
if (pendingTOTPSessions[sessionId]) {
|
|
try {
|
|
pendingTOTPSessions[sessionId].client.end();
|
|
} catch {
|
|
// expected
|
|
}
|
|
delete pendingTOTPSessions[sessionId];
|
|
}
|
|
|
|
const client = new SSHClient();
|
|
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"info",
|
|
"sftp_auth",
|
|
"Resolving authentication credentials",
|
|
),
|
|
);
|
|
|
|
let resolvedCredentials = {
|
|
password,
|
|
sshKey,
|
|
keyPassword,
|
|
authType,
|
|
sudoPassword: undefined as string | undefined,
|
|
certPublicKey: undefined as string | undefined,
|
|
};
|
|
let resolvedIp = ip;
|
|
let resolvedPort = port;
|
|
let resolvedUsername = username;
|
|
let resolvedSshOptions: PluginSshHost["sshOptions"];
|
|
let resolvedJumpHosts = jumpHosts;
|
|
let resolvedScpLegacy = false;
|
|
let resolvedUseSocks5 = useSocks5;
|
|
let resolvedSocks5Host = socks5Host;
|
|
let resolvedSocks5Port = socks5Port;
|
|
let resolvedSocks5Username = socks5Username;
|
|
let resolvedSocks5Password = socks5Password;
|
|
let resolvedSocks5ProxyChain = socks5ProxyChain;
|
|
|
|
const resolveFrom = async () => {
|
|
const resolvedHost = (await ctx.ssh.resolveHost(hostId, {
|
|
syncId: hostSyncId,
|
|
})) as LooseHost | null;
|
|
assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId);
|
|
if (!resolvedHost) return;
|
|
resolvedIp = resolvedHost.ip;
|
|
resolvedPort = resolvedHost.port;
|
|
resolvedUsername = resolvedHost.username;
|
|
resolvedCredentials = {
|
|
password: resolvedHost.password,
|
|
sshKey: resolvedHost.key,
|
|
keyPassword: keyPassword || resolvedHost.keyPassword,
|
|
authType: resolvedHost.authType,
|
|
sudoPassword: resolvedHost.sudoPassword as string | undefined,
|
|
certPublicKey: (resolvedHost as { certPublicKey?: string })
|
|
.certPublicKey,
|
|
};
|
|
resolvedSshOptions =
|
|
resolvedHost.sshOptions as PluginSshHost["sshOptions"];
|
|
resolvedScpLegacy =
|
|
(await ctx.settings.getHost<boolean>(
|
|
Number(resolvedHost.id),
|
|
"scpLegacy",
|
|
)) === true;
|
|
if (resolvedHost.useSocks5) {
|
|
resolvedUseSocks5 = resolvedHost.useSocks5;
|
|
resolvedSocks5Host = resolvedHost.socks5Host;
|
|
resolvedSocks5Port = resolvedHost.socks5Port;
|
|
resolvedSocks5Username = resolvedHost.socks5Username;
|
|
resolvedSocks5Password = resolvedHost.socks5Password;
|
|
resolvedSocks5ProxyChain = resolvedHost.socks5ProxyChain;
|
|
}
|
|
if (
|
|
(!resolvedJumpHosts || resolvedJumpHosts.length === 0) &&
|
|
resolvedHost.jumpHosts &&
|
|
resolvedHost.jumpHosts.length > 0
|
|
) {
|
|
resolvedJumpHosts = resolvedHost.jumpHosts;
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"info",
|
|
"jump",
|
|
`Loaded ${resolvedHost.jumpHosts.length} jump host(s) from server-side host data`,
|
|
),
|
|
);
|
|
}
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"info",
|
|
"sftp_auth",
|
|
"Credentials resolved from server-side host data",
|
|
),
|
|
);
|
|
};
|
|
|
|
if (hostId && userId && !password && !sshKey) {
|
|
try {
|
|
await resolveFrom();
|
|
} catch (error) {
|
|
if (
|
|
error instanceof HostAddressMismatchError ||
|
|
error instanceof HostNotOnThisServerError
|
|
)
|
|
throw error;
|
|
fileLogger.warn(`Failed to resolve host credentials for ${hostId}`, {
|
|
operation: "ssh_credentials",
|
|
hostId,
|
|
error: getErrorMessage(error),
|
|
});
|
|
}
|
|
} else if (credentialId && hostId && userId) {
|
|
try {
|
|
await resolveFrom();
|
|
} catch (error) {
|
|
if (
|
|
error instanceof HostAddressMismatchError ||
|
|
error instanceof HostNotOnThisServerError
|
|
)
|
|
throw error;
|
|
fileLogger.warn(`Failed to resolve credentials for host ${hostId}`, {
|
|
operation: "ssh_credentials",
|
|
hostId,
|
|
credentialId,
|
|
error: getErrorMessage(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
let serverHostId = hostId;
|
|
if (hostSyncId && userId) {
|
|
const resolvedHost = await ctx.ssh.resolveHost(hostId, {
|
|
syncId: hostSyncId,
|
|
});
|
|
assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId);
|
|
serverHostId = resolveServerHostId(hostId, resolvedHost) ?? hostId;
|
|
}
|
|
|
|
const effectiveAuthType =
|
|
resolvedCredentials.authType ||
|
|
(resolvedCredentials.sshKey
|
|
? "key"
|
|
: resolvedCredentials.password
|
|
? "password"
|
|
: undefined);
|
|
if (!effectiveAuthType) {
|
|
fileLogger.warn(
|
|
"No valid authentication method provided for file manager",
|
|
{ operation: "file_connect", sessionId, hostId },
|
|
);
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"error",
|
|
"sftp_auth",
|
|
"No valid authentication method provided",
|
|
),
|
|
);
|
|
return res.status(400).json({
|
|
error: "Either password or SSH key must be provided",
|
|
connectionLogs,
|
|
});
|
|
}
|
|
|
|
const connectTarget = {
|
|
id: serverHostId,
|
|
ip: resolvedIp,
|
|
port: resolvedPort,
|
|
username: resolvedUsername,
|
|
userId,
|
|
authType: effectiveAuthType,
|
|
password: resolvedCredentials.password,
|
|
key: resolvedCredentials.sshKey,
|
|
keyPassword: resolvedCredentials.keyPassword,
|
|
certPublicKey: resolvedCredentials.certPublicKey,
|
|
sshOptions: resolvedSshOptions ?? null,
|
|
jumpHosts: resolvedJumpHosts,
|
|
useSocks5: resolvedUseSocks5,
|
|
socks5Host: resolvedSocks5Host,
|
|
socks5Port: resolvedSocks5Port,
|
|
socks5Username: resolvedSocks5Username,
|
|
socks5Password: resolvedSocks5Password,
|
|
socks5ProxyChain: resolvedSocks5ProxyChain,
|
|
} as PluginSshHost;
|
|
|
|
const ssh = pluginSsh();
|
|
const prepared = await ssh.prepare(connectTarget, {
|
|
purpose: "file-manager",
|
|
profile: "session",
|
|
client,
|
|
serverHostId,
|
|
log: (level, message) =>
|
|
connectionLogs.push(createConnectionLog(level, "sftp_auth", message)),
|
|
});
|
|
const config = prepared.config as Record<string, unknown>;
|
|
|
|
if (prepared.outcome.status !== "ready") {
|
|
const outcome = prepared.outcome;
|
|
if (
|
|
outcome.status === "error" &&
|
|
outcome.code === "passphrase-required"
|
|
) {
|
|
return res.json({ status: "passphrase_required", connectionLogs });
|
|
}
|
|
fileLogger.warn("File manager SSH auth could not be prepared", {
|
|
operation: "file_connect",
|
|
sessionId,
|
|
hostId,
|
|
authType: effectiveAuthType,
|
|
error: outcome.message,
|
|
});
|
|
connectionLogs.push(
|
|
createConnectionLog("error", "sftp_auth", outcome.message),
|
|
);
|
|
if (outcome.status === "interaction-required") {
|
|
return res.status(401).json({
|
|
error: outcome.message,
|
|
requiresAuthInteraction: outcome.interaction,
|
|
...(outcome.flag ? { [outcome.flag]: true } : {}),
|
|
connectionLogs,
|
|
});
|
|
}
|
|
return res.status(400).json({
|
|
error:
|
|
outcome.status === "error" && outcome.code === "invalid-key"
|
|
? "Invalid SSH key format"
|
|
: outcome.message,
|
|
connectionLogs,
|
|
});
|
|
}
|
|
|
|
const credentialless = !ssh.requiresSecret(effectiveAuthType);
|
|
|
|
let responseSent = false;
|
|
|
|
connectionLogs.push(
|
|
createConnectionLog("info", "dns", `Resolving DNS for ${ip}`),
|
|
);
|
|
connectionLogs.push(
|
|
createConnectionLog("info", "tcp", `Connecting to ${ip}:${port}`),
|
|
);
|
|
connectionLogs.push(
|
|
createConnectionLog("info", "handshake", "Initiating SSH handshake"),
|
|
);
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"info",
|
|
"sftp_connecting",
|
|
"Establishing SSH connection...",
|
|
),
|
|
);
|
|
|
|
client.on("ready", () => {
|
|
if (responseSent) return;
|
|
responseSent = true;
|
|
fileLogger.info("File manager SSH connection established", {
|
|
operation: "file_ssh_connected",
|
|
sessionId,
|
|
userId,
|
|
hostId: serverHostId,
|
|
ip,
|
|
port,
|
|
username,
|
|
});
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"success",
|
|
"connected",
|
|
"SSH connection established successfully",
|
|
),
|
|
);
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"success",
|
|
"sftp_connected",
|
|
"SFTP session established successfully",
|
|
),
|
|
);
|
|
sshSessions[sessionId] = {
|
|
client,
|
|
isConnected: true,
|
|
lastActive: Date.now(),
|
|
activeOperations: 0,
|
|
channelOpener: new ChannelOpenSerializer(),
|
|
userId,
|
|
ip,
|
|
port,
|
|
hostId: serverHostId,
|
|
username,
|
|
sudoPassword: resolvedCredentials.sudoPassword,
|
|
scpLegacy: resolvedScpLegacy,
|
|
};
|
|
scheduleSessionCleanup(sessionId);
|
|
|
|
if (userId) {
|
|
void (async () => {
|
|
await ctx.audit.record({
|
|
request: req,
|
|
action: "file_manager_connect",
|
|
resourceType: "host",
|
|
resourceId: serverHostId ? String(serverHostId) : undefined,
|
|
resourceName: `${username}@${ip}:${port}`,
|
|
success: true,
|
|
});
|
|
})();
|
|
}
|
|
|
|
res.json({
|
|
status: "success",
|
|
message: "SSH connection established",
|
|
connectionLogs,
|
|
});
|
|
|
|
if (hostId && userId) {
|
|
void logFileManagerActivity(hostId, userId, username, ip, port);
|
|
}
|
|
});
|
|
|
|
client.on("error", (err) => {
|
|
if (responseSent) return;
|
|
responseSent = true;
|
|
fileLogger.error("SSH connection failed for file manager", {
|
|
operation: "file_connect",
|
|
sessionId,
|
|
hostId,
|
|
ip,
|
|
port,
|
|
username,
|
|
error: err.message,
|
|
});
|
|
|
|
let errorStage: ConnectionStage;
|
|
if (
|
|
err.message.includes("ENOTFOUND") ||
|
|
err.message.includes("getaddrinfo")
|
|
) {
|
|
errorStage = "dns";
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"error",
|
|
errorStage,
|
|
`DNS resolution failed: ${err.message}`,
|
|
),
|
|
);
|
|
} else if (
|
|
err.message.includes("ECONNREFUSED") ||
|
|
err.message.includes("ETIMEDOUT")
|
|
) {
|
|
errorStage = "tcp";
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"error",
|
|
errorStage,
|
|
`TCP connection failed: ${err.message}`,
|
|
),
|
|
);
|
|
} else if (
|
|
err.message.includes("handshake") ||
|
|
err.message.includes("key exchange")
|
|
) {
|
|
errorStage = "handshake";
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"error",
|
|
errorStage,
|
|
`SSH handshake failed: ${err.message}`,
|
|
),
|
|
);
|
|
} else if (
|
|
err.message.includes("authentication") ||
|
|
err.message.includes("Authentication")
|
|
) {
|
|
errorStage = "auth";
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"error",
|
|
errorStage,
|
|
`Authentication failed: ${err.message}`,
|
|
),
|
|
);
|
|
} else if (err.message.includes("verification failed")) {
|
|
errorStage = "handshake";
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"error",
|
|
errorStage,
|
|
"SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.",
|
|
),
|
|
);
|
|
} else {
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"error",
|
|
"error",
|
|
`SSH connection failed: ${err.message}`,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (
|
|
err.message.includes("Cannot parse privateKey") &&
|
|
err.message.includes("no passphrase")
|
|
) {
|
|
res.json({ status: "passphrase_required", connectionLogs });
|
|
} else if (
|
|
credentialless &&
|
|
(err.message.includes("authentication") ||
|
|
err.message.includes("All configured authentication methods failed"))
|
|
) {
|
|
res.json({
|
|
status: "auth_required",
|
|
reason: "no_keyboard",
|
|
connectionLogs,
|
|
});
|
|
} else {
|
|
res
|
|
.status(500)
|
|
.json({ status: "error", message: err.message, connectionLogs });
|
|
}
|
|
});
|
|
|
|
client.on("close", () => {
|
|
fileLogger.info("File manager SSH connection closed", {
|
|
operation: "file_ssh_disconnected",
|
|
sessionId,
|
|
userId,
|
|
hostId,
|
|
});
|
|
if (sshSessions[sessionId]) sshSessions[sessionId].isConnected = false;
|
|
cleanupSession(sessionId);
|
|
});
|
|
|
|
const parkForAnswer = (
|
|
finish: (responses: string[]) => void,
|
|
prompts: Array<{ prompt: string; echo: boolean }>,
|
|
promptIndex: number,
|
|
isBrowserSignIn = false,
|
|
) => {
|
|
pendingTOTPSessions[sessionId] = {
|
|
client,
|
|
finish,
|
|
config,
|
|
createdAt: Date.now(),
|
|
sessionId,
|
|
hostId,
|
|
ip,
|
|
port,
|
|
username,
|
|
userId,
|
|
prompts,
|
|
totpPromptIndex: promptIndex,
|
|
resolvedPassword: resolvedCredentials.password,
|
|
totpAttempts: 0,
|
|
...(isBrowserSignIn ? { isBrowserSignIn: true } : {}),
|
|
};
|
|
};
|
|
|
|
client.on(
|
|
"keyboard-interactive",
|
|
(
|
|
name: string,
|
|
instructions: string,
|
|
_instructionsLang: string,
|
|
prompts: Array<{ prompt: string; echo: boolean }>,
|
|
finish: (responses: string[]) => void,
|
|
) => {
|
|
const decision = ssh.classifyKeyboardInteractive(
|
|
{ name, instructions, prompts },
|
|
connectTarget,
|
|
);
|
|
const autoFinish = () =>
|
|
finish(ssh.autoResponses(prompts, resolvedCredentials.password));
|
|
|
|
if (decision.kind === "auto") {
|
|
finish(decision.responses);
|
|
return;
|
|
}
|
|
|
|
if (decision.kind === "browser") {
|
|
if (responseSent) return;
|
|
responseSent = true;
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"info",
|
|
"sftp_auth",
|
|
`${decision.label} sign-in required`,
|
|
{ url: decision.url },
|
|
),
|
|
);
|
|
parkForAnswer(finish, prompts, -1, true);
|
|
res.json({
|
|
requires_browser_sign_in: true,
|
|
sessionId,
|
|
label: decision.label,
|
|
url: decision.url,
|
|
code: decision.code,
|
|
connectionLogs,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const promptIndex = decision.promptIndex;
|
|
const promptText = prompts[promptIndex].prompt;
|
|
const isPasswordPrompt = /password/i.test(promptText);
|
|
|
|
if (
|
|
decision.kind === "input" &&
|
|
!(decision as { isPush?: boolean }).isPush &&
|
|
!isPasswordPrompt
|
|
) {
|
|
autoFinish();
|
|
return;
|
|
}
|
|
|
|
if (decision.kind === "input" && isPasswordPrompt && credentialless) {
|
|
if (responseSent) return;
|
|
responseSent = true;
|
|
client.end();
|
|
res.json({ status: "auth_required", reason: "no_keyboard" });
|
|
return;
|
|
}
|
|
|
|
if (responseSent || pendingTOTPSessions[sessionId]) {
|
|
autoFinish();
|
|
return;
|
|
}
|
|
responseSent = true;
|
|
parkForAnswer(finish, prompts, promptIndex);
|
|
|
|
if (decision.kind === "input" && isPasswordPrompt) {
|
|
res.json({
|
|
requires_totp: true,
|
|
sessionId,
|
|
prompt: promptText,
|
|
isPassword: true,
|
|
});
|
|
return;
|
|
}
|
|
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"info",
|
|
"sftp_auth",
|
|
"TOTP verification required",
|
|
{
|
|
prompt: promptText,
|
|
},
|
|
),
|
|
);
|
|
res.json({
|
|
requires_totp: true,
|
|
sessionId,
|
|
prompt: promptText,
|
|
connectionLogs,
|
|
});
|
|
},
|
|
);
|
|
|
|
const jumpHostCount: number = Array.isArray(resolvedJumpHosts)
|
|
? resolvedJumpHosts.length
|
|
: 0;
|
|
|
|
if (jumpHostCount > 0) {
|
|
if (hasSocks5Config(connectTarget)) {
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"info",
|
|
"proxy",
|
|
"Connecting via proxy + jump hosts",
|
|
),
|
|
);
|
|
}
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"info",
|
|
"jump",
|
|
`Connecting via ${jumpHostCount} jump host(s)`,
|
|
),
|
|
);
|
|
} else if (hasSocks5Config(connectTarget)) {
|
|
connectionLogs.push(
|
|
createConnectionLog("info", "proxy", "Connecting via proxy", {
|
|
proxyHost: resolvedSocks5Host,
|
|
proxyPort: resolvedSocks5Port || 1080,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// A jump host asking for TOTP answers through /connect-totp like the host.
|
|
const jumpHostPrompt: PluginSshPromptChannel = {
|
|
ask: (request) =>
|
|
new Promise((resolve) => {
|
|
if (
|
|
(request.kind !== "totp" && request.kind !== "input") ||
|
|
responseSent ||
|
|
pendingTOTPSessions[sessionId]
|
|
) {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
responseSent = true;
|
|
parkForAnswer(
|
|
(answers) => resolve(answers[0] ?? null),
|
|
[
|
|
{
|
|
prompt: request.prompt,
|
|
echo: request.kind === "input" ? request.echo : false,
|
|
},
|
|
],
|
|
0,
|
|
);
|
|
res.json({
|
|
requires_totp: true,
|
|
sessionId,
|
|
prompt: request.prompt,
|
|
...(request.kind === "input" && !request.isPush
|
|
? { isPassword: true }
|
|
: {}),
|
|
connectionLogs,
|
|
});
|
|
}),
|
|
};
|
|
|
|
try {
|
|
const transport = await ssh.openTransport(connectTarget, config, {
|
|
prompt: jumpHostPrompt,
|
|
});
|
|
if (transport.via === "proxy") {
|
|
connectionLogs.push(
|
|
createConnectionLog(
|
|
"success",
|
|
"proxy",
|
|
"Proxy connected successfully",
|
|
),
|
|
);
|
|
}
|
|
if (transport.jumpClient) {
|
|
const jumpClient = transport.jumpClient as SSHClient;
|
|
client.on("close", () => jumpClient.end());
|
|
}
|
|
} catch (error) {
|
|
fileLogger.error("File manager transport failed", error, {
|
|
operation: "file_transport",
|
|
sessionId,
|
|
hostId,
|
|
});
|
|
// Already answered with a jump host prompt: fail the pending verify.
|
|
if (responseSent) {
|
|
client.emit("error", error);
|
|
return;
|
|
}
|
|
const stage =
|
|
isJumpHostChainError(error) || jumpHostCount > 0 ? "jump" : "proxy";
|
|
connectionLogs.push(
|
|
createConnectionLog("error", stage, getErrorMessage(error)),
|
|
);
|
|
return res.status(500).json({
|
|
error: isJumpHostChainError(error)
|
|
? `Failed to connect through jump hosts: ${error.message}`
|
|
: getErrorMessage(error),
|
|
connectionLogs,
|
|
});
|
|
}
|
|
|
|
client.connect(config);
|
|
});
|
|
|
|
async function logFileManagerActivity(
|
|
hostId: number,
|
|
userId: string,
|
|
username: string,
|
|
ip: string,
|
|
port: number,
|
|
): Promise<void> {
|
|
try {
|
|
const host = await ctx.hosts.get(hostId);
|
|
await ctx.asUser(userId, () =>
|
|
ctx.hosts.recordActivity(
|
|
hostId,
|
|
"file_manager",
|
|
host?.name || `${username}@${ip}:${port}`,
|
|
),
|
|
);
|
|
} catch (error) {
|
|
fileLogger.warn("Failed to log file manager activity", {
|
|
operation: "activity_log_error",
|
|
userId,
|
|
hostId,
|
|
error: getErrorMessage(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/file-manager/connect-totp:
|
|
* post:
|
|
* summary: Verify TOTP and complete connection
|
|
* description: Verifies the TOTP code and completes the SSH connection for file manager.
|
|
* tags:
|
|
* - File Manager
|
|
* responses:
|
|
* 200:
|
|
* description: TOTP verified, SSH connection established.
|
|
* 400:
|
|
* description: Session ID and TOTP code required.
|
|
* 401:
|
|
* description: Invalid TOTP code or authentication required.
|
|
* 404:
|
|
* description: TOTP session expired.
|
|
* 408:
|
|
* description: TOTP session timeout.
|
|
*/
|
|
app.post("/connect-totp", async (req, res) => {
|
|
const { sessionId, totpCode } = req.body;
|
|
const userId = ctx.currentActor();
|
|
|
|
if (!userId) {
|
|
fileLogger.error("TOTP verification rejected: no authenticated user", {
|
|
operation: "file_totp_auth",
|
|
sessionId,
|
|
});
|
|
return res.status(401).json({ error: "Authentication required" });
|
|
}
|
|
if (!sessionId || !totpCode) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Session ID and TOTP code required" });
|
|
}
|
|
|
|
const session = pendingTOTPSessions[sessionId];
|
|
if (!session) {
|
|
fileLogger.warn("TOTP session not found or expired", {
|
|
operation: "file_totp_verify",
|
|
sessionId,
|
|
userId,
|
|
});
|
|
return res
|
|
.status(404)
|
|
.json({ error: "TOTP session expired. Please reconnect." });
|
|
}
|
|
|
|
if (Date.now() - session.createdAt > 180000) {
|
|
delete pendingTOTPSessions[sessionId];
|
|
try {
|
|
session.client.end();
|
|
} catch {
|
|
// expected
|
|
}
|
|
return res
|
|
.status(408)
|
|
.json({ error: "TOTP session timeout. Please reconnect." });
|
|
}
|
|
|
|
const responses = (session.prompts || []).map((p, index) => {
|
|
if (index === session.totpPromptIndex) return totpCode;
|
|
if (/password/i.test(p.prompt) && session.resolvedPassword) {
|
|
return session.resolvedPassword;
|
|
}
|
|
return "";
|
|
});
|
|
|
|
let responseSent = false;
|
|
|
|
session.client.once("ready", () => {
|
|
if (responseSent) return;
|
|
responseSent = true;
|
|
clearTimeout(responseTimeout);
|
|
delete pendingTOTPSessions[sessionId];
|
|
|
|
setTimeout(() => {
|
|
sshSessions[sessionId] = {
|
|
client: session.client,
|
|
isConnected: true,
|
|
lastActive: Date.now(),
|
|
activeOperations: 0,
|
|
channelOpener: new ChannelOpenSerializer(),
|
|
userId,
|
|
ip: session.ip,
|
|
port: session.port,
|
|
hostId: session.hostId,
|
|
username: session.username,
|
|
};
|
|
scheduleSessionCleanup(sessionId);
|
|
|
|
res.json({
|
|
status: "success",
|
|
message: "TOTP verified, SSH connection established",
|
|
});
|
|
|
|
if (session.hostId && session.userId) {
|
|
void logFileManagerActivity(
|
|
session.hostId,
|
|
session.userId,
|
|
session.username ?? "",
|
|
session.ip ?? "",
|
|
session.port ?? 22,
|
|
);
|
|
}
|
|
}, 200);
|
|
});
|
|
|
|
session.client.once("error", (err) => {
|
|
if (responseSent) return;
|
|
responseSent = true;
|
|
clearTimeout(responseTimeout);
|
|
delete pendingTOTPSessions[sessionId];
|
|
fileLogger.error("TOTP verification failed", {
|
|
operation: "file_totp_verify",
|
|
sessionId,
|
|
userId,
|
|
error: err.message,
|
|
});
|
|
res.status(401).json({ status: "error", message: "Invalid TOTP code" });
|
|
});
|
|
|
|
const responseTimeout = setTimeout(() => {
|
|
if (!responseSent) {
|
|
responseSent = true;
|
|
delete pendingTOTPSessions[sessionId];
|
|
res.status(408).json({ error: "TOTP verification timeout" });
|
|
}
|
|
}, 60000);
|
|
|
|
session.finish(responses);
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/file-manager/connect-browser-sign-in:
|
|
* post:
|
|
* summary: Continue after a browser sign-in
|
|
* description: Answers the parked keyboard-interactive round once the user finished signing in in the browser (an SSH gateway's approval, for example).
|
|
* tags:
|
|
* - File Manager
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - sessionId
|
|
* properties:
|
|
* sessionId:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Signed in, SSH connection established.
|
|
* 401:
|
|
* description: Authentication failed or unauthorized.
|
|
* 404:
|
|
* description: Sign-in session expired.
|
|
* 408:
|
|
* description: Sign-in session timeout.
|
|
*/
|
|
app.post("/connect-browser-sign-in", async (req, res) => {
|
|
const { sessionId } = req.body;
|
|
const userId = ctx.currentActor();
|
|
|
|
if (!userId) {
|
|
return res.status(401).json({ error: "Authentication required" });
|
|
}
|
|
if (!sessionId) {
|
|
return res.status(400).json({ error: "Session ID required" });
|
|
}
|
|
|
|
const session = pendingTOTPSessions[sessionId];
|
|
if (!session) {
|
|
return res
|
|
.status(404)
|
|
.json({ error: "Sign-in session expired. Please reconnect." });
|
|
}
|
|
if (!session.isBrowserSignIn) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "Session is not waiting for a browser sign-in" });
|
|
}
|
|
if (Date.now() - session.createdAt > 300000) {
|
|
delete pendingTOTPSessions[sessionId];
|
|
try {
|
|
session.client.end();
|
|
} catch {
|
|
// expected
|
|
}
|
|
return res
|
|
.status(408)
|
|
.json({ error: "Sign-in session timeout. Please reconnect." });
|
|
}
|
|
|
|
let responseSent = false;
|
|
const responseTimeout = setTimeout(() => {
|
|
if (!responseSent) {
|
|
responseSent = true;
|
|
delete pendingTOTPSessions[sessionId];
|
|
res.status(408).json({ error: "Sign-in verification timeout" });
|
|
}
|
|
}, 60000);
|
|
|
|
session.client.once("ready", () => {
|
|
if (responseSent) return;
|
|
responseSent = true;
|
|
clearTimeout(responseTimeout);
|
|
delete pendingTOTPSessions[sessionId];
|
|
|
|
setTimeout(() => {
|
|
sshSessions[sessionId] = {
|
|
client: session.client,
|
|
isConnected: true,
|
|
lastActive: Date.now(),
|
|
activeOperations: 0,
|
|
channelOpener: new ChannelOpenSerializer(),
|
|
userId,
|
|
ip: session.ip,
|
|
port: session.port,
|
|
hostId: session.hostId,
|
|
username: session.username,
|
|
};
|
|
scheduleSessionCleanup(sessionId);
|
|
|
|
res.json({
|
|
status: "success",
|
|
message: "Signed in, SSH connection established",
|
|
});
|
|
|
|
if (session.hostId && session.userId) {
|
|
void logFileManagerActivity(
|
|
session.hostId,
|
|
session.userId,
|
|
session.username ?? "",
|
|
session.ip ?? "",
|
|
session.port ?? 22,
|
|
);
|
|
}
|
|
}, 200);
|
|
});
|
|
|
|
session.client.once("error", () => {
|
|
if (responseSent) return;
|
|
responseSent = true;
|
|
clearTimeout(responseTimeout);
|
|
delete pendingTOTPSessions[sessionId];
|
|
res.status(401).json({ status: "error", message: "Sign-in failed" });
|
|
});
|
|
|
|
session.finish([""]);
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/file-manager/disconnect:
|
|
* post:
|
|
* summary: Disconnect from SSH
|
|
* tags:
|
|
* - File Manager
|
|
* responses:
|
|
* 200:
|
|
* description: SSH connection disconnected.
|
|
*/
|
|
app.post("/disconnect", (req, res) => {
|
|
const { sessionId } = req.body;
|
|
const userId = ctx.currentActor();
|
|
const session = sshSessions[sessionId];
|
|
if (session && (!userId || !verifySessionOwnership(session, userId))) {
|
|
return res.status(403).json({ error: "Session access denied" });
|
|
}
|
|
cleanupSession(sessionId);
|
|
res.json({ status: "success", message: "SSH connection disconnected" });
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/file-manager/sudo-password:
|
|
* post:
|
|
* summary: Set sudo password for session
|
|
* tags:
|
|
* - File Manager
|
|
* responses:
|
|
* 200:
|
|
* description: Sudo password set successfully.
|
|
* 400:
|
|
* description: Invalid session.
|
|
*/
|
|
app.post("/sudo-password", (req, res) => {
|
|
const { sessionId, password } = req.body;
|
|
const userId = ctx.currentActor();
|
|
const session = sshSessions[sessionId];
|
|
if (!session || !session.isConnected) {
|
|
return res.status(400).json({ error: "Invalid or disconnected session" });
|
|
}
|
|
if (!userId || !verifySessionOwnership(session, userId)) {
|
|
return res.status(403).json({ error: "Session access denied" });
|
|
}
|
|
session.sudoPassword = password;
|
|
session.lastActive = Date.now();
|
|
res.json({ status: "success", message: "Sudo password set" });
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/file-manager/status:
|
|
* get:
|
|
* summary: Get SSH connection status
|
|
* tags:
|
|
* - File Manager
|
|
* parameters:
|
|
* - in: query
|
|
* name: sessionId
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: SSH connection status.
|
|
*/
|
|
app.get("/status", (req, res) => {
|
|
const sessionId = req.query.sessionId as string;
|
|
const userId = ctx.currentActor();
|
|
const session = sshSessions[sessionId];
|
|
if (session && (!userId || !verifySessionOwnership(session, userId))) {
|
|
return res.status(403).json({ error: "Session access denied" });
|
|
}
|
|
res.json({ status: "success", connected: !!session?.isConnected });
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/file-manager/keepalive:
|
|
* post:
|
|
* summary: Keep SSH session alive
|
|
* tags:
|
|
* - File Manager
|
|
* responses:
|
|
* 200:
|
|
* description: Session keepalive successful.
|
|
* 400:
|
|
* description: Session ID is required or session not found.
|
|
*/
|
|
app.post("/keepalive", async (req, res) => {
|
|
const { sessionId } = req.body;
|
|
const userId = ctx.currentActor();
|
|
|
|
if (!sessionId) {
|
|
return res.status(400).json({ error: "Session ID is required" });
|
|
}
|
|
const session = sshSessions[sessionId];
|
|
if (!session || !session.isConnected) {
|
|
return res.status(400).json({
|
|
error: "SSH session not found or not connected",
|
|
connected: false,
|
|
});
|
|
}
|
|
if (!userId || !verifySessionOwnership(session, userId)) {
|
|
return res.status(403).json({ error: "Session access denied" });
|
|
}
|
|
|
|
session.lastActive = Date.now();
|
|
scheduleSessionCleanup(sessionId);
|
|
|
|
if (session.sftp && !session.sftpPending) {
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
session.sftp!.stat("/", (err) => (err ? reject(err) : resolve()));
|
|
});
|
|
} catch {
|
|
session.sftp = undefined;
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
status: "success",
|
|
connected: true,
|
|
message: "Session keepalive successful",
|
|
lastActive: session.lastActive,
|
|
});
|
|
});
|
|
|
|
registerFileListingRoutes(app, {
|
|
ctx,
|
|
sshSessions,
|
|
activeListRequests,
|
|
verifySessionOwnership,
|
|
});
|
|
registerFileContentRoutes(app, { ctx, sshSessions, verifySessionOwnership });
|
|
registerFileOperationRoutes(app, {
|
|
ctx,
|
|
sshSessions,
|
|
verifySessionOwnership,
|
|
});
|
|
registerFileDownloadRoutes(app, {
|
|
ctx,
|
|
sshSessions,
|
|
scheduleSessionCleanup,
|
|
verifySessionOwnership,
|
|
});
|
|
registerFileActionRoutes(app, {
|
|
ctx,
|
|
sshSessions,
|
|
scheduleSessionCleanup,
|
|
verifySessionOwnership,
|
|
});
|
|
registerBookmarkRoutes(app, ctx, {
|
|
table,
|
|
pinnedTable,
|
|
shortcutsTable,
|
|
transferRecentTable,
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/file-manager/extractArchive:
|
|
* post:
|
|
* summary: Extract archive file
|
|
* description: Extracts an archive file (.tar, .tar.gz, .tgz, .zip, .tar.bz2, .tbz2, .tar.xz, .txz) to a specified or default location on the remote host.
|
|
* tags:
|
|
* - File Manager
|
|
* responses:
|
|
* 200:
|
|
* description: Archive extracted successfully.
|
|
* 400:
|
|
* description: Missing required parameters, SSH connection not established, or unsupported archive format.
|
|
* 500:
|
|
* description: Failed to extract archive.
|
|
*/
|
|
app.post("/extractArchive", async (req, res) => {
|
|
const { sessionId, archivePath, extractPath } = req.body;
|
|
const userId = ctx.currentActor();
|
|
|
|
if (!sessionId || !archivePath) {
|
|
return res.status(400).json({ error: "Missing required parameters" });
|
|
}
|
|
const session = sshSessions[sessionId];
|
|
if (!session || !session.isConnected) {
|
|
return res.status(400).json({ error: "SSH session not connected" });
|
|
}
|
|
if (!userId || !verifySessionOwnership(session, userId)) {
|
|
return res.status(403).json({ error: "Session access denied" });
|
|
}
|
|
|
|
session.lastActive = Date.now();
|
|
scheduleSessionCleanup(sessionId);
|
|
|
|
const fileName = archivePath.split("/").pop() || "";
|
|
const fileExt = fileName.toLowerCase();
|
|
|
|
let extractCommand: string;
|
|
const targetPath =
|
|
extractPath || archivePath.substring(0, archivePath.lastIndexOf("/"));
|
|
|
|
const escapedArchive = archivePath.replace(/'/g, "'\"'\"'");
|
|
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
|
|
const escapedDecompressed = archivePath
|
|
.replace(/\.gz$/, "")
|
|
.replace(/'/g, "'\"'\"'");
|
|
|
|
if (fileExt.endsWith(".tar.gz") || fileExt.endsWith(".tgz")) {
|
|
extractCommand = `tar -xzf '${escapedArchive}' -C '${escapedTarget}'`;
|
|
} else if (fileExt.endsWith(".tar.bz2") || fileExt.endsWith(".tbz2")) {
|
|
extractCommand = `tar -xjf '${escapedArchive}' -C '${escapedTarget}'`;
|
|
} else if (fileExt.endsWith(".tar.xz")) {
|
|
extractCommand = `tar -xJf '${escapedArchive}' -C '${escapedTarget}'`;
|
|
} else if (fileExt.endsWith(".tar")) {
|
|
extractCommand = `tar -xf '${escapedArchive}' -C '${escapedTarget}'`;
|
|
} else if (fileExt.endsWith(".zip")) {
|
|
extractCommand = `unzip -o '${escapedArchive}' -d '${escapedTarget}'`;
|
|
} else if (fileExt.endsWith(".gz") && !fileExt.endsWith(".tar.gz")) {
|
|
extractCommand = `gunzip -c '${escapedArchive}' > '${escapedDecompressed}'`;
|
|
} else if (fileExt.endsWith(".bz2") && !fileExt.endsWith(".tar.bz2")) {
|
|
extractCommand = `bunzip2 -k '${escapedArchive}'`;
|
|
} else if (fileExt.endsWith(".xz") && !fileExt.endsWith(".tar.xz")) {
|
|
extractCommand = `unxz -k '${escapedArchive}'`;
|
|
} else if (fileExt.endsWith(".7z")) {
|
|
extractCommand = `7z x '${escapedArchive}' -o'${escapedTarget}'`;
|
|
} else if (fileExt.endsWith(".rar")) {
|
|
extractCommand = `unrar x '${escapedArchive}' '${escapedTarget}/'`;
|
|
} else {
|
|
return res.status(400).json({ error: "Unsupported archive format" });
|
|
}
|
|
|
|
fileLogger.info("Extracting archive", {
|
|
operation: "extract_archive",
|
|
sessionId,
|
|
archivePath,
|
|
extractPath: targetPath,
|
|
command: extractCommand,
|
|
});
|
|
|
|
execChannel(session, extractCommand, (err, stream) => {
|
|
if (err) {
|
|
fileLogger.error("SSH exec error during extract:", err, {
|
|
operation: "extract_archive",
|
|
sessionId,
|
|
archivePath,
|
|
});
|
|
return res
|
|
.status(500)
|
|
.json({ error: "Failed to execute extract command" });
|
|
}
|
|
|
|
let errorOutput = "";
|
|
stream.on("data", () => {});
|
|
stream.stderr.on("data", (data: Buffer) => {
|
|
errorOutput += data.toString();
|
|
});
|
|
stream.on("close", (code: number) => {
|
|
if (code !== 0) {
|
|
fileLogger.error("Extract command failed", {
|
|
operation: "extract_archive",
|
|
sessionId,
|
|
archivePath,
|
|
exitCode: code,
|
|
error: errorOutput,
|
|
});
|
|
|
|
let friendlyError = errorOutput || "Failed to extract archive";
|
|
if (
|
|
errorOutput.includes("command not found") ||
|
|
errorOutput.includes("not found")
|
|
) {
|
|
let missingCmd = "";
|
|
let installHint = "";
|
|
if (fileExt.endsWith(".zip")) {
|
|
missingCmd = "unzip";
|
|
installHint =
|
|
"apt install unzip / yum install unzip / brew install unzip";
|
|
} else if (
|
|
fileExt.endsWith(".tar.gz") ||
|
|
fileExt.endsWith(".tgz") ||
|
|
fileExt.endsWith(".tar.bz2") ||
|
|
fileExt.endsWith(".tbz2") ||
|
|
fileExt.endsWith(".tar.xz") ||
|
|
fileExt.endsWith(".tar")
|
|
) {
|
|
missingCmd = "tar";
|
|
installHint = "Usually pre-installed on Linux/Unix systems";
|
|
} else if (fileExt.endsWith(".gz")) {
|
|
missingCmd = "gunzip";
|
|
installHint =
|
|
"apt install gzip / yum install gzip / Usually pre-installed";
|
|
} else if (fileExt.endsWith(".bz2")) {
|
|
missingCmd = "bunzip2";
|
|
installHint =
|
|
"apt install bzip2 / yum install bzip2 / brew install bzip2";
|
|
} else if (fileExt.endsWith(".xz")) {
|
|
missingCmd = "unxz";
|
|
installHint =
|
|
"apt install xz-utils / yum install xz / brew install xz";
|
|
} else if (fileExt.endsWith(".7z")) {
|
|
missingCmd = "7z";
|
|
installHint =
|
|
"apt install p7zip-full / yum install p7zip / brew install p7zip";
|
|
} else if (fileExt.endsWith(".rar")) {
|
|
missingCmd = "unrar";
|
|
installHint =
|
|
"apt install unrar / yum install unrar / brew install unrar";
|
|
}
|
|
if (missingCmd) {
|
|
friendlyError = `Command '${missingCmd}' not found on remote server. Please install it first: ${installHint}`;
|
|
}
|
|
}
|
|
return res.status(500).json({ error: friendlyError });
|
|
}
|
|
|
|
fileLogger.success("Archive extracted successfully", {
|
|
operation: "extract_archive",
|
|
sessionId,
|
|
archivePath,
|
|
extractPath: targetPath,
|
|
});
|
|
res.json({
|
|
success: true,
|
|
message: "Archive extracted successfully",
|
|
extractPath: targetPath,
|
|
});
|
|
});
|
|
|
|
stream.on("error", (streamErr) => {
|
|
fileLogger.error("SSH extractArchive stream error:", streamErr, {
|
|
operation: "extract_archive",
|
|
sessionId,
|
|
archivePath,
|
|
});
|
|
if (!res.headersSent) {
|
|
res
|
|
.status(500)
|
|
.json({ error: "Stream error while extracting archive" });
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/file-manager/compressFiles:
|
|
* post:
|
|
* summary: Compress files
|
|
* description: Compresses files and/or directories on the remote host.
|
|
* tags:
|
|
* - File Manager
|
|
* responses:
|
|
* 200:
|
|
* description: Files compressed successfully.
|
|
* 400:
|
|
* description: Missing required parameters or unsupported compression format.
|
|
* 500:
|
|
* description: Failed to compress files.
|
|
*/
|
|
app.post("/compressFiles", async (req, res) => {
|
|
const { sessionId, paths, archiveName, format } = req.body;
|
|
const userId = ctx.currentActor();
|
|
|
|
if (
|
|
!sessionId ||
|
|
!paths ||
|
|
!Array.isArray(paths) ||
|
|
paths.length === 0 ||
|
|
!archiveName
|
|
) {
|
|
return res.status(400).json({ error: "Missing required parameters" });
|
|
}
|
|
const session = sshSessions[sessionId];
|
|
if (!session || !session.isConnected) {
|
|
return res.status(400).json({ error: "SSH session not connected" });
|
|
}
|
|
if (!userId || !verifySessionOwnership(session, userId)) {
|
|
return res.status(403).json({ error: "Session access denied" });
|
|
}
|
|
|
|
session.lastActive = Date.now();
|
|
scheduleSessionCleanup(sessionId);
|
|
|
|
const compressionFormat = format || "zip";
|
|
let compressCommand: string;
|
|
|
|
const firstPath = paths[0];
|
|
const workingDir =
|
|
firstPath.substring(0, firstPath.lastIndexOf("/")) || "/";
|
|
const escapeShell = (s: string) => s.replace(/'/g, "'\"'\"'");
|
|
const fileNames = paths
|
|
.map((p) => {
|
|
const name = p.split("/").pop();
|
|
return `'./${escapeShell(name || "")}'`;
|
|
})
|
|
.join(" ");
|
|
|
|
let archivePath = "";
|
|
if (archiveName.includes("/")) {
|
|
archivePath = archiveName;
|
|
} else {
|
|
archivePath = workingDir.endsWith("/")
|
|
? `${workingDir}${archiveName}`
|
|
: `${workingDir}/${archiveName}`;
|
|
}
|
|
|
|
const escapedDir = escapeShell(workingDir);
|
|
const escapedArchive = escapeShell(archivePath);
|
|
|
|
if (compressionFormat === "zip") {
|
|
compressCommand = `cd '${escapedDir}' && zip -r '${escapedArchive}' -- ${fileNames}`;
|
|
} else if (compressionFormat === "tar.gz" || compressionFormat === "tgz") {
|
|
compressCommand = `cd '${escapedDir}' && tar -czf '${escapedArchive}' -- ${fileNames}`;
|
|
} else if (
|
|
compressionFormat === "tar.bz2" ||
|
|
compressionFormat === "tbz2"
|
|
) {
|
|
compressCommand = `cd '${escapedDir}' && tar -cjf '${escapedArchive}' -- ${fileNames}`;
|
|
} else if (compressionFormat === "tar.xz") {
|
|
compressCommand = `cd '${escapedDir}' && tar -cJf '${escapedArchive}' -- ${fileNames}`;
|
|
} else if (compressionFormat === "tar") {
|
|
compressCommand = `cd '${escapedDir}' && tar -cf '${escapedArchive}' -- ${fileNames}`;
|
|
} else if (compressionFormat === "7z") {
|
|
compressCommand = `cd '${escapedDir}' && 7z a '${escapedArchive}' -- ${fileNames}`;
|
|
} else {
|
|
return res.status(400).json({ error: "Unsupported compression format" });
|
|
}
|
|
|
|
fileLogger.info("Compressing files", {
|
|
operation: "compress_files",
|
|
sessionId,
|
|
paths,
|
|
archivePath,
|
|
format: compressionFormat,
|
|
command: compressCommand,
|
|
});
|
|
|
|
execChannel(session, compressCommand, (err, stream) => {
|
|
if (err) {
|
|
fileLogger.error("SSH exec error during compress:", err, {
|
|
operation: "compress_files",
|
|
sessionId,
|
|
paths,
|
|
});
|
|
return res
|
|
.status(500)
|
|
.json({ error: "Failed to execute compress command" });
|
|
}
|
|
|
|
let errorOutput = "";
|
|
stream.on("data", () => {});
|
|
stream.stderr.on("data", (data: Buffer) => {
|
|
errorOutput += data.toString();
|
|
});
|
|
stream.on("close", (code: number) => {
|
|
if (code !== 0) {
|
|
fileLogger.error("Compress command failed", {
|
|
operation: "compress_files",
|
|
sessionId,
|
|
paths,
|
|
archivePath,
|
|
exitCode: code,
|
|
error: errorOutput,
|
|
});
|
|
|
|
let friendlyError = errorOutput || "Failed to compress files";
|
|
if (
|
|
errorOutput.includes("command not found") ||
|
|
errorOutput.includes("not found")
|
|
) {
|
|
const commandMap: Record<string, { cmd: string; install: string }> =
|
|
{
|
|
zip: {
|
|
cmd: "zip",
|
|
install:
|
|
"apt install zip / yum install zip / brew install zip",
|
|
},
|
|
"tar.gz": {
|
|
cmd: "tar",
|
|
install: "Usually pre-installed on Linux/Unix systems",
|
|
},
|
|
"tar.bz2": {
|
|
cmd: "tar",
|
|
install: "Usually pre-installed on Linux/Unix systems",
|
|
},
|
|
"tar.xz": {
|
|
cmd: "tar",
|
|
install: "Usually pre-installed on Linux/Unix systems",
|
|
},
|
|
tar: {
|
|
cmd: "tar",
|
|
install: "Usually pre-installed on Linux/Unix systems",
|
|
},
|
|
"7z": {
|
|
cmd: "7z",
|
|
install:
|
|
"apt install p7zip-full / yum install p7zip / brew install p7zip",
|
|
},
|
|
};
|
|
const info = commandMap[compressionFormat];
|
|
if (info) {
|
|
friendlyError = `Command '${info.cmd}' not found on remote server. Please install it first: ${info.install}`;
|
|
}
|
|
}
|
|
return res.status(500).json({ error: friendlyError });
|
|
}
|
|
|
|
fileLogger.success("Files compressed successfully", {
|
|
operation: "compress_files",
|
|
sessionId,
|
|
paths,
|
|
archivePath,
|
|
format: compressionFormat,
|
|
});
|
|
res.json({
|
|
success: true,
|
|
message: "Files compressed successfully",
|
|
archivePath,
|
|
});
|
|
});
|
|
|
|
stream.on("error", (streamErr) => {
|
|
fileLogger.error("SSH compressFiles stream error:", streamErr, {
|
|
operation: "compress_files",
|
|
sessionId,
|
|
paths,
|
|
});
|
|
if (!res.headersSent) {
|
|
res
|
|
.status(500)
|
|
.json({ error: "Stream error while compressing files" });
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
const hostTransferDeps: HostTransferDeps = {
|
|
sshSessions,
|
|
getSessionSftp,
|
|
execChannel,
|
|
verifySessionOwnership,
|
|
openDedicatedTransferSession,
|
|
closeDedicatedTransferSession,
|
|
};
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/file-manager/transferMethodPreview:
|
|
* post:
|
|
* summary: Preview how a host to host transfer would run
|
|
* description: Says whether tar or per-file SFTP would be used, and why, without moving anything.
|
|
* tags: [File Manager]
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [sourceSessionId, destSessionId, sourcePaths, destPath]
|
|
* properties:
|
|
* sourceSessionId: { type: string }
|
|
* destSessionId: { type: string }
|
|
* sourcePaths: { type: array, items: { type: string } }
|
|
* destPath: { type: string }
|
|
* methodPreference: { type: string, enum: [auto, tar, item_sftp] }
|
|
* responses:
|
|
* 200: { description: The chosen method and the reason. }
|
|
* 400: { description: Missing parameters. }
|
|
*/
|
|
app.post("/transferMethodPreview", async (req, res) => {
|
|
const {
|
|
sourceSessionId,
|
|
destSessionId,
|
|
sourcePaths,
|
|
destPath,
|
|
methodPreference,
|
|
} = req.body;
|
|
if (
|
|
!sourceSessionId ||
|
|
!destSessionId ||
|
|
!destPath ||
|
|
!sourcePaths ||
|
|
!Array.isArray(sourcePaths) ||
|
|
sourcePaths.length === 0
|
|
) {
|
|
return res.status(400).json({ error: "Missing required parameters" });
|
|
}
|
|
try {
|
|
const userId = ctx.currentActor();
|
|
const preview = await previewArchiveTransferMethod(hostTransferDeps, {
|
|
sourceSessionId,
|
|
destSessionId,
|
|
sourcePaths,
|
|
destPath,
|
|
methodPreference:
|
|
methodPreference === "tar" || methodPreference === "item_sftp"
|
|
? methodPreference
|
|
: "auto",
|
|
userId: userId ?? "",
|
|
});
|
|
res.json(preview);
|
|
} catch (err) {
|
|
fileLogger.error("Failed to preview transfer method", err, {
|
|
operation: "host_transfer",
|
|
sourceSessionId,
|
|
destSessionId,
|
|
sourcePaths,
|
|
});
|
|
res
|
|
.status(500)
|
|
.json({ error: getErrorMessage(err, "Failed to preview method") });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/file-manager/transferToHost:
|
|
* post:
|
|
* summary: Copy or move files from one host to another
|
|
* description: Starts a background transfer between two of the caller's file manager sessions.
|
|
* tags: [File Manager]
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [sourceSessionId, destSessionId, sourcePaths, destPath]
|
|
* properties:
|
|
* sourceSessionId: { type: string }
|
|
* destSessionId: { type: string }
|
|
* sourcePaths: { type: array, items: { type: string } }
|
|
* destPath: { type: string }
|
|
* methodPreference: { type: string, enum: [auto, tar, item_sftp] }
|
|
* move: { type: boolean, description: Delete the source after a successful copy. }
|
|
* parallelSegmentCount: { type: integer }
|
|
* responses:
|
|
* 200: { description: The new transfer id. }
|
|
* 400: { description: Missing parameters or a session is not connected. }
|
|
* 403: { description: A session belongs to someone else. }
|
|
* 500: { description: The transfer could not start. }
|
|
*/
|
|
app.post("/transferToHost", async (req, res) => {
|
|
const {
|
|
sourceSessionId,
|
|
sourcePaths,
|
|
destSessionId,
|
|
destPath,
|
|
move,
|
|
methodPreference,
|
|
parallelSegmentCount: parallelSegmentCountRaw,
|
|
} = req.body;
|
|
const userId = ctx.currentActor();
|
|
|
|
if (
|
|
!sourceSessionId ||
|
|
!destSessionId ||
|
|
!destPath ||
|
|
!sourcePaths ||
|
|
!Array.isArray(sourcePaths) ||
|
|
sourcePaths.length === 0
|
|
) {
|
|
return res.status(400).json({ error: "Missing required parameters" });
|
|
}
|
|
|
|
const sourceSession = sshSessions[sourceSessionId];
|
|
const destSession = sshSessions[destSessionId];
|
|
if (!sourceSession?.isConnected || !destSession?.isConnected) {
|
|
return res.status(400).json({ error: "SSH session not connected" });
|
|
}
|
|
if (
|
|
!userId ||
|
|
!verifySessionOwnership(sourceSession, userId) ||
|
|
!verifySessionOwnership(destSession, userId)
|
|
) {
|
|
return res.status(403).json({ error: "Session access denied" });
|
|
}
|
|
|
|
sourceSession.lastActive = Date.now();
|
|
destSession.lastActive = Date.now();
|
|
scheduleSessionCleanup(sourceSessionId);
|
|
scheduleSessionCleanup(destSessionId);
|
|
|
|
try {
|
|
const rawParallel = Number(parallelSegmentCountRaw);
|
|
const parallelSegmentCount = Number.isFinite(rawParallel)
|
|
? Math.max(1, Math.min(8, Math.floor(rawParallel)))
|
|
: undefined;
|
|
|
|
const { transferId } = startHostTransfer(hostTransferDeps, {
|
|
sourceSessionId,
|
|
sourcePaths,
|
|
destSessionId,
|
|
destPath,
|
|
move: !!move,
|
|
userId,
|
|
methodPreference:
|
|
methodPreference === "tar" || methodPreference === "item_sftp"
|
|
? methodPreference
|
|
: "auto",
|
|
parallelSegmentCount,
|
|
});
|
|
|
|
res.json({ transferId });
|
|
} catch (err) {
|
|
fileLogger.error("Failed to start host transfer", err, {
|
|
operation: "host_transfer",
|
|
sourceSessionId,
|
|
destSessionId,
|
|
sourcePaths,
|
|
});
|
|
res.status(500).json({ error: "Failed to start transfer" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/file-manager/activeTransfers:
|
|
* get:
|
|
* summary: List the caller's running host to host transfers
|
|
* tags: [File Manager]
|
|
* responses:
|
|
* 200: { description: The active transfers. }
|
|
* 401: { description: Not signed in. }
|
|
*/
|
|
app.get("/activeTransfers", async (req, res) => {
|
|
const userId = ctx.currentActor();
|
|
if (!userId)
|
|
return res.status(401).json({ error: "Authentication required" });
|
|
await probeHungStreamTransfers(hostTransferDeps);
|
|
res.json({ transfers: listActiveTransfers(userId) });
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/file-manager/transferStatus/{transferId}:
|
|
* get:
|
|
* summary: Get a transfer's progress
|
|
* tags: [File Manager]
|
|
* parameters:
|
|
* - in: path
|
|
* name: transferId
|
|
* required: true
|
|
* schema: { type: string }
|
|
* responses:
|
|
* 200: { description: The transfer's status and progress. }
|
|
* 401: { description: Not signed in. }
|
|
* 404: { description: No such transfer for this user. }
|
|
*/
|
|
app.get("/transferStatus/:transferId", async (req, res) => {
|
|
const userId = ctx.currentActor();
|
|
const transferId = req.params.transferId;
|
|
if (!userId)
|
|
return res.status(401).json({ error: "Authentication required" });
|
|
await probeHungStreamTransfers(hostTransferDeps);
|
|
const status = getTransferStatus(transferId, userId);
|
|
if (!status) return res.status(404).json({ error: "Transfer not found" });
|
|
res.json(status);
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/file-manager/transferCancel/{transferId}:
|
|
* post:
|
|
* summary: Cancel a running transfer
|
|
* tags: [File Manager]
|
|
* parameters:
|
|
* - in: path
|
|
* name: transferId
|
|
* required: true
|
|
* schema: { type: string }
|
|
* responses:
|
|
* 200: { description: The transfer was asked to stop. }
|
|
* 401: { description: Not signed in. }
|
|
*/
|
|
app.post("/transferCancel/:transferId", (req, res) => {
|
|
const userId = ctx.currentActor();
|
|
const transferId = req.params.transferId;
|
|
const cancelled = userId
|
|
? requestTransferCancel(transferId, userId)
|
|
: false;
|
|
if (!cancelled)
|
|
return res
|
|
.status(404)
|
|
.json({ error: "Transfer not found or not running" });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/file-manager/transferCleanup/{transferId}:
|
|
* post:
|
|
* summary: Remove the partial files a cancelled transfer left
|
|
* tags: [File Manager]
|
|
* parameters:
|
|
* - in: path
|
|
* name: transferId
|
|
* required: true
|
|
* schema: { type: string }
|
|
* responses:
|
|
* 200: { description: What was removed. }
|
|
* 401: { description: Not signed in. }
|
|
* 404: { description: No such transfer for this user. }
|
|
*/
|
|
app.post("/transferCleanup/:transferId", async (req, res) => {
|
|
const userId = ctx.currentActor();
|
|
const transferId = req.params.transferId;
|
|
if (!userId)
|
|
return res.status(401).json({ error: "Authentication required" });
|
|
try {
|
|
const result = await cleanupCancelledTransfer(
|
|
hostTransferDeps,
|
|
transferId,
|
|
userId,
|
|
);
|
|
res.json(result);
|
|
} catch (err) {
|
|
const message = getErrorMessage(err, "Failed to clean up transfer");
|
|
const status = message === "Transfer not found" ? 404 : 400;
|
|
res.status(status).json({ error: message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/file-manager/transferRetry/{transferId}:
|
|
* post:
|
|
* summary: Retry a failed or cancelled transfer
|
|
* tags: [File Manager]
|
|
* parameters:
|
|
* - in: path
|
|
* name: transferId
|
|
* required: true
|
|
* schema: { type: string }
|
|
* responses:
|
|
* 200: { description: The transfer started again. }
|
|
* 401: { description: Not signed in. }
|
|
* 404: { description: No such transfer for this user. }
|
|
*/
|
|
app.post("/transferRetry/:transferId", (req, res) => {
|
|
const userId = ctx.currentActor();
|
|
const transferId = req.params.transferId;
|
|
if (!userId)
|
|
return res.status(401).json({ error: "Authentication required" });
|
|
const retried = retryHostTransfer(hostTransferDeps, transferId, userId);
|
|
if (!retried)
|
|
return res
|
|
.status(404)
|
|
.json({ error: "Transfer not found or not retryable" });
|
|
res.json({ ok: true, transferId });
|
|
});
|
|
|
|
ctx.http.router<Router>({ bodyLimit: "1gb" }).use(app);
|
|
|
|
const service = createFilesService(ctx, {
|
|
sshSessions,
|
|
verifySessionOwnership,
|
|
});
|
|
ctx.services.provide("files.sftp", service);
|
|
|
|
ctx.log.info("File manager mounted at /plugin-api/file-manager");
|
|
}
|
|
|
|
export async function deactivate() {
|
|
// Sessions are closed by the disposable registered in activate; the http
|
|
// router, db tables and service are all disposed by core.
|
|
}
|