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.
@@ -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.
@@ -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"