diff --git a/.gitignore b/.gitignore index 71ee4af..258406a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ /*.xcodeproj xcuserdata/ Package.resolved +citadel_host_key_ed25519 .vscode/launch.json diff --git a/Examples/TerminalAppServer/.gitignore b/Examples/TerminalAppServer/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/Examples/TerminalAppServer/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Examples/TerminalAppServer/Package.swift b/Examples/TerminalAppServer/Package.swift new file mode 100644 index 0000000..ff8ba41 --- /dev/null +++ b/Examples/TerminalAppServer/Package.swift @@ -0,0 +1,26 @@ +// swift-tools-version: 5.10 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "TerminalAppServer", + platforms: [ + .macOS(.v12), + ], + dependencies: [ + .package(url: "https://github.com/joannis/SwiftTUI.git", branch: "jo/allow-use-with-concurrency"), + .package(path: "../.."), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .executableTarget( + name: "TerminalAppServer", + dependencies: [ + .product(name: "Citadel", package: "Citadel"), + .product(name: "SwiftTUI", package: "SwiftTUI"), + ] + ), + ] +) diff --git a/Examples/TerminalAppServer/Sources/main.swift b/Examples/TerminalAppServer/Sources/main.swift new file mode 100644 index 0000000..e039069 --- /dev/null +++ b/Examples/TerminalAppServer/Sources/main.swift @@ -0,0 +1,110 @@ +import Citadel +import Crypto +import Foundation +import NIO +import NIOFoundationCompat +import NIOSSH +import SwiftTUI + +@main struct ExampleSSHServer { + static func main() async throws { + let privateKey: Curve25519.Signing.PrivateKey + let privateKeyURL = URL(fileURLWithPath: "./citadel_host_key_ed25519") + + // Read or create a private key + if let file = try? Data(contentsOf: privateKeyURL) { + // File exists, read it into a Curve25519 private key + privateKey = try Curve25519.Signing.PrivateKey(sshEd25519: file) + } else { + // File does not exist, create a new Curve25519 private + privateKey = Curve25519.Signing.PrivateKey() + + // Write the private key to a file + try privateKey.makeSSHRepresentation().write(to: privateKeyURL, atomically: true, encoding: .utf8) + } + + let server = try await SSHServer.host( + host: "localhost", + port: 2323, + hostKeys: [ + NIOSSHPrivateKey(ed25519Key: privateKey) + ], + authenticationDelegate: LoginHandler(username: "joannis", password: "test") + ) + + server.enableShell(withDelegate: CustomAppShell()) + + try await server.closeFuture.get() + } +} + +struct MyTerminalView: View { + var body: some View { + VStack { + Text("Hello, world!") + .background(.red) + .foregroundColor(.white) + + Button("Click me") { + print("clicked") + } + + Button("Don't click") { + print("Clicked anyways") + } + } + .border() + } +} + +final class CustomAppShell: ShellDelegate { + @MainActor public func startShell( + inbound: AsyncStream, + outbound: ShellOutboundWriter, + context: SSHShellContext + ) async throws { + let app = Application(rootView: MyTerminalView()) { string in + outbound.write(ByteBuffer(string: string)) + } + + await withTaskGroup(of: Void.self) { group in + group.addTask { @MainActor in + for await message in inbound { + if case .stdin(let input) = message { + app.handleInput(Data(buffer: input)) + } + } + } + group.addTask { @MainActor in + for await windowSize in context.windowSize { + app.changeWindosSize(to: Size( + width: Extended(windowSize.columns), + height: Extended(windowSize.rows) + )) + } + } + + app.draw() + } + } +} + +struct LoginHandler: NIOSSHServerUserAuthenticationDelegate { + let username: String + let password: String + + var supportedAuthenticationMethods: NIOSSHAvailableUserAuthenticationMethods { + .password + } + + func requestReceived( + request: NIOSSHUserAuthenticationRequest, + responsePromise: EventLoopPromise + ) { + if case .password(.init(password: password)) = request.request, request.username == username { + return responsePromise.succeed(.success) + } + + return responsePromise.succeed(.failure) + } +} \ No newline at end of file diff --git a/Package.resolved b/Package.resolved index 44123a6..fde8e45 100644 --- a/Package.resolved +++ b/Package.resolved @@ -10,6 +10,15 @@ "version": "5.3.0" } }, + { + "package": "ColorizeSwift", + "repositoryURL": "https://github.com/mtynior/ColorizeSwift.git", + "state": { + "branch": null, + "revision": "2a354639173d021f4648cf1912b2b00a3a7cd83c", + "version": "1.6.0" + } + }, { "package": "swift-atomics", "repositoryURL": "https://github.com/apple/swift-atomics.git", diff --git a/Package.swift b/Package.swift index 1495d2c..4d49cfb 100644 --- a/Package.swift +++ b/Package.swift @@ -21,6 +21,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), .package(url: "https://github.com/attaswift/BigInt.git", from: "5.2.0"), .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "2.1.0"), + .package(url: "https://github.com/mtynior/ColorizeSwift.git", from: "1.5.0"), ], targets: [ .target(name: "CCitadelBcrypt"), @@ -33,8 +34,14 @@ let package = Package( .product(name: "_CryptoExtras", package: "swift-crypto"), .product(name: "BigInt", package: "BigInt"), .product(name: "Logging", package: "swift-log"), + .productItem(name: "ColorizeSwift", package: "ColorizeSwift") ] ), + .executableTarget( + name: "CitadelServerExample", + dependencies: [ + "Citadel" + ]), .testTarget( name: "CitadelTests", dependencies: [ diff --git a/Sources/Citadel/Exec/Client/ExecClient.swift b/Sources/Citadel/Exec/Client/ExecClient.swift new file mode 100644 index 0000000..6a47b69 --- /dev/null +++ b/Sources/Citadel/Exec/Client/ExecClient.swift @@ -0,0 +1,126 @@ +import Foundation +import NIO +import NIOSSH + +final class TTYHandler: ChannelDuplexHandler { + typealias InboundIn = SSHChannelData + typealias InboundOut = ByteBuffer + typealias OutboundIn = ByteBuffer + typealias OutboundOut = SSHChannelData + + let maxResponseSize: Int + var isIgnoringInput = false + var response = ByteBuffer() + let done: EventLoopPromise + + init( + maxResponseSize: Int, + done: EventLoopPromise + ) { + self.maxResponseSize = maxResponseSize + self.done = done + } + + func handlerAdded(context: ChannelHandlerContext) { + context.channel.setOption(ChannelOptions.allowRemoteHalfClosure, value: true).whenFailure { error in + context.fireErrorCaught(error) + } + } + + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + switch event { + case let status as SSHChannelRequestEvent.ExitStatus: + if status.exitStatus != 0 { + done.fail(SSHClient.CommandFailed(exitCode: status.exitStatus)) + } + default: + context.fireUserInboundEventTriggered(event) + } + } + + func handlerRemoved(context: ChannelHandlerContext) { + done.succeed(response) + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let data = self.unwrapInboundIn(data) + + guard case .byteBuffer(var bytes) = data.data, !isIgnoringInput else { + return + } + + switch data.type { + case .channel: + if + response.readableBytes + bytes.readableBytes > maxResponseSize + { + isIgnoringInput = true + done.fail(CitadelError.commandOutputTooLarge) + return + } + + // Channel data is forwarded on, the pipe channel will handle it. + response.writeBuffer(&bytes) + return + case .stdErr: + done.fail(TTYSTDError(message: bytes)) + default: + () + } + } + + func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + let data = self.unwrapOutboundIn(data) + context.write(self.wrapOutboundOut(SSHChannelData(type: .channel, data: .byteBuffer(data))), promise: promise) + } +} + +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. + /// - Parameters: + /// - command: The command to execute. + /// - maxResponseSize: The maximum size of the response. If the response is larger, the command will fail. + public func executeCommand(_ command: String, maxResponseSize: Int = .max) async throws -> ByteBuffer { + let promise = eventLoop.makePromise(of: ByteBuffer.self) + + let channel: Channel + + do { + channel = try await eventLoop.flatSubmit { + let createChannel = self.eventLoop.makePromise(of: Channel.self) + self.session.sshHandler.createChannel(createChannel) { channel, _ in + channel.pipeline.addHandlers( + TTYHandler( + maxResponseSize: maxResponseSize, + done: promise + ) + ) + } + + self.eventLoop.scheduleTask(in: .seconds(15)) { + createChannel.fail(CitadelError.channelCreationFailed) + } + + return createChannel.futureResult + }.get() + } catch { + promise.fail(error) + throw error + } + + // We need to exec a thing. + let execRequest = SSHChannelRequestEvent.ExecRequest( + command: command, + wantReply: true + ) + + return try await eventLoop.flatSubmit { + channel.triggerUserOutboundEvent(execRequest).whenFailure { [channel] error in + channel.close(promise: nil) + promise.fail(error) + } + + return promise.futureResult + }.get() + } +} diff --git a/Sources/Citadel/Exec/ExecDelegate.swift b/Sources/Citadel/Exec/Server/ExecDelegate.swift similarity index 100% rename from Sources/Citadel/Exec/ExecDelegate.swift rename to Sources/Citadel/Exec/Server/ExecDelegate.swift diff --git a/Sources/Citadel/Exec/ExecHandler.swift b/Sources/Citadel/Exec/Server/ExecHandler.swift similarity index 100% rename from Sources/Citadel/Exec/ExecHandler.swift rename to Sources/Citadel/Exec/Server/ExecHandler.swift diff --git a/Sources/Citadel/SFTP/Server/SFTPServer.swift b/Sources/Citadel/SFTP/Server/SFTPServer.swift index 35d8b3c..efa0ce9 100644 --- a/Sources/Citadel/SFTP/Server/SFTPServer.swift +++ b/Sources/Citadel/SFTP/Server/SFTPServer.swift @@ -32,6 +32,25 @@ public struct SSHContext { public let username: String? } +public struct SSHShellContext { + public struct WindowSize { + public let columns: Int + public let rows: Int + } + + public let session: SSHContext + internal let channel: Channel + public let windowSize: AsyncStream + + public var isClosed: Bool { + !channel.isActive + } + + public func close(mode: CloseMode = .all) async throws { + try await channel.close(mode: mode) + } +} + /// The delegate for the SFTP subsystem. This is the interface that the SFTP subsystem uses to interact with the rest of the application. The delegate is responsible for implementing the various SFTP operations. public protocol SFTPDelegate { /// Returns the attributes for the file at the given path. This is equivalent to the `stat()` system call. @@ -69,10 +88,10 @@ public protocol SFTPDelegate { func rename(oldPath: String, newPath: String, flags: UInt32, context: SSHContext) async throws -> SFTPStatusCode } -struct SFTPServerSubsystem { +enum SFTPServerSubsystem { static func setupChannelHanders( channel: Channel, - delegate: SFTPDelegate, + sftp: SFTPDelegate, logger: Logger, username: String? ) -> EventLoopFuture { @@ -80,7 +99,7 @@ struct SFTPServerSubsystem { let serializeHandler = MessageToByteHandler(SFTPMessageSerializer()) let sftpInboundHandler = SFTPServerInboundHandler( logger: logger, - delegate: delegate, + delegate: sftp, eventLoop: channel.eventLoop, username: username ) diff --git a/Sources/Citadel/Server.swift b/Sources/Citadel/Server.swift index d8903f5..7d13873 100644 --- a/Sources/Citadel/Server.swift +++ b/Sources/Citadel/Server.swift @@ -22,12 +22,14 @@ final class SubsystemHandler: ChannelDuplexHandler { typealias OutboundIn = SSHChannelData typealias OutboundOut = SSHChannelData + let shell: ShellDelegate? let sftp: SFTPDelegate? let eventLoop: EventLoop var configured: EventLoopPromise - init(sftp: SFTPDelegate?, eventLoop: EventLoop) { + init(sftp: SFTPDelegate?, shell: ShellDelegate?, eventLoop: EventLoop) { self.sftp = sftp + self.shell = shell self.eventLoop = eventLoop self.configured = eventLoop.makePromise() } @@ -38,24 +40,55 @@ final class SubsystemHandler: ChannelDuplexHandler { } } + func handlerRemoved(context: ChannelHandlerContext) { + self.configured.succeed(()) + } + func channelInactive(context: ChannelHandlerContext) { context.fireChannelInactive() } func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { switch event { + case let event as SSHChannelRequestEvent.ExecRequest: + context.fireUserInboundEventTriggered(event) + case is SSHChannelRequestEvent.ShellRequest: + guard let shell = shell, let parent = context.channel.parent else { + _ = context.channel.triggerUserOutboundEvent(ChannelFailureEvent()).flatMap { + self.configured.succeed(()) + return context.channel.close() + } + return + } + + parent.pipeline.handler(type: NIOSSHHandler.self).flatMap { handler in + ShellServerSubsystem.setupChannelHanders( + channel: context.channel, + shell: shell, + logger: .init(label: "nl.orlandos.citadel.sftp-server"), + username: handler.username + ) + }.flatMap { () -> EventLoopFuture in + let promise = context.eventLoop.makePromise(of: Void.self) + context.channel.triggerUserOutboundEvent(ChannelSuccessEvent(), promise: promise) + self.configured.succeed(()) + return promise.futureResult + }.whenFailure { _ in + context.channel.triggerUserOutboundEvent(ChannelFailureEvent(), promise: nil) + } case let event as SSHChannelRequestEvent.SubsystemRequest: switch event.subsystem { case "sftp": guard let sftp = sftp, let parent = context.channel.parent else { context.channel.close(promise: nil) + self.configured.succeed(()) return } parent.pipeline.handler(type: NIOSSHHandler.self).flatMap { handler in SFTPServerSubsystem.setupChannelHanders( channel: context.channel, - delegate: sftp, + sftp: sftp, logger: .init(label: "nl.orlandos.citadel.sftp-server"), username: handler.username ) @@ -93,6 +126,7 @@ final class SubsystemHandler: ChannelDuplexHandler { final class CitadelServerDelegate { var sftp: SFTPDelegate? var exec: ExecDelegate? + var shell: ShellDelegate? var directTCPIP: DirectTCPIPDelegate? fileprivate init() {} @@ -104,6 +138,7 @@ final class CitadelServerDelegate { handlers.append(SubsystemHandler( sftp: self.sftp, + shell: self.shell, eventLoop: channel.eventLoop )) @@ -166,6 +201,10 @@ public final class SSHServer { self.delegate.directTCPIP = delegate } + public func enableShell(withDelegate delegate: ShellDelegate) { + self.delegate.shell = delegate + } + /// Closes the SSH Server, stopping new connections from coming in. public func close() async throws { try await channel.close() diff --git a/Sources/Citadel/Shell/Server/ShellDelegate.swift b/Sources/Citadel/Shell/Server/ShellDelegate.swift new file mode 100644 index 0000000..6705099 --- /dev/null +++ b/Sources/Citadel/Shell/Server/ShellDelegate.swift @@ -0,0 +1,140 @@ +import NIO +import NIOSSH +import Logging + +public enum ShellClientEvent { + case stdin(ByteBuffer) +} + +public struct ShellServerEvent: Sendable { + internal enum Event { + case stdout(ByteBuffer) + } + + let event: Event + + public static func stdout(_ data: ByteBuffer) -> ShellServerEvent { + ShellServerEvent(event: .stdout(data)) + } +} + +public protocol ShellDelegate { + func startShell( + inbound: AsyncStream, + outbound: ShellOutboundWriter, + context: SSHShellContext + ) async throws +} + +public struct ShellOutboundWriter: Sendable { + let continuation: AsyncThrowingStream.Continuation + + public func write(_ string: String) { + write(ByteBuffer(string: string)) + } + + public func write(_ data: [UInt8]) { + write(ByteBuffer(bytes: data)) + } + + public func write(_ data: ByteBuffer) { + continuation.yield(.stdout(data)) + } +} + +final class ShellServerInboundHandler: ChannelInboundHandler { + typealias InboundIn = ByteBuffer + + let logger: Logger + let delegate: ShellDelegate + let username: String? + let eventLoop: EventLoop + let inbound = AsyncStream.makeStream() + let outbound = AsyncThrowingStream.makeStream() + let windowSize = AsyncStream.makeStream() + + init(logger: Logger, delegate: ShellDelegate, eventLoop: EventLoop, username: String?) { + self.logger = logger + self.delegate = delegate + self.username = username + self.eventLoop = eventLoop + } + + func handlerAdded(context: ChannelHandlerContext) { + let channel = context.channel + + let shellContext = SSHShellContext( + session: SSHContext(username: self.username), + channel: channel, + windowSize: windowSize.stream + ) + + let done = context.eventLoop.makePromise(of: Void.self) + done.completeWithTask { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await self.delegate.startShell( + inbound: self.inbound.stream, + outbound: ShellOutboundWriter(continuation: self.outbound.continuation), + context: shellContext + ) + } + + group.addTask { + for try await message in self.outbound.stream { + switch message.event { + case .stdout(let data): + try await channel.writeAndFlush(data) + } + } + } + + do { + try await group.next() + try await shellContext.close() + } catch { + try await shellContext.close() + throw error + } + } + } + + done.futureResult.whenFailure(context.fireErrorCaught) + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + inbound.continuation.yield(.stdin(unwrapInboundIn(data))) + } + + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + switch event { + case let event as SSHChannelRequestEvent.WindowChangeRequest: + windowSize.continuation.yield(.init(columns: event.terminalCharacterWidth, rows: event.terminalRowHeight)) + default: + context.fireUserInboundEventTriggered(event) + } + } +} + +enum ShellServerSubsystem { + static func setupChannelHanders( + channel: Channel, + shell: ShellDelegate, + logger: Logger, + username: String? + ) -> EventLoopFuture { + let shellInboundHandler = ShellServerInboundHandler( + logger: logger, + delegate: shell, + eventLoop: channel.eventLoop, + username: username + ) + + return channel.pipeline.addHandlers( + SSHChannelDataUnwrapper(), + SSHOutboundChannelDataWrapper(), + shellInboundHandler, + CloseErrorHandler(logger: logger) + ) + } +} diff --git a/Sources/CitadelServerExample/EchoShell/BasicCommands.swift b/Sources/CitadelServerExample/EchoShell/BasicCommands.swift new file mode 100644 index 0000000..12818d7 --- /dev/null +++ b/Sources/CitadelServerExample/EchoShell/BasicCommands.swift @@ -0,0 +1,93 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Citadel open source and the ClamShell project +// +// Copyright (c) 2023 Gregor Feigel and the Citadel project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Foundation +import NIO +import NIOSSH +import Citadel + +// Basic Commands +// Each command must return an array of UINT8 or nil. Streams are currently not supported. + +public struct InvokeCommandContext: Sendable { + public let arguments: [String] +} + +public protocol ShellCommand { + var name: String { get } + var description: String { get } + + func invoke( + invocation: InvokeCommandContext, + inbound: AsyncStream.AsyncIterator, + outbound: ShellOutboundWriter, + context: SSHShellContext + ) async throws +} + +struct ExitCommand: ShellCommand { + let name: String = "exit" + let description: String = "Ends the SSH session." + + func invoke( + invocation: InvokeCommandContext, + inbound: AsyncStream.AsyncIterator, + outbound: ShellOutboundWriter, + context: SSHShellContext + ) async throws { + outbound.write(ByteBuffer(bytes: Terminal.newLine + Terminal.newLine)) + } +} + +struct ClearCommand: ShellCommand { + let name: String = "clear" + let description: String = "Clears the screen." + + func invoke( + invocation: InvokeCommandContext, + inbound: AsyncStream.AsyncIterator, + outbound: ShellOutboundWriter, + context: SSHShellContext + ) async throws { + outbound.write(Terminal.clear()) + } +} + +struct WhoAmICommand: ShellCommand { + let name: String = "whoami" + let description: String = "Returns the username." + + func invoke( + invocation: InvokeCommandContext, + inbound: AsyncStream.AsyncIterator, + outbound: ShellOutboundWriter, + context: SSHShellContext + ) async throws { + outbound.write(context.session.username ?? "") + } +} + +struct DateCommand: ShellCommand { + let name: String = "date" + let description: String = "Returns the system time." + + func invoke( + invocation: InvokeCommandContext, + inbound: AsyncStream.AsyncIterator, + outbound: ShellOutboundWriter, + context: SSHShellContext + ) async throws { + let date = Date().getDateFormattedBy("EEE MMM d HH:mm:ss zzz yyyy") + outbound.write(date) + } +} \ No newline at end of file diff --git a/Sources/CitadelServerExample/EchoShell/EchoShell.swift b/Sources/CitadelServerExample/EchoShell/EchoShell.swift new file mode 100644 index 0000000..143270e --- /dev/null +++ b/Sources/CitadelServerExample/EchoShell/EchoShell.swift @@ -0,0 +1,186 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Citadel open source and the ClamShell project +// +// Copyright (c) 2023 Gregor Feigel and the Citadel project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Foundation +import NIO +import NIOSSH +import ColorizeSwift +import Citadel + +// Simple shell emulator that returns the user input and offers some basic commands like: help, history, clear, whoami, date and exit. +public struct SimpleShell: ShellDelegate { + public var message: String = "Welcome to Citadel Server!" + public var hostname: String = "citadel" + public var commands: [any ShellCommand] = [ + ExitCommand(), + ClearCommand(), + // HistoryCommand(), + WhoAmICommand(), + DateCommand(), + // HelpCommand() + ] + + public func startShell( + inbound: AsyncStream, + outbound: ShellOutboundWriter, + context: SSHShellContext + ) async throws { + var shell = _SimpleShell( + commands: commands, + continuation: outbound, + context: context, + hostname: hostname + ) + + var iterator = inbound.makeAsyncIterator() + outbound.write(message) + outbound.write(Terminal.newLine) + outbound.write(shell.pseudoPrompt) + + while let message = await iterator.next() { + if case .stdin(var input) = message { + try await shell.writeInput(&input, inbound: iterator) + } + + if context.isClosed || Task.isCancelled { + break + } + } + } +} + +fileprivate struct _SimpleShell { + let commands: [any ShellCommand] + let outbound: ShellOutboundWriter + let context: SSHShellContext + let hostname: String + + init( + commands: [any ShellCommand], + continuation: ShellOutboundWriter, + context: SSHShellContext, + hostname: String + ) { + self.commands = commands + self.outbound = continuation + self.context = context + self.hostname = hostname + } + + private var sessionId: UUID = UUID() + private var currentLine = ByteBuffer() + private var terminal: Terminal = Terminal() + + public mutating func clearLine() { currentLine.clear() } + + public mutating func writeInput( + _ input: inout ByteBuffer, + inbound: AsyncStream.AsyncIterator + ) async throws { + let view = input.readableBytesView + if let enterIndex = view.firstIndex(of: Terminal.enter) { + let size = enterIndex - view.startIndex + currentLine.writeImmutableBuffer(input.readSlice(length: size)!) + + terminal.resetTrack() + + let inputString = currentLine.readString(length: currentLine.readableBytes) + + if + let inputString, + let command = commands.first(where: { $0.name == inputString }) + { + outbound.write(Terminal.newLine) + do { + try await command.invoke( + // TODO: Actual arguments + invocation: InvokeCommandContext(arguments: [inputString]), + inbound: inbound, + outbound: outbound, + context: context + ) + + outbound.write(Terminal.newLine) + } catch { + outbound.write(Terminal.newLine) + outbound.write("Error: \(error)".red()) + outbound.write(Terminal.newLine) + } + + outbound.write(pseudoPrompt) + } else { + if let inputString { + outbound.write(Terminal.newLine) + outbound.write("Unknown command: \(inputString)".red()) + } + + // write prompt + outbound.write(Terminal.newLine) + outbound.write(pseudoPrompt) + } + + currentLine.clear() + } else { + let input = input.readSlice(length: input.readableBytes)! + currentLine.writeImmutableBuffer(input) + outbound.write(input) + } + + // // check for commands + // if let command = commands.first(where: { Array($0.name.utf8) == currentLine }) { + // break + // } + + // if !currentLine.isEmpty { + // // start new line + // outbound.write(Terminal.newLine) + + // // echo line + // outbound.write(currentLine) + + // currentLine = [] + // } + + // case Terminal.deleteCommand: + // if !currentLine.isEmpty { + // outbound.write(Terminal.delete()) + // currentLine = currentLine.dropLast() + // } + // // case Terminal.arrowUp: + // // if let output = history.go_back(currentLineCount: currentLine.count) { + // // stdout(output.0) + // // currentLine = output.1 + // // } + // // case Terminal.arrowDown: + // // if let output = history.go_forward(currentLineCount: currentLine.count) { + // // stdout(output.0) + // // currentLine = output.1 + // // } + // case Terminal.arrowLeft: + // if let output: [UInt8] = terminal.trackMoveLeft(limit: currentLine.count) { + // outbound.write(output) + // } + // case Terminal.arrowRight: + // if let output = terminal.trackMoveRight(limit: currentLine.count) { + // outbound.write(output) + // } + // default: + // currentLine.writeImmutableBuffer(input) + // outbound.write(input) + // } + } + + fileprivate var pseudoPrompt: [UInt8] { + return Array("\(context.session.username ?? "")@\(hostname)".green().utf8) + Array(":~$ ".foregroundColor(.darkViolet).utf8) + } +} \ No newline at end of file diff --git a/Sources/CitadelServerExample/EchoShell/Extensions.swift b/Sources/CitadelServerExample/EchoShell/Extensions.swift new file mode 100644 index 0000000..50c7cb5 --- /dev/null +++ b/Sources/CitadelServerExample/EchoShell/Extensions.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Citadel open source and the ClamShell project +// +// Copyright (c) 2023 Gregor Feigel and the Citadel project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Foundation + +extension Date { + func getDateFormattedBy(_ format: String, utc: Bool = true) -> String { + let dateformat = DateFormatter() + if utc { + dateformat.timeZone = TimeZone(identifier: "UTC") + } + dateformat.dateFormat = format + return dateformat.string(from: self) + } +} diff --git a/Sources/CitadelServerExample/EchoShell/Terminal.swift b/Sources/CitadelServerExample/EchoShell/Terminal.swift new file mode 100644 index 0000000..7a4af1b --- /dev/null +++ b/Sources/CitadelServerExample/EchoShell/Terminal.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Citadel open source and the ClamShell project +// +// Copyright (c) 2023 Gregor Feigel and the Citadel project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Foundation + +struct Terminal { + static let enter: UInt8 = 0x0d + static let deleteCommand: [UInt8] = [127] + static let newLine: [UInt8] = [0x0a, 0x0d] + static let arrowUp: [UInt8] = [0x1B, 0x5B, 0x41] + static let arrowDown: [UInt8] = [0x1B, 0x5B, 0x42] + static let arrowLeft: [UInt8] = [0x1B, 0x5B, 0x44] + static let arrowRight: [UInt8] = [0x1B, 0x5B, 0x43] + static let altArrowLeft: [UInt8] = [0x1B, 0x5B, 0x1B, 0x5B, 0x44] + static let altArrowRight: [UInt8] = [0x1B, 0x5B, 0x1B, 0x5B, 0x43] + + @inlinable static func moveBackwards(char: UInt8) -> [UInt8] { + return [0x1B, 0x5B, char, 0x44] + } + + @inlinable static func clear() -> [UInt8] { + return [0x1B, 0x5B, 0x32, 0x4A, 0x1B, 0x5B, 0x48] + } + + @inlinable static func delete() -> [UInt8] { + return [0x1B, 0x5B, 0x44, 0x20, 0x1B, 0x5B, 0x44] + } + + var column: Int = 0 + + mutating func trackMoveLeft(limit: Int) -> [UInt8]? { + if column < limit { + column += 1 + return [0x1B, 0x5B, 0x44] + } else { + return nil + } + } + + mutating func trackMoveRight(limit: Int) -> [UInt8]? { + if column > 0 { + column -= 1 + return [0x1B, 0x5B, 0x43] + } else { + return nil + } + } + + mutating func resetTrack() { column = 0 } +} diff --git a/Sources/CitadelServerExample/HostKeyFile.swift b/Sources/CitadelServerExample/HostKeyFile.swift new file mode 100644 index 0000000..17a50af --- /dev/null +++ b/Sources/CitadelServerExample/HostKeyFile.swift @@ -0,0 +1,45 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Citadel open source and the ClamShell project +// +// Copyright (c) 2023 Gregor Feigel and the Citadel project authors +// Licensed under MIT License +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: MIT +// +//===----------------------------------------------------------------------===// + +import Foundation +import Crypto +import NIOSSH + +public extension NIOSSHPrivateKey { + init(file: URL = FileManager.default.temporaryDirectory.appendingPathComponent("citadel_ssh_host_key")) throws { + let hostKeyFile = HostKey(file: file) + try self.init(ed25519Key: .init(rawRepresentation: hostKeyFile.key)) + } +} + +public struct HostKey { + init(file: URL = FileManager.default.temporaryDirectory.appendingPathComponent("citadel_ssh_host_key")) { + self.file = file + } + + let file: URL + + var key: Data { + get throws { + if FileManager.default.fileExists(atPath: file.path) { + return try Data(contentsOf: file) + } else { + // generate, store and return new key + let key: Curve25519.Signing.PrivateKey = .init() + try key.rawRepresentation.write(to: file) + return key.rawRepresentation + } + } + } +} + diff --git a/Sources/CitadelServerExample/Server.swift b/Sources/CitadelServerExample/Server.swift new file mode 100644 index 0000000..67a90f6 --- /dev/null +++ b/Sources/CitadelServerExample/Server.swift @@ -0,0 +1,57 @@ +import Citadel +import Crypto +import Foundation +import NIO +import NIOSSH + +@main struct ExampleSSHServer { + static func main() async throws { + let privateKey: Curve25519.Signing.PrivateKey + let privateKeyURL = URL(fileURLWithPath: "./citadel_host_key_ed25519") + + // Read or create a private key + if let file = try? Data(contentsOf: privateKeyURL) { + // File exists, read it into a Curve25519 private key + privateKey = try Curve25519.Signing.PrivateKey(sshEd25519: file) + } else { + // File does not exist, create a new Curve25519 private + privateKey = Curve25519.Signing.PrivateKey() + + // Write the private key to a file + try privateKey.makeSSHRepresentation().write(to: privateKeyURL, atomically: true, encoding: .utf8) + } + + let server = try await SSHServer.host( + host: "localhost", + port: 2323, + hostKeys: [ + NIOSSHPrivateKey(ed25519Key: privateKey) + ], + authenticationDelegate: LoginHandler(username: "joannis", password: "test") + ) + + server.enableShell(withDelegate: SimpleShell()) + + try await server.closeFuture.get() + } +} + +struct LoginHandler: NIOSSHServerUserAuthenticationDelegate { + let username: String + let password: String + + var supportedAuthenticationMethods: NIOSSHAvailableUserAuthenticationMethods { + .password + } + + func requestReceived( + request: NIOSSHUserAuthenticationRequest, + responsePromise: EventLoopPromise + ) { + if case .password(.init(password: password)) = request.request, request.username == username { + return responsePromise.succeed(.success) + } + + return responsePromise.succeed(.failure) + } +} \ No newline at end of file