Citadel 是围绕 NIOSSH 构建的高级 API。它使 NIOSSH 更易于访问和采用,同时提供了 NIOSSH 范围之外的工具。

Citadel 正由我们的团队或 Swift 专家积极开发中。请通过我们的 Discord 社区 与我们联系。

您需要专业的支持吗?请通过 joannis@unbeatable.software 联系我们

客户端使用

Citadel 的 SSHClient 需要首先连接到 SSH 服务器

let client = try await SSHClient.connect(
    host: "example.com",
    authenticationMethod: .passwordBased(username: "joannis", password: "s3cr3t"),
    hostKeyValidator: .acceptAnything(), // Please use another validator if at all possible, it's insecure
    reconnect: .never
)

使用该客户端,我们支持几种类型的操作

执行命令

您可以使用以下代码通过 SSH 执行命令

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

此外,可以设置最大响应大小,并且 stderr 可以与 stdout 合并,以便答案包含两个流的内容

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

executeCommand 函数将信息累积到连续的 ByteBuffer 中。这对于非交互式命令(如 catls)很有用。

executeCommandPair 函数或 executeCommandStream 函数可用于独立访问 stdoutstderr。这两个函数还将信息累积到单独的连续 ByteBuffer 中。

如何使用 executeCommandPair 的示例

let streams = 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
}

如何使用 executeCommandStream 的示例

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 目前公开了用于流式传输到进程 stdin 的 API。仅适用于 withPTY 和 withTTY。

如何使用 pty 模型的示例

try await client.withPTY(
        SSHChannelRequestEvent.PseudoTerminalRequest(
            wantReply: true,
            term: "xterm",
            terminalCharacterWidth: 80,
            terminalRowHeight: 24,
            terminalPixelWidth: 0,
            terminalPixelHeight: 0,
            terminalModes: .init([.ECHO: 1])
        ),
        environment: [SSHChannelRequestEvent.EnvironmentRequest(wantReply: true, name: "LANG", value: "en_US.UTF-8")]) {
        
        ttyOutput, ttyStdinWriter in 
        
        ...do something...
}

SFTP 客户端

要开始使用 SFTP,您必须基于您的 SSHClient 实例化一个 SFTPClient

// 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 转发(代理)

// 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(...)
}

这将创建一个连接到 SSH 服务器的通道,然后转发到目标主机。这对于代理 TCP-IP 连接(例如 MongoDB、Redis、MySQL 等)非常有用。

服务器

要使用 Citadel,首先您需要创建并启动一个 SSH 服务器,使用您自己的身份验证委托

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
            }
        }
    }
}

然后,创建服务器

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)
)

然后,启用 SFTP 服务器或允许执行命令。不用担心,这些命令不会针对主机系统。您可以自己实现文件系统和 shell 访问!因此您可以决定权限、实际存储位置,并进行您需要的任何操作

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

如果您从 main.swift@main 注释类型运行 SSHServer,请确保 Swift 不会退出或 deinit 服务器。一个适用于大多数情况的简单解决方案是使用服务器的 closeFuture

try await server.closeFuture.get()

Exec 服务器

当创建命令执行委托时,只需实现 ExecDelegate 协议和以下函数

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

setEnvironmentValue 函数添加一个环境变量,您可以将其传递给子进程。start 命令只是“在 shell 中”执行命令。如何以及是否处理该命令取决于您。执行的 command 作为第一个参数输入,第二个参数(ExecOutputHandler)包含经过身份验证的用户、用于 stdinstdoutstderr 的管道,以及一些指示进程已退出的函数调用。

无论您是模拟进程还是连接真实的子进程,要求都是相同的。您必须提供退出代码或从执行函数中抛出错误。您也可以使用错误在 outputHandler 上 fail 进程。最后,您必须返回一个代表您的进程的 ExecCommandContext。它可以接收远程 terminate 信号,或接收 stdin 通过 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 setEnvironmentValue(_ value: String, forKey key: String) async throws {
        // Set the environment variable
        environment[key] = value
    }

    public func start(command: String, outputHandler: ExecOutputHandler) async throws -> ExecCommandContext {
        // Start the command
        let process = Process()

        // This uses bash as the shell to execute the command
        // You can use any shell you want, or even a custom one
        // This is just an example, you can do whatever you want
        // as long as you provide an exit code
        process.executableURL = URL(fileURLWithPath: "/bin/bash")
        process.arguments = ["-c", command]
        process.environment = environment
        process.standardInput = outputHandler.stdin
        process.standardOutput = outputHandler.stdout
        process.standardError = outputHandler.stderr
        process.terminationHandler = { process in
            // Send the exit code
            outputHandler.exit(code: Int(process.terminationStatus))
        }

        // Start the process
        try process.run()
        return ExecProcessContext(process: process)
    }
}

SFTP 服务器

当您在 Citadel 中实现 SFTP 时,您负责处理后勤。无论是通过后端的 MongoDB 存储、真实的文件系统还是您的 S3 存储桶。

助手

大多数人最需要的助手是 OpenSSH 密钥解析。我们支持 PrivateKey 类型的扩展,例如我们自己的 Insecure.RSA.PrivateKey,以及现有的 SwiftCrypto 类型,如 Curve25519.Signing.PrivateKey

// Parse an OpenSSH RSA private key. This is the same format as the one used by OpenSSH
let sshFile = try String(contentsOf: ..)
let privateKey = try Insecure.RSA.PrivateKey(sshRsa: sshFile)

FAQ

如果您无法连接到服务器,则可能是您的服务器使用了 NIOSSH 不支持的已弃用算法集。不过不用担心,因为 Citadel 确实实现了这些!如果可以避免,请不要使用这些,因为它们因(安全)原因而被弃用。

// Create a new set of algorithms
var algorithms = SSHAlgorithms()

algorithms.transportProtectionSchemes = .add([
    AES128CTR.self
])

algorithms.keyExchangeAlgorithms = .add([
    DiffieHellmanGroup14Sha1.self,
    DiffieHellmanGroup14Sha256.self
])

然后您可以在 SSHClient 中使用这些算法,以及任何其他可能的协议配置选项

// Connect to the server using the new algorithms and a password-based authentication method
let client = try await SSHClient.connect(
    host: "example.com",
    authenticationMethod: .passwordBased(username: "joannis", password: "s3cr3t"),
    hostKeyValidator: .acceptAnything(), // Please use another validator if at all possible, it's insecure
    reconnect: .never,
    algorithms: algorithms,
    protocolOptions: [
        .maximumPacketSize(1 << 20)
    ]
)

您还可以使用 SSHAlgorithms.all 来启用所有支持的算法。

TODO

部分代码被保留,直到 SwiftNIO SSH 中的进一步工作完成。我们目前正在与 Apple 合作解决这些问题。

贡献

我很高兴接受关于新 API 的想法和 PR。