diff --git a/cmd/mavpoll/main.go b/cmd/mavpoll/main.go index f3f0702..d9f299c 100644 --- a/cmd/mavpoll/main.go +++ b/cmd/mavpoll/main.go @@ -6,10 +6,11 @@ // restart-free, fail-independent — a crashing poller can't touch the store key // (it never had it), worst case a stale env fact until the next tick. // -// Two sources, each its own provenance (the loop's rules trust source): +// Four sources, each its own provenance (the loop's rules trust source): // - netdata → poll:netdata resource alarms (disk/mem/cert/temp) // - kuma → poll:uptimekuma service up/down (the source of truth for it) // - zenmoney → poll:zenmoney spending/income totals (Vikunja #125) +// - wireguard → infer:wg latest handshake, the presence signal // // The zenmoney source is why the token lives HERE and not in core: the poller // already owns every other third-party credential, it holds no store key, and @@ -77,26 +78,15 @@ func run(args []string) error { return fmt.Errorf("nothing to poll: set -netdata, -kuma, -wg and/or -zenmoney-token-file") } - // The token is read from a file, never taken as a flag value: an argv token - // is visible in `ps` to every user on the box and lands in the compose file - // and the shell history. Read once at start — a rotated token means a - // restart, which is cheaper than re-reading his credential every hour. - var zen *zenmoney.Client - if *zenTokenFile != "" { - raw, err := os.ReadFile(*zenTokenFile) - if err != nil { - return fmt.Errorf("read zenmoney token: %w", err) - } - zen, err = zenmoney.New(strings.TrimSpace(string(raw)), *zenURL, *timeout*3) - if err != nil { - return err - } + zen, err := newZenClient(*zenTokenFile, *zenURL, *timeout) + if err != nil { + return err } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - core, err := ipc.DialWait(*socket, 60*time.Second) + core, err := ipc.DialWait(*socket, coreDialWait) if err != nil { return err } @@ -117,14 +107,46 @@ func run(args []string) error { // The token is never logged, not even its length. log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q zenmoney=%v every %s)", *interval, *netdataURL, *kumaURL, *wgIface, zen != nil, *zenInterval) + p.loop(ctx, *interval) + return nil +} + +// coreDialWait — how long to wait for core's socket at start. The poller and +// core come up together under compose, so a cold start is a wait, not a failure. +const coreDialWait = 60 * time.Second + +// zenTimeoutFactor — the zenmoney client gets a longer deadline than the other +// sources. A diff call walks his whole transaction history, where netdata and +// kuma answer from memory. +const zenTimeoutFactor = 3 + +// newZenClient builds the money client, or nil when no token file was given. +// +// The token is read from a file, never taken as a flag value: an argv token is +// visible in `ps` to every user on the box and lands in the compose file and +// the shell history. Read once at start — a rotated token means a restart, +// which is cheaper than re-reading his credential every hour. +func newZenClient(tokenFile, baseURL string, timeout time.Duration) (*zenmoney.Client, error) { + if tokenFile == "" { + return nil, nil + } + raw, err := os.ReadFile(tokenFile) + if err != nil { + return nil, fmt.Errorf("read zenmoney token: %w", err) + } + return zenmoney.New(strings.TrimSpace(string(raw)), baseURL, timeout*zenTimeoutFactor) +} + +// loop polls until the context is cancelled. +func (p *poller) loop(ctx context.Context, interval time.Duration) { p.pollOnce(ctx) // fire immediately; don't idle a full interval on start - t := time.NewTicker(*interval) + t := time.NewTicker(interval) defer t.Stop() for { select { case <-ctx.Done(): log.Printf("mavpoll: bye") - return nil + return case <-t.C: p.pollOnce(ctx) } @@ -151,8 +173,8 @@ type poller struct { zenLast time.Time } -// pollOnce — one sweep of both sources. A failure in one source logs and does -// NOT abort the other: netdata being down shouldn't blind kuma and vice versa. +// pollOnce — one sweep of every configured source. A failure in one logs and +// does NOT abort the rest: netdata being down shouldn't blind kuma. func (p *poller) pollOnce(ctx context.Context) { now := time.Now() if p.netdataURL != "" { @@ -244,6 +266,14 @@ func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error { // ---- wireguard: latest handshake → presence signal ------------------------- +const ( + // wgFactKey / wgSource — the presence signal, read by the decay in core. + // The source says infer because a handshake is evidence he is home, not a + // reading of where he is. + wgFactKey = "wg_handshake" + wgSource = "infer:wg" +) + // pollWg reads `wg show latest-handshakes` and writes a wg_handshake // fact (source=infer:wg) stamped with the MOST RECENT peer handshake time — not // now(). Presence decays from the real handshake instant, so the fact's ts must @@ -263,20 +293,19 @@ func (p *poller) pollWg(ctx context.Context) error { return nil // no peer has ever handshaked → drop out of presence } hs := time.Unix(maxTs, 0) - prev, err := p.core.LatestFactBySource(ctx, "wg_handshake", "infer:wg") + prev, err := p.core.LatestFactBySource(ctx, wgFactKey, wgSource) if err == nil && !hs.After(prev.Ts) { return nil // not newer → no churn } - if err != nil && err != ipc.ErrNoFact && !isNoFact(err) { - return fmt.Errorf("read wg_handshake: %w", err) + if err != nil && !isNoFact(err) { + return fmt.Errorf("read %s: %w", wgFactKey, err) } - if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{ - Ts: hs, Kind: "env", Key: "wg_handshake", Value: `"up"`, - Source: "infer:wg", Confidence: 1.0, - }); err != nil { - return fmt.Errorf("write wg_handshake: %w", err) + // The ts is the handshake instant, not now(): presence decays from when he + // was last seen. + if err := p.writeFact(ctx, wgFactKey, wgSource, `"up"`, hs); err != nil { + return err } - log.Printf("mavpoll: wg_handshake @ %s (infer:wg)", hs.Format(time.RFC3339)) + log.Printf("mavpoll: %s @ %s (%s)", wgFactKey, hs.Format(time.RFC3339), wgSource) return nil } @@ -431,29 +460,53 @@ func kumaState(v float64) string { // ---- helpers --------------------------------------------------------------- -// writeIfChanged writes a `facts(kind=env)` row only when val differs from the -// latest fact for (key, source). Values are stored JSON-encoded (the store's -// convention: `"down"`, `"critical"`), matching how rules compare f.Value. -func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, now time.Time) error { - jv, _ := json.Marshal(val) // string never fails to marshal +// factConfidence — every poll is a direct reading of another service, never an +// inference, so the fact goes in at full confidence. +const factConfidence = 1.0 + +// unchanged reports whether the latest fact for (key, source) already holds +// jsonVal. A missing fact is not an error here, it is the first write. +func (p *poller) unchanged(ctx context.Context, key, source, jsonVal string) (bool, error) { prev, err := p.core.LatestFactBySource(ctx, key, source) switch { - case err == nil && prev.Value == string(jv): - return nil // unchanged → no churn - case err != nil && err != ipc.ErrNoFact && !isNoFact(err): - return fmt.Errorf("read %s: %w", key, err) + case err == nil: + return prev.Value == jsonVal, nil + case isNoFact(err): + return false, nil + default: + return false, fmt.Errorf("read %s: %w", key, err) } - _, err = p.core.WriteFact(ctx, ipc.WriteFactReq{ +} + +// writeFact writes one `facts(kind=env)` row. Every poll in this file lands +// here, so the row shape is written once. +func (p *poller) writeFact(ctx context.Context, key, source, jsonVal string, now time.Time) error { + _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{ Ts: now, Kind: "env", Key: key, - Value: string(jv), + Value: jsonVal, Source: source, - Confidence: 1.0, // a direct reading, not an inference + Confidence: factConfidence, }) if err != nil { return fmt.Errorf("write %s: %w", key, err) } + return nil +} + +// writeIfChanged writes only when val differs from the latest fact for +// (key, source). Values are stored JSON-encoded (the store's convention: +// `"down"`, `"critical"`), matching how rules compare f.Value. +func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, now time.Time) error { + jv, _ := json.Marshal(val) // a string never fails to marshal + same, err := p.unchanged(ctx, key, source, string(jv)) + if err != nil || same { + return err // unchanged → no churn + } + if err := p.writeFact(ctx, key, source, string(jv), now); err != nil { + return err + } log.Printf("mavpoll: %s=%s (%s)", key, val, source) return nil } @@ -466,18 +519,12 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, no // The log line names the key and the source, never the figures: mavpoll's log // is not the place his spending ends up. func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal string, now time.Time) error { - prev, err := p.core.LatestFactBySource(ctx, key, source) - switch { - case err == nil && prev.Value == jsonVal: - return nil - case err != nil && err != ipc.ErrNoFact && !isNoFact(err): - return fmt.Errorf("read %s: %w", key, err) + same, err := p.unchanged(ctx, key, source, jsonVal) + if err != nil || same { + return err } - if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{ - Ts: now, Kind: "env", Key: key, Value: jsonVal, - Source: source, Confidence: 1.0, - }); err != nil { - return fmt.Errorf("write %s: %w", key, err) + if err := p.writeFact(ctx, key, source, jsonVal, now); err != nil { + return err } log.Printf("mavpoll: %s updated (%s)", key, source) return nil @@ -490,11 +537,8 @@ func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal str // The log line names the key only, never the figures: mavpoll's log is not the // place his spending ends up. func (p *poller) writeMoneyFact(ctx context.Context, key, jsonVal string, now time.Time) error { - if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{ - Ts: now, Kind: "env", Key: key, Value: jsonVal, - Source: zenmoney.Source, Confidence: 1.0, - }); err != nil { - return fmt.Errorf("write %s: %w", key, err) + if err := p.writeFact(ctx, key, zenmoney.Source, jsonVal, now); err != nil { + return err } log.Printf("mavpoll: %s read (%s)", key, zenmoney.Source) return nil @@ -506,6 +550,10 @@ func isNoFact(err error) bool { return errors.Is(err, ipc.ErrNoFact) } +// maxBodyBytes caps what a source can make the poller hold. Kuma's whole +// metrics page is a few hundred kilobytes, so 4 MiB is slack, not a budget. +const maxBodyBytes = 4 << 20 + func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -519,7 +567,7 @@ func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) return nil, err } defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) if err != nil { return nil, err } diff --git a/internal/capture/capture.go b/internal/capture/capture.go index 9f25979..4607d67 100644 --- a/internal/capture/capture.go +++ b/internal/capture/capture.go @@ -161,6 +161,18 @@ func (s *Session) finish() error { return s.spool.Sync() } +// discard closes the spool and deletes it, leaving nothing behind. Used by the +// reaper and by Abort, which throw a recording away rather than harvest it. +func (s *Session) discard() { + s.mu.Lock() + _ = s.finish() + path := s.path + s.mu.Unlock() + if path != "" { + _ = os.Remove(path) + } +} + // Duration is how much audio has been collected, from the bytes rather than the // wall clock: a stream that dropped frames should report the audio that exists, // not the time that passed. @@ -174,9 +186,16 @@ func (s *Session) duration() time.Duration { return pcmDuration(s.format, s.n) } +// bytesPerSample is one sample across all channels. Cutting a buffer anywhere +// that is not a multiple of it shifts every following sample by a byte. +func bytesPerSample(f audio.Format) int64 { return int64(f.SampleBits / 8 * f.Channels) } + +// bytesPerSecond is the format's byte rate, 32000 for the canonical 16 kHz mono. +func bytesPerSecond(f audio.Format) int64 { return int64(f.SampleRate) * bytesPerSample(f) } + // pcmDuration is how long n bytes of PCM lasts in the given format. func pcmDuration(f audio.Format, n int64) time.Duration { - per := int64(f.SampleRate) * int64(f.Channels) * int64(f.SampleBits) / 8 + per := bytesPerSecond(f) if per <= 0 { return 0 } @@ -279,19 +298,21 @@ func (r *Recorder) Start(label string) (*Session, error) { f.Close() return nil, err } + // A session that never opened leaves no spool file behind. + abandon := func(err error) (*Session, error) { + f.Close() + _ = os.Remove(f.Name()) + return nil, err + } // The header is written first and rewritten at Stop with the real length, // so the spool file is a playable WAV rather than headerless PCM that has // to be copied to gain 44 bytes. if _, err := f.Write(hdr); err != nil { - f.Close() - _ = os.Remove(f.Name()) - return nil, fmt.Errorf("capture: spool header: %w", err) + return abandon(fmt.Errorf("capture: spool header: %w", err)) } token, err := newToken() if err != nil { - f.Close() - _ = os.Remove(f.Name()) - return nil, err + return abandon(err) } s := &Session{ Label: strings.TrimSpace(label), @@ -330,15 +351,11 @@ func (r *Recorder) reapLocked() { } s.mu.Lock() s.expired = true - _ = s.finish() - path := s.path s.mu.Unlock() - if path != "" { - // The audio goes with it. A recording nobody stopped is one nobody is - // waiting for, and keeping it would mean storing a meeting on the - // strength of a dropped connection. - _ = os.Remove(path) - } + // The audio goes with it. A recording nobody stopped is one nobody is + // waiting for, and keeping it would mean storing a meeting on the strength + // of a dropped connection. + s.discard() r.current = nil } @@ -536,13 +553,7 @@ func (r *Recorder) Abort(token string) bool { return false } r.current = nil - s.mu.Lock() - _ = s.finish() - path := s.path - s.mu.Unlock() - if path != "" { - _ = os.Remove(path) - } + s.discard() return true } @@ -576,7 +587,7 @@ func (r *Recorder) transcribeFile(ctx context.Context, path string, format audio } // Never cut mid-sample: a split inside an int16 shifts every following // sample by a byte and turns the tail of the window into noise. - if bps := int64(format.SampleBits / 8 * format.Channels); bps > 0 { + if bps := bytesPerSample(format); bps > 0 { size -= size % bps } if size <= 0 { @@ -608,12 +619,13 @@ func (r *Recorder) transcribeFile(ctx context.Context, path string, format audio // is read by him in a note next to the words around it. const gapMarker = "[…не разобрала…]" -// windowBytes is how many PCM bytes one STT window holds. +// windowBytes is how many PCM bytes one STT window holds, rounded down to a +// whole sample. func windowBytes(f audio.Format, window time.Duration) int64 { - bps := int64(f.SampleBits / 8 * f.Channels) + bps := bytesPerSample(f) if bps <= 0 || f.SampleRate <= 0 || window <= 0 { return 0 } - per := int64(window.Seconds()) * int64(f.SampleRate) * bps + per := int64(window.Seconds()) * bytesPerSecond(f) return per - per%bps }