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:
Luke Gustafson
2026-06-21 15:55:41 -05:00
committed by GitHub
parent 8072b4ede9
commit d1a8dcf3c6
165 changed files with 87244 additions and 60766 deletions
+36 -4
View File
@@ -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;
+72 -2
View File
@@ -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",
);
});
});
+34
View File
@@ -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() !== "";
}
+12 -3
View File
@@ -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,
+27 -4
View File
@@ -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,
+206 -1
View File
@@ -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);
},
);
}
+140 -8
View File
@@ -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() ||
+1
View File
@@ -39,6 +39,7 @@ export interface SSHSession {
transferDedicated?: boolean;
transferId?: string;
browseSessionId?: string;
scpLegacy?: boolean;
}
export interface PendingTOTPSession {
+91 -13
View File
@@ -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",
+49 -10
View File
@@ -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;
}
+20 -2
View File
@@ -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)
+17 -1
View File
@@ -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
View File
@@ -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(