Real-time OpenWrt device presence & static-IP dashboard — multi-source fusion, sub-second events, zero-dep Web UI
Argus is a Go library + CLI for real-time WiFi/wired device presence on OpenWrt routers. It fuses six data sources (ahsapd · hostapd · logread · DHCP leases · ARP · ICMP) into a single sub-second event stream — Online / Offline / Change — and ships an opt-in Web UI with static-IP reservations, device aliases, and one-click recovery tools. Zero-dep, works on stock OpenWrt + MediaTek vendor firmwares (C-Life and similar). Named after the hundred-eyed giant of Greek myth — whose eyes never all slept.
Quick start:
# Releases page: https://github.com/xxl6097/argusd/releases
scp argusd root@192.168.1.1:/tmp/ && ssh root@192.168.1.1 \
'/tmp/argusd -listen :8080 -aliases /etc/argusd/aliases.json'
# Open http://<router-ip>:8080/- Features
- Quick Start
- Web UI · built-in dashboard
- Architecture
- API Overview
- Configuration
- Observability
- Roadmap
- Compatibility
- Contributing
- 🔀 Multi-source fusion — ahsapd + hostapd +
logread -f+ DHCP leases + ARP states + ICMP probe, all merged into one stream. - 🏭 Vendor-agnostic zero-config — auto-detects
ahsapd(vendor firmware) orhostapd.*(stock OpenWrt) at startup. - ⚡ Sub-second events — kernel log streaming (
New Sta,Del Sta,Deauth,DHCPACK…) delivers online/offline in 1–2 s. - 🛡️ Multi-dimensional offline detection — three-layer decision: ICMP ping filter + AP association table with RSSI tiers + ARP
FAILED/INCOMPLETEshortcut. - 🌊 Flap suppression — 90 s cooldown plus 30 s same-kind suppression window eliminates weak-signal thrashing. Both independently toggleable via
Config.DisableCooldown/DisableFlapSuppression. - 🧩 Pure stdlib, single static binary — ~2.6 MB static binary (
CGO_ENABLED=0, GOARCH=arm64). Drop into/tmpand run. - 🔬 Observability — four hook surfaces, all opt-in and zero-cost when unused:
DecisionHandler(1.7 ns/op, 0 allocs) surfaces 17 internal branch decisions;WithLoggeremits structured logs (slog/zap/zerolog adapter in ~5 lines);WithSpanRecorderemits distributed-tracing spans (OTel adapter ~15 lines); theargusmetricssubpackage ships zero-dependency counters (Counters/LabeledCounters) ready to bridge to Prometheus / OTLP. - 🔒 Security hardened — IP regex +
net.ParseIPdouble validation, interface whitelist — no command injection. - 🧵 Concurrency-safe —
sync.Mutexprotects shared state; events emitted outside the lock;go test -raceclean across 60+ tests and 9 lifecycle tests. - 🛟 Panic-safe callbacks — user callbacks (
EventHandler/ErrorHandler/DecisionHandler) are wrapped withdefer recover. AnEventHandlerpanic is reported viaonErrorand does not kill any Watcher goroutine. - 🔄 Hot-reload lifecycle (v0.5.0+) —
Watcher.Stop(ctx)+ re-run preservesknown/ cooldown / flap state across config reload (SIGHUP pattern). Real-router validated: 10 restarts on MT7981 show zero goroutine leak (Threads: 15 → 15). Seedocs/SIGHUP-real-device-test.md. - 🎯 Sentinel errors + structured validation —
ErrHandlerRequired/ErrInvalidConfig/ErrNoFetcher/ErrFetchFailed/ErrAlreadyRunning, allerrors.Is-compatible.Config.Validatereturns*ConfigErrorwith field-level detail reachable viaerrors.As— ideal for web config UIs.
import (
"context"
"fmt"
"log"
"os/signal"
"syscall"
argus "github.com/xxl6097/argusd"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
w := argus.New(
argus.OnFetcherDetected(func(k argus.FetcherKind) {
log.Printf("data source: %s", k)
}),
)
err := w.Run(ctx, func(e argus.Event) {
switch e.Kind {
case argus.EventOnline:
fmt.Printf("[+] %s joined %s\n", e.Device.MAC, e.Device.IP)
case argus.EventOffline:
fmt.Printf("[-] %s left\n", e.Device.MAC)
case argus.EventChange:
for _, c := range e.Changes {
fmt.Printf("[~] %s %s: %q → %q\n",
e.Device.MAC, c.Field, c.Old, c.New)
}
}
}, nil)
if err != nil {
log.Fatal(err)
}
}Prebuilt binaries for common OpenWrt CPU architectures are published on the Releases page (amd64 / arm64 / armv5 / armv7 / mips / mipsle / mips64 / mips64le / riscv64 / 386, all static).
# Download the matching archive, verify, and deploy.
VER=v1.0.1
TARGET=linux-mipsle-softfloat # replace with your arch
curl -LO "https://github.com/xxl6097/argusd/releases/download/${VER}/argusd_${VER}_${TARGET}.tar.gz"
curl -LO "https://github.com/xxl6097/argusd/releases/download/${VER}/SHA256SUMS"
sha256sum -c SHA256SUMS --ignore-missing
tar -xzf argusd_${VER}_${TARGET}.tar.gz
scp argusd_${VER}_${TARGET}/argusd root@192.168.1.1:/tmp/argusd
ssh root@192.168.1.1 '/tmp/argusd'Or build from source:
# Cross-compile for OpenWrt (aarch64 example).
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
go build -trimpath -ldflags="-s -w" \
-o argusd ./cmd/argusd
scp argusd root@192.168.1.1:/tmp/
ssh root@192.168.1.1 '/tmp/argusd'Sample output:
2026/05/09 18:40:21 data source: ahsapd
MAC IP Hostname Vendor Type Signal Link
──────────────────────────────────────────────────────────────────────────────────────────
2C:CF:67:1D:27:AC 192.168.1.11 raspberrypi rasp.. PC - wired
B0:FC:36:32:94:61 192.168.1.5 lenovo DESK.. Phone -38(strong) 5G/avgb-5G
BA:79:97:73:89:8D 192.168.1.213 BA799773898D - Phone -44(strong) 5G/avgb-5G
──────────────────────────────────────────────────────────────────────────────────────────
4 devices online (WiFi: 3, Wired: 1)
[2026-05-09 18:42:03] [syslog] WIFI_CONNECT BA:79:...
[2026-05-09 18:42:03] [syslog] DHCP_ACK BA:79:... IP=192.168.1.213
[2026-05-09 18:42:03] [event] ONLINE BA:79:... 192.168.1.213 iPhone -44(strong) 5G/avgb-5G
Argus ships an opt-in, zero-dependency HTTP + Server-Sent-Events dashboard in the argusweb subpackage. Single embedded HTML file, vanilla JS, mobile-responsive. Pass -listen :8080 to argusd, or wire argusweb.NewServer into your own http.Handler tree.
Desktop main view — device table on the left (seven columns: status / MAC / IP / hostname / vendor / signal / link-type), SSE-driven live event stream on the right, and system buttons (restart-network / reboot) in the top-right corner.
Static-IP modal — opens via the 📌 pin button. Enter an IP and optional name; tick "take effect now (restart WiFi)" to run wifi reload / ahsapd restart so every client reconnects within ~3–5 seconds and the new IP applies immediately. If unticked, the server only writes the UCI config and tries a per-station kick — suitable for firmwares where single-station disconnect actually works.
Alias rename — click ✎ to inline-rename; UTF-8 (Chinese / spaces / dots / dashes) is accepted; an empty string clears the alias. Persisted to aliases.json with atomic writes.
IP-conflict one-click replace — when the target IP is already bound to a different MAC, the server returns 409 Conflict. The frontend prompts with the owner MAC; clicking "OK" auto-runs DELETE /api/dhcp?mac=<owner> then retries the POST. "Cancel" leaves both reservations unchanged.
Mobile (viewport ≤ 640px) — the table switches to a card layout: MAC / status badge / hostname / vendor / link / signal stacked vertically, one card per device.
| Feature | Description | Since |
|---|---|---|
| Live device table | SSE-driven; MAC / IP / hostname / vendor / type / signal / link-type / status columns | v0.13.0 |
| Online/Offline column | Offline rows retained per WithOfflineRetention (default 7d / 512 entries); shown with relative time such as "2m ago" |
v0.13.3 |
| Mobile responsive | Card layout below 640 px breakpoint | v0.13.1 |
| Adaptive columns | table-layout:auto with per-column min-widths; columns expand to full content when the screen is wide, truncate with ellipsis + hover tooltip only when cramped |
v0.15.5 |
| Reconnect coalescing | OFFLINE→ONLINE bursts within 10 s collapse into one RECONNECT row | v0.13.2 |
| Vendor column | OUI lookup, ellipsis + tooltip for long names | v0.15.0 |
| Aliases (renamable) | ✎ inline-rename button, UTF-8 names (Chinese / spaces / dots accepted), file-backed JSON, atomic writes | v0.14.0 / v0.15.4 |
| Static DHCP reservations | 📌 button → modal; UCI-backed; optional immediate-apply (reload + lease prune + ARP flush + station kick) | v0.15.0 / v0.15.2 / v0.15.7 |
| IP conflict guard | 409 Conflict if the target IP is already bound to a different MAC; UI offers a 1-click "replace" (delete old, retry) | v0.15.3 / v0.15.5 |
| Recovery endpoint | POST /api/dhcp?purge_argus=1 removes every dhcp.argus_* section |
v0.15.3 |
| Opt-in WiFi restart | Save-dialog checkbox runs wifi reload / ahsapd restart so every client re-associates within seconds — nuclear option for firmwares where per-station kick is a no-op |
v0.15.8 |
| System actions | Header buttons: "restart network" (soft, 5–15 s LAN blip, config preserved) and "reboot router" (hard, 30–60 s full reboot), each with confirmation prompts | v0.15.9 |
| Write auth | WithWriteAuth(predicate) gates every POST/DELETE (aliases / dhcp / system); default allows loopback + RFC1918 |
v0.14.0 |
- Status badges — connection state (
connected/reconnecting…) + online/offline counters stay in the top-right. - 🔒 icon — devices with a static reservation show a lock before the IP; hover reveals "static reservation active".
- 📌 button — opens the static-IP modal. If the MAC already has a reservation, a red "Remove" button appears in the modal footer.
- ✎ button — opens an inline rename form; Enter saves, Esc cancels, empty string clears the alias.
- Event badge colors —
ONLINE/RECONNECTare green,OFFLINE/FLAPred,CHANGEamber. - Long-text hover — any cell truncated by ellipsis shows full content on hover.
- Toast feedback — saving a static IP surfaces a multi-line status toast:
reloaded/old lease pruned/ARP cache flushed/station kicked/WiFi restarted, so you know exactly what the server did. - Offline devices stay manageable — offline rows are dimmed but the ✎ / 📌 buttons still work, letting you pre-assign aliases and static IPs for devices that aren't online yet.
# CLI: bind on all interfaces, port 8080
./argusd -listen :8080 \
-aliases /etc/argusd/aliases.json # optional: enable alias store
# Open http://<router-ip>:8080/Or mount in your own server:
w := argus.New(argus.WithFetcher(...))
aliases := argusweb.NewAliasStore("/etc/argusd/aliases.json")
dhcp, _ := argusweb.NewUCIDHCPManager() // returns ErrDHCPManagerUnavailable off-OpenWrt
srv := argusweb.NewServer(w,
argusweb.WithAliases(aliases),
argusweb.WithDHCPManager(dhcp),
argusweb.WithOfflineRetention(7*24*time.Hour),
argusweb.WithOfflineMax(512),
argusweb.WithWriteAuth(func(r *http.Request) bool {
return r.Header.Get("X-Token") == os.Getenv("ARGUS_TOKEN")
}),
)
w.RegisterEventHandler(srv.OnEvent) // feed events into the SSE stream
go http.ListenAndServe(":8080", srv)All responses are JSON. Writes are gated by WithWriteAuth (default: loopback + RFC1918 allowed; everything else returns 403).
| Route | Methods | Description |
|---|---|---|
/ |
GET | Dashboard HTML (embedded single file) |
/api/devices |
GET | {count, online, offline, capabilities:{aliases,dhcp}, devices:[...]}; each row carries status / offline_at_ms / alias |
/api/events |
GET | SSE stream; event name = EventKind.String() (ONLINE / OFFLINE / CHANGE) |
/api/aliases |
GET / POST / DELETE | MAC ↔ friendly-name CRUD; 503 when WithAliases is not set |
/api/dhcp |
GET / POST / DELETE | Static DHCP reservation CRUD; 503 when WithDHCPManager is not set; POST/DELETE accept ?restart_wifi=1 to trigger immediate-apply (v0.15.8+) |
/api/dhcp?purge_argus=1 |
POST | Removes every dhcp.argus_* section (recovery tool, v0.15.3+) |
/api/system/restart-network |
POST | /etc/init.d/network restart (soft network restart, v0.15.9+) |
/api/system/reboot |
POST | /sbin/reboot (full router reboot, v0.15.9+) |
POST /api/dhcp error codes:
400— invalid MAC / IP / name403— blocked byWithWriteAuth409— target IP already reserved for a different MAC; body{error, ip, owner_mac}names the current owner (v0.15.3+)503— noDHCPManagerattached
applyReport (the apply field in every DHCP write response) contains: reloaded[] · pruned[] · arp_flushed · kicked · wifi_restarted. The dashboard renders its toast from these fields.
Full wire-shape contract lives in STABILITY.md — stable public surface since v0.13.0.
NewUCIDHCPManager() works on any OpenWrt-like system with the uci CLI; other platforms get ErrDHCPManagerUnavailable. Verified on MediaTek MT7981 / C-Life vendor firmware (odhcpd) and stock OpenWrt (dnsmasq).
Beware of dual DHCP servers — if your LAN has a secondary router (iStoreOS / OpenClash and similar) with DHCP enabled by default, it will race the main router's offers and some devices will end up with the secondary router as their gateway (static reservations then seem to misbehave randomly). Diagnose with
ip neighon the main router (check each device's gateway); fix by disabling DHCP on the secondary:uci set dhcp.lan.ignore=1 && uci commit dhcp && /etc/init.d/dnsmasq restart.
Six feeds enter the Event Fusion Engine; the Watcher emits events (business), decisions (observability), and errors (failures).
┌──────────────┐
│ logread │ ← realtime kernel events
│ -f │ (Connect/Disconnect/Deauth/DHCPACK)
└──────┬───────┘
│
┌─ ubus call ────┐ ┌──────┼──────┐ ┌─ ARP state ──┐
│ ahsapd.sta or │ → │ Event │ ← │ ip neigh │
│ hostapd.<iface>│ │ Fusion │ │ FAILED/OK │
└────────────────┘ │ Engine │ └──────────────┘
│ │
┌─ DHCP leases ──┐ │ │ ┌─ ICMP probe ─┐
│ /tmp/dhcp. │ → │ │ ← │ ping -c 1 │
│ leases │ │ │ │ -W 1 │
└────────────────┘ └──────┬──────┘ └──────────────┘
│
┌─────▼──────┐
│ Watcher │ ← diff + cooldown + flap-suppress
└─────┬──────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
EventHandler DecisionHandler ErrorHandler
(business) (observability) (failures)
See ONLINE.md and OFFLINE.md for detailed decision flows.
| Type | Purpose |
|---|---|
argus.Watcher |
Main entry: New(opts...) *Watcher, Run, Stop, List, Known, EnsureFetcher, FetcherKind |
argus.Event / EventKind |
Business events (Online / Offline / Change) |
argus.Decision / DecisionKind |
Internal decision trace (17 branches) |
argus.Config / argus.ConfigError |
Tunable thresholds + structured validation errors (v0.9.0+) |
argus.Fetcher |
Data source interface, auto-detected |
argus.Prober |
Liveness probe; default ICMPProber{Timeout: 1s} |
argus.Hint / argus.HintSource / argus.DefaultHintSource |
Injectable enrichment (v0.7.0+) — DHCP/ARP on non-OpenWrt targets |
argus.LoggerHandler / LogLevel / LogAttr |
Structured logging hook (v0.9.0+) |
argus.SpanRecorder / SpanRecorderFunc |
Distributed-tracing hook (v0.12.0+) |
argus.SyslogEvent |
Raw syslog parse result |
argus.DetectLocalLocation() |
Parse /etc/TZ → *time.Location (no global mutation) |
argus.SetupLocalTimezone() |
Deprecated. Mutates time.Local |
| Sentinel errors | ErrHandlerRequired / ErrInvalidConfig / ErrNoFetcher / ErrFetchFailed / ErrAlreadyRunning (all errors.Is-compatible) |
github.com/xxl6097/argusd/argusmetrics |
Zero-dep Counters + LabeledCounters (v0.7.0 / v0.10.0+) |
github.com/xxl6097/argusd/argustest |
FixedFetcher / FakeProber for downstream tests (v0.6.0+) |
Functional options:
argus.WithConfig(cfg) // override defaults
argus.WithFetcher(custom) // custom data source
argus.WithProber(nil) // disable liveness probe
argus.WithBaseline(old.Known()) // seed known-set on restart
argus.WithHintSource(custom) // custom DHCP/ARP enrichment (v0.7.0+)
argus.WithLogger(h) // structured logging (v0.9.0+)
argus.WithSpanRecorder(r) // distributed tracing (v0.12.0+)
argus.OnFetcherDetected(func(k) {...}) // detection callback
argus.WithDecisionHandler(func(d) {...}) // decision traceAll thresholds live in argus.Config. Zero values preserve defaults.
w := argus.New(argus.WithConfig(argus.Config{
// Polling cadence
PollInterval: 1 * time.Second, // default 1s
OfflineMisses: 5, // default 5
FetchTimeout: 3 * time.Second, // default 3s
// Anti-flap
OfflineCooldown: 90 * time.Second,
CooldownReleaseRSSI: -65,
WeakRSSI: -80,
ExtremelyWeakRSSI: -88,
WeakMissThreshold: 5,
ExtremelyWeakMissThreshold: 2,
FlapSuppressionWindow: 30 * time.Second,
}))Guidelines:
| Scenario | Suggested change |
|---|---|
| Aggressive IoT gateway (tolerate noise) | FlapSuppressionWindow: 0, OfflineCooldown: time.Nanosecond |
| Home/away automation | keep defaults |
| Crowded WiFi environment | WeakRSSI: -75, WeakMissThreshold: 10 |
| Trust AP table only | WithProber(nil) |
Argus exposes five opt-in observability channels; pick the right one for the right audience.
| Channel | Type | Frequency | Use case |
|---|---|---|---|
EventHandler (arg to Run) |
Event |
Sparse | Business logic (home/away automation) |
ErrorHandler (arg to Run) |
error |
Rare | Non-fatal failures |
WithDecisionHandler |
Decision |
Dense | Tuning / debugging |
WithLogger (v0.9.0+) |
LogLevel + attrs |
Lifecycle + anomaly | slog/zap/zerolog bridge |
WithSpanRecorder (v0.12.0+) |
span start/finish | Per Run + per disconnect |
OTel / Datadog tracing |
Plus the argusmetrics subpackage for in-process counter aggregation (bridgeable to Prometheus / OTLP in ~10 lines; see godoc).
For raw syslog mirroring, call WatchSyslog(ctx, func(SyslogEvent), onError) directly — it's a standalone helper, not a Watcher option.
Sample decision trace:
[decision] CONNECT_HINT BA:79:... (IP=192.168.1.213)
[decision] CONNECT_EMIT BA:79:... (IP=192.168.1.213)
[event] ONLINE BA:79:... 192.168.1.213 iPhone -44(strong) 5G/avgb-5G
[decision] POLL_WEAK_MISS BA:79:... (RSSI=-82 misses=3/5)
[decision] POLL_WEAK_MISS BA:79:... (RSSI=-85 misses=5/5)
[decision] OFFLINE_EMIT BA:79:... (via=poll RSSI=-85)
[event] OFFLINE BA:79:...
DecisionHandler is zero-cost when not registered — no allocations, no time.Now() calls.
- ahsapd / hostapd dual fetcher with auto-detection
- syslog
logread -freal-time stream - ICMP liveness probe with parallel semaphore
- Cooldown + flap suppression
- Decision handler observability
-
go test -raceclean (multi-Go-version matrix, 1.21–1.25) - Lifecycle:
Stop+ restart (v0.5.0) - Portability:
HintSourceabstraction (v0.7.0) - Metrics:
argusmetrics.Counters+LabeledCounters(v0.7.0 / v0.10.0) - Structured logging hook
WithLogger(v0.9.0) - Structured validation errors
ConfigError(v0.9.0) - Distributed tracing hook
SpanRecorder(v0.12.0) - Fuzz targets for syslog / DHCP lease parsers (v0.12.0)
- Built-in Web UI (HTTP + SSE, v0.13.0)
- Device aliases with UTF-8 names (v0.14.0 / v0.15.4)
- Static DHCP reservations via UCI + immediate-apply (v0.15.0 / v0.15.2 / v0.15.7 / v0.15.8)
- IP-conflict 409 + one-click replace + PurgeArgusOwned recovery (v0.15.3 / v0.15.5)
- System endpoints: reboot + restart-network (v0.15.9)
- v1.0 tagged — Stable surface locked under SemVer v1 rules
- Direct
ubussocket integration (skip CLI) - IPv6-only device support
- Home Assistant
device_trackerbridge - Prometheus
/metricsendpoint (argusweb bridge)
| Platform | Data source | Status |
|---|---|---|
| MediaTek MT7981 vendor firmware | ahsapd | ✅ Reference target |
| OpenWrt 23.05+ stock | hostapd.* | 🧪 Theoretical, awaiting real-device validation |
Any Linux with logread + ubus |
syslog-only |
Go 1.21+ (N-2 policy: current + two preceding minor versions). No cgo. Cross-compiles to any GOOS/GOARCH that runs OpenWrt.
PRs welcome. See CONTRIBUTING.md. Before submitting, make sure the following pass locally:
go vet ./...
go test -race ./...
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./cmd/argusdCHANGELOG.md— version history (features & fixes)STABILITY.md— API stability guarantees & v1.0 criteriaONLINE.md— online decision deep-diveOFFLINE.md— offline + cooldown analysisdocs/SIGHUP-real-device-test.md— v0.5.0 Stop+Restart real-router validation reportdocs/blog/ios-static-ip.md— debugging story: the 3 ways "set static IP" silently fails on iOS + OpenWrt- GoDoc — API reference
MIT © 2026 — see LICENSE
"Every station. Every event. Every eye open."




