From ca6a966ad60bdc20313a1a91f489d1ed0aba4944 Mon Sep 17 00:00:00 2001 From: c4ch3c4d3 Date: Mon, 13 Apr 2026 16:40:14 -0600 Subject: [PATCH] Add runtime-configured Lab 3 browser terminal --- README.md | 22 +-- content/labs/lab-3-llama-cpp-and-ollama.md | 6 +- docs/lab3-terminal-deployment.md | 43 ++--- .../labs/Lab3TerminalFrame.test.tsx | 91 +++++++++-- src/components/labs/Lab3TerminalFrame.tsx | 152 ++++++++++++------ src/lib/lab3-runtime.ts | 52 ++++++ 6 files changed, 270 insertions(+), 96 deletions(-) create mode 100644 src/lib/lab3-runtime.ts diff --git a/README.md b/README.md index 2f0a74f..234be82 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/content/labs/lab-3-llama-cpp-and-ollama.md b/content/labs/lab-3-llama-cpp-and-ollama.md index e59cfde..c48f2bf 100644 --- a/content/labs/lab-3-llama-cpp-and-ollama.md +++ b/content/labs/lab-3-llama-cpp-and-ollama.md @@ -24,14 +24,14 @@ In this lab, we will: Execute sections require running commands and producing output. -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.
If the embedded terminal is unavailable, you can still fall back to: - SSH - :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 project’s original goal was to make LLaMA models accessible on systems wit For this lab we will work with **WhiteRabbitNeo‑V3‑7B**, a cybersecurity‑oriented fine‑tune of Qwen2.5‑Coder‑7B. 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.
- Warning: 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 /home/student/lab3/WhiteRabbitNeo to speed up lab execution. + Warning: 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 /home/student/lab3/WhiteRabbitNeo when they are available in the deployment.
### 1. Locate & download the model diff --git a/docs/lab3-terminal-deployment.md b/docs/lab3-terminal-deployment.md index fe30e67..65b887c 100644 --- a/docs/lab3-terminal-deployment.md +++ b/docs/lab3-terminal-deployment.md @@ -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 diff --git a/src/components/labs/Lab3TerminalFrame.test.tsx b/src/components/labs/Lab3TerminalFrame.test.tsx index cca0abb..57570e7 100644 --- a/src/components/labs/Lab3TerminalFrame.test.tsx +++ b/src/components/labs/Lab3TerminalFrame.test.tsx @@ -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(); + 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(); 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(); + + 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(); - 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); diff --git a/src/components/labs/Lab3TerminalFrame.tsx b/src/components/labs/Lab3TerminalFrame.tsx index 3de3a53..9dd1d02 100644 --- a/src/components/labs/Lab3TerminalFrame.tsx +++ b/src/components/labs/Lab3TerminalFrame.tsx @@ -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(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({

Use the lab shell directly in your browser

The terminal is docked to the bottom of the page. Expand it with the - arrow when you're ready to work from /home/student/lab3. + arrow when you're ready to log in as {runtimeConfig.username}{" "} + and work from {runtimeConfig.workingDirectory}.

@@ -76,14 +103,20 @@ export function Lab3TerminalFrame({ {isOpen ? "Hide Terminal" : "Open Terminal"} - - Open in New Tab - + {isConfigResolved ? ( + + Open in New Tab + + ) : ( + + Loading terminal... + + )} @@ -107,35 +140,44 @@ export function Lab3TerminalFrame({

- Work from /home/student/lab3, or pop the terminal out - into a full tab for more room. + Log in as {runtimeConfig.username}, work from{" "} + {runtimeConfig.workingDirectory}, or pop the terminal + out into a full tab for more room.

- - Open in New Tab - + {isConfigResolved ? ( + + Open in New Tab + + ) : null}
-