Files
dashgradio_tunnel/app/services.py
T

172 lines
5.1 KiB
Python

from __future__ import annotations
import shlex
import socket
import subprocess
import time
from dataclasses import dataclass
from typing import Iterable, List, Optional
import psutil
@dataclass
class ServiceRecord:
port: int
protocol: str
service: str
pid: Optional[int]
user: Optional[str]
command: Optional[str]
container: Optional[str]
local_address: str
class PortScanner:
def __init__(self, cache_ttl: float = 2.0) -> None:
self._cache_ttl = cache_ttl
self._last_scan = 0.0
self._cache: List[ServiceRecord] = []
def scan(self) -> List[ServiceRecord]:
now = time.monotonic()
if self._cache and now - self._last_scan < self._cache_ttl:
return self._cache
self._cache = self._perform_scan()
self._last_scan = now
return self._cache
def _perform_scan(self) -> List[ServiceRecord]:
connections = psutil.net_connections(kind="inet")
container_names = _load_container_names()
process_cache: dict[int, tuple[str, Optional[str], list[str]]] = {}
seen_ports: set[int] = set()
results: List[ServiceRecord] = []
for conn in connections:
if conn.status != psutil.CONN_LISTEN:
continue
if not conn.laddr:
continue
port = conn.laddr.port
if port in seen_ports:
continue
seen_ports.add(port)
protocol = _protocol_for_conn(conn.type)
process_name = "unknown"
process_user: Optional[str] = None
process_command: Optional[str] = None
pid = conn.pid
if pid and pid > 0:
cache_entry = process_cache.get(pid)
if cache_entry is None:
cache_entry = _describe_process(pid)
process_cache[pid] = cache_entry
process_name, process_user, raw_command = cache_entry
if raw_command:
process_command = _format_commandline(raw_command)
local_address = f"{conn.laddr.ip}:{conn.laddr.port}"
container = _container_name_for_pid(pid, container_names) if pid else None
results.append(
ServiceRecord(
port=port,
protocol=protocol,
service=process_name,
pid=pid,
user=process_user,
command=process_command,
container=container,
local_address=local_address,
)
)
return results
def _protocol_for_conn(sock_type: int) -> str:
if sock_type == socket.SOCK_STREAM:
return "tcp"
if sock_type == socket.SOCK_DGRAM:
return "udp"
return "inet"
def _describe_process(pid: int) -> tuple[str, Optional[str], list[str]]:
try:
process = psutil.Process(pid)
name = process.name() or "unknown"
user = None
try:
user = process.username()
except psutil.Error:
user = None
command = process.cmdline()
except psutil.Error:
return "unknown", None, []
return name, user, command
def _format_commandline(cmdline: Iterable[str]) -> str:
command = " ".join(shlex.quote(part) for part in cmdline if part)
if len(command) > 150:
return command[:150].rstrip() + "…"
return command
def _load_container_names() -> dict[str, str]:
try:
result = subprocess.run(
[
"docker",
"ps",
"--no-trunc",
"--format",
"{{.ID}} {{.Names}}",
],
capture_output=True,
text=True,
check=True,
)
except (subprocess.SubprocessError, FileNotFoundError):
return {}
containers: dict[str, str] = {}
for line in result.stdout.splitlines():
parts = line.strip().split(maxsplit=1)
if len(parts) != 2:
continue
container_id, name = parts
containers[container_id.lower()] = name
return containers
def _container_name_for_pid(pid: Optional[int], container_names: dict[str, str]) -> Optional[str]:
if not pid:
return None
try:
with open(f"/proc/{pid}/cgroup", "r", encoding="utf-8") as handle:
content = handle.read()
except OSError:
return None
for line in content.splitlines():
lower = line.lower()
if not any(keyword in lower for keyword in ("docker", "containerd", "kubepods", "podman")):
continue
for part in line.split("/"):
part = part.strip()
if not part:
continue
if _looks_like_container_id(part):
for container_id, name in container_names.items():
if container_id.startswith(part.lower()):
return name
return None
def _looks_like_container_id(value: str) -> bool:
value = value.strip()
if len(value) < 12:
return False
return all(ch in "0123456789abcdef" for ch in value[:12].lower())