Circuit Breaker logo: a hexagonal badge containing a hooded figure at a monitor of code and charts, against a purple night city, above the Circuit Breaker wordmark.

Circuit Breaker

Network observabilityPython · Go

8probe sources feeding discovery

89.0k
lines of JS and JSX in the frontend
73.8k
lines of Python in the backend
12.7k
lines of Go in the edge agent
45.4k
lines of test across the monorepo
435
routed endpoints, every one covered by a checked-in auth policy
109
database migrations
11
map layout engines, all pure functions
10
entity types drawn on the graph
200
stars on GitHub
9
forks
17
published releases since March
MIT
licence, checked into the repository

Circuit Breaker is a self-hosted control plane for a homelab or a small datacenter. It discovers what you run, draws it as a live topology, polls it for health, and keeps a tamper-evident record of every change. The premise is that you should not have to draw your own network diagram: devices are discovered, not declared, and the map is a live view of what is actually on the wire rather than a document that went stale the day after you drew it.

It runs on my own hardware, on the four-node Proxmox cluster, and it is in an iterative development cycle with semantic versioning so every change has provenance.

The shape of the repository#

Three deployable units live in one monorepo. The backend owns all the domain logic and is the only thing that touches the database; the frontend is a pure API consumer; the Go agent is an optional edge process that runs on machines you want first-party telemetry from.

Path What it is Stack
apps/backend API, workers, integrations, migrations. 57 router modules, 82 service modules, 80+ ORM models. Python 3.12, FastAPI, SQLAlchemy 2, Alembic, Pydantic v2
apps/frontend Single-page app. 29 pages, 164 components, 27 custom hooks, half of them stream consumers. React, Vite, React Flow, sigma and graphology, d3-force, Plotly
apps/agent Edge collector: host facts, network scoping, remote probes. Ships its own updater and spool. Go 1.25, Noise IK, gorilla/websocket, miekg/dns
docker/, deploy/ Single-image runtime: supervisord units, nginx config, Postgres bootstrap, migration hooks. Dockerfile.mono, supervisord, nginx, pgbouncer
specs/, docs/ Release-gate evidence, ADRs, the security suppression ledger, the user-facing docs site. Markdown, JSON policy files checked in CI

Source, by language

  • JS / JSXfrontend – 29 pages, 164 components, 27 hooks89.0k50.7%
  • Pythonbackend – API, six workers, integrations73.8k42.1%
  • Goedge agent – collector, probes, updater12.7k7.2%
The frontend is the larger half, which is the honest shape of a product whose whole purpose is one screen. A further 45.4k lines of test sit outside this bar.

GitHub’s own counter measures the same tree in bytes rather than lines, and reaches the opposite verdict about which unit dominates. Both numbers are checkable, so the page carries both:

The same source, weighed in bytes

  • Pythonbackend – over half the tree by weight7.71MB60.8%
  • JS / JSXfrontend – first by lines, second by bytes3.61MB28.4%
  • Goedge agent – collector, probes, updater1.37MB10.8%
Ranked by bytes the order flips, because a line is not a byte: the backend’s Python averages about 105 bytes a line against roughly 40 for the frontend’s JSX – GitHub’s byte counts divided by the architecture reference’s line counts. Two honest measurements of one repository; the unit you quote picks the story.

One rule shapes the backend more than any other: routers decode and authorize, services do the work. A route module resolves the caller, validates a schema and calls into a service; no SQL and no domain branching live in the route layer. That is what makes the same logic reusable from the six background workers, which import services directly and never call the HTTP API.

One author carries the whole tree: GitHub’s contribution graph shows 529 contributions from BlkLeg beside a single dependabot bump.

One container, one process tree#

The deployment target is somebody’s homelab, so the whole stack ships as a single container: Postgres, NATS, Redis, the API, six workers and nginx under one supervisor, on a read-only root filesystem with a single writable /data volume. The API binds to loopback only. Nginx is the one process listening on a public port.

Fig 1 – one image, one process tree

  1. Public edge

    • nginxTLS, static SPA, per-stream proxy locations
  2. Application

    • FastAPI, uvicorntwo workers, loopback only
    • six workersdiscovery, notification, telemetry, monitor-scheduler, monitor-poll, probe-dispatch
  3. Bus

    • NATS with JetStreamdurable queues, at-least-once
  4. State

    • PostgreSQL 15TimescaleDB, pgbouncer
    • Rediscache, rate limits, presence
    • /datathe one writable volume

Nothing but nginx is reachable from the network, and the API and the six workers never call each other directly. They meet on the bus and in the database, which is why any worker can be restarted, scaled out, or pointed at an external Postgres without the API noticing.

Supervisord restarts any unit in place, so the deployment needs no orchestrator. Every embedded service is reached through a URL from config and can be swapped for an external one.

Bundling a database and a message bus into an application image looks lazy until you count the support burden of the alternative. Every additional service in a compose file is another thing a user has to understand before the tool works at all, and the users here are homelabbers, not platform teams. The convenience is the default, not the ceiling.

Three ways to install it#

The single image above is the Docker path’s unit of delivery. The README recommends installing natively instead – systemd, no Docker prerequisite – and ships a Proxmox script and a compose mode beside it:

curl -fsSL https://raw.githubusercontent.com/BlkLeg/CircuitBreaker/main/install.sh | sudo bash

The Proxmox path runs on the hypervisor itself: one script creates a Debian 12 LXC container, installs natively inside it and auto-configures the Proxmox API integration, walking through setup in an interactive TUI in about three minutes. The compose path installs Docker only if it is missing, fetches the official compose templates into ~/.circuitbreaker, writes .env defaults and runs docker compose up -d.

First run on the native path prints the URL of an out-of-box setup wizard, served behind a self-signed certificate until a real one is issued. The plain HTTP port (8088) stays reachable but cannot create the first account.

Note: the initial user is only ever minted over TLS. The README carries that in a parenthesis; it is the one detail most likely to cost somebody an evening.

cb, the CLI, ships with the two native installers; compose deployments manage themselves with docker compose instead. What it runs:

Command What it does
cb status Status of every Circuit Breaker service
cb doctor Health checks and diagnosis
cb logs Tail all service logs, live
cb restart Restart all services
cb update Update to the latest version
cb backup Back up the database
cb version Show the installed version
cb uninstall Remove Circuit Breaker

What happens to one request#

Every call from the SPA carries an HttpOnly session cookie and, on writes, a double-submit CSRF token. Starlette wraps middleware in reverse registration order, so the last one added is the first one executed – the diagram below is in execution order, which is the order that matters when you are debugging a 403.

Fig 2 – seven middleware layers, then authorization

  1. 1-7 middleware

    • TenantMiddlewareresolve tenant scope
    • TenantRateLimittoken bucket
    • SecurityHeadersCSP, HSTS, frame-deny
    • Loggingredacted at the filter
    • LegacyTokendeprecated API tokens
    • CSRFdouble-submit on writes
    • CORSsame-origin unless configured
  2. 8 route match

    • 435 endpointsevery one covered by a checked-in auth policy
  3. 9 authorization

    • require_auth, require_rolerole hierarchy and scopes, session revocation check
  4. 10 service

    • service moduleall domain logic, one transaction per request
  5. 11 effects

    • audit_logactor, IP, diff, prev_hash
    • NATS publishto live clients
    • Redis countersrate limit state

Layer 8 is the interesting one. All 435 routes are enumerated into endpoint_inventory.json and diffed against endpoint_policy.json in CI: adding a route without an auth dependency fails the build unless it is explicitly listed as an allowed exception.

Authorization is a tested property of the application rather than a convention people are trusted to remember.

Discovery never writes to your inventory#

Scans fan out across whatever the deployment can reach. Results are fingerprinted against a vendor catalog and a device knowledge base, then written to a scan_results staging table that nothing else in the system reads from. The catalog is a real asset – more than 70 devices across 20 vendors, Dell, HPE, Ubiquiti, Synology and APC among them – with freeform entry for what it does not know.

Fig 3 – eight probe sources, one review gate

  1. Probe

    • nmap, masscanport sweeps
    • mDNS, ARP, DHCPthe local segment, answering or not
    • SNMP, LLDPneighbour tables
    • Docker, Proxmox, OPNsenseAPIs that know their own contents
  2. Collect

    • discovery workerprogress published to the UI as it runs
  3. Identify

    • fingerprintOUI, vendor catalog, device knowledge base
  4. Stage

    • scan_resultsstaging only, inert, read by nothing else
  5. Promote

    • a person clicks mergeinto hardware, service and network rows
    • dismissedkept as history

Everything upstream of the review gate is disposable. That is exactly what makes it safe to run an aggressive scan against a production network you care about.

Reconcilers run the same merge logic on a schedule for sources authoritative about themselves – a Proxmox cluster’s own node list, an agent’s own network facts – but even those write through the staging table.

The consequence is architectural, not just procedural: the discovery pipeline is append-only, so it can be re-run, dropped or rewritten without any risk to an inventory somebody has spent months curating. Every change to inventory has the same shape and leaves the same audit row, whether a human or a scheduler caused it.

Telemetry that degrades to a queue depth#

The telemetry worker polls every enabled device on its own interval. Credentials are decrypted from the vault at poll time and never held in plaintext at rest. The naive version of this writes a row per poll.

Fig 4 – poll, queue, batch, cache

  1. Poll

    • RedfishiDRAC and iLO
    • SNMPUPS units and switches
    • Proxmox APIhypervisors
    • Fernet vaultcredentials decrypted per poll
  2. Publish

    • TELEMETRY streamtelemetry.ingest per device, durable across restarts
  3. Ingest

    • pull consumerbatches of 50 per commit, acks after the write
  4. Store

    • TimescaleDBhypertables, retained
    • Redis60 second TTL, latest state
  5. Push

    • telemetry WebSockethealth rings update in place on every open map

When NATS is unavailable the collector writes straight through to the database instead of failing. The queue is an optimization the system is allowed to lose without losing monitoring.

Decoupling buys three things: poll latency never blocks on the database, an ingest worker restart loses nothing, and a slow database degrades to a queue depth rather than to dropped readings.

The fallback is the part worth defending, and it is not unique to this path. Every route that uses the bus has a degraded mode that works without it.

Real-time fan-out#

Anything that changes state publishes a message; nothing polls the database to find out what happened. Subjects are namespaced and declared as constants in a single module, so the set of things the system can announce is greppable in one file. Each subject family surfaces on exactly one transport.

Subjects Transport Client
notifications.*, alert.* SSE /events/stream sseClient into an emitter
discovery.scan.*, device.found WS /discovery/stream useDiscoveryStream
telemetry.update, proxmox.* WS /telemetry/stream useTelemetryStream
alert.monitor.down.{id} WS /monitors/stream useMonitorStream
topology.node.*, cable.* WS /topology/stream useTopologyStream
agents.event WS /agents/stream useAgentLive

Notifications use SSE because they are one-directional and need to survive proxies; the rest use WebSockets because the client also sends viewport hints and subscription filters. Every stream re-checks its session every fifteen seconds, so revoking a session kills an open stream instead of waiting for the client to disconnect, and SSE degrades to a two-second database poll if NATS is down. Connections are capped globally and per-IP so one stuck browser tab cannot exhaust the server.

Checks run from wherever they can see#

Uptime checks are scheduled by a single clock guarded by a Postgres advisory lock: many replicas may be running, exactly one enqueues. Each tick claims due items and advances their next run time in the same statement, so all scheduling state lives in the database and a restart resumes cleanly with nothing wedged. A check only becomes an event when its status changes, so a target that flaps for an hour produces one notification rather than a thousand.

There are two work queues, deliberately. Checks the server runs itself go on one; checks executed by a remote agent go on another. An agent that stops draining backs up its own queue and nothing else. Fair sharing lives in the scheduler, where no single vantage point may take more than fifty slots of a two-hundred-item tick, so one agent with a thousand assigned checks cannot starve the rest.

The agent exists for vantage point rather than for scale. It sees what an out-of-band controller cannot: process-level host facts, the kernel neighbour table, and checks run from inside a network segment the server has no route to. It dials out over WSS and authenticates with a Noise IK handshake – no session cookie and no bearer token on that path, because the handshake is the authentication and the agent’s static key is its identity from enrollment onward. It is entirely optional, and the system is fully functional without it.

Most of the engineering in the agent is failure handling, for one reason: a severed network is usually not a closed socket. Writes to a dead socket succeed is the longer account of what that costs, of the 64 MiB disk spool that catches the outage, and of the symmetric read deadline that detects it in sixty seconds instead of fifteen minutes.

The map is two graphs, not one#

Every data path above exists to put something on one screen, and the map is the component that took the most hours. Not because a graph is hard to draw, but because it has to stay correct and legible while eight independent sources mutate it underneath the cursor. Telemetry pushes a health change, a scan finishes, a monitor flips a node red, someone drags forty nodes at once, and none of that is allowed to lose an edge or throw away a layout somebody spent an afternoon arranging.

The idea that makes it tractable is that the graph and its picture are two different things, fetched separately and stored separately.

Fig 5 – three fetches, one store, two renderers

  1. Structure, server-owned

    • build_topology_graph10 entity types, 11 relation kinds, tenant-filtered, no N+1
    • GET /graph/topologyETag over graph contents, 304 when nothing moved
  2. Presentation, client-owned

    • graph_layoutsone row per map, ten maps maximum
    • GET /graph/layoutpositions, shapes, boundaries, routing overrides
  3. One store

    • nodes and edgesrefs mirror state so callbacks stay stable
    • live overlaystatus, latency, uptime folded on by id – never reaches the layout engines
  4. Render

    • React Flowreal DOM nodes, custom cards, port handles, drag
    • Sigma over WebGLthe same graph once the DOM gives up

The live-overlay row never reaches the layout engines. That is the invariant that stops a health ring from making the whole map twitch every time a device changes state.

Eleven layout engines sit between the store and the renderers, all of them pure functions of nodes, edges and viewport. Exactly one write path runs the other way: a layout snapshot, posted once, on dirty.

That split is what lets everything else be simple. Deleting a service removes a node without disturbing anyone’s layout. Re-running discovery adds nodes that auto-place into free space instead of forcing a re-layout. And a layout engine can be swapped or rewritten without a migration, because no server code has an opinion about geometry.

Circuit Breaker's topology view in its dark theme: dozens of device nodes arranged in a loose radial graph joined by teal and amber links, with a side panel showing one node's health, CPU, memory and disk usage.
The topology view with a node selected. The side panel carries live health and resource figures for that host; the toolbar filters by environment and group.

Nothing about a layout engine touches the network, so switching is instant, undoable and testable without a DOM, and adding a twelfth is one file and one entry in a list. The two renderers are chosen by scale: React Flow gives real DOM nodes – rich cards, telemetry rings, port handles, drag-to-arrange – and stops being pleasant somewhere in the hundreds, at which point Sigma over WebGL takes the same graph.

Layout is where the iteration went. A network of this size renders as an unreadable hairball under a naive force simulation, so the map offers deliberate arrangements instead. The concentric layout below pushes every node onto rings and routes the links across the middle, which trades edge clarity for a complete picture of scale.

The same topology in a concentric ring layout under a red theme: nodes distributed around two rings with amber connection curves crossing the centre, and an icon toolbar along the bottom.
The concentric layout, in one of the alternate themes. Every node sits on a ring, so the whole estate is visible at once at the cost of legible individual edges.

One smaller rule does a surprising amount of work: edges are derived, not drawn. A line between two nodes means a real relation exists in the database – hosts, runs, on_network, depends_on. Ad-hoc edges can be added, and they render dashed and in a different colour precisely so that nobody mistakes decoration for fact.

Ten separate hooks carry data load, layout, mutations, edge interaction, boundary interaction, visual lines, drag snapping, real-time merge, timers and tab state. Every timer lives in one of them, so unmount cleanup is a single path instead of a leak hunt.

The bug that cost the most hours on this screen was a multi-select drag that could silently drop edges. React Flow emits a batch of change events during a drag, and a race between the position update and the edge re-snap could commit a node array whose edges no longer resolved: connections vanished from the screen and, if an autosave landed first, from the database. The fix snapshots the edge id set at drag start, diffs it against every candidate commit and restores anything that went missing. Fifty lines that exist entirely to make a feature feel trustworthy.

A topology map is judged in the first three seconds – whether it looks considered or looks like a graph library’s default output. Most of the refinement went into things nobody will name: edge routing that picks handle sides by relative position, boundary shapes that sit behind nodes without eating clicks, auto-placement that finds free space instead of stacking, and a viewport fit that frames the estate rather than centring its bounding box.

Security shaped this, not the other way round#

This is infrastructure tooling holding IPMI credentials and hypervisor API keys for somebody’s entire estate. Several of the decisions above exist because of that rather than despite it.

Control What it does
Route policy gate All 435 routes are diffed against a checked-in policy file in CI. A new endpoint without an auth dependency fails the build.
Fernet secrets vault Credentials encrypted at rest with a key that lives outside the database. The vault refuses to generate an ephemeral key at import time, so a wrong key is a loud failure rather than silent data loss.
Hash-chained audit log Each entry stores SHA-256(payload + prev_hash) under a write lock, so concurrent appends cannot fork the chain. Verification walks it and reports the first break.
Mid-stream revalidation Long-lived SSE and WebSocket connections re-check session validity every fifteen seconds. Revoking a session ends the stream.
Least-privilege container Read-only root filesystem, cap_drop: ALL with seven capabilities added back for named reasons, no-new-privileges, supervisor-dropped UIDs per service.
SSRF and egress control Integration targets pass a URL validator and a network ACL before any request leaves the process. Outbound traffic can be pinned to a proxy, and an air-gap mode disables it entirely.
Authentication bcrypt password hashing, TOTP MFA, and OAuth through GitHub and Google plus generic OIDC for Authentik, Keycloak and any other OIDC provider. Sessions are HttpOnly cookies throughout.
Automatic HTTPS nginx terminates TLS: Let’s Encrypt for public domains, a local self-signed CA for LAN names.

Five things a reviewer asks#

Why one container instead of a compose stack#

The users are homelabbers, and every additional service is a support burden. Bundling Postgres, NATS and Redis under supervisord makes the install one command and one volume, and because each is reached through a URL from config, any of them can be pointed at an external instance when someone outgrows the default.

The native systemd installer now ships beside the image and is the README’s recommended default. The argument is identical either way: one command, one volume, nothing for a homelab user to orchestrate.

Why a message bus in a single-node application#

Not for scale – for decoupling and durability. It is what lets the collector stop caring whether the database is slow, lets a worker restart mid-upgrade without losing samples, and lets six independent processes fan work out without any of them knowing the others exist. Every path that uses it also has a degraded mode that works without it.

Why the agent exists when SNMP already works#

Vantage point. An agent sees what an out-of-band controller cannot: process-level host facts, the kernel neighbour table, and checks executed from inside a network segment the server cannot route to. It is optional, and the system is fully functional without it.

What was the hardest bug#

The silent-link failure in the agent. Writes to a black-holed socket keep succeeding, so the agent believed a dead link was healthy and an entire outage’s samples went into the void instead of the spool. The fix – symmetric read deadlines on both ends, tied to a heartbeat interval both sides agree on – is three lines and a long comment explaining why they are load-bearing.

What would you change#

main.py is 2,100 lines of router registration and lifespan wiring that wants to be a declarative router manifest. MapPage.jsx is still 3,000 lines after ten hooks were extracted from it, and the orchestration that remains should become a reducer. The frontend is JSX with JSDoc types rather than TypeScript, which the stream-heavy hooks would benefit from most. And the eighty-model ORM module should be a package split by bounded context; there is a proposal for that in the tree already.

The loop closes#

Read the system left to right and the story is simple: the estate is polled and scanned by workers, everything they learn is announced on a bus, the API turns that into inventory and pushes live changes down open streams, and the map draws it.

Fig 6 – the whole system, one loop

  1. 01 collect

    • telemetry_collectorper-device interval
    • integration synccluster and VM inventory
    • discovery workerscan, stage, review
    • monitor schedulerto poll and to probe dispatch
  2. 02 distribute

    • NATS JetStreamCB_EVENTS, TELEMETRY, MONITOR_POLL, MONITOR_PROBE
  3. 03 serve

    • FastAPI435 policy-gated routes, RBAC and audit on write
    • SSE and five WebSocketscapped per-IP, revalidated every 15 seconds
    • PostgreSQL, TimescaleDB, audit_log, Redis, vaultthe state everything else reads
  4. 04 render

    • topology map engine11 layouts, two renderers, overlays that never move geometry
    • 29 other pagesdiscovery review, monitors, agents, IPAM, racks, storage, logs, audit

A scan finds a host, you merge it, a node appears on the map, you right-click and add a monitor, the scheduler enqueues it, a poll worker or an agent runs the check, and the event alerts you and repaints that same node. Every user action re-enters at 01.

Three things are true everywhere: nothing but nginx is reachable from the network, no two processes call each other directly, and no path writes to your inventory without either a human decision or an audit row.

Where it is going#

It is pre-1.0 and says so in its own README: not fully audited, LAN-only until the release gates pass. The README’s notice is version-stamped – "0.4.0. Not fully audited; several 1.0 security acceptance rows are still unevidenced" – and names internet-exposed deployment as outside the 1.0.0 support boundary, which is itself checked in at docs/release/1.0.0-support-contract.md. The gates – the endpoint policy, a security suppression ledger, and evidence files under specs/ – run in CI, which is the part that makes the claim checkable rather than merely reassuring. The user guide lives at blkleg.github.io/CircuitBreaker, next to the architecture reference this page counts from.

Seventeen releases since the repository was created at the end of February say the cycle is real:

Releases shipped, by quarter

  • Q1 2026the repository is three weeks old – the whole 0.1 and 0.2 line952.9%
  • Q2 2026the 0.3 line, then May and June go quiet211.8%
  • Q3 2026v0.3.3 and v0.3.4 in July, three 1.0.0 rcs in August, v0.4.0 on 1 September635.3%
Nine releases in the repository’s first month, a quiet quarter, then the 1.0.0 candidates. ‘Iterative development cycle’ in the opening paragraph is this chart, not a slogan.

The map is not a view bolted onto the platform. It is what the platform is for, and every data path above exists to put something on it.

Source on GitHub