@passioncode-ai/fabric-agent-adapter 0.73.0 → 0.75.0

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +11 -7
  3. package/SECURITY.md +17 -0
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +6 -2
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +3 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +16 -17
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +14 -2
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/channel-agent.example.json +1 -1
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -1
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -1
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +6 -2
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -1
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +3 -1
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +10 -4
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +33 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +6 -4
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +4 -2
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +19 -2
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +37 -3
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +38 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +53 -3
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -5
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +545 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +43 -7
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +29 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +13 -4
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +845 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +35 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +214 -9
  35. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +8 -5
  36. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +6 -2
  37. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +380 -0
  38. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +6 -2
  39. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +8 -2
  40. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +3 -2
  41. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/practices.md +1 -1
  42. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  43. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
@@ -0,0 +1,845 @@
1
+ """peer_owner: which OS account owns each end of a loopback TCP connection.
2
+
3
+ peer_owner 1.1.0+kit.1: Observatory's peer_owner 1.1.0, vendored into the Fabric service kit as `fabric_peer_owner` (layer L0;
4
+ DEC-0154 HA-2, FAA-226) from Project Observatory's `observatory/engine/peer_owner.py` at
5
+ commit f86f0622 (passioncode-ai/project-observatory-dashboard, the same licence). It differs
6
+ from that file in this paragraph, in `Owner` and `Row` (NamedTuples here where it has
7
+ frozen dataclasses: a dataclass with postponed annotations needs its module in sys.modules,
8
+ and a service may load a kit file by its path without one), and in writing `Optional[X]`
9
+ for `X | None` in its signatures (the kit's floor is Python 3.9, where `typing.get_type_hints`
10
+ cannot read `X | None`), in `classify` / `classify_sid` asking for this process's own uid or
11
+ SID before the system's (a client running as root or SYSTEM owns root's listener), and in
12
+ two Linux and selection rules: on Linux `server_end` reads an accepted connection's own row
13
+ and, for one not accept()ed yet (printed with uid 0 and inode 0), the listener row on its
14
+ local address (state 0A; Observatory's 1.2.0 reading), `peer` reads a client
15
+ row with inode 0 (a closed client) as unknown, and several rows of one owner are that owner;
16
+ a row printed twice by a `/proc` read under churn counts once, and a `/proc` question that
17
+ finds no row or several reads the table once more; ctypes is imported where it is used, so a
18
+ Python without it reads netstat on macOS and imports the module everywhere.
19
+ A limit /proc sets: it carries a socket's creator and no pid, so a listening socket that root
20
+ created and handed to another account (systemd socket activation with User=) reads as root's. Standard library only, no import of another kit module.
21
+ The kit's tests are `test/test_fabric_peer_owner.py`, over `test/fixtures/peer-owner/`;
22
+ its Node twin is `fabric-peer-owner.mjs` (netstat, /proc and PowerShell, no ctypes).
23
+
24
+ WHY. Loopback is one per machine, not one per OS account. On a host with several accounts,
25
+ every account's processes reach every service any account listens with on 127.0.0.1, and a
26
+ loopback socket proves nothing about who is on the other end. This module answers that
27
+ question from the host's socket table, read without privileges:
28
+
29
+ listener(port) -> Owner who listens on 127.0.0.1:<port> (also 0.0.0.0, ::1, ::)
30
+ server_end(sock) -> Owner for a client's connected socket: who accepted it
31
+ peer(sock) -> Owner for a server's accepted socket: who connected
32
+ peer_at(server, client) the same as `peer`, from two (host, port) addresses
33
+ own_connection(port, ...) a CheckedHTTPConnection, connected: every connect, the first and
34
+ any reconnect http.client makes after `Connection: close`, checks
35
+ that the server end is this account's, or raises ListenerRefused
36
+ before a byte of the request is sent
37
+
38
+ `Owner.kind` is one of:
39
+
40
+ this-account the socket belongs to the account running this process
41
+ another-account another OS account's
42
+ system uid 0, pid 1 (launchd holds the sockets of socket-activated jobs) or, on
43
+ Windows, the system's own accounts and processes
44
+ unknown the owner could not be told; `reason` says why
45
+ none `listener` only: nothing listens on that port
46
+
47
+ A caller that must not leak (a client about to send a bearer, a server on a multi-account
48
+ host about to read a request) treats everything but `this-account` as a refusal, `unknown`
49
+ included: fail closed.
50
+
51
+ HOW, ON EACH OS. Every read is bounded (MAX_TABLE_BYTES of table, TIMEOUT_SECONDS) and the
52
+ answer is never cached across connections; only `listener` is cached, for LISTENER_TTL. A
53
+ TIME_WAIT row is a connection already closed and is never an answer; two live rows with the same
54
+ addresses are `unknown` (`several-rows`), never a guess.
55
+
56
+ - macOS, primary (`sysctl`): `sysctl net.inet.tcp.pcblist_n` through ctypes, the record list
57
+ `netstat` itself reads. Each connection is an `xinpcb_n` (addresses and ports) followed by an
58
+ `xsocket_n` that carries `so_uid` and `so_last_pid` (XNU `bsd/netinet/in_pcb.h` and
59
+ `bsd/sys/socketvar.h`, both under `#pragma pack(4)`); the two are paired by the pcb handle
60
+ (`xi_inpp` equals `so_pcb`). Measured on macOS 26: about 4 ms for 770 sockets, every pid equal
61
+ to the one `netstat` printed. The `xtcpcb_n` record that follows gives the TCP state
62
+ (`t_state`), so listeners and TIME_WAIT rows are told apart. The layout is not an API, so the
63
+ first read in a process calibrates it: it opens a connected pair of its own (listen, connect,
64
+ accept) and the listener and both ends of the pair must read back with their exact addresses,
65
+ this process's uid and its pid. A layout that fails calibration is not used again in that
66
+ process.
67
+ - macOS, fallback (`netstat`): `/usr/sbin/netstat -anv -p tcp`, its `process:pid` column, then
68
+ `ps -o uid= -p PID` for the owner of the one row a question needs. Measured: about 20 ms over
69
+ 846 sockets, and it names the pids of root's listeners, which `lsof` run by an ordinary account
70
+ cannot see. Another local service implemented the same check this way; it is kept as the
71
+ documented fallback for a macOS whose record layout the calibration refuses.
72
+ - Linux (`proc`): `/proc/net/tcp` and `/proc/net/tcp6`, whose `uid` column every account may
73
+ read (also under `hidepid=2`, which hides `/proc/<pid>` and not this file). No pid: that is
74
+ only readable for this account's own processes, and the decision needs the uid alone.
75
+ - Windows (`iphlpapi`): `GetExtendedTcpTable(TCP_TABLE_OWNER_PID_ALL)` gives each row's owning
76
+ pid; `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)`, `OpenProcessToken(TOKEN_QUERY)` and
77
+ `GetTokenInformation(TokenUser)` give its account SID, compared with this process's. A process
78
+ that cannot be opened (another account's, usually) is `unknown`, never `another-account`:
79
+ the caller refuses either way, and the reason says which it was. `Owner.uid` is None there.
80
+ - Anything else: `unknown`, reason `unsupported-platform`.
81
+
82
+ `LOCAL_PEERCRED`, `getpeereid` and Linux `SO_PEERCRED` answer this question for Unix-domain
83
+ sockets only, not for TCP, which is why a socket table is read here at all.
84
+
85
+ WHAT IT NEVER DOES. It never reads a byte from the socket it is asked about, never resolves a
86
+ name, never sends anything, and never needs a privilege. It reports the process names
87
+ `netstat` prints only as far as the pid behind them.
88
+ """
89
+ from __future__ import annotations
90
+
91
+ import http.client
92
+ import ipaddress
93
+ import os
94
+ import socket
95
+ import struct
96
+ import subprocess
97
+ import sys
98
+ import threading
99
+ import time
100
+ from typing import NamedTuple, Optional, Tuple
101
+
102
+ # This file's own version, for projects that copy it; not the engine's (that is configuration.VERSION).
103
+ PEER_OWNER_VERSION = "1.1.0+kit.1" # Observatory's 1.1.0 with the kit's differences above
104
+
105
+ THIS_ACCOUNT = "this-account"
106
+ ANOTHER_ACCOUNT = "another-account"
107
+ SYSTEM = "system"
108
+ UNKNOWN = "unknown"
109
+ NONE = "none"
110
+ KINDS = (THIS_ACCOUNT, ANOTHER_ACCOUNT, SYSTEM, UNKNOWN, NONE)
111
+
112
+ #: The most table bytes one read accepts. The spec of this module asked for 1 MB; measured,
113
+ #: `pcblist_n` costs about 860 bytes a socket, so 1 MB would hold about 1,200 sockets and a busy
114
+ #: developer machine passes that. 4 MiB holds about 4,800.
115
+ MAX_TABLE_BYTES = 4 * 1024 * 1024
116
+ #: Every subprocess this module starts is stopped after this many seconds.
117
+ TIMEOUT_SECONDS = 2.0
118
+ #: `listener(port)` answers from a read at most this old (a doctor asks several times in a row).
119
+ LISTENER_TTL = 1.0
120
+
121
+ NETSTAT = "/usr/sbin/netstat"
122
+ PS = "/bin/ps"
123
+
124
+ #: Client codes of `own_connection` (ListenerRefused.code).
125
+ SERVER_NOT_OWN = "server-not-own"
126
+ SERVER_UNVERIFIED = "server-unverified"
127
+ SERVER_NOT_THIS_WORKSPACE = "server-not-this-workspace"
128
+
129
+
130
+ # The fields carry real types, not postponed strings: a NamedTuple's generated __new__ resolves its annotations in a
131
+ # namespace without builtins, where `int | None` as a string is unresolvable (and a TypeError on Python 3.9).
132
+ _OwnerFields = NamedTuple("_OwnerFields", [("kind", str), ("uid", Optional[int]), ("pid", Optional[int]),
133
+ ("method", str), ("reason", Optional[str])])
134
+ _OwnerFields.__new__.__defaults__ = (None, None, "none", None)
135
+ _RowFields = NamedTuple("_RowFields", [("local", Tuple[str, int]), ("remote", Tuple[str, int]), ("listening", bool),
136
+ ("uid", Optional[int]), ("pid", Optional[int]), ("time_wait", bool)])
137
+ _RowFields.__new__.__defaults__ = (None, None, False)
138
+
139
+
140
+ class Owner(_OwnerFields):
141
+ """Who owns one end of a connection, or a listener. `reason` says why when `kind` is unknown."""
142
+ __slots__ = ()
143
+
144
+ def as_dict(self) -> dict:
145
+ return dict(self._asdict())
146
+
147
+
148
+ class Row(_RowFields):
149
+ """One TCP socket of the host's table. `uid` is None where the table carries no owner
150
+ (netstat, Windows): it is resolved from `pid` only for the row a question selects."""
151
+ __slots__ = ()
152
+
153
+
154
+ class TableUnreadable(Exception):
155
+ """The socket table could not be read; the message is the reason, never a socket's content."""
156
+
157
+
158
+ class ListenerRefused(Exception):
159
+ """`own_connection` refused the server end; nothing was sent on the connection."""
160
+
161
+ def __init__(self, code: str, message: str, owner: Optional[Owner] = None) -> None:
162
+ super().__init__(message)
163
+ self.code = code
164
+ self.owner = owner
165
+
166
+
167
+ # -- addresses --------------------------------------------------------------------------------------------------------
168
+
169
+ def norm_host(host: str) -> str:
170
+ """One spelling per address: an IPv4-mapped IPv6 address is its IPv4 form, `*` is the
171
+ wildcard, `localhost` is 127.0.0.1. Anything unparseable is returned unchanged (and matches
172
+ only itself)."""
173
+ if host in ("*", ""):
174
+ return "*"
175
+ if host == "localhost":
176
+ return "127.0.0.1"
177
+ host = host.split("%", 1)[0]
178
+ try:
179
+ ip = ipaddress.ip_address(host)
180
+ except ValueError:
181
+ return host
182
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
183
+ ip = ip.ipv4_mapped
184
+ if ip.is_unspecified:
185
+ return "*"
186
+ return str(ip)
187
+
188
+
189
+ def _address(addr) -> tuple[str, int]:
190
+ """A socket address (2-tuple for IPv4, 4-tuple for IPv6) as a normalized (host, port)."""
191
+ return norm_host(str(addr[0])), int(addr[1])
192
+
193
+
194
+ # -- classification ---------------------------------------------------------------------------------------------------
195
+
196
+ def _posix_uid() -> Optional[int]:
197
+ """This process's uid, or None where the OS has none (Windows compares SIDs instead). The one
198
+ place this module reads it: this file is vendored without its project's OS layer, so the uid
199
+ is read here, once, on the POSIX paths only (an engine boundary test names this function)."""
200
+ return os.getuid() if hasattr(os, "getuid") else None
201
+
202
+
203
+ def classify(uid: Optional[int], pid: Optional[int], *, method: str, own_uid: Optional[int] = None) -> Owner:
204
+ """`this-account` for this process's uid, `system` for uid 0 or pid 1, else `another-account`. A client that runs
205
+ as root itself reads root's listener as its own account's (the kit's order; the vendored file put uid 0 first)."""
206
+ if uid is None:
207
+ return Owner(UNKNOWN, None, pid, method, "no-owner")
208
+ if own_uid is None:
209
+ own_uid = _posix_uid()
210
+ if own_uid is not None and uid == own_uid and pid != 1:
211
+ return Owner(THIS_ACCOUNT, uid, pid, method)
212
+ if uid == 0 or pid == 1:
213
+ return Owner(SYSTEM, uid, pid, method)
214
+ return Owner(ANOTHER_ACCOUNT, uid, pid, method)
215
+
216
+
217
+ # -- parsers: pure functions over captured text or bytes (the tests' fixtures) ---------------------------------------
218
+
219
+ def parse_netstat(text: str) -> list[Row]:
220
+ """`netstat -anv -p tcp` on macOS. Process names may hold spaces ("Some App:123"), so the
221
+ `process:pid` column is what is left between the leading and the trailing columns, both
222
+ counted from the header. A header without `process:pid` (an older macOS) is unreadable."""
223
+ lines = text.splitlines()
224
+ header = next((i for i, line in enumerate(lines) if "process:pid" in line and "Proto" in line), None)
225
+ if header is None:
226
+ raise TableUnreadable("netstat-format: no process:pid column")
227
+ names = lines[header].split()
228
+ at = names.index("process:pid")
229
+ # "Local Address" and "Foreign Address" are two header words each and one data column each.
230
+ leading = at - sum(1 for w in names[:at] if w == "Address")
231
+ trailing = len(names) - at - 1
232
+ rows: list[Row] = []
233
+ for line in lines[header + 1:]:
234
+ tok = line.split()
235
+ if len(tok) < leading + trailing + 1 or not tok[0].startswith("tcp"):
236
+ continue
237
+ local = _netstat_address(tok[3])
238
+ remote = _netstat_address(tok[4])
239
+ if local is None or remote is None:
240
+ continue
241
+ process = " ".join(tok[leading:len(tok) - trailing])
242
+ pid = None
243
+ if ":" in process:
244
+ tail = process.rsplit(":", 1)[1]
245
+ pid = int(tail) if tail.isdigit() else None
246
+ rows.append(Row(local, remote, tok[5] == "LISTEN", None, pid, tok[5] == "TIME_WAIT"))
247
+ return rows
248
+
249
+
250
+ def _netstat_address(text: str) -> Optional[tuple[str, int]]:
251
+ """`127.0.0.1.47311`, `::1.47070`, `*.47555`, `*.*`: the port after the last dot. A long IPv6
252
+ address is cut short by netstat, which only ever matters off loopback."""
253
+ if "." not in text:
254
+ return None
255
+ host, port = text.rsplit(".", 1)
256
+ try:
257
+ return norm_host(host), (0 if port == "*" else int(port))
258
+ except ValueError:
259
+ return None
260
+
261
+
262
+ def parse_ps_uid(text: str) -> Optional[int]:
263
+ """`ps -o uid= -p PID`: one number, or nothing when the process is gone."""
264
+ text = text.strip()
265
+ return int(text) if text.isdigit() else None
266
+
267
+
268
+ def parse_proc_net_tcp(text: str, *, family: int, byteorder: Optional[str] = None) -> list[Row]:
269
+ """`/proc/net/tcp` (family AF_INET) or `/proc/net/tcp6` (AF_INET6). The kernel prints each
270
+ 32-bit word of an address in host byte order and each port in network order, in hex. A
271
+ connected row with inode 0 has no socket yet (a connection the server has not accept()ed,
272
+ or one already closed): the kernel prints uid 0 for it, so its uid is not read (None);
273
+ `_owner_of` reads a server end not yet accepted as its listener's and a closed client as
274
+ unknown (the kit's addition, 0.74.0 unit readings 3 and 4)."""
275
+ order = byteorder or sys.byteorder
276
+ rows: list[Row] = []
277
+ for line in text.splitlines()[1:]:
278
+ tok = line.split()
279
+ if len(tok) < 10 or not tok[0].endswith(":"):
280
+ continue
281
+ try:
282
+ local = _proc_address(tok[1], family, order)
283
+ remote = _proc_address(tok[2], family, order)
284
+ uid = int(tok[7])
285
+ except ValueError:
286
+ continue
287
+ listening, time_wait = tok[3].upper() == "0A", tok[3].upper() == "06"
288
+ if not listening and tok[9] == "0":
289
+ uid = None
290
+ rows.append(Row(local, remote, listening, uid, None, time_wait))
291
+ return rows
292
+
293
+
294
+ def _proc_address(text: str, family: int, order: str) -> tuple[str, int]:
295
+ host, port = text.split(":")
296
+ raw = bytes.fromhex(host)
297
+ if order == "little":
298
+ raw = b"".join(raw[i:i + 4][::-1] for i in range(0, len(raw), 4))
299
+ want = 4 if family == socket.AF_INET else 16
300
+ if len(raw) != want:
301
+ raise ValueError("address length")
302
+ return norm_host(socket.inet_ntop(family, raw)), int(port, 16)
303
+
304
+
305
+ #: XNU's export records (`#pragma pack(4)`): kinds and the offsets this module reads.
306
+ XSO_SOCKET = 0x001
307
+ XSO_INPCB = 0x010
308
+ XSO_TCPCB = 0x020
309
+ _TCP_STATE = 36 # xtcpcb_n.t_state: TCPS_LISTEN 1, TCPS_TIME_WAIT 10
310
+ _TCP_MIN = 40
311
+ _TCPS_LISTEN = 1
312
+ _TCPS_TIME_WAIT = 10
313
+ _INP_HANDLE = slice(8, 16) # xinpcb_n.xi_inpp
314
+ _INP_PORTS = 16 # inp_fport, inp_lport, network order
315
+ _INP_VFLAG = 44 # inp_vflag: INP_IPV4 0x1, INP_IPV6 0x2
316
+ _INP_FOREIGN = 48 # 16 bytes; an IPv4 address in its last 4 (in_addr_4in6)
317
+ _INP_LOCAL = 64
318
+ _INP_MIN = 80
319
+ _SO_PCB = slice(28, 36) # xsocket_n.so_pcb, equal to the inpcb's xi_inpp
320
+ _SO_UID = 64 # so_uid, then so_last_pid
321
+ _SO_MIN = 72
322
+
323
+
324
+ def parse_pcblist_n(blob: bytes) -> list[Row]:
325
+ """The `net.inet.tcp.pcblist_n` record list: an `xinpgen` header, then for each socket a run
326
+ of records each starting with (length, kind) and padded to 8 bytes, ended by a second
327
+ `xinpgen`. Records of other kinds (socket buffers, stats, the tcpcb) are skipped by length."""
328
+ if len(blob) < 8:
329
+ raise TableUnreadable("pcblist_n: short table")
330
+ first = struct.unpack_from("<I", blob, 0)[0]
331
+ if first < 8 or first > 64:
332
+ raise TableUnreadable("pcblist_n: unknown header")
333
+ off = (first + 7) & ~7
334
+ pcbs: dict[bytes, tuple] = {}
335
+ owners: dict[bytes, tuple[int, int]] = {}
336
+ states: dict[bytes, int] = {}
337
+ current: Optional[bytes] = None # the inpcb the records that follow belong to
338
+ while off + 8 <= len(blob):
339
+ length, kind = struct.unpack_from("<II", blob, off)
340
+ if length < 8:
341
+ break
342
+ if length == first and off + length >= len(blob) - 8:
343
+ break # the closing xinpgen
344
+ if off + length > len(blob):
345
+ raise TableUnreadable("pcblist_n: a record runs past the table")
346
+ if kind == XSO_INPCB and length >= _INP_MIN:
347
+ rec = blob[off:off + length]
348
+ fport, lport = struct.unpack_from(">HH", rec, _INP_PORTS)
349
+ if rec[_INP_VFLAG] & 0x1:
350
+ foreign = socket.inet_ntop(socket.AF_INET, rec[_INP_FOREIGN + 12:_INP_FOREIGN + 16])
351
+ local = socket.inet_ntop(socket.AF_INET, rec[_INP_LOCAL + 12:_INP_LOCAL + 16])
352
+ else:
353
+ foreign = socket.inet_ntop(socket.AF_INET6, rec[_INP_FOREIGN:_INP_FOREIGN + 16])
354
+ local = socket.inet_ntop(socket.AF_INET6, rec[_INP_LOCAL:_INP_LOCAL + 16])
355
+ current = bytes(rec[_INP_HANDLE])
356
+ pcbs[current] = (norm_host(local), lport, norm_host(foreign), fport)
357
+ elif kind == XSO_SOCKET and length >= _SO_MIN:
358
+ uid, pid = struct.unpack_from("<Ii", blob, off + _SO_UID)
359
+ owners[bytes(blob[off + _SO_PCB.start:off + _SO_PCB.stop])] = (uid, pid)
360
+ elif kind == XSO_TCPCB and length >= _TCP_MIN and current is not None:
361
+ states[current] = struct.unpack_from("<i", blob, off + _TCP_STATE)[0]
362
+ off += (length + 7) & ~7
363
+ rows = []
364
+ for handle, (local, lport, foreign, fport) in pcbs.items():
365
+ uid, pid = owners.get(handle, (None, None))
366
+ state = states.get(handle)
367
+ # Without a tcpcb, a listener is the row with no peer: foreign port 0, foreign address the wildcard.
368
+ listening = state == _TCPS_LISTEN if state is not None else (fport == 0 and foreign == "*")
369
+ rows.append(Row((local, lport), (foreign, fport), listening, uid, pid, state == _TCPS_TIME_WAIT))
370
+ return rows
371
+
372
+
373
+ #: Windows' MIB rows (iphlpapi): sizes and the LISTEN and TIME_WAIT states.
374
+ _MIB_TCP_STATE_LISTEN = 2
375
+ _MIB_TCP_STATE_TIME_WAIT = 11
376
+
377
+
378
+ def parse_windows_tcp_table(blob: bytes, family: int) -> list[Row]:
379
+ """A `GetExtendedTcpTable(..., TCP_TABLE_OWNER_PID_ALL)` buffer: a DWORD count, then
380
+ MIB_TCPROW_OWNER_PID (24 bytes) or MIB_TCP6ROW_OWNER_PID (56 bytes) rows. Addresses are in
381
+ network order in memory; a port is the low 16 bits of its DWORD, in network order."""
382
+ if len(blob) < 4:
383
+ raise TableUnreadable("iphlpapi: short table")
384
+ count = struct.unpack_from("<I", blob, 0)[0]
385
+ size, base = (24, 4) if family == socket.AF_INET else (56, 4)
386
+ rows = []
387
+ for i in range(count):
388
+ at = base + i * size
389
+ if at + size > len(blob):
390
+ break
391
+ if family == socket.AF_INET:
392
+ state = struct.unpack_from("<I", blob, at)[0]
393
+ local = socket.inet_ntop(family, blob[at + 4:at + 8])
394
+ lport = struct.unpack_from(">H", blob, at + 8)[0]
395
+ remote = socket.inet_ntop(family, blob[at + 12:at + 16])
396
+ rport = struct.unpack_from(">H", blob, at + 16)[0]
397
+ pid = struct.unpack_from("<I", blob, at + 20)[0]
398
+ else:
399
+ local = socket.inet_ntop(family, blob[at:at + 16])
400
+ lport = struct.unpack_from(">H", blob, at + 20)[0]
401
+ remote = socket.inet_ntop(family, blob[at + 24:at + 40])
402
+ rport = struct.unpack_from(">H", blob, at + 44)[0]
403
+ state = struct.unpack_from("<I", blob, at + 48)[0]
404
+ pid = struct.unpack_from("<I", blob, at + 52)[0]
405
+ rows.append(Row((norm_host(local), lport), (norm_host(remote), rport),
406
+ state == _MIB_TCP_STATE_LISTEN, None, pid, state == _MIB_TCP_STATE_TIME_WAIT))
407
+ return rows
408
+
409
+
410
+ # -- readers: the live table on this host ------------------------------------------------------------------------------
411
+
412
+ _LOCK = threading.Lock()
413
+ #: macOS: None until the first read calibrates the sysctl layout, then True or False for this process.
414
+ _SYSCTL_OK: list = [None]
415
+
416
+
417
+ def _run(argv: list[str]) -> str:
418
+ try:
419
+ done = subprocess.run(argv, capture_output=True, timeout=TIMEOUT_SECONDS, check=False,
420
+ stdin=subprocess.DEVNULL)
421
+ except (OSError, subprocess.TimeoutExpired) as exc:
422
+ raise TableUnreadable(f"{os.path.basename(argv[0])}: {type(exc).__name__}") from None
423
+ if done.returncode != 0:
424
+ raise TableUnreadable(f"{os.path.basename(argv[0])}: exit {done.returncode}")
425
+ if len(done.stdout) > MAX_TABLE_BYTES:
426
+ raise TableUnreadable(f"{os.path.basename(argv[0])}: table over {MAX_TABLE_BYTES} bytes")
427
+ return done.stdout.decode("utf-8", "replace")
428
+
429
+
430
+ def _sysctl_blob(name: bytes = b"net.inet.tcp.pcblist_n") -> bytes:
431
+ try:
432
+ import ctypes
433
+ import ctypes.util
434
+ except ImportError: # a CPython built without libffi: netstat reads
435
+ raise TableUnreadable("sysctl: no ctypes") from None
436
+ try:
437
+ libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
438
+ except OSError as exc:
439
+ raise TableUnreadable(f"sysctl: libc: {type(exc).__name__}") from None
440
+ fn = libc.sysctlbyname
441
+ fn.argtypes = [ctypes.c_char_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t), ctypes.c_void_p,
442
+ ctypes.c_size_t]
443
+ fn.restype = ctypes.c_int
444
+ for _attempt in range(3):
445
+ need = ctypes.c_size_t(0)
446
+ if fn(name, None, ctypes.byref(need), None, 0) != 0:
447
+ raise TableUnreadable(f"sysctl: errno {ctypes.get_errno()}")
448
+ size = min(int(need.value * 1.25) + 4096, MAX_TABLE_BYTES)
449
+ if need.value > MAX_TABLE_BYTES:
450
+ raise TableUnreadable(f"sysctl: table over {MAX_TABLE_BYTES} bytes")
451
+ buf = ctypes.create_string_buffer(size)
452
+ got = ctypes.c_size_t(size)
453
+ if fn(name, buf, ctypes.byref(got), None, 0) == 0:
454
+ return buf.raw[:got.value]
455
+ if ctypes.get_errno() != 12: # ENOMEM: the table grew, ask again
456
+ raise TableUnreadable(f"sysctl: errno {ctypes.get_errno()}")
457
+ raise TableUnreadable("sysctl: the table kept growing")
458
+
459
+
460
+ def calibration_holds(rows: list[Row], server: tuple[str, int], client: tuple[str, int], *,
461
+ uid: int, pid: int) -> bool:
462
+ """The listener and both ends of one connected pair, each exactly once, live, with their exact
463
+ addresses and this uid and pid: what a layout must read back to be trusted."""
464
+ def one(local, remote, listening):
465
+ hits = [r for r in rows if r.local == local and r.remote == remote and r.listening == listening
466
+ and not r.time_wait]
467
+ return len(hits) == 1 and hits[0].uid == uid and hits[0].pid == pid
468
+ return (one(server, ("*", 0), True) and one(server, client, False) and one(client, server, False))
469
+
470
+
471
+ def _calibrate_sysctl() -> bool:
472
+ """Open a connected pair of this process's own (listen, connect, accept) and read it back
473
+ (`calibration_holds`). A layout that does not hold is never trusted in this process, and
474
+ `netstat` answers instead."""
475
+ probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
476
+ client = accepted = None
477
+ try:
478
+ probe.bind(("127.0.0.1", 0))
479
+ probe.listen(1)
480
+ client = socket.create_connection(probe.getsockname(), timeout=TIMEOUT_SECONDS)
481
+ accepted, _ = probe.accept()
482
+ rows = parse_pcblist_n(_sysctl_blob())
483
+ return calibration_holds(rows, _address(probe.getsockname()), _address(client.getsockname()),
484
+ uid=_posix_uid(), pid=os.getpid())
485
+ except (TableUnreadable, OSError, struct.error, ValueError):
486
+ return False
487
+ finally:
488
+ for s in (client, accepted, probe):
489
+ if s is not None:
490
+ s.close()
491
+
492
+
493
+ def method() -> str:
494
+ """The method the next read on this host uses: sysctl, netstat, proc, iphlpapi or none."""
495
+ if sys.platform == "darwin":
496
+ with _LOCK:
497
+ if _SYSCTL_OK[0] is None:
498
+ _SYSCTL_OK[0] = _calibrate_sysctl()
499
+ return "sysctl" if _SYSCTL_OK[0] else "netstat"
500
+ if sys.platform.startswith("linux"):
501
+ return "proc"
502
+ if sys.platform == "win32":
503
+ return "iphlpapi"
504
+ return "none"
505
+
506
+
507
+ def read_table() -> tuple[list[Row], str]:
508
+ """The host's TCP sockets and the method that read them; TableUnreadable with the reason."""
509
+ how = method()
510
+ if how == "sysctl":
511
+ try:
512
+ return parse_pcblist_n(_sysctl_blob()), how
513
+ except (TableUnreadable, struct.error, ValueError):
514
+ how = "netstat" # this read only; calibration stands
515
+ if how == "netstat":
516
+ return parse_netstat(_run([NETSTAT, "-anv", "-p", "tcp"])), how
517
+ if how == "proc":
518
+ rows: list[Row] = []
519
+ for name, family in (("/proc/net/tcp", socket.AF_INET), ("/proc/net/tcp6", socket.AF_INET6)):
520
+ try:
521
+ with open(name, "rb") as fh:
522
+ raw = fh.read(MAX_TABLE_BYTES + 1)
523
+ except FileNotFoundError:
524
+ continue # no IPv6 on this kernel
525
+ except OSError as exc:
526
+ raise TableUnreadable(f"proc: {type(exc).__name__}") from None
527
+ if len(raw) > MAX_TABLE_BYTES:
528
+ raise TableUnreadable(f"proc: table over {MAX_TABLE_BYTES} bytes")
529
+ rows += parse_proc_net_tcp(raw.decode("ascii", "replace"), family=family)
530
+ return rows, how
531
+ if how == "iphlpapi":
532
+ return _windows_table(), how
533
+ raise TableUnreadable("unsupported-platform")
534
+
535
+
536
+ def resolve(row: Row, how: str) -> Owner:
537
+ """The owner of one selected row: its uid when the table carried one, else the pid's."""
538
+ if sys.platform == "win32" and how == "iphlpapi":
539
+ return _windows_owner(row.pid, how)
540
+ if row.uid is not None:
541
+ return classify(row.uid, row.pid, method=how)
542
+ if row.pid is None or row.pid <= 0:
543
+ return Owner(UNKNOWN, None, row.pid, how, "no-pid")
544
+ if row.pid == 1:
545
+ return Owner(SYSTEM, None, 1, how)
546
+ try:
547
+ uid = parse_ps_uid(_run([PS, "-o", "uid=", "-p", str(row.pid)]))
548
+ except TableUnreadable as exc:
549
+ return Owner(UNKNOWN, None, row.pid, how, f"ps: {exc}")
550
+ if uid is None:
551
+ return Owner(UNKNOWN, None, row.pid, how, "process-gone")
552
+ return classify(uid, row.pid, method=how)
553
+
554
+
555
+ # -- Windows ----------------------------------------------------------------------------------------------------------
556
+
557
+ def _windows_table() -> list[Row]: # pragma: no cover - exercised on Windows only
558
+ try:
559
+ import ctypes
560
+ from ctypes import wintypes
561
+ except ImportError:
562
+ raise TableUnreadable("iphlpapi: no ctypes") from None
563
+ iphlpapi = ctypes.WinDLL("iphlpapi")
564
+ fn = iphlpapi.GetExtendedTcpTable
565
+ fn.argtypes = [ctypes.c_void_p, ctypes.POINTER(wintypes.DWORD), wintypes.BOOL, wintypes.ULONG,
566
+ ctypes.c_int, wintypes.ULONG]
567
+ fn.restype = wintypes.DWORD
568
+ rows: list[Row] = []
569
+ for family in (socket.AF_INET, socket.AF_INET6):
570
+ size = wintypes.DWORD(0)
571
+ fn(None, ctypes.byref(size), False, family, 5, 0) # 5: TCP_TABLE_OWNER_PID_ALL
572
+ for _attempt in range(3):
573
+ if size.value > MAX_TABLE_BYTES:
574
+ raise TableUnreadable(f"iphlpapi: table over {MAX_TABLE_BYTES} bytes")
575
+ buf = ctypes.create_string_buffer(max(size.value, 4))
576
+ code = fn(buf, ctypes.byref(size), False, family, 5, 0)
577
+ if code == 0:
578
+ rows += parse_windows_tcp_table(buf.raw[:size.value], family)
579
+ break
580
+ if code != 122: # ERROR_INSUFFICIENT_BUFFER
581
+ raise TableUnreadable(f"iphlpapi: error {code}")
582
+ else:
583
+ raise TableUnreadable("iphlpapi: the table kept growing")
584
+ return rows
585
+
586
+
587
+ #: Accounts of the system itself: LocalSystem, LocalService, NetworkService.
588
+ _SYSTEM_SIDS = frozenset({"S-1-5-18", "S-1-5-19", "S-1-5-20"})
589
+
590
+
591
+ def _windows_sid(pid: Optional[int]) -> str: # pragma: no cover - exercised on Windows only
592
+ """The account SID of a process (None: this one). Raises OSError when it cannot be read."""
593
+ try:
594
+ import ctypes
595
+ from ctypes import wintypes
596
+ except ImportError:
597
+ raise OSError("no ctypes") from None
598
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
599
+ advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
600
+ kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
601
+ kernel32.OpenProcess.restype = wintypes.HANDLE
602
+ kernel32.GetCurrentProcess.restype = wintypes.HANDLE
603
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
604
+ kernel32.LocalFree.argtypes = [ctypes.c_void_p]
605
+ advapi32.OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)]
606
+ advapi32.GetTokenInformation.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD,
607
+ ctypes.POINTER(wintypes.DWORD)]
608
+ advapi32.ConvertSidToStringSidW.argtypes = [ctypes.c_void_p, ctypes.POINTER(wintypes.LPWSTR)]
609
+ if pid is None:
610
+ process, close = kernel32.GetCurrentProcess(), False
611
+ else:
612
+ process = kernel32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
613
+ close = True
614
+ if not process:
615
+ raise OSError(ctypes.get_last_error(), "OpenProcess")
616
+ token = wintypes.HANDLE()
617
+ try:
618
+ if not advapi32.OpenProcessToken(process, 0x0008, ctypes.byref(token)): # TOKEN_QUERY
619
+ raise OSError(ctypes.get_last_error(), "OpenProcessToken")
620
+ try:
621
+ size = wintypes.DWORD(0)
622
+ advapi32.GetTokenInformation(token, 1, None, 0, ctypes.byref(size)) # TokenUser
623
+ buf = ctypes.create_string_buffer(max(size.value, 1))
624
+ if not advapi32.GetTokenInformation(token, 1, buf, size, ctypes.byref(size)):
625
+ raise OSError(ctypes.get_last_error(), "GetTokenInformation")
626
+ sid = ctypes.cast(buf, ctypes.POINTER(ctypes.c_void_p))[0] # TOKEN_USER.User.Sid
627
+ text = wintypes.LPWSTR()
628
+ if not advapi32.ConvertSidToStringSidW(sid, ctypes.byref(text)):
629
+ raise OSError(ctypes.get_last_error(), "ConvertSidToStringSidW")
630
+ try:
631
+ return str(text.value)
632
+ finally:
633
+ kernel32.LocalFree(text)
634
+ finally:
635
+ kernel32.CloseHandle(token)
636
+ finally:
637
+ if close:
638
+ kernel32.CloseHandle(process)
639
+
640
+
641
+ def classify_sid(sid: Optional[str], pid: Optional[int], *, own_sid: str, method: str = "iphlpapi") -> Owner:
642
+ """Windows: the System process and the system's own accounts are `system`, this process's
643
+ SID `this-account`, any other SID `another-account`, an unreadable one `unknown`."""
644
+ if pid in (0, 4):
645
+ return Owner(SYSTEM, None, pid, method)
646
+ if sid is None:
647
+ return Owner(UNKNOWN, None, pid, method, "process-not-readable")
648
+ if sid == own_sid:
649
+ return Owner(THIS_ACCOUNT, None, pid, method) # before the system SIDs: a client running as SYSTEM
650
+ if sid in _SYSTEM_SIDS:
651
+ return Owner(SYSTEM, None, pid, method)
652
+ return Owner(ANOTHER_ACCOUNT, None, pid, method)
653
+
654
+
655
+ def _windows_owner(pid: Optional[int], how: str) -> Owner: # pragma: no cover - exercised on Windows only
656
+ try:
657
+ own = _windows_sid(None)
658
+ except OSError as exc:
659
+ return Owner(UNKNOWN, None, pid, how, f"own-sid: {exc.args[-1] if exc.args else type(exc).__name__}")
660
+ if pid in (0, 4):
661
+ return classify_sid(None, pid, own_sid=own, method=how)
662
+ try:
663
+ sid = _windows_sid(pid)
664
+ except OSError:
665
+ sid = None
666
+ return classify_sid(sid, pid, own_sid=own, method=how)
667
+
668
+
669
+ # -- the questions ----------------------------------------------------------------------------------------------------
670
+
671
+ def find(rows: list[Row], local: tuple[str, int], remote: tuple[str, int]) -> list[Row]:
672
+ """The live connected rows with exactly this local and remote address: one, or the caller
673
+ cannot tell. A TIME_WAIT row is a connection already closed and is left out. A row printed twice is one
674
+ socket: Linux resumes a `/proc` read that sockets came and went under by position, and can print the same
675
+ row again (0.74.0 seam reading 6); rows equal in every field count once."""
676
+ return list(dict.fromkeys(row for row in rows
677
+ if not row.listening and not row.time_wait and row.local == local
678
+ and row.remote == remote))
679
+
680
+
681
+ def _owner_of(local: tuple[str, int], remote: tuple[str, int], server: bool = False) -> Owner:
682
+ for _ in range(2):
683
+ try:
684
+ rows, how = read_table()
685
+ except TableUnreadable as exc:
686
+ return Owner(UNKNOWN, None, None, method(), str(exc))
687
+ found = find(rows, local, remote)
688
+ if how != "proc" or len(found) == 1:
689
+ break
690
+ # A /proc read under churn can skip a row or print one twice; one more read before answering unknown.
691
+ if not found:
692
+ return Owner(UNKNOWN, None, None, how, "no-row")
693
+ if len(found) > 1:
694
+ return Owner(UNKNOWN, None, None, how, "several-rows")
695
+ if how == "proc" and server and found[0].uid is None:
696
+ # Linux: a connection the server has not accept()ed yet is printed with uid 0 and inode 0; it belongs to the
697
+ # listener on its local address (the exact address, else the wildcard), and dies with it
698
+ # (inet_csk_listen_stop), so that listener answers. An accepted connection's own row carries the uid of the
699
+ # process that accepted it (sock_graft): it answers itself, so a listener swapped after another account
700
+ # accepted the client cannot pass (Project Observatory's 1.2.0 reading, board thread ced00f6e; the contract's
701
+ # pending CT-254, FAA-258).
702
+ # A server row its owner already closed (an orphan) also has inode 0 and reads so: no process holds it, so
703
+ # nothing sent to it reaches anyone (0.74.0 unit reading 6).
704
+ return _one_owner(_listeners_of(rows, found[0].local), how, "not-accepted")
705
+ if how == "proc" and found[0].uid is None:
706
+ # A client's socket with inode 0 has already closed (0.74.0 unit reading 4): never another socket's owner.
707
+ return Owner(UNKNOWN, None, None, how, "closed")
708
+ return resolve(found[0], how)
709
+
710
+
711
+ def _listeners_of(rows: list[Row], local: tuple[str, int]) -> list[Row]:
712
+ """The listeners that took a connection to `local`: on its exact address, else the wildcard."""
713
+ for host in (local[0], "*"):
714
+ hits = [row for row in rows if row.listening and row.local == (host, local[1])]
715
+ if hits:
716
+ return hits
717
+ return []
718
+
719
+
720
+ def _one_owner(found: list[Row], how: str, none_reason: Optional[str] = None) -> Owner:
721
+ """The owner of the rows a question selected: one row, or several of one owner (SO_REUSEPORT workers, an IPv4 and
722
+ an IPv6 socket of one process). Several owners cannot be told apart: unknown (several-rows)."""
723
+ if not found:
724
+ return Owner(UNKNOWN, None, None, how, none_reason) if none_reason else Owner(NONE, None, None, how)
725
+ keys = {(row.uid, row.pid) for row in found}
726
+ uids = {row.uid for row in found}
727
+ if len(keys) == 1 or (None not in uids and len(uids) == 1):
728
+ return resolve(found[0], how)
729
+ return Owner(UNKNOWN, None, None, how, "several-rows")
730
+
731
+
732
+ def peer_at(server: tuple, client: tuple) -> Owner:
733
+ """The owner of the CLIENT socket of the connection between `server` and `client`, as a
734
+ server sees them (its own address, the peer's address): the row whose local address is the
735
+ client's and whose remote address is the server's."""
736
+ return _owner_of(_address(client), _address(server))
737
+
738
+
739
+ def peer(sock: socket.socket) -> Owner:
740
+ """For a server's accepted socket: who connected. Reads nothing from `sock`."""
741
+ try:
742
+ server, client = sock.getsockname(), sock.getpeername()
743
+ except OSError as exc:
744
+ return Owner(UNKNOWN, None, None, "none", f"socket: {type(exc).__name__}")
745
+ return peer_at(server, client)
746
+
747
+
748
+ def server_end(sock: socket.socket) -> Owner:
749
+ """For a client's connected socket: who accepted it. The row whose local address is the
750
+ server's and whose remote address is this socket's own, which is the server process's
751
+ accepted socket, so a listener swapped after `listen` cannot pass for the one that answers.
752
+ On Linux a connection not yet accept()ed has no owner of its own (inode 0): its listener
753
+ answers (the contract's pending CT-254, FAA-258)."""
754
+ try:
755
+ own, server = sock.getsockname(), sock.getpeername()
756
+ except OSError as exc:
757
+ return Owner(UNKNOWN, None, None, "none", f"socket: {type(exc).__name__}")
758
+ return _owner_of(_address(server), _address(own), server=True)
759
+
760
+
761
+ #: port -> (time.monotonic() of the read, the Owner)
762
+ _LISTENERS: dict[int, tuple[float, Owner]] = {}
763
+
764
+ #: The binds that shadow a connection to 127.0.0.1, most specific first.
765
+ _LISTEN_ORDER = ("127.0.0.1", "*", "::1")
766
+
767
+
768
+ def pick_listener(rows: list[Row], port: int) -> list[Row]:
769
+ """The listeners a connection to 127.0.0.1:<port> reaches: those on the most specific bind
770
+ that has any. More than one (a port reused by two sockets) cannot be told apart."""
771
+ for host in _LISTEN_ORDER:
772
+ hits = [row for row in rows if row.listening and row.local == (host, port)]
773
+ if hits:
774
+ return hits
775
+ return []
776
+
777
+
778
+ def listener(port: int) -> Owner:
779
+ """Who listens on 127.0.0.1:<port>, or on 0.0.0.0 or ::1, which take the same connections.
780
+ `none` when nothing does, `unknown` (`several-rows`) when sockets of two owners do. Cached for
781
+ LISTENER_TTL seconds."""
782
+ now = time.monotonic()
783
+ hit = _LISTENERS.get(port)
784
+ if hit is not None and now - hit[0] < LISTENER_TTL:
785
+ return hit[1]
786
+ try:
787
+ rows, how = read_table()
788
+ except TableUnreadable as exc:
789
+ owner = Owner(UNKNOWN, None, None, method(), str(exc))
790
+ else:
791
+ owner = _one_owner(pick_listener(rows, port), how)
792
+ _LISTENERS[port] = (now, owner)
793
+ return owner
794
+
795
+
796
+ def refusal(owner: Owner, port: int, expect_pid: Optional[int] = None) -> Optional[tuple[str, str]]:
797
+ """(code, why) when a client must not send on a connection whose server end is `owner`."""
798
+ if owner.kind in (ANOTHER_ACCOUNT, SYSTEM):
799
+ return SERVER_NOT_OWN, f"the server on port {port} belongs to {owner.kind}"
800
+ if owner.kind != THIS_ACCOUNT:
801
+ return SERVER_UNVERIFIED, f"the owner of the server on port {port} could not be read ({owner.reason})"
802
+ if expect_pid is not None and owner.pid is not None and owner.pid != expect_pid:
803
+ return SERVER_NOT_THIS_WORKSPACE, (f"the server on port {port} is pid {owner.pid}, "
804
+ f"not the expected pid {expect_pid}")
805
+ return None
806
+
807
+
808
+ class CheckedHTTPConnection(http.client.HTTPConnection):
809
+ """An `http.client.HTTPConnection` whose every `connect()` checks the server end before
810
+ returning. `http.client` connects again by itself (`auto_open`) when the server answered
811
+ `Connection: close` or the caller closed the connection, and that new connection may reach a
812
+ different listener; so the check is in `connect()`, not once in `own_connection`. A refused
813
+ connect closes the socket and raises ListenerRefused before any byte of a request is sent."""
814
+
815
+ def __init__(self, host: str, port: int, *, expect_pid: Optional[int] = None, timeout: float = 10.0) -> None:
816
+ super().__init__(host, port, timeout=timeout)
817
+ self.expect_pid = expect_pid
818
+
819
+ def set_tunnel(self, *args, **kwargs): # noqa: D401 - a tunnel would put a proxy's socket under the check
820
+ raise ListenerRefused(SERVER_UNVERIFIED, "a tunnel would check the proxy, not the server; nothing was sent")
821
+
822
+ def connect(self) -> None:
823
+ super().connect()
824
+ owner = server_end(self.sock)
825
+ refused = refusal(owner, self.port, self.expect_pid)
826
+ if refused is not None:
827
+ self.close()
828
+ raise ListenerRefused(refused[0], f"{refused[1]}; nothing was sent", owner)
829
+
830
+
831
+ def own_connection(port: int, *, expect_pid: Optional[int] = None, timeout: float = 10.0,
832
+ host: str = "127.0.0.1") -> CheckedHTTPConnection:
833
+ """A `CheckedHTTPConnection` to `host:port`, connected, whose server end is this account's
834
+ (and, with `expect_pid`, that process's). The check runs on the very connection the caller
835
+ then sends on, so no listener can change between the check and the request, and it runs
836
+ again on every reconnect `http.client` makes. No proxy is consulted. Raises ListenerRefused
837
+ after closing the connection:
838
+
839
+ server-not-own another account's or the system's process accepted it
840
+ server-unverified its owner could not be told (nothing is sent: fail closed)
841
+ server-not-this-workspace this account's process, but not the pid expected
842
+ """
843
+ conn = CheckedHTTPConnection(host, port, expect_pid=expect_pid, timeout=timeout)
844
+ conn.connect()
845
+ return conn