DEV Community

pathvector-dev
pathvector-dev

Posted on • Originally published at blog.pathvector.dev

Established is not a status label — it's a write permission

Originally published at https://blog.pathvector.dev/protocol-in-code-bgp-11/ — part of the free Protocol Lab series.

This post is part of Protocol in Code, a free series that reads network protocols not as configuration examples but as logic — inputs, state, and branches. Every session points at a small Python file and asks you to read it the way you'd read any other program. The source lives at github.com/pathvector-studio/protocol-in-code.

Note: If you're earlier in the journey and want hands-on packet-level exercises before diving into implementation logic, start with the Protocol Lab series instead. This series assumes you're comfortable reading Python and want to know why the branch is where it is.

The question

Here's the one to keep turning over as you read:

What has to be true before the control plane should accept an UPDATE from a neighbor?

That question sounds like it should have a long answer. Route validity, attribute well-formedness, policy, next-hop reachability, max-prefix limits — a real BGP implementation checks all of it. But before any of that, there's a gate so simple it's easy to skip past entirely, and it's the one this session isolates.

The previous session built an integrated pipeline: an UPDATE arrives, gets stored, gets compared, gets selected. It worked. But it quietly assumed something — that the route arriving at the front of the pipeline had already earned the right to be there. This session makes that assumption explicit and gives it a name.

Read the file in three moves

The file is src/protocol_in_code/bgp/peer_state.py, with src/protocol_in_code/bgp/session.py as its companion. It's short enough that you could read it top to bottom in thirty seconds and learn nothing. Read it in this order instead.

1. What a peer actually is

@dataclass(frozen=True)
class PeerSession:
    peer_id: str
    config: BGPSessionConfig
    state: SessionState


def open_peer_session(peer_id: str, config: BGPSessionConfig) -> PeerSession:
    return PeerSession(peer_id=peer_id, config=config, state=establish_neighbor(config))
Enter fullscreen mode Exit fullscreen mode

open_peer_session() does almost nothing. It takes an identifier and a config, calls establish_neighbor() from session.py, and staples the resulting state onto a frozen dataclass. That's the whole constructor.

The thing worth noticing is what it doesn't do. It doesn't retry. It doesn't leave the state mutable so a later step can nudge a peer into Established because the operator wants the route. The dataclass is frozen=True: whatever establish_neighbor() decided, that's the peer's state for the lifetime of this object. The session outcome is an input to everything downstream, not something downstream code negotiates with.

So PeerSession isn't really a connection object. It's a decision, packaged.

2. The gate, stated as one question

def session_accepts_updates(peer: PeerSession) -> bool:
    return peer.state is SessionState.ESTABLISHED
Enter fullscreen mode Exit fullscreen mode

One line. One comparison. No or, no fallback, no "well, OpenConfirm is close enough."

The function name is doing real work here — it could have been called is_established(), which would describe the state. Instead it's named for the consequence: does this session accept updates? That naming is the whole point of the session. Established isn't a label you read off a show bgp summary output to feel good about. It's the answer to a permission question that gets asked on every inbound UPDATE.

Note the is rather than ==. SessionState is an enum, and identity comparison means there's exactly one object that satisfies this check. There's no clever coercion, no truthy near-miss. Either the peer holds that exact enum member or it doesn't.

3. Where the if sits

def receive_update_if_established(
    adj_rib_in: AdjRIBIn,
    peer: PeerSession,
    prefix: str,
    attributes: PathAttributes,
) -> bool:
    if not session_accepts_updates(peer):
        return False

    store_received_path(adj_rib_in, peer.peer_id, prefix, attributes)
    return True
Enter fullscreen mode Exit fullscreen mode

This is the entire lesson, and it's four lines of body.

The important thing is not the amount of code. It is the placement of the if.

Look at what's on each side of the early return. Above it: nothing has happened. Below it: a write into Adj-RIB-In. There is no path through this function where a route lands in the RIB without first passing session_accepts_updates(). Not "gets stored and then filtered later." Not "gets stored with a flag." The write is structurally downstream of the check.

Compare that to the alternative you've probably seen in real codebases:

# NOT what this module does — the shape to avoid
store_received_path(adj_rib_in, peer.peer_id, prefix, attributes)
if not session_accepts_updates(peer):
    mark_as_pending(adj_rib_in, peer.peer_id, prefix)  # ...
Enter fullscreen mode Exit fullscreen mode

Same information, same check, completely different guarantee. In that version, Adj-RIB-In contains routes from peers that never reached Established, and correctness depends on every reader downstream remembering to filter. In the version the module actually implements, Adj-RIB-In has an invariant: everything in it came from an established session. Downstream code doesn't have to re-check, because the shape of the function made the bad state unrepresentable.

That's why the return type is bool and not None. The caller gets told whether the write happened. A silent no-op would be a worse API — the caller couldn't distinguish "stored" from "dropped at the gate."

Note: The store_received_path() call takes peer.peer_id as the key, not the prefix alone. Adj-RIB-In is per-peer by construction — which is exactly why the gate can be per-peer too. If the RIB were flat, the gate would have to live somewhere messier.

Same shape, different protocol

This gate is worth recognizing because you've already met it under other names.

A TLS session that hasn't completed the handshake can carry bytes — the TCP connection is up, the socket is writable — but application data sent before Finished isn't protected by the negotiated keys, and a correct implementation refuses to hand it to the application. Same shape: transport reachability is not the same thing as protocol readiness, and the code has to enforce the difference with a branch, not a comment.

Or take conntrack: a packet matching an existing flow tuple gets fast-pathed, but only if the state machine says the flow is ESTABLISHED. A packet arriving for a SYN_SENT entry takes a different branch entirely. Again: the state isn't decoration, it's the gate on which code path runs.

In all three cases the mistake looks identical — treating "the pipe is open" as "the protocol is ready." BGP is just the clearest place to see it, because the state has a name printed in every operator's terminal, and it's easy to read that name as a status indicator rather than as a precondition.

Toy model boundary

This is where the series stays honest with you.

This lesson isolates only the session-state gate. Established is the first gate in this toy model — it is not the only gate a real implementation uses, and reading it as such will mislead you.

Specifically, what's missing:

  • Address-family activation. A real BGP session negotiates which AFI/SAFI pairs are active. A peer can be perfectly Established for IPv4 unicast and still have no business sending you IPv6 or VPNv4 routes. The toy model has one implicit address family and no per-family gate.
  • Negotiated capability checks. Multiprotocol extensions, route refresh, add-path, 4-byte ASN handling — all negotiated during OPEN, all things that change what an UPDATE is even allowed to contain. None of that exists here.
  • The rest of the state machine. establish_neighbor() collapses what RFC 4271 models as Idle → Connect → Active → OpenSent → OpenConfirm → Established into a single call with a single outcome. There are no hold timers, no keepalives, no transitions out of Established. In a real implementation, a peer can be established at the moment an UPDATE arrives and gone by the time it's processed.
  • Inbound policy. Passing the session gate gets a route into Adj-RIB-In. In a real router, inbound route-maps, prefix lists, and max-prefix limits all sit between the wire and that store.

The reason the toy model cuts all of it is that each of those gates has the same structural shape as the one you just read — a boolean question placed before a write. Once you can see the shape clearly in four lines, the real implementation's dozen gates read as variations rather than as new material.

Run it

The walkthrough is runnable:

PYTHONPATH=src python3 examples/bgp/session_11_walkthrough.py
Enter fullscreen mode Exit fullscreen mode

Three things to watch for in the output:

  1. One peer whose TCP reachability never gets it to Established. The transport is fine. The session isn't. Watch what session_accepts_updates() returns for it.
  2. One peer whose UPDATE is accepted. Trace it through receive_update_if_established() and confirm it takes the path past the early return.
  3. Only the established peer appearing in Adj-RIB-In. This is the invariant made visible. The rejected peer left no trace — no partial entry, no pending flag, nothing to clean up later.

That third point is the one to sit with. The absence in the RIB is the whole result.

Check yourself

Answer these by reading the source, not by reasoning from what you know about BGP. If you have to guess, go back to the file.

  • A peer's TCP connection is established and packets are flowing, but session_accepts_updates() returns False. Where exactly in peer_state.py does an inbound route get stopped, and what is left behind in Adj-RIB-In afterward?
  • Suppose you moved the store_received_path() call above the if and returned False afterward anyway. What invariant about Adj-RIB-In would break, and which downstream reader would notice first?
  • PeerSession is frozen=True. If a peer's session dropped out of Established after open_peer_session() returned, what in this file would detect it — and what does your answer tell you about which parts of the state machine this toy model doesn't have?

You're done with this session when you can explain why Established is a write permission rather than a status label, and point at the exact if that keeps a route out of Adj-RIB-In.

Further reading

Top comments (0)