From 8cb18e5e87b1e2182acfe92aaf67a357b2e4de05 Mon Sep 17 00:00:00 2001 From: Nicholas Adamou <10106289+nicholasadamou@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:14:11 -0400 Subject: [PATCH] feat: extend-TTL, image pre-pull, and session UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features & UX batch: - PATCH /api/sessions/:id extends a running session's TTL (in-memory override, since labels are immutable and there's no database — best-effort, reverts on API restart); dashboard "+15m" control - createSession now pulls the image if missing (fixing first-launch on a fresh host) and POST /api/images/pull warms all configured images; the dashboard exposes a "Pre-pull browser images" action - dashboard: per-session copy-link button, a "Reload stream" reconnect button in the viewer, and a note that clipboard/file transfer live in the Kasm control bar; the per-session VNC password is shown under the frame Docs (api.md, openapi.yaml, web.md) and tests updated; 80 api + 17 web tests. --- apps/api/src/__tests__/_fakes.ts | 10 +++ apps/api/src/__tests__/app.test.ts | 42 ++++++++++ .../__tests__/docker-session-runtime.test.ts | 58 +++++++++++++ apps/api/src/app.ts | 39 ++++++++- apps/api/src/docker-session-runtime.ts | 81 ++++++++++++++++++- apps/api/src/schemas.ts | 6 ++ apps/web/src/App.tsx | 43 ++++++++++ apps/web/src/components/SessionList.tsx | 36 +++++++++ apps/web/src/components/SessionViewer.tsx | 18 ++++- apps/web/src/lib/api.test.ts | 25 ++++++ apps/web/src/lib/api.ts | 19 +++++ apps/web/src/styles.css | 11 +++ docs/api.md | 32 ++++++++ docs/openapi.yaml | 75 +++++++++++++++++ docs/web.md | 12 ++- packages/shared/src/index.ts | 10 +++ 16 files changed, 510 insertions(+), 7 deletions(-) diff --git a/apps/api/src/__tests__/_fakes.ts b/apps/api/src/__tests__/_fakes.ts index 5c85206..42e6eb0 100644 --- a/apps/api/src/__tests__/_fakes.ts +++ b/apps/api/src/__tests__/_fakes.ts @@ -52,6 +52,16 @@ export const createFakeSessionRuntime = ( async listSessions(): Promise { return current ? [current] : []; }, + async extendSession(sessionId: string, ttlSeconds: number): Promise { + if (!current || current.sessionId !== sessionId) { + return null; + } + current = { ...current, expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString() }; + return current; + }, + async pullBrowserImages() { + return [{ image: "kasmweb/chromium:1.18.0", ok: true }]; + }, async stopSession(sessionId: string): Promise { stopped.push(sessionId); const wasPresent = current !== null; diff --git a/apps/api/src/__tests__/app.test.ts b/apps/api/src/__tests__/app.test.ts index d648c10..dbaaf21 100644 --- a/apps/api/src/__tests__/app.test.ts +++ b/apps/api/src/__tests__/app.test.ts @@ -108,6 +108,48 @@ describe("Airlock API", () => { expect(response.status).toBe(429); }); + it("extends a session's TTL via PATCH", async () => { + const app = createApp({ + config: testConfig, + sessionRuntime: createFakeSessionRuntime() + }); + + const response = await request(app).patch("/api/sessions/session-1").send({ ttlSeconds: 600 }); + expect(response.status).toBe(200); + expect(response.body.sessionId).toBe("session-1"); + }); + + it("returns 404 when extending an unknown session", async () => { + const app = createApp({ + config: testConfig, + sessionRuntime: createFakeSessionRuntime({ initial: null }) + }); + + const response = await request(app).patch("/api/sessions/nope").send({ ttlSeconds: 600 }); + expect(response.status).toBe(404); + }); + + it("rejects an invalid extend body", async () => { + const app = createApp({ + config: testConfig, + sessionRuntime: createFakeSessionRuntime() + }); + + const response = await request(app).patch("/api/sessions/session-1").send({ ttlSeconds: 5 }); + expect(response.status).toBe(400); + }); + + it("pulls browser images on demand", async () => { + const app = createApp({ + config: testConfig, + sessionRuntime: createFakeSessionRuntime() + }); + + const response = await request(app).post("/api/images/pull"); + expect(response.status).toBe(200); + expect(Array.isArray(response.body.images)).toBe(true); + }); + it("serves Prometheus metrics", async () => { const app = createApp({ config: testConfig, diff --git a/apps/api/src/__tests__/docker-session-runtime.test.ts b/apps/api/src/__tests__/docker-session-runtime.test.ts index 8197390..1962d1e 100644 --- a/apps/api/src/__tests__/docker-session-runtime.test.ts +++ b/apps/api/src/__tests__/docker-session-runtime.test.ts @@ -62,6 +62,7 @@ class FakeDocker { createCalls: CreateContainerCall[] = []; startedIds: string[] = []; networksCreated: string[] = []; + pulledImages: string[] = []; pingCount = 0; private readonly hostPortByContainerPort: Record; private readonly nextContainerId: string; @@ -139,6 +140,21 @@ class FakeDocker { this.pingCount += 1; return "OK"; }; + + getImage = (_name: string) => ({ + inspect: async (): Promise => ({ Id: "img" }) + }); + + pull = async (image: string): Promise => { + this.pulledImages.push(image); + return {}; + }; + + modem = { + followProgress: (_stream: unknown, onFinished: (error: Error | null) => void): void => { + onFinished(null); + } + }; } describe("DockerSessionRuntime", () => { @@ -358,6 +374,48 @@ describe("DockerSessionRuntime", () => { expect(docker.pingCount).toBe(1); }); + it("extendSession pushes the expiry out and keeps the container from pruning", async () => { + const docker = new FakeDocker({ containers: [makeFakeContainer()] }); + const runtime = new DockerSessionRuntime({ + config: baseConfig, + docker: docker as unknown as Docker + }); + + // The label expiry is 2026-04-30T12:30:00Z; extend well past a later "now". + const extended = await runtime.extendSession("s-1", 3600); + expect(extended).not.toBeNull(); + expect(new Date(extended!.expiresAt).getTime()).toBeGreaterThan( + new Date("2026-04-30T13:00:00.000Z").getTime() + ); + + // At a time past the original label expiry, the extended session survives. + const pruned = await runtime.pruneExpiredSessions(new Date("2026-04-30T12:45:00.000Z")); + expect(pruned).toBe(0); + expect(docker.removed).toEqual([]); + }); + + it("extendSession returns null for an unknown session", async () => { + const docker = new FakeDocker({ containers: [makeFakeContainer()] }); + const runtime = new DockerSessionRuntime({ + config: baseConfig, + docker: docker as unknown as Docker + }); + expect(await runtime.extendSession("missing", 600)).toBeNull(); + }); + + it("pullBrowserImages pulls each unique configured image", async () => { + const docker = new FakeDocker(); + const runtime = new DockerSessionRuntime({ + config: baseConfig, + docker: docker as unknown as Docker + }); + + const results = await runtime.pullBrowserImages(); + expect(results.every((r) => r.ok)).toBe(true); + expect(docker.pulledImages).toContain("kasmweb/chromium:1.18.0"); + expect(docker.pulledImages).toContain("kasmweb/tor-browser:1.18.0"); + }); + it("createSession removes the container and throws when no host port is mapped", async () => { const docker = new FakeDocker({ nextContainerId: "doomed", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 1d9a152..b8d88ae 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -16,7 +16,7 @@ import { logger } from "./logger"; import { Metrics } from "./metrics"; import { createRateLimit } from "./rate-limit"; import { resolveOrRespond } from "./resolve-session"; -import { createSessionBodySchema } from "./schemas"; +import { createSessionBodySchema, extendSessionBodySchema } from "./schemas"; import { toSessionResponse } from "./session-response"; export interface CreateAppOptions { @@ -183,6 +183,43 @@ export const createApp = ({ config, sessionRuntime, metrics }: CreateAppOptions) }) ); + app.patch( + "/api/sessions/:sessionId", + bearerAuth, + asyncRoute(async (request: Request, response: Response) => { + const parsed = extendSessionBodySchema.safeParse(request.body); + if (!parsed.success) { + response.status(400).json({ + error: parsed.error.issues.map((issue) => issue.message).join(", ") + }); + return; + } + + const session = await sessionRuntime.extendSession( + request.params.sessionId, + parsed.data.ttlSeconds + ); + if (!session) { + response.status(404).json({ error: "Session not found." }); + return; + } + logger.info("session.extended", { + sessionId: session.sessionId, + expiresAt: session.expiresAt + }); + response.json(toSessionResponse(session, config)); + }) + ); + + app.post( + "/api/images/pull", + bearerAuth, + asyncRoute(async (_request: Request, response: Response) => { + const images = await sessionRuntime.pullBrowserImages(); + response.json({ images }); + }) + ); + app.delete( "/api/sessions/:sessionId", bearerAuth, diff --git a/apps/api/src/docker-session-runtime.ts b/apps/api/src/docker-session-runtime.ts index 2d688ad..ae80c36 100644 --- a/apps/api/src/docker-session-runtime.ts +++ b/apps/api/src/docker-session-runtime.ts @@ -6,8 +6,10 @@ import { AirlockSession, CreateSessionInput, DecodedSessionLabels, + ImagePullResult, SessionRuntime, browserProfile, + clampTtl, decodeSessionLabels, encodeSessionLabels, expiresAt as computeExpiresAt, @@ -86,6 +88,11 @@ export interface DockerSessionRuntimeOptions { export class DockerSessionRuntime implements SessionRuntime { private readonly docker: Docker; private readonly config: AirlockConfig; + // Extended expiries by sessionId. Docker labels are immutable after creation + // and there is no database, so a TTL extension is held in memory and applied + // on read/prune. It is best-effort: an API restart drops it and the session + // reverts to its label expiry. Single-node only. + private readonly expiryOverrides = new Map(); constructor(options: DockerSessionRuntimeOptions) { this.config = options.config; @@ -110,6 +117,9 @@ export class DockerSessionRuntime implements SessionRuntime { if (launch.networkIsolation) { await this.ensureNetwork(launch.networkName); } + // createContainer does not auto-pull; make sure the image is present so the + // first launch on a fresh host works instead of failing with "no such image". + await this.ensureImage(image); const launchEnv = { ...profile.buildLaunchEnv({ @@ -183,6 +193,59 @@ export class DockerSessionRuntime implements SessionRuntime { } } + async extendSession(sessionId: string, ttlSeconds: number): Promise { + const match = await this.findContainerBySessionId(sessionId); + if (!match) { + return null; + } + const newExpiry = computeExpiresAt(new Date(), clampTtl(ttlSeconds)); + this.expiryOverrides.set(sessionId, newExpiry.toISOString()); + return this.mapContainerToSession(match.container, match.decoded); + } + + async pullBrowserImages(): Promise { + const images = [...new Set(Object.values(this.config.containerLaunch.browserImages))]; + const results: ImagePullResult[] = []; + for (const image of images) { + try { + await this.pullImage(image); + results.push({ image, ok: true }); + } catch (error) { + logger.warn("image.pull_failed", { + image, + message: error instanceof Error ? error.message : String(error) + }); + results.push({ image, ok: false }); + } + } + return results; + } + + private async ensureImage(image: string): Promise { + try { + await this.docker.getImage(image).inspect(); + return; + } catch (error) { + if (!isContainerNotFound(error)) { + throw error; + } + } + await this.pullImage(image); + } + + private async pullImage(image: string): Promise { + const stream = await this.docker.pull(image); + await new Promise((resolve, reject) => { + this.docker.modem.followProgress(stream, (error: Error | null) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + } + async getSession(sessionId: string): Promise { const match = await this.findContainerBySessionId(sessionId); if (!match) { @@ -215,11 +278,13 @@ export class DockerSessionRuntime implements SessionRuntime { return false; } await this.safeRemoveContainer(match.container.Id); + this.expiryOverrides.delete(sessionId); return true; } async pruneExpiredSessions(now: Date = new Date()): Promise { const containers = await this.listManagedContainers(true); + const liveSessionIds = new Set(); let pruned = 0; for (const container of containers) { @@ -227,12 +292,23 @@ export class DockerSessionRuntime implements SessionRuntime { if (!decoded) { continue; } + liveSessionIds.add(decoded.sessionId); - if (isExpired(decoded, now)) { + // Honor an in-memory TTL extension over the label's original expiry. + const effectiveExpiresAt = this.expiryOverrides.get(decoded.sessionId) ?? decoded.expiresAt; + if (isExpired({ expiresAt: effectiveExpiresAt }, now)) { await this.safeRemoveContainer(container.Id); + this.expiryOverrides.delete(decoded.sessionId); pruned += 1; } } + + // Drop overrides for sessions that no longer exist (e.g. AutoRemoved). + for (const sessionId of this.expiryOverrides.keys()) { + if (!liveSessionIds.has(sessionId)) { + this.expiryOverrides.delete(sessionId); + } + } return pruned; } @@ -275,7 +351,8 @@ export class DockerSessionRuntime implements SessionRuntime { browserUrl: profile.streamUrl(this.config.server.sessionHost, hostPort), vncPassword: decoded.vncPassword, createdAt: decoded.createdAt, - expiresAt: decoded.expiresAt + // Reflect an in-memory TTL extension when one is set for this session. + expiresAt: this.expiryOverrides.get(decoded.sessionId) ?? decoded.expiresAt }; } diff --git a/apps/api/src/schemas.ts b/apps/api/src/schemas.ts index 717bd9e..573ba91 100644 --- a/apps/api/src/schemas.ts +++ b/apps/api/src/schemas.ts @@ -15,3 +15,9 @@ export const createSessionBodySchema = z.object({ }); export type CreateSessionBody = z.infer; + +export const extendSessionBodySchema = z.object({ + ttlSeconds: z.number().int().min(TTL_MIN_SECONDS).max(TTL_MAX_SECONDS) +}); + +export type ExtendSessionBody = z.infer; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index adf3223..84a7147 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -26,7 +26,9 @@ export const App = (): JSX.Element => { const [launchError, setLaunchError] = useState(null); const [launching, setLaunching] = useState(false); const [terminatingId, setTerminatingId] = useState(null); + const [extendingId, setExtendingId] = useState(null); const [activeSessionId, setActiveSessionId] = useState(null); + const [pullState, setPullState] = useState<"idle" | "pulling" | "done">("idle"); const client = useMemo(() => createAirlockClient({ token: token || undefined }), [token]); @@ -108,6 +110,30 @@ export const App = (): JSX.Element => { } }; + const handleExtend = async (sessionId: string, ttlSeconds: number): Promise => { + setExtendingId(sessionId); + try { + await client.extendSession(sessionId, ttlSeconds); + await refreshSessions(); + } catch (error) { + setListError(messageOf(error)); + } finally { + setExtendingId(null); + } + }; + + const handlePullImages = async (): Promise => { + setPullState("pulling"); + try { + await client.pullImages(); + setPullState("done"); + window.setTimeout(() => setPullState("idle"), 3000); + } catch (error) { + setLaunchError(messageOf(error)); + setPullState("idle"); + } + }; + const handleTerminate = async (sessionId: string): Promise => { setTerminatingId(sessionId); try { @@ -167,6 +193,21 @@ export const App = (): JSX.Element => { onLaunch={handleLaunch} /> ) : null} +
+ + Warm the Kasm images so the first launch is fast. +
@@ -177,7 +218,9 @@ export const App = (): JSX.Element => { setActiveSessionId(id)} + onExtend={handleExtend} onTerminate={handleTerminate} />
diff --git a/apps/web/src/components/SessionList.tsx b/apps/web/src/components/SessionList.tsx index ce40817..287ff4c 100644 --- a/apps/web/src/components/SessionList.tsx +++ b/apps/web/src/components/SessionList.tsx @@ -2,17 +2,24 @@ import { useEffect, useState } from "react"; import { SessionResponse } from "../lib/api"; import { formatTimeRemaining } from "../lib/time"; +// Minutes added when the operator clicks "Extend". +const EXTEND_SECONDS = 15 * 60; + export interface SessionListProps { sessions: SessionResponse[]; terminatingId: string | null; + extendingId: string | null; onOpen: (sessionId: string) => void; + onExtend: (sessionId: string, ttlSeconds: number) => void; onTerminate: (sessionId: string) => void; } export const SessionList = ({ sessions, terminatingId, + extendingId, onOpen, + onExtend, onTerminate }: SessionListProps): JSX.Element => { const now = useTick(1000); @@ -33,6 +40,15 @@ export const SessionList = ({ {formatTimeRemaining(session.expiresAt, now)} left
+ + + ); +}; + // Re-render on an interval so the "time left" labels count down live. const useTick = (intervalMs: number): number => { const [now, setNow] = useState(() => Date.now()); diff --git a/apps/web/src/components/SessionViewer.tsx b/apps/web/src/components/SessionViewer.tsx index 43f5c7e..35252c9 100644 --- a/apps/web/src/components/SessionViewer.tsx +++ b/apps/web/src/components/SessionViewer.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { SessionResponse } from "../lib/api"; export interface SessionViewerProps { @@ -6,6 +7,11 @@ export interface SessionViewerProps { } export const SessionViewer = ({ session, onClose }: SessionViewerProps): JSX.Element => { + // Bumping the key remounts the iframe, forcing a fresh connection to the + // stream — the manual "reconnect" when a session drops or the cert is + // accepted in another tab. + const [reloadKey, setReloadKey] = useState(0); + return (
@@ -15,6 +21,13 @@ export const SessionViewer = ({ session, onClose }: SessionViewerProps): JSX.Ele {session.browser} · {session.targetUrl} +