Fleet sync for Juniper SRX firewalls
a mechub project — sovereign network-security automation
Unofficial / community project. This repository is an independent, community-driven project. It is not affiliated with, endorsed by, sponsored by, or supported by Hewlett Packard Enterprise or Juniper Networks. "HPE", "Juniper", "SRX", "JUNOS", "Security Director" and "Juniper Mist" are trademarks of their respective owners and are used here only to describe what this software interoperates with. Please direct support and licensing questions about those products to the respective vendors.
Keep a fleet of Juniper SRX firewalls in sync with a designated master SRX.
Reads selected configuration sections from the master and pushes them to a
list of target devices, with safety rails (commit confirmed), drift
detection, and per-target include lists.
An optional Rust-backed NETCONF transport (--transport rustez, Python 3.12
only) is available alongside the default PyEZ backend; see Transports.
python -m venv .venv
source .venv/bin/activate
pip install -e .[dev]
Optional extras:
pip install -e .[keyring] # OS keyring secret provider
pip install -e .[vault] # HashiCorp Vault secret provider
pip install -e .[rust] # rustez NETCONF backend (Python 3.12 only; --transport rustez)
Python 3.11+ required (Python 3.12 exactly for the [rust] extra — rustez
ships cp312 wheels only).
-
Write an inventory file (
inv.yaml):source: host: srx-master.example.net auth: { provider: env } targets: - host: srx-site-a.example.net auth: { provider: env } include: [objects, policies, nat, qos, zones] - host: srx-site-b.example.net auth: { provider: env } include: [policies, nat, zones] zone_map: # optional: master zone → slave zone DMZ: EDGE
Each target's
include:explicitly lists the categories it will receive — no top-level default, no implicit inheritance. Read the inventory and you know exactly what every device gets.zone_mapis optional; see Zone translation. -
Provide credentials out-of-band (see Secrets):
export SRX_USER_SRX_MASTER_EXAMPLE_NET=srxsync export SRX_SSH_KEY_SRX_MASTER_EXAMPLE_NET=~/.ssh/id_ed25519 # repeat per target host -
Check first:
srxsync check --inventory inv.yaml --verbose -
Push (always commit-confirmed by default):
srxsync push --inventory inv.yaml --merge --commit-confirmed 5
srxsync push --inventory inv.yaml (--replace | --merge)
[--commit-confirmed N] [--max-parallel N]
[--on-error continue|abort] [--dry-run]
[--transport {pyez,rustez}]
srxsync check --inventory inv.yaml [--verbose] [--transport {pyez,rustez}]
| Flag | Meaning |
|---|---|
--replace / --merge |
Mutually exclusive. --replace annotates each category root with replace="replace" so Junos wipes slave-specific siblings within synced subtrees. --merge only adds/updates. Security zones are always merged, never replaced. |
--commit-confirmed N |
Commit with an N-minute auto-rollback timer; srxsync calls commit again to seal. Default: 5. |
--max-parallel N |
Concurrent target workers. Default: 5. |
--on-error continue|abort |
On a per-target failure, continue (exit non-zero at end) or cancel remaining work. Default: continue. |
--dry-run |
Fetch source + compute per-target payload, print what would change, do not load or commit. |
--verbose (check only) |
Show per-category XML diff for drifted targets. |
--transport {pyez,rustez} |
NETCONF backend. pyez (default) is junos-eznc; rustez is the Rust-backed client (install with pip install -e .[rust]). Both produce identical results; rustez is faster for large fleets. |
Exit 0 only when every target succeeded (push) or is in sync (check).
Synced scope is defined in srxsync/data/categories.yaml:
| Category | Junos paths |
|---|---|
objects |
security address-book, applications |
policies |
security policies |
nat |
security nat |
qos |
class-of-service |
zones |
security zones (interfaces pruned — device-specific) |
Excluded from sync: interfaces, routing-options, protocols, system,
chassis, and zone-to-interface bindings. These are device-specific and
never cross-synced.
Adding NTP, SNMP, or anything else is a YAML edit, not a code change. Append
to srxsync/data/categories.yaml:
ntp:
paths:
- /configuration/system/ntpThen add ntp to the include: list of whichever targets should receive it.
A target can use different zone names than the master. Add a zone_map
(master zone → slave zone) to the target; unlisted zones keep their name.
targets:
- host: srx-site-a.example.net
auth: { provider: env }
include: [objects, policies, nat, zones]
zone_map:
APP1: APPS
APP2: APPS
APP3: APPS- Zone names are rewritten in policies, global policies, NAT rule-sets and
address-book
attach. - Policy contexts that collapse into one (
APP1→UNTRUST,APP2→UNTRUST→APPS→UNTRUST) are concatenated in master order. A duplicate policy name in a merged context fails that target.APP1→APP2becomesAPPS→APPS. - Zones are never deleted.
security zonesis always merged, even with--replace. A zone fed by several master zones (APPS) is pushed as an empty zone: created if missing, otherwise left exactly as the slave has it. - srxsync warns (stderr, never fails) when it creates a zone, or a zone exists, with no interfaces — bind one on the slave.
- Translation is single-pass:
{A: B, B: C}maps A to B and B to C (no chaining). - A warning is also printed when a translated policy references a zone that is
missing on the slave and
zonesis not ininclude(the slave commit will fail). - A
zone_mapkey that isn't a zone on the master prints a warning (likely a typo) and is ignored. checkonly reports zones srxsync manages: zones that exist only on the slave are not drift, and N:1 zones are checked for existence only.- Entries in a master zone's own address-book are not carried to a merged zone; keep shared objects in a global address-book.
Credentials never live in the inventory file. auth.provider selects a
SecretProvider:
| Provider | Reads |
|---|---|
env |
SRX_USER_<HOST>, SRX_PASSWORD_<HOST> or SRX_SSH_KEY_<HOST> (tilde-expanded path). Host dots and dashes become underscores. |
netrc |
~/.netrc entry matching machine <host> |
keyring |
OS keyring slot srxsync/<auth.key>, stored as username:password |
vault |
HashiCorp Vault path auth.path, VAULT_ADDR + VAULT_TOKEN env |
Example env-var wiring for srx-a.example.net:
export SRX_USER_SRX_A_EXAMPLE_NET=srxsync
export SRX_SSH_KEY_SRX_A_EXAMPLE_NET=~/.ssh/id_ed25519
- No partial commits. Every
loadis paired withcommit confirmed+confirm. Script crash or network drop between them → the SRX auto-rolls back after the timer expires. - Per-target isolation. One target's failure never affects another
(separate
Transportinstances per worker). - Best-effort cleanup. On exception,
rollback()+close()run; cleanup errors are swallowed so the original error is preserved. - Honest exit code. Exit 0 only if every target reported success;
--on-error continuedoes not mask failures.
Two layers, no device mocks.
pytest tests/unit/ # pure logic against XML fixtures
pytest tests/integration/ # real vSRX lab (skipped if tests/lab.yaml missing)
Copy tests/lab.yaml.example to tests/lab.yaml (gitignored) and fill in
three vSRX hosts (one master + two slaves). Export credentials per host, or
set the defaults once:
export SRXSYNC_LAB_USER=srxsync
export SRXSYNC_LAB_SSH_KEY=~/.ssh/id_ed25519
The lab tests exercise the full pipeline — drift detection, merge sync,
--replace wipe, and commit-confirmed auto-rollback. Every test is
parametrized over both transports (pyez and rustez) when the [rust]
extra is installed; rustez cases are skipped cleanly otherwise. The
commit-confirmed test waits 75 seconds for the Junos rollback timer, so
expect ~3–4 minutes for a full integration run across both backends.
CLI → Orchestrator → (CategoryModel, DiffBuilder, DriftDetector, Transport)
│ │
└──── SecretProvider (env/netrc/keyring/vault)
- CategoryModel (
categories.py) — loadsdata/categories.yaml, resolves category names → paths + prune rules. - DiffBuilder (
diff.py) — extracts source subtrees, applies prune rules, annotates replace-mode category roots. - ZoneMap (
zonemap.py) — applies a target'szone_map: rewrites zone references, merges collapsed policy contexts, pushes N:1 zones as empty shells. - ZoneWarn (
zonewarn.py) — per-target warnings for payload zones that are new, empty, or missing on the slave. - DriftDetector (
drift.py) — canonical XML compare of source vs target within the synced scope. - Transport (
transport/base.py) — abstract; two concrete backends selected at runtime via--transport:PyEZTransport(transport/pyez.py) — junos-eznc, default.RustezTransport(transport/rustez.py) — Rust-backed via rustez + rustnetconf, gated by the[rust]extra. Integration tests parametrize both backends so parity is enforced.
- Orchestrator (
orchestrator.py) — async semaphore-capped fanout, per-target category resolution and lifecycle (connect → load → commit confirmed → confirm → close),--on-errorhandling, aggregate exit code. Master is fetched once with the union of all target includes.
A lightweight wall-time comparator lives at
tests/bench/bench_transports.py:
source .venv/bin/activate
source ~/.srxsync.env
python tests/bench/bench_transports.py
It measures 20 fetches and 3 merge-pushes per backend against the
inventory in inv.yaml and prints a markdown table. See
docs/superpowers/specs/2026-04-23-transport-benchmark-design.md
for the exact measurement contract.
master is the single branch. It ships two NETCONF backends behind the
same Transport interface, selected with --transport {pyez,rustez}:
pyez(default) — PyEZ (jnpr.junos). Pure Python;pip install -e .[dev]and go.rustez(optional) — built on rustEZ / rustnetconf. Requires Python 3.12 exactly (rustez ships cp312 wheels only; the[rust]extra's marker ispython_version == "3.12", not>=, so it stays a no-op resolver skip on newer interpreters instead of a failed source build); install withpip install -e .[rust,dev]. The extra is capped atrustez>=0.8.4,<0.9, the line parity was lab-verified on. Validated against PyEZ by a canonical-XML parity test and the parametrized lab suite.
Locking is aligned across backends. Both PyEZ and rustez take an
exclusive config lock at connect() and hold it until close(), so check
and the source fetch lock each device while reading, on either backend. This
closes the window rustez previously left open between connect() and the
first load(), where another session could mutate the candidate
config underneath an in-flight push. See #8.
Licensed under MIT.
Unless explicitly stated otherwise, contributions submitted for inclusion in this project are licensed under the MIT License.
a mechub project · deterministic decides · the model explains · a human approves
github.com/fastrevmd-lab