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
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`
- the public proxy forwards `/wetty` to the local WeTTY port
- WebSocket upgrade happens at the reverse proxy
- WeTTY is launched as root so it can present `/bin/login` locally instead of SSH
- `lab3TerminalUrl`
- `lab3Username`
- `lab3WorkingDirectory`
Example service command:
Local development still falls back to `/wetty`, and `.env.example` keeps that default for simple standalone runs.
```bash
wetty --host 127.0.0.1 --port 3001 --base /wetty --allow-iframe
```
The Linux/WSL deployment contract is:
- 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
+3 -3
View File
@@ -24,14 +24,14 @@ In this lab, we will:
<strong>Execute</strong> sections require running commands and producing output.
</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>
If the embedded terminal is unavailable, you can still fall back to:
- 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
@@ -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.
<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>
### 1. Locate & download the model
+23 -20
View File
@@ -1,32 +1,35 @@
# 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
- Run WeTTY on the lab host, bound to `127.0.0.1`
- Mount it behind the same public origin as the Next.js app
- Let the reverse proxy handle WebSocket upgrade for `/wetty`
- Launch WeTTY as root so it uses `/bin/login` locally
- Keep SSH available as a fallback path, not the primary student UX
- Create a managed `student` Unix account
- Bind `sshd` to `127.0.0.1:22` only
- Run WeTTY on the lab host and expose it to the browser at `/wetty`
- Point WeTTY at localhost SSH without pre-setting the username so students see a login prompt
- Start students in `/home/student/lab3`
Example launch command:
```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
- Keep it bound to localhost only
- Reverse proxy `/wetty` to the local WeTTY port over HTTPS
## Docker Shape
- 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.
- Install `openssh-server`
- Restrict `sshd` to `127.0.0.1` and the managed `student` account
- Install WeTTY with the managed Node runtime
- Generate `/courseware-runtime.json` from deployment-time values
- Start the wiki and WeTTY as separate managed services
+80 -11
View File
@@ -1,10 +1,16 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
Lab3TerminalFrame,
} from "~/components/labs/Lab3TerminalFrame";
import {
LAB3_DEFAULT_TERMINAL_PATH,
Lab3TerminalFrame,
LAB3_DEFAULT_WORKING_DIRECTORY,
fetchLab3RuntimeConfig,
getLab3TerminalPath,
} from "~/components/labs/Lab3TerminalFrame";
LAB3_RUNTIME_CONFIG_PATH,
normalizeLab3RuntimeConfig,
} from "~/lib/lab3-runtime";
describe("getLab3TerminalPath", () => {
it("falls back to the default same-origin terminal path", () => {
@@ -20,17 +26,54 @@ describe("getLab3TerminalPath", () => {
});
});
describe("Lab3TerminalFrame", () => {
const originalEnvValue = process.env.NEXT_PUBLIC_LAB3_TERMINAL_PATH;
describe("normalizeLab3RuntimeConfig", () => {
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(() => {
process.env.NEXT_PUBLIC_LAB3_TERMINAL_PATH = originalEnvValue;
vi.restoreAllMocks();
});
it("renders the embedded terminal with the configured path", () => {
process.env.NEXT_PUBLIC_LAB3_TERMINAL_PATH = "lab-terminal";
it("reads the runtime config artifact from the public path", async () => {
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");
expect(frame).toHaveAttribute("src", "/lab-terminal");
@@ -42,10 +85,36 @@ describe("Lab3TerminalFrame", () => {
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", () => {
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");
fireEvent.click(toggle);
+75 -27
View File
@@ -1,34 +1,60 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
export const LAB3_DEFAULT_TERMINAL_PATH = "/wetty";
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}`;
}
import { fetchLab3RuntimeConfig, normalizeLab3RuntimeConfig } from "~/lib/lab3-runtime";
type Lab3TerminalFrameProps = {
srcPath?: string;
};
export function Lab3TerminalFrame({
srcPath = getLab3TerminalPath(process.env.NEXT_PUBLIC_LAB3_TERMINAL_PATH),
}: Lab3TerminalFrameProps) {
export function Lab3TerminalFrame({ srcPath }: Lab3TerminalFrameProps) {
const frameRef = useRef<HTMLIFrameElement>(null);
const [isOpen, setIsOpen] = useState(false);
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() {
const frame = frameRef.current;
@@ -61,7 +87,8 @@ export function Lab3TerminalFrame({
<h3>Use the lab shell directly in your browser</h3>
<p className="lab3-terminal-inline__lede">
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>
</div>
@@ -76,6 +103,7 @@ export function Lab3TerminalFrame({
{isOpen ? "Hide Terminal" : "Open Terminal"}
</button>
{isConfigResolved ? (
<a
className="lab3-terminal-inline__link"
href={terminalPath}
@@ -84,6 +112,11 @@ export function Lab3TerminalFrame({
>
Open in New Tab
</a>
) : (
<span className="lab3-terminal-inline__link" aria-live="polite">
Loading terminal...
</span>
)}
</div>
</section>
@@ -107,10 +140,12 @@ export function Lab3TerminalFrame({
<div className="lab3-terminal-dock__panel" id="lab3-terminal-dock-panel">
<div className="lab3-terminal-dock__header">
<p className="lab3-terminal-dock__lede">
Work from <code>/home/student/lab3</code>, or pop the terminal out
into a full tab for more room.
Log in as <code>{runtimeConfig.username}</code>, work from{" "}
<code>{runtimeConfig.workingDirectory}</code>, or pop the terminal
out into a full tab for more room.
</p>
{isConfigResolved ? (
<a
className="lab3-terminal-dock__link"
href={terminalPath}
@@ -119,9 +154,11 @@ export function Lab3TerminalFrame({
>
Open in New Tab
</a>
) : null}
</div>
<div className="lab3-terminal-dock__frame-shell">
{isConfigResolved ? (
<iframe
className="lab3-terminal-dock__frame"
loading="lazy"
@@ -131,11 +168,16 @@ export function Lab3TerminalFrame({
src={terminalPath}
title="Lab 3 terminal session"
/>
) : (
<div className="lab3-terminal-dock__frame" aria-hidden="true" />
)}
</div>
{status === "loading" ? (
<p className="lab3-terminal-dock__status" aria-live="polite">
Connecting to the embedded terminal...
{isConfigResolved
? "Connecting to the embedded terminal..."
: "Loading terminal configuration..."}
</p>
) : null}
@@ -144,12 +186,18 @@ export function Lab3TerminalFrame({
className="lab3-terminal-dock__status lab3-terminal-dock__status--warning"
aria-live="polite"
>
The reverse proxy is not running for <code>{terminalPath}</code>.
The browser terminal service is unavailable right now.
</p>
) : (
<p className="lab3-terminal-dock__status">
If the terminal page does not appear, open it in a new tab or fall
back to SSH on <code>&lt;IP&gt;:22</code>.
{isConfigResolved ? (
<>
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>
)}
</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);
}