Files
Termind/Scripts/ssh-authentication-fixture.py
T

450 lines
17 KiB
Python

#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Kaede Akino
# SPDX-License-Identifier: GPL-3.0-only
"""Loopback-only SSH authentication fixture; never executes client commands.
Run with a temporary virtual environment containing paramiko==4.0.0:
python Scripts/ssh-authentication-fixture.py --metadata /tmp/ssh-fixture.json
Pass that path to tests as TEST_RUNNER_TERMIND_SSH_FIXTURE. Keys are generated
in memory; the metadata file is removed when this process exits.
"""
import argparse
import base64
import hashlib
import io
import json
import os
import select
import signal
import socket
import threading
import time
import paramiko
class Approvals:
def __init__(self):
self.lock = threading.Lock()
self.pending = {}
@staticmethod
def banner(transport, username):
message = paramiko.Message()
message.add_byte(paramiko.common.cMSG_USERAUTH_BANNER)
message.add_string("Tailscale SSH requires approval.\nhttps://login.example/ssh/" + username)
message.add_string("en")
transport._send_message(message)
def wait(self, transport, username):
state = {"event": threading.Event(), "accepted": False, "transport": transport}
with self.lock:
self.pending[username] = state
try:
self.banner(transport, username)
while transport.is_active() and not state["event"].wait(0.1):
pass
return paramiko.AUTH_SUCCESSFUL if state["accepted"] else paramiko.AUTH_FAILED
finally:
with self.lock:
self.pending.pop(username, None)
def serve(self, listener):
while True:
try:
peer, _ = listener.accept()
except OSError:
return
with peer:
peer.settimeout(5)
try:
command = b""
while not command.endswith(b"\n") and len(command) < 1024:
data = peer.recv(1024)
if not data:
break
command += data
action, username = command.decode("ascii").strip().split(" ", 1)
with self.lock:
state = self.pending.get(username)
if state is None:
peer.sendall(b"missing\n")
continue
if action == "repeat":
self.banner(state["transport"], username)
elif action in ("accept", "reject", "disconnect"):
state["accepted"] = action == "accept"
if action == "disconnect":
state["transport"].close()
state["event"].set()
else:
raise ValueError("Unknown fixture action")
peer.sendall(b"ok\n")
except (OSError, ValueError, EOFError, paramiko.SSHException):
pass
class MemoryFile(paramiko.SFTPHandle):
def __init__(self, data):
super().__init__()
self.data = data
def read(self, offset, length):
return bytes(self.data[offset:offset + length])
def write(self, offset, data):
self.data[offset:offset + len(data)] = data
return paramiko.SFTP_OK
def stat(self):
return Files.attributes(self.data)
class Files(paramiko.SFTPServerInterface):
# Per-connection test data; never touches the local file system.
def __init__(self, server, *args, **kwargs):
super().__init__(server, *args, **kwargs)
self.files = {"/fixture.txt": bytearray(b"fixture\n")}
@staticmethod
def attributes(data):
item = paramiko.SFTPAttributes()
item.st_mode = 0o100644
item.st_size = len(data)
return item
def canonicalize(self, path):
return "/" if path == "." else path
def list_folder(self, path):
result = []
for name, data in self.files.items():
item = self.attributes(data)
item.filename = name.rsplit("/", 1)[-1]
result.append(item)
return result
def stat(self, path):
if path not in self.files:
return paramiko.SFTP_NO_SUCH_FILE
return self.attributes(self.files[path])
lstat = stat
def open(self, path, flags, attr):
if path not in self.files:
if not flags & os.O_CREAT:
return paramiko.SFTP_NO_SUCH_FILE
self.files[path] = bytearray()
elif flags & os.O_EXCL:
return paramiko.SFTP_FAILURE
if flags & os.O_TRUNC:
self.files[path].clear()
return MemoryFile(self.files[path])
def remove(self, path):
if path not in self.files:
return paramiko.SFTP_NO_SUCH_FILE
del self.files[path]
return paramiko.SFTP_OK
def rename(self, source, destination):
if source not in self.files:
return paramiko.SFTP_NO_SUCH_FILE
if destination in self.files:
return paramiko.SFTP_FAILURE
self.files[destination] = self.files.pop(source)
return paramiko.SFTP_OK
def posix_rename(self, source, destination):
if source not in self.files:
return paramiko.SFTP_NO_SUCH_FILE
self.files[destination] = self.files.pop(source)
return paramiko.SFTP_OK
class Authentication(paramiko.ServerInterface):
def __init__(self, client_key, approvals=None):
self.approvals = approvals
self.listeners = {}
self.client_key = client_key
self.user = ""
self.factor = False
self.round = 0
self.destinations = {}
self.shells = set()
def get_allowed_auths(self, username):
if username.startswith("check-"):
return ""
if self.factor:
return "password" if username == "kbd-password" else "keyboard-interactive"
return {
"password": "password", "password-mfa": "password", "banner-password": "password",
"publickey": "publickey", "encrypted-key": "publickey",
"publickey-mfa": "publickey", "reject": "password",
"key-password": "publickey,password",
}.get(username, "keyboard-interactive")
def check_auth_none(self, username):
self.user = username
if self.approvals is not None and username.startswith("check-"):
return self.approvals.wait(self.transport, username)
if username == "banner-password":
Approvals.banner(self.transport, username)
return paramiko.AUTH_SUCCESSFUL if username in ("none", "none-slow-forward") else paramiko.AUTH_FAILED
def check_auth_password(self, username, password):
if password != "fixture-password":
return paramiko.AUTH_FAILED
if username == "password-mfa":
self.factor = True
return paramiko.AUTH_PARTIALLY_SUCCESSFUL
accepted = username in ("password", "key-password", "banner-password") or (username == "kbd-password" and self.factor)
return paramiko.AUTH_SUCCESSFUL if accepted else paramiko.AUTH_FAILED
def check_auth_publickey(self, username, key):
if username in ("publickey", "encrypted-key") and key == self.client_key:
return paramiko.AUTH_SUCCESSFUL
if username == "publickey-mfa" and key == self.client_key:
self.factor = True
return paramiko.AUTH_PARTIALLY_SUCCESSFUL
return paramiko.AUTH_FAILED
def check_auth_interactive(self, username, submethods):
self.round = 1
if username in ("password-mfa", "publickey-mfa") and not self.factor:
return paramiko.AUTH_FAILED
if username == "push":
return paramiko.InteractiveQuery("PAM", "Approve this login on your device.")
if username == "multi":
return paramiko.InteractiveQuery("PAM 登录", "Two questions", ("用户名", True), ("OTP", False))
return paramiko.InteractiveQuery("OTP", "Enter the current code.", ("OTP", False))
def check_auth_interactive_response(self, responses):
if self.user == "kbd-password" and responses == ["123456"]:
self.factor = True
return paramiko.AUTH_PARTIALLY_SUCCESSFUL
if self.user == "multi":
if self.round == 1 and responses == ["部署 user", "123456"]:
self.round += 1
return paramiko.InteractiveQuery("PAM", "Empty answers are valid.", ("Optional", False))
return paramiko.AUTH_SUCCESSFUL if self.round == 2 and responses == [""] else paramiko.AUTH_FAILED
if self.user == "push":
return paramiko.AUTH_SUCCESSFUL if responses == [] else paramiko.AUTH_FAILED
return paramiko.AUTH_SUCCESSFUL if responses == ["123456"] else paramiko.AUTH_FAILED
def check_channel_request(self, kind, chanid):
return paramiko.OPEN_SUCCEEDED if kind == "session" else paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
def check_channel_exec_request(self, channel, command):
def reply():
channel.sendall(b"fixture\n")
channel.send_exit_status(0)
channel.shutdown_write()
threading.Thread(target=reply, daemon=True).start()
return True
def check_channel_pty_request(self, *args):
return True
def check_channel_shell_request(self, channel):
self.shells.add(channel.get_id())
return True
def check_channel_direct_tcpip_request(self, chanid, origin, destination):
# Allow routing only back to this fixture, never to other services.
if destination not in self.allowed_destinations:
return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
self.destinations[chanid] = destination
return paramiko.OPEN_SUCCEEDED
def check_port_forward_request(self, address, port):
if self.user == "none-slow-forward":
time.sleep(0.1)
if address not in ("127.0.0.1", "::1"):
return False
listener = socket.socket(socket.AF_INET6 if ":" in address else socket.AF_INET)
try:
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind((address, port))
listener.listen(128)
listener.settimeout(0.2)
except OSError as error:
print(f"Fixture bind {address}:{port} failed: {error}", flush=True)
listener.close()
return False
bound = listener.getsockname()[1]
self.listeners[(address, bound)] = listener
def accept():
while self.transport.is_active():
try:
peer, origin = listener.accept()
channel = self.transport.open_forwarded_tcpip_channel(origin[:2], (address, bound))
threading.Thread(target=relay_peer, args=(channel, peer), daemon=True).start()
except socket.timeout:
continue
except (OSError, EOFError, paramiko.SSHException):
break
threading.Thread(target=accept, daemon=True).start()
return bound
def cancel_port_forward_request(self, address, port):
listener = self.listeners.pop((address, port), None)
if listener:
listener.close()
def relay_peer(channel, peer):
with peer:
try:
readers = [channel, peer]
while readers:
ready, _, _ = select.select(readers, [], [], 1)
for source in ready:
destination = peer if source is channel else channel
data = source.recv(32768)
if data:
destination.sendall(data)
else:
readers.remove(source)
if destination is channel:
channel.shutdown_write()
else:
peer.shutdown(socket.SHUT_WR)
except (OSError, EOFError, paramiko.SSHException):
pass
finally:
try:
channel.close()
except (OSError, EOFError, paramiko.SSHException):
pass
def echo_server(listener):
def echo(peer):
with peer:
try:
while data := peer.recv(65536):
peer.sendall(data)
except OSError:
pass
while True:
try:
peer, _ = listener.accept()
threading.Thread(target=echo, args=(peer,), daemon=True).start()
except OSError:
return
def relay(channel, destination):
try:
relay_peer(channel, socket.create_connection(destination))
except OSError:
channel.close()
def serve(sock, host_key, client_key, port, echo_port, approvals=None, tailscale_port=None):
transport = paramiko.Transport(sock)
if approvals is not None:
transport.local_version = "SSH-2.0-Tailscale"
transport.banner_timeout = 120
transport.auth_timeout = 120
transport.add_server_key(host_key)
transport.set_subsystem_handler("sftp", paramiko.SFTPServer, Files)
server = Authentication(client_key, approvals)
server.port = port
server.transport = transport
server.allowed_destinations = {("127.0.0.1", port), ("127.0.0.1", echo_port), ("::1", echo_port)}
if tailscale_port is not None:
server.allowed_destinations.add(("127.0.0.1", tailscale_port))
channels = []
try:
transport.start_server(server=server)
while transport.is_active():
channel = transport.accept(0.2)
if channel:
channels.append(channel)
destination = server.destinations.get(channel.get_id())
if destination:
threading.Thread(target=relay, args=(channel, destination), daemon=True).start()
except (EOFError, OSError, paramiko.SSHException):
pass
finally:
for listener in server.listeners.values():
listener.close()
transport.close()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--metadata", required=True)
args = parser.parse_args()
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
host_key = paramiko.RSAKey.generate(2048)
client_key = paramiko.RSAKey.generate(2048)
private = io.StringIO()
client_key.write_private_key(private)
encrypted_private = io.StringIO()
client_key.write_private_key(encrypted_private, password="fixture-passphrase")
with (socket.socket() as listener, socket.socket() as echo_listener,
socket.socket() as tailscale_listener, socket.socket() as approval_listener):
echo_listener.bind(("127.0.0.1", 0))
echo_listener.listen(128)
echo_port = echo_listener.getsockname()[1]
threading.Thread(target=echo_server, args=(echo_listener,), daemon=True).start()
listener.bind(("127.0.0.1", 0))
listener.listen(128)
port = listener.getsockname()[1]
tailscale_listener.bind(("127.0.0.1", 0))
tailscale_listener.listen(128)
tailscale_port = tailscale_listener.getsockname()[1]
approval_listener.bind(("127.0.0.1", 0))
approval_listener.listen(128)
approvals = Approvals()
threading.Thread(target=approvals.serve, args=(approval_listener,), daemon=True).start()
def accept_tailscale():
while True:
try:
sock, _ = tailscale_listener.accept()
except OSError:
return
threading.Thread(
target=serve,
args=(sock, host_key, client_key, port, echo_port, approvals, tailscale_port),
daemon=True).start()
threading.Thread(target=accept_tailscale, daemon=True).start()
fingerprint = base64.b64encode(hashlib.sha256(host_key.asbytes()).digest()).decode().rstrip("=")
metadata = {
"port": port,
"echoPort": echo_port,
"tailscalePort": tailscale_port,
"approvalPort": approval_listener.getsockname()[1],
"privateKey": private.getvalue(),
"encryptedPrivateKey": encrypted_private.getvalue(),
"hostFingerprint": "SHA256:" + fingerprint,
}
# Ephemeral test material only, never a user credential.
fd = os.open(args.metadata, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as output:
json.dump(metadata, output)
print(f"SSH fixture listening on 127.0.0.1:{port}", flush=True)
try:
while True:
sock, _ = listener.accept()
threading.Thread(
target=serve, args=(sock, host_key, client_key, port, echo_port, None, tailscale_port),
daemon=True).start()
except KeyboardInterrupt:
pass
finally:
os.unlink(args.metadata)
if __name__ == "__main__":
main()