An OSPF Hello has to match before it can mean anything

This post is part of Protocol in Code, a free series that reads network protocols as logic — inputs, state, branches — rather than as configuration examples. The full source lives at github.com/pathvector-studio/protocol-in-code, and every module is backed by a small Python file you can read end to end in a sitting. If you're earlier in the journey and want hands-on packet-level exercises first, start with the companion Protocol Lab series and come back here.

The question

What has to match before an OSPF Hello can even start a neighbor relationship?

That's the whole module. Not "how does SPF work," not "what's in an LSA" — just the gate that runs before any of that is on the table. Most OSPF material introduces the Hello packet as a keepalive: routers shout on the wire every 10 seconds, and if you stop hearing one for 40 seconds you tear the neighbor down. That's true, and it's also the least interesting thing about Hello.

The interesting thing is that Hello is a filter. Before OSPF will consider two routers to be on speaking terms, a set of fields in the received packet has to agree with the local interface's configuration. If they don't, nothing happens — no adjacency, no database exchange, no error to the sender, usually no log line you'll notice. The relationship simply never begins.

If you've ever stared at two routers that are unmistakably on the same broadcast segment, both configured for OSPF, both sending Hellos, and neither one forming an adjacency — you've been on the wrong side of this filter. The code below is where that lives.

The source

The file this module reads is src/protocol_in_code/ospf/hello.py. It's short enough that you can hold all of it in your head, which is the point. Read it in this order:

  1. InterfaceHelloConfig — what the local interface believes about itself
  2. OSPFHelloPacket — what arrived on the wire
  3. evaluate_hello() — top to bottom, no skipping

Two sides of a comparison

Start with the two dataclasses, because the shape of the check is already visible in their fields.

@dataclass(frozen=True)
class InterfaceHelloConfig:
    local_router_id: str
    area_id: str
    netmask: str
    hello_interval: int
    dead_interval: int

That's the local side: five values, all of them things you configured (directly or by inheritance from an interface or process). Note what is not here — no neighbor list, no state, no timers. This is a config snapshot, frozen.

@dataclass(frozen=True)
class OSPFHelloPacket:
    source_router_id: str
    area_id: str
    netmask: str
    hello_interval: int
    dead_interval: int
    priority: int
    designated_router: str | None
    backup_designated_router: str | None
    neighbors: tuple[str, ...] = ()

The packet side is a superset. Four fields — area_id, netmask, hello_interval, dead_interval — line up name-for-name with the config. That alignment is not cosmetic; it's the check, written out in the type layout before any function runs. When two structs share a field name in a protocol implementation, ask what compares them.

The remaining fields (priority, designated_router, backup_designated_router, neighbors) are the ones OSPF uses after the gate opens — DR election and neighbor-state tracking. This module deliberately doesn't own those. It only touches neighbors, and only for one specific reason, which we'll get to.

The gate, one branch at a time

Here's evaluate_hello() in full. It is worth reading as a single continuous piece of logic rather than five separate rules:

def evaluate_hello(
    config: InterfaceHelloConfig,
    packet: OSPFHelloPacket,
) -> HelloCheckResult:
    reasons: list[str] = []

    if packet.source_router_id == config.local_router_id:
        reasons.append("router_id_loop")
    if packet.area_id != config.area_id:
        reasons.append("area_mismatch")
    if packet.netmask != config.netmask:
        reasons.append("netmask_mismatch")
    if packet.hello_interval != config.hello_interval:
        reasons.append("hello_interval_mismatch")
    if packet.dead_interval != config.dead_interval:
        reasons.append("dead_interval_mismatch")

    if reasons:
        return HelloCheckResult(
            accepted=False,
            saw_self=False,
            reasons=tuple(reasons),
        )

    saw_self = config.local_router_id in packet.neighbors
    return HelloCheckResult(
        accepted=True,
        saw_self=saw_self,
        reasons=(),
    )

Three structural facts jump out.

First: the checks accumulate rather than short-circuit. Each if appends to reasons and falls through to the next. A packet with both a wrong area and a wrong netmask comes back with both strings, not just the first one. This is a deliberate choice about diagnostics — when you're debugging a non-forming adjacency, "area_mismatch" alone might send you chasing one problem while a second one waits behind it. Real implementations vary here; many bail on the first failure and log a single reason. The accumulating form is more honest about the fact that misconfiguration is rarely singular.

Second: the first check isn't a mismatch at all.

if packet.source_router_id == config.local_router_id:
    reasons.append("router_id_loop")

Every other branch is !=. This one is ==. If the Hello claims to come from your own router ID, something is wrong — either you're seeing your own packet reflected back (a bridging loop, a misconfigured switch, a hairpin), or someone else on the segment is configured with the same router ID. Both are pathological, and neither should produce a neighbor. The reason string, router_id_loop, names the more common cause.

Note: OSPF router IDs are dotted-quad-shaped but are not addresses. They're identifiers, and nothing on the wire enforces uniqueness. Duplicate router IDs are a configuration error that the protocol can only detect by symptom — and this branch is one of the symptoms.

Third: saw_self is computed only after acceptance.

if reasons:
    return HelloCheckResult(
        accepted=False,
        saw_self=False,
        reasons=tuple(reasons),
    )

saw_self = config.local_router_id in packet.neighbors

The early return hardcodes saw_self=False. Not "unknown," not NoneFalse. That's a statement about meaning: if the packet failed the gate, the question "did they list me as a neighbor?" isn't merely unanswered, it's not askable. A packet from the wrong area doesn't get to tell you anything about your adjacency state, even if your router ID happens to appear in its neighbor list.

This is the sort of thing that reads as a small implementation detail and is actually a security and correctness boundary. Untrusted input doesn't get to influence state until it's been validated as belonging on this link.

Why saw_self is the interesting output

accepted is the obvious result. saw_self is the one that matters more.

OSPF's adjacency state machine walks through Down → Init → 2-Way → ExStart → Exchange → Loading → Full. The transition from Init to 2-Way is precisely this: I have received a Hello from a neighbor, and that Hello contains my own router ID in its neighbor list. That's the moment bidirectional reachability is confirmed. Until then, you know they can reach you (you got their packet), but you don't know that you can reach them.

So evaluate_hello() produces two facts and stops:

  • accepted — this packet belongs on this link
  • saw_self — bidirectional communication is confirmed

Everything downstream (DR election, database description exchange, LSA flooding) is fed by those two bits and doesn't appear in this module at all. The function's job is to turn a packet into an input for a state machine it doesn't own.

Same shape, different protocol: the "I only trust this relationship once I've seen my own identity reflected back" pattern is not OSPF-specific. It's the same shape as the TCP three-way handshake — SYN proves they can reach you, SYN-ACK carrying your sequence number proves you can reach them. BGP does it too, with the OPEN/KEEPALIVE exchange before the session is Established. Once you've seen the shape in one protocol, the others stop looking like arbitrary ceremony.

Run it

The module ships a walkthrough:

PYTHONPATH=src python3 examples/ospf/session_01_walkthrough.py

You're looking for two things in the output: one accepted Hello and one rejected Hello. Read the reasons tuple on the rejected one and trace it back to the specific if that produced it.

The genuinely useful exercise is to edit the walkthrough's inputs. Flip dead_interval and confirm you get dead_interval_mismatch. Then flip area_id as well and confirm you get both strings in the tuple, in source order — that accumulate-don't-short-circuit behavior is easy to nod along to and easy to get wrong when you predict it. Then set source_router_id equal to local_router_id while everything else matches, and watch a packet that agrees on every configured field still get rejected.

What this toy model leaves out

This file models the first teaching gate and nothing else. Being specific about the gap is the point:

  • No packet parsing. OSPFHelloPacket arrives as a Python dataclass with typed fields. Real OSPF Hellos are bytes inside an OSPF header inside an IP packet with protocol number 89. Header version, packet length, checksum, and authentication are all validated before anything in this file would run, and each of those is its own rejection path.
  • No timers. hello_interval and dead_interval are compared as integers and never used as durations. The actual protocol behavior — send a Hello every hello_interval, declare the neighbor down after dead_interval of silence — requires a clock and a scheduler, neither of which exists here. dead_interval in this model is purely a value that must agree.
  • No adjacency state machine. The module stops at accepted and saw_self. There is no Down, no Init, no 2-Way. The function computes an input; something else consumes it.
  • No DR/BDR election. priority, designated_router, and backup_designated_router are carried on the packet and never read by evaluate_hello(). They're present so the dataclass is honest about what a Hello contains, not because this module uses them.
  • An incomplete match list. Real OSPF also requires agreement on the area type (stub/NSSA options bits in the E-bit and N-bit), authentication type and credentials, and MTU during the subsequent Database Description exchange. The netmask check itself doesn't apply on point-to-point links. Five fields is the teaching set, not the specification.
  • No wire behavior on rejection. In this model a rejected Hello returns a result object. On a real link it is silently discarded, sometimes with a counter increment. The sender learns nothing.

None of these omissions change the answer to the core question. They change how much of the real failure surface you've seen.

Check yourself

Close this page and answer these from the source file alone:

  1. A packet arrives with a matching area, netmask, and both intervals — and source_router_id equal to your local_router_id. Does the function ever look at packet.neighbors? Trace the exact path through evaluate_hello() that decides this.
  2. Two routers on the same segment disagree only on hello_interval. Which one detects the problem, and what does the other one learn about it? What does the code say about who gets told?
  3. Why is saw_self hardcoded to False in the rejection branch rather than computed and returned alongside the reasons? Construct the concrete case where computing it anyway would be actively wrong.

Don't take my word for any of it — the file is under 60 lines.

You'll know you've got this module when you can name, without looking, the fields that must match before adjacency can continue, and explain why seeing your own router ID in a neighbor's list changes the next input to the adjacency state machine.

Further reading

  • RFC 2328 — OSPF Version 2. Section 9.5 covers sending Hello packets; Section 10.5 covers receiving them and is the direct analogue of evaluate_hello().
  • RFC 5340 — OSPF for IPv6, for how the same gate is expressed when the address family changes.
  • Source file: src/protocol_in_code/ospf/hello.py

Read more