Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Development of EchoShell - A simple shell emulator #39

Closed
Closed
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
25 changes: 17 additions & 8 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,22 @@
"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",
"state": {
"branch": null,
"revision": "ff3d2212b6b093db7f177d0855adbc4ef9c5f036",
"version": "1.0.3"
"revision": "6c89474e62719ddcc1e9614989fff2f68208fe10",
"version": "1.1.0"
}
},
{
Expand All @@ -42,26 +51,26 @@
"repositoryURL": "https://github.com/apple/swift-log.git",
"state": {
"branch": null,
"revision": "6fe203dc33195667ce1759bf0182975e4653ba1c",
"version": "1.4.4"
"revision": "32e8d724467f8fe623624570367e3d50c5638e46",
"version": "1.5.2"
}
},
{
"package": "swift-nio",
"repositoryURL": "https://github.com/apple/swift-nio.git",
"state": {
"branch": null,
"revision": "45167b8006448c79dda4b7bd604e07a034c15c49",
"version": "2.48.0"
"revision": "2d8e6ca36fe3e8ed74b0883f593757a45463c34d",
"version": "2.53.0"
}
},
{
"package": "swift-nio-ssh",
"repositoryURL": "https://github.com/Joannis/swift-nio-ssh.git",
"state": {
"branch": null,
"revision": "d5fc603de485eca5a1e657361e3a8452875d108b",
"version": "0.3.0"
"revision": "70506c0345480a9070ef71b58cff2b3f3e6a7662",
"version": "0.3.1"
}
}
]
Expand Down
9 changes: 8 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ let package = Package(
),
],
dependencies: [
.package(name: "swift-nio-ssh", url: "https://github.com/Joannis/swift-nio-ssh.git", "0.3.0" ..< "0.4.0"),
.package(name: "swift-nio-ssh", url: "https://github.com/Joannis/swift-nio-ssh.git", "0.3.1" ..< "0.4.0"),
.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"),
Expand All @@ -32,8 +33,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")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While I understand the desire to import this library - I don't want to add a dependency for this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, then I'll embed the necessary code parts of the library and mention the author above?

]
),
.executableTarget(
name: "CitadelServerExample",
dependencies: [
"Citadel"
]),
.testTarget(
name: "CitadelTests",
dependencies: [
Expand Down
124 changes: 124 additions & 0 deletions Sources/Citadel/Exec/Client/ExecClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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<ByteBuffer>

init(
maxResponseSize: Int,
done: EventLoopPromise<ByteBuffer>
) {
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 is SSHChannelRequestEvent.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<Void>?) {
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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,21 +91,6 @@ final class ExecHandler: ChannelDuplexHandler {
}
}

func channelRead(context: ChannelHandlerContext, data: NIOAny) {
let data = self.unwrapInboundIn(data)

guard case .byteBuffer(let bytes) = data.data else {
fatalError("Unexpected read type")
}

guard case .channel = data.type else {
context.fireErrorCaught(SSHServerError.invalidDataType)
return
}

context.fireChannelRead(self.wrapInboundOut(bytes))
}

func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise<Void>?) {
context.write(data, promise: promise)
}
Expand Down
46 changes: 46 additions & 0 deletions Sources/Citadel/HostKeyFile.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//===----------------------------------------------------------------------===//
//
// 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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These helpers cannot go in the library. But you can add them to your extension.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you consider to add this as an default directly to NIOSSHPrivateKey?

init(file: URL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("citadel_ssh_host_key")) throws {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The URL should not use this default. It's best to leave the defaults empty

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

let hostKeyFile = HostKey(file: file)
try self.init(ed25519Key: .init(rawRepresentation: hostKeyFile.key))
}
}

public struct HostKey {

init(file: URL = FileManager.default.homeDirectoryForCurrentUser.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
}
}
}
}

19 changes: 16 additions & 3 deletions Sources/Citadel/SFTP/Server/SFTPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ public struct SSHContext {
public let username: String?
}

public struct SSHShellContext {
public let session: SSHContext
internal let channel: Channel

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.
Expand Down Expand Up @@ -66,18 +79,18 @@ public protocol SFTPDelegate {
func readSymlink(atPath path: String, context: SSHContext) async throws -> [SFTPPathComponent]
}

struct SFTPServerSubsystem {
enum SFTPServerSubsystem {
static func setupChannelHanders(
channel: Channel,
delegate: SFTPDelegate,
sftp: SFTPDelegate,
logger: Logger,
username: String?
) -> EventLoopFuture<Void> {
let deserializeHandler = ByteToMessageHandler(SFTPMessageParser())
let serializeHandler = MessageToByteHandler(SFTPMessageSerializer())
let sftpInboundHandler = SFTPServerInboundHandler(
logger: logger,
delegate: delegate,
delegate: sftp,
eventLoop: channel.eventLoop,
username: username
)
Expand Down
Loading