Citadel

repository·main·Indexed 18 days ago

https://github.com/orlandos-nl/citadel

A high-level Swift API built on top of NIOSSH that simplifies SSH tasks including client connections, command execution, SFTP, jump hosts, and TCP-IP forwarding. It provides a framework for building custom SSH servers via NIOSSHServerUserAuthenticationDelegate and ExecDelegate, and includes support for parsing OpenSSH private keys and configuring custom SSHAlgorithms for legacy server compatibility.

Tokens
7K
Snippets
23
Records
25
Agent score
63%

What's inside Citadel

  1. How remote port forwarding works in Citadel

    main

    Remote port forwarding follows this lifecycle:

    1. The Citadel client connects to the SSH server.
    2. The client requests the server to listen on a specific port (the remote port).
    3. When a client connects to that port on the remote server, the SSH server opens a forwarded-tcpip channel.
    4. The Citadel client receives this connection via a closure and forwards it to a local host and port.
    5. Data flows bidirectionally: remote client ↔ SSH tunnel ↔ local service.

    Advanced Routing: You can inspect forwardedInfo.originatorAddress or forwardedInfo.listeningPort within the handler closure to implement custom routing logic, such as directing different connections to different local services.

  2. Implement an SSH Server

    main

    To run an SSH server with Citadel, follow these steps:

    1. Implement NIOSSHServerUserAuthenticationDelegate to handle user authentication (e.g., checking passwords or public keys against a database).
    2. Create the server using SSHServer.host(...), providing the host, port, host keys, and your authentication delegate.
    3. Enable specific services like command execution or SFTP by providing delegates that implement ExecDelegate or SFTPDelegate.
    4. Keep the server running by awaiting server.closeFuture.
    // 1. Define Authentication
    struct MyCustomAuthDelegate: NIOSSHServerUserAuthenticationDelegate { ... }
    
    // 2. Create Server
    let server = try await SSHServer.host(
        host: "0.0.0.0",
        port: 22,
        hostKeys: [NIOSSHPrivateKey(ed25519Key: .init())],
        authenticationDelegate: MyCustomAuthDelegate()
    )
    
    // 3. Enable Services
    server.enableExec(withDelegate: MyExecDelegate())
    server.enableSFTP(withDelegate: MySFTPDelegate())
    
    // 4. Keep alive
    try await server.closeFuture.get()
  3. Connect an SSHClient to a server

    main

    To use Citadel, you must first establish a connection using SSHClient.connect(to:) with an SSHClientSettings object. You must provide a host, an authentication method (such as .passwordBased), and a hostKeyValidator.

    Warning: Using .acceptAnything() for the hostKeyValidator is insecure and should only be used for testing/prototyping.

    let settings = SSHClientSettings(
        host: "example.com",
        authenticationMethod: { .passwordBased(username: "joannis", password: "s3cr3t") },
        // Please use another validator if at all possible, it's insecure
        hostKeyValidator: .acceptAnything()
    )
    let client = try await SSHClient.connect(to: settings)
  4. Use Jump Hosts to reach target hosts

    main

    Citadel allows you to chain connections through a jump host. You first connect to the jump host using SSHClient.connect, and then call .jump(to:) on that client instance to reach the target host.

    // 1. Connect to 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)
    
    // 2. Jump to 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)
  5. Configure SSH algorithms for legacy server compatibility

    main

    If you encounter connection failures due to deprecated algorithms not supported by standard NIOSSH, Citadel allows you to manually configure a custom set of SSHAlgorithms. You can add specific transport protection schemes and key exchange algorithms to a new SSHAlgorithms instance and pass it to the SSHClient.connect method.

    Note: Use these only when necessary, as deprecated algorithms are insecure. To enable all supported algorithms, you can use SSHAlgorithms.all.

    // Create a new set of algorithms
    var algorithms = SSHAlgorithms()
    
    algorithms.transportProtectionSchemes = .add([
        AES128CTR.self
    ])
    
    algorithms.keyExchangeAlgorithms = .add([
        DiffieHellmanGroup14Sha1.self,
        DiffieHellmanGroup14Sha256.self
    ])
    
    // Connect to the server using the custom algorithms
    let client = try await SSHClient.connect(
        host: "example.com",
        authenticationMethod: .passwordBased(username: "joannis", password: "s3cr3t"),
        hostKeyValidator: .acceptAnything(), // Warning: insecure for production
        reconnect: .never,
        algorithms: algorithms,
        protocolOptions: [
            .maximumPacketSize(1 << 20)
        ]
    )
  6. Run the Example Server using Docker

    main

    To test the Citadel client, you can run a local SSH server using the provided Dockerfile.

    1. Build the image: docker build --file ExampleServer.dockerfile --tag sshd-example .
    2. Run the container, mapping host port 2323 to container port 22: docker run -p 2323:22 sshd-example
    docker build --file ExampleServer.dockerfile --tag sshd-example .
    docker run -p 2323:22 sshd-example
  7. Configure SSH algorithms with SSHAlgorithms

    main

    Use SSHAlgorithms to manage and modify the cryptographic algorithms used for transport protection, key exchange, and public key/signature schemes. You can either replace the existing list of algorithms or add to them using the Modification enum.

    Available modification types:

    • .replace(with: [T]): Replaces the current list with the provided list.
    • .add([T]): Appends the provided list to the current list.

    SSHAlgorithms.all provides a pre-configured set of algorithms including AES128CTR, specific Diffie-Hellman groups, and RSA.

    var algorithms = SSHAlgorithms()
    algorithms.transportProtectionSchemes = .add([AES128CTR.self])
    algorithms.keyExchangeAlgorithms = .replace(with: [DiffieHellmanGroup14Sha256.self])
    // Use these algorithms when connecting
  8. Troubleshoot remote port forwarding issues

    main

    Address already in use

    If the remote port is already bound, choose a different port or use 0 to let the server choose an available port.

    Connection refused

    Verify that:

    1. Your local service is actually running.
    2. The remote port forward was successfully established.
    3. You are connecting to the correct remote host and port.

    Permission denied for port < 1024

    Binding to privileged ports (below 1024) usually requires root privileges on the remote server. Use a port number $\ge$ 1024.

  9. Use a Pseudo-Terminal (PTY) for interactive sessions

    main

    To stream data into a process's stdin (interactive behavior), use the withPTY method. This requires providing a PseudoTerminalRequest which defines terminal characteristics like dimensions and modes.

    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...
    }
  10. Implement remote port forwarding with `createRemotePortForward`

    main

    Remote port forwarding (reverse tunneling) allows you to expose a local service through a remote SSH server. You can use createRemotePortForward on a Citadel client to request the server to listen on a specific port and forward incoming connections back to your local machine.

    To implement this, provide a closure that is called for every incoming connection. Inside this closure, you typically use a ClientBootstrap to connect to your local service and then set up bidirectional data forwarding between the forwardedChannel and the localChannel.

    // Request remote port forwarding
    let forward = try await client.createRemotePortForward(
        host: "0.0.0.0",      // Listen on all interfaces
        port: 8080            // Port to listen on (0 = server chooses)
    ) { forwardedChannel, forwardedInfo in
        // This closure is called for each incoming connection
    
        // Connect to your local service
        return ClientBootstrap(group: forwardedChannel.eventLoop)
            .connect(host: "127.0.0.1", port: 3000)
            .flatMap { localChannel in
                // Set up bidirectional data forwarding
                // forwardedChannel ↔ localChannel
            }
    }
    
    print("Listening on remote port: \(forward.boundPort)")
    
    // Later, cancel the forward
    try await client.cancelRemotePortForward(forward)
  11. Perform SFTP operations

    main

    To use SFTP, instantiate an SFTPClient from an existing SSHClient using openSFTP().

    Common operations include:

    • getRealPath(atPath:): Resolves the real path of a directory or file.
    • listDirectory(atPath:): Lists contents of a directory.
    • createDirectory(atPath:): Creates a new directory.
    • withFile(filePath:flags:): A helper to safely open, read/write, and automatically close a file. Use SFTPFile.Flags (e.g., .read, .write, .forceCreate) to specify access modes.
    let sftp = try await client.openSFTP()
    
    // List and resolve paths
    let cwd = try await sftp.getRealPath(atPath: ".")
    let directoryContents = try await sftp.listDirectory(atPath: "/etc")
    
    // File manipulation
    try await sftp.createDirectory(atPath: "/etc/custom-folder")
    
    try await sftp.withFile(filePath: "/etc/resolv.conf", flags: [.read, .write, .forceCreate]) { file in
        try await file.write(ByteBuffer(string: "Hello, world", at: 0))
    }
    
    try await sftp.close()