193 lines
6.4 KiB
Python
Executable File
193 lines
6.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
# SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
"""Benchmark single-connection system SSH or a manually configured forwarder."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import resource
|
|
import subprocess
|
|
import tempfile
|
|
|
|
from port_forwarding_benchmark_common import (
|
|
endpoint_measurement,
|
|
environment,
|
|
reserve_port,
|
|
wait_for_port,
|
|
write_output,
|
|
)
|
|
|
|
|
|
def ssh_command(
|
|
metadata: dict[str, object], key_path: Path, kind: str, listener_port: int
|
|
) -> list[str]:
|
|
user = str(metadata["username"])
|
|
ssh_port = int(metadata["port"])
|
|
echo_port = int(metadata["echoPort"])
|
|
command = [
|
|
"/usr/bin/ssh",
|
|
"-N",
|
|
"-p",
|
|
str(ssh_port),
|
|
"-i",
|
|
str(key_path),
|
|
"-o",
|
|
"BatchMode=yes",
|
|
"-o",
|
|
"Compression=no",
|
|
"-o",
|
|
"ExitOnForwardFailure=yes",
|
|
"-o",
|
|
"StrictHostKeyChecking=no",
|
|
"-o",
|
|
"UserKnownHostsFile=/dev/null",
|
|
]
|
|
if kind == "local":
|
|
command += ["-L", f"127.0.0.1:{listener_port}:127.0.0.1:{echo_port}"]
|
|
elif kind == "dynamic":
|
|
command += ["-D", f"127.0.0.1:{listener_port}"]
|
|
else:
|
|
command += ["-R", f"127.0.0.1:{listener_port}:127.0.0.1:{echo_port}"]
|
|
command.append(f"{user}@127.0.0.1")
|
|
return command
|
|
|
|
|
|
def system_measurement(
|
|
metadata: dict[str, object],
|
|
key_path: Path,
|
|
kind: str,
|
|
streams: int,
|
|
rounds: int,
|
|
) -> dict[str, object]:
|
|
listener_port = reserve_port()
|
|
before_self = resource.getrusage(resource.RUSAGE_SELF)
|
|
before_children = resource.getrusage(resource.RUSAGE_CHILDREN)
|
|
process = subprocess.Popen(
|
|
ssh_command(metadata, key_path, kind, listener_port),
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
try:
|
|
wait_for_port(listener_port, process_failed=lambda: process.poll() is not None)
|
|
record = endpoint_measurement(
|
|
"system-ssh-1",
|
|
listener_port,
|
|
kind,
|
|
streams,
|
|
int(metadata["echoPort"]),
|
|
rounds,
|
|
)
|
|
rss_text = subprocess.check_output(
|
|
["/bin/ps", "-o", "rss=", "-p", str(process.pid)], text=True
|
|
).strip()
|
|
record["forwarder_peak_rss_mib"] = int(rss_text or 0) / 1_024
|
|
finally:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait()
|
|
after_self = resource.getrusage(resource.RUSAGE_SELF)
|
|
after_children = resource.getrusage(resource.RUSAGE_CHILDREN)
|
|
record["cpu_s"] = (
|
|
after_self.ru_utime
|
|
- before_self.ru_utime
|
|
+ after_self.ru_stime
|
|
- before_self.ru_stime
|
|
+ after_children.ru_utime
|
|
- before_children.ru_utime
|
|
+ after_children.ru_stime
|
|
- before_children.ru_stime
|
|
)
|
|
return record
|
|
|
|
|
|
def make_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--metadata", type=Path, required=True)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
parser.add_argument("--repetitions", type=int, default=5)
|
|
parser.add_argument("--rounds", type=int, default=128)
|
|
parser.add_argument(
|
|
"--quick",
|
|
action="store_true",
|
|
help="Use one repetition and test 1 and 16 streams instead of 1, 16 and 64",
|
|
)
|
|
modes = parser.add_subparsers(dest="mode", required=True)
|
|
modes.add_parser("system-ssh", help="Automatically configure macOS /usr/bin/ssh")
|
|
external = modes.add_parser("external", help="Measure an already running forwarding listener")
|
|
external.add_argument("--name", required=True)
|
|
external.add_argument("--kind", choices=("local", "dynamic", "remote"), required=True)
|
|
external.add_argument("--port", type=int, required=True)
|
|
return parser
|
|
|
|
|
|
def main() -> None:
|
|
args = make_parser().parse_args()
|
|
if args.repetitions < 1:
|
|
raise SystemExit("--repetitions must be at least 1")
|
|
if args.rounds < 1:
|
|
raise SystemExit("--rounds must be at least 1")
|
|
metadata = json.loads(args.metadata.read_text())
|
|
repetitions = 1 if args.quick else args.repetitions
|
|
streams = [1, 16] if args.quick else [1, 16, 64]
|
|
records: list[dict[str, object]] = []
|
|
environment_values = environment(repetitions, metadata.get("server"))
|
|
|
|
if args.mode == "system-ssh":
|
|
with tempfile.TemporaryDirectory(prefix="termind-system-ssh-benchmark-") as directory:
|
|
key_path = Path(directory) / "client"
|
|
descriptor = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
with os.fdopen(descriptor, "w") as key_file:
|
|
key_file.write(str(metadata["privateKey"]))
|
|
for _ in range(repetitions):
|
|
for kind in ("local", "dynamic", "remote"):
|
|
for stream_count in streams:
|
|
records.append(
|
|
system_measurement(metadata, key_path, kind, stream_count, args.rounds)
|
|
)
|
|
environment_values["systemSSH"] = subprocess.check_output(
|
|
["/usr/bin/ssh", "-V"], stderr=subprocess.STDOUT, text=True
|
|
).strip()
|
|
note = "macOS system SSH uses one process, one listener and one direct SSH connection per measurement."
|
|
else:
|
|
if not 1 <= args.port <= 65_535:
|
|
raise SystemExit("--port must be from 1 through 65535")
|
|
wait_for_port(args.port)
|
|
implementation = f"external:{args.name}"
|
|
for _ in range(repetitions):
|
|
for stream_count in streams:
|
|
records.append(
|
|
endpoint_measurement(
|
|
implementation,
|
|
args.port,
|
|
args.kind,
|
|
stream_count,
|
|
int(metadata["echoPort"]),
|
|
args.rounds,
|
|
)
|
|
)
|
|
environment_values.update(
|
|
{
|
|
"externalName": args.name,
|
|
"externalKind": args.kind,
|
|
"externalPort": args.port,
|
|
}
|
|
)
|
|
note = (
|
|
"The external listener is treated as one direct SSH connection. CPU, RSS and application startup "
|
|
"time are not measured."
|
|
)
|
|
|
|
write_output(args.output, records, environment_values, note)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|