diff --git a/README.md b/README.md index be47a51..4a82448 100644 --- a/README.md +++ b/README.md @@ -21,25 +21,6 @@ let client = try await SSHClient.connect( Using that client, we support a couple types of operations: -### TCP-IP Forwarding (Proxying) - -```swift -// The address that is presented as the locally exposed interface -// This is purely communicated to the SSH server -let address = try SocketAddress(ipAddress: "fe80::1", port: 27017) -let configuredProxyChannel = try await client.createDirectTCPIPChannel( - using: SSHChannelType.DirectTCPIP( - targetHost: "localhost", // MongoDB host - targetPort: 27017, // MongoDB port - originatorAddress: address - ) -) { proxyChannel in - proxyChannel.pipeline.addHandlers(...) -} -``` - -This will create a channel that is connected to the SSH server, and then forwarded to the target host. This is useful for proxying TCP-IP connections, such as MongoDB, Redis, MySQL, etc. - ### Executing Commands You can execute a command through SSH using the following code: @@ -76,14 +57,13 @@ An example of how executeCommandStream can be used: ```swift let streams = try await client.executeCommandStream("cat /foo/bar.log") -var asyncStreams = streams.makeAsyncIterator() - -while let blob = try await asyncStreams.next() { - switch blob { - case .stdout(let stdout): - // do something with stdout - case .stderr(let stderr): - // do something with stderr + +for try await event in streams { + switch event { + case .stdout(let stdout): + // do something with stdout + case .stderr(let stderr): + // do something with stderr } } ``` @@ -130,25 +110,15 @@ let directoryContents = try await sftp.listDirectory(atPath: "/etc") // Create a directory try await sftp.createDirectory(atPath: "/etc/custom-folder") -// Open a file -let resolv = try await sftp.openFile(filePath: "/etc/resolv.conf", flags: .read) - -// Read a file in bulk -let resolvContents: ByteBuffer = try await resolv.readAll() - -// Read a file in chunks -let chunk: ByteBuffer = try await resolv.read(from: index, length: maximumByteCount) - -// Close a file -try await resolv.close() - -// Write to a file -let file = try await sftp.openFile(filePath: "/etc/resolv.conf", flags: [.read, .write, .forceCreate]) -let fileWriterIndex = 0 -try await file.write(ByteBuffer(string: "Hello, world", at: fileWriterIndex) -try await file.close() +// Write to a file (using a helper that cleans up the file automatically) +try await sftp.withFile( + filePath: "/etc/resolv.conf", + flags: [.read, .write, .forceCreate] +) { file in + try await file.write(ByteBuffer(string: "Hello, world", at: 0)) +} -// Read a file using a helper. This closes the file automatically +// Read a file let data = try await sftp.withFile( filePath: "/etc/resolv.conf", flags: .read @@ -160,6 +130,25 @@ let data = try await sftp.withFile( try await sftp.close() ``` +### TCP-IP Forwarding (Proxying) + +```swift +// The address that is presented as the locally exposed interface +// This is purely communicated to the SSH server +let address = try SocketAddress(ipAddress: "fe80::1", port: 27017) +let configuredProxyChannel = try await client.createDirectTCPIPChannel( + using: SSHChannelType.DirectTCPIP( + targetHost: "localhost", // MongoDB host + targetPort: 27017, // MongoDB port + originatorAddress: address + ) +) { proxyChannel in + proxyChannel.pipeline.addHandlers(...) +} +``` + +This will create a channel that is connected to the SSH server, and then forwarded to the target host. This is useful for proxying TCP-IP connections, such as MongoDB, Redis, MySQL, etc. + ## Servers To use Citadel, first you need to create & start an SSH server, using your own authentication delegate: @@ -303,7 +292,7 @@ public final class MyExecDelegate: ExecDelegate { } ``` -### SFTP Server +### SFTP Servers When you implement SFTP in Citadel, you're responsible for taking care of logistics. Be it through a backing MongoDB store, a real filesystem, or your S3 bucket. @@ -358,7 +347,6 @@ You can also use `SSHAlgorithms.all` to enable all supported algorithms. A couple of code is held back until further work in SwiftNIO SSH is completed. We're currently working with Apple to resolve these. - [ ] RSA Authentication (implemented & supported, but in a [fork of NIOSSH](https://github.com/Joannis/swift-nio-ssh-1/pull/1)) -- [ ] Much more documentation & tutorials ## Contributing diff --git a/Sources/Citadel/Exec/Client/ExecClient.swift b/Sources/Citadel/Exec/Client/ExecClient.swift index 6a47b69..0a65863 100644 --- a/Sources/Citadel/Exec/Client/ExecClient.swift +++ b/Sources/Citadel/Exec/Client/ExecClient.swift @@ -2,16 +2,24 @@ import Foundation import NIO import NIOSSH +/// A channel handler that manages TTY (terminal) input/output for SSH command execution. +/// This handler processes both incoming and outgoing data through the SSH channel. final class TTYHandler: ChannelDuplexHandler { typealias InboundIn = SSHChannelData typealias InboundOut = ByteBuffer typealias OutboundIn = ByteBuffer typealias OutboundOut = SSHChannelData + /// Maximum allowed size for command response data let maxResponseSize: Int + /// Flag to indicate if input should be ignored (e.g., when response size exceeds limit) var isIgnoringInput = false + /// Buffer to store the command's response data var response = ByteBuffer() + /// Promise that will be fulfilled with the final response let done: EventLoopPromise + /// Buffer to store error messages from stderr + private var errorBuffer = ByteBuffer() init( maxResponseSize: Int, @@ -39,7 +47,11 @@ final class TTYHandler: ChannelDuplexHandler { } func handlerRemoved(context: ChannelHandlerContext) { - done.succeed(response) + if errorBuffer.readableBytes > 0 { + done.fail(TTYSTDError(message: errorBuffer)) + } else { + done.succeed(response) + } } func channelRead(context: ChannelHandlerContext, data: NIOAny) { @@ -63,7 +75,7 @@ final class TTYHandler: ChannelDuplexHandler { response.writeBuffer(&bytes) return case .stdErr: - done.fail(TTYSTDError(message: bytes)) + errorBuffer.writeBuffer(&bytes) default: () } @@ -76,10 +88,24 @@ final class TTYHandler: ChannelDuplexHandler { } extension SSHClient { - /// Executes a command on the remote server. This will return the output of the command. If the command fails, the error will be thrown. If the output is too large, the command will fail. + /// Executes a command on the remote SSH server and returns its output. + /// + /// This method establishes a new channel, executes the specified command, and collects + /// its output. The command execution is handled asynchronously and includes timeout protection + /// for channel creation. + /// /// - Parameters: - /// - command: The command to execute. - /// - maxResponseSize: The maximum size of the response. If the response is larger, the command will fail. + /// - command: The shell command to execute on the remote server + /// - maxResponseSize: Maximum allowed size for the command's output in bytes. + /// If exceeded, throws `CitadelError.commandOutputTooLarge` + /// + /// - Returns: A ByteBuffer containing the command's output + /// + /// - Throws: + /// - `CitadelError.channelCreationFailed` if the channel cannot be created within 15 seconds + /// - `CitadelError.commandOutputTooLarge` if the response exceeds maxResponseSize + /// - `SSHClient.CommandFailed` if the command returns a non-zero exit status + /// - `TTYSTDError` if there was output to stderr public func executeCommand(_ command: String, maxResponseSize: Int = .max) async throws -> ByteBuffer { let promise = eventLoop.makePromise(of: ByteBuffer.self) diff --git a/Sources/Citadel/SFTP/Client/SFTPClient.swift b/Sources/Citadel/SFTP/Client/SFTPClient.swift index c5aa69d..f9d30be 100644 --- a/Sources/Citadel/SFTP/Client/SFTPClient.swift +++ b/Sources/Citadel/SFTP/Client/SFTPClient.swift @@ -105,6 +105,20 @@ public final class SFTPClient: Sendable { } /// List the contents of a directory on the SFTP server. + /// + /// - Parameter path: The path to list + /// - Returns: Array of directory entries + /// - Throws: SFTPError if the request fails + /// + /// ## Example + /// ```swift + /// let contents = try await sftp.listDirectory(atPath: "/home/user") + /// for item in contents { + /// print(item.filename) + /// print(item.longname) // ls -l style output + /// print(item.attributes) // File attributes + /// } + /// ``` public func listDirectory( atPath path: String ) async throws -> [SFTPMessage.Name] { @@ -151,7 +165,19 @@ public final class SFTPClient: Sendable { return names } - /// Get the attributes of a file on the SFTP server. If the file does not exist, an error is thrown. + /// Get the attributes of a file on the SFTP server. + /// + /// - Parameter filePath: Path to the file + /// - Returns: File attributes including size, permissions, etc + /// - Throws: SFTPError if the file doesn't exist or request fails + /// + /// ## Example + /// ```swift + /// let attrs = try await sftp.getAttributes(at: "test.txt") + /// print("Size:", attrs.size) + /// print("Permissions:", attrs.permissions) + /// print("Modified:", attrs.modificationTime) + /// ``` public func getAttributes( at filePath: String ) async throws -> SFTPFileAttributes { @@ -170,15 +196,29 @@ public final class SFTPClient: Sendable { return attributes.attributes } - /// Open a file at the specified path on the SFTP server, using the given flags and attributes. If the `.create` - /// flag is specified, the given attributes are applied to the created file. If successful, an `SFTPFile` is - /// returned which can be used to perform various operations on the open file. The file object must be explicitly - /// closed by the caller; the client does not keep track of open files. + /// Open a file at the specified path on the SFTP server. /// - /// - Warning: The `attributes` parameter is currently unimplemented; any values provided are ignored. + /// - Parameters: + /// - filePath: Path to the file + /// - flags: File open flags (.read, .write, .create, etc) + /// - attributes: File attributes to set if creating file + /// - Returns: An SFTPFile object for performing operations + /// - Throws: SFTPError if open fails /// - /// - Important: This API is annoying to use safely. Strongly consider using - /// `withFile(filePath:flags:attributes:_:)` instead. + /// ## Example + /// ```swift + /// // Open file for reading + /// let file = try await sftp.openFile( + /// filePath: "test.txt", + /// flags: .read + /// ) + /// + /// // Read entire contents + /// let data = try await file.readToEnd() + /// + /// // Don't forget to close + /// try await file.close() + /// ``` public func openFile( filePath: String, flags: SFTPOpenFileFlags, @@ -202,12 +242,34 @@ public final class SFTPClient: Sendable { return SFTPFile(client: self, path: filePath, handle: handle.handle) } - /// Open a file at the specified path on the SFTP server, using the given flags. If the `.create` flag is specified, - /// the given attributes are applied to the created file. If the open succeeds, the provided closure is invoked with - /// an `SFTPFile` object which can be used to perform operations on the file. When the closure returns, the file is - /// automatically closed. The `SFTPFile` object must not be persisted beyond the lifetime of the closure. + /// Open and automatically close a file with the given closure. + /// + /// - Parameters: + /// - filePath: Path to the file + /// - flags: File open flags (.read, .write, .create, etc) + /// - attributes: File attributes to set if creating file + /// - closure: Operation to perform with the open file + /// - Returns: The value returned by the closure + /// - Throws: SFTPError if open fails or closure throws /// - /// - Warning: The `attributes` parameter is currently unimplemented; any values provided are ignored. + /// ## Example + /// ```swift + /// // Read file contents + /// let contents = try await sftp.withFile( + /// filePath: "test.txt", + /// flags: .read + /// ) { file in + /// try await file.readToEnd() + /// } + /// + /// // Write file contents + /// try await sftp.withFile( + /// filePath: "new.txt", + /// flags: [.write, .create] + /// ) { file in + /// try await file.write(ByteBuffer(string: "Hello World")) + /// } + /// ``` public func withFile( filePath: String, flags: SFTPOpenFileFlags, @@ -226,7 +288,24 @@ public final class SFTPClient: Sendable { } } - /// Create a directory at the specified path on the SFTP server. If the directory already exists, an error is thrown. + /// Create a directory at the specified path. + /// + /// - Parameters: + /// - path: Path where directory should be created + /// - attributes: Attributes to set on the new directory + /// - Throws: SFTPError if creation fails + /// + /// ## Example + /// ```swift + /// // Create simple directory + /// try await sftp.createDirectory(atPath: "new_folder") + /// + /// // Create with specific permissions + /// try await sftp.createDirectory( + /// atPath: "private_folder", + /// attributes: .init(permissions: 0o700) + /// ) + /// ``` public func createDirectory( atPath path: String, attributes: SFTPFileAttributes = .none @@ -242,7 +321,15 @@ public final class SFTPClient: Sendable { self.logger.debug("SFTP created directory \(path)") } - /// Remove a file at the specified path on the SFTP server + /// Remove a file at the specified path. + /// + /// - Parameter filePath: Path to the file to remove + /// - Throws: SFTPError if removal fails + /// + /// ## Example + /// ```swift + /// try await sftp.remove(at: "file_to_delete.txt") + /// ``` public func remove( at filePath: String ) async throws { @@ -256,7 +343,15 @@ public final class SFTPClient: Sendable { self.logger.debug("SFTP removed file at \(filePath)") } - /// Remove a directory at the specified path on the SFTP server + /// Remove a directory at the specified path. + /// + /// - Parameter filePath: Path to the directory to remove + /// - Throws: SFTPError if removal fails + /// + /// ## Example + /// ```swift + /// try await sftp.rmdir(at: "empty_directory") + /// ``` public func rmdir( at filePath: String ) async throws { @@ -270,7 +365,21 @@ public final class SFTPClient: Sendable { self.logger.debug("SFTP removed directory at \(filePath)") } - /// Rename a file + /// Rename a file or directory. + /// + /// - Parameters: + /// - oldPath: Current path of the file + /// - newPath: Desired new path + /// - flags: Optional flags affecting the rename operation + /// - Throws: SFTPError if rename fails + /// + /// ## Example + /// ```swift + /// try await sftp.rename( + /// at: "old_name.txt", + /// to: "new_name.txt" + /// ) + /// ``` public func rename( at oldPath: String, to newPath: String, @@ -288,7 +397,20 @@ public final class SFTPClient: Sendable { self.logger.debug("SFTP renamed file at \(oldPath) to \(newPath)") } - // Obtain the real path of the directory eg "/opt/vulscan/.. -> /opt" and Pass in ". "on initialization You can get the current working directory + /// Get the canonical absolute path. + /// + /// - Parameter path: Path to resolve + /// - Returns: Absolute canonical path + /// - Throws: SFTPError if path resolution fails + /// + /// ## Example + /// ```swift + /// // Resolve current directory + /// let pwd = try await sftp.getRealPath(atPath: ".") + /// + /// // Resolve relative path + /// let absolute = try await sftp.getRealPath(atPath: "../some/path") + /// ``` public func getRealPath(atPath path: String) async throws -> String { guard case let .name(realpath) = try await sendRequest(.realpath(.init(requestId: self.allocateRequestId(), path: path))) else { self.logger.warning("SFTP server returned bad response to open file request, this is a protocol error") @@ -303,20 +425,32 @@ extension SSHClient { /// Open a SFTP subchannel over the SSH connection using the `sftp` subsystem. /// /// - Parameters: - /// - logger: A logger to use for logging SFTP operations. Creates a new logger by default. See below for details. - /// - closure: A closure to execute with the opened SFTP client. The client is automatically closed when the - /// closure returns. + /// - logger: A logger to use for logging SFTP operations. Creates a new logger by default. + /// - closure: A closure to execute with the opened SFTP client. The client is automatically closed when the closure returns. /// /// ## Logging levels - /// /// Several events in the lifetime of an SFTP connection are logged to the provided logger at various levels: /// - `.critical`, `.error`: Unused. /// - `.warning`: Logs non-`ok` SFTP status responses and SSH-level errors. /// - `.info`: Logs major interesting events in the SFTP connection lifecycle (opened, closed, etc.) /// - `.debug`: Logs detailed connection events (opened file, read from file, wrote to file, etc.) - /// - `.trace`: Logs a protocol-level packet trace, including raw packet bytes (excluding large items such - /// as incoming data read from a file). Care is taken to ensure sensitive information is not included in - /// packet traces. + /// - `.trace`: Logs a protocol-level packet trace. + /// + /// ## Example + /// ```swift + /// let client = try await SSHClient(/* ... */) + /// + /// try await client.withSFTP { sftp in + /// // List directory contents + /// let contents = try await sftp.listDirectory(atPath: "/home/user") + /// + /// // Read a file + /// try await sftp.withFile(filePath: "test.txt", flags: .read) { file in + /// let data = try await file.readToEnd() + /// print(String(buffer: data)) + /// } + /// } + /// ``` public func withSFTP( logger: Logger = .init(label: "nl.orlandos.citadel.sftp"), _ closure: @escaping @Sendable (SFTPClient) async throws -> ReturnType @@ -335,21 +469,21 @@ extension SSHClient { /// Open a SFTP subchannel over the SSH connection using the `sftp` subsystem. /// /// - Parameters: - /// - subsystem: The subsystem name sent to the SSH server. You probably want to just use the default of `sftp`. - /// - logger: A logger to use for logging SFTP operations. Creates a new logger by default. See below for details. - /// - /// ## Logging levels - /// - /// Several events in the lifetime of an SFTP connection are logged to the provided logger at various levels: + /// - logger: A logger to use for logging SFTP operations. Creates a new logger by default. + /// - Returns: An initialized SFTP client + /// - Throws: SFTPError if connection fails or version is unsupported /// - /// - `.critical`, `.error`: Unused. - /// - `.warning`: Logs non-`ok` SFTP status responses and SSH-level errors. - /// - `.notice`: Unused. - /// - `.info`: Logs major interesting events in the SFTP connection lifecycle (opened, closed, etc.) - /// - `.debug`: Logs detailed connection events (opened file, read from file, wrote to file, etc.) - /// - `.trace`: Logs a protocol-level packet trace, including raw packet bytes (excluding large items such - /// as incoming data read from a file). Care is taken to ensure sensitive information is not included in - /// packet traces. + /// ## Example + /// ```swift + /// let client = try await SSHClient(/* ... */) + /// let sftp = try await client.openSFTP() + /// + /// // Use SFTP client + /// let contents = try await sftp.listDirectory(atPath: "/home/user") + /// + /// // Remember to close when done + /// try await sftp.close() + /// ``` public func openSFTP( logger: Logger = .init(label: "nl.orlandos.citadel.sftp") ) async throws -> SFTPClient { diff --git a/Sources/Citadel/SFTP/Client/SFTPFile.swift b/Sources/Citadel/SFTP/Client/SFTPFile.swift index abada40..95de277 100644 --- a/Sources/Citadel/SFTP/Client/SFTPFile.swift +++ b/Sources/Citadel/SFTP/Client/SFTPFile.swift @@ -46,6 +46,19 @@ public final class SFTPFile { } /// Read the attributes of the file. This is equivalent to the `stat()` system call. + /// + /// - Returns: File attributes including size, permissions, etc + /// - Throws: SFTPError if the file handle is invalid or request fails + /// + /// ## Example + /// ```swift + /// let file = try await sftp.withFile(filePath: "test.txt", flags: .read) { file in + /// let attrs = try await file.readAttributes() + /// print("File size:", attrs.size ?? 0) + /// print("Modified:", attrs.modificationTime) + /// print("Permissions:", String(format: "%o", attrs.permissions)) + /// } + /// ``` public func readAttributes() async throws -> SFTPFileAttributes { guard self.isActive else { throw SFTPError.fileHandleInvalid } @@ -60,16 +73,27 @@ public final class SFTPFile { return attributes.attributes } - /// Read up to the given number of bytes from the file, starting at the given byte offset. If the offset - /// is past the last byte of the file, an error will be returned. The offset is a 64-bit quantity, but - /// no more than `UInt32.max` bytes may be read in a single chunk. + /// Read up to the given number of bytes from the file, starting at the given byte offset. /// - /// - Note: Calling the method with no parameters will result in a buffer of up to 4GB worth of data. To - /// retreive the full contents of larger files, see `readAll()` below. + /// - Parameters: + /// - offset: Starting position in the file (defaults to 0) + /// - length: Maximum number of bytes to read (defaults to UInt32.max) + /// - Returns: ByteBuffer containing the read data + /// - Throws: SFTPError if the file handle is invalid or read fails /// - /// - Warning: The contents of large files will end up fully buffered in memory. It is strongly recommended - /// that callers provide a relatively small `length` value and stream the contents to their destination in - /// chunks rather than trying to gather it all at once. + /// ## Example + /// ```swift + /// let file = try await sftp.withFile(filePath: "test.txt", flags: .read) { file in + /// // Read first 1024 bytes + /// let start = try await file.read(from: 0, length: 1024) + /// + /// // Read next 1024 bytes + /// let middle = try await file.read(from: 1024, length: 1024) + /// + /// // Read remaining bytes (up to 4GB) + /// let rest = try await file.read(from: 2048) + /// } + /// ``` public func read(from offset: UInt64 = 0, length: UInt32 = .max) async throws -> ByteBuffer { guard self.isActive else { throw SFTPError.fileHandleInvalid } @@ -90,12 +114,24 @@ public final class SFTPFile { } } - /// Read all bytes in the file into a single in-memory buffer. Reads are done in chunks of up to 4GB each. - /// For files below that size, use `file.read()` instead. If an error is encountered during any of the - /// chunk reads, it cancels all remaining reads and discards the buffer. + /// Read all bytes in the file into a single in-memory buffer. + /// + /// - Returns: ByteBuffer containing the entire file contents + /// - Throws: SFTPError if the file handle is invalid or read fails /// - /// - Tip: This method is overkill unless you expect to be working with very large files. You may - /// want to make sure the host of said code has plenty of spare RAM. + /// ## Example + /// ```swift + /// try await sftp.withFile(filePath: "test.txt", flags: .read) { file in + /// // Read entire file + /// let contents = try await file.readAll() + /// print("File size:", contents.readableBytes) + /// + /// // Convert to string if text file + /// if let text = String(buffer: contents) { + /// print("Contents:", text) + /// } + /// } + /// ``` public func readAll() async throws -> ByteBuffer { let attributes = try await self.readAttributes() @@ -131,9 +167,33 @@ public final class SFTPFile { return buffer } - /// Write the given data to the file, starting at the provided offset. If the offset is past the current end of the - /// file, the behavior is server-dependent, but it is safest to assume that this is not permitted. The offset is - /// ignored if the file was opened with the `.append` flag. + /// Write data to the file at the specified offset. + /// + /// - Parameters: + /// - data: ByteBuffer containing the data to write + /// - offset: Position in file to start writing (defaults to 0) + /// - Throws: SFTPError if the file handle is invalid or write fails + /// + /// ## Example + /// ```swift + /// try await sftp.withFile( + /// filePath: "test.txt", + /// flags: [.write, .create] + /// ) { file in + /// // Write string data + /// try await file.write(ByteBuffer(string: "Hello World\n")) + /// + /// // Append more data + /// let moreData = ByteBuffer(string: "More content") + /// try await file.write(moreData, at: 12) // After newline + /// + /// // Write large data in chunks + /// let chunk1 = ByteBuffer(string: "First chunk") + /// let chunk2 = ByteBuffer(string: "Second chunk") + /// try await file.write(chunk1, at: 0) + /// try await file.write(chunk2, at: UInt64(chunk1.readableBytes)) + /// } + /// ``` public func write(_ data: ByteBuffer, at offset: UInt64 = 0) async throws -> Void { guard self.isActive else { throw SFTPError.fileHandleInvalid } @@ -160,12 +220,9 @@ public final class SFTPFile { self.logger.debug("SFTP finished writing \(data.readerIndex) bytes @ \(offset) to file \(self.handle.sftpHandleDebugDescription)") } - /// Close the file. No further operations may take place on the file after it is closed. A file _must_ be closed - /// before the last reference to it goes away. + /// Close the file handle. /// - /// - Note: Files are automatically closed if the SFTP channel is shut down, but it is strongly recommended that - /// callers explicitly close the file anyway, as multiple close operations are idempotent. The "close before - /// deinit" requirement is enforced in debug builds by an assertion; violations are ignored in release builds. + /// - Throws: SFTPError if close fails public func close() async throws -> Void { guard self.isActive else { // Don't blow up if close is called on an invalid handle; it's too easy for it to happen by accident. diff --git a/Sources/Citadel/TTY/Client/TTY.swift b/Sources/Citadel/TTY/Client/TTY.swift index 9acf5fd..42c0d61 100644 --- a/Sources/Citadel/TTY/Client/TTY.swift +++ b/Sources/Citadel/TTY/Client/TTY.swift @@ -3,12 +3,17 @@ import Logging import NIO import NIOSSH +/// Represents an error that occurred while processing TTY standard error output public struct TTYSTDError: Error { + /// The error message as a raw byte buffer public let message: ByteBuffer } +/// A pair of streams representing the stdout and stderr output of an executed command public struct ExecCommandStream { + /// An async stream of bytes representing the standard output public let stdout: AsyncThrowingStream + /// An async stream of bytes representing the standard error public let stderr: AsyncThrowingStream struct Continuation { @@ -37,23 +42,15 @@ public struct ExecCommandStream { } } +/// Represents the output from an executed command, either stdout or stderr data public enum ExecCommandOutput { + /// Standard output data as a byte buffer case stdout(ByteBuffer) + /// Standard error data as a byte buffer case stderr(ByteBuffer) } -struct EmptySequence: Sendable, AsyncSequence { - struct AsyncIterator: AsyncIteratorProtocol { - func next() async throws -> Element? { - nil - } - } - - func makeAsyncIterator() -> AsyncIterator { - AsyncIterator() - } -} - +/// An async sequence that provides TTY output data @available(macOS 15.0, *) public struct TTYOutput: AsyncSequence { internal let sequence: AsyncThrowingStream @@ -73,9 +70,12 @@ public struct TTYOutput: AsyncSequence { } } +/// Allows writing data to a TTY's standard input and controlling terminal properties public struct TTYStdinWriter { internal let channel: Channel + /// Write raw bytes to the TTY's standard input + /// - Parameter buffer: The bytes to write public func write(_ buffer: ByteBuffer) async throws { try await channel.writeAndFlush(SSHChannelData(type: .channel, data: .byteBuffer(buffer))) } @@ -119,6 +119,7 @@ final class ExecCommandHandler: ChannelDuplexHandler { func handlerAdded(context: ChannelHandlerContext) { context.channel.setOption(ChannelOptions.allowRemoteHalfClosure, value: true).whenFailure { error in + self.logger.debug("Failed to set allowRemoteHalfClosure: \(error)") context.fireErrorCaught(error) } } @@ -132,6 +133,7 @@ final class ExecCommandHandler: ChannelDuplexHandler { case let status as SSHChannelRequestEvent.ExitStatus: onOutput(context.channel, .exit(status.exitStatus)) default: + self.logger.debug("Received unknown channel event in command handler: \(event)") context.fireUserInboundEventTriggered(event) } } @@ -154,6 +156,7 @@ final class ExecCommandHandler: ChannelDuplexHandler { case .stdErr: onOutput(context.channel, .stderr(buffer)) default: + self.logger.debug("Received channel data not known by Citadel") // We don't know this std channel () } @@ -165,16 +168,35 @@ final class ExecCommandHandler: ChannelDuplexHandler { } extension SSHClient { + /// Error thrown when a command exits with a non-zero status code public struct CommandFailed: Error { + /// The exit code returned by the command public let exitCode: Int } - /// Executes a command on the remote server. This will return the output of the command (stdout). If the command fails, the error will be thrown. If the output is too large, the command will fail. + /// Executes a command on the remote server and returns its output as a single buffer /// - Parameters: - /// - command: The command to execute. - /// - maxResponseSize: The maximum size of the response. If the response is larger, the command will fail. - /// - mergeStreams: If the answer should also include stderr. - /// - inShell: Whether to request the remote server to start a shell before executing the command. + /// - command: The command to execute on the remote server + /// - maxResponseSize: Maximum allowed size of the combined output in bytes. Defaults to Int.max + /// - mergeStreams: Whether to include stderr output in the result. Defaults to false + /// - inShell: Whether to execute the command within a shell context. Defaults to false + /// - Returns: A ByteBuffer containing the command's output + /// - Throws: CitadelError.commandOutputTooLarge if output exceeds maxResponseSize + /// CommandFailed if the command exits with non-zero status + /// + /// ## Example + /// ```swift + /// // Simple command execution + /// let output = try await client.executeCommand("ls -la") + /// print(String(buffer: output)) + /// + /// // Execute with merged stderr and limited output size + /// let result = try await client.executeCommand( + /// "find /", + /// maxResponseSize: 1024 * 1024, // 1MB max + /// mergeStreams: true + /// ) + /// ``` public func executeCommand( _ command: String, maxResponseSize: Int = .max, @@ -188,6 +210,7 @@ extension SSHClient { switch chunk { case .stderr(let chunk): guard mergeStreams else { + logger.debug("Error data received, but ignored because `mergeStreams` is disabled") continue } @@ -206,10 +229,27 @@ extension SSHClient { return result } - /// Executes a command on the remote server. This will return the output stream of the command. If the command fails, the error will be thrown. + /// Executes a command on the remote server and returns a stream of its output /// - Parameters: - /// - command: The command to execute. - /// - inShell: Whether to request the remote server to start a shell before executing the command. + /// - command: The command to execute on the remote server + /// - environment: Array of environment variables to set for the command + /// - inShell: Whether to execute the command within a shell context. Defaults to false + /// - Returns: An async stream that yields command output as it becomes available + /// - Throws: CommandFailed if the command exits with non-zero status + /// + /// ## Example + /// ```swift + /// // Stream command output as it arrives + /// let stream = try await client.executeCommandStream("tail -f /var/log/system.log") + /// for try await output in stream { + /// switch output { + /// case .stdout(let buffer): + /// print("stdout:", String(buffer: buffer)) + /// case .stderr(let buffer): + /// print("stderr:", String(buffer: buffer)) + /// } + /// } + /// ``` public func executeCommandStream( _ command: String, environment: [SSHChannelRequestEvent.EnvironmentRequest] = [], @@ -241,6 +281,7 @@ extension SSHClient { case .stderr(let stderr): streamContinuation.yield(.stderr(stderr)) case .eof(let error): + self.logger.debug("EOF triggered, ending the command stream.") if let error { streamContinuation.finish(throwing: error) } else if let exitCode, exitCode != 0 { @@ -258,6 +299,7 @@ extension SSHClient { hasReceivedChannelSuccess = true } case .exit(let status): + self.logger.debug("Process exited with status code \(status). Will await on EOF for correct exit") exitCode = status } } @@ -297,6 +339,12 @@ extension SSHClient { return (channel, stream) } + /// Creates a pseudo-terminal (PTY) session and executes the provided closure with input/output streams + /// - Parameters: + /// - request: PTY configuration parameters + /// - environment: Array of environment variables to set for the PTY session + /// - perform: Closure that receives TTY input/output streams and performs terminal operations + /// - Throws: Any errors that occur during PTY setup or operation @available(macOS 15.0, *) public func withPTY( _ request: SSHChannelRequestEvent.PseudoTerminalRequest, @@ -322,6 +370,31 @@ extension SSHClient { } } + /// Creates a TTY session and executes the provided closure with input/output streams + /// + /// - Parameters: + /// - environment: Array of environment variables to set for the TTY session + /// - perform: Closure that receives TTY input/output streams and performs terminal operations + /// - Throws: Any errors that occur during TTY setup or operation + /// + /// ## Example + /// ```swift + /// // Create an interactive shell session + /// try await client.withTTY { inbound, outbound in + /// // Send commands + /// try await outbound.write(ByteBuffer(string: "echo $PATH\n")) + /// + /// // Process output + /// for try await output in inbound { + /// switch output { + /// case .stdout(let buffer): + /// print(String(buffer: buffer)) + /// case .stderr(let buffer): + /// print("Error:", String(buffer: buffer)) + /// } + /// } + /// } + /// ``` @available(macOS 15.0, *) public func withTTY( environment: [SSHChannelRequestEvent.EnvironmentRequest] = [], @@ -346,9 +419,34 @@ extension SSHClient { } } - /// Executes a command on the remote server. This will return the pair of streams stdout and stderr of the command. If the command fails, the error will be thrown. + /// Executes a command and returns separate stdout and stderr streams + /// + /// Example: + /// ```swift + /// let client = try await SSHClient(/* ... */) + /// + /// // Execute a command with separate stdout/stderr handling + /// let streams = try await client.executeCommandPair("make") + /// + /// // Handle stdout + /// Task { + /// for try await output in streams.stdout { + /// print("stdout:", String(buffer: output)) + /// } + /// } + /// + /// // Handle stderr + /// Task { + /// for try await error in streams.stderr { + /// print("stderr:", String(buffer: error)) + /// } + /// } + /// ``` /// - Parameters: - /// - command: The command to execute. + /// - command: The command to execute on the remote server + /// - inShell: Whether to execute the command within a shell context. Defaults to false + /// - Returns: An ExecCommandStream containing separate stdout and stderr streams + /// - Throws: CommandFailed if the command exits with non-zero status public func executeCommandPair(_ command: String, inShell: Bool = false) async throws -> ExecCommandStream { var stdoutContinuation: AsyncThrowingStream.Continuation! var stderrContinuation: AsyncThrowingStream.Continuation!