dig_stun/establish.rs
1//! Provenance and agreement (`SPEC.md` §7): how several untrusted readings of this node's own
2//! reflexive address combine into something worth writing into an on-chain advertisement — or,
3//! failing that, into nothing at all.
4//!
5//! [`establish`] fails closed at every branch except [`FamilyVerdict::Established`] (`SPEC.md` §7.5):
6//! a wrong establishment puts an address into a coin, permanently, with collateral behind it; a
7//! wrong non-establishment costs one epoch's rewards and is visible to the operator.
8
9use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
10
11use crate::scope::{fold_ip, scope_of_ip, Scope};
12
13/// Floor: an address needs at least this many independent source classes agreeing before it is
14/// established (`SPEC.md` §7.4) — the same floor this ecosystem calls corroboration elsewhere
15/// (`dig-node` `SPEC.md` §18.16 `CORROBORATION_FLOOR`). An assumption, not a derived constant.
16pub const MIN_INDEPENDENT_CLASSES: usize = 2;
17/// Floor when EVERY agreeing class is a `peer:*` class (`SPEC.md` §7.4): raised because two peer
18/// classes is exactly two cheap VMs in two provider blocks, and three makes a full eclipse of the
19/// requester's direct pool the only way to forge agreement.
20pub const PEER_ONLY_MIN_CLASSES: usize = 3;
21
22/// One source's report of this node's reflexive address (`SPEC.md` §7.1).
23///
24/// `#[non_exhaustive]`: an additive field is a patch release for consumers, who construct this via
25/// [`Reading::new`] rather than a struct literal.
26#[non_exhaustive]
27#[derive(Debug, Clone)]
28pub struct Reading {
29 /// The independence class of whoever reported this, rendered by `SourceClass`'s
30 /// [`Display`](std::fmt::Display) impl (i.e. `SourceClass::operator(..).to_string()`).
31 /// Two readings corroborate each other exactly when their `source` strings DIFFER and their
32 /// addresses agree — string inequality is the ONLY independence comparison this crate defines.
33 pub source: String,
34 /// Optional identity of the individual reporter (a peer_id, a resolved server address). For
35 /// diagnostics only; never consulted by [`establish`].
36 pub witness: Option<String>,
37 /// The address the source said this node appears at. The PORT is carried but never compared —
38 /// only the IP participates in agreement (`SPEC.md` §7.3).
39 pub addr: SocketAddr,
40}
41
42impl Reading {
43 /// Construct a reading with no witness.
44 pub fn new(source: impl Into<String>, addr: SocketAddr) -> Self {
45 Reading {
46 source: source.into(),
47 witness: None,
48 addr,
49 }
50 }
51
52 /// Attach a witness identity for diagnostics (never consulted by [`establish`]).
53 pub fn with_witness(mut self, witness: impl Into<String>) -> Self {
54 self.witness = Some(witness.into());
55 self
56 }
57}
58
59/// The grammar [`Reading::source`] follows (`SPEC.md` §7.2). Each variant is one independence
60/// class; two readings whose classes render the SAME string are the same class and cannot
61/// corroborate each other.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum SourceClass {
64 /// A server the operator configured (a `DIG_STUN_SERVER` entry). Each configured endpoint is
65 /// its own class — the operator vouched for it specifically.
66 Operator {
67 /// The endpoint host, normalised the same way the operator's config entry was.
68 host: String,
69 /// The endpoint port.
70 port: u16,
71 },
72 /// The DIG relay's co-located STUN server. One class per relay host.
73 Relay {
74 /// The relay's host.
75 host: String,
76 },
77 /// A third-party public STUN host (`stun.l.google.com`, `stun.cloudflare.com`, …). One class
78 /// per host — they are different operators.
79 Public {
80 /// The public STUN host.
81 host: String,
82 },
83 /// A DIG peer answering over IPv4 (`SPEC.md` §6). One class per IPv4 `/16` — the SAME partition
84 /// `dig_gossip::util::ip_address::subnet_group` uses, so a consumer already holding that group
85 /// key renders the identical class from the same IP.
86 PeerV4 {
87 /// The first octet of the peer's transport address.
88 a: u8,
89 /// The second octet of the peer's transport address.
90 b: u8,
91 },
92 /// A DIG peer answering over IPv6. One class per IPv6 `/32`.
93 PeerV6 {
94 /// The first 16-bit group of the peer's transport address.
95 h0: u16,
96 /// The second 16-bit group of the peer's transport address.
97 h1: u16,
98 },
99}
100
101impl SourceClass {
102 /// The class for a server the operator configured.
103 pub fn operator(host: impl Into<String>, port: u16) -> Self {
104 SourceClass::Operator {
105 host: host.into(),
106 port,
107 }
108 }
109
110 /// The class for the DIG relay's co-located STUN server.
111 pub fn relay(host: impl Into<String>) -> Self {
112 SourceClass::Relay { host: host.into() }
113 }
114
115 /// The class for a third-party public STUN host.
116 pub fn public(host: impl Into<String>) -> Self {
117 SourceClass::Public { host: host.into() }
118 }
119
120 /// The class for a DIG peer, from the peer's TRANSPORT address `ip`. `ip` is folded per
121 /// `SPEC.md` §5.3 (`fold_ip`) before its leading bytes are taken, so a mapped or compat IPv6
122 /// peer renders the same class as its plain-IPv4 twin. A consumer that already computed
123 /// `dig_gossip::util::ip_address::subnet_group(ip)` for the same `ip` lands in the same
124 /// partition (`SPEC.md` §7.2) — this crate does not, and must not, re-implement that function.
125 pub fn peer(ip: IpAddr) -> Self {
126 match fold_ip(ip) {
127 IpAddr::V4(v4) => {
128 let o = v4.octets();
129 SourceClass::PeerV4 { a: o[0], b: o[1] }
130 }
131 IpAddr::V6(v6) => {
132 let s = v6.segments();
133 SourceClass::PeerV6 { h0: s[0], h1: s[1] }
134 }
135 }
136 }
137
138 /// Whether this class is one of the two `peer:*` forms (`SPEC.md` §7.3 step 4).
139 fn is_peer(&self) -> bool {
140 matches!(
141 self,
142 SourceClass::PeerV4 { .. } | SourceClass::PeerV6 { .. }
143 )
144 }
145
146 /// Parse a rendered class back out of the grammar. Round-trips every form the
147 /// [`Display`](std::fmt::Display) impl above produces; `None` for anything else.
148 pub fn parse(s: &str) -> Option<Self> {
149 if let Some(rest) = s.strip_prefix("operator:") {
150 let (host, port) = rest.rsplit_once(':')?;
151 return Some(SourceClass::Operator {
152 host: host.to_string(),
153 port: port.parse().ok()?,
154 });
155 }
156 if let Some(host) = s.strip_prefix("relay:") {
157 return Some(SourceClass::Relay {
158 host: host.to_string(),
159 });
160 }
161 if let Some(host) = s.strip_prefix("public:") {
162 return Some(SourceClass::Public {
163 host: host.to_string(),
164 });
165 }
166 if let Some(rest) = s.strip_prefix("peer:v4:") {
167 let (a, b) = rest.split_once('.')?;
168 return Some(SourceClass::PeerV4 {
169 a: a.parse().ok()?,
170 b: b.parse().ok()?,
171 });
172 }
173 if let Some(rest) = s.strip_prefix("peer:v6:") {
174 let (h0, h1) = rest.split_once(':')?;
175 return Some(SourceClass::PeerV6 {
176 h0: u16::from_str_radix(h0, 16).ok()?,
177 h1: u16::from_str_radix(h1, 16).ok()?,
178 });
179 }
180 None
181 }
182}
183
184impl std::fmt::Display for SourceClass {
185 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186 match self {
187 SourceClass::Operator { host, port } => write!(f, "operator:{host}:{port}"),
188 SourceClass::Relay { host } => write!(f, "relay:{host}"),
189 SourceClass::Public { host } => write!(f, "public:{host}"),
190 SourceClass::PeerV4 { a, b } => write!(f, "peer:v4:{a}.{b}"),
191 SourceClass::PeerV6 { h0, h1 } => write!(f, "peer:v6:{h0:04x}:{h1:04x}"),
192 }
193 }
194}
195
196/// The outcome for ONE address family (`SPEC.md` §7.3). Exhaustive: a new variant is a breaking
197/// change.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub enum FamilyVerdict {
200 /// No reading named an address in this family at all.
201 NoReadings,
202 /// More than one distinct IP was reported in this family — nothing is established, however
203 /// many readings agree with each other, because a single dissenting source is treated as proof
204 /// that something is wrong (`SPEC.md` §7.3 step 3).
205 Disagreement {
206 /// The distinct IPs reported, sorted and deduplicated.
207 addrs: Vec<IpAddr>,
208 },
209 /// The readings agree on one IP, but too few independent classes reported it.
210 Insufficient {
211 /// The number of distinct source classes that reported the agreed IP.
212 classes: usize,
213 /// Whether every one of those classes was a `peer:*` class (`SPEC.md` §7.3 step 4) — when
214 /// true the floor is [`PEER_ONLY_MIN_CLASSES`] rather than [`MIN_INDEPENDENT_CLASSES`].
215 peer_only: bool,
216 },
217 /// Enough independent classes agree, but the agreed IP is not [`Scope::GlobalUnicast`] — a true
218 /// reading of the node's position, and still never something to advertise to strangers.
219 NotGlobal {
220 /// The agreed IP.
221 ip: IpAddr,
222 /// Its scope.
223 scope: Scope,
224 },
225 /// The agreed IP is established: enough independent classes agree, unanimously, on a globally
226 /// routable address.
227 Established {
228 /// The established IP.
229 ip: IpAddr,
230 /// How many independent classes agreed on it.
231 classes: usize,
232 },
233}
234
235/// The result of [`establish`]: one [`FamilyVerdict`] per address family, evaluated independently
236/// (`SPEC.md` §7.3).
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub struct Established {
239 /// The IPv6 family's verdict.
240 pub ipv6: FamilyVerdict,
241 /// The IPv4 family's verdict.
242 pub ipv4: FamilyVerdict,
243}
244
245impl Established {
246 /// The established IPv6 address, or `None` for any verdict other than
247 /// [`FamilyVerdict::Established`].
248 pub fn ipv6_addr(&self) -> Option<Ipv6Addr> {
249 match &self.ipv6 {
250 FamilyVerdict::Established {
251 ip: IpAddr::V6(v6), ..
252 } => Some(*v6),
253 _ => None,
254 }
255 }
256
257 /// The established IPv4 address, or `None` for any verdict other than
258 /// [`FamilyVerdict::Established`].
259 pub fn ipv4_addr(&self) -> Option<Ipv4Addr> {
260 match &self.ipv4 {
261 FamilyVerdict::Established {
262 ip: IpAddr::V4(v4), ..
263 } => Some(*v4),
264 _ => None,
265 }
266 }
267}
268
269/// Combine untrusted `readings` of this node's own reflexive address into an [`Established`]
270/// verdict per address family (`SPEC.md` §7.3).
271///
272/// A reading belongs to the IPv4 family when its address is IPv4, or folds to IPv4 under `SPEC.md`
273/// §5.3 (`fold_ip`); otherwise IPv6. Non-answers — a timeout, an [`crate::observe::Refusal`], an
274/// RPC method-not-found, a parse error — are NOT readings and MUST NOT be passed here: they neither
275/// agree nor dissent.
276pub fn establish(readings: &[Reading]) -> Established {
277 let mut ipv4: Vec<(IpAddr, &str)> = Vec::new();
278 let mut ipv6: Vec<(IpAddr, &str)> = Vec::new();
279 for reading in readings {
280 let folded = fold_ip(reading.addr.ip());
281 let bucket = match folded {
282 IpAddr::V4(_) => &mut ipv4,
283 IpAddr::V6(_) => &mut ipv6,
284 };
285 bucket.push((folded, reading.source.as_str()));
286 }
287
288 Established {
289 ipv4: verdict_for_family(&ipv4),
290 ipv6: verdict_for_family(&ipv6),
291 }
292}
293
294/// The five-step decision of `SPEC.md` §7.3, applied to the readings of ONE address family.
295fn verdict_for_family(readings: &[(IpAddr, &str)]) -> FamilyVerdict {
296 if readings.is_empty() {
297 return FamilyVerdict::NoReadings;
298 }
299
300 let mut addrs: Vec<IpAddr> = readings.iter().map(|(ip, _)| *ip).collect();
301 addrs.sort();
302 addrs.dedup();
303 if addrs.len() > 1 {
304 return FamilyVerdict::Disagreement { addrs };
305 }
306 let ip = addrs[0];
307
308 let mut classes: Vec<&str> = readings.iter().map(|(_, source)| *source).collect();
309 classes.sort_unstable();
310 classes.dedup();
311 let peer_only = classes
312 .iter()
313 .all(|s| SourceClass::parse(s).map(|c| c.is_peer()).unwrap_or(false));
314
315 if classes.len() < MIN_INDEPENDENT_CLASSES {
316 return FamilyVerdict::Insufficient {
317 classes: classes.len(),
318 peer_only,
319 };
320 }
321 if peer_only && classes.len() < PEER_ONLY_MIN_CLASSES {
322 return FamilyVerdict::Insufficient {
323 classes: classes.len(),
324 peer_only: true,
325 };
326 }
327
328 let scope = scope_of_ip(ip);
329 if scope != Scope::GlobalUnicast {
330 return FamilyVerdict::NotGlobal { ip, scope };
331 }
332
333 FamilyVerdict::Established {
334 ip,
335 classes: classes.len(),
336 }
337}