| Concept | Description |
|---|---|
| Style | JUNOS-like: {} blocks, ; terminators, # comments |
| Top-Level | environment, plugin, bgp, ospf |
| BGP Block | bgp { group <name> { peer <ip> { ... } } } - wraps all BGP config |
| OSPF Block | ospf { areas { area <id> { ... } } interfaces { interface <name> { area <id> } } } - native OSPFv2 config root |
| Inheritance | 3 levels: bgp globals, group defaults, peer overrides (deep-merge for containers) |
| Schema | YANG-driven: parser dispatches by node type (leaf, container, list, leaf-list) |
| Migration | ze config migrate converts ExaBGP syntax to ze-native |
When to read full doc: Config keywords, parsing bugs, new config sections.
Purpose: Document complete ze configuration file syntax
Ze configuration uses a JUNOS-like hierarchy of sections, keywords, and values. A value statement can use an explicit semicolon. The tokenizer automatically inserts a semicolon after a value-ending token at a newline, at end of file (EOF), or before }.
A [ opens a list, except straight after a : inside a word, where it
brackets an address and stays in the word through its ]. That is how the
IPv6 route-target redirect is written, extended-community [redirect:[2001:db8::1]:100], the same form ze prints for a received one. A
[ glued to any other character, such as 0x48[64,24], still opens a list.
The parser is YANG-driven: each config node's type (leaf, leaf-list, container, list) determines how it is parsed. No custom ze:syntax annotations are used in ze-native config.
A keyword ze once accepted and no longer does parses as an unknown field, and
the field name alone does not tell an operator what to write instead.
retiredKeywords maps each one to its replacement, and every unknown-field
refusal in the hierarchical parser, the set parser and the metadata parser
carries that replacement in the message.
The parser accepts ONE spelling, so this table is the message and never a second
grammar. Ze rewrites no file: the operator edits the keyword. Two keywords are
retired today, process (write attach process) and certificate-fingerprint
(write ca <pki-ca-name>, naming the certificate authority root exported from
the hub with ze show pki local-ca pem).
Every unknown-key refusal, from the hierarchical parser at the root, in a
block, in a list entry or at an editor context, and from the set parser, names
the closest key valid at that position: unknown field in bgp: router-idd (did you mean router-id?) (line 2). A retired keyword gets its replacement instead,
and a name close to no valid key gets no hint. The distance is the one the CLI
uses for a mistyped command, suggest.Command.
A block that moved out of a container to become a top-level root gets the
command that rewrites it instead. bgp { policy { ... } } is the one such block
today: the policy component registers the move from init() with
config.MustRegisterRelocatedContainer("bgp", "policy"), the schema marks the
old position, and both parsers answer policy is no longer inside bgp: it is now the top-level policy { ... } block, then fix: ze config migrate -o <new-file> <file>. No alias accepts the old position.
ze config migrate parses the file with config.MigrationSchema(), which
grafts each relocated root back under its old parent, and its last
transformation, relocated-blocks, moves the block to the top level and merges
it with any top-level block already there. A list entry written at both
positions is refused with both locations named, because no merge can choose
between them.
A leaf a schema change retired is refused the same way. The package that owns
the rewrite registers the leaf with config.MustRegisterRetiredLeaf(path, name, node, replacement), both parsers answer <name> is no longer a leaf of <path>: write <replacement> instead, then the ze config migrate fix, and
MigrationSchema grafts the old leaf back so the migration can read it. The
redistribute import family and tag leaves are the two retired leaves today.
A feature package adds its own transformation with
migration.MustRegisterTransformation, so migrate.go never names it. The
registered ones run after the built-in ones, in registration order, and
ze config migrate --list shows them with the rest.
An enumeration refusal includes its original path, the values it accepts, and the leaf statement to write. A bracket list where the schema expects a container names that path and renders the container's immediate fields instead, including enum choices. Hierarchical, set and set-meta parsing derive these corrections from schema nodes rather than a separate list of feature-specific hints.
keyword value;
A value is a bare word or a quoted string. A bare word ends at whitespace, a
brace, a bracket, ;, a quote or #, and a word that starts with ( or )
is read as that parenthesis instead (endsWord, scan, tokenizer.go). A [
straight after a : stays in the word through its ], as in
redirect:[2001:db8::1]:100. A double-quoted string decodes \", \\, \n
and \t (and \'); a single-quoted one decodes the same escapes. A quote
with no closing quote before the end of the file is a syntax error naming the
line it opens on: no string runs to the end of the file (readString,
refuseUnterminated in parser.go).
Every printer (ze config fmt, show, the set writers the editor stores)
writes a value bare only when the tokenizer reads that bare word back as the
same value, and quotes it otherwise (QuoteIfNeeded, list_key.go). The
decision asks the tokenizer itself (bareWordReadsBack), so the quoting rule
cannot drift from what ends a word. x]y(z, a[b, a value holding a newline or
a carriage return, and (open are therefore written quoted.
A leaf-list accepts either a single value or a bracket list:
community 65001:100; # single value
community [ 65001:100 65001:200 65001:300 ]; # bracket list
as-path [ 65001 65002 65003 ]; # bracket list
Both forms are equivalent for a single item: community 65001:100; and community [ 65001:100 ]; produce the same result.
A container written as two blocks merges into one, and a leaf-list both
blocks set keeps the members of both, as repeated statements in one block do
(RFC 7950 Section 7.7). A set keeps one copy of each member. An ordered
leaf-list (ze:ordered) keeps every copy, in order. A member deactivated in
either block stays deactivated:
filter { import { filters [ a b ]; inactive: filters b; final accept; } }
filter { import { filters [ b c ]; final accept; } } # filters is [ a b c ], b inactive
An ordered leaf-list whose merged members repeat a deactivated member is refused, because the deactivation marks a value and cannot say which copy it means. The error names the leaf, the member and the fix:
seq: deactivated member "b" is listed 2 times once the repeated blocks are merged;
seq is an ordered list that keeps repeated members, so ze cannot tell which copy
is deactivated. Fix: write the seq list once, in one block, or remove the line
"inactive: seq b;"
keyword {
child1 value1;
child2 value2;
}
A container with one leaf may print inline when that leaf has a one-line representation. Repeated Flex values keep the container's block, because each value needs its own statement. Value-or-array leaves retain all their members. Comments or deactivation markers inside the container also keep the block.
A Flex node accepts a value statement, a container or a named list entry. Repeated value statements in a file keep their written order and duplicates. Hierarchical parsing records each statement; plain set replay appends each value line rather than replacing the previous line. Metadata-bearing lines with the same author, source and session-start time retain that sequence too. Different identities describe competing assignments of the whole leaf, not simultaneous occurrences: the tree holds the last assignment and metadata keeps each owner's value. A metadata printer writes that contested set once, so reloading or adopting a session cannot multiply its records.
Every printer quotes and escapes each occurrence as one value when needed. An empty value, a hash, a quote, an embedded newline or a multi-word value therefore reads back unchanged; quoting never removes duplicates or reorders the sequence. The comparison view uses the same token codec, so an escaped newline remains one displayed statement and validation highlights map to its working line.
An interactive editor set replaces the whole Flex value, removing every
previous occurrence while preserving the whole-leaf inactive state and
comments. It is an assignment, not file replay.
keyword key1 {
child1 value1;
}
keyword key2 {
child1 value2;
}
A list entry can also be written on one line, with the key first and the child values after it. The values are assigned positionally in YANG definition order, and the LAST child absorbs every remaining token:
nlri {
ipv4/unicast add 10.0.0.0/24
}
What the last child RECEIVES depends on its node kind, and the schema decides it rather than the brackets an author happened to write.
| Last child | keyword key [ a b ]; stores |
|---|---|
A leaf-list (ze:syntax "bracket", ze:syntax "value-or-array", or a plain YANG leaf-list) |
a and b as two MEMBERS, through the same store the block form uses |
| Any other leaf (a leaf, a multi-word leaf, a flex value, a presence container's value) | the single string "[ a b ]", joined with any other tokens on the line |
| A container, a list, an inline list or a freeform block | nothing: the line is refused, naming the child and the block form to write instead |
So nth 2 [ 3491 ] and nth 2 { asn [ 3491 ]; } produce one tree, and the NLRI
entry above is untouched, because its last child is a plain leaf. A leaf-list
before the last child takes its one value as a member the same way, and a
value that falls on a container or a list (peer A "x" gives x to the
peer's first child, the attach container) is refused, because no printer
could write it back (refuseInlineSlot).
The serializer writes the one-line form back for a list whose schema has exactly
ONE child and that child is a leaf-list. Every other list keeps the block form,
because the one-line form drops the child NAME and a reader could not tell which
leaf the members landed in. A list with an entry whose key is one of its child
names is written one block per entry, never as name { key value }, which
reads back as an anonymous entry setting that child (entriesArePositional).
(*Tree).ToMap lowers the parsed tree to map[string]any for delivery, and the
shape it emits for a leaf-list or a list is not the one the node type suggests.
An in-process component receives that map unchanged. A plugin receives it
marshaled to JSON, which turns a []string into a []any.
| Node | Members | In process | After JSON |
|---|---|---|---|
| leaf | set | string, whatever the YANG type |
string, whatever the YANG type |
| leaf-list | none active | key absent | key absent |
| leaf-list | exactly one | bare string |
bare string |
| leaf-list | two or more | []string |
[]any |
| list | any count, one included | map[string]any keyed by the list key |
map[string]any keyed by the list key |
Three consequences decide how a reader is written. A plain leaf is a STRING at
both ends, whatever its YANG type says. Tree.values is a map[string]string
and the lowering copies it through, so a boolean leaf arrives as "true" and
a uint16 leaf as "30". A leaf-list is not always a slice, so asserting one
drops the value at exactly one member. A list is never a slice at any count.
Its key leaf is the map key rather than a field inside the entry, so asserting a
slice drops it at every count.
A list whose YANG key names several leaves (key "peer vrf interface", the
BFD single-hop-session list) is keyed by the entry's canonical ADDRESS, not
by its first word: the first key leaf's value, then name value for each other
key leaf in YANG key order whose value is not its YANG default. A key leaf
with no default is omitted only when absent. So the entries
single-hop-session fe80::b { interface zbl0; } and
single-hop-session fe80::b { interface zbl1; } are stored, and delivered to
the plugin, under fe80::b interface zbl0 and fe80::b interface zbl1, and
vrf default written or omitted is the same entry. The other key leaves stay
leaves of the entry as written. A key value of such a list that is empty or
holds whitespace is refused at parse, because it would make the address
ambiguous, and two entries with one address are refused as a duplicate list
key. Brace form writes the first value alone on the opening line, because the
body carries the other key leaves. Set form writes the whole address on every
line of the entry (set bfd single-hop-session fe80::b interface zbl1 profile fast), each token quoted on its own, plus one line holding the address alone,
and never a line that sets a key leaf the address carries. The set parser
(with and without meta) reads an address left to right: the first token is the
first key value, then a name value pair is consumed while the next token
names another key leaf of the list and a token follows it; a key leaf named
twice is refused. The CLI editor reads every path by the same rule, so
set bfd single-hop-session fe80::b interface zbl1 creates the entry on
zbl1 (with its interface leaf written), a leaf after the address lands on
that entry only, delete of a full address removes one entry, and a set-form
show of fe80::b matches the address as one segment and prints none of the
link entries' lines. A rename or copy changes only the first key value: the
other key leaves carry over, a destination that changes one is refused with
the delete and set lines that move the entry, and the session change file
writes each key as one quoted token. A rename or copy of an entry of a keyless
list, or of an entry under one, is refused. A list whose key names one leaf
keeps its key exactly as written.
An empty single-key entry is still configuration. For example,
interface { dummy zetrk1 {} } saves as set interface dummy zetrk1,
and parsing that line recreates the entry. Plain and metadata-bearing set
files preserve it without requiring a description or another placeholder
leaf. A nonempty single-key entry needs no extra address-only line: its
children already recreate it. An inactive empty entry uses nop instead
of set, preserving its inactive state. Annotated set displays use the same
entry lines with their metadata gutter.
The web editor addresses the entry by its address as path segments
(/show/bfd/single-hop-session/fe80::b/interface/zbl1/); its list table heads
the key column with the first key leaf and links each row to its own segments,
and its add form posts the other key leaves as fields that complete the
address. Neither the add form nor the rename form changes the case of a key.
gNMI names the keys (single-hop-session[peer=fe80::b][interface=zbl1]): a
path must name the first key leaf, [vrf=default] names the same entry as no
vrf, a response path names every key leaf from the schema (an absent one at
its default), and a Set of a key leaf is refused as naming it twice.
Read all three through internal/core/configvalue (Bool, Int, LeafList,
ListEntries) rather than asserting a type. The obligation is stated in the
configuration rule.
A .(bool) or .(float64) assertion on a delivered leaf never succeeds, and it
fails the way an absent value fails. The reader keeps its default and nothing is
logged, so the operator's setting is discarded in silence. Three shipped call
sites read that way, in fib/kernel and the L2TP pool. One of them delegated
every subscriber a /56 whatever delegation-length said. A hand-typed test
fixture hides it, because a fixture carrying "delegation-length":48 as a JSON
number is a payload the producer never emits.
The lowering is ONE WAY. A bare string is a plain leaf and a one-member
leaf-list alike, so no lowered map can be rebuilt into a Tree without the schema
that declared the node. A consumer that needs a Tree takes the PARSED one
(preparePKIConfig, cmd/ze/hub/main_pki.go), never a rebuild of the map.
A list is delivered as a map, and a map states no order, so the operator's ENTRY ORDER needs a second key to survive lowering. There are two lowerings.
| Lowering | Reads | Carries the order |
|---|---|---|
(*Tree).ToMap |
gNMI, ze config show, config validation, the CLI editor |
no |
(*Tree).ToPluginMap |
the map delivered to a plugin, at startup and at reload | yes |
ToPluginMap emits the order of every list of two or more entries as a sibling
key: the list name with an @ prefix, holding the entry keys in the operator's
order. A YANG node name never starts with @, so the sidecar can never collide
with a declared sibling. A list of one entry carries no sidecar.
ToMap carries no sidecar at any count, because its consumers are schema-bound:
gNMI would publish a path no YANG module declares, ze config show | json would
show the key to the operator, and whole-config validation would refuse it as an
unknown node.
A list declared ordered-by user MUST be read with configorder.Entries
(internal/core/configorder), which returns the entries in the delivered order
and refuses a list of two or more entries that arrives with none. Read an
unordered list with configvalue.ListEntries. The obligation is stated in the
configuration rule.
Every printer writes every list's entries in the order the tree recorded,
including plain, positional multi-block, Flex and InlineList entries. This
applies to hierarchical output (ze config fmt, ze config migrate format hierarchical, show), set and set-with-metadata files, annotated views and
blame. Entries absent from the record print afterward, sorted by key; removed
or repeated keys in the record do not print twice or lose another entry.
ListEntryKeys is the shared ordering producer.
This display rule does not change how consumers read a list. The schema still
carries ordered-by user as ListNode.UserOrdered, and the readers above keep
their contracts.
Order in a display is distinct from order in a comparison. Reordering a plain
list is no configuration change, including when the list is nested or uses
Flex or InlineList syntax. PruneUnchanged removes that order-only difference
without leaving empty ancestor blocks. Reordering a list declared ordered-by user is a change: pruning retains the list as order context, and show | compare marks moved entries. Comments alone are not changes; inactive state
and intentionally present empty containers still are.
Scalar comparisons use the printer's canonical spelling, so replaying the same
value from hierarchical, set or metadata-bearing set form does not invent a
change. Ephemeral nodes remain omitted; hidden persisted nodes still count.
The visual diff uses the same entry writers as the hierarchical printer, including grouped positional rows and inline leaf-list entries.
ze config diff keeps its resolved BGP value comparison and also compares
schema-declared user-ordered lists from the parsed configuration. It reports
their entry-key sequence under the existing @<list-name> metadata convention,
at the full configuration path (for example,
firewall/table/wan/chain/input/@term). Both text and JSON carry the old and
new sequences. A list's first appearance or disappearance is an addition or
removal; a membership or order change in an existing list is a changed sequence.
Firewall-only inputs need no BGP block. This order comparison does not add
general value comparison for other non-BGP configuration.
Any structural statement may be prefixed with inactive: to mark it
inactive. The node stays in the file and round-trips through save/load,
but is removed by PruneInactive before consumers see the tree, so the
runtime treats it as absent.
bgp {
inactive: router-id 10.0.0.1; # leaf -- value preserved verbatim
inactive: peer scratch { ... } # list entry -- whole subtree skipped
inactive: filter { ... } # container -- whole subtree skipped
filter {
import {
filters [ no-self-as reject-bogons ];
inactive: filters no-self-as; # single leaf-list member
final accept;
}
}
}
A single deactivated leaf-list member keeps its bare value in the
leaf line and is declared inactive by a follow-up inactive: <leaf> <member> statement (above: no-self-as stays listed, marked
inactive). Both kinds of leaf-list write it this way: a plain one
(name-server 8.8.8.8 1.1.1.1;) and a bracket one (address [ ... ];).
inactive: <leaf> <member>; with one bare value is the hierarchical
spelling of the set format's nop <leaf> <member> (below), on both kinds:
it deactivates the member, and a member the list does not hold yet is
added, deactivated. inactive: <leaf> [ ... ]; is the whole leaf, so a
whole inactive leaf-list is written in brackets even when it holds one
member.
The internal
inactive:<member> token never appears in serialized output -- it
would fail per-item type validation (e.g. ip-address) on reparse.
The CLI verbs ze config deactivate <file> <path> and ze config activate <file> <path> add and remove the prefix. The TUI accepts the
same deactivate <path> / activate <path> commands while editing.
The mechanism is engine-level: every YANG node is deactivatable, no
schema annotation is required.
In the set / single-line format, nop is a set line marked
inactive, the Junos way: the keyword replaces set on a deactivated
line, where the hierarchical format writes the inactive: prefix. So
nop <leaf-list> <member> adds the member deactivated, as the set
line would add it active. "nop" means "no operation": the config entry
exists in the file but produces no operational effect. Toggling
activation is a 3-byte in-place edit (set <-> nop):
set and delete in this format are the canonical CLI verbs in their engine
path form, and the parser takes their spelling from command.Verbs rather than
writing the words again, so renaming a verb renames the file keyword with it.
nop and inactive belong to this format alone and the verb registry holds
neither.
nop bgp router-id 10.0.0.1
For leaf-lists with per-member deactivation, a deactivated member is
written nop <path> <member>, for both kinds of leaf-list. A plain
leaf-list gives each member its own line: active members use set,
deactivated members use nop:
set system name-server 8.8.8.8
nop system name-server 1.1.1.1
A bracket leaf-list sets the whole list on one line, every member listed,
and follows it with one nop <path> <member> line per deactivated member.
A nop line that names one value is always one member: written before the
list line, or naming a value the list does not hold yet, it adds the member
deactivated, and a later list line that names the member keeps it
deactivated. A later list line still replaces the members, so it drops a
member it does not name. Only a nop line that names a list in brackets
(nop <path> [ ... ]) deactivates the whole leaf:
set interface ethernet eth0 unit 0 ipv4 address [ 192.0.2.1/24 192.0.2.3/30 ]
nop interface ethernet eth0 unit 0 ipv4 address 192.0.2.3/30
delete <path> <member> removes that one member, of a plain or a bracket
leaf-list alike; delete <path> removes the whole leaf.
Container deactivation uses a structural nop <container-path> line
before its children. Children retain their own set/nop state:
nop bgp
set bgp router-id 10.0.0.1
set bgp neighbor 192.0.2.1 peer-as 65001
Backward compatibility: the inactive <path> keyword from older
configs is still accepted by the parser, and so is inactive <path> <member> after a bracket leaf-list's list line, the spelling an older
release wrote for a deactivated bracket member. On save, inactive lines
are migrated to the nop form automatically. An older draft could also
spell a bracket list as one quoted value (address "192.0.2.1/24 192.0.2.3/30"): when that value fails as one member but each of its words
is a valid member, the parser reads the words as the members.
Coexists with per-feature disable semantics (e.g. a peer's
admin-state disable leaf), which are protocol-aware and operationally
distinct from "no such node". Use inactive: when you want the node
absent at apply time; use the per-feature disable when you want the
protocol to know about the node but treat it as administratively down.
A comment runs from # to the end of the line, anywhere whitespace may stand,
and is the only comment form. It changes no value. The parser keeps each one on
the statement it was written beside, and the hierarchical printer
(config.Serialize, so ze config fmt and ze config migrate format hierarchical) writes it back beside that statement, wherever the canonical
order moves the statement:
| Written | Belongs to | Printed |
|---|---|---|
| On its own line above a statement | That statement (leaf, container, list entry, inactive: line) |
On its own line directly above it, at its indent |
After a statement on its line, after {, after } |
That statement, its opening line, its closing brace | After the same line, one space before # |
| On its own lines after a block's last statement | The block | Inside the block, last before }; at the end of the file for the root |
| A group before a block's first statement, a blank line after it | The block's head | First in the block, then one blank line |
After [ in a bracket leaf-list |
The leaf | Above the leaf |
| Inside a freeform, flex or inline-list body, or a container written without braces | The enclosing statement | Above that statement |
A statement written twice (a leaf set twice, two blocks of one container) keeps the comments of both. The last same-line comment stays on the line, and the earlier ones print above the statement.
A container whose inside carries a comment, or whose entries carry one when it
is ze:flatten, prints as a block rather than on one line, because a one-line
spelling has no line for the comment. The container's own comment above it
keeps the one-line spelling.
Blank lines are presentation: fmt keeps only the one after a head group.
A comment never makes PruneUnchanged keep an unchanged node. The set and
set-meta printers do not print comments yet, so a commit from an editing
session, which writes the set-meta form, drops them; an editor with no session
writes the hierarchical form and keeps them, its stamp replaced rather than
stacked (SerializeStamped).
The editor's displays print the operator's comments where the tree carries
them: show prints through config.Serialize, or SerializeSubtree below
the root (the set display prints set lines, which carry none). The
editor's change view ignores comments when it compares configuration, so a
comment never marks a line changed. It prints no comment line; its validation
highlight still lands on the statement's line in the working text, below any
comment above it.
Committed config files carry a schema stamp as the first line, naming the Ze
release that wrote them (YY.MM.DD):
# ze-schema: 26.10.07
The stamp is a comment (ignored by all parsers). On the first line it is always
the file's head, so ze config fmt keeps it on line 1. FormatSchemaStamp builds it
from version.Release() and every persistence site re-emits it: the editor
commit, schema evolution at startup, and downgrade recovery. It is not stored
in the YANG tree. A writer of the hierarchical form goes through
SerializeStamped, which replaces the stamp the parsed tree kept as its head
rather than adding a second one, so a file names one release, the one that
wrote it. ScanStampRelease reads it back from the first line, and a
file without a stamp yields an empty release. version.CompareReleases sorts
an empty or unparseable release (such as a dev build's dev) as older than
every dated one, so an unstamped file is never "newer" than the binary.
At start, after the config loads, the hub runs every registered
migration.Evolution whose Release is newer than the file's stamp
(applyEvolutions). Each one's Detect decides whether the tree needs it, and
Apply changes the tree; when any applied, the result is re-stamped with this
binary's release and saved back. An unstamped or dev-stamped file runs every
evolution, except one marked StampedOnly.
StampedOnly exists for an evolution that writes an old default into a file.
A shape change is safe on any file, because Detect sees the old shape. An
unset leaf looks the same under both defaults, so only a dated stamp says which
one the file was written under, and a file without one keeps the current
default. ipsec-unmatched-keeps-bypass is one: it writes vpn ipsec unmatched bypass into a file saved while bypass was the default
(IPsec guide).
The stamp is the one on the content the config loader parsed
(LoadConfigResult.StampRelease). After a downgrade recovery (below) that is
the rollback's own stamp, not the one recovery published over it, so a
recovered rollback still gets the evolutions it is owed.
An evolution that changes how the router behaves carries a Notice: what it
wrote, why, and the command that undoes it. applyEvolutions logs each one at
Warn, the default level, naming the config file (or stdin) and whether the
change was saved back. An evolution that only changes a shape leaves Notice
empty and is logged at Info with the others, so the Warn is kept for the
changes an operator must hear about.
When ze starts and LoadConfig fails on the active config, the hub calls
RecoverConfig, unless the failure is a ze:validate refusal
(ErrCustomValidation): a value the rules refuse is reported, never answered
by starting on an older config. Recovery needs the persistent store, so a
config read from stdin is not recovered.
RecoverConfig acts only when the stamp on the failing config names a newer
release than the binary's own, meaning the binary was downgraded. It then walks
the config's history in the store (ListVersions, newest first), skips each
version whose stamp is also newer than the binary, and attempts a full parse on
the rest. The first version that parses becomes the running config.
| Step | What happens |
|---|---|
| 1 | LoadConfig fails, the failure is not ErrCustomValidation, and a store is open |
| 2 | The stamp on the failing config is newer than version.Release(); otherwise ze refuses to start with the load error |
| 3 | Walk history newest first, skip versions stamped newer than the binary |
| 4 | Full parse on each candidate (the stamp is a hint, the parse is the gate) |
| 5 | First success: the failing config is written into history (WriteVersion), so it is kept, not lost |
| 6 | The recovered tree, re-stamped with this binary's release, is published through the store as a candidate and promoted to active; with an explicit config file the commit also writes that file |
| 7 | If no version parses, or publication fails, ze refuses to start with a clear error |
Publication refuses when the active config changed while recovery ran ("configuration changed externally during schema recovery").
Recovery runs at startup only, not on SIGHUP reload. A reload failure during runtime surfaces as an error rather than silently reverting to old config.
# Comment
plugin {
external <name> {
...
}
}
bgp {
router-id <ip>; # BGP-level global (inherited by all peers)
session { asn { local <asn>; } } # BGP-level local AS (inherited by all peers)
group <name> {
<peer-fields> # Group-level defaults (shared by all peers in group)
peer <name> {
<peer-fields> # Peer-level overrides
}
}
peer <name> { # Standalone peer (no group inheritance)
<peer-fields>
}
}
Ze-specific block for setting environment configuration from the config file. See environment-block.md for full documentation.
environment {
log { level DEBUG; }
tcp { attempts 3; }
}
Container for plugin definitions. Supports external for subprocess plugins.
Future: builtin and wasm for other plugin types.
plugin {
external <name> {
run <path>;
encoder json; # or text (v4 only)
timeout 10s; # stage timeout (default: 5s)
}
}
| Keyword | Type | Description |
|---|---|---|
| run | string | Command to execute |
| encoder | string | json or text |
| timeout | duration | Startup stall timeout (e.g., 5s, 1m, 500ms): how long a startup stage may go with no plugin completing one, not a budget for the stage itself. Default: 5s. 0 = use default. Negative rejected. |
Timeout semantics: During startup, all plugins synchronize at each stage. The timeout controls how long this plugin waits for all plugins to complete each stage. With multiple plugins, use the same timeout for all, or set the longest timeout on all plugins to avoid fast plugins timing out while waiting for slow ones.
Top-level native OSPFv2 configuration. The ospf container is a YANG config
root and auto-loads the OSPF edge plugin. Interfaces bind to areas explicitly by
setting interfaces/interface/area; Ze does not use FRR-style network <prefix> area <id> matching. network-type accepts broadcast, point-to-point, and
loopback; passive and loopback records do not open raw sockets. The NSM
consumes mtu-ignore, retransmit-interval, transmit-delay, and dead timers
from the same interface list.
ospf {
router-id 10.0.0.1
areas {
area 0.0.0.0 { area-type normal; }
}
interfaces {
interface eth0 {
area 0.0.0.0
network-type point-to-point
mtu-ignore true
}
interface lo0 {
area 0.0.0.0
network-type loopback
}
}
}
router-id uses ze:validate "ospf-router-id". area-id and interface area
use ze:validate "ospf-area-id" and are checked again by the in-process config
verifier so an interface cannot reference an undeclared area.
isis, ospf, static, connected and rib each carry the same
filter { import { ... } } block, declared once as the rib-import-filter
grouping in ze-policy-conf. filters is an ordered list of names of filter
instances defined under the top-level policy {} root. final is mandatory.
policy {
prefix-list ISIS-IN {
entry 10.0.0.0/24 { action accept; }
}
}
isis {
filter {
import {
filters [ ISIS-IN ];
final accept;
}
}
}
| Keyword | Type | Description |
|---|---|---|
| filters | ordered leaf-list of string | Filter instance names, run in order. A filter that rejects stops the chain. A filter that does not reject passes the route on |
| final | accept or reject |
Decides a route that passed every filter. Required, even with no filters |
The protocol's chain runs first, then the rib chain. A rejected route stays
in the Loc-RIB as a hidden path that is never selected. A commit that changes
a chain judges every stored path again. Load refuses these chains
(Route Policy at the Loc-RIB):
- a chain with no
final - a
<plugin>:<filter>reference - a name that is not a
policyinstance - a filter that reads a field the routes do not carry
- a filter type with no Loc-RIB route evaluator
isis carries filter { import { ... } export { ... } }, declared as the
igp-filter grouping in ze-policy-conf. The export chain has the same two
keywords as the import chain and judges every route redistribution hands the
protocol, before it is advertised: a rejected route is not advertised, and a
modify instance's set metric becomes the advertised metric.
isis {
filter {
export {
filters [ loopbacks ];
final accept;
}
}
}
Load refuses an export chain with no final, a name that is not a policy
instance, and a filter that reads a field a redistributed route does not carry,
level among them (Route Filters and Redistribution).
Peers are organized into named groups under bgp. Groups provide shared defaults inherited by all member peers.
bgp {
group <name> {
<peer-fields> # Group-level defaults
peer <name> {
<peer-fields> # Peer-level overrides
}
}
}
| Element | Type | Purpose |
|---|---|---|
group |
list (key: name) | Named collection of peers with shared defaults |
peer inside group |
list (key: name) | Peer with optional overrides of group defaults |
peer at bgp level |
list (key: name) | Standalone peer (no group inheritance) |
| Priority | Source | Scope |
|---|---|---|
| Lowest | bgp block globals |
session { asn { local; } }, router-id -- inherited by all peers |
| Middle | Group defaults | All peer-fields set on the group |
| Highest | Peer overrides | All peer-fields set on the peer |
Containers (like capability, timer) deep-merge at key level -- both group and peer capabilities are combined. Leaves (like hold-time inside timer) override -- peer value wins over group value.
Every peer below gives remote and local DIFFERENT addresses, and that is a
property of BGP rather than a style choice: the two ends of one session are two
speakers, so they hold two addresses. A configuration naming one address for both
describes a session that cannot exist, and Ze refuses to advertise a route whose
NEXT_HOP is the peer's own address (RFC 4271 Section 5.1.3), so such a peer
receives nothing while looking established. The refusal is
originatedNextHopIsPeerOwn and egressNextHopIsPeerOwn, both in
internal/component/bgp/reactor/forward_next_hop.go.
bgp {
router-id 1.2.3.4
session { asn { local 65000; } }
group rr-clients {
timer { receive-hold-time 180; }
session { capability { route-refresh enable; } }
peer router-east {
connection {
remote { ip 10.0.0.1; }
local { ip 10.0.0.254; }
}
session { asn { remote 65001; } }
}
peer client-b {
connection {
remote { ip 10.0.0.2; }
local { ip 10.0.0.254; }
}
session { asn { remote 65002; } }
timer { receive-hold-time 90; } # Overrides group's 180
}
}
group edge-peers {
timer { receive-hold-time 30; }
peer edge-gw {
connection {
remote { ip 192.168.1.1; }
local { ip 192.168.1.254; }
}
session { asn { remote 64500; } }
}
}
}
| Rule | Detail |
|---|---|
| Required | Every peer must have a name (it is the list key) |
| Unique | Two peers with the same name produce a config validation error |
| First character | ASCII letter, digit, or underscore ([a-zA-Z0-9_]) |
| Subsequent characters | ASCII letter, digit, underscore, hyphen, or dot ([a-zA-Z0-9_.-]) |
| CLI usage | peer <name> <command> selects the peer |
ze config migrate converts old syntax:
template { bgp { peer <pattern> { inherit-name X; } } }becomesgroup X { }inherit Xin peers moves the peer intogroup Xneighborblocks becomepeerblocks inside groupsredistribute destination D import S { family [ ... ]; tag N; }becomes apolicy { match redistribute-D-S { family [ ... ]; tag [ N ]; } }instance, named first in the rule'sfilters, and every import rule without afinaland withoutfiltersgetsfinal accept.tag 0staystag [ 0 ], which selects the untagged routes. Amatchinstance already holding that name is refused
Container for all BGP-related configuration (peers, groups, global settings).
bgp {
router-id <ip>; # Global router-id (inherited by all peers)
session { asn { local <asn>; } } # Global local AS (inherited by all peers)
group <name> {
# Group-level defaults (inherited by all peers in group)
session { asn { remote <asn>; } }
timer { receive-hold-time <seconds>; connect-retry <seconds>; }
session { capability { ... } }
session { family { ... } }
peer <name> {
# Connection (transport-level)
connection {
remote { ip <ip>; port <port>; connect <bool>; }
local { ip <ip>; port <port>; accept <bool>; }
md5 { password <string>; ip <ip>; }
ttl { max <0-255>; set <0-255>; min <0-255>; }
}
# Session (BGP protocol)
session {
asn { local <asn>; remote <asn>; }
router-id <ip>;
link-local <ipv6>;
family { ... }
capability { ... }
}
# Behavior (operational knobs)
behavior {
group-updates <bool>;
manual-eor <bool>;
}
# Other
description <string>;
timer { receive-hold-time <seconds>; send-hold-time <seconds>; connect-retry <seconds>; }
rib { adj { in <bool>; out <bool>; } out { ... } }
attach process <plugin-name> { ... }
update { ... }
}
}
}
Migration: ze config migrate converts old syntax:
neighbortobgp { group <name> { peer } }template { inherit-name X }togroup X { }inherit Xin peers moves peer intogroup X
Peer configuration is organized into nested containers by concern.
| Keyword | Type | Description |
|---|---|---|
connection { local { ip; port; accept; } } |
container | Local address (auto or IP), listen port, accept inbound connections (default: true) |
connection { remote { ip; port; connect; } } |
container | Peer IP address, dial port, initiate outbound connections (default: true) |
connection { md5 { password; ip; } } |
container | TCP MD5 authentication (RFC 2385) |
connection { ttl { max; set; min; } } |
container | GTSM max (RFC 5082), outgoing TTL, minimum incoming TTL |
| Keyword | Type | Description |
|---|---|---|
session { asn { local; remote; } } |
container | Local and remote AS numbers |
session { router-id; } |
IP | BGP router ID override |
session { link-local; } |
IPv6 | IPv6 link-local address for MP_REACH_NLRI next-hop (RFC 2545) |
session { family { ... } } |
list | Address families with per-family prefix enforcement |
session { capability { ... } } |
container | BGP capabilities (see below) |
| Keyword | Type | Description |
|---|---|---|
behavior { group-updates; } |
bool | Group UPDATE messages for efficiency (default: true) |
behavior { manual-eor; } |
bool | Manual End-of-RIB control |
| Keyword | Type | Description |
|---|---|---|
description |
string | Peer description |
timer { receive-hold-time; send-hold-time; connect-retry; } |
container | Timer settings (defaults: 90s, 0/auto, 120s). A non-zero send-hold-time must be greater than receive-hold-time (RFC 9687 Section 4.4) |
rib { adj { in; out; } out { ... } } |
container | RIB configuration (adj-rib-in/out, outbound batching) |
attach process <name> { ... } |
list | Plugin process bindings |
update { ... } |
container | Route announcements |
All capabilities support a four-mode vocabulary, declared once as the
capability-mode typedef and reused by every mode leaf under capability:
| Mode | Advertise? | Enforcement |
|---|---|---|
enable |
Yes | None |
disable |
No | None |
require |
Yes | Reject peer if capability missing |
refuse |
No | Reject peer if capability present |
Simple capabilities -- mode is the value:
capability {
asn4 require; # Reject peers without 4-byte ASN
route-refresh enable; # Advertise, no enforcement
graceful-restart enable; # Advertise GR support
extended-message require; # Reject peers without extended message
software-version disable; # Don't advertise
}
Removed capabilities -- these are not supported and will be rejected:
multi-session, operational, aigp. These were ExaBGP-era capabilities with no ze runtime implementation.
Block capabilities -- mode key inside block (for capabilities with sub-parameters):
capability {
graceful-restart {
mode require; # Reject peers without GR
restart-time 120;
long-lived-stale-time 3600; # RFC 9494 LLGR period (0-16777215 seconds)
}
}
Nexthop -- structured inline list keyed by family:
capability {
nexthop {
ipv4/unicast ipv6; # IPv4 unicast with IPv6 next-hop (enable)
ipv4/mpls-vpn ipv6 require; # IPv4 VPN with IPv6 next-hop (require mode)
}
}
Each line is parsed as: <family> <next-hop-afi> [<mode>] where family is the list key, next-hop-afi is ipv4 or ipv6, and mode defaults to enable.
ADD-PATH -- unified block with default direction, per-family overrides, and PATHS-LIMIT:
capability {
add-path {
direction send/receive; # default for all families
limit { receive 10; } # default PATHS-LIMIT
family {
ipv4/unicast { mode require; } # per-family mode
}
}
}
The direction and limit on the container are inherited by all negotiated families. Per-family entries override direction, limit, or mode.
Defaults: ASN4 defaults to enable. All other capabilities are absent (opt-in) -- they only participate in negotiation when explicitly configured.
Mode leaves: asn4, route-refresh and extended-message are
capability-mode leaves, written route-refresh enable in a file and
set ... capability route-refresh enable in the editor. They take the four
modes only: a bare name (route-refresh;), a block (route-refresh { }) and
the words true and false are refused, at parse and at commit. A mode word
outside that vocabulary, on any capability, is a config error that names the
word; the reactor never defaults it to enable. link-local-nexthop is a leaf
too, and takes enable or disable only, because its plugin enforces neither
require nor refuse.
family {
ipv4/unicast;
ipv4/multicast;
ipv4/nlri-mpls;
ipv4/mpls-vpn;
ipv4/mup;
ipv6/unicast;
ipv6/multicast;
ipv6/nlri-mpls;
ipv6/mpls-vpn;
ipv6/mup;
l2vpn/vpls;
l2vpn/evpn;
}
Block syntax is also supported: ipv4 { unicast; multicast require; }.
ADD-PATH and PATHS-LIMIT are configured inside session > capability > add-path:
capability {
add-path {
direction send/receive; # default for all families
limit { receive 10; } # default PATHS-LIMIT (inherited)
family {
ipv4/unicast {
direction receive; # per-family override
limit { receive 5; } # per-family limit override
mode require; # reject peer if missing
}
ipv6/unicast # inherits direction + limit from container
}
}
}
The direction leaf and the limit container's receive leaf on the add-path container apply to all negotiated families unless overridden per-family. A receive limit is advertised only for a family whose direction includes receive, and the old scalar limit N; is refused with the replacement line. Per-family mode supports enable (default), require, refuse, disable.
# Named process binding (preferred)
attach process <plugin-name> {
content {
encoding json; # json | text (default: inherit from plugin)
format parsed; # parsed | raw | full (default: parsed)
}
receive [ update state negotiated ]; # enum list of message types
send [ update ]; # enum list of sendable types
}
| Value | Description |
|---|---|
update |
Route announcements |
open |
Session open messages |
notification |
Error notifications |
keepalive |
Keepalive messages |
refresh |
Route refresh requests |
state |
Peer up/down events |
negotiated |
Capability negotiation results |
* |
Every event type, in both directions |
Plugins may register additional event types (e.g., rpki, update-rpki) that can also appear in receive lists. These are validated at runtime against the plugin registry.
Direction is part of the type. A plain type means both directions. A -received or -sent suffix means one: receive [ update-received ] asks for the UPDATEs the peer sends to ze, and receive [ update-sent ] asks for the ones ze sends to the peer. The two together mean both, which is what the plain type already says.
A token is resolved against the event registry whole before any suffix is cut, so a plugin type whose name ends in -sent keeps its name and is never read as a direction.
sent on its own is not a type and is refused. It named a direction, and the direction now belongs to the type it applies to, so receive [ sent state ] becomes receive [ update-sent state ].
all is not accepted. * says the same thing without reading like a type name, and it says it deliberately: a config that names * accepts every event type a plugin registers later, which is exactly what the word all used to do by accident.
receive decides delivery, and an absent block feeds nothing. ze builds a
peer-to-process index from the resolved settings of every peer and consults it
on each peer-scoped event. A process is handed an event when the peer's block
grants that type in that direction AND the process subscribed to it. A peer
that states no block for a running process feeds it nothing, whatever the
process asked for at startup. The two halves are reported when they disagree,
at plugin ready and after every config apply.
A group's block reaches its members, and a member's list replaces it.
attach process lives in the peer-fields grouping, so it is legal at
bgp/group, bgp/group/peer and bgp/peer. receive and send are
leaf-lists outside cumulativePaths, so a member that restates a process block
REPLACES the group's list for that member rather than adding to it. The index
is built after that merge, which is why a peer a dynamic group creates carries
its group's blocks under the address its connection arrived from: no config
document names its generated dyn-<address> identity, and none needs to.
| Value | Description |
|---|---|
update |
Can inject routes |
refresh |
Can request route refresh |
raw |
Can send a whole BGP message it built itself, an OPEN or a NOTIFICATION included |
* |
Every message type, including every one a plugin registers later |
update, refresh and raw are the three BASE types. A plugin registers more through
Registration.SendTypes, and naming one in a send list auto-loads the plugin
that enables it: send [ enhanced-refresh ] starts bgp-route-refresh, which
sends the RFC 7313 BoRR and EoRR markers. * names no type, so it auto-loads
nothing.
A direction suffix has no meaning in a send list, because every send type is sent.
all is not accepted. List send types explicitly, or write *.
Invalid enum values are rejected at parse time.
send is enforced, not documentation. A process reaches only the peers that
attach it with the type the command puts on the wire. A peer the command names
but the permission refuses is dropped and reported; a command whose every peer
refuses fails. update covers an announce, a withdrawal, an End-of-RIB marker, a
named commit, a cache forward and a stored-route relay; refresh covers a route
refresh, a BoRR, an EoRR and a soft clear. A command an operator types is not a
process and is not gated.
The reactor applies it in two places, and a peer-naming command reaches one of
them. Six commands resolve a peer SELECTOR and are gated there. Four more name
their peers directly, without a selector, and each applies the same check:
send bgp <selector> cached <id>, the forward-cached and
relay-stored-route plugin RPCs, and
send bgp <selector> raw.
Raw has a word of its own: raw. send bgp <selector> raw ... carries a whole
BGP message the caller chose, an OPEN or a NOTIFICATION included, so no other
send type describes it and send [ update ] does not imply it. Write
attach process <name> { send [ raw ] } for any program that injects raw
messages. Until 2026-08-30 the rail was gated on attachment alone, and a bare
attach process <name> { } was enough; that spelling no longer permits it.
A truthful send [ update ] or send [ raw ] moves the peer's End-of-RIB.
The session's initial sync counts the bindings that can put a route on the wire
by either rail, and holds the marker until each one has sent plugin session ready, so the marker means what RFC 4724 Section 4 says it means: this
speaker's initial routing update is complete, plugin-injected routes included. A
program granted one of those permissions that never sends the signal delays the
marker by the sync timeout instead. Every ze plugin that can be counted sends it
(bgp-rib, bgp-watchdog, bgp-rs, bgp-adj-rib-in), and a plugin author who
originates routes at peer-up must. A plugin with nothing to send still sends it,
on every peer-up: the barrier cannot tell "finished with nothing to send" from
"still working".
The wait does not queue anything. The initial sync has written every route it owns before it starts waiting, so a route a program pushes during the wait goes straight to the wire, in front of the marker, where a route belonging to the initial update belongs.
Two bindings are DERIVED, and only two. Both come from the redistribute
root, and each is granted only when a rule implies it. A peer that already names
the process keeps its own binding unchanged.
| The rule | The derived binding |
|---|---|
a source registered under the BGP protocol, such as import bgp |
receive [ update state refresh ] toward the process bgp-rib runs under, because the Loc-RIB is the source and reads a peer's UPDATEs only through an attach process grant |
destination bgp |
receive [ state ] and send [ update ] toward the process redistribute-orchestrator runs under, because that plugin puts the route on the peer's wire and send is what permits it |
redistribute { destination <proto> { import <source> } } is read once at load
and again on every SIGHUP reload, and a config it cannot turn into rules
REFUSES the load, and ze config validate and a commit refuse it too. Five
shapes fail, each naming what the operator typed: a source no component
registered, a family name that is not a registered address family, a
destination that imports nothing, a destination no protocol declared (the
refusal lists redistribute.DestinationNames, the declared set), and a rule
naming a source or a destination whose protocol has no block in the config (import bgp or destination bgp
with no bgp { } block, and the same for isis and ospf). The root each
source or destination needs is declared from the protocol's own init()
(RouteSource.ConfigRoot, Destination.ConfigRoot), before any plugin starts,
so the check names no protocol.
The rules are installed by the redistribute orchestrator, the plugin that owns
the redistribute root, from its own config section at startup and at each
reload. The block needs no bgp block: redistribution between IS-IS, OSPF,
static and connected runs on a router with no BGP.
The refusal replaced a warning. The warning left the global evaluator nil, which
disabled every redistribution rule in the file rather than the one line that was
wrong, and its only trace was a startup log line. ze doctor on a config file
reports either fault through its config-parse diagnostic, because it loads the
config through LoadConfig and the load refuses both (runChecks).
An empty rule list installs an EMPTY evaluator rather than leaving the previous
one standing, so a reload that removed the last redistribute block stops
redistributing.
Route filtering via named filter instances. Appears in peer-fields grouping
(group and peer levels) and the bgp container (global level).
filter {
import { filters [ rpki:validate community:scrub ]; final accept; }
export { filters [ aspath:prepend ]; final reject; }
}
| Keyword | Type | Description |
|---|---|---|
import, export |
presence container | Configure one direction's policy |
filters |
ordered leaf-list of string | Named filter instances or <plugin>:<filter> references; may be empty |
final |
mandatory enumeration | accept or reject, with no default |
Chains are cumulative across config levels (bgp > group > peer). A member can
be written three ways, and all three resolve to the same dispatch target: the
plain instance name NO-TRANSIT, the filter-type form reject-asn:NO-TRANSIT,
and the plugin form bgp-filter-path-asn:NO-TRANSIT.
The most-specific present direction supplies the final disposition; an absent child inherits it. Each original bgp, group and peer layer is validated before inheritance, even when it has no peers. A child cannot repair a parent's missing final. Dynamic group templates take the cumulative bgp/group filters and the group's final when present.
Filter accept continues and reject stops. On exhaustion, including an explicitly empty policy, the final decides. Full payload modifications end filter traversal but still receive the final decision. With no explicit policy at any inherited level, EBGP rejects at exhaustion and IBGP accepts. Automatic and inactive references do not establish explicit policy presence (RFC 8212 Section 3).
The old import [ NAME ]; and export [ NAME ]; forms are refused: write
import { filters [ NAME ]; final accept; } or the corresponding export policy.
The plain MY-LOOP, type-qualified loop-detection:MY-LOOP, and canonical
loop:MY-LOOP forms identify the same configured instance. The effective chain
retains the canonical reference, but its execution belongs to the registered
in-process protocol stage, not to a plugin process named after the instance.
The hook runs once at that stage; it is not called again by the per-peer RPC
chain. This also applies to dynamic group templates.
Active named internal references are supported only on import. Config refuses
one on export rather than treating an unexecuted check as acceptance; an
inactive reference remains an explicit no-execution decision.
The refusal identifies the original bgp, group, or peer layer and prints a
deactivate ... filter export filters <reference> command using the spelling
written there, with identifiers quoted for the editor. Run the printed top,
deactivate, and commit now commands in the configuration editor. An inherited
reference must be deactivated where it was written, not at the affected peer.
This keeps the inactive member, other references, and the direction's final.
One filter is injected rather than written: prependDefaultFilters
(internal/component/bgp/config/peers.go) puts each loop-detection entry
defined under the top-level policy block at the head of every peer's import chain. An operator
suppresses that injection by naming the entry in the chain, and deactivating a
named member turns the filter off for that peer:
filter {
import {
filters [ MY-LOOP ];
inactive: filters MY-LOOP;
final accept;
}
}
A deactivated member stays in the chain and never executes. It is how an
operator records a decision to run a session without a check, and the config
layer reads it as a decision rather than as an absence: filterChainContains
counts it, and so does the transit-leak obligation below.
There is no mandatory filter an operator cannot configure, and no overrides
keyword. RFC 9234 Only-To-Customer marking is applied by the bgp-role plugin
from the peer's role block, not by a filter named in a chain.
Automatic loop references do not invent an operator policy or change an explicit
final. Config and runtime carry a single validated policy value so references
and disposition belong to the same generation.
A peer that declares an RFC 9234 role must name, in the chains that role binds,
a filter of a type that can refuse a path through a transit provider
(RFC 7454 Section 9). reject-asn is such a type. A config that omits it is
REFUSED, naming the peer, the role and the chain.
| Local role | Remote is | Import chain | Export chain |
|---|---|---|---|
peer |
a settlement-free peer | REQUIRED | REQUIRED |
provider |
our customer | REQUIRED | not required |
customer |
our transit provider | not required | REQUIRED |
rs |
a client of the route server we run | REQUIRED | REQUIRED |
rs-client |
the route server we are a client of | REQUIRED | REQUIRED |
| absent | undeclared | not required | not required |
RFC 9234 names the LOCAL speaker's position, so customer means the remote is
our upstream. A deactivated member satisfies the obligation: naming the filter
and switching it off records the decision. A peer or a dynamic group that declares
no role is accepted, and ze doctor enumerates them under
doctor-bgp-peer-no-role.
Validation checks local policy filter names against the filter types of the
top-level policy root (the lists marked ze:filter, policy.BuildRegistry),
canonicalizes short filter-type references through the plugin registry, and leaves explicit <plugin>:<filter>
references for runtime dispatch. Runtime calls use the filter-update callback
on the selected plugin.
The update { attribute {} nlri {} } block is the ze-native way to announce routes in configuration files. All ze-native route config uses this format.
Path attributes shared by all NLRI in the update block.
update {
attribute {
origin igp;
next-hop 10.0.0.1;
local-preference 100;
med 200;
as-path [ 65001 65002 ];
community [ 65001:100 65001:200 ];
extended-community [ target:65001:100 ];
large-community [ 65001:100:200 ];
aggregator 65001:10.0.0.1;
atomic-aggregate enable;
originator-id 10.0.0.1;
cluster-list [ 3.3.3.3 192.168.201.1 ];
path-information 1.2.3.4;
label 16000;
labels [ 100 200 300 ];
split /25;
attribute [ 0x04 0x80 0x00000064 ]; # generic hex attributes
}
nlri {
...
}
}
<nlri-line> := <family> [rd <rd>] [label <label>] <op> <payload> ;
<op> := add | del | eor
<payload> := <bracket-list> | <structured-payload>
<family>-- address family, e.g.ipv4/unicast,ipv4/mpls-vpn,ipv4/flowrd/label-- optional VPN qualifiers, placed before the operation keyword<op>-- mandatory operation:add(announce),del(withdraw),eor(end-of-rib, no payload)<bracket-list>--[ prefix1 prefix2 ... ]-- for prefix-based families, one route per entry<structured-payload>-- for complex families (FlowSpec, VPLS, EVPN), one route per line
| Family Category | Bracket List? | Example |
|---|---|---|
| Prefix (unicast, multicast, mpls, mpls-vpn) | Yes | ipv4/unicast add [ 10.0.0.0/24 10.0.1.0/24 ]; |
| FlowSpec (flow, flow-vpn) | No -- one per line | ipv4/flow add source-ipv4 10.0.0.2/32; |
| VPLS | No -- one per line | l2vpn/vpls rd X add ve-id 5 ve-block-offset 1 ...; |
| EVPN | No -- one per line | l2vpn/evpn add <route-type-specific>; |
After add, if the next token is [ the parser reads a bracket list of single-token entries (one route per token). Otherwise it reads one structured NLRI until ;.
update {
attribute {
origin igp;
next-hop 10.0.0.1;
local-preference 100;
}
nlri {
# Simple unicast
ipv4/unicast add 10.0.0.0/24;
ipv4/unicast add [ 10.0.1.0/24 10.0.2.0/24 10.0.3.0/24 ];
# Withdrawal
ipv4/unicast del 10.0.99.0/24;
# End-of-RIB
ipv4/unicast eor;
}
}
# VPN with rd and label qualifiers before add
update {
attribute { origin igp; next-hop 192.168.0.1; }
nlri {
ipv4/mpls-vpn rd 100:100 label 20012 add 10.0.0.0/24;
ipv4/mpls-vpn rd 100:100 label 20012 add [ 10.0.1.0/24 10.0.2.0/24 ];
}
}
# VPLS
update {
attribute { origin igp; next-hop 192.168.201.1; }
nlri {
l2vpn/vpls rd 192.168.201.1:123 add ve-id 5 ve-block-offset 1
ve-block-size 8 label-base 10702;
}
}
FlowSpec routes (RFC 8955) use the same update { nlri { } } syntax. The legacy flow { route { match {} then {} } } block is not supported -- use ze config migrate to convert ExaBGP-format FlowSpec configs.
FlowSpec actions are expressed as extended-community attributes. Match criteria are inline after the family and add keyword.
# Simple discard rule
update {
attribute {
extended-community [ rate-limit:0 ];
}
nlri {
ipv4/flow add source-ipv4 10.0.0.1/32;
}
}
# Complex match with redirect
update {
attribute {
extended-community [ redirect:65500:12345 ];
}
nlri {
ipv4/flow add destination-ipv4 192.168.0.1/32 source-ipv4 10.0.0.2/32
protocol [ =tcp =udp ] destination-port [ >8080&<8088 =3128 ]
source-port >1024;
}
}
# FlowSpec VPN (with rd qualifier before add)
update {
attribute {
extended-community [ rate-limit:0 ];
}
nlri {
ipv4/flow-vpn rd 65535:65536 add source-ipv4 10.0.0.1/32;
}
}
| Criterion | Description |
|---|---|
source-ipv4 / source-ipv6 |
Source prefix |
destination-ipv4 / destination-ipv6 |
Destination prefix |
protocol |
IP protocol, a number (0 to 255) or a protocol name, under any match operator: =tcp, !=udp, >5, [ >=6&<=17 ], >=tcp&<=udp. Config and update text read it with one grammar (parseNumericTermsText), and a malformed value is refused with the value and the fix |
port |
Source or destination port, 0 to 65535 |
destination-port |
Destination port, 0 to 65535 |
source-port |
Source port, 0 to 65535 |
next-header |
IPv6 next header, the same component and grammar as protocol |
tcp-flags |
TCP flags (syn, rst, fin, ack, urg, push) |
icmp-type |
ICMP type, 0 to 255 or a type name (echo-request, unreachable, ...) |
icmp-code |
ICMP code, 0 to 255 or a code name (host-unreachable, ...) |
fragment |
Fragment flags (dont-fragment, is-fragment, first-fragment, last-fragment and their short aliases), each optionally led by ! (not), = (match) or & (AND with the term before). Config and update text read it with one bitmask grammar (parseBitmaskValue) |
dscp |
DSCP value, 0 to 63 |
packet-length |
Packet length, 0 to 65535 for both families (RFC 8955 Section 4.2.2.10, which RFC 8956 Section 3 applies to IPv6) |
traffic-class |
The ExaBGP spelling of dscp. RFC 8956 Section 8 registers component type 11 under one name for both families, so the two words build one component |
flow-label |
IPv6 flow label, 0 to 1048575 (the 20-bit field of RFC 8956 Section 3.7) |
| Operator | Meaning |
|---|---|
= |
Equal |
> |
Greater than |
>= |
Greater or equal |
< |
Less than |
<= |
Less or equal |
!= |
Not equal |
& |
AND with previous |
Every numeric criterion (protocol, next-header, the three ports,
packet-length, icmp-type, icmp-code, dscp, traffic-class,
flow-label) takes these operators, in config and in update text, through one
parser (parseNumericTermsText) with one width table (componentMaxValue) and
one name table (componentValueNames). A value wider than its field, such as
destination-port 65536, a term with no value after &, a leading & with no
term before it, and a value that is neither a number nor a known name are each
refused at load with the criterion, the value and the fix. A bare value means
=. Successive terms are ORed; & inside a token ANDs the next term with the
one before it, so [ >8080&<8088 =3128 ] is (>8080 AND <8088) OR =3128.
The bitmask criteria, tcp-flags and fragment, take one grammar in config
and in update text through one parser (parseBitmaskTermsText), ExaBGP 6.0's.
& ANDs the next term with the one before it and successive terms are ORed,
as for numeric criteria: syn&ack is SYN set AND ACK set, [ syn ack ] is SYN
set OR ACK set. + names several bits of one operand: syn+ack is SYN or ACK
set, and =syn+ack both set. A term may lead with = (all the operand's bits
set, RFC 8955 Section 4.2.1.2 match bit), ! (none set) or != (not all set),
and its operand is a flag name or a number from 0 to 255. A leading & with no
term before it, an & or + with nothing after it, and an unknown flag are
refused at load with the criterion and the fix.
| Extended Community | Action |
|---|---|
rate-limit:0 |
Discard (rate-limit to zero) |
rate-limit:<bps> |
Rate limit in bytes per second |
rate-limit:<bps>:bytes |
Explicit bytes-per-second form, canonicalized to rate-limit:<bps> |
rate-limit:<pps>:packets |
Rate limit in packets per second |
redirect:<asn>:<value> |
Redirect to the VRF that imports this route-target. A 2-octet AS takes type 0x80 with a 4-octet value; a 4-octet AS takes type 0x82 with a 2-octet value |
redirect:<ipv4>:<value> |
The same redirect with an IPv4-address route-target: type 0x81 with a 2-octet value |
redirect-to-nexthop <ipv4> |
Redirect to one IPv4 address: the draft-ietf-idr-flowspec-redirect-ip community, type 0x01 sub-type 0x0c, copy bit clear |
redirect-to-nexthop <ipv6> |
Redirect to one IPv6 address. This community is 20 octets, so it travels in IPV6_EXTENDED_COMMUNITIES (attribute 25, RFC 5701) instead of attribute 16 |
copy-to-nexthop <ipv4>, copy-to-nexthop <ipv6> |
Copy the matched traffic to one address: the same community as redirect-to-nexthop <address> with the copy ("C") bit of its local administrator set (draft-ietf-idr-flowspec-redirect-ip Section 2) |
redirect-to-nexthop-draft |
Redirect to the route's next hop: community 0x0800 (draft-simpson-idr-flowspec-redirect), which carries the target in the MP_REACH_NLRI next hop |
copy-to-nexthop |
Copy to the route's next hop: community 0x0800 with its copy bit. Only an address after the word selects the IETF form above |
action sample, action terminal, action sample-terminal, action none |
Sampling and terminal action bits. A word outside this set is refused |
mark <dscp> |
Set DSCP value, 0 to 63. RFC 8955 Section 7.5 gives the DSCP six bits, so a larger value is refused instead of truncated |
The three redirect encodings are the three RFC 8955 Section 7.4 names: the administrator you write selects the type, and the type fixes how large the value can be. A value too large for its form is refused, because a truncated route-target names a different VRF.
A rate must be a finite number. A negative rate is refused because RFC 8955 Section 7.1 forbids encoding one, and NaN and Inf are refused because the RFC gives a rate or the zero that means discard, and a decoder reads NaN back as that zero.
Legacy ExaBGP 5.0 packet-rate syntax rate-limit-packets:<pps> is still accepted during parsing. ExaBGP's explicit byte form rate-limit:<bps>:bytes is also accepted and normalized to bare rate-limit:<bps>.
Routes can be tagged with a watchdog group and held until explicitly announced:
update {
attribute { origin igp; next-hop 10.0.0.1; }
watchdog { name mypool; withdraw true; }
nlri {
ipv4/unicast add 10.0.0.0/24;
}
}
Standalone watchdog commands (via API):
request bgp watchdog announce <name> # send all routes in pool to peers
request bgp watchdog withdraw <name> # withdraw all routes in pool from peers
192.168.1.1
2001:db8::1
10.0.0.0/8
2001:db8::/32
65001 # 2-byte
4200000001 # 4-byte
auto # Use local-as
65001:100 # Standard (AS:value)
0x12345678 # Numeric
no-export
no-advertise
no-export-subconfed
nopeer
target:65001:100
origin:65001:100
redirect:65001:100
l2info:19:0:1500:111
0x0002fde800000001 # Raw hex (8 bytes)
65001:100:200 # ASN:value:value
65001:100 # Type 0 (ASN2:value)
192.168.1.1:100 # Type 1 (IP:value)
4200000001:100 # Type 2 (ASN4:value)
enable # or true
disable # or false
A boolean leaf takes these four words and nothing else. The parser normalizes
enable to true and disable to false before the tree stores the value.
require and refuse are capability modes, an enumeration of their own
(capability-mode above), and a boolean leaf given either is refused with
invalid bool.
igp
egp
incomplete
bgp {
router-id 1.2.3.4 # BGP-level global
session { asn { local 65000; } } # BGP-level global
group rr-clients {
timer { receive-hold-time 90; } # Group default
session {
family { ipv4/unicast { prefix { maximum 1000000; } } }
}
peer client-a {
connection {
remote { ip 192.168.1.2; }
local { ip 192.168.1.1; }
}
session { asn { remote 65002; } }
}
}
}
| Concept | Description |
|---|---|
| BGP globals | session { asn { local; } } and router-id at bgp level, inherited by all peers |
| Group defaults | Any peer-fields set on the group, inherited by all peers in that group |
| Peer overrides | Any peer-fields set on the peer, takes highest precedence |
| Deep merge | Containers (e.g., session, timer) merge keys from both group and peer |
| Leaf override | Scalar values (e.g., receive-hold-time inside timer) at peer level replace group values |
Peers directly under bgp (not inside a group) inherit only BGP-level globals:
bgp {
router-id 192.0.2.1
session { asn { local 65000; } }
peer my-peer {
connection {
remote { ip 10.0.0.5; }
local { ip 10.0.0.1; }
}
session { asn { remote 65001; } }
timer { receive-hold-time 180; }
}
}
A default statement on a YANG leaf is SCHEMA METADATA. It is not a value in
the parsed config tree, and a component that reads its config section directly
sees the leaf as absent.
config.ApplyDefaults (internal/component/config/schema_defaults.go) is what
puts a declared default into a tree, and it must be called with the schema node
for the subtree being filled. Two things follow, and both have produced defects:
- A component that never calls it reads every unwritten leaf as missing. If it then treats missing as zero, or as "no configuration", the declared default is documentation that no code obeys.
- Where a component DOES call it, the pattern is three steps:
config.YANGSchema(),schema.Lookup("<container path>"), thenconfig.ApplyDefaults(subtree, node).applyPeerSchemaDefaults(internal/component/bgp/config/peers.go) fills peer entries this way, andparseAdminDistanceConfig(internal/component/sysrib/sysrib.go) fills the protocol distance table.
A failure to load the schema is not a reason to carry on with an empty tree.
applyPeerSchemaDefaults returns silently on both error paths; the distance
parser returns the error instead, because that value decides which route the
kernel installs and a silent empty answer is indistinguishable from a
deliberate one. Which behavior is right depends on what the missing defaults
would cause, and the choice is made deliberately rather than by copying.
A component that declares a config root is called with that root's section, and
it is NOT called when the operator wrote no block for it.
BuildPluginConfigSections (internal/component/config/plugin_verify.go) asks
ExtractConfigSubtree for each declared root, and skips the root when the answer
is nil, so the configure callback never runs.
The consequence is the trap: state a component initializes only inside its
configure callback stays at its Go zero value on every config that omits the
block. Where one config in the tree carries the block and the rest do not, every
test passes and the feature is inert almost everywhere. runSysRIBPlugin
(internal/component/sysrib/register.go) shipped that way on 2026-09-04: the
administrative distance table was resolved in OnConfigure alone, so a config
with no rib block left every protocol on its producer's constant permanently.
Seed the declared state at start, then let a section override it. The seed
is the same parse over an empty tree, so the schema stays the one declaration:
parseAdminDistanceConfig("{}") fills every leaf from its YANG default. The
rollback path needs the same seed, because a callback that never fired leaves no
previous value to restore and an empty map would strip what the seed installed.
runSysRIBPlugin now seeds two tables this way. parseFIBImportConfig("{}")
fills the FIB permission set beside the distance table, and a rib { fib-withhold } list overrides it. Both tables take one route through the
callbacks: OnConfigVerify parses them, OnConfigure and OnConfigApply
install them, and the rollback restores the seed when the previous value is
empty.
Ze defines custom extensions in ze-extensions.yang that control config parsing, validation, and UI behavior:
| Extension | Argument | Applied to | Purpose |
|---|---|---|---|
ze:syntax |
mode | any node | Parser syntax mode (flex, freeform, inline-list, etc.) |
ze:key-type |
type | inline-list | Key type for inline lists |
ze:sensitive |
-- | leaf | Marks leaf as containing sensitive data |
ze:validate |
name | leaf, leaf-list | References a custom validator function |
ze:cumulative |
-- | leaf-list | Accumulate values across config inheritance levels |
ze:decorate |
name | leaf | Attaches display-time decorator for web UI |
ze:required |
path | list | Descendant field must have value after config inheritance resolution. Path argument is mandatory; bare ze:required; is rejected at schema load (use mandatory true for leaf-level required). Enforced generically for any list at validate, editor, and startup. |
ze:suggest |
path | list | Field shown in creation form with inherited defaults |
ze:listener |
-- | list | Marks a named list server { key "name"; } as a network listener. CollectListeners walks every enabled service with this extension at parse time and feeds ValidateListenerConflicts, which rejects overlapping ip:port pairs with a message naming both services. Covers web, ssh, mcp, looking-glass, prometheus, plugin hub, and api-server rest/grpc. |
ze:command |
handler | container | Marks a config-false container as executable CLI command |
ze:edit-shortcut |
-- | command | Available in edit mode as shortcut |
ze:display-key |
-- | leaf | Display label for keyless list entries in web UI |
ze:hidden |
value | leaf | Hidden from config display and web editor |
ze:allow-unknown-fields |
-- | container | Container accepts arbitrary key-value pairs |
ze:route-attributes |
-- | inline-list | Node accepts standard BGP route attributes |
ze:os |
goos | any node | Restricts node to a specific OS (any GOOS value); excluded from schema on other platforms |
- ExaBGP Legacy Syntax --
static,announce,flowblocks accepted for migration - API Update Syntax -- API command syntax for route injection (not yet implemented)
Last Updated: 2026-04-02