mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
* fix: harden file reads and timer cleanup * fix: preserve literal file path escapes
495 lines
15 KiB
TypeScript
495 lines
15 KiB
TypeScript
import crypto from "crypto";
|
|
import express, { type Request, type Response } from "express";
|
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
|
import { AuthManager } from "../../utils/auth-manager.js";
|
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
|
import { sshLogger } from "../../utils/logger.js";
|
|
import { sessionManager } from "../terminal/session-manager.js";
|
|
import {
|
|
isLiveSession,
|
|
isLiveSessionOwnedBy,
|
|
isSharingEnabledForHost,
|
|
} from "./live-sessions.js";
|
|
import { GuacamoleTokenService } from "../guacamole/token-service.js";
|
|
import { createCurrentSessionShareRepository } from "../../database/repositories/factory.js";
|
|
|
|
const router = express.Router();
|
|
const authManager = AuthManager.getInstance();
|
|
const authenticateJWT = authManager.createAuthMiddleware();
|
|
const permissionManager = PermissionManager.getInstance();
|
|
const tokenService = GuacamoleTokenService.getInstance();
|
|
|
|
const DEFAULT_EXPIRY_HOURS = 24;
|
|
const MAX_EXPIRY_HOURS = 24 * 30;
|
|
|
|
type Protocol = "ssh" | "rdp" | "vnc" | "telnet";
|
|
type PermissionLevel = "read-only" | "read-write";
|
|
|
|
interface ResolveRateEntry {
|
|
count: number;
|
|
windowStart: number;
|
|
}
|
|
const resolveAttempts = new Map<string, ResolveRateEntry>();
|
|
function computeExpiresAt(expiryHours: number | undefined): string {
|
|
const hours = Math.min(
|
|
Math.max(expiryHours ?? DEFAULT_EXPIRY_HOURS, 1),
|
|
MAX_EXPIRY_HOURS,
|
|
);
|
|
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
|
|
}
|
|
|
|
const RESOLVE_WINDOW_MS = 60 * 1000;
|
|
const RESOLVE_MAX_ATTEMPTS = 30;
|
|
|
|
function isResolveRateLimited(ip: string): boolean {
|
|
const now = Date.now();
|
|
const entry = resolveAttempts.get(ip);
|
|
if (!entry || now - entry.windowStart > RESOLVE_WINDOW_MS) {
|
|
resolveAttempts.set(ip, { count: 1, windowStart: now });
|
|
return false;
|
|
}
|
|
entry.count += 1;
|
|
return entry.count > RESOLVE_MAX_ATTEMPTS;
|
|
}
|
|
|
|
setInterval(
|
|
() => {
|
|
const now = Date.now();
|
|
for (const [ip, entry] of resolveAttempts.entries()) {
|
|
if (now - entry.windowStart > RESOLVE_WINDOW_MS)
|
|
resolveAttempts.delete(ip);
|
|
}
|
|
},
|
|
5 * 60 * 1000,
|
|
).unref();
|
|
|
|
/**
|
|
* @openapi
|
|
* /session-sharing/create:
|
|
* post:
|
|
* summary: Create a session share (link or targeted user)
|
|
* description: Mints a share grant for a live terminal/RDP/VNC/Telnet session. Caller must own the live session.
|
|
* tags:
|
|
* - Session Sharing
|
|
* security:
|
|
* - bearerAuth: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required:
|
|
* - hostId
|
|
* - sessionId
|
|
* - protocol
|
|
* - shareType
|
|
* - permissionLevel
|
|
* properties:
|
|
* hostId:
|
|
* type: integer
|
|
* sessionId:
|
|
* type: string
|
|
* tabInstanceId:
|
|
* type: string
|
|
* protocol:
|
|
* type: string
|
|
* enum: [ssh, rdp, vnc, telnet]
|
|
* shareType:
|
|
* type: string
|
|
* enum: [link, user]
|
|
* targetUserId:
|
|
* type: string
|
|
* permissionLevel:
|
|
* type: string
|
|
* enum: [read-only, read-write]
|
|
* expiryHours:
|
|
* type: number
|
|
* responses:
|
|
* 200:
|
|
* description: Share created
|
|
* 400:
|
|
* description: Invalid request
|
|
* 403:
|
|
* description: Sharing disabled, or caller does not own the session
|
|
* 500:
|
|
* description: Server error
|
|
*/
|
|
router.post("/create", authenticateJWT, async (req: Request, res: Response) => {
|
|
try {
|
|
const userId = (req as AuthenticatedRequest).userId!;
|
|
const {
|
|
hostId,
|
|
sessionId,
|
|
tabInstanceId,
|
|
protocol,
|
|
shareType,
|
|
targetUserId,
|
|
permissionLevel,
|
|
expiryHours,
|
|
} = req.body ?? {};
|
|
|
|
if (!hostId || !sessionId || !protocol || !shareType || !permissionLevel) {
|
|
return res.status(400).json({ error: "Missing required fields" });
|
|
}
|
|
if (!["ssh", "rdp", "vnc", "telnet"].includes(protocol)) {
|
|
return res.status(400).json({ error: "Invalid protocol" });
|
|
}
|
|
if (!["link", "user"].includes(shareType)) {
|
|
return res.status(400).json({ error: "Invalid shareType" });
|
|
}
|
|
if (!["read-only", "read-write"].includes(permissionLevel)) {
|
|
return res.status(400).json({ error: "Invalid permissionLevel" });
|
|
}
|
|
if (shareType === "user" && !targetUserId) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: "targetUserId is required for user shares" });
|
|
}
|
|
|
|
const numericHostId = Number(hostId);
|
|
|
|
const { enabled: sharingEnabled } =
|
|
await isSharingEnabledForHost(numericHostId);
|
|
if (!sharingEnabled) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Session sharing is disabled for this host" });
|
|
}
|
|
|
|
if (!isLiveSessionOwnedBy(protocol, String(sessionId), userId)) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "You do not own this live session" });
|
|
}
|
|
|
|
if (shareType === "user") {
|
|
const accessInfo = await permissionManager.canAccessHost(
|
|
targetUserId,
|
|
numericHostId,
|
|
"connect",
|
|
);
|
|
if (!accessInfo.hasAccess) {
|
|
return res.status(403).json({
|
|
error: "Target user does not have access to this host",
|
|
});
|
|
}
|
|
}
|
|
|
|
const shareId = crypto.randomUUID();
|
|
const linkToken =
|
|
shareType === "link"
|
|
? crypto.randomBytes(24).toString("base64url")
|
|
: null;
|
|
const expiresAt = computeExpiresAt(expiryHours);
|
|
|
|
const created = await createCurrentSessionShareRepository().create({
|
|
id: shareId,
|
|
hostId: numericHostId,
|
|
ownerUserId: userId,
|
|
protocol,
|
|
sessionId: String(sessionId),
|
|
tabInstanceId: tabInstanceId ?? null,
|
|
shareType,
|
|
targetUserId: shareType === "user" ? targetUserId : null,
|
|
linkToken,
|
|
permissionLevel,
|
|
expiresAt,
|
|
});
|
|
|
|
res.json({
|
|
shareId: created.id,
|
|
linkToken: created.linkToken,
|
|
expiresAt: created.expiresAt,
|
|
});
|
|
} catch (error) {
|
|
sshLogger.error("Failed to create session share", error, {
|
|
operation: "session_share_create_error",
|
|
});
|
|
res.status(500).json({ error: "Failed to create session share" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /session-sharing/host/{hostId}/active:
|
|
* get:
|
|
* summary: List active session shares for a host
|
|
* description: Returns active (non-revoked, non-expired) shares owned by the caller for the given host.
|
|
* tags:
|
|
* - Session Sharing
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: hostId
|
|
* required: true
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: List of active shares
|
|
* 400:
|
|
* description: Invalid host id
|
|
* 500:
|
|
* description: Server error
|
|
*/
|
|
router.get(
|
|
"/host/:hostId/active",
|
|
authenticateJWT,
|
|
async (req: Request, res: Response) => {
|
|
try {
|
|
const userId = (req as AuthenticatedRequest).userId!;
|
|
const hostId = Number.parseInt(String(req.params.hostId), 10);
|
|
if (!hostId || Number.isNaN(hostId)) {
|
|
return res.status(400).json({ error: "Invalid host ID" });
|
|
}
|
|
|
|
const shares =
|
|
await createCurrentSessionShareRepository().findActiveSharesForHost(
|
|
hostId,
|
|
userId,
|
|
);
|
|
|
|
res.json({ shares });
|
|
} catch (error) {
|
|
sshLogger.error("Failed to list session shares", error, {
|
|
operation: "session_share_list_error",
|
|
});
|
|
res.status(500).json({ error: "Failed to list session shares" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /session-sharing/{shareId}:
|
|
* delete:
|
|
* summary: Revoke a session share
|
|
* description: Revokes a share. Owner or admin only. Best-effort kick of live SSH participants; guac joins are not force-disconnected in v1.
|
|
* tags:
|
|
* - Session Sharing
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: shareId
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Share revoked
|
|
* 403:
|
|
* description: Not authorized to revoke this share
|
|
* 404:
|
|
* description: Share not found
|
|
* 500:
|
|
* description: Server error
|
|
*/
|
|
router.delete(
|
|
"/:shareId",
|
|
authenticateJWT,
|
|
async (req: Request, res: Response) => {
|
|
try {
|
|
const userId = (req as AuthenticatedRequest).userId!;
|
|
const shareId = String(req.params.shareId);
|
|
|
|
const repository = createCurrentSessionShareRepository();
|
|
const share = await repository.findById(shareId);
|
|
if (!share) {
|
|
return res.status(404).json({ error: "Share not found" });
|
|
}
|
|
|
|
let revoked = await repository.revoke(shareId, userId);
|
|
if (!revoked) {
|
|
if (await permissionManager.isAdmin(userId)) {
|
|
revoked = await repository.revokeAsAdmin(shareId);
|
|
}
|
|
}
|
|
|
|
if (!revoked) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: "Not authorized to revoke this share" });
|
|
}
|
|
|
|
// Best-effort kick of live participants. SSH sessions support ending
|
|
// just the guests via ownerEndSession; guac joins aren't force-kickable
|
|
// from a REST handler (guacamole-lite exposes no kick API), so a revoked
|
|
// guac link only blocks *future* resolves until the guest's own socket ends.
|
|
if (share.protocol === "ssh") {
|
|
try {
|
|
sessionManager.ownerEndSession(
|
|
share.sessionId,
|
|
"Session share revoked by owner",
|
|
);
|
|
} catch {
|
|
// best-effort only
|
|
}
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
sshLogger.error("Failed to revoke session share", error, {
|
|
operation: "session_share_revoke_error",
|
|
});
|
|
res.status(500).json({ error: "Failed to revoke session share" });
|
|
}
|
|
},
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /session-sharing/resolve/{linkToken}:
|
|
* get:
|
|
* summary: Resolve a guest share link
|
|
* description: Public, unauthenticated endpoint for anonymous share-link guests. Never returns host name, IP, username, or hostId. Rate-limited per IP.
|
|
* tags:
|
|
* - Session Sharing
|
|
* parameters:
|
|
* - in: path
|
|
* name: linkToken
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Resolved share connection info
|
|
* 404:
|
|
* description: Link not found, expired, revoked, or sharing disabled
|
|
* 429:
|
|
* description: Too many requests
|
|
* 500:
|
|
* description: Server error
|
|
*/
|
|
router.get("/resolve/:linkToken", async (req: Request, res: Response) => {
|
|
try {
|
|
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
|
if (isResolveRateLimited(ip)) {
|
|
return res.status(429).json({ error: "Too many requests" });
|
|
}
|
|
|
|
const linkToken = String(req.params.linkToken);
|
|
const repository = createCurrentSessionShareRepository();
|
|
const share = await repository.findByLinkToken(linkToken);
|
|
if (!share) {
|
|
return res.status(404).json({ error: "Link not found or expired" });
|
|
}
|
|
|
|
const { enabled: sharingEnabled } = await isSharingEnabledForHost(
|
|
share.hostId,
|
|
);
|
|
if (!sharingEnabled) {
|
|
return res.status(404).json({ error: "Link not found or expired" });
|
|
}
|
|
|
|
const protocol = share.protocol as Protocol;
|
|
if (!isLiveSession(protocol, share.sessionId)) {
|
|
return res.status(404).json({ error: "Session is no longer active" });
|
|
}
|
|
|
|
// Field-by-field by design - never spread a host row into this response.
|
|
// Anonymous guests must never see hostname/IP/username/hostId (decision #5).
|
|
const response: {
|
|
protocol: Protocol;
|
|
permissionLevel: PermissionLevel;
|
|
wsPath: string;
|
|
connectParams?: Record<string, string>;
|
|
} = {
|
|
protocol,
|
|
permissionLevel: share.permissionLevel as PermissionLevel,
|
|
wsPath:
|
|
protocol === "ssh"
|
|
? `/terminal/ws?shareToken=${encodeURIComponent(linkToken)}`
|
|
: "/guacamole/websocket/",
|
|
};
|
|
|
|
if (protocol !== "ssh") {
|
|
const joinToken = tokenService.createJoinToken(
|
|
share.sessionId,
|
|
share.permissionLevel === "read-only",
|
|
);
|
|
response.connectParams = { token: joinToken };
|
|
}
|
|
|
|
try {
|
|
await repository.touchShareUsage(share.id);
|
|
await repository.recordParticipantJoin(share.id, null, "Guest");
|
|
} catch {
|
|
// best-effort, never fail the resolve response over audit bookkeeping
|
|
}
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
sshLogger.error("Failed to resolve session share link", error, {
|
|
operation: "session_share_resolve_error",
|
|
});
|
|
res.status(500).json({ error: "Failed to resolve share link" });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /session-sharing/{shareId}/end:
|
|
* post:
|
|
* summary: End a shared session for all participants
|
|
* description: Owner-only. Terminates the underlying session and notifies joined participants. Guac protocol kick is best-effort in v1.
|
|
* tags:
|
|
* - Session Sharing
|
|
* security:
|
|
* - bearerAuth: []
|
|
* parameters:
|
|
* - in: path
|
|
* name: shareId
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Session ended
|
|
* 403:
|
|
* description: Not the owner of this share
|
|
* 404:
|
|
* description: Share not found
|
|
* 500:
|
|
* description: Server error
|
|
*/
|
|
router.post(
|
|
"/:shareId/end",
|
|
authenticateJWT,
|
|
async (req: Request, res: Response) => {
|
|
try {
|
|
const userId = (req as AuthenticatedRequest).userId!;
|
|
const shareId = String(req.params.shareId);
|
|
|
|
const repository = createCurrentSessionShareRepository();
|
|
const share = await repository.findById(shareId);
|
|
if (!share) {
|
|
return res.status(404).json({ error: "Share not found" });
|
|
}
|
|
if (share.ownerUserId !== userId) {
|
|
return res.status(403).json({ error: "Not the owner of this share" });
|
|
}
|
|
|
|
if (share.protocol === "ssh") {
|
|
sessionManager.ownerEndSession(
|
|
share.sessionId,
|
|
"Session ended by owner",
|
|
);
|
|
}
|
|
// Guac protocols: no kick API available from a REST handler in v1 - see
|
|
// DELETE /:shareId for the same limitation.
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
sshLogger.error("Failed to end shared session", error, {
|
|
operation: "session_share_end_error",
|
|
});
|
|
res.status(500).json({ error: "Failed to end shared session" });
|
|
}
|
|
},
|
|
);
|
|
|
|
export default router;
|