#!/usr/bin/env python3 """ The VLAB client script. Example usage: ./vlab.py -k keyfile.vlabkey -b an_fpga_board The script will assume your username is the same as the system user. To specify a different username use the -u option. The VLAB server and port are given as defaults to the -r and -p arguments so the user should not have to specify them, but they can be overridden if required. Ian Gray, 2017-2026 """ import argparse import os import shlex import signal import socket import sys import time import urllib.error import urllib.request from subprocess import DEVNULL, PIPE, Popen from typing import NoReturn ############################ # Update version string here and in 'current_version' file when updating this script # Version number must be in 'x.y.z' format current_version = "1.3.0" current_branch = "master" ############################ def err(error_string) -> NoReturn: print(error_string) sys.exit(1) parser = argparse.ArgumentParser(description="VLAB client script") parser.add_argument( "-r", "--relay", nargs=1, default=["rts001.cs.york.ac.uk"], help="The hostname of the relay server.", ) parser.add_argument( "-p", "--port", nargs=1, default=["2222"], help="The ssh port of the relay server to connect to.", ) parser.add_argument( "-l", "--localport", nargs=1, default=["12345"], help="Local port to forward connections to.", ) parser.add_argument( "-k", "--key", nargs=1, help="VLAB keyfile to use for authentication." ) parser.add_argument("-u", "--user", nargs=1, help="VLAB username.") parser.add_argument( "-b", "--board", nargs=1, default=["vlab_zybo-z7"], help="Requested board class." ) parser.add_argument("-s", "--serial", nargs=1, help="Requested board serial number.") parser.add_argument( "-v", "--verbose", default=False, action="store_true", help="Enable verbose logging.", ) parser.add_argument( "-c", "--capture", default=False, action="store_true", help="Capture the FPGA framebuffer and save as JPEG.", ) parser.add_argument( "-o", "--output", nargs=1, help="Output filename for capture (default: capture_YYYYMMDD_HHMMSS.jpg)", ) parser.add_argument( "--vdma", nargs=1, help="Base address of the framebuffer VDMA peripheral for --capture, e.g. 0x43000000 " "(default: 0x43000000).", ) parser.add_argument( "-n", "--no-terminal", default=False, action="store_true", help="Hold the board and the port forward open without starting a terminal. " "Leaves the board's serial port free for --capture-serial. For scripting.", ) parser.add_argument( "--capture-serial", nargs=1, metavar="SECONDS", help="Capture the board's serial output for the given number of seconds and write " "it to stdout, or to --output if given. Needs a session started with " "--no-terminal.", ) parsed = parser.parse_args() error_info = ( "Read the instructions at\n" "\thttps://iangray001.github.io/embs/docs/setup/vlab-quickstart/" ) # Check for an update from GitHub repository update_url = "https://raw.githubusercontent.com/RTSYork/VLAB/{}/client_version".format( current_branch ) if parsed.verbose: print("Checking for update at URL: {}".format(update_url)) req = urllib.request.Request(update_url) try: response = urllib.request.urlopen(req) content = response.readline() remote_version_string = content.decode("utf-8") remote_version = tuple(map(int, (remote_version_string.split(".")))) local_version = tuple(map(int, (current_version.split(".")))) if local_version < remote_version: print("A new version of the VLAB script is available on GitHub") print( "You have version v{} and the latest is v{}".format( current_version, remote_version_string ) ) print( "Download the latest version from https://raw.githubusercontent.com/RTSYork/VLAB/{}/vlab.py".format( current_branch ) ) print() try: input("Press Control-C to exit, or press Enter to continue...") except KeyboardInterrupt: sys.exit(0) except urllib.error.URLError as e: print("Error checking for script updates:", e.reason) # Check the keyfile exists if not parsed.key: err("Specify a keyfile with --key.") if not os.path.isfile(parsed.key[0]): err( "Keyfile {} does not exist. Specify a keyfile with --key.".format(parsed.key[0]) ) # Handle capture mode if parsed.capture: ssh_cmd = [ "ssh", "-oPasswordAuthentication=no", "-i", parsed.key[0], "-p", parsed.port[0], ] if parsed.user is not None: ssh_cmd.extend(["-l", parsed.user[0]]) # Encode an optional VDMA base address into the relay command token (capture[:vdma]), # mirroring the boardclass:port:serial form used for board sessions. The relay validates # the value before use; omitting it lets the boardserver fall back to its built-in default. if parsed.vdma is not None: ssh_cmd.extend([parsed.relay[0], "capture:{}".format(parsed.vdma[0])]) else: ssh_cmd.extend([parsed.relay[0], "capture"]) if parsed.verbose: print("Capture ssh command: {}".format(ssh_cmd)) print("Requesting framebuffer capture...") proc = Popen(ssh_cmd, stdout=PIPE) stdout, _ = proc.communicate() if proc.returncode != 0 or len(stdout) == 0: if len(stdout) > 0: print(stdout.decode("utf-8", errors="replace").strip()) else: print("Capture failed.") sys.exit(1) # Verify JPEG magic bytes if len(stdout) < 2 or stdout[:2] != b"\xff\xd8": print(stdout.decode("utf-8", errors="replace").strip()) sys.exit(1) if parsed.output: filename = parsed.output[0] else: filename = "capture_{}.jpg".format(time.strftime("%Y%m%d_%H%M%S")) with open(filename, "wb") as f: f.write(stdout) print("Saved to {} ({} bytes)".format(filename, len(stdout))) sys.exit(0) # Handle serial capture mode. Like --capture this is a one-shot command against a # session the user already holds, so it needs no port forward of its own. if parsed.capture_serial: seconds = parsed.capture_serial[0] if not seconds.isdigit() or int(seconds) < 1: err("--capture-serial takes a whole number of seconds.") ssh_cmd = [ "ssh", "-oPasswordAuthentication=no", "-i", parsed.key[0], "-p", parsed.port[0], ] if parsed.user is not None: ssh_cmd.extend(["-l", parsed.user[0]]) ssh_cmd.extend([parsed.relay[0], "serial:{}".format(seconds)]) if parsed.verbose: print("Serial capture ssh command: {}".format(ssh_cmd), file=sys.stderr) # Only the captured bytes go to stdout, so that the caller can redirect them. # The relay writes its own progress and errors to stderr, which passes through. proc = Popen(ssh_cmd, stdout=PIPE) captured, _ = proc.communicate() if proc.returncode != 0: sys.exit(proc.returncode) if parsed.output: with open(parsed.output[0], "wb") as f: f.write(captured) print( "Saved {} bytes to {}".format(len(captured), parsed.output[0]), file=sys.stderr, ) else: sys.stdout.buffer.write(captured) sys.stdout.buffer.flush() sys.exit(0) # Check that the requested ports are free to use local_port = int(parsed.localport[0]) with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: local_port_in_use = s.connect_ex(("localhost", local_port)) == 0 if local_port_in_use: err( "Error: Local port {} is in use by another user on this machine, or otherwise unavailable. " "Specify a different port with --localport or -l.\n" "\nEach user must choose their own unique local port when running on a shared machine (e.g. a departmental " "server).\nSee the section on 'Using the VLAB on a Shared Machine' at " "https://wiki.york.ac.uk/display/RTS/The+VLAB+Quickstart+Guide for more information.".format( local_port ) ) # First get a port to use on the relay server if parsed.user is not None: ssh_cmd = [ "ssh", "-oPasswordAuthentication=no", "-i", parsed.key[0], "-p", parsed.port[0], "-l", parsed.user[0], parsed.relay[0], "getport", ] else: ssh_cmd = [ "ssh", "-oPasswordAuthentication=no", "-i", parsed.key[0], "-p", parsed.port[0], parsed.relay[0], "getport", ] if parsed.verbose: print("First ssh command: {}".format(ssh_cmd)) stdout, stderr = Popen(ssh_cmd, stdout=PIPE, stderr=PIPE).communicate() try: error = stderr.decode("ascii").strip() except UnicodeError: error = "" reply = None err("Could not decode error output from ssh subprocess") if len(stdout) == 0 and len(error) > 0: errstr = "" if error.find("UNPROTECTED PRIVATE KEY FILE") != -1: errstr += "The permissions on the key {} are too permissive.\n".format( parsed.key[0] ) errstr += "Only your user should be able to access the key, or it will be rejected by ssh.\n" errstr += "To fix this, on Linux run the command\n\tchmod 0600 {}\n".format( parsed.key[0] ) errstr += "On Windows, see the instructions at the link below.\n" elif ( error.find("Operation timed out") != -1 or error.find("Connection refused") != -1 ): errstr += "Cannot see the VLAB relay server: {}\n".format(parsed.relay[0]) errstr += "Try the following:\n\tssh {}\n".format(parsed.relay[0]) errstr += "You should get a password prompt:\n\t> @{}'s password\n".format( parsed.relay[0] ) errstr += "If this doesn't connect, or the prompt is for any other server, set up your .ssh/config file\n" elif error.find("Permission denied") != -1: errstr += error errstr += "\nEnsure that you have copied your private key:\n" errstr += "\tssh-copy-id csteach1.york.ac.uk\n\t(or csresearch1.york.ac.uk for staff)\n" errstr += "Also, if your VLAB username is different to your normal username, set it with -u:\n" errstr += "\t./vlab.py -k {} -u myusername\n".format(parsed.key[0]) elif error.find("Resource temporarily unavailable") != -1: errstr += error errstr += ( "\nThis often means a firewall has blocked the connection. Try temporarily disabling your firewall " "application.\n" ) elif error.find("Connection closed by") != -1: errstr += error errstr += ( "\nEnsure that you have set the correct usernames in your ssh config.\n" ) elif error.find("REMOTE HOST IDENTIFICATION HAS CHANGED") != -1: errstr += error errstr += ( "\n\n\nThe host key in the VLAB has changed. This should not happen and might indicate a " "man-in-the-middle attack.\n" ) errstr += "If you are sure this is not the case, delete the line mentioned above from the file ~/.ssh/known_hosts\n" else: print(error) errstr += error_info err(errstr) try: reply = stdout.decode("ascii").strip() except UnicodeError: reply = None err( "Invalid reply from VLAB server. Incorrect message format.\n{}".format( error_info ) ) if len(reply) > 9: if reply[:9] == "VLABPORT:": port_string = reply[9:] ephemeral_port = int(port_string) else: ephemeral_port = None err("Invalid reply from VLAB server. Wrong header.\n{}".format(error_info)) else: ephemeral_port = None err("Invalid reply from VLAB server. Message too short.\n{}".format(error_info)) print( "Tunnelling to relay server '{}' using port {}...".format( parsed.relay[0], ephemeral_port ) ) # Now create the actual connection ssh_user = "" if parsed.user is not None: ssh_user = " -l {} ".format(parsed.user[0]) # Assemble the command we send to the relay shell if parsed.serial is not None: relay_command = "{}:{}:{}".format(parsed.board[0], ephemeral_port, parsed.serial[0]) else: relay_command = "{}:{}".format(parsed.board[0], ephemeral_port) # Ask the relay to allocate and tunnel as usual but not to start a terminal, so # that the board's serial port stays free for --capture-serial. if parsed.no_terminal: relay_command = "noterm:{}".format(relay_command) ssh_cmd = ( "ssh -L {}:localhost:{} -o PasswordAuthentication=no -o ExitOnForwardFailure=yes " "-e none -i {} {} -p {} -tt {} {}".format( local_port, ephemeral_port, parsed.key[0], ssh_user, parsed.port[0], parsed.relay[0], relay_command, ) ) if parsed.verbose: print("Second ssh command: {}".format(ssh_cmd)) if not parsed.no_terminal: os.system(ssh_cmd) sys.exit(0) # Not shell=True: the board is released when ssh disconnects, so this process must # be able to signal ssh itself rather than an intervening /bin/sh. proc = Popen( shlex.split(ssh_cmd), stdin=DEVNULL, stdout=PIPE, bufsize=1, universal_newlines=True ) if proc.stdout is None: err("Failed to capture stdout from SSH subprocess.") def end_session(signum=None, frame=None): _ = (signum, frame) # discard the parameters to avoid unused error if proc.poll() is None: proc.terminate() # A script will stop this with SIGTERM, and a closing terminal sends SIGHUP. # Either way the board must be handed back rather than left locked. for _sig in (signal.SIGTERM, signal.SIGHUP): signal.signal(_sig, end_session) ready = False try: for line in proc.stdout: line = line.rstrip() if not ready and line.endswith("VLAB_SESSION_READY"): ready = True print("VLAB_READY:{}".format(local_port), flush=True) print( "Board ready. hw_server is forwarded to localhost:{}.".format( local_port ), flush=True, ) print( "Leave this running while you use the board; the lock is released when it exits.", flush=True, ) continue if line: print(line, flush=True) except KeyboardInterrupt: pass finally: end_session() try: proc.wait(timeout=30) except Exception: proc.kill() proc.wait() if not ready: err("The board session never became ready.") sys.exit(proc.returncode)