README.md
Rama main · réplica de lectura del repositorio original.
<p align="center">
<img src="internal/web/static/logo.svg" width="96" height="96" alt="wgroster logo">
</p>
<h1 align="center">wgroster</h1>
<p align="center"><em>Self-service WireGuard configs and live fleet status.</em></p>
<p align="center">
<img src="docs/screenshots/status.png" alt="wgroster status dashboard" width="880">
</p>
**wgroster** is a minimalist, self-hosted portal where users grab their own
WireGuard configuration and admins keep a live view of every concentrator and
peer across the fleet.
It is **tracking-only**: it is the source of truth for *who gets which config*
and shows *what each concentrator reports*, but it **never connects to or writes
to your WireGuard servers**. Provisioning peers on the servers stays in your
hands (manually, via Ansible, or via the bundled agent that runs **on** the
concentrator).
## Features
**Self-service (users)**
- Sign in with LDAP; register a machine by pasting its **public key** — the
private key never reaches the server.
- Generate a key pair **in the browser** (no `wg` CLI needed), or paste an
existing one.
- Download a ready-to-use `.conf`, or scan a **QR code** from the mobile app.
- Optional **scan-once self-enrollment** (`self_enroll`): name a device, the
browser generates the keys and an IP is reserved on the spot, so a complete
config/QR is shown immediately — it connects once an admin approves (guarded
by `max_pending_per_user` and `pending_expiry_days`).
**Administration**
- Declare VPN **endpoints** (concentrators) with their public key, `host:port`,
`AllowedIPs`, DNS, MTU and keepalive; each gets a rotatable **upload token**
and a downloadable **concentrator `wg0.conf`** (interface + all peers).
- Approve pending machines, or **create & activate** a machine directly.
- **Disable** a machine to take it out of service without deleting it: it leaves
every concentrator's expected peer list but keeps its address, endpoint links
and history, so enabling it again is one click.
- IP addresses from a global pool with **next-free suggestion** and uniqueness
validation; link a machine to **one or more endpoints** (multi-site).
- Endpoint fields are validated on the spot (`AllowedIPs`, `host:port`, DNS,
tunnel IP, MTU, keepalive), and two endpoints whose `AllowedIPs` **overlap**
cannot be combined on one machine — WireGuard would route the shared networks
through only one of the peers, leaving the other tunnel silently unused.
- Edit everything (name, key, address, endpoints) from a clean modal.
**Source of truth & drift detection**
- Concentrators push `wg show dump`; wgroster compares it to the declared peers
and flags **online / offline / missing-on-hub / not-linked-here / unexpected**
and **AllowedIPs mismatch**.
- Resolve drift from the peer drawer in one step: **adopt** a peer whose key the
portal has never seen (importing a config that predates wgroster), or **link /
approve** a known machine the hub already carries.
- Exposes the **expected peer list** (`wg` / `tsv` / `json`) so external tooling
(or the bundled agent) can reconcile the servers.
**Offboarding**
- Once a day the portal asks the directory whether every machine owner still
exists. An account that stays gone for `orphan_grace_days` is flagged on the
machines page, in the audit log and on the alert webhook — and, with
`orphan_action: disable`, its machines are **disabled** and drop out of every
concentrator's expected peer list.
- The dormancy check reports machines that have not handshaked for
`dormant_days` (and those approved long ago that never connected), optionally
disabling them — a device nobody uses any more keeps a working peer otherwise.
**Observability**
- Live **status dashboard** (auto-refresh) with per-peer **throughput**.
- Click a peer for a **detail drawer**: rx/tx **traffic curves** (14-day
history), recent reports, identity/drift, and remote-IP info — reverse DNS
plus optional **offline GeoIP** (MaxMind, looked up locally, no external call).
- **Prometheus** `/metrics`, optional **alert webhook**, and an **admin audit
log**.
**Auth & accounts**
- OpenLDAP (admin via group membership, display name from `cn`, and avatar from
`jpegPhoto` when present — shown in the navbar and on the admin machines list;
names and photos of users who never logged in are resolved too, via the
`search_bind_dn` service account or anonymously when the directory allows it).
- Optional **local admin** (bcrypt) to bootstrap or run without LDAP, with
self-service password change.
- HMAC-signed session cookies, CSRF protection, login rate-limiting.
**UI & ops**
- **System / Light / Dark** theme.
- Single static Go binary, pure-Go SQLite (no CGO), Docker image.
- Front-end assets **self-hosted & embedded** (no CDN) → **strict CSP** (no
inline JS/eval); content-hashed for cache-busting.
- Graceful shutdown, security headers (HSTS behind HTTPS), reverse-proxy aware.
## Screenshots
| Per-peer detail drawer (traffic, geo, drift) | Admin — machines grouped by user |
| :--: | :--: |
|  |  |
| **Self-service dashboard** | **Dark mode** |
|  |  |
<sub>More in [`docs/screenshots/`](docs/screenshots) (endpoints, audit log, login). Regenerate with `scripts/screenshots.sh`.</sub>
## Quickstart (2 minutes, no LDAP)
Prebuilt images are published to the GitHub Container Registry, so all you need
is Docker:
```sh
mkdir -p data
cat > config.yaml <<'EOF'
listen: ":8080"
session_key: "dev-only-change-me"
database: "/data/wgroster.db"
vpn_cidr: "10.0.0.0/16"
local_admin:
username: "admin"
password: "admin" # dev only; use password_hash in production
EOF
docker run --rm -p 8080:8080 \
-v "$PWD/config.yaml:/config/config.yaml:ro" \
-v "$PWD/data:/data" \
ghcr.io/wgroster/wgroster:latest
```
Prefer building from source? See [Binary](#binary) below.
Open <http://127.0.0.1:8080>, sign in as `admin` / `admin`, then:
1. **Endpoints** → create one (any public key / `host:port` works to explore).
2. **My machines** → add a machine; click **Generate a keypair in your browser**.
3. **Machines** (admin) → open the machine, assign an IP and the endpoint, save.
4. Back to **My machines** → **View config** to download the `.conf` or QR code.
No LDAP, no WireGuard install required to try the flow.
## How it works
Each machine has a **single** WireGuard interface (one `Address` from the global
`vpn_cidr` pool, which may span several ranges) and one `[Peer]` block per linked endpoint. Client IPs are
globally unique across all sites; each endpoint declares the networks reachable
behind it via its `AllowedIPs`.
```
internal/
config/ YAML configuration geoip/ optional offline MaxMind lookup
store/ SQLite persistence (pure Go) ldap/ OpenLDAP bind + admin group
auth/ signed session cookies + CSRF ipam/ global client address pool
wg/ config gen + "wg show" parse web/ handlers, templates, embedded assets
```
## How does it compare to wg-portal?
[wg-portal](https://github.com/h44z/wg-portal) is the reference project in this
space and it is excellent — if it fits your needs, use it. The two tools solve
different problems:
| | wgroster | wg-portal |
| --- | --- | --- |
| Relationship to the servers | never connects to them; agents push status and pull the expected peer list | manages the interfaces directly (wgctrl, MikroTik, pfSense) |
| Unit of management | a user's machine: one address, N endpoints | a WireGuard interface and its peers |
| Private keys | never leave the client | generated and stored server-side, so configs can be emailed |
| Fleet view | status dashboard, per-peer traffic history, drift detection | Prometheus metrics for Grafana |
Pick wgroster if your concentrators are provisioned by something else (Ansible,
an appliance, a team that owns them) and you need a roster plus an honest view
of what each hub actually reports. Pick wg-portal if you want the portal itself
to configure WireGuard, or if you need OAuth/passkeys, per-peer expiry, email
delivery or a database other than SQLite.
## Configuration
Copy `config.example.yaml` to `config.yaml` and adjust it. Key fields:
- `vpn_cidr` — global client address pool (e.g. `10.0.0.0/16`), or a list of
non-overlapping ranges (`["10.0.0.0/16", "10.8.0.0/24"]`) filled in order.
- `ldap.*` — OpenLDAP URL, bind DN pattern, admin group, name attribute, photo
attribute (`photo_attr`, default `jpegPhoto`), optional service account.
- `session_key` — secret for signing cookies (`openssl rand -hex 32`), or the
`WG_SESSION_KEY` environment variable.
- `local_admin` (optional) — built-in admin, checked before LDAP. LDAP becomes
optional when it is set.
- `audit_retention_days` — prune admin audit entries older than N days
(0 = keep forever).
- `orphan_grace_days`, `orphan_action` — directory offboarding check
(0 = off); see [Offboarding](#offboarding).
- `metrics_token`, `alert_webhook_url`, `geoip_db` / `geoip_asn_db`,
`cookie_secure`, `trusted_proxy` — see the sections below.
Generate a bcrypt hash for the local admin password:
```sh
echo -n 's3cret' | ./wgroster -hash-password # -> $2a$10$... (local_admin.password_hash)
```
## Running
### Docker
```sh
cp config.example.yaml config.yaml # then edit it
docker compose up -d
```
Compose pulls `ghcr.io/wgroster/wgroster:latest`; the database lives in `./data`
and the config is mounted read-only. To build the image locally instead,
uncomment `build: .` in `docker-compose.yml` and run `docker compose up -d
--build`. Set `TZ` (e.g. `environment: { TZ: Europe/Paris }`) for local times in
the UI/audit log.
Image tags: `latest` (default branch), `vX.Y.Z` / `vX.Y` (releases), and the
commit SHA. Pin a release tag in production.
### Binary
```sh
go build -o wgroster ./cmd/wgroster
./wgroster -config config.yaml
```
## Concentrators
Each concentrator pushes its status regularly. The **agent kit** automates this
and runs entirely **on the concentrator** — wgroster never connects to it.
```sh
install -m755 scripts/wgroster-agent.sh /usr/local/bin/wgroster-agent.sh
cp scripts/wgroster-agent.{service,timer} /etc/systemd/system/
# edit WG_PORTAL_URL / WG_ENDPOINT_ID / WG_TOKEN / WG_RECONCILE in the .service
systemctl daemon-reload
systemctl enable --now wgroster-agent.timer
```
- `WG_RECONCILE=0` (default) → **monitoring only** (push `wg show dump`).
- `WG_RECONCILE=1` → also **reconcile**: pull the expected peers and apply them
locally with `wg syncconf` (add/update/remove in a single call, leaving the
sessions of unchanged peers alone) so the hub matches wgroster. An empty
answer from the portal is treated as a truncated response and skipped, so a
hiccup can never wipe the hub's peers.
**Prefer the systemd timer over cron.** A failed `oneshot` run is recorded in
the journal (`journalctl -u wgroster-agent`) and shows up in
`systemctl --failed` — it never emails you. The agent also retries transient
network blips (`curl --retry`), so a brief outage doesn't even count as a
failure. If you must use cron instead, remember cron mails on *any* output: set
`MAILTO=""` in the crontab, or route diagnostics to the journal
(`… 2>&1 | logger -t wgroster-agent`) rather than blindly discarding them with
`>/dev/null`. You don't need local mail to catch a silent concentrator anyway —
wgroster alerts on it centrally via `wg_last_report_age_seconds` (see
[Monitoring & alerting](#monitoring--alerting)).
Or do it by hand (the ready-made commands and token are on the **Endpoints**
admin page):
```sh
wg show all dump | curl -s -H "Authorization: Bearer <TOKEN>" \
--data-binary @- https://wgroster.example.com/api/endpoints/<ID>/status
curl -s -H "Authorization: Bearer <TOKEN>" \
"https://wgroster.example.com/api/endpoints/<ID>/expected-peers?format=wg"
```
**Bootstrapping a new concentrator.** Every endpoint exposes a complete
`wg0.conf` for the hub itself: an `[Interface]` section (tunnel address, listen
port, MTU — with a `PrivateKey` placeholder you fill in, since wgroster never
sees private keys) followed by one `[Peer]` per assigned machine. Unlike
`expected-peers` (which returns only the peer list for ongoing reconciliation),
this is the full file for first-time setup. It is available three ways:
- **Concentrator setup** panel on the **Endpoints** admin page — shown inline,
copyable, and downloadable.
- Pulled with the endpoint's upload token (no portal login), e.g. on first boot:
```sh
curl -s -H "Authorization: Bearer <TOKEN>" \
"https://wgroster.example.com/api/endpoints/<ID>/config" > /etc/wireguard/wg0.conf
```
Then fill in the private key and `wg-quick up wg0`.
## Offboarding
The portal is the source of truth for *who* gets a config, so an account that
disappears from the directory must not leave a working peer behind. With
`orphan_grace_days` set (LDAP required), the hourly maintenance sweep asks the
directory once a day whether each machine owner still has an entry:
```yaml
orphan_grace_days: 7 # consecutive days absent before acting
orphan_action: "flag" # flag (report only) | disable (take machines out of service)
```
- **flag** (default) — the owner gets a *Not in directory* badge on the machines
page, an `owner.orphaned` entry in the audit log and an alert webhook. Nothing
else changes: you decide what to do.
- **disable** — additionally marks the owner's active machines **disabled**,
which removes them from `expected-peers` on every concentrator (the agent then
drops the peer on its next run). The address and endpoint links are kept, so if
the account comes back it is one click to enable them again.
The failure mode that matters here is the *false* positive, so the check is
built to fail safe:
- an absence only counts when the directory actually answered — an unreachable
server, a refused bind or a revoked service account is "no information", never
a departure;
- it takes `orphan_grace_days` consecutive daily answers, and one presence
resets the counter (a re-created account recovers on its own);
- a round where **no owner at all could be confirmed present**, or where a
**majority** look absent, is refused outright and logged: a wrong
`bind_dn_pattern`, a revoked search account or a directory that stopped
allowing anonymous reads makes everyone look gone, and that must not
disconnect the fleet. A portal with a single machine owner therefore never
flags — there is no second owner to corroborate the directory.
Disabled is a status of its own, deliberately not "pending": an offboarded
device is not awaiting review, so it stays out of the queue, out of
`max_pending_per_user` and out of `pending_expiry_days` — its address is never
silently freed while the account is gone. Machines that *are* sent back to
pending (a user changing the public key of an active or disabled machine — a
disabled one cannot be re-enabled with a key nobody reviewed) are subject to
`pending_expiry_days`
like any other, counted from the moment they entered the queue rather than from
their creation, so you get the full review window before the address is freed.
## Dormant devices
An offboarded *owner* is only half the problem: a device that simply stopped
being used keeps a working peer forever. With `dormant_days` set, the same
hourly sweep looks at what the concentrators report:
```yaml
dormant_days: 90 # days without a handshake before acting
dormant_action: "flag" # flag (report only) | disable (take out of service)
```
The portal records the latest handshake it ever saw for each public key, so the
answer survives the peer disappearing from a hub — a machine no concentrator
carries any more is *more* dormant, not less. A machine that was approved more
than `dormant_days` ago and never connected at all counts too.
A dormant machine gets an orange badge on the machines page, a
`machine.dormant` (or `machine.dormant_disabled`) entry in the audit log and an
alert webhook, once — not on every sweep. If the device shows up again the flag
clears on its own; a machine that was *disabled* stays disabled until an
administrator enables it.
The same fail-safe reasoning as offboarding applies, because the same kind of
mistake is possible — silence that says nothing about the devices:
- no endpoint has reported in the last hour → nobody is telling the portal what
is happening, so nothing is dormant;
- more than half the active machines look dormant → the shape of an outage that
has just ended (every agent stopped, a rotated upload token, the portal itself
down for a week), not of a fleet. A portal with a single active machine
therefore never reaps it.
## Monitoring & alerting
The **Status** page shows per-peer throughput and drift. Click a peer for the
detail drawer (traffic curves, recent reports, reverse DNS, and — if you provide
local **MaxMind GeoLite2** databases via `geoip_db` (Country *or* City) and
optionally `geoip_asn_db` — country/city/ASN, resolved offline).
`/metrics` exposes a Prometheus model (never anonymous — needs `metrics_token`
or an admin session):
- `wg_build_info{version="…"}` — always 1, labelled with the running version.
- `wg_endpoints_total`, `wg_endpoints_reporting`, `wg_machines_total`,
`wg_machines_pending`, `wg_machines_disabled`, `wg_machines_dormant`
- `wg_peers_online|offline|missing|unlinked|unexpected{endpoint="…"}` —
`unlinked` counts reported peers the portal knows but has not activated on that
endpoint, `unexpected` those with a public key it has never seen.
- `wg_last_report_age_seconds{endpoint="…"}` — alert when a concentrator goes
quiet.
```yaml
scrape_configs:
- job_name: wgroster
metrics_path: /metrics
scheme: https
authorization: { credentials: "<METRICS_TOKEN>" }
static_configs: [{ targets: ["wgroster.example.com:443"] }]
```
An optional **alert webhook** (`alert_webhook_url`) is POSTed on transitions:
```json
{"endpoint":"paris","type":"missing","status":"firing","detail":"2 peer(s) missing on hub","time":"2026-06-04T08:00:00Z"}
```
`type`: `stale | missing | unlinked | unexpected | mismatch | orphan | dormant`;
`status`: `firing | resolved`. An `orphan` alert carries `user` instead of
`endpoint`, and a `dormant` alert carries `machine`:
```json
{"user":"jdoe","type":"orphan","status":"firing","detail":"owner \"jdoe\" is no longer in the directory (2 machine(s) disabled)","time":"2026-08-28T08:00:00Z"}
{"machine":"jdoe/laptop","type":"dormant","status":"firing","detail":"machine \"laptop\" of \"jdoe\" is dormant (last handshake 4 months ago) and has been disabled","time":"2026-08-28T08:00:00Z"}
```
Every admin action is recorded on the **Audit** page (`/admin/audit`).
## Security & production checklist
- [ ] Serve over HTTPS at a reverse proxy and set `cookie_secure: true`
(enables HSTS).
- [ ] Set a stable `session_key` (or `WG_SESSION_KEY`).
- [ ] Use `local_admin.password_hash` (bcrypt), never `password` — or LDAP only.
- [ ] `trusted_proxy: true` **only** behind a proxy that overwrites
`X-Forwarded-For` (correct client IPs and login rate-limiting).
- [ ] Set `metrics_token` for Prometheus.
- [ ] Set `orphan_grace_days` so departed accounts stop being valid peers.
- [ ] Back up the SQLite database.
Reverse proxy examples — Caddy:
```
wgroster.example.com {
reverse_proxy 127.0.0.1:8080
}
```
nginx:
```nginx
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
## Backups
All state is the single SQLite file (`database:`):
```sh
sqlite3 /data/wgroster.db ".backup '/backup/wgroster-$(date +%F).db'"
```
## Development
The Tailwind CSS is prebuilt at `internal/web/static/app.css` (dark mode = the
`class` strategy). Regenerate it after changing template classes with the
standalone Tailwind CLI:
```sh
tailwindcss -i tailwind.input.css -o internal/web/static/app.css --minify
```
Run the checks: `gofmt -l . && go vet ./... && go test ./...`.
Regenerate the README screenshots (seeds a fake DB, runs the server, drives a
headless Chrome — requires Google Chrome / Chromium):
```sh
./scripts/screenshots.sh # writes docs/screenshots/*.png
```
`tools/seed` builds the demo database and `tools/screenshots` (a separate Go
module, so chromedp never reaches the main one) captures the pages.
## License
Licensed under the [Apache License 2.0](LICENSE).
---
<p align="center">
<sub>Sponsored by</sub><br>
<a href="https://bleemeo.com"><img src="https://bleemeo.com/static/logos/bleemeo_logo.svg" alt="Bleemeo" height="40"></a>
</p>