diff --git a/README.md b/README.md index 17dfa45..e6f36e7 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,25 @@ Default endpoints: - Unsloth Studio: `http://127.0.0.1:8888` - Promptfoo UI: `http://127.0.0.1:15500` - Wiki: `http://127.0.0.1:80` +- Lab 3 Terminal: `http://127.0.0.1:7681/wetty` + +## Lab 3 Browser Terminal + +Linux and WSL deployments now require a managed `student` password hash before `./labctl up` or `./labctl preflight`. + +Example: + +```bash +export COURSEWARE_STUDENT_PASSWORD_HASH="$(openssl passwd -6 'student-password')" +./labctl up +``` + +The deployment will: + +- create the managed `student` account +- create `/home/student/lab3` +- bind `sshd` to `127.0.0.1:22` only +- install WeTTY and expose it at `http://127.0.0.1:7681/wetty` ## Notes diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index 0dad747..bda65b1 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -18,9 +18,12 @@ courseware_unsloth_home: "{{ courseware_state_dir }}/unsloth-home" courseware_ollama_models_dir: "{{ courseware_models_dir }}/ollama" courseware_node_runtime_dir: "{{ courseware_tools_dir }}/node-runtime" courseware_node_runtime_bin_dir: "{{ courseware_node_runtime_dir }}/node_modules/node/bin" +courseware_wetty_dir: "{{ courseware_tools_dir }}/wetty" courseware_promptfoo_dir: "{{ courseware_lab6_dir }}" courseware_wiki_repo_dir: "{{ courseware_repos_dir }}/LLM-Labs" +courseware_wiki_runtime_config_path: "{{ courseware_wiki_repo_dir }}/public/courseware-runtime.json" courseware_llama_cpp_bin_dir: "{{ courseware_repos_dir }}/llama.cpp/build/bin" +courseware_lab3_dir: "/home/student/lab3" courseware_bind_host: "0.0.0.0" courseware_url_host: "127.0.0.1" @@ -33,6 +36,7 @@ courseware_ports: unsloth: 8888 promptfoo: 15500 wiki: 80 + wetty: 7681 courseware_transformerlab_install_mode: "single-user-pinned" courseware_transformerlab_version: "v0.28.2" @@ -63,7 +67,11 @@ courseware_chunkviz_commit: "a891eacafda1f28a12373ad3b00102e68f07c57f" courseware_promptfoo_version: "0.119.0" courseware_kiln_release_tag: "v0.18.1" courseware_node_runtime_version: "20.20.2" +courseware_wetty_spec: "wetty@2.5.0" +courseware_wetty_base_path: "/wetty" courseware_wiki_repo: "https://git.zuccaro.me/bzuccaro/LLM-Labs.git" +courseware_student_username: "student" +courseware_student_password_hash: "{{ lookup('env', 'COURSEWARE_STUDENT_PASSWORD_HASH') | default('', true) }}" courseware_open_webui_spec: "open-webui" courseware_embedding_atlas_spec: "embedding-atlas" @@ -151,3 +159,4 @@ courseware_services: - "unsloth" - "promptfoo" - "wiki" + - "wetty" diff --git a/ansible/playbooks/up.yml b/ansible/playbooks/up.yml index 7fa82ed..fa8c49c 100644 --- a/ansible/playbooks/up.yml +++ b/ansible/playbooks/up.yml @@ -8,6 +8,7 @@ - packages - lab_assets - node_runtime + - { role: terminal, when: ansible_system == "Linux" } - llama_cpp - transformerlab - open_webui diff --git a/ansible/roles/terminal/tasks/main.yml b/ansible/roles/terminal/tasks/main.yml new file mode 100644 index 0000000..883983b --- /dev/null +++ b/ansible/roles/terminal/tasks/main.yml @@ -0,0 +1,200 @@ +- name: Fail when student password hash is not configured + fail: + msg: >- + Set COURSEWARE_STUDENT_PASSWORD_HASH in the environment before running ./labctl up. + Example: + export COURSEWARE_STUDENT_PASSWORD_HASH="$(openssl passwd -6 'student-password')" + when: courseware_student_password_hash | trim | length == 0 + +- name: Install terminal prerequisites + become: true + apt: + name: + - openssh-server + state: present + update_cache: true + +- name: Ensure sshd drop-in directory exists + become: true + file: + path: /etc/ssh/sshd_config.d + state: directory + mode: "0755" + +- name: Configure courseware loopback-only sshd policy + become: true + template: + src: sshd-courseware-terminal.conf.j2 + dest: /etc/ssh/sshd_config.d/50-courseware-terminal.conf + mode: "0644" + register: courseware_terminal_sshd_config + +- name: Validate sshd configuration + become: true + command: + argv: + - /usr/sbin/sshd + - -t + - -f + - /etc/ssh/sshd_config + changed_when: false + +- name: Ensure sshd runtime directory exists + become: true + file: + path: /run/sshd + state: directory + mode: "0755" + +- name: Start and enable sshd with systemd when available + become: true + systemd: + name: ssh + state: started + enabled: true + when: ansible_service_mgr == "systemd" + +- name: Check for running sshd when systemd is unavailable + become: true + command: pgrep -x sshd + register: courseware_terminal_sshd_pid + changed_when: false + failed_when: false + when: ansible_service_mgr != "systemd" + +- name: Reload running sshd when config changed outside systemd + become: true + command: pkill -HUP -x sshd + when: + - ansible_service_mgr != "systemd" + - courseware_terminal_sshd_pid.rc == 0 + - courseware_terminal_sshd_config.changed + +- name: Start sshd when it is not already running outside systemd + become: true + command: + argv: + - /usr/sbin/sshd + when: + - ansible_service_mgr != "systemd" + - courseware_terminal_sshd_pid.rc != 0 + +- name: Ensure managed terminal user exists + become: true + user: + name: "{{ courseware_student_username }}" + password: "{{ courseware_student_password_hash }}" + shell: /bin/bash + create_home: true + state: present + +- name: Ensure lab 3 workspace root exists + become: true + file: + path: "{{ courseware_lab3_dir }}" + state: directory + owner: "{{ courseware_student_username }}" + group: "{{ courseware_student_username }}" + mode: "0755" + +- name: Ensure lab 3 WhiteRabbitNeo workspace exists + become: true + file: + path: "{{ courseware_lab3_dir }}/WhiteRabbitNeo" + state: directory + owner: "{{ courseware_student_username }}" + group: "{{ courseware_student_username }}" + mode: "0755" + +- name: Write lab 3 workspace README + become: true + template: + src: lab3-workspace-readme.txt.j2 + dest: "{{ courseware_lab3_dir }}/README.txt" + owner: "{{ courseware_student_username }}" + group: "{{ courseware_student_username }}" + mode: "0644" + +- name: Check for repo-local WhiteRabbitNeo base repo + stat: + path: "{{ courseware_root }}/assets/lab2/WhiteRabbitNeo-V3-7B" + register: courseware_lab3_base_repo_stat + +- name: Check for repo-local WhiteRabbitNeo GGUF directory + stat: + path: "{{ courseware_root }}/assets/lab2/WhiteRabbitNeo_WhiteRabbitNeo-V3-7B-GGUF" + register: courseware_lab3_gguf_repo_stat + +- name: Link WhiteRabbitNeo base repo into the student workspace when repo-local assets exist + become: true + file: + src: "{{ courseware_root }}/assets/lab2/WhiteRabbitNeo-V3-7B" + dest: "{{ courseware_lab3_dir }}/WhiteRabbitNeo/WhiteRabbitNeo-V3-7B" + state: link + force: true + owner: "{{ courseware_student_username }}" + group: "{{ courseware_student_username }}" + when: courseware_lab3_base_repo_stat.stat.exists + +- name: Link WhiteRabbitNeo GGUF directory into the student workspace when repo-local assets exist + become: true + file: + src: "{{ courseware_root }}/assets/lab2/WhiteRabbitNeo_WhiteRabbitNeo-V3-7B-GGUF" + dest: "{{ courseware_lab3_dir }}/WhiteRabbitNeo/WhiteRabbitNeo_WhiteRabbitNeo-V3-7B-GGUF" + state: link + force: true + owner: "{{ courseware_student_username }}" + group: "{{ courseware_student_username }}" + when: courseware_lab3_gguf_repo_stat.stat.exists + +- name: Link WhiteRabbitNeo download helper into the student workspace + become: true + file: + src: "{{ courseware_lab2_dir }}/download_whiterabbitneo-gguf.sh" + dest: "{{ courseware_lab3_dir }}/download_whiterabbitneo-gguf.sh" + state: link + force: true + owner: "{{ courseware_student_username }}" + group: "{{ courseware_student_username }}" + +- name: Create contained WeTTY directory + file: + path: "{{ courseware_wetty_dir }}" + state: directory + mode: "0755" + +- name: Install contained WeTTY runtime + command: + argv: + - npm + - install + - "{{ courseware_wetty_spec }}" + args: + chdir: "{{ courseware_wetty_dir }}" + creates: "{{ courseware_wetty_dir }}/node_modules/.bin/wetty" + environment: + PATH: "{{ courseware_node_runtime_bin_dir }}:{{ ansible_env.PATH }}" + +- name: Check loopback sshd listener + become: true + command: ss -ltn + register: courseware_terminal_ss_listeners + changed_when: false + +- name: Assert sshd is loopback-only + assert: + that: + - "'127.0.0.1:22' in courseware_terminal_ss_listeners.stdout" + - "'0.0.0.0:22' not in courseware_terminal_ss_listeners.stdout" + - "'[::]:22' not in courseware_terminal_ss_listeners.stdout" + fail_msg: "sshd must listen only on 127.0.0.1:22 for the browser terminal deployment." + +- name: Assert WeTTY binary exists + stat: + path: "{{ courseware_wetty_dir }}/node_modules/.bin/wetty" + register: courseware_wetty_bin_stat + +- name: Fail when WeTTY installation is incomplete + fail: + msg: "WeTTY was not installed under {{ courseware_wetty_dir }}." + when: not courseware_wetty_bin_stat.stat.exists diff --git a/ansible/roles/terminal/templates/lab3-workspace-readme.txt.j2 b/ansible/roles/terminal/templates/lab3-workspace-readme.txt.j2 new file mode 100644 index 0000000..249aa41 --- /dev/null +++ b/ansible/roles/terminal/templates/lab3-workspace-readme.txt.j2 @@ -0,0 +1,14 @@ +This workspace is managed for the Lab 3 browser terminal. + +You should log in as: +- username: {{ courseware_student_username }} + +Start here: +- working directory: {{ courseware_lab3_dir }} + +Helpful paths: +- WhiteRabbitNeo helper: {{ courseware_lab3_dir }}/download_whiterabbitneo-gguf.sh +- repo-local base repo symlink: {{ courseware_lab3_dir }}/WhiteRabbitNeo/WhiteRabbitNeo-V3-7B +- repo-local GGUF symlink: {{ courseware_lab3_dir }}/WhiteRabbitNeo/WhiteRabbitNeo_WhiteRabbitNeo-V3-7B-GGUF + +Some symlinks only appear after the corresponding repo-local lab assets are present. diff --git a/ansible/roles/terminal/templates/sshd-courseware-terminal.conf.j2 b/ansible/roles/terminal/templates/sshd-courseware-terminal.conf.j2 new file mode 100644 index 0000000..527bd9c --- /dev/null +++ b/ansible/roles/terminal/templates/sshd-courseware-terminal.conf.j2 @@ -0,0 +1,12 @@ +# Managed by Local Courseware Deployment. +ListenAddress 127.0.0.1 +AddressFamily inet +PermitRootLogin no +PasswordAuthentication yes +KbdInteractiveAuthentication no +ChallengeResponseAuthentication no +UsePAM yes +AllowUsers {{ courseware_student_username }} +AllowTcpForwarding no +X11Forwarding no +PrintMotd no diff --git a/ansible/roles/wiki/tasks/main.yml b/ansible/roles/wiki/tasks/main.yml index 8b0d840..be1aa3a 100644 --- a/ansible/roles/wiki/tasks/main.yml +++ b/ansible/roles/wiki/tasks/main.yml @@ -36,6 +36,12 @@ environment: PATH: "{{ courseware_node_runtime_bin_dir }}:{{ ansible_env.PATH }}" +- name: Render wiki runtime config + template: + src: courseware-runtime.json.j2 + dest: "{{ courseware_wiki_runtime_config_path }}" + mode: "0644" + - name: Stat wiki build output stat: path: "{{ courseware_wiki_repo_dir }}/.next/BUILD_ID" diff --git a/ansible/roles/wiki/templates/courseware-runtime.json.j2 b/ansible/roles/wiki/templates/courseware-runtime.json.j2 new file mode 100644 index 0000000..4a77805 --- /dev/null +++ b/ansible/roles/wiki/templates/courseware-runtime.json.j2 @@ -0,0 +1,5 @@ +{ + "lab3TerminalUrl": "http://{{ courseware_url_host }}:{{ courseware_ports.wetty }}{{ courseware_wetty_base_path }}", + "lab3Username": "{{ courseware_student_username }}", + "lab3WorkingDirectory": "{{ courseware_lab3_dir }}" +} diff --git a/ansible/templates/runtime.env.j2 b/ansible/templates/runtime.env.j2 index f87febf..fe6b56a 100644 --- a/ansible/templates/runtime.env.j2 +++ b/ansible/templates/runtime.env.j2 @@ -11,9 +11,12 @@ COURSEWARE_EMBEDDING_ATLAS_PORT="{{ courseware_ports.embedding_atlas }}" COURSEWARE_UNSLOTH_PORT="{{ courseware_ports.unsloth }}" COURSEWARE_PROMPTFOO_PORT="{{ courseware_ports.promptfoo }}" COURSEWARE_WIKI_PORT="{{ courseware_ports.wiki }}" +COURSEWARE_WETTY_PORT="{{ courseware_ports.wetty }}" OLLAMA_BIN="{{ courseware_ollama_bin }}" OLLAMA_MODELS_DIR="{{ courseware_ollama_models_dir }}" NODE_RUNTIME_BIN_DIR="{{ courseware_node_runtime_bin_dir }}" +WETTY_BIN="{{ courseware_wetty_dir }}/node_modules/.bin/wetty" +COURSEWARE_WETTY_BASE_PATH="{{ courseware_wetty_base_path }}" OPEN_WEBUI_VENV="{{ courseware_venvs_dir }}/open-webui" OPEN_WEBUI_DATA_DIR="{{ courseware_state_dir }}/open-webui" CHUNKVIZ_DIR="{{ courseware_repos_dir }}/ChunkViz" @@ -25,10 +28,13 @@ TRANSFORMERLAB_DEFAULT_USER_EMAIL="{{ courseware_transformerlab_default_user_ema TRANSFORMERLAB_DEFAULT_USER_PASSWORD="{{ courseware_transformerlab_default_user_password }}" TRANSFORMERLAB_DEFAULT_USER_FIRST_NAME="{{ courseware_transformerlab_default_user_first_name }}" TRANSFORMERLAB_DEFAULT_USER_LAST_NAME="{{ courseware_transformerlab_default_user_last_name }}" +COURSEWARE_STUDENT_USERNAME="{{ courseware_student_username }}" +COURSEWARE_LAB3_DIR="{{ courseware_lab3_dir }}" UNSLOTH_BIN="{{ ansible_env.HOME }}/.local/bin/unsloth" PROMPTFOO_DIR="{{ courseware_promptfoo_dir }}" PROMPTFOO_BIN="{{ courseware_tools_dir }}/promptfoo/node_modules/.bin/promptfoo" WIKI_DIR="{{ courseware_wiki_repo_dir }}" +WIKI_RUNTIME_CONFIG_PATH="{{ courseware_wiki_runtime_config_path }}" LLAMA_CPP_BIN_DIR="{{ courseware_llama_cpp_bin_dir }}" KILN_LINUX_BIN="{{ courseware_apps_dir }}/kiln/Kiln" KILN_MAC_APP="{{ courseware_apps_dir }}/Kiln.app" diff --git a/labctl b/labctl old mode 100755 new mode 100644 index a66a38d..f40ca82 --- a/labctl +++ b/labctl @@ -49,6 +49,31 @@ Pinned component versions: EOF } +require_student_password_hash() { + local current_host_profile + + current_host_profile=$(host_profile) + if [ "$current_host_profile" = "macos" ]; then + return + fi + + if [ -n "${COURSEWARE_STUDENT_PASSWORD_HASH:-}" ]; then + return + fi + + cat <<'EOF' >&2 +Missing COURSEWARE_STUDENT_PASSWORD_HASH. + +Set a password hash for the managed `student` login before running this command. Example: + + export COURSEWARE_STUDENT_PASSWORD_HASH="$(openssl passwd -6 'student-password')" + +Then rerun: + ./labctl up +EOF + exit 1 +} + confirm_installation() { local response local tlab_version @@ -527,6 +552,7 @@ main() { case "$cmd" in up) confirm_installation + require_student_password_hash run_playbook up.yml run_project_script "$ROOT_DIR/scripts/service_manager.sh" start all ;; @@ -536,6 +562,7 @@ main() { ;; preflight) confirm_installation + require_student_password_hash run_playbook up.yml --tags preflight ;; versions) diff --git a/scripts/common.sh b/scripts/common.sh index bf45e22..a5b2f59 100644 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -17,9 +17,15 @@ load_runtime_env() { : "${COURSEWARE_URL_HOST:=127.0.0.1}" : "${COURSEWARE_PROMPTFOO_PORT:=15500}" : "${COURSEWARE_WIKI_PORT:=80}" + : "${COURSEWARE_WETTY_PORT:=7681}" + : "${COURSEWARE_WETTY_BASE_PATH:=/wetty}" + : "${COURSEWARE_STUDENT_USERNAME:=student}" + : "${COURSEWARE_LAB3_DIR:=/home/student/lab3}" : "${NODE_RUNTIME_BIN_DIR:=$COURSEWARE_STATE_DIR/tools/node-runtime/node_modules/node/bin}" + : "${WETTY_BIN:=$COURSEWARE_STATE_DIR/tools/wetty/node_modules/.bin/wetty}" : "${PROMPTFOO_DIR:=$COURSEWARE_STATE_DIR/lab6}" : "${WIKI_DIR:=$COURSEWARE_STATE_DIR/repos/LLM-Labs}" + : "${WIKI_RUNTIME_CONFIG_PATH:=$WIKI_DIR/public/courseware-runtime.json}" : "${LLAMA_CPP_BIN_DIR:=$COURSEWARE_STATE_DIR/repos/llama.cpp/build/bin}" if [ -n "${OLLAMA_BIN:-}" ] && [[ "$OLLAMA_BIN" != */* ]] && command -v "$OLLAMA_BIN" >/dev/null 2>&1; then @@ -43,7 +49,8 @@ service_list() { "embedding-atlas" \ "unsloth" \ "promptfoo" \ - "wiki" + "wiki" \ + "wetty" } service_pid_file() { @@ -64,6 +71,7 @@ service_port() { unsloth) printf '%s\n' "${COURSEWARE_UNSLOTH_PORT}" ;; promptfoo) printf '%s\n' "${COURSEWARE_PROMPTFOO_PORT}" ;; wiki) printf '%s\n' "${COURSEWARE_WIKI_PORT}" ;; + wetty) printf '%s\n' "${COURSEWARE_WETTY_PORT}" ;; *) return 1 ;; esac } @@ -78,6 +86,7 @@ service_url() { unsloth) printf 'http://%s:%s\n' "$COURSEWARE_URL_HOST" "$COURSEWARE_UNSLOTH_PORT" ;; promptfoo) printf 'http://%s:%s\n' "$COURSEWARE_URL_HOST" "$COURSEWARE_PROMPTFOO_PORT" ;; wiki) printf 'http://%s:%s\n' "$COURSEWARE_URL_HOST" "$COURSEWARE_WIKI_PORT" ;; + wetty) printf 'http://%s:%s%s\n' "$COURSEWARE_URL_HOST" "$COURSEWARE_WETTY_PORT" "$COURSEWARE_WETTY_BASE_PATH" ;; *) return 1 ;; esac } @@ -144,6 +153,15 @@ service_command() { "$COURSEWARE_BIND_HOST" \ "$COURSEWARE_WIKI_PORT" ;; + wetty) + printf 'cd "%s" && PATH="%s:$PATH" exec "%s" --host %s --port %s --base %s --allow-iframe --ssh-host 127.0.0.1 --ssh-port 22 --ssh-auth password' \ + "$COURSEWARE_ROOT" \ + "$NODE_RUNTIME_BIN_DIR" \ + "$WETTY_BIN" \ + "$COURSEWARE_BIND_HOST" \ + "$COURSEWARE_WETTY_PORT" \ + "$COURSEWARE_WETTY_BASE_PATH" + ;; *) return 1 ;; diff --git a/scripts/service_manager.sh b/scripts/service_manager.sh old mode 100755 new mode 100644 index 1f5dd55..658dc12 --- a/scripts/service_manager.sh +++ b/scripts/service_manager.sh @@ -28,6 +28,44 @@ ensure_transformerlab_default_user() { --last-name "${TRANSFORMERLAB_DEFAULT_USER_LAST_NAME:-}" >>"$STATE_DIR/logs/transformerlab_default_user.log" 2>&1 || true } +check_wetty_prereqs() { + if ! id "$COURSEWARE_STUDENT_USERNAME" >/dev/null 2>&1; then + echo "Missing terminal user '$COURSEWARE_STUDENT_USERNAME'. Re-run ./labctl up after setting COURSEWARE_STUDENT_PASSWORD_HASH." >&2 + exit 1 + fi + + if [ ! -x "$WETTY_BIN" ]; then + echo "Missing WeTTY binary at $WETTY_BIN. Re-run ./labctl up." >&2 + exit 1 + fi + + if [ ! -f "$WIKI_RUNTIME_CONFIG_PATH" ]; then + echo "Missing wiki runtime config at $WIKI_RUNTIME_CONFIG_PATH. Re-run ./labctl up." >&2 + exit 1 + fi + + if [ ! -d "$COURSEWARE_LAB3_DIR" ]; then + echo "Missing lab workspace at $COURSEWARE_LAB3_DIR. Re-run ./labctl up." >&2 + exit 1 + fi + + if ! python3 - <<'PY' +import socket, sys +sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +sock.settimeout(1) +try: + sock.connect(("127.0.0.1", 22)) +except OSError: + sys.exit(1) +finally: + sock.close() +PY + then + echo "Loopback sshd is not reachable on 127.0.0.1:22." >&2 + exit 1 + fi +} + resolve_targets() { if [ $# -eq 0 ]; then echo "No target specified." >&2 @@ -82,7 +120,7 @@ service_ready() { promptfoo) curl -fsS "$(service_url "$service")/health" >/dev/null 2>&1 ;; - open-webui|chunkviz|embedding-atlas|unsloth|wiki) + open-webui|chunkviz|embedding-atlas|unsloth|wiki|wetty) curl -fsS "$(service_url "$service")" >/dev/null 2>&1 ;; *) @@ -176,6 +214,9 @@ start_one() { --required-support "batched" >>"$STATE_DIR/logs/transformerlab_plugin_supports.log" 2>&1 || true fi ;; + wetty) + check_wetty_prereqs + ;; *) ;; esac @@ -289,6 +330,7 @@ Unsloth Studio: $(service_url unsloth) Promptfoo CLI: $PROMPTFOO_BIN Promptfoo UI: $(service_url promptfoo) Wiki: $(service_url wiki) +Lab 3 Terminal: $(service_url wetty) Kiln app: ${KILN_LAUNCH_PATH:-not installed} EOF }