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
node/app/plugins.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Plugin loading and sidecar manifest helpers for NodeApp."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import importlib
|
|
5
|
+
from typing import Any, Sequence
|
|
6
|
+
|
|
7
|
+
from .. import compartment as compartment_mod
|
|
8
|
+
from ..planes.services.sidecars import compartment as sidecars_mod
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
'load_plugins',
|
|
12
|
+
'plugin_sidecars',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_plugins(
|
|
17
|
+
specs: Sequence[Any],
|
|
18
|
+
manifest: Sequence[type[compartment_mod.Compartment]],
|
|
19
|
+
) -> tuple[type[compartment_mod.Compartment], ...]:
|
|
20
|
+
"""The manifest with each configured plugin class spliced in after its anchor.
|
|
21
|
+
|
|
22
|
+
A plugin ref is `package.module:Class`; the class must be a Compartment
|
|
23
|
+
with a name no manifest compartment uses. `after` names the compartment
|
|
24
|
+
it ticks right after (default: the end, before `updates` if that is
|
|
25
|
+
present, since updates reports on everything before it). Every failure
|
|
26
|
+
is a ValueError naming the ref: a node would rather not start than start
|
|
27
|
+
with a plugin it could not load, which is also why loading happens here
|
|
28
|
+
and not lazily in a tick.
|
|
29
|
+
|
|
30
|
+
This imports operator-declared code into the node's process. It is a
|
|
31
|
+
trust decision the config file states; see PluginsConfig.
|
|
32
|
+
"""
|
|
33
|
+
out = list(manifest)
|
|
34
|
+
names: set[str] = {str(c.name) for c in out}
|
|
35
|
+
for spec in specs:
|
|
36
|
+
mod_name, _, cls_name = spec.ref.partition(':')
|
|
37
|
+
try:
|
|
38
|
+
mod = importlib.import_module(mod_name)
|
|
39
|
+
except ImportError as e:
|
|
40
|
+
raise ValueError('plugin %s: cannot import %s (%s)' % (spec.ref, mod_name, e)) from e
|
|
41
|
+
cls = getattr(mod, cls_name, None)
|
|
42
|
+
if not (isinstance(cls, type) and issubclass(cls, compartment_mod.Compartment)):
|
|
43
|
+
raise ValueError('plugin %s: %s is not a Compartment class' % (spec.ref, cls_name))
|
|
44
|
+
pname = getattr(cls, 'name', None)
|
|
45
|
+
if not pname:
|
|
46
|
+
raise ValueError('plugin %s declares no name' % spec.ref)
|
|
47
|
+
if pname in names:
|
|
48
|
+
raise ValueError('plugin %s: a compartment named %r already exists' % (spec.ref, pname))
|
|
49
|
+
if spec.after is None:
|
|
50
|
+
idx = len(out)
|
|
51
|
+
if out and out[-1].name == 'updates':
|
|
52
|
+
idx -= 1
|
|
53
|
+
else:
|
|
54
|
+
if spec.after not in names:
|
|
55
|
+
raise ValueError('plugin %s: after=%r names no compartment (have: %s)'
|
|
56
|
+
% (spec.ref, spec.after, ' '.join(sorted(names))))
|
|
57
|
+
idx = next(i for i, c in enumerate(out) if c.name == spec.after) + 1
|
|
58
|
+
out.insert(idx, cls)
|
|
59
|
+
names.add(pname)
|
|
60
|
+
return tuple(out)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def plugin_sidecars(cls: type[Any]) -> list[sidecars_mod.Sidecar]:
|
|
64
|
+
"""The sidecar manifests a plugin class declares, parsed and linted
|
|
65
|
+
(`sidecars = ({...}, '@/etc/x.json')`). Every failure is a ValueError
|
|
66
|
+
naming the plugin, so the same message serves startup and `plugin-check`."""
|
|
67
|
+
out: list[sidecars_mod.Sidecar] = []
|
|
68
|
+
for i, raw in enumerate(getattr(cls, 'sidecars', ()) or ()):
|
|
69
|
+
try:
|
|
70
|
+
if isinstance(raw, str) and raw.startswith('@'):
|
|
71
|
+
out.append(sidecars_mod.manifest_mod.load_file(raw[1:], origin=sidecars_mod.RESERVED_ORIGIN))
|
|
72
|
+
else:
|
|
73
|
+
out.append(sidecars_mod.manifest_mod.parse(raw, origin=sidecars_mod.RESERVED_ORIGIN))
|
|
74
|
+
except (sidecars_mod.ManifestError, OSError, ValueError) as e:
|
|
75
|
+
raise ValueError('plugin %s: sidecars[%d]: %s' % (getattr(cls, 'name', cls.__name__), i, e)) from e
|
|
76
|
+
return out
|
node/app/router.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Declarative protocol message router for DIDComm PIURI dispatch.
|
|
2
|
+
|
|
3
|
+
Routes inbound envelopes to registered subsystem handlers based on their
|
|
4
|
+
PIURI (Protocol Identification URI) prefix or exact message type.
|
|
5
|
+
Standard library only.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import collections
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
HandlerFunc = Callable[[Any], Optional[Tuple[Any, ...]]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProtocolRouter:
|
|
16
|
+
"""DIDComm protocol router mapping message types to subsystem handlers."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._exact_routes: Dict[str, HandlerFunc] = {}
|
|
20
|
+
self._prefix_routes: Dict[str, HandlerFunc] = {}
|
|
21
|
+
self._catchalls: List[HandlerFunc] = []
|
|
22
|
+
self._dispatch_counts: collections.Counter[str] = collections.Counter()
|
|
23
|
+
|
|
24
|
+
def register_exact(self, msg_type: str, handler: HandlerFunc) -> None:
|
|
25
|
+
"""Register a handler for an exact DIDComm message type."""
|
|
26
|
+
self._exact_routes[msg_type] = handler
|
|
27
|
+
|
|
28
|
+
def register(self, prefix: str, handler: HandlerFunc) -> None:
|
|
29
|
+
"""Register a handler for a PIURI prefix (e.g. 'https://fleetmesh.org/sync/1.0')."""
|
|
30
|
+
self._prefix_routes[prefix] = handler
|
|
31
|
+
|
|
32
|
+
def register_catchall(self, handler: HandlerFunc) -> None:
|
|
33
|
+
"""Register a fallback handler invoked when no prefix matches or handler yields None."""
|
|
34
|
+
self._catchalls.append(handler)
|
|
35
|
+
|
|
36
|
+
def unregister(self, prefix_or_type: str) -> None:
|
|
37
|
+
"""Remove a previously registered route."""
|
|
38
|
+
self._exact_routes.pop(prefix_or_type, None)
|
|
39
|
+
self._prefix_routes.pop(prefix_or_type, None)
|
|
40
|
+
|
|
41
|
+
def route_for(self, msg_type: str) -> Optional[HandlerFunc]:
|
|
42
|
+
"""Find the most specific registered handler for a message type."""
|
|
43
|
+
if not msg_type:
|
|
44
|
+
return None
|
|
45
|
+
if msg_type in self._exact_routes:
|
|
46
|
+
return self._exact_routes[msg_type]
|
|
47
|
+
# Match longest prefix first
|
|
48
|
+
for prefix in sorted(self._prefix_routes.keys(), key=len, reverse=True):
|
|
49
|
+
if msg_type.startswith(prefix):
|
|
50
|
+
return self._prefix_routes[prefix]
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
def dispatch(self, inbound: Any) -> Optional[Tuple[Any, ...]]:
|
|
54
|
+
"""Dispatch an inbound envelope to the matching handler.
|
|
55
|
+
|
|
56
|
+
Returns the handler result (typically a status tuple), or None if unhandled.
|
|
57
|
+
"""
|
|
58
|
+
msg = getattr(inbound, 'message', inbound)
|
|
59
|
+
msg_type = msg.get('type', '') if isinstance(msg, dict) else ''
|
|
60
|
+
|
|
61
|
+
handler = self.route_for(msg_type)
|
|
62
|
+
if handler is not None:
|
|
63
|
+
res = handler(inbound)
|
|
64
|
+
if res is not None:
|
|
65
|
+
self._dispatch_counts[msg_type] += 1
|
|
66
|
+
return res
|
|
67
|
+
|
|
68
|
+
for catchall in self._catchalls:
|
|
69
|
+
res = catchall(inbound)
|
|
70
|
+
if res is not None:
|
|
71
|
+
self._dispatch_counts[msg_type or 'catchall'] += 1
|
|
72
|
+
return res
|
|
73
|
+
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
def registered_prefixes(self) -> List[str]:
|
|
77
|
+
"""Return all registered PIURI prefixes."""
|
|
78
|
+
return sorted(self._prefix_routes.keys())
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def counts(self) -> collections.Counter[str]:
|
|
82
|
+
"""Dispatch count by message type."""
|
|
83
|
+
return self._dispatch_counts
|
node/app/socket.py
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""The control socket: how an operator asks a running node to do something.
|
|
2
|
+
|
|
3
|
+
A running node owns its directory and its relay face port, so a second
|
|
4
|
+
process cannot stand one up beside it to act on its behalf. Declarative
|
|
5
|
+
state - peers, trust, coverage - goes through the config file, which the
|
|
6
|
+
daemon re-reads every tick. An action - pull now, terminate a peering, ask
|
|
7
|
+
a peer about a third node, force a tick - needs the daemon itself, and this
|
|
8
|
+
is the channel: a unix socket in the node directory, one JSON object per
|
|
9
|
+
line each way, readable and writable by the node's own user and nobody else.
|
|
10
|
+
|
|
11
|
+
The protocol is named and versioned: `meshnode-control/1`. A request is
|
|
12
|
+
{"cmd": name, "args": {...}}; a reply is {"ok": true, "protocol": ...,
|
|
13
|
+
"result": ...} or {"ok": false, "protocol": ..., "error": reason}. The
|
|
14
|
+
commands, their arguments and what each answers are served by the socket
|
|
15
|
+
itself under `commands`, from the same table the daemon dispatches on, so
|
|
16
|
+
what it says about itself cannot drift from what it does. A field is only
|
|
17
|
+
ever added to a reply within a major version; a field removed or renamed is
|
|
18
|
+
`meshnode-control/2`.
|
|
19
|
+
|
|
20
|
+
The socket path is derived from the directory on both sides, so nothing
|
|
21
|
+
has to be looked up. macOS limits a unix socket path to about a hundred
|
|
22
|
+
bytes, and a test's temporary directory is longer than that, so a directory
|
|
23
|
+
whose path will not fit gets a socket in the temp directory named by the
|
|
24
|
+
hash of the real path. An operator's /var/lib/meshnode fits.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import inspect
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import socket
|
|
33
|
+
import tempfile
|
|
34
|
+
import threading
|
|
35
|
+
import time
|
|
36
|
+
from typing import Any, Callable, Iterator, Mapping
|
|
37
|
+
|
|
38
|
+
SOCK: str = 'control.sock'
|
|
39
|
+
PROTOCOL: str = 'meshnode-control/1'
|
|
40
|
+
MAX_LINE: int = 1 << 20
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Stream(object):
|
|
44
|
+
"""A dispatcher returns one of these for a verb that answers more than
|
|
45
|
+
once. The server sends the ack line, then one line per envelope `pull`
|
|
46
|
+
yields until the client closes or `pull` returns None past `idle`
|
|
47
|
+
seconds of silence with the client gone. `close` runs once at the end.
|
|
48
|
+
A streaming connection does not count against the request semaphore:
|
|
49
|
+
a reader that holds on for an hour must not starve `health`."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, ack: Any, pull: Callable[[float], Any], close: Callable[[], None] | None = None) -> None:
|
|
52
|
+
self.ack: Any = ack
|
|
53
|
+
self.pull: Callable[[float], Any] = pull
|
|
54
|
+
self.close: Callable[[], None] | None = close
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ControlError(Exception):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def socket_path(directory: str) -> str:
|
|
62
|
+
real = os.path.realpath(directory)
|
|
63
|
+
p = os.path.join(real, SOCK)
|
|
64
|
+
if len(p.encode()) < 100:
|
|
65
|
+
return p
|
|
66
|
+
return os.path.join(tempfile.gettempdir(),
|
|
67
|
+
'meshnode-%s.sock' % hashlib.sha256(real.encode()).hexdigest()[:16])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _recv_line(conn: socket.socket) -> bytes:
|
|
71
|
+
buf = b''
|
|
72
|
+
while not buf.endswith(b'\n'):
|
|
73
|
+
chunk = conn.recv(65536)
|
|
74
|
+
if not chunk:
|
|
75
|
+
break
|
|
76
|
+
buf += chunk
|
|
77
|
+
if len(buf) > MAX_LINE:
|
|
78
|
+
raise ControlError('request too large')
|
|
79
|
+
return buf
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _reader_alive(conn: socket.socket) -> bool:
|
|
83
|
+
try:
|
|
84
|
+
conn.setblocking(False)
|
|
85
|
+
try:
|
|
86
|
+
conn.recv(1, socket.MSG_PEEK)
|
|
87
|
+
finally:
|
|
88
|
+
conn.setblocking(True)
|
|
89
|
+
except BlockingIOError:
|
|
90
|
+
return True
|
|
91
|
+
except OSError:
|
|
92
|
+
return False
|
|
93
|
+
return False # EOF, or the reader spoke: either way this stream is over
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Server(object):
|
|
97
|
+
"""Serves dispatch(cmd, args) -> result over the socket, one request per connection."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, directory: str, dispatch: Callable[..., Any], max_conns: int = 16,
|
|
100
|
+
max_streams: int = 32) -> None:
|
|
101
|
+
self.path: str = socket_path(directory)
|
|
102
|
+
self.dispatch: Callable[..., Any] = dispatch
|
|
103
|
+
# Whether the dispatcher wants to know who is calling. Decided once,
|
|
104
|
+
# here, rather than guessed per request: a dispatcher written before
|
|
105
|
+
# actors existed takes two arguments and keeps working, and one that
|
|
106
|
+
# wants attribution says so by accepting `actor`.
|
|
107
|
+
try:
|
|
108
|
+
self._wants_actor: bool = 'actor' in inspect.signature(dispatch).parameters
|
|
109
|
+
except (TypeError, ValueError):
|
|
110
|
+
self._wants_actor = False
|
|
111
|
+
self._stop: threading.Event = threading.Event()
|
|
112
|
+
self.sock: socket.socket | None = None
|
|
113
|
+
self.served: int = 0
|
|
114
|
+
self.streams: int = 0 # live streaming connections
|
|
115
|
+
self.max_conns: int = max_conns
|
|
116
|
+
self.max_streams: int = max_streams
|
|
117
|
+
self.errors: list[str] = []
|
|
118
|
+
self._sem: threading.BoundedSemaphore = threading.BoundedSemaphore(max_conns)
|
|
119
|
+
self._slock = threading.Lock()
|
|
120
|
+
self._inode: int | None = None
|
|
121
|
+
|
|
122
|
+
def start(self) -> Server:
|
|
123
|
+
try:
|
|
124
|
+
os.unlink(self.path)
|
|
125
|
+
except OSError:
|
|
126
|
+
pass
|
|
127
|
+
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
128
|
+
old_umask = os.umask(0o177)
|
|
129
|
+
try:
|
|
130
|
+
self.sock.bind(self.path)
|
|
131
|
+
os.chmod(self.path, 0o600)
|
|
132
|
+
finally:
|
|
133
|
+
os.umask(old_umask)
|
|
134
|
+
# Remember which inode is ours, so stop() never removes a socket a
|
|
135
|
+
# newer node bound at the same path after this one lost it.
|
|
136
|
+
try:
|
|
137
|
+
self._inode = os.stat(self.path).st_ino
|
|
138
|
+
except OSError:
|
|
139
|
+
self._inode = None
|
|
140
|
+
# 0600 on the socket means nothing if another user can rename the
|
|
141
|
+
# directory entry out from under it before the operator connects.
|
|
142
|
+
try:
|
|
143
|
+
mode = os.stat(os.path.dirname(self.path)).st_mode
|
|
144
|
+
except OSError:
|
|
145
|
+
mode = 0
|
|
146
|
+
if mode & 0o022:
|
|
147
|
+
self.errors.append('the directory holding %s is group/world-writable; another user could replace the socket'
|
|
148
|
+
% self.path)
|
|
149
|
+
self.sock.listen(max(64, self.max_conns * 2))
|
|
150
|
+
self.sock.settimeout(0.5)
|
|
151
|
+
threading.Thread(target=self._loop, daemon=True).start()
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
def _refuse_busy(self, conn: socket.socket) -> None:
|
|
155
|
+
"""Told off its own thread: a slow client must not stall accept()."""
|
|
156
|
+
try:
|
|
157
|
+
conn.settimeout(5.0)
|
|
158
|
+
reply = {'ok': False, 'protocol': PROTOCOL, 'error': 'too many concurrent control requests'}
|
|
159
|
+
conn.sendall((json.dumps(reply) + '\n').encode('utf-8'))
|
|
160
|
+
except OSError:
|
|
161
|
+
pass
|
|
162
|
+
finally:
|
|
163
|
+
conn.close()
|
|
164
|
+
|
|
165
|
+
def _loop(self) -> None:
|
|
166
|
+
while not self._stop.is_set():
|
|
167
|
+
try:
|
|
168
|
+
assert self.sock is not None
|
|
169
|
+
conn, _ = self.sock.accept()
|
|
170
|
+
except socket.timeout:
|
|
171
|
+
continue
|
|
172
|
+
except OSError:
|
|
173
|
+
break
|
|
174
|
+
if not self._sem.acquire(blocking=False):
|
|
175
|
+
threading.Thread(target=self._refuse_busy, args=(conn,), daemon=True).start()
|
|
176
|
+
continue
|
|
177
|
+
threading.Thread(target=self._one_wrapped, args=(conn,), daemon=True).start()
|
|
178
|
+
|
|
179
|
+
def _one_wrapped(self, conn: socket.socket) -> None:
|
|
180
|
+
released = False
|
|
181
|
+
try:
|
|
182
|
+
stream = self._one(conn)
|
|
183
|
+
if stream is not None:
|
|
184
|
+
# Hand the slot back before the long part: the stream has its own cap.
|
|
185
|
+
self._sem.release()
|
|
186
|
+
released = True
|
|
187
|
+
self._stream(conn, stream)
|
|
188
|
+
finally:
|
|
189
|
+
if not released:
|
|
190
|
+
self._sem.release()
|
|
191
|
+
|
|
192
|
+
def _stream(self, conn: socket.socket, stream: Stream) -> None:
|
|
193
|
+
# The slot was taken in _one, when the cap was checked; this only
|
|
194
|
+
# hands it back.
|
|
195
|
+
try:
|
|
196
|
+
conn.settimeout(None)
|
|
197
|
+
conn.setblocking(True)
|
|
198
|
+
while not self._stop.is_set():
|
|
199
|
+
env = stream.pull(1.0)
|
|
200
|
+
if env is None:
|
|
201
|
+
# Nothing to send. A reader that went away shows as EOF on
|
|
202
|
+
# a non-blocking peek (a live one that has nothing to say
|
|
203
|
+
# raises EWOULDBLOCK); the protocol has the reader say
|
|
204
|
+
# nothing after the request, so anything else it sends is
|
|
205
|
+
# also taken as "done".
|
|
206
|
+
if not _reader_alive(conn):
|
|
207
|
+
break
|
|
208
|
+
continue
|
|
209
|
+
try:
|
|
210
|
+
conn.sendall((json.dumps(env, default=str) + '\n').encode('utf-8'))
|
|
211
|
+
except OSError:
|
|
212
|
+
break
|
|
213
|
+
finally:
|
|
214
|
+
with self._slock:
|
|
215
|
+
self.streams -= 1
|
|
216
|
+
if stream.close is not None:
|
|
217
|
+
try:
|
|
218
|
+
stream.close()
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
try:
|
|
222
|
+
conn.close()
|
|
223
|
+
except OSError:
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
def _one(self, conn: socket.socket) -> Stream | None:
|
|
227
|
+
"""Answer one request. Returns the Stream to keep serving, or None when the connection is done."""
|
|
228
|
+
stream: Stream | None = None
|
|
229
|
+
raw_stream: Stream | None = None
|
|
230
|
+
reserved = False # a stream slot taken below, handed to _stream or back
|
|
231
|
+
try:
|
|
232
|
+
conn.settimeout(30)
|
|
233
|
+
try:
|
|
234
|
+
try:
|
|
235
|
+
req = json.loads(_recv_line(conn).decode('utf-8'))
|
|
236
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as e:
|
|
237
|
+
# The client's fault, not a bug in a command: say so and
|
|
238
|
+
# keep it out of the error ring an operator reads.
|
|
239
|
+
raise ControlError('a request is one line of JSON: %s' % e)
|
|
240
|
+
if not isinstance(req, dict) or not isinstance(req.get('cmd'), str):
|
|
241
|
+
raise ControlError('a request is {"cmd": ..., "args": {...}}; `commands` lists them')
|
|
242
|
+
if not isinstance(req.get('args') or {}, dict):
|
|
243
|
+
raise ControlError('args is an object')
|
|
244
|
+
# Who is asking, established at the boundary. SO_PEERCRED
|
|
245
|
+
# can only be read off the live connection, so this is the
|
|
246
|
+
# one place it exists; by the time a command runs the socket
|
|
247
|
+
# is a detail nobody downstream can recover.
|
|
248
|
+
#
|
|
249
|
+
# The claimed operator comes from the request and is exactly
|
|
250
|
+
# that - claimed. `Actor.assurance` and the proof ladder say
|
|
251
|
+
# what was actually established, and `peercred` names a unix
|
|
252
|
+
# user rather than a DID, so it corroborates and never
|
|
253
|
+
# identifies. Nothing here checks an ability: see the note in
|
|
254
|
+
# node/config/change.py on why enforcing one against a
|
|
255
|
+
# 0600 socket would be a control that protects nothing.
|
|
256
|
+
if self._wants_actor:
|
|
257
|
+
result = self.dispatch(req['cmd'], req.get('args') or {},
|
|
258
|
+
actor=actor_of(conn, req))
|
|
259
|
+
else:
|
|
260
|
+
result = self.dispatch(req['cmd'], req.get('args') or {})
|
|
261
|
+
if isinstance(result, Stream):
|
|
262
|
+
# Check the cap and take the slot in one step, before the
|
|
263
|
+
# ack goes out. The count used to rise only once _stream
|
|
264
|
+
# began, after the ack, so a reader holding its ack was
|
|
265
|
+
# not counted yet, and a second request in that gap passed
|
|
266
|
+
# the check: two streams over a cap of one. CI hit it under
|
|
267
|
+
# a parallel suite (audit 2026-10-15 follow-up).
|
|
268
|
+
with self._slock:
|
|
269
|
+
full = self.streams >= self.max_streams
|
|
270
|
+
if not full:
|
|
271
|
+
self.streams += 1
|
|
272
|
+
reserved = True
|
|
273
|
+
if full:
|
|
274
|
+
if result.close is not None:
|
|
275
|
+
result.close()
|
|
276
|
+
raise ControlError('too many open streams (%d); close one' % self.max_streams)
|
|
277
|
+
stream = result
|
|
278
|
+
raw_stream = result
|
|
279
|
+
result = result.ack
|
|
280
|
+
reply: Mapping[str, Any] = {'ok': True, 'protocol': PROTOCOL, 'result': result}
|
|
281
|
+
except ControlError as e:
|
|
282
|
+
reply = {'ok': False, 'protocol': PROTOCOL, 'error': str(e)}
|
|
283
|
+
except Exception as e: # a bug in one command answers one request
|
|
284
|
+
if len(self.errors) >= 200:
|
|
285
|
+
self.errors.pop(0)
|
|
286
|
+
self.errors.append('%s: %s' % (type(e).__name__, e))
|
|
287
|
+
reply = {'ok': False, 'protocol': PROTOCOL, 'error': '%s: %s' % (type(e).__name__, e)}
|
|
288
|
+
self.served += 1
|
|
289
|
+
conn.sendall((json.dumps(reply, default=str) + '\n').encode('utf-8'))
|
|
290
|
+
except OSError:
|
|
291
|
+
stream = None
|
|
292
|
+
finally:
|
|
293
|
+
if stream is None:
|
|
294
|
+
if reserved: # the ack never reached the reader: no stream to serve
|
|
295
|
+
with self._slock:
|
|
296
|
+
self.streams -= 1
|
|
297
|
+
if raw_stream is not None and raw_stream.close is not None:
|
|
298
|
+
raw_stream.close()
|
|
299
|
+
conn.close()
|
|
300
|
+
return stream
|
|
301
|
+
|
|
302
|
+
def stop(self) -> None:
|
|
303
|
+
self._stop.set()
|
|
304
|
+
try:
|
|
305
|
+
if self.sock is not None:
|
|
306
|
+
self.sock.close()
|
|
307
|
+
except (OSError, AttributeError):
|
|
308
|
+
pass
|
|
309
|
+
# Only remove the socket this server bound. A node that lost the
|
|
310
|
+
# path to a newer one (a stale-socket replacement, a restart racing
|
|
311
|
+
# a slow shutdown) must not make the live node unreachable on its
|
|
312
|
+
# way out.
|
|
313
|
+
try:
|
|
314
|
+
if self._inode is not None and os.stat(self.path).st_ino == self._inode:
|
|
315
|
+
os.unlink(self.path)
|
|
316
|
+
except OSError:
|
|
317
|
+
pass
|
|
318
|
+
|
|
319
|
+
def state(self) -> Mapping[str, Any]:
|
|
320
|
+
"""What `health` says about this socket."""
|
|
321
|
+
with self._slock:
|
|
322
|
+
streams = self.streams
|
|
323
|
+
return {'path': self.path, 'served': self.served, 'streams': streams,
|
|
324
|
+
'errors': list(self.errors[-5:])}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def stream(directory: str, cmd: str, timeout: float | None = None, **args: Any) -> Iterator[Any]:
|
|
328
|
+
"""Ask for a streaming verb and yield each envelope as it arrives. The
|
|
329
|
+
first value yielded is the ack (the request's own result); then one per
|
|
330
|
+
line until the node closes or the caller stops iterating. `timeout` is
|
|
331
|
+
how long to wait for the next line, None forever."""
|
|
332
|
+
path = socket_path(directory)
|
|
333
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
334
|
+
s.settimeout(timeout if timeout is not None else 90.0)
|
|
335
|
+
try:
|
|
336
|
+
s.connect(path)
|
|
337
|
+
except OSError as e:
|
|
338
|
+
s.close()
|
|
339
|
+
raise ControlError('no node is running in %s (%s)' % (directory, e.strerror or e))
|
|
340
|
+
try:
|
|
341
|
+
s.sendall((json.dumps({'cmd': cmd, 'args': args}) + '\n').encode('utf-8'))
|
|
342
|
+
# The ack and the first envelopes may arrive in one read; keep the remainder.
|
|
343
|
+
buf = b''
|
|
344
|
+
while b'\n' not in buf:
|
|
345
|
+
chunk = s.recv(65536)
|
|
346
|
+
if not chunk:
|
|
347
|
+
raise ControlError('the node closed the socket without answering')
|
|
348
|
+
buf += chunk
|
|
349
|
+
if len(buf) > MAX_LINE:
|
|
350
|
+
raise ControlError('reply too large')
|
|
351
|
+
raw, buf = buf.split(b'\n', 1)
|
|
352
|
+
reply = json.loads(raw.decode('utf-8'))
|
|
353
|
+
if not reply.get('ok'):
|
|
354
|
+
raise ControlError(reply.get('error') or 'refused')
|
|
355
|
+
yield reply.get('result')
|
|
356
|
+
s.settimeout(timeout)
|
|
357
|
+
while True:
|
|
358
|
+
while b'\n' in buf:
|
|
359
|
+
line, buf = buf.split(b'\n', 1)
|
|
360
|
+
if line:
|
|
361
|
+
yield json.loads(line.decode('utf-8'))
|
|
362
|
+
try:
|
|
363
|
+
chunk = s.recv(65536)
|
|
364
|
+
except socket.timeout:
|
|
365
|
+
raise ControlError('no event within %.0fs' % (timeout or 0))
|
|
366
|
+
if not chunk:
|
|
367
|
+
return
|
|
368
|
+
buf += chunk
|
|
369
|
+
finally:
|
|
370
|
+
s.close()
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def running(directory: str) -> bool:
|
|
374
|
+
return os.path.exists(socket_path(directory))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def call(directory: str, cmd: str, timeout: float = 90.0, request: Mapping[str, Any] | None = None, **args: Any) -> Any:
|
|
378
|
+
"""Ask the node running in `directory` to do one thing. Raises ControlError
|
|
379
|
+
when there is no node to ask, or when the node says no.
|
|
380
|
+
|
|
381
|
+
`timeout` is how long this client waits. A request argument that shares
|
|
382
|
+
the name (pull's own `timeout`) goes in `request`, which is merged over
|
|
383
|
+
`args`; before that, `meshnode pull --timeout` never reached the daemon."""
|
|
384
|
+
path = socket_path(directory)
|
|
385
|
+
if request:
|
|
386
|
+
args = {**args, **request}
|
|
387
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
388
|
+
s.settimeout(timeout)
|
|
389
|
+
try:
|
|
390
|
+
s.connect(path)
|
|
391
|
+
except OSError as e:
|
|
392
|
+
s.close()
|
|
393
|
+
raise ControlError('no node is running in %s (%s)' % (directory, e.strerror or e))
|
|
394
|
+
try:
|
|
395
|
+
s.sendall((json.dumps({'cmd': cmd, 'args': args}) + '\n').encode('utf-8'))
|
|
396
|
+
raw = _recv_line(s)
|
|
397
|
+
except socket.timeout:
|
|
398
|
+
raise ControlError('the node did not answer within %.0fs' % timeout)
|
|
399
|
+
finally:
|
|
400
|
+
s.close()
|
|
401
|
+
if not raw:
|
|
402
|
+
raise ControlError('the node closed the socket without answering')
|
|
403
|
+
reply = json.loads(raw.decode('utf-8'))
|
|
404
|
+
if not reply.get('ok'):
|
|
405
|
+
raise ControlError(reply.get('error') or 'refused')
|
|
406
|
+
return reply.get('result')
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def actor_of(conn: 'socket.socket', req: Mapping[str, Any]) -> Any:
|
|
410
|
+
"""Who made this request, with every rung that answered recorded.
|
|
411
|
+
|
|
412
|
+
Built here because SO_PEERCRED can only be read off a live connection.
|
|
413
|
+
The operator and session are whatever the caller sent - a claim, marked
|
|
414
|
+
as one - and the peercred rung corroborates that a local user made the
|
|
415
|
+
call without ever claiming to identify a DID.
|
|
416
|
+
|
|
417
|
+
Imported lazily: node/control.py is reached by the CLI as well as the
|
|
418
|
+
daemon, and the config package pulls in the whole typed config tree.
|
|
419
|
+
"""
|
|
420
|
+
from ..config import change as change_mod # noqa: PLC0415
|
|
421
|
+
claimed = str(req.get('operator') or '')
|
|
422
|
+
proofs = [change_mod.asserted(claimed)] if claimed else []
|
|
423
|
+
cred = change_mod.peercred_of(conn)
|
|
424
|
+
if cred is not None:
|
|
425
|
+
proofs.append(cred)
|
|
426
|
+
return change_mod.Actor(
|
|
427
|
+
operator=claimed,
|
|
428
|
+
session=str(req.get('session') or ''),
|
|
429
|
+
tool=str(req.get('tool') or change_mod.LOCAL_TOOL),
|
|
430
|
+
at=int(time.time()),
|
|
431
|
+
proofs=tuple(proofs))
|
node/app/systemd.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""sd_notify: the one line the node says to systemd.
|
|
2
|
+
|
|
3
|
+
Separate because it touches a socket and nothing else here does; the
|
|
4
|
+
config module should not need `socket`.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import socket
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def sd_notify(state: str) -> bool:
|
|
13
|
+
"""Send state notification to systemd's service manager via NOTIFY_SOCKET.
|
|
14
|
+
|
|
15
|
+
Zero-dependency Unix datagram implementation supporting filesystem and abstract sockets.
|
|
16
|
+
Returns True if sent successfully, False otherwise.
|
|
17
|
+
"""
|
|
18
|
+
notify_socket = os.environ.get('NOTIFY_SOCKET')
|
|
19
|
+
if not notify_socket:
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
if notify_socket.startswith('@'):
|
|
23
|
+
notify_socket = '\0' + notify_socket[1:]
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
|
27
|
+
try:
|
|
28
|
+
payload = state.encode('utf-8')
|
|
29
|
+
if not payload.endswith(b'\n'):
|
|
30
|
+
payload += b'\n'
|
|
31
|
+
sock.sendto(payload, notify_socket)
|
|
32
|
+
return True
|
|
33
|
+
finally:
|
|
34
|
+
sock.close()
|
|
35
|
+
except Exception:
|
|
36
|
+
return False
|