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.
- fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
- fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
- fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
- fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
- fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
- fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
- node/RELEASE +1 -0
- node/__init__.py +35 -0
- node/app/__init__.py +49 -0
- node/app/commands.py +265 -0
- node/app/config.py +131 -0
- node/app/control.py +670 -0
- node/app/core.py +867 -0
- node/app/plugins.py +76 -0
- node/app/router.py +83 -0
- node/app/socket.py +431 -0
- node/app/systemd.py +36 -0
- node/app/views.py +204 -0
- node/channels/__init__.py +378 -0
- node/channels/bus.py +331 -0
- node/channels/journal.py +46 -0
- node/commands/__init__.py +140 -0
- node/commands/adapter.py +210 -0
- node/commands/base.py +278 -0
- node/commands/cluster_cmds.py +183 -0
- node/commands/compute_cmds.py +401 -0
- node/commands/core.py +474 -0
- node/commands/gossip_cmds.py +106 -0
- node/commands/nsite_cmds.py +141 -0
- node/commands/peering.py +402 -0
- node/commands/plugin_cmds.py +135 -0
- node/commands/sidecar_cmds.py +120 -0
- node/commands/sync.py +388 -0
- node/commands/transport_cmds.py +451 -0
- node/commands/video_cmds.py +99 -0
- node/compartment/__init__.py +124 -0
- node/compartment/base.py +868 -0
- node/compartment/command.py +458 -0
- node/compartment/context.py +163 -0
- node/compartment/every.py +108 -0
- node/compartment/handler.py +131 -0
- node/compartment/lifecycle.py +460 -0
- node/compartment/lint.py +388 -0
- node/compartment/need.py +158 -0
- node/compartment/problem.py +521 -0
- node/compartment/refusals.py +120 -0
- node/compartment/registry.py +790 -0
- node/compartment/schema.py +290 -0
- node/compartment/sidecar.py +499 -0
- node/compartment/stores.py +662 -0
- node/compartment/wire.py +225 -0
- node/compartment/worker.py +61 -0
- node/config/__init__.py +43 -0
- node/config/base.py +116 -0
- node/config/change.py +441 -0
- node/config/changestore.py +201 -0
- node/config/compartments.py +1144 -0
- node/config/node.py +318 -0
- node/config/parameter.py +563 -0
- node/config/protocols.py +274 -0
- node/config/records.py +171 -0
- node/examples/__init__.py +1 -0
- node/examples/demo.py +226 -0
- node/examples/follower.py +46 -0
- node/interfaces.py +235 -0
- node/keystore/__init__.py +27 -0
- node/keystore/core.py +103 -0
- node/keystore/enclave.py +688 -0
- node/meshnode.py +111 -0
- node/metrics/__init__.py +396 -0
- node/metrics/adapters.py +102 -0
- node/metrics/face.py +110 -0
- node/planes/__init__.py +21 -0
- node/planes/identity/__init__.py +20 -0
- node/planes/identity/announce/__init__.py +9 -0
- node/planes/identity/announce/compartment.py +447 -0
- node/planes/identity/announce/types.py +26 -0
- node/planes/identity/discovery/__init__.py +9 -0
- node/planes/identity/discovery/compartment.py +522 -0
- node/planes/identity/discovery/types.py +13 -0
- node/planes/identity/linked/__init__.py +1 -0
- node/planes/identity/linked/compartment.py +270 -0
- node/planes/identity/linked/types.py +25 -0
- node/planes/identity/presence/__init__.py +7 -0
- node/planes/identity/presence/compartment.py +409 -0
- node/planes/identity/presence/types.py +14 -0
- node/planes/operations/__init__.py +13 -0
- node/planes/operations/scheduler/__init__.py +9 -0
- node/planes/operations/scheduler/compartment.py +353 -0
- node/planes/operations/scheduler/types.py +21 -0
- node/planes/operations/updates/__init__.py +17 -0
- node/planes/operations/updates/core.py +310 -0
- node/planes/operations/updates/store.py +79 -0
- node/planes/operations/updates/types.py +20 -0
- node/planes/peering/__init__.py +28 -0
- node/planes/peering/budget/__init__.py +43 -0
- node/planes/peering/budget/chain.py +43 -0
- node/planes/peering/budget/core.py +537 -0
- node/planes/peering/budget/evaluation.py +80 -0
- node/planes/peering/budget/meter.py +100 -0
- node/planes/peering/budget/receipts.py +439 -0
- node/planes/peering/budget/shape.py +101 -0
- node/planes/peering/budget/tick.py +110 -0
- node/planes/peering/budget/types.py +67 -0
- node/planes/peering/budget/windows.py +346 -0
- node/planes/peering/capacity/__init__.py +7 -0
- node/planes/peering/capacity/compartment.py +342 -0
- node/planes/peering/capacity/types.py +24 -0
- node/planes/peering/peering/__init__.py +25 -0
- node/planes/peering/peering/core.py +464 -0
- node/planes/peering/peering/envelope.py +142 -0
- node/planes/peering/peering/handshake.py +198 -0
- node/planes/peering/peering/shape.py +80 -0
- node/planes/peering/peering/types.py +39 -0
- node/planes/peering/reporter/__init__.py +42 -0
- node/planes/peering/reporter/constants.py +29 -0
- node/planes/peering/reporter/core.py +299 -0
- node/planes/peering/reporter/finding.py +74 -0
- node/planes/peering/reporter/findings.py +171 -0
- node/planes/peering/reporter/publish.py +285 -0
- node/planes/peering/reporter/reading.py +193 -0
- node/planes/peering/reporter/shape.py +86 -0
- node/planes/peering/reporter/types.py +50 -0
- node/planes/peering/strain/__init__.py +9 -0
- node/planes/peering/strain/compartment.py +204 -0
- node/planes/peering/strain/types.py +21 -0
- node/planes/peering/trust/__init__.py +9 -0
- node/planes/peering/trust/compartment.py +239 -0
- node/planes/peering/trust/types.py +30 -0
- node/planes/services/__init__.py +33 -0
- node/planes/services/compute/__init__.py +160 -0
- node/planes/services/compute/common.py +134 -0
- node/planes/services/compute/compartment.py +681 -0
- node/planes/services/compute/executors/__init__.py +121 -0
- node/planes/services/compute/executors/agent.py +840 -0
- node/planes/services/compute/executors/bacalhau.py +605 -0
- node/planes/services/compute/executors/base.py +127 -0
- node/planes/services/compute/executors/harness.py +439 -0
- node/planes/services/compute/executors/mcp.py +454 -0
- node/planes/services/compute/executors/media.py +898 -0
- node/planes/services/compute/executors/nondet.py +283 -0
- node/planes/services/compute/executors/providers.py +499 -0
- node/planes/services/compute/executors/registry.py +140 -0
- node/planes/services/compute/executors/wasm.py +244 -0
- node/planes/services/compute/executors/zk.py +604 -0
- node/planes/services/compute/jobs.py +227 -0
- node/planes/services/compute/load.py +180 -0
- node/planes/services/compute/market.py +433 -0
- node/planes/services/compute/sandbox.py +282 -0
- node/planes/services/compute/types.py +84 -0
- node/planes/services/compute_wire/__init__.py +58 -0
- node/planes/services/compute_wire/compartment.py +256 -0
- node/planes/services/compute_wire/messages.py +320 -0
- node/planes/services/compute_wire/sealing.py +147 -0
- node/planes/services/compute_wire/session.py +572 -0
- node/planes/services/compute_wire/types.py +49 -0
- node/planes/services/compute_wire/verify.py +331 -0
- node/planes/services/dvm/__init__.py +24 -0
- node/planes/services/dvm/gateway.py +526 -0
- node/planes/services/dvm/protocol.py +35 -0
- node/planes/services/dvm/translate.py +15 -0
- node/planes/services/dvm/types.py +25 -0
- node/planes/services/gateway/__init__.py +40 -0
- node/planes/services/gateway/settle.py +279 -0
- node/planes/services/gateway/translate.py +644 -0
- node/planes/services/gossip/__init__.py +22 -0
- node/planes/services/gossip/core.py +929 -0
- node/planes/services/gossip/envelope.py +154 -0
- node/planes/services/gossip/mesh.py +127 -0
- node/planes/services/gossip/types.py +50 -0
- node/planes/services/nsite/__init__.py +7 -0
- node/planes/services/nsite/client.py +62 -0
- node/planes/services/nsite/compartment.py +752 -0
- node/planes/services/nsite/types.py +44 -0
- node/planes/services/paidfetch/__init__.py +7 -0
- node/planes/services/paidfetch/compartment.py +862 -0
- node/planes/services/paidfetch/types.py +99 -0
- node/planes/services/settle/__init__.py +27 -0
- node/planes/services/settle/compartment.py +384 -0
- node/planes/services/settle/engine.py +456 -0
- node/planes/services/settle/gateways.py +541 -0
- node/planes/services/settle/payment.py +512 -0
- node/planes/services/settle/store.py +74 -0
- node/planes/services/settle/types.py +58 -0
- node/planes/services/sidecars/__init__.py +8 -0
- node/planes/services/sidecars/compartment.py +461 -0
- node/planes/services/sidecars/manifest.py +304 -0
- node/planes/services/sidecars/manifests/10-grasp.json +37 -0
- node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
- node/planes/services/sidecars/manifests/30-magpie.json +28 -0
- node/planes/services/sidecars/types.py +43 -0
- node/planes/services/video/__init__.py +106 -0
- node/planes/services/video/barter.py +407 -0
- node/planes/services/video/cli.py +158 -0
- node/planes/services/video/compartment.py +932 -0
- node/planes/services/video/ingest.py +524 -0
- node/planes/services/video/ledger.py +705 -0
- node/planes/services/video/packager.py +847 -0
- node/planes/services/video/player.py +456 -0
- node/planes/services/video/timed_segments.py +124 -0
- node/planes/services/video/transit.py +478 -0
- node/planes/store/__init__.py +24 -0
- node/planes/store/blob_replication/__init__.py +7 -0
- node/planes/store/blob_replication/compartment.py +496 -0
- node/planes/store/blob_replication/types.py +35 -0
- node/planes/store/blob_wire/__init__.py +48 -0
- node/planes/store/blob_wire/client.py +57 -0
- node/planes/store/blob_wire/core.py +359 -0
- node/planes/store/blob_wire/intake.py +74 -0
- node/planes/store/blob_wire/messages.py +85 -0
- node/planes/store/projected/__init__.py +7 -0
- node/planes/store/projected/compartment.py +258 -0
- node/planes/store/projected/types.py +29 -0
- node/planes/store/retention/__init__.py +7 -0
- node/planes/store/retention/compartment.py +214 -0
- node/planes/store/retention/types.py +12 -0
- node/planes/store/sync/__init__.py +35 -0
- node/planes/store/sync/core.py +311 -0
- node/planes/store/sync/envelope.py +106 -0
- node/planes/store/sync/handover.py +206 -0
- node/planes/store/sync/initiator.py +128 -0
- node/planes/store/sync/responder.py +114 -0
- node/planes/store/sync/rules.py +179 -0
- node/planes/store/sync/shape.py +80 -0
- node/planes/store/sync/types.py +51 -0
- node/planes/transport/__init__.py +23 -0
- node/planes/transport/bridge/__init__.py +13 -0
- node/planes/transport/bridge/compartment.py +785 -0
- node/planes/transport/bridge/ratelimit.py +44 -0
- node/planes/transport/bridge/types.py +43 -0
- node/planes/transport/lan/__init__.py +9 -0
- node/planes/transport/lan/compartment.py +446 -0
- node/planes/transport/lan/types.py +25 -0
- node/planes/transport/link/__init__.py +7 -0
- node/planes/transport/link/compartment.py +257 -0
- node/planes/transport/link/types.py +27 -0
- node/planes/transport/mediation/__init__.py +45 -0
- node/planes/transport/mediation/compartment.py +238 -0
- node/planes/transport/mediation/leaf.py +107 -0
- node/planes/transport/mediation/messages.py +302 -0
- node/planes/transport/mediation/service.py +233 -0
- node/planes/transport/mediation/store.py +37 -0
- node/planes/transport/mediation/types.py +20 -0
- node/planes/transport/p2p/__init__.py +7 -0
- node/planes/transport/p2p/compartment.py +349 -0
- node/planes/transport/p2p/types.py +22 -0
- node/protocols/__init__.py +277 -0
- node/protocols/activitypub/__init__.py +56 -0
- node/protocols/activitypub/actor.py +230 -0
- node/protocols/activitypub/federation.py +490 -0
- node/protocols/activitypub/media.py +145 -0
- node/protocols/activitypub/models.py +286 -0
- node/protocols/activitypub/package.py +271 -0
- node/protocols/activitypub/store.py +564 -0
- node/protocols/arcade/__init__.py +26 -0
- node/protocols/atproto/__init__.py +13 -0
- node/protocols/atproto/firehose.py +126 -0
- node/protocols/atproto/metering.py +165 -0
- node/protocols/atproto/package.py +155 -0
- node/protocols/atproto/repo.py +250 -0
- node/protocols/atproto/xrpc.py +285 -0
- node/protocols/base.py +109 -0
- node/protocols/circles/__init__.py +29 -0
- node/protocols/dm/__init__.py +50 -0
- node/protocols/grasp/__init__.py +22 -0
- node/protocols/isotime.py +15 -0
- node/protocols/nostr/__init__.py +51 -0
- node/protocols/nostr/blobstore.py +383 -0
- node/protocols/nostr/blossom.py +627 -0
- node/protocols/nostr/client.py +377 -0
- node/protocols/nostr/descriptor.py +211 -0
- node/protocols/nostr/eventstore.py +304 -0
- node/protocols/nostr/package.py +182 -0
- node/protocols/nostr/peers.py +109 -0
- node/protocols/nostr/relayserver.py +656 -0
- node/protocols/p2panda/__init__.py +22 -0
- node/protocols/p2panda/identity.py +19 -0
- node/protocols/p2panda/package.py +236 -0
- node/protocols/p2panda/rung.py +152 -0
- node/protocols/p2panda/store.py +273 -0
- node/protocols/rhio/__init__.py +33 -0
- node/protocols/rhio/package.py +275 -0
- node/protocols/rhio/rung.py +136 -0
- node/protocols/rhio/store.py +1423 -0
- node/protocols/ssb/muxrpc.py +256 -0
- node/protocols/ssb/package.py +267 -0
- node/protocols/textile/__init__.py +111 -0
- node/protocols/textile/transducers.py +259 -0
- node/release.py +119 -0
- node/storage/__init__.py +88 -0
- node/storage/accounts.py +305 -0
- node/storage/adapter.py +161 -0
- node/storage/base.py +385 -0
- node/storage/dialect.py +209 -0
- node/storage/grants.py +317 -0
- node/storage/locator.py +150 -0
- node/storage/postgres.py +294 -0
- node/storage/projected.py +953 -0
- node/storage/sharding.py +448 -0
- node/storage/standing.py +568 -0
- node/testnet/__init__.py +9 -0
- node/testnet/cluster.py +731 -0
- node/testnet/fips_daemon.py +230 -0
- node/transport/__init__.py +94 -0
- node/transport/const.py +110 -0
- node/transport/didcomm_inbound.py +251 -0
- node/transport/dnstxt.py +169 -0
- node/transport/fips_rung/__init__.py +67 -0
- node/transport/fips_rung/core.py +666 -0
- node/transport/fips_rung/listener.py +96 -0
- node/transport/fips_rung/types.py +12 -0
- node/transport/host.py +334 -0
- node/transport/iroh_rung/__init__.py +29 -0
- node/transport/iroh_rung/core.py +477 -0
- node/transport/iroh_rung/quic.py +942 -0
- node/transport/iroh_rung/types.py +22 -0
- node/transport/ladder.py +145 -0
- node/transport/lineproto.py +49 -0
- node/transport/mdns.py +303 -0
- node/transport/netfetch.py +283 -0
- node/transport/relay_rung/__init__.py +23 -0
- node/transport/relay_rung/cooldown.py +87 -0
- node/transport/relay_rung/core.py +138 -0
- node/transport/relay_rung/inbound.py +161 -0
- node/transport/relay_rung/ledger.py +104 -0
- node/transport/relay_rung/listen.py +121 -0
- node/transport/relay_rung/outbound.py +181 -0
- node/transport/relay_rung/shape.py +71 -0
- node/transport/relay_rung/types.py +58 -0
- node/transport/relay_rung/wire.py +63 -0
- node/transport/router.py +761 -0
- node/transport/rung.py +276 -0
- node/transport/ssb_rung.py +166 -0
- node/transport/ws.py +502 -0
- sidecar/fleetmesh_announce.py +338 -0
- sidecar/fleetmesh_p2p.py +373 -0
- sidecar/fleetmesh_rhio.py +352 -0
- 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
|