Detect Tailscale SSH by server identification and authenticate with the configured username without requiring credentials or Pro. Keep browser approval concurrent with transport polling and dismiss prompts when authentication finishes. Preserve managed Tailnet access limits and add loopback regression coverage.
447 lines
17 KiB
Python
447 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""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()
|