@passioncode-ai/fabric-agent-adapter 0.72.0 → 0.74.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.
- package/CHANGELOG.md +154 -0
- package/README.md +4 -3
- package/SECURITY.md +17 -0
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +6 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +10 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +36 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +6 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +19 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +54 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +841 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent_schema.py +496 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +38 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +53 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +545 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +43 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +29 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +13 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +845 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +35 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +212 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/service-channel-agent-extension.schema.json +700 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +6 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +21 -8
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/practices.md +15 -6
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py
ADDED
|
@@ -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
|