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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ netpulse-ssh = netpulse.ssh.cli:app