sysgraph 0.0.11__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.
sysgraph/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.0.11"
sysgraph/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from sysgraph.app import main
2
+
3
+ main()
sysgraph/app.py ADDED
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import logging
4
+ import os
5
+ from contextlib import asynccontextmanager
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import coloredlogs
10
+ from fastapi import FastAPI
11
+ from fastapi.middleware.gzip import GZipMiddleware
12
+ from fastapi.responses import FileResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+ from pydantic import BaseModel
15
+
16
+ from sysgraph.discovery import build_graph
17
+
18
+ LOGGER = logging.getLogger(__name__)
19
+
20
+
21
+ @asynccontextmanager
22
+ async def lifespan(app):
23
+ coloredlogs.install()
24
+ logging.info(f"init for process with PID {os.getpid()}")
25
+ yield
26
+ logging.info(f"shutdown for process with PID {os.getpid()}")
27
+
28
+
29
+ app = FastAPI(title="sysgraph API", version="0.1.0", lifespan=lifespan)
30
+ app.add_middleware(GZipMiddleware, minimum_size=500)
31
+
32
+ # Vite build output — run scripts/build-ui.sh to produce it.
33
+ _dist_dir = Path(__file__).parent / "dist"
34
+
35
+ if not _dist_dir.is_dir():
36
+ LOGGER.warning(
37
+ "dist/ not found — frontend will not be served. "
38
+ "Run scripts/build-ui.sh to produce a production build."
39
+ )
40
+
41
+
42
+ # Root route serves the SPA index — defined before the catch-all mount.
43
+ @app.get("/", include_in_schema=False)
44
+ def index():
45
+ index_path = _dist_dir / "index.html"
46
+ return FileResponse(index_path)
47
+
48
+
49
+ class GraphNodeSchema(BaseModel):
50
+ id: str
51
+ type: str
52
+ properties: dict[str, Any]
53
+
54
+
55
+ class GraphEdgeSchema(BaseModel):
56
+ id: str
57
+ source_id: str
58
+ target_id: str
59
+ type: str
60
+ properties: dict[str, Any]
61
+
62
+
63
+ class GraphSchema(BaseModel):
64
+ nodes: list[GraphNodeSchema]
65
+ edges: list[GraphEdgeSchema]
66
+
67
+
68
+ @app.get("/api/health")
69
+ def health():
70
+ return {"status": "ok"}
71
+
72
+
73
+ @app.get("/api/graph", response_model=GraphSchema)
74
+ def get_graph() -> GraphSchema:
75
+ graph = build_graph()
76
+ graph_dict = graph.as_dict()
77
+
78
+ LOGGER.debug(graph_dict)
79
+
80
+ return GraphSchema(
81
+ nodes=[
82
+ GraphNodeSchema(
83
+ id=node["id"], type=node["type"], properties=node["properties"]
84
+ )
85
+ for node in graph_dict["nodes"]
86
+ ],
87
+ edges=[
88
+ GraphEdgeSchema(
89
+ id=edge["id"],
90
+ source_id=edge["source_id"],
91
+ target_id=edge["target_id"],
92
+ type=edge["type"],
93
+ properties=edge["properties"],
94
+ )
95
+ for edge in graph_dict["edges"]
96
+ ],
97
+ )
98
+
99
+
100
+ # Serve built assets (JS/CSS bundles, Shoelace icons, etc.) from the same
101
+ # directory that provides index.html. This catch-all mount MUST come after
102
+ # all explicit routes so that /api/* and / are matched first.
103
+ if _dist_dir.is_dir():
104
+ app.mount(
105
+ "/", StaticFiles(directory=str(_dist_dir)), name="static"
106
+ )
107
+
108
+
109
+ def main():
110
+ import argparse
111
+
112
+ import uvicorn
113
+
114
+ parser = argparse.ArgumentParser(description="sysgraph server")
115
+ parser.add_argument(
116
+ "-p",
117
+ "--port",
118
+ type=int,
119
+ default=int(os.environ.get("PORT", 8000)),
120
+ help="port to listen on (default: 8000, or PORT env var)",
121
+ )
122
+ args = parser.parse_args()
123
+
124
+ uvicorn.run(
125
+ "sysgraph.app:app",
126
+ host="0.0.0.0",
127
+ port=args.port,
128
+ reload=True,
129
+ log_level="info",
130
+ )
131
+
132
+
133
+ if __name__ == "__main__":
134
+ main()
sysgraph/discovery.py ADDED
@@ -0,0 +1,530 @@
1
+ import logging
2
+ import os
3
+ import re
4
+ import subprocess
5
+ from collections import defaultdict
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from pathlib import Path
8
+ from socket import AddressFamily
9
+
10
+ import psutil
11
+
12
+ from sysgraph.graph import Graph, Node
13
+ from sysgraph.model import (
14
+ NetConnection,
15
+ Process,
16
+ ProcessOpenFile,
17
+ SocketAddress,
18
+ UnixDomainSocket,
19
+ UnixDomainSocketConnection,
20
+ UnixDomainSocketProcRef,
21
+ )
22
+
23
+ LOGGER = logging.getLogger(__name__)
24
+
25
+
26
+ def discover_processes() -> list[Process]:
27
+ processes = []
28
+
29
+ attrs = [
30
+ "pid",
31
+ "ppid",
32
+ "username",
33
+ "cmdline",
34
+ "name",
35
+ "cpu_times",
36
+ "environ",
37
+ ]
38
+ for proc in psutil.process_iter(attrs):
39
+ info = proc.info
40
+ p = Process(pid=info["pid"])
41
+ p.parent_pid = info["ppid"]
42
+ p.user = info["username"]
43
+ p.command = " ".join(info["cmdline"]) if info["cmdline"] else None
44
+ p.name = info.get("name")
45
+
46
+ cpu_times = info.get("cpu_times")
47
+ if cpu_times:
48
+ p.cpu_user = cpu_times.user
49
+ p.cpu_system = cpu_times.system
50
+
51
+ p.environment = info.get("environ")
52
+
53
+ processes.append(p)
54
+
55
+ return processes
56
+
57
+
58
+ def discover_unix_sockets() -> list[UnixDomainSocket]:
59
+ """Discover Unix Domain Sockets as reported by kernel.
60
+
61
+ Note however that connection with UDS will have at least 2 records reported
62
+ with opposite local/peer inodes. They can be further groupped to
63
+ find connected pairs of processes.
64
+
65
+ Returns:
66
+ list[UnixDomainSocket]: _description_
67
+ """
68
+ result = subprocess.run(
69
+ ["ss", "-xp"], capture_output=True, text=True, check=False
70
+ )
71
+
72
+ sockets = []
73
+
74
+ # example line:
75
+ # users:(("dbus-daemon",pid=1950,fd=12))
76
+ def parse_proccess(s: str) -> list[UnixDomainSocketProcRef]:
77
+ processes = []
78
+
79
+ for match in re.finditer(
80
+ r'\("(?P<name>[^"]+)",pid=(?P<pid>[0-9]+),fd=(?P<fd>[0-9]+)\)', s
81
+ ):
82
+ ref = UnixDomainSocketProcRef(pid=int(match.group("pid")))
83
+ ref.name = match.group("name")
84
+ ref.fd = int(match.group("fd"))
85
+ processes.append(ref)
86
+
87
+ return processes
88
+
89
+ for line in result.stdout.splitlines()[1:]:
90
+ segments = line.split(" ")
91
+ segments = [s for s in segments if s]
92
+
93
+ uds = UnixDomainSocket(
94
+ local_inode=int(segments[5]),
95
+ peer_inode=int(segments[7]),
96
+ )
97
+ uds.local_path = segments[4]
98
+ uds.peer_path = segments[6]
99
+
100
+ if len(segments) > 8:
101
+ uds.processes = parse_proccess(segments[8])
102
+
103
+ uds.state = segments[1]
104
+ uds.uds_type = segments[0]
105
+
106
+ sockets.append(uds)
107
+
108
+ LOGGER.info(f"found {len(sockets)} UDS")
109
+
110
+ return sockets
111
+
112
+
113
+ def discover_connected_uds(
114
+ sockets: list[UnixDomainSocket],
115
+ ) -> list[UnixDomainSocketConnection]:
116
+ """Discover connected UDS pairs from the list of discovered UDS sockets.
117
+
118
+ Each connection will have two UDS sockets with opposite local/peer inodes.
119
+
120
+ Args:
121
+ sockets (list[UnixDomainSocket]): List of discovered UDS sockets.
122
+ Returns:
123
+ list[UnixDomainSocketConnection]: List of connected UDS pairs.
124
+ """
125
+
126
+ inode_map = {}
127
+ for uds in sockets:
128
+ inode_map[(uds.local_inode, uds.peer_inode)] = uds
129
+
130
+ connections = []
131
+ visited = set()
132
+
133
+ for uds in sockets:
134
+ if (uds.peer_inode, uds.local_inode) in inode_map:
135
+ peer_uds = inode_map[(uds.peer_inode, uds.local_inode)]
136
+ if (uds.local_inode, uds.peer_inode) not in visited and (
137
+ uds.peer_inode,
138
+ uds.local_inode,
139
+ ) not in visited:
140
+ connection = UnixDomainSocketConnection(
141
+ socket1=uds, socket2=peer_uds
142
+ )
143
+ connections.append(connection)
144
+ visited.add((uds.local_inode, uds.peer_inode))
145
+ visited.add((uds.peer_inode, uds.local_inode))
146
+
147
+ LOGGER.info(f"found {len(connections)} connected UDS pairs")
148
+ return connections
149
+
150
+
151
+ def get_processes_open_files() -> dict[int, list[ProcessOpenFile]]:
152
+ """Read open file descriptors directly from /proc instead of spawning lsof."""
153
+ result_map: dict[int, list[ProcessOpenFile]] = defaultdict(list)
154
+ proc_path = Path("/proc")
155
+
156
+ for pid_dir in proc_path.iterdir():
157
+ if not pid_dir.name.isdigit():
158
+ continue
159
+ pid = int(pid_dir.name)
160
+ fd_dir = pid_dir / "fd"
161
+ try:
162
+ fd_entries = list(fd_dir.iterdir())
163
+ except (PermissionError, OSError):
164
+ continue
165
+
166
+ for fd_entry in fd_entries:
167
+ if not fd_entry.name.isdigit():
168
+ continue
169
+ try:
170
+ target = os.readlink(fd_entry)
171
+ except (PermissionError, OSError):
172
+ continue
173
+
174
+ # only collect pipes (the only type used downstream)
175
+ if not target.startswith("pipe:"):
176
+ continue
177
+
178
+ fd_num = int(fd_entry.name)
179
+ # extract inode from "pipe:[12345]"
180
+ inode = target[6:-1]
181
+
182
+ # read mode from /proc/[pid]/fdinfo/[fd]
183
+ mode = None
184
+ try:
185
+ fdinfo = (pid_dir / "fdinfo" / fd_entry.name).read_text()
186
+ for line in fdinfo.splitlines():
187
+ if line.startswith("flags:"):
188
+ flags = int(line.split(":", 1)[1].strip(), 8)
189
+ access = flags & 0o3
190
+ mode = "r" if access == 0o0 else "w"
191
+ break
192
+ except (PermissionError, OSError):
193
+ pass
194
+
195
+ open_file = ProcessOpenFile(fd_num, "FIFO", target)
196
+ open_file.node = inode
197
+ open_file.mode = mode
198
+ result_map[pid].append(open_file)
199
+
200
+ return result_map
201
+
202
+
203
+ def get_net_connections(pid: int) -> list[NetConnection]:
204
+ proc = psutil.Process(pid)
205
+ connections: list[NetConnection] = []
206
+ for pcon in proc.net_connections(kind="all"):
207
+ if pcon.family not in (AddressFamily.AF_INET, AddressFamily.AF_INET6):
208
+ continue
209
+ connections.append(
210
+ NetConnection(
211
+ pid=pid,
212
+ local_address=SocketAddress(pcon.laddr.ip, pcon.laddr.port),
213
+ remote_address=SocketAddress(pcon.raddr.ip, pcon.raddr.port)
214
+ if pcon.raddr
215
+ else None,
216
+ socket_type=pcon.type.name,
217
+ state=pcon.status,
218
+ )
219
+ )
220
+ return connections
221
+
222
+
223
+ def get_all_net_connections() -> dict[int, list[NetConnection]]:
224
+ """Fetch all network connections system-wide in a single call."""
225
+ result: dict[int, list[NetConnection]] = defaultdict(list)
226
+ for pcon in psutil.net_connections(kind="inet"):
227
+ if pcon.pid is None:
228
+ continue
229
+ result[pcon.pid].append(
230
+ NetConnection(
231
+ pid=pcon.pid,
232
+ local_address=SocketAddress(pcon.laddr.ip, pcon.laddr.port),
233
+ remote_address=SocketAddress(pcon.raddr.ip, pcon.raddr.port)
234
+ if pcon.raddr
235
+ else None,
236
+ socket_type=pcon.type.name,
237
+ state=pcon.status,
238
+ )
239
+ )
240
+ return result
241
+
242
+
243
+ def build_graph(discover_uds_connectivity: bool = True) -> Graph:
244
+ graph = Graph()
245
+
246
+ pid_to_node: dict[int, Node] = {}
247
+
248
+ # run independent discovery steps in parallel (all I/O-bound)
249
+ with ThreadPoolExecutor() as executor:
250
+ processes_future = executor.submit(discover_processes)
251
+ uds_future = executor.submit(discover_unix_sockets)
252
+ open_files_future = executor.submit(get_processes_open_files)
253
+ net_connections_future = executor.submit(get_all_net_connections)
254
+
255
+ processes = processes_future.result()
256
+ uds = uds_future.result()
257
+ open_files_map = open_files_future.result()
258
+ all_net_connections = net_connections_future.result()
259
+
260
+ for proc in processes:
261
+ proc_node_id = f"process::{proc.pid}"
262
+ node = graph.add_node(
263
+ proc_node_id,
264
+ "process",
265
+ properties={
266
+ "pid": proc.pid,
267
+ "command": proc.command,
268
+ "user": proc.user,
269
+ "name": proc.name,
270
+ "cpu_user": proc.cpu_user,
271
+ "cpu_system": proc.cpu_system,
272
+ "environment": proc.environment,
273
+ },
274
+ )
275
+ pid_to_node[proc.pid] = node
276
+
277
+ # add parent-child relationships
278
+ for proc in processes:
279
+ proc_node = pid_to_node[proc.pid]
280
+ parent_proc_node = None
281
+ if proc.parent_pid is not None:
282
+ parent_proc_node = pid_to_node.get(proc.parent_pid)
283
+ if parent_proc_node is not None:
284
+ _ = graph.add_edge(
285
+ source_id=parent_proc_node.id,
286
+ target_id=proc_node.id,
287
+ rel_type="child_process",
288
+ )
289
+
290
+ uds_node_map: dict[int, Node] = {}
291
+
292
+ def ensure_uds_node(uds_socket: UnixDomainSocket) -> Node:
293
+ inode = uds_socket.local_inode
294
+
295
+ if inode in uds_node_map:
296
+ return uds_node_map[inode]
297
+
298
+ node_id = f"uds::{inode}"
299
+
300
+ if uds_socket.local_path:
301
+ if uds_socket.local_path == "*":
302
+ label = f"uds:[{inode}]"
303
+ else:
304
+ label = uds_socket.local_path
305
+
306
+ # strip trailing @ if present (abstract unix socket)
307
+ label = label.rstrip("@")
308
+ else: # no local path
309
+ label = f"uds:[{inode}]"
310
+
311
+ node = graph.add_node(
312
+ node_id,
313
+ "uds",
314
+ properties={
315
+ "label": label,
316
+ "local_inode": uds_socket.local_inode,
317
+ "local_address": uds_socket.local_path,
318
+ "peer_inode": uds_socket.peer_inode,
319
+ "peer_address": uds_socket.peer_path,
320
+ "state": uds_socket.state,
321
+ "uds_type": uds_socket.uds_type,
322
+ },
323
+ )
324
+ uds_node_map[inode] = node
325
+ return node
326
+
327
+ # track which process→uds edges have been added to avoid duplicates
328
+ uds_process_edges: set[tuple[int, int]] = set()
329
+
330
+ # create UDS nodes for ALL discovered sockets and connect them
331
+ # to their processes
332
+ for uds_socket in uds:
333
+ uds_node = ensure_uds_node(uds_socket)
334
+ for p_ref in uds_socket.processes:
335
+ edge_key = (p_ref.pid, uds_socket.local_inode)
336
+ if p_ref.pid in pid_to_node and edge_key not in uds_process_edges:
337
+ uds_process_edges.add(edge_key)
338
+ graph.add_edge(
339
+ source_id=pid_to_node[p_ref.pid].id,
340
+ target_id=uds_node.id,
341
+ rel_type="uds",
342
+ properties={
343
+ "label": f"uds (fd={p_ref.fd})",
344
+ "fd": p_ref.fd,
345
+ },
346
+ )
347
+
348
+ # optionally discover connected UDS pairs and add connection edges
349
+ if discover_uds_connectivity:
350
+ for con in discover_connected_uds(uds):
351
+ uds_node1 = ensure_uds_node(con.socket1)
352
+ uds_node2 = ensure_uds_node(con.socket2)
353
+
354
+ graph.add_edge(
355
+ uds_node1.id,
356
+ uds_node2.id,
357
+ "uds_connection",
358
+ properties={
359
+ "directional": False,
360
+ "dashed": True,
361
+ },
362
+ )
363
+
364
+ pipe_node_to_node = {}
365
+
366
+ def ensure_pipe_node(file_node):
367
+ if file_node not in pipe_node_to_node:
368
+ pipe_node_to_node[file_node] = graph.add_node(
369
+ f"pipe::{file_node}",
370
+ "pipe",
371
+ properties={
372
+ "label": f"pipe:[{file_node}]",
373
+ },
374
+ )
375
+ return pipe_node_to_node[file_node]
376
+
377
+ for pid, files in open_files_map.items():
378
+ if pid not in pid_to_node:
379
+ continue
380
+ process_node = pid_to_node[pid]
381
+ for file in files:
382
+ # so far just show the pipes
383
+ if file.file_type != "FIFO":
384
+ continue
385
+ node = ensure_pipe_node(file.node)
386
+
387
+ if file.mode == "r":
388
+ _ = graph.add_edge(
389
+ source_id=node.id,
390
+ target_id=process_node.id,
391
+ rel_type="pipe",
392
+ properties={
393
+ "label": f"pipe (fd={file.fd})",
394
+ "fd": file.fd,
395
+ "mode": file.mode,
396
+ },
397
+ )
398
+ else:
399
+ _ = graph.add_edge(
400
+ source_id=process_node.id,
401
+ target_id=node.id,
402
+ rel_type="pipe",
403
+ properties={
404
+ "label": f"pipe (fd={file.fd})",
405
+ "fd": file.fd,
406
+ "mode": file.mode,
407
+ },
408
+ )
409
+
410
+ socket_to_pids: dict[tuple[SocketAddress, str], list[int]] = {}
411
+ socket_to_node: dict[tuple[SocketAddress, str], Node] = {}
412
+
413
+ def is_ipv6(address: str) -> bool:
414
+ return ":" in address
415
+
416
+ def ensure_socket(address: SocketAddress, socket_type, state: str):
417
+ key = (address, socket_type)
418
+ if key in socket_to_node:
419
+ return socket_to_node[key]
420
+ socket_node_id = f"socket::{address}::{socket_type}"
421
+ socket_node = graph.add_node(socket_node_id, "socket")
422
+
423
+ simple_socket_type = {
424
+ "SOCK_DGRAM": "UDP",
425
+ "SOCK_STREAM": "TCP",
426
+ }
427
+
428
+ simple_type = simple_socket_type.get(socket_type, socket_type)
429
+
430
+ if is_ipv6(address.ip):
431
+ socket_node.properties["label"] = (
432
+ f"[{address.ip}]:{address.port} ({simple_type})"
433
+ )
434
+ else:
435
+ socket_node.properties["label"] = (
436
+ f"{address.ip}:{address.port} ({simple_type})"
437
+ )
438
+
439
+ socket_node.properties["state"] = state
440
+ socket_node.properties["socket_type"] = socket_type
441
+
442
+ socket_to_node[key] = socket_node
443
+ return socket_node
444
+
445
+ connected_sockets = set()
446
+
447
+ def ensure_sockets_connected(socket1: Node, socket2: Node):
448
+ key = tuple(sorted([socket1.id, socket2.id]))
449
+ if key in connected_sockets:
450
+ return
451
+ connected_sockets.add(key)
452
+ _ = graph.add_edge(
453
+ socket1.id,
454
+ socket2.id,
455
+ "socket_connection",
456
+ {
457
+ "directional": False,
458
+ "dashed": True,
459
+ },
460
+ )
461
+
462
+ for proc in processes:
463
+ net_connections = all_net_connections.get(proc.pid, [])
464
+ if not net_connections:
465
+ continue
466
+ proc_node = pid_to_node.get(proc.pid)
467
+ if proc_node is None:
468
+ continue
469
+
470
+ for net_con in net_connections:
471
+ socket_id = (net_con.local_address, net_con.socket_type)
472
+
473
+ # ensure socket in graph
474
+ local_socket = ensure_socket(
475
+ net_con.local_address,
476
+ net_con.socket_type,
477
+ net_con.state,
478
+ )
479
+
480
+ # process connection
481
+ if proc.pid not in socket_to_pids.get(socket_id, []):
482
+ socket_to_pids[socket_id] = socket_to_pids.get(
483
+ socket_id, []
484
+ ) + [proc.pid]
485
+ graph.add_edge(
486
+ source_id=proc_node.id,
487
+ target_id=local_socket.id,
488
+ rel_type="socket",
489
+ )
490
+
491
+ if net_con.remote_address:
492
+ remote_socket = ensure_socket(
493
+ net_con.remote_address,
494
+ net_con.socket_type,
495
+ net_con.state,
496
+ )
497
+ ensure_sockets_connected(local_socket, remote_socket)
498
+
499
+ # post-process all sockets which are NOT connected to any process -- these
500
+ # are remote endpoints, group the by IP address
501
+ external_ip_to_node = {}
502
+
503
+ def ensure_external_ip(address):
504
+ if address not in external_ip_to_node:
505
+ external_ip_to_node[address] = graph.add_node(
506
+ f"external_ip::{address}",
507
+ "external_ip",
508
+ {
509
+ "label": address,
510
+ },
511
+ )
512
+ return external_ip_to_node[address]
513
+
514
+ for socket, socket_node in socket_to_node.items():
515
+ pids = socket_to_pids.get(socket)
516
+
517
+ if pids:
518
+ continue
519
+
520
+ external_ip = socket[0].ip
521
+ external_ip_node = ensure_external_ip(external_ip)
522
+
523
+ # add connection
524
+ graph.add_edge(
525
+ external_ip_node.id,
526
+ socket_node.id,
527
+ "external_socket",
528
+ )
529
+
530
+ return graph