Traversio

SSH Port Latency

Measure SSH-port route setup timing and an encrypted SSH service-request RTT directly from the Traversio library.

Traversio exposes a small public utility API for measuring how an SSH route behaves before final-target authentication.

Use SSHClient.measurePortLatency(...) when you want to compare:

  • route setup time
  • a real SSH_MSG_SERVICE_REQUEST("ssh-userauth") / SSH_MSG_SERVICE_ACCEPT round trip after the final target's SSH transport is encrypted

This is useful for support screens, diagnostics exports, and server responsiveness checks before authentication on the final target.

This utility is not a host-trust or login-success check. Final-target samples verify the server's host-key signature as part of key exchange, then measure the encrypted ssh-userauth service request; they do not apply the final target's hostKeyPolicy and they stop before final-target user authentication. Use SSHClient.connect(...) when the app needs the normal host-trust decision.

For a machine that is already connected, do not run this utility on a recurring dashboard timer. Read await connection.latency from the existing SSHConnection instead. Traversio updates that snapshot from live channel/global requests and configured keepalive replies, so a connected dashboard can refresh its displayed value without repeatedly opening short-lived SSH routes.

API Shape

@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, visionOS 1.0, *)
public static func measurePortLatency(
    host: String,
    port: UInt16 = 22,
    options: SSHPortLatencyOptions = .init()
) async throws -> SSHPortLatencyReport

public static func measurePortLatency(
    host: String,
    port: UInt16 = 22,
    connectionProxy: SSHConnectionProxy? = nil,
    proxyJumpHosts: [SSHProxyJumpHost] = [],
    options: SSHPortLatencyOptions = .init(),
    logHandler: SSHClientLogHandler = .disabled
) async throws -> SSHPortLatencyReport

public static func measurePortLatency(
    configuration: SSHClientConfiguration,
    options: SSHPortLatencyOptions = .init(),
    logHandler: SSHClientLogHandler = .disabled
) async throws -> SSHPortLatencyReport

Supporting public types:

  • SSHPortLatencyOptions
  • SSHPortLatencyReport
  • SSHPortLatencySample
  • SSHPortLatencyFailure
  • SSHPortLatencyStatistics

Measure An Endpoint

import Traversio

@available(macOS 10.15, iOS 13.0, *)
func inspectPort() async throws {
    let report = try await SSHClient.measurePortLatency(
        host: "example.com",
        port: 22,
        options: SSHPortLatencyOptions(
            sampleCount: 5,
            connectTimeout: 2,
            firstServerByteTimeout: 2,
            delayBetweenSamples: 0.1
        )
    )

    print(report.connectRTTStatistics.averageMilliseconds)
    print(report.sshServiceRequestRTTStatistics.averageMilliseconds)
    print(report.estimatedPathOneWayFromSSHServiceRequestStatistics.averageMilliseconds)
}

Measure Through A Configured Route

Use the configuration: overload when your machine definition already carries connectionProxy or proxyJumpHosts.

let report = try await SSHClient.measurePortLatency(
    configuration: machineConfiguration,
    options: SSHPortLatencyOptions(
        sampleCount: 5,
        connectTimeout: 2,
        firstServerByteTimeout: 2,
        delayBetweenSamples: 0.1
    )
)

Route behavior:

  • direct targets use Traversio's normal TCP transport factory
  • connectionProxy targets include SOCKS5 or HTTP CONNECT setup in each sample
  • proxyJumpHosts targets authenticate the jump chain once, then measure fresh direct-tcpip channel opens to the final endpoint
  • each final-target sample completes SSH version exchange, key exchange, and NEWKEYS, then measures the encrypted ssh-userauth service-request RTT
  • final-target samples do not apply the final target's host-trust policy; the route is measured before final-target user authentication

The report keeps both the raw timings and the aggregated statistics, so your app can either:

  • show only the summary values
  • expose every sample attempt
  • keep the failures alongside the successful samples in a support export

The Main Metrics

The library keeps these values on purpose. They answer different questions.

report.connectRTTStatistics.averageMilliseconds

This is the average time from stream setup start until the route setup step returns.

Use it when you want to know:

  • how fast the local machine can establish the selected route
  • whether a nearby VPN, proxy, or load balancer is accepting the route quickly
  • whether failures are happening before any server-side SSH bytes are seen

This is a route setup duration, not a replacement for ping. On some backends a direct stream handle can be returned before the peer has sent anything. For ProxyJump samples, this is the final direct-tcpip channel open after the jump chain is already authenticated.

report.sshServiceRequestRTTStatistics.averageMilliseconds

This is the average round-trip time for the final-target SSH service request.

The library first completes SSH version exchange, key exchange, and NEWKEYS with the final target. It then measures the time from sending SSH_MSG_SERVICE_REQUEST("ssh-userauth") to receiving SSH_MSG_SERVICE_ACCEPT.

Use it when you want a ping-like number for UI or support output. It is not ICMP ping, but it has the same round-trip shape.

report.estimatedPathOneWayFromSSHServiceRequestStatistics.averageMilliseconds

This is derived from:

report.sshServiceRequestRTTStatistics.averageMilliseconds / 2

The library first completes SSH version exchange, key exchange, and NEWKEYS with the final target. It then measures the round trip from sending SSH_MSG_SERVICE_REQUEST("ssh-userauth") to receiving SSH_MSG_SERVICE_ACCEPT, divides that value by 2, and exposes that number as an estimate.

Use it only when you explicitly want a derived one-way estimate:

  • "is this endpoint really nearby, or did a local proxy only make connect() look fast?"
  • "which server feels farther away when VPN, transparent proxying, or jump-host routing flattens route setup time?"

This remains an estimate because it includes small endpoint scheduling and packet-processing costs. In normal SSH servers those costs are usually small compared with proxy, jump-host, and wide-area network latency.

How To Choose

If you are deciding which metric to show in your own tooling, use this rule:

  • show report.sshServiceRequestRTTStatistics.averageMilliseconds when you want a ping-like latency value
  • show report.connectRTTStatistics.averageMilliseconds when you care about route setup on the local path
  • show report.estimatedPathOneWayFromSSHServiceRequestStatistics.averageMilliseconds only when the label clearly says it is an estimated one-way value
  • show both when you need to explain why a quick TCP connect and a slower final-server SSH service response can coexist

In support and operations workflows, showing both is usually the most useful choice.

Why The Numbers Can Disagree

Route setup duration and SSH service-request RTT can diverge significantly, and that difference often reflects the network path accurately.

Common reasons:

  • a VPN or transparent proxy accepts the TCP connection near the client, so route setup becomes very small
  • the remote SSH server is still far away, so the encrypted service request takes longer to round-trip
  • a proxy chain, load balancer, or middlebox finishes the TCP setup before the real server has replied
  • a jump host opens the final channel quickly while the final SSH server still has farther to reach
  • the server adds a small amount of scheduling or packet-processing delay

That means this kind of result is normal:

let report = try await SSHClient.measurePortLatency(host: "example.com")

print(report.connectRTTStatistics.averageMilliseconds)
print(report.sshServiceRequestRTTStatistics.averageMilliseconds)
print(report.estimatedPathOneWayFromSSHServiceRequestStatistics.averageMilliseconds)

You might see a very small route setup value and a much larger SSH service-request RTT. That usually means the route setup completed near the client, and the real final-server round trip still had to travel farther.

Why This Uses Service Request

At the TCP layer, the earliest server response is SYN-ACK. Packet boundaries stay below the stream socket API.

At the SSH application layer, the first SSH-related data is the server identification line:

SSH-2.0-OpenSSH_9.9

RFC 4253 allows the server to send non-SSH- text lines before that identification line. After identification exchange, the first binary SSH transport packet is SSH_MSG_KEXINIT.

Those early bytes are useful diagnostics, but they are not a clean request/response timing boundary. A server banner is sent proactively, and a server may also send SSH_MSG_KEXINIT immediately after its identification string. Dividing either timing by 2 can therefore overstate or understate a real path one-way estimate.

Traversio instead waits until the final target's encrypted SSH transport is active, then measures a concrete request/response pair:

SSH_MSG_SERVICE_REQUEST("ssh-userauth") -> SSH_MSG_SERVICE_ACCEPT

That packet pair does not authenticate. It just asks the server to enter the SSH user-authentication service. The server-side work is small, so the RTT is usually dominated by the selected route to the final SSH server and back.

Scope

This API covers a narrow diagnostic task:

  • it opens a direct TCP socket, a configured connection-proxy stream, or a ProxyJump direct-tcpip stream to host:port
  • it can reuse route fields from SSHClientConfiguration
  • it authenticates ProxyJump hosts because the final channel needs an established SSH hop chain
  • it completes final-target SSH transport setup and stops after SSH_MSG_SERVICE_ACCEPT

Use it as a latency utility. Full session success still depends on the later SSH stages.

SSHPortLatencyOptions controls:

  • sampleCount
  • connectTimeout
  • firstServerByteTimeout
  • delayBetweenSamples

Invalid values do not need to be handled with process-level traps. Call try options.validate() when you want to preflight user input, or pass the options into SSHClient.measurePortLatency(...) and catch SSHPortLatencyError from the measurement call. The typed cases cover invalid sample count, invalid timeouts, route setup timeout, SSH service-request timeout, and no successful samples.

SSHPortLatencyReport gives you:

  • samples
  • failures
  • connectRTTStatistics
  • firstServerByteAfterConnectStatistics
  • sshServiceRequestRTTStatistics
  • estimatedPathOneWayFromFirstServerByteStatistics
  • estimatedPathOneWayFromSSHServiceRequestStatistics

For developer-facing diagnostics pages and support bundles:

  • keep report.connectRTTStatistics.averageMilliseconds
  • keep report.sshServiceRequestRTTStatistics.averageMilliseconds
  • keep report.estimatedPathOneWayFromSSHServiceRequestStatistics.averageMilliseconds only when you clearly label it as an estimated one-way value

That gives developers enough context to choose the metric that matches their own environment and support workflow.

For the full type list and signatures, see Public API. For real connection failures, read Diagnostics.

On this page