{"id":51571823,"url":"https://github.com/forgekeep/nebula-mesh","last_synced_at":"2026-07-10T20:00:55.495Z","repository":{"id":357136221,"uuid":"1235536477","full_name":"forgekeep/nebula-mesh","owner":"forgekeep","description":"Self-hosted control plane for Slack Nebula mesh VPN — issue certificates, manage hosts, distribute config from one place. Go + SQLite + htmx.","archived":false,"fork":false,"pushed_at":"2026-07-01T10:35:32.000Z","size":4498,"stargazers_count":12,"open_issues_count":0,"forks_count":1,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-07-01T12:16:19.615Z","etag":null,"topics":["certificate-authority","devops","golang","htmx","infrastructure","mesh-network","nebula","networking","pki","self-hosted","sqlite","vpn","x509"],"latest_commit_sha":null,"homepage":"","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/forgekeep.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-11T12:21:48.000Z","updated_at":"2026-07-01T10:35:06.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/forgekeep/nebula-mesh","commit_stats":null,"previous_names":["juev/nebula-mesh","forgekeep/nebula-mesh"],"tags_count":19,"template":false,"template_full_name":null,"purl":"pkg:github/forgekeep/nebula-mesh","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/forgekeep%2Fnebula-mesh","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/forgekeep%2Fnebula-mesh/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/forgekeep%2Fnebula-mesh/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/forgekeep%2Fnebula-mesh/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/forgekeep","download_url":"https://codeload.github.com/forgekeep/nebula-mesh/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/forgekeep%2Fnebula-mesh/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35341768,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-10T02:00:06.465Z","response_time":60,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["certificate-authority","devops","golang","htmx","infrastructure","mesh-network","nebula","networking","pki","self-hosted","sqlite","vpn","x509"],"created_at":"2026-07-10T20:00:27.745Z","updated_at":"2026-07-10T20:00:55.386Z","avatar_url":"https://github.com/forgekeep.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# nebula-mesh\n\n[![CI](https://github.com/forgekeep/nebula-mesh/actions/workflows/ci.yml/badge.svg)](https://github.com/forgekeep/nebula-mesh/actions/workflows/ci.yml)\n[![Release](https://img.shields.io/github/v/release/forgekeep/nebula-mesh?display_name=tag\u0026sort=semver)](https://github.com/forgekeep/nebula-mesh/releases/latest)\n[![Go Reference](https://pkg.go.dev/badge/github.com/forgekeep/nebula-mesh.svg)](https://pkg.go.dev/github.com/forgekeep/nebula-mesh)\n[![Go Report Card](https://goreportcard.com/badge/github.com/forgekeep/nebula-mesh)](https://goreportcard.com/report/github.com/forgekeep/nebula-mesh)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n[![Go Version](https://img.shields.io/github/go-mod/go-version/forgekeep/nebula-mesh)](go.mod)\n\n\u003e Self-hosted control plane for [Slack's Nebula](https://github.com/slackhq/nebula) mesh VPN — issue certificates, manage hosts (including iOS / Android via QR), distribute config, rotate CAs, and roll out changes from one place.\n\n![Dashboard](docs/screenshots/dashboard.png)\n\n\u003csub\u003e\n\u003cstrong\u003eUI\u003c/strong\u003e:\n\u003ca href=\"docs/screenshots/hosts.png\"\u003ehosts\u003c/a\u003e ·\n\u003ca href=\"docs/screenshots/host-detail.png\"\u003ehost detail\u003c/a\u003e ·\n\u003ca href=\"docs/screenshots/host-new-advanced.png\"\u003ehost create (advanced)\u003c/a\u003e ·\n\u003ca href=\"docs/screenshots/networks.png\"\u003enetworks\u003c/a\u003e ·\n\u003ca href=\"docs/screenshots/profile.png\"\u003eprofile\u003c/a\u003e\u003cbr\u003e\n\u003cstrong\u003eAuth\u003c/strong\u003e:\n\u003ca href=\"docs/screenshots/login.png\"\u003elogin\u003c/a\u003e ·\n\u003ca href=\"docs/screenshots/register.png\"\u003eregister\u003c/a\u003e ·\n\u003ca href=\"docs/screenshots/2fa-setup.png\"\u003e2FA setup\u003c/a\u003e ·\n\u003ca href=\"docs/screenshots/2fa-enabled.png\"\u003e2FA enabled + recovery codes\u003c/a\u003e ·\n\u003ca href=\"docs/screenshots/login-totp.png\"\u003elogin → TOTP prompt\u003c/a\u003e\n\u003c/sub\u003e\n\n---\n\nNebula gives you a fast, mTLS-authenticated overlay network. But on its own, it leaves the operator to hand-roll certificate issuance, rotation, distribution and revocation — usually with shell scripts and a CA on a laptop. **nebula-mesh** is the missing management layer: a single Go binary plus an enrollment agent that turn Nebula into a self-service mesh you can run on one VM.\n\n### Install in 30 seconds\n\nOn a fresh Debian / Ubuntu VM (`amd64`):\n\n```sh\n# 1. Install the server.\nVERSION=0.7.3\ncurl -fsSLO \"https://github.com/forgekeep/nebula-mesh/releases/download/v${VERSION}/nebula-mgmt_${VERSION}_linux_amd64.deb\"\nsudo apt install -y \"./nebula-mgmt_${VERSION}_linux_amd64.deb\"\n\n# 2. Set the master key (required for CA encryption) and initialise.\nexport NEBULA_MGMT_MASTER_KEY=$(openssl rand -base64 32)\nsudo -E nebula-mgmt init --config /etc/nebula-mgmt/server.yml\nsudo systemctl enable --now nebula-mgmt\n```\n\n`init` binds `127.0.0.1:8080` by default, so the server is reachable locally: open `http://127.0.0.1:8080/ui/` (or tunnel with `ssh -L 8080:127.0.0.1:8080 \u003cserver\u003e`) and log in with the password printed by `init`.\n\nFor **remote access**, terminate TLS — set `tls_cert`+`tls_key`, or front with nginx/caddy/traefik and keep the loopback bind. Serving plaintext on a routable address is refused unless you explicitly set `allow_insecure_http: true` (or pass `--insecure-http`) — see [Security](#security).\n\nFor RPM / macOS / FreeBSD / Windows / Docker / source — and the host-side agent — see [Install](#install) below. For host enrolment and CLI walk-through, see [Quickstart](#quickstart).\n\n**Jump to:** [Why](#why) · [Features](#features) · [Architecture](#architecture) · [Install](#install) · [Quickstart](#quickstart) · [Operators \u0026 auth](#operators-auth-and-tenancy) · [Deployment](#deployment) · [Endpoints](#endpoints) · [Status](#status) · [Security](#security)\n\n\u003ca name=\"why\"\u003e\u003c/a\u003e\n## Why\n\nWhen this beats hand-rolled scripts or a managed service:\n\n| | Hand-rolled scripts | DefinedNetworking (managed) | **nebula-mesh** |\n|---|---|---|---|\n| Self-hosted | ✅ | ❌ | ✅ |\n| Web UI + REST API | ❌ | ✅ | ✅ |\n| Cert rotation \u0026 revocation | manual | ✅ | ✅ |\n| Single static binary | ✅ | n/a | ✅ |\n| Cost | your time | per-host | free (MIT) |\n| Lock-in | none | vendor | none |\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cstrong\u003eFeatures\u003c/strong\u003e — what nebula-mesh actually does\u003c/summary\u003e\n\u003ca name=\"features\"\u003e\u003c/a\u003e\n\n\n- **Web UI + REST API + CLI** — one server, three interfaces. Built with chi + Go templates + htmx (no SPA build step). Inline field-level form validation with state preservation on error.\n- **PKI lifecycle** — per-operator CAs encrypted at rest in SQLite under a process-wide AES-256-GCM master key (envelope encryption per [ADR 0002](docs/adr/0002-per-operator-cas.md)); per-host certs signed via `slackhq/nebula/cert`; blocklist-backed revocation. New operators get a default CA auto-provisioned on first sign-in.\n- **CA rotation** — when a CA approaches expiry (≤20% lifetime left), a warning badge appears in the UI; operators rotate manually with one click or opt into background auto-rotation. Existing host certificates remain valid until natural expiry. Details in [ADR 0008](docs/adr/0008-ca-rotation.md).\n- **Multi-operator** — local accounts, OIDC (Keycloak / Authentik / Okta / …), TOTP 2FA with recovery codes, configurable self-registration, per-operator API keys with atomic disable.\n- **Per-operator CAs** — each operator's networks form an isolated trust domain; non-admin operators cannot see or sign against another operator's CA. Network and host creation is gated on the operator owning at least one CA.\n- **Zero-trust enrollment** — hosts join with a single-use token; private keys never leave the host. Mobile hosts (iOS / Android) enroll via a self-contained QR code bundle.\n- **Auto-rotation** — agent polls the server, atomically writes new certs/config (temp + fsync + rename), reloads Nebula via `SIGHUP`. Agent supports idle-standby mode and a first-class `enroll` subcommand.\n- **Multi-address overlays** — networks and hosts can carry multiple overlay IPs (e.g. for dual-stack or multi-segment routing).\n- **Per-host advanced overrides** — `listen_host`, `mtu`, `tun_device`, `punchy`, `unsafe_routes` opt-in per host without touching the network default. Host records are editable via UI/API after creation (`PATCH /api/v1/hosts/{id}`).\n- **Audit trail** — every mutating UI / API / CLI call is recorded with actor, action, target, plus a stable `ca_id` on host events.\n- **Per-network firewall rules** — managed declaratively via API, distributed to all hosts.\n- **Production-ready basics** — `/healthz`, `/readyz`, Prometheus exporter at `/metrics` (legacy `expvar` view at `/debug/vars`), built-in cert-expiry alerter (audit + webhook + per-host Prometheus gauge), structured `slog` logs, optional in-process TLS, SQLite (WAL) with tracked migrations.\n- **Tiny footprint** — two static binaries (~15–25 MiB each), SQLite, no external deps. Runs on a $5 VM.\n\n\u003c/details\u003e\n\n\u003ca name=\"architecture\"\u003e\u003c/a\u003e\n## Architecture\n\nOne VM, two binaries:\n\n```\n┌──────────┐   REST/UI   ┌─────────────────────┐\n│ operator │ ──────────▶ │   nebula-mgmt       │\n└──────────┘   (HTTPS)   │  ┌───────────────┐  │\n                         │  │ chi API       │  │\n┌──────────┐   poll      │  │ web UI (htmx) │  │\n│ nebula-  │ ──────────▶ │  │ PKI + store   │  │\n│ agent    │ ◀────────── │  │ (SQLite WAL)  │  │\n│ + nebula │   updates   │  └───────────────┘  │\n└──────────┘             └─────────────────────┘\n   each host                  one VM / container\n```\n\n- `nebula-mgmt` — management server (HTTP API + web UI + CLI subcommands)\n- `nebula-agent` — runs on each Nebula host, polls for updates, atomically rewrites Nebula config, `SIGHUP`s Nebula\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cstrong\u003eInstall\u003c/strong\u003e — Linux packages, prebuilt binaries, Docker, from source\u003c/summary\u003e\n\u003ca name=\"install\"\u003e\u003c/a\u003e\n\n\nnebula-mesh ships **two** static binaries. Install whichever you need on each machine:\n\n| Binary | Where it runs |\n|---|---|\n| `nebula-mgmt` | one server (the control plane) |\n| `nebula-agent` | every Nebula host, next to `nebula` |\n\nPick an install method below. The examples assume `VERSION=0.7.3` — always replace with the latest from the [releases page](https://github.com/forgekeep/nebula-mesh/releases/latest). Each release ships a `checksums.txt` (SHA-256).\n\n\u003e ⚠️ **Install the latest release.** Versions older than the newest tag may be missing published security fixes — see the [security advisories](https://github.com/forgekeep/nebula-mesh/security/advisories). Do not pin to an old version unless you have verified it carries every advisory fix you need.\n\n### Debian / Ubuntu (`.deb`)\n\n```sh\nVERSION=0.7.3\nARCH=$(dpkg --print-architecture)   # amd64 | arm64 | armhf (agent only)\n\n# Server (control plane):\ncurl -fsSLO \"https://github.com/forgekeep/nebula-mesh/releases/download/v${VERSION}/nebula-mgmt_${VERSION}_linux_${ARCH}.deb\"\nsudo apt install -y \"./nebula-mgmt_${VERSION}_linux_${ARCH}.deb\"\n\n# Agent (each Nebula host):\ncurl -fsSLO \"https://github.com/forgekeep/nebula-mesh/releases/download/v${VERSION}/nebula-agent_${VERSION}_linux_${ARCH}.deb\"\nsudo apt install -y \"./nebula-agent_${VERSION}_linux_${ARCH}.deb\"\n```\n\n### RHEL / Fedora / Rocky / Alma (`.rpm`)\n\n```sh\nVERSION=0.7.3\nARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')\n\nsudo rpm -i \"https://github.com/forgekeep/nebula-mesh/releases/download/v${VERSION}/nebula-mgmt_${VERSION}_linux_${ARCH}.rpm\"\nsudo rpm -i \"https://github.com/forgekeep/nebula-mesh/releases/download/v${VERSION}/nebula-agent_${VERSION}_linux_${ARCH}.rpm\"\n```\n\n**What the package does.** Installs the binary to `/usr/bin/`, the systemd unit to `/lib/systemd/system/`, and an example config at `/etc/nebula-{mgmt,agent}/`. The service is **not** auto-started — run `nebula-mgmt init` (server) or `nebula-agent --server URL --token TOK` (agent) first, then `sudo systemctl enable --now nebula-{mgmt,agent}`. Configs are marked `noreplace`, so upgrades preserve your edits. `apt purge` / `dnf remove --purge` keeps `/etc/nebula-agent` and `/etc/nebula` so host keys survive accidental removal.\n\n### Prebuilt binaries (other Linux, macOS, FreeBSD, Windows)\n\nDownload a tarball from the [releases page](https://github.com/forgekeep/nebula-mesh/releases/latest) and extract:\n\n```sh\nVERSION=0.7.3\nOS=$(uname -s | tr '[:upper:]' '[:lower:]')\nARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')\n\n# Pick BIN=nebula-mgmt (server) or BIN=nebula-agent (host)\nBIN=nebula-mgmt\ncurl -fsSL \"https://github.com/forgekeep/nebula-mesh/releases/download/v${VERSION}/${BIN}_${VERSION}_${OS}_${ARCH}.tar.gz\" | tar -xz\n```\n\nSupported targets:\n\n| | linux/amd64 | linux/arm64 | linux/armv7 | darwin/amd64 | darwin/arm64 | freebsd/amd64 | freebsd/arm64 | windows/amd64 |\n|---|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|\n| `nebula-mgmt` | ✅ | ✅ | – | ✅ | ✅ | – | – | – |\n| `nebula-agent` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n\n### Docker\n\n```sh\n# Server:\ndocker run -d --name nebula-mgmt \\\n  -p 8080:8080 \\\n  -v nebula-mgmt-data:/var/lib/nebula-mgmt \\\n  -v nebula-mgmt-etc:/etc/nebula-mgmt \\\n  -e NEBULA_MGMT_MASTER_KEY \\\n  ghcr.io/forgekeep/nebula-mgmt:latest\n\n# Agent (typically sidecar to nebula, sharing the same PID namespace):\ndocker run -d --name nebula-agent \\\n  -v /etc/nebula-agent:/etc/nebula-agent \\\n  -v /etc/nebula:/etc/nebula \\\n  ghcr.io/forgekeep/nebula-agent:latest\n```\n\nThe image runs with `--insecure-http` (TLS belongs at your ingress/reverse proxy), so set `listen: \":8080\"` in the mounted `server.yml` for the published port to be reachable — the bare-metal default of `127.0.0.1:8080` would not be.\n\nImages: `ghcr.io/forgekeep/nebula-mgmt`, `ghcr.io/forgekeep/nebula-agent`. Tags: `:latest` and `:X.Y.Z` (semver, no `v` prefix). See [Packages](https://github.com/orgs/forgekeep/packages?repo_name=nebula-mesh).\n\n### From source\n\nRequires Go 1.26+.\n\n```sh\ngit clone https://github.com/forgekeep/nebula-mesh\ncd nebula-mesh\nmake build   # → bin/nebula-mgmt, bin/nebula-agent\n```\n\nVerify install with `nebula-mgmt version` / `nebula-agent --version`.\n\nLifecycle references: [`docs/server.md`](docs/server.md) (server), [`docs/agent.md`](docs/agent.md) (agent).\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cstrong\u003eQuickstart\u003c/strong\u003e — run the server, enroll a host, manage from the CLI\u003c/summary\u003e\n\u003ca name=\"quickstart\"\u003e\u003c/a\u003e\n\n\n### Run the server\n\n```sh\nsudo mkdir -p /var/lib/nebula-mgmt /etc/nebula-mgmt\nsudo cp configs/server.example.yml /etc/nebula-mgmt/server.yml\n\n# Generate a master key for CA encryption (required) and export it.\nexport NEBULA_MGMT_MASTER_KEY=$(openssl rand -base64 32)\n\n# One-time: initializes the database and provisions an admin-default CA.\nsudo -E bin/nebula-mgmt init --config /etc/nebula-mgmt/server.yml\n\n# Serve.\nsudo -E bin/nebula-mgmt serve --config /etc/nebula-mgmt/server.yml\n```\n\nOpen `http://localhost:8080/ui/` — log in as `admin` with the password configured in `ui_password` (falls back to the API key shown by `init`).\n\nNon-interactive deployments (systemd, Docker): set `NEBULA_MGMT_MASTER_KEY` via environment variable or `master_key` in `server.yml`.\n\n### Enroll a host\n\n**Server / desktop / VM** — create a host record on the server (CLI or Web UI), grab the one-time enrollment token, run `nebula-agent` on the host with `--server` + `--token` once, then put the agent under systemd. The agent keeps `host.crt` / `host.key` / `host.signing.key` / `ca.crt` / `config.yml` in sync, signs every poll with the per-host Ed25519 key generated at enrollment (ADR 0004), and exits 0 when the server returns `403 revoked` or `410 gone`.\n\n**iOS / Android** — create the host with type **Mobile bundle**; the Web UI then renders a QR-code bundle (cert + key + CA + config) that the official Nebula mobile app scans to enrol in one step. No agent runs on the device; rotation requires re-issuing a new bundle. See [`docs/agent.md`](docs/agent.md) for the bundle format.\n\n\u003e Full nebula-agent operations guide: [`docs/agent.md`](docs/agent.md) — installation, configuration, enrollment + systemd hand-off, signed-poll headers, force-rotate / re-enroll endpoints, troubleshooting, upgrade, and security notes.\n\n### Manage hosts from the CLI\n\n```sh\n# List hosts (optionally filter by network)\nnebula-mgmt host list --server https://mgmt.example.com:8080 --api-key \"$API_KEY\"\n\n# Block a host (revokes cert via blocklist, status → blocked)\nnebula-mgmt host block   --server ... --api-key \"$API_KEY\" --id \"$HOST_ID\"\n\n# Unblock a host (status → pending; re-enrollment required for a new cert)\nnebula-mgmt host unblock --server ... --api-key \"$API_KEY\" --id \"$HOST_ID\"\n\n# Delete a host (also blocklists any existing cert)\nnebula-mgmt host delete  --server ... --api-key \"$API_KEY\" --id \"$HOST_ID\"\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cstrong\u003eOperators, auth, and tenancy\u003c/strong\u003e — accounts, TOTP, OIDC, self-registration, per-operator CAs\u003c/summary\u003e\n\u003ca name=\"operators-auth-and-tenancy\"\u003e\u003c/a\u003e\n\n\nEach interactive admin should have their own operator account and per-operator API key. On `nebula-mgmt init`, an admin operator is seeded from `ui_password` (or, if that is empty, an auto-generated value used solely for the admin's bcrypt password hash). The admin's first operator API key is generated freshly inside init and printed to stdout once — capture it then; the server does not persist the plaintext to disk. Lost the key? Run `nebula-mgmt ops mint-admin-key --config \u003cpath\u003e` to mint a new admin API key.\n\n### Manage operators\n\n```sh\n# List operators\nnebula-mgmt user list --server ... --api-key \"$ADMIN_KEY\"\n\n# Create another operator (admin-only API)\nnebula-mgmt user create --server ... --api-key \"$ADMIN_KEY\" \\\n  --username alice --password 's3cret!' --display-name \"Alice\"\n\n# Per-operator API key (token shown once)\nnebula-mgmt apikey create --server ... --api-key \"$ADMIN_KEY\" \\\n  --operator \"$ALICE_ID\" --name laptop-cli\nnebula-mgmt apikey revoke --server ... --api-key \"$ADMIN_KEY\" \\\n  --operator \"$ALICE_ID\" --id \"$KEY_ID\"\n\n# Disable / re-enable an operator — invalidates sessions and API keys atomically\nnebula-mgmt user disable --server ... --api-key \"$ADMIN_KEY\" --id \"$ALICE_ID\"\nnebula-mgmt user enable  --server ... --api-key \"$ADMIN_KEY\" --id \"$ALICE_ID\"\n```\n\nAudit log entries (`/api/v1/audit-log`) record the actor for every mutating operator/host action.\n\n### Two-factor authentication (TOTP)\n\nOpen `/ui/2fa`, click **Enable 2FA**, scan the displayed `otpauth://` URL with 1Password / Bitwarden / Google Authenticator / Aegis / Authy / any compatible app, and confirm with a 6-digit code. The server then shows ten one-time recovery codes — save them offline. On the next login the UI asks for the 6-digit code (or one recovery code) after the password. Disabling 2FA requires re-confirming the current password. All sensitive operations (`operator.2fa.enabled`, `disabled`, `regen_codes`, `failed`, `verified`) appear in the audit log. API tokens are unaffected.\n\n**Admin enforcement.** Set `enforce_2fa: true` in `server.yml` (or `PATCH /api/v1/settings` with `{\"enforce_2fa\": true}` at runtime). Every local operator without TOTP is then routed to `/ui/2fa/required` after a successful password login and cannot reach any other UI page until enrolment finishes. `POST /ui/2fa/disable` returns `403` while the toggle is on and writes an `operator.2fa.enforced.disable_blocked` audit entry. OIDC operators are exempt — their second factor lives at the IdP.\n\n### Single sign-on via OIDC\n\nConfigure an `oidc:` block in `server.yml` (see `configs/server.example.yml`) to enable operator login through Keycloak / Authentik / Dex / Google Workspace / Okta / any standard OpenID Connect provider. The login page then shows a **Sign in with SSO** button alongside the local form.\n\n```yaml\noidc:\n  enabled: true\n  issuer: \"https://keycloak.example.com/realms/nebula\"\n  client_id: \"nebula-mesh\"\n  client_secret: \"\u003cfrom your provider\u003e\"\n  redirect_url: \"https://mgmt.example.com:8080/ui/oidc/callback\"\n  scopes: [\"openid\", \"profile\", \"email\", \"groups\"]\n  allowed_groups: [\"nebula-admins\"]\n```\n\nThe first successful login for an unknown subject creates a local operator record (`auth_provider=oidc`) tied to the `issuer+subject` pair. Local and OIDC users coexist; revoke an OIDC user by disabling the local record or removing them in the IdP.\n\n### Configurable self-registration\n\nBy default only administrators can create operator accounts. Set `allow_self_registration: true` in `server.yml`, or flip it from **Settings → Allow self-registration** in the Web UI, to let unauthenticated visitors sign up via `/ui/register`. Server-side checks gate the endpoint independently of the UI, so flipping the flag is enough to block self-registration. Self-registered operators get the `user` role; the operator-management API (`POST /api/v1/operators`, `disable`, etc) requires `role: admin`.\n\n### Settings page\n\n`/ui/settings` (admin-only — non-admins are 403'd, and the sidebar entry is hidden) exposes the runtime knobs administrators can flip without restarting the server: admin-enforced 2FA, self-registration, password policy (min length, required character classes, common-password blocklist, username block), and log level. Saved values land in the `server_settings` table; every save writes a `settings.update` audit-log entry. `server.yml` becomes the bootstrap snapshot — `allow_self_registration:` and `enforce_2fa:` are seeded once and then the DB row wins. Secrets (`master_key`, OIDC client secret, TLS file paths) stay in `server.yml` only.\n\n### Per-operator CAs\n\nWith `NEBULA_MGMT_MASTER_KEY` configured, operators can run their networks under isolated CAs. Mint, browse, retire, and delete CAs from the CLI (below), the REST API (`/api/v1/cas*`), or the Web UI at `/ui/cas` — every flow shares the same ownership check, so a non-admin operator only sees the CAs they own.\n\n```sh\n# Create a CA scoped to a real operator (the legacy config key is denied)\nnebula-mgmt ca create --server ... --api-key \"$OPERATOR_KEY\" --name tenant-a\n# → prints CA id + fingerprint\n\nnebula-mgmt ca list   --server ... --api-key \"$OPERATOR_KEY\"\nnebula-mgmt ca delete --server ... --api-key \"$OPERATOR_KEY\" --id \"$CA_ID\"\n```\n\nNon-admin operators see and manage only the CAs they own; admins see all. Hosts enrolled under a tenant CA receive **that** CA's certificate, not the default one. Audit log entries (`ca.created`, `ca.deleted`, plus existing `host.*` events with the host's `ca_id`) record both the actor and the affected CA. See [ADR 0002](docs/adr/0002-per-operator-cas.md) for the encryption-at-rest design.\n\n**CA rotation**: when a CA approaches its expiry (≤20% lifetime remaining), the UI shows a warning badge on the CA pages. Operators can click **Rotate** to create a successor CA; existing host certificates remain valid until their natural expiry. CLI: `nebula-mgmt ca rotate \u003cid\u003e`. Optional opt-in auto-rotation: set `ca_auto_rotate.enabled: true` in `server.yaml` to enable automatic rotation (disabled by default). See [ADR 0008](docs/adr/0008-ca-rotation.md) for the hybrid model and trust bundle distribution.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cstrong\u003eDeployment\u003c/strong\u003e — Docker / systemd / TLS, and how to back up the DB + master key\u003c/summary\u003e\n\u003ca name=\"deployment\"\u003e\u003c/a\u003e\n\n\n- **Docker** — `docker build -t nebula-mgmt .` (Dockerfile in repo).\n- **systemd** — unit files in [`deploy/systemd/`](deploy/systemd/).\n- **TLS** — set `tls_cert` + `tls_key` for in-process TLS, or front with nginx/caddy/traefik. Working snippets for all three live in [`deploy/reverse-proxy/`](deploy/reverse-proxy/) and ship inside the `.deb`/`.rpm` at `/usr/share/doc/nebula-mgmt/reverse-proxy/`. Each is opinionated, preserves `X-Forwarded-For`, and disables buffering on `/ui/events` so the SSE feed reaches the browser in real time.\n- **Rate limiting** — on by default. The Web UI, auth endpoints, `/api/v1/enroll`, and the bearer-authenticated admin API each run their own per-IP token bucket. Defaults: 5 req/s on login forms (burst 10), 2 req/s on enrolment (burst 5), 30 req/s on UI + admin API (burst 60), 60 req/s on agent polls (burst 120). Health (`/healthz`, `/readyz`, `/metrics`, `/debug/vars`, `/favicon.ico`, `/static/*`) is exempt. Run behind a reverse proxy? Set `rate_limit.trust_proxy_header: true` so the limiter keys on `X-Forwarded-For` instead of the proxy's connection address.\n\n### Backups \u0026 key handling\n\nPer [ADR 0002](docs/adr/0002-per-operator-cas.md) (which removed [ADR 0001](docs/adr/0001-ca-key-storage.md)), CA private keys live encrypted inside SQLite using envelope encryption. The master key (`NEBULA_MGMT_MASTER_KEY`, 32 random bytes, base64-encoded) is supplied at startup and **never written to disk or the DB**. Backups collapse to a single file:\n\n```sh\nsudo cp /var/lib/nebula-mgmt/nebula.db /backups/nebula-$(date +%F).db\n```\n\nKeep `NEBULA_MGMT_MASTER_KEY` in your secret manager — both the DB and the master key are required to mint a certificate.\n\nThe server administrator can decrypt every CA on the box (the master key is in process memory while the server runs). We accept this for the single-binary deployment story — see [ADR 0003](docs/adr/0003-ca-encryption-model.md) for the alternatives (operator-derived KEK, zero-knowledge, external signer) and why we did not adopt them today.\n\n\u003c/details\u003e\n\n\u003ca name=\"endpoints\"\u003e\u003c/a\u003e\n## Endpoints\n\nThe router (issue #69) splits the listener three ways: `/api/` for the API surface, root + `/ui/` for the Web UI, and a fixed list of ops paths kept at the root for monitoring scrapes. The bare `/` redirects browsers to `/ui/`.\n\n| Prefix | Path | Auth | Purpose |\n|---|---|---|---|\n| UI | `/` | (redirect) | 302 to `/ui/` so first-time visitors land on the dashboard. |\n| UI | `/ui/` | session cookie | web UI (rate-limited, see below). |\n| UI | `/static/*`, `/favicon.ico` | none | UI assets. |\n| API | `/api/v1/enroll` | enrollment token + signing public key | agent first-contact. |\n| API | `/api/v1/agent/updates` | signed PoP headers (ADR 0004) | agent poll. |\n| API | `/api/v1/...` | `Bearer \u003capi_key\u003e` | admin REST API. |\n| Ops | `/healthz` | none | liveness. |\n| Ops | `/readyz` | none | readiness (DB reachable). |\n| Ops | `/metrics` | none | Prometheus exposition (see [`internal/api/metrics.go`](internal/api/metrics.go)). Disable via `metrics.prometheus: false`. |\n| Ops | `/debug/vars`, `/debug/pprof/*` | none | Go `expvar` / pprof. |\n| Ops | `/health` | none | legacy alias for `/healthz`. |\n\nFull route list in [`internal/api/server.go`](internal/api/server.go) and [`internal/web/web.go`](internal/web/web.go).\n\n### Per-endpoint rate-limit groups\n\nOn by default; configurable in `server.yml`. Each group is a separate per-IP token bucket:\n\n| Group | Default (req/s / burst) | Endpoints |\n|---|---|---|\n| `auth` | 5 / 10 | `POST /ui/login`, `POST /ui/login/totp`, `POST /ui/register` |\n| `ui` | 30 / 60 | every other `/ui/...` page + the admin REST API under `/api/v1/...` |\n| `enroll` | 2 / 5 | `POST /api/v1/enroll` |\n| `agent_poll` | 60 / 120 | `GET /api/v1/agent/updates` |\n\nOps endpoints (`/healthz`, `/readyz`, `/metrics`, `/debug/`, `/favicon.ico`, `/static/*`) are exempt so scrapes never get 429s. Behind a reverse proxy, set `rate_limit.trust_proxy_header: true` to key the buckets on `X-Forwarded-For` instead of the proxy's loopback connection.\n\n### Optional: mTLS on the UI only\n\n`crypto/tls.ClientAuth` is a listener-level setting, so per-route client-cert verification lives in the reverse proxy rather than in `nebula-mgmt` itself. Each snippet in [`deploy/reverse-proxy/`](deploy/reverse-proxy/) ships a commented-out block that gates `/ui/` (and `/`) behind a client cert while leaving `/api/` and the ops endpoints reachable without one — agents and Prometheus do not present operator certificates.\n\n\u003ca name=\"status\"\u003e\u003c/a\u003e\n## Status\n\n**Beta.** Core flows (init, enroll, poll, rotate, revoke, audit, multi-CA) are covered by unit + integration tests with `-race`. API surface is not yet frozen — expect breaking changes until `v1.0.0`. Please open issues for anything rough.\n\n\u003ca name=\"security\"\u003e\u003c/a\u003e\n## Security\n\n- **Authentication.** Interactive logins are bcrypt-verified against the operator's password; sessions are DB-backed and revoked atomically on `user disable`. Optional TOTP 2FA + recovery codes. Optional OIDC SSO.\n- **Authorization.** Operator-management API and CA-management API require `role: admin`; non-admin operators can only see and act on the CAs they own.\n- **API keys.** Per-operator, stored as SHA-256 hashes — disable an operator and every key revokes in the same transaction. All admin authentication runs through DB-backed operator_api_keys (SHA-256 hashed).\n- **CA key material.** Stored encrypted at rest in SQLite under a process-wide AES-256-GCM master key (`NEBULA_MGMT_MASTER_KEY`), supplied at startup and never persisted. See [ADR 0002](docs/adr/0002-per-operator-cas.md) for the threat-model discussion and [ADR 0003](docs/adr/0003-ca-encryption-model.md) for the operator-derived-KEK / zero-knowledge alternatives we evaluated and deferred.\n- **Transport.** Always run the management server behind TLS — set `tls_cert` + `tls_key`, or front with nginx/caddy/traefik. Without TLS the server binds only a loopback address by default; it refuses to serve cleartext on a routable address unless you opt in with `allow_insecure_http: true` (or `--insecure-http`) — credentials would otherwise transit in the clear (#179).\n- **Disclosure.** Report vulnerabilities privately — see [SECURITY.md](SECURITY.md).\n\n## Contributing\n\nIssues, PRs, and [Discussions](https://github.com/forgekeep/nebula-mesh/discussions) welcome — ask setup questions in [Q\u0026A](https://github.com/forgekeep/nebula-mesh/discussions/categories/q-a) or share how you run it in [Show and tell](https://github.com/forgekeep/nebula-mesh/discussions/categories/show-and-tell). See [CONTRIBUTING.md](CONTRIBUTING.md) for the workflow and `make test \u0026\u0026 make lint` before opening a PR.\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n\n## Acknowledgements\n\nBuilt on top of [`slackhq/nebula`](https://github.com/slackhq/nebula). nebula-mesh is an independent project and is not affiliated with or endorsed by Slack.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fforgekeep%2Fnebula-mesh","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fforgekeep%2Fnebula-mesh","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fforgekeep%2Fnebula-mesh/lists"}