Add runtime-configured Lab 3 browser terminal

This commit is contained in:
2026-04-13 16:40:14 -06:00
parent 7e4d35b6a3
commit ca6a966ad6
6 changed files with 270 additions and 96 deletions
+12 -10
View File
@@ -20,20 +20,22 @@ npm run dev
## Lab 3 Web Terminal ## Lab 3 Web Terminal
Set `NEXT_PUBLIC_LAB3_TERMINAL_PATH` to the WeTTY endpoint used by your deployment. The default expected path is `/wetty`, and `.env.example` includes that value. Local environments can also provide a full URL such as `http://127.0.0.1:7681/wetty`. The Lab 3 terminal now prefers a runtime-served config artifact at `/courseware-runtime.json`.
The Lab 3 widget assumes: Expected fields:
- WeTTY runs on the lab host and is bound to `127.0.0.1` - `lab3TerminalUrl`
- the public proxy forwards `/wetty` to the local WeTTY port - `lab3Username`
- WebSocket upgrade happens at the reverse proxy - `lab3WorkingDirectory`
- WeTTY is launched as root so it can present `/bin/login` locally instead of SSH
Example service command: Local development still falls back to `/wetty`, and `.env.example` keeps that default for simple standalone runs.
```bash The Linux/WSL deployment contract is:
wetty --host 127.0.0.1 --port 3001 --base /wetty --allow-iframe
``` - loopback-only `sshd` on `127.0.0.1:22`
- WeTTY exposed to the browser on `http://127.0.0.1:7681/wetty`
- a managed `student` login
- a working directory at `/home/student/lab3`
## Project Structure ## Project Structure
+3 -3
View File
@@ -24,14 +24,14 @@ In this lab, we will:
<strong>Execute</strong> sections require running commands and producing output. <strong>Execute</strong> sections require running commands and producing output.
</div> </div>
To start this lab, use the embedded terminal below. It connects to the same lab machine in your browser and should prompt you to log in with the default `student` account. To start this lab, use the embedded terminal below. It connects to the same lab machine in your browser and should prompt you to log in with the managed `student` account.
<div data-lab3-terminal></div> <div data-lab3-terminal></div>
If the embedded terminal is unavailable, you can still fall back to: If the embedded terminal is unavailable, you can still fall back to:
- SSH - <IP>:22 - SSH - <IP>:22
- All necessary artifacts are in the `lab3` folder - The lab workspace is rooted at `/home/student/lab3`
## Objective 1: HuggingFace & LLaMa.cpp ## Objective 1: HuggingFace & LLaMa.cpp
@@ -99,7 +99,7 @@ The projects original goal was to make LLaMA models accessible on systems wit
For this lab we will work with **WhiteRabbitNeoV37B**, a cybersecurityoriented finetune of Qwen2.5Coder7B. This model is less popular than LLaMA-3.2, and if we'd like to run it in `llama.cpp` or Ollama, we first need to convert it into a usable GGUF artifact. For this lab we will work with **WhiteRabbitNeoV37B**, a cybersecurityoriented finetune of Qwen2.5Coder7B. This model is less popular than LLaMA-3.2, and if we'd like to run it in `llama.cpp` or Ollama, we first need to convert it into a usable GGUF artifact.
<div class="lab-callout lab-callout--warning"> <div class="lab-callout lab-callout--warning">
<strong>Warning:</strong> Although the next two steps show how to find and download this model so you can replicate the process, support files are already provided in <code>/home/student/lab3/WhiteRabbitNeo</code> to speed up lab execution. <strong>Warning:</strong> Although the next two steps show how to find and download this model so you can replicate the process, any course-provided WhiteRabbitNeo support files will be staged under <code>/home/student/lab3/WhiteRabbitNeo</code> when they are available in the deployment.
</div> </div>
### 1. Locate & download the model ### 1. Locate & download the model
+23 -20
View File
@@ -1,32 +1,35 @@
# Lab 3 Embedded Terminal Deployment # Lab 3 Embedded Terminal Deployment
Lab 3 now expects a same-origin WeTTY endpoint mounted at `/wetty` by default. Lab 3 now prefers a runtime config artifact served at `/courseware-runtime.json`.
Default runtime config:
```json
{
"lab3TerminalUrl": "http://127.0.0.1:7681/wetty",
"lab3Username": "student",
"lab3WorkingDirectory": "/home/student/lab3"
}
```
## Runtime Contract ## Runtime Contract
- Run WeTTY on the lab host, bound to `127.0.0.1` - Create a managed `student` Unix account
- Mount it behind the same public origin as the Next.js app - Bind `sshd` to `127.0.0.1:22` only
- Let the reverse proxy handle WebSocket upgrade for `/wetty` - Run WeTTY on the lab host and expose it to the browser at `/wetty`
- Launch WeTTY as root so it uses `/bin/login` locally - Point WeTTY at localhost SSH without pre-setting the username so students see a login prompt
- Keep SSH available as a fallback path, not the primary student UX - Start students in `/home/student/lab3`
Example launch command: Example launch command:
```bash ```bash
wetty --host 127.0.0.1 --port 3001 --base /wetty --allow-iframe wetty --host 0.0.0.0 --port 7681 --base /wetty --allow-iframe --ssh-host 127.0.0.1 --ssh-port 22 --ssh-auth password
``` ```
## Proxmox VM Shape ## Linux / WSL Shape
- Install WeTTY as a system service - Install `openssh-server`
- Keep it bound to localhost only - Restrict `sshd` to `127.0.0.1` and the managed `student` account
- Reverse proxy `/wetty` to the local WeTTY port over HTTPS - Install WeTTY with the managed Node runtime
- Generate `/courseware-runtime.json` from deployment-time values
## Docker Shape - Start the wiki and WeTTY as separate managed services
- Include WeTTY in the lab image
- Include the `login` and PAM pieces needed for `/bin/login`
- Run WeTTY under an init or supervisor process
- Reverse proxy `/wetty` to the local WeTTY port over HTTPS
If a specific container image cannot support `/bin/login`, the operational fallback is WeTTY SSH mode to localhost.
+80 -11
View File
@@ -1,10 +1,16 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import {
Lab3TerminalFrame,
} from "~/components/labs/Lab3TerminalFrame";
import { import {
LAB3_DEFAULT_TERMINAL_PATH, LAB3_DEFAULT_TERMINAL_PATH,
Lab3TerminalFrame, LAB3_DEFAULT_WORKING_DIRECTORY,
fetchLab3RuntimeConfig,
getLab3TerminalPath, getLab3TerminalPath,
} from "~/components/labs/Lab3TerminalFrame"; LAB3_RUNTIME_CONFIG_PATH,
normalizeLab3RuntimeConfig,
} from "~/lib/lab3-runtime";
describe("getLab3TerminalPath", () => { describe("getLab3TerminalPath", () => {
it("falls back to the default same-origin terminal path", () => { it("falls back to the default same-origin terminal path", () => {
@@ -20,17 +26,54 @@ describe("getLab3TerminalPath", () => {
}); });
}); });
describe("Lab3TerminalFrame", () => { describe("normalizeLab3RuntimeConfig", () => {
const originalEnvValue = process.env.NEXT_PUBLIC_LAB3_TERMINAL_PATH; it("fills in the default student terminal metadata", () => {
expect(normalizeLab3RuntimeConfig()).toEqual({
terminalPath: LAB3_DEFAULT_TERMINAL_PATH,
username: "student",
workingDirectory: LAB3_DEFAULT_WORKING_DIRECTORY,
});
});
});
describe("fetchLab3RuntimeConfig", () => {
afterEach(() => { afterEach(() => {
process.env.NEXT_PUBLIC_LAB3_TERMINAL_PATH = originalEnvValue; vi.restoreAllMocks();
}); });
it("renders the embedded terminal with the configured path", () => { it("reads the runtime config artifact from the public path", async () => {
process.env.NEXT_PUBLIC_LAB3_TERMINAL_PATH = "lab-terminal"; const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(
new Response(
JSON.stringify({
lab3TerminalUrl: "http://127.0.0.1:7681/wetty",
lab3Username: "student",
lab3WorkingDirectory: "/home/student/lab3",
}),
{ status: 200 },
),
);
render(<Lab3TerminalFrame />); await expect(fetchLab3RuntimeConfig()).resolves.toEqual({
terminalPath: "http://127.0.0.1:7681/wetty",
username: "student",
workingDirectory: "/home/student/lab3",
});
expect(fetchMock).toHaveBeenCalledWith(LAB3_RUNTIME_CONFIG_PATH, {
cache: "no-store",
});
});
});
describe("Lab3TerminalFrame", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("renders the embedded terminal with the configured path override", () => {
render(<Lab3TerminalFrame srcPath="lab-terminal" />);
const frame = screen.getByTitle("Lab 3 terminal session"); const frame = screen.getByTitle("Lab 3 terminal session");
expect(frame).toHaveAttribute("src", "/lab-terminal"); expect(frame).toHaveAttribute("src", "/lab-terminal");
@@ -42,10 +85,36 @@ describe("Lab3TerminalFrame", () => {
expect(screen.getAllByText("/home/student/lab3")).toHaveLength(2); expect(screen.getAllByText("/home/student/lab3")).toHaveLength(2);
}); });
it("loads the terminal settings from the runtime config artifact", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
lab3TerminalUrl: "http://127.0.0.1:7681/wetty",
lab3Username: "student",
lab3WorkingDirectory: "/srv/labs/lab3",
}),
{ status: 200 },
),
);
render(<Lab3TerminalFrame />);
await waitFor(() => {
expect(screen.getByTitle("Lab 3 terminal session")).toHaveAttribute(
"src",
"http://127.0.0.1:7681/wetty",
);
});
expect(screen.getAllByText("/srv/labs/lab3")).toHaveLength(2);
});
it("toggles the dock open and closed", () => { it("toggles the dock open and closed", () => {
render(<Lab3TerminalFrame srcPath="/wetty" />); render(<Lab3TerminalFrame srcPath="/wetty" />);
const toggle = screen.getAllByRole("button", { name: /open terminal|hide terminal|lab 3 terminal/i })[0]; const toggle = screen.getAllByRole("button", {
name: /open terminal|hide terminal|lab 3 terminal/i,
})[0];
expect(toggle).toHaveAttribute("aria-expanded", "false"); expect(toggle).toHaveAttribute("aria-expanded", "false");
fireEvent.click(toggle); fireEvent.click(toggle);
+100 -52
View File
@@ -1,34 +1,60 @@
"use client"; "use client";
import { useMemo, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
export const LAB3_DEFAULT_TERMINAL_PATH = "/wetty"; import { fetchLab3RuntimeConfig, normalizeLab3RuntimeConfig } from "~/lib/lab3-runtime";
export function getLab3TerminalPath(envValue?: string) {
const trimmedValue = envValue?.trim();
if (!trimmedValue) {
return LAB3_DEFAULT_TERMINAL_PATH;
}
if (/^https?:\/\//i.test(trimmedValue)) {
return trimmedValue;
}
return trimmedValue.startsWith("/") ? trimmedValue : `/${trimmedValue}`;
}
type Lab3TerminalFrameProps = { type Lab3TerminalFrameProps = {
srcPath?: string; srcPath?: string;
}; };
export function Lab3TerminalFrame({ export function Lab3TerminalFrame({ srcPath }: Lab3TerminalFrameProps) {
srcPath = getLab3TerminalPath(process.env.NEXT_PUBLIC_LAB3_TERMINAL_PATH),
}: Lab3TerminalFrameProps) {
const frameRef = useRef<HTMLIFrameElement>(null); const frameRef = useRef<HTMLIFrameElement>(null);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading"); const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
const terminalPath = useMemo(() => getLab3TerminalPath(srcPath), [srcPath]); const [isConfigResolved, setIsConfigResolved] = useState(Boolean(srcPath));
const [runtimeConfig, setRuntimeConfig] = useState(() => {
return normalizeLab3RuntimeConfig(
srcPath ? { lab3TerminalUrl: srcPath } : undefined,
);
});
const terminalPath = runtimeConfig.terminalPath;
useEffect(() => {
let isCancelled = false;
if (srcPath) {
setRuntimeConfig(normalizeLab3RuntimeConfig({ lab3TerminalUrl: srcPath }));
setIsConfigResolved(true);
return;
}
setIsConfigResolved(false);
setStatus("loading");
void fetchLab3RuntimeConfig()
.then((nextConfig) => {
if (isCancelled) return;
setRuntimeConfig(nextConfig);
})
.catch(() => {
if (isCancelled) return;
setRuntimeConfig(normalizeLab3RuntimeConfig());
})
.finally(() => {
if (isCancelled) return;
setIsConfigResolved(true);
});
return () => {
isCancelled = true;
};
}, [srcPath]);
useEffect(() => {
setStatus("loading");
}, [terminalPath]);
function handleFrameLoad() { function handleFrameLoad() {
const frame = frameRef.current; const frame = frameRef.current;
@@ -61,7 +87,8 @@ export function Lab3TerminalFrame({
<h3>Use the lab shell directly in your browser</h3> <h3>Use the lab shell directly in your browser</h3>
<p className="lab3-terminal-inline__lede"> <p className="lab3-terminal-inline__lede">
The terminal is docked to the bottom of the page. Expand it with the The terminal is docked to the bottom of the page. Expand it with the
arrow when you&apos;re ready to work from <code>/home/student/lab3</code>. arrow when you&apos;re ready to log in as <code>{runtimeConfig.username}</code>{" "}
and work from <code>{runtimeConfig.workingDirectory}</code>.
</p> </p>
</div> </div>
@@ -76,14 +103,20 @@ export function Lab3TerminalFrame({
{isOpen ? "Hide Terminal" : "Open Terminal"} {isOpen ? "Hide Terminal" : "Open Terminal"}
</button> </button>
<a {isConfigResolved ? (
className="lab3-terminal-inline__link" <a
href={terminalPath} className="lab3-terminal-inline__link"
rel="noreferrer" href={terminalPath}
target="_blank" rel="noreferrer"
> target="_blank"
Open in New Tab >
</a> Open in New Tab
</a>
) : (
<span className="lab3-terminal-inline__link" aria-live="polite">
Loading terminal...
</span>
)}
</div> </div>
</section> </section>
@@ -107,35 +140,44 @@ export function Lab3TerminalFrame({
<div className="lab3-terminal-dock__panel" id="lab3-terminal-dock-panel"> <div className="lab3-terminal-dock__panel" id="lab3-terminal-dock-panel">
<div className="lab3-terminal-dock__header"> <div className="lab3-terminal-dock__header">
<p className="lab3-terminal-dock__lede"> <p className="lab3-terminal-dock__lede">
Work from <code>/home/student/lab3</code>, or pop the terminal out Log in as <code>{runtimeConfig.username}</code>, work from{" "}
into a full tab for more room. <code>{runtimeConfig.workingDirectory}</code>, or pop the terminal
out into a full tab for more room.
</p> </p>
<a {isConfigResolved ? (
className="lab3-terminal-dock__link" <a
href={terminalPath} className="lab3-terminal-dock__link"
rel="noreferrer" href={terminalPath}
target="_blank" rel="noreferrer"
> target="_blank"
Open in New Tab >
</a> Open in New Tab
</a>
) : null}
</div> </div>
<div className="lab3-terminal-dock__frame-shell"> <div className="lab3-terminal-dock__frame-shell">
<iframe {isConfigResolved ? (
className="lab3-terminal-dock__frame" <iframe
loading="lazy" className="lab3-terminal-dock__frame"
onError={() => setStatus("error")} loading="lazy"
onLoad={handleFrameLoad} onError={() => setStatus("error")}
ref={frameRef} onLoad={handleFrameLoad}
src={terminalPath} ref={frameRef}
title="Lab 3 terminal session" src={terminalPath}
/> title="Lab 3 terminal session"
/>
) : (
<div className="lab3-terminal-dock__frame" aria-hidden="true" />
)}
</div> </div>
{status === "loading" ? ( {status === "loading" ? (
<p className="lab3-terminal-dock__status" aria-live="polite"> <p className="lab3-terminal-dock__status" aria-live="polite">
Connecting to the embedded terminal... {isConfigResolved
? "Connecting to the embedded terminal..."
: "Loading terminal configuration..."}
</p> </p>
) : null} ) : null}
@@ -144,12 +186,18 @@ export function Lab3TerminalFrame({
className="lab3-terminal-dock__status lab3-terminal-dock__status--warning" className="lab3-terminal-dock__status lab3-terminal-dock__status--warning"
aria-live="polite" aria-live="polite"
> >
The reverse proxy is not running for <code>{terminalPath}</code>. The browser terminal service is unavailable right now.
</p> </p>
) : ( ) : (
<p className="lab3-terminal-dock__status"> <p className="lab3-terminal-dock__status">
If the terminal page does not appear, open it in a new tab or fall {isConfigResolved ? (
back to SSH on <code>&lt;IP&gt;:22</code>. <>
If the terminal page does not appear, open it in a new tab and
confirm the service is reachable at <code>{terminalPath}</code>.
</>
) : (
"Waiting for terminal settings from the deployment runtime."
)}
</p> </p>
)} )}
</div> </div>
+52
View File
@@ -0,0 +1,52 @@
export const LAB3_DEFAULT_TERMINAL_PATH = "/wetty";
export const LAB3_DEFAULT_USERNAME = "student";
export const LAB3_DEFAULT_WORKING_DIRECTORY = "/home/student/lab3";
export const LAB3_RUNTIME_CONFIG_PATH = "/courseware-runtime.json";
export type Lab3RuntimeConfig = {
lab3TerminalUrl?: string;
lab3Username?: string;
lab3WorkingDirectory?: string;
};
export type ResolvedLab3RuntimeConfig = {
terminalPath: string;
username: string;
workingDirectory: string;
};
export function getLab3TerminalPath(envValue?: string) {
const trimmedValue = envValue?.trim();
if (!trimmedValue) {
return LAB3_DEFAULT_TERMINAL_PATH;
}
if (/^https?:\/\//i.test(trimmedValue)) {
return trimmedValue;
}
return trimmedValue.startsWith("/") ? trimmedValue : `/${trimmedValue}`;
}
export function normalizeLab3RuntimeConfig(
config?: Lab3RuntimeConfig,
): ResolvedLab3RuntimeConfig {
return {
terminalPath: getLab3TerminalPath(config?.lab3TerminalUrl),
username: config?.lab3Username?.trim() || LAB3_DEFAULT_USERNAME,
workingDirectory:
config?.lab3WorkingDirectory?.trim() || LAB3_DEFAULT_WORKING_DIRECTORY,
};
}
export async function fetchLab3RuntimeConfig() {
const response = await fetch(LAB3_RUNTIME_CONFIG_PATH, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Runtime config request failed: ${response.status}`);
}
const config = (await response.json()) as Lab3RuntimeConfig;
return normalizeLab3RuntimeConfig(config);
}