@cello-protocol/daemon 0.0.54 → 0.0.56
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.
- package/dist/daemon.d.ts +5 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +23 -11
- package/dist/daemon.js.map +1 -1
- package/dist/session-connection-gater.d.ts +4 -2
- package/dist/session-connection-gater.d.ts.map +1 -1
- package/dist/session-connection-gater.js +16 -10
- package/dist/session-connection-gater.js.map +1 -1
- package/dist/session-node-manager.d.ts +28 -0
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +246 -6
- package/dist/session-node-manager.js.map +1 -1
- package/dist/signaling-connect.d.ts +11 -0
- package/dist/signaling-connect.d.ts.map +1 -1
- package/dist/signaling-connect.js +48 -1
- package/dist/signaling-connect.js.map +1 -1
- package/package.json +4 -4
|
@@ -35,7 +35,8 @@ import { CELLO_CONTENT_PROTOCOL_ID, NodeAutoNatService } from "@cello-protocol/t
|
|
|
35
35
|
import { verify } from "@cello-protocol/crypto";
|
|
36
36
|
import { encodeSealPayload, MONIKER_RE, validateMoniker } from "@cello-protocol/protocol-types";
|
|
37
37
|
import { decodeParkEnvelope, authenticateParkedEntry, pubkeyMatchesHex } from "./park-envelope.js";
|
|
38
|
-
import {
|
|
38
|
+
import { isValidMultiaddr } from "@cello-protocol/transport";
|
|
39
|
+
import { AgentRelayClient, LEAF_KIND_CTRL, extractErrorMessage } from "./session-relay-client.js";
|
|
39
40
|
import { RelayReceiptStore } from "./relay-receipt-store.js";
|
|
40
41
|
import { SessionSealLeafStore } from "./session-seal-leaf-store.js";
|
|
41
42
|
import { PassthroughGatewayClient, GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT, } from "@cello-protocol/gateway";
|
|
@@ -87,6 +88,8 @@ export class SessionNodeManager {
|
|
|
87
88
|
#agentsWantingReceiver = new Set();
|
|
88
89
|
// M8B F14: standing-receiver create retry schedule (see constructor opts).
|
|
89
90
|
#srRetryDelaysMs;
|
|
91
|
+
/** DOD-NAT-REACHABILITY-1: reservation deadline — see #startReceiverNode. */
|
|
92
|
+
#srReservationTimeoutMs;
|
|
90
93
|
// Agents whose removeStandingReceiverForAgent ran while an #ensureStandingReceiver for them was
|
|
91
94
|
// in flight (parked on createNode/start, so the map had no entry to delete yet). The in-flight
|
|
92
95
|
// ensure checks this after start() and tears the fresh node down instead of installing an SR for
|
|
@@ -200,6 +203,7 @@ export class SessionNodeManager {
|
|
|
200
203
|
}
|
|
201
204
|
this.#autoNatProbers = opts.autoNatProbers ?? (() => []);
|
|
202
205
|
this.#srRetryDelaysMs = opts.standingReceiverRetryDelaysMs ?? [1_000, 5_000, 15_000];
|
|
206
|
+
this.#srReservationTimeoutMs = opts.standingReceiverReservationTimeoutMs ?? 8_000;
|
|
203
207
|
this.#securityGateway = opts.securityGateway ?? new PassthroughGatewayClient();
|
|
204
208
|
}
|
|
205
209
|
/**
|
|
@@ -2455,6 +2459,15 @@ export class SessionNodeManager {
|
|
|
2455
2459
|
if (addrs.length === 0) {
|
|
2456
2460
|
return { ok: false, reason: "no_counterparty_addrs", error: "the assignment carried no counterparty session addrs to dial" };
|
|
2457
2461
|
}
|
|
2462
|
+
// DOD-NAT-REACHABILITY-1: a /p2p-circuit counterparty address is dialed
|
|
2463
|
+
// THROUGH its relay, so the gater must admit that relay peer OUTBOUND. The
|
|
2464
|
+
// relay id is embedded in the address, which arrived inside the FROST-signed
|
|
2465
|
+
// assignment — the same authorization rail as the assigned witness relay.
|
|
2466
|
+
for (const addr of addrs) {
|
|
2467
|
+
const viaRelay = addr.match(/\/p2p\/([^/]+)\/p2p-circuit/);
|
|
2468
|
+
if (viaRelay)
|
|
2469
|
+
entry.gater.setAllowedOutboundPeer(viaRelay[1]);
|
|
2470
|
+
}
|
|
2458
2471
|
let lastError = "";
|
|
2459
2472
|
for (const addr of addrs) {
|
|
2460
2473
|
try {
|
|
@@ -2467,8 +2480,11 @@ export class SessionNodeManager {
|
|
|
2467
2480
|
return { ok: true };
|
|
2468
2481
|
}
|
|
2469
2482
|
catch (err) {
|
|
2470
|
-
//
|
|
2471
|
-
|
|
2483
|
+
// extractErrorMessage handles the transport's structured plain-object
|
|
2484
|
+
// throws (dial() never throws Error instances) — the old
|
|
2485
|
+
// `instanceof Error` idiom logged "[object Object]" on every dial
|
|
2486
|
+
// failure; try the next addr.
|
|
2487
|
+
lastError = extractErrorMessage(err);
|
|
2472
2488
|
}
|
|
2473
2489
|
}
|
|
2474
2490
|
this.#logger.warn("session.transport.connect.failed", {
|
|
@@ -3871,6 +3887,197 @@ export class SessionNodeManager {
|
|
|
3871
3887
|
this.#standingReceiverCreating.delete(agentName);
|
|
3872
3888
|
}
|
|
3873
3889
|
}
|
|
3890
|
+
/**
|
|
3891
|
+
* DOD-NAT-REACHABILITY-1 (Phase 2): relay endpoints the DIRECTORY handed this
|
|
3892
|
+
* agent at signaling-auth time — the freshest, health-filtered view of the
|
|
3893
|
+
* relay pool, and the only source a FRESH agent (no session history) has.
|
|
3894
|
+
*/
|
|
3895
|
+
#directoryRelayEndpoints = new Map();
|
|
3896
|
+
/**
|
|
3897
|
+
* DOD-NAT-REACHABILITY-1 (Phase 2): accept the directory's relay-pool endpoints
|
|
3898
|
+
* for an agent (arrives with signaling_auth_ok, i.e. on every connect AND every
|
|
3899
|
+
* reconnect). If the agent's standing receiver is up but holds NO reservation —
|
|
3900
|
+
* the agent-online ensure raced ahead of auth_ok, or every relay was down at
|
|
3901
|
+
* create time — rebuild it now so the agent becomes dialable without waiting
|
|
3902
|
+
* for a session handoff that (being unreachable) would never come.
|
|
3903
|
+
*/
|
|
3904
|
+
setDirectoryRelayEndpoints(agentName, endpoints) {
|
|
3905
|
+
this.#directoryRelayEndpoints.set(agentName, endpoints);
|
|
3906
|
+
if (endpoints.length === 0 || this.#shuttingDown)
|
|
3907
|
+
return;
|
|
3908
|
+
const sr = this.#standingReceivers.get(agentName);
|
|
3909
|
+
if (!sr)
|
|
3910
|
+
return; // not ensured yet — the coming ensure reads the map
|
|
3911
|
+
if (sr.node.listenAddresses().some((a) => a.includes("/p2p-circuit")))
|
|
3912
|
+
return; // already reserved
|
|
3913
|
+
this.#logger.info("session.standing_receiver.reservation.rebuild", {
|
|
3914
|
+
agentName,
|
|
3915
|
+
relayPeerIds: endpoints.map((e) => e.relayPeerId),
|
|
3916
|
+
});
|
|
3917
|
+
void this.#rebuildStandingReceiver(agentName);
|
|
3918
|
+
}
|
|
3919
|
+
/**
|
|
3920
|
+
* Replace an agent's reservation-less standing receiver with one that reserves.
|
|
3921
|
+
*
|
|
3922
|
+
* Deliberately NOT removeStandingReceiverForAgent()+ensureStandingReceiverForAgent():
|
|
3923
|
+
* the public remove CLEARS #agentsWantingReceiver, so a cello_stop_agent landing in
|
|
3924
|
+
* the window while node.stop() is awaited would find no map entry and no creating
|
|
3925
|
+
* marker, leave no tombstone, and the re-ensure would then RESURRECT a receiver for
|
|
3926
|
+
* an agent that asked to go dark — accepting inbound sessions for an offline agent.
|
|
3927
|
+
* Here the want-flag is left intact and re-checked after the stop: a concurrent stop
|
|
3928
|
+
* clears it, and the rebuild correctly no-ops.
|
|
3929
|
+
*/
|
|
3930
|
+
async #rebuildStandingReceiver(agentName) {
|
|
3931
|
+
try {
|
|
3932
|
+
const sr = this.#standingReceivers.get(agentName);
|
|
3933
|
+
if (sr) {
|
|
3934
|
+
this.#standingReceivers.delete(agentName);
|
|
3935
|
+
try {
|
|
3936
|
+
sr.autoNat.stop();
|
|
3937
|
+
await sr.node.stop();
|
|
3938
|
+
}
|
|
3939
|
+
catch (err) {
|
|
3940
|
+
this.#logger.warn("session.standing_receiver.teardown.failed", {
|
|
3941
|
+
agentName,
|
|
3942
|
+
error: extractErrorMessage(err),
|
|
3943
|
+
});
|
|
3944
|
+
}
|
|
3945
|
+
}
|
|
3946
|
+
// The agent may have gone offline while we were stopping the old node. Its
|
|
3947
|
+
// want-flag is the authority — never resurrect a receiver it disowned.
|
|
3948
|
+
if (!this.#agentsWantingReceiver.has(agentName) || this.#shuttingDown)
|
|
3949
|
+
return;
|
|
3950
|
+
await this.#ensureStandingReceiver(agentName);
|
|
3951
|
+
}
|
|
3952
|
+
catch (err) {
|
|
3953
|
+
this.#logger.warn("session.standing_receiver.reservation.rebuild.failed", {
|
|
3954
|
+
agentName,
|
|
3955
|
+
error: extractErrorMessage(err),
|
|
3956
|
+
});
|
|
3957
|
+
}
|
|
3958
|
+
}
|
|
3959
|
+
/**
|
|
3960
|
+
* DOD-NAT-REACHABILITY-1: circuit-relay listen addresses for an agent's known
|
|
3961
|
+
* relays, so its standing receiver takes reservations and becomes dialable
|
|
3962
|
+
* behind NAT. Sources, merged and deduped by relay peer id: the directory's
|
|
3963
|
+
* auth-time relay pool (freshest — first), then the persisted relay endpoints
|
|
3964
|
+
* of past sessions (getAgentRelayEndpoints — covers a directory that predates
|
|
3965
|
+
* the auth_ok extension).
|
|
3966
|
+
*/
|
|
3967
|
+
#reservationCircuitAddrs(agentName) {
|
|
3968
|
+
let persisted;
|
|
3969
|
+
try {
|
|
3970
|
+
persisted = this.getAgentRelayEndpoints(agentName);
|
|
3971
|
+
}
|
|
3972
|
+
catch (err) {
|
|
3973
|
+
// No DB / unknown agent — persisted source unavailable. The directory
|
|
3974
|
+
// source may still serve; reachability degrades only if both are empty.
|
|
3975
|
+
// Logged (not swallowed): a genuine DB failure must be distinguishable
|
|
3976
|
+
// from "fresh agent, no history" in the reachability trail.
|
|
3977
|
+
this.#logger.debug("session.standing_receiver.persisted_relays.unavailable", {
|
|
3978
|
+
agentName,
|
|
3979
|
+
error: extractErrorMessage(err),
|
|
3980
|
+
});
|
|
3981
|
+
persisted = [];
|
|
3982
|
+
}
|
|
3983
|
+
const merged = new Map();
|
|
3984
|
+
for (const ep of [...(this.#directoryRelayEndpoints.get(agentName) ?? []), ...persisted]) {
|
|
3985
|
+
if (!merged.has(ep.relayPeerId))
|
|
3986
|
+
merged.set(ep.relayPeerId, ep);
|
|
3987
|
+
}
|
|
3988
|
+
const addrs = [];
|
|
3989
|
+
const relayPeerIds = [];
|
|
3990
|
+
for (const ep of merged.values()) {
|
|
3991
|
+
const base = ep.relayAddrs[0];
|
|
3992
|
+
if (!base)
|
|
3993
|
+
continue;
|
|
3994
|
+
const candidate = base.includes("/p2p/")
|
|
3995
|
+
? `${base}/p2p-circuit`
|
|
3996
|
+
: `${base}/p2p/${ep.relayPeerId}/p2p-circuit`;
|
|
3997
|
+
// These addresses are built from DIRECTORY-supplied endpoints — data from off
|
|
3998
|
+
// this machine. A malformed one throws inside libp2p node construction, which
|
|
3999
|
+
// would take the standing receiver down entirely and leave the agent deaf to
|
|
4000
|
+
// ALL inbound (worse than the defect this fixes). A bad endpoint must cost one
|
|
4001
|
+
// relay, never the receiver.
|
|
4002
|
+
if (!isValidMultiaddr(candidate)) {
|
|
4003
|
+
this.#logger.warn("session.standing_receiver.relay_endpoint.invalid", {
|
|
4004
|
+
agentName,
|
|
4005
|
+
relayPeerId: ep.relayPeerId,
|
|
4006
|
+
addr: candidate,
|
|
4007
|
+
});
|
|
4008
|
+
continue;
|
|
4009
|
+
}
|
|
4010
|
+
addrs.push(candidate);
|
|
4011
|
+
relayPeerIds.push(ep.relayPeerId);
|
|
4012
|
+
}
|
|
4013
|
+
return { addrs, relayPeerIds };
|
|
4014
|
+
}
|
|
4015
|
+
/**
|
|
4016
|
+
* Start the standing receiver's libp2p node, with reservations if they can be had
|
|
4017
|
+
* QUICKLY, and without them if they cannot.
|
|
4018
|
+
*
|
|
4019
|
+
* THE INVARIANT — learned the hard way, live: **standing-receiver creation must
|
|
4020
|
+
* NEVER be gated on relay reachability.** libp2p's circuit listener awaits a live
|
|
4021
|
+
* connection to each relay before start() resolves, and it does not time out. Put
|
|
4022
|
+
* three relays in the initial listen set, have one of them not answer from this
|
|
4023
|
+
* network, and start() hangs FOREVER: no created event, no failure, no retry, no
|
|
4024
|
+
* alarm — the agent simply has no receiver and is deaf to ALL inbound, including
|
|
4025
|
+
* the direct path that worked before reservations existed. That is strictly worse
|
|
4026
|
+
* than the NAT defect this whole line exists to fix.
|
|
4027
|
+
*
|
|
4028
|
+
* So the reservation attempt is RACED against a deadline. If it wins, we get a
|
|
4029
|
+
* node that is both directly dialable and reachable through the relays. If it does
|
|
4030
|
+
* not, we fall back to a plain TCP receiver — the agent is reachable on the direct
|
|
4031
|
+
* path immediately — and the standing receiver is rebuilt with reservations later,
|
|
4032
|
+
* when the directory next hands us endpoints (setDirectoryRelayEndpoints → the
|
|
4033
|
+
* rebuild-if-deaf path). Degraded, loud, and never deaf.
|
|
4034
|
+
*/
|
|
4035
|
+
async #startReceiverNode(agentName, sessionId, gater, circuitAddrs, correlationId) {
|
|
4036
|
+
if (circuitAddrs.length > 0) {
|
|
4037
|
+
const withReservations = await this.#factory.createNode({
|
|
4038
|
+
sessionId,
|
|
4039
|
+
connectionGater: gater,
|
|
4040
|
+
nodeType: "standing_receiver",
|
|
4041
|
+
circuitRelayListenAddrs: circuitAddrs,
|
|
4042
|
+
});
|
|
4043
|
+
let timer;
|
|
4044
|
+
const timedOut = Symbol("reservation_timeout");
|
|
4045
|
+
try {
|
|
4046
|
+
const outcome = await Promise.race([
|
|
4047
|
+
withReservations.start().then(() => "started"),
|
|
4048
|
+
new Promise((resolve) => {
|
|
4049
|
+
timer = setTimeout(() => resolve(timedOut), this.#srReservationTimeoutMs);
|
|
4050
|
+
}),
|
|
4051
|
+
]);
|
|
4052
|
+
if (outcome === "started")
|
|
4053
|
+
return withReservations;
|
|
4054
|
+
}
|
|
4055
|
+
finally {
|
|
4056
|
+
if (timer !== undefined)
|
|
4057
|
+
clearTimeout(timer);
|
|
4058
|
+
}
|
|
4059
|
+
// The deadline passed with start() still pending — at least one relay is not
|
|
4060
|
+
// answering. Abandon this node (its start may still be parked on a dial; stop()
|
|
4061
|
+
// is best-effort and must not itself block the fallback) and come up TCP-only.
|
|
4062
|
+
this.#logger.warn("session.standing_receiver.reservation.timeout", {
|
|
4063
|
+
agentName,
|
|
4064
|
+
relayCount: circuitAddrs.length,
|
|
4065
|
+
timeoutMs: this.#srReservationTimeoutMs,
|
|
4066
|
+
reason: "at_least_one_relay_did_not_answer_in_time",
|
|
4067
|
+
correlationId,
|
|
4068
|
+
});
|
|
4069
|
+
void Promise.resolve()
|
|
4070
|
+
.then(() => withReservations.stop())
|
|
4071
|
+
.catch(() => { });
|
|
4072
|
+
}
|
|
4073
|
+
const plain = await this.#factory.createNode({
|
|
4074
|
+
sessionId,
|
|
4075
|
+
connectionGater: gater,
|
|
4076
|
+
nodeType: "standing_receiver",
|
|
4077
|
+
});
|
|
4078
|
+
await plain.start();
|
|
4079
|
+
return plain;
|
|
4080
|
+
}
|
|
3874
4081
|
/** One standing-receiver create attempt (extracted for the M8B F14 retry loop). */
|
|
3875
4082
|
async #tryCreateStandingReceiver(agentName, correlationId) {
|
|
3876
4083
|
const sessionId = `standing_receiver_${randomUUID()}`;
|
|
@@ -3879,13 +4086,23 @@ export class SessionNodeManager {
|
|
|
3879
4086
|
allowedPeerId: null, // open — counterparty unknown at creation time
|
|
3880
4087
|
logger: this.#logger,
|
|
3881
4088
|
});
|
|
4089
|
+
// DOD-NAT-REACHABILITY-1: reserve with the agent's known relays. The relay
|
|
4090
|
+
// peers are allowed OUTBOUND on the gater up front, so reservation refreshes
|
|
4091
|
+
// keep working after the receiver is claimed and setAllowedPeer() narrows
|
|
4092
|
+
// the inbound gate to the session counterparty.
|
|
4093
|
+
const reservations = this.#reservationCircuitAddrs(agentName);
|
|
4094
|
+
for (const relayPeerId of reservations.relayPeerIds) {
|
|
4095
|
+
gater.setAllowedOutboundPeer(relayPeerId);
|
|
4096
|
+
}
|
|
3882
4097
|
let node;
|
|
3883
4098
|
try {
|
|
3884
|
-
node = await this.#
|
|
3885
|
-
await node.start();
|
|
4099
|
+
node = await this.#startReceiverNode(agentName, sessionId, gater, reservations.addrs, correlationId);
|
|
3886
4100
|
}
|
|
3887
4101
|
catch (err) {
|
|
3888
|
-
|
|
4102
|
+
// extractErrorMessage, NOT String(err): the transport throws structured
|
|
4103
|
+
// plain objects ({ reason, message }), and String() destroys both into
|
|
4104
|
+
// "[object Object]" — the loud failure must carry its cause.
|
|
4105
|
+
const error = extractErrorMessage(err);
|
|
3889
4106
|
this.#logger.error("session.node.create.failed", {
|
|
3890
4107
|
sessionId,
|
|
3891
4108
|
agentName: `${STANDING_RECEIVER_AGENT_NAME}:${agentName}`,
|
|
@@ -3931,6 +4148,25 @@ export class SessionNodeManager {
|
|
|
3931
4148
|
sessionPeerId: node.getPeerId(),
|
|
3932
4149
|
correlationId,
|
|
3933
4150
|
});
|
|
4151
|
+
// DOD-NAT-REACHABILITY-1 observability: how reachable did this receiver come
|
|
4152
|
+
// up? circuitAddrs === 0 with reservations requested means every relay
|
|
4153
|
+
// refused/was unreachable — the agent is deaf to NAT'd initiators (public
|
|
4154
|
+
// ones can still connect directly). That must be LOUD, not a quiet shrug.
|
|
4155
|
+
const circuitAddrs = node.listenAddresses().filter((a) => a.includes("/p2p-circuit")).length;
|
|
4156
|
+
this.#logger.info("session.standing_receiver.reachability", {
|
|
4157
|
+
agentName,
|
|
4158
|
+
circuitAddrs,
|
|
4159
|
+
reservationsRequested: reservations.addrs.length,
|
|
4160
|
+
correlationId,
|
|
4161
|
+
});
|
|
4162
|
+
if (reservations.addrs.length > 0 && circuitAddrs === 0) {
|
|
4163
|
+
this.#logger.warn("session.standing_receiver.reservation.none", {
|
|
4164
|
+
agentName,
|
|
4165
|
+
reservationsRequested: reservations.addrs.length,
|
|
4166
|
+
relayPeerIds: reservations.relayPeerIds,
|
|
4167
|
+
correlationId,
|
|
4168
|
+
});
|
|
4169
|
+
}
|
|
3934
4170
|
return { outcome: "installed" };
|
|
3935
4171
|
}
|
|
3936
4172
|
/**
|
|
@@ -3946,6 +4182,10 @@ export class SessionNodeManager {
|
|
|
3946
4182
|
async removeStandingReceiverForAgent(agentName) {
|
|
3947
4183
|
// M8B F14: the agent no longer wants a receiver — disarm the teardown re-arm.
|
|
3948
4184
|
this.#agentsWantingReceiver.delete(agentName);
|
|
4185
|
+
// The directory hands these out at signaling-auth time, so a re-started agent
|
|
4186
|
+
// gets a fresh set on its next connect — holding the old ones would keep a
|
|
4187
|
+
// retired agent's relay list alive for the daemon's lifetime.
|
|
4188
|
+
this.#directoryRelayEndpoints.delete(agentName);
|
|
3949
4189
|
const sr = this.#standingReceivers.get(agentName);
|
|
3950
4190
|
if (!sr) {
|
|
3951
4191
|
// L1: an #ensureStandingReceiver for this agent may be in flight (parked on start(), so no
|