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 ADDED
@@ -0,0 +1,2 @@
1
+ # src/portmap/__init__.py
2
+ __version__ = "1.1.0"
portmap/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ # src/portmap/__main__.py
2
+ from portmap.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
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