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 +5 -0
- portslayer/cli.py +306 -0
- portslayer/core/__init__.py +5 -0
- portslayer/core/models.py +52 -0
- portslayer/core/port_scanner.py +331 -0
- portslayer/core/process_killer.py +150 -0
- portslayer/tui.py +268 -0
- portslayer/utils/__init__.py +4 -0
- portslayer/utils/platform_utils.py +35 -0
- portslayer/utils/validators.py +44 -0
- portslayer-1.1.0.dist-info/METADATA +359 -0
- portslayer-1.1.0.dist-info/RECORD +16 -0
- portslayer-1.1.0.dist-info/WHEEL +5 -0
- portslayer-1.1.0.dist-info/entry_points.txt +3 -0
- portslayer-1.1.0.dist-info/licenses/LICENSE +21 -0
- portslayer-1.1.0.dist-info/top_level.txt +1 -0
portslayer/__init__.py
ADDED
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,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
|
+
)
|