- Go 73.8%
- Kotlin 25.7%
- Dockerfile 0.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| android | ||
| assets | ||
| cmd/server | ||
| internal | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| README.md | ||
NotiGate
One self-hosted gateway for every alert.
push-first · battery-friendly · delay-free
NotiGate is the universal ingestion hub for your homelab's alerts: Unraid, Synology, Proxmox, Uptime Kuma, Grafana, Watchtower and plain shell scripts all feed into one normalized event schema — and NotiGate delivers them delay-free to dashboards and, soon, mobile devices that only wake up when an event actually arrives.
Unraid ──────┐ ┌──────► Dashboards · live (SSE/WebSocket)
Synology ────┤ │
Proxmox ─────┼──► NotiGate ────────────────┼──────► History API · JSON
Uptime Kuma ─┤ (normalize + route) │
Scripts ─────┘ └──────► Android · push (soon)
✨ Why NotiGate?
- 🔔 One schema for everything — every adapter normalizes into a single
internal event model (
source,title,message,priority,tags,click_url,timestamp). - ⚡ Delay-free ingestion — non-blocking broadcast hub; incoming webhooks are acked in milliseconds.
- 🔋 Battery-friendly by design — push/stream transport with wake-on-event, no polling loops draining phones.
- 🛡️ Hardened by default — trusted-proxy Real-IP resolution (spoof-proof), token-bucket rate limiting, constant-time token auth.
- 🔌 Drop-in compatibility — Discord-webhook mode means Uptime Kuma, Watchtower, Grafana and friends work without a special adapter.
- 📦 Operationally boring — one CGO-free static binary, SQLite in WAL mode, distroless container image.
🚀 Quick start
go build -o notigate ./cmd/server
API_KEYS="$(openssl rand -hex 32)" ./notigate
Without API_KEYS the server generates an ephemeral key and logs it at
startup (dev convenience — set API_KEYS in production).
curl -X POST http://localhost:8080/api/v1/send \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"source":"unraid","title":"Array Alert","message":"Disk 2 has SMART errors","priority":4,"tags":["hardware","storage"],"click_url":"https://unraid.lan"}'
🔌 Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /healthz |
– | Liveness for Docker / proxy upstream checks (+ version) |
| POST | /api/v1/send |
yes | Canonical JSON ingestion (202 + normalized event) |
| POST | /api/v1/ingest/discord |
yes | Discord-webhook compatible mode (204), e.g. Uptime Kuma |
| POST | /api/v1/ingest/synology |
yes | Synology DSM placeholders (%SUBJECT%, %TEXT%) |
| POST | /api/v1/ingest/unraid |
yes | Unraid notify fields (JSON or form) |
| GET | /api/v1/stream |
yes | Server-Sent Events live stream |
| GET | /api/v1/stream/ws |
yes | WebSocket live stream (same JSON objects as text frames) |
| GET | /api/v1/notifications |
yes | History, newest first (?limit=100) |
| POST | /api/v1/devices |
yes | Register a push device (FCM / UnifiedPush) |
| GET | /api/v1/devices |
yes | List devices (credentials never echoed) |
| DELETE | /api/v1/devices/{id} |
yes | Remove a device |
Auth: Authorization: Bearer <key> or ?token=<key> (for webhooks and
EventSource, which cannot set headers). Comparison is constant-time.
📦 Event schema
Every ingestion channel normalizes onto this single schema:
{
"id": "uuid-v4",
"source": "unraid",
"title": "Array Alert",
"message": "Disk 2 has SMART errors",
"priority": 4,
"tags": ["hardware", "storage"],
"click_url": "https://unraid.lan",
"timestamp": 1727180000
}
Priorities follow the ntfy convention: 0 = min … 5 = max, 3 = default. Discord embed colors are mapped automatically (green → 1, yellow → 4, red → 5).
📱 Mobile push (FCM / UnifiedPush)
Notifications are pushed asynchronously by a worker pool — ingestion is acked in milliseconds and delivery happens in the background:
-
UnifiedPush (self-hosted, no Google): register the distributor endpoint of any UnifiedPush app (ntfy, NextPush, …):
curl -X POST http://localhost:8080/api/v1/devices \ -H "Authorization: Bearer $API_KEY" \ -d '{"name":"Phone","provider":"unifiedpush","endpoint":"https://ntfy.lan/topic-xyz"}'The normalized notification arrives as a plain JSON POST on that endpoint. Endpoints answering 404/410 are removed automatically.
-
FCM (for the Android app): set
FCM_CREDENTIALS_FILEto a Google service-account JSON key with the Firebase Cloud Messaging API enabled. Access tokens are minted by signing RS256 JWTs — no Google SDK involved. Register devices with{"provider":"fcm","token":"<registration token>"}; unregistered tokens (404/410) are pruned automatically.
Device credentials are stored in SQLite but never returned by the API.
🧩 Adapter notes
- Synology DSM — point the DSM notification webhook at
/api/v1/ingest/synology?token=…&subject=%SUBJECT%&text=%TEXT%. - Unraid — POST the fields of Unraid's
notifyscript (event,subject,description,content,importance) as JSON or form;importancemaps alert→5, warning→4, normal→3.
📡 SSE & WebSocket behind reverse proxies
The stream sets X-Accel-Buffering: no, Cache-Control: no-cache and sends
a heartbeat comment every 30 s, so nginx (NPM), Caddy and Traefik neither
buffer nor drop idle connections. For EventSource clients, pass the token as
a query parameter — note that it can appear in proxy access logs, so keep
ALLOWED_ORIGINS tight for browser clients.
The WebSocket stream (/api/v1/stream/ws) carries the same notification
JSON objects as text frames, plus protocol-level pings every ~54 s. nginx
needs the standard upgrade headers for it:
location /api/v1/stream/ws {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://notigate;
}
WS upgrades are not CORS-preflighted, so the endpoint enforces the
ALLOWED_ORIGINS policy itself: requests without Origin (server-side
clients) pass, browser origins must be listed.
⚙️ Configuration
| Variable | Default | Meaning |
|---|---|---|
LISTEN_ADDR |
:8080 |
Listen address |
DB_PATH |
data/notigate.db |
SQLite file (WAL mode, CGO-free driver) |
API_KEYS |
(ephemeral, logged) | Comma-separated valid API keys |
TRUSTED_PROXIES |
127.0.0.0/8,::1/128 |
CIDRs allowed to set Forwarded/X-Forwarded-For/X-Real-IP |
ALLOWED_ORIGINS |
* |
CORS origins for dashboards |
RATE_LIMIT_RPS |
20 |
Token-bucket refill per client IP |
RATE_LIMIT_BURST |
40 |
Token-bucket capacity |
PUSH_WORKERS |
8 |
Async push delivery workers |
PUSH_QUEUE |
1024 |
Push job queue size (full queue drops + logs) |
FCM_CREDENTIALS_FILE |
(disabled) | Service-account JSON key to enable FCM delivery |
Real-IP resolution only honors proxy headers when the direct TCP peer is in
TRUSTED_PROXIES; everything else is treated as the client itself, which
prevents IP spoofing in the rate limiter and logs. Precedence: RFC 7239
Forwarded → X-Real-IP → X-Forwarded-For (right-to-left walk).
🏷️ Versioning
Build metadata is injected at link time and exposed through the -version
flag, the startup log and /healthz:
go build -trimpath -ldflags "-s -w \
-X notigate/internal/version.Version=$(git describe --tags --always) \
-X notigate/internal/version.Commit=$(git rev-parse --short HEAD) \
-X notigate/internal/version.Date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o notigate ./cmd/server
./notigate -version
Docker takes the same values as build args:
docker build --build-arg VERSION=$(git describe --tags --always) \
--build-arg COMMIT=$(git rev-parse --short HEAD) \
--build-arg DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) -t notigate .
Releases are tagged vX.Y.Z (annotated tags).
🐳 Docker
docker compose up -d --build
Distroless static image, single CGO-free binary, state under /data.
🗺️ Roadmap
- JSON ingestion (
/api/v1/send) + Discord webhook compatibility - SSE live streaming with proxy-safe heartbeats
- Trusted-proxy Real-IP, token-bucket rate limiting, constant-time auth
- Native Synology DSM + Unraid adapters
- Device registry + async push dispatcher (FCM HTTP v1 / UnifiedPush)
- Native Android app (Compose) consuming UnifiedPush
- WebSocket transport alongside SSE
- Token management UI backed by SQLite
Made with ⚡ and Go — crafted for the self-hosted homelab.