A single-container ICMP network sensor monitor for your homelab. One Go binary, one SQLite file, one dark dashboard — built to monitor exactly what you care about and says nothing until something changes.
pulsemon
- ICMP ping monitoring — single shared ping socket with per-probe sequence dispatch (accurate RTTs even under load). See ICMP & privileges below for the access model.
- Single container — static Go binary,
FROM scratchimage, SQLite storage - Sensors with multiple tags — group and filter however you like
- Per-sensor tuning — interval, timeout, loss threshold, error-after count
- Status derivation —
up/warning/errorfrom the sensor's most recent probes (see Alert behavior) - 1h / 24h latency graphs with whole-ms display
- Alerts — Google Chat + Discord, fan-out to every enabled destination, re-alerts, one-click test from Settings
- Alert controls — routing (all / by tag / by sensor), re-alert interval (0 = alert once only), one-click maintenance mode
- Dashboard — card ⇄ table views, inspector with history, activity feed, optimized for 1080p and up
- Authentication — optional username/password gate (Settings →
Authentication): create the first account to turn it on, add/remove more,
change your password, or disable it (removes all accounts). Sessions last
30 days;
/api/healthzstays open for external monitors
- Docker (with Compose v2)
- Port 9299 free on the host (change in
compose.yaml, or setPULSEMON_ADDR)
git clone https://github.com/invizen/pulsemon pulsemon && cd pulsemon
cp .env.example .env # optional: put a Google Chat webhook URL in .env
chmod 600 .env
mkdir -p data && chmod 0777 data # pre-create the data dir (see note below)
docker compose up -d --buildOpen http://localhost:9299 and add sensors from the new sensor button.
First boot: the database is created automatically on first run (
data/pulsemon.db). Two demo sensors (router,server) are seeded automatically — paused, targeting127.0.0.1— because not every host has a router or server on the same network; edit their targets (or delete them) and resume. Seeding runs only on an empty database.Why pre-create
data/? The container runs as uid 1000 (unprivileged). If the bind-mount source doesn't exist, Docker creates it root-owned and the container crash-loops withFailed to open DB: unable to open database file. Pre-creating the directory yourself (any ownership works —chmod 0777is the no-sudo option) avoids that. If it already happened,sudo chown -R 1000:1000 datafixes it.
curl -sfL https://raw.githubusercontent.com/invizen/pulsemon/main/install.sh -o /tmp/pulsemon-install.sh
bash /tmp/pulsemon-install.sh # latest release, or: bash /tmp/pulsemon-install.sh v0.1.7The installer (no sudo required) downloads and SHA-256-verifies the
release binary, installs it to ~/pulsemon/, writes a user-level
pulsemon.service (pointing at ~/pulsemon/data/pulsemon.db via PULSEMON_DB),
adds ~/pulsemon to PATH in ~/.bashrc (guarded — re-running never
duplicates the line), and enables + starts the service. It tries
sudo -n loginctl enable-linger so pulsemon keeps running after you log out;
if that needs a password it tells you the one command to run.
pulsemon update # self-update from any directory (v0.1.6+)
pulsemon update --restart # install AND restart the running service, then verify healthz
pulsemon update check # check only — exit 2 when a newer release exists
pulsemon update v0.1.7 # pin or roll back to a specific versionpulsemon update finds the running binary itself (os.Executable), verifies
the published SHA-256, and swaps it atomically — then prints the restart
command. After it runs: systemctl --user restart pulsemon.
--restart does that for you: it auto-restarts only when a live systemd
pulsemon service is confidently detected (user bus, or system bus when
running as root), then polls healthz and reports the outcome — so a
container or non-systemd host still just gets the restart hint (a bad new
version would otherwise take the monitor down).
pulsemon opens a single shared ICMP socket and dispatches replies by sequence number (so RTTs stay accurate when many sensors probe at once). On Linux there are two ways to send ICMP, and pulsemon tries them in order at startup:
- Datagram ping socket (
socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP)) — preferred. This is the socket thepingbinary uses as an ordinary user. It needs no root and noCAP_NET_RAW; instead the process's uid must fall insidenet.ipv4.ping_group_range(check withsysctl net.ipv4.ping_group_range). On Linux the kernel overwrites the ICMP identifier with a per-socket value and delivers only that socket's replies, so pulsemon dispatches by sequence + source IP on this path. - Raw socket (
SOCK_RAW+IPPROTO_ICMP) viaicmp.ListenPacket("ip4:icmp", …)— fallback. It requires root orCAP_NET_RAW, regardless ofping_group_range. Used automatically when the datagram socket isn't available.
Which one you're on is reported by GET /api/healthz as "icmp_mode"
("unprivileged-datagram" or "raw").
| Deployment | Result | How |
|---|---|---|
| Docker (default image) | ✅ unprivileged | The container runs as uid 1000 (see USER in Dockerfile); the datagram socket works off ping_group_range. Pre-create the data/ bind-mount dir (or chown -R 1000:1000 it) — see Quick start. |
| Bare / systemd as a normal user | ✅ if in range | Works when the user's uid is inside ping_group_range (the datagram socket); otherwise it falls back to the raw socket, which needs CAP_NET_RAW/root. |
Why not just
"udp4"? x/net/icmp'sListenPacket("udp4", …)is not an unprivileged ICMP socket — it creates a plain UDP socket and cannot send echo requests. The unprivileged path is theSOCK_DGRAM/IPPROTO_ICMPsocket above.
| Where | What |
|---|---|
.env (or container env) |
GOOGLE_CHAT_WEBHOOK_URL — Google Chat URL used when none is set in the dashboard |
| env | PULSEMON_ADDR — HTTP listen address, full host:port (e.g. :9299, 127.0.0.1:9299). Default :9299 (all interfaces). 8080 was the pre-0.1.19 default; 9299 avoids the dashboards/proxies that usually claim it. |
| env | PULSEMON_CERT + PULSEMON_KEY — optional TLS certificate and key file paths (PEM). Set both together to serve HTTPS directly (https://host:9299); omit both for plain HTTP. If the files are missing, pulsemon warns and serves plain HTTP (so a fresh clone with no certs/ dir doesn't crash-loop); if they exist but are corrupt or mismatched, startup fails — TLS is never silently degraded when a pair is in place. A self-signed or wildcard cert works for LAN use; use a real cert if the dashboard is reachable from outside. |
| env | PULSEMON_TRUSTED_PROXIES — optional comma-separated list of CIDRs (or bare IPs) of immediate TCP peers whose X-Forwarded-For header is trusted for login rate-limiting. By default X-Forwarded-For is ignored (the throttle keys on the direct TCP peer), so a spoofed/rotated header can't dodge the per-IP lockout. When pulsemon sits behind a reverse proxy (Caddy, nginx, …) and you want attempts attributed to the real client, set this to the proxy's IP(s), e.g. 127.0.0.1 or 192.168.1.0/24. The header is only ever read when the connection's source address is in this list. |
| Dashboard → Settings | Alert destinations (Google Chat, Discord — each with its own URL and toggle), alert routing, re-alert interval, maintenance mode, TLS certificate, Authentication (create the first account, add/remove accounts, change password, disable) |
| Per sensor | Interval, timeout, loss %, error-after, tags |
| CLI | pulsemon auth-user add <user> <pass> — create a dashboard account (the FIRST account is what turns authentication on). pulsemon auth-user remove <user> — delete an account (the last one is refused; use the dashboard's "Disable Authentication" for that). pulsemon auth-user list — list accounts. Auth is opt-in; with no accounts the dashboard is open, as before. |
Statuses are up, warning, and error. A sensor's status is derived from
its most recent probes, checked in this order:
-
up — a sensor recovering from error whose newest probe succeeded (error → up on a single good reply); a brand-new sensor with 1 successful probe; the last 2 probes both succeeded and loss over the last 8 probes is below the sensor's loss threshold (25% at the default); or a single recent loss amid sub-threshold stats (a blip — the LOSS metric and red sparkline bar show it). Recovery is checked first, so a sensor recovering from a long outage flips back quickly — it does not wait for the loss window to wash out.
-
error —
error afterconsecutive losses (2 at the default) -
warning — packet loss over the last 8 probes ≥ the sensor's loss threshold (25% at the default). loss_warn is the sole warning gate: a flapping sensor stays in warning on every tick — including 2-straight-good ticks — while its 8-probe loss is at/above the threshold, and reads up once the window loss falls back under it. A single loss below the threshold does not warn. (
spike_multnever affects status: it only tints dashboard sparkline bars — see below.) -
Sparkline spike tint: each sensor's 1-hour sparkline colors bars amber at/above
spike_mult ×that sensor's own window-average RTT (per-sensor, 2–10, default 5 — "Spike ×" in the sensor modal), so a 6 ms blip on a 2 ms LAN link is visible even though it's nowhere near the absolute thresholds. Bars above 200 ms stay red regardless. Display-only: the probe engine and status derivation never read it. -
New sensors default to: interval 60s, timeout 1000ms, loss warn 25%, error after 2. New and cloned sensors start paused so a fresh target can't fire alerts before you review it — resume it from the dashboard.
-
Fast re-check in error: while a sensor's status is error it is probed every 30s instead of the configured interval, until a probe brings it back to up, then it reverts to the configured interval. If the sensor's interval is already shorter than 30s, the error state keeps the sensor's own pace (it never polls faster than normal), so a total outage with many sensors down is bounded to ~2× the normal probe load — no ping storms.
-
Alerts fire on error transitions: up → error, warning → error, and recovery (→ up). Warning transitions (up ↔ warning) never alert — loss flapping is shown in the dashboard, not pushed to you.
-
While a sensor stays in error it re-alerts at the configured interval; set the interval to 0 to alert exactly once per outage (one "error" alert; the "recovered" alert still fires when it comes back).
-
Alerts fan out to every enabled destination with a configured URL (Google Chat and Discord). A fresh install has none configured — nothing sends until you add a URL in Settings.
-
Maintenance mode (Settings) silences all alerts without stopping probes; re-alert timers reset when it's turned off.
The dashboard is a single-page app driven by a small JSON API:
GET /api/sensors list sensors (with tags)
POST /api/sensors create
GET /api/sensors/{id} one sensor
PATCH /api/sensors/{id} update (name, target, tags, tuning, state)
DELETE /api/sensors/{id} delete (purges history)
POST /api/sensors/{id}/clone duplicate
POST /api/sensors/{id}/ping probe now
GET /api/sensors/{id}/history?range=1h|24h
GET /api/sensors/{id}/graph?range=1h|24h
GET /api/tags tag → sensor-count map
DELETE /api/tags?tag=X remove tag from all sensors
GET /api/events activity feed
DELETE /api/events clear activity feed
GET /api/settings webhook + alert settings
PUT /api/settings update
POST /api/settings/test send a test alert
POST /api/settings/tls install the dashboard TLS certificate (restarts)
DELETE /api/settings/tls remove the dashboard TLS certificate (restarts)
GET /api/auth/status auth enabled? current session valid? + username
POST /api/auth/login {"username": "...", "password": "..."} → session cookie (30d)
POST /api/auth/logout end the current session
GET /api/auth/users list accounts (session)
POST /api/auth/users {"username","password"} create an account (session; open while auth is off — bootstrap)
POST /api/auth/users/{u}/password change the CALLER's own password (session)
DELETE /api/auth/users/{u} delete an account; the last one is refused (session)
POST /api/auth/disable remove ALL accounts — turns auth off (session)
GET /api/healthz liveness (always open, even with auth on)
When authentication is enabled (at least one account exists), every /api/*
route requires a session cookie (or is one of the auth endpoints above) and
returns 401 without one; /api/healthz stays open so external monitors keep
working. Passwords are stored as bcrypt hashes; sessions are in-memory (a
restart clears them). A legacy single-token install migrates automatically on
first boot: the stored token becomes the password of an admin account.
go build -o pulsemon .
PULSEMON_DB=./data/pulsemon.db ./pulsemonapi.go REST API + sensor CRUD + settings + auth gate
probe.go probe worker, status derivation, alerting, webhooks
ping.go ICMP ping (shared socket: unprivileged datagram socket, raw fallback; seq dispatch)
db.go SQLite schema, migrations, seed
main.go entrypoint, -healthz flag, CLI subcommands
listener.go HTTP/HTTPS listener + restart orchestration
tls_settings.go dashboard TLS certificate install (validate + store + restart)
auth.go dashboard authentication (username/password, sessions, gate middleware)
web/ single-page dashboard (no build step)
Dockerfile golang:1.26-alpine → FROM scratch
compose.yaml