Add runtime-configured Lab 3 browser terminal
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 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.
|
||||
|
||||
<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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NEXT_PUBLIC_LAB3_TERMINAL_PATH = originalEnvValue;
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the embedded terminal with the configured path", () => {
|
||||
process.env.NEXT_PUBLIC_LAB3_TERMINAL_PATH = "lab-terminal";
|
||||
describe("fetchLab3RuntimeConfig", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
render(<Lab3TerminalFrame />);
|
||||
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 },
|
||||
),
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
@@ -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're ready to work from <code>/home/student/lab3</code>.
|
||||
arrow when you'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><IP>: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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user