mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 21:03:47 +00:00
release-2.4.1 (#921)
* chore(deps-dev): bump the dev-minor-updates group across 1 directory with 12 updates (#910) Bumps the dev-minor-updates group with 12 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.2.6` | `2.3.1` | | [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.3.6` | `1.4.1` | | [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.2.5` | `1.3.0` | | [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.2.6` | `1.3.1` | | [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.33.4` | `3.34.0` | | [electron](https://github.com/electron/electron) | `42.2.0` | `42.4.1` | | [electron-builder](https://github.com/electron-userland/electron-builder/tree/HEAD/packages/electron-builder) | `26.8.1` | `26.15.3` | | [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.16.0` | `1.20.0` | | [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.4.3` | `1.6.0` | | [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.76.1` | `7.79.0` | | [sharp](https://github.com/lovell/sharp) | `0.34.5` | `0.35.1` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.60.0` | `8.61.1` | Updates `@radix-ui/react-select` from 2.2.6 to 2.3.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select) Updates `@radix-ui/react-slider` from 1.3.6 to 1.4.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider) Updates `@radix-ui/react-slot` from 1.2.5 to 1.3.0 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot) Updates `@radix-ui/react-switch` from 1.2.6 to 1.3.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch) Updates `cytoscape` from 3.33.4 to 3.34.0 - [Release notes](https://github.com/cytoscape/cytoscape.js/releases) - [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.33.4...v3.34.0) Updates `electron` from 42.2.0 to 42.4.1 - [Release notes](https://github.com/electron/electron/releases) - [Commits](https://github.com/electron/electron/compare/v42.2.0...v42.4.1) Updates `electron-builder` from 26.8.1 to 26.15.3 - [Release notes](https://github.com/electron-userland/electron-builder/releases) - [Changelog](https://github.com/electron-userland/electron-builder/blob/master/packages/electron-builder/CHANGELOG.md) - [Commits](https://github.com/electron-userland/electron-builder/commits/electron-builder@26.15.3/packages/electron-builder) Updates `lucide-react` from 1.16.0 to 1.20.0 - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/1.20.0/packages/lucide-react) Updates `radix-ui` from 1.4.3 to 1.6.0 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui) Updates `react-hook-form` from 7.76.1 to 7.79.0 - [Release notes](https://github.com/react-hook-form/react-hook-form/releases) - [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md) - [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.76.1...v7.79.0) Updates `sharp` from 0.34.5 to 0.35.1 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.1) Updates `typescript-eslint` from 8.60.0 to 8.61.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.1/packages/typescript-eslint) --- updated-dependencies: - dependency-name: "@radix-ui/react-select" dependency-version: 2.3.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-slider" dependency-version: 1.4.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-slot" dependency-version: 1.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-switch" dependency-version: 1.3.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: cytoscape dependency-version: 3.34.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: electron dependency-version: 42.4.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: electron-builder dependency-version: 26.15.3 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: lucide-react dependency-version: 1.18.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: radix-ui dependency-version: 1.6.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: react-hook-form dependency-version: 7.79.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: sharp dependency-version: 0.35.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: typescript-eslint dependency-version: 8.61.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump node in /docker in the docker-major-updates group (#914) Bumps the docker-major-updates group in /docker with 1 update: node. Updates `node` from 24-slim to 26-slim --- updated-dependencies: - dependency-name: node dependency-version: 26-slim dependency-type: direct:production dependency-group: docker-major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * ci(deps): bump the github-actions group with 2 updates (#915) Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-node](https://github.com/actions/setup-node). Updates `actions/checkout` from 5 to 6 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) Updates `actions/setup-node` from 4 to 6 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the prod-minor-updates group across 1 directory with 5 updates (#916) Bumps the prod-minor-updates group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [axios](https://github.com/axios/axios) | `1.17.0` | `1.18.0` | | [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | `12.10.0` | `12.11.1` | | [body-parser](https://github.com/expressjs/body-parser) | `2.2.2` | `2.3.0` | | [multer](https://github.com/expressjs/multer) | `2.1.1` | `2.2.0` | | [undici](https://github.com/nodejs/undici) | `8.4.0` | `8.5.0` | Updates `axios` from 1.17.0 to 1.18.0 - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.17.0...v1.18.0) Updates `better-sqlite3` from 12.10.0 to 12.11.1 - [Release notes](https://github.com/WiseLibs/better-sqlite3/releases) - [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.10.0...v12.11.1) Updates `body-parser` from 2.2.2 to 2.3.0 - [Release notes](https://github.com/expressjs/body-parser/releases) - [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md) - [Commits](https://github.com/expressjs/body-parser/compare/v2.2.2...v2.3.0) Updates `multer` from 2.1.1 to 2.2.0 - [Release notes](https://github.com/expressjs/multer/releases) - [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md) - [Commits](https://github.com/expressjs/multer/compare/v2.1.1...v2.2.0) Updates `undici` from 8.4.0 to 8.5.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.4.0...v8.5.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.18.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: better-sqlite3 dependency-version: 12.11.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: body-parser dependency-version: 2.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: multer dependency-version: 2.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: undici dependency-version: 8.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat: add unlink button for dual auth * fix: general bug fixes * fix: general qol/small feature additions * fix: 100mb max upload size * fix: terminal syntax not handling carriage returns or shell prompt lines properly * feat: continued general improvements * fix: terminal outputting success right after folder path * chore: update release notes * feat: continued fixes and improvements * chore: lint, format, and bump version to 2.4.1 * chore: sync Crowdin translations for 2.4.1 * fix: rebase dev branch onto main before Crowdin download * fix: use merge instead of rebase to sync main before Crowdin --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -23,6 +23,7 @@ interface HostConfig {
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
useWarpgate?: boolean;
|
||||
credentialId?: number;
|
||||
userId?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
@@ -112,6 +113,7 @@ export class SSHAuthManager {
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
resolvedCredentials: ResolvedCredentials,
|
||||
hostConfig?: HostConfig,
|
||||
): void {
|
||||
this.context.isKeyboardInteractive = true;
|
||||
const promptTexts = prompts.map((p) => p.prompt);
|
||||
@@ -143,7 +145,7 @@ export class SSHAuthManager {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handlePasswordAuth(prompts, finish, resolvedCredentials);
|
||||
this.handlePasswordAuth(prompts, finish, resolvedCredentials, hostConfig);
|
||||
}
|
||||
|
||||
private handleWarpgateAuth(
|
||||
@@ -282,7 +284,22 @@ export class SSHAuthManager {
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
resolvedCredentials: ResolvedCredentials,
|
||||
hostConfig?: HostConfig,
|
||||
): void {
|
||||
// For Warpgate hosts: auto-answer password prompts silently using stored credentials.
|
||||
// Warpgate sends a password prompt before its browser-verification round; we must
|
||||
// not show a UI prompt here -- the WarpgateDialog handles user interaction later.
|
||||
if (hostConfig?.useWarpgate) {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password as string;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasStoredPassword =
|
||||
resolvedCredentials.password && resolvedCredentials.authType !== "none";
|
||||
|
||||
@@ -290,19 +307,34 @@ export class SSHAuthManager {
|
||||
/password/i.test(p.prompt),
|
||||
);
|
||||
|
||||
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
||||
// Find the first prompt we can't auto-answer. This handles DUO/PAM challenges
|
||||
// that don't say "password" (e.g. "Passcode or option (1-N):").
|
||||
const firstUnansweredIndex = prompts.findIndex((p) => {
|
||||
if (/password/i.test(p.prompt) && hasStoredPassword) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (firstUnansweredIndex !== -1) {
|
||||
if (this.context.keyboardInteractiveResponded) {
|
||||
return;
|
||||
}
|
||||
this.context.keyboardInteractiveResponded = true;
|
||||
|
||||
const promptIndex =
|
||||
passwordPromptIndex !== -1 && !hasStoredPassword
|
||||
? passwordPromptIndex
|
||||
: firstUnansweredIndex;
|
||||
|
||||
this.context.keyboardInteractiveFinish = (userResponses: string[]) => {
|
||||
const userInput = (userResponses[0] || "").trim();
|
||||
|
||||
const responses = prompts.map((p, index) => {
|
||||
if (index === passwordPromptIndex) {
|
||||
if (index === promptIndex) {
|
||||
return userInput;
|
||||
}
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
@@ -335,7 +367,7 @@ export class SSHAuthManager {
|
||||
this.context.ws.send(
|
||||
JSON.stringify({
|
||||
type: "password_required",
|
||||
prompt: prompts[passwordPromptIndex].prompt,
|
||||
prompt: prompts[promptIndex].prompt,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { pickResolvedUsername } from "./credential-username.js";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
pickResolvedUsername,
|
||||
expandOidcUsername,
|
||||
} from "./credential-username.js";
|
||||
|
||||
describe("pickResolvedUsername", () => {
|
||||
it("keeps the host username when one is set, even with a credential username", () => {
|
||||
@@ -26,3 +29,70 @@ describe("pickResolvedUsername", () => {
|
||||
expect(pickResolvedUsername(undefined, undefined, false)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandOidcUsername", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("returns the username unchanged when it has no placeholder", async () => {
|
||||
expect(await expandOidcUsername("alice", "user-1")).toBe("alice");
|
||||
expect(await expandOidcUsername(undefined, "user-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("expands the placeholder with the user's OIDC identifier", async () => {
|
||||
vi.doMock("../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: async () => [{ oidcIdentifier: "jdoe" }],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../database/db/schema.js", () => ({ users: {} }));
|
||||
vi.doMock("drizzle-orm", () => ({ eq: vi.fn() }));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
await import("./credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe");
|
||||
});
|
||||
|
||||
it("leaves the placeholder as-is when the user has no OIDC identifier", async () => {
|
||||
vi.doMock("../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: async () => [{ oidcIdentifier: null }],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../database/db/schema.js", () => ({ users: {} }));
|
||||
vi.doMock("drizzle-orm", () => ({ eq: vi.fn() }));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
await import("./credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
"$oidc.preferred_username",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the username unchanged when the DB lookup throws", async () => {
|
||||
vi.doMock("../database/db/index.js", () => ({
|
||||
getDb: () => {
|
||||
throw new Error("DB unavailable");
|
||||
},
|
||||
}));
|
||||
|
||||
const { expandOidcUsername: expand } =
|
||||
await import("./credential-username.js");
|
||||
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
|
||||
"$oidc.preferred_username",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,40 @@ export function pickResolvedUsername(
|
||||
return cred;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands the `$oidc.preferred_username` placeholder in an SSH username to the
|
||||
* connecting user's OIDC identifier. Returns the username unchanged if it does
|
||||
* not contain the placeholder or the user has no OIDC identifier.
|
||||
*/
|
||||
export async function expandOidcUsername(
|
||||
username: string | undefined,
|
||||
userId: string,
|
||||
): Promise<string | undefined> {
|
||||
if (!username || !username.includes("$oidc.preferred_username")) {
|
||||
return username;
|
||||
}
|
||||
|
||||
try {
|
||||
const { getDb } = await import("../database/db/index.js");
|
||||
const { users } = await import("../database/db/schema.js");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select({ oidcIdentifier: users.oidcIdentifier })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
const oidcIdentifier = rows[0]?.oidcIdentifier;
|
||||
if (!oidcIdentifier) return username;
|
||||
|
||||
return username.replace(/\$oidc\.preferred_username/g, oidcIdentifier);
|
||||
} catch {
|
||||
return username;
|
||||
}
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim() !== "";
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ type DockerSession = {
|
||||
lastActive: number;
|
||||
activeOperations: number;
|
||||
hostId?: number;
|
||||
isWindows?: boolean;
|
||||
};
|
||||
|
||||
type PendingDockerTotpSession = unknown;
|
||||
@@ -93,7 +94,10 @@ export function registerDockerContainerRoutes(
|
||||
|
||||
try {
|
||||
const allFlag = all ? "-a " : "";
|
||||
const command = `docker ps ${allFlag}--format '{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}'`;
|
||||
const formatStr = session.isWindows
|
||||
? `"{\\"id\\":\\"{{.ID}}\\",\\"name\\":\\"{{.Names}}\\",\\"image\\":\\"{{.Image}}\\",\\"status\\":\\"{{.Status}}\\",\\"state\\":\\"{{.State}}\\",\\"ports\\":\\"{{.Ports}}\\",\\"created\\":\\"{{.CreatedAt}}\\"}"`
|
||||
: `'{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}' `;
|
||||
const command = `docker ps ${allFlag}--format ${formatStr}`;
|
||||
|
||||
const output = await executeDockerCommand(
|
||||
session,
|
||||
@@ -916,7 +920,7 @@ export function registerDockerContainerRoutes(
|
||||
session.activeOperations++;
|
||||
|
||||
try {
|
||||
let command = `docker logs ${containerId} 2>&1`;
|
||||
let command = `docker logs ${containerId}`;
|
||||
|
||||
if (tail && tail > 0) {
|
||||
command += ` --tail ${Math.floor(tail)}`;
|
||||
@@ -934,6 +938,8 @@ export function registerDockerContainerRoutes(
|
||||
command += ` --until ${until}`;
|
||||
}
|
||||
|
||||
command += " 2>&1";
|
||||
|
||||
const logs = await executeDockerCommand(
|
||||
session,
|
||||
command,
|
||||
@@ -1026,7 +1032,10 @@ export function registerDockerContainerRoutes(
|
||||
session.activeOperations++;
|
||||
|
||||
try {
|
||||
const command = `docker stats ${containerId} --no-stream --format '{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}'`;
|
||||
const statsFormatStr = session.isWindows
|
||||
? `"{\\"cpu\\":\\"{{.CPUPerc}}\\",\\"memory\\":\\"{{.MemUsage}}\\",\\"memoryPercent\\":\\"{{.MemPerc}}\\",\\"netIO\\":\\"{{.NetIO}}\\",\\"blockIO\\":\\"{{.BlockIO}}\\",\\"pids\\":\\"{{.PIDs}}\\"}"`
|
||||
: `'{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}' `;
|
||||
const command = `docker stats ${containerId} --no-stream --format ${statsFormatStr}`;
|
||||
|
||||
const output = await executeDockerCommand(
|
||||
session,
|
||||
|
||||
@@ -44,6 +44,7 @@ interface SSHSession {
|
||||
activeOperations: number;
|
||||
hostId?: number;
|
||||
userId?: string;
|
||||
isWindows?: boolean;
|
||||
}
|
||||
|
||||
interface PendingTOTPSession {
|
||||
@@ -852,9 +853,10 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
|
||||
if (
|
||||
resolvedCredentials.authType === "none" ||
|
||||
resolvedCredentials.authType === "tailscale"
|
||||
resolvedCredentials.authType === "tailscale" ||
|
||||
resolvedCredentials.authType === "warpgate"
|
||||
) {
|
||||
// Tailscale SSH and "none" auth: no credentials needed
|
||||
// Tailscale SSH, "none", and Warpgate auth: no static credentials
|
||||
} else if (resolvedCredentials.authType === "password") {
|
||||
if (resolvedCredentials.password) {
|
||||
config.password = resolvedCredentials.password;
|
||||
@@ -1038,7 +1040,7 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
),
|
||||
);
|
||||
|
||||
sshSessions[sessionId] = {
|
||||
const session: SSHSession = {
|
||||
client,
|
||||
isConnected: true,
|
||||
lastActive: Date.now(),
|
||||
@@ -1047,8 +1049,24 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
userId,
|
||||
};
|
||||
|
||||
sshSessions[sessionId] = session;
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
client.exec("ver", (err, stream) => {
|
||||
if (!err && stream) {
|
||||
let output = "";
|
||||
stream.on("data", (d: Buffer) => {
|
||||
output += d.toString();
|
||||
});
|
||||
stream.on("close", () => {
|
||||
if (output.toLowerCase().includes("windows")) {
|
||||
session.isWindows = true;
|
||||
}
|
||||
});
|
||||
stream.stderr.on("data", () => {});
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "SSH connection established",
|
||||
@@ -1313,6 +1331,11 @@ app.post("/docker/ssh/connect", async (req, res) => {
|
||||
/password/i.test(p.prompt),
|
||||
);
|
||||
|
||||
if (resolvedCredentials.authType === "warpgate") {
|
||||
finish(prompts.map(() => ""));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(resolvedCredentials.authType === "none" ||
|
||||
resolvedCredentials.authType === "tailscale") &&
|
||||
@@ -2144,7 +2167,7 @@ app.get("/docker/validate/:sessionId", async (req, res) => {
|
||||
try {
|
||||
await executeDockerCommand(
|
||||
session,
|
||||
"docker ps >/dev/null 2>&1",
|
||||
"docker ps",
|
||||
sessionId,
|
||||
userId,
|
||||
session.hostId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Express } from "express";
|
||||
import type { Express, Request, Response } from "express";
|
||||
import Busboy from "busboy";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { fileLogger } from "../utils/logger.js";
|
||||
import {
|
||||
@@ -1302,4 +1303,208 @@ export function registerFileContentRoutes(
|
||||
|
||||
trySFTP();
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/uploadFileStream:
|
||||
* post:
|
||||
* summary: Stream-upload a file via multipart form
|
||||
* description: Uploads a file to the remote host by streaming multipart form data directly into an SFTP write stream, avoiding full in-memory buffering.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - sessionId
|
||||
* - path
|
||||
* - file
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* file:
|
||||
* type: string
|
||||
* format: binary
|
||||
* responses:
|
||||
* 200:
|
||||
* description: File uploaded successfully.
|
||||
* 400:
|
||||
* description: Missing required parameters or SSH connection not established.
|
||||
* 500:
|
||||
* description: Failed to upload file.
|
||||
*/
|
||||
app.post(
|
||||
"/ssh/file_manager/ssh/uploadFileStream",
|
||||
(req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
const contentType = req.headers["content-type"] || "";
|
||||
if (!contentType.includes("multipart/form-data")) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Expected multipart/form-data request" });
|
||||
}
|
||||
|
||||
let sessionId: string | undefined;
|
||||
let remotePath: string | undefined;
|
||||
let fileName: string | undefined;
|
||||
let uploadStartTime: number;
|
||||
let resolved = false;
|
||||
|
||||
const bb = Busboy({ headers: req.headers });
|
||||
|
||||
bb.on("field", (name: string, value: string) => {
|
||||
if (name === "sessionId") sessionId = value;
|
||||
if (name === "path") remotePath = value;
|
||||
});
|
||||
|
||||
bb.on(
|
||||
"file",
|
||||
(
|
||||
fieldname: string,
|
||||
fileStream: NodeJS.ReadableStream,
|
||||
info: { filename: string; encoding: string; mimeType: string },
|
||||
) => {
|
||||
fileName = info.filename;
|
||||
|
||||
if (!sessionId || !remotePath || !fileName) {
|
||||
fileStream.resume();
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "Missing sessionId or path field" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sshConn = sshSessions[sessionId];
|
||||
if (!sshConn?.isConnected) {
|
||||
fileStream.resume();
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
res.status(400).json({ error: "SSH connection not established" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!verifySessionOwnership(sshConn, userId)) {
|
||||
fileStream.resume();
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
res.status(403).json({ error: "Session access denied" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
uploadStartTime = Date.now();
|
||||
|
||||
const fullPath = remotePath.endsWith("/")
|
||||
? remotePath + fileName
|
||||
: remotePath + "/" + fileName;
|
||||
|
||||
fileLogger.info("Streaming file upload started", {
|
||||
operation: "file_upload_stream_start",
|
||||
sessionId,
|
||||
userId,
|
||||
path: fullPath,
|
||||
});
|
||||
|
||||
getSessionSftp(sshConn)
|
||||
.then((sftp) => {
|
||||
const writeStream = sftp.createWriteStream(fullPath);
|
||||
|
||||
writeStream.on("error", (err) => {
|
||||
fileLogger.error("SFTP write stream error during upload:", err);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Upload failed: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
writeStream.on("finish", () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
fileLogger.success("Streaming file upload completed", {
|
||||
operation: "file_upload_stream_complete",
|
||||
sessionId,
|
||||
userId,
|
||||
path: fullPath,
|
||||
duration: Date.now() - uploadStartTime,
|
||||
});
|
||||
res.json({
|
||||
message: "File uploaded successfully",
|
||||
path: fullPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `File uploaded: ${fullPath}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
writeStream.on("close", () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
fileLogger.success("Streaming file upload completed", {
|
||||
operation: "file_upload_stream_complete",
|
||||
sessionId,
|
||||
userId,
|
||||
path: fullPath,
|
||||
duration: Date.now() - uploadStartTime,
|
||||
});
|
||||
res.json({
|
||||
message: "File uploaded successfully",
|
||||
path: fullPath,
|
||||
toast: {
|
||||
type: "success",
|
||||
message: `File uploaded: ${fullPath}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
fileStream.on("error", (err) => {
|
||||
fileLogger.error("File read stream error during upload:", err);
|
||||
writeStream.destroy();
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Upload stream error: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
(fileStream as NodeJS.ReadableStream).pipe(
|
||||
writeStream as unknown as NodeJS.WritableStream,
|
||||
);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
fileStream.resume();
|
||||
fileLogger.error("SFTP session error during stream upload:", err);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
res.status(500).json({ error: `SFTP error: ${err.message}` });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
bb.on("error", (err: Error) => {
|
||||
fileLogger.error("Busboy parse error during stream upload:", err);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
res.status(500).json({ error: `Upload parse error: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
req.pipe(bb);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { Express } from "express";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { fileLogger } from "../utils/logger.js";
|
||||
import { getMimeType } from "./file-manager-utils.js";
|
||||
import { getSessionSftp, type SSHSession } from "./file-manager-session.js";
|
||||
import {
|
||||
execChannel,
|
||||
getSessionSftp,
|
||||
type SSHSession,
|
||||
} from "./file-manager-session.js";
|
||||
|
||||
type FileDownloadRoutesDeps = {
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
@@ -10,6 +14,37 @@ type FileDownloadRoutesDeps = {
|
||||
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
|
||||
};
|
||||
|
||||
function escapeSingleQuotes(path: string): string {
|
||||
return path.replace(/'/g, "'\"'\"'");
|
||||
}
|
||||
|
||||
function downloadViaExec(
|
||||
session: SSHSession,
|
||||
filePath: string,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const escaped = escapeSingleQuotes(filePath);
|
||||
execChannel(session, `cat '${escaped}'`, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
const chunks: Buffer[] = [];
|
||||
let stderr = "";
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
stream.on("close", (code: number) => {
|
||||
if (code !== 0) {
|
||||
return reject(
|
||||
new Error(stderr.trim() || `cat exited with code ${code}`),
|
||||
);
|
||||
}
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
stream.on("error", reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function registerFileDownloadRoutes(
|
||||
app: Express,
|
||||
{
|
||||
@@ -23,7 +58,7 @@ export function registerFileDownloadRoutes(
|
||||
* /ssh/file_manager/ssh/downloadFile:
|
||||
* post:
|
||||
* summary: Download a file
|
||||
* description: Downloads a file from the remote host.
|
||||
* description: Downloads a file from the remote host. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
@@ -88,6 +123,45 @@ export function registerFileDownloadRoutes(
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
if (sshConn.scpLegacy) {
|
||||
fileLogger.info(
|
||||
"Downloading file via legacy exec/cat (SCP legacy mode)",
|
||||
{
|
||||
operation: "file_download_legacy",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const data = await downloadViaExec(sshConn, filePath);
|
||||
const fileName = filePath.split("/").pop() || "download";
|
||||
fileLogger.success("File download completed (legacy mode)", {
|
||||
operation: "file_download_complete",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
path: filePath,
|
||||
bytes: data.length,
|
||||
duration: Date.now() - downloadStartTime,
|
||||
});
|
||||
return res.json({
|
||||
content: data.toString("base64"),
|
||||
fileName,
|
||||
size: data.length,
|
||||
mimeType: getMimeType(fileName),
|
||||
path: filePath,
|
||||
});
|
||||
} catch (err) {
|
||||
fileLogger.error("Legacy exec/cat download failed:", err);
|
||||
return res.status(500).json({
|
||||
error: `Failed to download file: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fileLogger.info("Opening SFTP channel", {
|
||||
operation: "file_sftp_open",
|
||||
sessionId,
|
||||
@@ -168,6 +242,33 @@ export function registerFileDownloadRoutes(
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/file_manager/ssh/downloadFileStream:
|
||||
* post:
|
||||
* summary: Stream-download a file
|
||||
* description: Downloads a file as a binary stream. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
|
||||
* tags:
|
||||
* - File Manager
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* sessionId:
|
||||
* type: string
|
||||
* path:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Binary file stream.
|
||||
* 400:
|
||||
* description: Missing required parameters.
|
||||
* 500:
|
||||
* description: Download failed.
|
||||
*/
|
||||
app.post("/ssh/file_manager/ssh/downloadFileStream", async (req, res) => {
|
||||
const { sessionId, path: filePath } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -188,6 +289,43 @@ export function registerFileDownloadRoutes(
|
||||
|
||||
sshConn.lastActive = Date.now();
|
||||
|
||||
const fileName = filePath.split("/").pop() || "download";
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${encodeURIComponent(fileName)}"`,
|
||||
);
|
||||
|
||||
if (sshConn.scpLegacy) {
|
||||
fileLogger.info("Streaming file via legacy exec/cat (SCP legacy mode)", {
|
||||
operation: "file_download_stream_legacy",
|
||||
sessionId,
|
||||
userId,
|
||||
path: filePath,
|
||||
});
|
||||
|
||||
const escaped = escapeSingleQuotes(filePath);
|
||||
execChannel(sshConn, `cat '${escaped}'`, (err, stream) => {
|
||||
if (err) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: `Download failed: ${err.message}` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
stream.on("error", (streamErr: Error) => {
|
||||
if (!res.headersSent) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Download failed: ${streamErr.message}` });
|
||||
} else {
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
stream.pipe(res);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sftp = await getSessionSftp(sshConn);
|
||||
const stats = await new Promise<{ size: number; isFile: () => boolean }>(
|
||||
@@ -200,12 +338,6 @@ export function registerFileDownloadRoutes(
|
||||
return res.status(400).json({ error: "Cannot download directories" });
|
||||
}
|
||||
|
||||
const fileName = filePath.split("/").pop() || "download";
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${encodeURIComponent(fileName)}"`,
|
||||
);
|
||||
res.setHeader("Content-Length", String(stats.size));
|
||||
|
||||
const readStream = sftp.createReadStream(filePath);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
execWithSudo,
|
||||
type SSHSession,
|
||||
} from "./file-manager-session.js";
|
||||
import { isWindowsSftpPath } from "./transfer-paths.js";
|
||||
|
||||
type FileOperationRoutesDeps = {
|
||||
sshSessions: Record<string, SSHSession>;
|
||||
@@ -373,11 +374,23 @@ export function registerFileOperationRoutes(
|
||||
type: isDirectory ? "directory" : "file",
|
||||
});
|
||||
sshConn.lastActive = Date.now();
|
||||
const escapedPath = itemPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const deleteCommand = isDirectory
|
||||
? `rm -rf '${escapedPath}'`
|
||||
: `rm -f '${escapedPath}'`;
|
||||
const isWindowsPath = isWindowsSftpPath(itemPath);
|
||||
let deleteCommand: string;
|
||||
if (isWindowsPath) {
|
||||
const winPath = itemPath
|
||||
.replace(/\//g, "\\")
|
||||
.replace(/^\\([A-Za-z]:\\)/, "$1");
|
||||
const escapedWinPath = winPath.replace(/"/g, '""');
|
||||
deleteCommand = isDirectory
|
||||
? `rd /s /q "${escapedWinPath}"`
|
||||
: `del /f /q "${escapedWinPath}"`;
|
||||
} else {
|
||||
const escapedPath = itemPath.replace(/'/g, "'\"'\"'");
|
||||
deleteCommand = isDirectory
|
||||
? `rm -rf '${escapedPath}'`
|
||||
: `rm -f '${escapedPath}'`;
|
||||
}
|
||||
|
||||
const executeDelete = (useSudo: boolean): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
@@ -465,10 +478,6 @@ export function registerFileOperationRoutes(
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// The shell didn't echo our SUCCESS sentinel. This happens on
|
||||
// non-POSIX shells (e.g. Windows PowerShell, where `rm -f` is
|
||||
// not supported) and the command can exit 0 while doing nothing.
|
||||
// Surface whatever the shell produced so the failure isn't silent.
|
||||
const detail =
|
||||
errorData.trim() ||
|
||||
outputData.trim() ||
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface SSHSession {
|
||||
transferDedicated?: boolean;
|
||||
transferId?: string;
|
||||
browseSessionId?: string;
|
||||
scpLegacy?: boolean;
|
||||
}
|
||||
|
||||
export interface PendingTOTPSession {
|
||||
|
||||
@@ -132,6 +132,7 @@ async function buildDedicatedTransferConnectConfig(
|
||||
client: SSHClient,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const { ip, port, username } = host;
|
||||
const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(host.id);
|
||||
const config: Record<string, unknown> = {
|
||||
host: ip?.replace(/^\[|\]$/g, "") || ip,
|
||||
port,
|
||||
@@ -149,6 +150,7 @@ async function buildDedicatedTransferConnectConfig(
|
||||
null,
|
||||
userId,
|
||||
false,
|
||||
preloadedHostData,
|
||||
),
|
||||
env: {
|
||||
TERM: "xterm-256color",
|
||||
@@ -726,6 +728,13 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
let resolvedPort = port;
|
||||
let resolvedUsername = username;
|
||||
let resolvedJumpHosts = jumpHosts;
|
||||
let resolvedScpLegacy = false;
|
||||
let resolvedUseSocks5 = useSocks5;
|
||||
let resolvedSocks5Host = socks5Host;
|
||||
let resolvedSocks5Port = socks5Port;
|
||||
let resolvedSocks5Username = socks5Username;
|
||||
let resolvedSocks5Password = socks5Password;
|
||||
let resolvedSocks5ProxyChain = socks5ProxyChain;
|
||||
if (hostId && userId && !password && !sshKey) {
|
||||
try {
|
||||
const { resolveHostById } = await import("./host-resolver.js");
|
||||
@@ -743,6 +752,15 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
};
|
||||
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
|
||||
hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax;
|
||||
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
|
||||
if (resolvedHost.useSocks5) {
|
||||
resolvedUseSocks5 = resolvedHost.useSocks5;
|
||||
resolvedSocks5Host = resolvedHost.socks5Host;
|
||||
resolvedSocks5Port = resolvedHost.socks5Port;
|
||||
resolvedSocks5Username = resolvedHost.socks5Username;
|
||||
resolvedSocks5Password = resolvedHost.socks5Password;
|
||||
resolvedSocks5ProxyChain = resolvedHost.socks5ProxyChain;
|
||||
}
|
||||
if (
|
||||
(!resolvedJumpHosts || resolvedJumpHosts.length === 0) &&
|
||||
resolvedHost.jumpHosts &&
|
||||
@@ -790,6 +808,15 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
};
|
||||
hostKeepaliveInterval = resolvedHost.terminalConfig?.keepaliveInterval;
|
||||
hostKeepaliveCountMax = resolvedHost.terminalConfig?.keepaliveCountMax;
|
||||
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
|
||||
if (resolvedHost.useSocks5) {
|
||||
resolvedUseSocks5 = resolvedHost.useSocks5;
|
||||
resolvedSocks5Host = resolvedHost.socks5Host;
|
||||
resolvedSocks5Port = resolvedHost.socks5Port;
|
||||
resolvedSocks5Username = resolvedHost.socks5Username;
|
||||
resolvedSocks5Password = resolvedHost.socks5Password;
|
||||
resolvedSocks5ProxyChain = resolvedHost.socks5ProxyChain;
|
||||
}
|
||||
if (
|
||||
(!resolvedJumpHosts || resolvedJumpHosts.length === 0) &&
|
||||
resolvedHost.jumpHosts &&
|
||||
@@ -822,6 +849,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(hostId);
|
||||
const config: Record<string, unknown> = {
|
||||
host: resolvedIp?.replace(/^\[|\]$/g, "") || resolvedIp,
|
||||
port: resolvedPort,
|
||||
@@ -829,10 +857,12 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval:
|
||||
typeof hostKeepaliveInterval === "number"
|
||||
? hostKeepaliveInterval * 1000
|
||||
? Math.max(5000, hostKeepaliveInterval * 1000)
|
||||
: 60000,
|
||||
keepaliveCountMax:
|
||||
typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 5,
|
||||
typeof hostKeepaliveCountMax === "number"
|
||||
? Math.max(1, hostKeepaliveCountMax)
|
||||
: 5,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
@@ -843,6 +873,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
null,
|
||||
userId,
|
||||
false,
|
||||
preloadedHostData,
|
||||
),
|
||||
env: {
|
||||
TERM: "xterm-256color",
|
||||
@@ -1015,7 +1046,8 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
}
|
||||
} else if (
|
||||
resolvedCredentials.authType === "none" ||
|
||||
resolvedCredentials.authType === "tailscale"
|
||||
resolvedCredentials.authType === "tailscale" ||
|
||||
resolvedCredentials.authType === "warpgate"
|
||||
) {
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
@@ -1109,6 +1141,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
hostId,
|
||||
username,
|
||||
sudoPassword: resolvedCredentials.sudoPassword,
|
||||
scpLegacy: resolvedScpLegacy,
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
res.json({
|
||||
@@ -1266,6 +1299,14 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
}
|
||||
|
||||
if (
|
||||
err.message.includes("Cannot parse privateKey") &&
|
||||
err.message.includes("no passphrase")
|
||||
) {
|
||||
res.json({
|
||||
status: "passphrase_required",
|
||||
connectionLogs,
|
||||
});
|
||||
} else if (
|
||||
(resolvedCredentials.authType === "none" ||
|
||||
resolvedCredentials.authType === "tailscale") &&
|
||||
(err.message.includes("authentication") ||
|
||||
@@ -1426,12 +1467,18 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
const hasStoredPassword =
|
||||
resolvedCredentials.password &&
|
||||
resolvedCredentials.authType !== "none" &&
|
||||
resolvedCredentials.authType !== "tailscale";
|
||||
resolvedCredentials.authType !== "tailscale" &&
|
||||
resolvedCredentials.authType !== "warpgate";
|
||||
|
||||
const passwordPromptIndex = prompts.findIndex((p) =>
|
||||
/password/i.test(p.prompt),
|
||||
);
|
||||
|
||||
if (resolvedCredentials.authType === "warpgate") {
|
||||
finish(prompts.map(() => ""));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(resolvedCredentials.authType === "none" ||
|
||||
resolvedCredentials.authType === "tailscale") &&
|
||||
@@ -1512,16 +1559,17 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
);
|
||||
|
||||
const proxyConfig: SOCKS5Config | null =
|
||||
useSocks5 &&
|
||||
(socks5Host ||
|
||||
(socks5ProxyChain && (socks5ProxyChain as ProxyNode[]).length > 0))
|
||||
resolvedUseSocks5 &&
|
||||
(resolvedSocks5Host ||
|
||||
(resolvedSocks5ProxyChain &&
|
||||
(resolvedSocks5ProxyChain as ProxyNode[]).length > 0))
|
||||
? {
|
||||
useSocks5,
|
||||
socks5Host,
|
||||
socks5Port,
|
||||
socks5Username,
|
||||
socks5Password,
|
||||
socks5ProxyChain: socks5ProxyChain as ProxyNode[],
|
||||
useSocks5: resolvedUseSocks5,
|
||||
socks5Host: resolvedSocks5Host,
|
||||
socks5Port: resolvedSocks5Port,
|
||||
socks5Username: resolvedSocks5Username,
|
||||
socks5Password: resolvedSocks5Password,
|
||||
socks5ProxyChain: resolvedSocks5ProxyChain as ProxyNode[],
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -1571,7 +1619,37 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
let forwardOutDone = false;
|
||||
const forwardOutTimeout = setTimeout(() => {
|
||||
if (!forwardOutDone) {
|
||||
forwardOutDone = true;
|
||||
fileLogger.error("Timeout waiting for jump host forwardOut", {
|
||||
operation: "file_jump_forward_timeout",
|
||||
sessionId,
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
});
|
||||
connectionLogs.push(
|
||||
createConnectionLog(
|
||||
"error",
|
||||
"jump",
|
||||
"Timed out waiting for jump host tunnel to target host",
|
||||
),
|
||||
);
|
||||
jumpClient.end();
|
||||
res.status(500).json({
|
||||
error: "Jump host tunnel timed out",
|
||||
connectionLogs,
|
||||
});
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => {
|
||||
if (forwardOutDone) return;
|
||||
forwardOutDone = true;
|
||||
clearTimeout(forwardOutTimeout);
|
||||
|
||||
if (err) {
|
||||
fileLogger.error("Failed to forward through jump host", err, {
|
||||
operation: "file_jump_forward",
|
||||
|
||||
@@ -21,6 +21,37 @@ interface VerificationResponse {
|
||||
}
|
||||
|
||||
export class SSHHostKeyVerifier {
|
||||
/**
|
||||
* Pre-fetches the host record from the database so the verifier callback
|
||||
* during SSH key exchange doesn't need to do an async DB query. This keeps
|
||||
* the key exchange critical path fast, avoiding LoginGraceTime expiry on
|
||||
* the remote server (especially important for jump-host tunneled connections).
|
||||
*/
|
||||
static async preloadHostData(hostId: number | null): Promise<{
|
||||
hostKeyFingerprint: string | null;
|
||||
hostKeyType: string | null;
|
||||
hostKeyAlgorithm: string | null;
|
||||
hostKeyChangedCount: number | null;
|
||||
name: string | null;
|
||||
} | null> {
|
||||
if (!hostId) return null;
|
||||
try {
|
||||
const host = await db.query.hosts.findFirst({
|
||||
where: eq(hosts.id, hostId),
|
||||
columns: {
|
||||
hostKeyFingerprint: true,
|
||||
hostKeyType: true,
|
||||
hostKeyAlgorithm: true,
|
||||
hostKeyChangedCount: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
return host ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async createHostVerifier(
|
||||
hostId: number | null,
|
||||
ip: string,
|
||||
@@ -28,6 +59,9 @@ export class SSHHostKeyVerifier {
|
||||
ws: WebSocket | null,
|
||||
userId: string,
|
||||
isJumpHost: boolean = false,
|
||||
preloadedHost?: Awaited<
|
||||
ReturnType<typeof SSHHostKeyVerifier.preloadHostData>
|
||||
>,
|
||||
): Promise<(hostkey: Buffer, verify: (valid: boolean) => void) => void> {
|
||||
return (hostkey: Buffer, verify: (valid: boolean) => void): void => {
|
||||
(async () => {
|
||||
@@ -52,9 +86,10 @@ export class SSHHostKeyVerifier {
|
||||
return;
|
||||
}
|
||||
|
||||
const host = await db.query.hosts.findFirst({
|
||||
where: eq(hosts.id, hostId),
|
||||
});
|
||||
const host =
|
||||
preloadedHost !== undefined
|
||||
? preloadedHost
|
||||
: await db.query.hosts.findFirst({ where: eq(hosts.id, hostId) });
|
||||
|
||||
if (!host) {
|
||||
sshLogger.warn(
|
||||
@@ -141,13 +176,6 @@ export class SSHHostKeyVerifier {
|
||||
}
|
||||
|
||||
if (host.hostKeyFingerprint === fingerprint) {
|
||||
await db
|
||||
.update(hosts)
|
||||
.set({
|
||||
hostKeyLastVerified: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(hosts.id, hostId));
|
||||
|
||||
sshLogger.info("Host key verified successfully", {
|
||||
operation: "host_key_verified",
|
||||
hostId,
|
||||
@@ -158,7 +186,18 @@ export class SSHHostKeyVerifier {
|
||||
userId,
|
||||
});
|
||||
|
||||
// Verify first, then update the timestamp asynchronously so the
|
||||
// DB write doesn't delay the SSH key exchange critical path.
|
||||
verify(true);
|
||||
db.update(hosts)
|
||||
.set({ hostKeyLastVerified: new Date().toISOString() })
|
||||
.where(eq(hosts.id, hostId))
|
||||
.catch((err) => {
|
||||
sshLogger.error("Failed to update hostKeyLastVerified", err, {
|
||||
operation: "host_key_update_timestamp",
|
||||
hostId,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,9 @@ interface SSHHostWithCredentials {
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: ProxyNode[];
|
||||
connectionType?: "ssh" | "rdp" | "vnc" | "telnet";
|
||||
rdpPort?: number;
|
||||
vncPort?: number;
|
||||
telnetPort?: number;
|
||||
}
|
||||
|
||||
type StatusEntry = {
|
||||
@@ -348,7 +351,12 @@ class PollingManager {
|
||||
|
||||
try {
|
||||
let pingHost = refreshedHost.ip;
|
||||
let pingPort = refreshedHost.port;
|
||||
const ct = refreshedHost.connectionType || "ssh";
|
||||
let pingPort: number;
|
||||
if (ct === "rdp") pingPort = refreshedHost.rdpPort ?? 3389;
|
||||
else if (ct === "vnc") pingPort = refreshedHost.vncPort ?? 5900;
|
||||
else if (ct === "telnet") pingPort = refreshedHost.telnetPort ?? 23;
|
||||
else pingPort = refreshedHost.port;
|
||||
if (refreshedHost.jumpHosts && refreshedHost.jumpHosts.length > 0) {
|
||||
const firstJump = await fetchHostById(
|
||||
refreshedHost.jumpHosts[0].hostId,
|
||||
@@ -792,6 +800,12 @@ async function resolveHostCredentials(
|
||||
socks5ProxyChain: host.socks5ProxyChain
|
||||
? JSON.parse(host.socks5ProxyChain as string)
|
||||
: undefined,
|
||||
connectionType:
|
||||
(host.connectionType as SSHHostWithCredentials["connectionType"]) ||
|
||||
"ssh",
|
||||
rdpPort: (host.rdpPort as number | undefined) ?? undefined,
|
||||
vncPort: (host.vncPort as number | undefined) ?? undefined,
|
||||
telnetPort: (host.telnetPort as number | undefined) ?? undefined,
|
||||
};
|
||||
|
||||
if (host.credentialId) {
|
||||
@@ -1028,7 +1042,11 @@ async function buildSshConfig(
|
||||
cause: keyError,
|
||||
});
|
||||
}
|
||||
} else if (host.authType === "none" || host.authType === "tailscale") {
|
||||
} else if (
|
||||
host.authType === "none" ||
|
||||
host.authType === "tailscale" ||
|
||||
host.authType === "warpgate"
|
||||
) {
|
||||
// no credentials needed
|
||||
} else if (host.authType === "opkssh") {
|
||||
// cert auth setup happens in createSshFactory (needs client instance)
|
||||
|
||||
@@ -3,7 +3,10 @@ import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import { pickResolvedUsername } from "./credential-username.js";
|
||||
import {
|
||||
pickResolvedUsername,
|
||||
expandOidcUsername,
|
||||
} from "./credential-username.js";
|
||||
import type { SSHHost } from "../../types/index.js";
|
||||
|
||||
const sshLogger = logger;
|
||||
@@ -120,6 +123,10 @@ export async function resolveHostById(
|
||||
: cred.password
|
||||
? "password"
|
||||
: "none";
|
||||
host.username = await expandOidcUsername(
|
||||
host.username as string | undefined,
|
||||
userId,
|
||||
);
|
||||
return host as unknown as SSHHost;
|
||||
}
|
||||
}
|
||||
@@ -150,6 +157,10 @@ export async function resolveHostById(
|
||||
: sharedCred.password
|
||||
? "password"
|
||||
: "none";
|
||||
host.username = await expandOidcUsername(
|
||||
host.username as string | undefined,
|
||||
userId,
|
||||
);
|
||||
return host as unknown as SSHHost;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -203,6 +214,11 @@ export async function resolveHostById(
|
||||
}
|
||||
}
|
||||
|
||||
host.username = await expandOidcUsername(
|
||||
host.username as string | undefined,
|
||||
userId,
|
||||
);
|
||||
|
||||
return host as unknown as SSHHost;
|
||||
}
|
||||
|
||||
|
||||
+131
-119
@@ -5,7 +5,7 @@ import ssh2Pkg, {
|
||||
type PseudoTtyOptions,
|
||||
} from "ssh2";
|
||||
const { Client, utils: ssh2Utils } = ssh2Pkg;
|
||||
import { SSH_ALGORITHMS } from "../utils/ssh-algorithms.js";
|
||||
import { buildSSHAlgorithms } from "../utils/ssh-algorithms.js";
|
||||
import axios from "axios";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { hosts } from "../database/db/schema.js";
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
waitForTmuxSession,
|
||||
} from "./tmux-helper.js";
|
||||
import { MemoryAgent, performPortKnocking } from "./terminal-auth-helpers.js";
|
||||
import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js";
|
||||
|
||||
interface ConnectToHostData {
|
||||
cols: number;
|
||||
@@ -187,9 +188,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
let isConnecting = false;
|
||||
let isConnected = false;
|
||||
let isCleaningUp = false;
|
||||
let cwdPending = false;
|
||||
let cwdBuffer = "";
|
||||
let isShellInitializing = false;
|
||||
let isDuplicateConnDiscarded = false;
|
||||
let warpgateAuthPromptSent = false;
|
||||
let warpgateAuthTimeout: NodeJS.Timeout | null = null;
|
||||
let isAwaitingAuthCredentials = false;
|
||||
@@ -237,7 +237,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
if (currentSessionId) {
|
||||
const session = sessionManager.getSession(currentSessionId);
|
||||
if (session?.isConnected) {
|
||||
sessionManager.detachWs(currentSessionId);
|
||||
// Only detach if this WS is still the one attached to the session.
|
||||
// If a refresh reconnected and reattached a new WS before this close
|
||||
// event fired, we must not clobber that new attachment.
|
||||
if (session.attachedWs === ws || session.attachedWs === null) {
|
||||
sessionManager.detachWs(currentSessionId);
|
||||
}
|
||||
} else {
|
||||
sessionManager.destroySession(currentSessionId);
|
||||
currentSessionId = null;
|
||||
@@ -446,15 +451,80 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
break;
|
||||
|
||||
case "get_cwd": {
|
||||
const activeStream =
|
||||
sessionManager.getSession(currentSessionId)?.sshStream ?? sshStream;
|
||||
if (!activeStream) {
|
||||
const activeConn =
|
||||
sessionManager.getSession(currentSessionId)?.sshConn ?? sshConn;
|
||||
if (!activeConn) {
|
||||
ws.send(JSON.stringify({ type: "cwd", path: "/" }));
|
||||
break;
|
||||
}
|
||||
cwdPending = true;
|
||||
cwdBuffer = "";
|
||||
activeStream.write('\x15a=TERMIX_CWD; echo "$a:$(pwd)"\r');
|
||||
activeConn.exec("pwd", (err, execStream) => {
|
||||
if (err) {
|
||||
ws.send(JSON.stringify({ type: "cwd", path: "/" }));
|
||||
return;
|
||||
}
|
||||
let stdout = "";
|
||||
execStream.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString("utf-8");
|
||||
});
|
||||
execStream.stderr.on("data", () => {});
|
||||
execStream.on("close", () => {
|
||||
const cwd = stdout.trim() || "/";
|
||||
const attachedWs =
|
||||
sessionManager.getSession(currentSessionId)?.attachedWs ?? ws;
|
||||
if (attachedWs.readyState === WebSocket.OPEN) {
|
||||
attachedWs.send(JSON.stringify({ type: "cwd", path: cwd }));
|
||||
}
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "open_file_in_editor": {
|
||||
const { path: requestedPath } = data as { path: string };
|
||||
const activeConn =
|
||||
sessionManager.getSession(currentSessionId)?.sshConn ?? sshConn;
|
||||
if (!activeConn || !requestedPath) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "open_file_in_editor",
|
||||
path: requestedPath || "/",
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const escapedPath = requestedPath.replace(/'/g, "'\\''");
|
||||
activeConn.exec(
|
||||
`realpath '${escapedPath}' 2>/dev/null || echo '${escapedPath}'`,
|
||||
(err, execStream) => {
|
||||
if (err) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "open_file_in_editor",
|
||||
path: requestedPath,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let stdout = "";
|
||||
execStream.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString("utf-8");
|
||||
});
|
||||
execStream.stderr.on("data", () => {});
|
||||
execStream.on("close", () => {
|
||||
const resolvedPath = stdout.trim() || requestedPath;
|
||||
const attachedWs =
|
||||
sessionManager.getSession(currentSessionId)?.attachedWs ?? ws;
|
||||
if (attachedWs.readyState === WebSocket.OPEN) {
|
||||
attachedWs.send(
|
||||
JSON.stringify({
|
||||
type: "open_file_in_editor",
|
||||
path: resolvedPath,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -990,7 +1060,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig.useSocks5 && resolvedHostData.useSocks5) {
|
||||
if (resolvedHostData.useSocks5) {
|
||||
hostConfig.useSocks5 = resolvedHostData.useSocks5;
|
||||
hostConfig.socks5Host = resolvedHostData.socks5Host;
|
||||
hostConfig.socks5Port = resolvedHostData.socks5Port;
|
||||
@@ -1134,27 +1204,43 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
!existingSession.sshStream.destroyed &&
|
||||
existingSession.sshConn !== sshConn
|
||||
) {
|
||||
const reusedSessionId = currentSessionId;
|
||||
sshLogger.info(
|
||||
"Reusing existing live session after duplicate connectToHost, closing new SSH conn",
|
||||
{
|
||||
operation: "terminal_reuse_existing_session",
|
||||
sessionId: currentSessionId,
|
||||
sessionId: reusedSessionId,
|
||||
tabInstanceId,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
// Null out currentSessionId before ending the duplicate connection so
|
||||
// the sshConn "close" handler does not destroy the reused session.
|
||||
// Set isDuplicateConnDiscarded so the close handler does not send a
|
||||
// "disconnected" message to the new WS that is now attached to the live session.
|
||||
// Null out currentSessionId before ending the duplicate connection so
|
||||
// the sshConn "close" handler does not destroy the reused session.
|
||||
// Set isDuplicateConnDiscarded so the close handler exits without
|
||||
// sending a "disconnected" message to the new WS.
|
||||
currentSessionId = null;
|
||||
isDuplicateConnDiscarded = true;
|
||||
clearTimeout(connectionTimeout);
|
||||
try {
|
||||
sshConn?.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
sshConn = null;
|
||||
sshStream = null;
|
||||
|
||||
// Point this WS handler's closure at the live session so the input
|
||||
// handler can forward keystrokes via currentSessionId.
|
||||
currentSessionId = reusedSessionId;
|
||||
sshStream = existingSession.sshStream;
|
||||
sshConn = existingSession.sshConn;
|
||||
isConnecting = false;
|
||||
isConnected = true;
|
||||
sessionManager.attachWs(currentSessionId, userId, ws, tabInstanceId);
|
||||
sessionManager.attachWs(reusedSessionId, userId, ws, tabInstanceId);
|
||||
|
||||
const buffered = sessionManager.getBuffer(existingSession);
|
||||
if (buffered) {
|
||||
@@ -1163,20 +1249,18 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "sessionCreated",
|
||||
sessionId: currentSessionId,
|
||||
sessionId: reusedSessionId,
|
||||
}),
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "sessionAttached",
|
||||
sessionId: currentSessionId,
|
||||
sessionId: reusedSessionId,
|
||||
}),
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({ type: "connected", message: "Session reattached" }),
|
||||
);
|
||||
|
||||
cleanupAuthState(connectionTimeout);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1350,44 +1434,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
const boundSessionId = currentSessionId;
|
||||
|
||||
const CWD_SENTINEL = "TERMIX_CWD:";
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
try {
|
||||
let utf8String = data.toString("utf-8");
|
||||
|
||||
if (cwdPending) {
|
||||
cwdBuffer += utf8String;
|
||||
const sentinelIdx = cwdBuffer.indexOf(CWD_SENTINEL);
|
||||
if (sentinelIdx !== -1) {
|
||||
const afterSentinel = cwdBuffer.slice(
|
||||
sentinelIdx + CWD_SENTINEL.length,
|
||||
);
|
||||
const newlineIdx = afterSentinel.search(/[\r\n]/);
|
||||
if (newlineIdx !== -1) {
|
||||
const cwd =
|
||||
afterSentinel.slice(0, newlineIdx).trim() || "/";
|
||||
cwdPending = false;
|
||||
// Strip the sentinel line from output sent to terminal
|
||||
const beforeSentinel = cwdBuffer.slice(0, sentinelIdx);
|
||||
const afterNewline = afterSentinel.slice(newlineIdx);
|
||||
utf8String = beforeSentinel + afterNewline;
|
||||
cwdBuffer = "";
|
||||
const attachedWs =
|
||||
sessionManager.getSession(boundSessionId)?.attachedWs ??
|
||||
ws;
|
||||
if (attachedWs.readyState === WebSocket.OPEN) {
|
||||
attachedWs.send(
|
||||
JSON.stringify({ type: "cwd", path: cwd }),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const utf8String = data.toString("utf-8");
|
||||
|
||||
if (!utf8String) return;
|
||||
|
||||
@@ -1475,7 +1524,14 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const runPostShellCommands = (delay: number) => {
|
||||
setTimeout(() => {
|
||||
if (initialPath && initialPath.trim() !== "") {
|
||||
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}"\r`;
|
||||
let cdCommand: string;
|
||||
if (isWindowsSftpPath(initialPath)) {
|
||||
const winPath = sftpPathToLocalPath(initialPath);
|
||||
const escaped = winPath.replace(/"/g, '""');
|
||||
cdCommand = `cd "${escaped}"\r`;
|
||||
} else {
|
||||
cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}"\r`;
|
||||
}
|
||||
stream.write(cdCommand);
|
||||
}
|
||||
if (executeCommand && executeCommand.trim() !== "") {
|
||||
@@ -1543,27 +1599,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}),
|
||||
);
|
||||
runPostShellCommands(0);
|
||||
} else if (detection.sessions.length === 1) {
|
||||
attachOrCreateTmuxSession(stream, detection.sessions[0].name);
|
||||
const sessionName = detection.sessions[0].name;
|
||||
const session = sessionManager.getSession(boundSessionId);
|
||||
if (session) {
|
||||
session.tmuxSessionName = sessionName;
|
||||
}
|
||||
sshLogger.info("Auto-attached to existing tmux session", {
|
||||
operation: "tmux_auto_attach",
|
||||
sessionName,
|
||||
hostId: id,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "tmux_session_attached",
|
||||
sessionName,
|
||||
}),
|
||||
);
|
||||
// Reattaching to existing session -- don't re-run
|
||||
// initialPath/executeCommand since the session already
|
||||
// has its own state
|
||||
} else {
|
||||
sshLogger.info(
|
||||
"Multiple tmux sessions found, sending list to frontend",
|
||||
@@ -1910,6 +1945,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
hostId: id,
|
||||
});
|
||||
|
||||
if (isDuplicateConnDiscarded) {
|
||||
cleanupAuthState(connectionTimeout);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAwaitingAuthCredentials) {
|
||||
if (currentSessionId) {
|
||||
sessionManager.destroySession(currentSessionId);
|
||||
@@ -1991,6 +2031,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
resolvedCredentials as unknown as Parameters<
|
||||
typeof sshAuthManager.handleKeyboardInteractive
|
||||
>[5],
|
||||
hostConfig,
|
||||
);
|
||||
|
||||
isKeyboardInteractive = sshAuthManager.context.isKeyboardInteractive;
|
||||
@@ -2008,19 +2049,24 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const hostKeepaliveInterval = hostConfig.terminalConfig?.keepaliveInterval;
|
||||
const hostKeepaliveCountMax = hostConfig.terminalConfig?.keepaliveCountMax;
|
||||
|
||||
// Pre-fetch the stored host key before connect so the verifier callback
|
||||
// runs synchronously during SSH key exchange, avoiding LoginGraceTime
|
||||
// expiry on slow connections (especially through jump host tunnels).
|
||||
const preloadedHostData = await SSHHostKeyVerifier.preloadHostData(id);
|
||||
|
||||
const connectConfig: Record<string, unknown> = {
|
||||
host: ip,
|
||||
port,
|
||||
username,
|
||||
tryKeyboard:
|
||||
resolvedCredentials.authType !== "none" &&
|
||||
resolvedCredentials.authType !== "tailscale",
|
||||
tryKeyboard: resolvedCredentials.authType !== "tailscale",
|
||||
keepaliveInterval:
|
||||
typeof hostKeepaliveInterval === "number"
|
||||
? hostKeepaliveInterval * 1000
|
||||
? Math.max(5000, hostKeepaliveInterval * 1000)
|
||||
: 30000,
|
||||
keepaliveCountMax:
|
||||
typeof hostKeepaliveCountMax === "number" ? hostKeepaliveCountMax : 3,
|
||||
typeof hostKeepaliveCountMax === "number"
|
||||
? Math.max(1, hostKeepaliveCountMax)
|
||||
: 5,
|
||||
readyTimeout: 120000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
@@ -2032,6 +2078,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
ws,
|
||||
userId,
|
||||
false,
|
||||
preloadedHostData,
|
||||
),
|
||||
env: {
|
||||
TERM: "xterm-256color",
|
||||
@@ -2045,51 +2092,16 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
LC_COLLATE: "en_US.UTF-8",
|
||||
COLORTERM: "truecolor",
|
||||
},
|
||||
algorithms: {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group18-sha512",
|
||||
"diffie-hellman-group17-sha512",
|
||||
"diffie-hellman-group16-sha512",
|
||||
"diffie-hellman-group15-sha512",
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
],
|
||||
serverHostKey: [
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
"ssh-rsa",
|
||||
"ssh-dss",
|
||||
],
|
||||
cipher: SSH_ALGORITHMS.cipher,
|
||||
hmac: [
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
algorithms: buildSSHAlgorithms(
|
||||
hostConfig.terminalConfig?.allowLegacyAlgorithms !== false,
|
||||
),
|
||||
};
|
||||
|
||||
if (
|
||||
resolvedCredentials.authType === "none" ||
|
||||
resolvedCredentials.authType === "tailscale"
|
||||
) {
|
||||
// Tailscale SSH and "none" auth: daemon handles authorization, no credentials needed
|
||||
// Tailscale SSH and "none": no static credentials needed
|
||||
} else if (resolvedCredentials.authType === "password") {
|
||||
if (!resolvedCredentials.password) {
|
||||
sshLogger.error(
|
||||
|
||||
Reference in New Issue
Block a user