@passioncode-ai/fabric-agent-adapter 0.81.0 → 0.82.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 +78 -0
- package/README.md +5 -3
- package/SECURITY.md +4 -2
- 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 +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +9 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +239 -7
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +3 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +23 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +22 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +11 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +22 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +6 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +73 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +87 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +29 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +8 -153
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +12 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +35 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +77 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +156 -138
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +239 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +117 -19
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +16 -8
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +46 -31
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +18 -16
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -13
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py
CHANGED
|
@@ -1,39 +1,29 @@
|
|
|
1
1
|
"""peer_owner: which OS account owns each end of a loopback TCP connection.
|
|
2
2
|
|
|
3
|
-
peer_owner 1.2.0+kit.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
and
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
digits only and a netstat with only its header reads as `TABLE_RESTRICTED` (FAA-273 items 3 and 4).
|
|
28
|
-
A limit /proc sets: it carries a socket's creator and no pid, so a listening socket that root
|
|
29
|
-
created and handed to another account (systemd socket activation with User=) reads as root's. Standard library only, no import of another kit module.
|
|
30
|
-
The kit's tests are `test/test_fabric_peer_owner.py`, over `test/fixtures/peer-owner/`;
|
|
31
|
-
its Node twin is `fabric-peer-owner.mjs` (netstat, /proc and PowerShell, no ctypes).
|
|
32
|
-
|
|
33
|
-
WHY. Loopback is one per machine, not one per OS account. On a host with several accounts,
|
|
34
|
-
every account's processes reach every service any account listens with on 127.0.0.1, and a
|
|
35
|
-
loopback socket proves nothing about who is on the other end. This module answers that
|
|
36
|
-
question from the host's socket table, read without privileges:
|
|
3
|
+
peer_owner 1.2.0+kit.6: Observatory's peer_owner 1.2.0 (passioncode-ai/project-observatory-dashboard,
|
|
4
|
+
`observatory/engine/peer_owner.py`, first vendored at f86f0622, read against 2180235c; the same licence), the kit's
|
|
5
|
+
`fabric_peer_owner` (layer L0; DEC-0154 HA-2, FAA-226, FAA-258). Observatory's 1.3.x (this account's own sockets
|
|
6
|
+
through libproc, DEC-0155 RT-2) is not in it (FAA-292). The kit's differences: `Owner` and `Row` are NamedTuples
|
|
7
|
+
(a dataclass with postponed annotations needs its module in sys.modules, and a service may load a kit file by
|
|
8
|
+
path); `Optional[X]` for `X | None` (the kit's floor is Python 3.9); `classify` / `classify_sid` ask for this
|
|
9
|
+
process's own uid or SID first (a client running as root or SYSTEM owns root's listener); on Linux `server_end`
|
|
10
|
+
reads an accepted connection's own row and, for one not accept()ed yet (uid 0, inode 0), the listener on its local
|
|
11
|
+
address (CT-254; reasons `not-accepted`, `several-rows`, `closed`), `peer` reads a client row with inode 0 as
|
|
12
|
+
unknown, several rows of one owner are that owner, a row printed twice counts once, and a `/proc` question that
|
|
13
|
+
finds no row or several reads once more; ctypes is imported where used. On macOS (kit.2 to kit.4, FAA-267,
|
|
14
|
+
FAA-273): the sysctl calibration also requires a child process's listener, an empty or header-only netstat is
|
|
15
|
+
`TABLE_RESTRICTED`, a caller may vouch for a listener with a pid it started (`pid_listens`,
|
|
16
|
+
`CheckedHTTPConnection(vouch_pid=...)`), the child's port is read under one deadline with `selectors`, a number is
|
|
17
|
+
ASCII digits only. DEC-0155 (kit.6, FAA-312): a netstat that fails, a table with no row of another uid (sysctl) or process
|
|
18
|
+
(netstat) and a missing connection row read as `table-restricted` (RT-1); a restricted table's listener is this
|
|
19
|
+
account's only on exactly 127.0.0.1 (RT-4); the vouch takes exactly 127.0.0.1 (RT-3), never from a set-uid
|
|
20
|
+
reader. `main` is the CLI, `listener <port>` (FAA-315). /proc carries a socket's creator, so a listener root created
|
|
21
|
+
and handed to another account (systemd socket activation, User=) reads as root's. Standard library only, no other
|
|
22
|
+
kit module. Tests: `test/test_fabric_peer_owner.py`; the Node twin is `fabric-peer-owner.mjs` (no ctypes).
|
|
23
|
+
|
|
24
|
+
WHY. Loopback is one per machine, not one per OS account. On a host with several accounts, every account's processes
|
|
25
|
+
reach every service any account listens with on 127.0.0.1, and a loopback socket proves nothing about who is on the
|
|
26
|
+
other end. This module answers that question from the host's socket table, read without privileges:
|
|
37
27
|
|
|
38
28
|
listener(port) -> Owner who listens on 127.0.0.1:<port> (also 0.0.0.0, ::1, ::)
|
|
39
29
|
server_end(sock) -> Owner for a client's connected socket: who accepted it
|
|
@@ -53,58 +43,51 @@ question from the host's socket table, read without privileges:
|
|
|
53
43
|
unknown the owner could not be told; `reason` says why
|
|
54
44
|
none `listener` only: nothing listens on that port
|
|
55
45
|
|
|
56
|
-
A caller that must not leak (a client about to send a bearer, a server on a multi-account
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
answer
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
`
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
(
|
|
70
|
-
|
|
71
|
-
(`t_state`), so listeners and TIME_WAIT rows are told apart. The layout is not an API, so the
|
|
72
|
-
first read in a process calibrates it: it opens a connected pair of its own (listen, connect,
|
|
73
|
-
accept) and the listener and both ends of the pair must read back with their exact addresses,
|
|
74
|
-
this process's uid and its pid. A layout that fails calibration is not used again in that
|
|
46
|
+
A caller that must not leak (a client about to send a bearer, a server on a multi-account host about to read a request)
|
|
47
|
+
treats everything but `this-account` as a refusal, `unknown` included: fail closed.
|
|
48
|
+
|
|
49
|
+
HOW, ON EACH OS. Every read is bounded (MAX_TABLE_BYTES of table, TIMEOUT_SECONDS) and the answer is never cached across
|
|
50
|
+
connections; only `listener` is cached, for LISTENER_TTL. A TIME_WAIT row is a connection already closed and is never an
|
|
51
|
+
answer; two live rows with the same addresses are `unknown` (`several-rows`), never a guess.
|
|
52
|
+
|
|
53
|
+
- macOS, primary (`sysctl`): `sysctl net.inet.tcp.pcblist_n` through ctypes, the record list `netstat` itself reads.
|
|
54
|
+
Each connection is an `xinpcb_n` (addresses and ports) followed by an `xsocket_n` that carries `so_uid` and
|
|
55
|
+
`so_last_pid` (XNU `bsd/netinet/in_pcb.h` and `bsd/sys/socketvar.h`, both under `#pragma pack(4)`); the two are
|
|
56
|
+
paired by the pcb handle (`xi_inpp` equals `so_pcb`). Measured on macOS 26: about 4 ms for 770 sockets, every pid
|
|
57
|
+
equal to the one `netstat` printed. The `xtcpcb_n` record that follows gives the TCP state (`t_state`), so listeners
|
|
58
|
+
and TIME_WAIT rows are told apart. The layout is not an API, so the first read in a process calibrates it: it opens
|
|
59
|
+
a connected pair of its own (listen, connect, accept) and the listener and both ends of the pair must read back with
|
|
60
|
+
their exact addresses, this process's uid and its pid. A layout that fails calibration is not used again in that
|
|
75
61
|
process.
|
|
76
|
-
- macOS 27.2
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
`GetTokenInformation(TokenUser)` give its account SID, compared with this process's. A process
|
|
93
|
-
that cannot be opened (another account's, usually) is `unknown`, never `another-account`:
|
|
94
|
-
the caller refuses either way, and the reason says which it was. `Owner.uid` is None there.
|
|
62
|
+
- macOS 27.2 shows an ad-hoc-signed interpreter (a Homebrew Python) only its own sockets: the calibration also
|
|
63
|
+
requires a child's listener, and a restricted table (DEC-0155 RT-1) is `unknown` with a reason beginning
|
|
64
|
+
`table-restricted`, never another account's. A caller that started the server (an installer, its launchd job) may
|
|
65
|
+
vouch with that pid: `lsof -p PID` asks the process, which the restriction leaves readable (RT-3).
|
|
66
|
+
- macOS, fallback (`netstat`): `/usr/sbin/netstat -anv -p tcp`, its `process:pid` column, then `ps -o uid= -p PID` for
|
|
67
|
+
the owner of the one row a question needs. Measured: about 20 ms over 846 sockets, and it names the pids of root's
|
|
68
|
+
listeners, which `lsof` run by an ordinary account cannot see. Another local service implemented the same check this
|
|
69
|
+
way; it is kept as the documented fallback for a macOS whose record layout the calibration refuses.
|
|
70
|
+
- Linux (`proc`): `/proc/net/tcp` and `/proc/net/tcp6`, whose `uid` column every account may read (also under
|
|
71
|
+
`hidepid=2`, which hides `/proc/<pid>` and not this file). No pid: that is only readable for this account's own
|
|
72
|
+
processes, and the decision needs the uid alone.
|
|
73
|
+
- Windows (`iphlpapi`): `GetExtendedTcpTable(TCP_TABLE_OWNER_PID_ALL)` gives each row's owning pid;
|
|
74
|
+
`OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)`, `OpenProcessToken(TOKEN_QUERY)` and
|
|
75
|
+
`GetTokenInformation(TokenUser)` give its account SID, compared with this process's. A process that cannot be opened
|
|
76
|
+
(another account's, usually) is `unknown`, never `another-account`: the caller refuses either way, and the reason
|
|
77
|
+
says which it was. `Owner.uid` is None there.
|
|
95
78
|
- Anything else: `unknown`, reason `unsupported-platform`.
|
|
96
79
|
|
|
97
|
-
`LOCAL_PEERCRED`, `getpeereid` and Linux `SO_PEERCRED` answer this question for Unix-domain
|
|
98
|
-
|
|
80
|
+
`LOCAL_PEERCRED`, `getpeereid` and Linux `SO_PEERCRED` answer this question for Unix-domain sockets only, not for TCP,
|
|
81
|
+
which is why a socket table is read here at all.
|
|
99
82
|
|
|
100
|
-
WHAT IT NEVER DOES. It never reads a byte from the socket it is asked about, never resolves a
|
|
101
|
-
|
|
102
|
-
`netstat` prints only as far as the pid behind them.
|
|
83
|
+
WHAT IT NEVER DOES. It never reads a byte from the socket it is asked about, never resolves a name, never sends
|
|
84
|
+
anything, and never needs a privilege. It reports the process names `netstat` prints only as far as the pid behind them.
|
|
103
85
|
"""
|
|
104
86
|
from __future__ import annotations
|
|
105
87
|
|
|
106
88
|
import http.client
|
|
107
89
|
import ipaddress
|
|
90
|
+
import json
|
|
108
91
|
import os
|
|
109
92
|
import socket
|
|
110
93
|
import struct
|
|
@@ -115,7 +98,7 @@ import time
|
|
|
115
98
|
from typing import NamedTuple, Optional, Tuple
|
|
116
99
|
|
|
117
100
|
# This file's own version, for projects that copy it; not the engine's (that is configuration.VERSION).
|
|
118
|
-
PEER_OWNER_VERSION = "1.2.0+kit.
|
|
101
|
+
PEER_OWNER_VERSION = "1.2.0+kit.6" # Observatory's 1.2.0 with the kit's differences above
|
|
119
102
|
|
|
120
103
|
THIS_ACCOUNT = "this-account"
|
|
121
104
|
ANOTHER_ACCOUNT = "another-account"
|
|
@@ -124,10 +107,8 @@ UNKNOWN = "unknown"
|
|
|
124
107
|
NONE = "none"
|
|
125
108
|
KINDS = (THIS_ACCOUNT, ANOTHER_ACCOUNT, SYSTEM, UNKNOWN, NONE)
|
|
126
109
|
|
|
127
|
-
#: The most table bytes one read accepts
|
|
128
|
-
#:
|
|
129
|
-
#: developer machine passes that. 4 MiB holds about 6,800 at the 616 bytes a socket measured on macOS 26
|
|
130
|
-
#: (0.74.0 product reading 11, FAA-260).
|
|
110
|
+
#: The most table bytes one read accepts: 1 MB held about 1,200 sockets at `pcblist_n`'s 860 bytes each, which a busy
|
|
111
|
+
#: developer machine passes; 4 MiB holds about 6,800 at 616 bytes measured on macOS 26 (FAA-260).
|
|
131
112
|
MAX_TABLE_BYTES = 4 * 1024 * 1024
|
|
132
113
|
#: Every subprocess this module starts is stopped after this many seconds.
|
|
133
114
|
TIMEOUT_SECONDS = 2.0
|
|
@@ -141,6 +122,8 @@ LSOF = "/usr/sbin/lsof"
|
|
|
141
122
|
#: The reason a socket table hides other processes' sockets from this one (FAA-267: macOS 27.2 shows an ad-hoc-signed
|
|
142
123
|
#: interpreter, such as a Homebrew Python, only its own sockets through sysctl and nothing through netstat).
|
|
143
124
|
TABLE_RESTRICTED = "table-restricted"
|
|
125
|
+
#: A macOS table that lists sockets but not the reader's own connection (DEC-0155 RT-1, FAA-312).
|
|
126
|
+
NO_ROW_RESTRICTED = TABLE_RESTRICTED + ": no row for this connection"
|
|
144
127
|
|
|
145
128
|
#: Client codes of `own_connection` (ListenerRefused.code).
|
|
146
129
|
SERVER_NOT_OWN = "server-not-own"
|
|
@@ -167,8 +150,7 @@ class Owner(_OwnerFields):
|
|
|
167
150
|
|
|
168
151
|
|
|
169
152
|
class Row(_RowFields):
|
|
170
|
-
"""One TCP socket of the host's table
|
|
171
|
-
(netstat, Windows): it is resolved from `pid` only for the row a question selects."""
|
|
153
|
+
"""One TCP socket of the host's table; `uid` is None where it carries none (netstat, Windows): read from `pid`."""
|
|
172
154
|
__slots__ = ()
|
|
173
155
|
|
|
174
156
|
|
|
@@ -188,9 +170,8 @@ class ListenerRefused(Exception):
|
|
|
188
170
|
# -- addresses --------------------------------------------------------------------------------------------------------
|
|
189
171
|
|
|
190
172
|
def norm_host(host: str) -> str:
|
|
191
|
-
"""One spelling per address: an IPv4-mapped IPv6 address is its IPv4 form, `*` is the
|
|
192
|
-
|
|
193
|
-
only itself)."""
|
|
173
|
+
"""One spelling per address: an IPv4-mapped IPv6 address is its IPv4 form, `*` is the wildcard, `localhost` is
|
|
174
|
+
127.0.0.1. Anything unparseable is returned unchanged (and matches only itself)."""
|
|
194
175
|
if host in ("*", ""):
|
|
195
176
|
return "*"
|
|
196
177
|
if host == "localhost":
|
|
@@ -215,12 +196,21 @@ def _address(addr) -> tuple[str, int]:
|
|
|
215
196
|
# -- classification ---------------------------------------------------------------------------------------------------
|
|
216
197
|
|
|
217
198
|
def _posix_uid() -> Optional[int]:
|
|
218
|
-
"""This process's uid, or None where the OS has none (Windows compares SIDs
|
|
219
|
-
|
|
220
|
-
is read here, once, on the POSIX paths only (an engine boundary test names this function)."""
|
|
199
|
+
"""This process's uid, or None where the OS has none (Windows compares SIDs). The one place this module reads it:
|
|
200
|
+
vendored without its project's OS layer, it reads it here, on POSIX only (an engine boundary test names it)."""
|
|
221
201
|
return os.getuid() if hasattr(os, "getuid") else None
|
|
222
202
|
|
|
223
203
|
|
|
204
|
+
def _own_pid() -> int:
|
|
205
|
+
"""This process's pid: a netstat that shows no other process's row is a restricted table (DEC-0155 RT-1)."""
|
|
206
|
+
return os.getpid()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _set_uid() -> bool:
|
|
210
|
+
"""Real and effective uid differ: such a reader uses none of DEC-0155's proofs (RT-2 to RT-5), the vouch included."""
|
|
211
|
+
return hasattr(os, "geteuid") and os.geteuid() != _posix_uid()
|
|
212
|
+
|
|
213
|
+
|
|
224
214
|
def classify(uid: Optional[int], pid: Optional[int], *, method: str, own_uid: Optional[int] = None) -> Owner:
|
|
225
215
|
"""`this-account` for this process's uid, `system` for uid 0 or pid 1, else `another-account`. A client that runs
|
|
226
216
|
as root itself reads root's listener as its own account's (the kit's order; the vendored file put uid 0 first)."""
|
|
@@ -310,12 +300,11 @@ def parse_ps_uid(text: str) -> Optional[int]:
|
|
|
310
300
|
|
|
311
301
|
|
|
312
302
|
def parse_proc_net_tcp(text: str, *, family: int, byteorder: Optional[str] = None) -> list[Row]:
|
|
313
|
-
"""`/proc/net/tcp` (family AF_INET) or `/proc/net/tcp6` (AF_INET6). The kernel prints each
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
unknown (the kit's addition, 0.74.0 unit readings 3 and 4)."""
|
|
303
|
+
"""`/proc/net/tcp` (family AF_INET) or `/proc/net/tcp6` (AF_INET6). The kernel prints each 32-bit word of an address
|
|
304
|
+
in host byte order and each port in network order, in hex. A connected row with inode 0 has no socket yet (a
|
|
305
|
+
connection the server has not accept()ed, or one already closed): the kernel prints uid 0 for it, so its uid is not
|
|
306
|
+
read (None); `_owner_of` reads a server end not yet accepted as its listener's and a closed client as unknown (the
|
|
307
|
+
kit's addition, 0.74.0 unit readings 3 and 4)."""
|
|
319
308
|
order = byteorder or sys.byteorder
|
|
320
309
|
rows: list[Row] = []
|
|
321
310
|
for line in text.splitlines()[1:]:
|
|
@@ -624,7 +613,12 @@ def read_table() -> tuple[list[Row], str]:
|
|
|
624
613
|
except (TableUnreadable, struct.error, ValueError):
|
|
625
614
|
how = "netstat" # this read only; calibration stands
|
|
626
615
|
if how == "netstat":
|
|
627
|
-
|
|
616
|
+
try:
|
|
617
|
+
text = _run([NETSTAT, "-anv", "-p", "tcp"])
|
|
618
|
+
except TableUnreadable as exc:
|
|
619
|
+
# A netstat that fails, exits non-zero or times out is a restricted table (DEC-0155 RT-1, FAA-312).
|
|
620
|
+
raise TableUnreadable(f"{TABLE_RESTRICTED}: {exc}") from None
|
|
621
|
+
return parse_netstat(text), how
|
|
628
622
|
if how == "proc":
|
|
629
623
|
rows: list[Row] = []
|
|
630
624
|
for name, family in (("/proc/net/tcp", socket.AF_INET), ("/proc/net/tcp6", socket.AF_INET6)):
|
|
@@ -779,6 +773,18 @@ def _windows_owner(pid: Optional[int], how: str) -> Owner: # pragma: no cover -
|
|
|
779
773
|
|
|
780
774
|
# -- the questions ----------------------------------------------------------------------------------------------------
|
|
781
775
|
|
|
776
|
+
def restricted(rows: list[Row], how: str, *, own_uid: Optional[int] = None, own_pid: Optional[int] = None) -> bool:
|
|
777
|
+
"""DEC-0155 RT-1 (FAA-312): a macOS table holding no row of another uid (sysctl, which carries uids) or of another
|
|
778
|
+
process (netstat, which carries pids) is restricted, and a missing row proves nothing. Never on Linux or Windows."""
|
|
779
|
+
if how == "sysctl":
|
|
780
|
+
own = _posix_uid() if own_uid is None else own_uid
|
|
781
|
+
return not any(row.uid is not None and row.uid != own for row in rows)
|
|
782
|
+
if how == "netstat":
|
|
783
|
+
own = _own_pid() if own_pid is None else own_pid
|
|
784
|
+
return not any(row.pid is not None and row.pid != own for row in rows)
|
|
785
|
+
return False
|
|
786
|
+
|
|
787
|
+
|
|
782
788
|
def find(rows: list[Row], local: tuple[str, int], remote: tuple[str, int]) -> list[Row]:
|
|
783
789
|
"""The live connected rows with exactly this local and remote address: one, or the caller
|
|
784
790
|
cannot tell. A TIME_WAIT row is a connection already closed and is left out. A row printed twice is one
|
|
@@ -799,19 +805,15 @@ def _owner_of(local: tuple[str, int], remote: tuple[str, int], server: bool = Fa
|
|
|
799
805
|
if how != "proc" or len(found) == 1:
|
|
800
806
|
break
|
|
801
807
|
# A /proc read under churn can skip a row or print one twice; one more read before answering unknown.
|
|
802
|
-
if not found:
|
|
803
|
-
return Owner(UNKNOWN, None, None, how, "no-row")
|
|
808
|
+
if not found: # a macOS table without the reader's own connection is restricted (DEC-0155 RT-1)
|
|
809
|
+
return Owner(UNKNOWN, None, None, how, NO_ROW_RESTRICTED if how in ("sysctl", "netstat") else "no-row")
|
|
804
810
|
if len(found) > 1:
|
|
805
811
|
return Owner(UNKNOWN, None, None, how, "several-rows")
|
|
806
812
|
if how == "proc" and server and found[0].uid is None:
|
|
807
|
-
# Linux: a connection
|
|
808
|
-
#
|
|
809
|
-
# (
|
|
810
|
-
#
|
|
811
|
-
# accepted the client cannot pass (Project Observatory's 1.2.0 reading, board thread ced00f6e; the contract's
|
|
812
|
-
# CT-254, HA-2's Linux source, released in v0.1.0+2026-11-07.3).
|
|
813
|
-
# A server row its owner already closed (an orphan) also has inode 0 and reads so: no process holds it, so
|
|
814
|
-
# nothing sent to it reaches anyone (0.74.0 unit reading 6).
|
|
813
|
+
# Linux: a connection not accept()ed yet (uid 0, inode 0) belongs to the listener on its local address (exact,
|
|
814
|
+
# else the wildcard) and dies with it (inet_csk_listen_stop); an accepted one's own row carries the accepting
|
|
815
|
+
# process's uid (sock_graft), so a listener swapped after another account accepted cannot pass (Observatory's
|
|
816
|
+
# 1.2.0, board thread ced00f6e; CT-254). An orphaned server row reads so too: nothing sent to it reaches anyone.
|
|
815
817
|
return _one_owner(_listeners_of(rows, found[0].local), how, "not-accepted")
|
|
816
818
|
if how == "proc" and found[0].uid is None:
|
|
817
819
|
# A client's socket with inode 0 has already closed (0.74.0 unit reading 4): never another socket's owner.
|
|
@@ -841,9 +843,8 @@ def _one_owner(found: list[Row], how: str, none_reason: Optional[str] = None) ->
|
|
|
841
843
|
|
|
842
844
|
|
|
843
845
|
def peer_at(server: tuple, client: tuple) -> Owner:
|
|
844
|
-
"""The owner of the CLIENT socket of the connection between `server` and `client`, as a
|
|
845
|
-
|
|
846
|
-
client's and whose remote address is the server's."""
|
|
846
|
+
"""The owner of the CLIENT socket of the connection between `server` and `client`, as a server sees them (its own
|
|
847
|
+
address, the peer's): the row whose local address is the client's and whose remote address is the server's."""
|
|
847
848
|
return _owner_of(_address(client), _address(server))
|
|
848
849
|
|
|
849
850
|
|
|
@@ -857,11 +858,10 @@ def peer(sock: socket.socket) -> Owner:
|
|
|
857
858
|
|
|
858
859
|
|
|
859
860
|
def server_end(sock: socket.socket) -> Owner:
|
|
860
|
-
"""For a client's connected socket: who accepted it. The row whose local address is the
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
answers (the contract's CT-254, HA-2's Linux source)."""
|
|
861
|
+
"""For a client's connected socket: who accepted it. The row whose local address is the server's and whose remote
|
|
862
|
+
address is this socket's own, which is the server process's accepted socket, so a listener swapped after `listen`
|
|
863
|
+
cannot pass for the one that answers. On Linux a connection not yet accept()ed has no owner of its own (inode 0):
|
|
864
|
+
its listener answers (the contract's CT-254, HA-2's Linux source)."""
|
|
865
865
|
try:
|
|
866
866
|
own, server = sock.getsockname(), sock.getpeername()
|
|
867
867
|
except OSError as exc:
|
|
@@ -887,9 +887,8 @@ def pick_listener(rows: list[Row], port: int) -> list[Row]:
|
|
|
887
887
|
|
|
888
888
|
|
|
889
889
|
def listener(port: int) -> Owner:
|
|
890
|
-
"""Who listens on 127.0.0.1:<port>, or on 0.0.0.0 or ::1, which take the same connections.
|
|
891
|
-
`
|
|
892
|
-
LISTENER_TTL seconds."""
|
|
890
|
+
"""Who listens on 127.0.0.1:<port>, or on 0.0.0.0 or ::1, which take the same connections. `none` when nothing does,
|
|
891
|
+
`unknown` (`several-rows`) when sockets of two owners do. Cached for LISTENER_TTL seconds."""
|
|
893
892
|
now = time.monotonic()
|
|
894
893
|
hit = _LISTENERS.get(port)
|
|
895
894
|
if hit is not None and now - hit[0] < LISTENER_TTL:
|
|
@@ -899,11 +898,18 @@ def listener(port: int) -> Owner:
|
|
|
899
898
|
except TableUnreadable as exc:
|
|
900
899
|
owner = Owner(UNKNOWN, None, None, method(), str(exc))
|
|
901
900
|
else:
|
|
902
|
-
owner = _one_owner(pick_listener(rows, port), how)
|
|
901
|
+
owner = _restricted_listener(rows, port, how) if restricted(rows, how) else _one_owner(pick_listener(rows, port), how)
|
|
903
902
|
_LISTENERS[port] = (now, owner)
|
|
904
903
|
return owner
|
|
905
904
|
|
|
906
905
|
|
|
906
|
+
def _restricted_listener(rows: list[Row], port: int, how: str) -> Owner:
|
|
907
|
+
"""DEC-0155 RT-4 (FAA-312): under a restricted table only this account's own bind on exactly 127.0.0.1 is its own."""
|
|
908
|
+
owner = _one_owner([row for row in rows if row.listening and row.local == ("127.0.0.1", port)], how)
|
|
909
|
+
reason = f"{TABLE_RESTRICTED}: no listener of this account on exactly 127.0.0.1:{port}"
|
|
910
|
+
return owner if owner.kind == THIS_ACCOUNT else Owner(UNKNOWN, None, None, how, reason)
|
|
911
|
+
|
|
912
|
+
|
|
907
913
|
def refusal(owner: Owner, port: int, expect_pid: Optional[int] = None) -> Optional[tuple[str, str]]:
|
|
908
914
|
"""(code, why) when a client must not send on a connection whose server end is `owner`."""
|
|
909
915
|
if owner.kind in (ANOTHER_ACCOUNT, SYSTEM):
|
|
@@ -917,11 +923,11 @@ def refusal(owner: Owner, port: int, expect_pid: Optional[int] = None) -> Option
|
|
|
917
923
|
|
|
918
924
|
|
|
919
925
|
def pid_listens(pid: int, port: int) -> bool:
|
|
920
|
-
"""macOS, when the socket table is restricted (FAA-267): whether process `pid`, this account's,
|
|
921
|
-
127.0.0.1:<port>
|
|
922
|
-
`lsof -p` asks the process itself (libproc), which the restriction does not hide. Only a caller that
|
|
923
|
-
itself (a launchd job it installed) may vouch with it."""
|
|
924
|
-
if sys.platform != "darwin" or not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1:
|
|
926
|
+
"""macOS, when the socket table is restricted (FAA-267, DEC-0155 RT-3): whether process `pid`, this account's,
|
|
927
|
+
listens on exactly 127.0.0.1:<port> (never the IPv4 wildcard, which `lsof` prints as it prints an IPv6-only one, nor
|
|
928
|
+
::1; FAA-312). `lsof -p` asks the process itself (libproc), which the restriction does not hide. Only a caller that
|
|
929
|
+
started `pid` itself (a launchd job it installed) may vouch with it, and never a set-uid one."""
|
|
930
|
+
if sys.platform != "darwin" or not isinstance(pid, int) or isinstance(pid, bool) or pid <= 1 or _set_uid():
|
|
925
931
|
return False
|
|
926
932
|
try:
|
|
927
933
|
uid = parse_ps_uid(_run([PS, "-o", "uid=", "-p", str(pid)]))
|
|
@@ -930,15 +936,13 @@ def pid_listens(pid: int, port: int) -> bool:
|
|
|
930
936
|
out = _run([LSOF, "-nP", "-a", "-p", str(pid), "-i4TCP", "-sTCP:LISTEN", "-Fn"])
|
|
931
937
|
except TableUnreadable:
|
|
932
938
|
return False
|
|
933
|
-
return any(host
|
|
939
|
+
return any(host == "127.0.0.1" and p == port for host, p in parse_lsof_listeners(out))
|
|
934
940
|
|
|
935
941
|
|
|
936
942
|
class CheckedHTTPConnection(http.client.HTTPConnection):
|
|
937
|
-
"""An `http.client.HTTPConnection` whose every `connect()` checks the server end before
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
different listener; so the check is in `connect()`, not once in `own_connection`. A refused
|
|
941
|
-
connect closes the socket and raises ListenerRefused before any byte of a request is sent."""
|
|
943
|
+
"""An `http.client.HTTPConnection` whose every `connect()` checks the server end before returning: `http.client`
|
|
944
|
+
connects again by itself (`auto_open`) after `Connection: close` or a close, and that connection may reach another
|
|
945
|
+
listener. A refused connect closes the socket and raises ListenerRefused before any byte of a request is sent."""
|
|
942
946
|
|
|
943
947
|
def __init__(self, host: str, port: int, *, expect_pid: Optional[int] = None, timeout: float = 10.0,
|
|
944
948
|
vouch_pid: Optional[int] = None) -> None:
|
|
@@ -964,19 +968,33 @@ class CheckedHTTPConnection(http.client.HTTPConnection):
|
|
|
964
968
|
|
|
965
969
|
def own_connection(port: int, *, expect_pid: Optional[int] = None, timeout: float = 10.0,
|
|
966
970
|
host: str = "127.0.0.1", vouch_pid: Optional[int] = None) -> CheckedHTTPConnection:
|
|
967
|
-
"""A `CheckedHTTPConnection` to `host:port`, connected, whose server end is this account's
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
after closing the connection:
|
|
971
|
+
"""A `CheckedHTTPConnection` to `host:port`, connected, whose server end is this account's (and, with `expect_pid`,
|
|
972
|
+
that process's). The check runs on the very connection the caller then sends on, so no listener can change between
|
|
973
|
+
the check and the request, and again on every reconnect `http.client` makes. No proxy is consulted. Raises
|
|
974
|
+
ListenerRefused after closing the connection:
|
|
972
975
|
|
|
973
976
|
server-not-own another account's or the system's process accepted it
|
|
974
977
|
server-unverified its owner could not be told (nothing is sent: fail closed)
|
|
975
978
|
server-not-this-workspace this account's process, but not the pid expected
|
|
976
979
|
|
|
977
|
-
`vouch_pid`, as on `CheckedHTTPConnection` and the Node twin's `checkedSocket`: a pid the caller started
|
|
978
|
-
(FAA-267).
|
|
979
|
-
"""
|
|
980
|
+
`vouch_pid`, as on `CheckedHTTPConnection` and the Node twin's `checkedSocket`: a pid the caller started (FAA-267)."""
|
|
980
981
|
conn = CheckedHTTPConnection(host, port, expect_pid=expect_pid, timeout=timeout, vouch_pid=vouch_pid)
|
|
981
982
|
conn.connect()
|
|
982
983
|
return conn
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def main(argv: Optional[list] = None) -> int:
|
|
987
|
+
"""`listener <port> [--json]`: the kind of who listens on 127.0.0.1:<port>, which the dashboard link gate takes as
|
|
988
|
+
`listener_owner` (FAA-315), or the whole owner as JSON; exit 2 on a usage error."""
|
|
989
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
990
|
+
as_json = args[2:] == ["--json"]
|
|
991
|
+
if len(args) != 2 + as_json or args[0] != "listener" or not _ascii_digits(args[1]) or not 0 < int(args[1]) < 65536:
|
|
992
|
+
print("usage: fabric_peer_owner.py listener <port> [--json]", file=sys.stderr)
|
|
993
|
+
return 2
|
|
994
|
+
owner = listener(int(args[1]))
|
|
995
|
+
print(json.dumps(owner.as_dict()) if as_json else owner.kind)
|
|
996
|
+
return 0
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
if __name__ == "__main__":
|
|
1000
|
+
sys.exit(main())
|