@passioncode-ai/fabric-agent-adapter 0.80.2 → 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 +157 -0
- package/README.md +18 -5
- package/SECURITY.md +25 -8
- package/bin/fabric-agent-adapter.js +24 -1
- 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 +6 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +16 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +116 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +33 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +257 -10
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -77
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +899 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
- 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/recipes/support-agent/README.md +1 -1
- 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 +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +47 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +31 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +33 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +19 -28
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +9 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +146 -3
- 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 +79 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +269 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +64 -5
- 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-package.mjs +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +89 -22
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +39 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +76 -11
- 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 +86 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +324 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +34 -3
- 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_package.py +103 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +156 -133
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +257 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +246 -24
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +16 -6
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +16 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +33 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
- 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_package.py
CHANGED
|
@@ -700,8 +700,76 @@ def compose(draft: Dict[str, Any], files: Dict[str, bytes]) -> Tuple[Dict[str, A
|
|
|
700
700
|
return pkg, notes
|
|
701
701
|
|
|
702
702
|
|
|
703
|
+
#: The agent's source manifest (contract DEC-0149, ``fabric-source/1``): when the folder carries one, ``pack`` writes
|
|
704
|
+
#: ``secrets[]`` from its secret slots and ``state[]`` from its store slots' paths, and FAC-SEM-160 holds them equal.
|
|
705
|
+
SOURCE_FILE = "fabric.source.json"
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _source_manifest(files: Dict[str, bytes]) -> Optional[Dict[str, Any]]:
|
|
709
|
+
"""The folder's source manifest, or None when it has none; a refusal when it is no ``fabric-source/1`` object."""
|
|
710
|
+
if SOURCE_FILE not in files:
|
|
711
|
+
return None
|
|
712
|
+
source = _load_json(files[SOURCE_FILE], SOURCE_FILE)
|
|
713
|
+
if source.get("format") != "fabric-source/1":
|
|
714
|
+
raise PackageError("%s is not a fabric-source/1 manifest (its format member)." % SOURCE_FILE)
|
|
715
|
+
return source
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def source_members(source: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
719
|
+
"""``secrets[]`` from the secret slots (name, purpose, optional) and ``state[]`` from the store slots (path, purpose)."""
|
|
720
|
+
secrets: List[Dict[str, Any]] = []
|
|
721
|
+
state: List[Dict[str, Any]] = []
|
|
722
|
+
for slot in _objects(source.get("slots")):
|
|
723
|
+
if slot.get("kind") == "secret" and isinstance(slot.get("name"), str):
|
|
724
|
+
secrets.append(dict({"name": slot["name"], "purpose": slot.get("purpose")}, **({"optional": True} if slot.get("optional") is True else {})))
|
|
725
|
+
elif slot.get("kind") == "store" and isinstance(slot.get("path"), str):
|
|
726
|
+
state.append({"path": slot["path"], "purpose": slot.get("purpose")})
|
|
727
|
+
return secrets, state
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def source_package_findings(source: Any, pkg: Any) -> List[Dict[str, str]]:
|
|
731
|
+
"""FAC-SEM-160 (contract ``sourcePackageFindings``): the package's secrets[] are the source's secret slots, by name
|
|
732
|
+
and optional; its state[] paths are the store slots' paths when state[] holds any entry."""
|
|
733
|
+
if not isinstance(source, dict) or not isinstance(pkg, dict):
|
|
734
|
+
return []
|
|
735
|
+
out: List[Dict[str, str]] = []
|
|
736
|
+
|
|
737
|
+
def flag(at: str, message: str) -> None:
|
|
738
|
+
out.append({"code": "FAC-SEM-160", "instancePath": at, "message": message})
|
|
739
|
+
slots = _objects(source.get("slots"))
|
|
740
|
+
secrets = {s["name"]: s.get("optional") is True for s in slots if s.get("kind") == "secret" and isinstance(s.get("name"), str)}
|
|
741
|
+
stores = list(dict.fromkeys(s["path"] for s in slots if s.get("kind") == "store" and isinstance(s.get("path"), str)))
|
|
742
|
+
packaged = [(e, i) for i, e in enumerate(pkg.get("secrets") if isinstance(pkg.get("secrets"), list) else []) if isinstance(e, dict)]
|
|
743
|
+
names = {e.get("name") for e, _ in packaged}
|
|
744
|
+
for name in secrets:
|
|
745
|
+
if name not in names:
|
|
746
|
+
flag("/package/secrets", "the source's secret slot %s is not in the package's secrets[]" % name)
|
|
747
|
+
for entry, index in packaged:
|
|
748
|
+
name = entry.get("name")
|
|
749
|
+
if not isinstance(name, str) or name not in secrets:
|
|
750
|
+
flag("/package/secrets/%d/name" % index, "secrets[%d] is no secret slot of the source" % index)
|
|
751
|
+
elif secrets[name] != (entry.get("optional") is True):
|
|
752
|
+
flag("/package/secrets/%d/optional" % index, "secrets[%d] is %soptional in the source" % (index, "" if secrets[name] else "not "))
|
|
753
|
+
state = pkg.get("state")
|
|
754
|
+
if not isinstance(state, list) or not state:
|
|
755
|
+
return out
|
|
756
|
+
paths = {e.get("path") for e in _objects(state)}
|
|
757
|
+
for path in stores:
|
|
758
|
+
if path not in paths:
|
|
759
|
+
flag("/package/state", "a store slot's path of the source is not in the package's state[]")
|
|
760
|
+
for index, entry in ((i, e) for i, e in enumerate(state) if isinstance(e, dict)):
|
|
761
|
+
if not isinstance(entry.get("path"), str) or entry["path"] not in stores:
|
|
762
|
+
flag("/package/state/%d/path" % index, "state[%d] is no store slot of the source" % index)
|
|
763
|
+
return out
|
|
764
|
+
|
|
765
|
+
|
|
703
766
|
def _all_problems(pkg: Dict[str, Any], files: Dict[str, bytes]) -> List[Dict[str, str]]:
|
|
704
767
|
problems = [dict(p, rule="schema") for p in schema_problems(pkg)]
|
|
768
|
+
if SOURCE_FILE in files:
|
|
769
|
+
try:
|
|
770
|
+
problems += [dict(p, rule=p["code"]) for p in source_package_findings(_source_manifest(files), pkg)]
|
|
771
|
+
except PackageError as exc:
|
|
772
|
+
problems.append({"rule": "source", "instancePath": "/files", "message": str(exc)})
|
|
705
773
|
problems += [dict(p, rule="path") for p in path_problems(pkg)]
|
|
706
774
|
problems += [dict(p, rule=p["code"]) for p in package_findings(pkg)]
|
|
707
775
|
problems += [{"rule": "folder", "problem": p["problem"], "instancePath": p["path"], "message": FOLDER_MESSAGES.get(p["problem"], p["problem"])}
|
|
@@ -716,6 +784,24 @@ FOLDER_MESSAGES = {
|
|
|
716
784
|
}
|
|
717
785
|
|
|
718
786
|
|
|
787
|
+
def _secrets_lacking(draft: Dict[str, Any], secrets: List[Dict[str, Any]]) -> List[str]:
|
|
788
|
+
"""The names a hand-written secrets[] declares that the source manifest has no secret slot for, in their order."""
|
|
789
|
+
have = {s["name"] for s in secrets}
|
|
790
|
+
declared = draft.get("secrets") if isinstance(draft.get("secrets"), list) else []
|
|
791
|
+
lacking: List[str] = []
|
|
792
|
+
for entry in declared:
|
|
793
|
+
name = entry.get("name") if isinstance(entry, dict) else None
|
|
794
|
+
if isinstance(name, str) and name not in have and name not in lacking:
|
|
795
|
+
lacking.append(name)
|
|
796
|
+
return lacking
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _lacking_message(lacking: List[str]) -> str:
|
|
800
|
+
return ("%s declares no secret slot for %s, which %s's secrets[] names: pack writes secrets[] from the manifest's "
|
|
801
|
+
"slots and never drops a declared secret. Add each as a slot of kind secret to %s (or take it out of "
|
|
802
|
+
"secrets[]), then pack again" % (SOURCE_FILE, ", ".join(lacking), PACKAGE_FILE, SOURCE_FILE))
|
|
803
|
+
|
|
804
|
+
|
|
719
805
|
def pack(folder: Any, apply: bool = False) -> Dict[str, Any]:
|
|
720
806
|
"""Pack ``folder`` (see the module's doc). The answer names the digest, ``agentRef`` and every problem; with
|
|
721
807
|
``apply`` and no problem, ``fabric.package.json`` is rewritten."""
|
|
@@ -725,8 +811,24 @@ def pack(folder: Any, apply: bool = False) -> Dict[str, Any]:
|
|
|
725
811
|
raise PackageError("Write %s in the folder first: schema, kind, id, version, publisher, licence and, for an agent, "
|
|
726
812
|
"resultSignal and evals with its receipt. pack fills in agentRef, files[] and digest." % PACKAGE_FILE)
|
|
727
813
|
draft = _load_json(files[PACKAGE_FILE], PACKAGE_FILE)
|
|
814
|
+
source = _source_manifest(files)
|
|
815
|
+
written: List[str] = []
|
|
816
|
+
refused: List[Dict[str, str]] = []
|
|
817
|
+
if source is not None: # DEC-0149: secrets[] and state[] come from the source manifest's slots
|
|
818
|
+
secrets, state = source_members(source)
|
|
819
|
+
lacking = _secrets_lacking(draft, secrets)
|
|
820
|
+
if lacking: # a declared secret is never dropped: the manifest gains its slot first
|
|
821
|
+
refused.append({"rule": "source", "instancePath": "/secrets", "message": _lacking_message(lacking)})
|
|
822
|
+
elif secrets or "secrets" in draft:
|
|
823
|
+
draft["secrets"] = secrets
|
|
824
|
+
written.append("secrets[]")
|
|
825
|
+
if state and draft.get("state") != []: # an empty state[] is a copy packed without state, kept
|
|
826
|
+
draft["state"] = state
|
|
827
|
+
written.append("state[]")
|
|
728
828
|
pkg, notes = compose(draft, files)
|
|
729
|
-
|
|
829
|
+
if written:
|
|
830
|
+
notes.append("%s written from %s" % (" and ".join(written), SOURCE_FILE))
|
|
831
|
+
problems = refused + _all_problems(pkg, files)
|
|
730
832
|
suite = pkg.get("evals", {}).get("suite") if isinstance(pkg.get("evals"), dict) else None
|
|
731
833
|
if isinstance(suite, str) and suite in files and any(p.get("instancePath") == "/evals/receipt/suite" for p in problems):
|
|
732
834
|
notes.append("the suite packed is %s: run it again and write that receipt" % sha256_of(files[suite]))
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py
CHANGED
|
@@ -1,35 +1,29 @@
|
|
|
1
1
|
"""peer_owner: which OS account owns each end of a loopback TCP connection.
|
|
2
2
|
|
|
3
|
-
peer_owner 1.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
`
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
its Node twin is `fabric-peer-owner.mjs` (netstat, /proc and PowerShell, no ctypes).
|
|
28
|
-
|
|
29
|
-
WHY. Loopback is one per machine, not one per OS account. On a host with several accounts,
|
|
30
|
-
every account's processes reach every service any account listens with on 127.0.0.1, and a
|
|
31
|
-
loopback socket proves nothing about who is on the other end. This module answers that
|
|
32
|
-
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:
|
|
33
27
|
|
|
34
28
|
listener(port) -> Owner who listens on 127.0.0.1:<port> (also 0.0.0.0, ::1, ::)
|
|
35
29
|
server_end(sock) -> Owner for a client's connected socket: who accepted it
|
|
@@ -49,58 +43,51 @@ question from the host's socket table, read without privileges:
|
|
|
49
43
|
unknown the owner could not be told; `reason` says why
|
|
50
44
|
none `listener` only: nothing listens on that port
|
|
51
45
|
|
|
52
|
-
A caller that must not leak (a client about to send a bearer, a server on a multi-account
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
answer
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
`
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
(
|
|
66
|
-
|
|
67
|
-
(`t_state`), so listeners and TIME_WAIT rows are told apart. The layout is not an API, so the
|
|
68
|
-
first read in a process calibrates it: it opens a connected pair of its own (listen, connect,
|
|
69
|
-
accept) and the listener and both ends of the pair must read back with their exact addresses,
|
|
70
|
-
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
|
|
71
61
|
process.
|
|
72
|
-
- macOS 27.2
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
`GetTokenInformation(TokenUser)` give its account SID, compared with this process's. A process
|
|
89
|
-
that cannot be opened (another account's, usually) is `unknown`, never `another-account`:
|
|
90
|
-
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.
|
|
91
78
|
- Anything else: `unknown`, reason `unsupported-platform`.
|
|
92
79
|
|
|
93
|
-
`LOCAL_PEERCRED`, `getpeereid` and Linux `SO_PEERCRED` answer this question for Unix-domain
|
|
94
|
-
|
|
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.
|
|
95
82
|
|
|
96
|
-
WHAT IT NEVER DOES. It never reads a byte from the socket it is asked about, never resolves a
|
|
97
|
-
|
|
98
|
-
`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.
|
|
99
85
|
"""
|
|
100
86
|
from __future__ import annotations
|
|
101
87
|
|
|
102
88
|
import http.client
|
|
103
89
|
import ipaddress
|
|
90
|
+
import json
|
|
104
91
|
import os
|
|
105
92
|
import socket
|
|
106
93
|
import struct
|
|
@@ -111,7 +98,7 @@ import time
|
|
|
111
98
|
from typing import NamedTuple, Optional, Tuple
|
|
112
99
|
|
|
113
100
|
# This file's own version, for projects that copy it; not the engine's (that is configuration.VERSION).
|
|
114
|
-
PEER_OWNER_VERSION = "1.
|
|
101
|
+
PEER_OWNER_VERSION = "1.2.0+kit.6" # Observatory's 1.2.0 with the kit's differences above
|
|
115
102
|
|
|
116
103
|
THIS_ACCOUNT = "this-account"
|
|
117
104
|
ANOTHER_ACCOUNT = "another-account"
|
|
@@ -120,9 +107,8 @@ UNKNOWN = "unknown"
|
|
|
120
107
|
NONE = "none"
|
|
121
108
|
KINDS = (THIS_ACCOUNT, ANOTHER_ACCOUNT, SYSTEM, UNKNOWN, NONE)
|
|
122
109
|
|
|
123
|
-
#: The most table bytes one read accepts
|
|
124
|
-
#:
|
|
125
|
-
#: developer machine passes that. 4 MiB holds about 4,800.
|
|
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).
|
|
126
112
|
MAX_TABLE_BYTES = 4 * 1024 * 1024
|
|
127
113
|
#: Every subprocess this module starts is stopped after this many seconds.
|
|
128
114
|
TIMEOUT_SECONDS = 2.0
|
|
@@ -136,6 +122,8 @@ LSOF = "/usr/sbin/lsof"
|
|
|
136
122
|
#: The reason a socket table hides other processes' sockets from this one (FAA-267: macOS 27.2 shows an ad-hoc-signed
|
|
137
123
|
#: interpreter, such as a Homebrew Python, only its own sockets through sysctl and nothing through netstat).
|
|
138
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"
|
|
139
127
|
|
|
140
128
|
#: Client codes of `own_connection` (ListenerRefused.code).
|
|
141
129
|
SERVER_NOT_OWN = "server-not-own"
|
|
@@ -162,8 +150,7 @@ class Owner(_OwnerFields):
|
|
|
162
150
|
|
|
163
151
|
|
|
164
152
|
class Row(_RowFields):
|
|
165
|
-
"""One TCP socket of the host's table
|
|
166
|
-
(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`."""
|
|
167
154
|
__slots__ = ()
|
|
168
155
|
|
|
169
156
|
|
|
@@ -183,9 +170,8 @@ class ListenerRefused(Exception):
|
|
|
183
170
|
# -- addresses --------------------------------------------------------------------------------------------------------
|
|
184
171
|
|
|
185
172
|
def norm_host(host: str) -> str:
|
|
186
|
-
"""One spelling per address: an IPv4-mapped IPv6 address is its IPv4 form, `*` is the
|
|
187
|
-
|
|
188
|
-
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)."""
|
|
189
175
|
if host in ("*", ""):
|
|
190
176
|
return "*"
|
|
191
177
|
if host == "localhost":
|
|
@@ -210,12 +196,21 @@ def _address(addr) -> tuple[str, int]:
|
|
|
210
196
|
# -- classification ---------------------------------------------------------------------------------------------------
|
|
211
197
|
|
|
212
198
|
def _posix_uid() -> Optional[int]:
|
|
213
|
-
"""This process's uid, or None where the OS has none (Windows compares SIDs
|
|
214
|
-
|
|
215
|
-
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)."""
|
|
216
201
|
return os.getuid() if hasattr(os, "getuid") else None
|
|
217
202
|
|
|
218
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
|
+
|
|
219
214
|
def classify(uid: Optional[int], pid: Optional[int], *, method: str, own_uid: Optional[int] = None) -> Owner:
|
|
220
215
|
"""`this-account` for this process's uid, `system` for uid 0 or pid 1, else `another-account`. A client that runs
|
|
221
216
|
as root itself reads root's listener as its own account's (the kit's order; the vendored file put uid 0 first)."""
|
|
@@ -305,12 +300,11 @@ def parse_ps_uid(text: str) -> Optional[int]:
|
|
|
305
300
|
|
|
306
301
|
|
|
307
302
|
def parse_proc_net_tcp(text: str, *, family: int, byteorder: Optional[str] = None) -> list[Row]:
|
|
308
|
-
"""`/proc/net/tcp` (family AF_INET) or `/proc/net/tcp6` (AF_INET6). The kernel prints each
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
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)."""
|
|
314
308
|
order = byteorder or sys.byteorder
|
|
315
309
|
rows: list[Row] = []
|
|
316
310
|
for line in text.splitlines()[1:]:
|
|
@@ -619,7 +613,12 @@ def read_table() -> tuple[list[Row], str]:
|
|
|
619
613
|
except (TableUnreadable, struct.error, ValueError):
|
|
620
614
|
how = "netstat" # this read only; calibration stands
|
|
621
615
|
if how == "netstat":
|
|
622
|
-
|
|
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
|
|
623
622
|
if how == "proc":
|
|
624
623
|
rows: list[Row] = []
|
|
625
624
|
for name, family in (("/proc/net/tcp", socket.AF_INET), ("/proc/net/tcp6", socket.AF_INET6)):
|
|
@@ -774,6 +773,18 @@ def _windows_owner(pid: Optional[int], how: str) -> Owner: # pragma: no cover -
|
|
|
774
773
|
|
|
775
774
|
# -- the questions ----------------------------------------------------------------------------------------------------
|
|
776
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
|
+
|
|
777
788
|
def find(rows: list[Row], local: tuple[str, int], remote: tuple[str, int]) -> list[Row]:
|
|
778
789
|
"""The live connected rows with exactly this local and remote address: one, or the caller
|
|
779
790
|
cannot tell. A TIME_WAIT row is a connection already closed and is left out. A row printed twice is one
|
|
@@ -794,19 +805,15 @@ def _owner_of(local: tuple[str, int], remote: tuple[str, int], server: bool = Fa
|
|
|
794
805
|
if how != "proc" or len(found) == 1:
|
|
795
806
|
break
|
|
796
807
|
# A /proc read under churn can skip a row or print one twice; one more read before answering unknown.
|
|
797
|
-
if not found:
|
|
798
|
-
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")
|
|
799
810
|
if len(found) > 1:
|
|
800
811
|
return Owner(UNKNOWN, None, None, how, "several-rows")
|
|
801
812
|
if how == "proc" and server and found[0].uid is None:
|
|
802
|
-
# Linux: a connection
|
|
803
|
-
#
|
|
804
|
-
# (
|
|
805
|
-
#
|
|
806
|
-
# accepted the client cannot pass (Project Observatory's 1.2.0 reading, board thread ced00f6e; the contract's
|
|
807
|
-
# pending CT-254, FAA-258).
|
|
808
|
-
# A server row its owner already closed (an orphan) also has inode 0 and reads so: no process holds it, so
|
|
809
|
-
# 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.
|
|
810
817
|
return _one_owner(_listeners_of(rows, found[0].local), how, "not-accepted")
|
|
811
818
|
if how == "proc" and found[0].uid is None:
|
|
812
819
|
# A client's socket with inode 0 has already closed (0.74.0 unit reading 4): never another socket's owner.
|
|
@@ -836,9 +843,8 @@ def _one_owner(found: list[Row], how: str, none_reason: Optional[str] = None) ->
|
|
|
836
843
|
|
|
837
844
|
|
|
838
845
|
def peer_at(server: tuple, client: tuple) -> Owner:
|
|
839
|
-
"""The owner of the CLIENT socket of the connection between `server` and `client`, as a
|
|
840
|
-
|
|
841
|
-
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."""
|
|
842
848
|
return _owner_of(_address(client), _address(server))
|
|
843
849
|
|
|
844
850
|
|
|
@@ -852,11 +858,10 @@ def peer(sock: socket.socket) -> Owner:
|
|
|
852
858
|
|
|
853
859
|
|
|
854
860
|
def server_end(sock: socket.socket) -> Owner:
|
|
855
|
-
"""For a client's connected socket: who accepted it. The row whose local address is the
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
answers (the contract's pending CT-254, FAA-258)."""
|
|
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)."""
|
|
860
865
|
try:
|
|
861
866
|
own, server = sock.getsockname(), sock.getpeername()
|
|
862
867
|
except OSError as exc:
|
|
@@ -882,9 +887,8 @@ def pick_listener(rows: list[Row], port: int) -> list[Row]:
|
|
|
882
887
|
|
|
883
888
|
|
|
884
889
|
def listener(port: int) -> Owner:
|
|
885
|
-
"""Who listens on 127.0.0.1:<port>, or on 0.0.0.0 or ::1, which take the same connections.
|
|
886
|
-
`
|
|
887
|
-
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."""
|
|
888
892
|
now = time.monotonic()
|
|
889
893
|
hit = _LISTENERS.get(port)
|
|
890
894
|
if hit is not None and now - hit[0] < LISTENER_TTL:
|
|
@@ -894,11 +898,18 @@ def listener(port: int) -> Owner:
|
|
|
894
898
|
except TableUnreadable as exc:
|
|
895
899
|
owner = Owner(UNKNOWN, None, None, method(), str(exc))
|
|
896
900
|
else:
|
|
897
|
-
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)
|
|
898
902
|
_LISTENERS[port] = (now, owner)
|
|
899
903
|
return owner
|
|
900
904
|
|
|
901
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
|
+
|
|
902
913
|
def refusal(owner: Owner, port: int, expect_pid: Optional[int] = None) -> Optional[tuple[str, str]]:
|
|
903
914
|
"""(code, why) when a client must not send on a connection whose server end is `owner`."""
|
|
904
915
|
if owner.kind in (ANOTHER_ACCOUNT, SYSTEM):
|
|
@@ -912,11 +923,11 @@ def refusal(owner: Owner, port: int, expect_pid: Optional[int] = None) -> Option
|
|
|
912
923
|
|
|
913
924
|
|
|
914
925
|
def pid_listens(pid: int, port: int) -> bool:
|
|
915
|
-
"""macOS, when the socket table is restricted (FAA-267): whether process `pid`, this account's,
|
|
916
|
-
127.0.0.1:<port>
|
|
917
|
-
`lsof -p` asks the process itself (libproc), which the restriction does not hide. Only a caller that
|
|
918
|
-
itself (a launchd job it installed) may vouch with it."""
|
|
919
|
-
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():
|
|
920
931
|
return False
|
|
921
932
|
try:
|
|
922
933
|
uid = parse_ps_uid(_run([PS, "-o", "uid=", "-p", str(pid)]))
|
|
@@ -925,15 +936,13 @@ def pid_listens(pid: int, port: int) -> bool:
|
|
|
925
936
|
out = _run([LSOF, "-nP", "-a", "-p", str(pid), "-i4TCP", "-sTCP:LISTEN", "-Fn"])
|
|
926
937
|
except TableUnreadable:
|
|
927
938
|
return False
|
|
928
|
-
return any(host
|
|
939
|
+
return any(host == "127.0.0.1" and p == port for host, p in parse_lsof_listeners(out))
|
|
929
940
|
|
|
930
941
|
|
|
931
942
|
class CheckedHTTPConnection(http.client.HTTPConnection):
|
|
932
|
-
"""An `http.client.HTTPConnection` whose every `connect()` checks the server end before
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
different listener; so the check is in `connect()`, not once in `own_connection`. A refused
|
|
936
|
-
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."""
|
|
937
946
|
|
|
938
947
|
def __init__(self, host: str, port: int, *, expect_pid: Optional[int] = None, timeout: float = 10.0,
|
|
939
948
|
vouch_pid: Optional[int] = None) -> None:
|
|
@@ -959,19 +968,33 @@ class CheckedHTTPConnection(http.client.HTTPConnection):
|
|
|
959
968
|
|
|
960
969
|
def own_connection(port: int, *, expect_pid: Optional[int] = None, timeout: float = 10.0,
|
|
961
970
|
host: str = "127.0.0.1", vouch_pid: Optional[int] = None) -> CheckedHTTPConnection:
|
|
962
|
-
"""A `CheckedHTTPConnection` to `host:port`, connected, whose server end is this account's
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
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:
|
|
967
975
|
|
|
968
976
|
server-not-own another account's or the system's process accepted it
|
|
969
977
|
server-unverified its owner could not be told (nothing is sent: fail closed)
|
|
970
978
|
server-not-this-workspace this account's process, but not the pid expected
|
|
971
979
|
|
|
972
|
-
`vouch_pid`, as on `CheckedHTTPConnection` and the Node twin's `checkedSocket`: a pid the caller started
|
|
973
|
-
(FAA-267).
|
|
974
|
-
"""
|
|
980
|
+
`vouch_pid`, as on `CheckedHTTPConnection` and the Node twin's `checkedSocket`: a pid the caller started (FAA-267)."""
|
|
975
981
|
conn = CheckedHTTPConnection(host, port, expect_pid=expect_pid, timeout=timeout, vouch_pid=vouch_pid)
|
|
976
982
|
conn.connect()
|
|
977
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())
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py
CHANGED
|
@@ -20,7 +20,7 @@ PROTOCOL_REVISIONS: Dict[str, Tuple[str, ...]] = {
|
|
|
20
20
|
"fabric-operator-channel": ("0.1",), "fabric-package": ("1",),
|
|
21
21
|
"fabric-peer-share": ("1",), "fabric-peer-share-envelope": ("1",), "fabric-peer-share-report": ("1",), "fabric-project-comms": ("0.1",), "fabric-proposal": ("1",),
|
|
22
22
|
"fabric-provider": ("0.1",), "fabric-relay": ("0.1",), "fabric-secret-envelope": ("1",), "fabric-service": ("0.1",),
|
|
23
|
-
"fabric-settings-backup": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "machine-report": ("1",), "mandate": ("1",), "memory": ("0.1",), "node-summary": ("1",),
|
|
23
|
+
"fabric-settings-backup": ("1",), "fabric-source": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "machine-report": ("1",), "mandate": ("1",), "memory": ("0.1",), "node-summary": ("1",),
|
|
24
24
|
"operator-source": ("1",), "org-adaptation": ("1",), "org-adaptation-receipt": ("1",), "org-authority": ("1",), "org-authority-draft": ("1",), "org-key-set": ("1",), "org-key-set-draft": ("1",),
|
|
25
25
|
"org-membership": ("1",), "org-root-registration": ("1",), "pairing-message": ("1",),
|
|
26
26
|
"peer-share-deletion": ("1",), "peer-share-frame": ("1",), "peer-share-revocation": ("1",),
|