diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml new file mode 100644 index 0000000..8a06cf4 --- /dev/null +++ b/.github/workflows/build_and_test.yml @@ -0,0 +1,153 @@ +# This file was initially autogenerated by maturin v1.5.0 +name: Build and Test + +on: + push: + branches: [master] + pull_request: + types: [opened, synchronize, reopened] + paths-ignore: + - "*.md" + - "*.example" + - ".gitignore" + +permissions: + contents: read + +jobs: + linux: + runs-on: ubuntu-latest + strategy: + matrix: + target: [x86_64, x86, aarch64, armv7, s390x, ppc64le] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Install Build Dependencies + run: | + sudo apt-get update + sudo apt-get install -y cmake libssl-dev pkg-config + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + manylinux: auto + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-linux-${{ matrix.target }} + path: dist + - name: Setup Docker + run: | + sudo apt-get install apt-transport-https ca-certificates curl software-properties-common + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - + sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" + sudo apt-get update + sudo apt-get install docker-ce + - name: pytest + if: ${{ startsWith(matrix.target, 'x86_64') }} + shell: bash + run: | + set -e + pip install "hussh[dev] @ ." --find-links dist --force-reinstall + pytest -v tests/ + - name: pytest + if: ${{ !startsWith(matrix.target, 'x86') && matrix.target != 'ppc64' }} + uses: uraimo/run-on-arch-action@v2.5.0 + with: + arch: ${{ matrix.target }} + distro: ubuntu22.04 + githubToken: ${{ github.token }} + install: | + apt-get update + apt-get install -y --no-install-recommends python3 python3-pip + pip3 install -U pip pytest + run: | + set -e + pip3 install hussh[dev] --find-links dist --force-reinstall + pytest -v tests/ + + windows: + runs-on: windows-latest + strategy: + matrix: + target: [x64, x86] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + architecture: ${{ matrix.target }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.target }} + path: dist + # - name: Set up Docker Buildx + # uses: docker/setup-buildx-action@v1 + # - name: pytest + # if: ${{ !startsWith(matrix.target, 'aarch64') }} + # shell: bash + # run: | + # set -e + # pip install "hussh[dev] @ ." --find-links dist --force-reinstall + # pip install pytest + # pytest -v tests/ + + macos: + runs-on: macos-latest + strategy: + matrix: + target: [x86_64, aarch64] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Install Build Dependencies + run: | + brew install openssl + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.target }} + path: dist + - name: pytest + if: ${{ !startsWith(matrix.target, 'aarch64') }} + shell: bash + run: | + set -e + pip install [dev] --find-links dist --force-reinstall + pytest -v tests/ + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: dist + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a394f94 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,115 @@ +# This file is autogenerated by maturin v1.5.0 +# To update, run +# +# maturin generate-ci --pytest github +# +name: CI + +on: + push: + tags: + - '*' + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + linux: + runs-on: ubuntu-latest + strategy: + matrix: + target: [x86_64, x86, aarch64, armv7, s390x, ppc64le] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + manylinux: auto + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-linux-${{ matrix.target }} + path: dist + + windows: + runs-on: windows-latest + strategy: + matrix: + target: [x64, x86] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + architecture: ${{ matrix.target }} + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.target }} + path: dist + + macos: + runs-on: macos-latest + strategy: + matrix: + target: [x86_64, aarch64] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Build wheels + uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --find-interpreter + sccache: 'true' + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.target }} + path: dist + + sdist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: dist + + release: + name: Release + runs-on: ubuntu-latest + if: "startsWith(github.ref, 'refs/tags/')" + needs: [linux, windows, macos, sdist] + steps: + - uses: actions/download-artifact@v4 + - name: Publish to PyPI + uses: PyO3/maturin-action@v1 + env: + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + with: + command: upload + args: --non-interactive --skip-existing wheels-*/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7048797 --- /dev/null +++ b/.gitignore @@ -0,0 +1,76 @@ +# Experimental +src/diy + +# Rust +/target + +# Byte-compiled / optimized / DLL files +__pycache__/ +.pytest_cache/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +.venv/ +env/ +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +include/ +man/ +venv/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt +pip-selfcheck.json + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot + +.DS_Store + +# Sphinx documentation +docs/_build/ + +# PyCharm +.idea/ + +# VSCode +.vscode/ + +# Pyenv +.python-version diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..04d2820 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,361 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" + +[[package]] +name = "cc" +version = "1.0.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "hussh" +version = "0.1.0" +dependencies = [ + "pyo3", + "ssh2", +] + +[[package]] +name = "indoc" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e186cfbae8084e513daff4240b4797e342f988cecda4fb6c939150f96315fd8" + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "libssh2-sys" +version = "0.3.0" +source = "git+https://github.com/alexcrichton/ssh2-rs?branch=master#ec94100b4a1c1730bfb30c3a1c88af3ea54fdd78" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037731f5d3aaa87a5675e895b63ddff1a87624bc29f77004ea829809654e48f6" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "lock_api" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "memoffset" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "openssl-sys" +version = "0.9.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dda2b0f344e78efc2facf7d195d098df0dd72151b26ab98da807afc26c198dff" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "pkg-config" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" + +[[package]] +name = "portable-atomic" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7170ef9988bc169ba16dd36a7fa041e5c4cbeb6a35b76d4c03daded371eae7c0" + +[[package]] +name = "proc-macro2" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53bdbb96d49157e65d45cc287af5f32ffadd5f4761438b527b055fb0d4bb8233" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "parking_lot", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deaa5745de3f5231ce10517a1f5dd97d53e5a2fd77aa6b5842292085831d48d7" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b42531d03e08d4ef1f6e85a2ed422eb678b8cd62b762e53891c05faf0d4afa" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7305c720fa01b8055ec95e484a6eca7a83c841267f0dd5280f0c8b8551d2c158" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c7e9b68bb9c3149c5b0cade5d07f953d6d125eb4337723c4ccdb665f1f96185" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "smallvec" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" + +[[package]] +name = "ssh2" +version = "0.9.4" +source = "git+https://github.com/alexcrichton/ssh2-rs?branch=master#ec94100b4a1c1730bfb30c3a1c88af3ea54fdd78" +dependencies = [ + "bitflags 2.5.0", + "libc", + "libssh2-sys", + "parking_lot", +] + +[[package]] +name = "syn" +version = "2.0.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7383cd0e49fff4b6b90ca5670bfd3e9d6a733b3f90c686605aa7eec8c4996032" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.12.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fc403891a21bcfb7c37834ba66a547a8f402146eba7265b5a6d88059c9ff2f" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unindent" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..495e660 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "hussh" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +name = "hussh" +crate-type = ["cdylib"] + +[dependencies] +# openssl = { version = "0.10", features = ["vendored"] } +pyo3 = "0.20.0" +# ssh2 = "0.9" +# temporary until ssh2#312 makes it into a release. probably 0.9.5 +ssh2 = { git = "https://github.com/alexcrichton/ssh2-rs", branch = "master" } diff --git a/README.md b/README.md new file mode 100644 index 0000000..fccfbbc --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# Hussh: SSH for humans. +Hussh (pronounced "hush") is an ssh library that offers low level performance through a high level interface. + +# Installation +``` +pip install hussh +``` + +# QuickStart +Hussh currently just offers a `Connection` class as your primary interface. +```python +from hussh import Connection + +conn = Connection(host="my.test.server", username="user", password="pass") +result = conn.execute("ls") +print(result.stdout) +``` + +That's it! One import and class instantion is all you need to: +- Execute commands +- Perform SCP actions +- Perform SFTP actions +- Get an interactive shell + +# Authentication +You've already seen password-based authentication, but here it is again. +```python +conn = Connection(host="my.test.server", username="user", password="pass") + +# or leave out username and connect as root +conn = Connection(host="my.test.server", password="pass") +``` + +If you prefer key-based authentication, Hussh can do that as well. +```python +conn = Connection(host="my.test.server", private_key="~/.ssh/id_rsa") + +# If your key is password protected, just use the password argument +conn = Connection(host="my.test.server", private_key="~/.ssh/id_rsa", password="pass") +``` + +Hussh can also do agent-based authentication, if you've already established it. +```python +conn = Connection("my.test.server") +``` + +# Executing commands +The most basic foundation of ssh libraries is the ability to execute commands against the remote host. +For Hussh, just use the `Connection` object's `execute` method. +```python +result = conn.execute("whoami") +print(result.stdout, result.stderr, result.status) +``` +Each execute returns an SSHResult object with command's stdout, stderr, and status. + +# SFTP +If you need to transfer files to/from the remote host, SFTP may be your best bet. + +## Writing Files and Data +```python +# write a local file to the remote destination +conn.sftp_write(local_path="/path/to/my/file", remote_path="/dest/path/file") + +# Write UTF-8 data to a remote file +conn.sftp_write_data(data="Hello there!", remote_path="/dest/path/file") +``` + +## Reading Files +```python +# You can copy a remote file to a local destination +conn.sftp_read(remote_path="/dest/path/file", local_path="/path/to/my/file") +# Or copy the remote file contents to a string +contents = conn.sftp_read(remote_path="/dest/path/file") +``` + +# SCP +For remote servers that support SCP, Hussh can do that to. + +## Writing Files and Data +```python +# write a local file to the remote destination +conn.scp_write(local_path="/path/to/my/file", remote_path="/dest/path/file") + +# Write UTF-8 data to a remote file +conn.scp_write_data(data="Hello there!", remote_path="/dest/path/file") +``` + +## Reading Files +```python +# You can copy a remote file to a local destination +conn.scp_read(remote_path="/dest/path/file", local_path="/path/to/my/file") +# Or copy the remote file contents to a string +contents = conn.scp_read(remote_path="/dest/path/file") +``` + + +# Interactive Shell +If you need to keep a shell open to perform more complex interactions, you can get an `InteractiveShell` instance from the `Connection` class instance. +To use the interactive shell, it is recommended to use the shell() context manager from the Connection class. +You can send commands to the shell using the `send` method, then get the results from exit_result when you exit the context manager. + +```python +with conn.shell() as shell: + shell.send("ls") + shell.send("pwd") + shell.send("whoami") + +print(shell.exit_result.stdout) +``` +**Note:** The `read` method sends an EOF to the shell, so you won't be able to send more commands after calling `read`. If you want to send more commands, you would need to create a new `InteractiveShell` instance. + +# Disclaimer +This is a VERY early project that should not be used in production code! +There isn't even proper exception handling, so try/except won't work. +With that said, try it out and let me know your thoughts! + +# Future Features +- Proper exception handling +- Async Connection class +- Low level bindings +- Misc codebase improvements +- TBD... diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0efcf4e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["maturin>=1.5,<2.0"] +build-backend = "maturin" + +[project] +name = "hussh" +description = "SSH for Humans" +readme = "README.md" +requires-python = ">=3.8" +keywords = ["ssh", "rust", "pyo3", "blazingly-fast"] +authors = [ + {name = "Jacob J Callahan", email = "jacob.callahan05@gmail.com"} +] +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dynamic = ["version"] + +[project.optional-dependencies] +dev = [ + "ruff", + "maturin", + "pytest", + "docker", +] + +[tool.maturin] +features = ["pyo3/extension-module"] diff --git a/src/connection.rs b/src/connection.rs new file mode 100644 index 0000000..995f1ed --- /dev/null +++ b/src/connection.rs @@ -0,0 +1,504 @@ +//! # connection.rs +//! +//! This module provides a higher-level class that makes establishing and using ssh connections easier. +//! It uses the `ssh2` and `pyo3` libraries to provide a Python-friendly interface for SSH operations. +//! +//! ## Classes +//! +//! ### SSHResult +//! A class that represents the result of an SSH operation. It includes the standard output, standard error, and exit status of the operation. +//! +//! ### Connection +//! A class that represents an SSH connection. It includes methods for executing commands, reading and writing files over SCP and SFTP, and creating an interactive shell. +//! +//! ### InteractiveShell +//! A class that represents an interactive shell over an SSH connection. It includes methods for sending commands and reading the output. +//! +//! ## Functions +//! +//! ### read_from_channel +//! A helper function that reads the output from an SSH channel and returns an `SSHResult`. +//! +//! ## Usage +//! +//! To use this module, create a `Connection` instance with the necessary connection details. Then, use the methods on the `Connection` instance to perform SSH operations. +//! +//! ```python +//! conn = Connection("my.test.server", username="user", password="pass") +//! result = conn.execute("ls") +//! print(result.stdout) +//! ``` +//! +//! Multiple forms of authentication are supported. You can use a password, a private key, or the default ssh-agent. +//! +//! ```python +//! conn = Connection("my.test.server", username="user", private_key="~/.ssh/id_rsa") +//! conn = Connection("my.test.server", username="user", password="pass") +//! conn = Connection("my.test.server", username="user") +//! ```` +//! +//! If you don't pass a port, the default SSH port (22) is used. +//! If you don't pass a username, "root" is used. +//! +//! To use the interactive shell, it is recommended to use the shell() context manager from the Connection class. +//! You can send commands to the shell using the `send` method, then get the results from exit_result when you exit the context manager. +//! Due to the nature of reading from the shell, do not use the `read` method if you want to send more commands. +//! +//! ```python +//! with conn.shell() as shell: +//! shell.send("ls") +//! shell.send("pwd") +//! shell.send("whoami") +//! +//! print(shell.exit_result.stdout) +//! ``` +//! +//! Note: The `read` method sends an EOF to the shell, so you won't be able to send more commands after calling `read`. If you want to send more commands, you would need to create a new `InteractiveShell` instance. +use pyo3::prelude::*; +use ssh2::{Channel, Session}; +use std::io::prelude::*; +use std::net::TcpStream; +use std::path::Path; +// use ssh2::FileStat; + +const MAX_BUFF_SIZE: usize = 65536; + +fn read_from_channel(channel: &mut Channel) -> SSHResult { + let mut stdout = String::new(); + channel.read_to_string(&mut stdout).unwrap(); + let mut stderr = String::new(); + channel.stderr().read_to_string(&mut stderr).unwrap(); + channel.wait_close().unwrap(); + let status = channel.exit_status().unwrap(); + SSHResult { + stdout, + stderr, + status, + } +} + +#[pyclass] +#[derive(Clone)] +pub struct SSHResult { + #[pyo3(get)] + pub stdout: String, + #[pyo3(get)] + pub stderr: String, + #[pyo3(get)] + pub status: i32, +} + +#[pymethods] +impl SSHResult { + // The __repl__ method for the SSHResult class + fn __repr__(&self) -> PyResult { + Ok(format!( + "SSHResult(stdout={}, stderr={}, status={})", + self.stdout, self.stderr, self.status + )) + } + + // The __str__ method for the SSHResult class + fn __str__(&self) -> PyResult { + Ok(format!( + "stdout={}\nstderr={}\nstatus={}", + self.stdout, self.stderr, self.status + )) + } +} + +/// # Connection +/// +/// `Connection` is a class that represents an SSH connection. It provides methods for executing commands, reading and writing files over SCP and SFTP, and creating an interactive shell. +/// +/// ## Attributes +/// +/// * `session`: The underlying SSH session. +/// * `host`: The host to connect to. +/// * `port`: The port to connect to. +/// * `username`: The username to use for authentication. +/// * `password`: The password to use for authentication. +/// * `private_key`: The path to the private key to use for authentication. +/// +/// ## Methods +/// +/// ### `new` +/// +/// Creates a new `Connection` instance. It takes the following parameters: +/// +/// * `host`: The host to connect to. +/// * `port`: The port to connect to. If not provided, the default SSH port (22) is used. +/// * `username`: The username to use for authentication. If not provided, "root" is used. +/// * `password`: The password to use for authentication. If not provided, an empty string is used. +/// * `private_key`: The path to the private key to use for authentication. If not provided, an empty string is used. +/// +/// ### `execute` +/// +/// Executes a command over the SSH connection and returns the result. It takes the following parameter: +/// +/// * `command`: The command to execute. +/// +/// ### `scp_read` +/// +/// Reads a file over SCP and returns the contents. It takes the following parameters: +/// +/// * `remote_path`: The path to the file on the remote system. +/// * `local_path`: The path to save the file on the local system. If not provided, the contents of the file are returned. +/// +/// ### `scp_write` +/// +/// Writes a file over SCP. It takes the following parameters: +/// +/// * `local_path`: The path to the file on the local system. +/// * `remote_path`: The path to save the file on the remote system. +/// +/// ### `scp_write_data` +/// +/// Writes data over SCP. It takes the following parameters: +/// +/// * `data`: The data to write. +/// * `remote_path`: The path to save the data on the remote system. +/// +/// ### `sftp_read` +/// +/// Reads a file over SFTP and returns the contents. It takes the following parameters: +/// +/// * `remote_path`: The path to the file on the remote system. +/// * `local_path`: The path to save the file on the local system. If not provided, the contents of the file are returned. +/// +/// ### `sftp_write` +/// +/// Writes a file over SFTP. It takes the following parameters: +/// +/// * `local_path`: The path to the file on the local system. +/// * `remote_path`: The path to save the file on the remote system. +/// +/// ### `__repr__` +/// +/// Returns a string representation of the `Connection` instance. +/// +/// ### `shell` +/// +/// Creates an `InteractiveShell` instance. It takes the following parameter: +/// +/// * `pty`: Whether to request a pseudo-terminal for the shell. If not provided, a pseudo-terminal is not requested. +#[pyclass] +pub struct Connection { + session: Session, + #[pyo3(get)] + host: String, + #[pyo3(get)] + port: i32, + #[pyo3(get)] + username: String, + #[pyo3(get)] + password: String, + #[pyo3(get)] + private_key: String, + #[pyo3(get)] + timeout: Option, +} + +#[pymethods] +impl Connection { + #[new] + fn new( + host: String, + port: Option, + username: Option, + password: Option, + private_key: Option, + timeout: Option, + ) -> Self { + // if port isn't set, use the default ssh port 22 + let port = port.unwrap_or(22); + // combine the host and port into a single string + let conn_str = format!("{}:{}", host, port); + let tcp_conn = TcpStream::connect(&conn_str).unwrap(); + let mut session = Session::new().unwrap(); + // if a timeout is set, use it + if let Some(timeout) = timeout { + session.set_timeout(timeout); + } + session.set_tcp_stream(tcp_conn); + session.handshake().unwrap(); + // if username isn't set, try using root + let username = username.unwrap_or("root".to_string()); + let password = password.unwrap_or("".to_string()); + let private_key = private_key.unwrap_or("".to_string()); + // if private_key is set, use it to authenticate + if private_key != "" { + // if a password is set, use it to decrypt the private key + if password != "" { + session + .userauth_pubkey_file(&username, None, Path::new(&private_key), Some(&password)) + .unwrap(); + } else { + // otherwise, try using the private key without a passphrase + session + .userauth_pubkey_file(&username, None, Path::new(&private_key), None) + .unwrap(); + } + } else if password != "" { + session.userauth_password(&username, &password).unwrap(); + } else { + // if password isn't set, try using the default ssh-agent + if session.userauth_agent(&username).is_err() { + panic!("Failed to authenticate with ssh-agent"); + } + } + Connection { + session, + port, + host, + username, + password, + private_key, + timeout, + } + } + + /// Executes a command over the SSH connection and returns the result. + fn execute(&self, command: String) -> SSHResult { + let mut channel = self.session.channel_session().unwrap(); + channel.exec(&command).unwrap(); + read_from_channel(&mut channel) + } + + /// Reads a file over SCP and returns the contents. + /// If `local_path` is provided, the file is saved to the local system. + /// Otherwise, the contents of the file are returned as a string. + fn scp_read(&self, remote_path: String, local_path: Option) -> PyResult { + let (mut remote_file, _) = self.session.scp_recv(Path::new(&remote_path)).unwrap(); + let mut contents = String::new(); + remote_file.read_to_string(&mut contents).unwrap(); + remote_file.send_eof().unwrap(); + remote_file.wait_eof().unwrap(); + remote_file.close().unwrap(); + remote_file.wait_close().unwrap(); + match local_path { + Some(local_path) => { + std::fs::write(local_path, &contents).unwrap(); + Ok("Ok".to_string()) + } + None => Ok(contents), + } + } + + /// Writes a file over SCP. + fn scp_write(&self, local_path: String, remote_path: String) -> PyResult<()> { + // if remote_path is a directory, append the local file name to the remote path + let remote_path = if remote_path.ends_with("/") { + format!( + "{}/{}", + remote_path, + Path::new(&local_path) + .file_name() + .unwrap() + .to_str() + .unwrap() + ) + } else { + remote_path + }; + let mut local_file = std::fs::File::open(&local_path).unwrap(); + let metadata = local_file.metadata().unwrap(); + // TODO: better handle permissions. Perhaps from metadata.permissions()? + let mut remote_file = self + .session + .scp_send(Path::new(&remote_path), 0o644, metadata.len(), None) + .unwrap(); + // create a variable-sized buffer to read the file and loop until EOF + let mut read_buffer = vec![0; std::cmp::min(metadata.len() as usize, MAX_BUFF_SIZE)]; + loop { + let bytes_read = local_file.read(&mut read_buffer).unwrap(); + if bytes_read == 0 { + break; + } + remote_file.write(&read_buffer[..bytes_read]).unwrap(); + } + remote_file.flush().unwrap(); + remote_file.send_eof().unwrap(); + remote_file.wait_eof().unwrap(); + remote_file.close().unwrap(); + remote_file.wait_close().unwrap(); + Ok(()) + } + + /// Writes data over SCP. + fn scp_write_data(&self, data: String, remote_path: String) -> PyResult<()> { + let mut remote_file = self + .session + .scp_send(Path::new(&remote_path), 0o644, data.len() as u64, None) + .unwrap(); + remote_file.write_all(data.as_bytes()).unwrap(); + remote_file.send_eof().unwrap(); + remote_file.wait_eof().unwrap(); + remote_file.close().unwrap(); + remote_file.wait_close().unwrap(); + Ok(()) + } + + /// Reads a file over SFTP and returns the contents. + /// If `local_path` is provided, the file is saved to the local system. + /// Otherwise, the contents of the file are returned as a string. + fn sftp_read(&self, remote_path: String, local_path: Option) -> PyResult { + let mut remote_file = self + .session + .sftp() + .unwrap() + .open(Path::new(&remote_path)) + .unwrap(); + let mut contents = String::new(); + remote_file.read_to_string(&mut contents).unwrap(); + remote_file.close().unwrap(); + match local_path { + Some(local_path) => { + std::fs::write(local_path, &contents).unwrap(); + Ok("Ok".to_string()) + } + None => Ok(contents), + } + } + + /// Writes a file over SFTP. + fn sftp_write(&self, local_path: String, remote_path: String) -> PyResult<()> { + let mut local_file = std::fs::File::open(&local_path).unwrap(); + let metadata = local_file.metadata().unwrap(); + let mut remote_file = self + .session + .sftp() + .unwrap() + .create(Path::new(&remote_path)) + .unwrap(); + // create a variable-sized buffer to read the file and loop until EOF + let mut read_buffer = vec![0; std::cmp::min(metadata.len() as usize, MAX_BUFF_SIZE)]; + loop { + let bytes_read = local_file.read(&mut read_buffer)?; + if bytes_read == 0 { + break; + } + remote_file.write(&read_buffer[..bytes_read])?; + } + // let stat = FileStat { + // size: None, + // uid: None, + // gid: None, + // perm: Some(0o644), + // atime: None, + // mtime: None, + // }; + // remote_file.setstat(stat).unwrap(); + remote_file.close().unwrap(); + Ok(()) + } + + /// Writes data over SFTP. + fn sftp_write_data(&self, data: String, remote_path: String) -> PyResult<()> { + let mut remote_file = self + .session + .sftp() + .unwrap() + .create(Path::new(&remote_path)) + .unwrap(); + remote_file.write_all(data.as_bytes()).unwrap(); + remote_file.close().unwrap(); + Ok(()) + } + + fn __repr__(&self) -> PyResult { + Ok(format!( + "Connection(host={}, port={}, username={}, password=*****)", + self.host, self.port, self.username + )) + } + + /// Creates an `InteractiveShell` instance. + /// If `pty` is `true`, a pseudo-terminal is requested for the shell. + /// Note: This is best used as a context manager + /// ```python + /// with conn.shell() as shell: + /// shell.send("ls") + /// shell.send("pwd") + /// print(shell.exit_result.stdout) + /// ``` + fn shell(&self, pty: Option) -> PyResult { + let mut channel = self.session.channel_session().unwrap(); + if let Some(pty) = pty { + if pty { + channel.request_pty("xterm", None, None).unwrap(); + } + } + channel.shell().unwrap(); + Ok(InteractiveShell { + channel: ChannelWrapper { channel }, + exit_result: None, + }) + } +} + +#[pyclass] +#[derive(Clone)] +pub struct ChannelWrapper { + channel: Channel, +} + +#[pyclass] +#[derive(Clone)] +struct InteractiveShell { + channel: ChannelWrapper, + #[pyo3(get)] + exit_result: Option, +} + +#[pymethods] +impl InteractiveShell { + #[new] + fn new(channel: ChannelWrapper) -> Self { + InteractiveShell { + channel, + exit_result: None, + } + } + + /// Reads the output from the shell and returns an `SSHResult`. + /// Note: This sends an EOF to the shell, so you won't be able to send more commands after calling `read`. + fn read(&mut self) -> SSHResult { + self.channel.channel.flush().unwrap(); + self.channel.channel.send_eof().unwrap(); + read_from_channel(&mut self.channel.channel) + } + + /// Sends a command to the shell. + /// If you don't want to add a newline at the end of the command, set `add_newline` to `false`. + fn send(&mut self, data: String, add_newline: Option) -> PyResult<()> { + let add_newline = add_newline.unwrap_or(true); + let data = if add_newline && !data.ends_with("\n") { + format!("{}\n", data) + } else { + data + }; + self.channel.channel.write_all(data.as_bytes()).unwrap(); + Ok(()) + } + + /// Closes the shell. + fn close(&mut self) -> PyResult<()> { + self.channel.channel.close().unwrap(); + Ok(()) + } + + fn __enter__(slf: PyRef) -> PyRef { + slf + } + + fn __exit__( + &mut self, + _exc_type: Option<&PyAny>, + _exc_value: Option<&PyAny>, + _traceback: Option<&PyAny>, + ) -> PyResult<()> { + self.exit_result = Some(self.read()); + Ok(()) + } +} diff --git a/src/diy.rs b/src/diy.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8701a69 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,10 @@ +use pyo3::prelude::*; + +mod connection; + +/// A Python module implemented in Rust. +#[pymodule] +fn hussh(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_class::()?; // Add the Connection class + Ok(()) +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..893958d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,76 @@ +"""Common setup for Hussh tests.""" + +import os +import subprocess +import time +from pathlib import PurePath + +import docker +import pexpect +import pytest + +TESTDIR = PurePath(__file__).parent + + +@pytest.fixture(scope="session") +def ensure_test_server_image(): + """Ensure that the test server Docker image is available.""" + client = docker.from_env() + try: + client.images.get("hussh-test-server") + except docker.errors.ImageNotFound: + client.images.build( + path=str(TESTDIR / "setup"), + tag="hussh-test-server", + ) + client.close() + + +@pytest.fixture(scope="session", autouse=True) +def run_test_server(ensure_test_server_image): + """Run a test server in a Docker container.""" + client = docker.from_env() + try: # check to see if the container is already running + container = client.containers.get("hussh-test-server") + except docker.errors.NotFound: # if not, start it + container = client.containers.run( + "hussh-test-server", + detach=True, + ports={"22/tcp": 8022}, + name="hussh-test-server", + ) + time.sleep(5) # give the server time to start + yield container + container.stop() + container.remove() + client.close() + + +@pytest.fixture(scope="session") +def setup_agent_auth(): + # Define the key paths + base_key = TESTDIR / "data/test_key" + auth_key = TESTDIR / "data/auth_test_key" + + # Start the ssh-agent and get the environment variables + output = subprocess.check_output(["ssh-agent", "-s"]) + env = dict( + line.split("=", 1) for line in output.decode().splitlines() if "=" in line + ) + + # Set the SSH_AUTH_SOCK and SSH_AGENT_PID environment variables + os.environ["SSH_AUTH_SOCK"] = env["SSH_AUTH_SOCK"] + os.environ["SSH_AGENT_PID"] = env["SSH_AGENT_PID"] + + # Add the keys to the ssh-agent + # subprocess.run(["ssh-add", str(base_key)], check=True) + result = subprocess.run(["ssh-add", str(base_key)], capture_output=True, text=True) + print(result.stdout) + print(result.stderr) + # The auth_key is password protected + child = pexpect.spawn("ssh-add", [str(auth_key)]) + child.expect("Enter passphrase for .*: ") + child.sendline("husshpuppy") + yield + # Kill the ssh-agent after the tests have run + subprocess.run(["ssh-agent", "-k"], check=True) diff --git a/tests/data/auth_test_key b/tests/data/auth_test_key new file mode 100644 index 0000000..8c35081 --- /dev/null +++ b/tests/data/auth_test_key @@ -0,0 +1,50 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +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 +mJJTIe7BZuN0wycklMXvIYHdE= +-----END OPENSSH PRIVATE KEY----- diff --git a/tests/data/auth_test_key.pub b/tests/data/auth_test_key.pub new file mode 100644 index 0000000..af229ca --- /dev/null +++ b/tests/data/auth_test_key.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDK73JTB6B6iCRUsQDStoW0yNzs3Itw1H5ofI0inQubNnJbRYeNSLkRiOmpBXx+RPRo2j5gHyVAys5PO2ePkRpdrBtwLl28ZBvYnpxry3LbhzPUbxYWAWaaSOV1jnXmkkgOaDK6BJ4vKDsgHQJ8IkXO4SsJ04qYYyQ+alfI/1kVRnQ4iA8MwuhUYoht7JNH8ddveom9bAJeFAL6c7Cu9EqriMEueyAsJa+rymvbHf2Okp14qnjRJZJ9QSkFRAAIl+1ih+eRqT8shWzX6xGxOn5hLmI108/nBtTwVVyabaE4hiB/3xX6U2e9x6YeP3x6ahbH7QIiAIdSxe6hnQFByNG5Jy0Gr0UMo4QcIJEXNfLgQce78lMoXIWDQbhBR/wpJyRTlmx6tSGTWElf8X9gFfV9RNeDbuXzXYNjRAyv+0WT7mN42VzH3LWHwrGl+k1qUhHx3Qwnj6CcDNe3NYCHXm579y2ICFt/hqZNPQIwjZhfO3IoTioS76+HBh1ZVszHo6zMpfICNpDNpeDMj0BFUWUdKVFCOU/77dwETs13ZZ8T+MdS0wpig9KJ+mWTg2lbo+jVz+ZDebuTnkCrKymH70pOOrgsiS+bS0D/82jOlZnUQEq6MUjY6/wHAaW0Y2k0/nPHAV4fxLT6QPUQZZqYKPTT9+h7tCZl7ielSTdek1EioQ== jake@jarvis diff --git a/tests/data/hp.txt b/tests/data/hp.txt new file mode 100644 index 0000000..cdbdf24 --- /dev/null +++ b/tests/data/hp.txt @@ -0,0 +1,26 @@ +I sing of thee, oh Hushpuppy, delight, +A golden morsel, crispy, warm, and bright. +From humble cornmeal, a treasure you rise, +Deep-fried to perfection, a joy to the eyes. + +Your fluffy heart, a cloud of pure bliss, +Melts on the tongue with a savory kiss. +Sweet onion whispers, a hint of the South, +A taste that evokes a welcoming mouth. + +You grace the table, beside fish or fowl, +A sidekick so loyal, a culinary jewel. +With barbecue's smoke or creamy chowder, +You stand as a friend, a taste to empower. + +More than a fritter, a comfort you bring, +A memory of childhood, a joy that you sing. +In every golden nugget, a story untold, +Of family gatherings, and laughter of old. + +So hail, Hushpuppy, king of the side, +A culinary hero, in whom we confide. +May your legacy linger, a taste that inspires, +A golden delight, setting tastebuds on fire! + +- Gemini diff --git a/tests/data/puppy.jpeg b/tests/data/puppy.jpeg new file mode 100644 index 0000000..7b7a0c1 Binary files /dev/null and b/tests/data/puppy.jpeg differ diff --git a/tests/data/test_key b/tests/data/test_key new file mode 100644 index 0000000..204f913 --- /dev/null +++ b/tests/data/test_key @@ -0,0 +1,49 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +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 +-----END OPENSSH PRIVATE KEY----- diff --git a/tests/data/test_key.pub b/tests/data/test_key.pub new file mode 100644 index 0000000..648a9ab --- /dev/null +++ b/tests/data/test_key.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCfINfJ7a/gZ/2vw1gDt+cApeUuL7QV11ZIXKYuzu85TSsi2mc2tgSE+ADnilpMupRsoOVNX5JSDNyM36JPO5CyuiWs+z/pjcs2c8Y1SPGXenQmJLwic1Osw05N4l9oNugSTHvK3H1MzfvYyP1WKTAPfCpIiUeIzfh5J2YirXVFGcZToRjyD2JZge4zGbxkSytP1iLYgxfmpbAzeOc/H4dC3u1iuK1IJoKyWsXOp2TOFUQ6ERGDa7gLZ4Fq8mNMPb3hhRYsDZOvEu+2c+I/g4Fb9d9f0+Ln7qnZeda2S12bpLMjzP5hMTSgHqavAeAkljj1l47/04j+yyW0fLpqNLncIUyApCuXTo7XU7gX73wiAWYsxNDkntMp2JTKenlwn/e4FmWwbBsWz8xlSaVKir7h9rHmKFZB5dSpG1GsK3bKmBKGwMhCTCNHKq61Rk/iiMrGiG+AfIbwujtRBN0PuEawHM/OFDZygMsT3II+iSCRiHaqeQQQOafwaf8Fsnf3MSu0rjEbqPnaD4wegV+XP1hcAO92NkEv/+WS/KiZtjhI8+gaeOIza5Jb/87tmhs9ZSfvpfsiv/vsdGC6QQ/2jV3PeCauPmlSyqvv1r63mKmFel8kSRGlPWINN9V5i8J2WcE+Eq3UBgsILG9AAwU/7qkk9NrqNKAo+CxXkJ5mIyXqxw== jake@jarvis diff --git a/tests/setup/Dockerfile b/tests/setup/Dockerfile new file mode 100644 index 0000000..54d25ca --- /dev/null +++ b/tests/setup/Dockerfile @@ -0,0 +1,12 @@ +FROM fedora:latest +RUN dnf -y update && dnf -y install openssh-server openssh-clients +RUN mkdir /var/run/sshd +RUN echo 'root:toor' | chpasswd +RUN sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/g' /etc/ssh/sshd_config +RUN sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/g' /etc/ssh/sshd_config +# Ensure ForceCommand is not set, which could result in scp not working +RUN sed -i 's/^ForceCommand/#ForceCommand/g' /etc/ssh/sshd_config +RUN ssh-keygen -A +COPY authorized_keys /root/.ssh/authorized_keys +EXPOSE 22 +CMD ["/usr/sbin/sshd", "-D"] diff --git a/tests/setup/authorized_keys b/tests/setup/authorized_keys new file mode 100644 index 0000000..64e2eab --- /dev/null +++ b/tests/setup/authorized_keys @@ -0,0 +1,2 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCfINfJ7a/gZ/2vw1gDt+cApeUuL7QV11ZIXKYuzu85TSsi2mc2tgSE+ADnilpMupRsoOVNX5JSDNyM36JPO5CyuiWs+z/pjcs2c8Y1SPGXenQmJLwic1Osw05N4l9oNugSTHvK3H1MzfvYyP1WKTAPfCpIiUeIzfh5J2YirXVFGcZToRjyD2JZge4zGbxkSytP1iLYgxfmpbAzeOc/H4dC3u1iuK1IJoKyWsXOp2TOFUQ6ERGDa7gLZ4Fq8mNMPb3hhRYsDZOvEu+2c+I/g4Fb9d9f0+Ln7qnZeda2S12bpLMjzP5hMTSgHqavAeAkljj1l47/04j+yyW0fLpqNLncIUyApCuXTo7XU7gX73wiAWYsxNDkntMp2JTKenlwn/e4FmWwbBsWz8xlSaVKir7h9rHmKFZB5dSpG1GsK3bKmBKGwMhCTCNHKq61Rk/iiMrGiG+AfIbwujtRBN0PuEawHM/OFDZygMsT3II+iSCRiHaqeQQQOafwaf8Fsnf3MSu0rjEbqPnaD4wegV+XP1hcAO92NkEv/+WS/KiZtjhI8+gaeOIza5Jb/87tmhs9ZSfvpfsiv/vsdGC6QQ/2jV3PeCauPmlSyqvv1r63mKmFel8kSRGlPWINN9V5i8J2WcE+Eq3UBgsILG9AAwU/7qkk9NrqNKAo+CxXkJ5mIyXqxw== +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDK73JTB6B6iCRUsQDStoW0yNzs3Itw1H5ofI0inQubNnJbRYeNSLkRiOmpBXx+RPRo2j5gHyVAys5PO2ePkRpdrBtwLl28ZBvYnpxry3LbhzPUbxYWAWaaSOV1jnXmkkgOaDK6BJ4vKDsgHQJ8IkXO4SsJ04qYYyQ+alfI/1kVRnQ4iA8MwuhUYoht7JNH8ddveom9bAJeFAL6c7Cu9EqriMEueyAsJa+rymvbHf2Okp14qnjRJZJ9QSkFRAAIl+1ih+eRqT8shWzX6xGxOn5hLmI108/nBtTwVVyabaE4hiB/3xX6U2e9x6YeP3x6ahbH7QIiAIdSxe6hnQFByNG5Jy0Gr0UMo4QcIJEXNfLgQce78lMoXIWDQbhBR/wpJyRTlmx6tSGTWElf8X9gFfV9RNeDbuXzXYNjRAyv+0WT7mN42VzH3LWHwrGl+k1qUhHx3Qwnj6CcDNe3NYCHXm579y2ICFt/hqZNPQIwjZhfO3IoTioS76+HBh1ZVszHo6zMpfICNpDNpeDMj0BFUWUdKVFCOU/77dwETs13ZZ8T+MdS0wpig9KJ+mWTg2lbo+jVz+ZDebuTnkCrKymH70pOOrgsiS+bS0D/82jOlZnUQEq6MUjY6/wHAaW0Y2k0/nPHAV4fxLT6QPUQZZqYKPTT9+h7tCZl7ielSTdek1EioQ== \ No newline at end of file diff --git a/tests/test_connection.py b/tests/test_connection.py new file mode 100644 index 0000000..f4e195e --- /dev/null +++ b/tests/test_connection.py @@ -0,0 +1,155 @@ +"""Tests for hussh.connection module.""" + +import pytest +from pathlib import Path +from hussh import Connection + + +TEXT_FILE = Path("tests/data/hp.txt").resolve() +IMG_FILE = Path("tests/data/puppy.jpeg").resolve() + + +@pytest.fixture +def conn(): + """Return a basic Connection object.""" + return Connection(host="localhost", port=8022, password="toor") + + +def test_password_auth(): + """Test that we can establish a connection with password-based authentication.""" + assert Connection(host="localhost", port=8022, password="toor") + + +def test_key_auth(): + """Test that we can establish a connection with key-based authentication.""" + assert Connection(host="localhost", port=8022, private_key="tests/data/test_key") + + +def test_key_with_password_auth(): + """Test that we can establish a connection with key-based authentication and a password.""" + assert Connection( + host="localhost", + port=8022, + private_key="tests/data/auth_test_key", + password="husshpuppy", + ) + + +@pytest.mark.skip("fixture-based setup for agent-based auth currently not working") +def test_agent_auth(setup_agent_auth): + """Test that we can establish a connection with agent-based authentication.""" + assert Connection(host="localhost", port=8022) + + +def test_basic_command(conn): + """Test that we can run a basic command.""" + result = conn.execute("echo hello") + assert result.status == 0 + assert result.stdout == "hello\n" + + +def test_bad_command(conn): + """Test that we can run a bad command.""" + result = conn.execute("kira") + assert result.status != 0 + assert "command not found" in result.stderr + + +def test_text_scp(conn): + """Test that we can copy a file to the server and read it back.""" + # copy a local file to the server + conn.scp_write(str(TEXT_FILE), "/root/hp.txt") + assert "hp.txt" in conn.execute("ls /root").stdout + # read the file back from the server + read_text = conn.scp_read("/root/hp.txt") + hp_text = Path(str(TEXT_FILE)).read_text() + assert read_text == hp_text + # copy the file from the server to a local file + conn.scp_read("/root/hp.txt", "scp_hp.txt") + scp_hp_text = Path("scp_hp.txt").read_text() + Path("scp_hp.txt").unlink() + assert scp_hp_text == hp_text + + +def test_scp_write_data(conn): + """Test that we can write a string to a file on the server.""" + conn.scp_write_data("hello", "/root/hello.txt") + assert "hello.txt" in conn.execute("ls /root").stdout + read_text = conn.scp_read("/root/hello.txt") + assert read_text == "hello" + + +@pytest.mark.skip("non-text files are not supported by scp") +def test_non_utf8_scp(conn): + """Test that we can copy a non-text file to the server and read it back.""" + # copy an image file to the server + conn.scp_write(str(IMG_FILE), "/root/puppy.jpeg") + assert "puppy.jpeg" in conn.execute("ls /root").stdout + # read the file back from the server + read_img = conn.scp_read("/root/puppy.jpeg") + img_data = Path(str(IMG_FILE)).read_bytes() + assert read_img == img_data + # copy the file from the server to a local file + conn.scp_read("/root/puppy.jpeg", "scp_puppy.jpeg") + scp_img_data = Path("scp_puppy.jpeg").read_bytes() + Path("scp_puppy.jpeg").unlink() + assert scp_img_data == img_data + + +def test_text_sftp(conn): + """Test that we can copy a file to the server and read it back.""" + # copy a local file to the server + conn.sftp_write(str(TEXT_FILE), "/root/hp.txt") + assert "hp.txt" in conn.execute("ls /root").stdout + # read the file back from the server + read_text = conn.sftp_read("/root/hp.txt") + hp_text = Path(str(TEXT_FILE)).read_text() + assert read_text == hp_text + # copy the file from the server to a local file + conn.sftp_read("/root/hp.txt", "sftp_hp.txt") + sftp_hp_text = Path("sftp_hp.txt").read_text() + Path("sftp_hp.txt").unlink() + assert sftp_hp_text == hp_text + + +def test_sftp_write_data(conn): + """Test that we can write a string to a file on the server.""" + conn.sftp_write_data("hello", "/root/hello.txt") + assert "hello.txt" in conn.execute("ls /root").stdout + read_text = conn.sftp_read("/root/hello.txt") + assert read_text == "hello" + + +@pytest.mark.skip("non-text files are not supported by sftp") +def test_non_utf8_sftp(conn): + """Test that we can copy a non-text file to the server and read it back.""" + # copy an image file to the server + conn.sftp_write(str(IMG_FILE), "/root/puppy.jpeg") + assert "puppy.jpeg" in conn.execute("ls /root").stdout + # read the file back from the server + read_img = conn.sftp_read("/root/puppy.jpeg") + img_data = Path(str(IMG_FILE)).read_bytes() + assert read_img == img_data + # copy the file from the server to a local file + conn.sftp_read("/root/puppy.jpeg", "sftp_puppy.jpeg") + sftp_img_data = Path("sftp_puppy.jpeg").read_bytes() + Path("sftp_puppy.jpeg").unlink() + assert sftp_img_data == img_data + + +def test_shell_context(conn): + """Test that we can run multiple commands in a shell context.""" + with conn.shell() as sh: + sh.send("echo test shell") + sh.send("bad command") + assert "test shell" in sh.exit_result.stdout + assert "command not found" in sh.exit_result.stderr + assert sh.exit_result.status != 0 + + +@pytest.mark.skip("Skipping until exceptions are implemented.") +def test_timeout(): + """Test that we can trigger a timeout.""" + conn = Connection(host="localhost", port=8022, password="toor", timeout=2000) + with pytest.raises(TimeoutError): + conn.execute("sleep 5")