Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/network-foundation.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ versions are separate namespaces. This protocol prefers version `2`, with a
version `1` fallback for diagnostic peers without node-profile support. Connecting
to a project does not adopt that project's release channel or publisher authority.

For an explicit bounded list of alternative addresses for the same trusted peer,
see [the candidate-connection API](peer-candidates.md). It adds no discovery or
relay service and never retries negotiation failures or application operations.

## Configure identities and trust explicitly

Each side needs an operator-provided PEM certificate, corresponding unencrypted
Expand Down
85 changes: 85 additions & 0 deletions docs/peer-candidates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Explicit trusted peer candidates

`connect_peer_candidates` is a bounded connection convenience for **one already
trusted peer** at alternative operator-supplied endpoints. It is a first local
NET-1 contract slice, not discovery, NAT traversal, a relay, public deployment or
proof of two-machine reachability. Existing `PeerClient.connect` remains available
for a single explicit endpoint.

```python
from aethermesh_core.network import connect_peer_candidates

connection = await connect_peer_candidates(
existing_identity,
(primary_endpoint, alternate_endpoint),
project_id="selected-project",
timeout=10.0,
)
async with connection:
status = await connection.client.request("status", {}, timeout=2.0)
```

The caller provides an existing `TLSIdentity`, a tuple of one to eight distinct
`PeerEndpoint` values, one project, and a finite total timeout in `(0, 3600]` seconds.
Every candidate must use the same exact certificate fingerprint and expected node
ID (or all omit that ID). Host, port and expected SAN name may differ only as
explicitly configured. Each endpoint still undergoes TLS 1.3 mutual authentication,
CA/SAN/pin checks, optional node-ID checking and project/protocol negotiation.
The helper never learns trust or endpoints from a peer, persists configuration,
creates keys, discovers addresses or starts a listener.

Candidates are tried once in caller order, using a new client for each attempt.
One monotonic deadline covers the entire invocation; it is never reset between
attempts. Existing `Limits.handshake_timeout` may bound an individual attempt more
narrowly. Async task cancellation propagates, closes the owned attempt and does
not advance to another candidate. Cleanup uses the existing bounded socket-close
mechanism and may finish after cancellation/deadline delivery; synchronous local
certificate loading is not preemptible. This is not a hard wall-clock guarantee.

Only two typed failures permit another attempt:

- `EndpointUnavailable`, a `ConnectionClosed` subtype: transport establishment
failed before a TLS transport was handed to the protocol negotiator.
- `EndpointTimeout`, a `RequestTimeout` subtype: establishment timed out before
protocol negotiation began.

Once transport establishment succeeds, **every** negotiation failure is terminal,
including EOF or timeout. This distinction matters because a server can reject a
project by closing without an explicit protocol error. Authentication failures,
wrong pins/SANs/node IDs, malformed/incompatible protocol, configuration errors and
unexpected exceptions never trigger fallback. Cleanup failures stop attempts; when
cleaning up a terminal failure, its original type (including cancellation) is
preserved and an ordinary cleanup exception is attached as its cause. A new
cancellation during cleanup remains terminal. Existing handlers for the base
`ConnectionClosed` and `RequestTimeout` classes continue to work. Exhausting all
candidates raises static `ConnectionClosed`; exhausting the total deadline raises
`RequestTimeout`. Only expiry of the helper’s own timer is translated to this
shared-deadline diagnostic; unrelated local/cleanup timeouts remain terminal
without being relabeled. No peer-supplied error strings or TLS paths enter new diagnostics.

`CandidateConnection` returns the selected endpoint, attempt count and caller-owned
client. Close it explicitly or use its async context manager. Successful connection
is an observation, not a guarantee the peer remains live. No requests are issued
beyond normal connection negotiation and any explicitly supplied profile announcement.
There is no operation callback, application retry, automatic reconnect or operation
replay. After handoff, request failures and uncertain outcomes remain the caller's
responsibility under the existing SDK contracts.

Tests use mocks for deterministic deadline boundaries and disposable TLS loopback
fixtures for refusal/fallback, identity/project rejection, transport versus
negotiation timeout, cancellation and an explicitly requested echo. Installed-wheel
acceptance verifies an unavailable address followed by an authenticated connection
and a caller-issued status request. These are not independent-machine or Internet
acceptance. Full NET-1 still needs separately reviewed discovery/relay interfaces,
operator-authorized infrastructure and two-machine evidence.

This connection utility does not grant publisher, release, serving, retention,
repair-execution or deletion authority. Retention's trusted nondecreasing-time or
durable observation-high-water gate, fresh ownership/byte checks and separate
authority remain mandatory before future serving or execution. GitHub remains
operational; project publishers, channels and transport identities remain separate.

Endpoints are frozen caller-owned configuration values. Normal field assignment
is rejected, and replacement values are validated before connection. This API is
not a sandbox against code in the same process deliberately bypassing Python
immutability or changing trusted identity files.
22 changes: 22 additions & 0 deletions scripts/peer_install_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,28 @@ async def download_api_smoke(peer: str, pki: Any, pins: list[str], cwd: Path) ->
assert server.stdout is not None
ready = json.loads(await asyncio.wait_for(server.stdout.readline(), 10))
endpoint = PeerEndpoint("127.0.0.1", ready["port"], "localhost", pins[0])
from aethermesh_core.network import Limits, connect_peer_candidates

with socket.socket() as unavailable:
unavailable.bind(("127.0.0.1", 0))
candidate = PeerEndpoint(
"127.0.0.1", unavailable.getsockname()[1], "localhost", pins[0]
)
connected = await connect_peer_candidates(
pki.client.identity(),
(candidate, endpoint),
project_id="api-download",
timeout=5,
limits=Limits(handshake_timeout=0.5),
)
async with connected:
assert connected.attempted_count == 2 and connected.endpoint == endpoint
await connected.client.request("status", {}, timeout=2)
assert not connected.client.connected
print(
"Installed explicit peer candidate fallback passed; no discovery or application replay."
)

staging, output = cwd / "api-stage", cwd / "api-output"

# End one installed SDK process after cooperative cancellation, then
Expand Down
7 changes: 7 additions & 0 deletions src/aethermesh_core/network/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@

from .bundle import BundleInfo, inspect_bundle
from .bundle_export import export_bundle
from .candidates import CandidateConnection, connect_peer_candidates
from .client import PeerClient
from .config import Limits, PeerEndpoint, TLSIdentity, certificate_fingerprint
from .content import CONTENT_OPERATION, MAX_CONTENT_BYTES, ContentRef, ContentStore
from .download import DownloadError, DownloadProgress, DownloadResult, download_transfer
from .errors import (
AuthenticationError,
ConnectionClosed,
EndpointTimeout,
EndpointUnavailable,
PeerError,
ProtocolError,
RemoteError,
Expand Down Expand Up @@ -44,12 +47,15 @@
"SUPPORTED_PROTOCOL_VERSIONS",
"AuthenticationError",
"BundleInfo",
"CandidateConnection",
"ConnectionClosed",
"ContentRef",
"ContentStore",
"DownloadError",
"DownloadProgress",
"DownloadResult",
"EndpointTimeout",
"EndpointUnavailable",
"EvaluationBudget",
"HardwareProfile",
"Limits",
Expand All @@ -67,6 +73,7 @@
"TransferManifest",
"TransferStore",
"certificate_fingerprint",
"connect_peer_candidates",
"download_transfer",
"evaluation_task",
"export_bundle",
Expand Down
122 changes: 122 additions & 0 deletions src/aethermesh_core/network/candidates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Explicit bounded alternatives for one trusted peer, never discovery or replay."""

from __future__ import annotations

import asyncio
import math
from dataclasses import dataclass
from typing import Self

from .client import PeerClient
from .config import Limits, PeerEndpoint, TLSIdentity
from .errors import (
ConnectionClosed,
EndpointTimeout,
EndpointUnavailable,
RequestTimeout,
)
from .profile import NodeProfile


@dataclass(frozen=True)
class CandidateConnection:
"""Caller-owned session; close explicitly or use an async context manager."""

client: PeerClient
endpoint: PeerEndpoint
attempted_count: int

async def close(self) -> None:
await self.client.close()

async def __aenter__(self) -> Self:
return self

async def __aexit__(self, *_exc: object) -> None:
await self.close()


def _validate(
identity: TLSIdentity,
endpoints: tuple[PeerEndpoint, ...],
timeout: float,
limits: Limits | None,
) -> None:
if not isinstance(identity, TLSIdentity) or (
limits is not None and not isinstance(limits, Limits)
):
raise TypeError("explicit TLS identity and typed limits required")
if (
isinstance(timeout, bool)
or not isinstance(timeout, (int, float))
or not math.isfinite(timeout)
or not 0 < timeout <= 3600
):
raise ValueError("candidate timeout must be finite and within (0, 3600]")
if (
not isinstance(endpoints, tuple)
or not 1 <= len(endpoints) <= 8
or any(not isinstance(row, PeerEndpoint) for row in endpoints)
or len(set(endpoints)) != len(endpoints)
):
raise ValueError("select one to eight distinct explicit peer endpoints")
if len({(row.fingerprint, row.expected_node_id) for row in endpoints}) != 1:
raise ValueError("all candidates must pin the same peer identity")


async def connect_peer_candidates(
identity: TLSIdentity,
endpoints: tuple[PeerEndpoint, ...],
*,
project_id: str,
timeout: float,
limits: Limits | None = None,
node_profile: NodeProfile | None = None,
) -> CandidateConnection:
"""Try each trusted endpoint once, in caller order, under one total deadline.

Only pre-negotiation transport unavailability or timeout permits fallback.
Authentication/protocol/configuration failures and cancellation are terminal.
No requests are sent beyond normal peer negotiation/profile announcement.
This does not discover, trust, reconnect, relay or replay application work.
The deadline bounds attempts; cooperative cancellation awaits existing bounded
socket cleanup, so it is not a hard wall-clock limit on synchronous local I/O.
"""
_validate(identity, endpoints, timeout, limits)
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
budget = asyncio.timeout_at(deadline)
try:
async with budget:
for count, endpoint in enumerate(endpoints, 1):
if loop.time() >= deadline:
raise RequestTimeout("Candidate connection deadline expired.")
client = PeerClient(
identity,
project_id=project_id,
limits=limits,
node_profile=node_profile,
)
try:
await client.connect(endpoint)
if loop.time() >= deadline:
raise RequestTimeout("Candidate connection deadline expired.")
except (EndpointUnavailable, EndpointTimeout):
await client.close()
if loop.time() >= deadline:
raise RequestTimeout(
"Candidate connection deadline expired."
) from None
except BaseException as failure:
try:
await client.close()
except Exception as cleanup:
raise failure from cleanup
raise
else:
return CandidateConnection(client, endpoint, count)
except TimeoutError as exc:
if not budget.expired():
raise
raise RequestTimeout("Candidate connection deadline expired.") from exc
raise ConnectionClosed("No selected endpoint established the peer session.")
Loading
Loading