netpulse-ssh 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netpulse_ssh-0.1.0/.gitignore +28 -0
- netpulse_ssh-0.1.0/PKG-INFO +97 -0
- netpulse_ssh-0.1.0/README.md +85 -0
- netpulse_ssh-0.1.0/docs/architecture.md +18 -0
- netpulse_ssh-0.1.0/docs/assets/netpulse_ssh_logo.png +0 -0
- netpulse_ssh-0.1.0/docs/contributing.md +31 -0
- netpulse_ssh-0.1.0/docs/design.md +14 -0
- netpulse_ssh-0.1.0/docs/usage.md +66 -0
- netpulse_ssh-0.1.0/pyproject.toml +23 -0
- netpulse_ssh-0.1.0/src/netpulse/ssh/__init__.py +1 -0
- netpulse_ssh-0.1.0/src/netpulse/ssh/api.py +57 -0
- netpulse_ssh-0.1.0/src/netpulse/ssh/cli.py +63 -0
- netpulse_ssh-0.1.0/src/netpulse/ssh/models.py +57 -0
- netpulse_ssh-0.1.0/src/netpulse/ssh/runner.py +233 -0
- netpulse_ssh-0.1.0/tests/integration/test_api.py +69 -0
- netpulse_ssh-0.1.0/tests/integration/test_cli.py +66 -0
- netpulse_ssh-0.1.0/tests/unit/test_ssh.py +152 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Virtual Environments
|
|
2
|
+
.venv/
|
|
3
|
+
venv/
|
|
4
|
+
ENV/
|
|
5
|
+
|
|
6
|
+
# Python Caches & Bytecode
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.pyc
|
|
9
|
+
*.pyo
|
|
10
|
+
*.pyd
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
|
|
15
|
+
# Rust / Maturin Build Artifacts
|
|
16
|
+
rust/target/
|
|
17
|
+
*.so
|
|
18
|
+
*.dylib
|
|
19
|
+
*.dll
|
|
20
|
+
*.pyd
|
|
21
|
+
|
|
22
|
+
# Local Configuration / Environment
|
|
23
|
+
.env
|
|
24
|
+
.env*.local
|
|
25
|
+
|
|
26
|
+
# Scratch / Temporary Files
|
|
27
|
+
scratch/
|
|
28
|
+
netpulse.db
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: netpulse-ssh
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: High-speed concurrent SSH command runner
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: asyncssh>=2.14.0
|
|
7
|
+
Requires-Dist: fastapi>=0.100.0
|
|
8
|
+
Requires-Dist: pydantic>=2.0.0
|
|
9
|
+
Requires-Dist: rich>=13.0.0
|
|
10
|
+
Requires-Dist: typer>=0.9.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
<h1 align="center">NetPulse SSH</h1>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<em>High-performance, standalone Python library & CLI for concurrent SSH command execution across network devices.</em>
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
<p align="center">
|
|
20
|
+
<a href="https://pypi.org/project/netpulse-ssh/"><img src="https://img.shields.io/pypi/v/netpulse-ssh?color=magenta&label=pypi%20package" alt="PyPI version"></a>
|
|
21
|
+
<a href="https://pypi.org/project/netpulse-ssh/"><img src="https://img.shields.io/pypi/pyversions/netpulse-ssh" alt="Python Versions"></a>
|
|
22
|
+
<a href="https://github.com/bonheurNE07/netpulse/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
|
|
23
|
+
<a href="https://github.com/bonheurNE07/netpulse"><img src="https://img.shields.io/badge/code%20style-black-000000.svg" alt="Code style: black"></a>
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
**`netpulse-ssh`** is an ultra-fast, resilient SSH execution engine extracted from the [NetPulse](https://github.com/bonheurNE07/netpulse) discovery suite. It allows network engineers and automation pipelines to execute commands across hundreds of network devices simultaneously, without blocking I/O or crashing due to a single offline host.
|
|
29
|
+
|
|
30
|
+
## ✨ Features
|
|
31
|
+
|
|
32
|
+
- **Massive Concurrency**: Scales horizontally using `asyncio` to execute commands across hundreds of targets simultaneously.
|
|
33
|
+
- **Legacy Equipment Healing**: Automatically detects strict OpenSSH cipher drops and seamlessly falls back to legacy algorithms (e.g., `diffie-hellman-group1-sha1`, `3des-cbc`) which are frequently required for older Cisco or Juniper gear.
|
|
34
|
+
- **Smart Privilege Escalation**: Natively supports Cisco `enable` mode privilege escalation without breaking automation.
|
|
35
|
+
- **Pagination Suppression**: Automatically injects `terminal length 0` before command execution to bypass interactive `--More--` prompts.
|
|
36
|
+
- **REST API Enabled**: Run the built-in FastAPI uvicorn wrapper to serve concurrent execution logic dynamically to web dashboards or automation scripts.
|
|
37
|
+
|
|
38
|
+
## 🚀 Quickstart
|
|
39
|
+
|
|
40
|
+
### Installation
|
|
41
|
+
|
|
42
|
+
Install globally or locally via `pip`:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install netpulse-ssh
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### CLI Usage
|
|
49
|
+
|
|
50
|
+
Execute a command across multiple devices concurrently and get a beautiful, structured table summary:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
netpulse-ssh execute 192.168.1.5 10.0.0.1 -c "show ip interface brief" -u admin -p password
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Python API Integration
|
|
57
|
+
|
|
58
|
+
Use our cleanly typed Pydantic models and logic natively inside your own tools. The runner is completely agnostic and returns an audit object containing stdout/stderr per host:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
import asyncio
|
|
62
|
+
from netpulse.ssh.models import SshHostConfig
|
|
63
|
+
from netpulse.ssh.runner import SshRunnerService
|
|
64
|
+
|
|
65
|
+
async def main():
|
|
66
|
+
hosts = [
|
|
67
|
+
SshHostConfig(ip="192.168.1.1", username="admin", password="password"),
|
|
68
|
+
SshHostConfig(ip="10.0.0.1", username="admin", password="password"),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
runner = SshRunnerService()
|
|
72
|
+
audit = await runner.execute_concurrently(hosts, "show version")
|
|
73
|
+
|
|
74
|
+
for result in audit.results:
|
|
75
|
+
print(f"Host {result.ip} status: {result.status}")
|
|
76
|
+
print(result.stdout)
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
asyncio.run(main())
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 📖 Documentation
|
|
83
|
+
|
|
84
|
+
Detailed documentation is available in the `docs/` directory:
|
|
85
|
+
- 🗺️ [**Usage Guide**](docs/usage.md) - Deep dive into CLI and REST API examples.
|
|
86
|
+
- 🏗️ [**Architecture**](docs/architecture.md) - Understand the asynchronous execution engine and legacy fallbacks.
|
|
87
|
+
- 🧠 [**Design Decisions**](docs/design.md) - Read about the decoupled persistence approach.
|
|
88
|
+
- 🧪 [**Contributing**](docs/contributing.md) - The development onboarding guide.
|
|
89
|
+
|
|
90
|
+
## 🤝 Contributing
|
|
91
|
+
|
|
92
|
+
We welcome contributions from the community! `netpulse-ssh` is open-source and maintained actively.
|
|
93
|
+
Please read our [**Contributing Guidelines**](docs/contributing.md) to understand how to clone the repository and submit your Pull Requests.
|
|
94
|
+
|
|
95
|
+
## 📜 License
|
|
96
|
+
|
|
97
|
+
Distributed under the **MIT License**. See `LICENSE` for more information.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<h1 align="center">NetPulse SSH</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<em>High-performance, standalone Python library & CLI for concurrent SSH command execution across network devices.</em>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://pypi.org/project/netpulse-ssh/"><img src="https://img.shields.io/pypi/v/netpulse-ssh?color=magenta&label=pypi%20package" alt="PyPI version"></a>
|
|
9
|
+
<a href="https://pypi.org/project/netpulse-ssh/"><img src="https://img.shields.io/pypi/pyversions/netpulse-ssh" alt="Python Versions"></a>
|
|
10
|
+
<a href="https://github.com/bonheurNE07/netpulse/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
|
|
11
|
+
<a href="https://github.com/bonheurNE07/netpulse"><img src="https://img.shields.io/badge/code%20style-black-000000.svg" alt="Code style: black"></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
**`netpulse-ssh`** is an ultra-fast, resilient SSH execution engine extracted from the [NetPulse](https://github.com/bonheurNE07/netpulse) discovery suite. It allows network engineers and automation pipelines to execute commands across hundreds of network devices simultaneously, without blocking I/O or crashing due to a single offline host.
|
|
17
|
+
|
|
18
|
+
## ✨ Features
|
|
19
|
+
|
|
20
|
+
- **Massive Concurrency**: Scales horizontally using `asyncio` to execute commands across hundreds of targets simultaneously.
|
|
21
|
+
- **Legacy Equipment Healing**: Automatically detects strict OpenSSH cipher drops and seamlessly falls back to legacy algorithms (e.g., `diffie-hellman-group1-sha1`, `3des-cbc`) which are frequently required for older Cisco or Juniper gear.
|
|
22
|
+
- **Smart Privilege Escalation**: Natively supports Cisco `enable` mode privilege escalation without breaking automation.
|
|
23
|
+
- **Pagination Suppression**: Automatically injects `terminal length 0` before command execution to bypass interactive `--More--` prompts.
|
|
24
|
+
- **REST API Enabled**: Run the built-in FastAPI uvicorn wrapper to serve concurrent execution logic dynamically to web dashboards or automation scripts.
|
|
25
|
+
|
|
26
|
+
## 🚀 Quickstart
|
|
27
|
+
|
|
28
|
+
### Installation
|
|
29
|
+
|
|
30
|
+
Install globally or locally via `pip`:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install netpulse-ssh
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### CLI Usage
|
|
37
|
+
|
|
38
|
+
Execute a command across multiple devices concurrently and get a beautiful, structured table summary:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
netpulse-ssh execute 192.168.1.5 10.0.0.1 -c "show ip interface brief" -u admin -p password
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Python API Integration
|
|
45
|
+
|
|
46
|
+
Use our cleanly typed Pydantic models and logic natively inside your own tools. The runner is completely agnostic and returns an audit object containing stdout/stderr per host:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import asyncio
|
|
50
|
+
from netpulse.ssh.models import SshHostConfig
|
|
51
|
+
from netpulse.ssh.runner import SshRunnerService
|
|
52
|
+
|
|
53
|
+
async def main():
|
|
54
|
+
hosts = [
|
|
55
|
+
SshHostConfig(ip="192.168.1.1", username="admin", password="password"),
|
|
56
|
+
SshHostConfig(ip="10.0.0.1", username="admin", password="password"),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
runner = SshRunnerService()
|
|
60
|
+
audit = await runner.execute_concurrently(hosts, "show version")
|
|
61
|
+
|
|
62
|
+
for result in audit.results:
|
|
63
|
+
print(f"Host {result.ip} status: {result.status}")
|
|
64
|
+
print(result.stdout)
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
asyncio.run(main())
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## 📖 Documentation
|
|
71
|
+
|
|
72
|
+
Detailed documentation is available in the `docs/` directory:
|
|
73
|
+
- 🗺️ [**Usage Guide**](docs/usage.md) - Deep dive into CLI and REST API examples.
|
|
74
|
+
- 🏗️ [**Architecture**](docs/architecture.md) - Understand the asynchronous execution engine and legacy fallbacks.
|
|
75
|
+
- 🧠 [**Design Decisions**](docs/design.md) - Read about the decoupled persistence approach.
|
|
76
|
+
- 🧪 [**Contributing**](docs/contributing.md) - The development onboarding guide.
|
|
77
|
+
|
|
78
|
+
## 🤝 Contributing
|
|
79
|
+
|
|
80
|
+
We welcome contributions from the community! `netpulse-ssh` is open-source and maintained actively.
|
|
81
|
+
Please read our [**Contributing Guidelines**](docs/contributing.md) to understand how to clone the repository and submit your Pull Requests.
|
|
82
|
+
|
|
83
|
+
## 📜 License
|
|
84
|
+
|
|
85
|
+
Distributed under the **MIT License**. See `LICENSE` for more information.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
The `netpulse-ssh` package is architected to perform massively concurrent, non-blocking SSH executions across networking infrastructure.
|
|
4
|
+
|
|
5
|
+
## Component Flow
|
|
6
|
+
|
|
7
|
+
1. **API / CLI Layer (`netpulse.ssh.api` / `netpulse.ssh.cli`)**
|
|
8
|
+
- Ingests a list of `SshHostConfig` requests containing targets, credentials, and connection timeouts.
|
|
9
|
+
- Dispatches them to the runner.
|
|
10
|
+
|
|
11
|
+
2. **Runner Service (`netpulse.ssh.runner.SshRunnerService`)**
|
|
12
|
+
- Uses `asyncio.gather` to launch parallel, non-blocking asynchronous routines for every target device.
|
|
13
|
+
- Collates outputs, latencies, and negotiation details into a structured `SshExecutionAudit` domain model.
|
|
14
|
+
|
|
15
|
+
3. **Smart SSH Client (`netpulse.ssh.runner.SmartSshClient`)**
|
|
16
|
+
- Leverages `asyncssh` to connect to devices.
|
|
17
|
+
- Automatically detects strict OpenSSH cipher drops and falls back dynamically to legacy algorithms (e.g., `diffie-hellman-group1-sha1`, `3des-cbc`) which are frequently required for older Cisco or Juniper gear.
|
|
18
|
+
- Automatically injects configuration prerequisites, specifically sending `terminal length 0` to disable pagination before executing the desired command.
|
|
Binary file
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Contributing to NetPulse SSH
|
|
2
|
+
|
|
3
|
+
Thank you for your interest in contributing to the `netpulse-ssh` package! This document outlines the process for contributing to this specific package.
|
|
4
|
+
|
|
5
|
+
## Development Environment Setup
|
|
6
|
+
|
|
7
|
+
This project uses `uv` for lightning-fast package management and `hatchling` as the build backend.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Clone the repository
|
|
11
|
+
git clone https://github.com/your-org/netpulse.git
|
|
12
|
+
cd netpulse
|
|
13
|
+
|
|
14
|
+
# Sync the workspace and install all dependencies
|
|
15
|
+
uv sync
|
|
16
|
+
|
|
17
|
+
# Run the SSH specific tests
|
|
18
|
+
uv run pytest packages/netpulse-ssh/tests/
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Project Structure
|
|
22
|
+
- `src/netpulse/ssh/`: The actual library source code.
|
|
23
|
+
- `tests/unit/`: Unit tests isolating the client and runner logic.
|
|
24
|
+
- `tests/integration/`: Integration tests validating the API and CLI wrappers.
|
|
25
|
+
|
|
26
|
+
## Pull Request Guidelines
|
|
27
|
+
1. Fork the repository and create your feature branch from `main`.
|
|
28
|
+
2. Ensure you have added appropriate unit and integration tests for your changes.
|
|
29
|
+
3. Verify all tests pass cleanly using `uv run pytest`.
|
|
30
|
+
4. Follow standard PEP-8 style guidelines.
|
|
31
|
+
5. Create a descriptive PR outlining the problem solved and the approach.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Design Decisions
|
|
2
|
+
|
|
3
|
+
## Decoupling
|
|
4
|
+
|
|
5
|
+
Previously, the SSH execution engine within `netpulse-core` tightly coupled the execution runtime with a native SQLite `DatabaseService`.
|
|
6
|
+
|
|
7
|
+
In `netpulse-ssh`, this logic was purposefully inverted:
|
|
8
|
+
1. **Runner Agnosticism**: The `SshRunnerService` does absolutely zero persistence. It only yields a fully populated `SshExecutionAudit` model in memory.
|
|
9
|
+
2. **Caller Responsibility**: The invoker (e.g., the monolithic `netpulse-api`) is now responsible for catching the domain model and applying it to its local `DatabaseService`.
|
|
10
|
+
|
|
11
|
+
This architectural pivot guarantees that `netpulse-ssh` can be pulled off the shelf and utilized natively inside other Python applications completely independent of the broader `netpulse` ecosystem.
|
|
12
|
+
|
|
13
|
+
## Error Handling
|
|
14
|
+
Exceptions experienced on individual devices (e.g. `ConnectionRefusedError`, timeouts, authentication failures) are isolated at the `SmartSshClient` scope. They mutate the individual `SshHostResult.status` to `FAILED` and populate `error_message`, ensuring that a single unresponsive device does not crash the broader concurrent runtime array.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# NetPulse SSH Usage
|
|
2
|
+
|
|
3
|
+
The `netpulse-ssh` package can be utilized as a standalone Command Line Interface (CLI), an independent REST API, or seamlessly imported as a standard Python library into your custom scripts.
|
|
4
|
+
|
|
5
|
+
## Standalone CLI
|
|
6
|
+
|
|
7
|
+
To execute a command across multiple devices concurrently:
|
|
8
|
+
```bash
|
|
9
|
+
netpulse-ssh execute 192.168.1.5 10.0.0.1 -c "show ip interface brief" -u admin -p password
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
- `-c, --command`: The SSH command to execute.
|
|
14
|
+
- `-u, --user`: The SSH login username.
|
|
15
|
+
- `-p, --pass`: The SSH password.
|
|
16
|
+
- `-e, --enable`: A privilege execution mode password (e.g., Cisco `enable`).
|
|
17
|
+
- `--port`: The SSH port (default `22`).
|
|
18
|
+
|
|
19
|
+
## Standalone API
|
|
20
|
+
|
|
21
|
+
You can boot up a standalone FastAPI server containing only the SSH router:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import uvicorn
|
|
25
|
+
from fastapi import FastAPI
|
|
26
|
+
from netpulse.ssh.api import ssh_router
|
|
27
|
+
|
|
28
|
+
app = FastAPI()
|
|
29
|
+
app.include_router(ssh_router)
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
uvicorn.run(app, host="0.0.0.0", port=8001)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Endpoint:** `POST /api/v1/ssh/execute`
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"hosts": [
|
|
39
|
+
{
|
|
40
|
+
"ip": "192.168.1.1",
|
|
41
|
+
"port": 22
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"command": "show version",
|
|
45
|
+
"username": "admin",
|
|
46
|
+
"password": "secret_password"
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Python Library
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import asyncio
|
|
54
|
+
from netpulse.ssh.models import SshHostConfig
|
|
55
|
+
from netpulse.ssh.runner import SshRunnerService
|
|
56
|
+
|
|
57
|
+
async def run():
|
|
58
|
+
hosts = [
|
|
59
|
+
SshHostConfig(ip="10.0.0.1", username="admin", password="password")
|
|
60
|
+
]
|
|
61
|
+
runner = SshRunnerService()
|
|
62
|
+
audit = await runner.execute_concurrently(hosts, "show version")
|
|
63
|
+
print(audit.results[0].stdout)
|
|
64
|
+
|
|
65
|
+
asyncio.run(run())
|
|
66
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "netpulse-ssh"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "High-speed concurrent SSH command runner"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"asyncssh>=2.14.0",
|
|
13
|
+
"typer>=0.9.0",
|
|
14
|
+
"rich>=13.0.0",
|
|
15
|
+
"fastapi>=0.100.0",
|
|
16
|
+
"pydantic>=2.0.0"
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
netpulse-ssh = "netpulse.ssh.cli:app"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/netpulse"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Implicit namespace package. No imports here.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException, status, FastAPI
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from netpulse.ssh.models import SshHostConfig, SshExecutionAudit
|
|
6
|
+
from netpulse.ssh.runner import SshRunnerService
|
|
7
|
+
|
|
8
|
+
app = FastAPI(title="NetPulse SSH API")
|
|
9
|
+
ssh_router = APIRouter()
|
|
10
|
+
|
|
11
|
+
class SshExecuteHost(BaseModel):
|
|
12
|
+
ip: str = Field(..., description="Target IP or hostname.")
|
|
13
|
+
port: int = Field(22, description="SSH port.")
|
|
14
|
+
|
|
15
|
+
class SshExecuteRequest(BaseModel):
|
|
16
|
+
hosts: List[SshExecuteHost] = Field(..., description="List of SSH hosts.")
|
|
17
|
+
command: str = Field(..., description="SSH command to execute.")
|
|
18
|
+
username: str = Field(..., description="SSH login username.")
|
|
19
|
+
password: Optional[str] = Field(None, description="SSH login password.")
|
|
20
|
+
enable_password: Optional[str] = Field(None, description="Cisco enable password.")
|
|
21
|
+
auto_negotiate: bool = Field(True, description="Enable key-exchange auto-negotiate fallbacks.")
|
|
22
|
+
ignore_host_keys: bool = Field(True, description="Ignore host verification checks.")
|
|
23
|
+
timeout_seconds: int = Field(10, description="Connection timeout.")
|
|
24
|
+
|
|
25
|
+
@ssh_router.post("/api/v1/ssh/execute", response_model=SshExecutionAudit, status_code=status.HTTP_200_OK)
|
|
26
|
+
async def execute_ssh_command(req: SshExecuteRequest):
|
|
27
|
+
"""
|
|
28
|
+
Executes a command concurrently across one or multiple remote SSH hosts.
|
|
29
|
+
Returns the audit trail containing stdout/stderr for each host.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
hosts_config = []
|
|
33
|
+
for h in req.hosts:
|
|
34
|
+
hosts_config.append(SshHostConfig(
|
|
35
|
+
ip=h.ip,
|
|
36
|
+
port=h.port,
|
|
37
|
+
username=req.username,
|
|
38
|
+
password=req.password,
|
|
39
|
+
enable_password=req.enable_password,
|
|
40
|
+
auto_negotiate=req.auto_negotiate,
|
|
41
|
+
ignore_host_keys=req.ignore_host_keys,
|
|
42
|
+
timeout_seconds=req.timeout_seconds
|
|
43
|
+
))
|
|
44
|
+
|
|
45
|
+
runner = SshRunnerService()
|
|
46
|
+
audit = await runner.execute_concurrently(hosts_config, req.command)
|
|
47
|
+
return audit
|
|
48
|
+
except Exception as e:
|
|
49
|
+
raise HTTPException(
|
|
50
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
51
|
+
detail={
|
|
52
|
+
"error": "SshExecutionError",
|
|
53
|
+
"message": f"Concurrent SSH runner failed: {e}"
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
app.include_router(ssh_router)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import asyncio
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from rich import print as rprint
|
|
7
|
+
|
|
8
|
+
from netpulse.ssh.models import SshHostConfig, SshStatus
|
|
9
|
+
from netpulse.ssh.runner import SshRunnerService
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="NetPulse SSH: High-speed concurrent command runner.")
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
@app.command("execute")
|
|
15
|
+
def execute(
|
|
16
|
+
hosts: List[str] = typer.Argument(..., help="List of IP addresses to target."),
|
|
17
|
+
command: str = typer.Option(..., "--command", "-c", help="Command to execute."),
|
|
18
|
+
username: str = typer.Option(..., "--user", "-u", help="SSH Username.", prompt=True),
|
|
19
|
+
password: str = typer.Option("", "--pass", "-p", help="SSH Password.", hide_input=True, prompt="Password (leave empty if using keys)"),
|
|
20
|
+
enable_password: str = typer.Option("", "--enable", "-e", help="Cisco enable password.", hide_input=True),
|
|
21
|
+
port: int = typer.Option(22, "--port", help="SSH port."),
|
|
22
|
+
):
|
|
23
|
+
"""
|
|
24
|
+
Execute a command concurrently across multiple SSH hosts and aggregate the results.
|
|
25
|
+
"""
|
|
26
|
+
hosts_config = [
|
|
27
|
+
SshHostConfig(
|
|
28
|
+
ip=ip,
|
|
29
|
+
port=port,
|
|
30
|
+
username=username,
|
|
31
|
+
password=password if password else None,
|
|
32
|
+
enable_password=enable_password if enable_password else None,
|
|
33
|
+
) for ip in hosts
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
async def run():
|
|
37
|
+
runner = SshRunnerService()
|
|
38
|
+
with console.status(f"[bold cyan]Executing '{command}' across {len(hosts)} hosts...", spinner="dots"):
|
|
39
|
+
audit = await runner.execute_concurrently(hosts_config, command)
|
|
40
|
+
|
|
41
|
+
table = Table(title=f"SSH Execution Summary: '{command}'")
|
|
42
|
+
table.add_column("Host IP", justify="left", style="cyan", no_wrap=True)
|
|
43
|
+
table.add_column("Status", justify="center")
|
|
44
|
+
table.add_column("Latency (ms)", justify="right", style="magenta")
|
|
45
|
+
table.add_column("Output / Error", justify="left", style="green")
|
|
46
|
+
|
|
47
|
+
for res in audit.results:
|
|
48
|
+
if res.status == SshStatus.SUCCESS:
|
|
49
|
+
status_str = "[bold green]SUCCESS[/bold green]"
|
|
50
|
+
output = res.stdout.strip()[:100] + ("..." if len(res.stdout) > 100 else "") if res.stdout else "No output"
|
|
51
|
+
else:
|
|
52
|
+
status_str = "[bold red]FAILED[/bold red]"
|
|
53
|
+
output = f"[red]{res.error_message}[/red]"
|
|
54
|
+
|
|
55
|
+
table.add_row(res.ip, status_str, f"{res.latency_ms}ms", output)
|
|
56
|
+
|
|
57
|
+
console.print(table)
|
|
58
|
+
rprint(f"[bold]Total Success:[/bold] {audit.success_count} | [bold]Total Failed:[/bold] {audit.failed_count}")
|
|
59
|
+
|
|
60
|
+
asyncio.run(run())
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
app()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import List, Optional, Dict, Any
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
6
|
+
|
|
7
|
+
class SshStatus(str, Enum):
|
|
8
|
+
SUCCESS = "success"
|
|
9
|
+
FAILED = "failed"
|
|
10
|
+
|
|
11
|
+
class SshHostConfig(BaseModel):
|
|
12
|
+
"""Configuration for an individual SSH connection target."""
|
|
13
|
+
model_config = ConfigDict(
|
|
14
|
+
use_enum_values=True,
|
|
15
|
+
validate_assignment=True,
|
|
16
|
+
populate_by_name=True
|
|
17
|
+
)
|
|
18
|
+
ip: str = Field(..., description="Target host IP address or hostname.")
|
|
19
|
+
port: int = Field(22, description="SSH port (defaults to 22).")
|
|
20
|
+
username: str = Field(..., description="SSH login username.")
|
|
21
|
+
password: Optional[str] = Field(None, description="SSH login password.")
|
|
22
|
+
enable_password: Optional[str] = Field(None, description="Cisco enable password.")
|
|
23
|
+
auto_negotiate: bool = Field(True, description="Attempt dynamic legacy cipher negotiation if handshake fails.")
|
|
24
|
+
ignore_host_keys: bool = Field(True, description="Bypass strict SSH host key checking.")
|
|
25
|
+
timeout_seconds: int = Field(10, description="SSH connection timeout.")
|
|
26
|
+
|
|
27
|
+
class SshHostResult(BaseModel):
|
|
28
|
+
"""Execution result details for an individual target host."""
|
|
29
|
+
model_config = ConfigDict(
|
|
30
|
+
use_enum_values=True,
|
|
31
|
+
validate_assignment=True
|
|
32
|
+
)
|
|
33
|
+
ip: str = Field(..., description="Target host IP address.")
|
|
34
|
+
status: SshStatus = Field(..., description="Connection or execution outcome status.")
|
|
35
|
+
stdout: Optional[str] = Field(None, description="Command execution standard output.")
|
|
36
|
+
stderr: Optional[str] = Field(None, description="Command execution standard error.")
|
|
37
|
+
latency_ms: Optional[float] = Field(None, description="Execution latency/duration in milliseconds.")
|
|
38
|
+
negotiated_kex: Optional[str] = Field(None, description="The negotiated key exchange algorithm.")
|
|
39
|
+
negotiated_cipher: Optional[str] = Field(None, description="The negotiated encryption cipher.")
|
|
40
|
+
error_message: Optional[str] = Field(None, description="Detailed failure reason if status is FAILED.")
|
|
41
|
+
|
|
42
|
+
class SshExecutionAudit(BaseModel):
|
|
43
|
+
"""Aggregate model representing a multi-host SSH execution session."""
|
|
44
|
+
model_config = ConfigDict(
|
|
45
|
+
use_enum_values=True,
|
|
46
|
+
validate_assignment=True
|
|
47
|
+
)
|
|
48
|
+
id: uuid.UUID = Field(default_factory=uuid.uuid4, description="Unique execution audit UUID.")
|
|
49
|
+
command: str = Field(..., description="The command executed across the targets.")
|
|
50
|
+
targets: List[str] = Field(..., description="Target host IP addresses.")
|
|
51
|
+
success_count: int = Field(..., description="Number of hosts successfully executed.")
|
|
52
|
+
failed_count: int = Field(..., description="Number of hosts that failed.")
|
|
53
|
+
executed_at: datetime = Field(
|
|
54
|
+
default_factory=lambda: datetime.now(timezone.utc),
|
|
55
|
+
description="Timestamp when the audit was generated (UTC)."
|
|
56
|
+
)
|
|
57
|
+
results: List[SshHostResult] = Field(..., description="Individual execution results.")
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
import logging
|
|
4
|
+
import asyncssh
|
|
5
|
+
from typing import List, Optional, Tuple
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from netpulse.ssh.models import SshHostConfig, SshHostResult, SshStatus, SshExecutionAudit
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
class TrustingSSHClient(asyncssh.SSHClient):
|
|
13
|
+
"""Custom SSH client that trusts all host keys to prevent IP-reassignment blockage."""
|
|
14
|
+
def validate_host_key(self, host: str, port: int, key: bytes, fingerprint: str) -> bool:
|
|
15
|
+
return True
|
|
16
|
+
|
|
17
|
+
class SmartSshClient:
|
|
18
|
+
"""
|
|
19
|
+
Highly resilient, non-blocking SSH client optimized for network engineers.
|
|
20
|
+
Automatically handles legacy cryptographic negotiation, pagination pager suppression,
|
|
21
|
+
host key changes, and privilege enable escalation.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
async def connect_and_execute(cls, config: SshHostConfig, command: str) -> SshHostResult:
|
|
26
|
+
"""
|
|
27
|
+
Resiliently connects to a host via SSH, performs command executions,
|
|
28
|
+
and logs performance/cryptographic parameters.
|
|
29
|
+
"""
|
|
30
|
+
ip = config.ip
|
|
31
|
+
port = config.port
|
|
32
|
+
username = config.username
|
|
33
|
+
password = config.password
|
|
34
|
+
enable_password = config.enable_password
|
|
35
|
+
timeout = config.timeout_seconds
|
|
36
|
+
|
|
37
|
+
start_time = time.perf_counter()
|
|
38
|
+
|
|
39
|
+
# Standard modern connection options
|
|
40
|
+
connect_opts = {
|
|
41
|
+
"host": ip,
|
|
42
|
+
"port": port,
|
|
43
|
+
"username": username,
|
|
44
|
+
"password": password,
|
|
45
|
+
"login_timeout": timeout,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Bypass host key checking using our custom validator
|
|
49
|
+
if config.ignore_host_keys:
|
|
50
|
+
connect_opts["client_factory"] = TrustingSSHClient
|
|
51
|
+
|
|
52
|
+
conn = None
|
|
53
|
+
negotiated_kex = None
|
|
54
|
+
negotiated_cipher = None
|
|
55
|
+
used_fallback = False
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
# 1. Attempt connection using modern secure algorithms
|
|
59
|
+
try:
|
|
60
|
+
conn = await asyncssh.connect(**connect_opts)
|
|
61
|
+
except (asyncssh.misc.ProtocolError, asyncssh.misc.DisconnectError) as e:
|
|
62
|
+
# Catch cryptographic key exchange or cipher mismatch errors
|
|
63
|
+
if config.auto_negotiate:
|
|
64
|
+
logger.warning(
|
|
65
|
+
f"Handshake failed with {ip}:{port} ({e}). Retrying with legacy cryptographic support..."
|
|
66
|
+
)
|
|
67
|
+
used_fallback = True
|
|
68
|
+
|
|
69
|
+
# Explicitly enable legacy KEX, signature (host keys), and encryption ciphers
|
|
70
|
+
legacy_opts = {
|
|
71
|
+
**connect_opts,
|
|
72
|
+
"kex_algs": [
|
|
73
|
+
"diffie-hellman-group1-sha1",
|
|
74
|
+
"diffie-hellman-group14-sha1",
|
|
75
|
+
"diffie-hellman-group-exchange-sha1",
|
|
76
|
+
"diffie-hellman-group-exchange-sha256",
|
|
77
|
+
"ecdh-sha2-nistp256",
|
|
78
|
+
"ecdh-sha2-nistp384",
|
|
79
|
+
"ecdh-sha2-nistp521",
|
|
80
|
+
],
|
|
81
|
+
"encryption_algs": [
|
|
82
|
+
"aes128-cbc",
|
|
83
|
+
"aes192-cbc",
|
|
84
|
+
"aes256-cbc",
|
|
85
|
+
"3des-cbc",
|
|
86
|
+
"aes128-ctr",
|
|
87
|
+
"aes192-ctr",
|
|
88
|
+
"aes256-ctr",
|
|
89
|
+
],
|
|
90
|
+
"signature_algs": [
|
|
91
|
+
"ssh-rsa",
|
|
92
|
+
"ssh-dss",
|
|
93
|
+
"ecdsa-sha2-nistp256",
|
|
94
|
+
"ssh-ed25519",
|
|
95
|
+
],
|
|
96
|
+
}
|
|
97
|
+
conn = await asyncssh.connect(**legacy_opts)
|
|
98
|
+
else:
|
|
99
|
+
raise e
|
|
100
|
+
|
|
101
|
+
# Log successfully negotiated parameters
|
|
102
|
+
negotiated_kex = conn.get_extra_info("kex_alg")
|
|
103
|
+
negotiated_cipher = conn.get_extra_info("cipher_alg")
|
|
104
|
+
|
|
105
|
+
# 2. Interactive execution shell session (VT100)
|
|
106
|
+
# This enables handling Cisco enable prompts and suppressing --More-- page breaks
|
|
107
|
+
stdout_str, stderr_str = await cls._execute_interactive(
|
|
108
|
+
conn=conn,
|
|
109
|
+
command=command,
|
|
110
|
+
enable_password=enable_password,
|
|
111
|
+
timeout=timeout
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
latency_ms = (time.perf_counter() - start_time) * 1000.0
|
|
115
|
+
|
|
116
|
+
return SshHostResult(
|
|
117
|
+
ip=ip,
|
|
118
|
+
status=SshStatus.SUCCESS,
|
|
119
|
+
stdout=stdout_str,
|
|
120
|
+
stderr=stderr_str if stderr_str else None,
|
|
121
|
+
latency_ms=round(latency_ms, 2),
|
|
122
|
+
negotiated_kex=negotiated_kex,
|
|
123
|
+
negotiated_cipher=negotiated_cipher
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
except Exception as e:
|
|
127
|
+
latency_ms = (time.perf_counter() - start_time) * 1000.0
|
|
128
|
+
error_msg = str(e)
|
|
129
|
+
if used_fallback:
|
|
130
|
+
error_msg = f"Legacy Handshake Fail: {error_msg}"
|
|
131
|
+
|
|
132
|
+
return SshHostResult(
|
|
133
|
+
ip=ip,
|
|
134
|
+
status=SshStatus.FAILED,
|
|
135
|
+
latency_ms=round(latency_ms, 2),
|
|
136
|
+
error_message=error_msg
|
|
137
|
+
)
|
|
138
|
+
finally:
|
|
139
|
+
if conn:
|
|
140
|
+
conn.close()
|
|
141
|
+
await conn.wait_closed()
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
async def _execute_interactive(
|
|
145
|
+
cls,
|
|
146
|
+
conn: asyncssh.SSHClientConnection,
|
|
147
|
+
command: str,
|
|
148
|
+
enable_password: Optional[str] = None,
|
|
149
|
+
timeout: int = 10
|
|
150
|
+
) -> Tuple[str, str]:
|
|
151
|
+
"""
|
|
152
|
+
Emulates a virtual terminal (pty) session to run enable escalations
|
|
153
|
+
and suppress terminal pagination natively.
|
|
154
|
+
"""
|
|
155
|
+
# Open interactive virtual terminal process
|
|
156
|
+
async with conn.create_process(term_type='vt100') as proc:
|
|
157
|
+
# 1. Handle Cisco privilege exec escalation
|
|
158
|
+
if enable_password:
|
|
159
|
+
proc.stdin.write("enable\n")
|
|
160
|
+
await asyncio.sleep(0.15)
|
|
161
|
+
proc.stdin.write(f"{enable_password}\n")
|
|
162
|
+
await asyncio.sleep(0.15)
|
|
163
|
+
|
|
164
|
+
# 2. Suppress terminal pagination (works on Cisco IOS / Cisco Mock environments)
|
|
165
|
+
# Non-Cisco environments will ignore/error this but proceed cleanly
|
|
166
|
+
proc.stdin.write("terminal length 0\n")
|
|
167
|
+
await asyncio.sleep(0.1)
|
|
168
|
+
|
|
169
|
+
# 3. Execute the actual command
|
|
170
|
+
proc.stdin.write(f"{command}\n")
|
|
171
|
+
await asyncio.sleep(0.15)
|
|
172
|
+
|
|
173
|
+
# Exit session cleanly
|
|
174
|
+
proc.stdin.write("exit\n")
|
|
175
|
+
|
|
176
|
+
# Read streams asynchronously with safety timeout
|
|
177
|
+
try:
|
|
178
|
+
stdout_data = await asyncio.wait_for(proc.stdout.read(), timeout=timeout)
|
|
179
|
+
stderr_data = await asyncio.wait_for(proc.stderr.read(), timeout=timeout)
|
|
180
|
+
except asyncio.TimeoutError:
|
|
181
|
+
stdout_data = "Error: Interactive terminal execution timed out."
|
|
182
|
+
stderr_data = ""
|
|
183
|
+
|
|
184
|
+
return stdout_data, stderr_data
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class SshRunnerService:
|
|
188
|
+
"""
|
|
189
|
+
Orchestrates high-speed, parallel SSH command executions across multiple network hosts.
|
|
190
|
+
Aggregates outcomes and returns a unified execution audit.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
async def execute_concurrently(self, hosts: List[SshHostConfig], command: str) -> SshExecutionAudit:
|
|
194
|
+
"""
|
|
195
|
+
Concurrently executes a command across multiple host configurations.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
hosts: List of SshHostConfig models
|
|
199
|
+
command: Configuration or diagnostic command string to run
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
An SshExecutionAudit model aggregating all host executions and metrics
|
|
203
|
+
"""
|
|
204
|
+
if not hosts:
|
|
205
|
+
return SshExecutionAudit(
|
|
206
|
+
command=command,
|
|
207
|
+
targets=[],
|
|
208
|
+
success_count=0,
|
|
209
|
+
failed_count=0,
|
|
210
|
+
results=[],
|
|
211
|
+
executed_at=datetime.now(timezone.utc)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Trigger concurrent executions using asyncio.gather
|
|
215
|
+
tasks = [SmartSshClient.connect_and_execute(cfg, command) for cfg in hosts]
|
|
216
|
+
results: List[SshHostResult] = await asyncio.gather(*tasks)
|
|
217
|
+
|
|
218
|
+
# Count outcomes and targets
|
|
219
|
+
success_count = sum(1 for res in results if res.status == SshStatus.SUCCESS)
|
|
220
|
+
failed_count = sum(1 for res in results if res.status == SshStatus.FAILED)
|
|
221
|
+
targets = [cfg.ip for cfg in hosts]
|
|
222
|
+
|
|
223
|
+
# Construct unified execution audit model
|
|
224
|
+
audit = SshExecutionAudit(
|
|
225
|
+
command=command,
|
|
226
|
+
targets=targets,
|
|
227
|
+
success_count=success_count,
|
|
228
|
+
failed_count=failed_count,
|
|
229
|
+
results=results,
|
|
230
|
+
executed_at=datetime.now(timezone.utc)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return audit
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from unittest.mock import patch, AsyncMock
|
|
3
|
+
from fastapi.testclient import TestClient
|
|
4
|
+
|
|
5
|
+
from netpulse.ssh.api import app
|
|
6
|
+
from netpulse.ssh.models import SshExecutionAudit, SshHostResult, SshStatus
|
|
7
|
+
|
|
8
|
+
def test_api_execute_ssh_command_success():
|
|
9
|
+
"""Verify POST /api/v1/ssh/execute returns execution audit successfully."""
|
|
10
|
+
client = TestClient(app)
|
|
11
|
+
|
|
12
|
+
mock_audit = SshExecutionAudit(
|
|
13
|
+
command="show version",
|
|
14
|
+
targets=["192.168.1.1"],
|
|
15
|
+
success_count=1,
|
|
16
|
+
failed_count=0,
|
|
17
|
+
results=[
|
|
18
|
+
SshHostResult(
|
|
19
|
+
ip="192.168.1.1",
|
|
20
|
+
status=SshStatus.SUCCESS,
|
|
21
|
+
stdout="Cisco IOS Software",
|
|
22
|
+
latency_ms=12.5,
|
|
23
|
+
negotiated_kex="curve25519-sha256",
|
|
24
|
+
negotiated_cipher="aes256-gcm@openssh.com"
|
|
25
|
+
)
|
|
26
|
+
]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
with patch("netpulse.ssh.api.SshRunnerService.execute_concurrently", new_callable=AsyncMock) as mock_exec:
|
|
30
|
+
mock_exec.return_value = mock_audit
|
|
31
|
+
|
|
32
|
+
payload = {
|
|
33
|
+
"hosts": [{"ip": "192.168.1.1", "port": 22}],
|
|
34
|
+
"command": "show version",
|
|
35
|
+
"username": "admin",
|
|
36
|
+
"password": "password",
|
|
37
|
+
"timeout_seconds": 10
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
response = client.post("/api/v1/ssh/execute", json=payload)
|
|
41
|
+
|
|
42
|
+
assert response.status_code == 200
|
|
43
|
+
data = response.json()
|
|
44
|
+
assert data["command"] == "show version"
|
|
45
|
+
assert data["success_count"] == 1
|
|
46
|
+
assert len(data["results"]) == 1
|
|
47
|
+
assert data["results"][0]["ip"] == "192.168.1.1"
|
|
48
|
+
assert data["results"][0]["stdout"] == "Cisco IOS Software"
|
|
49
|
+
|
|
50
|
+
def test_api_execute_ssh_command_failure():
|
|
51
|
+
"""Verify POST /api/v1/ssh/execute handles backend runner exceptions and returns 500."""
|
|
52
|
+
client = TestClient(app)
|
|
53
|
+
|
|
54
|
+
with patch("netpulse.ssh.api.SshRunnerService.execute_concurrently", new_callable=AsyncMock) as mock_exec:
|
|
55
|
+
mock_exec.side_effect = Exception("Kernel Panic in asyncssh loop")
|
|
56
|
+
|
|
57
|
+
payload = {
|
|
58
|
+
"hosts": [{"ip": "10.0.0.1", "port": 22}],
|
|
59
|
+
"command": "reboot",
|
|
60
|
+
"username": "root",
|
|
61
|
+
"password": "password"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
response = client.post("/api/v1/ssh/execute", json=payload)
|
|
65
|
+
|
|
66
|
+
assert response.status_code == 500
|
|
67
|
+
data = response.json()
|
|
68
|
+
assert data["detail"]["error"] == "SshExecutionError"
|
|
69
|
+
assert "Kernel Panic" in data["detail"]["message"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from unittest.mock import patch, AsyncMock
|
|
3
|
+
from typer.testing import CliRunner
|
|
4
|
+
|
|
5
|
+
from netpulse.ssh.cli import app
|
|
6
|
+
from netpulse.ssh.models import SshExecutionAudit, SshHostResult, SshStatus
|
|
7
|
+
|
|
8
|
+
runner = CliRunner()
|
|
9
|
+
|
|
10
|
+
def test_cli_execute_ssh_command_success():
|
|
11
|
+
"""Verify that the execute command invokes the runner and prints results successfully."""
|
|
12
|
+
mock_audit = SshExecutionAudit(
|
|
13
|
+
command="show version",
|
|
14
|
+
targets=["10.0.0.1"],
|
|
15
|
+
success_count=1,
|
|
16
|
+
failed_count=0,
|
|
17
|
+
results=[
|
|
18
|
+
SshHostResult(
|
|
19
|
+
ip="10.0.0.1",
|
|
20
|
+
status=SshStatus.SUCCESS,
|
|
21
|
+
stdout="Cisco IOS Software",
|
|
22
|
+
latency_ms=15.5,
|
|
23
|
+
negotiated_kex="curve25519-sha256",
|
|
24
|
+
negotiated_cipher="aes256-gcm@openssh.com"
|
|
25
|
+
)
|
|
26
|
+
]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
with patch("netpulse.ssh.cli.SshRunnerService.execute_concurrently", new_callable=AsyncMock) as mock_exec:
|
|
30
|
+
mock_exec.return_value = mock_audit
|
|
31
|
+
|
|
32
|
+
result = runner.invoke(app, ["execute", "10.0.0.1", "-c", "show version", "-u", "admin", "-p", "password"])
|
|
33
|
+
|
|
34
|
+
assert result.exit_code == 0
|
|
35
|
+
assert "SSH Execution Summary" in result.stdout
|
|
36
|
+
assert "10.0.0.1" in result.stdout
|
|
37
|
+
assert "SUCCESS" in result.stdout
|
|
38
|
+
assert "Cisco IOS Software" in result.stdout
|
|
39
|
+
|
|
40
|
+
def test_cli_execute_ssh_command_failure():
|
|
41
|
+
"""Verify that the execute command handles failures cleanly and prints them."""
|
|
42
|
+
mock_audit = SshExecutionAudit(
|
|
43
|
+
command="show version",
|
|
44
|
+
targets=["10.0.0.1"],
|
|
45
|
+
success_count=0,
|
|
46
|
+
failed_count=1,
|
|
47
|
+
results=[
|
|
48
|
+
SshHostResult(
|
|
49
|
+
ip="10.0.0.1",
|
|
50
|
+
status=SshStatus.FAILED,
|
|
51
|
+
error_message="Authentication failed: invalid username or password.",
|
|
52
|
+
latency_ms=10.0
|
|
53
|
+
)
|
|
54
|
+
]
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
with patch("netpulse.ssh.cli.SshRunnerService.execute_concurrently", new_callable=AsyncMock) as mock_exec:
|
|
58
|
+
mock_exec.return_value = mock_audit
|
|
59
|
+
|
|
60
|
+
result = runner.invoke(app, ["execute", "10.0.0.1", "-c", "show version", "-u", "admin", "-p", "wrongpassword"])
|
|
61
|
+
|
|
62
|
+
assert result.exit_code == 0
|
|
63
|
+
assert "10.0.0.1" in result.stdout
|
|
64
|
+
assert "FAILED" in result.stdout
|
|
65
|
+
assert "Authentication failed" in result.stdout
|
|
66
|
+
assert "Total Failed: 1" in result.stdout
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pytest
|
|
3
|
+
import uuid
|
|
4
|
+
import asyncio
|
|
5
|
+
from unittest.mock import patch, MagicMock, AsyncMock
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
import asyncssh
|
|
8
|
+
|
|
9
|
+
from netpulse.ssh.models import SshHostConfig, SshHostResult, SshExecutionAudit, SshStatus
|
|
10
|
+
from netpulse.ssh.runner import SmartSshClient, SshRunnerService
|
|
11
|
+
|
|
12
|
+
def test_ssh_models_instantiation():
|
|
13
|
+
"""Verify that SSH models validate inputs and set correct defaults."""
|
|
14
|
+
config = SshHostConfig(
|
|
15
|
+
ip="192.168.1.1",
|
|
16
|
+
username="admin",
|
|
17
|
+
password="secretpassword",
|
|
18
|
+
enable_password="enablepwd"
|
|
19
|
+
)
|
|
20
|
+
assert config.ip == "192.168.1.1"
|
|
21
|
+
assert config.port == 22
|
|
22
|
+
assert config.username == "admin"
|
|
23
|
+
assert config.password == "secretpassword"
|
|
24
|
+
assert config.enable_password == "enablepwd"
|
|
25
|
+
assert config.auto_negotiate is True
|
|
26
|
+
assert config.ignore_host_keys is True
|
|
27
|
+
|
|
28
|
+
result = SshHostResult(
|
|
29
|
+
ip="192.168.1.1",
|
|
30
|
+
status=SshStatus.SUCCESS,
|
|
31
|
+
stdout="interface GigabitEthernet0/1\n ip address 192.168.1.1",
|
|
32
|
+
latency_ms=45.2,
|
|
33
|
+
negotiated_kex="diffie-hellman-group14-sha1",
|
|
34
|
+
negotiated_cipher="aes128-cbc"
|
|
35
|
+
)
|
|
36
|
+
assert result.ip == "192.168.1.1"
|
|
37
|
+
assert result.status == SshStatus.SUCCESS
|
|
38
|
+
assert "GigabitEthernet0/1" in result.stdout
|
|
39
|
+
assert result.latency_ms == 45.2
|
|
40
|
+
assert result.negotiated_kex == "diffie-hellman-group14-sha1"
|
|
41
|
+
assert result.negotiated_cipher == "aes128-cbc"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.asyncio
|
|
45
|
+
@patch("asyncssh.connect", new_callable=AsyncMock)
|
|
46
|
+
async def test_ssh_client_success_standard(mock_connect):
|
|
47
|
+
"""Verify that SmartSshClient connects and executes successfully under standard handshakes."""
|
|
48
|
+
mock_conn = MagicMock()
|
|
49
|
+
mock_conn.wait_closed = AsyncMock()
|
|
50
|
+
mock_conn.get_extra_info.side_effect = lambda key: {
|
|
51
|
+
"kex_alg": "curve25519-sha256",
|
|
52
|
+
"cipher_alg": "aes256-gcm@openssh.com"
|
|
53
|
+
}.get(key)
|
|
54
|
+
|
|
55
|
+
# Mock the terminal process stream
|
|
56
|
+
mock_proc = AsyncMock()
|
|
57
|
+
mock_proc.stdout.read.return_value = "GigabitEthernet0/1 is up, line protocol is up"
|
|
58
|
+
mock_proc.stderr.read.return_value = ""
|
|
59
|
+
mock_conn.create_process.return_value.__aenter__ = AsyncMock(return_value=mock_proc)
|
|
60
|
+
mock_conn.create_process.return_value.__aexit__ = AsyncMock()
|
|
61
|
+
|
|
62
|
+
mock_connect.return_value = mock_conn
|
|
63
|
+
|
|
64
|
+
config = SshHostConfig(ip="192.168.1.5", username="admin", password="password")
|
|
65
|
+
|
|
66
|
+
result = await SmartSshClient.connect_and_execute(config, "show interface description")
|
|
67
|
+
|
|
68
|
+
assert result.status == SshStatus.SUCCESS
|
|
69
|
+
assert "GigabitEthernet0/1" in result.stdout
|
|
70
|
+
assert result.negotiated_kex == "curve25519-sha256"
|
|
71
|
+
assert result.negotiated_cipher == "aes256-gcm@openssh.com"
|
|
72
|
+
assert result.error_message is None
|
|
73
|
+
|
|
74
|
+
# Ensure it only connected once (no legacy retry needed)
|
|
75
|
+
mock_connect.assert_called_once()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.mark.asyncio
|
|
79
|
+
@patch("asyncssh.connect", new_callable=AsyncMock)
|
|
80
|
+
async def test_ssh_client_legacy_handshake_healing(mock_connect):
|
|
81
|
+
"""Verify that SmartSshClient retries connection with legacy ciphers if initial handshake fails."""
|
|
82
|
+
# First connect attempt raises NegotiationError
|
|
83
|
+
# Second connect attempt succeeds
|
|
84
|
+
mock_conn = MagicMock()
|
|
85
|
+
mock_conn.wait_closed = AsyncMock()
|
|
86
|
+
mock_conn.get_extra_info.side_effect = lambda key: {
|
|
87
|
+
"kex_alg": "diffie-hellman-group1-sha1",
|
|
88
|
+
"cipher_alg": "3des-cbc"
|
|
89
|
+
}.get(key)
|
|
90
|
+
|
|
91
|
+
mock_proc = AsyncMock()
|
|
92
|
+
mock_proc.stdout.read.return_value = "Legacy Cisco Switch Config..."
|
|
93
|
+
mock_proc.stderr.read.return_value = ""
|
|
94
|
+
mock_conn.create_process.return_value.__aenter__ = AsyncMock(return_value=mock_proc)
|
|
95
|
+
mock_conn.create_process.return_value.__aexit__ = AsyncMock()
|
|
96
|
+
|
|
97
|
+
mock_connect.side_effect = [
|
|
98
|
+
asyncssh.misc.ProtocolError("Key exchange failed: no matching algorithms"),
|
|
99
|
+
mock_conn
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
config = SshHostConfig(ip="192.168.1.10", username="admin", password="password", auto_negotiate=True)
|
|
103
|
+
|
|
104
|
+
result = await SmartSshClient.connect_and_execute(config, "show run")
|
|
105
|
+
|
|
106
|
+
assert result.status == SshStatus.SUCCESS
|
|
107
|
+
assert "Legacy Cisco Switch" in result.stdout
|
|
108
|
+
assert result.negotiated_kex == "diffie-hellman-group1-sha1"
|
|
109
|
+
assert result.negotiated_cipher == "3des-cbc"
|
|
110
|
+
|
|
111
|
+
# Ensure it connected twice (first fail, second legacy retry)
|
|
112
|
+
assert mock_connect.call_count == 2
|
|
113
|
+
# Second call must have legacy algorithms configured
|
|
114
|
+
legacy_call_args = mock_connect.call_args_list[1][1]
|
|
115
|
+
assert "diffie-hellman-group1-sha1" in legacy_call_args["kex_algs"]
|
|
116
|
+
assert "3des-cbc" in legacy_call_args["encryption_algs"]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.asyncio
|
|
120
|
+
@patch("asyncssh.connect", new_callable=AsyncMock)
|
|
121
|
+
async def test_ssh_runner_concurrent_execution(mock_connect):
|
|
122
|
+
"""Verify that SshRunnerService executes commands across multiple hosts in parallel and saves results."""
|
|
123
|
+
mock_conn = MagicMock()
|
|
124
|
+
mock_conn.wait_closed = AsyncMock()
|
|
125
|
+
mock_conn.get_extra_info.side_effect = lambda key: {
|
|
126
|
+
"kex_alg": "curve25519-sha256",
|
|
127
|
+
"cipher_alg": "aes256-gcm@openssh.com"
|
|
128
|
+
}.get(key)
|
|
129
|
+
|
|
130
|
+
mock_proc = AsyncMock()
|
|
131
|
+
mock_proc.stdout.read.return_value = "Command output"
|
|
132
|
+
mock_proc.stderr.read.return_value = ""
|
|
133
|
+
mock_conn.create_process.return_value.__aenter__ = AsyncMock(return_value=mock_proc)
|
|
134
|
+
mock_conn.create_process.return_value.__aexit__ = AsyncMock()
|
|
135
|
+
mock_connect.return_value = mock_conn
|
|
136
|
+
|
|
137
|
+
# Multi-host targets
|
|
138
|
+
hosts = [
|
|
139
|
+
SshHostConfig(ip="10.0.0.1", username="cisco", password="pwd"),
|
|
140
|
+
SshHostConfig(ip="10.0.0.2", username="cisco", password="pwd")
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
runner = SshRunnerService()
|
|
144
|
+
|
|
145
|
+
audit = await runner.execute_concurrently(hosts, "show clock")
|
|
146
|
+
|
|
147
|
+
assert isinstance(audit, SshExecutionAudit)
|
|
148
|
+
assert audit.success_count == 2
|
|
149
|
+
assert audit.failed_count == 0
|
|
150
|
+
assert len(audit.results) == 2
|
|
151
|
+
assert audit.results[0].stdout == "Command output"
|
|
152
|
+
assert audit.results[1].stdout == "Command output"
|