fleetmesh-node 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (338) hide show
  1. fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
  2. fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
  3. fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
  4. fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
  5. fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
  6. fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
  7. node/RELEASE +1 -0
  8. node/__init__.py +35 -0
  9. node/app/__init__.py +49 -0
  10. node/app/commands.py +265 -0
  11. node/app/config.py +131 -0
  12. node/app/control.py +670 -0
  13. node/app/core.py +867 -0
  14. node/app/plugins.py +76 -0
  15. node/app/router.py +83 -0
  16. node/app/socket.py +431 -0
  17. node/app/systemd.py +36 -0
  18. node/app/views.py +204 -0
  19. node/channels/__init__.py +378 -0
  20. node/channels/bus.py +331 -0
  21. node/channels/journal.py +46 -0
  22. node/commands/__init__.py +140 -0
  23. node/commands/adapter.py +210 -0
  24. node/commands/base.py +278 -0
  25. node/commands/cluster_cmds.py +183 -0
  26. node/commands/compute_cmds.py +401 -0
  27. node/commands/core.py +474 -0
  28. node/commands/gossip_cmds.py +106 -0
  29. node/commands/nsite_cmds.py +141 -0
  30. node/commands/peering.py +402 -0
  31. node/commands/plugin_cmds.py +135 -0
  32. node/commands/sidecar_cmds.py +120 -0
  33. node/commands/sync.py +388 -0
  34. node/commands/transport_cmds.py +451 -0
  35. node/commands/video_cmds.py +99 -0
  36. node/compartment/__init__.py +124 -0
  37. node/compartment/base.py +868 -0
  38. node/compartment/command.py +458 -0
  39. node/compartment/context.py +163 -0
  40. node/compartment/every.py +108 -0
  41. node/compartment/handler.py +131 -0
  42. node/compartment/lifecycle.py +460 -0
  43. node/compartment/lint.py +388 -0
  44. node/compartment/need.py +158 -0
  45. node/compartment/problem.py +521 -0
  46. node/compartment/refusals.py +120 -0
  47. node/compartment/registry.py +790 -0
  48. node/compartment/schema.py +290 -0
  49. node/compartment/sidecar.py +499 -0
  50. node/compartment/stores.py +662 -0
  51. node/compartment/wire.py +225 -0
  52. node/compartment/worker.py +61 -0
  53. node/config/__init__.py +43 -0
  54. node/config/base.py +116 -0
  55. node/config/change.py +441 -0
  56. node/config/changestore.py +201 -0
  57. node/config/compartments.py +1144 -0
  58. node/config/node.py +318 -0
  59. node/config/parameter.py +563 -0
  60. node/config/protocols.py +274 -0
  61. node/config/records.py +171 -0
  62. node/examples/__init__.py +1 -0
  63. node/examples/demo.py +226 -0
  64. node/examples/follower.py +46 -0
  65. node/interfaces.py +235 -0
  66. node/keystore/__init__.py +27 -0
  67. node/keystore/core.py +103 -0
  68. node/keystore/enclave.py +688 -0
  69. node/meshnode.py +111 -0
  70. node/metrics/__init__.py +396 -0
  71. node/metrics/adapters.py +102 -0
  72. node/metrics/face.py +110 -0
  73. node/planes/__init__.py +21 -0
  74. node/planes/identity/__init__.py +20 -0
  75. node/planes/identity/announce/__init__.py +9 -0
  76. node/planes/identity/announce/compartment.py +447 -0
  77. node/planes/identity/announce/types.py +26 -0
  78. node/planes/identity/discovery/__init__.py +9 -0
  79. node/planes/identity/discovery/compartment.py +522 -0
  80. node/planes/identity/discovery/types.py +13 -0
  81. node/planes/identity/linked/__init__.py +1 -0
  82. node/planes/identity/linked/compartment.py +270 -0
  83. node/planes/identity/linked/types.py +25 -0
  84. node/planes/identity/presence/__init__.py +7 -0
  85. node/planes/identity/presence/compartment.py +409 -0
  86. node/planes/identity/presence/types.py +14 -0
  87. node/planes/operations/__init__.py +13 -0
  88. node/planes/operations/scheduler/__init__.py +9 -0
  89. node/planes/operations/scheduler/compartment.py +353 -0
  90. node/planes/operations/scheduler/types.py +21 -0
  91. node/planes/operations/updates/__init__.py +17 -0
  92. node/planes/operations/updates/core.py +310 -0
  93. node/planes/operations/updates/store.py +79 -0
  94. node/planes/operations/updates/types.py +20 -0
  95. node/planes/peering/__init__.py +28 -0
  96. node/planes/peering/budget/__init__.py +43 -0
  97. node/planes/peering/budget/chain.py +43 -0
  98. node/planes/peering/budget/core.py +537 -0
  99. node/planes/peering/budget/evaluation.py +80 -0
  100. node/planes/peering/budget/meter.py +100 -0
  101. node/planes/peering/budget/receipts.py +439 -0
  102. node/planes/peering/budget/shape.py +101 -0
  103. node/planes/peering/budget/tick.py +110 -0
  104. node/planes/peering/budget/types.py +67 -0
  105. node/planes/peering/budget/windows.py +346 -0
  106. node/planes/peering/capacity/__init__.py +7 -0
  107. node/planes/peering/capacity/compartment.py +342 -0
  108. node/planes/peering/capacity/types.py +24 -0
  109. node/planes/peering/peering/__init__.py +25 -0
  110. node/planes/peering/peering/core.py +464 -0
  111. node/planes/peering/peering/envelope.py +142 -0
  112. node/planes/peering/peering/handshake.py +198 -0
  113. node/planes/peering/peering/shape.py +80 -0
  114. node/planes/peering/peering/types.py +39 -0
  115. node/planes/peering/reporter/__init__.py +42 -0
  116. node/planes/peering/reporter/constants.py +29 -0
  117. node/planes/peering/reporter/core.py +299 -0
  118. node/planes/peering/reporter/finding.py +74 -0
  119. node/planes/peering/reporter/findings.py +171 -0
  120. node/planes/peering/reporter/publish.py +285 -0
  121. node/planes/peering/reporter/reading.py +193 -0
  122. node/planes/peering/reporter/shape.py +86 -0
  123. node/planes/peering/reporter/types.py +50 -0
  124. node/planes/peering/strain/__init__.py +9 -0
  125. node/planes/peering/strain/compartment.py +204 -0
  126. node/planes/peering/strain/types.py +21 -0
  127. node/planes/peering/trust/__init__.py +9 -0
  128. node/planes/peering/trust/compartment.py +239 -0
  129. node/planes/peering/trust/types.py +30 -0
  130. node/planes/services/__init__.py +33 -0
  131. node/planes/services/compute/__init__.py +160 -0
  132. node/planes/services/compute/common.py +134 -0
  133. node/planes/services/compute/compartment.py +681 -0
  134. node/planes/services/compute/executors/__init__.py +121 -0
  135. node/planes/services/compute/executors/agent.py +840 -0
  136. node/planes/services/compute/executors/bacalhau.py +605 -0
  137. node/planes/services/compute/executors/base.py +127 -0
  138. node/planes/services/compute/executors/harness.py +439 -0
  139. node/planes/services/compute/executors/mcp.py +454 -0
  140. node/planes/services/compute/executors/media.py +898 -0
  141. node/planes/services/compute/executors/nondet.py +283 -0
  142. node/planes/services/compute/executors/providers.py +499 -0
  143. node/planes/services/compute/executors/registry.py +140 -0
  144. node/planes/services/compute/executors/wasm.py +244 -0
  145. node/planes/services/compute/executors/zk.py +604 -0
  146. node/planes/services/compute/jobs.py +227 -0
  147. node/planes/services/compute/load.py +180 -0
  148. node/planes/services/compute/market.py +433 -0
  149. node/planes/services/compute/sandbox.py +282 -0
  150. node/planes/services/compute/types.py +84 -0
  151. node/planes/services/compute_wire/__init__.py +58 -0
  152. node/planes/services/compute_wire/compartment.py +256 -0
  153. node/planes/services/compute_wire/messages.py +320 -0
  154. node/planes/services/compute_wire/sealing.py +147 -0
  155. node/planes/services/compute_wire/session.py +572 -0
  156. node/planes/services/compute_wire/types.py +49 -0
  157. node/planes/services/compute_wire/verify.py +331 -0
  158. node/planes/services/dvm/__init__.py +24 -0
  159. node/planes/services/dvm/gateway.py +526 -0
  160. node/planes/services/dvm/protocol.py +35 -0
  161. node/planes/services/dvm/translate.py +15 -0
  162. node/planes/services/dvm/types.py +25 -0
  163. node/planes/services/gateway/__init__.py +40 -0
  164. node/planes/services/gateway/settle.py +279 -0
  165. node/planes/services/gateway/translate.py +644 -0
  166. node/planes/services/gossip/__init__.py +22 -0
  167. node/planes/services/gossip/core.py +929 -0
  168. node/planes/services/gossip/envelope.py +154 -0
  169. node/planes/services/gossip/mesh.py +127 -0
  170. node/planes/services/gossip/types.py +50 -0
  171. node/planes/services/nsite/__init__.py +7 -0
  172. node/planes/services/nsite/client.py +62 -0
  173. node/planes/services/nsite/compartment.py +752 -0
  174. node/planes/services/nsite/types.py +44 -0
  175. node/planes/services/paidfetch/__init__.py +7 -0
  176. node/planes/services/paidfetch/compartment.py +862 -0
  177. node/planes/services/paidfetch/types.py +99 -0
  178. node/planes/services/settle/__init__.py +27 -0
  179. node/planes/services/settle/compartment.py +384 -0
  180. node/planes/services/settle/engine.py +456 -0
  181. node/planes/services/settle/gateways.py +541 -0
  182. node/planes/services/settle/payment.py +512 -0
  183. node/planes/services/settle/store.py +74 -0
  184. node/planes/services/settle/types.py +58 -0
  185. node/planes/services/sidecars/__init__.py +8 -0
  186. node/planes/services/sidecars/compartment.py +461 -0
  187. node/planes/services/sidecars/manifest.py +304 -0
  188. node/planes/services/sidecars/manifests/10-grasp.json +37 -0
  189. node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
  190. node/planes/services/sidecars/manifests/30-magpie.json +28 -0
  191. node/planes/services/sidecars/types.py +43 -0
  192. node/planes/services/video/__init__.py +106 -0
  193. node/planes/services/video/barter.py +407 -0
  194. node/planes/services/video/cli.py +158 -0
  195. node/planes/services/video/compartment.py +932 -0
  196. node/planes/services/video/ingest.py +524 -0
  197. node/planes/services/video/ledger.py +705 -0
  198. node/planes/services/video/packager.py +847 -0
  199. node/planes/services/video/player.py +456 -0
  200. node/planes/services/video/timed_segments.py +124 -0
  201. node/planes/services/video/transit.py +478 -0
  202. node/planes/store/__init__.py +24 -0
  203. node/planes/store/blob_replication/__init__.py +7 -0
  204. node/planes/store/blob_replication/compartment.py +496 -0
  205. node/planes/store/blob_replication/types.py +35 -0
  206. node/planes/store/blob_wire/__init__.py +48 -0
  207. node/planes/store/blob_wire/client.py +57 -0
  208. node/planes/store/blob_wire/core.py +359 -0
  209. node/planes/store/blob_wire/intake.py +74 -0
  210. node/planes/store/blob_wire/messages.py +85 -0
  211. node/planes/store/projected/__init__.py +7 -0
  212. node/planes/store/projected/compartment.py +258 -0
  213. node/planes/store/projected/types.py +29 -0
  214. node/planes/store/retention/__init__.py +7 -0
  215. node/planes/store/retention/compartment.py +214 -0
  216. node/planes/store/retention/types.py +12 -0
  217. node/planes/store/sync/__init__.py +35 -0
  218. node/planes/store/sync/core.py +311 -0
  219. node/planes/store/sync/envelope.py +106 -0
  220. node/planes/store/sync/handover.py +206 -0
  221. node/planes/store/sync/initiator.py +128 -0
  222. node/planes/store/sync/responder.py +114 -0
  223. node/planes/store/sync/rules.py +179 -0
  224. node/planes/store/sync/shape.py +80 -0
  225. node/planes/store/sync/types.py +51 -0
  226. node/planes/transport/__init__.py +23 -0
  227. node/planes/transport/bridge/__init__.py +13 -0
  228. node/planes/transport/bridge/compartment.py +785 -0
  229. node/planes/transport/bridge/ratelimit.py +44 -0
  230. node/planes/transport/bridge/types.py +43 -0
  231. node/planes/transport/lan/__init__.py +9 -0
  232. node/planes/transport/lan/compartment.py +446 -0
  233. node/planes/transport/lan/types.py +25 -0
  234. node/planes/transport/link/__init__.py +7 -0
  235. node/planes/transport/link/compartment.py +257 -0
  236. node/planes/transport/link/types.py +27 -0
  237. node/planes/transport/mediation/__init__.py +45 -0
  238. node/planes/transport/mediation/compartment.py +238 -0
  239. node/planes/transport/mediation/leaf.py +107 -0
  240. node/planes/transport/mediation/messages.py +302 -0
  241. node/planes/transport/mediation/service.py +233 -0
  242. node/planes/transport/mediation/store.py +37 -0
  243. node/planes/transport/mediation/types.py +20 -0
  244. node/planes/transport/p2p/__init__.py +7 -0
  245. node/planes/transport/p2p/compartment.py +349 -0
  246. node/planes/transport/p2p/types.py +22 -0
  247. node/protocols/__init__.py +277 -0
  248. node/protocols/activitypub/__init__.py +56 -0
  249. node/protocols/activitypub/actor.py +230 -0
  250. node/protocols/activitypub/federation.py +490 -0
  251. node/protocols/activitypub/media.py +145 -0
  252. node/protocols/activitypub/models.py +286 -0
  253. node/protocols/activitypub/package.py +271 -0
  254. node/protocols/activitypub/store.py +564 -0
  255. node/protocols/arcade/__init__.py +26 -0
  256. node/protocols/atproto/__init__.py +13 -0
  257. node/protocols/atproto/firehose.py +126 -0
  258. node/protocols/atproto/metering.py +165 -0
  259. node/protocols/atproto/package.py +155 -0
  260. node/protocols/atproto/repo.py +250 -0
  261. node/protocols/atproto/xrpc.py +285 -0
  262. node/protocols/base.py +109 -0
  263. node/protocols/circles/__init__.py +29 -0
  264. node/protocols/dm/__init__.py +50 -0
  265. node/protocols/grasp/__init__.py +22 -0
  266. node/protocols/isotime.py +15 -0
  267. node/protocols/nostr/__init__.py +51 -0
  268. node/protocols/nostr/blobstore.py +383 -0
  269. node/protocols/nostr/blossom.py +627 -0
  270. node/protocols/nostr/client.py +377 -0
  271. node/protocols/nostr/descriptor.py +211 -0
  272. node/protocols/nostr/eventstore.py +304 -0
  273. node/protocols/nostr/package.py +182 -0
  274. node/protocols/nostr/peers.py +109 -0
  275. node/protocols/nostr/relayserver.py +656 -0
  276. node/protocols/p2panda/__init__.py +22 -0
  277. node/protocols/p2panda/identity.py +19 -0
  278. node/protocols/p2panda/package.py +236 -0
  279. node/protocols/p2panda/rung.py +152 -0
  280. node/protocols/p2panda/store.py +273 -0
  281. node/protocols/rhio/__init__.py +33 -0
  282. node/protocols/rhio/package.py +275 -0
  283. node/protocols/rhio/rung.py +136 -0
  284. node/protocols/rhio/store.py +1423 -0
  285. node/protocols/ssb/muxrpc.py +256 -0
  286. node/protocols/ssb/package.py +267 -0
  287. node/protocols/textile/__init__.py +111 -0
  288. node/protocols/textile/transducers.py +259 -0
  289. node/release.py +119 -0
  290. node/storage/__init__.py +88 -0
  291. node/storage/accounts.py +305 -0
  292. node/storage/adapter.py +161 -0
  293. node/storage/base.py +385 -0
  294. node/storage/dialect.py +209 -0
  295. node/storage/grants.py +317 -0
  296. node/storage/locator.py +150 -0
  297. node/storage/postgres.py +294 -0
  298. node/storage/projected.py +953 -0
  299. node/storage/sharding.py +448 -0
  300. node/storage/standing.py +568 -0
  301. node/testnet/__init__.py +9 -0
  302. node/testnet/cluster.py +731 -0
  303. node/testnet/fips_daemon.py +230 -0
  304. node/transport/__init__.py +94 -0
  305. node/transport/const.py +110 -0
  306. node/transport/didcomm_inbound.py +251 -0
  307. node/transport/dnstxt.py +169 -0
  308. node/transport/fips_rung/__init__.py +67 -0
  309. node/transport/fips_rung/core.py +666 -0
  310. node/transport/fips_rung/listener.py +96 -0
  311. node/transport/fips_rung/types.py +12 -0
  312. node/transport/host.py +334 -0
  313. node/transport/iroh_rung/__init__.py +29 -0
  314. node/transport/iroh_rung/core.py +477 -0
  315. node/transport/iroh_rung/quic.py +942 -0
  316. node/transport/iroh_rung/types.py +22 -0
  317. node/transport/ladder.py +145 -0
  318. node/transport/lineproto.py +49 -0
  319. node/transport/mdns.py +303 -0
  320. node/transport/netfetch.py +283 -0
  321. node/transport/relay_rung/__init__.py +23 -0
  322. node/transport/relay_rung/cooldown.py +87 -0
  323. node/transport/relay_rung/core.py +138 -0
  324. node/transport/relay_rung/inbound.py +161 -0
  325. node/transport/relay_rung/ledger.py +104 -0
  326. node/transport/relay_rung/listen.py +121 -0
  327. node/transport/relay_rung/outbound.py +181 -0
  328. node/transport/relay_rung/shape.py +71 -0
  329. node/transport/relay_rung/types.py +58 -0
  330. node/transport/relay_rung/wire.py +63 -0
  331. node/transport/router.py +761 -0
  332. node/transport/rung.py +276 -0
  333. node/transport/ssb_rung.py +166 -0
  334. node/transport/ws.py +502 -0
  335. sidecar/fleetmesh_announce.py +338 -0
  336. sidecar/fleetmesh_p2p.py +373 -0
  337. sidecar/fleetmesh_rhio.py +352 -0
  338. sidecar/fleetmesh_ssb.py +603 -0
node/config/change.py ADDED
@@ -0,0 +1,441 @@
1
+ """Who changed what, and under which session.
2
+
3
+ A node managed by one operator needs none of this: the file changed, and
4
+ there was only ever one person who could have changed it. A node managed by
5
+ several does, and today it has nothing. `NodeConfig.operator` is a single
6
+ pubkey describing whose node it is, not who last touched it. The control
7
+ socket is chmod 0o600 with no principal at all, so every change over it is
8
+ anonymous and indistinguishable. There is no history of config changes
9
+ anywhere in the tree.
10
+
11
+ Three things make a change attributable, and the node currently has none of
12
+ them:
13
+
14
+ operator WHO - a pubkey or did:nostr. This is the UCAN issuer, and it
15
+ is the principal `Parameter.requires` is waiting for: that
16
+ field ships declared-only precisely because nothing
17
+ authenticated one. `Actor` is where it arrives.
18
+ session WHICH RUN - a uuid meshctl mints per invocation, so a dozen
19
+ changes from one `meshctl apply` are one act, and two
20
+ operators racing are two.
21
+ tool WHAT MADE IT - 'meshctl/0.2.0', or 'meshnode' for a local
22
+ command. A change nobody remembers making is usually a script.
23
+
24
+ A `Change` is one parameter moving, with the actor and the layer it came
25
+ from. Its `resource` is `Parameter.resource()` - the same string UCAN will
26
+ name in `with`, derived rather than written twice - so the record of what
27
+ happened and the authority to make it happen refer to the same thing.
28
+
29
+ Changes are announced on a declared channel, not written to a private file,
30
+ so they reach whatever backends an operator configured and are not a fourth
31
+ output path beside the journal, the tap and the log.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import contextlib
36
+ import contextvars
37
+ import dataclasses
38
+ import socket
39
+ import struct
40
+ import uuid
41
+ from typing import Any, Iterator, Mapping, Optional, Sequence, TypedDict
42
+
43
+ from fleetmesh_core.identity import did as did_mod
44
+
45
+ from .. import channels
46
+
47
+ # An actor the node cannot name. Used where a change is real but its author is
48
+ # not established - a file edited by hand, a pre-session command - rather than
49
+ # inventing an operator, because a wrong name in an audit record is worse than
50
+ # an honest blank.
51
+ UNKNOWN_OPERATOR: str = ''
52
+ LOCAL_TOOL: str = 'meshnode'
53
+
54
+ # The ladder, weakest first. Every rung is recorded, not only the one that
55
+ # answered, because the interesting facts live between the rungs:
56
+ #
57
+ # a rung ABSENT is not a rung that FAILED. "no signature offered" and
58
+ # "signature did not verify" are different events and only one of them is
59
+ # an attack.
60
+ #
61
+ # two rungs naming different subjects is impersonation, and it is visible
62
+ # only if both are kept. An actor asserting operator X whose signature
63
+ # verifies as Y is the case this exists to catch.
64
+ #
65
+ # an audit record has to be re-readable years later, so it must say what was
66
+ # checked at the time rather than what the current code would check.
67
+ ASSERTED: str = 'asserted' # the caller said so. No verification at all.
68
+ PEERCRED: str = 'peercred' # the kernel named a local uid on the socket.
69
+ SIGNED: str = 'signed' # a signature verified against the claimed DID.
70
+ DELEGATED: str = 'delegated' # a UCAN chain proved the ability, maybe via hops.
71
+ RUNGS: tuple[str, ...] = (ASSERTED, PEERCRED, SIGNED, DELEGATED)
72
+
73
+ # Rungs this node can actually climb today. DELEGATED needs the UCAN
74
+ # verification that `Parameter.requires` is also waiting on, so it is declared
75
+ # and never produced - the same footing, and for the same reason, as
76
+ # ACCESS_DECLARED_ONLY in parameter.py. A rung the node cannot climb must read
77
+ # as absent, never as satisfied.
78
+ RUNGS_AVAILABLE: tuple[str, ...] = (ASSERTED, PEERCRED, SIGNED)
79
+
80
+ OK, FAILED, ABSENT = 'ok', 'failed', 'absent'
81
+
82
+
83
+ def _method_of(did: str) -> str:
84
+ """The DID method name, or '' for anything that is not a DID.
85
+
86
+ Local because `did.parse` raises for a method this profile does not
87
+ speak, and a proof has to be able to record an unrecognised one rather
88
+ than fail while recording that it failed.
89
+ """
90
+ parts = did.split(':', 2)
91
+ return parts[1] if len(parts) >= 3 and parts[0] == 'did' else ''
92
+
93
+
94
+ class ProofRecord(TypedDict):
95
+ """One rung as a backend stores it."""
96
+ rung: str
97
+ ok: bool
98
+ subject: str
99
+ detail: str
100
+ method: str
101
+
102
+
103
+ @dataclasses.dataclass(frozen=True)
104
+ class Proof:
105
+ """One rung, and what it said.
106
+
107
+ `subject` is who this rung names in its own terms: a DID for SIGNED, a
108
+ `uid=1000` for PEERCRED. Kept per rung rather than collapsed into one
109
+ operator field, because collapsing is exactly what hides a disagreement.
110
+ """
111
+ rung: str
112
+ ok: bool
113
+ subject: str = ''
114
+ detail: str = ''
115
+ method: str = ''
116
+
117
+ def describe(self) -> 'ProofRecord':
118
+ return {'rung': self.rung, 'ok': self.ok, 'subject': self.subject,
119
+ 'detail': self.detail, 'method': self.method}
120
+
121
+
122
+ def asserted(operator: str) -> Proof:
123
+ """The caller's claim. `ok` is True because the claim was made, not
124
+ because it was checked - the rung itself is what says it is unverified."""
125
+ return Proof(rung=ASSERTED, ok=bool(operator), subject=operator,
126
+ detail='claimed by the caller, unverified',
127
+ method=_method_of(operator))
128
+
129
+
130
+ def peercred_of(conn: Any) -> Optional[Proof]:
131
+ """SO_PEERCRED off a unix socket, or None where the platform has none.
132
+
133
+ Linux answers with SO_PEERCRED and a struct of pid, uid, gid. macOS and
134
+ the BSDs answer a different question with LOCAL_PEERCRED and a different
135
+ struct, and `socket` does not expose it portably, so this returns None
136
+ there rather than guessing - a rung that cannot be climbed must read
137
+ absent, not satisfied, and a fabricated uid in an audit record is worse
138
+ than an honest blank.
139
+
140
+ None is also the answer for a socket that is not a unix socket at all,
141
+ because a uid means nothing across a network.
142
+ """
143
+ so_peercred = getattr(socket, 'SO_PEERCRED', None)
144
+ if so_peercred is None:
145
+ return None
146
+ try:
147
+ if conn.family != socket.AF_UNIX:
148
+ return None
149
+ raw = conn.getsockopt(socket.SOL_SOCKET, so_peercred, struct.calcsize('3i'))
150
+ pid, uid, gid = struct.unpack('3i', raw)
151
+ except (AttributeError, OSError, struct.error):
152
+ return None
153
+ return peercred(uid, gid, pid)
154
+
155
+
156
+ def peercred(uid: int, gid: int = -1, pid: int = -1) -> Proof:
157
+ """SO_PEERCRED on the control socket.
158
+
159
+ Real and free, and it identifies a unix user rather than an operator, so
160
+ it corroborates and never establishes: a uid is not a DID and this rung
161
+ never claims one.
162
+ """
163
+ bits = ['uid=%d' % uid]
164
+ if gid >= 0:
165
+ bits.append('gid=%d' % gid)
166
+ if pid >= 0:
167
+ bits.append('pid=%d' % pid)
168
+ return Proof(rung=PEERCRED, ok=uid >= 0, subject='uid:%d' % uid,
169
+ detail=' '.join(bits) + '; a local user, not a DID')
170
+
171
+
172
+ def signature(operator: str, msg32: bytes, sig_hex: str) -> Proof:
173
+ """A signature over `msg32`, checked against the claimed DID's own key.
174
+
175
+ Verified offline or not at all: `fleetmesh_core.identity.did` does no I/O, so a
176
+ resolvable method comes back refused by name rather than dialling out on
177
+ an audit path.
178
+ """
179
+ k = did_mod.signing_key(operator)
180
+ if k is None:
181
+ return Proof(rung=SIGNED, ok=False, subject=operator,
182
+ detail=did_mod.refusal(operator), method=_method_of(operator))
183
+ curve, _raw = k
184
+ ok = did_mod.verify(operator, msg32, sig_hex)
185
+ return Proof(rung=SIGNED, ok=ok, subject=operator, method=_method_of(operator),
186
+ detail='%s over %s' % ('verified' if ok else 'did not verify', curve))
187
+
188
+
189
+ def new_session() -> str:
190
+ """A session id. meshctl mints one per invocation and passes it down."""
191
+ return str(uuid.uuid4())
192
+
193
+
194
+ @dataclasses.dataclass(frozen=True)
195
+ class Actor:
196
+ """Who is acting, in which run, through what.
197
+
198
+ Frozen, because an actor is a statement about a moment and editing one
199
+ after the fact is exactly what an audit record must not permit.
200
+ """
201
+ operator: str = UNKNOWN_OPERATOR
202
+ session: str = ''
203
+ tool: str = LOCAL_TOOL
204
+ at: int = 0
205
+ proofs: tuple[Proof, ...] = ()
206
+
207
+ @property
208
+ def named(self) -> bool:
209
+ """Whether this actor identifies anyone. False is a legitimate answer
210
+ and is reported as one rather than filled in."""
211
+ return bool(self.operator)
212
+
213
+ @property
214
+ def assurance(self) -> str:
215
+ """The highest rung that verified, or '' for none.
216
+
217
+ One number is not the record - `ladder()` is - but it is what a policy
218
+ compares against, so it is computed from the proofs rather than
219
+ stored, and cannot be asserted independently of them.
220
+ """
221
+ best = ''
222
+ for p in self.proofs:
223
+ if p.ok and (not best or RUNGS.index(p.rung) > RUNGS.index(best)):
224
+ best = p.rung
225
+ return best
226
+
227
+ @property
228
+ def verified(self) -> bool:
229
+ """Whether an identity was actually proved, not merely claimed.
230
+
231
+ ASSERTED and PEERCRED do not count: the first is unchecked by
232
+ definition and the second names a unix user. This is the distinction
233
+ an audit record must keep, because a trail that cannot tell a claim
234
+ from a proof reads as stronger than it is.
235
+ """
236
+ return self.assurance in (SIGNED, DELEGATED)
237
+
238
+ def ladder(self) -> 'dict[str, str]':
239
+ """Every rung and its state: ok, failed, or absent.
240
+
241
+ The whole ladder, not just the rung that answered. A rung that was
242
+ never offered and a rung that failed are different events, and only
243
+ the second is an attack.
244
+ """
245
+ state = {r: ABSENT for r in RUNGS}
246
+ for p in self.proofs:
247
+ if p.rung in state:
248
+ state[p.rung] = OK if p.ok else FAILED
249
+ return state
250
+
251
+ def conflicts(self) -> list[str]:
252
+ """Rungs naming a DID that is not the one claimed.
253
+
254
+ This is the impersonation case and it is detectable only because
255
+ every rung keeps its own subject: an actor asserting operator X whose
256
+ signature verifies as Y would, with one collapsed field, have been
257
+ recorded as X.
258
+ """
259
+ out: list[str] = []
260
+ for p in self.proofs:
261
+ if not p.subject or p.subject.startswith('uid:'):
262
+ continue
263
+ if self.operator and p.subject != self.operator:
264
+ out.append('%s names %s, not %s' % (p.rung, p.subject[:24], self.operator[:24]))
265
+ return out
266
+
267
+ def describe(self) -> ActorRecord:
268
+ return {'operator': self.operator or '(unknown)', 'session': self.session,
269
+ 'tool': self.tool, 'at': self.at,
270
+ 'assurance': self.assurance or '(none)',
271
+ 'verified': self.verified,
272
+ 'ladder': self.ladder(),
273
+ 'conflicts': self.conflicts(),
274
+ 'proofs': [p.describe() for p in self.proofs]}
275
+
276
+
277
+ # The actor of the request in flight. A ContextVar and not an attribute on
278
+ # the node: the control socket serves every connection on its own thread, so
279
+ # `node.acting = actor` let two concurrent requests overwrite each other and
280
+ # a change could be attributed to whoever asked second. Each thread (and each
281
+ # asyncio task, should one ever call in) sees only the actor it was given.
282
+ ACTING: contextvars.ContextVar[Optional['Actor']] = contextvars.ContextVar('fleetmesh_acting', default=None)
283
+
284
+
285
+ def acting() -> Optional['Actor']:
286
+ """Who caused the work running now, or None outside a request."""
287
+ return ACTING.get()
288
+
289
+
290
+ @contextlib.contextmanager
291
+ def acting_as(actor: Optional['Actor']) -> Iterator[Optional['Actor']]:
292
+ """Run a block on behalf of `actor`, restoring whoever was acting before."""
293
+ token = ACTING.set(actor)
294
+ try:
295
+ yield actor
296
+ finally:
297
+ ACTING.reset(token)
298
+
299
+
300
+ class ActorRecord(TypedDict):
301
+ """An actor as a backend stores it. A named record for the reason
302
+ check-dicts.py enforces: an audit row is read by field name.
303
+
304
+ `ladder` and `proofs` are both here on purpose. The ladder is the summary
305
+ a person reads; the proofs are what was actually checked, which is what a
306
+ reader needs years later when the code has moved on.
307
+ """
308
+ operator: str
309
+ session: str
310
+ tool: str
311
+ at: int
312
+ assurance: str
313
+ verified: bool
314
+ ladder: dict[str, str]
315
+ conflicts: list[str]
316
+ proofs: list[ProofRecord]
317
+
318
+
319
+ class ChangeRecord(TypedDict):
320
+ """One change, as a backend stores it."""
321
+ resource: str
322
+ was: Any
323
+ now: Any
324
+ source: str
325
+ operator: str
326
+ session: str
327
+ tool: str
328
+ at: int
329
+
330
+
331
+ @dataclasses.dataclass(frozen=True)
332
+ class Change:
333
+ """One parameter moving from `was` to `now`.
334
+
335
+ `resource` comes from `Parameter.resource()`, so the audit record and the
336
+ UCAN capability that will authorise it name the same thing. `secret`
337
+ suppresses the values: an audit trail that prints the wallet URI it just
338
+ changed has leaked it to everyone who can read the log.
339
+ """
340
+ resource: str
341
+ was: Any
342
+ now: Any
343
+ source: str
344
+ actor: Actor
345
+ secret: bool = False
346
+
347
+ REDACTED: str = '(secret)'
348
+
349
+ def describe(self) -> ChangeRecord:
350
+ was: Any = self.REDACTED if self.secret else self.was
351
+ now: Any = self.REDACTED if self.secret else self.now
352
+ return {'resource': self.resource, 'was': was, 'now': now,
353
+ 'source': self.source, 'operator': self.actor.operator or '(unknown)',
354
+ 'session': self.actor.session, 'tool': self.actor.tool, 'at': self.actor.at}
355
+
356
+ def said(self) -> str:
357
+ who = self.actor.operator[:16] if self.actor.operator else '(unknown operator)'
358
+ shown_was = self.REDACTED if self.secret else self.was
359
+ shown_now = self.REDACTED if self.secret else self.now
360
+ sess = (' session ' + self.actor.session[:8]) if self.actor.session else ''
361
+ return 'config %s %r -> %r by %s via %s%s' % (
362
+ self.resource, shown_was, shown_now, who, self.source, sess)
363
+
364
+
365
+ def diff(resource_of: Any, before: Mapping[str, Any], after: Mapping[str, Any],
366
+ params: Sequence[Any], source: str, actor: Actor) -> list[Change]:
367
+ """Every parameter whose value differs between `before` and `after`.
368
+
369
+ Taken over the declared parameters rather than over the raw keys, so a key
370
+ nobody declared cannot be recorded as a change to a setting that does not
371
+ exist, and a `secret` is carried through to the record that must redact it.
372
+ """
373
+ out: list[Change] = []
374
+ for p in params:
375
+ was, now = before.get(p.key), after.get(p.key)
376
+ if was == now:
377
+ continue
378
+ out.append(Change(resource=resource_of(p), was=was, now=now,
379
+ source=source, actor=actor, secret=bool(getattr(p, 'secret', False))))
380
+ return out
381
+
382
+
383
+ # The channel a change is announced on. Declared here beside the record rather
384
+ # than in a central topic list, which is the arrangement node/channels.py
385
+ # exists to establish.
386
+ CHANGED = channels.Channel(
387
+ name='config.changed',
388
+ description='A configuration parameter moved, with who moved it, the session they '
389
+ 'moved it in, and the escalation layer it came from. Secret parameters '
390
+ 'report that they changed and not to what.',
391
+ level=channels.NOTE,
392
+ wire=lambda change: change.describe(),
393
+ say=lambda change: change.said(),
394
+ tags=('config', 'audit'))
395
+
396
+ # Raised when a change arrives with nobody named. Separate from the change
397
+ # itself so an operator can require attribution by watching one channel,
398
+ # rather than parsing every change for a blank field.
399
+ UNATTRIBUTED = channels.Channel(
400
+ name='config.unattributed',
401
+ description='A configuration change arrived with no operator identity. Expected for a '
402
+ 'file edited by hand; on a node managed by several operators it means a '
403
+ 'tool is not passing one.',
404
+ level=channels.TROUBLE,
405
+ wire=lambda change: change.describe(),
406
+ say=lambda change: 'config %s changed with no operator named (via %s)' % (
407
+ change.resource, change.source),
408
+ tags=('config', 'audit', 'trouble'))
409
+
410
+ IMPERSONATION = channels.Channel(
411
+ name='config.identity_conflict',
412
+ description='Two rungs of the identity ladder named different subjects - an actor '
413
+ 'claiming one operator whose signature verified as another. Visible only '
414
+ 'because every rung keeps its own subject.',
415
+ level=channels.TROUBLE,
416
+ wire=lambda change: change.describe(),
417
+ say='identity conflict on {resource} (operator {operator})',
418
+ tags=('config', 'audit', 'identity', 'trouble'))
419
+
420
+ CHANNELS: tuple[channels.Channel, ...] = (CHANGED, UNATTRIBUTED, IMPERSONATION)
421
+
422
+
423
+ def announce(log: Any, change: Change) -> None:
424
+ """Say a change happened, and say separately what is wrong with its author.
425
+
426
+ Three channels rather than three fields on one, so an operator can watch
427
+ the thing they care about: that every change is attributed, or that no
428
+ identity ever disagreed with itself.
429
+ """
430
+ log.say(CHANGED.name, change)
431
+ if not change.actor.named:
432
+ log.say(UNATTRIBUTED.name, change)
433
+ if change.actor.conflicts():
434
+ log.say(IMPERSONATION.name, change)
435
+
436
+
437
+ __all__ = ['Actor', 'ActorRecord', 'Proof', 'ProofRecord', 'asserted', 'peercred', 'signature',
438
+ 'RUNGS', 'RUNGS_AVAILABLE', 'peercred_of', 'ASSERTED', 'PEERCRED', 'SIGNED', 'DELEGATED',
439
+ 'OK', 'FAILED', 'ABSENT', 'IMPERSONATION',
440
+ 'Change', 'ChangeRecord', 'diff', 'announce', 'new_session',
441
+ 'CHANGED', 'UNATTRIBUTED', 'CHANNELS', 'UNKNOWN_OPERATOR', 'LOCAL_TOOL']
@@ -0,0 +1,201 @@
1
+ """The audit trail: every change, durable, in order.
2
+
3
+ `announce` says a change happened on a channel, and a channel reaches whatever
4
+ backends an operator configured. That is the right way to tell somebody, and
5
+ the wrong way to keep a record: if no file backend is configured there is no
6
+ trail at all, and an audit trail that depends on how the log was set up is not
7
+ an audit trail.
8
+
9
+ So a change is both announced and stored. The store is the record; the channel
10
+ is the notification.
11
+
12
+ `seq` is explicit and taken from `MAX(seq) + 1` under the store lock, for the
13
+ reason `grants.history` does the same: auto-increment is spelled differently
14
+ by the two backends and SQLite cannot add one with ALTER TABLE, while `rowid`
15
+ does not exist on PostgreSQL at all. Order matters here more than it does for
16
+ grants - "who changed it last" is the question an audit trail is for - so it
17
+ is a column and not an accident of storage.
18
+
19
+ `DURABILITY = 'commit'`: a change must be on disk before the call returns. The
20
+ window class would let a batch sit in memory, and the one record you cannot
21
+ afford to lose to a crash is the one that says what somebody just did.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from typing import Any, Optional, Sequence, TypedDict
27
+
28
+ from ..compartment.schema import Column, Index, Table
29
+ from ..storage import SqliteStore
30
+ from .change import Change
31
+
32
+
33
+ class ChangeRow(TypedDict):
34
+ """One stored change, as an operator reads it back.
35
+
36
+ A named record for the reason check-dicts.py enforces it: an audit row is
37
+ read by field name, and this is the row.
38
+ """
39
+ seq: int
40
+ at: int
41
+ resource: str
42
+ was: Any
43
+ now: Any
44
+ source: str
45
+ operator: str
46
+ session: str
47
+ tool: str
48
+ assurance: str
49
+ verified: bool
50
+ conflicts: list[str]
51
+ proofs: list[Any]
52
+
53
+
54
+ class TrailState(TypedDict):
55
+ """How much trail there is, and how much of it nobody owns."""
56
+ changes: int
57
+ last_seq: int
58
+ unattributed: int
59
+ unverified: int
60
+ conflicted: int
61
+
62
+
63
+ class ChangeStore(SqliteStore):
64
+ """Config changes, newest last by `seq`."""
65
+
66
+ DURABILITY = 'commit'
67
+
68
+ SCHEMA = (
69
+ Table('changes', (
70
+ Column('seq', 'INTEGER', primary_key=True),
71
+ Column('at', 'INTEGER', not_null=True),
72
+ Column('resource', 'TEXT', not_null=True),
73
+ Column('was', 'TEXT'),
74
+ Column('now', 'TEXT'),
75
+ Column('source', 'TEXT', not_null=True),
76
+ Column('operator', 'TEXT', not_null=True),
77
+ Column('session', 'TEXT', not_null=True),
78
+ Column('tool', 'TEXT', not_null=True),
79
+ Column('assurance', 'TEXT', not_null=True),
80
+ Column('verified', 'INTEGER', not_null=True),
81
+ Column('conflicts', 'TEXT', not_null=True),
82
+ Column('proofs', 'TEXT', not_null=True),
83
+ ), indices=(
84
+ # The three questions an operator asks of an audit trail: what changed
85
+ # on this setting, what did this person change, and what did that one
86
+ # run do.
87
+ Index('changes_resource', ('resource', 'seq')),
88
+ Index('changes_operator', ('operator', 'seq')),
89
+ Index('changes_session', ('session', 'seq')),
90
+ )),
91
+ )
92
+
93
+ def _next_seq(self) -> int:
94
+ """The next record number. Every caller holds the store lock, so the
95
+ read-then-write cannot race."""
96
+ row = self._one('SELECT COALESCE(MAX(seq), 0) + 1 FROM changes')
97
+ return int(row[0]) if row and row[0] is not None else 1
98
+
99
+ def put(self, change: Change) -> int:
100
+ """Store one change and return its seq.
101
+
102
+ The values go through `Change.describe()`, so a secret is already
103
+ redacted by the time it reaches a row: an audit trail that records the
104
+ wallet URI it just rotated has published it to everyone who can read
105
+ the database.
106
+ """
107
+ rec = change.describe()
108
+ act = change.actor
109
+ with self.transaction() as cur:
110
+ seq = self._next_seq()
111
+ cur.execute(
112
+ 'INSERT INTO changes (seq, at, resource, was, now, source, operator, session, '
113
+ 'tool, assurance, verified, conflicts, proofs) '
114
+ 'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)',
115
+ (seq, int(act.at), rec['resource'],
116
+ json.dumps(rec['was']), json.dumps(rec['now']), rec['source'],
117
+ act.operator, act.session, act.tool,
118
+ act.assurance or '', 1 if act.verified else 0,
119
+ json.dumps(act.conflicts()),
120
+ json.dumps([p.describe() for p in act.proofs])))
121
+ return seq
122
+
123
+ def _rows(self, where: str, args: Sequence[Any], limit: int) -> list[ChangeRow]:
124
+ rows = self._all(
125
+ 'SELECT seq, at, resource, was, now, source, operator, session, tool, '
126
+ 'assurance, verified, conflicts, proofs FROM changes '
127
+ + where + ' ORDER BY seq DESC LIMIT ?', tuple(args) + (int(limit),))
128
+ out: list[ChangeRow] = []
129
+ for r in rows:
130
+ row: ChangeRow = {
131
+ 'seq': int(r[0]), 'at': int(r[1]), 'resource': r[2],
132
+ 'was': json.loads(r[3]) if r[3] is not None else None,
133
+ 'now': json.loads(r[4]) if r[4] is not None else None,
134
+ 'source': r[5], 'operator': r[6] or '(unknown)', 'session': r[7],
135
+ 'tool': r[8], 'assurance': r[9] or '(none)', 'verified': bool(r[10]),
136
+ 'conflicts': json.loads(r[11] or '[]'),
137
+ 'proofs': json.loads(r[12] or '[]'),
138
+ }
139
+ out.append(row)
140
+ return out
141
+
142
+ def recent(self, limit: int = 50) -> list[ChangeRow]:
143
+ """Newest first, which is the order the question is usually asked in."""
144
+ return self._rows('', (), limit)
145
+
146
+ def of_resource(self, resource: str, limit: int = 50) -> list[ChangeRow]:
147
+ """What changed on one setting. 'who last touched this' lives here."""
148
+ return self._rows('WHERE resource = ?', (resource,), limit)
149
+
150
+ def of_operator(self, operator: str, limit: int = 50) -> list[ChangeRow]:
151
+ return self._rows('WHERE operator = ?', (operator,), limit)
152
+
153
+ def of_session(self, session: str, limit: int = 200) -> list[ChangeRow]:
154
+ """One run of one tool. A dozen changes from one `meshctl apply` are
155
+ one act, and this is what makes them readable as one."""
156
+ return self._rows('WHERE session = ?', (session,), limit)
157
+
158
+ def unattributed(self, limit: int = 50) -> list[ChangeRow]:
159
+ """Changes with nobody named. The thing to watch on a node several
160
+ people manage."""
161
+ return self._rows("WHERE operator = ''", (), limit)
162
+
163
+ def unverified(self, limit: int = 50) -> list[ChangeRow]:
164
+ """Changes whose author was claimed but never proved. Separate from
165
+ unattributed, because 'said they were X' and 'said nothing' are
166
+ different and only one of them is a policy violation worth chasing."""
167
+ return self._rows('WHERE verified = 0', (), limit)
168
+
169
+ def conflicted(self, limit: int = 50) -> list[ChangeRow]:
170
+ """Changes where two rungs named different subjects."""
171
+ return self._rows("WHERE conflicts <> '[]'", (), limit)
172
+
173
+ def state(self) -> TrailState:
174
+ """For the health block: how much trail there is and how much of it
175
+ nobody owns."""
176
+ row = self._one('SELECT COUNT(*), COALESCE(MAX(seq), 0) FROM changes')
177
+ total, last = (int(row[0]), int(row[1])) if row else (0, 0)
178
+ un = self._one("SELECT COUNT(*) FROM changes WHERE operator = ''")
179
+ nv = self._one('SELECT COUNT(*) FROM changes WHERE verified = 0')
180
+ cf = self._one("SELECT COUNT(*) FROM changes WHERE conflicts <> '[]'")
181
+ out: TrailState = {'changes': total, 'last_seq': last,
182
+ 'unattributed': int(un[0]) if un else 0,
183
+ 'unverified': int(nv[0]) if nv else 0,
184
+ 'conflicted': int(cf[0]) if cf else 0}
185
+ return out
186
+
187
+
188
+ def record(store: Optional[ChangeStore], log: Any, change: Change) -> Optional[int]:
189
+ """Store the change, then announce it.
190
+
191
+ Stored first on purpose. The announcement is a notification and may go
192
+ nowhere an operator kept; the row is the record, and a crash between the
193
+ two should lose the telling rather than the evidence.
194
+ """
195
+ from . import change as change_mod # noqa: PLC0415 (avoids a cycle)
196
+ seq = store.put(change) if store is not None else None
197
+ change_mod.announce(log, change)
198
+ return seq
199
+
200
+
201
+ __all__ = ['ChangeStore', 'ChangeRow', 'TrailState', 'record']