netpulse-ssh 0.1.0__py3-none-any.whl
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/__init__.py +1 -0
- netpulse/ssh/api.py +57 -0
- netpulse/ssh/cli.py +63 -0
- netpulse/ssh/models.py +57 -0
- netpulse/ssh/runner.py +233 -0
- netpulse_ssh-0.1.0.dist-info/METADATA +97 -0
- netpulse_ssh-0.1.0.dist-info/RECORD +9 -0
- netpulse_ssh-0.1.0.dist-info/WHEEL +4 -0
- netpulse_ssh-0.1.0.dist-info/entry_points.txt +2 -0
netpulse/ssh/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Implicit namespace package. No imports here.
|
netpulse/ssh/api.py
ADDED
|
@@ -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)
|
netpulse/ssh/cli.py
ADDED
|
@@ -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()
|
netpulse/ssh/models.py
ADDED
|
@@ -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.")
|
netpulse/ssh/runner.py
ADDED
|
@@ -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,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,9 @@
|
|
|
1
|
+
netpulse/ssh/__init__.py,sha256=gI9uL2EFnQk8XTlyZG3dNTx9c2yMYKIPBsh-Q8j89Ok,47
|
|
2
|
+
netpulse/ssh/api.py,sha256=_Ni30zHAW7PgBmonvRw1kWH-ki7ByLMIZEPDSPsEfW0,2383
|
|
3
|
+
netpulse/ssh/cli.py,sha256=B6-kclQluOPLAPLgcK1_KPI21OWPs_IGv_w_2rS6Wus,2656
|
|
4
|
+
netpulse/ssh/models.py,sha256=BqTinDsrQ1vyD26gsc8FS2eG45FeOOAaoTPI8Umk49A,2980
|
|
5
|
+
netpulse/ssh/runner.py,sha256=g3hfg30mSI2cSJPAZfbCLIva1XXbyPFYGY9t9mAfS3U,8813
|
|
6
|
+
netpulse_ssh-0.1.0.dist-info/METADATA,sha256=ajgaFbzG1jKvxx-GiypdpggFqIUhiNvzpiK2R4J8c7w,4272
|
|
7
|
+
netpulse_ssh-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
netpulse_ssh-0.1.0.dist-info/entry_points.txt,sha256=O1Mr_eNwjWTYiWy2RWPIJlxxhpIHsTDTklL_71b0ji0,54
|
|
9
|
+
netpulse_ssh-0.1.0.dist-info/RECORD,,
|