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
44 changes: 34 additions & 10 deletions crates/dictyon/examples/connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,24 +135,48 @@ async fn register_node(
) -> Result<(), ExampleError> {
info!("registering…");
match client.register(stream, auth_key).await? {
RegisterOutcome::NeedsAuth(url) => {
info!("visit to authorize: {url}");
let followup = client.poll_registration(stream, url.as_str()).await?;
report_register_outcome(followup);
}
outcome => report_register_outcome(outcome),
}
Ok(())
}

/// Log what a terminal [`RegisterOutcome`] means for this example run.
///
/// [`RegisterOutcome::NeedsAuth`] is handled by the caller (`register_node`)
/// before it reaches here: a real caller polls again via
/// [`ControlClient::poll_registration`] once the user has visited the URL,
/// so this example performs that round trip rather than only logging the
/// URL. A [`RegisterOutcome::NeedsAuth`] can still arrive here as the
/// *followup* poll's own result (the server has not observed the user
/// complete auth yet); a real caller would retry registration with a fresh
/// node key after `RotateNodeKey`.
fn report_register_outcome(outcome: RegisterOutcome) {
match outcome {
RegisterOutcome::Authorized(resp) => {
info!(
authorized = resp.machine_authorized,
expiry = ?resp.node_key_expiry,
"node authorized"
);
info!(authorized = resp.machine_authorized, "node authorized");
}
RegisterOutcome::NeedsAuth(url) => {
info!("visit to authorize: {url}");
}
RegisterOutcome::RotateNodeKey => {
warn!("server reports the node key has expired; a fresh key is required");
}
RegisterOutcome::NeedsAuth { auth_url } => {
info!("visit to authorize: {auth_url}");
let resp = client.poll_registration(stream, &auth_url).await?;
info!(authorized = resp.machine_authorized, "auth complete");
RegisterOutcome::Rejected { reason } => {
warn!(reason, "server rejected the registration request");
}
RegisterOutcome::Contradictory(fault) => {
warn!(?fault, "server sent a response the protocol does not allow");
}
// RegisterOutcome is #[non_exhaustive]; cover future variants.
_ => {
warn!("unknown register outcome variant; treating as unsupported");
}
}
Ok(())
}

async fn stream_map(
Expand Down
103 changes: 64 additions & 39 deletions crates/dictyon/src/control/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ use tracing::{debug, warn};
use crate::transport::ControlConnection;
use crate::wire::AsyncControlStream;

mod register;

use register::classify_register_response;
pub use register::{NonEmptyUrl, RegisterFault, RegisterOutcome};

/// Upper bound on the peers a netmap retains from a coordination server.
///
/// WHY: every peer-bearing field of a [`MapResponse`] is server-controlled and
Expand Down Expand Up @@ -99,22 +104,6 @@ impl From<crate::wire::WireError> for ControlError {
}
}

/// Outcome of an async registration attempt.
///
/// When the machine already has a valid pre-auth key, the server
/// authorizes it immediately ([`RegisterOutcome::Authorized`]). Otherwise,
/// the user must visit an auth URL ([`RegisterOutcome::NeedsAuth`]).
#[non_exhaustive]
pub enum RegisterOutcome {
/// The node was authorized; contains the server's registration response.
Authorized(RegisterResponse),
/// Interactive auth is required; the user must visit this URL.
NeedsAuth {
/// URL the user must visit to authorize this node.
auth_url: String,
},
}

/// The local view of the network map, maintained by applying
/// [`MapResponse`] updates.
///
Expand Down Expand Up @@ -307,13 +296,18 @@ impl ControlClient {

/// Register this node asynchronously using an [`AsyncControlStream`].
///
/// Serializes a [`RegisterRequest`], sends it over the stream, and parses
/// the response. Returns either an authorized [`RegisterResponse`] or the
/// auth URL the user must visit.
/// Serializes a [`RegisterRequest`], sends it over the stream, and
/// validates the response into a [`RegisterOutcome`] -- every field
/// combination the server can send, including ones the protocol does
/// not allow, lands in a variant of that type.
///
/// # Errors
///
/// Returns [`ControlError`] on serialization, I/O, or parse failure.
/// Returns [`ControlError`] on serialization, I/O, or a JSON payload
/// that fails to parse. A syntactically valid response the protocol
/// still rejects -- an explicit rejection, contradictory fields, an
/// expired key -- is not an error here; it is a [`RegisterOutcome`]
/// variant, because the request itself succeeded.
pub async fn register(
&mut self,
stream: &mut AsyncControlStream,
Expand All @@ -330,28 +324,18 @@ impl ControlClient {

let raw = stream.recv_message().await?;
let resp = parse_register_response(&raw)?;

if let Some(url) = resp.auth_url.clone() {
debug!(
target: "dictyon::control",
outcome = "needs_auth",
"register requires interactive auth",
);
Ok(RegisterOutcome::NeedsAuth { auth_url: url })
} else {
debug!(
target: "dictyon::control",
outcome = "authorized",
"register authorized",
);
Ok(RegisterOutcome::Authorized(resp))
}
let outcome = classify_register_response(resp);
log_register_outcome(&outcome);
Ok(outcome)
}

/// Poll for registration completion after the user has visited the auth URL.
///
/// Sends a new [`RegisterRequest`] with the `followup` field set to the
/// URL returned in the initial response, and waits for authorization.
/// URL returned in the initial response, and validates the response
/// into a [`RegisterOutcome`] via the same rules [`Self::register`]
/// uses -- a followup poll can be rejected or come back contradictory
/// exactly as the initial request can.
///
/// # Errors
///
Expand All @@ -360,7 +344,7 @@ impl ControlClient {
&mut self,
stream: &mut AsyncControlStream,
followup_url: &str,
) -> Result<RegisterResponse, ControlError> {
) -> Result<RegisterOutcome, ControlError> {
debug!(
target: "dictyon::control",
followup_url,
Expand All @@ -378,7 +362,10 @@ impl ControlClient {
stream.send_message(&framed).await?;

let raw = stream.recv_message().await?;
parse_register_response(&raw)
let resp = parse_register_response(&raw)?;
let outcome = classify_register_response(resp);
log_register_outcome(&outcome);
Ok(outcome)
}

/// Send the initial map request and start streaming map updates.
Expand Down Expand Up @@ -706,6 +693,44 @@ fn parse_register_response(raw: &[u8]) -> Result<RegisterResponse, ControlError>
})
}

/// Log a validated [`RegisterOutcome`] at a level matching how much it
/// deserves operator attention.
///
/// WHY: [`RegisterOutcome::Rejected`], `RotateNodeKey`, and `Contradictory`
/// are exactly the shapes this issue exists to stop silently tolerating, so
/// they log at `warn` -- a caller polling in a loop should not need to
/// inspect every outcome to notice the control server is sending something
/// it shouldn't.
fn log_register_outcome(outcome: &RegisterOutcome) {
match outcome {
RegisterOutcome::Authorized(_) => {
debug!(target: "dictyon::control", outcome = "authorized", "register authorized");
}
RegisterOutcome::NeedsAuth(url) => {
debug!(
target: "dictyon::control",
outcome = "needs_auth",
auth_url = %url,
"register requires interactive auth",
);
}
RegisterOutcome::RotateNodeKey => {
warn!(target: "dictyon::control", outcome = "rotate_node_key", "server reports the node key has expired");
}
RegisterOutcome::Rejected { reason } => {
warn!(target: "dictyon::control", outcome = "rejected", reason = %reason, "server rejected the registration request");
}
RegisterOutcome::Contradictory(fault) => {
warn!(
target: "dictyon::control",
outcome = "contradictory",
fault = ?fault,
"server sent a registration response the protocol does not allow",
);
}
}
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
Expand Down
Loading