Skip to content

feat(dioptron): serve the capability contract over a Unix socket - #87

Merged
forkwright merged 14 commits into
mainfrom
feat/dioptron-wire
Sep 25, 2026
Merged

forkwright merged 14 commits into
mainfrom
feat/dioptron-wire

Conversation

@forkwright

@forkwright forkwright commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

What changed

crates/dioptron gains the daemon's Unix-socket server. The custody store and the invocation lifecycle attach later through two seams:

  • TenantDirectory looks up a tenant's verifying key and bound uids. It must be a short point read.
  • Dispatcher takes ConnIdentity, Request, a CancelSignal and a deadline, and returns a Response. It uses a return-position impl Future, since async-trait is banned.

How the server behaves:

  • Socket. The socket file is 0600, inside a directory that must be 0700 and owned by the daemon.
    • An exclusive lock on the directory allows one daemon per socket directory, so two daemons can never race to replace each other's socket.
    • A stale socket is replaced only if it belongs to the directory owner and refuses connections. A regular file, a symlink, a foreign-owned socket, or a live listener all refuse startup.
  • Connections. A global connection semaphore caps concurrent connections. peer_cred() reads the peer's uid and pid at accept time.
  • Framing. The 12-byte header is read under a timeout, and magic, flags, reserved bits, kind and length are all checked before any allocation. The 4 KiB pre-auth cap covers every frame in both directions until Admitted; after that the negotiated max_frame applies. Bodies go through syntheke's validated, canonical decode.
  • Handshake (≤5 s total).
    • Version negotiation always picks a version inside the client's offered range. Incompatible still carries a valid max_frame.
    • The server sends a fresh 16-byte nonce.
    • Auth checks Ed25519 verify_strict over the shared transcript, then the peer uid against the tenant's bound uids.
    • Every failure takes the same three steps, and an unknown tenant is verified against a dummy key. They all end in one byte-identical Fault(AuthFailed).
  • After admission.
    • An in-flight cap per connection, with duplicate in-flight ids rejected.
    • Cancel reaches the dispatcher.
    • The deadline is clamped to the server's maximum and measured on its monotonic clock. A backstop answers DeadlineExceeded at the deadline plus a grace period.
    • Idle timeout.
    • A panicking dispatcher produces UnknownEffect.
    • Any protocol error sends one Fault(ProtocolError) and then closes the connection.
  • Memory. Limits::worst_case_bytes() = connections × (in-flight + 7) × max_frame. The defaults (16 connections, 9 in flight, the contract's 1 MiB frame) come to exactly 256 MiB, and a test checks that bound.
  • Shutdown. On a signal it stops accepting, cancels in-flight work, delivers any finished responses, removes the socket, and aborts whatever is left after the grace period.
  • Logs. They carry fixed reason strings plus uid, pid, tenant id, request id and capability name, never frame contents.

capability-contract.md states the pre-auth cap rule and the version-choice rule.

Why

S2 requires the Rust contract to be exposed through the accepted local Unix-socket boundary. That includes version negotiation, binding to the peer's identity, bounded frames, and propagation of cancellation and deadlines. Unknown versions, malformed frames and partial connections must fail closed, with no unbounded allocation.

Dependencies

Crate Why
tokio 1.53 (net, rt, io-util, time, sync, macros, signal) Async socket server; peer_cred() is safe
ed25519-dalek 3 (zeroize) Tenant authentication (same RustCrypto generation as phylake)
getrandom 0.4 Server nonces and the dummy-key seed
tracing 0.1 Structured logs without frame contents
tempfile, rustix (process) (dev) Socket directories; sending a real SIGTERM in the shutdown test

Stage and acceptance

Phase 01 S2, the wire boundary. This PR proves these acceptance clauses: forged peer identity; frame corruption, oversize and incompatible-version rejection; unknown versions, malformed frames and partial connections failing closed with no unbounded allocation.

The independent-client clause is covered by xenos (#83). The process-level tests against the real binary arrive with the lifecycle slice, which wires this server into main.

Proof (toolchain 1.97.1)

  • dioptron suite. It covers:
    • The handshake: success, incompatible version, and the chosen version staying inside the offered range.
    • Seven auth failures producing byte-identical faults: wrong key, a signature replayed from an earlier connection, a uid bound to the tenant that isn't the caller's, an unknown tenant, and three frames sent before auth. The dummy-key path is counted.
    • The pre-auth cap on Auth, Admitted and Fault.
    • Every header defect, corrupt and truncated bodies, and oversize lengths refused from the header alone.
    • Partial headers and bodies timing out.
    • The in-flight cap and duplicate ids, cancel propagation, deadline clamping and the backstop, and the idle timeout.
    • A panicking dispatcher, the connection semaphore, the socket mode, the directory lock, and stale-socket handling.
    • Shutdown on a real SIGTERM, and every error variant.
  • Workspace: 182 tests pass. The dioptron suite passed 30 stress runs: 10 at a time with 64 threads, while 16 CPU-hog processes loaded 4 cores.
  • clippy -D warnings, cargo deny check, cargo tree -d (only the documented syn skip), and the doc checks are clean.
  • An independent adversarial review:
    • made auth-failure work equivalent across paths;
    • bounded memory;
    • fixed a real race in stale-socket replacement;
    • removed peer-controlled bytes from the logs.

rkyv 0.8 with bytecheck is the wire format fixed by topology.md; the
format features are named so a conflicting layout fails the build.
snafu is the fleet error library. toml is a dev-dependency for the
contract fixture test. deny.toml allows MIT, Apache-2.0, and
Unicode-3.0 for these crates and skips the syn 2 copy that snafu-derive
and munge_macro still build with.
Identifiers with ULID text form, the capability, mode, scope, and
budget vocabulary, request and reply payloads for every capability,
the outcome taxonomy, the frame header codec, handshake frames, the
authentication transcript, and a decoder that bounds length before
copying and validates the archive before any field is read.
Every message type round-trips through a full frame; corrupted and
truncated bodies are rejected. The fixture test maps each file under
docs/contract/fixtures onto syntheke types and fails when the
directory or a declared fixture is missing.
Request gains a grant field naming the one grant a call acts under, in
execute and dry-run mode. GrantIssueRequest drops parent_grant: the
designated grant is the parent, so the two can no longer disagree.
Fault frames now refuse any failure other than ProtocolError and
AuthFailed on encode and decode, and VersionChoice is non-exhaustive.

Fixtures carry grant as a wire field on every capability request; the
cast gives tnt0f grant grn0f, and neg_foreign_grant covers a request
naming another tenant's grant. The fixture conformance test splits into
load, mapping, and test modules.
Every capability request names the grant it acts under; the daemon
authorizes against that grant's chain only. A missing or foreign
designated grant returns the same NotFoundOrDenied, and the idempotency
digest covers the grant. The wire section now lists the eight frame
kinds with their bytes, restricts Fault to connection-level failures,
fixes the auth transcript layout, and states time units. Fixture keys
move grant to the wire fields. The contract stays at version 1.
Names the crates on each side of the syn 2/syn 3 split, the one
alternative (holding rkyv at 0.8.17) and why it is rejected, and the
retirement condition. The license list takes the same entries and order
as the storage branch so the later merge touches only comments.
The socket server needs an async runtime with Unix listeners and peer
credentials (tokio), handshake signature verification on the custody
crypto's RustCrypto generation (ed25519-dalek 3), a fresh server nonce
from the one OS random source (getrandom 0.4), and per-connection spans
(tracing). tempfile and rkyv are dev-only. BSD-3-Clause is allowed for
ed25519-dalek, curve25519-dalek, and subtle.
Bind a 0600 socket in a 0700 directory, refusing any existing path that
is not a provably stale socket. Each connection takes a permit from a
global semaphore, reads peer credentials at accept, and completes the
handshake within 5 s under the 4 KiB pre-auth cap: version negotiation,
a fresh server nonce, and Ed25519 verify_strict over the syntheke
transcript plus a bound-uid check. Every auth failure, including a
request before admission, gets the same Fault(AuthFailed) bytes.

After Admitted, requests dispatch as tasks through the Dispatcher seam
with a cancel signal and a clamped monotonic deadline; a Cancel frame,
connection close, or shutdown fires the signal. Protocol violations end
the connection with one Fault(ProtocolError). TenantDirectory and
Dispatcher are the seams the custody store and lifecycle implement.
The 4 KiB cap covers every frame in both directions until Admitted, and
the chosen version always lies inside the client's offered range; an
Incompatible reply still carries a valid max_frame.
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	deny.toml
An unknown tenant skipped Ed25519 verification, so handshake timing told
a socket peer whether a tenant exists. The authenticator now verifies an
unknown tenant against a dummy key derived at startup from a random seed,
with an empty uid set: every Auth outcome runs the lookup, one
verification, and the uid check in that order and decides after all
three. A test-build counter proves the dummy verification runs.

The default limits allowed about 1.1 GiB of peer-sized frame buffers.
Limits::worst_case_bytes computes max_connections x (max_in_flight + 6)
x max_frame, the formula documented on Limits, and the defaults (16
connections, 10 in flight, the contract's 1 MiB frame) stay within the
256 MiB DEFAULT_MEMORY_BUDGET.

Review fixes:
- Take an exclusive lock on the socket directory before probing a stale
  socket, so a second daemon cannot unlink a first daemon's fresh socket
  between probe and removal (the old warning claimed AddrInUse, but the
  second daemon's remove_file ran first).
- Stop logging the display of a rejected header, which carried
  peer-chosen bytes.
- Send the test SIGTERM through rustix instead of a kill binary, drop the
  unused test-util and zeroize dev features, widen the connection bound
  in test limits, bound the permit retry loop by time, and correct the
  test module's claim of paused time.
# Conflicts:
#	Cargo.lock
#	Cargo.toml
Decoding now re-encodes each received body to reject non-canonical
archives, so the reader holds one more frame-sized buffer while it
decodes. The per-connection term rises from 6 to 7 frames and the
default in-flight bound drops from 10 to 9, keeping the defaults at
16 x (9 + 7) x 1 MiB = 256 MiB.
@forkwright
forkwright merged commit e0fa501 into main Sep 25, 2026
13 checks passed
@forkwright
forkwright deleted the feat/dioptron-wire branch September 25, 2026 20:39
@forkwright forkwright mentioned this pull request Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant