#!/usr/bin/env python3 # License: Apache License v2 """Client for the Tactility development server (Modules/development-module).""" import argparse import datetime import getpass import hashlib import json import os import select import signal import socket import ssl import struct import sys import tempfile import threading import time import _device_host DEFAULT_PORT = 6667 PROTOCOL_VERSION = 1 CONFIG_DIRECTORY = os.path.join(os.path.expanduser("~"), ".tactility") CLIENT_KEY_PATH = os.path.join(CONFIG_DIRECTORY, "client.key") CLIENT_CERTIFICATE_PATH = os.path.join(CONFIG_DIRECTORY, "client.crt") KNOWN_HOSTS_PATH = os.path.join(CONFIG_DIRECTORY, "known_hosts") CONNECT_TIMEOUT_SECONDS = 10 # Leaves time for the user to answer the prompt on the device ACCEPT_TIMEOUT_SECONDS = 90 PROMPT_HINT_DELAY_SECONDS = 1 # How long the device may take to answer a request, and to answer an install, which extracts the whole package first REQUEST_TIMEOUT_SECONDS = 10 INSTALL_TIMEOUT_SECONDS = 120 # tt runs this tool as "tt shell" PROGRAM_NAME = "tt shell" if "TACTILITY_SDK_VERSION" in os.environ else "shell.py" FRAME_DATA = 0x01 FRAME_RESIZE = 0x02 FRAME_EXIT = 0x03 FRAME_SYNC_STAT = 0x10 FRAME_SYNC_STAT_RESULT = 0x11 FRAME_SYNC_PUSH = 0x12 FRAME_SYNC_PULL = 0x13 FRAME_DONE = 0x14 FRAME_OK = 0x15 FRAME_FAIL = 0x16 FRAME_APP_INSTALL = 0x20 FRAME_APP_UNINSTALL = 0x21 FRAME_APP_RUN = 0x22 SYNC_FILE_MISSING = 0 SYNC_FILE_DIRECTORY = 2 SYNC_READ_SIZE = 64 * 1024 FRAME_HEADER_SIZE = 3 COMMAND_MAX = 511 FRAME_PAYLOAD_MAX = 1024 def fail(message): print(f"{PROGRAM_NAME}: {message}", file=sys.stderr) sys.exit(1) HIGHLIGHT_START = "\033[38;5;117m" HIGHLIGHT_END = "\033[0m" def use_color(stream): return stream.isatty() and "NO_COLOR" not in os.environ def format_fingerprint(fingerprint, stream=sys.stdout): """Groups the fingerprint in words of 4 and highlights the first 2 words, which the device shows too""" words = [fingerprint[i:i + 4] for i in range(0, len(fingerprint), 4)] if use_color(stream) and len(words) > 2: return f"{HIGHLIGHT_START}{' '.join(words[:2])}{HIGHLIGHT_END} {' '.join(words[2:])}" return " ".join(words) def certificate_fingerprint(der): return hashlib.sha256(der).hexdigest() # region Identity def ensure_client_identity(): """Creates the self-signed client certificate that identifies this computer to devices""" if os.path.isfile(CLIENT_KEY_PATH) and os.path.isfile(CLIENT_CERTIFICATE_PATH): return try: from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec from cryptography.x509.oid import NameOID except ImportError: fail("the 'cryptography' package is required: pip install cryptography") os.makedirs(CONFIG_DIRECTORY, exist_ok=True) key = ec.generate_private_key(ec.SECP256R1()) name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, client_name())]) now = datetime.datetime.now(datetime.timezone.utc) certificate = ( x509.CertificateBuilder() .subject_name(name) .issuer_name(name) .public_key(key.public_key()) .serial_number(x509.random_serial_number()) .not_valid_before(now - datetime.timedelta(days=1)) .not_valid_after(now + datetime.timedelta(days=365 * 50)) .sign(key, hashes.SHA256()) ) key_bytes = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()) descriptor = os.open(CLIENT_KEY_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(descriptor, "wb") as file: file.write(key_bytes) with open(CLIENT_CERTIFICATE_PATH, "wb") as file: file.write(certificate.public_bytes(serialization.Encoding.PEM)) print(f"Created client certificate in {CONFIG_DIRECTORY}") def client_name(): return f"{getpass.getuser()}@{socket.gethostname()}" def client_fingerprint(): with open(CLIENT_CERTIFICATE_PATH, "rb") as file: der = ssl.PEM_cert_to_DER_cert(file.read().decode("ascii")) return certificate_fingerprint(der) # endregion # region Known hosts def read_known_hosts(): hosts = {} if os.path.isfile(KNOWN_HOSTS_PATH): with open(KNOWN_HOSTS_PATH) as file: for line in file: parts = line.split() if len(parts) == 2: hosts[parts[0]] = parts[1] return hosts def verify_device(host, port, fingerprint): """Trust on first use: remembers the device fingerprint and refuses a device that changed it. @return True when the user was asked to trust the device""" address = f"{host}:{port}" known = read_known_hosts().get(address) if known == fingerprint: return False if known is not None: fail( f"the fingerprint of {address} changed!\n" f" expected: {format_fingerprint(known, sys.stderr)}\n" f" received: {format_fingerprint(fingerprint, sys.stderr)}\n" f"This could mean that someone intercepts the connection, or that the device was reset.\n" f"If you trust the device, remove {address} from {KNOWN_HOSTS_PATH}" ) print(f"The authenticity of {address} can't be established.") print(f"Device fingerprint: {format_fingerprint(fingerprint)}") print("Compare it with the fingerprint in the Development app on the device.") try: answer = input("Continue connecting? (y/N) ").strip().lower() except EOFError: print(flush=True) fail(f"{address} is not a known device yet: run 'tt shell' in a terminal once to verify and trust its fingerprint") if answer not in ("y", "yes"): fail("aborted") os.makedirs(CONFIG_DIRECTORY, exist_ok=True) with open(KNOWN_HOSTS_PATH, "a") as file: file.write(f"{address} {fingerprint}\n") return True # endregion # region Connection # A device that disappears without closing the connection (power loss, Wi-Fi drop) is noticed after # about KEEPALIVE_IDLE_SECONDS + KEEPALIVE_INTERVAL_SECONDS * KEEPALIVE_PROBES when the connection is idle, # and after USER_TIMEOUT_SECONDS when sent data isn't acknowledged KEEPALIVE_IDLE_SECONDS = 5 KEEPALIVE_INTERVAL_SECONDS = 3 KEEPALIVE_PROBES = 3 USER_TIMEOUT_SECONDS = 15 def enable_keepalive(raw_socket): raw_socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) if os.name == "nt": raw_socket.ioctl(socket.SIO_KEEPALIVE_VALS, (1, KEEPALIVE_IDLE_SECONDS * 1000, KEEPALIVE_INTERVAL_SECONDS * 1000)) return # Linux names the idle time TCP_KEEPIDLE, macOS TCP_KEEPALIVE idle_option = getattr(socket, "TCP_KEEPIDLE", None) or getattr(socket, "TCP_KEEPALIVE", None) options = [ (idle_option, KEEPALIVE_IDLE_SECONDS), (getattr(socket, "TCP_KEEPINTVL", None), KEEPALIVE_INTERVAL_SECONDS), (getattr(socket, "TCP_KEEPCNT", None), KEEPALIVE_PROBES), (getattr(socket, "TCP_USER_TIMEOUT", None), USER_TIMEOUT_SECONDS * 1000), ] for option, value in options: if option is not None: try: raw_socket.setsockopt(socket.IPPROTO_TCP, option, value) except OSError: pass class Connection: def __init__(self, host, port): context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) # Devices have self-signed certificates: verify_device() pins them instead context.check_hostname = False context.verify_mode = ssl.CERT_NONE context.load_cert_chain(CLIENT_CERTIFICATE_PATH, CLIENT_KEY_PATH) try: raw_socket = socket.create_connection((host, port), timeout=CONNECT_TIMEOUT_SECONDS) enable_keepalive(raw_socket) self.socket = context.wrap_socket(raw_socket) except (OSError, ssl.SSLError) as error: fail(f"failed to connect to {host}:{port}: {error}") self.buffer = b"" self.fingerprint = certificate_fingerprint(self.socket.getpeercert(binary_form=True)) def send(self, data): try: self.socket.sendall(data) except (OSError, ssl.SSLError): fail("the device closed the connection") def receive(self): """@return False when the connection closed. Raises socket.timeout when the socket timeout passes.""" try: data = self.socket.recv(4096) except socket.timeout: raise except (OSError, ssl.SSLError): return False if not data: return False self.buffer += data return True def read_line(self, timeout): self.socket.settimeout(timeout) try: while b"\n" not in self.buffer: if not self.receive(): return None except socket.timeout: return None line, self.buffer = self.buffer.split(b"\n", 1) return line.decode("utf-8", errors="replace") def send_frame(self, frame_type, payload=b""): for offset in range(0, max(len(payload), 1), FRAME_PAYLOAD_MAX): chunk = payload[offset:offset + FRAME_PAYLOAD_MAX] self.send(struct.pack(">BH", frame_type, len(chunk)) + chunk) def pop_frame(self): """@return (type, payload) or None when no complete frame was received yet""" if len(self.buffer) < FRAME_HEADER_SIZE: return None frame_type, length = struct.unpack(">BH", self.buffer[:FRAME_HEADER_SIZE]) if len(self.buffer) < FRAME_HEADER_SIZE + length: return None payload = self.buffer[FRAME_HEADER_SIZE:FRAME_HEADER_SIZE + length] self.buffer = self.buffer[FRAME_HEADER_SIZE + length:] return frame_type, payload def read_frame(self, timeout=REQUEST_TIMEOUT_SECONDS): """Waits for a frame. @return (type, payload)""" self.socket.settimeout(timeout) try: while (frame := self.pop_frame()) is None: if not self.receive(): fail("the device closed the connection") except socket.timeout: fail(f"the device did not answer within {timeout} seconds") return frame def close(self): try: self.socket.close() except OSError: pass def open_session(host, port, mode, columns=80, rows=24, command=None): ensure_client_identity() connection = Connection(host, port) if verify_device(host, port, connection.fingerprint): # The device closes a connection that doesn't greet it in time, which answering the question can take longer than connection.close() connection = Connection(host, port) verify_device(host, port, connection.fingerprint) hello = {"protocol": PROTOCOL_VERSION, "mode": mode, "client": client_name(), "columns": columns, "rows": rows} if command is not None: hello["command"] = command connection.send((json.dumps(hello, separators=(",", ":")) + "\n").encode("utf-8")) # A trusted client is answered right away, otherwise the device asks the user line = connection.read_line(PROMPT_HINT_DELAY_SECONDS) if line is None: print(f"Client fingerprint: {format_fingerprint(client_fingerprint())}", flush=True) print("Waiting for the device to accept the connection...", flush=True) line = connection.read_line(ACCEPT_TIMEOUT_SECONDS) if line is None: fail("the device closed the connection or did not answer") try: status = json.loads(line) except json.JSONDecodeError: fail(f"unexpected answer: {line}") if status.get("status") == "rejected": fail("the device rejected the connection") if status.get("status") != "accepted": fail(f"the device refused the connection: {status.get('message', line)}") connection.socket.settimeout(REQUEST_TIMEOUT_SECONDS) return connection # endregion # region Shell def terminal_size(): try: size = os.get_terminal_size(sys.stdout.fileno()) return size.columns, size.lines except OSError: return 80, 24 def write_output(data, previous_was_cr): """The device shell emits bare line feeds, which a raw terminal doesn't return the cursor for""" output = bytearray() for byte in data: if byte == 0x0A and not previous_was_cr: output += b"\r" output.append(byte) previous_was_cr = byte == 0x0D sys.stdout.buffer.write(bytes(output)) sys.stdout.buffer.flush() return previous_was_cr class RawTerminal: """Sends every key to the device instead of letting the terminal edit lines, and lets it show escape sequences""" def __init__(self, enabled): self.enabled = enabled self.restore = None def __enter__(self): if self.enabled: self.restore = enter_windows_raw_mode() if os.name == "nt" else enter_posix_raw_mode() return self def __exit__(self, *_): if self.restore is not None: self.restore() def enter_posix_raw_mode(): import termios import tty stdin = sys.stdin.fileno() saved_attributes = termios.tcgetattr(stdin) tty.setraw(stdin) return lambda: termios.tcsetattr(stdin, termios.TCSADRAIN, saved_attributes) def enter_windows_raw_mode(): import ctypes kernel32 = ctypes.windll.kernel32 enable_processed_input, enable_line_input, enable_echo_input = 0x0001, 0x0002, 0x0004 enable_virtual_terminal_input = 0x0200 enable_virtual_terminal_processing = 0x0004 utf8_code_page = 65001 input_handle = kernel32.GetStdHandle(-10) output_handle = kernel32.GetStdHandle(-11) input_mode = ctypes.c_uint32() output_mode = ctypes.c_uint32() kernel32.GetConsoleMode(input_handle, ctypes.byref(input_mode)) kernel32.GetConsoleMode(output_handle, ctypes.byref(output_mode)) input_code_page = kernel32.GetConsoleCP() # Keys arrive as the same escape sequences as on Linux and macOS, Ctrl+C included raw_input_mode = (input_mode.value & ~(enable_processed_input | enable_line_input | enable_echo_input)) | enable_virtual_terminal_input kernel32.SetConsoleMode(input_handle, raw_input_mode) kernel32.SetConsoleMode(output_handle, output_mode.value | enable_virtual_terminal_processing) kernel32.SetConsoleCP(utf8_code_page) def restore(): kernel32.SetConsoleMode(input_handle, input_mode.value) kernel32.SetConsoleMode(output_handle, output_mode.value) kernel32.SetConsoleCP(input_code_page) return restore def start_input_reader(wake_socket): """Reads stdin on a thread, because select() can't wait for it on Windows. None in the queue means end of input.""" import queue inputs = queue.Queue() def read_input(): stdin = sys.stdin.fileno() while True: try: data = os.read(stdin, FRAME_PAYLOAD_MAX) except OSError: data = b"" inputs.put(data or None) try: wake_socket.send(b"i") except BlockingIOError: pass except OSError: return if not data: return threading.Thread(target=read_input, daemon=True).start() return inputs def run_shell(args): """Opens an interactive shell, or runs a command like ssh does when one is given""" command = " ".join(args.command) if args.command else None if command is not None and len(command.encode("utf-8")) > COMMAND_MAX: fail(f"the command is longer than {COMMAND_MAX} bytes") columns, rows = terminal_size() connection = open_session(args.host, args.port, "shell", columns, rows, command) # A shell or command can be silent for as long as it likes connection.socket.settimeout(None) # Input and resizes wake up the select() below through this socket pair wake_read, wake_write = socket.socketpair() # A full buffer already wakes up the loop, and a signal handler must never block wake_write.setblocking(False) has_resize_signal = hasattr(signal, "SIGWINCH") previous_resize_handler = None if has_resize_signal: def on_resize(*_): try: wake_write.send(b"r") except OSError: pass previous_resize_handler = signal.signal(signal.SIGWINCH, on_resize) # Without a resize signal, the window size is checked this often resize_poll_seconds = None if has_resize_signal else 0.5 window_size = (columns, rows) # A command gets its input as is, an interactive shell gets every key is_terminal = os.isatty(sys.stdin.fileno()) and command is None exit_code = None previous_was_cr = False try: with RawTerminal(is_terminal): inputs = start_input_reader(wake_write) while exit_code is None: # Already decrypted data doesn't make the socket readable timeout = 0 if connection.socket.pending() > 0 else resize_poll_seconds readable, _, _ = select.select([connection.socket, wake_read], [], [], timeout) if wake_read in readable: wake_read.recv(256) while not inputs.empty(): data = inputs.get() if data is not None: connection.send_frame(FRAME_DATA, data) if terminal_size() != window_size: window_size = terminal_size() connection.send_frame(FRAME_RESIZE, struct.pack(">HH", *window_size)) if connection.socket in readable or connection.socket.pending() > 0: if not connection.receive(): break while (frame := connection.pop_frame()) is not None: frame_type, payload = frame if frame_type == FRAME_DATA: previous_was_cr = write_output(payload, previous_was_cr) elif frame_type == FRAME_EXIT and len(payload) == 4: exit_code = struct.unpack(">i", payload)[0] finally: if has_resize_signal: signal.signal(signal.SIGWINCH, previous_resize_handler) connection.close() wake_write.close() wake_read.close() if exit_code is None: print("\nConnection closed", file=sys.stderr) return 1 return exit_code # endregion # region Sync def check_remote_path(path): if not path.startswith("/"): fail(f"remote path must be absolute: {path}") def remote_stat(connection, path): """@return (file type, size)""" connection.send_frame(FRAME_SYNC_STAT, path.encode("utf-8")) frame_type, payload = connection.read_frame() if frame_type == FRAME_FAIL: fail(f"{path}: {payload.decode('utf-8', errors='replace')}") if frame_type != FRAME_SYNC_STAT_RESULT or len(payload) != 9: fail("unexpected answer from the device") return payload[0], struct.unpack(">Q", payload[1:])[0] def send_file(connection, request_type, name, local_path): """Sends a request that is followed by the file contents. @return the file size""" size = 0 connection.send_frame(request_type, name.encode("utf-8")) with open(local_path, "rb") as file: while data := file.read(SYNC_READ_SIZE): connection.send_frame(FRAME_DATA, data) size += len(data) connection.send_frame(FRAME_DONE) return size def expect_ok(connection, subject, timeout=REQUEST_TIMEOUT_SECONDS): frame_type, payload = connection.read_frame(timeout) if frame_type == FRAME_FAIL: fail(f"{subject}: {payload.decode('utf-8', errors='replace')}") if frame_type != FRAME_OK: fail("unexpected answer from the device") def print_transfer(description, size, start): duration = max(time.monotonic() - start, 0.001) print(f"{description}: {size / duration / 1024:.1f} KB/s ({size} bytes in {duration:.3f}s)") def run_push(args): check_remote_path(args.remote_path) if os.path.isdir(args.local_path): fail(f"{args.local_path}: directories are not supported") if not os.path.isfile(args.local_path): fail(f"{args.local_path}: no such file") connection = open_session(args.host, args.port, "sync") remote_path = args.remote_path file_type, _ = remote_stat(connection, remote_path) if file_type == SYNC_FILE_DIRECTORY or remote_path.endswith("/"): remote_path = remote_path.rstrip("/") + "/" + os.path.basename(args.local_path) start = time.monotonic() size = send_file(connection, FRAME_SYNC_PUSH, remote_path, args.local_path) expect_ok(connection, remote_path) connection.close() print_transfer(f"{args.local_path} -> {remote_path}", size, start) return 0 def run_pull(args): check_remote_path(args.remote_path) connection = open_session(args.host, args.port, "sync") file_type, _ = remote_stat(connection, args.remote_path) if file_type == SYNC_FILE_MISSING: fail(f"{args.remote_path}: no such file") if file_type == SYNC_FILE_DIRECTORY: fail(f"{args.remote_path}: directories are not supported") local_path = args.local_path if os.path.isdir(local_path): local_path = os.path.join(local_path, os.path.basename(args.remote_path)) start = time.monotonic() size = 0 connection.send_frame(FRAME_SYNC_PULL, args.remote_path.encode("utf-8")) # Leaves an existing file untouched when the transfer fails descriptor, temporary_path = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(local_path)), prefix=".tt-pull-") try: with os.fdopen(descriptor, "wb") as file: while True: frame_type, payload = connection.read_frame() if frame_type == FRAME_DATA: file.write(payload) size += len(payload) elif frame_type == FRAME_DONE: break elif frame_type == FRAME_FAIL: fail(f"{args.remote_path}: {payload.decode('utf-8', errors='replace')}") else: fail("unexpected answer from the device") os.replace(temporary_path, local_path) finally: if os.path.exists(temporary_path): os.remove(temporary_path) connection.close() print_transfer(f"{args.remote_path} -> {local_path}", size, start) return 0 # endregion # region App def install(host, port, package_path): """Installs an app package (.app file) on the device. Exits on failure.""" if not os.path.isfile(package_path): fail(f"{package_path}: no such file") connection = open_session(host, port, "app") try: send_file(connection, FRAME_APP_INSTALL, os.path.basename(package_path), package_path) expect_ok(connection, package_path, INSTALL_TIMEOUT_SECONDS) finally: connection.close() return True def app_request(host, port, request_type, app_id): connection = open_session(host, port, "app") try: connection.send_frame(request_type, app_id.encode("utf-8")) expect_ok(connection, app_id) finally: connection.close() return True def uninstall(host, port, package_id): """Uninstalls a package from the device. Succeeds when it wasn't installed. Exits on failure.""" return app_request(host, port, FRAME_APP_UNINSTALL, package_id) def run(host, port, app_id): """Starts an app on the device, or restarts it when it's in the foreground. Exits on failure.""" return app_request(host, port, FRAME_APP_RUN, app_id) def add_connection_arguments(parser): """Adds --help, --host and --port: -h is the host, so the help can't use it""" parser.add_argument("--help", action="help", help="show this help message and exit") parser.add_argument("-h", "--host", help="device hostname or IP address (default: the host of 'tt host')") parser.add_argument("-p", "--port", type=int, default=DEFAULT_PORT, help=f"device port (default: {DEFAULT_PORT})") def parse_arguments(parser): """Parses the arguments of add_connection_arguments(), using the stored device host when --host is missing""" args = parser.parse_args() if args.host is None: args.host = _device_host.read_device_host() if args.host is None: fail(_device_host.MISSING_HOST_MESSAGE) return args def add_push_arguments(parser): parser.add_argument("local_path", help="file on this computer") parser.add_argument("remote_path", help="absolute file or directory path on the device") def add_pull_arguments(parser): parser.add_argument("remote_path", help="absolute file path on the device") parser.add_argument("local_path", nargs="?", default=".", help="file or directory on this computer (default: current directory)") def main(): parser = argparse.ArgumentParser(prog=PROGRAM_NAME, description="open a shell on the device, or run a command in it", add_help=False) add_connection_arguments(parser) parser.add_argument("command", nargs=argparse.REMAINDER, help="command to run instead of an interactive shell") sys.exit(run_shell(parse_arguments(parser))) if __name__ == "__main__": import _bootstrap _bootstrap.run() main()