Skip to main content

gate4agent_node_wire/
call_home.rs

1//! The call-home preface: one frame, sent by a node that dials the relay
2//! instead of waiting to be dialled.
3//!
4//! Everything else on this wire is opened by the operator, so the operator
5//! always knows whose socket it holds. A node with no reachable address
6//! cannot be dialled at all, so it connects out -- and then the relay is
7//! holding an accepted socket with no idea which node is on the far end,
8//! and no way to guess, because verifying the node's own proof requires
9//! that node's access token.
10//!
11//! So the node names itself first, and the handshake that follows is
12//! byte-for-byte the one a dialled connection runs. See
13//! [`NodeCallHomeAnnounce`]'s own doc comment for why naming yourself is
14//! not authentication and grants nothing.
15
16use std::time::Duration;
17
18use gate4agent_node_protocol::{
19    read_json_frame_limited_body_timeout, write_json_frame_limited, FrameError,
20    NodeCallHomeAnnounce, NodeId, BUILD_STAMP, MAX_NODE_HELLO_FRAME_BYTES,
21};
22use tokio::io::{AsyncRead, AsyncWrite};
23
24/// How long the relay will wait for a freshly accepted socket to name its
25/// node before dropping it. Deliberately short and deliberately its own
26/// constant rather than the handshake's: an accepted socket that has not
27/// yet said anything is the cheapest thing in the world to open and the
28/// only cost of holding it is ours, so it gets less patience than a peer
29/// that has already identified itself.
30const ANNOUNCE_TIMEOUT_MS: u64 = 2_000;
31
32/// Why a call-home preface could not be read.
33#[derive(Debug)]
34pub enum CallHomeAnnounceError {
35    /// The socket said nothing, or not enough, before the deadline.
36    TimedOut,
37    /// The bytes were not a well-formed preface.
38    Frame(FrameError),
39    /// A preface arrived, from a peer built from a different tree than
40    /// this binary. Reported separately from `Frame` because it is the one
41    /// failure here that is a deployment mistake rather than a hostile or
42    /// broken peer, and it should read as one in a log.
43    BuildStamp { announced: String },
44    /// The name is not a syntactically valid node id. Checked here rather
45    /// than left to the lookup so a malformed name is refused at the door
46    /// with a reason, instead of becoming an indistinguishable "no such
47    /// node" a moment later.
48    InvalidNodeId,
49}
50
51impl std::fmt::Display for CallHomeAnnounceError {
52    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        match self {
54            Self::TimedOut => write!(formatter, "call-home peer did not announce itself in time"),
55            Self::Frame(error) => write!(formatter, "call-home announce frame is invalid: {error}"),
56            Self::BuildStamp { announced } => write!(
57                formatter,
58                "build stamp mismatch: local={BUILD_STAMP} remote={announced}",
59            ),
60            Self::InvalidNodeId => write!(formatter, "call-home announce carried an invalid node id"),
61        }
62    }
63}
64
65impl std::error::Error for CallHomeAnnounceError {}
66
67/// Node side: name yourself on a socket you just opened.
68pub async fn write_call_home_announce<W>(
69    writer: &mut W,
70    node_id: &NodeId,
71) -> Result<(), FrameError>
72where
73    W: AsyncWrite + Unpin,
74{
75    write_json_frame_limited(
76        writer,
77        &NodeCallHomeAnnounce::new(node_id.as_str()),
78        MAX_NODE_HELLO_FRAME_BYTES,
79    )
80    .await
81}
82
83/// Relay side: find out whose socket this is.
84///
85/// Returns the announced id parsed as a [`NodeId`]. It is a claim, not a
86/// credential: the caller looks up that node's configured access token and
87/// the handshake decides whether the claim was true.
88pub async fn read_call_home_announce<R>(reader: &mut R) -> Result<NodeId, CallHomeAnnounceError>
89where
90    R: AsyncRead + Unpin,
91{
92    let announce: NodeCallHomeAnnounce = tokio::time::timeout(
93        Duration::from_millis(ANNOUNCE_TIMEOUT_MS),
94        read_json_frame_limited_body_timeout(
95            reader,
96            MAX_NODE_HELLO_FRAME_BYTES,
97            Duration::from_millis(ANNOUNCE_TIMEOUT_MS),
98        ),
99    )
100    .await
101    .map_err(|_| CallHomeAnnounceError::TimedOut)?
102    .map_err(CallHomeAnnounceError::Frame)?;
103    if announce.build_stamp != BUILD_STAMP {
104        return Err(CallHomeAnnounceError::BuildStamp {
105            announced: announce.build_stamp,
106        });
107    }
108    NodeId::new(announce.node_id).map_err(|_| CallHomeAnnounceError::InvalidNodeId)
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    /// The preface round-trips over a plain byte pipe, which is the whole
116    /// contract: it is written by whoever opened the socket and read by
117    /// whoever accepted it, over any stream at all.
118    #[tokio::test]
119    async fn a_node_names_itself_and_the_relay_reads_the_name() {
120        let node_id = NodeId::new("fixture-node").unwrap();
121        let mut wire = Vec::new();
122        write_call_home_announce(&mut wire, &node_id).await.unwrap();
123        let read = read_call_home_announce(&mut wire.as_slice()).await.unwrap();
124        assert_eq!(read, node_id);
125    }
126
127    /// A peer built from a different tree is told which mismatch it is,
128    /// not handed a generic parse failure -- this is the one error here
129    /// that means "your deployment is mixed", and it has to read that way.
130    #[tokio::test]
131    async fn a_build_stamp_mismatch_names_itself_rather_than_looking_like_garbage() {
132        let foreign_stamp = "f".repeat(BUILD_STAMP.len());
133        let mut wire = Vec::new();
134        write_json_frame_limited(
135            &mut wire,
136            &NodeCallHomeAnnounce {
137                build_stamp: foreign_stamp.clone(),
138                node_id: "fixture-node".to_owned(),
139            },
140            MAX_NODE_HELLO_FRAME_BYTES,
141        )
142        .await
143        .unwrap();
144        let error = read_call_home_announce(&mut wire.as_slice()).await.unwrap_err();
145        assert!(
146            matches!(&error, CallHomeAnnounceError::BuildStamp { announced } if *announced == foreign_stamp),
147            "expected a named build stamp mismatch, got {error:?}",
148        );
149        assert_eq!(
150            error.to_string(),
151            format!("build stamp mismatch: local={BUILD_STAMP} remote={foreign_stamp}"),
152        );
153    }
154
155    /// Silence costs the relay a bounded wait and nothing else.
156    #[tokio::test]
157    async fn a_socket_that_says_nothing_is_dropped_rather_than_held() {
158        let (client, mut server) = tokio::io::duplex(64);
159        // `client` is kept alive and never written to: the peer connected
160        // and then said nothing, which is exactly the cheap-to-open,
161        // expensive-to-hold case the deadline exists for.
162        let error = read_call_home_announce(&mut server).await.unwrap_err();
163        drop(client);
164        assert!(
165            matches!(error, CallHomeAnnounceError::TimedOut),
166            "expected the announce deadline to fire, got {error:?}",
167        );
168    }
169}