mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: convert ssh tunnels into a plugin
This commit is contained in:
1 parent
c85c1fdb86
commit
1682861853
220 files changed
+36928
-11374
No files matched your search
@@ -10,7 +10,6 @@ import hostRoutes from "./routes/host.js";
|
||||
import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import sshAuthRoutes from "./routes/ssh-auth-routes.js";
|
||||
import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
|
||||
import terminalRoutes from "./routes/terminal.js";
|
||||
import sessionLogRoutes from "./routes/session-log-routes.js";
|
||||
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
|
||||
@@ -1809,7 +1808,6 @@ app.use("/host", hostRoutes);
|
||||
app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
app.use("/ssh-auth", sshAuthRoutes);
|
||||
app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
|
||||
app.use("/terminal", terminalRoutes);
|
||||
app.use("/session_logs", sessionLogRoutes);
|
||||
app.use("/session-sharing", sessionSharingRoutes);
|
||||
|
||||
@@ -394,18 +394,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS c2s_tunnel_presets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
config TEXT NOT NULL,
|
||||
platform TEXT,
|
||||
computer_name TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
|
||||
@@ -466,23 +466,6 @@ export const sshCredentialUsage = mysqlTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const c2sTunnelPresets = mysqlTable("c2s_tunnel_presets", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
config: text("config").notNull(),
|
||||
platform: text("platform"),
|
||||
computerName: text("computer_name"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
|
||||
export const sshFolders = mysqlTable(
|
||||
"ssh_folders",
|
||||
{
|
||||
|
||||
@@ -467,23 +467,6 @@ export const sshCredentialUsage = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const c2sTunnelPresets = pgTable("c2s_tunnel_presets", {
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
config: text("config").notNull(),
|
||||
platform: text("platform"),
|
||||
computerName: text("computer_name"),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: varchar("updated_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshFolders = pgTable(
|
||||
"ssh_folders",
|
||||
{
|
||||
|
||||
@@ -459,23 +459,6 @@ export const sshCredentialUsage = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const c2sTunnelPresets = sqliteTable("c2s_tunnel_presets", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
config: text("config").notNull(),
|
||||
platform: text("platform"),
|
||||
computerName: text("computer_name"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshFolders = sqliteTable(
|
||||
"ssh_folders",
|
||||
{
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { and, asc, eq, sql } from "drizzle-orm";
|
||||
import { c2sTunnelPresets } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type C2sTunnelPresetRecord = typeof c2sTunnelPresets.$inferSelect;
|
||||
|
||||
export interface C2sTunnelPresetCreateInput {
|
||||
name: string;
|
||||
config: string;
|
||||
platform?: string | null;
|
||||
computerName?: string | null;
|
||||
}
|
||||
|
||||
export type C2sTunnelPresetUpdateInput = Partial<C2sTunnelPresetCreateInput>;
|
||||
|
||||
export class C2sTunnelPresetRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listByUserId(userId: string): Promise<C2sTunnelPresetRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(c2sTunnelPresets)
|
||||
.where(eq(c2sTunnelPresets.userId, userId))
|
||||
.orderBy(asc(c2sTunnelPresets.name));
|
||||
}
|
||||
|
||||
async findByIdForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
): Promise<C2sTunnelPresetRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(c2sTunnelPresets)
|
||||
.where(
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async hasNameForUser(
|
||||
userId: string,
|
||||
name: string,
|
||||
excludingId?: number,
|
||||
): Promise<boolean> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: c2sTunnelPresets.id })
|
||||
.from(c2sTunnelPresets)
|
||||
.where(
|
||||
and(
|
||||
eq(c2sTunnelPresets.userId, userId),
|
||||
eq(c2sTunnelPresets.name, name),
|
||||
),
|
||||
);
|
||||
|
||||
return rows.some((row) => row.id !== excludingId);
|
||||
}
|
||||
|
||||
async createForUser(
|
||||
userId: string,
|
||||
input: C2sTunnelPresetCreateInput,
|
||||
): Promise<C2sTunnelPresetRecord> {
|
||||
const [created] = await insertReturning(this.context, c2sTunnelPresets, {
|
||||
userId,
|
||||
name: input.name,
|
||||
config: input.config,
|
||||
platform: input.platform ?? null,
|
||||
computerName: input.computerName ?? null,
|
||||
});
|
||||
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async updateForUser(
|
||||
userId: string,
|
||||
id: number,
|
||||
updates: C2sTunnelPresetUpdateInput,
|
||||
): Promise<C2sTunnelPresetRecord | null> {
|
||||
const [updated] = await updateReturning(
|
||||
this.context,
|
||||
c2sTunnelPresets,
|
||||
{
|
||||
...updates,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
},
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
);
|
||||
|
||||
if (updated) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteForUser(userId: string, id: number): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(c2sTunnelPresets)
|
||||
.where(
|
||||
and(eq(c2sTunnelPresets.id, id), eq(c2sTunnelPresets.userId, userId)),
|
||||
);
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(c2sTunnelPresets)
|
||||
.where(eq(c2sTunnelPresets.userId, userId));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result);
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { NotificationChannelRepository } from "./notification-channel-repository
|
||||
import { AutomationRepository } from "./automation-repository.js";
|
||||
import { ApiKeyRepository } from "./api-key-repository.js";
|
||||
import { AuditLogRepository } from "./audit-log-repository.js";
|
||||
import { C2sTunnelPresetRepository } from "./c2s-tunnel-preset-repository.js";
|
||||
import { CommandHistoryRepository } from "./command-history-repository.js";
|
||||
import { CredentialRepository } from "./credential-repository.js";
|
||||
import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js";
|
||||
@@ -185,13 +184,6 @@ export function createCurrentAuditLogRepository(): AuditLogRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentC2sTunnelPresetRepository(): C2sTunnelPresetRepository {
|
||||
return new C2sTunnelPresetRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("c2s_tunnel_preset_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentCommandHistoryRepository(): CommandHistoryRepository {
|
||||
return new CommandHistoryRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
import type {
|
||||
AuthenticatedRequest,
|
||||
TunnelConnection,
|
||||
} from "../../../types/index.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { authLogger, databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import type { C2sTunnelPresetRecord } from "../repositories/c2s-tunnel-preset-repository.js";
|
||||
import { createCurrentC2sTunnelPresetRepository } from "../repositories/factory.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
function parsePreset(row: C2sTunnelPresetRecord) {
|
||||
return {
|
||||
...row,
|
||||
config: JSON.parse(row.config) as TunnelConnection[],
|
||||
};
|
||||
}
|
||||
|
||||
function validateConfig(config: unknown): config is TunnelConnection[] {
|
||||
if (!Array.isArray(config)) return false;
|
||||
return config.every((item) => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const tunnel = item as Partial<TunnelConnection>;
|
||||
const mode = tunnel.mode || tunnel.tunnelType;
|
||||
return (
|
||||
tunnel.scope === "c2s" &&
|
||||
(mode === "local" || mode === "remote" || mode === "dynamic") &&
|
||||
typeof tunnel.sourcePort === "number" &&
|
||||
tunnel.sourcePort >= 1 &&
|
||||
tunnel.sourcePort <= 65535 &&
|
||||
(mode === "dynamic" ||
|
||||
(typeof tunnel.endpointPort === "number" &&
|
||||
tunnel.endpointPort >= 1 &&
|
||||
tunnel.endpointPort <= 65535))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /c2s-tunnel-presets:
|
||||
* get:
|
||||
* summary: List client tunnel presets
|
||||
* description: Returns the authenticated user's saved client-to-server tunnel presets.
|
||||
* tags:
|
||||
* - Tunnel Presets
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of tunnel presets.
|
||||
* 401:
|
||||
* description: Authentication required.
|
||||
* 500:
|
||||
* description: Failed to list presets.
|
||||
*/
|
||||
router.get(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!isNonEmptyString(userId)) {
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result =
|
||||
await createCurrentC2sTunnelPresetRepository().listByUserId(userId);
|
||||
res.json(result.map(parsePreset));
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to fetch C2S tunnel presets", error);
|
||||
res.status(500).json({ error: "Failed to fetch C2S tunnel presets" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /c2s-tunnel-presets:
|
||||
* post:
|
||||
* summary: Create a client tunnel preset
|
||||
* description: Saves a named client-to-server tunnel configuration for the authenticated user.
|
||||
* tags:
|
||||
* - Tunnel Presets
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* config:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Preset created.
|
||||
* 400:
|
||||
* description: Invalid name or config.
|
||||
* 401:
|
||||
* description: Authentication required.
|
||||
* 500:
|
||||
* description: Failed to create preset.
|
||||
*/
|
||||
router.post(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, config, platform, computerName } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(name)) {
|
||||
return res.status(400).json({ error: "Preset name is required" });
|
||||
}
|
||||
if (!validateConfig(config)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid C2S tunnel configuration" });
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
try {
|
||||
const presetRepository = createCurrentC2sTunnelPresetRepository();
|
||||
if (await presetRepository.hasNameForUser(userId, trimmedName)) {
|
||||
return res.status(409).json({ error: "Preset name already exists" });
|
||||
}
|
||||
|
||||
const created = await presetRepository.createForUser(userId, {
|
||||
name: trimmedName,
|
||||
config: JSON.stringify(config),
|
||||
platform: platform?.trim() || null,
|
||||
computerName: computerName?.trim() || null,
|
||||
});
|
||||
|
||||
databaseLogger.info("C2S tunnel preset created", {
|
||||
operation: "c2s_tunnel_preset_create",
|
||||
userId,
|
||||
presetId: created.id,
|
||||
});
|
||||
res.status(201).json(parsePreset(created));
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to create C2S tunnel preset", error);
|
||||
res.status(500).json({ error: "Failed to create C2S tunnel preset" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /c2s-tunnel-presets/{id}:
|
||||
* put:
|
||||
* summary: Update a client tunnel preset
|
||||
* description: Updates the name or config of one of the authenticated user's tunnel presets.
|
||||
* tags:
|
||||
* - Tunnel Presets
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Preset updated.
|
||||
* 400:
|
||||
* description: Invalid name or config.
|
||||
* 404:
|
||||
* description: Preset not found.
|
||||
* 500:
|
||||
* description: Failed to update preset.
|
||||
*/
|
||||
router.put(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = Number(req.params.id);
|
||||
const { name, config, platform, computerName } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !Number.isInteger(id)) {
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const presetRepository = createCurrentC2sTunnelPresetRepository();
|
||||
const existing = await presetRepository.findByIdForUser(userId, id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Preset not found" });
|
||||
}
|
||||
|
||||
const updateFields: Parameters<typeof presetRepository.updateForUser>[2] =
|
||||
{};
|
||||
|
||||
if (name !== undefined) {
|
||||
if (!isNonEmptyString(name)) {
|
||||
return res.status(400).json({ error: "Preset name is required" });
|
||||
}
|
||||
const trimmedName = name.trim();
|
||||
if (await presetRepository.hasNameForUser(userId, trimmedName, id)) {
|
||||
return res.status(409).json({ error: "Preset name already exists" });
|
||||
}
|
||||
updateFields.name = trimmedName;
|
||||
}
|
||||
|
||||
if (config !== undefined) {
|
||||
if (!validateConfig(config)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid C2S tunnel configuration" });
|
||||
}
|
||||
updateFields.config = JSON.stringify(config);
|
||||
}
|
||||
if (platform !== undefined)
|
||||
updateFields.platform = platform?.trim() || null;
|
||||
if (computerName !== undefined)
|
||||
updateFields.computerName = computerName?.trim() || null;
|
||||
|
||||
const updated = await presetRepository.updateForUser(
|
||||
userId,
|
||||
id,
|
||||
updateFields,
|
||||
);
|
||||
res.json(parsePreset(updated ?? existing));
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to update C2S tunnel preset", error);
|
||||
res.status(500).json({ error: "Failed to update C2S tunnel preset" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /c2s-tunnel-presets/{id}:
|
||||
* delete:
|
||||
* summary: Delete a client tunnel preset
|
||||
* description: Deletes one of the authenticated user's tunnel presets.
|
||||
* tags:
|
||||
* - Tunnel Presets
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Preset deleted.
|
||||
* 404:
|
||||
* description: Preset not found.
|
||||
* 500:
|
||||
* description: Failed to delete preset.
|
||||
*/
|
||||
router.delete(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = Number(req.params.id);
|
||||
|
||||
if (!isNonEmptyString(userId) || !Number.isInteger(id)) {
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const presetRepository = createCurrentC2sTunnelPresetRepository();
|
||||
const existing = await presetRepository.findByIdForUser(userId, id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Preset not found" });
|
||||
}
|
||||
|
||||
await presetRepository.deleteForUser(userId, id);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to delete C2S tunnel preset", error);
|
||||
res.status(500).json({ error: "Failed to delete C2S tunnel preset" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
createCurrentNotificationChannelRepository,
|
||||
createCurrentApiKeyRepository,
|
||||
createCurrentAuditLogRepository,
|
||||
createCurrentC2sTunnelPresetRepository,
|
||||
createCurrentCommandHistoryRepository,
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentDashboardServiceLinkRepository,
|
||||
@@ -110,7 +109,6 @@ export async function deleteUserAndRelatedData(
|
||||
await createCurrentDashboardServiceLinkRepository().deleteByUserId(userId);
|
||||
await createCurrentHomepageItemRepository().deleteByUserId(userId);
|
||||
await createCurrentHomepageLayoutRepository().deleteByUserId(userId);
|
||||
await createCurrentC2sTunnelPresetRepository().deleteByUserId(userId);
|
||||
await createCurrentOpksshTokenRepository().deleteByUserId(userId);
|
||||
await createCurrentVaultTokenRepository().deleteByUserId(userId);
|
||||
await createCurrentVaultProfileRepository().deleteByUserId(userId);
|
||||
|
||||
@@ -63,6 +63,11 @@ export interface ConnectHostOptions {
|
||||
/** Use this client instead of a new one. */
|
||||
client?: Client;
|
||||
transport?: OpenTransportOptions;
|
||||
/**
|
||||
* An already-open stream to the host (a forwardOut channel through another
|
||||
* host). Skips openSshTransport entirely.
|
||||
*/
|
||||
sock?: MutableConnectConfig["sock"];
|
||||
log?: SshAuthLog;
|
||||
}
|
||||
|
||||
@@ -105,10 +110,15 @@ export async function connectHost(
|
||||
throw new SshConnectError(outcome);
|
||||
}
|
||||
|
||||
const { jumpClient } = await openSshTransport(host, config, {
|
||||
log: options.log,
|
||||
...options.transport,
|
||||
});
|
||||
let jumpClient: Client | null = null;
|
||||
if (options.sock) {
|
||||
config.sock = options.sock;
|
||||
} else {
|
||||
({ jumpClient } = await openSshTransport(host, config, {
|
||||
log: options.log,
|
||||
...options.transport,
|
||||
}));
|
||||
}
|
||||
|
||||
const keyboardInteractive =
|
||||
options.keyboardInteractive ??
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import type { IncomingMessage } from "http";
|
||||
import type { Duplex } from "stream";
|
||||
import type { ClientChannel } from "ssh2";
|
||||
import type { WebSocket } from "ws";
|
||||
import { extractWebSocketToken } from "../../utils/ws-auth.js";
|
||||
|
||||
const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
|
||||
const C2S_WS_LOW_WATERMARK = 256 * 1024;
|
||||
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
|
||||
|
||||
export function extractRequestToken(req: IncomingMessage): string | undefined {
|
||||
return extractWebSocketToken(req);
|
||||
}
|
||||
|
||||
export function sendC2SError(ws: WebSocket, message: string): void {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ type: "error", error: message }));
|
||||
}
|
||||
}
|
||||
|
||||
export function describeC2SRelayError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
if (
|
||||
lowerMessage.includes("administratively prohibited") ||
|
||||
lowerMessage.includes("forwarding disabled") ||
|
||||
lowerMessage.includes("open failed")
|
||||
) {
|
||||
return `SSH forwarding was rejected by the endpoint server: ${message}`;
|
||||
}
|
||||
if (
|
||||
lowerMessage.includes("address already in use") ||
|
||||
lowerMessage.includes("unable to bind") ||
|
||||
lowerMessage.includes("bind")
|
||||
) {
|
||||
return `Remote port is not available on the endpoint server: ${message}`;
|
||||
}
|
||||
if (
|
||||
lowerMessage.includes("name or service not known") ||
|
||||
lowerMessage.includes("enotfound") ||
|
||||
lowerMessage.includes("econnrefused")
|
||||
) {
|
||||
return `Tunnel target is not reachable from the endpoint host: ${message}`;
|
||||
}
|
||||
|
||||
return message || "Failed to open relay";
|
||||
}
|
||||
|
||||
function pauseSourceForC2SWebSocket(ws: WebSocket, source?: Duplex): void {
|
||||
if (!source) return;
|
||||
if (ws.bufferedAmount <= C2S_WS_HIGH_WATERMARK) return;
|
||||
|
||||
source.pause();
|
||||
const resumeTimer = setInterval(() => {
|
||||
if (
|
||||
ws.readyState !== 1 ||
|
||||
source.destroyed ||
|
||||
ws.bufferedAmount <= C2S_WS_LOW_WATERMARK
|
||||
) {
|
||||
clearInterval(resumeTimer);
|
||||
if (ws.readyState === 1 && !source.destroyed) {
|
||||
source.resume();
|
||||
}
|
||||
}
|
||||
}, 25);
|
||||
}
|
||||
|
||||
export function sendC2SMessage(
|
||||
ws: WebSocket,
|
||||
message: Record<string, unknown>,
|
||||
source?: Duplex,
|
||||
): void {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify(message), (error) => {
|
||||
if (error && source && !source.destroyed) {
|
||||
source.destroy(error);
|
||||
}
|
||||
});
|
||||
pauseSourceForC2SWebSocket(ws, source);
|
||||
}
|
||||
}
|
||||
|
||||
export function writeC2SRemoteChunk(
|
||||
target: ClientChannel,
|
||||
chunk: Buffer,
|
||||
ws: WebSocket,
|
||||
closeTarget: () => void,
|
||||
): void {
|
||||
if (!target || target.destroyed) return;
|
||||
|
||||
if (target.writableLength > C2S_STREAM_WRITE_LIMIT) {
|
||||
closeTarget();
|
||||
return;
|
||||
}
|
||||
|
||||
const canContinue = target.write(chunk);
|
||||
if (!canContinue) {
|
||||
ws.pause();
|
||||
target.once("drain", () => {
|
||||
if (ws.readyState === 1) {
|
||||
ws.resume();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,464 +0,0 @@
|
||||
import { Client, type ClientChannel } from "ssh2";
|
||||
import type { WebSocket } from "ws";
|
||||
import type { TunnelConfig } from "../../../types/index.js";
|
||||
import { createSocks5Connection } from "../../utils/socks5-helper.js";
|
||||
import { tunnelLogger } from "../../utils/logger.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import {
|
||||
applyTunnelAuth,
|
||||
bindForwardIn,
|
||||
connectClient,
|
||||
forwardOut,
|
||||
getManagedTunnelAlgorithms,
|
||||
unbindForwardIn,
|
||||
} from "./ssh-primitives.js";
|
||||
import { sendC2SMessage, writeC2SRemoteChunk } from "./c2s-relay-utils.js";
|
||||
import { getTunnelMode } from "./utils.js";
|
||||
import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js";
|
||||
|
||||
export type C2SOpenMessage = {
|
||||
type: "open" | "test";
|
||||
tunnelConfig?: Partial<TunnelConfig>;
|
||||
targetHost?: string;
|
||||
targetPort?: number;
|
||||
};
|
||||
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
let c2sRemoteStreamCounter = 0;
|
||||
|
||||
function normalizeAddress(
|
||||
value: unknown,
|
||||
fallback?: string,
|
||||
): string | undefined {
|
||||
const address = typeof value === "string" ? value.trim() : "";
|
||||
return address || fallback;
|
||||
}
|
||||
|
||||
export async function resolveC2SSourceHostId(
|
||||
tunnelConfig: Partial<TunnelConfig>,
|
||||
findHostIdBySyncId: (syncId: string) => Promise<number | null>,
|
||||
): Promise<number> {
|
||||
const sourceHostSyncId = tunnelConfig.sourceHostSyncId?.trim();
|
||||
if (sourceHostSyncId) {
|
||||
const remoteHostId = await findHostIdBySyncId(sourceHostSyncId);
|
||||
if (!remoteHostId) {
|
||||
throw new Error("Endpoint SSH host was not found on the remote server");
|
||||
}
|
||||
return remoteHostId;
|
||||
}
|
||||
|
||||
if (!tunnelConfig.sourceHostId) {
|
||||
throw new Error("Endpoint SSH host is required");
|
||||
}
|
||||
return tunnelConfig.sourceHostId;
|
||||
}
|
||||
|
||||
async function resolveC2STunnelSource(
|
||||
tunnelConfig: Partial<TunnelConfig>,
|
||||
userId: string,
|
||||
): Promise<TunnelConfig> {
|
||||
const sourceHostId = await resolveC2SSourceHostId(tunnelConfig, (syncId) =>
|
||||
createCurrentHostResolutionRepository().findHostIdBySyncId(syncId),
|
||||
);
|
||||
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
sourceHostId,
|
||||
"connect",
|
||||
);
|
||||
if (!accessInfo.hasAccess) {
|
||||
throw new Error("Access denied to this host");
|
||||
}
|
||||
|
||||
const { resolveHostById } = await import("../host-resolver.js");
|
||||
const resolvedHost = await resolveHostById(sourceHostId, userId);
|
||||
if (!resolvedHost) {
|
||||
throw new Error("Endpoint SSH host not found");
|
||||
}
|
||||
|
||||
const localAddress = normalizeAddress(
|
||||
tunnelConfig.localAddress,
|
||||
tunnelConfig.bindHost,
|
||||
);
|
||||
const remoteAddress = normalizeAddress(
|
||||
tunnelConfig.remoteAddress,
|
||||
tunnelConfig.targetHost || "127.0.0.1",
|
||||
);
|
||||
|
||||
return {
|
||||
name: tunnelConfig.name || `c2s:${sourceHostId}`,
|
||||
scope: "c2s",
|
||||
mode: tunnelConfig.mode || "local",
|
||||
tunnelType:
|
||||
tunnelConfig.tunnelType ||
|
||||
(tunnelConfig.mode === "remote" ? "remote" : "local"),
|
||||
localAddress,
|
||||
remoteAddress,
|
||||
bindHost: localAddress,
|
||||
targetHost: remoteAddress,
|
||||
sourceHostId: resolvedHost.id || sourceHostId,
|
||||
sourceHostSyncId: tunnelConfig.sourceHostSyncId,
|
||||
tunnelIndex: tunnelConfig.tunnelIndex || 0,
|
||||
requestingUserId: userId,
|
||||
hostName:
|
||||
resolvedHost.name || `${resolvedHost.username}@${resolvedHost.ip}`,
|
||||
sourceIP: resolvedHost.ip,
|
||||
sourceSSHPort: resolvedHost.port,
|
||||
sourceUsername: resolvedHost.username,
|
||||
sourcePassword: resolvedHost.password,
|
||||
sourceAuthMethod: resolvedHost.authType,
|
||||
sourceSSHKey: resolvedHost.key,
|
||||
sourceKeyPassword: resolvedHost.keyPassword,
|
||||
sourceKeyType: resolvedHost.keyType,
|
||||
sourceCredentialId: resolvedHost.credentialId,
|
||||
sourceUserId: resolvedHost.userId,
|
||||
endpointIP: tunnelConfig.endpointIP || resolvedHost.ip,
|
||||
endpointSSHPort: tunnelConfig.endpointSSHPort || resolvedHost.port,
|
||||
endpointUsername: resolvedHost.username,
|
||||
endpointHost:
|
||||
tunnelConfig.endpointHost || resolvedHost.name || resolvedHost.ip,
|
||||
endpointAuthMethod: resolvedHost.authType,
|
||||
endpointSSHKey: resolvedHost.key,
|
||||
endpointKeyPassword: resolvedHost.keyPassword,
|
||||
endpointKeyType: resolvedHost.keyType,
|
||||
endpointCredentialId: resolvedHost.credentialId,
|
||||
endpointUserId: resolvedHost.userId,
|
||||
sourcePort: Number(tunnelConfig.sourcePort) || 0,
|
||||
endpointPort: Number(tunnelConfig.endpointPort) || 0,
|
||||
maxRetries: Number(tunnelConfig.maxRetries) || 0,
|
||||
retryInterval: Number(tunnelConfig.retryInterval) || 0,
|
||||
autoStart: Boolean(tunnelConfig.autoStart),
|
||||
isPinned: Boolean(resolvedHost.pin),
|
||||
useSocks5: Boolean(resolvedHost.useSocks5),
|
||||
socks5Host: resolvedHost.socks5Host,
|
||||
socks5Port: resolvedHost.socks5Port,
|
||||
socks5Username: resolvedHost.socks5Username,
|
||||
socks5Password: resolvedHost.socks5Password,
|
||||
socks5ProxyChain: resolvedHost.socks5ProxyChain,
|
||||
keepaliveInterval:
|
||||
typeof resolvedHost.terminalConfig?.keepaliveInterval === "number"
|
||||
? resolvedHost.terminalConfig.keepaliveInterval * 1000
|
||||
: 60000,
|
||||
keepaliveCountMax:
|
||||
typeof resolvedHost.terminalConfig?.keepaliveCountMax === "number"
|
||||
? resolvedHost.terminalConfig.keepaliveCountMax
|
||||
: 5,
|
||||
};
|
||||
}
|
||||
|
||||
async function connectC2SSourceClient(
|
||||
tunnelConfig: TunnelConfig,
|
||||
): Promise<Client> {
|
||||
const connOptions: Record<string, unknown> = {
|
||||
host:
|
||||
tunnelConfig.sourceIP?.replace(/^\[|\]$/g, "") || tunnelConfig.sourceIP,
|
||||
port: tunnelConfig.sourceSSHPort,
|
||||
username: tunnelConfig.sourceUsername,
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: tunnelConfig.keepaliveInterval ?? 30000,
|
||||
keepaliveCountMax: tunnelConfig.keepaliveCountMax ?? 3,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
algorithms: getManagedTunnelAlgorithms(),
|
||||
};
|
||||
|
||||
const client = new Client();
|
||||
const authOutcome = await applyTunnelAuth(
|
||||
connOptions,
|
||||
{
|
||||
password: tunnelConfig.sourcePassword,
|
||||
sshKey: tunnelConfig.sourceSSHKey,
|
||||
keyPassword: tunnelConfig.sourceKeyPassword,
|
||||
keyType: tunnelConfig.sourceKeyType,
|
||||
authMethod: tunnelConfig.sourceAuthMethod,
|
||||
},
|
||||
{
|
||||
client,
|
||||
userId: tunnelConfig.requestingUserId || tunnelConfig.sourceUserId,
|
||||
hostId: tunnelConfig.sourceHostId,
|
||||
username: tunnelConfig.sourceUsername,
|
||||
ip: tunnelConfig.sourceIP,
|
||||
port: tunnelConfig.sourceSSHPort,
|
||||
},
|
||||
);
|
||||
if (authOutcome.status !== "ready") {
|
||||
throw new Error(authOutcome.message);
|
||||
}
|
||||
|
||||
if (
|
||||
tunnelConfig.useSocks5 &&
|
||||
(tunnelConfig.socks5Host ||
|
||||
(tunnelConfig.socks5ProxyChain &&
|
||||
tunnelConfig.socks5ProxyChain.length > 0))
|
||||
) {
|
||||
const socks5Socket = await createSocks5Connection(
|
||||
tunnelConfig.sourceIP,
|
||||
tunnelConfig.sourceSSHPort,
|
||||
{
|
||||
useSocks5: tunnelConfig.useSocks5,
|
||||
socks5Host: tunnelConfig.socks5Host,
|
||||
socks5Port: tunnelConfig.socks5Port,
|
||||
socks5Username: tunnelConfig.socks5Username,
|
||||
socks5Password: tunnelConfig.socks5Password,
|
||||
socks5ProxyChain: tunnelConfig.socks5ProxyChain,
|
||||
},
|
||||
);
|
||||
if (socks5Socket) {
|
||||
connOptions.sock = socks5Socket;
|
||||
}
|
||||
}
|
||||
|
||||
return connectClient(connOptions, tunnelConfig.name, "source", client);
|
||||
}
|
||||
|
||||
async function handleC2SRemoteRelayOpen(
|
||||
ws: WebSocket,
|
||||
tunnelConfig: TunnelConfig,
|
||||
): Promise<void> {
|
||||
const tunnelName = tunnelConfig.name;
|
||||
const sourceClient = await connectC2SSourceClient(tunnelConfig);
|
||||
const bindHost =
|
||||
tunnelConfig.remoteAddress || tunnelConfig.targetHost || "127.0.0.1";
|
||||
const bindPort = Number(tunnelConfig.sourcePort);
|
||||
let closed = false;
|
||||
|
||||
if (!Number.isInteger(bindPort) || bindPort < 1 || bindPort > 65535) {
|
||||
throw new Error("Invalid remote port");
|
||||
}
|
||||
|
||||
const actualPort = await bindForwardIn(sourceClient, bindHost, bindPort);
|
||||
const streams = new Map<string, ClientChannel>();
|
||||
|
||||
const closeStream = (streamId: string): void => {
|
||||
const stream = streams.get(streamId);
|
||||
if (!stream) return;
|
||||
streams.delete(streamId);
|
||||
try {
|
||||
stream.destroy();
|
||||
} catch {
|
||||
// expected during shutdown
|
||||
}
|
||||
};
|
||||
|
||||
const close = (): void => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const streamId of streams.keys()) {
|
||||
closeStream(streamId);
|
||||
}
|
||||
unbindForwardIn(sourceClient, bindHost, actualPort);
|
||||
try {
|
||||
sourceClient.end();
|
||||
} catch {
|
||||
// expected during shutdown
|
||||
}
|
||||
};
|
||||
|
||||
sourceClient.on("tcp connection", (info, accept, reject) => {
|
||||
if (info.destPort !== actualPort) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
|
||||
const inbound = accept();
|
||||
const streamId = `${Date.now()}-${++c2sRemoteStreamCounter}`;
|
||||
streams.set(streamId, inbound);
|
||||
|
||||
sendC2SMessage(ws, { type: "connection", streamId });
|
||||
|
||||
inbound.on("data", (chunk) => {
|
||||
sendC2SMessage(
|
||||
ws,
|
||||
{
|
||||
type: "data",
|
||||
streamId,
|
||||
data: chunk.toString("base64"),
|
||||
},
|
||||
inbound,
|
||||
);
|
||||
});
|
||||
inbound.on("close", () => {
|
||||
streams.delete(streamId);
|
||||
sendC2SMessage(ws, { type: "close", streamId });
|
||||
});
|
||||
inbound.on("error", (error) => {
|
||||
streams.delete(streamId);
|
||||
sendC2SMessage(ws, {
|
||||
type: "close",
|
||||
streamId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ws.on("message", (data, isBinary) => {
|
||||
if (isBinary) return;
|
||||
|
||||
try {
|
||||
const message = JSON.parse(data.toString()) as {
|
||||
type?: string;
|
||||
streamId?: string;
|
||||
data?: string;
|
||||
};
|
||||
if (!message.streamId) return;
|
||||
|
||||
if (message.type === "data" && message.data) {
|
||||
const stream = streams.get(message.streamId);
|
||||
if (stream) {
|
||||
writeC2SRemoteChunk(
|
||||
stream,
|
||||
Buffer.from(message.data, "base64"),
|
||||
ws,
|
||||
() => closeStream(message.streamId as string),
|
||||
);
|
||||
}
|
||||
} else if (message.type === "close") {
|
||||
closeStream(message.streamId);
|
||||
}
|
||||
} catch (error) {
|
||||
tunnelLogger.warn("Invalid C2S remote relay message", {
|
||||
operation: "c2s_remote_relay_invalid_message",
|
||||
tunnelName,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", close);
|
||||
ws.on("error", close);
|
||||
sourceClient.on("close", () => {
|
||||
if (ws.readyState === 1) ws.close();
|
||||
});
|
||||
sourceClient.on("error", (error) => {
|
||||
sendC2SMessage(ws, {
|
||||
type: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
if (ws.readyState === 1) ws.close();
|
||||
});
|
||||
|
||||
tunnelLogger.info("C2S remote tunnel ready", {
|
||||
operation: "c2s_remote_tunnel_ready",
|
||||
tunnelName,
|
||||
bindHost,
|
||||
bindPort: actualPort,
|
||||
endpointHost: tunnelConfig.endpointHost,
|
||||
});
|
||||
sendC2SMessage(ws, { type: "ready", bindHost, bindPort: actualPort });
|
||||
}
|
||||
|
||||
export async function handleC2SRelayOpen(
|
||||
ws: WebSocket,
|
||||
message: C2SOpenMessage,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const tunnelConfig = await resolveC2STunnelSource(
|
||||
message.tunnelConfig || {},
|
||||
userId,
|
||||
);
|
||||
const mode = getTunnelMode(tunnelConfig);
|
||||
if (mode === "remote") {
|
||||
await handleC2SRemoteRelayOpen(ws, tunnelConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetHost =
|
||||
mode === "dynamic"
|
||||
? message.targetHost
|
||||
: tunnelConfig.remoteAddress || tunnelConfig.targetHost || "127.0.0.1";
|
||||
const targetPort =
|
||||
mode === "dynamic"
|
||||
? Number(message.targetPort)
|
||||
: Number(tunnelConfig.endpointPort);
|
||||
|
||||
if (!targetHost || !Number.isInteger(targetPort) || targetPort < 1) {
|
||||
throw new Error("Invalid client tunnel target");
|
||||
}
|
||||
|
||||
const sourceClient = await connectC2SSourceClient(tunnelConfig);
|
||||
const outbound = await forwardOut(sourceClient, targetHost, targetPort);
|
||||
|
||||
const close = () => {
|
||||
try {
|
||||
outbound.destroy();
|
||||
} catch {
|
||||
// expected during shutdown
|
||||
}
|
||||
try {
|
||||
sourceClient.end();
|
||||
} catch {
|
||||
// expected during shutdown
|
||||
}
|
||||
};
|
||||
|
||||
outbound.on("data", (chunk) => {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(chunk);
|
||||
}
|
||||
});
|
||||
outbound.on("close", () => {
|
||||
if (ws.readyState === 1) ws.close();
|
||||
});
|
||||
outbound.on("error", () => {
|
||||
if (ws.readyState === 1) ws.close();
|
||||
});
|
||||
ws.on("close", close);
|
||||
ws.on("error", close);
|
||||
ws.on("message", (data, isBinary) => {
|
||||
if (!isBinary) return;
|
||||
const chunk = Buffer.isBuffer(data)
|
||||
? data
|
||||
: Array.isArray(data)
|
||||
? Buffer.concat(data)
|
||||
: Buffer.from(data);
|
||||
outbound.write(chunk);
|
||||
});
|
||||
|
||||
ws.send(JSON.stringify({ type: "ready" }));
|
||||
}
|
||||
|
||||
export async function handleC2SRelayTest(
|
||||
ws: WebSocket,
|
||||
message: C2SOpenMessage,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const tunnelConfig = await resolveC2STunnelSource(
|
||||
message.tunnelConfig || {},
|
||||
userId,
|
||||
);
|
||||
const mode = getTunnelMode(tunnelConfig);
|
||||
const sourceClient = await connectC2SSourceClient(tunnelConfig);
|
||||
|
||||
try {
|
||||
if (mode === "remote") {
|
||||
const bindHost =
|
||||
tunnelConfig.remoteAddress || tunnelConfig.targetHost || "127.0.0.1";
|
||||
const bindPort = Number(tunnelConfig.sourcePort);
|
||||
if (!Number.isInteger(bindPort) || bindPort < 1 || bindPort > 65535) {
|
||||
throw new Error("Invalid remote port");
|
||||
}
|
||||
|
||||
const actualPort = await bindForwardIn(sourceClient, bindHost, bindPort);
|
||||
unbindForwardIn(sourceClient, bindHost, actualPort);
|
||||
} else if (mode === "local") {
|
||||
const targetHost =
|
||||
tunnelConfig.remoteAddress || tunnelConfig.targetHost || "127.0.0.1";
|
||||
const targetPort = Number(tunnelConfig.endpointPort);
|
||||
if (!Number.isInteger(targetPort) || targetPort < 1) {
|
||||
throw new Error("Invalid remote target port");
|
||||
}
|
||||
|
||||
const outbound = await forwardOut(sourceClient, targetHost, targetPort);
|
||||
outbound.destroy();
|
||||
}
|
||||
|
||||
sendC2SMessage(ws, { type: "ready" });
|
||||
} finally {
|
||||
try {
|
||||
sourceClient.end();
|
||||
} catch {
|
||||
// expected during shutdown
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import express from "express";
|
||||
import { createServer } from "http";
|
||||
|
||||
import { createCorsMiddleware } from "../../utils/cors-config.js";
|
||||
import { createCompressionMiddleware } from "../../utils/compression-config.js";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
import { tunnelLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
|
||||
import {
|
||||
describeC2SRelayError,
|
||||
extractRequestToken,
|
||||
sendC2SError,
|
||||
} from "./c2s-relay-utils.js";
|
||||
import {
|
||||
handleC2SRelayOpen,
|
||||
handleC2SRelayTest,
|
||||
type C2SOpenMessage,
|
||||
} from "./c2s-relay.js";
|
||||
|
||||
import { registerTunnelRoutes } from "./routes.js";
|
||||
import { initializeAutoStartTunnels } from "./manager.js";
|
||||
import { attachServicePortConflictHandler } from "../../utils/service-listen.js";
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", "loopback");
|
||||
app.use(createCompressionMiddleware());
|
||||
app.use(createCorsMiddleware(["GET", "POST", "PUT", "DELETE", "OPTIONS"]));
|
||||
app.use(cookieParser());
|
||||
app.use(express.json());
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
|
||||
registerTunnelRoutes(app);
|
||||
|
||||
const PORT = 30003;
|
||||
const server = createServer(app);
|
||||
const c2sRelayWss = new WebSocketServer({
|
||||
server,
|
||||
path: "/ssh/tunnel/c2s/stream",
|
||||
});
|
||||
|
||||
c2sRelayWss.on("error", (error) => {
|
||||
tunnelLogger.error("C2S relay WebSocket server error", error, {
|
||||
operation: "c2s_relay_wss_error",
|
||||
});
|
||||
});
|
||||
|
||||
c2sRelayWss.on("connection", (ws, req) => {
|
||||
let opened = false;
|
||||
|
||||
ws.on("error", (error) => {
|
||||
tunnelLogger.error("C2S relay WebSocket connection error", error, {
|
||||
operation: "c2s_relay_ws_error",
|
||||
});
|
||||
});
|
||||
|
||||
ws.once("message", async (raw) => {
|
||||
try {
|
||||
const token = extractRequestToken(req);
|
||||
const payload = token ? await authManager.verifyJWTToken(token) : null;
|
||||
if (!payload?.userId || payload.pendingTOTP) {
|
||||
sendC2SError(ws, "Authentication required");
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const message = JSON.parse(raw.toString()) as C2SOpenMessage;
|
||||
if (message.type !== "open" && message.type !== "test") {
|
||||
throw new Error("Invalid client tunnel relay request");
|
||||
}
|
||||
|
||||
opened = true;
|
||||
if (message.type === "test") {
|
||||
await handleC2SRelayTest(ws, message, payload.userId);
|
||||
} else {
|
||||
await handleC2SRelayOpen(ws, message, payload.userId);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = describeC2SRelayError(error);
|
||||
tunnelLogger.error("Failed to open C2S relay", error, {
|
||||
operation: "c2s_relay_open_failed",
|
||||
});
|
||||
sendC2SError(ws, message);
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
if (!opened) {
|
||||
tunnelLogger.info("C2S relay closed before opening", {
|
||||
operation: "c2s_relay_closed_before_open",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
attachServicePortConflictHandler(server, PORT, tunnelLogger, "tunnel");
|
||||
|
||||
server.listen(PORT, "127.0.0.1", () => {
|
||||
setTimeout(() => {
|
||||
initializeAutoStartTunnels();
|
||||
}, 2000);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,680 +0,0 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import express, { type Response } from "express";
|
||||
|
||||
import axios from "axios";
|
||||
import type {
|
||||
SSHHost,
|
||||
TunnelConfig,
|
||||
AuthenticatedRequest,
|
||||
} from "../../../types/index.js";
|
||||
import { CONNECTION_STATES } from "../../../types/index.js";
|
||||
import {
|
||||
logAudit,
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
} from "../../utils/audit-logger.js";
|
||||
import { tunnelLogger } from "../../utils/logger.js";
|
||||
import { SystemCrypto } from "../../utils/system-crypto.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
|
||||
import {
|
||||
RESERVED_TUNNEL_NAME_PREFIX,
|
||||
getTunnelMode,
|
||||
isReservedTunnelName,
|
||||
parseReservedTunnelName,
|
||||
validateTunnelConfig,
|
||||
} from "./utils.js";
|
||||
|
||||
import {
|
||||
tunnelConfigs,
|
||||
activeRetryTimers,
|
||||
pendingTunnelOperations,
|
||||
manualDisconnects,
|
||||
retryExhaustedTunnels,
|
||||
retryCounters,
|
||||
countdownIntervals,
|
||||
tunnelConnecting,
|
||||
tunnelStatusClients,
|
||||
connectionStatus,
|
||||
cleanupTunnelResources,
|
||||
broadcastTunnelStatus,
|
||||
handleDisconnect,
|
||||
sendTunnelStatusSnapshot,
|
||||
isSingleHostTunnel,
|
||||
getTunnelStatusForUser,
|
||||
canAccessTunnel,
|
||||
findHostByTunnelEndpoint,
|
||||
connectSSHTunnel,
|
||||
} from "./manager.js";
|
||||
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
/**
|
||||
* Ownership check for an action addressed by tunnel NAME alone.
|
||||
*
|
||||
* The existing check only ran inside `if (config && config.sourceHostId)`, and
|
||||
* web endpoint tunnels are deliberately kept out of tunnelConfigs -- so
|
||||
* `config` is always undefined for them and the check never ran at all. Host
|
||||
* ids are small sequential integers and endpoint ids are client-supplied with
|
||||
* no server-side randomness, so anyone could guess `web:{hostId}:{endpointId}`
|
||||
* and force-close another user's live tunnel; manualDisconnects then makes the
|
||||
* victim's own re-open fail for several seconds.
|
||||
*
|
||||
* A reserved name whose host id cannot be parsed FAILS CLOSED rather than
|
||||
* falling through to the unchecked path.
|
||||
*/
|
||||
export async function authorizeTunnelAction(
|
||||
userId: string,
|
||||
tunnelName: string,
|
||||
config: { sourceHostId?: number } | undefined,
|
||||
): Promise<{ allowed: boolean; hostId?: number }> {
|
||||
const hostId =
|
||||
config?.sourceHostId ?? parseReservedTunnelName(tunnelName)?.hostId;
|
||||
|
||||
if (hostId === undefined) {
|
||||
// Not a reserved name and not a registered config: nothing to check
|
||||
// against. Preserves the pre-existing behaviour for ordinary names;
|
||||
// tightening that is a separate change with its own blast radius.
|
||||
return { allowed: !isReservedTunnelName(tunnelName) };
|
||||
}
|
||||
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"connect",
|
||||
);
|
||||
return { allowed: accessInfo.hasAccess, hostId };
|
||||
}
|
||||
|
||||
export function registerTunnelRoutes(app: express.Express): void {
|
||||
app.get(
|
||||
"/ssh/tunnel/status",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
res.json(await getTunnelStatusForUser(req.userId));
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/ssh/tunnel/status/stream",
|
||||
authenticateJWT,
|
||||
(req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-store, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
res.flushHeaders?.();
|
||||
|
||||
tunnelStatusClients.set(res, req.userId);
|
||||
sendTunnelStatusSnapshot(res);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write(": keepalive\n\n");
|
||||
} catch {
|
||||
closeStream();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
const closeStream = () => {
|
||||
clearInterval(heartbeat);
|
||||
tunnelStatusClients.delete(res);
|
||||
};
|
||||
|
||||
req.on("close", closeStream);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/tunnel/status/{tunnelName}:
|
||||
* get:
|
||||
* summary: Get tunnel status by name
|
||||
* description: Retrieves the status of a specific SSH tunnel by its name.
|
||||
* tags:
|
||||
* - SSH Tunnels
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: tunnelName
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tunnel status.
|
||||
* 404:
|
||||
* description: Tunnel not found.
|
||||
*/
|
||||
app.get(
|
||||
"/ssh/tunnel/status/:tunnelName",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
const tunnelNameParam = req.params.tunnelName;
|
||||
const tunnelName = Array.isArray(tunnelNameParam)
|
||||
? tunnelNameParam[0]
|
||||
: tunnelNameParam;
|
||||
|
||||
// 404 rather than 403 for foreign tunnels: the name itself carries
|
||||
// host metadata, so confirming its existence would leak it.
|
||||
if (!(await canAccessTunnel(req.userId, tunnelName))) {
|
||||
return res.status(404).json({ error: "Tunnel not found" });
|
||||
}
|
||||
|
||||
const status = connectionStatus.get(tunnelName);
|
||||
|
||||
if (!status) {
|
||||
return res.status(404).json({ error: "Tunnel not found" });
|
||||
}
|
||||
|
||||
res.json({ name: tunnelName, status });
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/tunnel/connect:
|
||||
* post:
|
||||
* summary: Connect SSH tunnel
|
||||
* description: Establishes an SSH tunnel connection with the specified configuration.
|
||||
* tags:
|
||||
* - SSH Tunnels
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* sourceHostId:
|
||||
* type: integer
|
||||
* tunnelIndex:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Connection request received.
|
||||
* 400:
|
||||
* description: Invalid tunnel configuration.
|
||||
* 401:
|
||||
* description: Authentication required.
|
||||
* 403:
|
||||
* description: Access denied to this host.
|
||||
* 500:
|
||||
* description: Failed to connect tunnel.
|
||||
*/
|
||||
app.post(
|
||||
"/ssh/tunnel/connect",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const tunnelConfig: TunnelConfig = req.body;
|
||||
const userId = req.userId;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
if (!tunnelConfig || !tunnelConfig.name) {
|
||||
return res.status(400).json({ error: "Invalid tunnel configuration" });
|
||||
}
|
||||
|
||||
const tunnelName = tunnelConfig.name;
|
||||
|
||||
// The "web:" prefix is reserved for web endpoint tunnels, which skip the
|
||||
// retry machinery by name (see handleDisconnect). A user-supplied tunnel
|
||||
// using it would silently lose its own retry/reconnect behaviour, and
|
||||
// could collide with a live web endpoint forward. Rejected here, at the
|
||||
// only point tunnel names are accepted from a request -- note
|
||||
// validateTunnelConfig returns true unconditionally for any name that is
|
||||
// not the legacy 6-part format, so it is no defence.
|
||||
if (isReservedTunnelName(tunnelName)) {
|
||||
return res.status(400).json({
|
||||
error: `Tunnel names beginning with "${RESERVED_TUNNEL_NAME_PREFIX}" are reserved`,
|
||||
});
|
||||
}
|
||||
|
||||
tunnelConfig.requestingUserId = userId;
|
||||
|
||||
try {
|
||||
if (!validateTunnelConfig(tunnelName, tunnelConfig)) {
|
||||
tunnelLogger.error(`Tunnel config validation failed`, {
|
||||
operation: "tunnel_connect",
|
||||
tunnelName,
|
||||
configHostId: tunnelConfig.sourceHostId,
|
||||
configTunnelIndex: tunnelConfig.tunnelIndex,
|
||||
});
|
||||
return res.status(400).json({
|
||||
error: "Tunnel configuration does not match tunnel name",
|
||||
});
|
||||
}
|
||||
|
||||
if (tunnelConfig.sourceHostId) {
|
||||
const accessInfo = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
tunnelConfig.sourceHostId,
|
||||
"connect",
|
||||
);
|
||||
|
||||
if (!accessInfo.hasAccess) {
|
||||
tunnelLogger.warn("User attempted tunnel connect without access", {
|
||||
operation: "tunnel_connect_unauthorized",
|
||||
userId,
|
||||
hostId: tunnelConfig.sourceHostId,
|
||||
tunnelName,
|
||||
});
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Access denied to this host" });
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingTunnelOperations.has(tunnelName)) {
|
||||
try {
|
||||
await pendingTunnelOperations.get(tunnelName);
|
||||
} catch {
|
||||
tunnelLogger.warn(`Previous tunnel operation failed`, {
|
||||
tunnelName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const operation = (async () => {
|
||||
manualDisconnects.delete(tunnelName);
|
||||
retryCounters.delete(tunnelName);
|
||||
retryExhaustedTunnels.delete(tunnelName);
|
||||
|
||||
await cleanupTunnelResources(tunnelName);
|
||||
|
||||
if (tunnelConfigs.has(tunnelName)) {
|
||||
const existingConfig = tunnelConfigs.get(tunnelName);
|
||||
if (
|
||||
existingConfig &&
|
||||
(existingConfig.sourceHostId !== tunnelConfig.sourceHostId ||
|
||||
existingConfig.tunnelIndex !== tunnelConfig.tunnelIndex)
|
||||
) {
|
||||
throw new Error(`Tunnel name collision detected: ${tunnelName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!isSingleHostTunnel(tunnelConfig) &&
|
||||
(!tunnelConfig.endpointIP || !tunnelConfig.endpointUsername)
|
||||
) {
|
||||
try {
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
const internalAuthToken =
|
||||
await systemCrypto.getInternalAuthToken();
|
||||
|
||||
const allHostsResponse = await axios.get(
|
||||
"http://localhost:30001/host/db/host/internal/all",
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Internal-Auth-Token": internalAuthToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const allHosts: SSHHost[] = allHostsResponse.data || [];
|
||||
const endpointHost = findHostByTunnelEndpoint(
|
||||
allHosts,
|
||||
tunnelConfig.endpointHost,
|
||||
);
|
||||
|
||||
if (!endpointHost) {
|
||||
if (getTunnelMode(tunnelConfig) !== "remote") {
|
||||
tunnelConfig.endpointIP =
|
||||
tunnelConfig.endpointIP || tunnelConfig.endpointHost;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Endpoint host '${tunnelConfig.endpointHost}' not found in database`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!endpointHost.id) {
|
||||
throw new Error("Endpoint host not found");
|
||||
}
|
||||
|
||||
const endpointAccess = await permissionManager.canAccessHost(
|
||||
userId,
|
||||
endpointHost.id,
|
||||
"connect",
|
||||
);
|
||||
if (!endpointAccess.hasAccess) {
|
||||
tunnelLogger.warn(
|
||||
"User attempted tunnel connect without endpoint access",
|
||||
{
|
||||
operation: "tunnel_connect_endpoint_unauthorized",
|
||||
userId,
|
||||
hostId: endpointHost.id,
|
||||
tunnelName,
|
||||
},
|
||||
);
|
||||
throw new Error("Endpoint host not found");
|
||||
}
|
||||
|
||||
tunnelConfig.endpointIP = endpointHost.ip;
|
||||
tunnelConfig.endpointSSHPort = endpointHost.port;
|
||||
tunnelConfig.endpointUsername = endpointHost.username;
|
||||
tunnelConfig.endpointAuthMethod = endpointHost.authType;
|
||||
tunnelConfig.endpointKeyType = endpointHost.keyType;
|
||||
tunnelConfig.endpointCredentialId =
|
||||
endpointHost.userId === userId
|
||||
? endpointHost.credentialId
|
||||
: undefined;
|
||||
tunnelConfig.endpointUserId = userId;
|
||||
|
||||
// Resolve credentials server-side instead of from HTTP response
|
||||
if (endpointHost.id) {
|
||||
try {
|
||||
const { resolveHostById } =
|
||||
await import("../host-resolver.js");
|
||||
const resolved = await resolveHostById(
|
||||
endpointHost.id,
|
||||
userId,
|
||||
);
|
||||
if (resolved) {
|
||||
tunnelConfig.endpointPassword = resolved.password;
|
||||
tunnelConfig.endpointSSHKey = resolved.key;
|
||||
tunnelConfig.endpointKeyPassword = resolved.keyPassword;
|
||||
}
|
||||
} catch (credError) {
|
||||
tunnelLogger.warn(
|
||||
"Failed to resolve endpoint credentials from DB",
|
||||
{
|
||||
operation: "tunnel_endpoint_credential_resolve",
|
||||
endpointHostId: endpointHost.id,
|
||||
error: getErrorMessage(credError, "Unknown"),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (resolveError) {
|
||||
tunnelLogger.error(
|
||||
"Failed to resolve endpoint host",
|
||||
resolveError,
|
||||
{
|
||||
operation: "tunnel_connect_resolve_endpoint_failed",
|
||||
tunnelName,
|
||||
endpointHost: tunnelConfig.endpointHost,
|
||||
},
|
||||
);
|
||||
throw new Error(
|
||||
`Failed to resolve endpoint host: ${getErrorMessage(resolveError)}`,
|
||||
{ cause: resolveError },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tunnelConfigs.set(tunnelName, tunnelConfig);
|
||||
await connectSSHTunnel(tunnelConfig, 0);
|
||||
})();
|
||||
|
||||
pendingTunnelOperations.set(tunnelName, operation);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "tunnel_connect",
|
||||
resourceType: "tunnel",
|
||||
resourceId: tunnelConfig.sourceHostId
|
||||
? String(tunnelConfig.sourceHostId)
|
||||
: undefined,
|
||||
resourceName: tunnelName,
|
||||
details: JSON.stringify({
|
||||
endpointHost: tunnelConfig.endpointHost,
|
||||
endpointPort: tunnelConfig.endpointPort,
|
||||
sourcePort: tunnelConfig.sourcePort,
|
||||
}),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ message: "Connection request received", tunnelName });
|
||||
|
||||
operation
|
||||
.catch((err) => {
|
||||
tunnelLogger.error("Tunnel operation failed", err, {
|
||||
operation: "tunnel_operation_failed",
|
||||
tunnelName,
|
||||
});
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.FAILED,
|
||||
reason: getErrorMessage(err),
|
||||
});
|
||||
tunnelConnecting.delete(tunnelName);
|
||||
})
|
||||
.finally(() => {
|
||||
pendingTunnelOperations.delete(tunnelName);
|
||||
});
|
||||
} catch (error) {
|
||||
tunnelLogger.error("Failed to process tunnel connect", error, {
|
||||
operation: "tunnel_connect",
|
||||
tunnelName,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to connect tunnel" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/tunnel/disconnect:
|
||||
* post:
|
||||
* summary: Disconnect SSH tunnel
|
||||
* description: Disconnects an active SSH tunnel.
|
||||
* tags:
|
||||
* - SSH Tunnels
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* tunnelName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Disconnect request received.
|
||||
* 400:
|
||||
* description: Tunnel name required.
|
||||
* 401:
|
||||
* description: Authentication required.
|
||||
* 403:
|
||||
* description: Access denied.
|
||||
* 500:
|
||||
* description: Failed to disconnect tunnel.
|
||||
*/
|
||||
app.post(
|
||||
"/ssh/tunnel/disconnect",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const { tunnelName } = req.body;
|
||||
const userId = req.userId;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
if (!tunnelName) {
|
||||
return res.status(400).json({ error: "Tunnel name required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const config = tunnelConfigs.get(tunnelName);
|
||||
const authorized = await authorizeTunnelAction(
|
||||
userId,
|
||||
tunnelName,
|
||||
config,
|
||||
);
|
||||
if (!authorized.allowed) {
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
|
||||
tunnelLogger.info("Tunnel stop request received", {
|
||||
operation: "tunnel_stop_request",
|
||||
userId,
|
||||
hostId: config?.sourceHostId,
|
||||
tunnelName,
|
||||
});
|
||||
manualDisconnects.add(tunnelName);
|
||||
retryCounters.delete(tunnelName);
|
||||
retryExhaustedTunnels.delete(tunnelName);
|
||||
|
||||
if (activeRetryTimers.has(tunnelName)) {
|
||||
clearTimeout(activeRetryTimers.get(tunnelName)!);
|
||||
activeRetryTimers.delete(tunnelName);
|
||||
}
|
||||
|
||||
await cleanupTunnelResources(tunnelName, true);
|
||||
tunnelLogger.info("Tunnel cleanup completed", {
|
||||
operation: "tunnel_cleanup_complete",
|
||||
userId,
|
||||
hostId: config?.sourceHostId,
|
||||
tunnelName,
|
||||
});
|
||||
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.DISCONNECTED,
|
||||
manualDisconnect: true,
|
||||
});
|
||||
|
||||
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
|
||||
handleDisconnect(tunnelName, tunnelConfig, false);
|
||||
|
||||
setTimeout(() => {
|
||||
manualDisconnects.delete(tunnelName);
|
||||
}, 5000);
|
||||
|
||||
res.json({ message: "Disconnect request received", tunnelName });
|
||||
} catch (error) {
|
||||
tunnelLogger.error("Failed to disconnect tunnel", error, {
|
||||
operation: "tunnel_disconnect",
|
||||
tunnelName,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to disconnect tunnel" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ssh/tunnel/cancel:
|
||||
* post:
|
||||
* summary: Cancel tunnel retry
|
||||
* description: Cancels the retry mechanism for a failed SSH tunnel connection.
|
||||
* tags:
|
||||
* - SSH Tunnels
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* tunnelName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Cancel request received.
|
||||
* 400:
|
||||
* description: Tunnel name required.
|
||||
* 401:
|
||||
* description: Authentication required.
|
||||
* 403:
|
||||
* description: Access denied.
|
||||
* 500:
|
||||
* description: Failed to cancel tunnel retry.
|
||||
*/
|
||||
app.post(
|
||||
"/ssh/tunnel/cancel",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const { tunnelName } = req.body;
|
||||
const userId = req.userId;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
if (!tunnelName) {
|
||||
return res.status(400).json({ error: "Tunnel name required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const config = tunnelConfigs.get(tunnelName);
|
||||
const authorized = await authorizeTunnelAction(
|
||||
userId,
|
||||
tunnelName,
|
||||
config,
|
||||
);
|
||||
if (!authorized.allowed) {
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
|
||||
retryCounters.delete(tunnelName);
|
||||
retryExhaustedTunnels.delete(tunnelName);
|
||||
|
||||
if (activeRetryTimers.has(tunnelName)) {
|
||||
clearTimeout(activeRetryTimers.get(tunnelName)!);
|
||||
activeRetryTimers.delete(tunnelName);
|
||||
}
|
||||
|
||||
if (countdownIntervals.has(tunnelName)) {
|
||||
clearInterval(countdownIntervals.get(tunnelName)!);
|
||||
countdownIntervals.delete(tunnelName);
|
||||
}
|
||||
|
||||
await cleanupTunnelResources(tunnelName, true);
|
||||
|
||||
broadcastTunnelStatus(tunnelName, {
|
||||
connected: false,
|
||||
status: CONNECTION_STATES.DISCONNECTED,
|
||||
manualDisconnect: true,
|
||||
});
|
||||
|
||||
const tunnelConfig = tunnelConfigs.get(tunnelName) || null;
|
||||
handleDisconnect(tunnelName, tunnelConfig, false);
|
||||
|
||||
setTimeout(() => {
|
||||
manualDisconnects.delete(tunnelName);
|
||||
}, 5000);
|
||||
|
||||
res.json({ message: "Cancel request received", tunnelName });
|
||||
} catch (error) {
|
||||
tunnelLogger.error("Failed to cancel tunnel retry", error, {
|
||||
operation: "tunnel_cancel",
|
||||
tunnelName,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to cancel tunnel retry" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import type { Duplex } from "stream";
|
||||
import { tunnelLogger } from "../../utils/logger.js";
|
||||
|
||||
function parseSocksAddress(buffer: Buffer): {
|
||||
address: string;
|
||||
port: number;
|
||||
bytesRead: number;
|
||||
} | null {
|
||||
if (buffer.length < 7 || buffer[0] !== 0x05 || buffer[1] !== 0x01) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const addressType = buffer[3];
|
||||
let offset = 4;
|
||||
let address: string;
|
||||
|
||||
if (addressType === 0x01) {
|
||||
if (buffer.length < offset + 4 + 2) return null;
|
||||
address = Array.from(buffer.subarray(offset, offset + 4)).join(".");
|
||||
offset += 4;
|
||||
} else if (addressType === 0x03) {
|
||||
const len = buffer[offset];
|
||||
offset += 1;
|
||||
if (buffer.length < offset + len + 2) return null;
|
||||
address = buffer.subarray(offset, offset + len).toString("utf8");
|
||||
offset += len;
|
||||
} else if (addressType === 0x04) {
|
||||
if (buffer.length < offset + 16 + 2) return null;
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < 16; i += 2) {
|
||||
parts.push(buffer.readUInt16BE(offset + i).toString(16));
|
||||
}
|
||||
address = parts.join(":");
|
||||
offset += 16;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const port = buffer.readUInt16BE(offset);
|
||||
return { address, port, bytesRead: offset + 2 };
|
||||
}
|
||||
|
||||
export function handleSocks5Connect(
|
||||
inbound: Duplex,
|
||||
openOutbound: (host: string, port: number) => Promise<Duplex>,
|
||||
tunnelName: string,
|
||||
): void {
|
||||
let buffer = Buffer.alloc(0);
|
||||
let stage: "greeting" | "connect" | "piping" = "greeting";
|
||||
|
||||
const fail = (code = 0x01) => {
|
||||
if (!inbound.destroyed) {
|
||||
inbound.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
|
||||
inbound.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
const onData = (chunk: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
|
||||
if (stage === "greeting") {
|
||||
if (buffer.length < 2) return;
|
||||
if (buffer[0] !== 0x05) {
|
||||
fail(0x01);
|
||||
return;
|
||||
}
|
||||
const methodsLength = buffer[1];
|
||||
if (buffer.length < 2 + methodsLength) return;
|
||||
inbound.write(Buffer.from([0x05, 0x00]));
|
||||
buffer = buffer.subarray(2 + methodsLength);
|
||||
stage = "connect";
|
||||
}
|
||||
|
||||
if (stage === "connect") {
|
||||
const parsed = parseSocksAddress(buffer);
|
||||
if (!parsed) return;
|
||||
stage = "piping";
|
||||
inbound.off("data", onData);
|
||||
const remainder = buffer.subarray(parsed.bytesRead);
|
||||
openOutbound(parsed.address, parsed.port)
|
||||
.then((outbound) => {
|
||||
inbound.write(
|
||||
Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]),
|
||||
);
|
||||
if (remainder.length > 0) {
|
||||
outbound.write(remainder);
|
||||
}
|
||||
inbound.pipe(outbound).pipe(inbound);
|
||||
inbound.on("error", () => outbound.destroy());
|
||||
outbound.on("error", () => inbound.destroy());
|
||||
})
|
||||
.catch((error) => {
|
||||
tunnelLogger.error("SOCKS5 tunnel connect failed", error, {
|
||||
operation: "managed_tunnel_socks_connect_failed",
|
||||
tunnelName,
|
||||
targetHost: parsed.address,
|
||||
targetPort: parsed.port,
|
||||
});
|
||||
fail(0x05);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
inbound.on("data", onData);
|
||||
inbound.on("error", () => inbound.destroy());
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import { Client, type ClientChannel } from "ssh2";
|
||||
import type { Duplex } from "stream";
|
||||
import { SSH_ALGORITHMS } from "../../utils/ssh-algorithms.js";
|
||||
import { tunnelLogger } from "../../utils/logger.js";
|
||||
import { getSshAuthProvider } from "../connect/auth-provider-registry.js";
|
||||
import { ensureCoreSshAuthProviders } from "../connect/core-providers.js";
|
||||
import type { MutableConnectConfig, SshAuthOutcome } from "../connect/types.js";
|
||||
|
||||
export function getManagedTunnelAlgorithms() {
|
||||
return {
|
||||
kex: [
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp521",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp256",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"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"],
|
||||
};
|
||||
}
|
||||
|
||||
export interface TunnelCredentials {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authMethod?: string;
|
||||
certPublicKey?: string | null;
|
||||
terminalConfig?: Record<string, unknown> | null;
|
||||
vaultProfile?: { id?: number | null } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a tunnel side's auth into its connect options through the same
|
||||
* providers every other transport uses, so tunnels accept every auth type.
|
||||
*/
|
||||
export async function applyTunnelAuth(
|
||||
connOptions: Record<string, unknown>,
|
||||
credentials: TunnelCredentials,
|
||||
env: {
|
||||
client: Client;
|
||||
userId?: string | null;
|
||||
hostId?: number | null;
|
||||
username: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
},
|
||||
): Promise<SshAuthOutcome> {
|
||||
ensureCoreSshAuthProviders();
|
||||
const authType =
|
||||
credentials.authMethod || (credentials.sshKey ? "key" : "password");
|
||||
const provider = getSshAuthProvider(authType);
|
||||
if (!provider) {
|
||||
return {
|
||||
status: "error",
|
||||
code: "provider-missing",
|
||||
message: `Unsupported authentication type '${authType}' for tunnels`,
|
||||
};
|
||||
}
|
||||
|
||||
const outcome = await provider.prepare(
|
||||
connOptions as MutableConnectConfig,
|
||||
{
|
||||
id: env.hostId ?? 0,
|
||||
ip: env.ip,
|
||||
port: env.port,
|
||||
username: env.username,
|
||||
userId: env.userId,
|
||||
authType,
|
||||
password: credentials.password,
|
||||
key: credentials.sshKey,
|
||||
keyPassword: credentials.keyPassword,
|
||||
keyType: credentials.keyType,
|
||||
certPublicKey: credentials.certPublicKey,
|
||||
terminalConfig: credentials.terminalConfig ?? null,
|
||||
vaultProfile: credentials.vaultProfile ?? null,
|
||||
},
|
||||
{
|
||||
client: env.client,
|
||||
userId: env.userId ?? "",
|
||||
hostId: env.hostId ?? 0,
|
||||
purpose: "tunnel",
|
||||
interactive: false,
|
||||
log: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
if (
|
||||
outcome.status === "ready" &&
|
||||
connOptions.privateKey &&
|
||||
credentials.keyType &&
|
||||
credentials.keyType !== "auto"
|
||||
) {
|
||||
connOptions.privateKeyType = credentials.keyType;
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
export function connectClient(
|
||||
connOptions: Record<string, unknown>,
|
||||
tunnelName: string,
|
||||
role: "source" | "endpoint",
|
||||
client: Client = new Client(),
|
||||
): Promise<Client> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
client.once("ready", () => {
|
||||
settled = true;
|
||||
resolve(client);
|
||||
});
|
||||
client.once("error", (error) => {
|
||||
if (!settled) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
tunnelLogger.error("Managed tunnel SSH client error", error, {
|
||||
operation: "managed_tunnel_client_error",
|
||||
tunnelName,
|
||||
role,
|
||||
});
|
||||
});
|
||||
client.connect(connOptions);
|
||||
});
|
||||
}
|
||||
|
||||
export function forwardOut(
|
||||
client: Client,
|
||||
targetHost: string,
|
||||
targetPort: number,
|
||||
tunnelName?: string,
|
||||
): Promise<ClientChannel> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream) => {
|
||||
if (err) {
|
||||
if (tunnelName) {
|
||||
tunnelLogger.error("Managed tunnel forwardOut failed", err, {
|
||||
operation: "managed_tunnel_forward_out_failed",
|
||||
tunnelName,
|
||||
targetHost,
|
||||
targetPort,
|
||||
});
|
||||
}
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(stream);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function bindForwardIn(
|
||||
client: Client,
|
||||
bindHost: string,
|
||||
bindPort: number,
|
||||
): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.forwardIn(bindHost, bindPort, (err, actualPort) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(actualPort || bindPort);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function unbindForwardIn(
|
||||
client: Client,
|
||||
bindHost: string,
|
||||
bindPort: number,
|
||||
): void {
|
||||
try {
|
||||
client.unforwardIn(bindHost, bindPort, (err) => {
|
||||
if (err) {
|
||||
tunnelLogger.warn("Failed to unbind managed tunnel listener", {
|
||||
operation: "managed_tunnel_unforward_failed",
|
||||
bindHost,
|
||||
bindPort,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// The connection may already be gone.
|
||||
}
|
||||
}
|
||||
|
||||
export function pipeTunnelStreams(
|
||||
inbound: Duplex,
|
||||
outboundPromise: Promise<Duplex>,
|
||||
tunnelName: string,
|
||||
): void {
|
||||
outboundPromise
|
||||
.then((outbound) => {
|
||||
inbound.pipe(outbound).pipe(inbound);
|
||||
inbound.on("error", () => outbound.destroy());
|
||||
outbound.on("error", () => inbound.destroy());
|
||||
})
|
||||
.catch((error) => {
|
||||
tunnelLogger.error(
|
||||
"Failed to open managed tunnel outbound stream",
|
||||
error,
|
||||
{
|
||||
operation: "managed_tunnel_outbound_failed",
|
||||
tunnelName,
|
||||
},
|
||||
);
|
||||
inbound.destroy();
|
||||
});
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import type { ErrorType, TunnelConfig } from "../../../types/index.js";
|
||||
import { tunnelLogger } from "../../utils/logger.js";
|
||||
|
||||
export function classifyTunnelError(errorMessage: string): ErrorType {
|
||||
if (!errorMessage) return "UNKNOWN";
|
||||
|
||||
const message = errorMessage.toLowerCase();
|
||||
|
||||
if (
|
||||
message.includes("closed by remote host") ||
|
||||
message.includes("connection reset by peer") ||
|
||||
message.includes("connection refused") ||
|
||||
message.includes("broken pipe")
|
||||
) {
|
||||
return "NETWORK_ERROR";
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("authentication failed") ||
|
||||
message.includes("permission denied") ||
|
||||
message.includes("incorrect password")
|
||||
) {
|
||||
return "AUTHENTICATION_FAILED";
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("connect etimedout") ||
|
||||
message.includes("timeout") ||
|
||||
message.includes("timed out") ||
|
||||
message.includes("keepalive timeout")
|
||||
) {
|
||||
return "TIMEOUT";
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("bind: address already in use") ||
|
||||
message.includes("failed for listen port") ||
|
||||
message.includes("port forwarding failed")
|
||||
) {
|
||||
return "CONNECTION_FAILED";
|
||||
}
|
||||
|
||||
if (message.includes("permission") || message.includes("access denied")) {
|
||||
return "CONNECTION_FAILED";
|
||||
}
|
||||
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
export function getTunnelMarker(tunnelName: string): string {
|
||||
return `TUNNEL_MARKER_${tunnelName.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
||||
}
|
||||
|
||||
export function normalizeTunnelName(
|
||||
hostId: number,
|
||||
tunnelIndex: number,
|
||||
displayName: string,
|
||||
sourcePort: number,
|
||||
endpointHost: string,
|
||||
endpointPort: number,
|
||||
): string {
|
||||
return `${hostId}::${tunnelIndex}::${displayName}::${sourcePort}::${endpointHost}::${endpointPort}`;
|
||||
}
|
||||
|
||||
export function getTunnelMode(
|
||||
tunnelConfig: TunnelConfig,
|
||||
): "local" | "remote" | "dynamic" {
|
||||
return tunnelConfig.mode || tunnelConfig.tunnelType || "remote";
|
||||
}
|
||||
|
||||
export function getTunnelScope(tunnelConfig: TunnelConfig): "s2s" | "c2s" {
|
||||
return tunnelConfig.scope || "s2s";
|
||||
}
|
||||
|
||||
export function getTunnelBindHost(tunnelConfig: TunnelConfig): string {
|
||||
return tunnelConfig.bindHost || "127.0.0.1";
|
||||
}
|
||||
|
||||
export function parseTunnelName(tunnelName: string): {
|
||||
hostId?: number;
|
||||
tunnelIndex?: number;
|
||||
displayName: string;
|
||||
sourcePort: string;
|
||||
endpointHost: string;
|
||||
endpointPort: string;
|
||||
isLegacyFormat: boolean;
|
||||
} {
|
||||
const parts = tunnelName.split("::");
|
||||
|
||||
if (parts.length === 6) {
|
||||
return {
|
||||
hostId: parseInt(parts[0]),
|
||||
tunnelIndex: parseInt(parts[1]),
|
||||
displayName: parts[2],
|
||||
sourcePort: parts[3],
|
||||
endpointHost: parts[4],
|
||||
endpointPort: parts[5],
|
||||
isLegacyFormat: false,
|
||||
};
|
||||
}
|
||||
|
||||
tunnelLogger.warn(`Legacy tunnel name format: ${tunnelName}`);
|
||||
|
||||
const legacyParts = tunnelName.split("_");
|
||||
return {
|
||||
displayName: legacyParts[0] || "unknown",
|
||||
sourcePort: legacyParts[legacyParts.length - 3] || "0",
|
||||
endpointHost: legacyParts[legacyParts.length - 2] || "unknown",
|
||||
endpointPort: legacyParts[legacyParts.length - 1] || "0",
|
||||
isLegacyFormat: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateTunnelConfig(
|
||||
tunnelName: string,
|
||||
tunnelConfig: TunnelConfig,
|
||||
): boolean {
|
||||
const parsed = parseTunnelName(tunnelName);
|
||||
|
||||
if (parsed.isLegacyFormat) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
parsed.hostId === tunnelConfig.sourceHostId &&
|
||||
parsed.tunnelIndex === tunnelConfig.tunnelIndex &&
|
||||
String(parsed.sourcePort) === String(tunnelConfig.sourcePort) &&
|
||||
parsed.endpointHost === tunnelConfig.endpointHost &&
|
||||
String(parsed.endpointPort) === String(tunnelConfig.endpointPort)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tunnel names beginning with this prefix are reserved for web endpoint
|
||||
* tunnels: they are opened on demand and never retried on disconnect (see the
|
||||
* early return in `handleDisconnect`). A user-supplied tunnel name using this
|
||||
* prefix would silently disable its own retry/reconnect behaviour, and could
|
||||
* collide with a live web endpoint forward -- so the prefix is rejected at the
|
||||
* point tunnel names are accepted from a request (the /ssh/tunnel/connect
|
||||
* route).
|
||||
*/
|
||||
export const RESERVED_TUNNEL_NAME_PREFIX = "web:";
|
||||
|
||||
export function isReservedTunnelName(tunnelName: string): boolean {
|
||||
return tunnelName.startsWith(RESERVED_TUNNEL_NAME_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact inverse of `parseReservedTunnelName`. Routes that open or close a
|
||||
* web endpoint tunnel must call this rather than composing the string inline,
|
||||
* so the two can never drift apart.
|
||||
*/
|
||||
export function buildWebEndpointTunnelName(
|
||||
hostId: number,
|
||||
endpointId: string,
|
||||
): string {
|
||||
return `${RESERVED_TUNNEL_NAME_PREFIX}${hostId}:${endpointId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovers the host id and endpoint id encoded in a web endpoint tunnel name,
|
||||
* so a route that only has the name -- /ssh/tunnel/disconnect is not told
|
||||
* which host a reserved tunnel belongs to -- can still perform an ownership
|
||||
* check.
|
||||
*
|
||||
* Returns null for anything that is not a reserved name, or whose host id
|
||||
* segment is not a positive integer. Callers must treat null as "cannot
|
||||
* verify ownership" and FAIL CLOSED, never fall through to an unchecked path.
|
||||
*
|
||||
* The endpoint id is taken verbatim as everything after the first ":"
|
||||
* following the host id, so it may itself contain colons -- endpoint ids are
|
||||
* client-supplied with no character restriction. Splitting on the first colon
|
||||
* rather than the last is safe because the host id segment is digits only.
|
||||
*/
|
||||
export function parseReservedTunnelName(
|
||||
tunnelName: string,
|
||||
): { hostId: number; endpointId: string } | null {
|
||||
if (!isReservedTunnelName(tunnelName)) return null;
|
||||
|
||||
const rest = tunnelName.slice(RESERVED_TUNNEL_NAME_PREFIX.length);
|
||||
const separatorIndex = rest.indexOf(":");
|
||||
if (separatorIndex === -1) return null;
|
||||
|
||||
const hostIdPart = rest.slice(0, separatorIndex);
|
||||
const endpointId = rest.slice(separatorIndex + 1);
|
||||
if (!/^[0-9]+$/.test(hostIdPart) || !endpointId) return null;
|
||||
|
||||
const hostId = Number(hostIdPart);
|
||||
if (!Number.isInteger(hostId) || hostId < 1) return null;
|
||||
|
||||
return { hostId, endpointId };
|
||||
}
|
||||
@@ -128,6 +128,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
timeoutMs: options?.timeoutMs,
|
||||
prompt: options?.prompt,
|
||||
overrides: options?.overrides as Partial<MutableConnectConfig>,
|
||||
sock: options?.sock as MutableConnectConfig["sock"],
|
||||
});
|
||||
await audit("ssh_connect", describeHost(host), { success: true });
|
||||
|
||||
|
||||
@@ -308,12 +308,11 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
// server. See plugins/ssh-terminal, plugins/docker, plugins/host-metrics
|
||||
// and plugins/file-manager.
|
||||
// Every other plugin (AI, Proxmox, Remote Desktop, Fleets, Automations,
|
||||
// Network Topology, Workspaces, Web Endpoint) is absent for the same
|
||||
// reason: each one serves its routes under /plugin-api/<id>/ through
|
||||
// Network Topology, Workspaces, Web Endpoint, Tunnels) is absent for the
|
||||
// same reason: each one serves its routes under /plugin-api/<id>/ through
|
||||
// ctx.http on activate, so disabling it answers 503 instead of leaving a
|
||||
// dead import here. Automations' scheduler also starts from its own
|
||||
// activate() rather than here.
|
||||
await import("./hosts/tunnel/index.js");
|
||||
// dead import here. Automations' scheduler and tunnel autostart also
|
||||
// start from their own activate() rather than here.
|
||||
await import("./hosts/tmux/index.js");
|
||||
await import("./hosts/serial.js");
|
||||
await import("./services/dashboard.js");
|
||||
@@ -354,6 +353,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
const { runFileManagerSettingsMigration } =
|
||||
await import("./utils/crypto-migration/file-manager-settings-migration.js");
|
||||
await runFileManagerSettingsMigration();
|
||||
|
||||
const { runTunnelsSettingsMigration } =
|
||||
await import("./utils/crypto-migration/tunnels-settings-migration.js");
|
||||
await runTunnelsSettingsMigration();
|
||||
} catch (error) {
|
||||
systemLogger.warn("Plugin runtime failed to initialize", {
|
||||
operation: "plugin_init",
|
||||
|
||||
@@ -21,7 +21,6 @@ describe("bootstrap creates the tables the removed probes covered", () => {
|
||||
|
||||
// Exactly the tables whose unreachable re-creation was deleted.
|
||||
const TABLES = [
|
||||
"c2s_tunnel_presets",
|
||||
"sessions",
|
||||
"trusted_devices",
|
||||
"host_access",
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { C2sTunnelPresetRepository } from "../../../database/repositories/c2s-tunnel-preset-repository.js";
|
||||
|
||||
describe("C2sTunnelPresetRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<C2sTunnelPresetRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
`);
|
||||
|
||||
return new C2sTunnelPresetRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates and lists presets ordered by name", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.createForUser("user-1", {
|
||||
name: "Zulu",
|
||||
config: "[]",
|
||||
platform: "linux",
|
||||
computerName: "workstation",
|
||||
});
|
||||
await repo.createForUser("user-1", { name: "Alpha", config: "[]" });
|
||||
await repo.createForUser("user-2", { name: "Other", config: "[]" });
|
||||
|
||||
const presets = await repo.listByUserId("user-1");
|
||||
|
||||
expect(presets.map((preset) => preset.name)).toEqual(["Alpha", "Zulu"]);
|
||||
expect(presets[1]).toMatchObject({
|
||||
platform: "linux",
|
||||
computerName: "workstation",
|
||||
});
|
||||
});
|
||||
|
||||
it("finds, updates, and deletes user-owned presets", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const preset = await repo.createForUser("user-1", {
|
||||
name: "Home",
|
||||
config: "[]",
|
||||
});
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
expect(await repo.findByIdForUser("user-2", preset.id)).toBeNull();
|
||||
expect(await repo.hasNameForUser("user-1", "Home")).toBe(true);
|
||||
expect(await repo.hasNameForUser("user-1", "Home", preset.id)).toBe(false);
|
||||
|
||||
const updated = await repo.updateForUser("user-1", preset.id, {
|
||||
name: "Renamed",
|
||||
platform: "darwin",
|
||||
});
|
||||
expect(updated).toMatchObject({
|
||||
id: preset.id,
|
||||
name: "Renamed",
|
||||
platform: "darwin",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(
|
||||
await repo.updateForUser("user-2", preset.id, { name: "Nope" }),
|
||||
).toBeNull();
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(await repo.deleteForUser("user-2", preset.id)).toBe(false);
|
||||
expect(await repo.deleteForUser("user-1", preset.id)).toBe(true);
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
|
||||
it("deletes all presets for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.createForUser("user-1", { name: "One", config: "[]" });
|
||||
await repo.createForUser("user-1", { name: "Two", config: "[]" });
|
||||
await repo.createForUser("user-2", { name: "Other", config: "[]" });
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toBe(2);
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
|
||||
|
||||
expect(await repo.listByUserId("user-1")).toEqual([]);
|
||||
expect(
|
||||
(await repo.listByUserId("user-2")).map((preset) => preset.name),
|
||||
).toEqual(["Other"]);
|
||||
expect(writeCount).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -521,6 +521,30 @@ describe("connectHost", () => {
|
||||
).toEqual({ proxied: true });
|
||||
});
|
||||
|
||||
it("connects over a stream it is given and skips the transport", async () => {
|
||||
const connection = await connectHost(
|
||||
host({
|
||||
jumpHosts: [{ hostId: 2 }],
|
||||
useSocks5: true,
|
||||
socks5Host: "proxy",
|
||||
portKnockSequence: [{ port: 7000 }],
|
||||
}),
|
||||
{
|
||||
userId: "user-1",
|
||||
purpose: "tunnel",
|
||||
sock: { throughSource: true } as never,
|
||||
},
|
||||
);
|
||||
const config = (connection.client as unknown as FakeSshClient)
|
||||
.connectConfig!;
|
||||
expect(config.sock).toEqual({ throughSource: true });
|
||||
expect(config.hostVerifier).toBe(mocks.verifier);
|
||||
expect(mocks.createJumpHostChain).not.toHaveBeenCalled();
|
||||
expect(mocks.createSocks5Connection).not.toHaveBeenCalled();
|
||||
expect(mocks.performPortKnocking).not.toHaveBeenCalled();
|
||||
expect(connection.jumpClient).toBeNull();
|
||||
});
|
||||
|
||||
it("knocks before connecting when the host has a sequence", async () => {
|
||||
await connectHost(
|
||||
host({ portKnockSequence: [{ port: 7000 }, { port: 8000 }] }),
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const canAccessHost = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: { getInstance: () => ({ canAccessHost }) },
|
||||
}));
|
||||
|
||||
/**
|
||||
* /ssh/tunnel/disconnect and /ssh/tunnel/cancel are addressed by tunnel NAME.
|
||||
* Their ownership check used to run only inside
|
||||
* `if (config && config.sourceHostId)`, and web endpoint tunnels are
|
||||
* deliberately kept out of tunnelConfigs -- so `config` was always undefined
|
||||
* for them and the check never ran.
|
||||
*
|
||||
* Host ids are small sequential integers and endpoint ids are client-supplied
|
||||
* with no server-side randomness, so `web:{hostId}:{endpointId}` is guessable.
|
||||
* That let any authenticated user force-close another user's live tunnel, and
|
||||
* manualDisconnects then made the victim's own re-open fail for several
|
||||
* seconds.
|
||||
*/
|
||||
describe("authorizeTunnelAction", () => {
|
||||
beforeEach(() => {
|
||||
canAccessHost.mockReset();
|
||||
});
|
||||
|
||||
it("checks the host encoded in a reserved name when no config exists", async () => {
|
||||
canAccessHost.mockResolvedValue({ hasAccess: true });
|
||||
const { authorizeTunnelAction } =
|
||||
await import("../../hosts/tunnel/routes.js");
|
||||
|
||||
const result = await authorizeTunnelAction("u1", "web:7:e1", undefined);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(canAccessHost).toHaveBeenCalledWith("u1", 7, "connect");
|
||||
});
|
||||
|
||||
it("denies a reserved name whose host the user cannot access", async () => {
|
||||
canAccessHost.mockResolvedValue({ hasAccess: false });
|
||||
const { authorizeTunnelAction } =
|
||||
await import("../../hosts/tunnel/routes.js");
|
||||
|
||||
expect(
|
||||
(await authorizeTunnelAction("u1", "web:7:e1", undefined)).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed on a reserved name it cannot parse", async () => {
|
||||
// Never fall through to the unchecked path: an unparseable reserved name
|
||||
// means "cannot verify ownership", which must deny.
|
||||
const { authorizeTunnelAction } =
|
||||
await import("../../hosts/tunnel/routes.js");
|
||||
|
||||
for (const name of ["web:", "web:abc:e1", "web:0:e1"]) {
|
||||
expect((await authorizeTunnelAction("u1", name, undefined)).allowed).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
expect(canAccessHost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still checks a registered config's host id", async () => {
|
||||
canAccessHost.mockResolvedValue({ hasAccess: true });
|
||||
const { authorizeTunnelAction } =
|
||||
await import("../../hosts/tunnel/routes.js");
|
||||
|
||||
await authorizeTunnelAction("u1", "my-tunnel", { sourceHostId: 42 });
|
||||
expect(canAccessHost).toHaveBeenCalledWith("u1", 42, "connect");
|
||||
});
|
||||
|
||||
it("leaves an ordinary unregistered name allowed, as before", async () => {
|
||||
// Pre-existing behaviour for non-reserved names, preserved deliberately:
|
||||
// tightening it is a separate change with its own blast radius.
|
||||
const { authorizeTunnelAction } =
|
||||
await import("../../hosts/tunnel/routes.js");
|
||||
|
||||
expect(
|
||||
(await authorizeTunnelAction("u1", "my-tunnel", undefined)).allowed,
|
||||
).toBe(true);
|
||||
expect(canAccessHost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
RESERVED_TUNNEL_NAME_PREFIX,
|
||||
buildWebEndpointTunnelName,
|
||||
isReservedTunnelName,
|
||||
parseReservedTunnelName,
|
||||
} from "../../hosts/tunnel/utils.js";
|
||||
|
||||
describe("reserved tunnel names", () => {
|
||||
it("builds a name from a host id and endpoint id", () => {
|
||||
expect(buildWebEndpointTunnelName(7, "e1")).toBe("web:7:e1");
|
||||
});
|
||||
|
||||
it("recognises its own names and nothing else", () => {
|
||||
expect(isReservedTunnelName(buildWebEndpointTunnelName(7, "e1"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isReservedTunnelName("my-tunnel")).toBe(false);
|
||||
expect(isReservedTunnelName("website")).toBe(false);
|
||||
expect(isReservedTunnelName(RESERVED_TUNNEL_NAME_PREFIX)).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips through parse", () => {
|
||||
expect(
|
||||
parseReservedTunnelName(buildWebEndpointTunnelName(7, "e1")),
|
||||
).toEqual({
|
||||
hostId: 7,
|
||||
endpointId: "e1",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an endpoint id containing colons intact", () => {
|
||||
// Endpoint ids are client-supplied strings with no character restriction,
|
||||
// so splitting on the LAST colon would corrupt them. The host id segment
|
||||
// is digits only, so splitting on the first colon after it is safe.
|
||||
expect(parseReservedTunnelName("web:7:a:b:c")).toEqual({
|
||||
hostId: 7,
|
||||
endpointId: "a:b:c",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for anything it cannot verify", () => {
|
||||
// Callers use this for an ownership check, so null must mean "cannot
|
||||
// verify" and they must fail closed. Never return a partial guess.
|
||||
for (const name of [
|
||||
"my-tunnel",
|
||||
"web:",
|
||||
"web:7",
|
||||
"web::e1",
|
||||
"web:abc:e1",
|
||||
"web:-1:e1",
|
||||
"web:0:e1",
|
||||
"web:7:",
|
||||
"web:7.5:e1",
|
||||
]) {
|
||||
expect(parseReservedTunnelName(name)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveC2SSourceHostId } from "../../../hosts/tunnel/c2s-relay.js";
|
||||
|
||||
describe("resolveC2SSourceHostId", () => {
|
||||
it("uses the remote row matching the stable sync id", async () => {
|
||||
const findBySyncId = vi.fn().mockResolvedValue(42);
|
||||
|
||||
await expect(
|
||||
resolveC2SSourceHostId(
|
||||
{ sourceHostId: 7, sourceHostSyncId: "host-sync-id" },
|
||||
findBySyncId,
|
||||
),
|
||||
).resolves.toBe(42);
|
||||
expect(findBySyncId).toHaveBeenCalledWith("host-sync-id");
|
||||
});
|
||||
|
||||
it("keeps legacy local ids when no sync id is available", async () => {
|
||||
await expect(
|
||||
resolveC2SSourceHostId({ sourceHostId: 7 }, vi.fn()),
|
||||
).resolves.toBe(7);
|
||||
});
|
||||
|
||||
it("does not silently fall back to a mismatched id", async () => {
|
||||
await expect(
|
||||
resolveC2SSourceHostId(
|
||||
{ sourceHostId: 7, sourceHostSyncId: "missing" },
|
||||
vi.fn().mockResolvedValue(null),
|
||||
),
|
||||
).rejects.toThrow("not found on the remote server");
|
||||
});
|
||||
});
|
||||
@@ -133,6 +133,27 @@ describe("ctx.ssh", () => {
|
||||
await expect(ssh.connect(3)).rejects.toThrow(/acting user/);
|
||||
});
|
||||
|
||||
it("passes a given stream through to the pipeline, gated like any connect", async () => {
|
||||
const stream = { throughSource: true };
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
h.granted = new Set(["ssh:connect"]);
|
||||
await expect(ssh.connect(7, { sock: stream })).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
expect(h.connects).toEqual([]);
|
||||
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
await ssh.connect(7, { purpose: "tunnel", sock: stream });
|
||||
expect(h.connects[0].options).toMatchObject({
|
||||
purpose: "tunnel",
|
||||
sock: stream,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes open connections when the plugin is disposed", async () => {
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const bag = new DisposableBag("fixture");
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* The tunnel host-columns migration.
|
||||
*
|
||||
* An upgrade must be lossless: a host with tunnels on and a saved tunnel list
|
||||
* must keep both once the ssh_data columns are dropped. Running it twice
|
||||
* must not duplicate or clobber what it moved.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface HostRow {
|
||||
id: number;
|
||||
enable_tunnel: boolean;
|
||||
tunnel_connections: string | null;
|
||||
}
|
||||
|
||||
interface SettingsRow {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const hostRows: HostRow[] = [];
|
||||
const settingsRows: SettingsRow[] = [];
|
||||
|
||||
const find = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) =>
|
||||
settingsRows.find(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key,
|
||||
);
|
||||
|
||||
const pluginSettingsRepository = {
|
||||
get: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(pluginId, scope, scopeId, key) ?? null,
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(pluginId, scope, scopeId, key);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
return;
|
||||
}
|
||||
settingsRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => ({
|
||||
all: async () =>
|
||||
hostRows.filter(
|
||||
(row) => row.enable_tunnel || row.tunnel_connections !== null,
|
||||
),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { runTunnelsSettingsMigration } =
|
||||
await import("../../utils/crypto-migration/tunnels-settings-migration.js");
|
||||
|
||||
function storedValue(hostId: number, key: string): unknown {
|
||||
const row = settingsRows.find(
|
||||
(entry) => entry.scopeId === String(hostId) && entry.key === key,
|
||||
);
|
||||
return row?.value === null || row?.value === undefined
|
||||
? undefined
|
||||
: JSON.parse(row.value);
|
||||
}
|
||||
|
||||
const tunnel = {
|
||||
scope: "s2s",
|
||||
mode: "local",
|
||||
sourcePort: 8080,
|
||||
endpointHost: "db",
|
||||
endpointPort: 5432,
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
hostRows.length = 0;
|
||||
settingsRows.length = 0;
|
||||
});
|
||||
|
||||
describe("runTunnelsSettingsMigration", () => {
|
||||
it("moves a host's switch and saved tunnels into plugin settings", async () => {
|
||||
hostRows.push({
|
||||
id: 1,
|
||||
enable_tunnel: true,
|
||||
tunnel_connections: JSON.stringify([tunnel]),
|
||||
});
|
||||
|
||||
const result = await runTunnelsSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(1, "enableTunnel")).toBe(true);
|
||||
expect(storedValue(1, "tunnelConnections")).toEqual([tunnel]);
|
||||
expect(settingsRows.every((row) => row.pluginId === "tunnels")).toBe(true);
|
||||
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a disabled host's saved tunnels and its disabled switch", async () => {
|
||||
hostRows.push({
|
||||
id: 3,
|
||||
enable_tunnel: false,
|
||||
tunnel_connections: JSON.stringify([tunnel]),
|
||||
});
|
||||
|
||||
await runTunnelsSettingsMigration();
|
||||
|
||||
expect(storedValue(3, "enableTunnel")).toBe(false);
|
||||
expect(storedValue(3, "tunnelConnections")).toEqual([tunnel]);
|
||||
});
|
||||
|
||||
it("stores an unreadable tunnel list as empty rather than failing", async () => {
|
||||
hostRows.push({ id: 4, enable_tunnel: true, tunnel_connections: "{nope" });
|
||||
|
||||
const result = await runTunnelsSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(1);
|
||||
expect(storedValue(4, "tunnelConnections")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing on a fresh install", async () => {
|
||||
const result = await runTunnelsSettingsMigration();
|
||||
|
||||
expect(result.moved).toBe(0);
|
||||
expect(settingsRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("is idempotent: a second run changes nothing", async () => {
|
||||
hostRows.push({ id: 2, enable_tunnel: true, tunnel_connections: null });
|
||||
|
||||
await runTunnelsSettingsMigration();
|
||||
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
|
||||
|
||||
const second = await runTunnelsSettingsMigration();
|
||||
|
||||
expect(second.moved).toBe(0);
|
||||
expect(second.skipped).toBe(1);
|
||||
expect(settingsRows).toEqual(afterFirst);
|
||||
});
|
||||
|
||||
it("does not overwrite a value the plugin already saved", async () => {
|
||||
hostRows.push({
|
||||
id: 5,
|
||||
enable_tunnel: true,
|
||||
tunnel_connections: JSON.stringify([tunnel]),
|
||||
});
|
||||
await pluginSettingsRepository.set(
|
||||
"tunnels",
|
||||
"host",
|
||||
"5",
|
||||
"enableTunnel",
|
||||
"false",
|
||||
);
|
||||
|
||||
const result = await runTunnelsSettingsMigration();
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(storedValue(5, "enableTunnel")).toBe(false);
|
||||
expect(storedValue(5, "tunnelConnections")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Moves the two tunnel host columns into the tunnels plugin's host-scope
|
||||
* settings, before those columns are dropped from ssh_data.
|
||||
*
|
||||
* Idempotent: skips a host that already has a tunnels settings row, so it is
|
||||
* safe to run on every boot. Lossless: reads straight off the still-present
|
||||
* ssh_data columns, so it must run and finish before those columns are ever
|
||||
* dropped from schema.ts.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import { getDb } from "../../database/db/index.js";
|
||||
import { createCurrentPluginSettingsRepository } from "../../database/repositories/factory.js";
|
||||
|
||||
export interface TunnelsSettingsMigrationResult {
|
||||
moved: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface LegacyTunnelRow {
|
||||
id: number;
|
||||
enable_tunnel: number | boolean | null;
|
||||
tunnel_connections: string | null;
|
||||
}
|
||||
|
||||
function parseConnections(raw: string | null): unknown[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTunnelsSettingsMigration(): Promise<TunnelsSettingsMigrationResult> {
|
||||
const result: TunnelsSettingsMigrationResult = { moved: 0, skipped: 0 };
|
||||
|
||||
try {
|
||||
const drizzleDb = getDb();
|
||||
// Raw SQL, not the typed schema, so this keeps working once schema.ts
|
||||
// stops declaring these columns. The plugin's enable switch defaults to
|
||||
// off, so every host that had tunnels on (the old column default) or
|
||||
// any saved tunnel has to be copied, not just the non-default ones.
|
||||
const rows = await drizzleDb.all<LegacyTunnelRow>(sql`
|
||||
SELECT id, enable_tunnel, tunnel_connections
|
||||
FROM ssh_data
|
||||
WHERE enable_tunnel = true OR tunnel_connections IS NOT NULL
|
||||
`);
|
||||
|
||||
if (rows.length === 0) return result;
|
||||
|
||||
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
|
||||
|
||||
for (const row of rows) {
|
||||
const scopeId = String(row.id);
|
||||
const existing = await pluginSettingsRepository.get(
|
||||
"tunnels",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableTunnel",
|
||||
);
|
||||
if (existing && existing.value !== null) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await pluginSettingsRepository.set(
|
||||
"tunnels",
|
||||
"host",
|
||||
scopeId,
|
||||
"enableTunnel",
|
||||
JSON.stringify(!!row.enable_tunnel),
|
||||
);
|
||||
await pluginSettingsRepository.set(
|
||||
"tunnels",
|
||||
"host",
|
||||
scopeId,
|
||||
"tunnelConnections",
|
||||
JSON.stringify(parseConnections(row.tunnel_connections)),
|
||||
);
|
||||
result.moved++;
|
||||
}
|
||||
|
||||
if (result.moved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved tunnel settings for ${result.moved} host(s) into plugin settings`,
|
||||
{ operation: "tunnels_settings_migration", moved: result.moved },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// A failed migration must not stop the backend. Hosts simply keep
|
||||
// whatever tunnel settings they already had (none, on a fresh run).
|
||||
databaseLogger.warn("Tunnel settings migration failed", {
|
||||
operation: "tunnels_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -22,10 +22,6 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
url: "http://localhost:30001",
|
||||
description: "Main database and authentication server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30003",
|
||||
description: "SSH tunnel management server",
|
||||
},
|
||||
{
|
||||
url: "http://localhost:30004",
|
||||
description: "SSH file manager server",
|
||||
|
||||
Reference in new issue
Block a user