Skip to content

styliteag/birdseye

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

29 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

birdseye

Bird's-eye view of a self-hosted NetBird deployment: a long-running audit-event forwarder plus a handful of operator scripts, packaged as a single Docker image you can run alongside your existing NetBird docker-compose stack.

Targets self-hosted NetBird (not NetBird Cloud). Uses the unofficial netbird PyPI SDK (community-maintained, not affiliated with NetBird).

What it does

The birdseye container polls /api/events/audit on your NetBird management API and fans matching events out to three sinks:

Sink Format Toggle
stdout One line per event always on (read with docker logs)
Mattermost Compact markdown via incoming webhook, one message per poll MATTERMOST_WEBHOOK_URL empty → disabled
Email Plain text via SMTP EMAIL_MODE=off | immediate | digest

It also runs cleanup_ephemeral.py on a cron schedule (default every 15 min) to delete stale ephemeral peers that NetBird's built-in cleanup ticker sometimes misses, and an optional weekly backup that mails two encrypted 7z archives: a volume snapshot for byte-identical restore, and an API config export in readable JSON.

Highlights:

  • No event loss across restartslast_id persisted to a named volume, resumes exactly where it left off.
  • Bounded catch-up — if the container's been down for a while, MAX_CATCHUP (default 200) caps how many backlog events get forwarded to Mattermost/email so a 3-day outage doesn't flood your channel.
  • Self-alert on extended API outage — if the NetBird API is unreachable for more than OUTAGE_ALERT_MINUTES (default 10), the forwarder posts a 🚨 API unreachable message to Mattermost (which usually lives on a different host) and a recovery message when polling resumes.
  • Per-sink filters — each sink takes a comma-separated list of fnmatch globs over activity_code. Defaults: stdout/Mattermost see everything, email is curated to config-change events (policy.*,user.*,setupkey.*,personalaccesstoken.*,account.*).

Quick start

Pre-built images are published per-release to Docker Hub and GHCR:

  • styliteag/birdseye:latest
  • ghcr.io/styliteag/birdseye:latest

Clone the repo for the compose file and env template, then:

cd docker/
cp .env.example .env
# Edit .env — minimum: NB_URL, NB_API_KEY, NB_ADMIN_API_KEY,
# MATTERMOST_WEBHOOK_URL (or leave empty), TZ.
docker compose up -d
docker compose logs -f

Once running you should see [forwarder] first boot — seeded last_id=N, no backlog forwarded. Trigger any audit event in NetBird (e.g. toggle a policy) to confirm the pipeline works.

Running alongside your self-hosted NetBird

You can deploy birdseye in two ways. Pick one.

Option A — separate stack, public hostname (simpler)

birdseye runs as its own docker compose project, talks to NetBird over its public DNS name. Zero coupling between the two stacks.

In docker/.env:

NB_URL=https://netbird.example.com
NB_API_KEY=nbp_xxxxxxxxxxxxxxxxxxxxxxxxxxxx

Then docker compose up -d from inside docker/. This is the default the shipped docker-compose.yml uses — no edits needed.

Option B — same docker network as NetBird (no public roundtrip)

Join the docker network that your NetBird services share. The forwarder reaches the management API by internal hostname, so traffic never leaves the host.

First, find your NetBird network name:

docker network ls | grep netbird
# Typical output: netbird_default

Then edit docker/docker-compose.yml — uncomment the networks: blocks at the bottom and on the birdseye service, replacing the network name to match what docker network ls showed:

services:
  birdseye:
    # ... existing config ...
    networks:
      - netbird

networks:
  netbird:
    external: true
    name: netbird_default     # match `docker network ls`

And in docker/.env, point NB_URL at the internal service name (check docker compose ps in the NetBird stack to see what your management service is named — typically management or netbird-management):

NB_URL=http://management:33073

The port (33073 here) varies by NetBird version and how your self-hosted compose exposes the management API. Check the NetBird management container's ports with docker port netbird-management.

Option C — merge into your NetBird compose file

If you'd rather have one docker-compose.yml for everything, copy the birdseye: service block from docker/docker-compose.yml into your existing NetBird compose file, plus the birdseye-state volume. The service can then reference NetBird services directly without an external: true network declaration.

Configuration reference

All knobs are env vars. Full list with defaults in docker/.env.example. Most important:

Env var Default Purpose
NB_URL (required) NetBird management URL
NB_API_KEY (required) Read-only API token (forwarder)
NB_ADMIN_API_KEY (optional) Admin token for cleanup_ephemeral cron job
POLL_INTERVAL 60 Seconds between audit-API polls
MAX_CATCHUP 200 Cap on backlog events forwarded per restart
OUTAGE_ALERT_MINUTES 10 Mattermost self-alert threshold
BACKLOG_WARN_THRESHOLD 1000 Log a one-shot WARN when a single poll returns more than this many audit events. The NetBird audit endpoint has no cursor, so each poll re-downloads everything; growing past the threshold means it's time to lower server-side retention.
STDOUT_INCLUDE * Per-sink fnmatch glob list
MATTERMOST_INCLUDE *
EMAIL_INCLUDE policy.*,user.*,setupkey.*,personalaccesstoken.*,account.*
STDOUT_EXCLUDE (empty) Subtracted from STDOUT_INCLUDE. Lets you say "everything except X" without listing every other category. Example: STDOUT_INCLUDE=* + STDOUT_EXCLUDE=peer.login.expired to drop the noisy expiry events from stdout.
MATTERMOST_EXCLUDE (empty) Same semantics for the Mattermost sink.
EMAIL_EXCLUDE (empty) Same semantics for the email sink.
MATTERMOST_WEBHOOK_URL (empty = disabled) Mattermost incoming webhook
MATTERMOST_USERNAME birdseye Bot username on the webhook
MATTERMOST_STARTUP_TEST false When true, posts a one-shot smoke message to the webhook at container start so you can verify routing before the first audit event arrives. Failure is logged but does not abort the forwarder.
EMAIL_STARTUP_TEST false When true, sends a one-shot smoke mail (host, time, transport, recipients) at container start. Confirms SMTP host/port/TLS/credentials work without waiting for a configured event to fire.
EMAIL_MODE off off | immediate | digest
EMAIL_DIGEST_MINUTES 15 Digest flush interval
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASSWORD / SMTP_FROM / SMTP_TO (empty) SMTP settings (SMTP_TO is comma-separated). When SMTP_PORT is empty the default is derived from SMTP_TLS_MODE: 587 / 465 / 25.
SMTP_TLS_MODE (legacy: SMTP_STARTTLS=truestarttls, else none) Transport: starttls (submission, port 587), tls (implicit TLS / SMTPS, port 465), none (plain SMTP, port 25).
SMTP_STARTTLS (empty) Deprecated — kept for backward compatibility. Prefer SMTP_TLS_MODE. true maps to starttls, false to none.
CRON_CLEANUP_EPHEMERAL */15 * * * * Empty disables the cron job
CRON_BACKUP_NETBIRD (empty = disabled) Cron schedule for backup_volumes.py (typical: 0 3 * * 0)
BACKUP_PATHS (empty) Comma-separated paths inside the container to back up
BACKUP_ZIP_PASSWORD (empty) Passphrase for the AES256-encrypted 7z archive
BACKUP_EMAIL_TO (falls back to SMTP_TO) Recipient(s) of the volume-backup mail
EXPORT_EMAIL_TO (falls back to BACKUP_EMAIL_TOSMTP_TO) Recipient(s) of the API-export mail
BACKUP_MAX_ATTACHMENT_MB 20 Above this, an error mail is sent in place of the attachment (applies to each mail)
BACKUP_LABEL (empty) Free-form tag in the subject and filename (e.g. prod)
BACKUP_EXCLUDE (empty) Comma-separated 7z wildcards excluded from the volume archive (case-insensitive, recursive)
TZ UTC Timezone for displayed timestamps

Weekly backup

A single cron entry (CRON_BACKUP_NETBIRD, e.g. 0 3 * * 0 for Sunday 03:00) drives two independent jobs and sends two independent mails:

  1. Volume snapshot (backup_volumes.py) — packs mounted NetBird volumes into an encrypted 7z. For byte-identical restore.
  2. API config export (export_objects.py) — fetches every configuration endpoint from the management API and stores it as readable JSON in a second encrypted 7z. For seeing what was configured on a given date.

Either step can be disabled by leaving its inputs empty: skip volumes by leaving BACKUP_PATHS empty, skip the API export by leaving NB_ADMIN_API_KEY empty. If both are set, both run sequentially in the same job — a failure in one does not block the other.

Setup, step by step:

  1. Find the volumes you want to back up in your NetBird stack:

    docker volume ls | grep netbird
    # Typical: netbird_management, netbird_signal, netbird_caddy_data
  2. Mount them read-only into birdseye. Edit docker/docker-compose.yml, uncomment the example mounts under volumes: on the birdseye service, and the matching external: true declarations at the bottom. Match the volume names from step 1.

  3. Configure .env:

    CRON_BACKUP_NETBIRD=0 3 * * 0          # Sunday 03:00
    # --- Volume snapshot ---
    BACKUP_PATHS=/backup/management,/backup/signal,/backup/caddy
    BACKUP_EXCLUDE=geo*                    # skip GeoIP DBs etc., optional
    BACKUP_EMAIL_TO=ops@example.com        # or leave empty to reuse SMTP_TO
    # --- API config export ---
    NB_ADMIN_API_KEY=nbp_xxxx              # admin scope to read all objects
    EXPORT_EMAIL_TO=                       # optional — falls back to BACKUP_EMAIL_TO, then SMTP_TO
    # --- Shared ---
    BACKUP_ZIP_PASSWORD=<long random passphrase, store offline>
    BACKUP_MAX_ATTACHMENT_MB=20
    BACKUP_LABEL=prod
    # SMTP_HOST / SMTP_PORT / SMTP_FROM / SMTP_USER / SMTP_PASSWORD
    # are reused from the existing email sink configuration.
  4. Trigger each one manually to verify before relying on cron:

    docker compose exec birdseye \
      /app/.venv/bin/python /app/backup_volumes.py --dry-run
    docker compose exec birdseye \
      /app/.venv/bin/python /app/export_objects.py --dry-run
    # or both, in the same order the cron uses:
    docker compose exec birdseye /app/run_backup.sh
  5. Restore. Both archives use the same BACKUP_ZIP_PASSWORD:

    # Volume restore — byte-identical:
    7z x netbird-prod-<timestamp>.7z
    # then stop NetBird, replace the volume contents, restart
    #
    # Config inspection — readable JSON:
    7z x netbird-export-<timestamp>.7z
    ls export/   # peers.json, groups.json, policies.json, … + manifest.json

If an archive exceeds BACKUP_MAX_ATTACHMENT_MB, you receive a — FAILED mail with the actual size instead of a truncated attachment — raise the limit, trim BACKUP_PATHS, or move to off-host storage. The limit is checked against the base64-encoded payload (≈1.4× the raw archive), which is the size SMTP servers actually count. Gmail caps at 25 MB encoded, many corporate relays at 10 MB.

The cron line is only rendered when all of CRON_BACKUP_NETBIRD, BACKUP_ZIP_PASSWORD, SMTP_HOST, SMTP_FROM, a recipient (BACKUP_EMAIL_TO / EXPORT_EMAIL_TO / SMTP_TO), and at least one source (BACKUP_PATHS or NB_ADMIN_API_KEY) are set. Missing prerequisites print a one-line warning on startup and disable the job.

Caveat: live SQLite databases

NetBird's management service writes to store.db (SQLite) continuously. A 7z of the live file may capture an in-progress transaction and the restored database can fail with database is malformed or silently lose the last few writes. The Sunday-03:00 default minimises but does not eliminate the risk.

For a strict hot-consistent backup of the management volume, either:

  • Pause NetBird briefly before the backup (in a wrapper cron job) and resume afterwards:
    docker compose pause management && \
      docker compose exec birdseye /app/.venv/bin/python /app/backup_volumes.py; \
      docker compose unpause management
  • Or pre-snapshot the DB with sqlite3 ".backup" and back up the snapshot file (works without stopping NetBird).

What's in the image

The image bundles the long-running forwarder plus the operator scripts that were already in this repo. supervisord is PID 1, supervising:

  • event_forwarder.py — long-running audit poller
  • cron -f — runs cleanup_ephemeral.py on the CRON_CLEANUP_EPHEMERAL schedule and, when configured, run_backup.sh on CRON_BACKUP_NETBIRD (which sequentially invokes backup_volumes.py and export_objects.py depending on what is configured)

The one-shot operator scripts are also baked in and can be invoked via docker exec:

docker exec birdseye /app/.venv/bin/python /app/list_policies.py
docker exec birdseye /app/.venv/bin/python /app/netbird_overview.py
docker exec birdseye /app/.venv/bin/python /app/cleanup_ephemeral.py --dry-run
docker exec birdseye /app/.venv/bin/python /app/allow_ping.py --help
docker exec birdseye /app/.venv/bin/python /app/manage_posture.py --help
docker exec birdseye /app/.venv/bin/python /app/setup_keys.py --help

uv lives only in the builder stage, so inside the running image use the venv interpreter directly. The same pattern applies to the backup scripts shown earlier in this README.

Local development (without Docker)

If you'd rather hack on the scripts directly:

uv sync
cp .env.example .env   # at repo root, edit with NB_URL + NB_API_KEY
uv run events.py                          # streaming console viewer (the dev predecessor of event_forwarder)
uv run list_policies.py                   # one-shot
uv run docker/event_forwarder.py          # forwarder, with /var/lib/birdseye replaced by $STATE_FILE

Releases

./release.sh bumps the version, updates CHANGELOG.md, tags the commit, and pushes — which triggers the release-docker workflow to build and publish multi-arch images to Docker Hub and GHCR.

./release.sh patch    # 0.1.0 → 0.1.1 (default)
./release.sh minor    # 0.1.0 → 0.2.0
./release.sh major    # 0.1.0 → 1.0.0

Notes

  • The netbird PyPI package is community-maintained and not affiliated with NetBird. Some of its pydantic models reject valid values (notably the netbird-ssh protocol enum) — allow_ping.py and manage_posture.py work around this by bypassing the typed write path and calling client.post() / client.put() with raw dicts. See CLAUDE.md for the gotcha details.
  • Network-traffic events (/api/events/network-traffic) are cloud-only; the audit-event endpoint is the only event stream available on self-hosted NetBird. Tracking upstream issue: netbirdio/netbird#3935.

License

MIT

About

Bird's-eye view of a self-hosted NetBird: audit-event forwarder + scheduled cleanup jobs

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors