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,752 @@
1
+ """nsite: the node as a NIP-5A static-site host, reachable from the plain web.
2
+
3
+ Two kinds of hostname reach a site here:
4
+
5
+ npub1….<suffix> v<snapshot>.<suffix> <base36 pubkey><d>.<suffix>
6
+ the NIP's own grammar under each configured `suffixes` entry;
7
+ www.example.com
8
+ a CUSTOM DOMAIN the owner pointed here: a CNAME (or A) to the gateway
9
+ plus a TXT at `_nsite.example.com` naming the site. With
10
+ `require_domain_tag` (the default) the site's manifest must also carry
11
+ `["domain", "example.com"]`, so a DNS record someone else controls
12
+ cannot put a well-known pubkey's site behind a look-alike name.
13
+
14
+ The compartment holds no site state of its own beyond two small sqlite
15
+ tables (manifest cache, domain bindings) in the node's blob store database
16
+ directory. Blobs it fetches are verified against their sha256 and kept in the
17
+ node's BlobStore, which makes this node a Blossom mirror for every site it
18
+ serves; the store's own LRU quota bounds that, with a per-site cap on top.
19
+
20
+ Anything the outside world gets wrong - unknown label, no manifest, hash
21
+ mismatch, blob too big, unbound domain - is a refusal: counted, kept with
22
+ detail, and answered to the browser with a short status. Only our own bugs
23
+ are errors.
24
+
25
+ TLS for custom domains is not this process's job. The Ansible role's Caddy
26
+ asks `GET /nsite/_ask?domain=` before issuing a certificate; 200 means the
27
+ domain is verified here, anything else means Caddy must not. See
28
+ ops/roles/meshnode/templates/Caddyfile.j2.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import hashlib
33
+ import json
34
+ import logging
35
+ import urllib.parse
36
+ from typing import cast, Any, Callable, Mapping, Optional, Sequence
37
+
38
+ from node import compartment
39
+ from node.protocols.nostr import blossom
40
+ from node.transport import ws
41
+ from node import channels as ch_mod
42
+ from node import metrics as mx_mod
43
+ from node.compartment import stores as st
44
+ from node.compartment import Command, Compartment, Need
45
+ from node.compartment.schema import Column, Table
46
+ from node.config.parameter import Parameter
47
+ from node.storage import SqliteStore
48
+ from node.transport import dnstxt, netfetch
49
+ from fleetmesh_core.services import nsite as nr
50
+ from fleetmesh_core.wire.interfaces import NostrEvent
51
+ from node.config.compartments import NsiteConfig
52
+
53
+
54
+ from .types import (
55
+ DomainBinding,
56
+ NsiteState,
57
+ PublishResult,
58
+ )
59
+
60
+
61
+ try:
62
+ from fleetmesh_core.crypto.giftwrap import verify_event
63
+ except Exception: # pragma: no cover - giftwrap is part of the tree
64
+ def verify_event(ev: Mapping[str, Any]) -> bool:
65
+ return True
66
+
67
+ log = logging.getLogger(__name__)
68
+
69
+ SECURITY_HEADERS: dict[str, str] = {
70
+ # Third-party HTML on our origin's subdomains: keep it inside its own
71
+ # document, keep sniffing off, keep it out of frames elsewhere.
72
+ 'X-Content-Type-Options': 'nosniff',
73
+ 'X-Frame-Options': 'SAMEORIGIN',
74
+ 'Referrer-Policy': 'strict-origin-when-cross-origin',
75
+ 'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), gamepad=(self), fullscreen=(self), autoplay=(self)',
76
+ }
77
+
78
+ _SCHEMA = (
79
+ Table('nsite_manifest', (
80
+ Column('site_key', 'TEXT', primary_key=True),
81
+ Column('event_id', 'TEXT', not_null=True),
82
+ Column('json', 'TEXT', not_null=True),
83
+ Column('aggregate', 'TEXT', not_null=True),
84
+ Column('fetched_at', 'INTEGER', not_null=True),
85
+ )),
86
+ Table('nsite_domain', (
87
+ Column('domain', 'TEXT', primary_key=True),
88
+ Column('site_key', 'TEXT'),
89
+ Column('status', 'TEXT', not_null=True),
90
+ Column('why', 'TEXT'),
91
+ Column('checked_at', 'INTEGER', not_null=True),
92
+ )),
93
+ Table('nsite_site_bytes', (
94
+ Column('site_key', 'TEXT', not_null=True),
95
+ Column('sha256', 'TEXT', not_null=True),
96
+ Column('size', 'INTEGER', not_null=True),
97
+ ), primary_key=('site_key', 'sha256')),
98
+ )
99
+
100
+
101
+ def site_key(ref: nr.SiteRef) -> str:
102
+ if ref.kind == nr.KIND_SNAPSHOT:
103
+ return 'v:%s' % ref.event_id
104
+ if ref.kind == nr.KIND_NAMED:
105
+ return 'n:%s:%s' % (ref.pubkey, ref.d)
106
+ return 'r:%s' % ref.pubkey
107
+
108
+
109
+ def ref_from_key(key: str) -> nr.SiteRef:
110
+ kind, _, rest = key.partition(':')
111
+ if kind == 'v':
112
+ return nr.SiteRef(nr.KIND_SNAPSHOT, event_id=rest)
113
+ if kind == 'n':
114
+ pk, _, d = rest.partition(':')
115
+ return nr.SiteRef(nr.KIND_NAMED, pubkey=pk, d=d)
116
+ return nr.SiteRef(nr.KIND_ROOT, pubkey=rest)
117
+
118
+
119
+ class NsiteStore(SqliteStore):
120
+ """The three tables: resolved manifests, domain bindings, cached bytes.
121
+
122
+ On `BaseSqliteStore` like every other store in the node. It was not, until
123
+ storage step 4 asked what still imported sqlite3 and found this one keeping
124
+ its own connection - the subclass census had counted classes on the base,
125
+ not connections, so it was invisible. Being on the base is what lets a
126
+ second dialect reach it (docs/storage-backends.md).
127
+ """
128
+
129
+ SCHEMA = _SCHEMA
130
+
131
+ def manifest(self, key: str) -> Optional[tuple[NostrEvent, int]]:
132
+ r = self._one('SELECT json, fetched_at FROM nsite_manifest WHERE site_key=?', (key,))
133
+ return (json.loads(r[0]), int(r[1])) if r else None
134
+
135
+ def remember_manifest(self, key: str, ev: Mapping[str, Any], now: int) -> None:
136
+ self.execute('INSERT OR REPLACE INTO nsite_manifest VALUES (?,?,?,?,?)',
137
+ (key, ev['id'], json.dumps(ev, separators=(',', ':')), nr.aggregate(ev), now))
138
+ self.commit()
139
+
140
+ def forget_manifest(self, key: str) -> None:
141
+ self.execute('DELETE FROM nsite_manifest WHERE site_key=?', (key,))
142
+ self.commit()
143
+
144
+ def domain(self, domain: str) -> Optional[DomainBinding]:
145
+ r = self._one('SELECT site_key, status, why, checked_at FROM nsite_domain WHERE domain=?',
146
+ (domain,))
147
+ return {'domain': domain, 'site_key': r[0], 'status': r[1], 'why': r[2],
148
+ 'checked_at': int(r[3])} if r else None
149
+
150
+ def set_domain(self, domain: str, key: Optional[str], status: str, why: Optional[str], now: int) -> None:
151
+ self.execute('INSERT OR REPLACE INTO nsite_domain VALUES (?,?,?,?,?)',
152
+ (domain, key, status, why, now))
153
+ self.commit()
154
+
155
+ def drop_domain(self, domain: str) -> bool:
156
+ with self._lock:
157
+ n = self.execute('DELETE FROM nsite_domain WHERE domain=?', (domain,)).rowcount
158
+ self.commit()
159
+ return n > 0
160
+
161
+ def domains(self) -> list[DomainBinding]:
162
+ rows = self._all('SELECT domain, site_key, status, why, checked_at '
163
+ 'FROM nsite_domain ORDER BY domain')
164
+ return [{'domain': r[0], 'site_key': r[1], 'status': r[2], 'why': r[3],
165
+ 'checked_at': int(r[4])} for r in rows]
166
+
167
+ def note_bytes(self, key: str, sha: str, size: int) -> None:
168
+ self.execute('INSERT OR IGNORE INTO nsite_site_bytes VALUES (?,?,?)', (key, sha, size))
169
+ self.commit()
170
+
171
+ def site_bytes(self, key: str) -> int:
172
+ r = self._one('SELECT COALESCE(SUM(size),0) FROM nsite_site_bytes WHERE site_key=?', (key,))
173
+ return int(r[0]) if r else 0
174
+
175
+ def totals(self) -> dict[str, int]:
176
+ with self._lock:
177
+ m = self._one('SELECT COUNT(*) FROM nsite_manifest')
178
+ d = self._one("SELECT COUNT(*) FROM nsite_domain WHERE status='verified'")
179
+ b = self._one('SELECT COALESCE(SUM(size),0) FROM nsite_site_bytes')
180
+ return {'manifests_cached': int(m[0] if m else 0),
181
+ 'domains_verified': int(d[0] if d else 0),
182
+ 'cached_bytes': int(b[0] if b else 0)}
183
+
184
+
185
+ class NsiteGateway(Compartment):
186
+ """The compartment. Mounted on the relay face's HTTP fallthrough."""
187
+
188
+ name: str = 'nsite'
189
+ optional: bool = True
190
+ enabled_by: Optional[str] = 'nsite'
191
+ requires: tuple[str, ...] = ()
192
+ uses: tuple[str, ...] = ('bridge',)
193
+ reads: tuple[str, ...] = ('nsite', 'relays')
194
+ live: tuple[str, ...] = ('nsite',)
195
+ config_cls = NsiteConfig
196
+ SCHEMA = _SCHEMA
197
+ STORE_CLS = NsiteStore # builds its own; the base opens nothing for it (B5)
198
+ STORES = (
199
+ st.Store(key='recent_requests', description='recent HTTP requests served, newest last',
200
+ kind=st.RING, shape=tuple, bound=128),
201
+ )
202
+ recent_requests: st.Ring[tuple[Any, ...]]
203
+
204
+ CHANNELS = (
205
+ ch_mod.Channel('site_served', 'A static site resource was served to an HTTP client',
206
+ level=ch_mod.INFO, say='served {path} for site {site_key} ({bytes_sent} bytes)',
207
+ tags=('nsite', 'http')),
208
+ ch_mod.Channel('site_published', 'A NIP-5A nsite manifest was signed and published',
209
+ level=ch_mod.INFO, say='published nsite manifest {event_id} for site {site}',
210
+ tags=('nsite', 'publish')),
211
+ ch_mod.Channel('domain_verified', 'A custom domain was verified via DNS TXT and site manifest',
212
+ level=ch_mod.INFO, say='verified custom domain {domain} -> {site_key}',
213
+ tags=('nsite', 'dns')),
214
+ ch_mod.Channel('domain_refused', 'A custom domain verification was refused',
215
+ level=ch_mod.TROUBLE, say='refused custom domain {domain}: {why}',
216
+ tags=('nsite', 'dns')),
217
+ )
218
+ METRICS = (
219
+ mx_mod.Metric(name='nsite_requests_served', description='Static site HTTP requests served.',
220
+ kind=mx_mod.COUNTER, counts='site_served', tags=('nsite', 'http')),
221
+ mx_mod.Metric(name='nsite_sites_published', description='Static sites published.',
222
+ kind=mx_mod.COUNTER, counts='site_published', tags=('nsite', 'publish')),
223
+ mx_mod.Metric(name='nsite_domains_verified', description='Custom domains verified.',
224
+ kind=mx_mod.COUNTER, counts='domain_verified', tags=('nsite', 'dns')),
225
+ mx_mod.Metric(name='nsite_domains_refused', description='Custom domain verification failures.',
226
+ kind=mx_mod.COUNTER, counts='domain_refused', tags=('nsite', 'dns')),
227
+ )
228
+ NEEDS = (
229
+ Need('events', 'stores.events'),
230
+ Need('blobs', 'stores.blobs'),
231
+ Need('cfg', block=NsiteConfig),
232
+ Need('relays'),
233
+ Need('db_path', path='nsite.db'),
234
+ Need('bridge'),
235
+ Need('ks'),
236
+ Need('face'),
237
+ )
238
+ COMMANDS = (
239
+ Command(
240
+ key='domains',
241
+ name='Custom Domains',
242
+ description='list custom-domain bindings and their state',
243
+ tags=('nsite', 'domains'),
244
+ permission='nsite/domains',
245
+ run='domains_cmd',
246
+ reply='domains',
247
+ ),
248
+ Command(
249
+ key='sites',
250
+ name='Sites Summary',
251
+ description='cached manifests, verified domains and bytes',
252
+ tags=('nsite', 'sites'),
253
+ permission='nsite/sites',
254
+ run='sites_cmd',
255
+ reply='totals',
256
+ ),
257
+ Command(
258
+ key='verify',
259
+ name='Verify Domain',
260
+ description='re-check one custom domain now',
261
+ tags=('nsite', 'verify'),
262
+ parameters=(
263
+ Parameter(key='domain', name='domain', description='domain to verify', type=str, default='', required=True),
264
+ ),
265
+ permission='nsite/verify',
266
+ run='verify_cmd',
267
+ reply='binding',
268
+ ),
269
+ Command(
270
+ key='forget',
271
+ name='Forget Domain',
272
+ description='drop a binding; it is re-resolved on the next request',
273
+ tags=('nsite', 'forget'),
274
+ parameters=(
275
+ Parameter(key='domain', name='domain', description='domain binding to drop', type=str, default='', required=True),
276
+ ),
277
+ permission='nsite/forget',
278
+ run='forget_cmd',
279
+ reply='dropped',
280
+ ),
281
+ Command(
282
+ key='publish',
283
+ name='Publish Site',
284
+ description="sign this node's own site manifest from blobs already uploaded and publish it",
285
+ tags=('nsite', 'publish'),
286
+ parameters=(
287
+ Parameter(key='paths', name='paths', description='mapping of paths to sha256', type=dict, default=dict, required=True, cli=False),
288
+ Parameter(key='site', name='site', description='d tag (named site)', type=str, default=None, required=False),
289
+ Parameter(key='snapshot', name='snapshot', description='also publish immutable kind-5128 copy', type=bool, default=None, required=False),
290
+ Parameter(key='domains', name='domains', description='domains claimed for custom-domain proof', type=list, default=None, required=False, cli_name='--domain-claim'),
291
+ Parameter(key='servers', name='servers', description='Blossom URLs the blobs are on', type=list, default=None, required=False, cli_name='--server'),
292
+ Parameter(key='title', name='title', description='site title', type=str, default=None, required=False),
293
+ ),
294
+ permission='nsite/publish',
295
+ run='publish_cmd',
296
+ reply='manifest (+ snapshot) event ids and the site labels',
297
+ ),
298
+ )
299
+
300
+ def __init__(self, events: Any, blobs: Any, cfg: Optional[NsiteConfig] = None,
301
+ relays: Sequence[str] = (), store: Optional[NsiteStore] = None,
302
+ bridge: Optional[Any] = None, clock: Optional[Callable[[], int]] = None,
303
+ node: Optional[Any] = None,
304
+ query_relays: Optional[Callable[[Sequence[str], Sequence[Mapping[str, Any]]], list[NostrEvent]]] = None,
305
+ fetch_blob: Optional[Callable[[str, str, int], bytes]] = None,
306
+ lookup_txt: Optional[Callable[[str], list[str]]] = None,
307
+ ks: Optional[Any] = None, face: Optional[Any] = None,
308
+ blossom_url: Optional[str] = None,
309
+ db_path: Optional[str] = None) -> None:
310
+ super().__init__(node, clock)
311
+ # The keystore a manifest is signed with, the face whose HTTP side
312
+ # serves the site (and doubles as the Blossom server when the node
313
+ # has no separate one). A fake node in a suite supplies the same.
314
+ self.ks: Optional[Any] = ks if ks is not None else getattr(node, 'ks', None)
315
+ self.face: Optional[Any] = face if face is not None else getattr(node, 'face', None)
316
+ self.blossom_url: Optional[str] = blossom_url if blossom_url is not None else getattr(node, 'blossom_url', None)
317
+ self.events = events
318
+ self.blobs = blobs
319
+ self.cfg: NsiteConfig = cfg or NsiteConfig()
320
+ self.relays: tuple[str, ...] = tuple(self.cfg.relays or relays)
321
+ if store is not None:
322
+ self.store = store
323
+ elif db_path:
324
+ self.store = NsiteStore(db_path)
325
+ else:
326
+ self.store = NsiteStore()
327
+ self.bridge = bridge
328
+ self._query = query_relays or self._query_relays
329
+ self._fetch = fetch_blob or self._fetch_blob
330
+ self._txt = lookup_txt or (lambda name: dnstxt.lookup_txt(name, timeout=self.cfg.fetch_timeout))
331
+ self.served: int = 0
332
+ self.bytes_served: int = 0
333
+
334
+ # ---- lifecycle ----------------------------------------------------------
335
+ def start(self) -> None:
336
+ if self.face is not None and hasattr(self.face, 'http_handlers'):
337
+ self.face.http_handlers.append(self.handle_request)
338
+
339
+ def stop(self) -> None:
340
+ try:
341
+ self.store.close()
342
+ except Exception:
343
+ pass
344
+ super().stop()
345
+
346
+ def state(self) -> NsiteState:
347
+ return self.snapshot_state()
348
+
349
+ def snapshot_state(self) -> NsiteState:
350
+ t = self.store.totals()
351
+ return {'manifests_cached': t['manifests_cached'], 'domains_verified': t['domains_verified'],
352
+ 'cached_bytes': t['cached_bytes'], 'served': self.served, 'bytes_served': self.bytes_served,
353
+ 'suffixes': list(self.cfg.suffixes), 'custom_domains': self.cfg.custom_domains,
354
+ 'require_domain_tag': self.cfg.require_domain_tag, 'closed': bool(self.cfg.allow_domains)}
355
+
356
+ def trouble(self) -> list[str]:
357
+ out: list[str] = []
358
+ now = self.clock()
359
+ bad = self.refusals.within(self.TROUBLE_WINDOW, now, kind='blob', why='hash mismatch')
360
+ if bad:
361
+ out.append('nsite: %d blobs failed their hash in the last %ds - an upstream Blossom server is lying'
362
+ % (len(bad), self.TROUBLE_WINDOW))
363
+ return out
364
+
365
+ # ---- commands (control socket) -----------------------------------------
366
+ def domains_cmd(self) -> Mapping[str, Any]:
367
+ return {'domains': self.store.domains()}
368
+
369
+ def sites_cmd(self) -> Mapping[str, Any]:
370
+ return self.store.totals()
371
+
372
+ def verify_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
373
+ domain = nr.normalize_domain(str(args.get('domain', '')))
374
+ if not domain:
375
+ raise ValueError('domain required')
376
+ return {'binding': self.bind_domain(domain, force=True)}
377
+
378
+ def forget_cmd(self, args: Mapping[str, Any]) -> Mapping[str, Any]:
379
+ domain = nr.normalize_domain(str(args.get('domain', '')))
380
+ if not domain:
381
+ raise ValueError('domain required')
382
+ return {'dropped': self.store.drop_domain(domain)}
383
+
384
+ def publish_cmd(self, args: Mapping[str, Any]) -> PublishResult:
385
+ return self.publish(args)
386
+
387
+ def publish(self, args: Mapping[str, Any]) -> PublishResult:
388
+ """The node's own site: a manifest signed with the node key.
389
+
390
+ The blobs are already on a Blossom server (the CLI PUTs them to this
391
+ node's face before asking) - the control socket carries hashes, never
392
+ file bodies. Every path must be absolute and every hash 64 hex chars;
393
+ the blobs this node holds are checked to exist so a typo in a hash
394
+ cannot publish a site with holes in it.
395
+ """
396
+ sk = getattr(self.ks, 'node_sk', None)
397
+ if not sk:
398
+ raise ValueError('this node has no signing key for the manifest')
399
+ paths = args.get('paths')
400
+ if not isinstance(paths, Mapping) or not paths:
401
+ raise ValueError('paths must be a non-empty {path: sha256} object')
402
+ tags: list[list[str]] = []
403
+ missing: list[str] = []
404
+ for pth, h in sorted(paths.items()):
405
+ if not isinstance(pth, str) or not pth.startswith('/') or not isinstance(h, str) or not nr._RE_SHA.match(h.lower()):
406
+ raise ValueError('bad path entry %r' % (pth,))
407
+ if not self.blobs.has(h.lower()):
408
+ missing.append(pth)
409
+ tags.append(['path', pth, h.lower()])
410
+ if missing:
411
+ raise ValueError('blobs not on this node for: %s' % ', '.join(missing[:5]))
412
+ servers = list(args.get('servers') or [])
413
+ own = self.blossom_url
414
+ if not own:
415
+ face_url = getattr(self.face, 'url', '') or ''
416
+ if isinstance(face_url, str) and face_url.startswith(('ws://', 'wss://')):
417
+ own = 'http' + face_url[2:] # the face is also the Blossom server
418
+ if own and own not in servers:
419
+ servers.insert(0, own)
420
+ for u in servers:
421
+ tags.append(['server', str(u)])
422
+ for d in args.get('domains') or []:
423
+ tags.append(['domain', nr.normalize_domain(str(d))])
424
+ if args.get('title'):
425
+ tags.append(['title', str(args['title'])])
426
+ if args.get('embed'):
427
+ tags.append(['embed', 'allow'])
428
+ if args.get('isolation'):
429
+ tags.append(['isolation', 'coop-coep'])
430
+ site = args.get('site')
431
+ kind = nr.KIND_ROOT
432
+ if site:
433
+ if not nr._RE_NAMED.match('0' * 50 + str(site)):
434
+ raise ValueError('site id must be 1-13 of [a-z0-9-]')
435
+ kind = nr.KIND_NAMED
436
+ tags.append(['d', str(site)])
437
+ from fleetmesh_core.storage import store as refstore
438
+ now = self.clock()
439
+ ev = refstore.make_signed_event(sk, now, kind, tags, '')
440
+ tags_x = [t for t in tags if t[0] != 'd'] + [['x', nr.aggregate(ev)]]
441
+ ev = refstore.make_signed_event(sk, now, kind, tags + [['x', nr.aggregate(ev)]], '')
442
+ out: PublishResult = {'manifest': ev['id'], 'kind': kind, 'aggregate': nr.aggregate(ev), 'published_to': []}
443
+ ref = nr.SiteRef(kind, pubkey=ev['pubkey'], d=str(site or ''))
444
+ out['label'] = ref.label
445
+ events = [ev]
446
+ if args.get('snapshot'):
447
+ snap = refstore.make_signed_event(sk, now, nr.KIND_SNAPSHOT, tags_x, '')
448
+ out['snapshot'] = snap['id']
449
+ out['snapshot_label'] = nr.SiteRef(nr.KIND_SNAPSHOT, event_id=snap['id']).label
450
+ events.append(snap)
451
+ for e in events:
452
+ self.events.insert_event(e, now)
453
+ if self.bridge is not None and hasattr(self.bridge, 'publish'):
454
+ try:
455
+ okp, why = self.bridge.publish(e)
456
+ out['published_to'].append({'id': e['id'], 'ok': bool(okp), 'why': why})
457
+ except Exception as e2:
458
+ self.refusals.refuse('publish', 'bridge failed', e['id'], compartment.why_detail(e2))
459
+ self.store.forget_manifest(site_key(ref))
460
+ out['hosts'] = ['%s.%s' % (out['label'], s) for s in self.cfg.suffixes]
461
+ self.say('site_published', event_id=out.get('manifest', ''), site=out.get('label', ''))
462
+ return out
463
+
464
+ # ---- the request ---------------------------------------------------------
465
+ def handle_request(self, conn: Any, req: Any) -> bool:
466
+ """The face's hook: True if this was ours (answered), False to let
467
+ the rest of the face have it."""
468
+ headers = getattr(req, 'headers', {}) or {}
469
+ host_b = headers.get(b'host') or headers.get(b'Host') or b''
470
+ host = host_b.decode('latin-1', errors='replace') if isinstance(host_b, bytes) else str(host_b)
471
+ method = (getattr(req, 'method', 'GET') or 'GET').upper()
472
+ path = getattr(req, 'path', '/') or '/'
473
+
474
+ if path.startswith('/nsite/_ask'):
475
+ return self._ask(conn, path)
476
+
477
+ if method not in ('GET', 'HEAD'):
478
+ return False
479
+ ref = self.route(host)
480
+ if ref is None:
481
+ if not self.under_suffix(host):
482
+ return False
483
+ self.refusals.refuse('label', 'not a site label', host, {})
484
+ status, body, ctype, extra = self._refusal_response('404 Not Found', 'no site here')
485
+ else:
486
+ status, body, ctype, extra = self.serve(ref, path)
487
+ if method == 'HEAD':
488
+ body_out = b''
489
+ else:
490
+ body_out = body
491
+ hdrs = dict(SECURITY_HEADERS)
492
+ hdrs.update(extra)
493
+ hdrs = {k: v for k, v in hdrs.items() if v != ''}
494
+ hdrs.setdefault('Content-Length', str(len(body)))
495
+ ws.http_response(conn, status, body_out, ctype, hdrs)
496
+ return True
497
+
498
+ def route(self, host: str) -> Optional[nr.SiteRef]:
499
+ """Host header -> site, or None when the host is not a site here."""
500
+ ref = nr.parse_host(host, self.cfg.suffixes)
501
+ if ref is not None:
502
+ return ref
503
+ h = nr.normalize_domain(host.rsplit(':', 1)[0] if host.count(':') == 1 else host)
504
+ if not h or not self.cfg.custom_domains:
505
+ return None
506
+ if h in {nr.normalize_domain(x) for x in self.cfg.suffixes}:
507
+ return None # the bare suffix is the face itself
508
+ # A name under the suffix that is not a site label (www.<suffix>,
509
+ # docs.<suffix>) is the operator's own zone: it may still be bound
510
+ # like any custom domain, and when it is not, the 404 is ours.
511
+ b = self.bind_domain(h)
512
+ key = b.get('site_key')
513
+ if b.get('status') == 'verified' and key:
514
+ return ref_from_key(key)
515
+ return None
516
+
517
+ def under_suffix(self, host: str) -> bool:
518
+ """True for any name under a suffix - the zone this gateway owns,
519
+ where a host that is neither a site label nor a bound custom domain is
520
+ still our 404, never the relay's NIP-11 document."""
521
+ h = nr.normalize_domain(host.rsplit(':', 1)[0] if host.count(':') == 1 else host)
522
+ for sfx in self.cfg.suffixes:
523
+ sfx = nr.normalize_domain(sfx)
524
+ if h.endswith('.' + sfx):
525
+ return True
526
+ return False
527
+
528
+ def serve(self, ref: nr.SiteRef, path: str) -> tuple[str, bytes, str, dict[str, str]]:
529
+ """(status line, body, content type, extra headers)."""
530
+ ev = self.manifest(ref)
531
+ if ev is None:
532
+ return self._refusal_response('404 Not Found', 'no site here')
533
+ resolved = nr.resolve_path(nr.paths_of(ev), path)
534
+ if resolved is None:
535
+ self.refusals.refuse('path', 'not in manifest', site_key(ref), {'path': path})
536
+ return self._refusal_response('404 Not Found', 'not found')
537
+ data = self.blob(ref, ev, resolved.sha256)
538
+ if data is None:
539
+ return self._refusal_response('502 Bad Gateway', 'blob unavailable')
540
+ self.served += 1
541
+ self.bytes_served += len(data)
542
+ status = '200 OK' if resolved.status == 200 else '404 Not Found'
543
+ self.recent_requests.append((site_key(ref), path, len(data), status))
544
+ self.say('site_served', path=path, site_key=site_key(ref), bytes_sent=len(data))
545
+ headers = {
546
+ 'ETag': '"%s"' % resolved.sha256,
547
+ 'X-Nsite-Site': ref.label,
548
+ 'X-Nsite-Version': nr.aggregate(ev),
549
+ }
550
+ if ref.kind == nr.KIND_SNAPSHOT:
551
+ headers['Cache-Control'] = 'public, max-age=31536000, immutable'
552
+ else:
553
+ headers['Cache-Control'] = 'public, max-age=%d' % max(60, min(self.cfg.manifest_ttl_seconds, 3600))
554
+ embed_origins = list(self.cfg.frame_ancestors)
555
+ for origin in nr.frame_ancestors_of(ev):
556
+ if origin not in embed_origins:
557
+ embed_origins.append(origin)
558
+ if embed_origins or nr.allows_embed(ev):
559
+ ancestors_clause = " ".join(["'self'"] + embed_origins) if embed_origins else "'self' *"
560
+ headers['Content-Security-Policy'] = 'frame-ancestors %s' % ancestors_clause
561
+ headers['X-Frame-Options'] = ''
562
+ if self.cfg.cross_origin_isolation or nr.wants_cross_origin_isolation(ev):
563
+ headers['Cross-Origin-Opener-Policy'] = 'same-origin'
564
+ headers['Cross-Origin-Embedder-Policy'] = 'credentialless'
565
+ return status, data, resolved.content_type, headers
566
+
567
+ def _refusal_response(self, status: str, text: str) -> tuple[str, bytes, str, dict[str, str]]:
568
+ return status, (text + '\n').encode(), 'text/plain; charset=utf-8', {'Cache-Control': 'no-store'}
569
+
570
+ # ---- manifests -------------------------------------------------------------
571
+ def manifest(self, ref: nr.SiteRef) -> Optional[NostrEvent]:
572
+ key = site_key(ref)
573
+ now = self.clock()
574
+ cached = self.store.manifest(key)
575
+ ttl = 10 ** 9 if ref.kind == nr.KIND_SNAPSHOT else self.cfg.manifest_ttl_seconds
576
+ if cached and now - cached[1] < ttl:
577
+ return cached[0]
578
+ ev = self._local_manifest(ref)
579
+ if ev is None:
580
+ ev = self._remote_manifest(ref)
581
+ if ev is None:
582
+ if cached:
583
+ return cached[0] # stale is better than a blank page when the relays are down
584
+ self.refusals.refuse('manifest', 'not found', key)
585
+ return None
586
+ self.store.remember_manifest(key, ev, now)
587
+ return ev
588
+
589
+ def _local_manifest(self, ref: nr.SiteRef) -> Optional[NostrEvent]:
590
+ filt = dict(ref.filter())
591
+ if ref.kind == nr.KIND_ROOT and self.cfg.allow_legacy_kind:
592
+ filt['kinds'] = [nr.KIND_ROOT, nr.KIND_LEGACY_ROOT]
593
+ try:
594
+ rows = self.events.newest_matching(filt, 0, 1 << 40, self.clock(), 8)
595
+ except Exception as e:
596
+ log.debug('nsite: local lookup failed: %s', e)
597
+ return None
598
+ return self._pick(rows, ref)
599
+
600
+ def _remote_manifest(self, ref: nr.SiteRef) -> Optional[NostrEvent]:
601
+ if not self.relays:
602
+ return None
603
+ filt = dict(ref.filter())
604
+ if ref.kind == nr.KIND_ROOT and self.cfg.allow_legacy_kind:
605
+ filt['kinds'] = [nr.KIND_ROOT, nr.KIND_LEGACY_ROOT]
606
+ try:
607
+ rows = self._query(self.relays, [filt])
608
+ except Exception as e:
609
+ self.refusals.refuse('manifest', 'relays unreachable', site_key(ref), compartment.why_detail(e))
610
+ return None
611
+ ev = self._pick(rows, ref)
612
+ if ev is not None:
613
+ try:
614
+ self.events.insert_event(ev, self.clock())
615
+ except Exception as e:
616
+ log.debug('nsite: keeping manifest locally failed: %s', e)
617
+ return ev
618
+
619
+ def _pick(self, rows: Sequence[Mapping[str, Any]], ref: nr.SiteRef) -> Optional[NostrEvent]:
620
+ best: Optional[NostrEvent] = None
621
+ for ev in rows:
622
+ if not nr.is_manifest(ev, ref, allow_legacy=self.cfg.allow_legacy_kind):
623
+ continue
624
+ if not verify_event(ev):
625
+ self.refusals.refuse('manifest', 'bad signature', site_key(ref), {'id': str(ev.get('id'))})
626
+ continue
627
+ if best is None or (int(ev.get('created_at', 0)), ev.get('kind') == nr.KIND_ROOT) > \
628
+ (int(best.get('created_at', 0)), best.get('kind') == nr.KIND_ROOT):
629
+ best = cast(NostrEvent, ev)
630
+ return best
631
+
632
+ def _query_relays(self, relays: Sequence[str], filters: Sequence[Mapping[str, Any]]) -> list[NostrEvent]:
633
+ from node.protocols.nostr import client as relay
634
+ return relay.query_all(list(relays), list(filters), timeout=self.cfg.fetch_timeout)
635
+
636
+ def user_servers(self, pubkey: str) -> Optional[NostrEvent]:
637
+ """The author's kind-10063 server list, local then relays."""
638
+ filt = {'kinds': [nr.KIND_USER_SERVERS], 'authors': [pubkey], 'limit': 1}
639
+ try:
640
+ rows = self.events.newest_matching(filt, 0, 1 << 40, self.clock(), 1)
641
+ except Exception:
642
+ rows = []
643
+ if not rows and self.relays:
644
+ try:
645
+ rows = self._query(self.relays, [filt])
646
+ except Exception:
647
+ rows = []
648
+ rows = [r for r in rows if r.get('kind') == nr.KIND_USER_SERVERS and r.get('pubkey') == pubkey]
649
+ return max(rows, key=lambda r: int(r.get('created_at', 0))) if rows else None
650
+
651
+ # ---- blobs ----------------------------------------------------------------
652
+ def blob(self, ref: nr.SiteRef, ev: Mapping[str, Any], sha: str) -> Optional[bytes]:
653
+ key = site_key(ref)
654
+ data = self.blobs.get(sha)
655
+ if data is not None:
656
+ return data
657
+ if self.store.site_bytes(key) >= self.cfg.max_site_bytes:
658
+ self.refusals.refuse('quota', 'site over cap', key, {'cap': self.cfg.max_site_bytes})
659
+ return None
660
+ servers = nr.blossom_servers(ev, self.user_servers(ev.get('pubkey', '')) if ev.get('pubkey') else None,
661
+ self.cfg.fallback_servers)
662
+ if not servers:
663
+ self.refusals.refuse('blob', 'no servers', key, {'sha256': sha})
664
+ return None
665
+ for server in servers:
666
+ try:
667
+ data = self._fetch(server, sha, self.cfg.max_blob_bytes)
668
+ except netfetch.FetchTooLarge:
669
+ self.refusals.refuse('blob', 'too large', key, {'sha256': sha, 'server': server})
670
+ return None
671
+ except ValueError as e:
672
+ self.refusals.refuse('blob', 'hash mismatch', key, {'sha256': sha, 'server': server, 'why': str(e)})
673
+ continue
674
+ except Exception as e:
675
+ self.refusals.refuse('blob', 'server unreachable', key,
676
+ dict(compartment.why_detail(e), server=server))
677
+ continue
678
+ if hashlib.sha256(data).hexdigest() != sha:
679
+ self.refusals.refuse('blob', 'hash mismatch', key, {'sha256': sha, 'server': server})
680
+ continue
681
+ try:
682
+ self.blobs.put(data, nr.guess_type(sha), uploaded_by=ev.get('pubkey'), now=self.clock())
683
+ except Exception as e:
684
+ log.debug('nsite: caching %s failed: %s', sha, e)
685
+ self.store.note_bytes(key, sha, len(data))
686
+ return data
687
+ return None
688
+
689
+ def _fetch_blob(self, server: str, sha: str, max_bytes: int) -> bytes:
690
+ return blossom.BlossomClient.get_blob(server, sha, timeout=self.cfg.fetch_timeout, max_bytes=max_bytes)
691
+
692
+ # ---- custom domains -----------------------------------------------------------
693
+ def bind_domain(self, domain: str, force: bool = False) -> DomainBinding:
694
+ """The binding for `domain`, re-checked when older than the TTL.
695
+
696
+ verified TXT names a site we can see and (when required) the
697
+ manifest claims the domain
698
+ unbound no `_nsite.<domain>` TXT, or not parseable
699
+ unclaimed TXT points at a site whose manifest does not list the domain
700
+ denied operator deny list, or not on the allow list of a closed gateway
701
+ """
702
+ now = self.clock()
703
+ cur = self.store.domain(domain)
704
+ if cur and not force and now - cur['checked_at'] < self.cfg.domain_ttl_seconds:
705
+ return cur
706
+
707
+ def settle(status: str, key: Optional[str], why: Optional[str]) -> DomainBinding:
708
+ self.store.set_domain(domain, key, status, why, now)
709
+ if status != 'verified':
710
+ self.refusals.refuse('domain', status, domain, {'why': why or ''})
711
+ self.say('domain_refused', domain=domain, why=why or status)
712
+ else:
713
+ self.say('domain_verified', domain=domain, site_key=key or '')
714
+ return {'domain': domain, 'site_key': key, 'status': status, 'why': why, 'checked_at': now}
715
+
716
+ if domain in {nr.normalize_domain(d) for d in self.cfg.deny_domains}:
717
+ return settle('denied', None, 'operator deny list')
718
+ if self.cfg.allow_domains and domain not in {nr.normalize_domain(d) for d in self.cfg.allow_domains}:
719
+ return settle('denied', None, 'not on the allow list')
720
+ try:
721
+ txts = self._txt('_nsite.' + domain)
722
+ except dnstxt.DnsError as e:
723
+ if cur and cur['status'] == 'verified':
724
+ return cur # a resolver hiccup must not take a live site down
725
+ return settle('unbound', None, 'dns: %s' % e)
726
+ ref: Optional[nr.SiteRef] = None
727
+ for t in txts:
728
+ ref = nr.parse_txt_binding(t)
729
+ if ref:
730
+ break
731
+ if ref is None:
732
+ return settle('unbound', None, 'no _nsite TXT naming a site' if not txts else 'TXT present but not a site')
733
+ ev = self.manifest(ref)
734
+ if ev is None:
735
+ return settle('unbound', site_key(ref), 'site has no manifest')
736
+ if self.cfg.require_domain_tag and domain not in nr.claimed_domains(ev):
737
+ return settle('unclaimed', site_key(ref), 'manifest has no ["domain", "%s"] tag' % domain)
738
+ return settle('verified', site_key(ref), None)
739
+
740
+ def _ask(self, conn: Any, path: str) -> bool:
741
+ """Caddy on_demand_tls `ask`: 200 only for a verified custom domain."""
742
+ q = urllib.parse.parse_qs(urllib.parse.urlsplit(path).query)
743
+ domain = nr.normalize_domain((q.get('domain') or [''])[0])
744
+ ok = False
745
+ if domain:
746
+ if nr.parse_host(domain, self.cfg.suffixes) is not None or domain in {s.lower() for s in self.cfg.suffixes}:
747
+ ok = True
748
+ elif self.cfg.custom_domains:
749
+ ok = self.bind_domain(domain).get('status') == 'verified'
750
+ ws.http_response(conn, '200 OK' if ok else '403 Forbidden', b'ok\n' if ok else b'no\n',
751
+ 'text/plain; charset=utf-8', {'Cache-Control': 'no-store'})
752
+ return True