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
@@ -0,0 +1,490 @@
1
+ """ActivityPub Server-to-Server (S2S) Federation and Delivery.
2
+
3
+ Standard library only. Manages:
4
+ - Inbound inbox request handling (/inbox, /users/<user>/inbox) with HTTP signature verification.
5
+ - Actor key resolution (local cache & remote lookup).
6
+ - Inbound activity routing: Follow, Accept, Create, Undo, Delete, Announce.
7
+ - Outbox publishing and multi-recipient fanout delivery with Ed25519 / RSA HTTP signing.
8
+ - Strain and abuse scoring: tracks signature verification failures and throttles abusive hosts.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import time
14
+ import urllib.parse
15
+ import urllib.request
16
+ import uuid
17
+ from typing import Any, Callable, Mapping, Optional, Sequence
18
+
19
+ from fleetmesh_core.conformance.errors import FleetmeshError
20
+ from fleetmesh_core.crypto.http_signatures import (
21
+ parse_signature_header,
22
+ sign_http_request,
23
+ verify_http_signature,
24
+ )
25
+ from node.protocols.activitypub.actor import Actor
26
+ from node.protocols.activitypub.models import (
27
+ PUBLIC_COLLECTION,
28
+ Activity,
29
+ build_accept_activity,
30
+ build_create_activity,
31
+ build_follow_activity,
32
+ build_note,
33
+ build_undo_activity,
34
+ parse_activity,
35
+ )
36
+ from node.protocols.activitypub.store import ActivityPubStore
37
+
38
+ __all__ = [
39
+ 'FederationError',
40
+ 'StrainThrottledError',
41
+ 'StrainScoreKeeper',
42
+ 'FederationService',
43
+ 'is_safe_federation_url',
44
+ ]
45
+
46
+ MAX_INBOX_BODY_BYTES = 10 * 1024 * 1024 # 10 MB
47
+
48
+
49
+ def is_safe_federation_url(url: str, allow_local: bool = True) -> bool:
50
+ """Validate whether an outbound federation target URL is safe against SSRF attacks."""
51
+ if not url:
52
+ return False
53
+ parsed = urllib.parse.urlparse(url)
54
+ if parsed.scheme not in ('http', 'https'):
55
+ return False
56
+ if allow_local:
57
+ return True
58
+ if parsed.scheme != 'https':
59
+ return False
60
+ host = parsed.hostname
61
+ if not host:
62
+ return False
63
+ import ipaddress
64
+ try:
65
+ ip = ipaddress.ip_address(host)
66
+ if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
67
+ return False
68
+ except ValueError:
69
+ if host.lower() in ('localhost', 'metadata.google.internal') or host.endswith('.local'):
70
+ return False
71
+ return True
72
+
73
+
74
+ class FederationError(FleetmeshError):
75
+ """Base error for federation processing."""
76
+
77
+
78
+ class StrainThrottledError(FederationError):
79
+ """Raised when an abusive or high-strain host is temporarily throttled."""
80
+
81
+
82
+ class StrainScoreKeeper:
83
+ """Tracks verification failures per peer host to mitigate DoS and invalid traffic.
84
+
85
+ Integrates optionally with Fleetmesh StrainLedger.
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ max_failures: int = 5,
91
+ window_seconds: float = 300.0,
92
+ strain_ledger: Optional[Any] = None,
93
+ ):
94
+ self.max_failures = max_failures
95
+ self.window_seconds = window_seconds
96
+ self.strain_ledger = strain_ledger
97
+ self._failures: dict[str, list[float]] = {}
98
+
99
+ def _clean_old(self, host: str, now: float) -> list[float]:
100
+ cutoff = now - self.window_seconds
101
+ valid = [t for t in self._failures.get(host, []) if t >= cutoff]
102
+ self._failures[host] = valid
103
+ return valid
104
+
105
+ def is_throttled(self, host: str) -> bool:
106
+ """Check if a remote host currently exceeds allowable strain."""
107
+ now = time.time()
108
+ failures = self._clean_old(host, now)
109
+ return len(failures) >= self.max_failures
110
+
111
+ def record_failure(self, host: str) -> int:
112
+ """Record a signature verification failure or malformed request."""
113
+ now = time.time()
114
+ failures = self._clean_old(host, now)
115
+ failures.append(now)
116
+ self._failures[host] = failures
117
+
118
+ if self.strain_ledger is not None:
119
+ try:
120
+ self.strain_ledger.record(kind='abort', peer=host, transport='activitypub')
121
+ except Exception:
122
+ pass
123
+
124
+ return len(failures)
125
+
126
+ def record_success(self, host: str) -> None:
127
+ """Reward valid request by decreasing recorded failures."""
128
+ if host in self._failures and self._failures[host]:
129
+ self._failures[host].pop(0)
130
+
131
+
132
+ class FederationService:
133
+ """ActivityPub S2S Federation handler and outbound delivery engine."""
134
+
135
+ def __init__(
136
+ self,
137
+ store: ActivityPubStore,
138
+ local_actor: Actor,
139
+ private_key: Any,
140
+ strain_score_keeper: Optional[StrainScoreKeeper] = None,
141
+ remote_resolver: Optional[Callable[[str], Optional[Mapping[str, Any]]]] = None,
142
+ http_client: Optional[Callable[[str, Mapping[str, str], bytes], tuple[int, bytes]]] = None,
143
+ auto_accept_follows: bool = True,
144
+ allow_local_federation: bool = True,
145
+ ):
146
+ self.store = store
147
+ self.local_actor = local_actor
148
+ self.private_key = private_key
149
+ self.strain = strain_score_keeper or StrainScoreKeeper()
150
+ self.remote_resolver = remote_resolver or self._default_resolver
151
+ self.http_client = http_client or self._default_http_post
152
+ self.auto_accept_follows = auto_accept_follows
153
+ self.allow_local_federation = allow_local_federation
154
+
155
+ # Ensure local actor is registered in store
156
+ self.store.upsert_actor(local_actor)
157
+
158
+ def _default_resolver(self, uri: str) -> Optional[Mapping[str, Any]]:
159
+ """Fetch remote actor JSON-LD document over HTTP if not already in store."""
160
+ # Strip fragment if keyId was passed
161
+ actor_url = uri.split('#')[0]
162
+ cached = self.store.get_actor(actor_url)
163
+ if cached:
164
+ return cached
165
+
166
+ try:
167
+ req = urllib.request.Request(
168
+ actor_url,
169
+ headers={'Accept': 'application/activity+json, application/ld+json'},
170
+ )
171
+ with urllib.request.urlopen(req, timeout=10.0) as resp:
172
+ if resp.status == 200:
173
+ data = json.loads(resp.read().decode('utf-8'))
174
+ if isinstance(data, dict) and str(data.get('id') or actor_url).split('#')[0] == actor_url:
175
+ self.store.upsert_actor(data)
176
+ return data
177
+ except Exception:
178
+ pass
179
+ return None
180
+
181
+ def _default_http_post(
182
+ self,
183
+ url: str,
184
+ headers: Mapping[str, str],
185
+ body: bytes,
186
+ ) -> tuple[int, bytes]:
187
+ """Perform real HTTP POST request using urllib."""
188
+ req = urllib.request.Request(url, data=body, headers=headers, method='POST')
189
+ with urllib.request.urlopen(req, timeout=15.0) as resp:
190
+ return resp.status, resp.read()
191
+
192
+ def resolve_public_key(self, key_id: str) -> Optional[str]:
193
+ """Resolve public key PEM string for a given keyId."""
194
+ actor_url = key_id.split('#')[0]
195
+ doc = self.remote_resolver(actor_url)
196
+ if not doc:
197
+ return None
198
+
199
+ pk_obj = doc.get('publicKey')
200
+ if isinstance(pk_obj, dict):
201
+ # Check if this public key matches key_id
202
+ target_id = pk_obj.get('id')
203
+ if not target_id or target_id == key_id or key_id == actor_url:
204
+ pem = pk_obj.get('publicKeyPem')
205
+ if pem and isinstance(pem, str):
206
+ return pem
207
+ return None
208
+
209
+ # -------------------------------------------------------------------------
210
+ # Inbound Inbox Handling
211
+ # -------------------------------------------------------------------------
212
+
213
+ def handle_inbox(
214
+ self,
215
+ method: str,
216
+ path: str,
217
+ headers: Mapping[Any, Any],
218
+ body: bytes,
219
+ ) -> tuple[int, dict[str, str], bytes]:
220
+ """Process inbound HTTP request to shared or personal inbox."""
221
+ if method.upper() != 'POST':
222
+ return 405, {'Content-Type': 'application/json'}, b'{"error": "Method not allowed"}'
223
+
224
+ if len(body) > MAX_INBOX_BODY_BYTES:
225
+ return 413, {'Content-Type': 'application/json'}, b'{"error": "Payload too large"}'
226
+
227
+ norm_headers: dict[str, str] = {
228
+ (k.decode('latin-1') if isinstance(k, bytes) else str(k)): (
229
+ v.decode('latin-1') if isinstance(v, bytes) else str(v)
230
+ )
231
+ for k, v in headers.items()
232
+ }
233
+
234
+ # Extract Host for strain tracking
235
+ host = norm_headers.get('Host') or norm_headers.get('host') or 'unknown-host'
236
+
237
+ if self.strain.is_throttled(host):
238
+ return 429, {'Content-Type': 'application/json', 'Retry-After': '60'}, b'{"error": "Rate limit / strain exceeded"}'
239
+
240
+ # Find and parse Signature header
241
+ sig_val = None
242
+ for k, v in norm_headers.items():
243
+ if k.lower() == 'signature':
244
+ sig_val = v
245
+ break
246
+
247
+ if not sig_val:
248
+ self.strain.record_failure(host)
249
+ return 401, {'Content-Type': 'application/json'}, b'{"error": "Missing HTTP Signature"}'
250
+
251
+ try:
252
+ parsed_sig = parse_signature_header(sig_val)
253
+ except Exception:
254
+ self.strain.record_failure(host)
255
+ return 400, {'Content-Type': 'application/json'}, b'{"error": "Malformed Signature header"}'
256
+
257
+ key_id = parsed_sig.get('keyId')
258
+ if not key_id:
259
+ self.strain.record_failure(host)
260
+ return 400, {'Content-Type': 'application/json'}, b'{"error": "Missing keyId in Signature"}'
261
+
262
+ pub_key_pem = self.resolve_public_key(key_id)
263
+ if not pub_key_pem:
264
+ self.strain.record_failure(host)
265
+ return 401, {'Content-Type': 'application/json'}, b'{"error": "Could not resolve signer public key"}'
266
+
267
+ # Verify signature
268
+ if not verify_http_signature(method=method, path=path, headers=norm_headers, body=body, public_key=pub_key_pem):
269
+ self.strain.record_failure(host)
270
+ return 401, {'Content-Type': 'application/json'}, b'{"error": "Invalid HTTP Signature"}'
271
+
272
+ # Signature verified successfully
273
+ self.strain.record_success(host)
274
+
275
+ # Parse ActivityStreams 2.0 Activity
276
+ try:
277
+ activity = parse_activity(body)
278
+ except Exception as e:
279
+ return 400, {'Content-Type': 'application/json'}, json.dumps({'error': f"Invalid activity: {e}"}).encode('utf-8')
280
+
281
+ # Actor verification: signer keyId actor must match activity actor
282
+ signer_actor = key_id.split('#')[0]
283
+ if activity.actor != signer_actor:
284
+ return 403, {'Content-Type': 'application/json'}, b'{"error": "Signer does not match activity actor"}'
285
+
286
+ # Record activity in store
287
+ self.store.record_activity(activity, direction='inbox')
288
+
289
+ # Dispatch activity-specific logic
290
+ self._dispatch_inbound(activity)
291
+
292
+ return 202, {'Content-Type': 'application/json'}, b'{"status": "accepted"}'
293
+
294
+ def _dispatch_inbound(self, activity: Activity) -> None:
295
+ """Route inbound activity actions."""
296
+ act_type = activity.type
297
+
298
+ if act_type == 'Follow':
299
+ target = activity.object_id
300
+ if target == self.local_actor.actor_url:
301
+ self.store.add_follower(self.local_actor.actor_url, activity.actor)
302
+ if self.auto_accept_follows:
303
+ self._send_accept_follow(activity)
304
+
305
+ elif act_type == 'Undo':
306
+ obj = activity.object
307
+ obj_type = obj.get('type') if isinstance(obj, dict) else None
308
+ # If undoing a Follow
309
+ if obj_type == 'Follow' or isinstance(obj, str):
310
+ self.store.remove_follower(self.local_actor.actor_url, activity.actor)
311
+
312
+ elif act_type == 'Accept':
313
+ # Remote actor accepted our follow request
314
+ obj = activity.object
315
+ if isinstance(obj, dict) and obj.get('type') == 'Follow':
316
+ target_url = obj.get('object')
317
+ if isinstance(target_url, str):
318
+ self.store.add_following(self.local_actor.actor_url, target_url)
319
+ elif isinstance(obj, str):
320
+ self.store.add_following(self.local_actor.actor_url, activity.actor)
321
+
322
+ def _send_accept_follow(self, follow_activity: Activity) -> None:
323
+ """Construct and deliver an Accept activity in response to a Follow."""
324
+ accept_id = f"{self.local_actor.actor_url}/activities/accept-{uuid.uuid4().hex[:12]}"
325
+ accept_act = build_accept_activity(
326
+ activity_id=accept_id,
327
+ actor_uri=self.local_actor.actor_url,
328
+ follow_activity=follow_activity.to_dict(),
329
+ recipient_uri=follow_activity.actor,
330
+ )
331
+ self.store.record_activity(accept_act, direction='outbox')
332
+
333
+ # Find recipient's inbox
334
+ recipient_doc = self.remote_resolver(follow_activity.actor)
335
+ if recipient_doc:
336
+ inbox_url = recipient_doc.get('inbox')
337
+ if inbox_url:
338
+ try:
339
+ self.deliver_to_inbox(inbox_url, accept_act)
340
+ except Exception:
341
+ pass
342
+
343
+ # -------------------------------------------------------------------------
344
+ # Outbound Delivery
345
+ # -------------------------------------------------------------------------
346
+
347
+ def deliver_to_inbox(
348
+ self,
349
+ inbox_url: str,
350
+ activity_dict: Mapping[str, Any],
351
+ ) -> tuple[int, bytes]:
352
+ """Sign and deliver an activity directly to a target inbox."""
353
+ if not is_safe_federation_url(inbox_url, allow_local=self.allow_local_federation):
354
+ raise FederationError(f"Rejected unsafe federation URL (SSRF guard): {inbox_url}")
355
+
356
+ body = json.dumps(activity_dict, sort_keys=True).encode('utf-8')
357
+ base_headers = {
358
+ 'Content-Type': 'application/activity+json',
359
+ 'Accept': 'application/activity+json',
360
+ }
361
+ signed_headers = sign_http_request(
362
+ method='POST',
363
+ url=inbox_url,
364
+ headers=base_headers,
365
+ body=body,
366
+ private_key=self.private_key,
367
+ key_id=self.local_actor.public_key_id,
368
+ )
369
+
370
+ return self.http_client(inbox_url, signed_headers, body)
371
+
372
+ def post_note(
373
+ self,
374
+ content: str,
375
+ to: Optional[Sequence[str]] = None,
376
+ cc: Optional[Sequence[str]] = None,
377
+ in_reply_to: Optional[str] = None,
378
+ attachment: Optional[Sequence[Mapping[str, Any]]] = None,
379
+ ) -> Mapping[str, Any]:
380
+ """Publish a new Note, record Create activity, and fan out to followers/recipients."""
381
+ item_id = uuid.uuid4().hex[:12]
382
+ note_id = f"{self.local_actor.actor_url}/notes/{item_id}"
383
+ act_id = f"{self.local_actor.actor_url}/activities/create-{item_id}"
384
+
385
+ target_to = list(to) if to is not None else [PUBLIC_COLLECTION]
386
+ target_cc = list(cc) if cc is not None else [self.local_actor.followers_url]
387
+
388
+ note_doc = build_note(
389
+ id=note_id,
390
+ attributed_to=self.local_actor.actor_url,
391
+ content=content,
392
+ to=target_to,
393
+ cc=target_cc,
394
+ in_reply_to=in_reply_to,
395
+ attachment=attachment,
396
+ )
397
+
398
+ create_act = build_create_activity(
399
+ activity_id=act_id,
400
+ actor_uri=self.local_actor.actor_url,
401
+ note_or_object=note_doc,
402
+ to=target_to,
403
+ cc=target_cc,
404
+ )
405
+
406
+ # Record locally
407
+ self.store.record_activity(create_act, direction='outbox')
408
+
409
+ # Fan out delivery
410
+ self._fanout_activity(create_act)
411
+
412
+ return create_act
413
+
414
+ def follow(self, target_actor_url: str) -> Mapping[str, Any]:
415
+ """Send a Follow request to a remote actor."""
416
+ act_id = f"{self.local_actor.actor_url}/activities/follow-{uuid.uuid4().hex[:12]}"
417
+ follow_act = build_follow_activity(
418
+ activity_id=act_id,
419
+ actor_uri=self.local_actor.actor_url,
420
+ target_actor_uri=target_actor_url,
421
+ )
422
+ self.store.record_activity(follow_act, direction='outbox')
423
+
424
+ remote_doc = self.remote_resolver(target_actor_url)
425
+ if remote_doc:
426
+ inbox_url = remote_doc.get('inbox')
427
+ if inbox_url:
428
+ try:
429
+ self.deliver_to_inbox(inbox_url, follow_act)
430
+ except Exception:
431
+ pass
432
+
433
+ return follow_act
434
+
435
+ def unfollow(self, target_actor_url: str) -> Mapping[str, Any]:
436
+ """Send an Undo Follow request to a remote actor."""
437
+ act_id = f"{self.local_actor.actor_url}/activities/undo-{uuid.uuid4().hex[:12]}"
438
+ follow_to_undo = build_follow_activity(
439
+ activity_id=f"{self.local_actor.actor_url}/activities/follow-temp",
440
+ actor_uri=self.local_actor.actor_url,
441
+ target_actor_uri=target_actor_url,
442
+ )
443
+ undo_act = build_undo_activity(
444
+ activity_id=act_id,
445
+ actor_uri=self.local_actor.actor_url,
446
+ activity_to_undo=follow_to_undo,
447
+ recipient_uri=target_actor_url,
448
+ )
449
+ self.store.record_activity(undo_act, direction='outbox')
450
+ self.store.remove_following(self.local_actor.actor_url, target_actor_url)
451
+
452
+ remote_doc = self.remote_resolver(target_actor_url)
453
+ if remote_doc:
454
+ inbox_url = remote_doc.get('inbox')
455
+ if inbox_url:
456
+ try:
457
+ self.deliver_to_inbox(inbox_url, undo_act)
458
+ except Exception:
459
+ pass
460
+
461
+ return undo_act
462
+
463
+ def _fanout_activity(self, activity_doc: Mapping[str, Any]) -> None:
464
+ """Deliver activity to all addressed inboxes and follower inboxes."""
465
+ inboxes_to_deliver: set[str] = set()
466
+
467
+ # Check explicit recipients in to/cc
468
+ recipients = list(activity_doc.get('to') or []) + list(activity_doc.get('cc') or [])
469
+ delivers_to_followers = False
470
+
471
+ for r in recipients:
472
+ if r == self.local_actor.followers_url:
473
+ delivers_to_followers = True
474
+ elif r != PUBLIC_COLLECTION:
475
+ doc = self.remote_resolver(r)
476
+ if doc and 'inbox' in doc:
477
+ inboxes_to_deliver.add(doc['inbox'])
478
+
479
+ if delivers_to_followers:
480
+ followers = self.store.get_followers(self.local_actor.actor_url)
481
+ for f_uri in followers:
482
+ f_doc = self.remote_resolver(f_uri)
483
+ if f_doc and 'inbox' in f_doc:
484
+ inboxes_to_deliver.add(f_doc['inbox'])
485
+
486
+ for inbox in inboxes_to_deliver:
487
+ try:
488
+ self.deliver_to_inbox(inbox, activity_doc)
489
+ except Exception:
490
+ pass
@@ -0,0 +1,145 @@
1
+ """ActivityPub Media Attachment storage and Blossom BlobStore unification.
2
+
3
+ Standard library only. Manages:
4
+ - Storing and retrieving media attachments (images, audio, video, documents).
5
+ - Content-addressed indexing using SHA-256 digests.
6
+ - Direct unification with Fleetmesh Blossom BlobStore when available.
7
+ - HTTP media serving endpoint with immutable caching headers.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import urllib.request
13
+ from typing import Any, Mapping, Optional
14
+
15
+ from fleetmesh_core.conformance.errors import FleetmeshError
16
+
17
+ __all__ = [
18
+ 'ActivityPubMediaError',
19
+ 'ActivityPubMediaManager',
20
+ ]
21
+
22
+
23
+ class ActivityPubMediaError(FleetmeshError):
24
+ """Base error for media attachment operations."""
25
+
26
+
27
+ class ActivityPubMediaManager:
28
+ """Manages media attachments with Blossom BlobStore integration."""
29
+
30
+ def __init__(
31
+ self,
32
+ base_url: str = "https://node.example.org",
33
+ blob_store: Optional[Any] = None,
34
+ max_bytes: int = 50 * 1024 * 1024, # 50 MB
35
+ ):
36
+ self.base_url = base_url.rstrip('/')
37
+ self.blob_store = blob_store
38
+ self.max_bytes = max_bytes
39
+ # In-memory CAS fallback when BlobStore is not attached
40
+ self._memory_blobs: dict[str, bytes] = {}
41
+ self._media_types: dict[str, str] = {}
42
+
43
+ def store_attachment(
44
+ self,
45
+ data: bytes,
46
+ media_type: str = "application/octet-stream",
47
+ name: Optional[str] = None,
48
+ ) -> Mapping[str, Any]:
49
+ """Store binary attachment and produce ActivityStreams Document/Image descriptor."""
50
+ if len(data) > self.max_bytes:
51
+ raise ActivityPubMediaError(f"attachment exceeds maximum allowed size ({len(data)} > {self.max_bytes})")
52
+
53
+ sha256_hex = hashlib.sha256(data).hexdigest()
54
+
55
+ if self.blob_store is not None:
56
+ # Store in unified Blossom BlobStore
57
+ try:
58
+ self.blob_store.put(data, mime_type=media_type)
59
+ except Exception:
60
+ # If blob_store fails or lacks put signature, record locally
61
+ self._memory_blobs[sha256_hex] = data
62
+ else:
63
+ self._memory_blobs[sha256_hex] = data
64
+
65
+ self._media_types[sha256_hex] = media_type
66
+
67
+ # Classify ActivityStreams object type
68
+ as_type = "Document"
69
+ if media_type.startswith("image/"):
70
+ as_type = "Image"
71
+ elif media_type.startswith("video/"):
72
+ as_type = "Video"
73
+ elif media_type.startswith("audio/"):
74
+ as_type = "Audio"
75
+
76
+ media_url = f"{self.base_url}/media/{sha256_hex}"
77
+
78
+ doc = {
79
+ "type": as_type,
80
+ "mediaType": media_type,
81
+ "url": media_url,
82
+ "sha256": sha256_hex,
83
+ "size": len(data),
84
+ }
85
+ if name:
86
+ doc["name"] = name
87
+
88
+ return doc
89
+
90
+ def get_attachment(self, sha256_hex: str) -> tuple[Optional[bytes], Optional[str]]:
91
+ """Retrieve attachment binary data and mime-type."""
92
+ data = None
93
+ if self.blob_store is not None:
94
+ try:
95
+ data = self.blob_store.get(sha256_hex)
96
+ except Exception:
97
+ pass
98
+
99
+ if data is None:
100
+ data = self._memory_blobs.get(sha256_hex)
101
+
102
+ if data is None:
103
+ return None, None
104
+
105
+ media_type = self._media_types.get(sha256_hex, "application/octet-stream")
106
+ return data, media_type
107
+
108
+ def fetch_and_cache(
109
+ self,
110
+ url: str,
111
+ expected_sha256: Optional[str] = None,
112
+ ) -> Optional[Mapping[str, Any]]:
113
+ """Fetch remote media, verify digest, and cache locally."""
114
+ try:
115
+ req = urllib.request.Request(url, headers={'User-Agent': 'Fleetmesh-ActivityPub/1.0'})
116
+ with urllib.request.urlopen(req, timeout=10.0) as resp:
117
+ data = resp.read()
118
+ content_type = resp.headers.get_content_type() or "application/octet-stream"
119
+
120
+ sha256_hex = hashlib.sha256(data).hexdigest()
121
+ if expected_sha256 and sha256_hex.lower() != expected_sha256.lower():
122
+ return None
123
+
124
+ return self.store_attachment(data, media_type=content_type)
125
+ except Exception:
126
+ return None
127
+
128
+ def handle_media_request(self, path: str) -> tuple[int, dict[str, str], bytes]:
129
+ """Handle HTTP GET /media/<sha256> requests."""
130
+ parts = path.strip('/').split('/')
131
+ if len(parts) < 2 or parts[0] != 'media':
132
+ return 404, {'Content-Type': 'text/plain'}, b'Not found'
133
+
134
+ sha256_hex = parts[1]
135
+ data, media_type = self.get_attachment(sha256_hex)
136
+ if data is None:
137
+ return 404, {'Content-Type': 'text/plain'}, b'Media attachment not found'
138
+
139
+ headers = {
140
+ 'Content-Type': media_type or 'application/octet-stream',
141
+ 'Content-Length': str(len(data)),
142
+ 'Cache-Control': 'public, max-age=31536000, immutable',
143
+ 'ETag': f'"{sha256_hex}"',
144
+ }
145
+ return 200, headers, data