multi-controller update
Build and Push Docker Image / build (push) Successful in 4m39s

This commit is contained in:
2026-05-28 00:39:46 -05:00
parent c771a7171a
commit 72847daaf7
6 changed files with 922 additions and 706 deletions
+123 -135
View File
@@ -1,64 +1,66 @@
# UniFi Access Badge-In Dashboard
A Dockerised Flask + SQLite attendance dashboard that receives real-time door unlock
webhooks from the **UniFi Access Developer API**, resolves badge holders to real names,
and displays a live attendance table with first/latest badge times and ON TIME / LATE status.
webhooks from one or more **UniFi Access Developer API** controllers, resolves
badge holders to real names, and displays a unified live attendance table with
first/latest badge times, source controller, and ON TIME / LATE status.
**Multi-controller:** add as many UniFi Access controllers as the host can reach.
Webhooks are auto-registered when you add a controller from the UI.
---
## Requirements
- Unraid server (or any Linux host with Docker + Docker Compose)
- UniFi OS console running **UniFi Access 1.9.1 or later**
- UniFi Access on the same LAN as your Unraid server
- Port **12445** open from your Unraid host to the UniFi controller IP
- A UniFi Access **Developer API token** (NOT a UniFi OS / Network API token)
- Linux host with Docker + Docker Compose (Unraid works great)
- One or more UniFi OS consoles running **UniFi Access 1.9.1 or later**
- Network reachability from the dashboard host to each controller on port **12445**
- A **Developer API token** from each UniFi Access controller you plan to add
---
## Step 1 — Open firewall port 12445
## Step 1 — Open firewall port 12445 to each controller
The UniFi Access Open API runs exclusively on **port 12445** (HTTPS, self-signed cert).
Your Unraid server and any machine running the dashboard must be able to reach it.
The host running the dashboard must be able to reach each controller on that port.
In UniFi Network → Settings → Firewall & Security → Firewall Rules, add a **LAN IN** rule:
In UniFi Network → Settings → Firewall & Security → Firewall Rules, add a **LAN IN** rule on each controller:
| Field | Value |
|---|---|
| Action | Accept |
| Protocol | TCP |
| Destination Port | 12445 |
| Source | your LAN subnet (e.g. `10.0.0.0/8`) |
| Source | the subnet your dashboard host lives on |
Verify from your Unraid SSH terminal or PowerShell:
Verify from the dashboard host:
```bash
# Linux / Unraid:
# Linux:
nc -zv 10.0.0.1 12445
# Windows PowerShell:
Test-NetConnection -ComputerName 10.0.0.1 -Port 12445
# TcpTestSucceeded : True ← required
```
---
## Step 2 — Generate a UniFi Access Developer API token
## Step 2 — Generate a Developer API token on each controller
> ⚠️ This token is different from the UniFi OS / Network API token.
> ⚠️ This token is **different** from the UniFi OS / Network API token.
> Creating it in the wrong place will result in 401 Unauthorized errors.
1. Open your UniFi OS console at `https://<controller-ip>` in a browser.
2. Navigate into the **Access** app (blue door icon).
1. Open the UniFi OS console at `https://<controller-ip>` in a browser.
2. Open the **Access** app (blue door icon).
3. Go to **Settings → General → Advanced → API Token**.
4. Click **Create New**, enter a name and validity period, enable **all permission scopes**.
5. Click **Create** and **immediately copy the token** — it is shown only once.
4. Click **Create New**, name it, enable **all permission scopes**, and pick a validity period.
5. Click **Create** and **immediately copy the token** — it's only shown once.
Repeat for each controller you plan to add.
---
## Step 3 — Clone the repo on Unraid
SSH into your Unraid server and run:
## Step 3 — Clone the repo on the host
```bash
cd /mnt/user/appdata
@@ -75,15 +77,23 @@ cp .env.example .env
nano .env
```
Fill in all values:
The `UNIFI_*` and `WEBHOOK_SECRET` values are **optional**. If set, they auto-create
a "Default" controller on first boot — handy for single-controller installs. You can
leave them blank and add every controller via the UI instead.
```dotenv
UNIFI_HOST=10.0.0.1 # IP of your UniFi OS controller
UNIFI_PORT=12445 # UniFi Access Open API port — do not change
UNIFI_API_TOKEN=YOUR_TOKEN_HERE # Developer API token from Step 2
WEBHOOK_SECRET= # Leave blank until Step 6 gives you the secret
TZ=America/Chicago # Your local timezone
DB_PATH=/data/dashboard.db # Path inside the container — do not change
# Optional: seeds a "Default" controller on first boot
UNIFI_HOST=10.0.0.1
UNIFI_PORT=12445
UNIFI_API_TOKEN=YOUR_TOKEN_HERE
WEBHOOK_SECRET=
# Required
TZ=America/Chicago
DB_PATH=/data/dashboard.db
# Optional: override the URL the dashboard uses when registering webhooks
# DASHBOARD_BASE_URL=http://10.0.0.5:8000
```
> **Never commit `.env` to git.** It is listed in `.gitignore`.
@@ -101,111 +111,68 @@ The container will:
- Build the image from the local `Dockerfile`
- Start Flask on port **8000**
- Create `/data/dashboard.db` inside the container (mapped to `./data/` on the host)
- Immediately sync all users from your UniFi Access controller
- Schedule a user cache refresh every 6 hours
- If env-var credentials are set, seed a "Default" controller and sync its users
- Schedule a user-cache refresh every 6 hours for every enabled controller
Verify it is running:
Verify it's running:
```bash
/usr/bin/docker ps
# Should show: unifi-access-dashboard Up X seconds
/usr/bin/docker logs -f unifi-access-dashboard
# Should show: INFO:app:Synced X users from UniFi Access
```
---
## Step 6 — Register the webhook with UniFi Access
This registers your dashboard URL with UniFi Access so it receives door unlock events.
Run this **once** from inside the container console.
### Open the container console
In Unraid UI → **Docker tab** → click `unifi-access-dashboard`**Console**
Then paste this Python script (replace values with yours):
```bash
python3 -c "
import requests, urllib3
urllib3.disable_warnings()
HOST = '10.0.0.1'
TOKEN = 'YOUR_ACCESS_DEVELOPER_TOKEN_HERE'
DASH_URL = 'http://YOUR_UNRAID_IP:8000/api/unifi-access'
r = requests.post(
f'https://{HOST}:12445/api/v1/developer/webhooks/endpoints',
headers={
'Authorization': f'Bearer {TOKEN}',
'Content-Type': 'application/json'
},
json={
'name': 'Dashboard Unlock Events',
'endpoint': DASH_URL,
'events': ['access.door.unlock']
},
verify=False,
timeout=10
)
print('Status:', r.status_code)
print('Response:', r.text)
"
```
A successful response looks like:
```json
{
"code": "SUCCESS",
"data": {
"endpoint": "http://10.2.0.11:8000/api/unifi-access",
"events": ["access.door.unlock"],
"id": "afdb4271-...",
"name": "Dashboard Unlock Events",
"secret": "6e1d30c6ea8fa423"
}
}
```
Copy the **`secret`** value from the response.
### Add the secret to your .env
On Unraid SSH:
```bash
nano /mnt/user/appdata/unifi-access-dashboard/.env
# Set: WEBHOOK_SECRET=6e1d30c6ea8fa423 (use your actual secret)
```
Then restart the container to pick up the new value:
```bash
/usr/bin/docker compose up -d --build
```
---
## Step 7 — Open the dashboard
## Step 6 — Add controllers from the UI
Navigate to:
```
http://<UNRAID-IP>:8000/
http://<HOST-IP>:8000/
```
### Dashboard controls
Click the **⚙ Controllers** button in the header. For each UniFi Access instance you want
to receive events from, fill in:
| Field | Value |
|---|---|
| **Name** | Friendly label shown in the Source column (e.g. "Main Office", "Warehouse") |
| **Host / IP** | Controller IP, e.g. `10.0.0.1` |
| **Port** | `12445` (don't change unless your controller is non-standard) |
| **Developer API Token** | Token from Step 2 |
Click **Add Controller**. The dashboard will:
1. Call the controller's `POST /webhooks/endpoints` with this dashboard's URL.
2. Store the returned webhook secret so it can verify incoming events (HMAC-SHA256).
3. Immediately sync the controller's user list to resolve names.
If the controller can't reach this dashboard at the URL shown in the form hint
(it uses `window.location.origin` by default), set `DASHBOARD_BASE_URL` in `.env`
and restart.
Per-controller actions in the modal:
| Action | Description |
|---|---|
| **Test** | Hits the controller's `/users` endpoint to confirm the token works |
| **Sync** | Pulls latest users from this controller right now |
| **Enable / Disable** | Pause ingestion + sync without deleting the controller |
| **Remove** | Deletes the webhook from the controller and wipes all its badge events from the dashboard |
---
## Dashboard controls
| Control | Description |
|---|---|
| **Date picker** | Choose which day to view |
| **Badged in by** | Set your on-time cutoff (e.g. `09:00 AM`) |
| **Refresh** | Reload the table for the selected date/cutoff |
| **Sync Users** | Immediately pull latest users from UniFi Access |
| **Reset Day** | Delete all badge records for the selected date (testing only) |
| **Badged in by** | Set your on-time cutoff (HH:MM) |
| **Controller** | Filter the table to one controller, or show All |
| **Refresh** | Reload the table |
| **Sync Users** | Pull latest users from every enabled controller |
| **⚙ Controllers** | Add / manage controllers |
| **Reset Day** | Delete all badge records for the selected date (respects the Controller filter — testing only) |
### Dashboard columns
@@ -213,11 +180,16 @@ http://<UNRAID-IP>:8000/
|---|---|
| **#** | Row number |
| **Name** | Resolved display name from UniFi Access |
| **Source** | Controller this badge event came from |
| **First Badge In** | Earliest door entry for the day — never changes once set |
| **Latest Badge In** | Most recent entry — shows *"— same"* if only one badge event |
| **Actor ID** | First 8 characters of the UniFi user UUID |
| **Status** | ON TIME (green) or LATE (red) based on first badge vs cutoff |
> The same physical person on two different controllers will appear as two rows
> (different controllers issue different user UUIDs). They're distinguishable
> by the Source column.
---
## Updating from GitHub
@@ -228,19 +200,32 @@ git pull
/usr/bin/docker compose up -d --build
```
The SQLite database in `./data/` persists across rebuilds automatically.
The SQLite database in `./data/` persists across rebuilds. On first start after
upgrading from a single-controller install, existing badge events are
automatically attached to the seeded "Default" controller — nothing to migrate
by hand.
---
## API reference
| Method | Path | Params | Description |
All endpoints are unauthenticated by design — this app assumes a LAN-only
deployment. Do not expose port 8000 to the internet without putting a
reverse proxy with auth in front of it.
| Method | Path | Params / Body | Description |
|---|---|---|---|
| `POST` | `/api/unifi-access` | — | Receives UniFi Access webhook |
| `GET` | `/api/first-badge-status` | `date`, `cutoff` | Returns first + latest badge per user |
| `GET` | `/api/sync-users` | — | Triggers immediate user cache sync |
| `DELETE` | `/api/reset-day` | `date` | Deletes all records for given date |
| `GET` | `/api/debug-user-cache` | `actor_id` | Queries Access API for a specific user ID |
| `POST` | `/api/unifi-access/<controller_id>` | webhook body | Receives UniFi Access webhook for that controller |
| `POST` | `/api/unifi-access` | webhook body | Legacy alias — routes to the oldest controller |
| `GET` | `/api/first-badge-status` | `date`, `cutoff`, `controller_id?` | Returns first + latest badge per user |
| `GET` | `/api/controllers` | — | List configured controllers |
| `POST` | `/api/controllers` | `name`, `host`, `port`, `api_token` | Add a controller (also registers webhook) |
| `PATCH` | `/api/controllers/<id>` | `name?`, `enabled?` | Rename or enable/disable a controller |
| `DELETE` | `/api/controllers/<id>` | — | Remove a controller (deletes webhook + its events) |
| `POST` | `/api/controllers/<id>/test` | — | Test controller reachability + token |
| `POST` | `/api/controllers/<id>/sync` | — | Sync one controller's user cache immediately |
| `GET` | `/api/sync-users` | — | Sync every enabled controller |
| `DELETE` | `/api/reset-day` | `date`, `controller_id?` | Delete badge records for a date (optionally scoped to one controller) |
---
@@ -248,22 +233,25 @@ The SQLite database in `./data/` persists across rebuilds automatically.
| Symptom | Cause | Fix |
|---|---|---|
| `Webhook signature mismatch` | Wrong or missing `WEBHOOK_SECRET` in `.env` | Copy `secret` from Step 6 response into `.env`, rebuild |
| `401 Unauthorized` on webhook | Token invalid or wrong scope | Regenerate token in Access → Settings → General → API Token |
| Add Controller fails with "webhook registration rejected" | Token invalid or wrong scope | Regenerate token in Access → Settings → General → API Token with all scopes enabled |
| Add Controller fails with a connection error | Host unreachable on port 12445 | Verify firewall rule from Step 1; `nc -zv <ip> 12445` from the dashboard host |
| Events arrive but signature is rejected | Webhook secret missing or stale | Remove the controller and re-add it (the dashboard re-registers and gets a fresh secret) |
| Source column says "—" | Pre-migration row with no controller_id | Restart the container; the migration runs on every boot |
| Names show as `Unknown (xxxxxxxx...)` | Users not synced yet for that controller | Click **Sync** in the Controllers modal |
| Webhook URL stored in controller points to the wrong address | Browser's origin isn't reachable from the controller | Set `DASHBOARD_BASE_URL` in `.env`, remove + re-add the controller |
| `Port 12445 connection refused` | Firewall blocking port | Add LAN IN firewall rule in UniFi Network (Step 1) |
| Names show as `(Unknown)` | Users not cached yet | Click **Sync Users**; check logs for `Synced X users` |
| `before_first_request` error | Running Flask 3.0+ with old code | Use the latest `app.py` which uses `with app.app_context()` |
| `-SkipCertificateCheck` error in PowerShell | PowerShell 5.1 (not Core) | Add `[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }` before the request |
| Webhook POST returns 400 / no actor | Old `app.py` extracting wrong field | Use latest `app.py` which reads `data.actor.id` |
| Dashboard shows stale names after user rename | Cache not refreshed | Click **Sync Users** or wait for 6-hour auto-sync |
| Container starts but no users synced | `UNIFI_API_TOKEN` missing or wrong in `.env` | Check `.env` and rebuild |
| Dashboard shows stale names after a user rename | Cache not refreshed | Click **Sync Users** or wait for the 6-hour auto-sync |
---
## Security notes
- `.env` is excluded from git via `.gitignore` — never commit it.
- The `WEBHOOK_SECRET` ensures only genuine UniFi Access events are accepted (HMAC-SHA256).
- The API token is never exposed to the browser.
- The `/api/reset-day` and `/api/debug-user-cache` endpoints have no authentication — keep the container on your internal network only.
- For external access, place Nginx or Traefik with HTTPS in front of port `8000`.
- API tokens are stored **in plaintext** in the SQLite DB; the dashboard
assumes a LAN-only deployment. Filesystem permissions on `./data/dashboard.db`
are the only thing protecting them.
- All admin endpoints (`/api/controllers/*`, `/api/sync-users`, `/api/reset-day`)
are **unauthenticated**. Do not expose port 8000 publicly. If external access
is required, place Nginx, Traefik, or Caddy with HTTPS + auth in front of port `8000`.
- Each controller's `webhook_secret` is enforced via HMAC-SHA256 on incoming
events so spoofed webhook posts from outside the LAN are rejected.