Fix container detail terminal isolation and working directory
This commit is contained in:
1 parent
30e1c5e934
commit
58bc5d2547
6 files changed
+108
-36
No files matched your search
+48
-20
@@ -31,7 +31,7 @@ type StoredTerminalSession = {
|
||||
const INPUT_CHUNK_SIZE = 8192;
|
||||
const INPUT_BACKPRESSURE_THRESHOLD = 256 * 1024;
|
||||
const IMMEDIATE_INPUT_MAX_LENGTH = 4;
|
||||
const STORAGE_KEY = "app_container_terminal_sessions_v1";
|
||||
const STORAGE_KEY = "app_container_terminal_sessions_v3";
|
||||
|
||||
const summaryToneColor: Record<
|
||||
TerminalTabData["summaries"][number]["tone"],
|
||||
@@ -69,6 +69,20 @@ function shellQuote(value: string) {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function createContainerShellCommand({
|
||||
execTarget,
|
||||
shell,
|
||||
workingDirectory,
|
||||
}: Pick<TerminalTabData, "execTarget" | "shell" | "workingDirectory">) {
|
||||
const initialPath = workingDirectory || "/";
|
||||
const enterWorkingDirectory = `cd ${shellQuote(initialPath)} 2>/dev/null || cd /`;
|
||||
const startShell = `exec ${shellQuote(shell)}`;
|
||||
|
||||
return `docker exec -it ${shellQuote(execTarget)} ${shellQuote(
|
||||
shell,
|
||||
)} -c ${shellQuote(`${enterWorkingDirectory}; ${startShell}`)}`;
|
||||
}
|
||||
|
||||
function createSessionId(terminal: TerminalTabData) {
|
||||
const safeSessionTarget = terminal.execTarget
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "")
|
||||
@@ -106,15 +120,6 @@ function writeStoredSession(storageId: string, session: StoredTerminalSession) {
|
||||
|
||||
function getInitialSession(terminal: TerminalTabData): StoredTerminalSession {
|
||||
const storageId = getStorageId(terminal);
|
||||
const stored = readStoredSessions()[storageId];
|
||||
|
||||
if (stored?.id) {
|
||||
return {
|
||||
id: stored.id,
|
||||
initialized: Boolean(stored.initialized),
|
||||
};
|
||||
}
|
||||
|
||||
const nextSession = {
|
||||
id: createSessionId(terminal),
|
||||
initialized: false,
|
||||
@@ -267,6 +272,8 @@ function ContainerTerminalPane({
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !terminal.canExecute) return;
|
||||
let destroyed = false;
|
||||
let wsForEffect: WebSocket | null = null;
|
||||
let termForEffect: import("@xterm/xterm").Terminal | null = null;
|
||||
isDisposedRef.current = false;
|
||||
onStatusChange("connecting");
|
||||
|
||||
@@ -315,6 +322,7 @@ function ContainerTerminalPane({
|
||||
}
|
||||
|
||||
termRef.current = term;
|
||||
termForEffect = term;
|
||||
fitRef.current = fitAddon;
|
||||
scheduleResize();
|
||||
|
||||
@@ -331,6 +339,14 @@ function ContainerTerminalPane({
|
||||
const ticketResponse = await terminalApi.wsTicket(
|
||||
terminal.serverId,
|
||||
session.id,
|
||||
{
|
||||
type: "command",
|
||||
command: createContainerShellCommand({
|
||||
execTarget: terminal.execTarget,
|
||||
shell: terminal.shell,
|
||||
workingDirectory: terminal.workingDirectory,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (destroyed) return undefined;
|
||||
|
||||
@@ -339,9 +355,12 @@ function ContainerTerminalPane({
|
||||
ticket: ticketResponse.data.ticket,
|
||||
});
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsForEffect = ws;
|
||||
wsRef.current = ws;
|
||||
const isCurrentSocket = () => !destroyed && wsRef.current === ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!isCurrentSocket()) return;
|
||||
onStatusChange("connected");
|
||||
onSenderChange(sendInput);
|
||||
enqueueTerminalOutput("\x1b[32mSSH connection established\x1b[0m\r\n");
|
||||
@@ -349,11 +368,6 @@ function ContainerTerminalPane({
|
||||
`\x1b[36mOpening container shell: ${terminal.execTarget}\x1b[0m\r\n`,
|
||||
);
|
||||
if (shouldEnterContainer) {
|
||||
sendInput(
|
||||
`docker exec -it ${shellQuote(terminal.execTarget)} ${shellQuote(
|
||||
terminal.shell,
|
||||
)}\r`,
|
||||
);
|
||||
onSessionInitialized();
|
||||
} else {
|
||||
enqueueTerminalOutput(
|
||||
@@ -364,6 +378,7 @@ function ContainerTerminalPane({
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
if (!isCurrentSocket()) return;
|
||||
try {
|
||||
const msg = JSON.parse(ev.data as string);
|
||||
if (msg.type === "ready" || msg.type === "data") {
|
||||
@@ -385,11 +400,13 @@ function ContainerTerminalPane({
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
if (!isCurrentSocket()) return;
|
||||
onStatusChange("error");
|
||||
enqueueTerminalOutput("\x1b[31m\r\nWebSocket error\x1b[0m\r\n");
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!isCurrentSocket()) return;
|
||||
onStatusChange("disconnected");
|
||||
onSenderChange(null);
|
||||
enqueueTerminalOutput("\x1b[33m\r\n[Connection closed]\x1b[0m\r\n");
|
||||
@@ -430,11 +447,21 @@ function ContainerTerminalPane({
|
||||
encoderRef.current = null;
|
||||
resizeObserverRef.current?.disconnect();
|
||||
cleanup.then((fn) => fn?.());
|
||||
wsRef.current?.close();
|
||||
termRef.current?.dispose();
|
||||
termRef.current = null;
|
||||
wsRef.current = null;
|
||||
fitRef.current = null;
|
||||
if (wsForEffect) {
|
||||
wsForEffect.onopen = null;
|
||||
wsForEffect.onmessage = null;
|
||||
wsForEffect.onerror = null;
|
||||
wsForEffect.onclose = null;
|
||||
wsForEffect.close();
|
||||
if (wsRef.current === wsForEffect) {
|
||||
wsRef.current = null;
|
||||
}
|
||||
}
|
||||
termForEffect?.dispose();
|
||||
if (termRef.current === termForEffect) {
|
||||
termRef.current = null;
|
||||
fitRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
enqueueTerminalOutput,
|
||||
@@ -447,6 +474,7 @@ function ContainerTerminalPane({
|
||||
session.initialized,
|
||||
terminal.canExecute,
|
||||
terminal.execTarget,
|
||||
terminal.workingDirectory,
|
||||
terminal.serverId,
|
||||
terminal.shell,
|
||||
]);
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ export function createTerminalData(
|
||||
const inspect = isRecord(detail?.inspect) ? detail.inspect : {};
|
||||
const config = getRecord(inspect, "Config");
|
||||
const state = getRecord(inspect, "State");
|
||||
const workingDirectory = getString(config?.WorkingDir, "/app");
|
||||
const workingDirectory = getString(config?.WorkingDir, "/");
|
||||
const shell = "/bin/sh";
|
||||
const isRunning = container.status === "RUNNING";
|
||||
|
||||
|
||||
@@ -41,10 +41,12 @@ function readStoredTabs(): Tab[] {
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as Tab[];
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.map((tab) => ({
|
||||
...tab,
|
||||
label: tab.label || tab.serverName,
|
||||
}));
|
||||
return parsed
|
||||
.filter((tab) => !tab.sessionId.startsWith("container-"))
|
||||
.map((tab) => ({
|
||||
...tab,
|
||||
label: tab.label || tab.serverName,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
+6
-2
@@ -2769,14 +2769,18 @@ export const terminal = {
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}>("/terminal/exec", { serverId, command }),
|
||||
wsTicket: (serverId: string, sessionId?: string) =>
|
||||
wsTicket: (
|
||||
serverId: string,
|
||||
sessionId?: string,
|
||||
target?: { type: "shell" } | { type: "command"; command: string },
|
||||
) =>
|
||||
post<{
|
||||
success: boolean;
|
||||
data: {
|
||||
ticket: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
}>("/terminal/ws-ticket", { serverId, sessionId }),
|
||||
}>("/terminal/ws-ticket", { serverId, sessionId, target }),
|
||||
/** Returns WebSocket URL for interactive shell */
|
||||
wsUrl: (
|
||||
serverId: string,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
consumeTerminalSocketTicket,
|
||||
issueTerminalSocketTicket,
|
||||
} from "../services/terminal-ticket.service";
|
||||
import type { TerminalSocketTarget } from "../services/terminal-ticket.service";
|
||||
|
||||
const SESSION_TTL_MS = 30 * 60 * 1000;
|
||||
const TERMINAL_EXEC_TIMEOUT_MS = 20_000;
|
||||
@@ -50,6 +51,7 @@ interface TerminalSession {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
serverIp: string;
|
||||
target: TerminalSocketTarget;
|
||||
shellStream: TerminalShellStream;
|
||||
createdAt: number;
|
||||
lastActiveAt: number;
|
||||
@@ -149,22 +151,28 @@ async function createTerminalSession(
|
||||
cols: number,
|
||||
rows: number,
|
||||
sessionId?: string,
|
||||
target: TerminalSocketTarget = { type: "shell" },
|
||||
) {
|
||||
const ssh = await getConnection(server);
|
||||
const id = sessionId || randomUUID();
|
||||
|
||||
const shellStream = await new Promise<TerminalShellStream>(
|
||||
(resolve, reject) => {
|
||||
ssh.connection!.shell(
|
||||
{ term: "xterm-256color", cols, rows },
|
||||
(err, stream) => {
|
||||
const handleStream = (err: Error | undefined, stream: unknown) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(stream as TerminalShellStream);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const pty = { term: "xterm-256color", cols, rows };
|
||||
if (target.type === "command") {
|
||||
ssh.connection!.exec(target.command, { pty }, handleStream);
|
||||
return;
|
||||
}
|
||||
|
||||
ssh.connection!.shell(pty, handleStream);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -174,6 +182,7 @@ async function createTerminalSession(
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
serverIp: server.ip,
|
||||
target,
|
||||
shellStream,
|
||||
createdAt: Date.now(),
|
||||
lastActiveAt: Date.now(),
|
||||
@@ -193,7 +202,10 @@ export async function terminalRoutes(app: FastifyInstance) {
|
||||
app.get("/sessions", { preHandler: terminalAccess }, async (req, reply) => {
|
||||
pruneExpiredSessions();
|
||||
const sessions = [...terminalSessions.values()]
|
||||
.filter((session) => session.userId === req.userId)
|
||||
.filter(
|
||||
(session) =>
|
||||
session.userId === req.userId && session.target.type === "shell",
|
||||
)
|
||||
.map(getPublicSession)
|
||||
.sort((left, right) =>
|
||||
right.lastActiveAt.localeCompare(left.lastActiveAt),
|
||||
@@ -224,6 +236,15 @@ export async function terminalRoutes(app: FastifyInstance) {
|
||||
.object({
|
||||
serverId: z.string().min(1),
|
||||
sessionId: z.string().trim().min(1).max(128).optional(),
|
||||
target: z
|
||||
.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("shell") }),
|
||||
z.object({
|
||||
type: z.literal("command"),
|
||||
command: z.string().trim().min(1).max(4096),
|
||||
}),
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
@@ -232,7 +253,7 @@ export async function terminalRoutes(app: FastifyInstance) {
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
const { serverId, sessionId } = body.data;
|
||||
const { serverId, sessionId, target } = body.data;
|
||||
const server = await getAccessibleServer(
|
||||
req.userId,
|
||||
serverId,
|
||||
@@ -251,6 +272,7 @@ export async function terminalRoutes(app: FastifyInstance) {
|
||||
organizationId: req.organizationId,
|
||||
serverId,
|
||||
sessionId,
|
||||
target: target ?? { type: "shell" },
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
@@ -388,6 +410,7 @@ export async function terminalRoutes(app: FastifyInstance) {
|
||||
parsedCols,
|
||||
parsedRows,
|
||||
sessionId,
|
||||
socketTicket.target,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -443,14 +466,22 @@ export async function terminalRoutes(app: FastifyInstance) {
|
||||
socket.on("close", () => {
|
||||
if (session) {
|
||||
session.lastActiveAt = Date.now();
|
||||
detachSocket(session);
|
||||
if (session.target.type === "command") {
|
||||
destroySession(session.id);
|
||||
} else {
|
||||
detachSocket(session);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
if (session) {
|
||||
session.lastActiveAt = Date.now();
|
||||
detachSocket(session);
|
||||
if (session.target.type === "command") {
|
||||
destroySession(session.id);
|
||||
} else {
|
||||
detachSocket(session);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -8,10 +8,15 @@ export interface TerminalSocketTicket {
|
||||
organizationId?: string;
|
||||
serverId: string;
|
||||
sessionId?: string;
|
||||
target?: TerminalSocketTarget;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export type TerminalSocketTarget =
|
||||
| { type: "shell" }
|
||||
| { type: "command"; command: string };
|
||||
|
||||
const terminalSocketTickets = new Map<string, TerminalSocketTicket>();
|
||||
|
||||
function pruneExpiredTerminalSocketTickets(now = Date.now()) {
|
||||
@@ -27,6 +32,7 @@ export function issueTerminalSocketTicket(input: {
|
||||
organizationId?: string;
|
||||
serverId: string;
|
||||
sessionId?: string;
|
||||
target?: TerminalSocketTarget;
|
||||
}): TerminalSocketTicket {
|
||||
const now = Date.now();
|
||||
pruneExpiredTerminalSocketTickets(now);
|
||||
@@ -37,6 +43,7 @@ export function issueTerminalSocketTicket(input: {
|
||||
organizationId: input.organizationId,
|
||||
serverId: input.serverId,
|
||||
sessionId: input.sessionId,
|
||||
target: input.target,
|
||||
createdAt: now,
|
||||
expiresAt: now + TERMINAL_SOCKET_TICKET_TTL_MS,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user