DEV Community

pathvector-dev
pathvector-dev

Posted on • Originally published at blog.pathvector.dev

What a BGP Neighbor Needs — Reading Session Setup as Code

Originally published at https://blog.pathvector.dev/protocol-in-code-bgp-01/ — 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 you can step through. The full course and every source file it reads live in the repo: github.com/pathvector-studio/protocol-in-code. If you're newer to this and want to build the muscle by doing rather than reading, start with the hands-on companion series, Protocol Lab, then come back here.

Here's the question to keep turning over while you read:

What inputs are required to form a BGP neighbor, and where does the session stop when one of them is missing?

That second half is the interesting part. Most people can recite that BGP needs a peer IP and an AS number. Far fewer can tell you which state a session lands in when TCP is fine but the OPEN negotiation fails — versus when TCP itself never came up. Those are different failures, and they stop the machine in different places. The only way to know the difference cold is to read the branches.

The inputs: what even goes into a session

Before you can reason about failure, you need to know the full surface of inputs. In the toy model that's a single dataclass, and reading it is the fastest way to see what a BGP session actually depends on:

@dataclass(frozen=True)
class BGPSessionConfig:
    peer_ip: str
    peer_as: int
    local_as: int
    tcp_reachable: bool
    hold_time: int = 180
    keepalive_time: int = 60
    capabilities: tuple[BGPCapability, ...] = field(default_factory=tuple)
    open_message_ok: bool = True
    keepalive_received: bool = True
Enter fullscreen mode Exit fullscreen mode

Read that as a checklist of everything a neighbor needs, and why:

  • peer_ip — without a destination, TCP can't even start. No IP, no dial tone.
  • peer_as and local_as — you need a remote identity to validate against and a local identity to announce in your OPEN message. A BGP speaker introduces itself by AS number.
  • tcp_reachable — this is the one people skip past. BGP is an application riding on top of TCP. Before a single BGP message is exchanged, a TCP connection has to succeed.
  • hold_time / keepalive_time — a session isn't just created, it's maintained under timer rules.
  • capabilities — the two peers have to agree on what they're even able to talk about.
  • open_message_ok — OPEN negotiation can fail on its own terms, even when TCP is perfectly healthy.
  • keepalive_received — the session isn't fully established until a KEEPALIVE has actually come back.

Notice that peer_ip alone is nowhere near enough. It gets you a destination and nothing else. Everything after it is another gate the session has to pass through.

The states it can stop at

The second thing to read is the enum. It's the vocabulary the code uses for "how far did we get":

class SessionState(str, Enum):
    IDLE = "Idle"
    CONNECT = "Connect"
    ACTIVE = "Active"
    OPEN_SENT = "OpenSent"
    OPEN_CONFIRM = "OpenConfirm"
    ESTABLISHED = "Established"
Enter fullscreen mode Exit fullscreen mode

The canonical happy path runs straight through these:

Idle -> Connect -> Active -> OpenSent -> OpenConfirm -> Established
Enter fullscreen mode Exit fullscreen mode

But the enum isn't just a list of milestones you pass through on success. Each of these is also a place a failed session can come to rest. That's the mental shift this session is asking for. Established is not a config line you set — it's the label you earn only after every check upstream has passed.

Reading the machine

Now the main event. The whole state machine is one function, and it's worth reading top to bottom as a sequence of early returns — because each early return is a different way the session can stall:

def establish_neighbor(config: BGPSessionConfig) -> SessionState:
    """Walk a minimal BGP session state machine."""
    state = SessionState.IDLE

    if not config.peer_ip:
        return state

    state = SessionState.CONNECT
    if not config.tcp_reachable:
        return SessionState.ACTIVE

    state = SessionState.OPEN_SENT
    if config.peer_as <= 0 or config.local_as <= 0:
        return state
    if not config.open_message_ok:
        return state

    state = SessionState.OPEN_CONFIRM
    if not config.keepalive_received:
        return state

    return SessionState.ESTABLISHED
Enter fullscreen mode Exit fullscreen mode

Walk the gates in order:

Leaving Idle. We start at IDLE. The very first check is if not config.peer_ip. No peer IP means we never even try to dial, so we return Idle unchanged. This is the "you haven't given me anywhere to go" failure.

Connect, and the fallback to Active. Once we have an IP we move to CONNECT — meaning "attempting the TCP connection." Then:

    state = SessionState.CONNECT
    if not config.tcp_reachable:
        return SessionState.ACTIVE
Enter fullscreen mode Exit fullscreen mode

If TCP isn't reachable, we don't sit in Connect — we fall back to Active. This is the detail that trips people up. In real BGP, Active doesn't mean "up and doing things"; it means the speaker tried to connect, failed, and is now waiting to retry. It's arguably the most badly named state in all of networking. The code makes the semantics unambiguous: reaching Active here means TCP did not come up.

OpenSent, and the two ways to be stuck there. If TCP succeeded, we advance to OPEN_SENT — the OPEN message is on the wire and we're waiting on the peer. Two separate checks can pin us here:

    state = SessionState.OPEN_SENT
    if config.peer_as <= 0 or config.local_as <= 0:
        return state
    if not config.open_message_ok:
        return state
Enter fullscreen mode Exit fullscreen mode

Invalid AS numbers (either side ≤ 0) stop us in OpenSent. So does a failed OPEN negotiation. Both return the same state, but they're distinct causes — one is "your identities don't validate," the other is "the OPEN exchange itself was rejected." The key takeaway: an OPEN failure and a TCP failure stop the session in different placesOpenSent versus Active. If someone hands you a stuck session and says "it's in OpenSent," you already know TCP is fine and the problem is up in the BGP layer.

OpenConfirm, waiting on KEEPALIVE. Pass the OPEN checks and we move to OPEN_CONFIRM, which means "OPEN was accepted, now I need to hear a KEEPALIVE back":

    state = SessionState.OPEN_CONFIRM
    if not config.keepalive_received:
        return state
Enter fullscreen mode Exit fullscreen mode

No KEEPALIVE, and we rest at OpenConfirm. Only when that final gate passes do we return Established.

That last line is the whole point of the session. Established isn't a setting — it's the result of clearing every check above it, in order.

Run it and watch the states

The reading is the work, but the walkthrough lets you confirm your mental model against real output. It runs several BGPSessionConfig scenarios and prints which state each one reaches:

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

Before you run it, try to predict each scenario's landing state from the source alone. If your prediction and the printout disagree, that gap is exactly the thing to go re-read.

Toy Model Boundary

This is a deliberately minimal model, and being honest about what it isn't is the point.

The dataclass exposes hold_time, keepalive_time, and capabilities because they're part of a realistic BGP session's surface — you should know they exist. But establish_neighbor() does not actually negotiate or enforce them yet. In this session, the branches that really fire depend only on peer_ip, tcp_reachable, valid AS numbers, open_message_ok, and keepalive_received. The timers and capabilities are along for the ride as structure, not behavior.

A few more things the real protocol does that this model collapses:

  • Timing is boolean here. tcp_reachable, open_message_ok, and keepalive_received are pre-decided flags. Real BGP discovers each of these over time, with retries, backoff, and the ConnectRetry, Hold, and Keepalive timers governing the pace. Here, Active is a terminal return value; in the real FSM it's a state you loop back out of on the next connection attempt.
  • No collision detection, no passive/active roles. Real speakers can both initiate, detect the collision, and tear one connection down. This model has a single directional walk.
  • OPEN is a boolean, not a negotiation. open_message_ok hides the actual content — version, AS, hold time, BGP Identifier, and the capability optional parameters that capabilities is standing in for. Capability mismatch handling doesn't exist here.

None of that makes the model wrong — it makes it a lens. The FSM's shape (each input is a gate; each failure has a resting state) is exactly right, and that shape is what carries over to the real thing.

The same shape, elsewhere

The move this session trains — stop asking "did the config look correct?" and start asking "which field was missing, which condition failed, which state was the last successful one?" — is not specific to BGP. It's the reading lens for every stateful protocol handshake. A TLS handshake stalling at a specific message, a TCP connection stuck in SYN_SENT versus SYN_RECEIVED: same idea. The state you're stuck in is the diagnosis, because it tells you which gate you cleared and which one you didn't. Learn to read one FSM this way and you can read all of them.

Self-check: can you answer these from the source alone?

Don't run anything for these — answer them by reading establish_neighbor(), then verify. The point is to prove the state machine lives in your head, not your notes:

  1. If peer_ip is empty, what state comes back — and why is it that one rather than Connect?
  2. If TCP is unreachable, why does the session land in Active instead of simply staying in Connect?
  3. Invalid AS numbers and a failed OPEN both return the same state — which one, and how would you tell those two failures apart if all you had was the returned state?

If you can answer all three cold, you've got the session: BGP doesn't start at OPEN — it starts after TCP reachability, OPEN failure and TCP failure stop in different places, and Established is the result of passing several checks, not a single setting.

Further reading

  • RFC 4271 §1.1 — BGP overview and the role of TCP
  • RFC 4271 §3 — session summary and the peering model
  • RFC 4271 §4.2 — the OPEN message format and its fields

The source file for this session is src/protocol_in_code/bgp/session.py, and the runnable walkthrough is examples/bgp/session_01_walkthrough.py. Read the code, then make it lie to you — hand it a broken config and see if you called the resting state before Python did.

Top comments (0)