-
-
Notifications
You must be signed in to change notification settings - Fork 41
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #65 from orlandos-nl/jo/updated-shell
Updated the SSH shell example
- Loading branch information
Showing
18 changed files
with
956 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,4 +5,5 @@ | |
/*.xcodeproj | ||
xcuserdata/ | ||
Package.resolved | ||
citadel_host_key_ed25519 | ||
.vscode/launch.json |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
.DS_Store | ||
/.build | ||
/Packages | ||
xcuserdata/ | ||
DerivedData/ | ||
.swiftpm/configuration/registries.json | ||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata | ||
.netrc |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"), | ||
] | ||
), | ||
] | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<ShellClientEvent>, | ||
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<NIOSSHUserAuthenticationOutcome> | ||
) { | ||
if case .password(.init(password: password)) = request.request, request.username == username { | ||
return responsePromise.succeed(.success) | ||
} | ||
|
||
return responsePromise.succeed(.failure) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<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 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<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() | ||
} | ||
} |
File renamed without changes.
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.