Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apple/Packages/TableProCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ let package = Package(
),
.testTarget(
name: "TableProSSHTransportTests",
dependencies: ["TableProSSHTransport"],
dependencies: ["TableProSSHTransport", "TableProTestSupport"],
path: "Tests/TableProSSHTransportTests"
),
.testTarget(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,10 @@ public struct SSHConfiguration: Codable, Hashable, Sendable {
public var privateKeyPath: String?
public var jumpHosts: [SSHJumpHost]

/// Fields the macOS app stores inside `sshConfigJson` that this model does not use, kept only so
/// they survive an iOS sync round trip. Without them, re-encoding a synced connection on iOS
/// dropped the macOS `enabled`, remote file path, agent socket and TOTP settings, and the macOS
/// app then read the connection back with SSH turned off. Each is optional so a connection this
/// model creates omits it and the macOS side keeps inferring `enabled` from a non-empty host.
// Fields the macOS app stores inside `sshConfigJson` that this model does not use, carried so an
// iOS round trip keeps them. Each is optional, so a configuration this model creates omits it.
// The Mac reads a missing `enabled` as off, so the iOS form writes `macEnabled = true` for a
// tunnel it saves switched on.
public var macEnabled: Bool?
public var macUseSSHConfig: Bool?
public var macAgentSocketPath: String?
Expand Down Expand Up @@ -125,13 +124,10 @@ public struct SSHConfiguration: Codable, Hashable, Sendable {
}
}

/// How a hop authenticates, spelled the way the macOS app writes it. A hop is never dialled from
/// iOS, so the value is carried rather than used, and a spelling this build does not know still
/// belongs to the Mac that wrote it: it is held as it arrived and handed back untouched, the way
/// `DatabaseType` holds a type no plugin here implements. The exception is the case names shipped
/// iOS builds wrote (`privateKey`, `sshAgent`), which no macOS build can read: an already-shipped
/// one decodes this raw value strictly, and a `dataCorrupted` thrown inside `jumpHosts` takes the
/// whole SSH configuration with it, and with that the connection. Those normalize.
// How the Mac signs in to a hop, in the macOS spelling. iOS signs in to a hop with a key kept on the
// device, so this is carried, not used, and a spelling this build does not know goes back untouched.
// The case names shipped iOS builds wrote (`privateKey`, `sshAgent`) normalize: a shipped macOS build
// decodes this strictly, and a hop it cannot decode takes the whole SSH configuration with it.
public struct SSHJumpAuthMethod: Hashable, Sendable {
public static let privateKey = SSHJumpAuthMethod(rawValue: "Private Key")
public static let sshAgent = SSHJumpAuthMethod(rawValue: "SSH Agent")
Expand Down Expand Up @@ -179,6 +175,9 @@ public struct SSHJumpHost: Codable, Hashable, Sendable, Identifiable {
public var macAuthMethod: SSHJumpAuthMethod
public var macPrivateKeyPath: String

// iOS reads no `~/.ssh/config`, so an unset port is the SSH default here.
public var resolvedPort: Int { port ?? SSHConfiguration.defaultPort }

public init(
id: UUID = UUID(),
host: String = "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@ public struct SSHChannelRelay {
private func sendToLocal(_ buffer: UnsafeMutablePointer<CChar>, count: Int) -> Bool {
var totalSent = 0
while totalSent < count {
let sent = send(localFD, buffer.advanced(by: totalSent), count - totalSent, 0)
// Without the flag, a send to a peer that closed first raises SIGPIPE and kills the app.
let sent = send(localFD, buffer.advanced(by: totalSent), count - totalSent, MSG_NOSIGNAL)
if sent <= 0 { return false }
totalSent += sent
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//
// SSHJumpRelayFence.swift
// TableProSSHTransport
//

import Foundation
import os

// Cancelling a Swift task does not cancel the GCD block that runs a hop's `SSHChannelRelay`, so
// teardown wakes its descriptors and waits here for its last libssh2 call before freeing the channel
// or session. The stop action and the relay's final close share one lock, so a late stop cannot
// `shutdown` a descriptor number the kernel already handed to someone else.
public final class SSHJumpRelayFence: @unchecked Sendable {
private struct State {
var stopAction: (@Sendable () -> Void)?
var isFinished = false
}

private let state: OSAllocatedUnfairLock<State>
private let completion = DispatchGroup()

public init(stopAction: @escaping @Sendable () -> Void) {
state = OSAllocatedUnfairLock(initialState: State(stopAction: stopAction))
completion.enter()
}

public var isActive: Bool {
state.withLock { $0.stopAction != nil && !$0.isFinished }
}

public func stop() {
state.withLock { state in
guard let stopAction = state.stopAction, !state.isFinished else { return }
state.stopAction = nil
stopAction()
}
}

public func finish(cleanup: @Sendable () -> Void = {}) {
let didFinish = state.withLock { state -> Bool in
guard !state.isFinished else { return false }
state.stopAction = nil
state.isFinished = true
cleanup()
return true
}
if didFinish {
completion.leave()
}
}

public func wait() {
completion.wait()
}

public func waitForCompletion() async {
await withCheckedContinuation { continuation in
completion.notify(queue: .global(qos: .utility)) {
continuation.resume()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ struct SSHConfigurationTests {
#expect(!text.contains("b3BlbnNzaC1rZXktdjE"))
}

@Test("A configuration this model creates omits the macOS-only keys, so the host inference is unchanged")
@Test("A configuration this model creates omits the macOS-only keys")
func iosCreatedConfigOmitsMacKeys() throws {
let config = SSHConfiguration(host: "prod-1", username: "deploy")
let reencoded = try JSONEncoder().encode(config)
Expand Down Expand Up @@ -170,6 +170,22 @@ struct SSHConfigurationTests {
#expect(config.jumpHosts[1].port == 2_222)
}

@Test("A hop without a port dials the SSH default")
func portlessHopDialsDefaultPort() throws {
let config = try JSONDecoder().decode(SSHConfiguration.self, from: Data(Self.macJumpHostsJSON.utf8))

#expect(config.jumpHosts[0].resolvedPort == 22)
#expect(SSHJumpHost(host: "bastion-1").resolvedPort == 22)
}

@Test("A hop port the user set is the one dialed")
func setHopPortIsDialed() throws {
let config = try JSONDecoder().decode(SSHConfiguration.self, from: Data(Self.macJumpHostsJSON.utf8))

#expect(config.jumpHosts[1].resolvedPort == 2_222)
#expect(SSHJumpHost(host: "bastion-1", port: 2_200).resolvedPort == 2_200)
}

@Test("A hop's auth method and key path survive an iOS round trip")
func preservesJumpHostCredentialFields() throws {
let decoded = try JSONDecoder().decode(SSHConfiguration.self, from: Data(Self.macJumpHostsJSON.utf8))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,24 @@ struct SSHChannelRelayTests {
#expect(result == .channelClosed)
}

@Test("Channel data that arrives after the local peer closed ends the relay instead of raising SIGPIPE")
func channelDataAfterLocalPeerClosed() {
let local = SocketPair()
let transport = SocketPair()
defer { local.close(); transport.close() }

local.closeB()
var dummy: UInt8 = 1
_ = Darwin.send(transport.b, &dummy, 1, 0)

let payload = Data("late".utf8)
let io = FakeChannelIO(actions: [.data(payload)], fallback: .closed)
let result = runRelay(localFD: local.a, transportFD: transport.a, io: io)

#expect(result == .localClosed)
#expect(io.deliveredCount == payload.count)
}

@Test("Channel data is forwarded to the local socket")
func forwardsChannelData() {
let local = SocketPair()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//
// SSHJumpRelayFenceTests.swift
// TableProSSHTransportTests
//

import Foundation
import os
import TableProTestSupport
import Testing

@testable import TableProSSHTransport

struct SSHJumpRelayFenceTests {
@Test("ProxyJump cleanup waits until its relay no longer uses libssh2 handles")
func proxyJumpCleanupDrainsRelayBeforeFree() async {
let releaseRelay = DispatchSemaphore(value: 0)
let enteredRelay = OSAllocatedUnfairLock(initialState: false)
let relayFinished = OSAllocatedUnfairLock(initialState: false)
let cleanupObservedFinishedRelay = OSAllocatedUnfairLock(initialState: false)
let fence = SSHJumpRelayFence {
releaseRelay.signal()
}

Thread.detachNewThread {
enteredRelay.withLock { $0 = true }
releaseRelay.wait()
relayFinished.withLock { $0 = true }
fence.finish()
}

guard (try? await waitUntil(timeout: .seconds(1)) { enteredRelay.withLock { $0 } }) != nil else {
releaseRelay.signal()
Issue.record("The ProxyJump relay did not start")
return
}

let teardown = await BoundedCall.resultOnItsOwnThread(
within: .seconds(2),
onDeadline: { releaseRelay.signal() },
of: {
fence.stop()
fence.wait()
cleanupObservedFinishedRelay.withLock { value in
value = relayFinished.withLock { $0 }
}
return true
}
)

#expect(teardown == true)
#expect(cleanupObservedFinishedRelay.withLock { $0 })
#expect(!fence.isActive)
}

@Test("A completed ProxyJump relay disarms late descriptor interruption")
func proxyJumpCompletionDisarmsLateStop() {
let stopCount = OSAllocatedUnfairLock(initialState: 0)
let released = OSAllocatedUnfairLock(initialState: false)
let fence = SSHJumpRelayFence {
stopCount.withLock { $0 += 1 }
}

fence.finish {
released.withLock { $0 = true }
}
fence.stop()

#expect(released.withLock { $0 })
#expect(stopCount.withLock { $0 } == 0)
#expect(!fence.isActive)
}
}
62 changes: 62 additions & 0 deletions apple/TablePro/Core/SSH/LibSSH2JumpRelay.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// LibSSH2JumpRelay.swift
// TablePro
//
// Compiled into the macOS app and, by file reference, into the iOS app, which defaults to
// MainActor isolation. Every declaration states its own isolation for that reason; the iOS
// build is the check.
//

import Foundation

import TableProSSHTransport

nonisolated internal enum LibSSH2JumpRelay {
// The relay is the only thing that touches these once it starts, which the compiler cannot see
// through an OpaquePointer.
nonisolated private struct Handles: @unchecked Sendable {
let channel: OpaquePointer
let session: OpaquePointer
}

static func start(
channel: OpaquePointer,
localFD: Int32,
transportFD: Int32,
session: OpaquePointer,
sessionQueue: DispatchQueue
) -> SSHJumpRelayFence {
let relayQueue = DispatchQueue(
label: "com.TablePro.ssh.hop-relay",
qos: .utility
)
let handles = Handles(channel: channel, session: session)
let fence = SSHJumpRelayFence {
shutdown(localFD, SHUT_RDWR)
shutdown(transportFD, SHUT_RDWR)
}
relayQueue.async {
defer {
fence.finish {
shutdown(localFD, SHUT_RDWR)
Darwin.close(localFD)
}
}

let relay = SSHChannelRelay(
localFD: localFD,
transportFD: transportFD,
channelIO: LibSSH2ChannelIO(
channel: handles.channel,
session: handles.session,
sessionQueue: sessionQueue
),
bufferSize: 32_768,
isActive: { fence.isActive }
)

_ = relay.run()
}
return fence
}
}
Loading
Loading