portmap-dev 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.
- portmap/__init__.py +2 -0
- portmap/__main__.py +5 -0
- portmap/cli.py +40 -0
- portmap/main.py +201 -0
- portmap/scraper.py +131 -0
- portmap/static/index.html +806 -0
- portmap_dev-1.1.0.dist-info/METADATA +194 -0
- portmap_dev-1.1.0.dist-info/RECORD +11 -0
- portmap_dev-1.1.0.dist-info/WHEEL +4 -0
- portmap_dev-1.1.0.dist-info/entry_points.txt +2 -0
- portmap_dev-1.1.0.dist-info/licenses/LICENSE +21 -0
portmap/__init__.py
ADDED
portmap/__main__.py
ADDED
portmap/cli.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# src/portmap/cli.py
|
|
2
|
+
import argparse
|
|
3
|
+
import uvicorn
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
parser = argparse.ArgumentParser(
|
|
9
|
+
prog="portmap",
|
|
10
|
+
description="Real-time dashboard for local listening ports."
|
|
11
|
+
)
|
|
12
|
+
parser.add_argument(
|
|
13
|
+
"--port", "-p",
|
|
14
|
+
type=int,
|
|
15
|
+
default=7474,
|
|
16
|
+
help="Port to run the Portmap dashboard on (default: 7474)"
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--host",
|
|
20
|
+
default="127.0.0.1",
|
|
21
|
+
help="Host to bind to (default: 127.0.0.1)"
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--version", "-v",
|
|
25
|
+
action="version",
|
|
26
|
+
version="%(prog)s 1.1.0"
|
|
27
|
+
)
|
|
28
|
+
args = parser.parse_args()
|
|
29
|
+
|
|
30
|
+
print(f"Portmap running at http://{args.host}:{args.port}")
|
|
31
|
+
uvicorn.run(
|
|
32
|
+
"portmap.main:app",
|
|
33
|
+
host=args.host,
|
|
34
|
+
port=args.port,
|
|
35
|
+
log_level="warning" # suppress uvicorn noise, portmap speaks for itself
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
main()
|
portmap/main.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request
|
|
2
|
+
from fastapi.staticfiles import StaticFiles
|
|
3
|
+
from fastapi.responses import FileResponse
|
|
4
|
+
from .scraper import get_listening_connections
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import asyncio
|
|
9
|
+
import psutil
|
|
10
|
+
|
|
11
|
+
# Static assets ship inside the package, so resolve them relative to this file
|
|
12
|
+
# rather than the process's working directory — that way the app runs the same
|
|
13
|
+
# no matter which directory it is launched from.
|
|
14
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
15
|
+
|
|
16
|
+
# UIDs below this are reserved for privileged/system accounts on Linux
|
|
17
|
+
# (root is 0; the 1..999 range is daemons and service users). Ownership —
|
|
18
|
+
# not the PID value — is what determines whether a process is safe to kill.
|
|
19
|
+
SYSTEM_UID_MAX = 1000
|
|
20
|
+
|
|
21
|
+
# Hostnames that count as "the local machine". A request's Origin is only
|
|
22
|
+
# trusted when it points at one of these on the same port the request was
|
|
23
|
+
# sent to — see is_allowed_origin().
|
|
24
|
+
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1", "[::1]"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_allowed_origin(origin: str | None, host: str | None) -> bool:
|
|
28
|
+
"""Decide whether a browser Origin may talk to Portmap.
|
|
29
|
+
|
|
30
|
+
Portmap binds to localhost, but that does not stop another site open in
|
|
31
|
+
the same browser from calling our endpoints — the browser sends those
|
|
32
|
+
requests from the user's machine. The defense is the Origin header, which
|
|
33
|
+
the browser sets to the calling page's origin and which a page cannot
|
|
34
|
+
forge.
|
|
35
|
+
|
|
36
|
+
Policy:
|
|
37
|
+
* No Origin header → allow. Browsers always attach Origin to the
|
|
38
|
+
cross-site WebSocket and POST requests we care about, so a missing
|
|
39
|
+
Origin means a non-browser client (curl, tests), not the threat model.
|
|
40
|
+
* Origin present → its host must be loopback AND its port must match
|
|
41
|
+
the port this request was sent to (the Host header). This transparently
|
|
42
|
+
accepts whatever address the user opened the UI on (127.0.0.1 or
|
|
43
|
+
localhost, any port) while rejecting every external site.
|
|
44
|
+
"""
|
|
45
|
+
if origin is None:
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
parsed = urlparse(origin)
|
|
49
|
+
if parsed.hostname is None or parsed.hostname.lower() not in LOOPBACK_HOSTS:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
# The Origin's port must match the port the request was actually sent to,
|
|
53
|
+
# so a page served from a *different* local port cannot reach us either.
|
|
54
|
+
host_port = None
|
|
55
|
+
if host:
|
|
56
|
+
host_port = host.rsplit(":", 1)[-1] if ":" in host else None
|
|
57
|
+
|
|
58
|
+
return _port_of(parsed) == host_port
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _port_of(parsed) -> str | None:
|
|
62
|
+
if parsed.port is not None:
|
|
63
|
+
return str(parsed.port)
|
|
64
|
+
# No explicit port → the scheme's default (http → 80, https → 443).
|
|
65
|
+
return {"http": "80", "https": "443"}.get(parsed.scheme)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@asynccontextmanager
|
|
69
|
+
async def lifespan(app: FastAPI):
|
|
70
|
+
# Start the background scanner and keep a handle so we can stop it cleanly.
|
|
71
|
+
scanner = asyncio.create_task(scan_loop())
|
|
72
|
+
try:
|
|
73
|
+
yield
|
|
74
|
+
finally:
|
|
75
|
+
scanner.cancel()
|
|
76
|
+
try:
|
|
77
|
+
await scanner
|
|
78
|
+
except asyncio.CancelledError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
app = FastAPI(lifespan=lifespan)
|
|
83
|
+
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ConnectionManager:
|
|
87
|
+
def __init__(self):
|
|
88
|
+
self.active: list[WebSocket] = []
|
|
89
|
+
|
|
90
|
+
async def connect(self, ws: WebSocket):
|
|
91
|
+
await ws.accept()
|
|
92
|
+
self.active.append(ws)
|
|
93
|
+
|
|
94
|
+
def disconnect(self, ws: WebSocket):
|
|
95
|
+
if ws in self.active:
|
|
96
|
+
self.active.remove(ws)
|
|
97
|
+
|
|
98
|
+
async def broadcast(self, data):
|
|
99
|
+
dead = []
|
|
100
|
+
for ws in self.active:
|
|
101
|
+
try:
|
|
102
|
+
await ws.send_json(data)
|
|
103
|
+
except Exception:
|
|
104
|
+
dead.append(ws)
|
|
105
|
+
for ws in dead:
|
|
106
|
+
self.disconnect(ws)
|
|
107
|
+
|
|
108
|
+
manager = ConnectionManager()
|
|
109
|
+
_previous_snapshot: list = []
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def scan_loop():
|
|
113
|
+
global _previous_snapshot
|
|
114
|
+
while True:
|
|
115
|
+
try:
|
|
116
|
+
# psutil scanning is blocking (net_connections + per-PID /proc and
|
|
117
|
+
# filesystem reads). Run it in a worker thread so the event loop
|
|
118
|
+
# stays responsive for websocket broadcasts and the kill endpoint.
|
|
119
|
+
current = await asyncio.to_thread(get_listening_connections)
|
|
120
|
+
|
|
121
|
+
if current != _previous_snapshot:
|
|
122
|
+
await manager.broadcast(current)
|
|
123
|
+
_previous_snapshot = current
|
|
124
|
+
except Exception as e:
|
|
125
|
+
print(f"Scanner error: {e}")
|
|
126
|
+
await asyncio.sleep(0.5)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.get("/")
|
|
130
|
+
async def read_index():
|
|
131
|
+
return FileResponse(STATIC_DIR / "index.html")
|
|
132
|
+
|
|
133
|
+
@app.websocket("/ws/scan")
|
|
134
|
+
async def websocket_endpoint(ws: WebSocket):
|
|
135
|
+
# Reject cross-site sockets before the handshake completes. A page from
|
|
136
|
+
# another origin can open a WebSocket to us, so the Origin must be checked
|
|
137
|
+
# here just as it is on the kill endpoint.
|
|
138
|
+
if not is_allowed_origin(ws.headers.get("origin"), ws.headers.get("host")):
|
|
139
|
+
await ws.close(code=1008) # 1008 = policy violation
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
await manager.connect(ws)
|
|
143
|
+
|
|
144
|
+
if _previous_snapshot:
|
|
145
|
+
await ws.send_json(_previous_snapshot)
|
|
146
|
+
try:
|
|
147
|
+
while True:
|
|
148
|
+
await ws.receive_text()
|
|
149
|
+
except WebSocketDisconnect:
|
|
150
|
+
manager.disconnect(ws)
|
|
151
|
+
except Exception:
|
|
152
|
+
manager.disconnect(ws)
|
|
153
|
+
|
|
154
|
+
@app.post("/api/kill/{pid}")
|
|
155
|
+
async def kill_process(pid: int, request: Request):
|
|
156
|
+
# Block cross-site kill requests. Any page in the user's browser can POST
|
|
157
|
+
# here; only requests originating from the Portmap UI itself are allowed.
|
|
158
|
+
if not is_allowed_origin(
|
|
159
|
+
request.headers.get("origin"), request.headers.get("host")
|
|
160
|
+
):
|
|
161
|
+
raise HTTPException(status_code=403, detail="Origin not allowed.")
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
proc = psutil.Process(pid)
|
|
165
|
+
owner_uid = proc.uids().real
|
|
166
|
+
except psutil.NoSuchProcess:
|
|
167
|
+
raise HTTPException(status_code=404, detail=f"PID {pid} not found.")
|
|
168
|
+
except psutil.AccessDenied:
|
|
169
|
+
raise HTTPException(status_code=403, detail=f"Permission denied for PID {pid}.")
|
|
170
|
+
|
|
171
|
+
# Protect processes owned by privileged/system accounts, regardless of PID.
|
|
172
|
+
if owner_uid < SYSTEM_UID_MAX:
|
|
173
|
+
raise HTTPException(
|
|
174
|
+
status_code=403,
|
|
175
|
+
detail=f"Cannot kill process {pid}: owned by a protected system account.",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
proc.terminate()
|
|
180
|
+
try:
|
|
181
|
+
await asyncio.to_thread(proc.wait, timeout=5)
|
|
182
|
+
return {
|
|
183
|
+
"message": f"Process {pid} terminated gracefully.",
|
|
184
|
+
"method": "SIGTERM"
|
|
185
|
+
}
|
|
186
|
+
except psutil.TimeoutExpired:
|
|
187
|
+
proc.kill()
|
|
188
|
+
await asyncio.to_thread(proc.wait, timeout=2)
|
|
189
|
+
return {
|
|
190
|
+
"message": f"Process {pid} force killed after graceful termination timeout.",
|
|
191
|
+
"method": "SIGKILL"
|
|
192
|
+
}
|
|
193
|
+
except psutil.NoSuchProcess:
|
|
194
|
+
return {
|
|
195
|
+
"message": f"Process {pid} terminated.",
|
|
196
|
+
"method": "SIGTERM"
|
|
197
|
+
}
|
|
198
|
+
except psutil.AccessDenied:
|
|
199
|
+
raise HTTPException(status_code=403, detail=f"Permission denied for PID {pid}.")
|
|
200
|
+
except Exception as e:
|
|
201
|
+
raise HTTPException(status_code=500, detail=str(e))
|
portmap/scraper.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import psutil
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
FRAMEWORK_MARKERS = {
|
|
5
|
+
"manage.py": "Django",
|
|
6
|
+
"pyproject.toml": "Python",
|
|
7
|
+
"requirements.txt": "Python",
|
|
8
|
+
"package.json": "Node.js",
|
|
9
|
+
"go.mod": "Go",
|
|
10
|
+
"Cargo.toml": "Rust",
|
|
11
|
+
"Gemfile": "Ruby",
|
|
12
|
+
"pom.xml": "Java",
|
|
13
|
+
"composer.json": "PHP",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
INFRASTRUCTURE = {
|
|
17
|
+
"redis-server": "Redis",
|
|
18
|
+
"postgres": "PostgreSQL",
|
|
19
|
+
"mysqld": "MySQL",
|
|
20
|
+
"mongod": "MongoDB",
|
|
21
|
+
"nginx": "Nginx",
|
|
22
|
+
"docker-proxy": "Docker",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
SKIP_PROCESSES = {
|
|
26
|
+
"code", "code-oss", "codium", "cursor",
|
|
27
|
+
"webstorm", "idea", "pycharm", "clion", "rider",
|
|
28
|
+
"postman", "insomnia",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
SKIP_FLAGS = {
|
|
32
|
+
"--type=renderer", "--type=gpu-process",
|
|
33
|
+
"--type=utility", "--type=broker",
|
|
34
|
+
"node.mojom.nodeservice",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _should_skip(pid: int) -> bool:
|
|
39
|
+
try:
|
|
40
|
+
proc = psutil.Process(pid)
|
|
41
|
+
name = proc.name().lower().removesuffix(".exe")
|
|
42
|
+
if name in SKIP_PROCESSES:
|
|
43
|
+
return True
|
|
44
|
+
cmdline = " ".join(proc.cmdline())
|
|
45
|
+
return any(flag in cmdline for flag in SKIP_FLAGS)
|
|
46
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _find_git_root(pid: int) -> Path | None:
|
|
51
|
+
try:
|
|
52
|
+
path = Path(psutil.Process(pid).cwd())
|
|
53
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError):
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
for _ in range(6):
|
|
57
|
+
if (path / ".git").exists():
|
|
58
|
+
return path
|
|
59
|
+
parent = path.parent
|
|
60
|
+
if parent == path:
|
|
61
|
+
break
|
|
62
|
+
path = parent
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _project_name(git_root: Path) -> str:
|
|
67
|
+
# The git root directory name is the project name
|
|
68
|
+
return git_root.name or str(git_root)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _identify_framework(git_root: Path) -> str:
|
|
72
|
+
try:
|
|
73
|
+
entries = {e.name for e in git_root.iterdir()}
|
|
74
|
+
except OSError:
|
|
75
|
+
return "Project"
|
|
76
|
+
for marker, framework in FRAMEWORK_MARKERS.items():
|
|
77
|
+
if marker in entries:
|
|
78
|
+
return framework
|
|
79
|
+
return "Project"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_listening_connections() -> list[dict]:
|
|
83
|
+
results = []
|
|
84
|
+
seen = set()
|
|
85
|
+
|
|
86
|
+
for conn in psutil.net_connections(kind='inet'):
|
|
87
|
+
if conn.status != 'LISTEN':
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
port, pid = conn.laddr.port, conn.pid
|
|
91
|
+
if (port, pid) in seen:
|
|
92
|
+
continue
|
|
93
|
+
seen.add((port, pid))
|
|
94
|
+
|
|
95
|
+
# No PID → kernel/system
|
|
96
|
+
if pid is None:
|
|
97
|
+
results.append({"port": port, "pid": None,
|
|
98
|
+
"name": "System", "framework": None,
|
|
99
|
+
"cwd": None, "kind": "system"})
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
# Skip IDE workers and Chromium internal processes
|
|
103
|
+
if _should_skip(pid):
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
# Git root → confirmed project
|
|
107
|
+
git_root = _find_git_root(pid)
|
|
108
|
+
if git_root:
|
|
109
|
+
framework = _identify_framework(git_root)
|
|
110
|
+
project = _project_name(git_root)
|
|
111
|
+
results.append({"port": port, "pid": pid,
|
|
112
|
+
"name": project, "framework": framework,
|
|
113
|
+
"cwd": str(git_root), "kind": "project"})
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
# Known infrastructure daemon
|
|
117
|
+
raw_name = psutil.Process(pid).name() if pid else "unknown"
|
|
118
|
+
if raw_name.lower() in INFRASTRUCTURE:
|
|
119
|
+
label = INFRASTRUCTURE[raw_name.lower()]
|
|
120
|
+
results.append({"port": port, "pid": pid,
|
|
121
|
+
"name": label, "framework": label,
|
|
122
|
+
"cwd": None, "kind": "infrastructure"})
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
# Honest fallback
|
|
126
|
+
results.append({"port": port, "pid": pid,
|
|
127
|
+
"name": raw_name, "framework": None,
|
|
128
|
+
"cwd": None, "kind": "unknown"})
|
|
129
|
+
|
|
130
|
+
results.sort(key=lambda x: x["port"])
|
|
131
|
+
return results
|