Skip to main content

netget/server/hsrp/
codec.rs

1//! HSRP wire formats, both of them.
2//!
3//! **HSRPv1 and HSRPv2 are not the same protocol in a different dress — they are two
4//! unrelated encodings that happen to share a name, a port and an election idea.** v1 is a
5//! flat 20-byte struct (RFC 2281 §5); v2 is a sequence of TLVs. Nothing about one parses as
6//! the other, and the state *numbers* differ in a way that silently mis-decodes rather than
7//! failing: code `4` is **Speak** in v1 and **Standby** in v2. That collision is the single
8//! most dangerous thing in this file, which is why the enums below never carry a bare integer
9//! across a version boundary — `HsrpState` is converted through `from_v1_code`/`from_v2_code`
10//! and `v1_code`/`v2_code`, and there is no `as u8` shortcut anywhere.
11//!
12//! This module is pure: no sockets, no LLM, no state. `mod.rs` owns all of that.
13
14use anyhow::{anyhow, Context, Result};
15use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
16
17/// Exact size of an HSRPv1 datagram (RFC 2281 §5): eight one-byte fields, eight bytes of
18/// authentication data, four bytes of virtual IP.
19pub const V1_LEN: usize = 20;
20
21/// The value HSRPv1 puts in its `Version` byte. **It is 0, not 1** (RFC 2281 §5: "Currently,
22/// this is version 0"). Reading this as "v1 means byte 1" produces a packet no Cisco device
23/// accepts, and is the first thing to check if interop ever fails.
24pub const V1_VERSION_BYTE: u8 = 0;
25
26/// HSRPv2 Group State TLV type.
27pub const V2_TLV_GROUP_STATE: u8 = 1;
28/// HSRPv2 Interface State TLV type. Parsed past, never generated.
29pub const V2_TLV_INTERFACE_STATE: u8 = 2;
30/// HSRPv2 Text Authentication TLV type — the v2 home of the same 8-byte plaintext field v1
31/// carries inline.
32pub const V2_TLV_TEXT_AUTH: u8 = 3;
33/// HSRPv2 MD5 Authentication TLV type. Parsed structurally; see `Md5Auth`.
34pub const V2_TLV_MD5_AUTH: u8 = 4;
35
36/// Payload length of the Group State TLV, i.e. the byte after the type. Always 40.
37pub const V2_GROUP_STATE_LEN: u8 = 40;
38/// Total size of a Group State TLV including its two-byte type/length header.
39pub const V2_GROUP_STATE_TOTAL: usize = 2 + V2_GROUP_STATE_LEN as usize;
40/// Payload length of the Text Authentication TLV. Always 8, like v1's inline field.
41pub const V2_TEXT_AUTH_LEN: u8 = 8;
42/// Payload length of the MD5 Authentication TLV in the form this parser accepts.
43pub const V2_MD5_AUTH_LEN: u8 = 28;
44
45/// Width of the authentication field in both versions.
46pub const AUTH_FIELD_LEN: usize = 8;
47
48/// The notorious Cisco default. See `CLAUDE.md`: this is **plaintext on the wire and provides
49/// no security whatsoever** — it is a misconfiguration guard, not authentication.
50pub const DEFAULT_AUTH_DATA: &str = "cisco";
51
52/// Which of the two incompatible encodings a message uses.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum HsrpVersion {
55    V1,
56    V2,
57}
58
59impl HsrpVersion {
60    pub fn as_number(self) -> u8 {
61        match self {
62            HsrpVersion::V1 => 1,
63            HsrpVersion::V2 => 2,
64        }
65    }
66
67    /// Accepts the *protocol* version the operator means (1 or 2), not the byte on the wire.
68    pub fn from_number(value: u64) -> Result<Self> {
69        match value {
70            1 => Ok(HsrpVersion::V1),
71            2 => Ok(HsrpVersion::V2),
72            other => Err(anyhow!(
73                "HSRP version must be 1 or 2, got {other}. The two are different wire formats \
74                 and are not interoperable."
75            )),
76        }
77    }
78}
79
80/// What the message asserts. All three are elections moves, not queries.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum Opcode {
83    /// "I am here, at this priority, in this state."
84    Hello,
85    /// "I am taking Active from you, now."
86    Coup,
87    /// "I am giving up Active."
88    Resign,
89}
90
91impl Opcode {
92    pub fn code(self) -> u8 {
93        match self {
94            Opcode::Hello => 0,
95            Opcode::Coup => 1,
96            Opcode::Resign => 2,
97        }
98    }
99
100    pub fn from_code(code: u8) -> Result<Self> {
101        match code {
102            0 => Ok(Opcode::Hello),
103            1 => Ok(Opcode::Coup),
104            2 => Ok(Opcode::Resign),
105            other => Err(anyhow!("Unknown HSRP opcode {other} (expected 0, 1 or 2)")),
106        }
107    }
108
109    /// Stable lowercase token used in event data, action names and log lines.
110    pub fn as_str(self) -> &'static str {
111        match self {
112            Opcode::Hello => "hello",
113            Opcode::Coup => "coup",
114            Opcode::Resign => "resign",
115        }
116    }
117}
118
119/// Where the speaker says it is in the election.
120///
121/// **The numeric encodings differ between versions and overlap.** They are never exposed as
122/// numbers outside this module; the model sees and produces the names.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum HsrpState {
125    Initial,
126    Learn,
127    Listen,
128    Speak,
129    Standby,
130    Active,
131}
132
133impl HsrpState {
134    /// RFC 2281 §5: a bit-per-state encoding, so Speak is 4 and Active is 16.
135    pub fn v1_code(self) -> u8 {
136        match self {
137            HsrpState::Initial => 0,
138            HsrpState::Learn => 1,
139            HsrpState::Listen => 2,
140            HsrpState::Speak => 4,
141            HsrpState::Standby => 8,
142            HsrpState::Active => 16,
143        }
144    }
145
146    /// HSRPv2 renumbered these densely, so 4 is Standby here and Speak in v1.
147    pub fn v2_code(self) -> u8 {
148        match self {
149            HsrpState::Initial => 0,
150            HsrpState::Learn => 1,
151            HsrpState::Listen => 2,
152            HsrpState::Speak => 3,
153            HsrpState::Standby => 4,
154            HsrpState::Active => 5,
155        }
156    }
157
158    pub fn from_v1_code(code: u8) -> Result<Self> {
159        match code {
160            0 => Ok(HsrpState::Initial),
161            1 => Ok(HsrpState::Learn),
162            2 => Ok(HsrpState::Listen),
163            4 => Ok(HsrpState::Speak),
164            8 => Ok(HsrpState::Standby),
165            16 => Ok(HsrpState::Active),
166            other => Err(anyhow!(
167                "Unknown HSRPv1 state code {other} (expected 0, 1, 2, 4, 8 or 16)"
168            )),
169        }
170    }
171
172    pub fn from_v2_code(code: u8) -> Result<Self> {
173        match code {
174            0 => Ok(HsrpState::Initial),
175            1 => Ok(HsrpState::Learn),
176            2 => Ok(HsrpState::Listen),
177            3 => Ok(HsrpState::Speak),
178            4 => Ok(HsrpState::Standby),
179            5 => Ok(HsrpState::Active),
180            other => Err(anyhow!("Unknown HSRPv2 state code {other} (expected 0-5)")),
181        }
182    }
183
184    pub fn as_str(self) -> &'static str {
185        match self {
186            HsrpState::Initial => "initial",
187            HsrpState::Learn => "learn",
188            HsrpState::Listen => "listen",
189            HsrpState::Speak => "speak",
190            HsrpState::Standby => "standby",
191            HsrpState::Active => "active",
192        }
193    }
194
195    /// Parse the name the model uses. Deliberately name-only: accepting a number here would
196    /// reintroduce the v1/v2 code collision at the one place it is most likely to be wrong.
197    pub fn from_str_name(value: &str) -> Result<Self> {
198        match value.trim().to_ascii_lowercase().as_str() {
199            "initial" => Ok(HsrpState::Initial),
200            "learn" => Ok(HsrpState::Learn),
201            "listen" => Ok(HsrpState::Listen),
202            "speak" => Ok(HsrpState::Speak),
203            "standby" => Ok(HsrpState::Standby),
204            "active" => Ok(HsrpState::Active),
205            other => Err(anyhow!(
206                "Unknown HSRP state '{other}'. Use one of: initial, learn, listen, speak, \
207                 standby, active. (State names are used rather than numbers because HSRPv1 and \
208                 HSRPv2 encode them differently - code 4 is Speak in v1 and Standby in v2.)"
209            )),
210        }
211    }
212
213    /// True when this state claims the virtual IP, i.e. claims to be the segment's gateway.
214    pub fn claims_gateway(self) -> bool {
215        matches!(self, HsrpState::Active)
216    }
217}
218
219/// What an HSRPv2 MD5 Authentication TLV said, minus the digest.
220///
221/// **The digest is deliberately not carried here and never leaves the parser.** It is 16 raw
222/// bytes, which the root `CLAUDE.md` forbids putting in event data, and it is unverifiable
223/// anyway: verifying it needs the shared key, and NetGet stores nothing. What the model gets
224/// is the structural facts — that MD5 auth was used, by which key id, claiming which sender.
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub struct Md5Auth {
227    pub algorithm: u8,
228    pub flags: u16,
229    pub sender_address: Ipv4Addr,
230    pub key_id: u32,
231}
232
233/// One HSRP advertisement, version-independent.
234///
235/// Times are held in **seconds** in both versions. v1 carries seconds on the wire; v2 carries
236/// milliseconds and this struct converts at the edge, so nothing above the codec has to
237/// remember which unit a given version uses. The cost is that v2's sub-second timers are not
238/// expressible; `CLAUDE.md` records that.
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct HsrpMessage {
241    pub version: HsrpVersion,
242    pub opcode: Opcode,
243    pub state: HsrpState,
244    pub hellotime_secs: u32,
245    pub holdtime_secs: u32,
246    pub priority: u32,
247    pub group: u16,
248    /// Plaintext authentication string, trailing NULs stripped. `None` means the v2 message
249    /// carried no Text Authentication TLV. A v1 message always has the field, so `None` there
250    /// means it was all zeros.
251    pub auth_data: Option<String>,
252    pub virtual_ip: IpAddr,
253    /// v2 only: the sender's 6-byte identifier, in practice its MAC. Zero for v1.
254    pub identifier: [u8; 6],
255    /// v2 only, parse direction only.
256    pub md5_auth: Option<Md5Auth>,
257}
258
259impl HsrpMessage {
260    /// Render the identifier the way a human reads a MAC.
261    pub fn identifier_str(&self) -> String {
262        self.identifier
263            .iter()
264            .map(|b| format!("{b:02x}"))
265            .collect::<Vec<_>>()
266            .join(":")
267    }
268}
269
270/// Pad a plaintext authentication string into the fixed 8-byte field.
271///
272/// Longer than 8 bytes is an error rather than a truncation: a silently truncated
273/// authentication string produces a packet the peer rejects for a reason nothing logs, which
274/// is indistinguishable from this server having said nothing.
275fn encode_auth_field(auth: Option<&str>) -> Result<[u8; AUTH_FIELD_LEN]> {
276    let mut field = [0u8; AUTH_FIELD_LEN];
277    let Some(auth) = auth else {
278        return Ok(field);
279    };
280    if let Some((index, bad)) = auth.char_indices().find(|(_, c)| c.is_control()) {
281        return Err(anyhow!(
282            "HSRP auth_data contains a control character (U+{:04X}) at byte {index}. The field \
283             is a plaintext group password that peers print back in their diagnostics and that \
284             this server's own event log renders unquoted, so a newline there forges a log \
285             line. Send printable text only.",
286            bad as u32
287        ));
288    }
289    let bytes = auth.as_bytes();
290    if bytes.len() > AUTH_FIELD_LEN {
291        return Err(anyhow!(
292            "HSRP auth_data must be at most {AUTH_FIELD_LEN} bytes, got {} ('{auth}'). The \
293             field is a fixed 8-byte NUL-padded string.",
294            bytes.len()
295        ));
296    }
297    field[..bytes.len()].copy_from_slice(bytes);
298    Ok(field)
299}
300
301/// Read the fixed 8-byte authentication field back into a string.
302///
303/// Lossy on purpose: the field is nominally ASCII but nothing on the wire enforces it, and a
304/// hostile sender putting arbitrary bytes there must not be able to fail the parse (which
305/// would drop the whole advertisement) or panic a UTF-8 slice.
306fn decode_auth_field(field: &[u8]) -> Option<String> {
307    let trimmed: Vec<u8> = field.iter().copied().take_while(|b| *b != 0).collect();
308    if trimmed.is_empty() {
309        return None;
310    }
311    // Control characters become spaces. This string reaches the model and is rendered into
312    // `... auth={auth_data}, from={source_address}` by the event's own log template, which
313    // quotes nothing - so a neighbour putting a newline in these eight octets would otherwise
314    // forge a log line. Lossy on purpose: the field is a plaintext group password, never a
315    // control character, and a neighbour cannot be asked to resend.
316    Some(crate::utils::sanitize::line_field(
317        &String::from_utf8_lossy(&trimmed),
318    ))
319}
320
321fn u16_at(buf: &[u8], offset: usize) -> u16 {
322    u16::from_be_bytes([buf[offset], buf[offset + 1]])
323}
324
325fn u32_at(buf: &[u8], offset: usize) -> u32 {
326    u32::from_be_bytes([
327        buf[offset],
328        buf[offset + 1],
329        buf[offset + 2],
330        buf[offset + 3],
331    ])
332}
333
334// ---------------------------------------------------------------------------
335// Encoding
336// ---------------------------------------------------------------------------
337
338/// Serialize a message in whichever format its `version` names.
339pub fn encode(message: &HsrpMessage) -> Result<Vec<u8>> {
340    match message.version {
341        HsrpVersion::V1 => encode_v1(message),
342        HsrpVersion::V2 => encode_v2(message),
343    }
344}
345
346/// HSRPv1, RFC 2281 §5. Exactly 20 bytes, every field one byte except auth (8) and the
347/// virtual IP (4).
348///
349/// ```text
350///  0                   1                   2                   3
351///  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
352/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
353/// |   Version     |   Op Code     |     State     |   Hellotime   |
354/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
355/// |   Holdtime    |   Priority    |     Group     |   Reserved    |
356/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
357/// |                   Authentication  Data                        |
358/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
359/// |                   Authentication  Data                        |
360/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
361/// |                    Virtual IP Address                         |
362/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
363/// ```
364fn encode_v1(message: &HsrpMessage) -> Result<Vec<u8>> {
365    let IpAddr::V4(vip) = message.virtual_ip else {
366        return Err(anyhow!(
367            "HSRPv1 carries a 4-byte virtual IP and has no IPv6 form; '{}' is IPv6. Use \
368             version 2 for IPv6.",
369            message.virtual_ip
370        ));
371    };
372
373    let priority = u8::try_from(message.priority).map_err(|_| {
374        anyhow!(
375            "HSRPv1 priority is a single byte (0-255), got {}. HSRPv2 widened it to 32 bits.",
376            message.priority
377        )
378    })?;
379    let group = u8::try_from(message.group).map_err(|_| {
380        anyhow!(
381            "HSRPv1 group is a single byte (0-255), got {}. HSRPv2 widened it to 0-4095.",
382            message.group
383        )
384    })?;
385    let hellotime = u8::try_from(message.hellotime_secs).map_err(|_| {
386        anyhow!(
387            "HSRPv1 hellotime is a single byte of seconds (0-255), got {}.",
388            message.hellotime_secs
389        )
390    })?;
391    let holdtime = u8::try_from(message.holdtime_secs).map_err(|_| {
392        anyhow!(
393            "HSRPv1 holdtime is a single byte of seconds (0-255), got {}.",
394            message.holdtime_secs
395        )
396    })?;
397
398    let auth = encode_auth_field(message.auth_data.as_deref())?;
399
400    let mut out = Vec::with_capacity(V1_LEN);
401    out.push(V1_VERSION_BYTE);
402    out.push(message.opcode.code());
403    out.push(message.state.v1_code());
404    out.push(hellotime);
405    out.push(holdtime);
406    out.push(priority);
407    out.push(group);
408    out.push(0); // Reserved
409    out.extend_from_slice(&auth);
410    out.extend_from_slice(&vip.octets());
411
412    debug_assert_eq!(out.len(), V1_LEN);
413    Ok(out)
414}
415
416/// HSRPv2 — a Group State TLV, optionally followed by a Text Authentication TLV.
417///
418/// ```text
419/// +--------+--------+---------------------------------------------+
420/// | Type=1 | Len=40 |  Version | Opcode | State | IP Ver |  Group  |
421/// +--------+--------+---------------------------------------------+
422/// |         Identifier (6 bytes)        |     Priority (4)        |
423/// +---------------------------------------------------------------+
424/// |    Hellotime (4, ms)   |    Holdtime (4, ms)                   |
425/// +---------------------------------------------------------------+
426/// |            Virtual IP Address (16 bytes)                       |
427/// +---------------------------------------------------------------+
428/// ```
429///
430/// HSRPv2 has no RFC; this layout is taken from Cisco's published documentation and from
431/// packet-capture dissectors, and `CLAUDE.md` records that it has never been checked against a
432/// real Cisco device.
433fn encode_v2(message: &HsrpMessage) -> Result<Vec<u8>> {
434    if message.group > 4095 {
435        return Err(anyhow!(
436            "HSRPv2 group must be 0-4095, got {}.",
437            message.group
438        ));
439    }
440
441    // v2 times are milliseconds on the wire. A value large enough to overflow is a modelling
442    // error, not something to wrap silently into a fast timer.
443    let hellotime_ms = message.hellotime_secs.checked_mul(1000).ok_or_else(|| {
444        anyhow!(
445            "HSRPv2 hellotime {}s does not fit the 32-bit millisecond field.",
446            message.hellotime_secs
447        )
448    })?;
449    let holdtime_ms = message.holdtime_secs.checked_mul(1000).ok_or_else(|| {
450        anyhow!(
451            "HSRPv2 holdtime {}s does not fit the 32-bit millisecond field.",
452            message.holdtime_secs
453        )
454    })?;
455
456    let (ip_version, vip_bytes): (u8, [u8; 16]) = match message.virtual_ip {
457        IpAddr::V4(v4) => {
458            // The field is 16 bytes whatever the family; an IPv4 address occupies the first
459            // four and the rest stay zero.
460            let mut buf = [0u8; 16];
461            buf[..4].copy_from_slice(&v4.octets());
462            (4, buf)
463        }
464        IpAddr::V6(v6) => (6, v6.octets()),
465    };
466
467    let mut out = Vec::with_capacity(V2_GROUP_STATE_TOTAL + 2 + AUTH_FIELD_LEN);
468    out.push(V2_TLV_GROUP_STATE);
469    out.push(V2_GROUP_STATE_LEN);
470    out.push(HsrpVersion::V2.as_number());
471    out.push(message.opcode.code());
472    out.push(message.state.v2_code());
473    out.push(ip_version);
474    out.extend_from_slice(&message.group.to_be_bytes());
475    out.extend_from_slice(&message.identifier);
476    out.extend_from_slice(&message.priority.to_be_bytes());
477    out.extend_from_slice(&hellotime_ms.to_be_bytes());
478    out.extend_from_slice(&holdtime_ms.to_be_bytes());
479    out.extend_from_slice(&vip_bytes);
480
481    debug_assert_eq!(out.len(), V2_GROUP_STATE_TOTAL);
482
483    // In v2 the plaintext string is its own TLV rather than an inline field, so "no auth" is
484    // expressible here in a way it is not in v1.
485    if let Some(auth) = message.auth_data.as_deref() {
486        let field = encode_auth_field(Some(auth))?;
487        out.push(V2_TLV_TEXT_AUTH);
488        out.push(V2_TEXT_AUTH_LEN);
489        out.extend_from_slice(&field);
490    }
491
492    Ok(out)
493}
494
495// ---------------------------------------------------------------------------
496// Decoding
497// ---------------------------------------------------------------------------
498
499/// Parse a datagram as whichever of the two formats it is.
500///
501/// The two are told apart by their first bytes and cannot be confused: a v1 packet starts with
502/// its Version byte, which is **0**, while a v2 packet starts with a TLV type, which is never
503/// 0. Sniffing rather than trusting a configured version is deliberate — a real segment can
504/// carry both at once, and refusing to parse the one the operator did not configure would
505/// simply hide a neighbour from the model.
506pub fn decode(buf: &[u8]) -> Result<HsrpMessage> {
507    match buf.first() {
508        Some(&V1_VERSION_BYTE) => decode_v1(buf),
509        Some(_) => decode_v2(buf),
510        None => Err(anyhow!("Empty HSRP datagram")),
511    }
512}
513
514fn decode_v1(buf: &[u8]) -> Result<HsrpMessage> {
515    if buf.len() < V1_LEN {
516        return Err(anyhow!(
517            "HSRPv1 datagram is {} bytes, needs {V1_LEN}",
518            buf.len()
519        ));
520    }
521
522    let opcode = Opcode::from_code(buf[1])?;
523    let state = HsrpState::from_v1_code(buf[2])?;
524    let virtual_ip = Ipv4Addr::new(buf[16], buf[17], buf[18], buf[19]);
525
526    Ok(HsrpMessage {
527        version: HsrpVersion::V1,
528        opcode,
529        state,
530        hellotime_secs: u32::from(buf[3]),
531        holdtime_secs: u32::from(buf[4]),
532        priority: u32::from(buf[5]),
533        group: u16::from(buf[6]),
534        // buf[7] is Reserved and is ignored rather than validated: RFC 2281 does not require
535        // receivers to reject a non-zero value, and dropping the advertisement would hide a
536        // neighbour over a field nothing acts on.
537        auth_data: decode_auth_field(&buf[8..16]),
538        virtual_ip: IpAddr::V4(virtual_ip),
539        identifier: [0u8; 6],
540        md5_auth: None,
541    })
542}
543
544/// Walk the TLV chain. The Group State TLV is mandatory; Text and MD5 authentication are
545/// optional and may appear in either order; anything else is skipped by its own length.
546fn decode_v2(buf: &[u8]) -> Result<HsrpMessage> {
547    let mut group_state: Option<HsrpMessage> = None;
548    let mut text_auth: Option<String> = None;
549    let mut md5_auth: Option<Md5Auth> = None;
550
551    let mut offset = 0usize;
552    while offset + 2 <= buf.len() {
553        let tlv_type = buf[offset];
554        let tlv_len = usize::from(buf[offset + 1]);
555        let body_start = offset + 2;
556        let body_end = body_start
557            .checked_add(tlv_len)
558            .filter(|end| *end <= buf.len())
559            .ok_or_else(|| {
560                anyhow!(
561                    "HSRPv2 TLV type {tlv_type} at offset {offset} claims {tlv_len} bytes, past \
562                     the end of a {}-byte datagram",
563                    buf.len()
564                )
565            })?;
566        let body = &buf[body_start..body_end];
567
568        match tlv_type {
569            V2_TLV_GROUP_STATE => {
570                group_state = Some(decode_v2_group_state(body)?);
571            }
572            V2_TLV_TEXT_AUTH => {
573                text_auth = decode_auth_field(body);
574            }
575            V2_TLV_MD5_AUTH => {
576                md5_auth = decode_v2_md5_auth(body);
577            }
578            V2_TLV_INTERFACE_STATE => {
579                // Carries the sender's interface MTU/state. Nothing here acts on it.
580            }
581            _ => {
582                // Unknown TLVs are skipped by their length rather than treated as a parse
583                // failure: HSRPv2's whole point is that a speaker can add TLVs a peer does not
584                // know, and dropping the advertisement over one would hide the neighbour.
585            }
586        }
587
588        offset = body_end;
589    }
590
591    let mut message = group_state.context(
592        "HSRPv2 datagram carried no Group State TLV (type 1); there is nothing to report",
593    )?;
594    message.auth_data = text_auth;
595    message.md5_auth = md5_auth;
596    Ok(message)
597}
598
599fn decode_v2_group_state(body: &[u8]) -> Result<HsrpMessage> {
600    if body.len() < usize::from(V2_GROUP_STATE_LEN) {
601        return Err(anyhow!(
602            "HSRPv2 Group State TLV is {} bytes, needs {V2_GROUP_STATE_LEN}",
603            body.len()
604        ));
605    }
606
607    let wire_version = body[0];
608    if wire_version != HsrpVersion::V2.as_number() {
609        return Err(anyhow!(
610            "HSRPv2 Group State TLV declares version {wire_version}, expected 2"
611        ));
612    }
613
614    let opcode = Opcode::from_code(body[1])?;
615    let state = HsrpState::from_v2_code(body[2])?;
616    let ip_version = body[3];
617    let group = u16_at(body, 4);
618
619    let mut identifier = [0u8; 6];
620    identifier.copy_from_slice(&body[6..12]);
621
622    let priority = u32_at(body, 12);
623    let hellotime_ms = u32_at(body, 16);
624    let holdtime_ms = u32_at(body, 20);
625
626    let virtual_ip = match ip_version {
627        4 => IpAddr::V4(Ipv4Addr::new(body[24], body[25], body[26], body[27])),
628        6 => {
629            let mut octets = [0u8; 16];
630            octets.copy_from_slice(&body[24..40]);
631            IpAddr::V6(Ipv6Addr::from(octets))
632        }
633        other => {
634            return Err(anyhow!(
635                "HSRPv2 Group State TLV declares IP version {other}, expected 4 or 6"
636            ))
637        }
638    };
639
640    Ok(HsrpMessage {
641        version: HsrpVersion::V2,
642        opcode,
643        state,
644        // Integer-second view of a millisecond field; see the struct doc for what this costs.
645        hellotime_secs: hellotime_ms / 1000,
646        holdtime_secs: holdtime_ms / 1000,
647        priority,
648        group,
649        auth_data: None,
650        virtual_ip,
651        identifier,
652        md5_auth: None,
653    })
654}
655
656/// Read the structural fields of an MD5 Authentication TLV, and **not** the digest.
657///
658/// Returns `None` rather than an error for a short TLV: a malformed authentication option is
659/// not a reason to discard an otherwise well-formed advertisement, and the model is told
660/// nothing rather than something wrong.
661fn decode_v2_md5_auth(body: &[u8]) -> Option<Md5Auth> {
662    if body.len() < usize::from(V2_MD5_AUTH_LEN) {
663        return None;
664    }
665    Some(Md5Auth {
666        algorithm: body[0],
667        // body[1] is padding.
668        flags: u16_at(body, 2),
669        sender_address: Ipv4Addr::new(body[4], body[5], body[6], body[7]),
670        key_id: u32_at(body, 8),
671        // body[12..28] is the 16-byte digest. Deliberately not read: see `Md5Auth`.
672    })
673}