Documentation
¶
Overview ¶
Package argus provides real-time device presence detection for OpenWrt routers.
Argus — named after the hundred-eyed giant of Greek myth whose eyes never all slept — fuses six data sources into a single millisecond-grade event stream (Online / Offline / Change): ahsapd (vendor) or hostapd.* (stock) via ubus, logread -f syslog, /tmp/dhcp.leases, ip neigh, and ICMP liveness probes.
Quick start ¶
w := argus.New() // auto-detect fetcher
devices, _ := w.List(ctx) // one-shot snapshot
err := w.Run(ctx, func(e argus.Event) { // real-time stream
// handle EventOnline / EventOffline / EventChange
}, nil) // nil ErrorHandler = discard errors
Architecture ¶
┌───────────────────────────────┐
│ HintSource (enrichment) │
│ /tmp/dhcp.leases + ip neigh │
└────────────────┬──────────────┘
│ {IP, Hostname}
▼
┌──────────┐ ┌───────────┐ ┌──────┐ ┌────────────────┐ ┌────────────────┐
│ Fetcher │───▶│ baseline │───▶│ diff │───▶│ DecisionHandler│───▶│ EventHandler │
│ (ahsapd/ │ │ (known) │ │ │ │ (optional, │ │ Online/ │
│ hostapd)│ └───────────┘ └───┬──┘ │ zero-cost │ │ Offline/ │
└──────────┘ │ │ when nil) │ │ Change │
▲ ▼ └────────────────┘ └────────────────┘
│ ┌──────────┐
│ │ Prober │ (ICMP liveness; filters out fake-online)
│ └──────────┘
│
┌────┴───────────┐
│ logread -f │ (syslog hint stream; disconnect / deauth / assoc / DHCP)
└────────────────┘
The diff stage also consults an offlineCooldown map (90 s default) to suppress weak-signal flaps at the edge, and a FlapSuppressionWindow (30 s default) to collapse rapid-fire reconnects.
Extension points ¶
- WithFetcher: plug in a custom data source (tested with [argustest.FixedFetcher]).
- WithProber: replace ICMP liveness check (e.g. a faked in-memory map for tests).
- WithHintSource: inject a custom enrichment source for non-OpenWrt firmwares with /var/lib/misc/dnsmasq.leases or equivalent.
- WithDecisionHandler: receive internal decision traces (zero-cost when unset).
- WithLogger: structured logging adapter (slog / zap / zerolog in ~5 lines).
Lifecycle ¶
A single *Watcher may be Watcher.Run-Watcher.Stop-[Watcher.Run]ed repeatedly (SIGHUP hot-reload pattern). State preserved across restart: known set, offlineCooldown, lastEventAt, detected Fetcher. State reset: in-flight misses, disconnect dedup map, syslog hint channel, drop counter. Concurrent Run calls on the same Watcher fast-fail with ErrAlreadyRunning.
Observability ¶
- Logs via WithLogger (library never logs from the hot path; only lifecycle and recoverable anomalies).
- Metrics via the github.com/xxl6097/argusd/argusmetrics subpackage ([argusmetrics.Counters] for totals, [argusmetrics.LabeledCounters] for per-SSID / per-MAC / per-band bucketing), both zero-dependency.
- Decision traces via WithDecisionHandler — every internal choice the diff engine makes surfaces as a Decision record.
Error handling ¶
Library errors are matchable with errors.Is:
errors.Is(err, argus.ErrHandlerRequired) // nil callback errors.Is(err, argus.ErrInvalidConfig) // Config.Validate rejected errors.Is(err, argus.ErrNoFetcher) // ubus detection found nothing errors.Is(err, argus.ErrFetchFailed) // baseline fetch failed errors.Is(err, argus.ErrAlreadyRunning) // concurrent Run on same Watcher
Config.Validate returns a *ConfigError exposing the offending field, reachable via errors.As for form-level UI feedback.
Stability ¶
See the STABILITY.md document in the repository. The 0.x line is "minor-zero stable" (no breaking change to listed Stable surface within a 0.x.y window); v1.0 will lock that surface under SemVer v1 rules.
Supported Go versions ¶
The current Go release and the two preceding minors (N-2 policy). CI matrix tests every commit on Go 1.21 – 1.25.
Chinese summary · 中文摘要 ¶
Argus 是一个针对 OpenWrt 路由器的实时设备感知库, 融合 ahsapd / hostapd / syslog / DHCP / ARP / ICMP 六路数据源形成毫秒级事件流 (Online / Offline / Change)。 名字取自希腊神话中的百眼巨人 —— 他的眼睛永远不会同时闭上。零第三方依赖, 内置 argusmetrics 零分配计数器, 通过 WithDecisionHandler 暴露决策 trace, 生命周期 Stop / Restart 支持热重载。
Index ¶
- Variables
- func DetectFetcher(ctx context.Context, timeout time.Duration) (Fetcher, FetcherKind, error)
- func DetectLocalLocation() *time.Location
- func RenderTable(devs []Device) string
- func SetupLocalTimezone() *time.Locationdeprecated
- func TableHeader() (header, separator string)
- func WatchSyslog(ctx context.Context, onEvent SyslogHandler, onError ErrorHandler) error
- type AhsapdFetcher
- type Change
- type Config
- type ConfigError
- type Decision
- type DecisionHandler
- type DecisionKind
- type DefaultHintSource
- type Device
- type ErrorHandler
- type Event
- type EventHandler
- type EventKind
- type Fetcher
- type FetcherKind
- type Hint
- type HintSource
- type HostapdFetcher
- type ICMPProber
- type LogAttr
- type LogLevel
- type LoggerHandler
- type Option
- func OnFetcherDetected(cb func(FetcherKind)) Option
- func WithBaseline(baseline map[string]Device) Option
- func WithConfig(c Config) Option
- func WithDecisionHandler(cb DecisionHandler) Option
- func WithFetcher(f Fetcher) Option
- func WithHintSource(h HintSource) Option
- func WithLogger(h LoggerHandler) Option
- func WithProber(p Prober) Option
- func WithSpanRecorder(r SpanRecorder) Option
- type Prober
- type SpanRecorder
- type SpanRecorderFunc
- type SyslogEvent
- type SyslogHandler
- type SyslogKind
- type Watcher
- func (w *Watcher) EnsureFetcher(ctx context.Context) error
- func (w *Watcher) FetcherKind() FetcherKind
- func (w *Watcher) Known() map[string]Device
- func (w *Watcher) List(ctx context.Context) ([]Device, error)
- func (w *Watcher) Run(ctx context.Context, onEvent EventHandler, onError ErrorHandler) error
- func (w *Watcher) Stop(stopCtx context.Context) error
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrHandlerRequired 表示必填的回调 (典型: Run 的 onEvent) 为 nil。 ErrHandlerRequired = errors.New("argus: handler required") // ErrInvalidConfig 表示 Config 字段取值非法 (由 Config.Validate 判定)。 // Config.Validate 返回的具体错误会 wrap 这个 sentinel 且实现为 *ConfigError, // 允许 errors.As 拆出字段级详情。 ErrInvalidConfig = errors.New("argus: invalid config") // ErrNoFetcher 表示未显式提供 Fetcher 且自动探测 ubus 时未找到可用数据源。 ErrNoFetcher = errors.New("argus: no fetcher available") // ErrFetchFailed 包裹 Fetcher.Fetch 的失败, 便于上层通过 errors.Is 过滤拉取类错误。 ErrFetchFailed = errors.New("argus: fetch failed") // ErrAlreadyRunning 表示 Watcher 已有一个 Run 正在执行。同一 Watcher 在任何时刻 // 只能有一个 Run 活跃; 并发调用 Run 的后来者会立刻返回此错误, 避免共享状态被破坏。 // 允许的模式是先 Stop 再 Run (重启语义), 参见 (*Watcher).Stop。 ErrAlreadyRunning = errors.New("argus: watcher already running") )
Sentinel 错误, 可通过 errors.Is 判别。
使用方式:
if err := w.Run(ctx, onEvent, nil); err != nil {
switch {
case errors.Is(err, argus.ErrHandlerRequired):
// 忘传回调
case errors.Is(err, argus.ErrInvalidConfig):
// Config 非法 (可用 argus.AsConfigError 取出具体字段)
case errors.Is(err, argus.ErrNoFetcher):
// ubus 上没有 ahsapd / hostapd 服务
}
}
Functions ¶
func DetectFetcher ¶
DetectFetcher 探测路由器本机 ubus 上可用的接入设备数据源, 优先 ahsapd, 回退到 hostapd; 都不存在时返回错误。timeout 限制单次 ubus 探测耗时。
同时返回选中的 FetcherKind, 调用方可据此打日志。
func DetectLocalLocation ¶
DetectLocalLocation 按 OpenWrt 习惯探测路由器本机时区, 不修改全局状态。 优先级:
- /etc/TZ (POSIX 格式, 如 "CST-8")
- TZ 环境变量
探测失败时返回 nil。
func RenderTable ¶
RenderTable 把设备列表渲染为完整表格 (表头 + 分隔 + 行 + 分隔 + 汇总)。
func SetupLocalTimezone
deprecated
SetupLocalTimezone 显式将 time.Local 设置为路由器本机时区。 **修改全局状态** time.Local, 影响整个进程。建议在 main 函数最早期调用一次。
注意: 此函数会写 time.Local, 在测试中调用会污染全局状态。 若库使用者已自行管理时区, 改用 DetectLocalLocation() 获取 *time.Location 即可。
在交叉编译且未引入 time/tzdata 的二进制中, 默认 time.Local 会回退到 UTC。 此函数能让 OpenWrt 路由器上的时间输出与系统时间一致。
返回选中的时区, 未识别时返回 UTC 不修改 time.Local。
Deprecated: 修改全局 time.Local 是库层面的反模式。库的使用方应改用 DetectLocalLocation() 获取 *time.Location, 然后在自己的代码里通过 time.Time.In(loc) 做本地化格式化, 或在 main 里自行设置 time.Local。 此函数保留用于向后兼容, 不会在后续版本中移除, 但不推荐新代码使用。
func TableHeader ¶
func TableHeader() (header, separator string)
TableHeader 返回设备表格的表头和分隔线 (调用方可自行决定是否使用)。
func WatchSyslog ¶
func WatchSyslog(ctx context.Context, onEvent SyslogHandler, onError ErrorHandler) error
WatchSyslog 启动 `logread -f` 实时监听系统日志, 解析 WiFi / DHCP 相关事件 并通过 onEvent 回调上报。阻塞直到 ctx 取消或子进程退出。
ctx 取消时会终止子进程并关闭 stdout 管道; 非 ctx 取消导致的退出通过返回值上报, 调用方应视为监听失败, 必要时自行重启。
系统日志是被动监听, 不产生额外 CPU / 网络开销。
Types ¶
type AhsapdFetcher ¶
AhsapdFetcher 调用厂商私有 ubus 服务 `ahsapd.sta getStaInfo` 拉取设备列表。 适用于带 ahsapd 的厂商固件 (例如 MediaTek 7981 平台)。
type Change ¶
type Change struct {
Field string `json:"field"` // "IP" / "Hostname" / "Radio" / "SSID"
Old string `json:"old"`
New string `json:"new"`
}
Change 描述某个字段从旧值变为新值的细节, 仅在 EventChange 中使用。
type Config ¶
type Config struct {
// PollInterval 是相邻两次拉取之间的间隔。默认 1s。
PollInterval time.Duration `json:"poll_interval,omitempty"`
// OfflineMisses 是设备连续多少次未出现在拉取结果里才判定为离线 (默认路径, 不含 RSSI/ARP 加速)。
// 默认 5。
OfflineMisses int `json:"offline_misses,omitempty"`
// FetchTimeout 限制单次拉取调用的耗时。默认 3s。
FetchTimeout time.Duration `json:"fetch_timeout,omitempty"`
// OfflineCooldown 设备刚判离线后的冷却期: 冷却期内重复的 EventOffline 被压制,
// 且 RSSI < CooldownReleaseRSSI 的重新接入不触发 EventOnline。
//
// 只要设备持续处于弱信号状态, 冷却期会被每次轮询刷新, 避免抑制后再次触发重复离线。
// 设为 time.Nanosecond 可实际禁用冷却期。默认值见 DefaultConfig。
OfflineCooldown time.Duration `json:"offline_cooldown,omitempty"`
// CooldownReleaseRSSI 冷却期内 RSSI 恢复到此值 (含) 之上才允许触发 EventOnline。
// RSSI 为负值, 默认 -65 (强信号)。零值 (0 dBm) 无实际意义, 用作"使用默认"的标记。
CooldownReleaseRSSI int `json:"cooldown_release_rssi,omitempty"`
// WeakRSSI 信号弱的阈值, 低于此值触发 diff 的加速离线判定。默认 -80。
WeakRSSI int `json:"weak_rssi,omitempty"`
// ExtremelyWeakRSSI 信号极弱的阈值, 触发更激进的 threshold。默认 -88。
ExtremelyWeakRSSI int `json:"extremely_weak_rssi,omitempty"`
// WeakMissThreshold WeakRSSI ~ ExtremelyWeakRSSI 区间内的离线计数阈值。默认 5。
WeakMissThreshold int `json:"weak_miss_threshold,omitempty"`
// ExtremelyWeakMissThreshold < ExtremelyWeakRSSI 的离线计数阈值。默认 2。
ExtremelyWeakMissThreshold int `json:"extremely_weak_miss_threshold,omitempty"`
// FlapSuppressionWindow 抖动抑制窗口: 一台设备在此窗口内不会连续触发两个同类事件
// (两次 Online / 两次 Offline)。用于抵消中等信号设备的快闪。默认 30s。
// WithConfig 对零值按"保留默认"处理; 若需显式关闭, 请使用 DisableFlapSuppression。
FlapSuppressionWindow time.Duration `json:"flap_suppression_window,omitempty"`
// DisableCooldown 显式关闭冷却期机制。设置为 true 时, OfflineCooldown 相关的
// 所有抑制逻辑 (COOLDOWN_SUPPRESS_ONLINE / COOLDOWN_SUPPRESS_OFFLINE /
// COOLDOWN_CLEARED) 都不再触发, 离线事件后重新出现的设备立即触发上线。
// 默认 false (启用冷却期)。此字段与 OfflineCooldown 数值不冲突: true 时忽略数值。
DisableCooldown bool `json:"disable_cooldown,omitempty"`
// DisableFlapSuppression 显式关闭抖动抑制窗口 (与 FlapSuppressionWindow=0 等价,
// 但语义更清晰, 不依赖"零值 = 关闭"的约定)。默认 false (启用抑制)。
DisableFlapSuppression bool `json:"disable_flap_suppression,omitempty"`
}
Config 控制 Watcher 的轮询节奏、离线判定阈值、冷却期与弱信号分级。 字段为零值时会在 New 中回退到默认值, 因此最小用法是 New() 不带任何 Option。
JSON / TOML / YAML 反序列化支持: 字段带 `json:` 标签, 下游可以直接从 配置文件加载。零值保留默认的约定在反序列化后依然生效 (通过 WithConfig 合并), 所以缺失字段不会把 Config 重置为零。
Example ¶
ExampleConfig shows how to tune cooldown / flap-suppression knobs.
Zero values preserve defaults; to fully disable a feature, use DisableCooldown / DisableFlapSuppression rather than a magic value.
package main
import (
argus "github.com/xxl6097/argusd"
)
func main() {
_ = argus.New(argus.WithConfig(argus.Config{
// Crowded WiFi environment: raise the "weak" threshold so more
// borderline devices are treated as weak-signal.
WeakRSSI: -75,
WeakMissThreshold: 10,
// Turn off flap suppression entirely (e.g. aggressive IoT gateway
// that wants every edge event, even duplicates).
DisableFlapSuppression: true,
}))
}
Output:
Example (JsonReload) ¶
ExampleConfig_jsonReload shows loading Config from a JSON config file (e.g. /etc/argusd.json). Field names are stable across the v0.x line.
package main
import (
"encoding/json"
"log"
argus "github.com/xxl6097/argusd"
)
func main() {
jsonBlob := []byte(`{
"poll_interval": 2000000000,
"offline_misses": 7,
"weak_rssi": -75,
"disable_flap_suppression": true
}`)
var cfg argus.Config
if err := json.Unmarshal(jsonBlob, &cfg); err != nil {
log.Fatal(err)
}
w := argus.New(argus.WithConfig(cfg))
_ = w
}
Output:
func DefaultConfig ¶
func DefaultConfig() Config
DefaultConfig 返回库的默认配置:
- 上线 / 状态变更 ≈1s
- 正常路径离线检测 ≈5s (OfflineMisses × PollInterval)
- 信号极弱时离线检测 ≈2s
- 弱信号边缘设备的上下线抖动被 OfflineCooldown (默认 90s) 压制
所有字段均可通过 WithConfig 覆盖, 传零值保留默认。
type ConfigError ¶
type ConfigError struct {
// Field is the Go-level struct field name (e.g. "OfflineMisses"),
// stable across 0.x releases for any field in the Stable JSON list.
Field string
// Value is the offending value as passed by the caller (nil-safe —
// zero values are included verbatim).
Value any
// Reason is a short human-readable description of the constraint
// that was violated (e.g. "must be >= 1").
Reason string
}
ConfigError describes a single invalid Config field. Returned by Config.Validate wrapped under ErrInvalidConfig.
Typical consumer (e.g. a web config UI) uses errors.As to extract the offending field for form-level feedback:
var ce *argus.ConfigError
if errors.As(err, &ce) {
formErrors[ce.Field] = ce.Error()
}
The Unwrap chain matches ErrInvalidConfig so errors.Is(err, ErrInvalidConfig) still works for coarse-grained matching.
Example ¶
ExampleConfigError shows how to extract field-level details from a Config validation failure — useful when surfacing errors in a web UI where each form field needs its own message.
package main
import (
"errors"
"fmt"
argus "github.com/xxl6097/argusd"
)
func main() {
cfg := argus.DefaultConfig()
cfg.WeakMissThreshold = 0 // invalid
err := cfg.Validate()
if errors.Is(err, argus.ErrInvalidConfig) {
var ce *argus.ConfigError
if errors.As(err, &ce) {
fmt.Printf("field=%s reason=%s", ce.Field, ce.Reason)
}
}
}
Output: field=WeakMissThreshold reason=must be > 0
func (*ConfigError) Error ¶
func (e *ConfigError) Error() string
func (*ConfigError) Unwrap ¶
func (e *ConfigError) Unwrap() error
Unwrap lets errors.Is(err, ErrInvalidConfig) succeed.
type Decision ¶
type Decision struct {
Time time.Time `json:"time"`
Kind DecisionKind `json:"kind"`
MAC string `json:"mac"`
Detail string `json:"detail,omitempty"` // 可选的人类可读上下文 (如 "RSSI=-75 misses=3/5")
}
Decision 是一次内部决策的观测记录, 通过 DecisionHandler 暴露给上层。 用于日志 / 监控 / 调试; 业务侧通常只关心 Event, 但需要调参或排障时 Decision 提供了完整的判定链路信息。
JSON 序列化: Kind 用英文稳定标识 (见 DecisionKind.MarshalJSON)。
type DecisionHandler ¶
type DecisionHandler func(Decision)
DecisionHandler 接收内部决策观测。可为 nil, 为 nil 时决策不收集, 完全零成本。
type DecisionKind ¶
type DecisionKind int
DecisionKind 表示判定链路上的决策点类型。 每个决策代表 Watcher 内部的一次具体判断 (进入/跳过某个分支), 供上层观测库的"为什么触发/为什么没触发"过程, 便于调参和调试。
const ( // DecisionConnectHintReceived: 收到 syslog 接入事件 hint (WPA完成/MAC表新增/DHCP分配)。 DecisionConnectHintReceived DecisionKind = 1 // DecisionConnectSkippedKnown: 已在 known 中, 防重复跳过。 DecisionConnectSkippedKnown DecisionKind = 2 // DecisionConnectEmitted: 基于 hint 信息构建基础记录并触发 EventOnline。 DecisionConnectEmitted DecisionKind = 3 // DecisionCooldownSuppressOnline: 冷却期 + 弱信号, 静默更新 known, 不触发 Online。 DecisionCooldownSuppressOnline DecisionKind = 4 // DecisionCooldownCleared: 冷却期内信号恢复到强 (或 RSSI 未知), 清除冷却, 允许 Online。 DecisionCooldownCleared DecisionKind = 5 // DecisionFlapSuppressOnline: 窗口期内同类 Online 事件被压制。 DecisionFlapSuppressOnline DecisionKind = 6 // DecisionDisconnectHintReceived: 收到 syslog 断开事件 hint (Disconnect/Deauth/Del Sta)。 DecisionDisconnectHintReceived DecisionKind = 20 // DecisionDisconnectIgnoredUnknown: 不在 known 中, 忽略。 DecisionDisconnectIgnoredUnknown DecisionKind = 21 // DecisionDisconnectPingOK: 500ms 后 ping 仍可达 (漫游), 不触发离线。 DecisionDisconnectPingOK DecisionKind = 22 // DecisionOfflineEmitted: 触发 EventOffline。 DecisionOfflineEmitted DecisionKind = 23 // DecisionFlapSuppressOffline: 窗口期内同类 Offline 事件被压制。 DecisionFlapSuppressOffline DecisionKind = 24 // DecisionCooldownSuppressOffline: 冷却期内重复离线被静默移除, 不重复触发事件。 DecisionCooldownSuppressOffline DecisionKind = 25 // DecisionDisconnectSkippedInflight: 同一 MAC 已有正在处理的 disconnect worker, // 后续重复 hint (典型: disconnect/deauth/Del Sta 三连发) 直接跳过, 不再各自 // 进入 500ms Sleep + ping 流程。 DecisionDisconnectSkippedInflight DecisionKind = 26 // DecisionPollAPSleepProtected: 设备在 AP 关联表, RSSI 正常 ping 不通 → 息屏保护, 不计离线。 DecisionPollAPSleepProtected DecisionKind = 40 // DecisionPollWeakSignalMiss: 弱信号且 ping 不通, 累加 miss 计数 (可能尚未达阈值)。 DecisionPollWeakSignalMiss DecisionKind = 41 // DecisionPollARPFailedOffline: AP 关联表也没了, ARP 状态 FAILED/INCOMPLETE, 立即离线。 DecisionPollARPFailedOffline DecisionKind = 42 // DecisionPollMissesExhausted: 默认 miss 计数达阈值, 触发离线。 DecisionPollMissesExhausted DecisionKind = 43 )
func (DecisionKind) MarshalJSON ¶
func (k DecisionKind) MarshalJSON() ([]byte, error)
MarshalJSON 序列化为 DecisionKind.String() 的英文稳定标识 (如 "CONNECT_EMIT"), 而非整数值。整数值在 STABILITY.md 中被标为 Evolving, 字符串则稳定。
func (DecisionKind) String ¶
func (k DecisionKind) String() string
String 返回决策类型的稳定英文标识, 便于日志 / grep / 序列化。
type DefaultHintSource ¶
type DefaultHintSource struct {
// LeasesPath DHCP 租约文件路径, 留空使用默认 /tmp/dhcp.leases。
LeasesPath string
// ARPCommand 列出 ARP 表的命令 (argv 形式, 不走 shell), 留空使用默认
// []string{"ip", "neigh", "show"}。
ARPCommand []string
// CacheTTL 缓存过期时长, 0 或负值使用默认 5s。设为极短值 (如 1ns) 可实际禁用。
CacheTTL time.Duration
// contains filtered or unexported fields
}
DefaultHintSource 是库的默认 HintSource 实现, 读取 OpenWrt 标准路径。 可通过修改字段覆盖路径 / 命令, 适用于定制固件。
并发安全: 内部带 TTL 缓存, 默认 5 秒, 避免每秒多次 fork 子进程。
type Device ¶
type Device struct {
MAC string `json:"mac"` // 小写冒号格式, 例如 "aa:bb:cc:dd:ee:ff"
IP string `json:"ip,omitempty"` // IPv4 地址, 拿不到时为空
Hostname string `json:"hostname,omitempty"` // 主机名, 拿不到时为空
Vendor string `json:"vendor,omitempty"` // 厂商或设备型号 (来自 ahsapd staVendor)
Type string `json:"type,omitempty"` // 设备类别, 如 "Phone" / "PC", 拿不到时为空
Radio string `json:"radio,omitempty"` // "2.4G" / "5G", 有线接入时为空
SSID string `json:"ssid,omitempty"` // 关联的 SSID, 有线时为空
Channel int `json:"channel,omitempty"` // 信道号, 0 表示未知
RSSI int `json:"rssi,omitempty"` // 信号强度 dBm, 0 表示未知
UpTime time.Duration `json:"uptime_ns,omitempty"` // 已接入时长 (纳秒, 用 Duration.Nanoseconds)
AccessTime time.Time `json:"access_time,omitempty"` // 设备接入时刻 (路由器本机时区)
LastSeen time.Time `json:"last_seen,omitempty"` // 库最近一次观察到该设备的时刻
}
Device 是已接入设备的归一化记录, 由 Fetcher 产出, 也是事件回调中携带的载荷。
JSON 序列化: 字段名固化在下方 `json:` 标签中, 从 v0.6.0 起这些 JSON key 属于 STABILITY.md 中的 Stable 公共面, 可作为下游 Kafka / HTTP webhook / 数据库 列名安全使用。
调用方需要的所有信息都在结构体字段里, 不再依赖原始 ubus 字符串。
type Event ¶
type Event struct {
Time time.Time `json:"time"`
Kind EventKind `json:"kind"`
Device Device `json:"device"` // 当前快照 (对 EventOffline 表示设备最后一次的快照)
Changes []Change `json:"changes,omitempty"` // 仅 EventChange 时填充
}
Event 是一次设备状态迁移的完整描述, 通过 Watcher.Run 的回调投递。
JSON 序列化: 字段名固化在下方 `json:` 标签中, 从 v0.6.0 起这些 JSON key 属于 STABILITY.md 中的 Stable 公共面。Kind 序列化为英文字符串 (ONLINE / OFFLINE / CHANGE), 保证跨版本稳定——详见 EventKind.MarshalJSON。
type EventKind ¶
type EventKind int
EventKind 表示设备状态迁移的类型。
func (EventKind) MarshalJSON ¶
MarshalJSON 将 EventKind 序列化为稳定字符串 (String() 的结果), 而非整数值。整数值可能在 minor 版本间变化; 字符串保证稳定。
func (*EventKind) UnmarshalJSON ¶
UnmarshalJSON 支持双向兼容: 既接受字符串 ("ONLINE" / "OFFLINE" / "CHANGE"), 也接受老的整数表示, 便于从外部数据源回读。
type Fetcher ¶
Fetcher 抽象一次性拉取当前接入设备列表的能力。 默认通过 DetectFetcher 自动从 ubus 上选择 AhsapdFetcher (厂商私有) 或 HostapdFetcher (OpenWrt 官方 hostapd); 测试或对接其它服务时, 业务方可 自行实现该接口并通过 WithFetcher 注入。
type FetcherKind ¶
type FetcherKind string
FetcherKind 标识自动选择的 Fetcher 类型, 便于打日志或上报。
const ( FetcherAhsapd FetcherKind = "ahsapd" FetcherHostapd FetcherKind = "hostapd" )
type Hint ¶
Hint 是 HintSource 返回的单条辅助信息, 用于在 Fetcher 数据缺失时 填补 Device 的 IP / Hostname 字段。
导出于 v0.7.0, 便于自定义 HintSource 实现。
type HintSource ¶
HintSource 抽象"从 DHCP 租约 / ARP 表获取设备辅助信息"的能力, 允许使用方在非 OpenWrt 平台 (Docker 容器 / 自研固件) 注入自定义实现。
默认由 DefaultHintSource 提供 (读 /tmp/dhcp.leases + `ip neigh show`)。 返回值按小写 MAC 索引; 实现可自行决定是否带缓存。
type HostapdFetcher ¶
type HostapdFetcher struct {
// Interfaces 是要查询的 hostapd 服务名列表 (例如 ["hostapd.wlan0", "hostapd.wlan1"])。
// 留空表示动态探测。
Interfaces []string
// Timeout 限制单次 ubus 调用耗时; 0 表示不超时。
Timeout time.Duration
}
HostapdFetcher 适用于 OpenWrt 官方固件: 通过 `ubus call hostapd.<iface> get_clients` 列出每个无线接口的关联终端, 通过 `get_status` 取 SSID / 频率 / 信道, 并合并 ARP 表 中的有线设备, 最后用 DHCP 租约补全主机名 / IP。
Interfaces 留空时会在每次 Fetch 前自动探测 (调用 `ubus list hostapd.*`)。
type ICMPProber ¶
ICMPProber 基于本机 `ping` 命令实现 Prober。 Timeout 既是单次 ping 的等待秒数, 也是 Reachable 调用的最大耗时。
type LogAttr ¶
LogAttr is a single structured log field. Key is always a stable identifier (e.g. "mac", "kind", "elapsed_ms"); Value can be any type slog / zap / zerolog can format.
type LogLevel ¶
type LogLevel int
LogLevel is the severity the library emits for structured log events. Matches log/slog conventions (Debug < Info < Warn < Error) but is a plain int so consumers not on slog (zap/zerolog/stdlib log) can pattern match without importing log/slog.
const ( // LogLevelDebug is for verbose internal tracing (hot-path detail). // Argus itself currently emits nothing at Debug; reserved for future use. LogLevelDebug LogLevel = -4 // LogLevelInfo is for one-off lifecycle messages (detector picks a // Fetcher, Run starts, Stop completes). LogLevelInfo LogLevel = 0 // LogLevelWarn is for recoverable anomalies (syslog buffer drops, // cache refresh failures, ubus subprocess killed). LogLevelWarn LogLevel = 4 // LogLevelError is for non-recoverable failures that the library // still surfaces to onError. Every LogLevelError call has a // matching ErrorHandler invocation. LogLevelError LogLevel = 8 )
type LoggerHandler ¶
LoggerHandler is called by the library to emit structured log lines. It is called synchronously from the calling goroutine and MUST NOT block.
The library never logs from the hot decision path (emitDecision, safeInvokeEvent): every log call site is a one-off lifecycle or recoverable-anomaly event, dispatched at most a few times per second.
When LoggerHandler is nil (the default, see WithLogger), library log call sites bail out on a nil check without invoking the handler. The variadic attrs slice may still be constructed at the call site — this is fine for the paths Argus logs from; the hot path has no log calls.
Typical adapters:
// log/slog
argus.WithLogger(func(ctx context.Context, level argus.LogLevel, msg string, attrs ...argus.LogAttr) {
sa := make([]slog.Attr, len(attrs))
for i, a := range attrs {
sa[i] = slog.Any(a.Key, a.Value)
}
slog.LogAttrs(ctx, slog.Level(level), msg, sa...)
})
// zap
argus.WithLogger(func(_ context.Context, level argus.LogLevel, msg string, attrs ...argus.LogAttr) {
fields := make([]zap.Field, len(attrs))
for i, a := range attrs {
fields[i] = zap.Any(a.Key, a.Value)
}
switch level {
case argus.LogLevelWarn: zapLogger.Warn(msg, fields...)
case argus.LogLevelError: zapLogger.Error(msg, fields...)
default: zapLogger.Info(msg, fields...)
}
})
ErrorHandler remains the primary error-reporting surface for programmatic error handling (errors.Is matching). LoggerHandler is complementary — it surfaces the same events with structured context for observability pipelines (slog / zap / OpenTelemetry), without forcing consumers to parse error.Error() strings.
type Option ¶
type Option func(*Watcher)
Option 用于自定义 Watcher。
func OnFetcherDetected ¶
func OnFetcherDetected(cb func(FetcherKind)) Option
OnFetcherDetected 注册回调, 在自动探测完成时被调用一次, 报告选中的 Fetcher 类型。 显式 WithFetcher 注入时不会触发此回调。
func WithBaseline ¶
WithBaseline 以 baseline 为已知设备基线初始化 Watcher。Watcher 在启动时会跳过 对这些 MAC 的"新上线"识别 (不触发 EventOnline), 直接视为历史已知。
典型场景: 进程重启 / 热重载时, 把旧 Watcher 的 Known() 快照传给新 Watcher, 避免重启瞬间所有设备被识别为"新上线"导致业务事件风暴。
注意: baseline 是浅拷贝; 传入后请勿再并发修改这份 map。 不会触发 EnsureFetcher, 传入的设备字段由调用方保证正确。
Example ¶
ExampleWithBaseline demonstrates process hot-reload: carry the known-set from the old Watcher into the new one so no spurious "new online" events fire on restart.
package main
import (
"context"
"fmt"
argus "github.com/xxl6097/argusd"
)
func main() {
ctx := context.Background()
// Old Watcher somewhere in your code
old := argus.New()
_, _ = old.List(ctx)
// Capture the state snapshot (thread-safe deep copy)
snapshot := old.Known()
// Build a new Watcher seeded with that state; startup will not re-emit
// these MACs as EventOnline.
fresh := argus.New(argus.WithBaseline(snapshot))
fmt.Printf("Seeded with %d known devices\n", len(fresh.Known()))
}
Output:
func WithConfig ¶
WithConfig 设置自定义轮询配置。零值字段会保留默认值 (Config 字段的零值 0 通常不合法, 安全地作为"使用默认"的哨兵)。如需显式关闭冷却期或抖动抑制, 请使用 Config.DisableCooldown / DisableFlapSuppression 而非 OfflineCooldown=0。
func WithDecisionHandler ¶
func WithDecisionHandler(cb DecisionHandler) Option
WithDecisionHandler 注册决策观测回调, 用于记录 Watcher 内部判定链路 (上线/离线/ 冷却期/抖动抑制等决策点)。适合日志 / 调参 / 排障, 业务侧通常用 EventHandler 即可。 回调内不应阻塞, 决策产生频率较高。传 nil (或不调用本 Option) 完全不收集决策。
Example ¶
ExampleWithDecisionHandler enables the decision trace to inspect why a particular online/offline was (or wasn't) emitted.
DecisionHandler is zero-cost when not registered.
package main
import (
"context"
"log"
"os/signal"
"syscall"
argus "github.com/xxl6097/argusd"
)
func main() {
w := argus.New(
argus.WithDecisionHandler(func(d argus.Decision) {
log.Printf("[decision] %-24s %s %s", d.Kind, d.MAC, d.Detail)
}),
)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT)
defer stop()
_ = w.Run(ctx, func(e argus.Event) {
log.Printf("[event] %s %s", e.Kind, e.Device.MAC)
}, nil)
}
Output:
func WithHintSource ¶
func WithHintSource(h HintSource) Option
WithHintSource 注入自定义的 HintSource 实现。用于:
- 非 OpenWrt 平台 (Docker 容器 / 自研固件) 覆盖默认路径
- 单元测试中注入固定 hints 避免依赖真实 /tmp/dhcp.leases
- 从其他来源获取 hostname / IP (例如 DNS / DHCP server API)
传 nil 回退到包级默认 (DefaultHintSource 读 /tmp/dhcp.leases + ip neigh show)。 导出于 v0.7.0。
func WithLogger ¶
func WithLogger(h LoggerHandler) Option
WithLogger registers a structured logger. May be called multiple times — only the last registration takes effect.
When unregistered (the default), library log call sites bail on a nil check without invoking the handler.
Example ¶
ExampleWithLogger shows how to bridge Argus's structured log events to a third-party logger (here log/slog stand-in via fmt). The hot decision path does not log; this hook only fires for lifecycle events (watcher starting/stopping, fetcher detection) and recoverable anomalies (syslog buffer overflow, fetch failures).
package main
import (
"context"
"fmt"
argus "github.com/xxl6097/argusd"
)
func main() {
w := argus.New(
argus.WithLogger(func(_ context.Context, level argus.LogLevel, msg string, attrs ...argus.LogAttr) {
// Adapt to slog: slog.LogAttrs(ctx, slog.Level(level), msg, ...)
fmt.Printf("[%s] %s", level, msg)
for _, a := range attrs {
fmt.Printf(" %s=%v", a.Key, a.Value)
}
fmt.Println()
}),
)
_ = w
}
Output:
func WithProber ¶
WithProber 注入活性探测器, 用于识别 AP 关联表残留 (例如手机走出信号范围 但 AP 还未将其踢出关联表的场景)。传 nil 可关闭活性探测。 默认使用 ICMPProber{Timeout: 1s}。
func WithSpanRecorder ¶
func WithSpanRecorder(r SpanRecorder) Option
WithSpanRecorder registers a tracing hook. May be called multiple times; only the last registration takes effect.
When unregistered (the default), all library [Watcher.startSpan] call sites bail on a single nil check; no context.Context wrapping, no closure allocation, no observable cost on the hot path.
type Prober ¶
Prober 抽象设备活性探测; 用于识别"AP 关联表里仍然挂着, 但实际已经走出无线范围 或离开 LAN"的场景。Reachable 应在内部自行处理超时, 返回 true 表示可达。
type SpanRecorder ¶
type SpanRecorder interface {
// Start opens a new span. Returns a context.Context that should be
// used by the spanned operation, and a finish function that the
// caller must invoke exactly once. The finish function takes the
// final error (or nil on success).
Start(ctx context.Context, name string) (context.Context, func(err error))
}
SpanRecorder is an optional tracing hook the library uses to emit distributed-tracing spans for the multi-stage decision pipeline (syslog hint → ping check → emit Online/Offline).
It is the tracing analogue of LoggerHandler: a tiny interface the library calls into, with zero third-party dependency. Adapters to OpenTelemetry / OpenTracing / Datadog APM are typically ~15 lines.
Start opens a span scoped to the returned context. The returned finish function MUST be called exactly once with the operation's final error (or nil on success). Span lifecycle is owned by the caller; the library never panics if Start is called from a hot path with a non-nil recorder, but in practice it is only called from the lifecycle paths that already log (see WithLogger).
A nil SpanRecorder is the default: every Start call site short- circuits via a single nil check, no allocation.
Example adapter for OpenTelemetry:
import "go.opentelemetry.io/otel"
tracer := otel.Tracer("argus")
argus.WithSpanRecorder(argus.SpanRecorderFunc(
func(ctx context.Context, name string) (context.Context, func(error)) {
ctx, span := tracer.Start(ctx, name)
return ctx, func(err error) {
if err != nil {
span.RecordError(err)
}
span.End()
}
},
))
type SpanRecorderFunc ¶
SpanRecorderFunc adapts a plain function to the SpanRecorder interface, mirroring net/http.HandlerFunc style. Useful for inline declarations in WithSpanRecorder.
type SyslogEvent ¶
type SyslogEvent struct {
Time time.Time
Kind SyslogKind
MAC string // 小写冒号格式
IP string // DHCP 事件时有值
Iface string // 无线接口名 (如 rax0)
Raw string // 原始日志行
}
SyslogEvent 是从 OpenWrt 系统日志中解析出的设备事件。
type SyslogKind ¶
type SyslogKind int
SyslogKind 标识系统日志事件类型。
const ( // SyslogWifiConnect: 无线设备完成关联 + 4-way 握手 (内核 wifi_sys_conn_act)。 SyslogWifiConnect SyslogKind = iota + 1 // SyslogWifiDisconnect: 无线设备断开关联 (内核 wifi_sys_disconn_act)。 SyslogWifiDisconnect // SyslogDeauth: AP 收到或发出 Deauth 帧 (ap_peer_deauth_action)。 SyslogDeauth // SyslogMacTableDelete: AP 从 MAC 表中删除设备 (MacTableDeleteEntry)。 // 走出信号范围时 AP 不一定产生 disconnect/deauth, 但一定会 Del Sta。 SyslogMacTableDelete // SyslogWPAComplete: WPA 握手完成 (AP SETKEYS DONE)。 SyslogWPAComplete // SyslogMacTableInsert: AP 将新设备插入 MAC 表 (MacTableInsertEntry New Sta)。 SyslogMacTableInsert // SyslogDHCPAck: DHCP 分配确认 (dnsmasq-dhcp DHCPACK)。 SyslogDHCPAck )
func (SyslogKind) IsConnect ¶
func (k SyslogKind) IsConnect() bool
IsConnect 报告事件是否表示设备接入 (WPA 握手完成、MAC 表新增或 DHCP 分配)。
func (SyslogKind) IsDisconnect ¶
func (k SyslogKind) IsDisconnect() bool
IsDisconnect 报告事件是否表示设备断开。 MacTableDeleteEntry 是设备离开信号范围后 AP 必定产生的最终事件, 即使 disconnect_act / deauth 被跳过。
type Watcher ¶
type Watcher struct {
// contains filtered or unexported fields
}
Watcher 是库的主入口, 管理一份"已知设备"的状态, 通过周期拉取识别变化。
w := argus.New()
devices, _ := w.List(ctx) // 一次性拉取
w.Run(ctx, func(e Event) {...}, nil) // 实时监听 (阻塞)
未通过 WithFetcher 显式指定时, 首次 List/Run 会用 DetectFetcher 在 ubus 上 自动识别 ahsapd / hostapd 并选择对应实现; 探测结果会被缓存。
Run 内部同时启动 logread -f 监听系统日志: 当收到 wifi_sys_disconn_act / deauth / Del Sta 等事件时, 立即触发对该设备的活性探测并可即时判定离线。
并发模型: 轮询 diff、日志驱动 handleConnectHint 和 handleDisconnectHint 可能 在不同 goroutine 中被调度, 统一通过 stateMu 保护 known / misses / cooldown。
func New ¶
New 创建一个 Watcher。Option 用于覆盖默认配置或注入自定义组件。
Example ¶
ExampleNew demonstrates the minimal setup: build a Watcher with defaults, then run it until SIGINT/SIGTERM is received.
On OpenWrt, the library auto-detects ahsapd (vendor) or hostapd.* (stock); no options are required for the common case.
package main
import (
"context"
"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()
err := w.Run(ctx, func(e argus.Event) {
switch e.Kind {
case argus.EventOnline:
log.Printf("[+] %s joined %s", e.Device.MAC, e.Device.IP)
case argus.EventOffline:
log.Printf("[-] %s left", e.Device.MAC)
case argus.EventChange:
log.Printf("[~] %s changed: %+v", e.Device.MAC, e.Changes)
}
}, nil)
if err != nil {
log.Fatal(err)
}
}
Output:
func (*Watcher) EnsureFetcher ¶
EnsureFetcher 在 fetcher 未显式指定时, 触发一次 ubus 探测。多次调用安全。 通常无需手动调用 - List / Run 内部会自动触发。
func (*Watcher) FetcherKind ¶
func (w *Watcher) FetcherKind() FetcherKind
FetcherKind 返回当前 Watcher 使用的 Fetcher 类型。 仅在首次 List / Run 调用 (或显式调用 EnsureFetcher) 之后才有意义。 用户显式 WithFetcher 注入时返回空串。
func (*Watcher) Known ¶
Known 返回当前库认为"在线"的设备集的深拷贝快照, 按小写 MAC 索引。 并发安全, 可随时调用。
典型用途: 进程热重载时, 把快照传给新 Watcher 的 WithBaseline 以避免 重启瞬间所有设备被识别为"新上线"。
func (*Watcher) List ¶
List 立即拉取一次当前接入设备列表, 不会启动后台监听。 启用了 Prober 时, 不可达的"假在线"设备会被过滤掉; 如果 Run 正在运行且设备处于 离线冷却期 (刚判离线且 RSSI 仍弱), 该设备也会被过滤, 与 Run 的"在线"定义保持一致。 返回切片按 MAC 排序。
Example ¶
ExampleWatcher_List shows a one-shot snapshot of the currently-associated devices without starting background monitoring.
package main
import (
"context"
"fmt"
"log"
argus "github.com/xxl6097/argusd"
)
func main() {
w := argus.New()
ctx := context.Background()
devices, err := w.List(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println(argus.RenderTable(devices))
}
Output:
func (*Watcher) Run ¶
func (w *Watcher) Run(ctx context.Context, onEvent EventHandler, onError ErrorHandler) error
Run 启动后台监听并阻塞直到 ctx 被取消:
- 启动时先做一次基线拉取, 这次不会触发 onEvent;
- 之后每个轮询周期通过 onEvent 投递设备上线 / 离线 / 状态变更事件;
- 系统日志 hint 在独立 goroutine 中异步处理, 不阻塞主循环;
- 单次拉取失败通过 onError 上报但不会终止循环 (onError 可为 nil)。
仅在初始基线拉取失败时返回 error, ctx 取消时返回 nil。
并发与生命周期:
- 同一 Watcher 在任意时刻只能有一个 Run 活跃; 并发调用 Run 的后来者 会立即返回 ErrAlreadyRunning。先调用 Stop 或等当前 Run 返回后, 可以 再次 Run。
- Run 返回时保留 known / offlineCooldown / lastEventAt 和探测缓存的 Fetcher / detectKind, 支持重启复用。
- Run 入口会重置瞬态状态 (misses / disconnectInFlight / droppedHints / syslogHints channel), 避免上一轮遗留影响新一轮判定。
错误可通过 errors.Is 判别: ErrHandlerRequired / ErrInvalidConfig / ErrNoFetcher / ErrFetchFailed / ErrAlreadyRunning。
func (*Watcher) Stop ¶
Stop 优雅停止当前 Run, 等待所有 in-flight goroutine (syslog / consumer / hint worker) 退出或 stopCtx 超时。
约定:
- 幂等: 没有 Run 在运行时立即返回 nil。
- 可从任意 goroutine 调用, 包括与 Run 并发。
- stopCtx 超时返回 context.DeadlineExceeded; 此时内部 ctx 已取消, 未退出的 goroutine 会自行清理, 但 Run 本身可能尚未返回。
- Stop 后可以再次 Run; 瞬态状态 (misses / disconnectInFlight / syslogHints / droppedHints) 会在 Run 入口重置, timeless 状态 (known / offlineCooldown / lastEventAt) 保留。
典型用法 (SIGHUP 热重载配置):
sighup := make(chan os.Signal, 1)
signal.Notify(sighup, syscall.SIGHUP)
for {
go w.Run(ctx, onEvent, onError)
<-sighup
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
w.Stop(stopCtx)
cancel()
// 用新配置继续下一轮
}
Example ¶
ExampleWatcher_Stop shows the SIGHUP hot-reload pattern: the Watcher survives config changes without re-emitting Online for every known device.
State preservation across Stop → Run:
- preserved: known, offlineCooldown, lastEventAt, detected Fetcher
- reset: misses, disconnectInFlight, syslogHints channel, droppedHints
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"time"
argus "github.com/xxl6097/argusd"
)
func main() {
w := argus.New()
sighup := make(chan os.Signal, 1)
signal.Notify(sighup, syscall.SIGHUP)
sigterm := make(chan os.Signal, 1)
signal.Notify(sigterm, syscall.SIGTERM, syscall.SIGINT)
for {
ctx, cancel := context.WithCancel(context.Background())
runErr := make(chan error, 1)
go func() { runErr <- w.Run(ctx, onEvent, onError) }()
select {
case <-sighup:
// Hot-reload config
stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := w.Stop(stopCtx); err != nil {
log.Printf("stop timed out: %v", err)
}
stopCancel()
cancel()
<-runErr
log.Println("reloaded config; restarting watcher")
// loop continues, fresh Run with new config
case <-sigterm:
cancel()
<-runErr
return
}
}
}
// Example callback helpers for ExampleWatcher_Stop.
func onEvent(e argus.Event) {
log.Printf("[event] %s %s", e.Kind, e.Device.MAC)
}
func onError(err error) {
log.Printf("[error] %v", err)
}
Output:
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package argusmetrics provides in-process metric counters for Argus decision traces, without pulling in any third-party metrics library.
|
Package argusmetrics provides in-process metric counters for Argus decision traces, without pulling in any third-party metrics library. |
|
Package argustest 提供 argus 库使用者编写单元测试时可复用的测试替身。
|
Package argustest 提供 argus 库使用者编写单元测试时可复用的测试替身。 |
|
Package argusweb exposes a built-in HTTP + Server-Sent Events (SSE) dashboard on top of an argus.Watcher.
|
Package argusweb exposes a built-in HTTP + Server-Sent Events (SSE) dashboard on top of an argus.Watcher. |
|
cmd
|
|
|
argusd
command
Command argusd is the reference consumer of the argus library.
|
Command argusd is the reference consumer of the argus library. |




