Process()/Shell with PTY for swift-nio based SSH server

Hi, I am trying to implement command execution to a SSH server framework (Citadel) based on swift-nio.
I have wrapped Process() to pass user input to bash and added a method to capture the output. This allows commands to be executed, but all shell functions like coloring, escape/control characters, input echoing, etc are missing.
My goal is to directly forward the input received and forward the output of the shell. Just like any other SSH server.

I have read that a pseudo-terminal could fix this. But as I don't have much experience with PTYs, I don't know how to implement it. I also noticed that swift-nio provides a SSHChannelRequestEvent.PseudoTerminalRequest, could that already meet my needs?
I tinkered a bit with the openpty and received some more information, but it was printed to the standard output of the application and not to my output pipe. Also text colouring and echoing is missing. I guess I am overlooking something here.

Any help is very much appreciated.

// process.standardOutput = FileHandle(fileDescriptor: master)
var master: Int32 = 0
var slave: Int32 = 0

if openpty(&master, &slave, nil, nil, nil) != 0 {
    perror("openpty")
    return -1
}
@main struct ExampleSSHServer {
    
    static func main() async throws {        
        // File stored host key
        let hostKey = HostKey()
        
        let server = try await SSHServer.host(
            host: "localhost",
            port: 2222,
            hostKeys: [
                try .init(ed25519Key: .init(rawRepresentation: hostKey.key))
            ],
            authenticationDelegate: LoginHandler(username: "usr", password: "test")
        )
        
         server.enableShell(withDelegate: ExecShell())
        
        try await server.closeFuture.get()
    }
}


public struct ExecShell: ShellDelegate {
    public func startShell(reading stream: AsyncStream<ShellClientEvent>, context: SSHContext) async throws -> AsyncThrowingStream<ShellServerEvent, Error> {
        
        AsyncThrowingStream { continuation in
            Task {
               let prcs = Shell()
               try prcs.start()
                
               try prcs._readOutput { output in
                    continuation.yield(.stdout(ByteBuffer(bytes: output)))
                }
            
                for await message in stream {
                    if case .stdin(let input) = message {
                        let bytes = input.getBytes(at: input.readerIndex, length: input.readableBytes)!
                        prcs.write(Data(bytes))
                    }
                }
                continuation.finish()
            }
        }
    }
}
 
class Shell  {
    
    deinit {
        process.terminate()
     }
    
    private let process: Process = Process()
    private let inputPipe: Pipe  = Pipe()
    private var outputPipe: Pipe = Pipe()
 
    func start() throws {
        process.standardInput = inputPipe
        process.standardOutput = outputPipe
        process.standardError  = outputPipe
        process.launchPath = "/bin/bash"
        process.arguments = ["-i"]
        process.environment = ["TERM": "xterm-256color"]
        process.launch()
    }
    
    func write(_ command: Data) {
        inputPipe.fileHandleForWriting.write(command)
    }
    
    // sketchy read out
    func _readOutput(handler: @escaping (Data) -> Void) throws {
        let outputHandle = outputPipe.fileHandleForReading
        Task {
            while true {
                let data = outputHandle.availableData
                if data.isEmpty {
                    break
                }
                handler(data)
            }
        }
    }
}

Implementing a PTY is not trivial. As an example of some of what is required, you can take a look at a Go package for supporting PTYs in Go: GitHub - creack/pty: PTY interface for Go. I'm not aware of anything pre-existing in the Swift ecosystem, but you can investigate that package to get an idea of what you might need to do.

1 Like