diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a72dd2e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true + +[*.rs] +indent_style = space +indent_size = 4 + +# disable line wrapping in Markdown files +[docs/**.md] +max_line_length = 0 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9554fff --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Makiko changelog + +## 0.2.1 + +- Deprecate `Pubkey::algos_secure()` and `Pubkey::algos_compatible_less_secure()`, replace with +`Pubkey::algos()` + +## 0.2.0 + +The first generally usable version. diff --git a/Cargo.toml b/Cargo.toml index 2c10c41..fed93ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ readme = "README.md" keywords = ["ssh", "ssh-client", "tokio", "async"] categories = ["network-programming", "asynchronous"] -description = "Asynchronous SSH client library" +description = "Asynchronous SSH client library in pure Rust" [[test]] name = "compat" diff --git a/README.md b/README.md index 356eca0..9adbfbd 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # Makiko -Makiko is an asynchronous SSH client library for Rust. +Makiko is an asynchronous SSH client library in pure Rust. -**[Github][github] | [API docs][docs-rs] | [Crate][crates-io]** +**[Tutorial][tutorial] | [API docs][docs-rs] | [Github][github] | [Crate][crates-io]** -[github]: https://github.com/honzasp/makiko +[tutorial]: https://honzasp.github.io/makiko [docs-rs]: https://docs.rs/makiko/latest/makiko +[github]: https://github.com/honzasp/makiko [crates-io]: https://crates.io/crates/makiko ## Features @@ -27,6 +28,7 @@ Makiko is an asynchronous SSH client library for Rust. - Crypto from [Rust Crypto][rust-crypto] - Use your own sockets, spawn your own tasks - Uses [Tokio][tokio] interfaces (but does not depend on the Tokio runtime) +- Rust all the way down: no dependency on non-Rust libraries, direct or indirect [rust-crypto]: https://github.com/RustCrypto [tokio]: https://tokio.rs/ @@ -38,6 +40,10 @@ Makiko is an asynchronous SSH client library for Rust. Makiko gives you a lot of control over the SSH connection, it is meant to be a building block for libraries and advanced applications. +> Makiko and most of the cryptography crates from [Rust Crypto][rust-crypto] +> that Makiko uses have not yet been audited by a trusted third party. Use at +> your own risk! + ## Contributing Contributions are welcome! Please contact me ([@honzasp][honzasp]) or open a @@ -47,4 +53,4 @@ pull request. ## License -This software is released into the public domain. Please see [LICENSE](LICENSE). +This software is released into the public domain. Please see [UNLICENSE](UNLICENSE). diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..f55f639 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,10 @@ +# Copied from https://github.com/github/gitignore/blob/main/Jekyll.gitignore +# Ignore metadata generated by Jekyll +_site/ +.sass-cache/ +.jekyll-cache/ +.jekyll-metadata + +# Ignore folders generated by Bundler +.bundle/ +vendor/ diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 0000000..77b83a8 --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gem "jekyll", "~> 4.3" +gem "webrick" + +gem "just-the-docs", "0.4.0.rc4" diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock new file mode 100644 index 0000000..85f4e77 --- /dev/null +++ b/docs/Gemfile.lock @@ -0,0 +1,80 @@ +GEM + remote: https://rubygems.org/ + specs: + addressable (2.8.1) + public_suffix (>= 2.0.2, < 6.0) + colorator (1.1.0) + concurrent-ruby (1.1.10) + em-websocket (0.5.3) + eventmachine (>= 0.12.9) + http_parser.rb (~> 0) + eventmachine (1.2.7) + ffi (1.15.5) + forwardable-extended (2.6.0) + http_parser.rb (0.8.0) + i18n (1.12.0) + concurrent-ruby (~> 1.0) + jekyll (4.3.0) + addressable (~> 2.4) + colorator (~> 1.0) + em-websocket (~> 0.5) + i18n (~> 1.0) + jekyll-sass-converter (>= 2.0, < 4.0) + jekyll-watch (~> 2.0) + kramdown (~> 2.3, >= 2.3.1) + kramdown-parser-gfm (~> 1.0) + liquid (~> 4.0) + mercenary (>= 0.3.6, < 0.5) + pathutil (~> 0.9) + rouge (>= 3.0, < 5.0) + safe_yaml (~> 1.0) + terminal-table (>= 1.8, < 4.0) + webrick (~> 1.7) + jekyll-sass-converter (2.2.0) + sassc (> 2.0.1, < 3.0) + jekyll-seo-tag (2.8.0) + jekyll (>= 3.8, < 5.0) + jekyll-watch (2.2.1) + listen (~> 3.0) + just-the-docs (0.4.0.rc4) + jekyll (>= 3.8.5) + jekyll-seo-tag (>= 2.0) + rake (>= 12.3.1) + kramdown (2.4.0) + rexml + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) + liquid (4.0.3) + listen (3.7.1) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + mercenary (0.4.0) + pathutil (0.16.2) + forwardable-extended (~> 2.6) + public_suffix (5.0.0) + rake (13.0.6) + rb-fsevent (0.11.2) + rb-inotify (0.10.1) + ffi (~> 1.0) + rexml (3.2.5) + rouge (4.0.0) + safe_yaml (1.0.5) + sassc (2.4.0) + ffi (~> 1.9) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + unicode-display_width (2.3.0) + webrick (1.7.0) + +PLATFORMS + arm64-darwin-21 + x86_64-darwin-19 + x86_64-linux + +DEPENDENCIES + jekyll (~> 4.3) + just-the-docs (= 0.4.0.rc4) + webrick + +BUNDLED WITH + 2.3.9 diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..03d993c --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,27 @@ +title: "Makiko" +description: "Makiko is an asynchronous SSH client library in pure Rust" +theme: just-the-docs + +url: "https://honzasp.github.io/makiko" +baseurl: "/makiko" +aux_links: + "Github": "https://github.com/honzasp/makiko" + "API docs": "https://docs.rs/makiko/latest/makiko" +nav_external_links: + - title: "Makiko on Github" + url: "https://github.com/honzasp/makiko" + - title: "API documentation" + url: "https://docs.rs/makiko/latest/makiko" + +color_scheme: "makiko_light" +callouts: + "note": + title: "Note" + color: blue + "warning": + title: "Warning" + color: yellow +favicon_ico: "/favicon.ico" + +exclude: + - "Session.vim" diff --git a/docs/_includes/tutorial_next.html b/docs/_includes/tutorial_next.html new file mode 100644 index 0000000..e397c2b --- /dev/null +++ b/docs/_includes/tutorial_next.html @@ -0,0 +1,3 @@ +

+ Next: {{include.title}} +

diff --git a/docs/_sass/color_schemes/makiko_light.scss b/docs/_sass/color_schemes/makiko_light.scss new file mode 100644 index 0000000..bc5a2bb --- /dev/null +++ b/docs/_sass/color_schemes/makiko_light.scss @@ -0,0 +1,84 @@ +$body-text-color: #000; +$body-heading-color: #000; +$link-color: $blue-100; +$btn-primary-color: $blue-100; + +// Based on tango.css from https://github.com/jwarby/jekyll-pygments-themes +.highlight .hll { background-color: $grey-lt-000 } + +.highlight .err { color: $red-300; border: 1px solid $red-300 } /* Error */ +.highlight .o { color: $body-text-color } /* Operator */ +.highlight .ow { color: $body-text-color } /* Operator.Word */ +.highlight .x { color: $body-text-color } /* Other */ +.highlight .p { color: $body-text-color } /* Punctuation */ +.highlight .w { color: $grey-lt-000; text-decoration: underline } /* Text.Whitespace */ + +.highlight .g { color: $body-text-color } /* Generic */ +.highlight .gd { color: $red-100 } /* Generic.Deleted */ +.highlight .ge { color: $body-text-color; font-style: italic } /* Generic.Emph */ +.highlight .gr { color: $red-100 } /* Generic.Error */ +.highlight .gh { color: $blue-300; font-weight: bold } /* Generic.Heading */ +.highlight .gi { color: $green-100 } /* Generic.Inserted */ +.highlight .go { color: $body-text-color; font-style: italic } /* Generic.Output */ +.highlight .gp { color: $red-300 } /* Generic.Prompt */ +.highlight .gs { color: $body-text-color; font-weight: bold } /* Generic.Strong */ +.highlight .gu { color: $purple-200; font-weight: bold } /* Generic.Subheading */ +.highlight .gt { color: $red-300; font-weight: bold } /* Generic.Traceback */ + +.highlight .c { color: $grey-dk-100; font-style: italic } /* Comment */ +.highlight .cd { color: $grey-dk-100; font-style: italic } /* Comment.Doc */ +.highlight .cm { color: $grey-dk-100; font-style: italic } /* Comment.Multiline */ +.highlight .cp { color: $grey-dk-100; font-style: italic } /* Comment.Preproc */ +.highlight .c1 { color: $grey-dk-100; font-style: italic } /* Comment.Single */ +.highlight .cs { color: $grey-dk-100; font-style: italic } /* Comment.Special */ + +.highlight .k { color: $blue-100 } /* Keyword */ +.highlight .kc { color: $blue-100 } /* Keyword.Constant */ +.highlight .kd { color: $blue-100 } /* Keyword.Declaration */ +.highlight .kn { color: $blue-100 } /* Keyword.Namespace */ +.highlight .kp { color: $blue-100 } /* Keyword.Pseudo */ +.highlight .kr { color: $blue-100 } /* Keyword.Reserved */ +.highlight .kt { color: $blue-100 } /* Keyword.Type */ + +.highlight .l { color: $body-text-color } /* Literal */ +.highlight .ld { color: $body-text-color } /* Literal.Date */ + +.highlight .m { color: $blue-300 } /* Literal.Number */ +.highlight .mf { color: $blue-300 } /* Literal.Number.Float */ +.highlight .mh { color: $blue-300 } /* Literal.Number.Hex */ +.highlight .mi { color: $blue-300 } /* Literal.Number.Integer */ +.highlight .mo { color: $blue-300 } /* Literal.Number.Oct */ +.highlight .il { color: $blue-300 } /* Literal.Number.Integer.Long */ + +.highlight .s { color: $blue-200 } /* Literal.String */ +.highlight .sb { color: $blue-200 } /* Literal.String.Backtick */ +.highlight .sc { color: $blue-200 } /* Literal.String.Char */ +.highlight .sd { color: $blue-200; font-style: italic } /* Literal.String.Doc */ +.highlight .s2 { color: $blue-200 } /* Literal.String.Double */ +.highlight .se { color: $blue-200 } /* Literal.String.Escape */ +.highlight .sh { color: $blue-200 } /* Literal.String.Heblueoc */ +.highlight .si { color: $blue-200 } /* Literal.String.Interpol */ +.highlight .sx { color: $blue-200 } /* Literal.String.Other */ +.highlight .sr { color: $blue-200 } /* Literal.String.Regex */ +.highlight .s1 { color: $blue-200 } /* Literal.String.Single */ +.highlight .ss { color: $blue-200 } /* Literal.String.Symbol */ + +.highlight .n { color: $body-text-color } /* Name */ +.highlight .na { color: $red-200 } /* Name.Attribute */ +.highlight .nb { color: $body-text-color } /* Name.Builtin */ +.highlight .nc { color: $body-text-color } /* Name.Class */ +.highlight .no { color: $body-text-color } /* Name.Constant */ +.highlight .nd { color: $purple-200 } /* Name.Decorator */ +.highlight .ni { color: $red-200 } /* Name.Entity */ +.highlight .ne { color: $red-200 } /* Name.Exception */ +.highlight .nf { color: $body-text-color } /* Name.Function */ +.highlight .nl { color: $red-200 } /* Name.Label */ +.highlight .nn { color: $blue-300 } /* Name.Namespace */ +.highlight .nx { color: $body-text-color } /* Name.Other */ +.highlight .py { color: $body-text-color } /* Name.Property */ +.highlight .nt { color: $blue-200; font-weight: bold } /* Name.Tag */ +.highlight .nv { color: $body-text-color } /* Name.Variable */ +.highlight .bp { color: $red-200 } /* Name.Builtin.Pseudo */ +.highlight .vc { color: $body-text-color } /* Name.Variable.Class */ +.highlight .vg { color: $body-text-color } /* Name.Variable.Global */ +.highlight .vi { color: $body-text-color } /* Name.Variable.Instance */ diff --git a/docs/_sass/custom/custom.scss b/docs/_sass/custom/custom.scss new file mode 100644 index 0000000..3e75f83 --- /dev/null +++ b/docs/_sass/custom/custom.scss @@ -0,0 +1,17 @@ +.main-content { + a { + text-decoration-color: $link-color; + } + + ul > li::before { + color: $grey-dk-100; + } + + ol > li::before { + color: $grey-dk-100; + } +} + +hr { + background-color: $grey-lt-300; +} diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 0000000..30f78d8 Binary files /dev/null and b/docs/favicon.ico differ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..5c9714e --- /dev/null +++ b/docs/index.md @@ -0,0 +1,53 @@ +--- +title: Home +layout: home +--- + +# {{ site.title }} +{: .fs-9} + +{{ site.description }} +{: .fs-6 .fw-300 .text-grey-dk-200} + +[Get started](/tutorial){: .btn .btn-primary } +[API docs][docs-rs]{: .btn .ml-4 } +[Github][github]{: .btn .ml-4 } +[Crate][crates-io]{: .btn .ml-4 } + +[docs-rs]: https://docs.rs/makiko/latest/makiko +[github]: https://github.com/honzasp/makiko +[crates-io]: https://crates.io/crates/makiko + +## Features + +- SSH protocol 2 +- Authentication methods: publickey, password, none +- Shell/exec sessions +- Remote and local tunnels +- Raw SSH channels (low-level API) +- Ciphers: chacha20-poly1305, aes128-gcm, aes256-gcm, aes128-ctr, aes192-ctr, + aes256-ctr, aes128-cbc\*, aes192-cbc\*, aes256-cbc\* +- MACs: hmac-sha2-256-etm, hmac-sha2-512-etm, hmac-sha2-256, hmac-sha2-512, + hmac-sha1-etm\*, hmac-sha1\* +- Public key algorithms: ssh-ed25519, rsa-sha2-256, rsa-sha2-512, + ecdsa-sha2-nistp256\*, ecdsa-sha2-nistp384\*, ssh-rsa\* +- Key exchange algorithms: curve25519-sha256, diffie-hellman-group14-sha1\*, + diffie-hellman-group14-sha256\*, diffie-hellman-group16-sha512\*, + diffie-hellmann-group18-sha512\* +- Crypto from [Rust Crypto][rust-crypto] +- Use your own sockets, spawn your own tasks +- Uses [Tokio][tokio] interfaces (but does not depend on the Tokio runtime) +- Rust all the way down: no dependency on non-Rust libraries, direct or indirect + +[rust-crypto]: https://github.com/RustCrypto +[tokio]: https://tokio.rs/ + +\* Algorithms marked with an asterisk are not enabled by default +{: .text-grey-dk-200} + +## Low-level + +Makiko gives you a lot of control over the SSH connection, it is meant to be a building block for libraries and advanced applications. + +{: .warning } +Makiko and most of the cryptography crates from [Rust Crypto][rust-crypto] that Makiko uses have not yet been audited by a trusted third party. Use at your own risk! diff --git a/docs/tutorial/1-connect.md b/docs/tutorial/1-connect.md new file mode 100644 index 0000000..eda1fcd --- /dev/null +++ b/docs/tutorial/1-connect.md @@ -0,0 +1,223 @@ +--- +title: Connecting to the server +layout: page +parent: Tutorial +nav_order: 1 +--- + +# {{ page.title }} + +In this chapter, we will connect to an SSH server and print out the server public key. + +## An example server +{: #example-server } + +To test an SSH client, we need an SSH server! If you have Docker installed, you can run [my example SSH server][tutorial-image] in a Docker container as follows: + +[tutorial-image]: https://hub.docker.com/repository/docker/honzasp/makiko-tutorial/general + +``` +$ docker run --rm -p 2222:22 --name example-ssh-server honzasp/makiko-tutorial +``` + +This command will start the container in the background and it will bind the server to port 2222 on your localhost. You can connect to this server with username `alice` and password `alicealice`: + +``` +$ ssh -p 2222 alice@localhost +``` + +To stop the container, you can run: + +``` +$ docker stop example-ssh-server +``` + +If using Docker is not convenient for you, you can follow the tutorial by connecting to another SSH server that you can access, but you will need to adjust the connection details in the code. + +## Open the connection + +We will put all our code in `src/main.rs`. Makiko uses Tokio and async, so our main function looks as follows: + +```rust +#[tokio::main] +async fn main() { + ... // Our code will go here +} +``` + +We use the [`#[tokio::main]`][tokio-main] attribute to conveniently initialize the Tokio runtime and enable async code in `main()`. + +[tokio-main]: https://docs.rs/tokio/latest/tokio/attr.main.html + +{: .note } +You may wonder why we don't return a `Result` from `main()`. To keep things simple in the tutorial, we will panic when we encounter an error. In real code, you should [handle errors properly][error-handling]. + +[error-handling]: https://doc.rust-lang.org/book/ch09-00-error-handling.html + +### The socket + +First, we need to open a TCP socket to the SSH server. Makiko can work with anything that implements [`AsyncRead`][async-read] and [`AsyncWrite`][async-write], so you can also use Unix domain sockets, pipes or other exotic modes of transport. However, a [`tokio::net::TcpStream`][tcp-stream] will be the most usual choice: + +[async-read]: https://docs.rs/tokio/latest/tokio/io/trait.AsyncRead.html +[async-write]: https://docs.rs/tokio/latest/tokio/io/trait.AsyncWrite.html +[tcp-stream]: https://docs.rs/tokio/latest/tokio/net/struct.TcpStream.html + +```rust +let socket = tokio::net::TcpStream::connect(("localhost", 2222)).await + .expect("Could not open a TCP socket"); +``` + +### Configuration + +The SSH protocol supports many cryptographic algorithms for different aspects of the connection, such as key exchange or encryption. We need to configure the client using the [`makiko::ClientConfig`][client-config] struct, which specifies the algorithms that the client can use and other low-level details. In most cases, you can use the default configuration, which uses only very secure cryptography: + +[client-config]: https://docs.rs/makiko/latest/makiko/struct.ClientConfig.html + +```rust +// Recommended configuration that uses only the best crypto +let config = makiko::ClientConfig::default(); +``` + +However, if you need to connect to older SSH servers that don't support the newest crypto, you can use configuration that allows all algorithms implemented in Makiko. None of these algorithms are known to be _broken_, but they use primitives with known weaknesses (such as HMAC with SHA-1), are considered suspicious (NIST elliptic curves) or have suboptimal implementation in Makiko (Diffie-Hellman key exchange). + +```rust +// Less secure configuration compatible with almost all SSH servers +let config = makiko::ClientConfig::default_compatible_less_secure(); +``` + +If you want more fine-grained configuration, please [see the documentation][client-config]. + +### The client + +We now have all that is needed to open the [`makiko::Client`][client]: + +[client]: https://docs.rs/makiko/latest/makiko/struct.Client.html + +```rust +let (client, mut client_rx, client_fut) = makiko::Client::open(socket, config) + .expect("Could not open client"); +``` + +The [`Client::open()`][client-open] associated function returns three objects: a [`Client`][client], a [`ClientReceiver`][client-rx] and a [`ClientFuture`][client-fut]. + +[client-open]: https://docs.rs/makiko/latest/makiko/struct.Client.html#method.open +[client-rx]: https://docs.rs/makiko/latest/makiko/struct.ClientReceiver.html +[client-fut]: https://docs.rs/makiko/latest/makiko/struct.ClientFuture.html + +In the next sections, we will deal with the `ClientReceiver` and `ClientFuture`, and the following chapters will make use of the `Client`. + +## Polling the client + +To handle the SSH connection, we need to asynchronously run the code that performs I/O on the underlying socket. This code is encapsulated in the [`ClientFuture`][client-fut], which is a Rust [`Future`][future] that you need to poll to drive the connection forward. The future is resolved when the client is closed or when the connection fails with an error. + +[future]: https://doc.rust-lang.org/std/future/trait.Future.html + +In this tutorial, we will simply spawn a Tokio task to poll the future in the background and panic when the connection fails: + +```rust +tokio::task::spawn(async move { + client_fut.await.expect("Error in client future"); +}); +``` + +{: .note} +When we drop the [`JoinHandle`][join-handle] returned from [`spawn()`][task-spawn], Tokio will detach the task and run it in the background. This works well in our tutorial, but in practice, [it is usually better to follow the principles of structured concurrency][go-harmful] and always `.await` all tasks that you spawn. This will make sure that errors are always handled correctly, resources are cleaned up, and your program becomes easier to reason about. + +[join-handle]: https://docs.rs/tokio/latest/tokio/task/struct.JoinHandle.html +[task-spawn]: https://docs.rs/tokio/latest/tokio/task/fn.spawn.html +[go-harmful]: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/ + +## Handle client events + +During the lifetime of the SSH connection, the client will asynchronously produce various events. To handle these events, we will use the [`ClientReceiver`][client-rx]. This is a bit similar to channels in Tokio: Makiko sends events to this "channel", and you receive them using [`ClientReceiver::recv()`][client-rx-recv], which is like the [`Receiver::recv()`][mpsc-rx-recv] method of a Tokio channel: + +[client-rx-recv]: https://docs.rs/makiko/latest/makiko/struct.ClientReceiver.html#method.recv +[mpsc-rx-recv]: https://docs.rs/tokio/latest/tokio/sync/mpsc/struct.Receiver.html#method.recv + +```rust +loop { + // Wait for the next event. + let event = client_rx.recv().await + .expect("Error while receiving client event"); + + // Exit the loop when the client has closed. + let Some(event) = event else { + break + }; + + match event { + ... // We will handle the event here + } +} +``` + +### Server public key +{: #server-public-key } + +The produced events are variants of the enum [`ClientEvent`][client-event]. The most important variant that you always need to handle is [`ClientEvent::ServerPubkey`][client-event-server-pubkey], which you will get when Makiko receives the server's public key during key exchange. This always happens when the connection is initialized, but you may also get this event after the connection is established if the connection is "rekeyed" to derive fresh encryption secrets. + +[client-event]: https://docs.rs/makiko/latest/makiko/enum.ClientEvent.html +[client-event-server-pubkey]: https://docs.rs/makiko/latest/makiko/enum.ClientEvent.html#variant.ServerPubkey + +```rust +match event { + // Handle the server public key + makiko::ClientEvent::ServerPubkey(pubkey, accept) => { + ... // Verify the server public key here + }, + + ... // Handle other events here +} +``` + +The `ServerPubkey` variant has two fields: the server [`Pubkey`][pubkey] and an [`AcceptPubkey`][accept-pubkey] object that we will use to tell Makiko whether we accept or reject the key. + +[pubkey]: https://docs.rs/makiko/latest/makiko/pubkey/enum.Pubkey.html +[accept-pubkey]: https://docs.rs/makiko/latest/makiko/struct.AcceptPubkey.html + +To prevent [man-in-the-middle attacks][mitm], it is very important to verify that this public key belongs to the server that we wanted to connect to. Unfortunately, SSH does not provide any mechanism to verify identity of the server (in contrast to TLS, which is used in HTTPS to secure the Web and which provides certificate-based public key infrastructure). This means that it is up to you whether to accept or reject the public key. + +[mitm]: https://en.wikipedia.org/wiki/Man-in-the-middle_attack + +[Later in the tutorial]({% link tutorial/7-verify-pubkey.md %}), we will learn how to implement a [trust on first use (TOFU)][tofu] scheme using the standard `~/.ssh/known_hosts` file. But for now, we won't do any verification and we will accept any key: + +[tofu]: https://en.wikipedia.org/wiki/Trust_on_first_use + +```rust +match event { + // Handle the server public key: for now, we just accept all keys, but this makes + // us susceptible to man-in-the-middle attacks! + makiko::ClientEvent::ServerPubkey(pubkey, accept) => { + println!("Server pubkey type {}, fingerprint {}", pubkey.type_str(), pubkey.fingerprint()); + accept.accept(); + }, + + ... +} +``` + +{: .warning } +If you don't verify the server public key, [it might be treated as a security vulnerability][rust-cve]. + +[rust-cve]: https://blog.rust-lang.org/2023/01/10/cve-2022-46176.html + +### Other events + +You can [read the documentation][client-event] if you want to learn about other client events, but we won't need to handle them in this tutorial, so we can just ignore them: + +```rust +match event { + ... + + // All other events can be safely ignored + _ => {}, +} +``` + +--- + +You can find the full code for this tutorial in [`examples/tutorial_1.rs`][tutorial-1]. If all works well, the program prints the fingerprint of the server public key and hangs. In the next chapter, we will continue by authenticating to the server using a password. + +[tutorial-1]: https://github.com/honzasp/makiko/blob/master/examples/tutorial_1.rs + +{% include tutorial_next.html link="tutorial/2-password-auth.md" title="Password authentication" %} diff --git a/docs/tutorial/2-password-auth.md b/docs/tutorial/2-password-auth.md new file mode 100644 index 0000000..525fa36 --- /dev/null +++ b/docs/tutorial/2-password-auth.md @@ -0,0 +1,95 @@ +--- +title: Password authentication +layout: page +parent: Tutorial +nav_order: 2 +--- + +# {{ page.title }} + +In this chapter, we will authenticate to the SSH server using a password. + +{: .note } +Public key authentication, [which is described in the next chapter][3-pubkey-auth], is considered more secure than password authentication and should be preferred whenever possible, especially if you cannot securely verify the public key of the server that you are connecting to. + +[3-pubkey-auth]: {% link tutorial/3-pubkey-auth.md %} + +## Handle client events in a task + +In the previous chapter, we spawned a task to poll the [`ClientFuture`][client-fut] and we waited for [`ClientEvent`-s][client-event] from the [`ClientReceiver`][client-rx] on the main task. However, from now on, we will need the main task to invoke operations on the [`Client`][client], so we start by moving the event handling from the main task to another spawned task: + +[client-fut]: https://docs.rs/makiko/latest/makiko/struct.ClientFuture.html +[client-event]: https://docs.rs/makiko/latest/makiko/enum.ClientEvent.html +[client-rx]: https://docs.rs/makiko/latest/makiko/struct.ClientReceiver.html +[client]: https://docs.rs/makiko/latest/makiko/struct.Client.html + +```rust +// Do not handle the client events on the main task +/* +loop { + // Wait for the next event. + let event = client_rx.recv().await + .expect("Error while receiving client event"); + + ... +} +*/ + +// Instead, spawn another Tokio task to handle the client events. +tokio::task::spawn(async move { + loop { + // Wait for the next event. + let event = client_rx.recv().await + .expect("Error while receiving client event"); + + ... // Handle the events as before + } +}); +``` + +## Authenticate + +Back on the main task, we can now call the [`Client::auth_password()`][client-auth-password] method to attempt password authentication using username "alice" and password "alicealice": + +[client-auth-password]: https://docs.rs/makiko/latest/makiko/struct.Client.html#method.auth_password + +```rust +// Try to authenticate using a password. +let auth_res = client.auth_password("alice".into(), "alicealice".into()).await + .expect("Error when trying to authenticate"); +``` + +The method returns an [`AuthPasswordResult`][auth-password-result], which has these variants: + +- `Success` means that we are now authenticated. +- `Failure` means that the server has not accepted the authentication and provided an [`AuthFailure`][auth-failure] with details. +- `ChangePassword` means that the password was correct, but we need to change the password to a new one. The SSH specification provides a mechanism for changing the password, but I have not found any SSH server or client that implements this feature, so Makiko does not support it either. This means that we don't need to handle the `ChangePassword` variant in practice, so we treat it as an error. + +[auth-password-result]: https://docs.rs/makiko/latest/makiko/enum.AuthPasswordResult.html +[auth-failure]: https://docs.rs/makiko/latest/makiko/struct.AuthFailure.html + +In this tutorial, we can simply print a message on success and panic on failure: + +```rust +// Deal with all possible outcomes of password authentication. +match auth_res { + makiko::AuthPasswordResult::Success => { + println!("We have successfully authenticated using a password"); + }, + makiko::AuthPasswordResult::ChangePassword(prompt) => { + panic!("The server asks us to change password: {:?}", prompt); + }, + makiko::AuthPasswordResult::Failure(failure) => { + panic!("The server rejected authentication: {:?}", failure); + } +} +``` + +--- + +Full code for this tutorial can be found in [`examples/tutorial_2.rs`][tutorial-2]. The program will print a message if the authentication was successful, or an error if it failed. If you don't use the [example server for this tutorial][example-server], you may need to change the code to use a different username and password. + +[tutorial-2]: https://github.com/honzasp/makiko/blob/master/examples/tutorial_2.rs +[example-server]: {% link tutorial/1-connect.md %}#example-server + +{% include tutorial_next.html link="tutorial/3-pubkey-auth.md" title="Public key authentication" %} diff --git a/docs/tutorial/3-pubkey-auth.md b/docs/tutorial/3-pubkey-auth.md new file mode 100644 index 0000000..f573456 --- /dev/null +++ b/docs/tutorial/3-pubkey-auth.md @@ -0,0 +1,155 @@ +--- +title: Public key authentication +layout: page +parent: Tutorial +nav_order: 3 +--- + +# {{ page.title }} + +In this chapter, we will authenticate to the SSH server using a private key. With this authentication method, we send a public key to the server and we cryptographically prove that we own the corresponding private key. However, our private key is not transmitted, so it stays secure even if we connect to an untrusted server. The server then decides whether to accept the public key, for example by consulting a list of public keys stored in the [`~/.ssh/authorized_keys`][authorized-keys] file on the server. + +[authorized-keys]: https://man.openbsd.org/sshd#AUTHORIZED_KEYS_FILE_FORMAT + +## Get the private key + +Makiko supports several types of public/private keys: + +- [RSA][rsa] is the original public key cryptosystem based on integer factorization, which is still widely deployed. The theoretical algorithm is sound, but practical implementations of RSA have a long history of being subtly flawed, so using RSA is [discouraged][stop-using-rsa]. +- [Elliptic curve cryptography][ecc] is a more modern class of public key algorithms that are based on elliptic curves. Makiko supports: + - [ECDSA][ecdsa] is a standardized signature algorithm scheme based on elliptic curves. Makiko supports ECDSA with two curves, NIST P-256 and NIST P-384. However, there have been suspicions that these curves may contain a backdoor, because they are generated using parameters that have not been fully explained. + - [EdDSA][eddsa] is a different signature algorithm scheme. Makiko supports the algorithm [Ed25519][ed25519], which uses [Curve25519][curve25519]. This algorithm is fast and is considered very secure. + +[rsa]: https://en.wikipedia.org/wiki/RSA_(cryptosystem) +[stop-using-rsa]: https://blog.trailofbits.com/2019/07/08/fuck-rsa/ +[ecc]: https://en.wikipedia.org/wiki/Elliptic-curve_cryptography +[ecdsa]: https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm +[eddsa]: https://en.wikipedia.org/wiki/EdDSA +[ed25519]: https://ed25519.cr.yp.to/ +[curve25519]: https://cr.yp.to/ecdh.html + +In Makiko, public keys are represented as the enum [`Pubkey`][pubkey] and private keys as the enum [`Privkey`][privkey]. You can always obtain the public key from the private key by calling [`Privkey::pubkey()`][privkey-pubkey]. + +[pubkey]: https://docs.rs/makiko/latest/makiko/pubkey/enum.Pubkey.html +[privkey]: https://docs.rs/makiko/latest/makiko/pubkey/enum.Privkey.html +[privkey-pubkey]: https://docs.rs/makiko/latest/makiko/pubkey/enum.Privkey.html#method.pubkey + +### File formats for private keys + +To authenticate with public key authentication, we need to obtain the private key. Makiko can read private keys in the following formats: + +- PKCS#1: Legacy format for RSA keys, uses [ASN.1][asn1] and encodes the key using the [DER][der] encoding. +- PKCS#8: A newer format that can encode keys from different public key algorithms. This format is common when working with TLS and other cryptography applications. It is also based on ASN.1 and uses DER encoding. +- OpenSSH: A format for private keys that is used by OpenSSH. The key is not encoded using DER but with the same encoding that is used in the SSH protocol. + +[asn1]: https://en.wikipedia.org/wiki/ASN.1 +[der]: https://en.wikipedia.org/wiki/X.690#DER_encoding + +Private keys in these formats may also be encrypted. Makiko supports decrypting private keys in PKCS#8 and OpenSSH formats; encrypted keys in the PKCS#1 format are not supported. + +All these formats are binary. To make them easier to use, they are usually stored in [PEM][pem] format, which encodes the binary data in a textual form. PEM files can be easily recognized by starting with `-----BEGIN -----`, followed by base64-encoded data and ending with `-----END -----`, where `` is a string that determines the format of the binary data: + +- `-----BEGIN PRIVATE KEY-----` is a private key in PKCS#8 format, +- `-----BEGIN RSA PRIVATE KEY-----` is a private key in PKCS#1 format, +- `-----BEGIN OPENSSH PRIVATE KEY-----` is a private key in OpenSSH format, and +- `-----BEGIN ENCRYPTED PRIVATE KEY-----` is an encrypted private key in PKCS#8 format. + +[pem]: https://en.wikipedia.org/wiki/Privacy-Enhanced_Mail + +PEM files can also store other types of cryptographic material such as certificates (`-----BEGIN CERTIFICATE-----`). + +### Decode the private key + +In Makiko, the [`makiko::keys`][keys] module contains functions for decoding from all these formats, both binary and PEM. For the common case of reading an unencrypted private key from PEM, you can use the function [`decode_pem_privkey_nopass`][decode-pem-privkey-nopass], which automatically detects the format of the key from the PEM tag. This function returns an enum [`DecodedPrivkeyNopass`][decoded-privkey-nopass], which can take one of these variants: + +- `Privkey` if we successfully decoded the private key. +- `Pubkey` if the private key was encrypted, but we could at least decode the public key. This is supported only by the OpenSSH format. +- `Encrypted` if the file was encrypted and we could not decode anything. + +We will just use the [`DecodedPrivkeyNopass::privkey()`][decoded-privkey-nopass-privkey] convenience method to get the private key, if it is available. However, in a real application, we may want to prompt the user for the password if the key is encrypted. + +[keys]: https://docs.rs/makiko/latest/makiko/keys/index.html +[decode-pem-privkey-nopass]: https://docs.rs/makiko/latest/makiko/keys/fn.decode_pem_privkey_nopass.html +[decoded-privkey-nopass]: https://docs.rs/makiko/latest/makiko/keys/enum.DecodedPrivkeyNopass.html +[decoded-privkey-nopass-privkey]: https://docs.rs/makiko/latest/makiko/keys/enum.DecodedPrivkeyNopass.html#method.privkey + +```rust +// Decode our private key from PEM. +let privkey = makiko::keys::decode_pem_privkey_nopass(PRIVKEY_PEM) + .expect("Could not decode a private key from PEM") + .privkey().cloned() + .expect("Private key is encrypted"); +``` + +In this tutorial, we simply hard-coded the private key, but in practice, you would usually read the key from a file or from configuration. + +```rust +const PRIVKEY_PEM: &[u8] = br#" +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACDyVJsRfh+NmkQKg2Dh6rPVodiQ3nC+dVoGMoMtYcbMJQAAAJBPdwHAT3cB +wAAAAAtzc2gtZWQyNTUxOQAAACDyVJsRfh+NmkQKg2Dh6rPVodiQ3nC+dVoGMoMtYcbMJQ +AAAEA5ct+xfc9qlJ4I2Jee8HIrAhN55yxmtUmvKpjT7q6QXPJUmxF+H42aRAqDYOHqs9Wh +2JDecL51WgYygy1hxswlAAAABmVkd2FyZAECAwQFBgc= +-----END OPENSSH PRIVATE KEY----- +"#; +``` + +{: .warning } +Never hard-code private keys or other credentials into your program in practice! + +## Authenticate + +Once we have the private key, we need to select the signing algorithm that we will use for authentication. For ECDSA and EdDSA keys, the algorithm is determined by the key type, but for RSA keys, multiple algorithms are applicable. + +The RSA algorithms differ in the hash function that is used during signing: the original algorithm ["ssh-rsa"][ssh-rsa] uses the SHA-1 hash algorithm, which was found to be insecure, so the protocol was extended with new algorithms that replace the hash function with SHA-2 (Makiko supports ["rsa-sha2-256"][rsa-sha2-256] and ["rsa-sha2-512"][rsa-sha2-512]). These new algorithms work with the same keys as the old "ssh-rsa" algorithm, the only difference is in the mechanism that the client uses to prove to the server that it knows the private key. + +[ssh-rsa]: https://docs.rs/makiko/latest/makiko/pubkey/static.SSH_RSA_SHA1.html +[rsa-sha2-256]: https://docs.rs/makiko/latest/makiko/pubkey/static.RSA_SHA2_256.html +[rsa-sha2-512]: https://docs.rs/makiko/latest/makiko/pubkey/static.RSA_SHA2_512.html + +All public key algorithms supported in Makiko are listed in the [`makiko::pubkey`][supported-algos] module. In this chapter, we know that the private key that we decoded in the previous section is [an Ed25519 key][ed25519-privkey], so there is just a single applicable algorithm, ["ssh-ed25519"][ssh-ed25519]. In the next chapter, we will see how to select the algorithm more robustly. + +[supported-algos]: https://docs.rs/makiko/latest/makiko/pubkey/index.html#supported-algorithms +[ed25519-privkey]: https://docs.rs/makiko/latest/makiko/pubkey/struct.Ed25519Privkey.html +[ssh-ed25519]: https://docs.rs/makiko/latest/makiko/pubkey/static.SSH_ED25519.html + +```rust +// Select an algorithm for public key authentication. +let pubkey_algo = &makiko::pubkey::SSH_ED25519; +``` + +We can now call the [`Client::auth_pubkey()`][client-auth-pubkey] method to authenticate with username "edward" and the private key: + +[client-auth-pubkey]: https://docs.rs/makiko/latest/makiko/struct.Client.html#method.auth_pubkey + +```rust +// Try to authenticate with the private key +let auth_res = client.auth_pubkey("edward".into(), privkey, pubkey_algo).await + .expect("Error when trying to authenticate"); +``` + +The method returns an [`AuthPubkeyResult`][auth-pubkey-result], which is either a `Success` or `Failure`: + +[auth-pubkey-result]: https://docs.rs/makiko/latest/makiko/enum.AuthPubkeyResult.html + +```rust +// Deal with the possible outcomes of public key authentication. +match auth_res { + makiko::AuthPubkeyResult::Success => { + println!("We have successfully authenticated using a private key"); + }, + makiko::AuthPubkeyResult::Failure(failure) => { + panic!("The server rejected authentication: {:?}", failure); + } +} +``` + +--- + +Full code for this tutorial can be found in [`examples/tutorial_3.rs`][tutorial-3]. The program will print a message if the authentication was successful, or an error if it failed. If you don't use the [example server for this tutorial][example-server], you may need to change the code to use a different username and private key. + +[tutorial-3]: https://github.com/honzasp/makiko/blob/master/examples/tutorial_3.rs +[example-server]: {% link tutorial/1-connect.md %}#example-server + +{% include tutorial_next.html link="tutorial/4-pubkey-algo.md" title="Public key algorithm" %} diff --git a/docs/tutorial/4-pubkey-algo.md b/docs/tutorial/4-pubkey-algo.md new file mode 100644 index 0000000..4544842 --- /dev/null +++ b/docs/tutorial/4-pubkey-algo.md @@ -0,0 +1,117 @@ +--- +title: Public key algorithm +layout: page +parent: Tutorial +nav_order: 4 +--- + +# {{ page.title }} + +This chapter will build on the [previous chapter]({% link tutorial/3-pubkey-auth.md %}) by selecting the public key algorithm more robustly. + +## Get the list of supported algorithms + +We can use the [`Privkey::pubkey()`][privkey-pubkey] method to obtain the [`Pubkey`][pubkey] from the [`Privkey`][privkey] that we have read from PEM in the previous chapter: + +[privkey-pubkey]: https://docs.rs/makiko/latest/makiko/pubkey/enum.Privkey.html#method.pubkey +[pubkey]: https://docs.rs/makiko/latest/makiko/pubkey/enum.Pubkey.html +[privkey]: https://docs.rs/makiko/latest/makiko/pubkey/enum.Privkey.html + +```rust +// Get the public key from the private key. +let pubkey = privkey.pubkey(); +``` + +And to obtain the list of algorithms that Makiko supports for this public key, we can use the [`Pubkey::algos()`][pubkey-algos] method: + +[pubkey-algos]: https://docs.rs/makiko/latest/makiko/pubkey/enum.Pubkey.html#method.algos + +```rust +// Get the public key algorithms supported by the key. +let available_algos = pubkey.algos(); +``` + +Next, we need to find out which of these algorithms is also supported by the server. + +## Check public keys and an algorithms + +Armed with the private key and a list of algorithms, we could simply try to call [`Client::auth_pubkey()`][client-auth-pubkey] with each algorithm in turn. This is a reasonable approach, but it has two disadvantages: + +1. SSH servers typically limit the number of failed authentication attempts to a small number and will close the connection when this limit is exceeded. +2. The signing operation that is required for authentication might be expensive in terms of CPU time. + +[client-auth-pubkey]: https://docs.rs/makiko/latest/makiko/struct.Client.html#method.auth_pubkey + +Fortunately, the SSH protocol provides a mechanism to ask the server whether it would accept a given combination of public key and algorithm, without actually attempting the authentication. We can use this mechanism by calling [`Client::check_pubkey()`][client-check-pubkey], which takes the username, public key and public key algorithm, and returns a bool: + +[client-check-pubkey]: https://docs.rs/makiko/latest/makiko/struct.Client.html#method.check_pubkey + +```rust +// Try the algorithms one by one. +let username: String = "ruth".into(); +for pubkey_algo in available_algos.iter().copied() { + // Check whether this combination of a public key and algorithm would be acceptable to the + // server. + let check_ok = client.check_pubkey(username.clone(), &pubkey, pubkey_algo).await + .expect("Error when checking a public key"); + + ... +} +``` + +If the server says that it will not accept this public key and algorithm, we can try the next algorithm: + +```rust +for pubkey_algo in ... { + let check_ok = ...; + + // Skip this algorithm if the server rejected it. + if !check_ok { + println!("Server rejected public key and algorithm {:?}", pubkey_algo.name); + continue; + } + + ... +} +``` + +Otherwise, we can try to authenticate: + +```rust +for pubkey_algo in ... { + ... + + // Try to authenticate using this algorithm. + let auth_res = client.auth_pubkey(username.clone(), privkey.clone(), pubkey_algo).await + .expect("Error when trying to authenticate"); + match auth_res { + makiko::AuthPubkeyResult::Success => { + println!("We have successfully authenticated using algorithm {:?}", pubkey_algo.name); + break; + }, + makiko::AuthPubkeyResult::Failure(_) => { + println!("Authentication using public key and algorithm {:?} failed", pubkey_algo.name); + }, + } +} +``` + +Finally, we can use the [`Client::is_authenticated()`][client-is-authenticated] method to check whether we have been successful: + +[client-is-authenticated]: https://docs.rs/makiko/latest/makiko/struct.Client.html#method.is_authenticated + +```rust +// Check that we have been authenticated. +if !client.is_authenticated().unwrap() { + panic!("Could not authenticate"); +} +``` + +--- + +Full code for this tutorial can be found in [`examples/tutorial_4.rs`][tutorial-4]. The program will print messages about the authentication attemps, and it will panic if authentication fails. If you don't use the [example server for this tutorial][example-server], you may need to change the code to use a different username and private key. + +[tutorial-4]: https://github.com/honzasp/makiko/blob/master/examples/tutorial_4.rs +[example-server]: {% link tutorial/1-connect.md %}#example-server + +{% include tutorial_next.html link="tutorial/5-execute-command.md" title="Execute a command" %} diff --git a/docs/tutorial/5-execute-command.md b/docs/tutorial/5-execute-command.md new file mode 100644 index 0000000..e0cfde0 --- /dev/null +++ b/docs/tutorial/5-execute-command.md @@ -0,0 +1,182 @@ +--- +title: Execute a command +layout: page +parent: Tutorial +nav_order: 5 +--- + +# {{ page.title }} + +In the previous chapters, we connected to the server and authenticated ourselves, so now we can finally execute some commands! + +## Session + +A single SSH connection can host multiple logical channels of communication. The SSH protocol defines two kinds of channels: interactive _sessions_ and TCP/IP forwarding channels (or _tunnels_). In this chapter, we will learn how to use sessions to execute commands, and the next chapter will be about tunnels. + +One session corresponds to one process: you open a session, prepare the execution environment (such as environment variables), start the command or shell, and then interact with it. + +## Open a session + +To open a session, we use the method [`Client::open_session()`][client-open-session] (after we have authenticated successfully). To configure the underlying channel, this method needs a [`ChannelConfig`][channel-config]. You can adjust the configuration if you need to optimize the SSH flow control, but the default instance should work well for most use cases: + +[client-open-session]: https://docs.rs/makiko/latest/makiko/struct.Client.html#method.open_session +[channel-config]: https://docs.rs/makiko/latest/makiko/struct.ChannelConfig.html + +```rust +// Open a session on the server. +let channel_config = makiko::ChannelConfig::default(); +let (session, mut session_rx) = client.open_session(channel_config).await + .expect("Could not open a session"); +``` + +The `open_session()` method returns two objects, a [`Session`][session] and a [`SessionReceiver`][session-rx]. This is the same pattern as with [`Client`][client] and [`ClientReceiver`][client-rx]: you use the `Session` object to invoke operations on the session, and the `SessionReceiver` object to receive events from the session. + +[session]: https://docs.rs/makiko/latest/makiko/struct.Session.html +[session-rx]: https://docs.rs/makiko/latest/makiko/struct.SessionReceiver.html +[client]: https://docs.rs/makiko/latest/makiko/struct.Client.html +[client-rx]: https://docs.rs/makiko/latest/makiko/struct.ClientReceiver.html + +## Handle session events + +To handle events from the [`SessionReceiver`][session-rx], we will spawn another task, like we did with client events. To receive the events, we will use the method [`SessionReceiver::recv()`][session-rx-recv]. The events are represented using the enum [`SessionEvent`][session-event]. The `recv()` method returns `None` when the session is closed and no more events will be received: + +[session-rx-recv]: https://docs.rs/makiko/latest/makiko/struct.SessionReceiver.html#method.recv +[session-event]: https://docs.rs/makiko/latest/makiko/enum.SessionEvent.html + +```rust +tokio::task::spawn(async move { + loop { + // Wait for the next event. + let event = session_rx.recv().await + .expect("Error while receiving session event"); + + // Exit the loop when the session has closed. + let Some(event) = event else { + break + }; + + match event { + ... // We will handle the event here + } + } +}); +``` + +{: .warning } +You have to receive the events from the `SessionReceiver` even if you don't need to handle them (which should be rare). Makiko internally uses a channel to send events to the `SessionReceiver`, and if you don't receive the events, this channel will become full and the client will block. + +### Output from the process + +Output from the process is received as `StdoutData` and `StderrData` variants of [`SessionEvent`][session-event]: + +```rust +match event { + // Handle stdout/stderr output from the process. + makiko::SessionEvent::StdoutData(data) => { + println!("Process produced stdout: {:?}", data); + }, + makiko::SessionEvent::StderrData(data) => { + println!("Process produced stderr: {:?}", data); + }, + ... +} +``` + +The data is received as chunks of bytes, but the boundaries between the chunks are not meaningful, you should treat stdout and stderr as byte streams. + +### Process exit + +When the process exits, the SSH server sends an `ExitStatus` if the process exited with a status, or `ExitSignal` if it was killed by a signal: + +```rust +match event { + ... + // Handle exit of the process. + makiko::SessionEvent::ExitStatus(status) => { + println!("Process exited with status {}", status); + }, + makiko::SessionEvent::ExitSignal(signal) => { + println!("Process exited with signal {:?}: {:?}", signal.signal_name, signal.message); + }, + ... +} +``` + +### Other events + +The server may also send an `Eof` event after the process closes its stdout and stderr. We will ignore this event, together with any other events that might be introduced in future versions of the library: + +```rust +match event { + ... + // Ignore other events + _ => {}, +} +``` + +Note that the [`SessionEvent`][session-event] enum is marked as `#[non_exhaustive]`, so the Rust compiler will require you to add the catch-all `match` clause even if you handle all variants of the enum. This allows us to add new kinds of events to Makiko without breaking your code. + +## Execute the command + +The session is now ready, so we can execute the command using [`Session::exec()`][session-exec]. We will execute the command `sed s/blue/green/g`, which reads lines from the standard input, replaces `blue` with `green`, and prints the lines back to stdout: + +[session-exec]: https://docs.rs/makiko/latest/makiko/struct.Session.html#method.exec + +```rust +// Execute a command on the session +session.exec("sed s/blue/green/".as_bytes()) + .expect("Could not execute a command in the session") + .wait().await + .expect("Server returned an error when we tried to execute a command in the session"); + +``` + +The `exec()` method returns a [`SessionResp`][session-resp], which represents the server response to the execute request. We wait for the response using [`SessionResp::wait()`][session-resp-wait], but you can also ignore the response with [`SessionResp::ignore()`][session-resp-ignore] + +[session-resp]: https://docs.rs/makiko/latest/makiko/struct.SessionResp.html +[session-resp-wait]: https://docs.rs/makiko/latest/makiko/struct.SessionResp.html#method.wait +[session-resp-ignore]: https://docs.rs/makiko/latest/makiko/struct.SessionResp.html#method.ignore + +## Send data to the process + +We will use the [`Session::send_stdin()`][session-send-stdin] method to send data to the standard input of the running process, and [`Session::send_eof()`][session-send-eof] to send end-of-file, which will close the standard input: + +[session-send-stdin]: https://docs.rs/makiko/latest/makiko/struct.Session.html#method.send_stdin +[session-send-eof]: https://docs.rs/makiko/latest/makiko/struct.Session.html#method.send_eof + +```rust +// Send some data to the standard input of the process +session.send_stdin("blueberry jam\n".into()).await.unwrap(); +session.send_stdin("blue jeans\nsky blue".into()).await.unwrap(); +session.send_eof().await.unwrap(); +``` + +## Wait for the session + +We have started the process and sent some data to it, and now we need to wait until the process terminates and the session is closed. Recall that when the session is closed, the [`SessionReceiver`][session-rx] returns `None`, we break out of the event handling loop and the task terminates. We will change the code that we have written previously to store the [`JoinHandle`][join-handle] from the `spawn()` call: + +[join-handle]: https://docs.rs/tokio/latest/tokio/task/struct.JoinHandle.html + +```rust +let session_event_task = tokio::task::spawn(async move { + loop { + let event = ...; + } +}); +``` + +Back on the main task, we will wait for the event-handling task to terminate: + +```rust +// Wait for the task that handles session events +session_event_task.await.unwrap(); +``` + +--- + +Full code for this tutorial can be found in [`examples/tutorial_5.rs`][tutorial-5]. If you don't use the [example server for this tutorial][example-server], you may need to change the code to use a different username and password. + +[tutorial-5]: https://github.com/honzasp/makiko/blob/master/examples/tutorial_5.rs +[example-server]: {% link tutorial/1-connect.md %}#example-server + +{% include tutorial_next.html link="tutorial/6-open-tunnel.md" title="Open a tunnel" %} diff --git a/docs/tutorial/6-open-tunnel.md b/docs/tutorial/6-open-tunnel.md new file mode 100644 index 0000000..4f9a775 --- /dev/null +++ b/docs/tutorial/6-open-tunnel.md @@ -0,0 +1,138 @@ +--- +title: Open a tunnel +layout: page +parent: Tutorial +nav_order: 6 +--- + +# {{ page.title }} + +SSH is most commonly used to execute commands on a remote server, but another important use of the protocol is for tunnelling TCP/IP connections. There are two ways to open a tunnel: + +- _Local forwarding_ (aka [`ssh -L`][ssh-l]): the client asks the server to open a TCP connection to another host. In Makiko, this is implemented by [`Client::connect_tunnel()`][client-connect-tunnel]. In this chapter, we will learn how to use this method. +- _Remote forwarding_ (aka [`ssh -R`][ssh-r]): the client asks the server to listen on a port, and the server will open a tunnel for every TCP connection on this port. In Makiko, this is implemented by [`Client::bind_tunnel()`][client-bind-tunnel], [`Client::unbind_tunnel()`][client-unbind-tunnel] and the [`ClientEvent::Tunnel`][client-event-tunnel] variant of `ClientEvent`. We won't cover remote forwarding in this tutorial, please refer to the API documentation for details. + +[ssh-l]: https://man.openbsd.org/ssh#L +[client-connect-tunnel]: https://docs.rs/makiko/latest/makiko/struct.Client.html#method.connect_tunnel +[ssh-r]: https://man.openbsd.org/ssh#R +[client-bind-tunnel]: https://docs.rs/makiko/latest/makiko/struct.Client.html#method.bind_tunnel +[client-unbind-tunnel]: https://docs.rs/makiko/latest/makiko/struct.Client.html#method.unbind_tunnel +[client-event-tunnel]: https://docs.rs/makiko/latest/makiko/enum.ClientEvent.html#variant.Tunnel + +## Open a tunnel + +To demonstrate the use of tunnels, we will open a TCP/IP connection from the server to [httpbin.org][httpbin] and we will manually send a simple HTTP request over this connection. To open the tunnel, we use the method [`Client::connect_tunnel()`][client-connect-tunnel], which needs: + +- A [`ChannelConfig`][channel-config], which configures the underlying SSH channel. Similar to the previous chapter, you can change the configuration to tune performance, but the default configuration should be sufficient for now. +- The address that the server will connect to, given as a pair of host and port. The host can be specified as an IP address or as a domain name. We will connect to `"httpbin.org"` on port `80`. +- The address of the "originator" of the connection. This is also specified as a pair of host and port, but the host should be an IP address. For example, [`ssh -L`][ssh-l] will set this to the remote address of the local connection that is forwarded to the server, but we will use the null IP address and port in this tutorial. + +[httpbin]: https://httpbin.org/ +[channel-config]: https://docs.rs/makiko/latest/makiko/struct.ChannelConfig.html + +```rust +// Open a tunnel from the server. +let channel_config = makiko::ChannelConfig::default(); +let connect_addr = ("httpbin.org".into(), 80); +let origin_addr = ("0.0.0.0".into(), 0); +let (tunnel, mut tunnel_rx) = client.connect_tunnel(channel_config, connect_addr, origin_addr).await + .expect("Could not open a tunnel"); +``` + +In a direct analogy to [`Client::open_session()`][client-open-session], the `Client::connect_tunnel()` method returns a pair of objects: a [`Tunnel`][tunnel] object to send requests to the tunnel, and a [`TunnelReceiver`][tunnel-rx] to receive events from the tunnel. + +[client-open-session]: https://docs.rs/makiko/latest/makiko/struct.Client.html#method.open_session +[tunnel]: https://docs.rs/makiko/latest/makiko/struct.Tunnel.html +[tunnel-rx]: https://docs.rs/makiko/latest/makiko/struct.TunnelReceiver.html + +## Handle tunnel events + +We will use the same pattern as before to handle events from the tunnel: we spawn a task and receive the events, represented as enum [`TunnelEvent`][tunnel-event], using [`TunnelReceiver::recv()`][tunnel-rx-recv]. This method returns `None` when the tunnel closes: + +[tunnel-event]: https://docs.rs/makiko/latest/makiko/enum.TunnelEvent.html +[tunnel-rx-recv]: https://docs.rs/makiko/latest/makiko/struct.TunnelReceiver.html#method.recv + +```rust +let tunnel_event_task = tokio::task::spawn(async move { + loop { + // Wait for the next event. + let event = tunnel_rx.recv().await + .expect("Error while receiving tunnel event"); + + // Exit the loop when the tunnel has closed. + let Some(event) = event else { + break + }; + + match event { + ... // Handle the event + } + } +}); +``` + +{: .warning } +As with all `Receiver` objects in Makiko, you must receive the events from the `TunnelReceiver` in a timely manner. Makiko uses a bounded buffer of events, which will become full if you don't receive the event, causing the client to block. + +### Data received from the channel + +Events on a tunnel are quite simple, you can either get a chunk of data with the `Data` variant, or an end-of-file event with the `Eof` variant: + +```rust +match event { + // Handle data received from the tunnel. + makiko::TunnelEvent::Data(data) => { + println!("Received: {:?}", data); + }, + + // Handle EOF from the tunnel. + makiko::TunnelEvent::Eof => { + println!("Received eof"); + break + }, + + _ => {}, +} +``` + +## Send data to the channel + +Back on the main task, we can use the [`Tunnel::send_data()`][tunnel-send-data] method to send bytes over the tunnel. In our case, we send a very simple HTTP request to [`httpbin.org/get`][httpbin-get]: + +[tunnel-send-data]: https://docs.rs/makiko/latest/makiko/struct.Tunnel.html#method.send_data +[httpbin-get]: https://httpbin.org/#/HTTP_Methods/get_get + +```rust +// Send data to the tunnel +tunnel.send_data("GET /get HTTP/1.0\r\nhost: httpbin.org\r\n\r\n".into()).await + .expect("Could not send data to the tunnel"); +``` + +We can also close the tunnel for sending by calling [`Tunnel::send_eof()`][tunnel-send-eof]. However, the OpenSSH server will close the tunnel prematurely if we do so, so we comment out this call: + +[tunnel-send-eof]: https://docs.rs/makiko/latest/makiko/struct.Tunnel.html#method.send_eof + +```rust +// Do not close the outbound side of the tunnel, because this causes OpenSSH to prematurely +// close the tunnel. +/* +tunnel.send_eof().await + .expect("Could not send EOF to the tunnel"); +*/ +``` + +Finally, we wait until the tunnel is closed and the event handling task terminates: + +```rust +// Wait for the task that handles tunnel events +tunnel_event_task.await.unwrap(); +``` + +--- + +Full code for this tutorial can be found in [`examples/tutorial_6.rs`][tutorial-6]. If you don't use the [example server for this tutorial][example-server], you may need to change the code to use a different username and password. + +[tutorial-6]: https://github.com/honzasp/makiko/blob/master/examples/tutorial_6.rs +[example-server]: {% link tutorial/1-connect.md %}#example-server + +{% include tutorial_next.html link="tutorial/7-verify-pubkey.md" title="Verify the server key" %} diff --git a/docs/tutorial/7-verify-pubkey.md b/docs/tutorial/7-verify-pubkey.md new file mode 100644 index 0000000..4d7f5ee --- /dev/null +++ b/docs/tutorial/7-verify-pubkey.md @@ -0,0 +1,228 @@ +--- +title: Verify the server key +layout: page +parent: Tutorial +--- + +# {{ page.title }} + +[Back in chapter 1][ch1-server-public-key], we said that we should verify the public key presented by the server during the initial connection handshake (or during any follow-up key exchange). The SSH protocol does not contain any mechanism for verifying the server key, so you must do it yourself using a method that suits your application. + +[ch1-server-public-key]: {% link tutorial/1-connect.md %}#server-public-key + +For example, you can check that the key provided by the server belongs to an allowed set of keys configured with the client. For some applications, not verifying the key may also be a valid approach, but please make sure that you understand the implications. Reading [about man-in-the-middle attacks][rfc-mitm] in the SSH protocol architecture RFC might be a good start. + +[rfc-mitm]: https://www.rfc-editor.org/rfc/rfc4251#section-9.3.4 + +{: .note } +The SSH protocol does not verify the public key (it does not check that it belongs to the server that you wanted to connect to), but it **does** verify that the server owns the corresponding private key. + +## Trust on first use + +In this chapter, we will show how you can implement the [trust-on-first-use (TOFU)][tofu] approach for verifying public keys using the well-known [`~/.ssh/known_hosts`][sshd-known-hosts] file. + +[tofu]: https://en.wikipedia.org/wiki/Trust_on_first_use +[sshd-known-hosts]: https://man.openbsd.org/sshd.8#SSH_KNOWN_HOSTS_FILE_FORMAT + +With this approach, when we connect to a server, we look up its address in the `known_hosts` file. If no entry in this file matches the address, it means that we are connecting to the server for the first time, so we accept the server key unconditionally ("trust on first use") and add an entry to the file. + +On the other hand, if the file contains at least one entry that matches the address, we check that the key provided by the server is equal to the key from one of the matched entries. If the key fails this check, it means that the server key has changed from the last time that we connected to the server, which may mean that somebody is attempting a man-in-the-middle attack, so we reject the key, which aborts the connection. (This is the _"IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!"_ error that you may have encountered when using the `ssh` client from OpenSSH.) + +{: .note } +It is important to use the hostname of the SSH server as specified by the user when searching for entries in the `known_hosts` file. For example, if the user specifies a domain name, we must look up the domain name and not the resolved IP address, because an attacker can modify the DNS record of the server to point to a different IP address. + +In the rest of this chapter, we will be writing code that handles the [`ClientEvent::ServerPubkey`][client-event-server-pubkey] event: + +[client-event-server-pubkey]: https://docs.rs/makiko/latest/makiko/enum.ClientEvent.html#variant.ServerPubkey + +```rust +match event { + makiko::ClientEvent::ServerPubkey(pubkey, accept) => { + println!("Server pubkey type {}, fingerprint {}", pubkey.type_str(), pubkey.fingerprint()); + + ... // Verify the `pubkey` and call `accept.accept()` if it is valid + }, +} +``` + +## Read the `known_hosts` file + +Makiko provides support for reading a `known_hosts` file using features from the [`host_file` module][host-file-mod]. Makiko can read the file, but it can also append new entries to the file and losslessly write the updated file back. + +[host-file-mod]: https://docs.rs/makiko/latest/makiko/host_file/index.html + +We start by locating the file and reading its contents into memory: + +```rust +// Read the ~/.ssh/known_hosts file. +let hosts_path = home::home_dir().unwrap().join(".ssh/known_hosts"); +let hosts_data = std::fs::read(&hosts_path) + .expect("Could not read known_hosts file"); +``` + +We have used the [`home`][home] crate to reliably find the user's home directory, so you may need to add it to your `Cargo.toml`: + +[home]: https://docs.rs/home/latest/home/ + +```toml +[dependencies] +home = "0.5" +``` + +We can now use the [`host_file::File::decode()`][file-decode] method to parse the file and get a [`host_file::File`][file]. Note that this method does not return a `Result`: when it encounters invalid or unrecognized lines, it simply ignores them (but keeps them around, so that we can later losslessly encode the file back). + +[file-decode]: https://docs.rs/makiko/latest/makiko/host_file/struct.File.html#method.decode +[file]: https://docs.rs/makiko/latest/makiko/host_file/struct.File.html + +```rust +// Decode the contents of the file. +let mut hosts_file = makiko::host_file::File::decode(hosts_data.into()); +``` + +## Lookup the server address + +The `known_hosts` file is a sequence of entries ([`host_file::Entry`][host-file-entry]). Every entry stores a single public key and it contains a pattern that can match an address. + +[host-file-entry]: https://docs.rs/makiko/latest/makiko/host_file/struct.Entry.html + +The pattern might be a concrete hostname (such as `github.com`, `140.82.121.4` or `[localhost]:2222`), a wildcard pattern (such as `*.github.com` or `g?thub.com`) or a hash of the hostname (`|1|4n/lI1Js...my6Q=`). The hashed pattern is usually preferred, because it hides the identity of SSH servers that you have connected to, in case your `known_hosts` file is leaked. + +Some entires may also be marked as revoked, which means that the public key should be rejected instead of accepted. + +We can use the method [`host_file::File::match_host_port_key()`][file-match-host-port-key] to search for all entries that match the given host and port. The result of this search is a [`host_file::KeyMatch`][key-match]: + +```rust +// Lookup the server address in the file. +let key_match = hosts_file.match_host_port_key(host, port, &pubkey); +``` + +[file-match-host-port-key]: https://docs.rs/makiko/latest/makiko/host_file/struct.File.html#method.match_host_port_key +[key-match]: https://docs.rs/makiko/latest/makiko/host_file/enum.KeyMatch.html + +## Deal with the result of the lookup + +There are four variants of the [`host_file::KeyMatch`][key-match] enum, so we need to handle them all: + +```rust +match key_match { + ... +} +``` + +### Key is present in the file + +The `Accepted` variant means that the `known_hosts` file contains at least one entry that matches the hostname and which refers to the public key provided by the server. This means that we have previously decided to trust this key for this hostname, so we can call [`AcceptPubkey::accept()`][accept-pubkey-accept] to accept the key: + +[accept-pubkey-accept]: https://docs.rs/makiko/latest/makiko/struct.AcceptPubkey.html#method.accept + +```rust +match key_match { + // The given key was found in the file, this means that it is trusted and we + // can accept it. + makiko::host_file::KeyMatch::Accepted(entries) => { + println!("Found the server key in known_hosts file"); + for entry in entries.iter() { + println!("At line {}", entry.line()); + } + accept.accept(); + }, + ... +} +``` + +### Key was revoked + +The `Revoked` variant means that there was an entry that lists the key as revoked for the hostname, so we must reject the key. You can call [`AcceptPubkey::reject()`][accept-pubkey-reject] with a custom error that describes the reason for the rejection, or you can simply drop the `AcceptPubkey` object, which will reject the key with a default error: + +[accept-pubkey-reject]: https://docs.rs/makiko/latest/makiko/struct.AcceptPubkey.html#method.reject + +```rust +match key_match { + ... + // The key was revoked in the file, so we must reject it. + makiko::host_file::KeyMatch::Revoked(_entry) => { + println!("The server key was revoked in known_hosts file"); + }, + ... +} +``` + +### Other keys found in the file + +The `OtherKeys` variant means that we found entries that match the hostname, but all of them specified a different public key. This means that we already know the valid keys of this server, but the server provided a different key, so we must reject the key, because a man-in-the-middle attack might be going on: + +```rust +match key_match { + ... + // We found other keys for this server in the file, so the server changed its + // key, or somebody is doing a man-in-the-middle attack on us. + makiko::host_file::KeyMatch::OtherKeys(entries) => { + println!("The known_hosts file specifies other keys for this server:"); + for entry in entries.iter() { + println!("At line {}, pubkey type {}, fingerprint {}", + entry.line(), entry.pubkey().type_str(), entry.pubkey().fingerprint()); + } + println!("Aborting, you might be target of a man-in-the-middle attack!"); + }, + ... +} +``` + +### No entry was found + +Finally, the `NotFound` variant means that the file does not contain any entry matching the given hostname. In this case, we may decide to trust the key and add it to the `known_hosts` file: + +```rust +match key_match { + ... + // We did not find the key in the file, so we decide to accept the key and add + // it to the file. + makiko::host_file::KeyMatch::NotFound => { + println!("Did not find any key for this server in known_hosts file, \ + adding it to the file"); + accept.accept(); + + ... // Add an entry to the file + }, +} +``` + +To add an entry to the [`host_file::File`][file], we can use the [`host_file::File::append_entry()`][file-append-entry] method and the [`host_file::EntryBuilder`][entry-builder]: + +[file-append-entry]: https://docs.rs/makiko/latest/makiko/host_file/struct.File.html#method.append_entry +[entry-builder]: https://docs.rs/makiko/latest/makiko/host_file/struct.EntryBuilder.html + +```rust +// Append an entry with the key to the file. +hosts_file.append_entry( + makiko::host_file::File::entry_builder() + .host_port(host, port) + .key(pubkey) +); +``` + +To save the updated file to disk, we will use the [`host_file::File::encode()`][file-encode] method to get the modified contents of the file: + +[file-encode]: https://docs.rs/makiko/latest/makiko/host_file/struct.File.html#method.encode + +```rust +// Write the modified file back to disk. +let hosts_data = hosts_file.encode(); +std::fs::write(&hosts_path, &hosts_data) + .expect("Could not write the modified known_hosts file"); +``` + +The `encode()` method is lossless: it faithfully preserves all existing lines, including comments or invalid lines. + +--- + +Full code for this tutorial can be found in [`examples/tutorial_7.rs`][tutorial-7]. If you don't use the [example server for this tutorial][example-server], you may need to change the code to use a different username and password. + +[tutorial-7]: https://github.com/honzasp/makiko/blob/master/examples/tutorial_7.rs +[example-server]: {% link tutorial/1-connect.md %}#example-server + +This concludes the Makiko tutorial. Thank you for your interest, I hope that the library will be useful to you and that you will enjoy using it! + +

+ Next: API documentation +

diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md new file mode 100644 index 0000000..c3e5c29 --- /dev/null +++ b/docs/tutorial/index.md @@ -0,0 +1,39 @@ +--- +title: Tutorial +layout: page +has_children: true +--- + +# {{ page.title }} + +Makiko is an asynchronous SSH client library for Rust. It gives you a lot of control over the SSH connection, but this means that it is quite low-level. + +In this tutorial, we will to connect to a server, authenticate with a password or a public key, execute a command and open a tunnel. I will assume that you know [Rust][rust], have some experience with [Tokio][tokio] and have used SSH previously. + +[rust]: https://www.rust-lang.org/ +[tokio]: https://tokio.rs/ + +## The code + +If you want to follow along with the tutorial, create a new Rust project using Cargo: + +``` +$ cargo new hello_makiko +$ cd hello_makiko +``` + +And add the dependencies to Makiko and Tokio into your `Cargo.toml`: + +```toml +[dependencies] +makiko = "0.2.1" +tokio = {version = "1.25", features = ["full"]} +``` + +You can also find complete code for each chapter in this tutorial [in the `examples/` directory][examples] in the Makiko repository. + +[examples]: https://github.com/honzasp/makiko/tree/master/examples + +In the next chapter, we will start writing the code in `src/main.rs`. + +{% include tutorial_next.html link="tutorial/1-connect.md" title="Connecting to the server" %} diff --git a/examples/tutorial_1.rs b/examples/tutorial_1.rs new file mode 100644 index 0000000..99a2b30 --- /dev/null +++ b/examples/tutorial_1.rs @@ -0,0 +1,54 @@ +// Tutorial chapter 1: Connect to the server +// +// https://honzasp.github.io/makiko/tutorial/1-connect.html +// +// You can run the example with +// +// cargo run --example tutorial_1 +// + +#[tokio::main] +async fn main() { + // Connect to the SSH server. + let socket = tokio::net::TcpStream::connect(("localhost", 2222)).await + .expect("Could not open a TCP socket"); + + // Use the default secure configuration of the SSH client. + let config = makiko::ClientConfig::default(); + //let config = makiko::ClientConfig::default_compatible_less_secure(); + + // Create the SSH client. + let (client, mut client_rx, client_fut) = makiko::Client::open(socket, config) + .expect("Could not open client"); + + // Spawn a Tokio task that polls the client. + tokio::task::spawn(async move { + client_fut.await.expect("Error in client future"); + }); + + loop { + // Wait for the next event. + let event = client_rx.recv().await + .expect("Error while receiving client event"); + + // Exit the loop when the client has closed. + let Some(event) = event else { + break + }; + + match event { + // Handle the server public key: for now, we just accept all keys, but this makes + // us susceptible to man-in-the-middle attacks! + makiko::ClientEvent::ServerPubkey(pubkey, accept) => { + println!("Server pubkey type {}, fingerprint {}", pubkey.type_str(), pubkey.fingerprint()); + accept.accept(); + }, + + // All other events can be safely ignored + _ => {}, + } + } + + // We aren't going to use the `Client` object yet + let _ = client; +} diff --git a/examples/tutorial_2.rs b/examples/tutorial_2.rs new file mode 100644 index 0000000..b826256 --- /dev/null +++ b/examples/tutorial_2.rs @@ -0,0 +1,71 @@ +// Tutorial chapter 2: Password authentication +// +// https://honzasp.github.io/makiko/tutorial/2-password-auth.html +// +// You can run the example with +// +// cargo run --example tutorial_2 +// + +#[tokio::main] +async fn main() { + // Connect to the SSH server. + let socket = tokio::net::TcpStream::connect(("localhost", 2222)).await + .expect("Could not open a TCP socket"); + + // Use the default secure configuration of the SSH client. + let config = makiko::ClientConfig::default(); + //let config = makiko::ClientConfig::default_compatible_less_secure(); + + // Create the SSH client. + let (client, mut client_rx, client_fut) = makiko::Client::open(socket, config) + .expect("Could not open client"); + + // Spawn a Tokio task that polls the client. + tokio::task::spawn(async move { + client_fut.await.expect("Error in client future"); + }); + + // Spawn another Tokio task to handle the client events. + tokio::task::spawn(async move { + loop { + // Wait for the next event. + let event = client_rx.recv().await + .expect("Error while receiving client event"); + + // Exit the loop when the client has closed. + let Some(event) = event else { + break + }; + + match event { + // Handle the server public key: for now, we just accept all keys, but this makes + // us susceptible to man-in-the-middle attacks! + makiko::ClientEvent::ServerPubkey(pubkey, accept) => { + println!("Server pubkey type {}, fingerprint {}", pubkey.type_str(), pubkey.fingerprint()); + accept.accept(); + }, + + // All other events can be safely ignored + _ => {}, + } + } + }); + + // Try to authenticate using a password. + let auth_res = client.auth_password("alice".into(), "alicealice".into()).await + .expect("Error when trying to authenticate"); + + // Deal with all possible outcomes of password authentication. + match auth_res { + makiko::AuthPasswordResult::Success => { + println!("We have successfully authenticated using a password"); + }, + makiko::AuthPasswordResult::ChangePassword(prompt) => { + panic!("The server asks us to change password: {:?}", prompt); + }, + makiko::AuthPasswordResult::Failure(failure) => { + panic!("The server rejected authentication: {:?}", failure); + } + } +} diff --git a/examples/tutorial_3.rs b/examples/tutorial_3.rs new file mode 100644 index 0000000..ab6b121 --- /dev/null +++ b/examples/tutorial_3.rs @@ -0,0 +1,87 @@ +// Tutorial chapter 3: Public key authentication +// +// https://honzasp.github.io/makiko/tutorial/3-pubkey-auth.html +// +// You can run the example with +// +// cargo run --example tutorial_3 +// + +#[tokio::main] +async fn main() { + // Connect to the SSH server. + let socket = tokio::net::TcpStream::connect(("localhost", 2222)).await + .expect("Could not open a TCP socket"); + + // Use the default secure configuration of the SSH client. + let config = makiko::ClientConfig::default(); + //let config = makiko::ClientConfig::default_compatible_less_secure(); + + // Create the SSH client. + let (client, mut client_rx, client_fut) = makiko::Client::open(socket, config) + .expect("Could not open client"); + + // Spawn a Tokio task that polls the client. + tokio::task::spawn(async move { + client_fut.await.expect("Error in client future"); + }); + + // Spawn another Tokio task to handle the client events. + tokio::task::spawn(async move { + loop { + // Wait for the next event. + let event = client_rx.recv().await + .expect("Error while receiving client event"); + + // Exit the loop when the client has closed. + let Some(event) = event else { + break + }; + + match event { + // Handle the server public key: for now, we just accept all keys, but this makes + // us susceptible to man-in-the-middle attacks! + makiko::ClientEvent::ServerPubkey(pubkey, accept) => { + println!("Server pubkey type {}, fingerprint {}", pubkey.type_str(), pubkey.fingerprint()); + accept.accept(); + }, + + // All other events can be safely ignored + _ => {}, + } + } + }); + + // Decode our private key from PEM. + let privkey = makiko::keys::decode_pem_privkey_nopass(PRIVKEY_PEM) + .expect("Could not decode a private key from PEM") + .privkey().cloned() + .expect("Private key is encrypted"); + + // Select an algorithm for public key authentication. + let pubkey_algo = &makiko::pubkey::SSH_ED25519; + + // Try to authenticate with the private key + let auth_res = client.auth_pubkey("edward".into(), privkey, pubkey_algo).await + .expect("Error when trying to authenticate"); + + // Deal with the possible outcomes of public key authentication. + match auth_res { + makiko::AuthPubkeyResult::Success => { + println!("We have successfully authenticated using a private key"); + }, + makiko::AuthPubkeyResult::Failure(failure) => { + panic!("The server rejected authentication: {:?}", failure); + } + } +} + +const PRIVKEY_PEM: &[u8] = br#" +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACDyVJsRfh+NmkQKg2Dh6rPVodiQ3nC+dVoGMoMtYcbMJQAAAJBPdwHAT3cB +wAAAAAtzc2gtZWQyNTUxOQAAACDyVJsRfh+NmkQKg2Dh6rPVodiQ3nC+dVoGMoMtYcbMJQ +AAAEA5ct+xfc9qlJ4I2Jee8HIrAhN55yxmtUmvKpjT7q6QXPJUmxF+H42aRAqDYOHqs9Wh +2JDecL51WgYygy1hxswlAAAABmVkd2FyZAECAwQFBgc= +-----END OPENSSH PRIVATE KEY----- +"#; diff --git a/examples/tutorial_4.rs b/examples/tutorial_4.rs new file mode 100644 index 0000000..8a85067 --- /dev/null +++ b/examples/tutorial_4.rs @@ -0,0 +1,118 @@ +// Tutorial chapter 4: Public key algorithm +// +// https://honzasp.github.io/makiko/tutorial/4-pubkey-algo.html +// +// You can run the example with +// +// cargo run --example tutorial_4 +// + +#[tokio::main] +async fn main() { + // Connect to the SSH server. + let socket = tokio::net::TcpStream::connect(("localhost", 2222)).await + .expect("Could not open a TCP socket"); + + // Use the default secure configuration of the SSH client. + let config = makiko::ClientConfig::default(); + //let config = makiko::ClientConfig::default_compatible_less_secure(); + + // Create the SSH client. + let (client, mut client_rx, client_fut) = makiko::Client::open(socket, config) + .expect("Could not open client"); + + // Spawn a Tokio task that polls the client. + tokio::task::spawn(async move { + client_fut.await.expect("Error in client future"); + }); + + // Spawn another Tokio task to handle the client events. + tokio::task::spawn(async move { + loop { + // Wait for the next event. + let event = client_rx.recv().await + .expect("Error while receiving client event"); + + // Exit the loop when the client has closed. + let Some(event) = event else { + break + }; + + match event { + // Handle the server public key: for now, we just accept all keys, but this makes + // us susceptible to man-in-the-middle attacks! + makiko::ClientEvent::ServerPubkey(pubkey, accept) => { + println!("Server pubkey type {}, fingerprint {}", pubkey.type_str(), pubkey.fingerprint()); + accept.accept(); + }, + + // All other events can be safely ignored. + _ => {}, + } + } + }); + + // Decode our private key from PEM. + let privkey = makiko::keys::decode_pem_privkey_nopass(PRIVKEY_PEM) + .expect("Could not decode a private key from PEM") + .privkey().cloned() + .expect("Private key is encrypted"); + + // Get the public key from the private key. + let pubkey = privkey.pubkey(); + + // Get the public key algorithms supported by the key. + let available_algos = pubkey.algos(); + + // Try the algorithms one by one. + let username: String = "ruth".into(); + for pubkey_algo in available_algos.iter().copied() { + // Check whether this combination of a public key and algorithm would be acceptable to the + // server. + let check_ok = client.check_pubkey(username.clone(), &pubkey, pubkey_algo).await + .expect("Error when checking a public key"); + + // Skip this algorithm if the server rejected it. + if !check_ok { + println!("Server rejected public key and algorithm {:?}", pubkey_algo.name); + continue; + } + + // Try to authenticate using this algorithm. + let auth_res = client.auth_pubkey(username.clone(), privkey.clone(), pubkey_algo).await + .expect("Error when trying to authenticate"); + match auth_res { + makiko::AuthPubkeyResult::Success => { + println!("We have successfully authenticated using algorithm {:?}", pubkey_algo.name); + break; + }, + makiko::AuthPubkeyResult::Failure(_) => { + println!("Authentication using public key and algorithm {:?} failed", pubkey_algo.name); + }, + } + } + + // Check that we have been authenticated. + if !client.is_authenticated().unwrap() { + panic!("Could not authenticate"); + } +} + +const PRIVKEY_PEM: &[u8] = br#" +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAlwAAAAdzc2gtcn +NhAAAAAwEAAQAAAIEA5y4OZWndQMr8TGCMTuO38TlWt+WzAcyNxHyeJgGbBT0PneDtSFb4 +nFzyNV8IxBSG18aECmcOLqpCWn21io6Zs+Rr8pAqG2md6Wbfc097EnEpkJsuAJoQLlU/C2 +g4cLEnFlabboq3B9W/UtFXXICTtQbzv1TCoj1kQCPObQ+9ihEAAAIAcCOChXAjgoUAAAAH +c3NoLXJzYQAAAIEA5y4OZWndQMr8TGCMTuO38TlWt+WzAcyNxHyeJgGbBT0PneDtSFb4nF +zyNV8IxBSG18aECmcOLqpCWn21io6Zs+Rr8pAqG2md6Wbfc097EnEpkJsuAJoQLlU/C2g4 +cLEnFlabboq3B9W/UtFXXICTtQbzv1TCoj1kQCPObQ+9ihEAAAADAQABAAAAgQDM1U4EJW +zZAAHUWqd3LuXIYpmnj2qwaWIjepdV7Y5BcfzoUmdr9UOKqAAjsfS+Z8GiZk6QOQej6U+p +hkDYZ8len8g3hzxYwa3P6bomJAibRdhBu4OL5zRw8xnM9VQdJ93nc0pZokL3ltjG4hEpyV +6ltbep6mNGr8Vbf3JbSv0YwQAAAEEAl2cdVGalH2a/PWoBJmCDYcNpNKJZoZldp0p52Bqw +pCxjzOdQqWzv8xLKm/5bCh03j1mn8BwmKPtzit3Z040W6gAAAEEA/ZSEUw+UkvJjGY6SNw +cxjRslF1Rs5sPrNX6JhVUf2VpglqGtdOmrFxXhDMQcawdfmPPISCxLUsLqgiL6ohHNvQAA +AEEA6WLRFRvwAHPT7lzaiyKjsDaFzyA9r0+csRDVDe3VJ5mSq2xo3+0YoeF6rarzpSTbyQ +pshWng0o8WBTVRrNqA5QAAAARydXRoAQIDBAU= +-----END OPENSSH PRIVATE KEY----- +"#; diff --git a/examples/tutorial_5.rs b/examples/tutorial_5.rs new file mode 100644 index 0000000..7348fb5 --- /dev/null +++ b/examples/tutorial_5.rs @@ -0,0 +1,125 @@ +// Tutorial chapter 5: Execute a command +// +// https://honzasp.github.io/makiko/tutorial/5-execute-command.html +// +// You can run the example with +// +// cargo run --example tutorial_5 +// + +#[tokio::main] +async fn main() { + // Connect to the SSH server. + let socket = tokio::net::TcpStream::connect(("localhost", 2222)).await + .expect("Could not open a TCP socket"); + + // Use the default secure configuration of the SSH client. + let config = makiko::ClientConfig::default(); + //let config = makiko::ClientConfig::default_compatible_less_secure(); + + // Create the SSH client. + let (client, mut client_rx, client_fut) = makiko::Client::open(socket, config) + .expect("Could not open client"); + + // Spawn a Tokio task that polls the client. + tokio::task::spawn(async move { + client_fut.await.expect("Error in client future"); + }); + + // Spawn another Tokio task to handle the client events. + tokio::task::spawn(async move { + loop { + // Wait for the next event. + let event = client_rx.recv().await + .expect("Error while receiving client event"); + + // Exit the loop when the client has closed. + let Some(event) = event else { + break + }; + + match event { + // Handle the server public key: for now, we just accept all keys, but this makes + // us susceptible to man-in-the-middle attacks! + makiko::ClientEvent::ServerPubkey(pubkey, accept) => { + println!("Server pubkey type {}, fingerprint {}", pubkey.type_str(), pubkey.fingerprint()); + accept.accept(); + }, + + // All other events can be safely ignored + _ => {}, + } + } + }); + + // Try to authenticate using a password. + let auth_res = client.auth_password("alice".into(), "alicealice".into()).await + .expect("Error when trying to authenticate"); + + // Deal with all possible outcomes of password authentication. + match auth_res { + makiko::AuthPasswordResult::Success => { + println!("We have successfully authenticated using a password"); + }, + makiko::AuthPasswordResult::ChangePassword(prompt) => { + panic!("The server asks us to change password: {:?}", prompt); + }, + makiko::AuthPasswordResult::Failure(failure) => { + panic!("The server rejected authentication: {:?}", failure); + } + } + + // Open a session on the server. + let channel_config = makiko::ChannelConfig::default(); + let (session, mut session_rx) = client.open_session(channel_config).await + .expect("Could not open a session"); + + // Handle session events asynchronously in a Tokio task. + let session_event_task = tokio::task::spawn(async move { + loop { + // Wait for the next event. + let event = session_rx.recv().await + .expect("Error while receiving session event"); + + // Exit the loop when the session has closed. + let Some(event) = event else { + break + }; + + match event { + // Handle stdout/stderr output from the process. + makiko::SessionEvent::StdoutData(data) => { + println!("Process produced stdout: {:?}", data); + }, + makiko::SessionEvent::StderrData(data) => { + println!("Process produced stderr: {:?}", data); + }, + + // Handle exit of the process. + makiko::SessionEvent::ExitStatus(status) => { + println!("Process exited with status {}", status); + }, + makiko::SessionEvent::ExitSignal(signal) => { + println!("Process exited with signal {:?}: {:?}", signal.signal_name, signal.message); + }, + + // Ignore other events + _ => {}, + } + } + }); + + // Execute a command on the session + session.exec("sed s/blue/green/".as_bytes()) + .expect("Could not execute a command in the session") + .wait().await + .expect("Server returned an error when we tried to execute a command in the session"); + + // Send some data to the standard input of the process + session.send_stdin("blueberry jam\n".into()).await.unwrap(); + session.send_stdin("blue jeans\nsky blue".into()).await.unwrap(); + session.send_eof().await.unwrap(); + + // Wait for the task that handles session events + session_event_task.await.unwrap(); +} diff --git a/examples/tutorial_6.rs b/examples/tutorial_6.rs new file mode 100644 index 0000000..2a15d98 --- /dev/null +++ b/examples/tutorial_6.rs @@ -0,0 +1,123 @@ +// Tutorial chapter 6: Open a tunnel +// +// https://honzasp.github.io/makiko/tutorial/6-open-tunnel.html +// +// You can run the example with +// +// cargo run --example tutorial_6 +// + +#[tokio::main] +async fn main() { + env_logger::init(); + + // Connect to the SSH server. + let socket = tokio::net::TcpStream::connect(("localhost", 2222)).await + .expect("Could not open a TCP socket"); + + // Use the default secure configuration of the SSH client. + let config = makiko::ClientConfig::default(); + //let config = makiko::ClientConfig::default_compatible_less_secure(); + + // Create the SSH client. + let (client, mut client_rx, client_fut) = makiko::Client::open(socket, config) + .expect("Could not open client"); + + // Spawn a Tokio task that polls the client. + tokio::task::spawn(async move { + client_fut.await.expect("Error in client future"); + }); + + // Spawn another Tokio task to handle the client events. + tokio::task::spawn(async move { + loop { + // Wait for the next event. + let event = client_rx.recv().await + .expect("Error while receiving client event"); + + // Exit the loop when the client has closed. + let Some(event) = event else { + break + }; + + match event { + // Handle the server public key: for now, we just accept all keys, but this makes + // us susceptible to man-in-the-middle attacks! + makiko::ClientEvent::ServerPubkey(pubkey, accept) => { + println!("Server pubkey type {}, fingerprint {}", pubkey.type_str(), pubkey.fingerprint()); + accept.accept(); + }, + + // All other events can be safely ignored + _ => {}, + } + } + }); + + // Try to authenticate using a password. + let auth_res = client.auth_password("alice".into(), "alicealice".into()).await + .expect("Error when trying to authenticate"); + + // Deal with all possible outcomes of password authentication. + match auth_res { + makiko::AuthPasswordResult::Success => { + println!("We have successfully authenticated using a password"); + }, + makiko::AuthPasswordResult::ChangePassword(prompt) => { + panic!("The server asks us to change password: {:?}", prompt); + }, + makiko::AuthPasswordResult::Failure(failure) => { + panic!("The server rejected authentication: {:?}", failure); + } + } + + // Open a tunnel from the server. + let channel_config = makiko::ChannelConfig::default(); + let connect_addr = ("httpbin.org".into(), 80); + let origin_addr = ("0.0.0.0".into(), 0); + let (tunnel, mut tunnel_rx) = client.connect_tunnel(channel_config, connect_addr, origin_addr).await + .expect("Could not open a tunnel"); + + // Handle tunnel events asynchronously in a Tokio task. + let tunnel_event_task = tokio::task::spawn(async move { + loop { + // Wait for the next event. + let event = tunnel_rx.recv().await + .expect("Error while receiving tunnel event"); + + // Exit the loop when the tunnel has closed. + let Some(event) = event else { + break + }; + + match event { + // Handle data received from the tunnel. + makiko::TunnelEvent::Data(data) => { + println!("Received: {:?}", data); + }, + + // Handle EOF from the tunnel. + makiko::TunnelEvent::Eof => { + println!("Received eof"); + break + }, + + _ => {}, + } + } + }); + + // Send data to the tunnel + tunnel.send_data("GET /get HTTP/1.0\r\nhost: httpbin.org\r\n\r\n".into()).await + .expect("Could not send data to the tunnel"); + + // Do not close the outbound side of the tunnel, because this causes OpenSSH to prematurely + // close the tunnel. + /* + tunnel.send_eof().await + .expect("Could not send EOF to the tunnel"); + */ + + // Wait for the task that handles tunnel events + tunnel_event_task.await.unwrap(); +} diff --git a/examples/tutorial_7.rs b/examples/tutorial_7.rs new file mode 100644 index 0000000..52c8363 --- /dev/null +++ b/examples/tutorial_7.rs @@ -0,0 +1,129 @@ +// Tutorial chapter 7: Verify the server key +// +// https://honzasp.github.io/makiko/tutorial/7-verify-pubkey.md +// +// You can run the example with +// +// cargo run --example tutorial_7 +// + +#[tokio::main] +async fn main() { + // Connect to the SSH server. + let host = "localhost"; + let port = 2222; + let socket = tokio::net::TcpStream::connect((host, port)).await + .expect("Could not open a TCP socket"); + + // Use the default secure configuration of the SSH client. + let config = makiko::ClientConfig::default(); + //let config = makiko::ClientConfig::default_compatible_less_secure(); + + // Create the SSH client. + let (client, mut client_rx, client_fut) = makiko::Client::open(socket, config) + .expect("Could not open client"); + + // Spawn a Tokio task that polls the client. + tokio::task::spawn(async move { + client_fut.await.expect("Error in client future"); + }); + + // Spawn another Tokio task to handle the client events. + tokio::task::spawn(async move { + loop { + // Wait for the next event. + let event = client_rx.recv().await + .expect("Error while receiving client event"); + + // Exit the loop when the client has closed. + let Some(event) = event else { + break + }; + + match event { + makiko::ClientEvent::ServerPubkey(pubkey, accept) => { + println!("Server pubkey type {}, fingerprint {}", pubkey.type_str(), pubkey.fingerprint()); + + // Read the ~/.ssh/known_hosts file. + let hosts_path = home::home_dir().unwrap().join(".ssh/known_hosts"); + let hosts_data = std::fs::read(&hosts_path) + .expect("Could not read known_hosts file"); + + // Decode the contents of the file. + let mut hosts_file = makiko::host_file::File::decode(hosts_data.into()); + + // Lookup the server address in the file. + let key_match = hosts_file.match_host_port_key(host, port, &pubkey); + + match key_match { + // The given key was found in the file, this means that it is trusted and we + // can accept it. + makiko::host_file::KeyMatch::Accepted(entries) => { + println!("Found the server key in known_hosts file"); + for entry in entries.iter() { + println!("At line {}", entry.line()); + } + accept.accept(); + }, + + // The key was revoked in the file, so we must reject it. + makiko::host_file::KeyMatch::Revoked(_entry) => { + println!("The server key was revoked in known_hosts file"); + }, + + // We found other keys for this server in the file, so the server changed its + // key, or somebody is doing a man-in-the-middle attack on us. + makiko::host_file::KeyMatch::OtherKeys(entries) => { + println!("The known_hosts file specifies other keys for this server:"); + for entry in entries.iter() { + println!("At line {}, pubkey type {}, fingerprint {}", + entry.line(), entry.pubkey().type_str(), entry.pubkey().fingerprint()); + } + println!("Aborting, you might be target of a man-in-the-middle attack!"); + }, + + // We did not find the key in the file, so we decide to accept the key and add + // it to the file. + makiko::host_file::KeyMatch::NotFound => { + println!("Did not find any key for this server in known_hosts file, \ + adding it to the file"); + accept.accept(); + + // Append an entry with the key to the file. + hosts_file.append_entry( + makiko::host_file::File::entry_builder() + .host_port(host, port) + .key(pubkey) + ); + + // Write the modified file back to disk. + let hosts_data = hosts_file.encode(); + std::fs::write(&hosts_path, &hosts_data) + .expect("Could not write the modified known_hosts file"); + }, + } + }, + + // All other events can be safely ignored + _ => {}, + } + } + }); + + // Try to authenticate using a password. + let auth_res = client.auth_password("alice".into(), "alicealice".into()).await + .expect("Error when trying to authenticate"); + + // Deal with all possible outcomes of password authentication. + match auth_res { + makiko::AuthPasswordResult::Success => { + println!("We have successfully authenticated using a password"); + }, + makiko::AuthPasswordResult::ChangePassword(prompt) => { + panic!("The server asks us to change password: {:?}", prompt); + }, + makiko::AuthPasswordResult::Failure(failure) => { + panic!("The server rejected authentication: {:?}", failure); + } + } +} diff --git a/src/client/channel.rs b/src/client/channel.rs index 93bb821..7f80e92 100644 --- a/src/client/channel.rs +++ b/src/client/channel.rs @@ -80,7 +80,7 @@ impl Channel { pub async fn send_eof(&self) -> Result<()> { match self.try_send_eof().await { Ok(_) => Ok(()), - // it is common that the peer closes the channel before we have a change to send EOF, + // it is common that the peer closes the channel before we have a chance to send EOF, // so we just ignore the error in this case Err(Error::ChannelClosed) => Ok(()), Err(err) => Err(err), diff --git a/src/client/session.rs b/src/client/session.rs index 2b6c7d2..063a3be 100644 --- a/src/client/session.rs +++ b/src/client/session.rs @@ -21,6 +21,7 @@ use super::client::Client; /// /// Once the session is open, you will typically go through three stages: /// - prepare the execution environment: [`env()`][Self::env()], +/// [`request_pty()`][Self::request_pty()], /// - start the execution: [`shell()`][Self::shell()], [`exec()`][Self::exec()], /// [`subsystem()`][Self::subsystem()], /// - interact with the process: [`send_stdin()`][Self::send_stdin()], @@ -232,8 +233,8 @@ impl Session { /// Future server response to a [`Session`] request. /// -/// You may either wait for the response using [`.wait()`][Self::wait], or ignore the response -/// using [`.ignore()`]. +/// You may either wait for the response using [`.wait()`][Self::wait()], or ignore the response +/// using [`.ignore()`][Self::ignore()]. #[derive(Debug)] #[must_use = "please use .wait().await to await the response, or .ignore() to ignore it"] pub struct SessionResp { diff --git a/src/host_file.rs b/src/host_file.rs index 22a9971..faccdf2 100644 --- a/src/host_file.rs +++ b/src/host_file.rs @@ -101,6 +101,7 @@ pub enum KeyMatch<'e> { /// The key was accepted for this hostname. /// /// The `Vec` lists the entries that match the hostname and the key, it is always non-empty. + // TODO: rename this to `Found` Accepted(Vec<&'e Entry>), /// The key was revoked. @@ -113,7 +114,7 @@ pub enum KeyMatch<'e> { /// The `Vec` list all non-revoked entries that match the hostname, it is always non-empty. OtherKeys(Vec<&'e Entry>), - /// The combination of key and host was not found. + /// No entry that matches this hostname was found. NotFound, } diff --git a/src/lib.rs b/src/lib.rs index 143093e..446cfa8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,13 @@ -//! Asynchronous SSH client library for Rust. +//! Asynchronous SSH client library in pure Rust. +//! +//! You may want to **[read the tutorial][tutorial]** to get started with Makiko. +//! +//! [tutorial]: https://honzasp.github.io/makiko +//! +//! - Entry point for making SSH connections is the [`Client`]. +//! - Functions for decoding keys are in the [`keys`] module. +//! - Support for the `known_hosts` file is in the [`host_file`] module. //! -//! The entry point for making SSH connections is the [`Client`]. #![allow(clippy::collapsible_if)] #![allow(clippy::unused_unit)] #![allow(clippy::unit_arg)]