2026-10-16 00:54:13 +02:00

695 lines
26 KiB
Python
Executable File

#!/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
def run_command(host, port, command, timeout=REQUEST_TIMEOUT_SECONDS):
"""Runs a command without a terminal. @return (exit code, output text)"""
connection = open_session(host, port, "shell", command=command)
output = bytearray()
try:
while True:
frame_type, payload = connection.read_frame(timeout)
if frame_type == FRAME_DATA:
output += payload
elif frame_type == FRAME_EXIT and len(payload) == 4:
return struct.unpack(">i", payload)[0], output.decode("utf-8", errors="replace").replace("\r", "")
finally:
connection.close()
# 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()