SkillAgentSearch skills...

Citadel

SSH Client & Server framework in Swift

Install / Use

/learn @orlandos-nl/Citadel
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

Citadel is a high level API around NIOSSH. It makes NIOSSH accessible and easy to adopt, while providing tools otherwise out-of-scope for NIOSSH.

Citadel is in active development by our team or Swift experts. Get in touch with our Discord Community.

Client Usage

Citadel's SSHClient needs a connection to a SSH server first:

let settings = SSHClientSettings(
    host: "example.com",
    authenticationMethod: { .passwordBased(username: "joannis", password: "s3cr3t") },
    // Please use another validator if at all possible, it's insecure
    // But it's an easy way to try out Citadel
    hostKeyValidator: .acceptAnything()
)
let client = try await SSHClient.connect(to: settings)

Using that client, we support a couple types of operations:

Executing Commands

You can execute a command through SSH using the following code:

let stdout = try await client.executeCommand("ls -la ~")

Additionally, a maximum responsive response size can be set, and stderr can be merged with stdout so that the answer contains the content of both streams:

let stdoutAndStderr = try await client.executeCommand("ls -la ~", maxResponseSize: 42, mergeStreams: true)

The executeCommand function accumulated information into a contiguous ByteBuffer. This is useful for non-interactive commands such as cat and ls.

The executeCommandPair function or executeCommandStream function can be used to access stdout and stderr independently. Both functions also accumulate information into contiguous separate ByteBuffers.

An example of how executeCommandPair can be used:

let answer = try await client.executeCommandPair("cat /foo/bar.log")

for try await blob in answer.stdout {
    // do something with blob
}

for try await blob in answer.stderr {
    // do something with blob
}

An example of how executeCommandStream can be used:

let streams = try await client.executeCommandStream("cat /foo/bar.log")

for try await event in streams {
    switch event {
    case .stdout(let stdout):
        // do something with stdout
    case .stderr(let stderr):
        // do something with stderr
    }
}

Citadel currently expose APIs for streaming into a process' stdin. only withPTY and withTTY.

An example of how pty model can be used:

try await client.withPTY(
    SSHChannelRequestEvent.PseudoTerminalRequest(
        wantReply: true,
        term: "xterm",
        terminalCharacterWidth: 80,
        terminalRowHeight: 24,
        terminalPixelWidth: 0,
        terminalPixelHeight: 0,
        terminalModes: .init([.ECHO: 1])
    )
) { ttyOutput, ttyStdinWriter in
    // ...do something...
}

Jump Hosts

Citadel supports jumping to another Host. First, connect to the jump host:

let jumpHostSettings = SSHClientSettings(
    host: "jump.example.com",
    authenticationMethod: .passwordBased(username: "joannis", password: "s3cr3t"),
    hostKeyValidator: .acceptAnything()
)
let jumpHostClient = try await SSHClient.connect(to: jumpHostSettings)

Then, jump to the target host:

let targetHostSettings = SSHClientSettings(
    host: "target.example.com",
    authenticationMethod: .passwordBased(username: "joannis", password: "s3cr3t"),
    hostKeyValidator: .acceptAnything()
)
let targetHostClient = try await jumpHostClient.jump(to: targetHostSettings)

You can chain multiple jumps this way as well.

SFTP Client

To begin with SFTP, you must instantiate an SFTPClient based on your SSHClient:

// Open an SFTP session on the SSH client
let sftp = try await client.openSFTP()

// Get the current working directory
let cwd = try await sftp.getRealPath(atPath: ".")
//Obtain the real path of the directory eg "/opt/vulscan/.. -> /opt"
let truePath = try await sftp.getRealPath(atPath: "/opt/vulscan/..")
// List the contents of the /etc directory
let directoryContents = try await sftp.listDirectory(atPath: "/etc")

// Create a directory
try await sftp.createDirectory(atPath: "/etc/custom-folder")

// 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
let data = try await sftp.withFile(
    filePath: "/etc/resolv.conf",
    flags: .read
) { file in
    try await file.readAll()
}

// Close the SFTP session
try await sftp.close()

TCP-IP Forwarding (Proxying)

// 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:

import NIOSSH
import Citadel

// Create a custom authentication delegate that uses MongoDB to authenticate users
// This is just an example, you can use any database you want
// You can use public key authentication, password authentication, or both.
struct MyCustomMongoDBAuthDelegate: NIOSSHServerUserAuthenticationDelegate {
    let db: MongoKitten.Database

    let supportedAuthenticationMethods: NIOSSHAvailableUserAuthenticationMethods = [.password, .publicKey]
    
    func requestReceived(request: NIOSSHUserAuthenticationRequest, responsePromise: EventLoopPromise<NIOSSHUserAuthenticationOutcome>) {
        responsePromise.completeWithTask {
            // Authenticate the user
            guard let user = try await db[User.self].findOne(matching: { user in
                user.$username == username
            }) else {
                // User does not exist
                return .failure
            }

            switch request.request {
            case .hostBased. none:
                // Not supported
                return .failure
            case .publicKey(let publicKey):
                // Check if the public key is correct
                guard publicKey.publicKey == user.publicKey else {
                    return .failure
                }

                return .success
            case .password(let request):
                // Uses Vapor's Bcrypt library to verify the password
                guard try Bcrypt.verify(request.password, created: user.password) else {
                    return .failure
                }
                
                return .success
            }
        }
    }
}

Then, create the server:

let server = try await SSHServer.host(
    host: "0.0.0.0",
    port: 22,
    hostKeys: [
        // This hostkey changes every app boot, it's more practical to use a pre-generated one
        NIOSSHPrivateKey(ed25519Key: .init())
    ],
    authenticationDelegate: MyCustomMongoDBAuthDelegate(db: mongokitten)
)

Then, enable the SFTP server or allow executing commands. Don't worry, these commands do not target the host system. You can implement filsystem and shell access yourself! So you get to dictate permissions, where it's actually stored, and do any shenanigans you need:

server.enableExec(withDelegate: MyExecDelegate())
server.enableSFTP(withDelegate: MySFTPDelegate())

If you're running the SSHServer from main.swift or an @main annotated type, make sure that Swift doesn't exit or deinit the server. A simple solution that is applicable most of the time is to use the server's closeFuture.

try await server.closeFuture.get()

Exec Server

When creating a command execution delegate, simply implement the ExecDelegate protocol and the following functions:

func setEnvironmentValue(_ value: String, forKey key: String) async throws
func start(command: String, outputHandler: ExecOutputHandler) async throws -> ExecCommandContext

The setEnvironmentValue function adds an environment variable, which you can pass onto child processes. The start command simply executed the command "in the shell". How and if you process that command is up to you. The executed command is inputted as the first argument, and the second argument (the ExecOutputHandler), contains the authenticated user, Pipes for stdin, stdout and stderr as well as some function calls for indicating a process has exited.

Whether you simulate a process, or hook up a real child-process, the requirements are the same. You must provide an exit code or throw an error out of the executing function. You can also fail on the outputHandler the process using an error. Finally, you'll have to return an ExecCommandContext that represents your process. This can receive remote terminate signals, or receive a notification that stdin was closed through inputClosed.

import Foundation

/// A context that represents a process that is being executed.
/// This can receive remote `terminate` signals, or receive a notification that `stdin` was closed through `inputClosed`.
struct ExecProcessContext: ExecCommandContext {
    let process: Process
    
    func terminate() async throws {
        process.terminate()
    }
    
    func inputClosed() async throws {
        try process.stdin.close()
    }
}

/// An example of a custom ExecDelegate that uses bash as the shell to execute commands
public final class MyExecDelegate: ExecDelegate {
    var environment: [String: String] = [:]

    public func 
View on GitHub
GitHub Stars349
CategoryDevelopment
Updated3d ago
Forks80

Languages

Swift

Security Score

100/100

Audited on Mar 24, 2026

No findings