mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
release-2.7.1 (#1296)
* Add Helm and GitOps deployment setup * fix: build better-sqlite3 from source in Docker (#1267) * fix: preserve runtime SSL settings (#1268) * fix: support forwarding from the memory SSH agent (#1269) * fix: support forwarding from the memory agent * style: format memory agent test * fix: prompt for encrypted SFTP key passphrases (#1270) * fix: prompt for SFTP key passphrases * style: format SSH key utility test * fix: include host context in automation notifications (#1271) * fix: include host context in automation notifications * style: format automation notification changes * fix: reserve sidebar height for host tags (#1272) * fix: keep host action rows stable at large font sizes (#1273) * fix: honor certificate setting during server probe (#1274) * fix: package standard Linux icon sizes (#1275) * fix: avoid duplicate Docker HTTPS listener (#1276) * Fix host status without metrics collection (#1277) * fix: allow eight-digit secure auth codes (#1263) Allow TOTP prompts to accept secure auth codes longer than six digits without blocking valid authentication attempts. Generated with Codebuff 🤖 Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> * Harden Helm deployment defaults * Update Helm workflow action * Exclude Helm templates from Prettier * Fix browser RDP file drops (#1279) * Fix Proxmox guest credential usernames (#1280) * Add WSL local terminal option (#1281) * refactor: split the transfer engine into focused modules (#1282) * refactor: extract SFTP promisify helpers into sftp-promisify module * refactor: extract transfer timing and rate stats into transfer-stats module * refactor: extract transfer error classes and recovery checks into transfer-errors module * refactor: extract host/path utility helpers into transfer-host-utils module * refactor: extract SFTP directory tree helpers into transfer-sftp-dir module * refactor: extract segment copy job builder into transfer-segment-copy module * refactor: extract file scan and sample helpers into transfer-scan module * refactor: move throttled progress helper into transfer-stats module * style: format transfer modules * perf: optimize tmux monitor aggregation (#1283) * fix: reserve credential tag row height (#1284) * feat: edit AI provider model settings (#1285) * fix: clarify click-to-expand host setting (#1286) * fix: allow portable imports on remote databases (#1287) * fix: allow HTTPS to share the configured port (#1288) * fix: resolve synced jump hosts on the server (#1289) * fix: make terminal clipboard shortcuts layout independent (#1290) * fix: use compatible fetch dispatcher for Tailscale (#1291) * fix: add OIDC environment recovery override (#1292) * fix: coalesce rapid mobile terminal input (#1293) * fix: coalesce rapid mobile terminal input * fix: support clean xterm patch installs * fix: resolve synced remote desktop host IDs (#1295) * feat: make the SFTP file manager path bar editable (#1294) Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * feat: add passkey sign in to the login screen * fix: remove rounded corners from the host list search bar * fix: stop image storage settings text wrapping to one word per line * fix: prevent malformed websocket messages from crashing the server * chore: increment version * fix: remove gaps between host rows in the sidebar list Keep sub-pixel row measurements and stop wiping the size cache on hover. * fix: Failed to connect through jump hosts (#1180) https://github.com/Termix-SSH/Support/issues/1180 * feat: Progress bar for file downloads in the file manager (#1158) https://github.com/Termix-SSH/Support/issues/1158 * feat: Allow setting Silent OIDC Login via ENV var (#1174) https://github.com/Termix-SSH/Support/issues/1174 * feat: `IdentityFile` to limit the number of attempts by agents (#1165) https://github.com/Termix-SSH/Support/issues/1165 * feat: Credentials clone (#1159) https://github.com/Termix-SSH/Support/issues/1159 * chore: update release notes * docs: move helm setup guide to the docs site * fix: type errors in FilteredAgent agent identity handling * fix: remove stale better-sqlite3 prebuilds so the source build is used * fix: actually build better-sqlite3 from source so arm64 docker images work * fix: credential edit pencil in host editor and add clone action to credential list * fix: clear editingHost so the credential pencil actually opens the editor * chore: run format and lint * fix: folder drag and drop upload failing in the file manager * chore: sync Crowdin translations for 2.7.1 --------- Co-authored-by: alex-ctms <alex-ctms@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Chetan Kumar <74929596+ckloop@users.noreply.github.com> Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> Co-authored-by: ZacharyZcR <payasonorahc@protonmail.com> Co-authored-by: dropafterfree <maxime.bonillo@gmail.com> Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com>
This commit is contained in:
165 files changed
+6778
-2090
No files matched your search
@@ -9,7 +9,7 @@ import { createCorsMiddleware } from "../../utils/cors-config.js";
|
||||
import { createCompressionMiddleware } from "../../utils/compression-config.js";
|
||||
import cookieParser from "cookie-parser";
|
||||
import axios from "axios";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import ssh2Pkg, { Client as SSHClient } from "ssh2";
|
||||
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
||||
import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js";
|
||||
import { fileLogger } from "../../utils/logger.js";
|
||||
@@ -42,8 +42,11 @@ import {
|
||||
} from "./transfer-engine.js";
|
||||
import { registerFileContentRoutes } from "./content-routes.js";
|
||||
import { createConnectionLog } from "../connection-log.js";
|
||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../../utils/ssh-key-utils.js";
|
||||
import { createJumpHostChain, JumpHostChainError } from "../jump-host-chain.js";
|
||||
import {
|
||||
isPrivateKeyPassphraseError,
|
||||
preparePrivateKeyForSSH2,
|
||||
} from "../../utils/ssh-key-utils.js";
|
||||
import {
|
||||
ChannelOpenSerializer,
|
||||
execChannel,
|
||||
@@ -840,7 +843,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
resolvedCredentials = {
|
||||
password: resolvedHost.password,
|
||||
sshKey: resolvedHost.key,
|
||||
keyPassword: resolvedHost.keyPassword,
|
||||
keyPassword: keyPassword || resolvedHost.keyPassword,
|
||||
authType: resolvedHost.authType,
|
||||
sudoPassword: resolvedHost.sudoPassword as string | undefined,
|
||||
certPublicKey: (resolvedHost as { certPublicKey?: string })
|
||||
@@ -909,7 +912,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
resolvedCredentials = {
|
||||
password: resolvedHost.password,
|
||||
sshKey: resolvedHost.key,
|
||||
keyPassword: resolvedHost.keyPassword,
|
||||
keyPassword: keyPassword || resolvedHost.keyPassword,
|
||||
authType: resolvedHost.authType,
|
||||
sudoPassword: resolvedHost.sudoPassword as string | undefined,
|
||||
certPublicKey: (resolvedHost as { certPublicKey?: string })
|
||||
@@ -1049,6 +1052,12 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
resolvedCredentials.keyPassword,
|
||||
);
|
||||
|
||||
const parsedKey = ssh2Pkg.utils.parseKey(
|
||||
config.privateKey as Buffer,
|
||||
resolvedCredentials.keyPassword,
|
||||
);
|
||||
if (parsedKey instanceof Error) throw parsedKey;
|
||||
|
||||
if (resolvedCredentials.keyPassword)
|
||||
config.passphrase = resolvedCredentials.keyPassword;
|
||||
|
||||
@@ -1071,6 +1080,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
),
|
||||
);
|
||||
} catch (keyError) {
|
||||
if (isPrivateKeyPassphraseError(keyError)) {
|
||||
return res.json({ status: "passphrase_required", connectionLogs });
|
||||
}
|
||||
|
||||
fileLogger.error("SSH key format error for file manager", {
|
||||
operation: "file_connect",
|
||||
sessionId,
|
||||
@@ -1832,7 +1845,10 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
),
|
||||
);
|
||||
return res.status(500).json({
|
||||
error: "Failed to connect through jump hosts",
|
||||
error:
|
||||
error instanceof JumpHostChainError
|
||||
? `Failed to connect through jump hosts: ${error.message}`
|
||||
: "Failed to connect through jump hosts",
|
||||
connectionLogs,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import { randomUUID } from "crypto";
|
||||
import { networkInterfaces } from "os";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import type { ClientChannel } from "ssh2";
|
||||
import { fileLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
basename,
|
||||
buildPathFromSegments,
|
||||
dirname,
|
||||
getWorkingDir,
|
||||
inferPlatformFromPath,
|
||||
@@ -14,7 +12,6 @@ import {
|
||||
normalizeSftpPath,
|
||||
pathsOverlap,
|
||||
sftpPathToLocalPath,
|
||||
splitPathSegments,
|
||||
type TransferPlatform,
|
||||
} from "../transfer-paths.js";
|
||||
import {
|
||||
@@ -26,6 +23,60 @@ import {
|
||||
type TransferScanSummary,
|
||||
} from "./transfer-routing.js";
|
||||
import { verifySftpFileIntegrity } from "./transfer-integrity.js";
|
||||
import {
|
||||
promisifySftpChmod,
|
||||
promisifySftpClose,
|
||||
promisifySftpFstat,
|
||||
promisifySftpOpen,
|
||||
promisifySftpStat,
|
||||
promisifySftpUnlink,
|
||||
} from "./sftp-promisify.js";
|
||||
import {
|
||||
buildTransferHopTimings,
|
||||
computeTransferMbPerSec,
|
||||
createEmptyXferStats,
|
||||
createHopWallClock,
|
||||
createThrottledProgress,
|
||||
elapsedMs,
|
||||
hopSpanMs,
|
||||
mergeXferStats,
|
||||
noteHopEnd,
|
||||
noteHopStart,
|
||||
type PipelinedXferStats,
|
||||
type TransferTimings,
|
||||
} from "./transfer-stats.js";
|
||||
import {
|
||||
TransferCancelledError,
|
||||
TransferStalledError,
|
||||
isRecoverableTransferError,
|
||||
} from "./transfer-errors.js";
|
||||
import {
|
||||
escapeShell,
|
||||
isLocalSshEndpoint,
|
||||
isPermissionError,
|
||||
isRootOnlyPath,
|
||||
} from "./transfer-host-utils.js";
|
||||
import {
|
||||
SFTP_OPEN_READ,
|
||||
SFTP_OPEN_WRITE,
|
||||
SFTP_OPEN_WRITE_RESUME,
|
||||
} from "./sftp-promisify.js";
|
||||
import {
|
||||
collectFileWorkItems,
|
||||
readSftpSample,
|
||||
type FileWorkItem,
|
||||
} from "./transfer-scan.js";
|
||||
import {
|
||||
deletePathSftp,
|
||||
ensureDirectoryTreeSftp,
|
||||
} from "./transfer-sftp-dir.js";
|
||||
import {
|
||||
DEFAULT_PARALLEL_SEGMENT_COUNT,
|
||||
SFTP_XFER_SEGMENT_SIZE,
|
||||
buildSegmentCopyJobs,
|
||||
clampParallelSegmentCount,
|
||||
type SegmentCopyJob,
|
||||
} from "./transfer-segment-copy.js";
|
||||
import {
|
||||
buildDirectProbeCommand,
|
||||
buildDirectRsyncCommand,
|
||||
@@ -107,31 +158,11 @@ export type TransferStatus =
|
||||
"running" | "success" | "partial" | "error" | "cancelled";
|
||||
export type TransferMethod = "stream" | "tar" | "item_sftp" | "direct_rsync";
|
||||
|
||||
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 type {
|
||||
TransferHopId,
|
||||
TransferHopMetrics,
|
||||
TransferTimings,
|
||||
} from "./transfer-stats.js";
|
||||
|
||||
export interface TransferProgress {
|
||||
transferId: string;
|
||||
@@ -204,34 +235,6 @@ interface ActiveXferControl {
|
||||
const activeXferControls = new Map<string, ActiveXferControl>();
|
||||
const cancelWatchdogs = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
class TransferCancelledError extends Error {
|
||||
constructor() {
|
||||
super("Transfer cancelled");
|
||||
this.name = "TransferCancelledError";
|
||||
}
|
||||
}
|
||||
|
||||
class TransferStalledError extends Error {
|
||||
readonly byteOffset?: number;
|
||||
readonly segmentIndex?: number;
|
||||
|
||||
constructor(byteOffset?: number, segmentIndex?: number) {
|
||||
const pos = byteOffset !== undefined ? ` at byte offset ${byteOffset}` : "";
|
||||
const seg = segmentIndex !== undefined ? ` (segment ${segmentIndex})` : "";
|
||||
super(`Transfer stalled — no data moved for 45 seconds${pos}${seg}`);
|
||||
this.name = "TransferStalledError";
|
||||
this.byteOffset = byteOffset;
|
||||
this.segmentIndex = segmentIndex;
|
||||
}
|
||||
}
|
||||
|
||||
class TransferConnectionLostError extends Error {
|
||||
constructor(message = "Transfer SSH connection lost") {
|
||||
super(message);
|
||||
this.name = "TransferConnectionLostError";
|
||||
}
|
||||
}
|
||||
|
||||
function throwIfCancelled(transferId: string): void {
|
||||
if (cancelRequestedTransfers.has(transferId)) {
|
||||
throw new TransferCancelledError();
|
||||
@@ -315,8 +318,6 @@ const SMALL_FILE_SYNC_THRESHOLD = 10 * 1024 * 1024;
|
||||
const SFTP_XFER_CHUNK_SIZE = 256 * 1024;
|
||||
/** Pipelined in-flight READ requests per leg (ssh2 fastGet/fastPut default is 64). */
|
||||
const SFTP_XFER_CONCURRENCY = 32;
|
||||
/** Reset pipelined scheduler every segment to avoid long-run deadlocks at GiB boundaries. */
|
||||
const SFTP_XFER_SEGMENT_SIZE = 256 * 1024 * 1024;
|
||||
/** Files above this size use segmented copy; smaller files use a single scheduler run. */
|
||||
const SFTP_XFER_SEGMENT_THRESHOLD = 32 * 1024 * 1024;
|
||||
/** Per-segment attempts before giving up (sequential and parallel). */
|
||||
@@ -328,17 +329,9 @@ const SFTP_SEQUENTIAL_COPY_MAX_ATTEMPTS = 2;
|
||||
const SFTP_PARALLEL_COPY_MAX_ATTEMPTS = 2;
|
||||
/** Short backoff before opening fresh dedicated SSH sessions. */
|
||||
const TRANSFER_SESSION_RESET_DELAYS_MS = [1000, 2000, 3000];
|
||||
const DEFAULT_PARALLEL_SEGMENT_COUNT = 2;
|
||||
const MAX_PARALLEL_SEGMENT_COUNT = 8;
|
||||
const TRANSFER_HANDLE_CLOSE_TIMEOUT_MS = 2500;
|
||||
const HUNG_TRANSFER_MS = 90_000;
|
||||
const HUNG_RECONNECTING_MS = 180_000;
|
||||
const TRANSFER_PROGRESS_INTERVAL_MS = 200;
|
||||
const SFTP_OPEN_READ = 0x00000001;
|
||||
/** WRITE | CREATE | TRUNCATE — new file or overwrite from start. */
|
||||
const SFTP_OPEN_WRITE = 0x00000002 | 0x00000008 | 0x00000010;
|
||||
/** READ | WRITE | CREATE — resume into an existing partial file without truncating. */
|
||||
const SFTP_OPEN_WRITE_RESUME = 0x00000001 | 0x00000002 | 0x00000008;
|
||||
|
||||
interface TransferReconnectContext {
|
||||
deps: HostTransferDeps;
|
||||
@@ -364,31 +357,6 @@ function buildTransferReconnectContext(
|
||||
return { deps, transferId, ...meta };
|
||||
}
|
||||
|
||||
function isRecoverableTransferConnectionError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("no response from server") ||
|
||||
msg.includes("connection lost") ||
|
||||
msg.includes("not connected") ||
|
||||
msg.includes("econnreset") ||
|
||||
msg.includes("econnrefused") ||
|
||||
msg.includes("etimedout") ||
|
||||
msg.includes("socket hang up") ||
|
||||
msg.includes("protocol error") ||
|
||||
msg.includes("connection closed") ||
|
||||
msg.includes("channel open failure")
|
||||
);
|
||||
}
|
||||
|
||||
function isRecoverableTransferError(err: unknown): boolean {
|
||||
return (
|
||||
err instanceof TransferStalledError ||
|
||||
err instanceof TransferConnectionLostError ||
|
||||
isRecoverableTransferConnectionError(err)
|
||||
);
|
||||
}
|
||||
|
||||
async function probeDestResumeOffset(
|
||||
destSftp: SFTPWrapper,
|
||||
destPath: string,
|
||||
@@ -560,69 +528,6 @@ async function resetDedicatedTransferSessions(
|
||||
return { sourceSession, destSession, sourceSftp, destSftp };
|
||||
}
|
||||
|
||||
let cachedLocalAddresses: Set<string> | null = null;
|
||||
|
||||
function normalizeHostAddress(host: string): string {
|
||||
const trimmed = host.trim().toLowerCase();
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return trimmed.split(":")[0] ?? trimmed;
|
||||
}
|
||||
|
||||
function getLocalAddresses(): Set<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);
|
||||
}
|
||||
|
||||
function createThrottledProgress(onProgress?: (bytes: number) => void) {
|
||||
let pending = 0;
|
||||
let lastFlush = 0;
|
||||
|
||||
const flush = () => {
|
||||
if (pending > 0) {
|
||||
onProgress?.(pending);
|
||||
pending = 0;
|
||||
lastFlush = Date.now();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
add(bytes: number) {
|
||||
pending += bytes;
|
||||
const now = Date.now();
|
||||
if (now - lastFlush >= TRANSFER_PROGRESS_INTERVAL_MS) {
|
||||
flush();
|
||||
}
|
||||
},
|
||||
flush,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeShell(s: string): string {
|
||||
return s.replace(/'/g, "'\"'\"'");
|
||||
}
|
||||
|
||||
async function detectTransferPlatform(
|
||||
deps: HostTransferDeps,
|
||||
session: SSHSessionLike,
|
||||
@@ -680,150 +585,6 @@ async function detectTransferPlatform(
|
||||
return "unix";
|
||||
}
|
||||
|
||||
function isRootOnlyPath(path: string): boolean {
|
||||
const normalized = normalizeSftpPath(path);
|
||||
return (
|
||||
normalized === "/" ||
|
||||
/^\/[A-Za-z]:$/.test(normalized) ||
|
||||
/^[A-Za-z]:$/.test(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
function isPermissionError(err: Error): boolean {
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("permission denied") ||
|
||||
msg.includes("eacces") ||
|
||||
msg.includes("access denied")
|
||||
);
|
||||
}
|
||||
|
||||
function promisifySftpStat(
|
||||
sftp: SFTPWrapper,
|
||||
path: string,
|
||||
): Promise<import("ssh2").Stats> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.stat(path, (err, stats) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stats);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function promisifySftpUnlink(sftp: SFTPWrapper, path: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.unlink(path, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function promisifySftpRmdir(sftp: SFTPWrapper, path: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.rmdir(path, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function execCommand(
|
||||
deps: HostTransferDeps,
|
||||
session: SSHSessionLike,
|
||||
@@ -1157,10 +918,6 @@ function finalizeTransfer(
|
||||
return result;
|
||||
}
|
||||
|
||||
function elapsedMs(start: number): number {
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
async function verifyTransferredFile(
|
||||
deps: HostTransferDeps,
|
||||
transferId: string,
|
||||
@@ -1208,103 +965,6 @@ async function verifyTransferredFile(
|
||||
});
|
||||
}
|
||||
|
||||
export function computeTransferMbPerSec(
|
||||
bytes: number,
|
||||
ms: number,
|
||||
): number | undefined {
|
||||
if (ms <= 0 || bytes <= 0) return undefined;
|
||||
return ((bytes / ms) * 1000) / (1024 * 1024);
|
||||
}
|
||||
|
||||
interface HopWallClock {
|
||||
firstAt: number | null;
|
||||
lastAt: number | null;
|
||||
}
|
||||
|
||||
function createHopWallClock(): HopWallClock {
|
||||
return { firstAt: null, lastAt: null };
|
||||
}
|
||||
|
||||
function noteHopStart(
|
||||
clock: HopWallClock,
|
||||
t: number = performance.now(),
|
||||
): void {
|
||||
if (clock.firstAt === null) clock.firstAt = t;
|
||||
}
|
||||
|
||||
function noteHopEnd(clock: HopWallClock, t: number = performance.now()): void {
|
||||
clock.lastAt = t;
|
||||
}
|
||||
|
||||
function hopSpanMs(clock: HopWallClock): number {
|
||||
if (clock.firstAt === null || clock.lastAt === null) return 0;
|
||||
return Math.max(0, clock.lastAt - clock.firstAt);
|
||||
}
|
||||
|
||||
interface PipelinedXferStats {
|
||||
bytes: number;
|
||||
sourceReadSpanMs: number;
|
||||
destWriteSpanMs: number;
|
||||
destWriteKind: "sftp" | "local";
|
||||
}
|
||||
|
||||
function createEmptyXferStats(): PipelinedXferStats {
|
||||
return {
|
||||
bytes: 0,
|
||||
sourceReadSpanMs: 0,
|
||||
destWriteSpanMs: 0,
|
||||
destWriteKind: "sftp",
|
||||
};
|
||||
}
|
||||
|
||||
function mergeXferStats(
|
||||
target: PipelinedXferStats,
|
||||
source: PipelinedXferStats,
|
||||
): void {
|
||||
target.bytes += source.bytes;
|
||||
target.sourceReadSpanMs += source.sourceReadSpanMs;
|
||||
target.destWriteSpanMs += source.destWriteSpanMs;
|
||||
target.destWriteKind = source.destWriteKind;
|
||||
}
|
||||
|
||||
function buildTransferHopTimings(
|
||||
stats: PipelinedXferStats,
|
||||
transferMs: number,
|
||||
): Pick<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,
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteSourcePathsAfterSuccess(
|
||||
deps: HostTransferDeps,
|
||||
transferId: string,
|
||||
@@ -1334,63 +994,6 @@ async function ensureDestParentForFile(
|
||||
await ensureDestDirectory(deps, destSession, parent);
|
||||
}
|
||||
|
||||
interface FileWorkItem {
|
||||
sourcePath: string;
|
||||
destPath: string;
|
||||
mode: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
async function collectFileWorkItems(
|
||||
sftp: SFTPWrapper,
|
||||
sourcePath: string,
|
||||
destRoot: string,
|
||||
destBaseName?: string,
|
||||
): Promise<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;
|
||||
}
|
||||
|
||||
async function scanSourcePathsForRouting(
|
||||
sftp: SFTPWrapper,
|
||||
sourcePaths: string[],
|
||||
@@ -1430,63 +1033,6 @@ async function scanSourcePathsForRouting(
|
||||
return summary;
|
||||
}
|
||||
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function promisifySftpClose(sftp: SFTPWrapper, handle: Buffer): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.close(handle, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface PipelinedXferOptions {
|
||||
fileSize?: number;
|
||||
initialOffset?: number;
|
||||
@@ -1500,43 +1046,6 @@ interface PipelinedXferOptions {
|
||||
onResumeOffset?: (offset: number) => void;
|
||||
}
|
||||
|
||||
interface SegmentCopyJob {
|
||||
offset: number;
|
||||
length: number;
|
||||
segmentIndex: number;
|
||||
}
|
||||
|
||||
function clampParallelSegmentCount(value?: number): number {
|
||||
const n = value ?? DEFAULT_PARALLEL_SEGMENT_COUNT;
|
||||
return Math.max(1, Math.min(MAX_PARALLEL_SEGMENT_COUNT, Math.floor(n)));
|
||||
}
|
||||
|
||||
function buildSegmentCopyJobs(
|
||||
fileSize: number,
|
||||
initialOffset: number,
|
||||
destResumeSize: number,
|
||||
): SegmentCopyJob[] {
|
||||
const jobs: SegmentCopyJob[] = [];
|
||||
for (
|
||||
let offset = initialOffset;
|
||||
offset < fileSize;
|
||||
offset += SFTP_XFER_SEGMENT_SIZE
|
||||
) {
|
||||
const length = Math.min(SFTP_XFER_SEGMENT_SIZE, fileSize - offset);
|
||||
const segmentIndex = Math.floor(offset / SFTP_XFER_SEGMENT_SIZE);
|
||||
if (destResumeSize >= offset + length) {
|
||||
continue;
|
||||
}
|
||||
const start = destResumeSize > offset ? destResumeSize : offset;
|
||||
jobs.push({
|
||||
offset: start,
|
||||
length: offset + length - start,
|
||||
segmentIndex,
|
||||
});
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
|
||||
function closeAllTransferSessions(
|
||||
deps: HostTransferDeps,
|
||||
ctx: TransferReconnectContext,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { Client as SSHClient } from "ssh2";
|
||||
import { fileLogger } from "../utils/logger.js";
|
||||
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { preparePrivateKeyForSSH2 } from "../utils/ssh-key-utils.js";
|
||||
import { getErrorMessage } from "../utils/error-message.js";
|
||||
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
|
||||
import { getJumpHostSocks5Config } from "./jump-host-proxy.js";
|
||||
import { applyAgentAuth } from "./terminal-auth-helpers.js";
|
||||
@@ -46,6 +48,17 @@ async function resolveJumpHost(
|
||||
}
|
||||
}
|
||||
|
||||
export class JumpHostChainError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly hopIndex: number,
|
||||
readonly totalHops: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "JumpHostChainError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function createJumpHostChain(
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
userId: string,
|
||||
@@ -76,7 +89,11 @@ export async function createJumpHostChain(
|
||||
totalHops,
|
||||
});
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
throw new JumpHostChainError(
|
||||
`Jump host ${i + 1} of ${totalHops} was not found`,
|
||||
i,
|
||||
totalHops,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,11 +123,20 @@ export async function createJumpHostChain(
|
||||
true,
|
||||
);
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
// eslint-disable-next-line no-async-promise-executor
|
||||
const connected = await new Promise<boolean>(async (resolve) => {
|
||||
const readyTimeoutMs = 60000;
|
||||
const timeout = setTimeout(() => {
|
||||
lastError = new Error(
|
||||
`Timed out waiting for jump host ${i + 1}/${totalHops} to authenticate`,
|
||||
);
|
||||
resolve(false);
|
||||
}, 30000);
|
||||
// ssh2 has no explicit cancel; ending the client stops it from
|
||||
// firing "ready"/"error" after we've already resolved.
|
||||
jumpClient.end();
|
||||
}, readyTimeoutMs + 5000);
|
||||
|
||||
jumpClient.on("ready", () => {
|
||||
clearTimeout(timeout);
|
||||
@@ -119,6 +145,7 @@ export async function createJumpHostChain(
|
||||
|
||||
jumpClient.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
lastError = err;
|
||||
fileLogger.error(
|
||||
`Jump host ${i + 1}/${totalHops} connection failed`,
|
||||
err,
|
||||
@@ -145,7 +172,7 @@ export async function createJumpHostChain(
|
||||
port: jumpHostConfig.port || 22,
|
||||
username: jumpHostConfig.username,
|
||||
tryKeyboard: jumpHostConfig.authType !== "none",
|
||||
readyTimeout: 60000,
|
||||
readyTimeout: readyTimeoutMs,
|
||||
hostVerifier: jumpHostVerifier,
|
||||
algorithms: {
|
||||
kex: [
|
||||
@@ -190,11 +217,19 @@ export async function createJumpHostChain(
|
||||
if (jumpHostConfig.authType === "password" && jumpHostConfig.password) {
|
||||
connectConfig.password = jumpHostConfig.password;
|
||||
} else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) {
|
||||
const cleanKey = jumpHostConfig.key
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
|
||||
try {
|
||||
connectConfig.privateKey = preparePrivateKeyForSSH2(
|
||||
jumpHostConfig.key,
|
||||
jumpHostConfig.keyPassword,
|
||||
);
|
||||
} catch (keyError) {
|
||||
clearTimeout(timeout);
|
||||
lastError = new Error(
|
||||
`Jump host ${i + 1}/${totalHops} key error: ${getErrorMessage(keyError, "Invalid private key format")}`,
|
||||
);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (jumpHostConfig.keyPassword) {
|
||||
connectConfig.passphrase = jumpHostConfig.keyPassword;
|
||||
}
|
||||
@@ -237,6 +272,7 @@ export async function createJumpHostChain(
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(timeout);
|
||||
lastError = err;
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
@@ -254,7 +290,14 @@ export async function createJumpHostChain(
|
||||
|
||||
if (!connected) {
|
||||
clients.forEach((c) => c.end());
|
||||
return null;
|
||||
throw new JumpHostChainError(
|
||||
getErrorMessage(
|
||||
lastError,
|
||||
`Jump host ${i + 1} of ${totalHops} failed to connect`,
|
||||
),
|
||||
i,
|
||||
totalHops,
|
||||
);
|
||||
}
|
||||
|
||||
currentClient = jumpClient;
|
||||
@@ -262,6 +305,7 @@ export async function createJumpHostChain(
|
||||
|
||||
return currentClient;
|
||||
} catch (error) {
|
||||
if (error instanceof JumpHostChainError) throw error;
|
||||
fileLogger.error("Failed to create jump host chain", error, {
|
||||
operation: "jump_host_chain",
|
||||
});
|
||||
|
||||
@@ -576,16 +576,32 @@ class PollingManager {
|
||||
} else {
|
||||
isOnline = await tcpPing(refreshedHost.ip, pingPort, 5000);
|
||||
}
|
||||
const config = this.pollingConfigs.get(refreshedHost.id);
|
||||
let authenticated: boolean | undefined;
|
||||
if (
|
||||
isOnline &&
|
||||
supportsMetrics(refreshedHost) &&
|
||||
!config?.statsConfig.metricsEnabled
|
||||
) {
|
||||
try {
|
||||
await withSshConnection(refreshedHost, async () => undefined);
|
||||
authenticated = true;
|
||||
} catch {
|
||||
authenticated = false;
|
||||
}
|
||||
}
|
||||
const statusEntry: StatusEntry = {
|
||||
status: statusAfterReachabilityCheck(
|
||||
isOnline,
|
||||
this.statusStore.get(refreshedHost.id)?.status,
|
||||
),
|
||||
status:
|
||||
authenticated === undefined
|
||||
? statusAfterReachabilityCheck(
|
||||
isOnline,
|
||||
this.statusStore.get(refreshedHost.id)?.status,
|
||||
)
|
||||
: statusAfterAuthentication(authenticated),
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
this.statusStore.set(refreshedHost.id, statusEntry);
|
||||
if (isOnline && this.activeViewers.has(refreshedHost.id)) {
|
||||
const config = this.pollingConfigs.get(refreshedHost.id);
|
||||
if (config?.statsConfig.metricsEnabled) {
|
||||
this.scheduleInitialMetricsPoll(config.host, config.viewerUserId);
|
||||
}
|
||||
|
||||
+93
-89
@@ -4,6 +4,7 @@ import { SerialPort } from "serialport";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { parseWsMessage } from "../utils/ws-message.js";
|
||||
|
||||
interface SerialConnectData {
|
||||
path: string;
|
||||
@@ -13,11 +14,6 @@ interface SerialConnectData {
|
||||
parity?: "none" | "even" | "odd";
|
||||
}
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: string;
|
||||
data?: SerialConnectData | string | unknown;
|
||||
}
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
const wss = new WebSocketServer({ port: 30011 });
|
||||
@@ -93,109 +89,117 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
};
|
||||
|
||||
ws.on("message", async (raw: RawData) => {
|
||||
let parsed: WebSocketMessage;
|
||||
let type: string;
|
||||
let data: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw.toString()) as WebSocketMessage;
|
||||
({ type, data } = parseWsMessage(raw));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, data } = parsed;
|
||||
|
||||
switch (type) {
|
||||
case "list_ports": {
|
||||
try {
|
||||
const ports = await SerialPort.list();
|
||||
send({ type: "ports_list", data: ports });
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "error",
|
||||
data: getErrorMessage(err, "Failed to list ports"),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "connect": {
|
||||
if (port?.isOpen) {
|
||||
port.close();
|
||||
port = null;
|
||||
}
|
||||
|
||||
const cfg = data as SerialConnectData;
|
||||
if (!cfg?.path || !cfg?.baudRate) {
|
||||
send({ type: "error", data: "Missing port path or baud rate" });
|
||||
try {
|
||||
switch (type) {
|
||||
case "list_ports": {
|
||||
try {
|
||||
const ports = await SerialPort.list();
|
||||
send({ type: "ports_list", data: ports });
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "error",
|
||||
data: getErrorMessage(err, "Failed to list ports"),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
port = new SerialPort({
|
||||
path: cfg.path,
|
||||
baudRate: cfg.baudRate,
|
||||
dataBits: cfg.dataBits ?? 8,
|
||||
stopBits: cfg.stopBits ?? 1,
|
||||
parity: cfg.parity ?? "none",
|
||||
autoOpen: false,
|
||||
});
|
||||
case "connect": {
|
||||
if (port?.isOpen) {
|
||||
port.close();
|
||||
port = null;
|
||||
}
|
||||
|
||||
port.open((err) => {
|
||||
if (err) {
|
||||
sshLogger.error("Serial port open failed", err, {
|
||||
operation: "serial_open",
|
||||
path: cfg.path,
|
||||
userId,
|
||||
});
|
||||
send({ type: "error", data: err.message });
|
||||
port = null;
|
||||
return;
|
||||
}
|
||||
sshLogger.info("Serial port opened", {
|
||||
operation: "serial_open",
|
||||
const cfg = data as SerialConnectData;
|
||||
if (!cfg?.path || !cfg?.baudRate) {
|
||||
send({ type: "error", data: "Missing port path or baud rate" });
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
port = new SerialPort({
|
||||
path: cfg.path,
|
||||
baudRate: cfg.baudRate,
|
||||
userId,
|
||||
dataBits: cfg.dataBits ?? 8,
|
||||
stopBits: cfg.stopBits ?? 1,
|
||||
parity: cfg.parity ?? "none",
|
||||
autoOpen: false,
|
||||
});
|
||||
send({ type: "connected" });
|
||||
});
|
||||
|
||||
port.on("data", (chunk: Buffer) => {
|
||||
send({ type: "data", data: chunk.toString("binary") });
|
||||
});
|
||||
port.open((err) => {
|
||||
if (err) {
|
||||
sshLogger.error("Serial port open failed", err, {
|
||||
operation: "serial_open",
|
||||
path: cfg.path,
|
||||
userId,
|
||||
});
|
||||
send({ type: "error", data: err.message });
|
||||
port = null;
|
||||
return;
|
||||
}
|
||||
sshLogger.info("Serial port opened", {
|
||||
operation: "serial_open",
|
||||
path: cfg.path,
|
||||
baudRate: cfg.baudRate,
|
||||
userId,
|
||||
});
|
||||
send({ type: "connected" });
|
||||
});
|
||||
|
||||
port.on("error", (err) => {
|
||||
send({ type: "error", data: err.message });
|
||||
});
|
||||
port.on("data", (chunk: Buffer) => {
|
||||
send({ type: "data", data: chunk.toString("binary") });
|
||||
});
|
||||
|
||||
port.on("close", () => {
|
||||
send({ type: "disconnected" });
|
||||
port = null;
|
||||
});
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "error",
|
||||
data: getErrorMessage(err, "Failed to open serial port"),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
port.on("error", (err) => {
|
||||
send({ type: "error", data: err.message });
|
||||
});
|
||||
|
||||
case "input": {
|
||||
if (!port?.isOpen) break;
|
||||
const input = typeof data === "string" ? data : "";
|
||||
if (!input) break;
|
||||
port.write(Buffer.from(input, "binary"), (err) => {
|
||||
if (err) {
|
||||
send({ type: "error", data: err.message });
|
||||
port.on("close", () => {
|
||||
send({ type: "disconnected" });
|
||||
port = null;
|
||||
});
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "error",
|
||||
data: getErrorMessage(err, "Failed to open serial port"),
|
||||
});
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "disconnect": {
|
||||
cleanup();
|
||||
send({ type: "disconnected" });
|
||||
break;
|
||||
case "input": {
|
||||
if (!port?.isOpen) break;
|
||||
const input = typeof data === "string" ? data : "";
|
||||
if (!input) break;
|
||||
port.write(Buffer.from(input, "binary"), (err) => {
|
||||
if (err) {
|
||||
send({ type: "error", data: err.message });
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "disconnect": {
|
||||
cleanup();
|
||||
send({ type: "disconnected" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
sshLogger.error("Error handling serial WebSocket message", err, {
|
||||
operation: "serial_message_handler_error",
|
||||
userId,
|
||||
messageType: type,
|
||||
});
|
||||
send({ type: "error", data: "Failed to process message" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import dgram from "dgram";
|
||||
import net from "net";
|
||||
import ssh2Pkg, {
|
||||
type BaseAgent as BaseAgentType,
|
||||
type GetStreamCallback,
|
||||
type IdentityCallback,
|
||||
type KnownPublicKeys,
|
||||
type ParsedKey,
|
||||
type SignCallback,
|
||||
type SigningRequestOptions,
|
||||
} from "ssh2";
|
||||
|
||||
const { BaseAgent } = ssh2Pkg;
|
||||
type KnownPublicKey = KnownPublicKeys[number];
|
||||
|
||||
const { AgentProtocol, BaseAgent } = ssh2Pkg;
|
||||
const DEFAULT_PORT_KNOCK_TIMEOUT_MS = 1000;
|
||||
|
||||
type Sleep = (ms: number) => Promise<void>;
|
||||
@@ -34,6 +39,23 @@ export class MemoryAgent extends BaseAgent {
|
||||
cb(null, [this.key]);
|
||||
}
|
||||
|
||||
getStream(cb: GetStreamCallback): void {
|
||||
const protocol = new AgentProtocol(false);
|
||||
|
||||
protocol.on("identities", (request) => {
|
||||
protocol.getIdentitiesReply(request, [this.key]);
|
||||
});
|
||||
|
||||
protocol.on("sign", (request, publicKey, data, options) => {
|
||||
this.sign(publicKey, data, options, (error, signature) => {
|
||||
if (error || !signature) return protocol.failureReply(request);
|
||||
protocol.signReply(request, signature);
|
||||
});
|
||||
});
|
||||
|
||||
cb(null, protocol);
|
||||
}
|
||||
|
||||
sign(
|
||||
_pubKey: ParsedKey | Buffer | string,
|
||||
data: Buffer,
|
||||
@@ -85,6 +107,84 @@ export async function resolveAgentSocket(
|
||||
return { socketPath: resolved };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an agent so only identities matching a specific public key are
|
||||
* offered to the server, mirroring ssh_config's IdentityFile + IdentitiesOnly
|
||||
* for agent auth. Prevents exhausting the server's MaxAuthTries when the
|
||||
* agent holds many keys.
|
||||
*/
|
||||
export class FilteredAgent extends BaseAgent {
|
||||
private inner: BaseAgentType;
|
||||
private publicKeyBlob: Buffer;
|
||||
|
||||
constructor(inner: BaseAgentType, publicKeyBlob: Buffer) {
|
||||
super();
|
||||
this.inner = inner;
|
||||
this.publicKeyBlob = publicKeyBlob;
|
||||
}
|
||||
|
||||
private matches(key: KnownPublicKey): boolean {
|
||||
try {
|
||||
const blob =
|
||||
typeof key === "string"
|
||||
? Buffer.from(key)
|
||||
: Buffer.isBuffer(key)
|
||||
? key
|
||||
: "getPublicSSH" in key
|
||||
? key.getPublicSSH()
|
||||
: null;
|
||||
return (
|
||||
Buffer.isBuffer(blob) && Buffer.compare(blob, this.publicKeyBlob) === 0
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getIdentities(cb: IdentityCallback): void {
|
||||
this.inner.getIdentities((err, keys) => {
|
||||
if (err || !keys) return cb(err, keys);
|
||||
cb(
|
||||
null,
|
||||
keys.filter((key) => this.matches(key)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
getStream(cb: GetStreamCallback): void {
|
||||
if (typeof this.inner.getStream !== "function") {
|
||||
return cb(new Error("Agent does not support forwarding."));
|
||||
}
|
||||
this.inner.getStream(cb);
|
||||
}
|
||||
|
||||
sign(
|
||||
pubKey: ParsedKey | Buffer | string,
|
||||
data: Buffer,
|
||||
optionsOrCb: SigningRequestOptions | SignCallback,
|
||||
cb?: SignCallback,
|
||||
): void {
|
||||
this.inner.sign(
|
||||
pubKey,
|
||||
data,
|
||||
optionsOrCb as SigningRequestOptions,
|
||||
cb as SignCallback,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseAgentIdentityBlob(agentIdentity: string): Buffer | null {
|
||||
const { utils } = ssh2Pkg;
|
||||
const parsed = utils.parseKey(agentIdentity.trim());
|
||||
if (parsed instanceof Error || !parsed) return null;
|
||||
const key = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
try {
|
||||
return key.getPublicSSH();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyAgentAuth(
|
||||
connectConfig: Record<string, unknown>,
|
||||
terminalConfig: Record<string, unknown> | undefined,
|
||||
@@ -93,7 +193,21 @@ export async function applyAgentAuth(
|
||||
if ("error" in result) return result;
|
||||
|
||||
const { createAgent } = ssh2Pkg;
|
||||
connectConfig.agent = createAgent(result.socketPath);
|
||||
const agent = createAgent(result.socketPath);
|
||||
|
||||
const agentIdentity = (
|
||||
terminalConfig?.agentIdentity as string | undefined
|
||||
)?.trim();
|
||||
if (agentIdentity) {
|
||||
const publicKeyBlob = parseAgentIdentityBlob(agentIdentity);
|
||||
if (!publicKeyBlob) {
|
||||
return { error: "Invalid public key provided for agent identity." };
|
||||
}
|
||||
connectConfig.agent = new FilteredAgent(agent, publicKeyBlob);
|
||||
} else {
|
||||
connectConfig.agent = agent;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,15 @@ export const HOST_NOT_ON_THIS_SERVER_MESSAGE =
|
||||
"This host does not exist on the sync server, so the connection was refused. " +
|
||||
'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.';
|
||||
|
||||
export function resolveServerJumpHosts(
|
||||
clientJumpHosts: Array<{ hostId: number }> | undefined,
|
||||
serverJumpHosts: Array<{ hostId: number }> | undefined,
|
||||
hostSyncId?: string | null,
|
||||
): Array<{ hostId: number }> | undefined {
|
||||
if (hostSyncId) return serverJumpHosts ?? [];
|
||||
return clientJumpHosts?.length ? clientJumpHosts : serverJumpHosts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown where a mismatch is reported by rejecting rather than by messaging
|
||||
* the socket. Callers whose host-resolution is wrapped in a "failed to resolve
|
||||
|
||||
+829
-781
File diff suppressed because it is too large.
Load diff
@@ -177,8 +177,8 @@ export function buildPaneMetrics(
|
||||
const treePids: number[] = [];
|
||||
const queue = [pane.pid];
|
||||
const seen = new Set<number>();
|
||||
while (queue.length > 0) {
|
||||
const pid = queue.shift()!;
|
||||
for (let cursor = 0; cursor < queue.length; cursor++) {
|
||||
const pid = queue[cursor];
|
||||
if (seen.has(pid)) continue;
|
||||
seen.add(pid);
|
||||
if (byPid.has(pid)) treePids.push(pid);
|
||||
@@ -225,9 +225,19 @@ export function attachPanesToWindows(
|
||||
windows: Map<string, TmuxWindow[]>,
|
||||
panes: RawPane[],
|
||||
): void {
|
||||
const windowsBySessionAndIndex = new Map<string, Map<number, TmuxWindow>>();
|
||||
for (const [sessionName, sessionWindows] of windows) {
|
||||
const byIndex = new Map<number, TmuxWindow>();
|
||||
for (const window of sessionWindows) {
|
||||
if (!byIndex.has(window.index)) byIndex.set(window.index, window);
|
||||
}
|
||||
windowsBySessionAndIndex.set(sessionName, byIndex);
|
||||
}
|
||||
|
||||
for (const pane of panes) {
|
||||
const sessionWindows = windows.get(pane.sessionName) || [];
|
||||
const window = sessionWindows.find((w) => w.index === pane.windowIndex);
|
||||
const window = windowsBySessionAndIndex
|
||||
.get(pane.sessionName)
|
||||
?.get(pane.windowIndex);
|
||||
if (window) {
|
||||
const { sessionName: _s, windowIndex: _w, ...paneFields } = pane;
|
||||
window.panes.push(paneFields);
|
||||
|
||||
Reference in new issue
Block a user