portslayer 1.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.
portslayer/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """PortSlayer — cross-platform port inspector and process killer."""
2
+
3
+ __version__ = "1.1.0"
4
+ __author__ = "PortSlayer Contributors"
5
+ __license__ = "MIT"
portslayer/cli.py ADDED
@@ -0,0 +1,306 @@
1
+ """
2
+ PortSlayer CLI — powered by Typer + Rich.
3
+
4
+ Commands:
5
+ portslayer Launch the interactive TUI dashboard
6
+ portslayer list List all active ports
7
+ portslayer find <port> Show all bindings matching a port or prefix (e.g. '808' -> 8080/8081/8083)
8
+ portslayer kill <port> Kill the process(es) bound to an exact port
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+ from typing import Optional
16
+
17
+ import typer
18
+ from rich import box
19
+ from rich.console import Console
20
+ from rich.panel import Panel
21
+ from rich.prompt import Confirm
22
+ from rich.table import Table
23
+ from rich.text import Text
24
+
25
+ from .core import PortInfo, PortScanner, ProcessKiller
26
+ from .utils import get_platform, is_admin, validate_port, validate_port_prefix
27
+
28
+ app = typer.Typer(
29
+ name="portslayer",
30
+ help="[bold cyan]PortSlayer[/] — inspect and kill processes by port.",
31
+ rich_markup_mode="rich",
32
+ no_args_is_help=False,
33
+ )
34
+
35
+ console = Console()
36
+ scanner = PortScanner()
37
+ killer = ProcessKiller()
38
+
39
+
40
+ # ────────────────────────────────────────────────────────────────────────────
41
+ # Helpers
42
+ # ────────────────────────────────────────────────────────────────────────────
43
+
44
+
45
+ def _state_style(state: str) -> str:
46
+ s = state.upper()
47
+ if "LISTEN" in s:
48
+ return "bold green"
49
+ if "ESTABLISHED" in s:
50
+ return "cyan"
51
+ if "TIME_WAIT" in s or "CLOSE" in s:
52
+ return "yellow"
53
+ return "white"
54
+
55
+
56
+ def _build_table(entries: list[PortInfo], title: str = "Active Ports") -> Table:
57
+ table = Table(
58
+ title=title,
59
+ box=box.ROUNDED,
60
+ header_style="bold magenta",
61
+ show_lines=False,
62
+ expand=False,
63
+ )
64
+ table.add_column("Port", style="bold yellow", justify="right", no_wrap=True)
65
+ table.add_column("Protocol", justify="center")
66
+ table.add_column("State", no_wrap=True)
67
+ table.add_column("PID", justify="right", style="dim")
68
+ table.add_column("Process", style="bold")
69
+ table.add_column("Local Address")
70
+ table.add_column("Remote Address", style="dim")
71
+
72
+ for e in entries:
73
+ table.add_row(
74
+ str(e.port),
75
+ e.protocol,
76
+ Text(e.state, style=_state_style(e.state)),
77
+ str(e.pid) if e.pid else "—",
78
+ e.process_name,
79
+ e.local_address,
80
+ e.remote_address,
81
+ )
82
+ return table
83
+
84
+
85
+ class _NullStatus:
86
+ def __enter__(self) -> "_NullStatus":
87
+ return self
88
+
89
+ def __exit__(self, *exc_info: object) -> None:
90
+ return None
91
+
92
+
93
+ def _status(message: str, *, quiet: bool = False):
94
+ """Return a Rich status spinner, or a no-op context manager when *quiet*."""
95
+ return _NullStatus() if quiet else console.status(message)
96
+
97
+
98
+ def _print_entries(entries: list[PortInfo], title: str, as_json: bool) -> None:
99
+ if as_json:
100
+ print(json.dumps([e.as_dict() for e in entries], indent=2))
101
+ return
102
+ console.print(_build_table(entries, title=title))
103
+
104
+
105
+ def _abort(msg: str) -> None:
106
+ console.print(f"[bold red]Error:[/] {msg}")
107
+ raise typer.Exit(code=1)
108
+
109
+
110
+ def _check_port_arg(port: int) -> None:
111
+ ok, reason = validate_port(port)
112
+ if not ok:
113
+ _abort(reason)
114
+
115
+
116
+ def _check_port_prefix_arg(port: str) -> None:
117
+ ok, reason = validate_port_prefix(port)
118
+ if not ok:
119
+ _abort(reason)
120
+
121
+
122
+ def _warn_if_no_admin() -> None:
123
+ if not is_admin():
124
+ console.print(
125
+ Panel(
126
+ "[yellow]Warning:[/] Some processes may be hidden without "
127
+ "root / Administrator privileges.",
128
+ expand=False,
129
+ border_style="yellow",
130
+ )
131
+ )
132
+
133
+
134
+ # ────────────────────────────────────────────────────────────────────────────
135
+ # Commands
136
+ # ────────────────────────────────────────────────────────────────────────────
137
+
138
+
139
+ @app.command("list")
140
+ def cmd_list(
141
+ port: Optional[str] = typer.Option(
142
+ None,
143
+ "--port",
144
+ "-p",
145
+ help="Filter by port number or partial prefix, e.g. '808' matches 8080/8081/8083.",
146
+ ),
147
+ process: Optional[str] = typer.Option(None, "--process", "-n", help="Filter by process name."),
148
+ protocol: Optional[str] = typer.Option(
149
+ None, "--protocol", "-P", help="Filter by protocol (tcp|udp)."
150
+ ),
151
+ listening_only: bool = typer.Option(
152
+ False, "--listening", "-l", help="Show only LISTENING ports."
153
+ ),
154
+ as_json: bool = typer.Option(
155
+ False, "--json", help="Output as JSON instead of a table (for scripting)."
156
+ ),
157
+ ) -> None:
158
+ """List all active ports with process details."""
159
+ if not as_json:
160
+ _warn_if_no_admin()
161
+
162
+ if port is not None:
163
+ _check_port_prefix_arg(port)
164
+
165
+ if protocol and protocol.upper() not in ("TCP", "UDP"):
166
+ _abort("Protocol must be 'tcp' or 'udp'.")
167
+
168
+ with _status("[cyan]Scanning ports…[/]", quiet=as_json):
169
+ entries = scanner.list_ports(port_prefix=port, process=process, protocol=protocol)
170
+
171
+ if listening_only:
172
+ entries = [e for e in entries if e.is_listening]
173
+
174
+ if not entries and not as_json:
175
+ console.print("[yellow]No matching ports found.[/]")
176
+ raise typer.Exit()
177
+
178
+ _print_entries(entries, title=f"Active Ports ({len(entries)} entries)", as_json=as_json)
179
+
180
+
181
+ @app.command("find")
182
+ def cmd_find(
183
+ port: str = typer.Argument(
184
+ ...,
185
+ help="Port number or partial prefix to inspect, e.g. '808' matches 8080/8081/8083.",
186
+ ),
187
+ as_json: bool = typer.Option(
188
+ False, "--json", help="Output as JSON instead of a table (for scripting)."
189
+ ),
190
+ ) -> None:
191
+ """Show all processes bound to ports starting with PORT."""
192
+ _check_port_prefix_arg(port)
193
+ if not as_json:
194
+ _warn_if_no_admin()
195
+
196
+ with _status(f"[cyan]Looking up ports matching '{port}'…[/]", quiet=as_json):
197
+ entries = scanner.find_ports_by_prefix(port)
198
+
199
+ if not entries and not as_json:
200
+ console.print(f"[yellow]No process found on any port matching '{port}'.[/]")
201
+ raise typer.Exit()
202
+
203
+ distinct_ports = sorted({e.port for e in entries})
204
+ title = f"Port {port}" if distinct_ports == [int(port)] else f"Ports matching '{port}' ({len(distinct_ports)} ports)"
205
+ _print_entries(entries, title=title, as_json=as_json)
206
+
207
+
208
+ @app.command("kill")
209
+ def cmd_kill(
210
+ port: Optional[int] = typer.Argument(
211
+ None, help="Exact port whose process(es) should be killed."
212
+ ),
213
+ name: Optional[str] = typer.Option(
214
+ None,
215
+ "--name",
216
+ "-n",
217
+ help="Kill by process name instead of port (matches every port that process owns).",
218
+ ),
219
+ force: bool = typer.Option(
220
+ False, "--force", "-f", help="Skip confirmation prompt (use with care)."
221
+ ),
222
+ ) -> None:
223
+ """Kill the process(es) bound to PORT, or all processes matching --name, after confirmation."""
224
+ if (port is None) == (name is None):
225
+ _abort("Provide exactly one of PORT or --name.")
226
+
227
+ _warn_if_no_admin()
228
+
229
+ if port is not None:
230
+ _check_port_arg(port)
231
+ with console.status(f"[cyan]Looking up port {port}…[/]"):
232
+ entries = scanner.find_port(port)
233
+ not_found_msg = f"No process found on port {port}."
234
+ title = f"[red]About to kill — Port {port}[/]"
235
+ else:
236
+ with console.status(f"[cyan]Looking up processes named '{name}'…[/]"):
237
+ entries = scanner.list_ports(process=name)
238
+ not_found_msg = f"No process found matching name '{name}'."
239
+ title = f"[red]About to kill — processes matching '{name}'[/]"
240
+
241
+ if not entries:
242
+ console.print(f"[yellow]{not_found_msg}[/]")
243
+ raise typer.Exit()
244
+
245
+ # Show what will be killed
246
+ console.print(_build_table(entries, title=title))
247
+
248
+ unique_pids = {e.pid for e in entries if e.pid > 0}
249
+ pid_summary = ", ".join(str(p) for p in sorted(unique_pids))
250
+ console.print(
251
+ f"\n[bold]PID(s) to terminate:[/] [red]{pid_summary}[/]\n"
252
+ )
253
+
254
+ if not force:
255
+ confirmed = Confirm.ask(
256
+ "[bold red]Confirm kill?[/] This cannot be undone",
257
+ default=False,
258
+ )
259
+ if not confirmed:
260
+ console.print("[yellow]Aborted.[/]")
261
+ raise typer.Exit()
262
+
263
+ results = killer.kill_by_port(entries)
264
+ for r in results:
265
+ if r.success:
266
+ console.print(f"[bold green]OK[/] {r.message}")
267
+ else:
268
+ console.print(f"[bold red]FAIL[/] {r.message}")
269
+
270
+ if not all(r.success for r in results):
271
+ raise typer.Exit(code=1)
272
+
273
+
274
+ # ────────────────────────────────────────────────────────────────────────────
275
+ # Version command
276
+ # ────────────────────────────────────────────────────────────────────────────
277
+
278
+
279
+ def _version_callback(value: bool) -> None:
280
+ if value:
281
+ from . import __version__
282
+ console.print(f"PortSlayer [bold cyan]{__version__}[/]")
283
+ raise typer.Exit()
284
+
285
+
286
+ @app.callback(invoke_without_command=True)
287
+ def main(
288
+ ctx: typer.Context,
289
+ version: bool = typer.Option(
290
+ False,
291
+ "--version",
292
+ "-v",
293
+ callback=_version_callback,
294
+ is_eager=True,
295
+ help="Show version and exit.",
296
+ ),
297
+ ) -> None:
298
+ """[bold cyan]PortSlayer[/] — cross-platform port inspector and process killer."""
299
+ if ctx.invoked_subcommand is None:
300
+ from .tui import run_tui
301
+
302
+ run_tui()
303
+
304
+
305
+ if __name__ == "__main__":
306
+ app()
@@ -0,0 +1,5 @@
1
+ from .models import PortInfo
2
+ from .port_scanner import PortScanner
3
+ from .process_killer import ProcessKiller
4
+
5
+ __all__ = ["PortInfo", "PortScanner", "ProcessKiller"]
@@ -0,0 +1,52 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Optional
3
+
4
+
5
+ @dataclass
6
+ class PortInfo:
7
+ """Represents a single active port binding."""
8
+
9
+ port: int
10
+ pid: int
11
+ process_name: str
12
+ protocol: str # "TCP" | "UDP"
13
+ state: str # "LISTEN" | "ESTABLISHED" | "TIME_WAIT" | …
14
+ local_address: str
15
+ remote_address: str = "—"
16
+
17
+ # ------------------------------------------------------------------ #
18
+ # Convenience helpers
19
+ # ------------------------------------------------------------------ #
20
+
21
+ @property
22
+ def is_listening(self) -> bool:
23
+ return "LISTEN" in self.state.upper()
24
+
25
+ def matches(
26
+ self,
27
+ port: Optional[int] = None,
28
+ port_prefix: Optional[str] = None,
29
+ process: Optional[str] = None,
30
+ protocol: Optional[str] = None,
31
+ ) -> bool:
32
+ """Return True if this entry satisfies all supplied filter criteria."""
33
+ if port is not None and self.port != port:
34
+ return False
35
+ if port_prefix is not None and not str(self.port).startswith(port_prefix):
36
+ return False
37
+ if process is not None and process.lower() not in self.process_name.lower():
38
+ return False
39
+ if protocol is not None and self.protocol.upper() != protocol.upper():
40
+ return False
41
+ return True
42
+
43
+ def as_dict(self) -> dict:
44
+ return {
45
+ "port": self.port,
46
+ "pid": self.pid,
47
+ "process_name": self.process_name,
48
+ "protocol": self.protocol,
49
+ "state": self.state,
50
+ "local_address": self.local_address,
51
+ "remote_address": self.remote_address,
52
+ }
@@ -0,0 +1,331 @@
1
+ """
2
+ Platform-aware port scanner.
3
+
4
+ Linux – primary: ``ss -tulnp`` / fallback: ``lsof -i``
5
+ Windows – ``netstat -ano`` + ``tasklist``
6
+ macOS – ``lsof -i -n -P``
7
+
8
+ All subprocess calls use explicit argument lists — never shell=True —
9
+ to prevent command-injection.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ from typing import Optional
18
+
19
+ from .models import PortInfo
20
+ from ..utils.platform_utils import Platform, get_platform
21
+
22
+
23
+ # ────────────────────────────────────────────────────────────────────────────
24
+ # Public interface
25
+ # ────────────────────────────────────────────────────────────────────────────
26
+
27
+
28
+ class PortScanner:
29
+ """Collect active port bindings for the current platform."""
30
+
31
+ def __init__(self) -> None:
32
+ self._platform = get_platform()
33
+
34
+ # ------------------------------------------------------------------
35
+ # Main entry-points
36
+ # ------------------------------------------------------------------
37
+
38
+ def list_ports(
39
+ self,
40
+ port: Optional[int] = None,
41
+ port_prefix: Optional[str] = None,
42
+ process: Optional[str] = None,
43
+ protocol: Optional[str] = None,
44
+ ) -> list[PortInfo]:
45
+ """Return all active ports, optionally filtered."""
46
+ raw = self._collect()
47
+ return [
48
+ e
49
+ for e in raw
50
+ if e.matches(port=port, port_prefix=port_prefix, process=process, protocol=protocol)
51
+ ]
52
+
53
+ def find_port(self, port: int) -> list[PortInfo]:
54
+ """Return every binding on the exact port *port*."""
55
+ return self.list_ports(port=port)
56
+
57
+ def find_ports_by_prefix(self, prefix: str) -> list[PortInfo]:
58
+ """Return every binding whose port number starts with *prefix*.
59
+
60
+ E.g. prefix "808" matches 8080, 8081, 8083, ...
61
+ """
62
+ return self.list_ports(port_prefix=prefix)
63
+
64
+ # ------------------------------------------------------------------
65
+ # Platform dispatch
66
+ # ------------------------------------------------------------------
67
+
68
+ def _collect(self) -> list[PortInfo]:
69
+ if self._platform == Platform.LINUX:
70
+ return self._collect_linux()
71
+ if self._platform == Platform.WINDOWS:
72
+ return self._collect_windows()
73
+ if self._platform == Platform.MACOS:
74
+ return self._collect_macos()
75
+ raise RuntimeError(f"Unsupported platform: {self._platform}")
76
+
77
+ # ------------------------------------------------------------------
78
+ # Linux
79
+ # ------------------------------------------------------------------
80
+
81
+ def _collect_linux(self) -> list[PortInfo]:
82
+ try:
83
+ return self._linux_via_ss()
84
+ except Exception:
85
+ pass
86
+ try:
87
+ return self._linux_via_lsof()
88
+ except Exception:
89
+ return []
90
+
91
+ def _linux_via_ss(self) -> list[PortInfo]:
92
+ result = _run(["ss", "-tulnp"])
93
+ return _parse_ss(result.stdout)
94
+
95
+ def _linux_via_lsof(self) -> list[PortInfo]:
96
+ result = _run(["lsof", "-i", "-n", "-P"])
97
+ return _parse_lsof(result.stdout)
98
+
99
+ # ------------------------------------------------------------------
100
+ # Windows
101
+ # ------------------------------------------------------------------
102
+
103
+ def _collect_windows(self) -> list[PortInfo]:
104
+ result = _run(["netstat", "-ano"])
105
+ pid_names = _windows_pid_names()
106
+ return _parse_netstat(result.stdout, pid_names)
107
+
108
+ # ------------------------------------------------------------------
109
+ # macOS
110
+ # ------------------------------------------------------------------
111
+
112
+ def _collect_macos(self) -> list[PortInfo]:
113
+ result = _run(["lsof", "-i", "-n", "-P"])
114
+ return _parse_lsof(result.stdout)
115
+
116
+
117
+ # ────────────────────────────────────────────────────────────────────────────
118
+ # Parsers
119
+ # ────────────────────────────────────────────────────────────────────────────
120
+
121
+
122
+ def _parse_ss(output: str) -> list[PortInfo]:
123
+ """
124
+ Parse ``ss -tulnp`` output.
125
+
126
+ Example line:
127
+ tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=783,fd=3))
128
+ """
129
+ entries: list[PortInfo] = []
130
+ for line in output.splitlines():
131
+ line = line.strip()
132
+ # Skip header or empty lines
133
+ if not line or line.startswith("Netid"):
134
+ continue
135
+
136
+ parts = line.split()
137
+ if len(parts) < 5:
138
+ continue
139
+
140
+ proto = parts[0].upper() # tcp / udp
141
+ state = parts[1].upper() if proto == "TCP" else "—"
142
+ local = parts[4]
143
+
144
+ # Extract port from "address:port"
145
+ port = _extract_port(local)
146
+ if port is None:
147
+ continue
148
+
149
+ # Extract PID and process name from users:(("name",pid=N,fd=N))
150
+ pid, pname = _parse_ss_process(line)
151
+
152
+ entries.append(
153
+ PortInfo(
154
+ port=port,
155
+ pid=pid,
156
+ process_name=pname,
157
+ protocol=proto,
158
+ state=state,
159
+ local_address=local,
160
+ remote_address=parts[5] if len(parts) > 5 else "—",
161
+ )
162
+ )
163
+ return entries
164
+
165
+
166
+ _SS_PROCESS_RE = re.compile(r'users:\(\("([^"]+)",pid=(\d+)')
167
+
168
+
169
+ def _parse_ss_process(line: str) -> tuple[int, str]:
170
+ m = _SS_PROCESS_RE.search(line)
171
+ if m:
172
+ return int(m.group(2)), m.group(1)
173
+ return 0, "—"
174
+
175
+
176
+ def _parse_lsof(output: str) -> list[PortInfo]:
177
+ """
178
+ Parse ``lsof -i -n -P`` output.
179
+
180
+ Example line:
181
+ sshd 783 root 3u IPv4 12345 0t0 TCP *:22 (LISTEN)
182
+ """
183
+ entries: list[PortInfo] = []
184
+ for line in output.splitlines():
185
+ parts = line.split()
186
+ # Expect at minimum: COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME
187
+ if len(parts) < 9:
188
+ continue
189
+ pname = parts[0]
190
+ try:
191
+ pid = int(parts[1])
192
+ except ValueError:
193
+ continue
194
+
195
+ proto = parts[7].upper() # TCP / UDP
196
+ addr_field = parts[8]
197
+
198
+ # State is inside parentheses at the end, e.g. "(LISTEN)"
199
+ state = "—"
200
+ if len(parts) >= 10:
201
+ raw_state = parts[9].strip("()")
202
+ state = raw_state.upper()
203
+
204
+ port = _extract_port(addr_field)
205
+ if port is None:
206
+ continue
207
+
208
+ entries.append(
209
+ PortInfo(
210
+ port=port,
211
+ pid=pid,
212
+ process_name=pname,
213
+ protocol=proto,
214
+ state=state,
215
+ local_address=addr_field,
216
+ )
217
+ )
218
+ return entries
219
+
220
+
221
+ def _parse_netstat(output: str, pid_names: dict[int, str]) -> list[PortInfo]:
222
+ """
223
+ Parse ``netstat -ano`` on Windows.
224
+
225
+ Example line:
226
+ TCP 0.0.0.0:80 0.0.0.0:0 LISTENING 1234
227
+ """
228
+ entries: list[PortInfo] = []
229
+ for line in output.splitlines():
230
+ line = line.strip()
231
+ if not line or line.startswith("Active") or line.startswith("Proto"):
232
+ continue
233
+
234
+ parts = line.split()
235
+ if len(parts) < 4:
236
+ continue
237
+
238
+ proto = parts[0].upper()
239
+ local = parts[1]
240
+ remote = parts[2]
241
+
242
+ # TCP lines: Proto Local Foreign State PID
243
+ # UDP lines: Proto Local Foreign PID
244
+ if proto == "TCP":
245
+ if len(parts) < 5:
246
+ continue
247
+ state = parts[3].upper()
248
+ try:
249
+ pid = int(parts[4])
250
+ except ValueError:
251
+ continue
252
+ else: # UDP
253
+ state = "—"
254
+ try:
255
+ pid = int(parts[3])
256
+ except ValueError:
257
+ continue
258
+
259
+ port = _extract_port(local)
260
+ if port is None:
261
+ continue
262
+
263
+ pname = pid_names.get(pid, "—")
264
+
265
+ entries.append(
266
+ PortInfo(
267
+ port=port,
268
+ pid=pid,
269
+ process_name=pname,
270
+ protocol=proto,
271
+ state=state,
272
+ local_address=local,
273
+ remote_address=remote,
274
+ )
275
+ )
276
+ return entries
277
+
278
+
279
+ # ────────────────────────────────────────────────────────────────────────────
280
+ # Windows helpers
281
+ # ────────────────────────────────────────────────────────────────────────────
282
+
283
+
284
+ def _windows_pid_names() -> dict[int, str]:
285
+ """Return {pid: process_name} mapping from ``tasklist``."""
286
+ try:
287
+ result = _run(["tasklist", "/FO", "CSV", "/NH"])
288
+ names: dict[int, str] = {}
289
+ for line in result.stdout.splitlines():
290
+ line = line.strip().strip('"')
291
+ parts = [p.strip('"') for p in line.split('","')]
292
+ if len(parts) >= 2:
293
+ try:
294
+ names[int(parts[1])] = parts[0]
295
+ except ValueError:
296
+ pass
297
+ return names
298
+ except Exception:
299
+ return {}
300
+
301
+
302
+ # ────────────────────────────────────────────────────────────────────────────
303
+ # Shared utilities
304
+ # ────────────────────────────────────────────────────────────────────────────
305
+
306
+
307
+ def _extract_port(addr: str) -> Optional[int]:
308
+ """
309
+ Extract the port number from strings like:
310
+ 0.0.0.0:8080 [::]:443 *:22 :::8080
311
+ Returns None if no port can be parsed.
312
+ """
313
+ # IPv6 bracket notation: [::1]:8080
314
+ m = re.search(r"\]:(\d+)$", addr)
315
+ if m:
316
+ return int(m.group(1))
317
+ # IPv4 / wildcard: 0.0.0.0:8080 or *:22
318
+ m = re.search(r":(\d+)$", addr)
319
+ if m:
320
+ return int(m.group(1))
321
+ return None
322
+
323
+
324
+ def _run(cmd: list[str]) -> subprocess.CompletedProcess:
325
+ """Run *cmd* safely (no shell) and return the completed process."""
326
+ return subprocess.run(
327
+ cmd,
328
+ capture_output=True,
329
+ text=True,
330
+ timeout=15,
331
+ )