portvac 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- portvac-0.1.0/LICENSE +21 -0
- portvac-0.1.0/PKG-INFO +115 -0
- portvac-0.1.0/README.md +89 -0
- portvac-0.1.0/pyproject.toml +41 -0
- portvac-0.1.0/setup.cfg +4 -0
- portvac-0.1.0/src/portvac/__init__.py +3 -0
- portvac-0.1.0/src/portvac/__main__.py +6 -0
- portvac-0.1.0/src/portvac/cli.py +280 -0
- portvac-0.1.0/src/portvac/core.py +276 -0
- portvac-0.1.0/src/portvac.egg-info/PKG-INFO +115 -0
- portvac-0.1.0/src/portvac.egg-info/SOURCES.txt +13 -0
- portvac-0.1.0/src/portvac.egg-info/dependency_links.txt +1 -0
- portvac-0.1.0/src/portvac.egg-info/entry_points.txt +2 -0
- portvac-0.1.0/src/portvac.egg-info/top_level.txt +1 -0
- portvac-0.1.0/tests/test_core.py +187 -0
portvac-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 portvac contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
portvac-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: portvac
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: See what's listening on a TCP port and free it - cross-platform, zero dependencies.
|
|
5
|
+
Author: yyfjj
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jjdoor/portvac-py
|
|
8
|
+
Project-URL: Repository, https://github.com/jjdoor/portvac-py
|
|
9
|
+
Project-URL: Issues, https://github.com/jjdoor/portvac-py/issues
|
|
10
|
+
Keywords: port,kill-port,process,lsof,cli,devtools,cross-platform,debugging,sysadmin,zero-dependencies
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: POSIX
|
|
17
|
+
Classifier: Operating System :: MacOS
|
|
18
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Topic :: System :: Networking :: Monitoring
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# portvac
|
|
28
|
+
|
|
29
|
+
**See what's listening on a TCP port - and free it.** The "port already in use"
|
|
30
|
+
error costs every developer a minute of `lsof` / `netstat` / `taskkill`
|
|
31
|
+
archaeology. `portvac` lists who's on a port in one command, then vacates it on
|
|
32
|
+
confirm. Cross-platform, **zero dependencies**.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install portvac
|
|
36
|
+
portvac # list every listening TCP port
|
|
37
|
+
portvac 3000 # show what's on 3000, then kill it (asks first)
|
|
38
|
+
portvac 3000 -k # kill without asking (scripts / CI)
|
|
39
|
+
portvac 3000 -s KILL # SIGKILL instead of the default SIGTERM
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Node shop? `npx portvac 3000 -k` works too - same tool, same behaviour.
|
|
43
|
+
|
|
44
|
+
## Why
|
|
45
|
+
|
|
46
|
+
> "Port 3000 is already in use" - and now you're pasting `lsof -i :3000` into
|
|
47
|
+
> Stack Overflow for the fourth time this week.
|
|
48
|
+
|
|
49
|
+
`lsof` flags differ from `netstat` which differs from Windows `taskkill`. You
|
|
50
|
+
remember none of them under pressure. `portvac` is the one command that works
|
|
51
|
+
the same on macOS, Linux, and Windows: **look first, kill second**, never
|
|
52
|
+
silent.
|
|
53
|
+
|
|
54
|
+
## How it works
|
|
55
|
+
|
|
56
|
+
1. **List** - shells out to the platform's own tool (`lsof` / `ss` / `netstat`)
|
|
57
|
+
to read listening sockets. No daemon, no network.
|
|
58
|
+
2. **Show** - prints a clean table: port, pid, process name, address, family.
|
|
59
|
+
3. **Vacate** - on a port argument, asks `y/N`, then sends a signal (default
|
|
60
|
+
`TERM`) to the owning pid(s). `-k` skips the prompt.
|
|
61
|
+
|
|
62
|
+
Nothing is installed system-wide and no data leaves your machine.
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
portvac List every TCP port currently listening
|
|
68
|
+
portvac <port> Show what's on <port>, then kill it (asks first)
|
|
69
|
+
portvac <port> --list Show only, don't kill
|
|
70
|
+
portvac <port> -k Kill without confirmation
|
|
71
|
+
portvac <port> -s KILL Send KILL instead of TERM
|
|
72
|
+
portvac --json [<port>] Machine-readable output (great for piping)
|
|
73
|
+
|
|
74
|
+
-k, --force Kill without confirmation
|
|
75
|
+
-l, --list List only; never kill
|
|
76
|
+
-s, --signal <SIG> TERM (default), KILL, INT, QUIT, HUP
|
|
77
|
+
--json Emit JSON; suppresses colors and prompts
|
|
78
|
+
-v, --version
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### JSON
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
$ portvac --json 3000
|
|
85
|
+
{
|
|
86
|
+
"port": 3000,
|
|
87
|
+
"listeners": [
|
|
88
|
+
{ "port": 3000, "pid": 12345, "name": "node", "address": "0.0.0.0", "protocol": "tcp", "family": "ipv4" }
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Platform notes
|
|
94
|
+
|
|
95
|
+
| Platform | Lookup | Kill |
|
|
96
|
+
|----------|--------|------|
|
|
97
|
+
| macOS / BSD | `lsof -iTCP -sTCP:LISTEN -P -n` | `os.kill(pid, sig)` |
|
|
98
|
+
| Linux | `ss -tlnp` (falls back to `netstat -tlnp`) | `os.kill(pid, sig)` |
|
|
99
|
+
| Windows | `netstat -ano` + `tasklist` | `taskkill /PID <pid> /F /T` |
|
|
100
|
+
|
|
101
|
+
On Windows there's no POSIX signal hierarchy, so `portvac` force-terminates
|
|
102
|
+
(`taskkill /F`). To see another user's processes you may need elevated
|
|
103
|
+
privileges, same as the underlying tools.
|
|
104
|
+
|
|
105
|
+
## Exit codes
|
|
106
|
+
|
|
107
|
+
| Code | Meaning |
|
|
108
|
+
|------|---------|
|
|
109
|
+
| `0` | listed, or killed successfully |
|
|
110
|
+
| `1` | nothing listening / port not in use / aborted |
|
|
111
|
+
| `2` | error (bad args, tool missing, kill failed) |
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
portvac-0.1.0/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# portvac
|
|
2
|
+
|
|
3
|
+
**See what's listening on a TCP port - and free it.** The "port already in use"
|
|
4
|
+
error costs every developer a minute of `lsof` / `netstat` / `taskkill`
|
|
5
|
+
archaeology. `portvac` lists who's on a port in one command, then vacates it on
|
|
6
|
+
confirm. Cross-platform, **zero dependencies**.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install portvac
|
|
10
|
+
portvac # list every listening TCP port
|
|
11
|
+
portvac 3000 # show what's on 3000, then kill it (asks first)
|
|
12
|
+
portvac 3000 -k # kill without asking (scripts / CI)
|
|
13
|
+
portvac 3000 -s KILL # SIGKILL instead of the default SIGTERM
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Node shop? `npx portvac 3000 -k` works too - same tool, same behaviour.
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
> "Port 3000 is already in use" - and now you're pasting `lsof -i :3000` into
|
|
21
|
+
> Stack Overflow for the fourth time this week.
|
|
22
|
+
|
|
23
|
+
`lsof` flags differ from `netstat` which differs from Windows `taskkill`. You
|
|
24
|
+
remember none of them under pressure. `portvac` is the one command that works
|
|
25
|
+
the same on macOS, Linux, and Windows: **look first, kill second**, never
|
|
26
|
+
silent.
|
|
27
|
+
|
|
28
|
+
## How it works
|
|
29
|
+
|
|
30
|
+
1. **List** - shells out to the platform's own tool (`lsof` / `ss` / `netstat`)
|
|
31
|
+
to read listening sockets. No daemon, no network.
|
|
32
|
+
2. **Show** - prints a clean table: port, pid, process name, address, family.
|
|
33
|
+
3. **Vacate** - on a port argument, asks `y/N`, then sends a signal (default
|
|
34
|
+
`TERM`) to the owning pid(s). `-k` skips the prompt.
|
|
35
|
+
|
|
36
|
+
Nothing is installed system-wide and no data leaves your machine.
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
portvac List every TCP port currently listening
|
|
42
|
+
portvac <port> Show what's on <port>, then kill it (asks first)
|
|
43
|
+
portvac <port> --list Show only, don't kill
|
|
44
|
+
portvac <port> -k Kill without confirmation
|
|
45
|
+
portvac <port> -s KILL Send KILL instead of TERM
|
|
46
|
+
portvac --json [<port>] Machine-readable output (great for piping)
|
|
47
|
+
|
|
48
|
+
-k, --force Kill without confirmation
|
|
49
|
+
-l, --list List only; never kill
|
|
50
|
+
-s, --signal <SIG> TERM (default), KILL, INT, QUIT, HUP
|
|
51
|
+
--json Emit JSON; suppresses colors and prompts
|
|
52
|
+
-v, --version
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### JSON
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
$ portvac --json 3000
|
|
59
|
+
{
|
|
60
|
+
"port": 3000,
|
|
61
|
+
"listeners": [
|
|
62
|
+
{ "port": 3000, "pid": 12345, "name": "node", "address": "0.0.0.0", "protocol": "tcp", "family": "ipv4" }
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Platform notes
|
|
68
|
+
|
|
69
|
+
| Platform | Lookup | Kill |
|
|
70
|
+
|----------|--------|------|
|
|
71
|
+
| macOS / BSD | `lsof -iTCP -sTCP:LISTEN -P -n` | `os.kill(pid, sig)` |
|
|
72
|
+
| Linux | `ss -tlnp` (falls back to `netstat -tlnp`) | `os.kill(pid, sig)` |
|
|
73
|
+
| Windows | `netstat -ano` + `tasklist` | `taskkill /PID <pid> /F /T` |
|
|
74
|
+
|
|
75
|
+
On Windows there's no POSIX signal hierarchy, so `portvac` force-terminates
|
|
76
|
+
(`taskkill /F`). To see another user's processes you may need elevated
|
|
77
|
+
privileges, same as the underlying tools.
|
|
78
|
+
|
|
79
|
+
## Exit codes
|
|
80
|
+
|
|
81
|
+
| Code | Meaning |
|
|
82
|
+
|------|---------|
|
|
83
|
+
| `0` | listed, or killed successfully |
|
|
84
|
+
| `1` | nothing listening / port not in use / aborted |
|
|
85
|
+
| `2` | error (bad args, tool missing, kill failed) |
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "portvac"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "See what's listening on a TCP port and free it - cross-platform, zero dependencies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "yyfjj" }]
|
|
13
|
+
keywords = ["port", "kill-port", "process", "lsof", "cli", "devtools", "cross-platform", "debugging", "sysadmin", "zero-dependencies"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Intended Audience :: System Administrators",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: POSIX",
|
|
21
|
+
"Operating System :: MacOS",
|
|
22
|
+
"Operating System :: Microsoft :: Windows",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Topic :: System :: Networking :: Monitoring",
|
|
25
|
+
"Topic :: Utilities",
|
|
26
|
+
]
|
|
27
|
+
dependencies = []
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/jjdoor/portvac-py"
|
|
31
|
+
Repository = "https://github.com/jjdoor/portvac-py"
|
|
32
|
+
Issues = "https://github.com/jjdoor/portvac-py/issues"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
portvac = "portvac.cli:main"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools]
|
|
38
|
+
package-dir = { "" = "src" }
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["src"]
|
portvac-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""portvac command-line interface. Mirrors bin/cli.js behaviour-for-behaviour."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from . import __version__ as VERSION
|
|
12
|
+
from . import core
|
|
13
|
+
|
|
14
|
+
# ---- tiny color helpers (no dep) ----
|
|
15
|
+
_COLOR = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _c(code, s):
|
|
19
|
+
return f"\x1b[{code}m{s}\x1b[0m" if _COLOR else s
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def red(s): return _c("31", s)
|
|
23
|
+
def green(s): return _c("32", s)
|
|
24
|
+
def yellow(s): return _c("33", s)
|
|
25
|
+
def dim(s): return _c("2", s)
|
|
26
|
+
def bold(s): return _c("1", s)
|
|
27
|
+
def cyan(s): return _c("36", s)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
HELP = f"""{bold('portvac')} - see what's listening on a TCP port and free it. Cross-platform, zero deps.
|
|
31
|
+
|
|
32
|
+
{bold('Usage')}
|
|
33
|
+
portvac List every TCP port currently listening
|
|
34
|
+
portvac <port> Show what's on <port>, then kill it (asks first)
|
|
35
|
+
portvac <port> --list Show what's on <port> only, don't kill
|
|
36
|
+
portvac <port> -k Kill without asking (great for scripts)
|
|
37
|
+
portvac <port> -s KILL Use KILL instead of the default TERM
|
|
38
|
+
portvac --json [<port>] Machine-readable output
|
|
39
|
+
|
|
40
|
+
{bold('Flags')}
|
|
41
|
+
-k, --force Kill without confirmation
|
|
42
|
+
-l, --list List only; never kill
|
|
43
|
+
-s, --signal <SIG> Signal to send (TERM, KILL, INT, QUIT, HUP). Default TERM
|
|
44
|
+
--json Emit JSON; suppresses colors and prompts
|
|
45
|
+
-h, --help Show this help
|
|
46
|
+
-v, --version Show version
|
|
47
|
+
|
|
48
|
+
{bold('Exit codes')} 0 ok · 1 nothing listening / aborted · 2 error
|
|
49
|
+
|
|
50
|
+
On macOS/BSD it shells out to {cyan('lsof')}; on Linux to {cyan('ss')} (falling back to
|
|
51
|
+
{cyan('netstat')}); on Windows to {cyan('netstat')} + {cyan('taskkill')}. Nothing is installed
|
|
52
|
+
and no data leaves your machine.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fail(msg):
|
|
57
|
+
sys.stderr.write(red(f"portvac: {msg}\n"))
|
|
58
|
+
sys.exit(2)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def flag(args, name):
|
|
62
|
+
"""Value after --name, or None."""
|
|
63
|
+
if name in args:
|
|
64
|
+
i = args.index(name)
|
|
65
|
+
if i + 1 < len(args):
|
|
66
|
+
return args[i + 1]
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def has(args, name):
|
|
71
|
+
return name in args
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---- platform detection & listener collection -----------------------------
|
|
75
|
+
|
|
76
|
+
def detect_source() -> str:
|
|
77
|
+
p = sys.platform
|
|
78
|
+
if p in ("darwin", "freebsd", "openbsd"):
|
|
79
|
+
return "lsof"
|
|
80
|
+
if p == "win32":
|
|
81
|
+
return "netstat-windows"
|
|
82
|
+
# linux: prefer ss, fall back to netstat
|
|
83
|
+
if shutil.which("ss"):
|
|
84
|
+
return "ss"
|
|
85
|
+
return "netstat-linux"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _raw_listeners(source):
|
|
89
|
+
if source == "lsof":
|
|
90
|
+
return subprocess.run(["lsof", "-iTCP", "-sTCP:LISTEN", "-P", "-n"],
|
|
91
|
+
capture_output=True, text=True)
|
|
92
|
+
if source == "ss":
|
|
93
|
+
return subprocess.run(["ss", "-tlnp"], capture_output=True, text=True)
|
|
94
|
+
if source == "netstat-linux":
|
|
95
|
+
return subprocess.run(["netstat", "-tlnp"], capture_output=True, text=True)
|
|
96
|
+
if source == "netstat-windows":
|
|
97
|
+
return subprocess.run(["netstat", "-ano"], capture_output=True, text=True)
|
|
98
|
+
raise ValueError(f"unknown source: {source}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _pid_name_map_windows():
|
|
102
|
+
res = subprocess.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True, text=True)
|
|
103
|
+
m = {}
|
|
104
|
+
if res.returncode != 0 or not res.stdout:
|
|
105
|
+
return m
|
|
106
|
+
for line in res.stdout.split("\n"):
|
|
107
|
+
match = re.match(r'^"([^"]+)","(\d+)"', line)
|
|
108
|
+
if match:
|
|
109
|
+
m[int(match.group(2))] = match.group(1)
|
|
110
|
+
return m
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_listeners():
|
|
114
|
+
"""Collect + parse + dedup listeners. Returns (listeners, warning)."""
|
|
115
|
+
source = detect_source()
|
|
116
|
+
try:
|
|
117
|
+
res = _raw_listeners(source)
|
|
118
|
+
except FileNotFoundError as e:
|
|
119
|
+
return [], f"could not run {source}: {e}. Is the tool installed?"
|
|
120
|
+
raw = res.stdout or ""
|
|
121
|
+
listeners = core.parse_listeners(raw, source)
|
|
122
|
+
if source == "netstat-windows":
|
|
123
|
+
names = _pid_name_map_windows()
|
|
124
|
+
for l in listeners:
|
|
125
|
+
l["name"] = names.get(l["pid"])
|
|
126
|
+
listeners = core.dedup_listeners(listeners)
|
|
127
|
+
return listeners, None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---- killing ---------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
_SIG_CONST = {
|
|
133
|
+
"HUP": signal.SIGHUP,
|
|
134
|
+
"INT": signal.SIGINT,
|
|
135
|
+
"QUIT": signal.SIGQUIT,
|
|
136
|
+
"KILL": signal.SIGKILL,
|
|
137
|
+
"USR1": signal.SIGUSR1,
|
|
138
|
+
"USR2": signal.SIGUSR2,
|
|
139
|
+
"TERM": signal.SIGTERM,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def kill_pids(pids, signal_name):
|
|
144
|
+
results = []
|
|
145
|
+
if sys.platform == "win32":
|
|
146
|
+
# Windows has no POSIX signals; /F forces termination.
|
|
147
|
+
for pid in pids:
|
|
148
|
+
r = subprocess.run(["taskkill", "/PID", str(pid), "/F", "/T"],
|
|
149
|
+
capture_output=True, text=True)
|
|
150
|
+
results.append({"pid": pid, "ok": r.returncode == 0,
|
|
151
|
+
"error": r.stderr.strip() or None})
|
|
152
|
+
return results
|
|
153
|
+
sig = _SIG_CONST[signal_name]
|
|
154
|
+
for pid in pids:
|
|
155
|
+
try:
|
|
156
|
+
os.kill(pid, sig)
|
|
157
|
+
results.append({"pid": pid, "ok": True, "error": None})
|
|
158
|
+
except ProcessLookupError:
|
|
159
|
+
results.append({"pid": pid, "ok": False, "error": "no such process"})
|
|
160
|
+
except PermissionError:
|
|
161
|
+
results.append({"pid": pid, "ok": False, "error": "permission denied"})
|
|
162
|
+
except OSError as e:
|
|
163
|
+
results.append({"pid": pid, "ok": False, "error": str(e)})
|
|
164
|
+
return results
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---- confirmation prompt ---------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def confirm(question):
|
|
170
|
+
if not sys.stdin.isatty():
|
|
171
|
+
return False
|
|
172
|
+
try:
|
|
173
|
+
ans = input(question).strip().lower()
|
|
174
|
+
except EOFError:
|
|
175
|
+
return False
|
|
176
|
+
return ans in ("y", "yes")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---- commands --------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def cmd_list(args):
|
|
182
|
+
listeners, warning = get_listeners()
|
|
183
|
+
if warning:
|
|
184
|
+
sys.stderr.write(yellow(f"{warning}\n"))
|
|
185
|
+
if has(args, "--json"):
|
|
186
|
+
sys.stdout.write(json.dumps({"listeners": listeners}, indent=2) + "\n")
|
|
187
|
+
return 0
|
|
188
|
+
if not listeners:
|
|
189
|
+
sys.stdout.write(dim("no TCP ports are listening.\n"))
|
|
190
|
+
return 0
|
|
191
|
+
sys.stdout.write(core.format_table(listeners) + "\n")
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def cmd_port(port_arg, args):
|
|
196
|
+
try:
|
|
197
|
+
port = core.normalize_port(port_arg)
|
|
198
|
+
except ValueError as e:
|
|
199
|
+
fail(str(e))
|
|
200
|
+
|
|
201
|
+
listeners, warning = get_listeners()
|
|
202
|
+
if warning:
|
|
203
|
+
sys.stderr.write(yellow(f"{warning}\n"))
|
|
204
|
+
|
|
205
|
+
matches = core.select_by_port(listeners, port)
|
|
206
|
+
|
|
207
|
+
if has(args, "--json"):
|
|
208
|
+
sys.stdout.write(json.dumps({"port": port, "listeners": matches}, indent=2) + "\n")
|
|
209
|
+
return 0 if matches else 1
|
|
210
|
+
|
|
211
|
+
if not matches:
|
|
212
|
+
sys.stdout.write(dim(f"nothing listening on port {port}.\n"))
|
|
213
|
+
return 1
|
|
214
|
+
|
|
215
|
+
sys.stdout.write(f"listening on port {bold(str(port))}:\n")
|
|
216
|
+
sys.stdout.write(core.format_table(matches) + "\n")
|
|
217
|
+
|
|
218
|
+
if has(args, "--list") or has(args, "-l"):
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
pids = core.unique_pids(matches)
|
|
222
|
+
if not pids:
|
|
223
|
+
sys.stderr.write(yellow(f"found listeners on {port} but no pid to kill (need root?)\n"))
|
|
224
|
+
return 2
|
|
225
|
+
|
|
226
|
+
signal_name = core.parse_signal(flag(args, "--signal") or flag(args, "-s"))
|
|
227
|
+
force = has(args, "-k") or has(args, "--force")
|
|
228
|
+
|
|
229
|
+
if not force:
|
|
230
|
+
parts = []
|
|
231
|
+
for p in pids:
|
|
232
|
+
m = next((x for x in matches if x["pid"] == p), None)
|
|
233
|
+
name = m["name"] if m and m["name"] else "?"
|
|
234
|
+
parts.append(f"{cyan(name)} (pid {p})")
|
|
235
|
+
who = ", ".join(parts)
|
|
236
|
+
if not confirm(f"Kill {who} on port {port} with {signal_name}? [y/N] "):
|
|
237
|
+
sys.stdout.write(dim("aborted. Use -k to skip the prompt.\n"))
|
|
238
|
+
return 1
|
|
239
|
+
|
|
240
|
+
results = kill_pids(pids, signal_name)
|
|
241
|
+
failed = [r for r in results if not r["ok"]]
|
|
242
|
+
for r in results:
|
|
243
|
+
mark = green("✓") if r["ok"] else red("✗")
|
|
244
|
+
tail = "" if r["ok"] else f" - {r['error']}"
|
|
245
|
+
sys.stdout.write(f"{mark} pid {r['pid']} {'killed' if r['ok'] else 'failed'}{tail}\n")
|
|
246
|
+
if failed:
|
|
247
|
+
sys.stderr.write(yellow(
|
|
248
|
+
f"{len(failed)} process(es) could not be killed (try -s KILL or run as root).\n"))
|
|
249
|
+
return 2
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ---- main ------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
def main(argv=None):
|
|
256
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
257
|
+
if argv and argv[0] in ("-h", "--help"):
|
|
258
|
+
sys.stdout.write(HELP)
|
|
259
|
+
return 0
|
|
260
|
+
if argv and argv[0] in ("-v", "--version"):
|
|
261
|
+
sys.stdout.write(VERSION + "\n")
|
|
262
|
+
return 0
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
# No args, or a flag-first form like `portvac --json` -> list everything.
|
|
266
|
+
if not argv or argv[0].startswith("-"):
|
|
267
|
+
return cmd_list(argv)
|
|
268
|
+
first = argv[0]
|
|
269
|
+
if first == "list":
|
|
270
|
+
return cmd_list(argv[1:])
|
|
271
|
+
if re.fullmatch(r"\d+", first):
|
|
272
|
+
return cmd_port(first, argv[1:])
|
|
273
|
+
fail(f"unknown argument: {first} (try --help)")
|
|
274
|
+
except ValueError as e:
|
|
275
|
+
fail(str(e))
|
|
276
|
+
return 0
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
if __name__ == "__main__":
|
|
280
|
+
sys.exit(main())
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""portvac core - pure listener parsing & selection. No fs, no network, no OS
|
|
2
|
+
calls, no clock. Everything that touches the OS lives in cli.py.
|
|
3
|
+
|
|
4
|
+
A "listener" is a normalized dict:
|
|
5
|
+
{"port": int, "pid": int|None, "name": str|None,
|
|
6
|
+
"address": str, "protocol": "tcp", "family": "ipv4"|"ipv6"}
|
|
7
|
+
|
|
8
|
+
The parsers turn the raw stdout of platform tools (lsof / ss / netstat) into
|
|
9
|
+
this shape so the rest of the tool - and the tests - never care which OS
|
|
10
|
+
produced it. Mirrors src/core.js byte-for-behaviour.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
SIGNALS = {"HUP": 1, "INT": 2, "QUIT": 3, "KILL": 9, "USR1": 10, "USR2": 12, "TERM": 15}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize_port(value) -> int:
|
|
19
|
+
"""Coerce a port argument into an int 0-65535. Raises ValueError on garbage."""
|
|
20
|
+
if isinstance(value, bool): # guard: bool is an int subclass
|
|
21
|
+
raise ValueError(f"invalid port: {value!r}")
|
|
22
|
+
if isinstance(value, int):
|
|
23
|
+
if value < 0 or value > 65535:
|
|
24
|
+
raise ValueError(f"invalid port: {value} (use 0-65535)")
|
|
25
|
+
return value
|
|
26
|
+
s = str(value).strip()
|
|
27
|
+
if not re.fullmatch(r"\d+", s):
|
|
28
|
+
raise ValueError(f'invalid port: "{value}" (use 0-65535)')
|
|
29
|
+
n = int(s)
|
|
30
|
+
if n < 0 or n > 65535:
|
|
31
|
+
raise ValueError(f'invalid port: "{value}" (use 0-65535)')
|
|
32
|
+
return n
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_signal(value) -> str:
|
|
36
|
+
"""Normalize a signal to a canonical uppercase name (no SIG prefix).
|
|
37
|
+
|
|
38
|
+
Accepts "TERM", "SIGTERM", "15". Default (None/"") is "TERM".
|
|
39
|
+
"""
|
|
40
|
+
if value is None or value == "":
|
|
41
|
+
return "TERM"
|
|
42
|
+
s = str(value).strip().upper()
|
|
43
|
+
if s.startswith("SIG"):
|
|
44
|
+
s = s[3:]
|
|
45
|
+
if re.fullmatch(r"\d+", s):
|
|
46
|
+
for name, num in SIGNALS.items():
|
|
47
|
+
if num == int(s):
|
|
48
|
+
return name
|
|
49
|
+
raise ValueError(f"unsupported signal: {value!r}")
|
|
50
|
+
if s in SIGNALS:
|
|
51
|
+
return s
|
|
52
|
+
raise ValueError(f'unsupported signal: "{value}" (try TERM, KILL, INT, QUIT, HUP)')
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ----- address helpers ------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
_ADDR_PORT_RE = re.compile(r"^(.*):(\d+)$")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _split_address_port(addr_port):
|
|
61
|
+
m = _ADDR_PORT_RE.match(addr_port)
|
|
62
|
+
if not m:
|
|
63
|
+
return None
|
|
64
|
+
return m.group(1), int(m.group(2))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _family_of(address):
|
|
68
|
+
# anything bracketed or containing multiple colons is IPv6; a bare "*" is
|
|
69
|
+
# reported as ipv4 (lsof emits separate IPv4/IPv6 rows for it anyway)
|
|
70
|
+
if address.startswith("[") or (":" in address and not re.fullmatch(r"\d+\.\d+\.\d+\.\d+", address)):
|
|
71
|
+
return "ipv6"
|
|
72
|
+
return "ipv4"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _normalize_wildcard(address, family):
|
|
76
|
+
if address == "*":
|
|
77
|
+
return "[::]" if family == "ipv6" else "0.0.0.0"
|
|
78
|
+
return address
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ----- parsers --------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
_LSOF_RE = re.compile(r"^(\S+)\s+(\d+)\s+\S+\s+\S+\s+(IPv4|IPv6)\s+\S+\s+\S+\s+\S+\s+(.+)$")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_lsof(raw) -> list:
|
|
87
|
+
"""Parse `lsof -iTCP -sTCP:LISTEN -P -n` output (macOS / BSD / Linux)."""
|
|
88
|
+
out = []
|
|
89
|
+
lines = str(raw).split("\n")
|
|
90
|
+
for line in lines[1:]: # skip header
|
|
91
|
+
t = line.strip()
|
|
92
|
+
if not t:
|
|
93
|
+
continue
|
|
94
|
+
m = _LSOF_RE.match(t)
|
|
95
|
+
if not m:
|
|
96
|
+
continue
|
|
97
|
+
family = m.group(3).lower()
|
|
98
|
+
addr_part = re.sub(r"\s*\(LISTEN\)\s*$", "", m.group(4)).strip()
|
|
99
|
+
sp = _split_address_port(addr_part)
|
|
100
|
+
if not sp:
|
|
101
|
+
continue
|
|
102
|
+
address, port = sp
|
|
103
|
+
out.append({
|
|
104
|
+
"port": port,
|
|
105
|
+
"pid": int(m.group(2)),
|
|
106
|
+
"name": m.group(1),
|
|
107
|
+
"address": _normalize_wildcard(address, family),
|
|
108
|
+
"protocol": "tcp",
|
|
109
|
+
"family": family,
|
|
110
|
+
})
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_SS_RE = re.compile(r"^LISTEN\s+\S+\s+\S+\s+(\S+)\s+\S+\s*(.*)$")
|
|
115
|
+
_SS_PROC_RE = re.compile(r'users:\(\("([^"]+)",pid=(\d+)')
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def parse_ss(raw) -> list:
|
|
119
|
+
"""Parse `ss -tlnp` output (Linux). Process column is optional."""
|
|
120
|
+
out = []
|
|
121
|
+
for line in str(raw).split("\n")[1:]: # skip header
|
|
122
|
+
t = line.strip()
|
|
123
|
+
if not t:
|
|
124
|
+
continue
|
|
125
|
+
m = _SS_RE.match(t)
|
|
126
|
+
if not m:
|
|
127
|
+
continue
|
|
128
|
+
sp = _split_address_port(m.group(1))
|
|
129
|
+
if not sp:
|
|
130
|
+
continue
|
|
131
|
+
address, port = sp
|
|
132
|
+
family = _family_of(address)
|
|
133
|
+
pid, name = None, None
|
|
134
|
+
pm = _SS_PROC_RE.search(m.group(2))
|
|
135
|
+
if pm:
|
|
136
|
+
name, pid = pm.group(1), int(pm.group(2))
|
|
137
|
+
out.append({
|
|
138
|
+
"port": port,
|
|
139
|
+
"pid": pid,
|
|
140
|
+
"name": name,
|
|
141
|
+
"address": _normalize_wildcard(address, family),
|
|
142
|
+
"protocol": "tcp",
|
|
143
|
+
"family": family,
|
|
144
|
+
})
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
_NETSTAT_LINUX_RE = re.compile(r"^tcp\S*\s+\S+\s+\S+\s+(\S+)\s+\S+\s+LISTEN\s+(\S+)$")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_netstat_linux(raw) -> list:
|
|
152
|
+
"""Parse `netstat -tlnp` output (Linux)."""
|
|
153
|
+
out = []
|
|
154
|
+
for line in str(raw).split("\n"): # match by pattern; header rows won't match
|
|
155
|
+
t = line.strip()
|
|
156
|
+
if not t:
|
|
157
|
+
continue
|
|
158
|
+
m = _NETSTAT_LINUX_RE.match(t)
|
|
159
|
+
if not m:
|
|
160
|
+
continue
|
|
161
|
+
sp = _split_address_port(m.group(1))
|
|
162
|
+
if not sp:
|
|
163
|
+
continue
|
|
164
|
+
address, port = sp
|
|
165
|
+
family = _family_of(address)
|
|
166
|
+
pid, name = None, None
|
|
167
|
+
pm = re.match(r"^(\d+)/(.+)$", m.group(2))
|
|
168
|
+
if pm:
|
|
169
|
+
pid, name = int(pm.group(1)), pm.group(2)
|
|
170
|
+
out.append({
|
|
171
|
+
"port": port,
|
|
172
|
+
"pid": pid,
|
|
173
|
+
"name": name,
|
|
174
|
+
"address": _normalize_wildcard(address, family),
|
|
175
|
+
"protocol": "tcp",
|
|
176
|
+
"family": family,
|
|
177
|
+
})
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
_NETSTAT_WIN_RE = re.compile(r"^TCP\s+(\S+)\s+\S+\s+LISTENING\s+(\d+)$", re.IGNORECASE)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def parse_netstat_windows(raw) -> list:
|
|
185
|
+
"""Parse `netstat -ano` output (Windows). Process names stay None here."""
|
|
186
|
+
out = []
|
|
187
|
+
for line in str(raw).split("\n"):
|
|
188
|
+
t = line.strip()
|
|
189
|
+
if not t:
|
|
190
|
+
continue
|
|
191
|
+
m = _NETSTAT_WIN_RE.match(t)
|
|
192
|
+
if not m:
|
|
193
|
+
continue
|
|
194
|
+
sp = _split_address_port(m.group(1))
|
|
195
|
+
if not sp:
|
|
196
|
+
continue
|
|
197
|
+
address, port = sp
|
|
198
|
+
family = _family_of(address)
|
|
199
|
+
out.append({
|
|
200
|
+
"port": port,
|
|
201
|
+
"pid": int(m.group(2)),
|
|
202
|
+
"name": None,
|
|
203
|
+
"address": _normalize_wildcard(address, family),
|
|
204
|
+
"protocol": "tcp",
|
|
205
|
+
"family": family,
|
|
206
|
+
})
|
|
207
|
+
return out
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def parse_listeners(raw, source) -> list:
|
|
211
|
+
"""Dispatch parser by source descriptor."""
|
|
212
|
+
if source == "lsof":
|
|
213
|
+
return parse_lsof(raw)
|
|
214
|
+
if source == "ss":
|
|
215
|
+
return parse_ss(raw)
|
|
216
|
+
if source == "netstat-linux":
|
|
217
|
+
return parse_netstat_linux(raw)
|
|
218
|
+
if source == "netstat-windows":
|
|
219
|
+
return parse_netstat_windows(raw)
|
|
220
|
+
raise ValueError(f"unknown listener source: {source}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def dedup_listeners(listeners) -> list:
|
|
224
|
+
"""Drop duplicate (port, pid, family) rows - lsof emits one per fd."""
|
|
225
|
+
seen = set()
|
|
226
|
+
out = []
|
|
227
|
+
for l in listeners:
|
|
228
|
+
key = (l["port"], l["pid"], l["family"])
|
|
229
|
+
if key in seen:
|
|
230
|
+
continue
|
|
231
|
+
seen.add(key)
|
|
232
|
+
out.append(l)
|
|
233
|
+
return out
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def select_by_port(listeners, port) -> list:
|
|
237
|
+
"""Listeners matching a port, sorted by pid (None last)."""
|
|
238
|
+
p = normalize_port(port)
|
|
239
|
+
return sorted(
|
|
240
|
+
[l for l in listeners if l["port"] == p],
|
|
241
|
+
key=lambda l: (l["pid"] is None, l["pid"] if l["pid"] is not None else 0),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def unique_pids(listeners) -> list:
|
|
246
|
+
"""Collect unique pids from a set of listeners (for killing)."""
|
|
247
|
+
seen = set()
|
|
248
|
+
out = []
|
|
249
|
+
for l in listeners:
|
|
250
|
+
pid = l["pid"]
|
|
251
|
+
if pid is not None and pid not in seen:
|
|
252
|
+
seen.add(pid)
|
|
253
|
+
out.append(pid)
|
|
254
|
+
return sorted(out)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def format_table(listeners) -> str:
|
|
258
|
+
"""Format listeners as a fixed-width human table. Returns '' for empty."""
|
|
259
|
+
if not listeners:
|
|
260
|
+
return ""
|
|
261
|
+
cols = ["port", "pid", "name", "address", "family"]
|
|
262
|
+
rows = [{
|
|
263
|
+
"port": str(l["port"]),
|
|
264
|
+
"pid": str(l["pid"]) if l["pid"] is not None else "-",
|
|
265
|
+
"name": l["name"] or "-",
|
|
266
|
+
"address": l["address"] or "*",
|
|
267
|
+
"family": (l["family"] or "").upper(),
|
|
268
|
+
} for l in listeners]
|
|
269
|
+
w = {c: len(c) for c in cols}
|
|
270
|
+
for r in rows:
|
|
271
|
+
for c in cols:
|
|
272
|
+
w[c] = max(w[c], len(r[c]))
|
|
273
|
+
head = " ".join(c.upper().ljust(w[c]) for c in cols)
|
|
274
|
+
sep = " ".join("-" * w[c] for c in cols)
|
|
275
|
+
body = "\n".join(" ".join(r[c].ljust(w[c]) for c in cols) for r in rows)
|
|
276
|
+
return f"{head}\n{sep}\n{body}"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: portvac
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: See what's listening on a TCP port and free it - cross-platform, zero dependencies.
|
|
5
|
+
Author: yyfjj
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jjdoor/portvac-py
|
|
8
|
+
Project-URL: Repository, https://github.com/jjdoor/portvac-py
|
|
9
|
+
Project-URL: Issues, https://github.com/jjdoor/portvac-py/issues
|
|
10
|
+
Keywords: port,kill-port,process,lsof,cli,devtools,cross-platform,debugging,sysadmin,zero-dependencies
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: POSIX
|
|
17
|
+
Classifier: Operating System :: MacOS
|
|
18
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Topic :: System :: Networking :: Monitoring
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# portvac
|
|
28
|
+
|
|
29
|
+
**See what's listening on a TCP port - and free it.** The "port already in use"
|
|
30
|
+
error costs every developer a minute of `lsof` / `netstat` / `taskkill`
|
|
31
|
+
archaeology. `portvac` lists who's on a port in one command, then vacates it on
|
|
32
|
+
confirm. Cross-platform, **zero dependencies**.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install portvac
|
|
36
|
+
portvac # list every listening TCP port
|
|
37
|
+
portvac 3000 # show what's on 3000, then kill it (asks first)
|
|
38
|
+
portvac 3000 -k # kill without asking (scripts / CI)
|
|
39
|
+
portvac 3000 -s KILL # SIGKILL instead of the default SIGTERM
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Node shop? `npx portvac 3000 -k` works too - same tool, same behaviour.
|
|
43
|
+
|
|
44
|
+
## Why
|
|
45
|
+
|
|
46
|
+
> "Port 3000 is already in use" - and now you're pasting `lsof -i :3000` into
|
|
47
|
+
> Stack Overflow for the fourth time this week.
|
|
48
|
+
|
|
49
|
+
`lsof` flags differ from `netstat` which differs from Windows `taskkill`. You
|
|
50
|
+
remember none of them under pressure. `portvac` is the one command that works
|
|
51
|
+
the same on macOS, Linux, and Windows: **look first, kill second**, never
|
|
52
|
+
silent.
|
|
53
|
+
|
|
54
|
+
## How it works
|
|
55
|
+
|
|
56
|
+
1. **List** - shells out to the platform's own tool (`lsof` / `ss` / `netstat`)
|
|
57
|
+
to read listening sockets. No daemon, no network.
|
|
58
|
+
2. **Show** - prints a clean table: port, pid, process name, address, family.
|
|
59
|
+
3. **Vacate** - on a port argument, asks `y/N`, then sends a signal (default
|
|
60
|
+
`TERM`) to the owning pid(s). `-k` skips the prompt.
|
|
61
|
+
|
|
62
|
+
Nothing is installed system-wide and no data leaves your machine.
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
portvac List every TCP port currently listening
|
|
68
|
+
portvac <port> Show what's on <port>, then kill it (asks first)
|
|
69
|
+
portvac <port> --list Show only, don't kill
|
|
70
|
+
portvac <port> -k Kill without confirmation
|
|
71
|
+
portvac <port> -s KILL Send KILL instead of TERM
|
|
72
|
+
portvac --json [<port>] Machine-readable output (great for piping)
|
|
73
|
+
|
|
74
|
+
-k, --force Kill without confirmation
|
|
75
|
+
-l, --list List only; never kill
|
|
76
|
+
-s, --signal <SIG> TERM (default), KILL, INT, QUIT, HUP
|
|
77
|
+
--json Emit JSON; suppresses colors and prompts
|
|
78
|
+
-v, --version
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### JSON
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
$ portvac --json 3000
|
|
85
|
+
{
|
|
86
|
+
"port": 3000,
|
|
87
|
+
"listeners": [
|
|
88
|
+
{ "port": 3000, "pid": 12345, "name": "node", "address": "0.0.0.0", "protocol": "tcp", "family": "ipv4" }
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Platform notes
|
|
94
|
+
|
|
95
|
+
| Platform | Lookup | Kill |
|
|
96
|
+
|----------|--------|------|
|
|
97
|
+
| macOS / BSD | `lsof -iTCP -sTCP:LISTEN -P -n` | `os.kill(pid, sig)` |
|
|
98
|
+
| Linux | `ss -tlnp` (falls back to `netstat -tlnp`) | `os.kill(pid, sig)` |
|
|
99
|
+
| Windows | `netstat -ano` + `tasklist` | `taskkill /PID <pid> /F /T` |
|
|
100
|
+
|
|
101
|
+
On Windows there's no POSIX signal hierarchy, so `portvac` force-terminates
|
|
102
|
+
(`taskkill /F`). To see another user's processes you may need elevated
|
|
103
|
+
privileges, same as the underlying tools.
|
|
104
|
+
|
|
105
|
+
## Exit codes
|
|
106
|
+
|
|
107
|
+
| Code | Meaning |
|
|
108
|
+
|------|---------|
|
|
109
|
+
| `0` | listed, or killed successfully |
|
|
110
|
+
| `1` | nothing listening / port not in use / aborted |
|
|
111
|
+
| `2` | error (bad args, tool missing, kill failed) |
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/portvac/__init__.py
|
|
5
|
+
src/portvac/__main__.py
|
|
6
|
+
src/portvac/cli.py
|
|
7
|
+
src/portvac/core.py
|
|
8
|
+
src/portvac.egg-info/PKG-INFO
|
|
9
|
+
src/portvac.egg-info/SOURCES.txt
|
|
10
|
+
src/portvac.egg-info/dependency_links.txt
|
|
11
|
+
src/portvac.egg-info/entry_points.txt
|
|
12
|
+
src/portvac.egg-info/top_level.txt
|
|
13
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
portvac
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from portvac.core import (
|
|
4
|
+
normalize_port, parse_signal,
|
|
5
|
+
parse_lsof, parse_ss, parse_netstat_linux, parse_netstat_windows, parse_listeners,
|
|
6
|
+
dedup_listeners, select_by_port, unique_pids, format_table,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
# ----- normalize_port -------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
def test_normalize_port_accepts_ints_and_numeric_strings():
|
|
12
|
+
assert normalize_port(3000) == 3000
|
|
13
|
+
assert normalize_port("8080") == 8080
|
|
14
|
+
assert normalize_port("0") == 0
|
|
15
|
+
assert normalize_port("65535") == 65535
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_normalize_port_rejects_garbage_and_out_of_range():
|
|
19
|
+
with pytest.raises(ValueError):
|
|
20
|
+
normalize_port("abc")
|
|
21
|
+
with pytest.raises(ValueError):
|
|
22
|
+
normalize_port("-1")
|
|
23
|
+
with pytest.raises(ValueError):
|
|
24
|
+
normalize_port("65536")
|
|
25
|
+
with pytest.raises(ValueError):
|
|
26
|
+
normalize_port("3000a")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ----- parse_signal ---------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def test_parse_signal_defaults_to_term_and_strips_sig_prefix():
|
|
32
|
+
assert parse_signal(None) == "TERM"
|
|
33
|
+
assert parse_signal("") == "TERM"
|
|
34
|
+
assert parse_signal("TERM") == "TERM"
|
|
35
|
+
assert parse_signal("sigkill") == "KILL"
|
|
36
|
+
assert parse_signal("KILL") == "KILL"
|
|
37
|
+
assert parse_signal("15") == "TERM" # numeric -> canonical name
|
|
38
|
+
assert parse_signal("9") == "KILL"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_parse_signal_rejects_unknown_signals():
|
|
42
|
+
with pytest.raises(ValueError):
|
|
43
|
+
parse_signal("SIGSEGV")
|
|
44
|
+
with pytest.raises(ValueError):
|
|
45
|
+
parse_signal("99")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ----- parse_lsof (real macOS output) ---------------------------------------
|
|
49
|
+
|
|
50
|
+
LSOF_SAMPLE = """COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
|
51
|
+
rapportd 618 benjamin 11u IPv4 0x10415ffa2a699725 0t0 TCP *:59505 (LISTEN)
|
|
52
|
+
rapportd 618 benjamin 13u IPv6 0xfd59be99dbab164 0t0 TCP *:59505 (LISTEN)
|
|
53
|
+
OrbStack 2529 benjamin 82u IPv4 0xbf8bb265f3586c58 0t0 TCP 127.0.0.1:32222 (LISTEN)
|
|
54
|
+
OrbStack 2529 benjamin 83u IPv6 0x34f6f7a71ad73a59 0t0 TCP [::1]:32222 (LISTEN)
|
|
55
|
+
OrbStack 2529 benjamin 115u IPv4 0x36e1f1db735e05 0t0 TCP *:5432 (LISTEN)
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_parse_lsof_parses_real_macos_output_all_address_shapes():
|
|
60
|
+
ls = parse_lsof(LSOF_SAMPLE)
|
|
61
|
+
assert len(ls) == 5
|
|
62
|
+
assert ls[0] == {"port": 59505, "pid": 618, "name": "rapportd", "address": "0.0.0.0", "protocol": "tcp", "family": "ipv4"}
|
|
63
|
+
assert ls[1] == {"port": 59505, "pid": 618, "name": "rapportd", "address": "[::]", "protocol": "tcp", "family": "ipv6"}
|
|
64
|
+
assert ls[2] == {"port": 32222, "pid": 2529, "name": "OrbStack", "address": "127.0.0.1", "protocol": "tcp", "family": "ipv4"}
|
|
65
|
+
assert ls[3] == {"port": 32222, "pid": 2529, "name": "OrbStack", "address": "[::1]", "protocol": "tcp", "family": "ipv6"}
|
|
66
|
+
assert ls[4] == {"port": 5432, "pid": 2529, "name": "OrbStack", "address": "0.0.0.0", "protocol": "tcp", "family": "ipv4"}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_parse_lsof_skips_header_and_blank_lines():
|
|
70
|
+
assert parse_lsof("COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n") == []
|
|
71
|
+
assert parse_lsof("") == []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ----- parse_ss (Linux) -----------------------------------------------------
|
|
75
|
+
|
|
76
|
+
SS_SAMPLE = """State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
|
|
77
|
+
LISTEN 0 128 0.0.0.0:3000 0.0.0.0:* users:(("node",pid=12345,fd=23))
|
|
78
|
+
LISTEN 0 128 127.0.0.1:5432 0.0.0.0:* users:(("python",pid=12346,fd=4))
|
|
79
|
+
LISTEN 0 128 [::]:8080 [::]:* users:(("nginx",pid=12347,fd=6))
|
|
80
|
+
LISTEN 0 128 0.0.0.0:9000 0.0.0.0:*
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_parse_ss_parses_linux_output_incl_ipv6_and_missing_process():
|
|
85
|
+
ls = parse_ss(SS_SAMPLE)
|
|
86
|
+
assert len(ls) == 4
|
|
87
|
+
assert ls[0] == {"port": 3000, "pid": 12345, "name": "node", "address": "0.0.0.0", "protocol": "tcp", "family": "ipv4"}
|
|
88
|
+
assert ls[2] == {"port": 8080, "pid": 12347, "name": "nginx", "address": "[::]", "protocol": "tcp", "family": "ipv6"}
|
|
89
|
+
assert ls[3] == {"port": 9000, "pid": None, "name": None, "address": "0.0.0.0", "protocol": "tcp", "family": "ipv4"}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ----- parse_netstat_linux --------------------------------------------------
|
|
93
|
+
|
|
94
|
+
NETSTAT_LINUX_SAMPLE = """Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
|
|
95
|
+
tcp 0 128 0.0.0.0:3000 0.0.0.0:* LISTEN 12345/node
|
|
96
|
+
tcp 0 128 127.0.0.1:5432 0.0.0.0:* LISTEN 12346/python
|
|
97
|
+
tcp6 0 128 :::8080 :::* LISTEN 12347/nginx
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_parse_netstat_linux_parses_output():
|
|
102
|
+
ls = parse_netstat_linux(NETSTAT_LINUX_SAMPLE)
|
|
103
|
+
assert len(ls) == 3
|
|
104
|
+
assert ls[0] == {"port": 3000, "pid": 12345, "name": "node", "address": "0.0.0.0", "protocol": "tcp", "family": "ipv4"}
|
|
105
|
+
assert ls[2] == {"port": 8080, "pid": 12347, "name": "nginx", "address": "::", "protocol": "tcp", "family": "ipv6"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ----- parse_netstat_windows ------------------------------------------------
|
|
109
|
+
|
|
110
|
+
NETSTAT_WIN_SAMPLE = """Active Connections
|
|
111
|
+
|
|
112
|
+
Proto Local Address Foreign Address State PID
|
|
113
|
+
TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 12345
|
|
114
|
+
TCP 127.0.0.1:5432 0.0.0.0:0 LISTENING 12346
|
|
115
|
+
TCP [::]:8080 [::]:0 LISTENING 12347
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_parse_netstat_windows_parses_output_name_stays_none():
|
|
120
|
+
ls = parse_netstat_windows(NETSTAT_WIN_SAMPLE)
|
|
121
|
+
assert len(ls) == 3
|
|
122
|
+
assert ls[0] == {"port": 3000, "pid": 12345, "name": None, "address": "0.0.0.0", "protocol": "tcp", "family": "ipv4"}
|
|
123
|
+
assert ls[2] == {"port": 8080, "pid": 12347, "name": None, "address": "[::]", "protocol": "tcp", "family": "ipv6"}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ----- parse_listeners dispatch --------------------------------------------
|
|
127
|
+
|
|
128
|
+
def test_parse_listeners_dispatches_by_source():
|
|
129
|
+
assert len(parse_listeners(LSOF_SAMPLE, "lsof")) == 5
|
|
130
|
+
assert len(parse_listeners(SS_SAMPLE, "ss")) == 4
|
|
131
|
+
with pytest.raises(ValueError):
|
|
132
|
+
parse_listeners("", "nope")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ----- dedup_listeners ------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def test_dedup_collapses_same_port_pid_family():
|
|
138
|
+
ls = parse_lsof(LSOF_SAMPLE)
|
|
139
|
+
dup = ls + [dict(ls[0])]
|
|
140
|
+
assert len(dedup_listeners(dup)) == len(ls)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_dedup_keeps_distinct_families_on_same_port():
|
|
144
|
+
ls = dedup_listeners(parse_lsof(LSOF_SAMPLE))
|
|
145
|
+
on59505 = [l for l in ls if l["port"] == 59505]
|
|
146
|
+
assert len(on59505) == 2 # ipv4 + ipv6, same pid
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ----- select_by_port -------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def test_select_by_port_filters_and_sorts_by_pid():
|
|
152
|
+
ls = dedup_listeners(parse_lsof(LSOF_SAMPLE))
|
|
153
|
+
on32222 = select_by_port(ls, 32222)
|
|
154
|
+
assert len(on32222) == 2
|
|
155
|
+
assert [l["family"] for l in on32222] == ["ipv4", "ipv6"]
|
|
156
|
+
assert select_by_port(ls, 9999) == []
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_select_by_port_rejects_bad_port():
|
|
160
|
+
with pytest.raises(ValueError):
|
|
161
|
+
select_by_port([], "nope")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ----- unique_pids ----------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def test_unique_pids_dedups_and_sorts():
|
|
167
|
+
ls = dedup_listeners(parse_lsof(LSOF_SAMPLE))
|
|
168
|
+
on32222 = select_by_port(ls, 32222)
|
|
169
|
+
assert unique_pids(on32222) == [2529] # both families, one pid
|
|
170
|
+
assert unique_pids([{"pid": 3}, {"pid": 1}, {"pid": 1}]) == [1, 3]
|
|
171
|
+
assert unique_pids([{"pid": None}]) == []
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ----- format_table ---------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def test_format_table_empty_is_empty_string():
|
|
177
|
+
assert format_table([]) == ""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_format_table_renders_header_separator_rows():
|
|
181
|
+
ls = dedup_listeners(parse_lsof(LSOF_SAMPLE))
|
|
182
|
+
table = format_table(select_by_port(ls, 32222))
|
|
183
|
+
assert "PORT" in table
|
|
184
|
+
assert "----" in table
|
|
185
|
+
assert "32222" in table
|
|
186
|
+
assert "OrbStack" in table
|
|
187
|
+
assert len(table.split("\n")) == 4 # header + sep + 2 rows
|