diff --git a/scripts/breakout-to-wave-terminal/README.md b/scripts/breakout-to-wave-terminal/README.md new file mode 100644 index 0000000..c08ba4b --- /dev/null +++ b/scripts/breakout-to-wave-terminal/README.md @@ -0,0 +1,193 @@ +\ +# CML2 Consoles in Wave Terminal (Linux) + +A lightweight launcher that integrates **Cisco Modeling Labs 2 (CML2)** with **Wave Terminal**, automatically creating one terminal block for each device console. + +This project uses the **CML2 breakout** utility as a local console proxy together with Wave Terminal's `wsh run` command. + +> **Repository:** `cml-breakout-wave-terminal` + +Repository Base: https://github.com/hendapaim/cml-breakout-wave-terminal +--- + +# Features + +- 🚀 Automatic Wave Terminal console creation +- 🔄 Refreshes CML topology every launch +- 🔌 Automatically starts the Breakout proxy when required +- 🖥️ One terminal block per enabled serial console +- 📦 Portable launcher (directory can be moved or renamed) +- 🔒 Local-only console listener by default (`[::1]`) +- ⚡ Supports `telnet`, `nc`, or `ncat` + +--- + +# Requirements + +- Linux +- Cisco Modeling Labs 2 +- Python 3 +- Wave Terminal +- CML2 `breakout` binary +- One of: + - `telnet` + - `netcat-openbsd (nc)` + - `ncat` + +--- + +# Project Structure + +```text +. +├── breakout +├── config.yaml +├── labs.yaml +├── main.py +├── wave-cml2 +└── README.md +``` + +--- + +# Initial Setup + +Export your CML credentials: + +```bash +export BREAKOUT_USERNAME=admin +export BREAKOUT_PASSWORD='your-cml2-password' +``` + +Initialize the project: + +```bash +./breakout -config config.yaml -labs labs.yaml init INIT + +python3 main.py INIT +``` + +This command: + +- Connects to CML2 +- Detects the selected lab +- Retrieves console port assignments +- Writes the information into `labs.yaml` + +Review `labs.yaml` and ensure the desired lab and devices are enabled. + +--- + +# Launch Consoles + +Run: + +```bash +python3 main.py INIT --run +``` + +or execute the generated launcher directly: + +```bash +./wave-cml2 +``` + +Every launch automatically: + +1. Refreshes the lab from CML2 +2. Updates console port assignments +3. Rebuilds the launcher +4. Starts the Breakout proxy if necessary +5. Waits until the local proxy is ready +6. Opens one Wave Terminal block for every enabled serial console + +No manual port management is required. + +--- + +# Using a Different Console Host + +The launcher searches for the following files relative to its own location: + +- `breakout` +- `config.yaml` +- `labs.yaml` + +Because of this, the directory can be moved without modifying paths. + +To temporarily use another console proxy: + +```bash +CONSOLE_HOST=127.0.0.1 ./wave-cml2 +``` + +--- + +# Security + +By default the Breakout listener binds only to: + +```text +[::1] +``` + +This keeps console ports accessible only from the local machine. + +--- + +# Supported Console Clients + +The launcher automatically uses the first available client: + +1. `telnet` +2. `nc` +3. `ncat` + +Install `netcat-openbsd` if none are already installed. + +--- + +# Legacy Note + +`config.ini` is retained only for backward compatibility. + +The recommended workflow uses: + +- `config.yaml` +- `labs.yaml` +- `wave-cml2` + +--- + +# Workflow + +```text +CML2 + │ + ▼ +breakout + │ + ▼ +Local Console Proxy + │ + ▼ +wave-cml2 + │ + ▼ +Wave Terminal + │ + ├── Router 1 + ├── Router 2 + ├── Switch 1 + └── ... +``` + +--- + + +# Author +Henda Paim - https://www.linkedin.com/in/hendapaim0 + +# License + +Use and modify freely according to the license included in this repository. \ No newline at end of file diff --git a/scripts/breakout-to-wave-terminal/breakout b/scripts/breakout-to-wave-terminal/breakout new file mode 100755 index 0000000..a76f58b Binary files /dev/null and b/scripts/breakout-to-wave-terminal/breakout differ diff --git a/scripts/breakout-to-wave-terminal/config.yaml b/scripts/breakout-to-wave-terminal/config.yaml new file mode 100644 index 0000000..54cb512 --- /dev/null +++ b/scripts/breakout-to-wave-terminal/config.yaml @@ -0,0 +1,40 @@ +# +# breakout configuration file +# - 'username' and 'password' for authentication +# NOTE: the password is not exported with 2.8, if it exists +# when starting the breakout then it will be used but it won't +# be saved when exiting the program. Use environment variables +# instead! +# Using the UI *did* save the password in previous versions. +# - replace hostname 'cml-controller.cml.lab' with your IP or FQDN +# - set an empty 'listen_address' to listen globally +# +# Known environment variables: +# +# BREAKOUT_USERNAME +# BREAKOUT_PASSWORD +# BREAKOUT_CONTROLLER +# BREAKOUT_LISTEN_ADDRESS +# BREAKOUT_LAB_CONFIG_NAME +# BREAKOUT_UI_SERVER_PORT +# BREAKOUT_CONSOLE_START_PORT +# BREAKOUT_VNC_START_PORT +# BREAKOUT_POPULATE_ALL +# BREAKOUT_VERIFY_TLS +# BREAKOUT_EXTRA_LF +# + +console_start_port: 9000 +# CML2 controller. Override any of these values with BREAKOUT_* variables. +controller: https://cml2 +extra_lf: false +lab_config_name: labs.yaml +listen_address: '[::1]' +populate_all: false +ui_server_port: 8080 +username: admin +password: '240819Hp#' +# Never save the CML2 password here. Export BREAKOUT_PASSWORD before running +# breakout, or let breakout request it interactively. +verify_tls: false +vnc_start_port: 5900 diff --git a/scripts/breakout-to-wave-terminal/labs.yaml b/scripts/breakout-to-wave-terminal/labs.yaml new file mode 100644 index 0000000..cce3042 --- /dev/null +++ b/scripts/breakout-to-wave-terminal/labs.yaml @@ -0,0 +1,47 @@ +05923109-fe68-41d3-a638-55962bb9977d: + created: "2026-05-15T23:00:44+00:00" + effective_permissions: + - lab_admin + - lab_exec + - lab_edit + - lab_view + enabled: true + id: 05923109-fe68-41d3-a638-55962bb9977d + lab_description: "" + lab_title: INIT + link_count: 4 + node_count: 5 + nodes: + 005dfa97-a3dc-4b4f-b548-372941209b1f: + devices: + - enabled: true + listen_port: 9002 + name: serial0 + running: false + status: "" + label: "65200" + 98bacfe7-5d37-4702-8b00-9c2ffcd81e80: + devices: + - enabled: true + listen_port: 9003 + name: serial0 + running: false + status: "" + label: "65300" + acae0012-6d7f-4b25-8f30-30938593abf4: + devices: + - enabled: true + listen_port: 9001 + name: serial0 + running: false + status: "" + label: "65100" + d44f037a-8082-4aa9-9723-f91607c7e3b7: + devices: + - enabled: true + listen_port: 9000 + name: serial0 + running: false + status: "" + label: "65001" + state: STARTED diff --git a/scripts/breakout-to-wave-terminal/main.py b/scripts/breakout-to-wave-terminal/main.py new file mode 100644 index 0000000..71eef78 --- /dev/null +++ b/scripts/breakout-to-wave-terminal/main.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Create Wave Terminal console blocks for the local CML2 breakout proxy.""" + +from __future__ import annotations + +import argparse +import os +import shlex +import stat +import subprocess +import sys +from pathlib import Path +from typing import Any + +import yaml + +ROOT = Path(__file__).resolve().parent +DEFAULT_CONFIG = ROOT / "config.yaml" +DEFAULT_LABS = ROOT / "labs.yaml" +DEFAULT_LAUNCHER = ROOT / "wave-cml2" + + +def read_yaml(path: Path) -> dict[str, Any]: + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except (OSError, yaml.YAMLError) as exc: + raise RuntimeError(f"Cannot read {path}: {exc}") from exc + if not isinstance(data, dict): + raise RuntimeError(f"{path} must contain a YAML mapping") + return data + + +def select_lab(labs: dict[str, Any], wanted: str | None) -> tuple[str, dict[str, Any]]: + candidates = [] + for lab_id, lab in labs.items(): + if isinstance(lab, dict) and lab.get("enabled"): + if wanted is None or wanted in (lab_id, lab.get("lab_title")): + candidates.append((str(lab_id), lab)) + if not candidates: + hint = f" matching '{wanted}'" if wanted else "" + raise RuntimeError(f"No enabled CML2 lab{hint}. Run './breakout init ' first.") + if len(candidates) > 1: + raise RuntimeError("More than one enabled lab matches; pass its ID or title.") + return candidates[0] + + +def console_nodes(lab: dict[str, Any]) -> list[tuple[str, int]]: + result: list[tuple[str, int]] = [] + for node in (lab.get("nodes") or {}).values(): + if not isinstance(node, dict): + continue + label = str(node.get("label", "unnamed")) + for device in node.get("devices") or []: + if isinstance(device, dict) and device.get("enabled") and device.get("listen_port"): + result.append((label, int(device["listen_port"]))) + if not result: + raise RuntimeError("The selected lab has no enabled serial consoles.") + return sorted(result, key=lambda item: (item[0].casefold(), item[1])) + + +def write_launcher( + path: Path, + title: str, + host: str, + nodes: list[tuple[str, int]], +) -> None: + lines = [ + "#!/usr/bin/env bash", + "# Generated by main.py. Starts CML2 serial consoles in Wave Terminal.", + "set -euo pipefail", + "SCRIPT_DIR=$(cd -- \"$(dirname -- \"${BASH_SOURCE[0]}\")\" && pwd -P)", + f"LAB_TITLE={shlex.quote(title)}", + f"CONSOLE_HOST=${{CONSOLE_HOST:-{shlex.quote(host)}}}", + "BREAKOUT=\"$SCRIPT_DIR/breakout\"", + "BREAKOUT_CONFIG=\"$SCRIPT_DIR/config.yaml\"", + "BREAKOUT_LABS=\"$SCRIPT_DIR/labs.yaml\"", + "connect() {", + " local port=$1", + " if command -v telnet >/dev/null 2>&1; then exec telnet \"$CONSOLE_HOST\" \"$port\"; fi", + " if command -v nc >/dev/null 2>&1; then exec nc \"$CONSOLE_HOST\" \"$port\"; fi", + " if command -v ncat >/dev/null 2>&1; then exec ncat \"$CONSOLE_HOST\" \"$port\"; fi", + " echo 'Install telnet or netcat-openbsd to open CML2 consoles.' >&2; exit 127", + "}", + "if [[ ${1:-} == --connect ]]; then connect \"${2:?missing port}\"; fi", + "command -v wsh >/dev/null 2>&1 || { echo 'Run this script from a Wave Terminal block.' >&2; exit 1; }", + "# Refresh the lab on every normal launch. The guard prevents recursion after", + "# main.py has rebuilt this file with the current CML2 devices and ports.", + "if [[ ${CML2_WAVE_REFRESHED:-} != 1 ]]; then", + " if [[ -z ${BREAKOUT_PASSWORD:-} ]]; then", + " read -r -s -p \"CML2 password for ${BREAKOUT_USERNAME:-admin}: \" BREAKOUT_PASSWORD", + " echo", + " export BREAKOUT_PASSWORD", + " fi", + " echo \"Refreshing active devices for CML2 lab: $LAB_TITLE\"", + " export CML2_WAVE_REFRESHED=1", + " exec python3 \"$SCRIPT_DIR/main.py\" \"$LAB_TITLE\" --init --output \"$SCRIPT_DIR/wave-cml2\" --run", + "fi", + "proxy_ready() {", + " python3 - \"$CONSOLE_HOST\" \"$1\" <<'PY'", + "import socket, sys", + "host, port = sys.argv[1], int(sys.argv[2])", + "try:", + " with socket.create_connection((host, port), timeout=0.25): pass", + "except OSError: sys.exit(1)", + "PY", + "}", + f"FIRST_PORT={nodes[0][1]}", + "if ! proxy_ready \"$FIRST_PORT\"; then", + " echo 'Starting the CML2 breakout proxy in a Wave block...'", + " proxy_command=$(printf 'exec %q -config %q -labs %q -logtostderr run' \"$BREAKOUT\" \"$BREAKOUT_CONFIG\" \"$BREAKOUT_LABS\")", + " wsh run -c \"$proxy_command\"", + " for _ in {1..20}; do proxy_ready \"$FIRST_PORT\" && break; sleep 0.25; done", + " proxy_ready \"$FIRST_PORT\" || { echo 'breakout did not start; check its Wave block and CML2 credentials.' >&2; exit 1; }", + "fi", + "echo \"Opening CML2 consoles for: $LAB_TITLE\"", + ] + for label, port in nodes: + lines.append(f'wsh run -c "\\\"$SCRIPT_DIR/wave-cml2\\\" --connect {port}"') + lines.append(f"echo {shlex.quote(f' {label}: {host}:{port}')}") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("lab", nargs="?", help="enabled CML2 lab ID or title") + parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG) + parser.add_argument("--labs", type=Path, default=DEFAULT_LABS) + parser.add_argument("--output", type=Path, default=DEFAULT_LAUNCHER) + parser.add_argument("--run", action="store_true", help="open all consoles immediately in Wave") + parser.add_argument("--init", action="store_true", help="refresh breakout's labs.yaml before generating") + args = parser.parse_args() + + if args.init: + command = [ + str(ROOT / "breakout"), + "-config", str(args.config), + "-labs", str(args.labs), + "-logtostderr", + "init", + ] + if args.lab: + command.append(args.lab) + subprocess.run(command, check=True) + config = read_yaml(args.config) + labs = read_yaml(args.labs) + _, lab = select_lab(labs, args.lab) + # Keep the configured listener family. In particular, [::1] must not be + # silently changed to 127.0.0.1 because a host may enforce IPv6-only binds. + listen_address = str(config.get("listen_address", "")).strip() + host = "127.0.0.1" if not listen_address else listen_address.strip("[]") + write_launcher( + args.output.resolve(), + str(lab.get("lab_title", "CML2")), + host, + console_nodes(lab), + ) + print(f"Created {args.output} for lab '{lab.get('lab_title')}'.") + print(f"Run {args.output} from Wave Terminal to start breakout and open the consoles.") + if args.run: + if not os.environ.get("WAVETERM_BLOCKID"): + raise RuntimeError("--run must be used from a Wave Terminal block") + subprocess.run([str(args.output.resolve())], check=True) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (RuntimeError, subprocess.CalledProcessError) as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/breakout-to-wave-terminal/wave-cml2 b/scripts/breakout-to-wave-terminal/wave-cml2 new file mode 100755 index 0000000..a02cda3 --- /dev/null +++ b/scripts/breakout-to-wave-terminal/wave-cml2 @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Generated by main.py. Starts CML2 serial consoles in Wave Terminal. +set -euo pipefail +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +LAB_TITLE=INIT +CONSOLE_HOST=${CONSOLE_HOST:-::1} +BREAKOUT="$SCRIPT_DIR/breakout" +BREAKOUT_CONFIG="$SCRIPT_DIR/config.yaml" +BREAKOUT_LABS="$SCRIPT_DIR/labs.yaml" +connect() { + local port=$1 + if command -v telnet >/dev/null 2>&1; then exec telnet "$CONSOLE_HOST" "$port"; fi + if command -v nc >/dev/null 2>&1; then exec nc "$CONSOLE_HOST" "$port"; fi + if command -v ncat >/dev/null 2>&1; then exec ncat "$CONSOLE_HOST" "$port"; fi + echo 'Install telnet or netcat-openbsd to open CML2 consoles.' >&2; exit 127 +} +if [[ ${1:-} == --connect ]]; then connect "${2:?missing port}"; fi +command -v wsh >/dev/null 2>&1 || { echo 'Run this script from a Wave Terminal block.' >&2; exit 1; } +# Refresh the lab on every normal launch. The guard prevents recursion after +# main.py has rebuilt this file with the current CML2 devices and ports. +if [[ ${CML2_WAVE_REFRESHED:-} != 1 ]]; then + if [[ -z ${BREAKOUT_PASSWORD:-} ]]; then + read -r -s -p "CML2 password for ${BREAKOUT_USERNAME:-admin}: " BREAKOUT_PASSWORD + echo + export BREAKOUT_PASSWORD + fi + echo "Refreshing active devices for CML2 lab: $LAB_TITLE" + export CML2_WAVE_REFRESHED=1 + exec python3 "$SCRIPT_DIR/main.py" "$LAB_TITLE" --init --output "$SCRIPT_DIR/wave-cml2" --run +fi +proxy_ready() { + python3 - "$CONSOLE_HOST" "$1" <<'PY' +import socket, sys +host, port = sys.argv[1], int(sys.argv[2]) +try: + with socket.create_connection((host, port), timeout=0.25): pass +except OSError: sys.exit(1) +PY +} +FIRST_PORT=9000 +if ! proxy_ready "$FIRST_PORT"; then + echo 'Starting the CML2 breakout proxy in a Wave block...' + proxy_command=$(printf 'exec %q -config %q -labs %q -logtostderr run' "$BREAKOUT" "$BREAKOUT_CONFIG" "$BREAKOUT_LABS") + wsh run -c "$proxy_command" + for _ in {1..20}; do proxy_ready "$FIRST_PORT" && break; sleep 0.25; done + proxy_ready "$FIRST_PORT" || { echo 'breakout did not start; check its Wave block and CML2 credentials.' >&2; exit 1; } +fi +echo "Opening CML2 consoles for: $LAB_TITLE" +wsh run -c "\"$SCRIPT_DIR/wave-cml2\" --connect 9000" +echo ' 65001: ::1:9000' +wsh run -c "\"$SCRIPT_DIR/wave-cml2\" --connect 9001" +echo ' 65100: ::1:9001' +wsh run -c "\"$SCRIPT_DIR/wave-cml2\" --connect 9002" +echo ' 65200: ::1:9002' +wsh run -c "\"$SCRIPT_DIR/wave-cml2\" --connect 9003" +echo ' 65300: ::1:9003'