Compare commits

...

78 Commits

Author SHA1 Message Date
Cédric Verstraeten
92d3311192 Merge pull request #316 from kerberos-io/feature/srtsp-support
feature/srtsp-support
2026-08-13 10:00:47 +02:00
Cédric Verstraeten
97a8c1fcaf Update README-RTSPS-TLS.md 2026-08-13 09:49:45 +02:00
Cédric Verstraeten
de5b0666bd Clarify SSL_CERT_DIR usage and provide examples for empty trust sources in README-RTSPS-TLS.md 2026-08-12 15:13:54 +00:00
Cédric Verstraeten
ffdb8b6f22 Update README-RTSPS-TLS.md with configuration details and validation steps for RTSPS 2026-08-12 15:11:13 +00:00
Cédric Verstraeten
250e3b0b20 Add detailed guide for RTSPS and TLS certificate configuration 2026-08-12 15:09:32 +00:00
Cédric Verstraeten
2bb8144e79 Update RTSPS certificate verification instructions in README 2026-08-12 14:47:24 +00:00
Cédric Verstraeten
72d4fca63c Add RTSPS support and configuration options for secure camera streams
- Updated README.md to include instructions for using RTSPS camera URLs.
- Enhanced gortsplib.go to support insecure TLS configuration for self-signed certificates.
- Added unit tests for RTSPS TLS configuration in gortsplib_test.go.
2026-08-12 14:28:01 +00:00
Cédric Verstraeten
81cd95379b Merge pull request #315 from kerberos-io/fix/backchannel-reconnect
fix/backchannel-reconnect
2026-08-10 17:49:16 +02:00
Cédric Verstraeten
cd50f58138 Upgrade Go and RTSP dependencies
Upgrade to Go 1.25, gortsplib v5, and refreshed Pion dependencies. Adapt RTSP client APIs and normalize RTP timestamps for FPS tracking, with coverage for PTS conversion.
2026-08-10 15:46:02 +02:00
Cédric Verstraeten
b2f029117e Fix test cases by adding missing newlines and improving timeout handling 2026-08-10 12:52:37 +00:00
Cédric Verstraeten
db135acea9 Implement backchannel reconnection logic and enhance test coverage for write failures 2026-08-10 12:47:10 +00:00
Cédric Verstraeten
7b589b53f9 Harden RTSP backchannel streaming
Add paced, randomized RTP packetization with talkspurt markers and rollover-safe timestamps. Reconnect failed backchannel sessions with cancellable exponential backoff, initialize audio channels during bootstrap, and cover packetizer behavior with tests.
2026-08-10 14:45:18 +02:00
Cédric Verstraeten
c1740c752e Merge pull request #313 from kerberos-io/fix/moq-recovery-strategy
fix/moq-recovery-strategy
2026-08-07 16:28:27 +02:00
Cédric Verstraeten
5862786381 Deduplicate repeated H.264 keyframes
Remove exact duplicate IDR NALUs during normalization and drop repeated keyframes within a short timestamp window. Add normalization statistics, logging, reset handling, and coverage for deduplication behavior.
2026-08-07 15:46:50 +02:00
Cédric Verstraeten
e8dd64f54b Enhance MoQ streaming: implement quality tier broadcasting and subscriber management 2026-08-07 13:32:20 +00:00
Cédric Verstraeten
ba96b63002 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-07 13:28:38 +02:00
Cédric Verstraeten
c7c6bcbdf2 Add live stream recovery gating
Drop stale H.264 packets until a recent keyframe arrives, with lifecycle logging and slow MoQ write diagnostics. Add focused FrameGate tests and configure the UI package registry.
2026-08-07 13:23:44 +02:00
Cédric Verstraeten
faa3b4eabb Merge pull request #312 from kerberos-io/fix/moq-double-pts-insertion
fix/moq-double-pts-insertion
2026-08-06 22:46:30 +02:00
Cédric Verstraeten
f0a6eb7d98 Merge pull request #311 from kerberos-io/fix/preserve-recording-fps-precision
preserve-recording-fps-precision
2026-08-06 21:19:18 +02:00
Cédric Verstraeten
33a58cddf7 Fix live stream presentation timestamps
Use capture presentation time directly for MoQ timestamps instead of adding composition time, which is already reflected in PTS.
2026-08-06 21:05:13 +02:00
Kilian Boute
fea6d81246 prevent fps rounding 2026-08-06 17:46:38 +02:00
Cédric Verstraeten
dbff9fbc8e Merge pull request #310 from kerberos-io/feature/integrate-moq-streaming-protocol
feature/integrate-moq-streaming-protocol
2026-08-05 22:40:46 +02:00
Cédric Verstraeten
63b352b5e2 Remove obsolete MoQ Dockerfile and update build workflows to streamline image creation 2026-08-05 20:36:26 +00:00
Cédric Verstraeten
2ffb210ccb Implement NormalizeH264AccessUnit for H.264 payload normalization and update live stream publishing to use the new function 2026-08-05 20:25:05 +00:00
Cédric Verstraeten
ff643d21ef Add MoQ devcontainer verification
Switch devcontainers to Debian Trixie for the required glibc version and add automated MoQ package tests, Agent linking, and version checks through a script and VS Code task.
2026-08-05 21:07:32 +02:00
Cédric Verstraeten
8f04a6d42f Add MoQ image build workflow
Build and verify MoQ images on amd64 and arm64 pull requests, and document MoQ Docker build and runtime configuration.
2026-08-05 20:54:06 +02:00
Cédric Verstraeten
4395fe2417 Add optional MoQ live-stream publisher
Adds a dedicated MoQ build path that publishes H.264 live streams to a configurable relay, with retry handling, stream selection, Annex B framing, and Docker packaging. Standard builds retain a no-op implementation.
2026-08-05 20:53:37 +02:00
Cédric Verstraeten
18392e136e Merge pull request #308 from kerberos-io/feat/recording-fps-metadata
feat: propagate recording FPS with uploads
2026-08-05 15:16:25 +02:00
Cédric Verstraeten
ed916eb042 Extend recording upload metadata with duration and timestamp
The upload marker now carries filename, device key, timestamp and duration alongside FPS, populated from the finalized MP4 at recording time.

Uploads propagate the new fields: legacy uploads add X-Kerberos-Storage-Duration and X-Kerberos-Storage-Timestamp headers, and resumable (tus) uploads include duration and timestamp in Upload-Metadata. setQueuedRecordingFPSHeader is renamed to setQueuedRecordingMetadataHeaders, and decoding of historical markers remains backwards compatible.
2026-08-05 15:12:51 +02:00
Cédric Verstraeten
72b8160dc4 Use JSON .metadata markers for upload queue
Replace plain-text FPS upload markers (named after the recording file) with JSON-encoded .metadata files via a new models.RecordingUploadMetadata type. This makes the marker format extensible for future fields beyond FPS.

Backward compatibility is preserved: cleanup, upload, and FPS-lookup code now check both the new .metadata marker and the legacy same-named marker, so recordings queued by older agents are still recognized as pending and their FPS still honored.
2026-08-05 14:58:14 +02:00
Cédric Verstraeten
4f41786038 Store measured FPS in upload marker
Instead of snapshotting the configured IP camera FPS, derive the average frame rate from the samples actually committed to the finalized MP4.

Adds MP4.AverageFPS(), backed by a SampleCount that is now incremented when a video sample is successfully written, and passes that value to queueRecordingForUpload. Validation (0 < fps <= 240, finite) moves to the numeric value, and unknown FPS still produces an empty, backwards-compatible marker.
2026-08-05 14:36:40 +02:00
Cédric Verstraeten
8fb186fd6d Merge branch 'master' into feat/recording-fps-metadata 2026-08-05 14:19:53 +02:00
Cédric Verstraeten
420b8b8a01 Merge pull request #309 from kerberos-io/fix/pixel-threshold-default
fix/pixel-threshold-default
2026-08-05 14:12:09 +02:00
Cédric Verstraeten
5a13416bed Upgrade ONVIF dependency to v1.2.2
Update the Kerberos ONVIF module dependency and its recorded checksums.
2026-08-05 14:09:14 +02:00
Cédric Verstraeten
704011c20b Restore motion threshold defaults
Treat nil, zero, and negative pixel-change thresholds as the historical default of 150, removing the disabled-motion behavior and adding configuration tests.
2026-08-05 14:04:46 +02:00
Kilian Boute
6683c9b994 feat: propagate recording FPS with uploads 2026-08-04 14:07:44 +00:00
Cédric Verstraeten
2092f3e49d Merge pull request #306 from kerberos-io/fix/heartbeat-offline-tus-retry
fix(cloud): prevent cameras going offline from unbounded TUS retries
2026-07-28 11:26:50 +02:00
Cédric Verstraeten
d815e39e1d Merge pull request #307 from kerberos-io/fix/default-pixel-threshold-value
fix/default-pixel-threshold-value
2026-07-28 11:25:27 +02:00
Cédric Verstraeten
e2e1f8cfa8 Merge pull request #273 from sharedjourney/feature/onvif-event-stream
Feature/onvif event stream
2026-07-28 11:25:07 +02:00
Cédric Verstraeten
ba5992378e Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-28 11:19:15 +02:00
Cédric Verstraeten
2163a8e146 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-28 11:19:05 +02:00
Cédric Verstraeten
1fec49500e Refactor pixel change threshold comment formatting for clarity 2026-07-28 09:14:54 +00:00
Cédric Verstraeten
8175908073 Fix default pixel change threshold value for motion detection 2026-07-28 09:14:44 +00:00
Kilian Boute
1bcce4694d fix(cloud): prevent cameras going offline from unbounded TUS retries
Two fixes for cameras flipping to offline while capture is healthy:

- Heartbeat HTTP timeout (cloud.go): add a 30s Timeout to both
  http.Client branches so a hung heartbeat POST can no longer stall the
  heartbeat loop past the 180s window Hub uses to mark a camera offline.

- Bounded TUS retry loop (tus_client.go): refresh the retry budget only
  on genuine net progress by tracking a highWaterOffset across all
  attempts (vs a per-attempt startHighWater snapshot). A vault that keeps
  resetting the offset (persistent 409 ERR_MISMATCHED_OFFSET) now gives
  up after maxAttempts and re-queues instead of re-uploading the first
  chunk forever and saturating the uplink.

Adds regression test TestUploadVaultResumable_MismatchedOffsetGivesUp
with a loseProgress fake-server mode reproducing the cross-replica
offset-reset loop; asserts the upload terminates with a bounded PATCH
count. Root cause (vault-side cross-replica offset reset) remains
deferred to the vault repo.
2026-07-27 14:47:42 +00:00
T. Tradesman
b26f0190c6 fix(onvif): only real transitions trigger, and sanitise logged topics
Three defects in the dispatch path.

Deleted was still a trigger. The Initialized guard was a denylist of one
value, so a property removal with an active-looking payload passed
straight through. Both Initialized and Deleted are announcements about a
property, not motion starting, so accept the transitions instead:
Changed, and Unknown for the events that omit the optional attribute.

ev.Topic reached the log unmodified. It is camera-controlled, unbounded
and unfiltered, and logrus's coloured text formatter — the default —
writes the message without quoting, so an embedded newline forges whole
log entries. A compromised camera could fabricate ERROR lines or spoof
another device's id in the logs an operator is reading to diagnose that
camera. Escape control characters and bound the length; the reject path
logs every event received, so an oversized topic was also a cheap way to
evict a container's retained history.

The trigger line was logged before the send, so an event dropped on a
full channel or at shutdown left a line claiming a recording that never
started. Log it in the send case.
2026-07-23 14:55:47 +02:00
T. Tradesman
91194f5c1a fix(onvif): ignore subscription state replays as recording triggers
A camera replays the current state of every property topic as
PropertyOperation=Initialized whenever a pull-point subscription is
created. dispatchEvent looked only at Kind and State, so any motion
property that happened to be active at that moment counted as a fresh
trigger — meaning every reconnect restarts a recording, and a flapping
subscription manufactures motion with no motion.

Observed on a camera whose pull-point was being recreated every ~18s:
each recreate replayed ~90 property events, and once real motion made
the VMD property active the replays kept re-triggering it.

Rejects Initialized specifically rather than accepting only Changed.
PropertyOperation is optional per WS-Notification and absent on many
non-property events, which decode reports as PropertyUnknown; those are
real events and must still trigger.
2026-07-23 14:55:47 +02:00
T. Tradesman
57cfc90c4b feat(onvif): log the topic that triggered a recording
dispatchEvent logged only rejected events, so the topic that actually
started a recording was invisible — the only way to identify it was to
enumerate every rejected topic and reason about what was left. On a
camera emitting 18 distinct topics that is not a diagnosis.

Log the Kind and topic on the dispatch path too, at debug, matching the
reject line's shape so both sides of the decision grep the same way.
2026-07-23 14:55:47 +02:00
Sebastian Norling
357cc719a5 docs(machinery/onvif): trim event-stream comments to WHY
Audit against CLAUDE.md's 'default to no comments; only when WHY is
non-obvious'. Net ~30 lines removed.

Dropped (rot-prone or redundant)
--------------------------------
* 'matching what the pixel-diff detector emits' — references a
  sibling file's behaviour.
* 'Timestamp in seconds matches what computervision/main.go emits;
  downstream consumers (capture/main.go) tolerate...' — both
  cross-file references; classic 'will rot when the sibling
  changes'.
* 'Motion-stop wiring into the recorder state machine is tracked as
  a follow-up; today the recorder uses a fixed PostRecording
  timeout' — PR-description content masquerading as a code comment.
* 'happens only on ctx cancel today (library handles its own
  reconnect)' — 'today' is a red flag; either drop or assert via
  test, not narrate.
* dispatchEvent's first paragraph restating what the function does.
* runStreamOnce's first sentence (WHAT).
* isONVIFMotionEnabled's reference to sibling Capture fields
  ('unlike Recording / Motion / Snapshots which default to
  enabled').

Kept (real WHYs)
----------------
* The shutdown-race rationale on dispatchEvent's ctx guards.
* logStreamError's severity-mapping rationale.
* The library-handles-reconnect-but-not-initial-connect rationale
  for the backoff constants.
* The recovering-flag rationale (on-call ops use case).
* The flag-read-once invariant on HandleONVIFEventStream.
2026-07-23 11:21:23 +02:00
Sebastian Norling
4f2a96b5e1 fix(machinery/onvif): harden event-stream dispatch and add TDD coverage
Addresses the critical and important findings from the second review of
the agent integration. TDD followed locally: tests were written first
and confirmed RED against the previous implementation before the fix
turned them GREEN.

Critical fixes
--------------
* Shutdown-race panic (concurrency P0): the 3s gap between the agent's
  ctx cancel and close(HandleMotion) was reachable by a buffered event
  delivered after cancel, where dispatchEvent's send-with-default
  select would panic on the closed channel. dispatchEvent now takes
  ctx, has a pre-check after the kind/state/recording filters, and
  the send select includes a <-ctx.Done() arm. Pinned by
  TestDispatchEvent_CtxCancelledAndHandleMotionClosed_DoesNotPanic
  (asserts NotPanics; current code without the fix panics).

* No retry on initial connect (Go P0 + ops P1): previously the
  goroutine exited permanently if ConnectToOnvifDevice or
  stream.NewStream failed at agent start — a brief boot-time DNS or
  network blip silently disabled ONVIF until restart. Construction is
  now wrapped in a retry loop with exponential backoff (1s -> 5min),
  matching what cloud.HandleHeartBeat does for its ONVIF connection
  attempts. The library handles in-stream recovery already; this
  covers the gap the library cannot see.

* Strict 'true' match (Go P0): isONVIFMotionEnabled now normalises
  case and trims whitespace, so 'True', 'TRUE', ' true ' all enable
  the feature. Pinned by TestIsONVIFMotionEnabled_CaseAndWhitespace.

Important fixes
---------------
* Empty DeviceID fallback (Go P1): resolveDeviceID falls back from
  configuration.Name to camera.ONVIFXAddr to 'unknown' so log lines
  and metrics always have a useful identifier. Pinned by
  TestResolveDeviceID_FallbackChain.

* Recovery log (ops P1): the run loop tracks a 'recovering' flag set
  when an ErrPullFailed/ErrRecreateFailed lands on Errors and cleared
  on the first successful Event. Logs an Info 'event stream recovered'
  line so on-call operators can see error streaks clear, instead of
  waking up to ERROR with no closure.

* Misconfig log bumped Info -> Warning so the
  'ONVIFXAddr is empty' line stands out from the heartbeat noise.

Tests
-----
events_test.go covers the dispatch contract end-to-end:
  * Motion+Active -> HandleMotion (happy path).
  * Motion+Inactive ignored (motion-stop is a documented follow-up).
  * Non-motion kinds ignored.
  * Recording='false' gates the send.
  * Full HandleMotion drops rather than blocks.
  * Ctx-cancelled + closed HandleMotion does not panic.
  * isONVIFMotionEnabled handles case and whitespace.
  * resolveDeviceID fallback chain.

go.mod / go.sum: testify moved from indirect to direct dependency.

Deferred (out of scope for this commit, tracked as follow-ups):
  * Heartbeat surface for ONVIF state ('disabled|running|failed') —
    requires a Cloud.go change beyond this integration's scope.
  * OTel span/metric for stream lifecycle.
  * Runtime toggle without restart (config-reload).
  * Replace-directive layout documentation — separate docs commit.
2026-07-23 11:21:23 +02:00
Sebastian Norling
ed85261c8e feat(machinery/onvif): consume ONVIF events via stream package
Resolves kerberos-io/agent#173. When Capture.ONVIFMotion='true' the
agent opens a stream.NewStream against the configured ONVIF endpoint
and forwards Motion+Active events to HandleMotion, so AXIS cameras
(and any other ONVIF-conformant device) can drive motion-triggered
recording without relying on the pixel-diff detector.

Why this shape
--------------
Maintainer @cedricve's direction on #194 was 'extend the ONVIF
library first to expose a go channel and hide the protocol complexity'.
That work landed in kerberos-io/onvif (event/stream sub-package);
the agent integration is now a thin consumer: connect to the device,
wrap it in NewStream, range over Events, push Motion events at the
existing HandleMotion channel.

Design choices
--------------
* New file machinery/src/onvif/events.go keeps the new code separate
  from the existing PTZ/IO-focused onvif/main.go so reviewers can
  read it without scrolling.
* Opt-in via Capture.ONVIFMotion. Defaults preserve the current
  pixel-diff behaviour so nothing changes for existing users.
* DispatchEvent only fires on StateActive — the leading edge. Motion
  STOP requires the recorder state machine to accept an explicit
  stop signal which today does not exist; tracked as a follow-up so
  this first PR stays small.
* Non-blocking send to HandleMotion: drop ONVIF motion events when
  the channel is full rather than block the stream goroutine and
  starve subscription renewal.
* Error logging routes by typed-error category from the library:
  ErrRecreateFailed is loud (camera may be offline), ErrPullFailed
  and ErrRenewFailed are debug-level (auto-recovers).
* Goroutine lifetime tied to *communication.Context, the same
  cancellable context the agent uses to restart on config change.

Not in this commit (intentionally deferred to follow-ups)
---------------------------------------------------------
* Motion STOP wiring into capture/main.go's recorder state machine.
* Replacing the ad-hoc CreatePullPointSubscription / GetEventMessages
  polling in cloud/Cloud.go with a stream consumer for DigitalInput
  and DigitalOutput events. The current code keeps working; the
  stream is purely additive.
* Removing the temporary 'replace' directive on
  github.com/kerberos-io/onvif once the event/stream changes are
  tagged upstream.
2026-07-23 11:21:23 +02:00
Sebastian Norling
5a58808f20 feat(machinery/config): add ONVIFMotion capture flag
Adds Capture.ONVIFMotion and the matching AGENT_CAPTURE_ONVIF_MOTION
environment override. When set to 'true', the agent will open an
ONVIF event stream against the configured camera and route Motion
events into the existing HandleMotion channel (wired up in the
next commit).

Defaults to empty (disabled), so existing deployments using the
pixel-diff motion detector see no behaviour change.
2026-07-23 11:21:23 +02:00
Cédric Verstraeten
94b26cf096 Merge pull request #305 from kerberos-io/feature/add-motion-detection-pixel-changes
feature/add-motion-detection-pixel-changes
2026-07-16 15:33:02 +02:00
Cédric Verstraeten
57ef7ebaaf Enhance ONVIF fingerprinting and brand profiles
Expand discovery and classification to better identify device vendors and stream paths by adding new brand aliases/profiles (including D-Link, Trendnet, Lorex, Honeywell, Pelco, and TOA), improved realm matching, and hostname-based brand hints. Add HTTP body fingerprinting for OEM/rebadged devices, introduce audio-device detection with a new `is_audio` API field, and prevent camera RTSP guessing/fallback URLs for audio-only devices while updating discovery logging labels.
2026-07-16 14:39:55 +02:00
Cédric Verstraeten
ddf58fe633 Add Linksys ONVIF fingerprint and RTSP paths
Adds Linksys camera detection across ONVIF heuristics by introducing a dedicated brand profile, realm aliases, and banner fingerprinting. It also prioritizes Linksys-specific RTSP endpoints (including /ONVIF/channel1 and /ONVIF/channel2) and includes fallback stream paths used by Cisco/Linksys models.
2026-07-16 13:20:47 +02:00
Cédric Verstraeten
6f2d35cdf1 Add RTSP brand probing to ONVIF discovery
Extend device discovery to generate RTSP stream candidates using built-in brand profiles, RTSP DESCRIBE probing, auth-realm parsing, and port hints. Add `RTSPStreams`/`RTSPStream` to API responses, prefer verified stream URLs as primary `RTSPURL`, and let stronger RTSP-derived brand/model signals refine detected camera metadata. Also add focused unit tests for discriminating vs non-discriminating devices, realm-based brand/model detection, and generic fallback behavior.
2026-07-16 09:09:27 +02:00
Cédric Verstraeten
c836cef28d Add advanced discovery and stream verification
Introduces a new ONVIF/network discovery pipeline that combines WS-Discovery, subnet-aware host/port scanning, banner fingerprinting, and MAC vendor enrichment to identify likely cameras. Adds API and CLI support for discovery options (`/api/camera/discover`, `-subnet`), plus a richer discovered-device response model. Also adds MQTT `verify-stream` handling to probe RTSP streams and return codec/resolution/fps, and persists detected stream FPS into config for main/sub streams.
2026-07-15 23:17:23 +02:00
Cédric Verstraeten
c97bb70cb5 Update main.go 2026-07-15 12:08:10 +02:00
Cédric Verstraeten
96b145b046 Reset config.json to clean defaults
Removes sensitive credentials, private keys, and personal configuration values from config.json. Clears RTSP URLs, hub keys, encryption/signing keys, kstorage credentials, and resets various settings to neutral defaults.
2026-07-14 21:57:54 +02:00
Cédric Verstraeten
09a697e00b Make PixelChangeThreshold a pointer to distinguish unset vs 0
Changes PixelChangeThreshold from int to *int so nil (unset) defaults to 150, while 0 explicitly disables motion detection. Updates ProcessMotion to handle the new three-state logic and also emits pixelChangeThreshold in MQTT motion messages for live view visualization.
2026-07-14 21:52:24 +02:00
Cédric Verstraeten
1d0714f199 Merge pull request #304 from kerberos-io/feature/tweak-remote-recording
feature/tweak-remote-recording
2026-07-13 21:17:50 +02:00
Cédric Verstraeten
42e91867ec Refactor JSON handling and enhance motion detection
Replace unsafe fmt.Sprintf JSON formatting with proper struct marshalling in cloud.go. Add rawJSONOrEmptyArray() helper to safely handle json.RawMessage with fallback to empty arrays. Enhance motion detection overlay data in computervision by adding main stream dimensions (mainWidth/mainHeight) alongside motion frame dimensions, enabling accurate live-view scaling of detected regions.
2026-07-13 21:10:55 +02:00
Cédric Verstraeten
155c4a7e44 Support motion regions in continuous recording mode
Refactor motion detection logic to enable motion region visualization during continuous recording. Motion detection now runs in continuous mode when a motion region is configured, allowing live-view overlay display without triggering motion-based recording. In continuous mode without regions, motion detection is skipped as before. Updated conditional logic and added clarifying comments explaining the different code paths.
2026-07-13 17:59:34 +02:00
Cédric Verstraeten
4fe4977559 Update main.go 2026-07-13 17:32:18 +02:00
Cédric Verstraeten
67e66e863a Add continuousRecording flag to Hub status
Report whether a camera is in continuous recording mode (24/7) to the Hub. This allows the Hub live view to disable the manual record button, which is a no-op when the camera is already recording continuously.
2026-07-13 16:51:46 +02:00
Cédric Verstraeten
bd34e9d836 Auto-stop stale manual recordings
Add heartbeat-aware lifecycle management for manual/live-view recordings. The agent now tracks manual recording start time and viewer heartbeats, auto-stops recordings when heartbeats lapse or a max duration is reached, and clears state on stop/restart. MQTT recording payloads gain a `heartbeat` flag so keep-alives refresh active sessions without unintentionally restarting recordings after auto-stop.
2026-07-13 16:29:46 +02:00
Cédric Verstraeten
1a0e6bf153 Merge pull request #299 from kerberos-io/feature/remote-recording
feature/remote-recording
2026-07-07 16:39:16 +02:00
Cédric Verstraeten
52aef0870e Merge pull request #302 from kerberos-io/upgrade/onvif-library
upgrade/onvif-library
2026-07-07 16:38:18 +02:00
Cédric Verstraeten
012ed3b658 Remove indirect dependency on github.com/icholy/digest and update onvif to version 1.2.1 2026-07-07 14:22:40 +00:00
Cédric Verstraeten
7ced8a3044 Update onvif dependency to version 1.2.1 2026-07-07 14:20:44 +00:00
Cédric Verstraeten
f043be5371 Merge pull request #301 from kerberos-io/feature/remove-default-value-for-max-directory-size
feature/remove-default-value-for-max-directory-size
2026-07-03 16:41:48 +02:00
Cédric Verstraeten
b85d9858d1 Update config.json 2026-07-03 16:31:40 +02:00
Cédric Verstraeten
434730b970 Merge pull request #300 from kerberos-io/feature/improved-cleanup-and-tus-upload-on-network-error
feature/improved-cleanup-and-tus-upload-on-network-error
2026-07-03 14:39:04 +02:00
Cédric Verstraeten
94df7298e3 Fix default reserve MB 2026-07-03 14:35:51 +02:00
Cédric Verstraeten
0f76baec1f Implementation of better cleanup and upload mechanism, 2026-07-03 14:21:25 +02:00
Cédric Verstraeten
6ae61ea046 Update main.go 2026-06-30 12:19:47 +02:00
Cédric Verstraeten
93e17ac73e Update communication.go 2026-06-30 12:11:23 +02:00
Cédric Verstraeten
0037f5a0ab Add manual recording functionality and UI notifications for recording state changes 2026-06-30 10:11:01 +00:00
Cédric Verstraeten
79f225ad3c Update main.go 2026-06-29 11:17:51 +02:00
61 changed files with 6507 additions and 533 deletions

View File

@@ -1,4 +1,4 @@
FROM mcr.microsoft.com/devcontainers/go:1.24-bookworm
FROM mcr.microsoft.com/devcontainers/go:1.25-trixie
# Install node environment
RUN apt-get update && \

View File

@@ -1,7 +1,7 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/python
{
"name": "go:1.24-bookworm",
"name": "go:1.24-trixie",
"runArgs": [
"--name=agent",
"--network=host"
@@ -20,5 +20,5 @@
3000,
8080
],
"postCreateCommand": "cd ui && yarn install && yarn build && cd ../machinery && go mod download"
"postCreateCommand": "cd ui && yarn install && yarn build && cd ../machinery && go mod download && bash ./verify-moq-devcontainer.sh"
}

View File

@@ -16,10 +16,8 @@ jobs:
include:
- architecture: amd64
runner: ubuntu-24.04
dockerfile: Dockerfile
- architecture: arm64
runner: ubuntu-24.04-arm
dockerfile: Dockerfile.arm64
steps:
- name: Checkout
uses: actions/checkout@v3
@@ -34,7 +32,7 @@ jobs:
length: 7
- name: Run Build
run: |
docker build -t ${{ matrix.architecture }} -f ${{ matrix.dockerfile }} .
docker build -t ${{ matrix.architecture }} .
CID=$(docker create ${{matrix.architecture}})
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
docker rm ${CID}

View File

@@ -108,7 +108,7 @@ jobs:
length: 7
- name: Run Build
run: |
docker build --provenance=false --build-arg VERSION=${{ needs.bump-release.outputs.tag }} -t ${{matrix.architecture}} -f Dockerfile.arm64 .
docker build --provenance=false --build-arg VERSION=${{ needs.bump-release.outputs.tag }} -t ${{matrix.architecture}} .
CID=$(docker create ${{matrix.architecture}})
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
docker rm ${CID}

View File

@@ -71,7 +71,7 @@ jobs:
length: 7
- name: Run Build
run: |
docker build --provenance=false --build-arg VERSION=${{github.event.inputs.tag || github.ref_name}} -t ${{matrix.architecture}} -f Dockerfile.arm64 .
docker build --provenance=false --build-arg VERSION=${{github.event.inputs.tag || github.ref_name}} -t ${{matrix.architecture}} .
CID=$(docker create ${{matrix.architecture}})
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
docker rm ${CID}

14
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "agent: moq verify",
"type": "shell",
"command": "bash ./verify-moq-devcontainer.sh",
"options": {
"cwd": "${workspaceFolder}/machinery"
},
"problemMatcher": []
}
]
}

View File

@@ -1,13 +1,15 @@
ARG BASE_IMAGE_VERSION=amd64-ddbe40e
ARG GO_IMAGE=golang:1.25-trixie
ARG RUNTIME_IMAGE=debian:trixie-slim
ARG VERSION=0.0.0
FROM kerberos/base:${BASE_IMAGE_VERSION} AS build-machinery
FROM ${GO_IMAGE} AS build-machinery
LABEL AUTHOR=uug.ai
# Re-declare VERSION inside this stage so the value passed via
# `--build-arg VERSION=...` (e.g. the release tag) is available below.
# ARGs declared before the first FROM are not visible inside build stages.
ARG VERSION
ARG TARGETARCH
ENV GOROOT=/usr/local/go
ENV GOPATH=/go
@@ -17,9 +19,10 @@ ENV GOSUMDB=off
##########################################
# Installing some additional dependencies.
RUN apt-get upgrade -y && apt-get update && apt-get install -y --fix-missing --no-install-recommends \
RUN apt-get update && apt-get install -y --fix-missing --no-install-recommends \
git build-essential cmake pkg-config unzip libgtk2.0-dev \
curl ca-certificates libcurl4-openssl-dev libssl-dev libjpeg62-turbo-dev && \
curl ca-certificates libavcodec-dev libavutil-dev libcurl4-openssl-dev \
libssl-dev libjpeg62-turbo-dev libswscale-dev && \
rm -rf /var/lib/apt/lists/*
##############################################################################
@@ -43,7 +46,9 @@ RUN cd /go/src/github.com/kerberos-io/agent/machinery && \
if [ -z "${VERSION}" ] || [ "${VERSION}" = "0.0.0" ]; then \
VERSION=$(cd /go/src/github.com/kerberos-io/agent && git describe --tags --always 2>/dev/null || echo "0.0.0"); \
fi && \
go build -tags timetzdata,netgo,osusergo --ldflags "-s -w -X github.com/kerberos-io/agent/machinery/src/utils.VERSION=${VERSION} -extldflags '-static -latomic'" main.go && \
BUILD_TAGS=timetzdata,netgo,osusergo && \
case "${TARGETARCH:-$(go env GOARCH)}" in amd64|arm64) BUILD_TAGS="moq,${BUILD_TAGS}" ;; esac && \
go build -tags "${BUILD_TAGS}" --ldflags "-s -w -X github.com/kerberos-io/agent/machinery/src/utils.VERSION=${VERSION}" main.go && \
mkdir -p /agent && \
mv main /agent && \
mv version /agent && \
@@ -89,12 +94,16 @@ RUN mkdir -p ./agent && cp -r /go/src/github.com/kerberos-io/agent/machinery/www
############################################
# Publish main binary to GitHub release
FROM alpine:latest
FROM ${RUNTIME_IMAGE}
############################
# Protect by non-root user.
RUN addgroup -S kerberosio && adduser -S agent -G kerberosio && addgroup agent video
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl ffmpeg libatomic1 libcap2-bin libstdc++6 && \
rm -rf /var/lib/apt/lists/* && \
groupadd --system kerberosio && \
useradd --system --gid kerberosio --groups video --create-home agent
#################################
# Copy files from previous images
@@ -102,8 +111,6 @@ RUN addgroup -S kerberosio && adduser -S agent -G kerberosio && addgroup agent v
COPY --chown=0:0 --from=build-machinery /dist /
COPY --chown=0:0 --from=build-ui /dist /
RUN apk update && apk add ca-certificates curl ffmpeg libstdc++ libc6-compat --no-cache && rm -rf /var/cache/apk/*
##################
# Try running agent
@@ -123,7 +130,7 @@ RUN chown -R agent:kerberosio /home/agent/www
###########################
# Grant the necessary root capabilities to the process trying to bind to the privileged port
RUN apk add libcap && setcap 'cap_net_bind_service=+ep' /home/agent/main
RUN setcap 'cap_net_bind_service=+ep' /home/agent/main
###################
# Run non-root user

View File

@@ -1,147 +0,0 @@
ARG BASE_IMAGE_VERSION=arm64-ddbe40e
ARG VERSION=0.0.0
FROM kerberos/base:${BASE_IMAGE_VERSION} AS build-machinery
LABEL AUTHOR=uug.ai
# Re-declare VERSION inside this stage so the value passed via
# `--build-arg VERSION=...` (e.g. the release tag) is available below.
# ARGs declared before the first FROM are not visible inside build stages.
ARG VERSION
ENV GOROOT=/usr/local/go
ENV GOPATH=/go
ENV PATH=$GOPATH/bin:$GOROOT/bin:/usr/local/lib:$PATH
ENV GOSUMDB=off
##########################################
# Installing some additional dependencies.
RUN apt-get upgrade -y && apt-get update && apt-get install -y --fix-missing --no-install-recommends \
git build-essential cmake pkg-config unzip libgtk2.0-dev \
curl ca-certificates libcurl4-openssl-dev libssl-dev libjpeg62-turbo-dev && \
rm -rf /var/lib/apt/lists/*
##############################################################################
# Copy all the relevant source code in the Docker image, so we can build this.
RUN mkdir -p /go/src/github.com/kerberos-io/agent
COPY machinery /go/src/github.com/kerberos-io/agent/machinery
RUN rm -rf /go/src/github.com/kerberos-io/agent/machinery/.env
##################################################################
# Get the latest commit hash, so we know which version we're running
COPY .git /go/src/github.com/kerberos-io/agent/.git
RUN cd /go/src/github.com/kerberos-io/agent/.git && git log --format="%H" -n 1 | head -c7 > /go/src/github.com/kerberos-io/agent/machinery/version
RUN cat /go/src/github.com/kerberos-io/agent/machinery/version
##################
# Build Machinery
RUN cd /go/src/github.com/kerberos-io/agent/machinery && \
go mod download && \
if [ -z "${VERSION}" ] || [ "${VERSION}" = "0.0.0" ]; then \
VERSION=$(cd /go/src/github.com/kerberos-io/agent && git describe --tags --always 2>/dev/null || echo "0.0.0"); \
fi && \
go build -tags timetzdata,netgo,osusergo --ldflags "-s -w -X github.com/kerberos-io/agent/machinery/src/utils.VERSION=${VERSION} -extldflags '-static -latomic'" main.go && \
mkdir -p /agent && \
mv main /agent && \
mv version /agent && \
mv data /agent && \
mkdir -p /agent/data/cloud && \
mkdir -p /agent/data/snapshots && \
mkdir -p /agent/data/log && \
mkdir -p /agent/data/recordings && \
mkdir -p /agent/data/capture-test && \
mkdir -p /agent/data/config
####################################
# Let's create a /dist folder containing just the files necessary for runtime.
# Later, it will be copied as the / (root) of the output image.
WORKDIR /dist
RUN cp -r /agent ./
####################################################################################
# This will collect dependent libraries so they're later copied to the final image.
RUN /dist/agent/main version
FROM node:22-alpine AS build-ui
RUN apk update && apk upgrade --available && sync
########################
# Build Web (React app)
RUN mkdir -p /go/src/github.com/kerberos-io/agent/machinery/www
COPY ui /go/src/github.com/kerberos-io/agent/ui
RUN cd /go/src/github.com/kerberos-io/agent/ui && rm -rf yarn.lock && yarn config set network-timeout 300000 && \
yarn && yarn build
####################################
# Let's create a /dist folder containing just the files necessary for runtime.
# Later, it will be copied as the / (root) of the output image.
WORKDIR /dist
RUN mkdir -p ./agent && cp -r /go/src/github.com/kerberos-io/agent/machinery/www ./agent/
############################################
# Publish main binary to GitHub release
FROM alpine:latest
############################
# Protect by non-root user.
RUN addgroup -S kerberosio && adduser -S agent -G kerberosio && addgroup agent video
#################################
# Copy files from previous images
COPY --chown=0:0 --from=build-machinery /dist /
COPY --chown=0:0 --from=build-ui /dist /
RUN apk update && apk add ca-certificates curl ffmpeg libstdc++ libc6-compat --no-cache && rm -rf /var/cache/apk/*
##################
# Try running agent
RUN mv /agent/* /home/agent/
RUN /home/agent/main version
#######################
# Make template config
RUN cp /home/agent/data/config/config.json /home/agent/data/config.template.json
###########################
# Set permissions correctly
RUN chown -R agent:kerberosio /home/agent/data
RUN chown -R agent:kerberosio /home/agent/www
###########################
# Grant the necessary root capabilities to the process trying to bind to the privileged port
RUN apk add libcap && setcap 'cap_net_bind_service=+ep' /home/agent/main
###################
# Run non-root user
USER agent
######################################
# By default the app runs on port 80
EXPOSE 80
######################################
# Check if agent is still running
HEALTHCHECK CMD curl --fail http://localhost:80 || exit 1
###################################################
# Leeeeettttt'ssss goooooo!!!
# Run the shizzle from the right working directory.
WORKDIR /home/agent
CMD ["./main", "-action", "run", "-port", "80"]

639
README-RTSPS-TLS.md Normal file
View File

@@ -0,0 +1,639 @@
# RTSPS and TLS certificates
This guide explains how Kerberos Agent connects to an IP camera over RTSPS,
how to issue a camera certificate with a private CA, and how to validate the
complete trust path. It also explains why some apparently corrupted trust
bundles can still allow a connection.
The camera-specific steps were verified with a Bosch FLEXIDOME micro 3100i.
Other Bosch firmware versions may use different labels or ports.
The commands were tested with Smallstep CLI `0.30.6` and OpenSSL `3.5.6` on
Debian. Check `step certificate sign --help` when using an older Smallstep CLI.
The OpenSSL isolation flags `-no-CApath` and `-no-CAstore` require a version that
lists them in `openssl s_client -help`.
## Tested configuration
| Setting | Value |
| --- | --- |
| Camera | Bosch FLEXIDOME micro 3100i |
| Example camera address | `10.0.30.11` |
| RTSPS port | `9554` |
| Main stream | `rtsps://<user>:<password>@10.0.30.11:9554/?inst=1` |
| Sub stream | `rtsps://<user>:<password>@10.0.30.11:9554/?inst=2` |
| Certificate SAN | `IP Address:10.0.30.11` |
| Bosch certificate usage | `HTTPS` |
| Agent trust input | Issuing intermediate plus root CA |
Replace the example address and certificate names throughout this guide. Keep
camera credentials out of source control and percent-encode reserved URL
characters in usernames and passwords.
## Mental model
### RTSPS, SRTSP, TLS, and SRTP
- The standard URL scheme is `rtsps://`. Do not use `srtsp://`.
- Bosch interfaces and documentation may use SRTSP or Secure RTSP as product
terminology.
- RTSPS carries the RTSP control connection over TLS. With gortsplib, media is
normally interleaved over the same TCP/TLS connection for this camera.
- SRTP is a separate media protection mechanism and is negotiated only when the
camera advertises an appropriate secure RTP profile.
Encryption alone does not prove which camera the Agent reached. Verified TLS
also checks that:
1. The camera certificate is signed by a trusted authority.
2. The certificate is valid at the current time.
3. The URL host matches a certificate Subject Alternative Name (SAN).
Modern Go verification uses SANs for identity. A Common Name alone is not
sufficient. Connecting to `10.0.30.11` requires an IP SAN with that exact value,
not `DNS:10.0.30.11` and not only a device-name DNS SAN.
### Agent behavior
Kerberos Agent uses gortsplib for RTSP and RTSPS. With the normal configuration,
gortsplib receives a nil custom TLS configuration and Go performs standard
certificate and hostname verification with the process trust pool.
`AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=true` is an explicit escape hatch that
sets `InsecureSkipVerify` for camera clients. It should be false in a verified
deployment.
## Communication and certificate flow
The certificate is used during the TLS handshake, before the first RTSP command
is exchanged. It is not attached to `DESCRIBE`, `SETUP`, or `PLAY`, and the CA
trust bundle is never sent to the camera.
```mermaid
sequenceDiagram
participant Agent as Kerberos Agent
participant Trust as Go trust pool
participant Camera as Camera RTSPS :9554
Agent->>Trust: Load trusted CAs from SSL_CERT_FILE and CA directories
Agent->>Camera: Open TCP connection
Agent->>Camera: Send TLS ClientHello
Camera-->>Agent: Send TLS ServerHello and camera certificate
Agent->>Trust: Verify chain, validity, serverAuth, and URL host against SAN
Trust-->>Agent: Accept or reject the camera identity
Agent->>Camera: Complete TLS handshake
Note over Agent,Camera: All following traffic is encrypted by TLS
Agent->>Camera: DESCRIBE with RTSP authentication
Camera-->>Agent: Return SDP and available media tracks
Agent->>Camera: SETUP selected video and audio tracks over TCP
Agent->>Camera: PLAY
Camera-->>Agent: Send interleaved RTP and RTCP media over TLS
```
The files and keys have distinct roles:
| Material | Location | Purpose | Sent over the connection |
| --- | --- | --- | --- |
| Camera leaf certificate | Camera | Identifies the camera and binds its public key to its SAN | Yes, by the camera during the TLS handshake |
| Camera private key | Camera | Proves that the camera owns the presented certificate | No |
| Intermediate and root CA PEM bundle | Agent | Lets Go build and trust the camera certificate chain | No |
| RTSP username and password | Agent configuration or URL | Authenticates the Agent to the RTSP service after TLS succeeds | An authentication response is sent inside TLS; its form depends on the RTSP authentication method |
For an `rtsps://` URL, the Agent parses the URL and gives gortsplib the host and
TLS settings. gortsplib opens the TCP connection and starts TLS. Go compares the
certificate presented by the camera with the local trust pool, checks its
validity period and server usage, and matches the URL hostname or IP address to
the certificate SAN. Only a successful handshake creates the encrypted channel
needed for the RTSP exchange.
The Agent then sends `DESCRIBE`, selects the advertised video and audio tracks,
sends `SETUP`, and starts delivery with `PLAY`. For the tested camera, gortsplib
uses interleaved TCP, so the RTSP control messages and RTP/RTCP media remain
inside the same encrypted TLS connection. Main stream, sub stream, and enabled
audio backchannel clients each establish and verify their own connection.
If certificate verification fails, the TLS handshake does not complete and no
usable RTSP session is established. Setting
`AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=true` keeps traffic encrypted but skips
certificate-chain and hostname verification, so an attacker could impersonate
the camera. It is not equivalent to trusting the camera certificate.
## Decide the certificate identity first
Choose the stable name used in every Agent URL before creating the certificate:
- For an IP URL, add that address as an IP SAN.
- For a DNS URL, add the exact hostname as a DNS SAN.
- Add both when clients legitimately use both forms.
A certificate stops matching if the camera IP changes. Use a static address,
DHCP reservation, or stable DNS name.
## Configure RTSPS in the Bosch UI
1. Sign in to the camera as an administrator.
2. Open **Configuration**.
3. Open **Network > Network Services**.
4. Enable **RTSPS**.
5. Confirm port `9554`, or record the configured alternative.
6. Click **Set**.
RTSP on port `554` and RTSPS on port `9554` are separate services. Enabling
RTSPS does not make an `rtsp://` URL secure.
## Generate the private key and CSR on the camera
Keeping the TLS private key on the camera avoids exporting it to an operator
workstation or deployment system.
1. Open **Service > Certificates**.
2. Click **Add**.
3. Select **Generate signing request**.
4. Select `RSA 2048bit` or the stronger option supported by all clients.
5. Enter a unique file name, such as `agent-rtsps`.
6. Enter a descriptive Common Name and any required organization fields.
7. Click **Generate**.
8. Download the resulting CSR from the certificate table.
On the tested firmware, this form contains no SAN field. The downloaded CSR
therefore has no IP SAN. The CA must add the SAN while signing.
Inspect the CSR before signing:
```bash
openssl req -in camera.csr.pem -noout -verify -subject
openssl req -in camera.csr.pem -noout -text
```
The first command must report `Certificate request self-signature verify OK`.
An absent `Subject Alternative Name` section is expected for this firmware.
## Prepare Smallstep
Use an existing organizational CA when one is available. Creating a new CA
creates a new long-lived trust domain that must be distributed, protected,
backed up, and eventually rotated.
### Install the CLI on Debian amd64
```bash
curl -fsSL \
https://dl.smallstep.com/cli/docs-ca-install/latest/step-cli_amd64.deb \
-o /tmp/step-cli_amd64.deb
sudo dpkg -i /tmp/step-cli_amd64.deb
rm /tmp/step-cli_amd64.deb
step version
```
Use the official package matching the host architecture on other systems.
### Create a dedicated offline CA
Skip this section when using an existing CA.
```bash
umask 077
mkdir -p "$HOME/.step/secrets" "$HOME/.step/camera"
openssl rand -base64 48 > "$HOME/.step/secrets/camera_ca_password"
chmod 600 "$HOME/.step/secrets/camera_ca_password"
step ca init \
--pki \
--name "UUG Camera CA" \
--password-file "$HOME/.step/secrets/camera_ca_password"
```
This produces:
```text
$HOME/.step/certs/root_ca.crt
$HOME/.step/certs/intermediate_ca.crt
$HOME/.step/secrets/root_ca_key
$HOME/.step/secrets/intermediate_ca_key
$HOME/.step/secrets/camera_ca_password
```
The files under `secrets/` are sensitive. Keep them mode `600`, never commit
them, and back them up to encrypted persistent storage. A devcontainer can be
rebuilt or deleted; it is not sufficient as the only CA backup.
## Add the SAN while signing
Copy the camera CSR into a protected working directory:
```bash
cp camera.csr.pem "$HOME/.step/camera/camera.csr.pem"
```
Create `$HOME/.step/camera/bosch-rtsps.tpl`:
```json
{
"subject": {
"commonName": {{ toJson .Insecure.CR.Subject.CommonName }}
},
"ipAddresses": ["10.0.30.11"],
"keyUsage": ["keyEncipherment", "digitalSignature"],
"extKeyUsage": ["serverAuth", "clientAuth"]
}
```
The template preserves the camera CSR public key, sets the IP identity, and
creates a TLS leaf rather than a CA certificate.
Sign it with a validity period that ends before the intermediate CA expires.
A one-year leaf is preferable to a ten-year leaf when automated renewal is
available:
```bash
step certificate sign \
--template "$HOME/.step/camera/bosch-rtsps.tpl" \
--bundle \
--not-after 8760h \
--password-file "$HOME/.step/secrets/camera_ca_password" \
"$HOME/.step/camera/camera.csr.pem" \
"$HOME/.step/certs/intermediate_ca.crt" \
"$HOME/.step/secrets/intermediate_ca_key" \
> "$HOME/.step/camera/bosch-rtsps-chain.pem"
```
For an online `step-ca`, do not assume `step ca sign` accepts a `--san` flag. It
does not. Authorize SANs in the one-time token or configure a provisioner
template that produces the required SANs.
## Validate before upload
Inspect the leaf certificate, which is the first PEM block in the chain file:
```bash
openssl x509 \
-in "$HOME/.step/camera/bosch-rtsps-chain.pem" \
-noout -subject -issuer -dates -ext subjectAltName -ext extendedKeyUsage
```
Confirm the SAN separately because some OpenSSL versions display only the last
requested extension:
```bash
openssl x509 \
-in "$HOME/.step/camera/bosch-rtsps-chain.pem" \
-noout -ext subjectAltName
```
Verify the path and IP identity:
```bash
openssl verify \
-CAfile "$HOME/.step/certs/root_ca.crt" \
-untrusted "$HOME/.step/certs/intermediate_ca.crt" \
-verify_ip 10.0.30.11 \
"$HOME/.step/camera/bosch-rtsps-chain.pem"
```
Confirm that the signed leaf uses the exact public key from the camera CSR:
```bash
csr_key=$(
openssl req -in "$HOME/.step/camera/camera.csr.pem" -pubkey -noout |
openssl pkey -pubin -outform DER 2>/dev/null |
sha256sum | cut -d' ' -f1
)
cert_key=$(
openssl x509 -in "$HOME/.step/camera/bosch-rtsps-chain.pem" -pubkey -noout |
openssl pkey -pubin -outform DER 2>/dev/null |
sha256sum | cut -d' ' -f1
)
test "$csr_key" = "$cert_key"
```
Do not upload a certificate when any of these checks fail.
## Upload and assign the certificate
1. Return to **Service > Certificates**.
2. Click **Add > Upload certificate**.
3. Select the leaf-plus-intermediate PEM chain.
4. Click **Upload** and wait for `100%`.
5. Confirm that the former CSR row is now a `Certificate`.
6. Confirm that the key icon is present. It proves that the camera associated
the certificate with its retained private key.
7. Open the new certificate's **Usage** selector.
8. Select only **HTTPS**.
9. Leave **CBS client** assigned to the original Bosch `DeviceCertificate`.
10. Click **Set** and wait for the table to reload.
On the tested firmware, there is no separate SRTSP usage. RTSPS presents the
certificate assigned to HTTPS. Reassigning HTTPS therefore changes both the
web interface and RTSPS certificate.
After saving, the expected split is:
| Certificate | Usage |
| --- | --- |
| Private-CA camera certificate | `HTTPS` |
| Bosch `DeviceCertificate` | `CBS client` |
The browser may warn about the new HTTPS certificate until the private root CA
is trusted by the workstation.
## Account for the Bosch chain behavior
The tested firmware served only the leaf certificate on ports `443` and `9554`,
even when the uploaded file contained the leaf and intermediate. Uploading the
intermediate separately as a trusted camera certificate did not change the
served chain.
Confirm the behavior:
```bash
openssl s_client \
-connect 10.0.30.11:9554 \
-showcerts </dev/null 2>/dev/null |
grep -c '^-----BEGIN CERTIFICATE-----$'
```
A result of `1` means the client must already have the issuing intermediate.
Create a portable trust bundle containing the intermediate and root:
```bash
step certificate bundle \
"$HOME/.step/certs/intermediate_ca.crt" \
"$HOME/.step/certs/root_ca.crt" \
"$HOME/.step/camera/uug-camera-trust-bundle.pem"
chmod 644 "$HOME/.step/camera/uug-camera-trust-bundle.pem"
```
The trust bundle is public material. The CA private keys and password are not.
## Configure Kerberos Agent
For a process running directly in the same environment:
```dotenv
AGENT_CAPTURE_IPCAMERA_RTSP="rtsps://<user>:<password>@10.0.30.11:9554/?inst=1"
AGENT_CAPTURE_IPCAMERA_SUB_RTSP="rtsps://<user>:<password>@10.0.30.11:9554/?inst=2"
AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false
SSL_CERT_FILE=/home/agent/data/config/uug-camera-trust-bundle.pem
```
For a container, mount the public bundle read-only at the exact path visible
inside the container. The Agent image creates `/home/agent/data/config` and
includes Debian's `ca-certificates` package:
```bash
docker run \
-v /secure/config/uug-camera-trust-bundle.pem:/home/agent/data/config/uug-camera-trust-bundle.pem:ro \
-e SSL_CERT_FILE=/home/agent/data/config/uug-camera-trust-bundle.pem \
-e AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false \
-e 'AGENT_CAPTURE_IPCAMERA_RTSP=rtsps://<user>:<password>@10.0.30.11:9554/?inst=1' \
-e 'AGENT_CAPTURE_IPCAMERA_SUB_RTSP=rtsps://<user>:<password>@10.0.30.11:9554/?inst=2' \
kerberos/agent:latest
```
Restart the Agent after changing trust files. Go can cache the process system
certificate pool after its first use, so editing a file does not guarantee that
an already-running process reloads it.
The default production mode retains the image's normal public roots in addition
to the private camera CA. For a deliberately private-CA-only deployment, mount
an empty directory and set `SSL_CERT_DIR` to its path:
```bash
-v /secure/config/empty-ca-dir:/home/agent/data/config/empty-ca-dir:ro \
-e SSL_CERT_DIR=/home/agent/data/config/empty-ca-dir
```
Do not use `SSL_CERT_DIR=`. Go treats an empty value as unset and scans its
default certificate directories.
Only use that mode when the Agent does not need public roots for other TLS
connections.
## Validate the live endpoints
### Strict TLS and identity check
Use only the specified bundle, without OpenSSL's default CA locations:
```bash
openssl s_client \
-brief \
-connect 10.0.30.11:9554 \
-verify_ip 10.0.30.11 \
-verify_return_error \
-CAfile "$HOME/.step/camera/uug-camera-trust-bundle.pem" \
-no-CApath \
-no-CAstore \
</dev/null
```
Repeat with port `443`. Both must report `Verification: OK`.
Confirm that identity checking is active by repeating the command with a wrong
address, such as `-verify_ip 10.0.30.12`. It must fail with an IP address
mismatch.
### Confirm the live leaf is the generated leaf
```bash
live_fingerprint=$(
openssl s_client -connect 10.0.30.11:9554 -showcerts </dev/null 2>/dev/null |
openssl x509 -noout -fingerprint -sha256 |
cut -d= -f2
)
local_fingerprint=$(
openssl x509 \
-in "$HOME/.step/camera/bosch-rtsps-chain.pem" \
-noout -fingerprint -sha256 |
cut -d= -f2
)
test "$live_fingerprint" = "$local_fingerprint"
```
### Validate the media path
A successful TLS handshake does not prove that RTSP authentication, DESCRIBE,
SETUP, PLAY, and RTP delivery work. Start a fresh Agent with verified TLS and
confirm that it connects without an x509 error and receives frames. During the
verified setup described here, a gortsplib probe completed all RTSP operations
and received an RTP packet over TCP.
## Why a tampered bundle may still connect
Editing PEM text is not always a useful negative TLS test.
### A certificate can still parse after a byte change
Base64 can remain syntactically valid when one character changes. OpenSSL may
still list the certificate subject and issuer even though a signature is now
invalid. Parsing and signature verification are different operations.
### Trust anchors are not validated through a parent
Every certificate loaded into Go's root pool is a trust anchor, including a
non-self-signed intermediate CA. Verification can terminate at that certificate.
If tampering changes only the intermediate's signature from its parent root,
but does not change its public key, that intermediate can still validate the
camera leaf when it is trusted directly. Its now-invalid parent signature is
not consulted at the trust boundary.
This is equivalent to OpenSSL's partial-chain behavior:
```bash
openssl s_client \
-connect 10.0.30.11:9554 \
-verify_ip 10.0.30.11 \
-verify_return_error \
-partial_chain \
-CAfile tampered-bundle.pem \
-no-CApath \
-no-CAstore \
</dev/null
```
### `SSL_CERT_FILE` does not isolate Go from CA directories
On Unix, Go uses `SSL_CERT_FILE` instead of its default aggregate CA file, but it
still scans default certificate directories such as `/etc/ssl/certs`. Setting
`SSL_CERT_FILE` alone therefore does not remove CA certificates installed with
`update-ca-certificates`.
Blank values do not select empty trust sources. Both `SSL_CERT_FILE=` and
`SSL_CERT_DIR=` are treated as unset, so Go falls back to its default aggregate
CA file and certificate directories. To test with no trusted certificates on
Linux, use a non-empty file path that contains no certificates and a non-empty
directory path that contains no certificates:
```bash
mkdir -p /tmp/empty-ca-dir
SSL_CERT_FILE=/dev/null \
SSL_CERT_DIR=/tmp/empty-ca-dir \
AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false \
GOWORK=off \
go run -tags moq . -action run -port 8080
```
That fresh process must fail with `x509: certificate signed by unknown
authority`.
Use exactly one trust-distribution approach when possible:
1. Mount a private trust bundle and set `SSL_CERT_FILE`; or
2. Install the CA certificates into the operating-system trust store.
Using both is valid, but makes isolation tests less obvious.
### Running processes can retain old roots
A long-running Go process may already have loaded and cached the trust pool.
Always start a new process after changing trust configuration during a negative
test.
## Perform a meaningful negative test
Do not corrupt only the root or intermediate signature. Instead, give a fresh
Agent process a completely unrelated CA and hide the default CA directories.
```bash
mkdir -p /tmp/empty-ca-dir
openssl req \
-x509 -newkey rsa:2048 -nodes -days 1 \
-subj '/CN=Unrelated Test Root' \
-keyout /tmp/unrelated-test-root.key \
-out /tmp/unrelated-test-root.crt
SSL_CERT_FILE=/tmp/unrelated-test-root.crt \
SSL_CERT_DIR=/tmp/empty-ca-dir \
AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false \
GOWORK=off \
go run -tags moq . -action run -port 8080
```
The connection must fail with an unknown-authority or chain-building error.
Delete the temporary test key and certificate afterward.
To test bundle integrity rather than client distrust, validate the intermediate
against the root explicitly:
```bash
openssl verify \
-CAfile "$HOME/.step/certs/root_ca.crt" \
-no-CApath \
-no-CAstore \
"$HOME/.step/certs/intermediate_ca.crt"
```
Store and compare approved SHA-256 fingerprints when detecting unauthorized
certificate-file changes is a requirement.
Restore an accidentally edited bundle from the protected CA certificates, then
restart the Agent:
```bash
step certificate bundle -f \
"$HOME/.step/certs/intermediate_ca.crt" \
"$HOME/.step/certs/root_ca.crt" \
"$HOME/.step/camera/uug-camera-trust-bundle.pem"
openssl verify \
-CAfile "$HOME/.step/certs/root_ca.crt" \
-no-CApath \
-no-CAstore \
"$HOME/.step/certs/intermediate_ca.crt"
```
## Optional system trust installation
On Debian, install both public CA certificates when every process in the system
should trust this camera PKI:
```bash
sudo install -m 0644 \
"$HOME/.step/certs/root_ca.crt" \
/usr/local/share/ca-certificates/uug-camera-ca.crt
sudo install -m 0644 \
"$HOME/.step/certs/intermediate_ca.crt" \
/usr/local/share/ca-certificates/uug-camera-intermediate-ca.crt
sudo update-ca-certificates
```
This creates links below `/etc/ssl/certs`. Remove those files and rerun
`update-ca-certificates` before attempting an isolated trust-bundle test.
## Renewal and recovery
- Renew before the leaf or intermediate expires.
- Generate a new camera CSR if the firmware cannot renew the existing key.
- Sign the new CSR with all required SANs.
- Upload and validate the new certificate before deleting the old one.
- Preserve an alternate administrative access path while changing HTTPS usage.
- Back up the CA certificates, encrypted CA keys, and password separately.
- If the CA private keys are lost, create a new CA and redistribute its trust
before replacing camera certificates.
## Production checklist
- [ ] The Agent URL uses `rtsps://`, not `rtsp://` or `srtsp://`.
- [ ] RTSPS is enabled on the camera and the configured port is reachable.
- [ ] The certificate SAN exactly matches the Agent URL host.
- [ ] The leaf public key matches the camera-generated CSR.
- [ ] The certificate has `serverAuth` extended key usage.
- [ ] The certificate expires before its issuer.
- [ ] HTTPS is assigned to the private-CA certificate.
- [ ] CBS client remains assigned to the Bosch device certificate.
- [ ] The Agent has the intermediate and root CA certificates it needs.
- [ ] `AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false`.
- [ ] The Agent was restarted after trust changes.
- [ ] A strict TLS check reports `Verification: OK`.
- [ ] A real Agent connection receives RTP packets.
- [ ] CA private keys and passwords are backed up outside the devcontainer.

View File

@@ -189,6 +189,21 @@ Next to attaching the configuration file, it is also possible to override the co
-e AGENT_CAPTURE_CONTINUOUS=true \
-d --restart=always kerberos/agent:latest
### Secure camera streams (RTSPS)
The Agent accepts `rtsps://` camera URLs. Do not use `srtsp://`; RTSPS is RTSP over TLS. For a Bosch FLEXIDOME micro 3100i, enable **Secure RTSP** under **Network > Network Services** and use port `9554`:
```bash
AGENT_CAPTURE_IPCAMERA_RTSP='rtsps://username:password@camera.example:9554/?inst=1'
AGENT_CAPTURE_IPCAMERA_SUB_RTSP='rtsps://username:password@camera.example:9554/?inst=2'
```
Certificate verification is enabled by default. The URL hostname or IP address must match the camera certificate SAN. On this Bosch firmware, RTSPS presents the certificate assigned to **HTTPS**; there is no separate SRTSP certificate usage. Leave **CBS client** assigned to the Bosch device certificate.
For a private CA, mount a PEM trust bundle containing every CA certificate needed to build the camera certificate chain and set `SSL_CERT_FILE` to that file. This Bosch firmware presents only its leaf certificate, so include both the issuing intermediate and root certificates in the bundle. As a temporary fallback, `AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=true` disables certificate verification for camera streams only.
See [RTSPS and TLS certificates](README-RTSPS-TLS.md) for the complete Bosch UI, private-CA, deployment, validation, and troubleshooting procedure.
| Name | Description | Default Value |
| --------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------ |
| `LOG_LEVEL` | Level for logging, could be "info", "warning", "debug", "error" or "fatal". | "info" |
@@ -203,12 +218,14 @@ Next to attaching the configuration file, it is also possible to override the co
| `AGENT_REMOVE_AFTER_UPLOAD` | When enabled, recordings uploaded successfully to a storage will be removed from disk. | "true" |
| `AGENT_OFFLINE` | Makes sure no external connection is made. | "false" |
| `AGENT_AUTO_CLEAN` | Cleans up the recordings directory. | "true" |
| `AGENT_AUTO_CLEAN_MAX_SIZE` | If `AUTO_CLEAN` enabled, set the max size of the recordings directory (in MB). | "100" |
| `AGENT_AUTO_CLEAN_MAX_SIZE` | If `AUTO_CLEAN` enabled, cap the recordings directory at this size (in MB). When unset/0, recordings may use the whole disk instead (see `AGENT_AUTO_CLEAN_MIN_FREE_SPACE`). | "100" |
| `AGENT_AUTO_CLEAN_MIN_FREE_SPACE` | When `AUTO_CLEAN` is enabled and no `MAX_SIZE` is set, keep at least this much free space (in MB) on the recordings disk before deleting the oldest (already-uploaded first) recordings. Defaults to 5% of the disk. | "" |
| `AGENT_TIME` | Enable the timetable for Kerberos Agent | "false" |
| `AGENT_TIMETABLE` | A (weekly) time table to specify when to make recordings "start1,end1,start2,end2;start1.. | "" |
| `AGENT_REGION_POLYGON` | A single polygon set for motion detection: "x1,y1;x2,y2;x3,y3;... | "" |
| `AGENT_CAPTURE_IPCAMERA_RTSP` | Full-HD RTSP endpoint to the camera you're targetting. | "" |
| `AGENT_CAPTURE_IPCAMERA_SUB_RTSP` | Sub-stream RTSP endpoint used for livestreaming (WebRTC). | "" |
| `AGENT_CAPTURE_IPCAMERA_RTSP` | Full-HD RTSP or RTSPS endpoint for the target camera. | "" |
| `AGENT_CAPTURE_IPCAMERA_SUB_RTSP` | RTSP or RTSPS sub-stream endpoint used for livestreaming (WebRTC). | "" |
| `AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE` | Disable RTSPS camera certificate verification; use only when a trusted CA cannot be installed. | "false" |
| `AGENT_CAPTURE_IPCAMERA_BASE_WIDTH` | Force a specific width resolution for live view processing. | "" |
| `AGENT_CAPTURE_IPCAMERA_BASE_HEIGHT` | Force a specific height resolution for live view processing. | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF` | Mark as a compliant ONVIF device. | "" |
@@ -411,13 +428,53 @@ Remember the build step of the `web` part, during build time we move the build d
## Building for Docker
Inside the root of this `agent` repository, you will find a `Dockerfile`. This file contains the instructions for building and shipping a **Kerberos Agent**. Important to note is that you start from a prebuilt base image, `kerberos/base:xxx`.
This base image already contains a couple of tools, such as Golang, FFmpeg and OpenCV. We do this for faster compilation times.
Inside the root of this `agent` repository, you will find a `Dockerfile`. This file contains the instructions for building and shipping a **Kerberos Agent**. It uses Debian Trixie to support the native dependencies used by the Agent, including Media over QUIC.
By running the `docker build` command, you will create the Kerberos Agent Docker image. After building you can simply run the image as a Docker container.
docker build -t kerberos/agent .
### Media over QUIC
The standard AMD64 and ARM64 images include the optional MoQ publisher. Its Rust
FFI archive requires CGO and glibc 2.38 or newer, which is why the standard image
uses Debian Trixie. The publisher is disabled unless explicitly enabled at runtime:
docker run --rm -p 80:80 \
-e AGENT_LIVE_MOQ_ENABLED=true \
-e AGENT_LIVE_MOQ_URL=https://relay.uug.ai/anon \
kerberos/agent
`AGENT_LIVE_MOQ_BROADCAST_PREFIX` defaults to `devices`. MoQ viewers subscribe to
a relay and never negotiate with the Agent, so every quality tier is published as
its own broadcast and switching quality is simply a resubscribe:
| Tier | Broadcast | Source |
| ------ | ------------------------------------- | ------------------------------------------ |
| `high` | `devices/<agent-key>/live.hang` | highest-resolution camera stream |
| `low` | `devices/<agent-key>/live-low.hang` | sub stream (main stream when none is set) |
Each tier only uploads while it has at least one subscriber, so the tier nobody
watches costs virtually no bandwidth. `AGENT_LIVE_MOQ_QUALITY` accepts `high` or
`low` to pin the Agent to a single tier; viewers requesting the other tier then
find no broadcast. Any other value (including the default) publishes both. The
initial implementation publishes H.264 video only.
The `/anon` relay route is intended for interoperability testing. Production
deployments must set `AGENT_LIVE_MOQ_URL` to a short-lived, device-scoped
publisher URL issued by Hub API.
To verify the native SDK in a development container, rebuild the Agent or shared
monorepo devcontainer so it uses the Trixie base, then run the VS Code task
`agent: moq verify`. The same check is available from a terminal:
cd machinery
bash ./verify-moq-devcontainer.sh
The check requires glibc 2.38 or newer, runs the tagged package tests, links the
complete Agent with `-tags moq`, and executes the resulting binary's version
command. Both devcontainers also run this check during their post-create setup.
## What is new?
This repository contains the next generation of Kerberos.io, **Kerberos Agent (v3)**, and is the successor of the machinery and web repositories. A switch in technologies and architecture has been made. This version is still under active development and can be followed on the [develop branch](https://github.com/kerberos-io/agent/tree/develop) and [project overview](https://github.com/kerberos-io/agent/projects/1).

View File

@@ -7,7 +7,7 @@
"offline": "false",
"auto_clean": "true",
"remove_after_upload": "true",
"max_directory_size": 100,
"max_directory_size": 0,
"timezone": "Africa/Ceuta",
"capture": {
"name": "",
@@ -122,4 +122,4 @@
"signing": {},
"realtimeprocessing": "false",
"realtimeprocessing_topic": ""
}
}

View File

@@ -1,6 +1,6 @@
module github.com/kerberos-io/agent/machinery
go 1.24.2
go 1.25.0
replace google.golang.org/genproto => google.golang.org/genproto v0.0.0-20250519155744-55703ea1f237
@@ -8,7 +8,7 @@ require (
github.com/Eyevinn/mp4ff v0.48.0
github.com/InVisionApp/conjungo v1.1.0
github.com/appleboy/gin-jwt/v2 v2.10.3
github.com/bluenviron/gortsplib/v4 v4.14.1
github.com/bluenviron/gortsplib/v5 v5.6.3
github.com/bluenviron/mediacommon v1.14.0
github.com/cedricve/go-onvif v0.0.0-20200222191200-567e8ce298f6
github.com/dromara/carbon/v2 v2.6.8
@@ -24,14 +24,16 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/kellydunn/golang-geo v0.7.0
github.com/kerberos-io/joy4 v1.0.64
github.com/kerberos-io/onvif v1.0.0
github.com/kerberos-io/onvif v1.2.2
github.com/minio/minio-go/v6 v6.0.57
github.com/moq-dev/moq-go v0.5.7
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
github.com/pion/interceptor v0.1.40
github.com/pion/rtp v1.8.19
github.com/pion/webrtc/v4 v4.1.2
github.com/pion/interceptor v0.1.47
github.com/pion/rtp v1.10.5
github.com/pion/webrtc/v4 v4.2.18
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.11.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/swag v1.16.4
@@ -51,13 +53,14 @@ require (
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/beevik/etree v1.2.0 // indirect
github.com/bluenviron/mediacommon/v2 v2.2.0 // indirect
github.com/bluenviron/mediacommon/v2 v2.9.2 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
github.com/clbanning/mxj v1.8.4 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/elastic/go-windows v1.0.2 // indirect
github.com/elgs/gostrgen v0.0.0-20161222160715-9d61ae07eeae // indirect
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect
@@ -76,7 +79,6 @@ require (
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
github.com/icholy/digest v0.1.23 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/juju/errors v1.0.0 // indirect
@@ -94,21 +96,23 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/moq-dev/moq-go-ffi v0.3.7 // indirect
github.com/nxadm/tail v1.4.11 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pion/datachannel v1.5.10 // indirect
github.com/pion/dtls/v3 v3.0.6 // indirect
github.com/pion/ice/v4 v4.0.10 // indirect
github.com/pion/logging v0.2.3 // indirect
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/datachannel v1.6.2 // indirect
github.com/pion/dtls/v3 v3.1.5 // indirect
github.com/pion/ice/v4 v4.4.0 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.15 // indirect
github.com/pion/sctp v1.8.39 // indirect
github.com/pion/sdp/v3 v3.0.13 // indirect
github.com/pion/srtp/v3 v3.0.5 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/pion/rtcp v1.2.17 // indirect
github.com/pion/sctp v1.11.1 // indirect
github.com/pion/sdp/v3 v3.0.19 // indirect
github.com/pion/srtp/v3 v3.0.12 // indirect
github.com/pion/stun/v3 v3.1.6 // indirect
github.com/pion/transport/v4 v4.0.2 // indirect
github.com/pion/turn/v5 v5.0.12 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
@@ -122,13 +126,14 @@ require (
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
golang.org/x/arch v0.16.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.30.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.47.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect
google.golang.org/grpc v1.72.1 // indirect

View File

@@ -393,12 +393,12 @@ github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhpl
github.com/bazelbuild/rules_go v0.49.0/go.mod h1:Dhcz716Kqg1RHNWos+N6MlXNkjNP2EwZQ0LukRKJfMs=
github.com/beevik/etree v1.2.0 h1:l7WETslUG/T+xOPs47dtd6jov2Ii/8/OjCldk5fYfQw=
github.com/beevik/etree v1.2.0/go.mod h1:aiPf89g/1k3AShMVAzriilpcE4R/Vuor90y83zVZWFc=
github.com/bluenviron/gortsplib/v4 v4.14.1 h1:v99NmXeeJFfbrO+ipPzPxYGibQaR5ZOUESOA9UQZhsI=
github.com/bluenviron/gortsplib/v4 v4.14.1/go.mod h1:3LaEcg0d47+kfXju5KSlsSxCiZ3IKBI/sqIrBPcsS64=
github.com/bluenviron/gortsplib/v5 v5.6.3 h1:OXvHthQZ9fZbLh6r3Go2wuF4XQ4/QW4WTIM2f4bv/W4=
github.com/bluenviron/gortsplib/v5 v5.6.3/go.mod h1:kzHgUtvl8NWNsQ5Vsez6Vuugk6ItFT4ByCPm8J/kbSQ=
github.com/bluenviron/mediacommon v1.14.0 h1:lWCwOBKNKgqmspRpwpvvg3CidYm+XOc2+z/Jw7LM5dQ=
github.com/bluenviron/mediacommon v1.14.0/go.mod h1:z5LP9Tm1ZNfQV5Co54PyOzaIhGMusDfRKmh42nQSnyo=
github.com/bluenviron/mediacommon/v2 v2.2.0 h1:fGXEX0OEvv5VhGHOv3Q2ABzOtSkIpl9UbwOHrnKWNTk=
github.com/bluenviron/mediacommon/v2 v2.2.0/go.mod h1:a6MbPmXtYda9mKibKVMZlW20GYLLrX2R7ZkUE+1pwV0=
github.com/bluenviron/mediacommon/v2 v2.9.2 h1:jvYeBjvhHKFOBRMTMm4hvrSjyOlCelkOkx6708DidQM=
github.com/bluenviron/mediacommon/v2 v2.9.2/go.mod h1:jMf/OJDaJl02xRgkLM2zbidUHnDYqLnO1dMMveCmyyU=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
@@ -755,8 +755,6 @@ github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
github.com/icholy/digest v0.1.23 h1:4hX2pIloP0aDx7RJW0JewhPPy3R8kU+vWKdxPsCCGtY=
github.com/icholy/digest v0.1.23/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
@@ -776,8 +774,8 @@ github.com/kellydunn/golang-geo v0.7.0 h1:A5j0/BvNgGwY6Yb6inXQxzYwlPHc6WVZR+Mrar
github.com/kellydunn/golang-geo v0.7.0/go.mod h1:YYlQPJ+DPEzrHx8kT3oPHC/NjyvCCXE+IuKGKdrjrcU=
github.com/kerberos-io/joy4 v1.0.64 h1:gTUSotHSOhp9mNqEecgq88tQHvpj7TjmrvPUsPm0idg=
github.com/kerberos-io/joy4 v1.0.64/go.mod h1:nZp4AjvKvTOXRrmDyAIOw+Da+JA5OcSo/JundGfOlFU=
github.com/kerberos-io/onvif v1.0.0 h1:pLJrK6skPkK+5Bj4XfqHUkQ2I+p5pwELnp+kQTJWXiQ=
github.com/kerberos-io/onvif v1.0.0/go.mod h1:P1kUcCfeotJSlL1jwGseH6NSnCwWiuJLl3gAzafnLbA=
github.com/kerberos-io/onvif v1.2.2 h1:QnxITps7xvAVD2abWRsa3+p9QexjKESQQldUFMx/mYA=
github.com/kerberos-io/onvif v1.2.2/go.mod h1:XSgEQXmEDjUQTbdXvsaRJt6Az8YPGj7L+j5iXKEGijU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
@@ -847,6 +845,10 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/moq-dev/moq-go v0.5.7 h1:LfFpgAU8FRMcnU85L5Lb03HzxhYbQ2+BhT/pJQdjy5U=
github.com/moq-dev/moq-go v0.5.7/go.mod h1:5K8zjKKjWe5lzfCtlfxoAfdCr3KX6EBuTOZsz0WBnzw=
github.com/moq-dev/moq-go-ffi v0.3.7 h1:+xwPOzTJHvB0tuTnW6znbvvOgT4yLnN7sMqXXZYh02M=
github.com/moq-dev/moq-go-ffi v0.3.7/go.mod h1:zxpOlUetvaoxWBnbXTdILtLLUytaoVuLvO36lftwWO0=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
@@ -864,38 +866,40 @@ github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2
github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc=
github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E=
github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc=
github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU=
github.com/pion/ice/v4 v4.4.0 h1:wvHDDqimaC38Y7MVpD46Y63p246ChvXd87VKoLYS5b4=
github.com/pion/ice/v4 v4.4.0/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU=
github.com/pion/interceptor v0.1.47 h1:yw8t5pJ2f8t78NgU+8EmxhaqYLXS7uFCC/tAGOaSDBo=
github.com/pion/interceptor v0.1.47/go.mod h1:7yoRBzaIDETPC6cIN8Zj9EyGqHv1ImOpcTFPha6MuOM=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
github.com/pion/rtp v1.8.19 h1:jhdO/3XhL/aKm/wARFVmvTfq0lC/CvN1xwYKmduly3c=
github.com/pion/rtp v1.8.19/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4=
github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo=
github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pion/webrtc/v4 v4.1.2 h1:mpuUo/EJ1zMNKGE79fAdYNFZBX790KE7kQQpLMjjR54=
github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZsGN+8U=
github.com/pion/rtcp v1.2.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw=
github.com/pion/rtcp v1.2.17/go.mod h1:7kBpuBJaWwax4hzc/pgexY8vkOpvh8atgYDbaKZq0iU=
github.com/pion/rtp v1.10.5 h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE=
github.com/pion/rtp v1.10.5/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
github.com/pion/sctp v1.11.1 h1:O4dIFyURw1KTST7w+gtD4gLeYXkhPa0xXLHMMoe/OSA=
github.com/pion/sctp v1.11.1/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0=
github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ=
github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU=
github.com/pion/srtp/v3 v3.0.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc=
github.com/pion/srtp/v3 v3.0.12/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns=
github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8=
github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pion/turn/v5 v5.0.12 h1:6+b69ivQQXSlyfkp2AKripqD2k3W32qXK8QzCzpJWPI=
github.com/pion/turn/v5 v5.0.12/go.mod h1:CQACsRDJtjQ+6RSrGHrS2PCIerLwbW3uqXRqOvtjAFg=
github.com/pion/webrtc/v4 v4.2.18 h1:smA/3g6Gy4RohM0VIZ5KKY/12TQbxv3XFgpUMyb2EUI=
github.com/pion/webrtc/v4 v4.2.18/go.mod h1:vmzi6s+rvhoIuT94DPqivB+0xJXs9rG4QRD+4MgBtlY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -955,8 +959,9 @@ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/substrait-io/substrait-go v0.4.2/go.mod h1:qhpnLmrcvAnlZsUyPXZRqldiHapPTXC3t7xFgDi3aQg=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
@@ -1164,8 +1169,8 @@ golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -1234,8 +1239,9 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1323,8 +1329,8 @@ golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1403,8 +1409,9 @@ golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -1513,8 +1520,8 @@ golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -1581,8 +1588,8 @@ golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1594,6 +1601,8 @@ golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -1667,8 +1676,9 @@ golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -2109,7 +2119,6 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@@ -5,6 +5,7 @@ import (
"flag"
"fmt"
"os"
"strings"
"time"
"github.com/kerberos-io/agent/machinery/src/capture"
@@ -76,12 +77,14 @@ func main() {
var name string
var port string
var timeout string
var subnet string
flag.StringVar(&action, "action", "version", "Tell us what you want do 'run' or 'version'")
flag.StringVar(&configDirectory, "config", ".", "Where is the configuration stored")
flag.StringVar(&name, "name", "agent", "Provide a name for the agent")
flag.StringVar(&port, "port", "80", "On which port should the agent run")
flag.StringVar(&timeout, "timeout", "2000", "Number of milliseconds to wait for the ONVIF discovery to complete")
flag.StringVar(&subnet, "subnet", "", "Optional subnet(s) to scan for discovery, e.g. '192.168.1.0/24' (comma-separated). Defaults to the local interfaces.")
flag.Parse()
// Specify the level of loggin: "info", "warning", "debug", "error" or "fatal."
@@ -112,7 +115,13 @@ func main() {
log.Log.Fatal("main.Main(): could not parse timeout: " + err.Error())
return
}
onvif.Discover(timeout)
var subnets []string
for _, part := range strings.Split(subnet, ",") {
if trimmed := strings.TrimSpace(part); trimmed != "" {
subnets = append(subnets, trimmed)
}
}
onvif.Discover(timeout, subnets...)
}
case "decrypt":
{

View File

@@ -0,0 +1,238 @@
package capture
import (
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/kerberos-io/agent/machinery/src/models"
)
// writeRecording creates a file under recordingsDir and sets its modtime so the
// tests can control the "oldest" ordering deterministically.
func writeRecording(t *testing.T, recordingsDir, name string, ageMinutes int) {
t.Helper()
full := filepath.Join(recordingsDir, name)
if err := os.WriteFile(full, []byte("data"), 0o644); err != nil {
t.Fatalf("write recording %s: %v", name, err)
}
mod := time.Now().Add(-time.Duration(ageMinutes) * time.Minute)
if err := os.Chtimes(full, mod, mod); err != nil {
t.Fatalf("chtimes %s: %v", name, err)
}
}
// markPending creates the upload marker in cloudDir for the given recording,
// marking it as still queued for upload.
func markPending(t *testing.T, cloudDir, name string) {
t.Helper()
markerName := models.RecordingUploadMetadataFileName(name)
if err := os.WriteFile(filepath.Join(cloudDir, markerName), nil, 0o644); err != nil {
t.Fatalf("write marker %s: %v", name, err)
}
}
func newCleanupDirs(t *testing.T) (string, string) {
t.Helper()
base := t.TempDir()
recordingsDir := filepath.Join(base, "data", "recordings")
cloudDir := filepath.Join(base, "data", "cloud")
if err := os.MkdirAll(recordingsDir, 0o755); err != nil {
t.Fatalf("mkdir recordings: %v", err)
}
if err := os.MkdirAll(cloudDir, 0o755); err != nil {
t.Fatalf("mkdir cloud: %v", err)
}
return recordingsDir, cloudDir
}
// The core regression: when the oldest recording is still pending upload but a
// newer one has already been uploaded, cleanup must delete the uploaded (safe)
// one and leave the pending recording on disk so it can still be uploaded.
func TestPickRecordingToCleanup_PrefersUploaded(t *testing.T) {
recordingsDir, cloudDir := newCleanupDirs(t)
// oldest is still pending upload (marker present).
writeRecording(t, recordingsDir, "oldest_pending.mp4", 30)
markPending(t, cloudDir, "oldest_pending.mp4")
// newer one has already been uploaded (no marker).
writeRecording(t, recordingsDir, "newer_uploaded.mp4", 10)
name, pending, err := pickRecordingToCleanup(recordingsDir, cloudDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pending {
t.Fatalf("expected a safe (already-uploaded) deletion, got pending=true")
}
if name != "newer_uploaded.mp4" {
t.Fatalf("cleanup picked %q, want the uploaded recording newer_uploaded.mp4", name)
}
}
func TestPickRecordingToCleanup_RecognizesLegacyMarkerName(t *testing.T) {
recordingsDir, cloudDir := newCleanupDirs(t)
writeRecording(t, recordingsDir, "legacy_pending.mp4", 30)
if err := os.WriteFile(filepath.Join(cloudDir, "legacy_pending.mp4"), nil, 0o644); err != nil {
t.Fatalf("write legacy marker: %v", err)
}
writeRecording(t, recordingsDir, "uploaded.mp4", 10)
name, pending, err := pickRecordingToCleanup(recordingsDir, cloudDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pending || name != "uploaded.mp4" {
t.Fatalf("cleanup picked name=%q pending=%v, want uploaded.mp4 pending=false", name, pending)
}
}
// Among several already-uploaded recordings, the oldest uploaded one is chosen.
func TestPickRecordingToCleanup_OldestUploadedFirst(t *testing.T) {
recordingsDir, cloudDir := newCleanupDirs(t)
writeRecording(t, recordingsDir, "old_uploaded.mp4", 40)
writeRecording(t, recordingsDir, "mid_uploaded.mp4", 20)
// pending one must be ignored even though it is not the oldest.
writeRecording(t, recordingsDir, "pending.mp4", 30)
markPending(t, cloudDir, "pending.mp4")
name, pending, err := pickRecordingToCleanup(recordingsDir, cloudDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pending {
t.Fatalf("expected pending=false, got true")
}
if name != "old_uploaded.mp4" {
t.Fatalf("cleanup picked %q, want old_uploaded.mp4", name)
}
}
// Last resort: when every recording is still pending upload, cleanup returns the
// oldest one with pending=true so the caller can drop it (and its marker) to keep
// the disk bounded.
func TestPickRecordingToCleanup_AllPendingFallsBackToOldest(t *testing.T) {
recordingsDir, cloudDir := newCleanupDirs(t)
writeRecording(t, recordingsDir, "a_old.mp4", 50)
markPending(t, cloudDir, "a_old.mp4")
writeRecording(t, recordingsDir, "b_new.mp4", 5)
markPending(t, cloudDir, "b_new.mp4")
name, pending, err := pickRecordingToCleanup(recordingsDir, cloudDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !pending {
t.Fatalf("expected pending=true when every recording is queued for upload")
}
if name != "a_old.mp4" {
t.Fatalf("cleanup picked %q, want the oldest pending a_old.mp4", name)
}
}
// An empty recordings directory yields os.ErrNotExist so the caller does nothing.
func TestPickRecordingToCleanup_Empty(t *testing.T) {
recordingsDir, cloudDir := newCleanupDirs(t)
if _, _, err := pickRecordingToCleanup(recordingsDir, cloudDir); err != os.ErrNotExist {
t.Fatalf("expected os.ErrNotExist for an empty directory, got %v", err)
}
}
// writeSizedRecording writes a recording of an exact byte size so tests can
// exercise the megabyte-based directory-cap threshold.
func writeSizedRecording(t *testing.T, dir, name string, size int) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), make([]byte, size), 0o644); err != nil {
t.Fatalf("write sized recording %s: %v", name, err)
}
}
// When AGENT_AUTO_CLEAN_MAX_SIZE (MaxDirectorySize) is set, cleanup triggers once
// the recordings directory grows past that many megabytes.
func TestRecordingsNeedCleanup_FixedCap(t *testing.T) {
recordingsDir, _ := newCleanupDirs(t)
// ~2 MB of recordings on disk.
writeSizedRecording(t, recordingsDir, "big.mp4", 2*1000*1000)
over := &models.Configuration{Config: models.Config{MaxDirectorySize: 1}}
need, err := recordingsNeedCleanup(recordingsDir, over)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !need {
t.Fatalf("expected cleanup when 2MB of recordings exceed the 1MB cap")
}
under := &models.Configuration{Config: models.Config{MaxDirectorySize: 100}}
need, err = recordingsNeedCleanup(recordingsDir, under)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if need {
t.Fatalf("expected no cleanup when 2MB of recordings stay under the 100MB cap")
}
}
// With no fixed cap (the default), cleanup is driven by the free space left on
// the recordings filesystem versus the reserve.
func TestRecordingsNeedCleanup_DefaultDiskReserve(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("disk usage stats are only implemented on linux")
}
recordingsDir, _ := newCleanupDirs(t)
totalMB, availableMB, err := diskUsageMB(recordingsDir)
if err != nil {
t.Fatalf("diskUsageMB: %v", err)
}
if totalMB <= 0 || availableMB <= 0 {
t.Skipf("unexpected disk stats total=%dMB available=%dMB", totalMB, availableMB)
}
// A reserve larger than the whole disk means free space is always below it.
over := &models.Configuration{Config: models.Config{MinFreeSpace: totalMB + availableMB}}
need, err := recordingsNeedCleanup(recordingsDir, over)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !need {
t.Fatalf("expected cleanup when free space (%dMB) is below the reserve", availableMB)
}
// A 1 MB reserve leaves plenty of free space, so nothing should be cleaned.
under := &models.Configuration{Config: models.Config{MinFreeSpace: 1}}
need, err = recordingsNeedCleanup(recordingsDir, under)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if need {
t.Fatalf("expected no cleanup when free space (%dMB) exceeds the 1MB reserve", availableMB)
}
}
// The default 5% reserve must never truncate to 0MB on small disks, otherwise
// cleanup would only trigger once the disk is completely full.
func TestDefaultReserveMB(t *testing.T) {
cases := []struct {
totalMB int64
want int64
}{
{totalMB: 0, want: 1}, // no/unknown disk size still reserves 1MB
{totalMB: 10, want: 1}, // 5% = 0MB -> floored to 1MB
{totalMB: 19, want: 1}, // 5% = 0MB -> floored to 1MB
{totalMB: 20, want: 1}, // 5% = exactly 1MB
{totalMB: 100, want: 5}, // 5% = 5MB
{totalMB: 1000, want: 50},
}
for _, c := range cases {
if got := defaultReserveMB(c.totalMB); got != c.want {
t.Errorf("defaultReserveMB(%d) = %d, want %d", c.totalMB, got, c.want)
}
}
}

View File

@@ -0,0 +1,23 @@
//go:build linux
package capture
import "syscall"
// diskUsageMB returns the total capacity and the currently available space (both
// in megabytes, decimal) of the filesystem that contains path. Auto-clean uses
// it to default its cleanup threshold to the real disk capacity instead of a
// fixed size, so recordings can grow to fill the disk while keeping a reserve
// free. Linux is the agent's deployment target (amd64/arm64 containers).
func diskUsageMB(path string) (totalMB int64, availableMB int64, err error) {
var stat syscall.Statfs_t
if err = syscall.Statfs(path, &stat); err != nil {
return 0, 0, err
}
blockSize := int64(stat.Bsize)
totalMB = int64(stat.Blocks) * blockSize / 1000 / 1000
// Bavail is the free space available to unprivileged users, which is the
// space we can actually keep writing recordings into.
availableMB = int64(stat.Bavail) * blockSize / 1000 / 1000
return totalMB, availableMB, nil
}

View File

@@ -0,0 +1,13 @@
//go:build !linux
package capture
import "errors"
// diskUsageMB is only implemented on Linux (the agent's deployment target). On
// other platforms (e.g. local macOS/Windows dev builds) auto-clean falls back to
// its historical fixed-size directory cap, so this reports the capability as
// unavailable.
func diskUsageMB(path string) (totalMB int64, availableMB int64, err error) {
return 0, 0, errors.New("disk usage stats are not supported on this platform")
}

View File

@@ -8,24 +8,26 @@ import "C"
import (
"context"
"crypto/tls"
"errors"
"fmt"
"image"
"os"
"reflect"
"strconv"
"sync"
"time"
"unsafe"
"github.com/bluenviron/gortsplib/v4"
"github.com/bluenviron/gortsplib/v4/pkg/base"
"github.com/bluenviron/gortsplib/v4/pkg/description"
"github.com/bluenviron/gortsplib/v4/pkg/format"
"github.com/bluenviron/gortsplib/v4/pkg/format/rtph264"
"github.com/bluenviron/gortsplib/v4/pkg/format/rtph265"
"github.com/bluenviron/gortsplib/v4/pkg/format/rtplpcm"
"github.com/bluenviron/gortsplib/v4/pkg/format/rtpmpeg4audio"
"github.com/bluenviron/gortsplib/v4/pkg/format/rtpsimpleaudio"
"github.com/bluenviron/gortsplib/v5"
"github.com/bluenviron/gortsplib/v5/pkg/base"
"github.com/bluenviron/gortsplib/v5/pkg/description"
"github.com/bluenviron/gortsplib/v5/pkg/format"
"github.com/bluenviron/gortsplib/v5/pkg/format/rtph264"
"github.com/bluenviron/gortsplib/v5/pkg/format/rtph265"
"github.com/bluenviron/gortsplib/v5/pkg/format/rtplpcm"
"github.com/bluenviron/gortsplib/v5/pkg/format/rtpmpeg4audio"
"github.com/bluenviron/gortsplib/v5/pkg/format/rtpsimpleaudio"
"github.com/bluenviron/mediacommon/pkg/codecs/h264"
"github.com/bluenviron/mediacommon/pkg/codecs/h265"
"github.com/bluenviron/mediacommon/pkg/codecs/mpeg4audio"
@@ -38,6 +40,16 @@ import (
var tracer = otel.Tracer("github.com/kerberos-io/agent/machinery/src/capture")
const rtspsInsecureEnv = "AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE"
func rtspsTLSConfig() *tls.Config {
if os.Getenv(rtspsInsecureEnv) != "true" {
return nil
}
return &tls.Config{InsecureSkipVerify: true} // #nosec G402 -- explicit opt-in for cameras with self-signed certificates
}
// Implements the RTSPClient interface.
type Golibrtsp struct {
RTSPClient
@@ -317,11 +329,12 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
_, span := tracer.Start(ctxOtel, "Connect")
defer span.End()
transport := gortsplib.TransportTCP
protocol := gortsplib.ProtocolTCP
g.health = newStreamHealth()
g.Client = gortsplib.Client{
RequestBackChannels: false,
Transport: &transport,
Protocol: &protocol,
TLSConfig: rtspsTLSConfig(),
// Route gortsplib's packet-loss / decode-error reporting through our
// structured logger with stream context (replaces its plain stdout
// logging). These hooks are what let us tell whether the camera is
@@ -342,7 +355,9 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
}
// connect to the server
err = g.Client.Start(u.Scheme, u.Host)
g.Client.Scheme = u.Scheme
g.Client.Host = u.Host
err = g.Client.Start()
if err != nil {
log.Log.Debug("capture.golibrtsp.Connect(Start): " + err.Error())
}
@@ -593,10 +608,11 @@ func (g *Golibrtsp) ConnectBackChannel(ctx context.Context, ctxRunAgent context.
defer span.End()
// Transport TCP
transport := gortsplib.TransportTCP
protocol := gortsplib.ProtocolTCP
g.Client = gortsplib.Client{
RequestBackChannels: true,
Transport: &transport,
Protocol: &protocol,
TLSConfig: rtspsTLSConfig(),
}
// parse URL
u, err := base.ParseURL(g.Url)
@@ -606,7 +622,9 @@ func (g *Golibrtsp) ConnectBackChannel(ctx context.Context, ctxRunAgent context.
}
// connect to the server
err = g.Client.Start(u.Scheme, u.Host)
g.Client.Scheme = u.Scheme
g.Client.Host = u.Host
err = g.Client.Start()
if err != nil {
log.Log.Error("capture.golibrtsp.ConnectBackChannel(): " + err.Error())
}
@@ -680,6 +698,12 @@ func compositionOffsetMs(ext dtsExtractor, au [][]byte, pts int64, clockRate int
return offset * 1000 / int64(clockRate)
}
func ptsToDuration(pts int64, clockRate int) time.Duration {
rate := int64(clockRate)
return time.Duration(pts/rate)*time.Second +
time.Duration(pts%rate)*time.Second/time.Duration(rate)
}
// Start the RTSP client, and start reading packets.
func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets.Queue, configuration *models.Configuration, communication *models.Communication) (err error) {
log.Log.Debug("capture.golibrtsp.Start(): started")
@@ -693,13 +717,13 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
// called when a MULAW audio RTP packet arrives
if g.AudioG711Media != nil && g.AudioG711Forma != nil {
g.Client.OnPacketRTP(g.AudioG711Media, g.AudioG711Forma, func(rtppkt *rtp.Packet) {
pts, ok := g.Client.PacketPTS(g.AudioG711Media, rtppkt)
// decode timestamp
pts2, ok := g.Client.PacketPTS2(g.AudioG711Media, rtppkt)
pts2, ok := g.Client.PacketPTS(g.AudioG711Media, rtppkt)
if !ok {
log.Log.Debug("capture.golibrtsp.Start(): " + "unable to get PTS")
return
}
pts := ptsToDuration(pts2, g.AudioG711Forma.ClockRate())
// extract LPCM samples from RTP packets
op, err := g.AudioG711Decoder.Decode(rtppkt)
@@ -729,12 +753,12 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
if g.AudioMPEG4Media != nil && g.AudioMPEG4Forma != nil {
g.Client.OnPacketRTP(g.AudioMPEG4Media, g.AudioMPEG4Forma, func(rtppkt *rtp.Packet) {
// decode timestamp
pts, ok := g.Client.PacketPTS(g.AudioMPEG4Media, rtppkt)
pts2, ok := g.Client.PacketPTS2(g.AudioMPEG4Media, rtppkt)
pts2, ok := g.Client.PacketPTS(g.AudioMPEG4Media, rtppkt)
if !ok {
log.Log.Error("capture.golibrtsp.Start(): " + "unable to get PTS")
return
}
pts := ptsToDuration(pts2, g.AudioMPEG4Forma.ClockRate())
// Encode the AAC samples from RTP packets
// extract access units from RTP packets
@@ -788,12 +812,12 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
if len(rtppkt.Payload) > 0 {
// decode timestamps — validate each call separately
pts, okPTS := g.Client.PacketPTS(g.VideoH264Media, rtppkt)
pts2, okPTS2 := g.Client.PacketPTS2(g.VideoH264Media, rtppkt)
pts2, okPTS2 := g.Client.PacketPTS(g.VideoH264Media, rtppkt)
if !okPTS2 {
log.Log.Debug("capture.golibrtsp.Start(): unable to get PTS2 from PacketPTS2")
log.Log.Debug("capture.golibrtsp.Start(): unable to get PTS")
return
}
pts := ptsToDuration(pts2, g.VideoH264Forma.ClockRate())
// Extract access units from RTP packets.
// We need a complete access unit to determine whether
@@ -807,15 +831,13 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
}
// Frame is complete — update per-stream FPS from PTS.
if okPTS {
ft := g.fpsTrackers[g.VideoH264Index]
if ft == nil {
ft = newFPSTracker(30)
g.fpsTrackers[g.VideoH264Index] = ft
}
if ptsFPS := ft.update(pts); ptsFPS > 0 && ptsFPS <= 120 {
g.Streams[g.VideoH264Index].FPS = ptsFPS
}
ft := g.fpsTrackers[g.VideoH264Index]
if ft == nil {
ft = newFPSTracker(30)
g.fpsTrackers[g.VideoH264Index] = ft
}
if ptsFPS := ft.update(pts); ptsFPS > 0 && ptsFPS <= 120 {
g.Streams[g.VideoH264Index].FPS = ptsFPS
}
// We'll need to read out a few things.
@@ -864,6 +886,7 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
// Get FPS using enhanced method
fps := g.getEnhancedFPS(&sps, g.VideoH264Index)
g.Streams[g.VideoH264Index].FPS = fps
g.persistStreamFPS(configuration, streamType, fps)
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.Start(%s): Final FPS=%.2f", streamType, fps))
g.VideoH264Forma.SPS = nalu
if streamType == "main" && len(nalu) > 0 {
@@ -1034,12 +1057,12 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
if len(rtppkt.Payload) > 0 {
// decode timestamps — validate each call separately
pts, okPTS := g.Client.PacketPTS(g.VideoH265Media, rtppkt)
pts2, okPTS2 := g.Client.PacketPTS2(g.VideoH265Media, rtppkt)
pts2, okPTS2 := g.Client.PacketPTS(g.VideoH265Media, rtppkt)
if !okPTS2 {
log.Log.Debug("capture.golibrtsp.Start(): unable to get PTS")
return
}
pts := ptsToDuration(pts2, g.VideoH265Forma.ClockRate())
// Extract access units from RTP packets.
// We need a complete access unit to determine whether
@@ -1053,15 +1076,14 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
}
// Frame is complete — update per-stream FPS from PTS.
if okPTS {
ft := g.fpsTrackers[g.VideoH265Index]
if ft == nil {
ft = newFPSTracker(30)
g.fpsTrackers[g.VideoH265Index] = ft
}
if ptsFPS := ft.update(pts); ptsFPS > 0 && ptsFPS <= 120 {
g.Streams[g.VideoH265Index].FPS = ptsFPS
}
ft := g.fpsTrackers[g.VideoH265Index]
if ft == nil {
ft = newFPSTracker(30)
g.fpsTrackers[g.VideoH265Index] = ft
}
if ptsFPS := ft.update(pts); ptsFPS > 0 && ptsFPS <= 120 {
g.Streams[g.VideoH265Index].FPS = ptsFPS
g.persistStreamFPS(configuration, streamType, ptsFPS)
}
// Preserve the decoded access unit (in decode order) for DTS
@@ -1537,6 +1559,21 @@ func (g *Golibrtsp) initFPSCalculation() {
}
// Get enhanced FPS information from SPS with fallback to PTS-based calculation.
// persistStreamFPS stores the computed frame rate into the shared config so it
// is reported to the hub/UI (mirrors how width/height are persisted). The value
// is rounded to 2 decimals with trailing zeros trimmed (e.g. "25", "29.97").
func (g *Golibrtsp) persistStreamFPS(configuration *models.Configuration, streamType string, fps float64) {
if fps <= 0 {
return
}
fpsStr := strconv.FormatFloat(float64(int(fps*100+0.5))/100, 'f', -1, 64)
if streamType == "main" {
configuration.Config.Capture.IPCamera.FPS = fpsStr
} else if streamType == "sub" {
configuration.Config.Capture.IPCamera.SubFPS = fpsStr
}
}
// The PTS-based FPS is computed per completed frame via fpsTracker.update(),
// so by the time this is called we already have a good estimate.
func (g *Golibrtsp) getEnhancedFPS(sps *h264.SPS, streamIndex int8) float64 {

View File

@@ -0,0 +1,22 @@
package capture
import "testing"
func TestRTSPSTLSConfig(t *testing.T) {
t.Run("verifies certificates by default", func(t *testing.T) {
t.Setenv(rtspsInsecureEnv, "")
if got := rtspsTLSConfig(); got != nil {
t.Fatalf("rtspsTLSConfig() = %#v, want nil", got)
}
})
t.Run("allows explicit insecure mode", func(t *testing.T) {
t.Setenv(rtspsInsecureEnv, "true")
got := rtspsTLSConfig()
if got == nil || !got.InsecureSkipVerify {
t.Fatalf("rtspsTLSConfig() = %#v, want InsecureSkipVerify enabled", got)
}
})
}

View File

@@ -4,11 +4,15 @@ package capture
import (
"context"
"encoding/base64"
"encoding/json"
"image"
"math"
"os"
"path/filepath"
"strconv"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/gin-gonic/gin"
"github.com/kerberos-io/agent/machinery/src/conditions"
"github.com/kerberos-io/agent/machinery/src/encryption"
@@ -20,45 +24,326 @@ import (
"go.opentelemetry.io/otel/trace"
)
func CleanupRecordingDirectory(configDirectory string, configuration *models.Configuration) {
autoClean := configuration.Config.AutoClean
if autoClean == "true" {
maxSize := configuration.Config.MaxDirectorySize
if maxSize == 0 {
maxSize = 300
}
// Total size of the recording directory.
recordingsDirectory := configDirectory + "/data/recordings"
size, err := utils.DirSize(recordingsDirectory)
if err == nil {
sizeInMB := size / 1000 / 1000
if sizeInMB >= maxSize {
// Remove the oldest recording
oldestFile, err := utils.FindOldestFile(recordingsDirectory)
if err == nil {
err := os.Remove(recordingsDirectory + "/" + oldestFile.Name())
log.Log.Info("HandleRecordStream: removed oldest file as part of cleanup - " + recordingsDirectory + "/" + oldestFile.Name())
if err != nil {
log.Log.Info("HandleRecordStream: something went wrong, " + err.Error())
}
} else {
log.Log.Info("HandleRecordStream: something went wrong, " + err.Error())
}
}
} else {
log.Log.Info("HandleRecordStream: something went wrong, " + err.Error())
}
// publishRecordingState notifies the hub (and ultimately the live-view UI) that
// this camera started ("recording": true) or stopped ("recording": false)
// recording, so the frontend can show a "recording" indicator while the agent
// is recording (e.g. a motion clip triggered manually from the live view or by
// motion detection). It is a best-effort broadcast: when no hub/MQTT is
// configured (or the agent is offline) it is a no-op, and a missed message is
// self-healed by the frontend's safety timeout.
func publishRecordingState(mqttClient mqtt.Client, hubKey string, configuration *models.Configuration, recording bool) {
if mqttClient == nil || hubKey == "" || configuration.Config.Offline == "true" {
return
}
message := models.Message{
Payload: models.Payload{
Action: "recording",
DeviceId: configuration.Config.Key,
Value: map[string]interface{}{
"timestamp": time.Now().Unix(),
"recording": recording,
},
},
}
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey, 2, false, payload)
} else {
log.Log.Info("HandleRecordStream: Autoclean disabled, nothing to do here.")
log.Log.Error("capture.main.publishRecordingState(): failed to package MQTT message: " + err.Error())
}
}
func HandleRecordStream(queue *packets.Queue, configDirectory string, configuration *models.Configuration, communication *models.Communication, rtspClient RTSPClient) {
func recordingUploadMetadata(name, deviceKey string, timestamp int64, mp4Video *video.MP4) models.RecordingUploadMetadata {
metadata := models.RecordingUploadMetadata{
FileName: filepath.Base(name),
DeviceKey: deviceKey,
Timestamp: timestamp,
Duration: mp4Video.VideoTotalDuration,
}
value := mp4Video.AverageFPS()
if value > 0 && value <= 240 && !math.IsInf(value, 0) && !math.IsNaN(value) {
metadata.FPS = value
}
return metadata
}
// queueRecordingForUpload creates the marker consumed by the upload worker and
// stores metadata captured from the finalized recording.
func queueRecordingForUpload(configDirectory string, metadata models.RecordingUploadMetadata) {
payload, err := json.Marshal(metadata)
if err != nil {
log.Log.Error("capture.main.queueRecordingForUpload(): " + err.Error())
return
}
// Publish the marker with a same-filesystem rename. Writing directly to the
// watched directory would briefly expose an empty file to the upload poller.
marker, err := os.CreateTemp(filepath.Join(configDirectory, "data"), ".upload-marker-*")
if err == nil {
_, err = marker.Write(payload)
}
if err == nil {
err = marker.Chmod(0644)
}
if marker != nil {
if closeErr := marker.Close(); err == nil {
err = closeErr
}
defer os.Remove(marker.Name())
}
if err == nil {
err = os.Rename(marker.Name(), filepath.Join(configDirectory, "data", "cloud", models.RecordingUploadMetadataFileName(metadata.FileName)))
}
if err != nil {
log.Log.Error("capture.main.queueRecordingForUpload(): " + err.Error())
}
}
const (
// manualRecordingHeartbeatTimeout is how long the agent keeps a manual
// (live-view / remote) recording alive after the LAST viewer heartbeat. The
// frontend re-sends the record command every ~15s while the user stays on the
// page; if several heartbeats are missed (the viewer closed the tab, went idle
// or lost connectivity) the recorder auto-stops the recording so the camera
// doesn't record forever when the "stop" message never arrives.
manualRecordingHeartbeatTimeout = 45 * time.Second
// manualRecordingMaxDuration caps a single manual recording so a forgotten
// record button can't record indefinitely even while the viewer keeps sending
// heartbeats. After this the recording auto-stops and the viewer must press
// record again to continue.
manualRecordingMaxDuration = 5 * time.Minute
)
// manualRecordingExpired reports whether an active manual (live-view) recording
// has outlived its viewer heartbeat window or the maximum duration cap. When it
// has, it clears the manual-recording state (so the motion recorder lets the
// current clip close normally and broadcasts recording:false) and returns true.
// It is a no-op returning false when no manual recording is active.
func manualRecordingExpired(communication *models.Communication, now int64) bool {
if communication.IsRecordingManual.IsNotSet() {
return false
}
manualStart := communication.RecordingManualStart.Load()
maxDurationReached := manualStart > 0 && now-manualStart > manualRecordingMaxDuration.Milliseconds()
// The heartbeat timeout only applies once the viewer has proven it supports
// heartbeats (an older frontend that starts a recording but never heartbeats
// still records up to the max-duration cap instead of being cut off early).
heartbeatExpired := false
if communication.RecordingManualHeartbeatSeen.IsSet() {
lastHeartbeat := communication.RecordingManualHeartbeat.Load()
heartbeatExpired = lastHeartbeat > 0 && now-lastHeartbeat > manualRecordingHeartbeatTimeout.Milliseconds()
}
if !heartbeatExpired && !maxDurationReached {
return false
}
if heartbeatExpired {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): auto-stopping manual recording, no viewer heartbeat within timeout.")
} else {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): auto-stopping manual recording, maximum duration reached.")
}
communication.IsRecordingManual.UnSet()
communication.RecordingManualHeartbeat.Store(0)
communication.RecordingManualStart.Store(0)
communication.RecordingManualHeartbeatSeen.UnSet()
return true
}
func CleanupRecordingDirectory(configDirectory string, configuration *models.Configuration) {
autoClean := configuration.Config.AutoClean
if autoClean != "true" {
log.Log.Info("HandleRecordStream: Autoclean disabled, nothing to do here.")
return
}
recordingsDirectory := configDirectory + "/data/recordings"
cloudDirectory := configDirectory + "/data/cloud"
// Decide whether we still need to free up space. See recordingsNeedCleanup
// for the two modes: an explicit fixed directory cap
// (AGENT_AUTO_CLEAN_MAX_SIZE) or, by default, letting recordings use the whole
// disk while keeping a free-space reserve.
needsCleanup, err := recordingsNeedCleanup(recordingsDirectory, configuration)
if err != nil {
log.Log.Info("HandleRecordStream: something went wrong, " + err.Error())
return
}
if !needsCleanup {
return
}
// Remove the oldest recording, but PREFER recordings that have already been
// uploaded (i.e. no longer have a pending marker in data/cloud). This stops
// auto-clean from deleting recordings that are still queued for upload. That
// previously caused silent data loss: during a network outage the upload
// backlog grows, cleanup deletes the oldest (still un-uploaded) recording to
// stay under MaxDirectorySize, and when connectivity returns the upload loop
// finds the marker but the file is gone -> the recording is dropped and never
// reaches the vault.
//
// Only when EVERY recording on disk is still pending upload do we fall back to
// deleting the oldest pending one, as a last resort to keep the disk bounded
// (otherwise a long outage would fill the disk and stop new recordings).
name, pending, err := pickRecordingToCleanup(recordingsDirectory, cloudDirectory)
if err != nil {
log.Log.Info("HandleRecordStream: something went wrong, " + err.Error())
return
}
if err := os.Remove(recordingsDirectory + "/" + name); err != nil {
log.Log.Info("HandleRecordStream: something went wrong, " + err.Error())
return
}
if pending {
// Data-loss event: the whole recordings directory is an un-uploaded
// backlog (e.g. a prolonged network outage), so we had to drop a recording
// that was never uploaded to keep recording new footage. Also remove the
// now-dangling upload marker so the upload loop doesn't keep trying to
// upload a file that no longer exists.
log.Log.Warning("HandleRecordStream: removed oldest recording as part of cleanup, but it was STILL PENDING UPLOAD (disk full of un-uploaded recordings) - " + recordingsDirectory + "/" + name)
for _, markerName := range uploadMarkerNames(name) {
if err := os.Remove(filepath.Join(cloudDirectory, markerName)); err != nil && !os.IsNotExist(err) {
log.Log.Info("HandleRecordStream: could not remove dangling upload marker " + markerName + ", " + err.Error())
}
}
} else {
log.Log.Info("HandleRecordStream: removed oldest file as part of cleanup - " + recordingsDirectory + "/" + name)
}
}
// recordingsNeedCleanup reports whether auto-clean should free up space in the
// recordings directory. There are two modes:
//
// - AGENT_AUTO_CLEAN_MAX_SIZE (MaxDirectorySize, MB) set: cap the size of the
// recordings directory itself (the historical behaviour).
// - MaxDirectorySize == 0 (the default): recordings may use the WHOLE disk.
// Cleanup only triggers once the free space on the recordings filesystem
// drops to/below a reserve. The reserve is AGENT_AUTO_CLEAN_MIN_FREE_SPACE
// (MinFreeSpace, MB) when set, otherwise 5% of the disk's total capacity.
//
// If disk stats can't be read (e.g. non-Linux dev builds) it falls back to the
// historical fixed 300 MB directory cap so behaviour stays bounded.
func recordingsNeedCleanup(recordingsDirectory string, configuration *models.Configuration) (bool, error) {
maxSize := configuration.Config.MaxDirectorySize
// Explicit fixed cap on the recordings directory size.
if maxSize > 0 {
size, err := utils.DirSize(recordingsDirectory)
if err != nil {
return false, err
}
return size/1000/1000 >= maxSize, nil
}
// Default: allow recordings to use the full disk, keeping a reserve free.
totalMB, availableMB, err := diskUsageMB(recordingsDirectory)
if err != nil {
// Disk stats unavailable: fall back to the historical 300 MB cap.
size, derr := utils.DirSize(recordingsDirectory)
if derr != nil {
return false, derr
}
return size/1000/1000 >= 300, nil
}
reserveMB := configuration.Config.MinFreeSpace
if reserveMB <= 0 {
reserveMB = defaultReserveMB(totalMB)
}
return availableMB <= reserveMB, nil
}
// defaultReserveMB returns the free-space reserve (MB) to keep on the recordings
// disk when AGENT_AUTO_CLEAN_MIN_FREE_SPACE is not set: 5% of the disk total,
// but never below 1MB. On very small disks 5% truncates to 0MB, which would
// disable the reserve entirely (cleanup only once availableMB <= 0), so we floor
// it at 1MB to preserve the intended "keep some space free" behaviour.
func defaultReserveMB(totalMB int64) int64 {
reserveMB := totalMB * 5 / 100
if reserveMB < 1 {
reserveMB = 1
}
return reserveMB
}
// pickRecordingToCleanup chooses which recording to delete to free space in the
// recordings directory. It returns the oldest recording that has already been
// uploaded (no pending marker with the same name in cloudDirectory). Only when
// every recording is still pending upload does it return the oldest recording
// overall with pending=true, signalling the caller that it is about to drop an
// un-uploaded recording as a last resort.
func pickRecordingToCleanup(recordingsDirectory, cloudDirectory string) (string, bool, error) {
entries, err := os.ReadDir(recordingsDirectory)
if err != nil {
return "", false, err
}
var oldestSafeName, oldestAnyName string
var oldestSafeTime, oldestAnyTime time.Time
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil || !info.Mode().IsRegular() {
continue
}
modTime := info.ModTime()
if oldestAnyName == "" || modTime.Before(oldestAnyTime) {
oldestAnyName = entry.Name()
oldestAnyTime = modTime
}
// A recording is still pending upload if either its current .metadata
// marker or a marker created by an older agent exists.
if recordingPendingUpload(cloudDirectory, entry.Name()) {
continue
}
if oldestSafeName == "" || modTime.Before(oldestSafeTime) {
oldestSafeName = entry.Name()
oldestSafeTime = modTime
}
}
if oldestSafeName != "" {
return oldestSafeName, false, nil
}
if oldestAnyName != "" {
return oldestAnyName, true, nil
}
return "", false, os.ErrNotExist
}
func uploadMarkerNames(recordingName string) []string {
return []string{models.RecordingUploadMetadataFileName(recordingName), filepath.Base(recordingName)}
}
func recordingPendingUpload(cloudDirectory, recordingName string) bool {
for _, markerName := range uploadMarkerNames(recordingName) {
if _, err := os.Stat(filepath.Join(cloudDirectory, markerName)); err == nil {
return true
}
}
return false
}
func HandleRecordStream(queue *packets.Queue, configDirectory string, configuration *models.Configuration, communication *models.Communication, rtspClient RTSPClient, mqttClient mqtt.Client) {
config := configuration.Config
hubKey := config.HubKey
loc, _ := time.LoadLocation(config.Timezone)
// Start each capture session with manual recording off, so a leftover
// request from before a restart/reconnect doesn't silently persist.
communication.IsRecordingManual.UnSet()
communication.RecordingManualHeartbeat.Store(0)
communication.RecordingManualStart.Store(0)
communication.RecordingManualHeartbeatSeen.UnSet()
if config.Capture.Recording == "false" {
log.Log.Info("capture.main.HandleRecordStream(): disabled, we will not record anything.")
} else {
@@ -217,12 +502,13 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
}
}
// Create a symbol link.
fc, _ := os.Create(configDirectory + "/data/cloud/" + name)
fc.Close()
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config.Key, startRecording, mp4Video))
recordingStatus = "idle"
// Notify the hub / live-view UI that this camera stopped recording.
publishRecordingState(mqttClient, hubKey, configuration, false)
// Clean up the recording directory if necessary.
CleanupRecordingDirectory(configDirectory, configuration)
}
@@ -299,6 +585,9 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
recordingStatus = "started"
// Notify the hub / live-view UI that this camera started recording.
publishRecordingState(mqttClient, hubKey, configuration, true)
} else if start {
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
@@ -370,12 +659,13 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
}
}
// Create a symbol link.
fc, _ := os.Create(configDirectory + "/data/cloud/" + name)
fc.Close()
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config.Key, startRecording, mp4Video))
recordingStatus = "idle"
// Notify the hub / live-view UI that this camera stopped recording.
publishRecordingState(mqttClient, hubKey, configuration, false)
// Clean up the recording directory if necessary.
CleanupRecordingDirectory(configDirectory, configuration)
}
@@ -492,6 +782,17 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
default:
}
// While a manual recording is active, keep it alive: refresh the
// motion timestamp every iteration so the post-recording timeout
// never fires. The clip still rolls over at maxRecordingPeriod and
// is restarted below, until the viewer stops the manual recording.
// It also auto-stops when the viewer's heartbeat lapses (closed page
// or idle) or the max remote-recording duration is reached, so a
// missed "stop" message can't keep the camera recording forever.
if communication.IsRecordingManual.IsSet() && !manualRecordingExpired(communication, now) {
motionTimestamp = now
}
if start && (motionTimestamp+postRecording-now < 0 || now-startRecording > maxRecordingPeriod-500) && nextPkt.IsKeyFrame {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): timestamp+postRecording-now < 0 - " + strconv.FormatInt(motionTimestamp+postRecording-now, 10) + " < 0")
log.Log.Info("capture.main.HandleRecordStream(motiondetection): now-startRecording > maxRecordingPeriod-500 - " + strconv.FormatInt(now-startRecording, 10) + " > " + strconv.FormatInt(maxRecordingPeriod-500, 10))
@@ -523,6 +824,9 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
}
start = true
// Notify the hub / live-view UI that this camera started recording.
publishRecordingState(mqttClient, hubKey, configuration, true)
}
if start {
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
@@ -557,6 +861,21 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
mp4Video.Close(&config)
log.Log.Info("capture.main.HandleRecordStream(motiondetection): file save: " + name)
// Notify the hub / live-view UI that this camera stopped recording.
publishRecordingState(mqttClient, hubKey, configuration, false)
// If the viewer still has a manual recording running, this clip just
// rolled over at the max length — immediately kick off the next
// segment so recording stays continuous until they stop it. Skip the
// restart when the recording has expired (heartbeat lapsed or max
// duration reached), so it ends here instead of recording forever.
if communication.IsRecordingManual.IsSet() && !manualRecordingExpired(communication, time.Now().UnixMilli()) {
select {
case communication.HandleMotion <- models.MotionDataPartial{Timestamp: time.Now().Unix(), NumberOfChanges: 100000000}:
default:
}
}
// Update the name of the recording with the duration.
// We will update the name of the recording with the duration in milliseconds.
if mp4Video.VideoTotalDuration > 0 {
@@ -610,9 +929,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
}
}
// Create a symbol linc.
fc, _ := os.Create(configDirectory + "/data/cloud/" + name)
fc.Close()
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config.Key, displayTime, mp4Video))
// Clean up the recording directory if necessary.
CleanupRecordingDirectory(configDirectory, configuration)

View File

@@ -0,0 +1,88 @@
package capture
import (
"encoding/json"
"math"
"os"
"path/filepath"
"testing"
"time"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/video"
)
func TestPTSToDuration(t *testing.T) {
tests := []struct {
name string
pts int64
clockRate int
want time.Duration
}{
{name: "one video second", pts: 90_000, clockRate: 90_000, want: time.Second},
{name: "one audio frame", pts: 1_024, clockRate: 8_000, want: 128 * time.Millisecond},
{name: "fractional millisecond", pts: 45_045, clockRate: 90_000, want: 500*time.Millisecond + 500*time.Microsecond},
{name: "negative timestamp", pts: -45_045, clockRate: 90_000, want: -500*time.Millisecond - 500*time.Microsecond},
{name: "large timestamp", pts: 90_000 * 60 * 60 * 24, clockRate: 90_000, want: 24 * time.Hour},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := ptsToDuration(test.pts, test.clockRate); got != test.want {
t.Fatalf("ptsToDuration(%d, %d) = %s, want %s", test.pts, test.clockRate, got, test.want)
}
})
}
}
func TestQueueRecordingForUploadStoresFinalizedMetadata(t *testing.T) {
configDirectory := t.TempDir()
if err := os.MkdirAll(filepath.Join(configDirectory, "data", "cloud"), 0o755); err != nil {
t.Fatalf("mkdir cloud queue: %v", err)
}
mp4Video := &video.MP4{VideoTotalDuration: 20452, SampleCount: 613}
metadata := recordingUploadMetadata("recording.mp4", "device-key", 1785934709414, mp4Video)
queueRecordingForUpload(configDirectory, metadata)
got, err := os.ReadFile(filepath.Join(configDirectory, "data", "cloud", "recording.metadata"))
if err != nil {
t.Fatalf("read upload marker: %v", err)
}
var stored models.RecordingUploadMetadata
if err := json.Unmarshal(got, &stored); err != nil {
t.Fatalf("decode upload marker: %v", err)
}
expectedFPS := mp4Video.AverageFPS()
if stored.FileName != "recording.mp4" || stored.DeviceKey != "device-key" || stored.Timestamp != 1785934709414 || stored.Duration != 20452 || math.Abs(stored.FPS-expectedFPS) > 1e-9 {
t.Fatalf("upload marker = %+v", stored)
}
if stored.FPS == math.Floor(stored.FPS) {
t.Fatalf("upload marker FPS = %v, want fractional precision", stored.FPS)
}
}
func TestQueueRecordingForUploadKeepsUnknownFPSCompatible(t *testing.T) {
for _, fps := range []float64{0, 0.99, -1, math.NaN(), math.Inf(1), 241} {
t.Run("invalid FPS", func(t *testing.T) {
configDirectory := t.TempDir()
if err := os.MkdirAll(filepath.Join(configDirectory, "data", "cloud"), 0o755); err != nil {
t.Fatalf("mkdir cloud queue: %v", err)
}
metadata := models.RecordingUploadMetadata{FileName: "recording.mp4"}
if fps >= 1 && fps <= 240 && !math.IsNaN(fps) && !math.IsInf(fps, 0) {
metadata.FPS = fps
}
queueRecordingForUpload(configDirectory, metadata)
got, err := os.ReadFile(filepath.Join(configDirectory, "data", "cloud", "recording.metadata"))
if err != nil {
t.Fatalf("read upload marker: %v", err)
}
if string(got) != `{"filename":"recording.mp4","device_key":"","timestamp":0,"duration":0}` {
t.Fatalf("upload marker = %q, want metadata without FPS", got)
}
})
}
}

View File

@@ -6,7 +6,6 @@ import (
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"strings"
@@ -79,7 +78,8 @@ func HandleUpload(configDirectory string, configuration *models.Configuration, c
default:
}
fileName := f.Name()
markerFileName := f.Name()
fileName := models.RecordingFileNameFromUploadMarker(markerFileName)
uploaded := false
configured := false
err = nil
@@ -114,7 +114,7 @@ func HandleUpload(configDirectory string, configuration *models.Configuration, c
// Check if the file is uploaded, if so, remove it.
if uploaded {
delay = 500 * time.Millisecond // reset
err := os.Remove(watchDirectory + fileName)
err := os.Remove(watchDirectory + markerFileName)
if err != nil {
log.Log.Error("HandleUpload: " + err.Error())
}
@@ -128,7 +128,7 @@ func HandleUpload(configDirectory string, configuration *models.Configuration, c
}
}
} else if !configured {
err := os.Remove(watchDirectory + fileName)
err := os.Remove(watchDirectory + markerFileName)
if err != nil {
log.Log.Error("HandleUpload: " + err.Error())
}
@@ -219,17 +219,33 @@ func GetSystemInfo() (models.System, error) {
return system, nil
}
// rawJSONOrEmptyArray returns pre-marshalled JSON bytes as a json.RawMessage.
// When the input is empty it falls back to an empty JSON array so the
// surrounding payload always stays valid JSON.
func rawJSONOrEmptyArray(b []byte) json.RawMessage {
if len(b) == 0 {
return json.RawMessage("[]")
}
return json.RawMessage(b)
}
func HandleHeartBeat(configuration *models.Configuration, communication *models.Communication, uptimeStart time.Time) {
log.Log.Debug("cloud.HandleHeartBeat(): started")
// Bound every heartbeat POST so a stalled connection (e.g. a saturated uplink
// or an unresponsive Hub/Vault) fails fast on this cycle instead of blocking
// the whole heartbeat loop indefinitely. A hung POST would otherwise stop all
// further heartbeats, and Hub marks a camera offline once its last heartbeat is
// older than 180s even while capture is healthy.
const heartbeatHTTPTimeout = 30 * time.Second
var client *http.Client
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client = &http.Client{Transport: tr}
client = &http.Client{Transport: tr, Timeout: heartbeatHTTPTimeout}
} else {
client = &http.Client{}
client = &http.Client{Timeout: heartbeatHTTPTimeout}
}
kerberosAgentVersion := utils.VERSION
@@ -472,6 +488,14 @@ loop:
hasBackChannel = "true"
}
// Whether this camera records continuously (24/7) rather than on
// motion. The Hub live view uses this to disable the manual record
// button, which is a no-op in continuous mode (already recording).
continuousRecording := "false"
if config.Capture.Continuous == "true" {
continuousRecording = "true"
}
hub_encryption := "false"
if config.HubEncryption == "true" {
hub_encryption = "true"
@@ -496,48 +520,98 @@ loop:
// We need a hub URI and hub public key before we will send a heartbeat
if hubURI != "" && key != "" {
var object = fmt.Sprintf(`{
"key" : "%s",
"version" : "%s",
"hub_encryption": "%s",
"e2e_encryption": "%s",
"release" : "%s",
"cpuid" : "%s",
"clouduser" : "%s",
"cloudpublickey" : "%s",
"cameraname" : "%s",
"enterprise" : %t,
"hostname" : "%s",
"architecture" : "%s",
"totalMemory" : "%d",
"usedMemory" : "%d",
"freeMemory" : "%d",
"processMemory" : "%d",
"mac_list" : %s,
"ip_list" : %s,
"board" : "",
"disk1size" : "%s",
"disk3size" : "%s",
"diskvdasize" : "%s",
"uptime" : "%s",
"boot_time" : "%s",
"siteID" : "%s",
"onvif" : "%s",
"onvif_zoom" : "%s",
"onvif_pantilt" : "%s",
"onvif_presets": "%s",
"onvif_presets_list": %s,
"onvif_events_list": %s,
"cameraConnected": "%s",
"hasBackChannel": "%s",
"livePreviewHttp": true,
"numberoffiles" : "33",
"timestamp" : 1564747908,
"cameratype" : "IPCamera",
"docker" : true,
"kios" : false,
"raspberrypi" : false
}`, config.Key, kerberosAgentVersion, hub_encryption, e2e_encryption, system.Version, system.CPUId, username, key, name, isEnterprise, system.Hostname, system.Architecture, system.TotalMemory, system.UsedMemory, system.FreeMemory, system.ProcessUsedMemory, macs, ips, "0", "0", "0", uptimeString, boottimeString, config.HubSite, onvifEnabled, onvifZoom, onvifPanTilt, onvifPresets, onvifPresetsList, onvifEventsList, cameraConnected, hasBackChannel)
heartbeat := struct {
Key string `json:"key"`
Version string `json:"version"`
HubEncryption string `json:"hub_encryption"`
E2EEncryption string `json:"e2e_encryption"`
Release string `json:"release"`
CPUId string `json:"cpuid"`
CloudUser string `json:"clouduser"`
CloudPublicKey string `json:"cloudpublickey"`
CameraName string `json:"cameraname"`
Enterprise bool `json:"enterprise"`
Hostname string `json:"hostname"`
Architecture string `json:"architecture"`
TotalMemory string `json:"totalMemory"`
UsedMemory string `json:"usedMemory"`
FreeMemory string `json:"freeMemory"`
ProcessMemory string `json:"processMemory"`
MacList json.RawMessage `json:"mac_list"`
IPList json.RawMessage `json:"ip_list"`
Board string `json:"board"`
Disk1Size string `json:"disk1size"`
Disk3Size string `json:"disk3size"`
DiskVdaSize string `json:"diskvdasize"`
Uptime string `json:"uptime"`
BootTime string `json:"boot_time"`
SiteID string `json:"siteID"`
Onvif string `json:"onvif"`
OnvifZoom string `json:"onvif_zoom"`
OnvifPanTilt string `json:"onvif_pantilt"`
OnvifPresets string `json:"onvif_presets"`
OnvifPresetsList json.RawMessage `json:"onvif_presets_list"`
OnvifEventsList json.RawMessage `json:"onvif_events_list"`
CameraConnected string `json:"cameraConnected"`
HasBackChannel string `json:"hasBackChannel"`
ContinuousRecording string `json:"continuousRecording"`
LivePreviewHTTP bool `json:"livePreviewHttp"`
NumberOfFiles string `json:"numberoffiles"`
Timestamp int64 `json:"timestamp"`
CameraType string `json:"cameratype"`
Docker bool `json:"docker"`
Kios bool `json:"kios"`
RaspberryPi bool `json:"raspberrypi"`
}{
Key: config.Key,
Version: kerberosAgentVersion,
HubEncryption: hub_encryption,
E2EEncryption: e2e_encryption,
Release: system.Version,
CPUId: system.CPUId,
CloudUser: username,
CloudPublicKey: key,
CameraName: name,
Enterprise: isEnterprise,
Hostname: system.Hostname,
Architecture: system.Architecture,
TotalMemory: strconv.FormatUint(system.TotalMemory, 10),
UsedMemory: strconv.FormatUint(system.UsedMemory, 10),
FreeMemory: strconv.FormatUint(system.FreeMemory, 10),
ProcessMemory: strconv.FormatUint(system.ProcessUsedMemory, 10),
MacList: rawJSONOrEmptyArray(macs),
IPList: rawJSONOrEmptyArray(ips),
Board: "",
Disk1Size: "0",
Disk3Size: "0",
DiskVdaSize: "0",
Uptime: uptimeString,
BootTime: boottimeString,
SiteID: config.HubSite,
Onvif: onvifEnabled,
OnvifZoom: onvifZoom,
OnvifPanTilt: onvifPanTilt,
OnvifPresets: onvifPresets,
OnvifPresetsList: rawJSONOrEmptyArray(onvifPresetsList),
OnvifEventsList: rawJSONOrEmptyArray(onvifEventsList),
CameraConnected: cameraConnected,
HasBackChannel: hasBackChannel,
ContinuousRecording: continuousRecording,
LivePreviewHTTP: true,
NumberOfFiles: "33",
Timestamp: 1564747908,
CameraType: "IPCamera",
Docker: true,
Kios: false,
RaspberryPi: false,
}
objectBytes, err := json.Marshal(heartbeat)
if err != nil {
log.Log.Error("cloud.HandleHeartBeat(): error while marshalling heartbeat: " + err.Error())
objectBytes = []byte("{}")
}
object := string(objectBytes)
// Get the private key to encrypt the data using symmetric encryption: AES.
privateKey := config.HubPrivateKey
@@ -551,11 +625,21 @@ loop:
// Base64 encode the encrypted data.
encryptedBase64 := base64.StdEncoding.EncodeToString(encrypted)
object = fmt.Sprintf(`{
"cloudpublicKey": "%s",
"encrypted" : %t,
"encryptedData" : "%s"
}`, config.HubKey, true, encryptedBase64)
encryptedPayload := struct {
CloudPublicKey string `json:"cloudpublicKey"`
Encrypted bool `json:"encrypted"`
EncryptedData string `json:"encryptedData"`
}{
CloudPublicKey: config.HubKey,
Encrypted: true,
EncryptedData: encryptedBase64,
}
encryptedBytes, err := json.Marshal(encryptedPayload)
if err != nil {
log.Log.Error("cloud.HandleHeartBeat(): error while marshalling encrypted heartbeat: " + err.Error())
encryptedBytes = []byte("{}")
}
object = string(encryptedBytes)
}
var jsonStr = []byte(object)
@@ -586,43 +670,86 @@ loop:
secretAccessKey := config.KStorage.SecretAccessKey
if vaultURI != "" && accessKey != "" && secretAccessKey != "" {
var object = fmt.Sprintf(`{
"key" : "%s",
"version" : "%s",
"release" : "%s",
"cpuid" : "%s",
"clouduser" : "%s",
"cloudpublickey" : "%s",
"cameraname" : "%s",
"enterprise" : %t,
"hostname" : "%s",
"architecture" : "%s",
"totalMemory" : "%d",
"usedMemory" : "%d",
"freeMemory" : "%d",
"processMemory" : "%d",
"mac_list" : %s,
"ip_list" : %s,
"board" : "",
"disk1size" : "%s",
"disk3size" : "%s",
"diskvdasize" : "%s",
"uptime" : "%s",
"boot_time" : "%s",
"siteID" : "%s",
"onvif" : "%s",
"onvif_zoom" : "%s",
"onvif_pantilt" : "%s",
"onvif_presets": "%s",
"onvif_presets_list": %s,
"cameraConnected": "%s",
"numberoffiles" : "33",
"timestamp" : 1564747908,
"cameratype" : "IPCamera",
"docker" : true,
"kios" : false,
"raspberrypi" : false
}`, config.Key, kerberosAgentVersion, system.Version, system.CPUId, username, key, name, isEnterprise, system.Hostname, system.Architecture, system.TotalMemory, system.UsedMemory, system.FreeMemory, system.ProcessUsedMemory, macs, ips, "0", "0", "0", uptimeString, boottimeString, config.HubSite, onvifEnabled, onvifZoom, onvifPanTilt, onvifPresets, onvifPresetsList, cameraConnected)
heartbeat := struct {
Key string `json:"key"`
Version string `json:"version"`
Release string `json:"release"`
CPUId string `json:"cpuid"`
CloudUser string `json:"clouduser"`
CloudPublicKey string `json:"cloudpublickey"`
CameraName string `json:"cameraname"`
Enterprise bool `json:"enterprise"`
Hostname string `json:"hostname"`
Architecture string `json:"architecture"`
TotalMemory string `json:"totalMemory"`
UsedMemory string `json:"usedMemory"`
FreeMemory string `json:"freeMemory"`
ProcessMemory string `json:"processMemory"`
MacList json.RawMessage `json:"mac_list"`
IPList json.RawMessage `json:"ip_list"`
Board string `json:"board"`
Disk1Size string `json:"disk1size"`
Disk3Size string `json:"disk3size"`
DiskVdaSize string `json:"diskvdasize"`
Uptime string `json:"uptime"`
BootTime string `json:"boot_time"`
SiteID string `json:"siteID"`
Onvif string `json:"onvif"`
OnvifZoom string `json:"onvif_zoom"`
OnvifPanTilt string `json:"onvif_pantilt"`
OnvifPresets string `json:"onvif_presets"`
OnvifPresetsList json.RawMessage `json:"onvif_presets_list"`
CameraConnected string `json:"cameraConnected"`
NumberOfFiles string `json:"numberoffiles"`
Timestamp int64 `json:"timestamp"`
CameraType string `json:"cameratype"`
Docker bool `json:"docker"`
Kios bool `json:"kios"`
RaspberryPi bool `json:"raspberrypi"`
}{
Key: config.Key,
Version: kerberosAgentVersion,
Release: system.Version,
CPUId: system.CPUId,
CloudUser: username,
CloudPublicKey: key,
CameraName: name,
Enterprise: isEnterprise,
Hostname: system.Hostname,
Architecture: system.Architecture,
TotalMemory: strconv.FormatUint(system.TotalMemory, 10),
UsedMemory: strconv.FormatUint(system.UsedMemory, 10),
FreeMemory: strconv.FormatUint(system.FreeMemory, 10),
ProcessMemory: strconv.FormatUint(system.ProcessUsedMemory, 10),
MacList: rawJSONOrEmptyArray(macs),
IPList: rawJSONOrEmptyArray(ips),
Board: "",
Disk1Size: "0",
Disk3Size: "0",
DiskVdaSize: "0",
Uptime: uptimeString,
BootTime: boottimeString,
SiteID: config.HubSite,
Onvif: onvifEnabled,
OnvifZoom: onvifZoom,
OnvifPanTilt: onvifPanTilt,
OnvifPresets: onvifPresets,
OnvifPresetsList: rawJSONOrEmptyArray(onvifPresetsList),
CameraConnected: cameraConnected,
NumberOfFiles: "33",
Timestamp: 1564747908,
CameraType: "IPCamera",
Docker: true,
Kios: false,
RaspberryPi: false,
}
objectBytes, err := json.Marshal(heartbeat)
if err != nil {
log.Log.Error("cloud.HandleHeartBeat(): error while marshalling vault heartbeat: " + err.Error())
objectBytes = []byte("{}")
}
object := string(objectBytes)
var jsonStr = []byte(object)
buffy := bytes.NewBuffer(jsonStr)

View File

@@ -85,6 +85,7 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
req.Header.Set("X-Kerberos-Hub-PublicKey", config.HubKey)
req.Header.Set("X-Kerberos-Hub-PrivateKey", config.HubPrivateKey)
req.Header.Set("X-Kerberos-Hub-Region", config.S3.Region)
setQueuedRecordingMetadataHeaders(req.Header, fileName)
var client *http.Client
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
@@ -128,6 +129,7 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
req.Header.Set("X-Kerberos-Hub-PublicKey", config.HubKey)
req.Header.Set("X-Kerberos-Hub-PrivateKey", config.HubPrivateKey)
req.Header.Set("X-Kerberos-Hub-Region", config.S3.Region)
setQueuedRecordingMetadataHeaders(req.Header, fileName)
resp, err = client.Do(req)
if resp != nil {
defer resp.Body.Close()

View File

@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"os"
"strconv"
"time"
"github.com/kerberos-io/agent/machinery/src/log"
@@ -164,6 +165,7 @@ func uploadVaultLegacy(vault models.KStorage, publicKey, deviceKey, fileName, la
}
req.Header.Set("Content-Type", "video/mp4")
setVaultHeaders(req.Header, vault, publicKey, deviceKey, fileName)
setQueuedRecordingMetadataHeaders(req.Header, fileName)
client := newVaultHTTPClient(0)
resp, err := client.Do(req)
@@ -199,17 +201,59 @@ func setVaultHeaders(h http.Header, vault models.KStorage, publicKey, deviceKey,
}
// newVaultHTTPClient builds an HTTP client honouring the AGENT_TLS_INSECURE
// escape hatch. A timeout of 0 disables the client-level timeout, which is
// required for streaming large upload bodies.
// escape hatch. A timeout of 0 disables the *overall* client timeout, which is
// required for streaming large upload bodies without capping the total transfer
// time. Transport-level timeouts are still applied so that a lost network
// connection (for example the internet being disconnected) fails reasonably
// fast and the upload is retried, instead of the request hanging until the OS
// TCP timeout (which can be many minutes) and blocking the whole upload loop.
func newVaultHTTPClient(timeout time.Duration) *http.Client {
client := &http.Client{}
// Start from a clone of the default transport so we keep its sane dial and
// TLS-handshake timeouts, connection pooling and HTTP/2 support even when the
// AGENT_TLS_INSECURE escape hatch is enabled (a bare http.Transport would have
// no dial/handshake timeouts at all).
transport := http.DefaultTransport.(*http.Transport).Clone()
// ResponseHeaderTimeout bounds how long we wait for the vault's response
// headers *after* the request body has been fully written. It does not limit
// the time spent streaming the (potentially large) upload body, so big
// recordings still upload fine, but a vault/network that disappears while we
// wait for the acknowledgement is detected and the upload is retried instead
// of hanging indefinitely.
transport.ResponseHeaderTimeout = vaultResponseHeaderTimeout()
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
if transport.TLSClientConfig == nil {
transport.TLSClientConfig = &tls.Config{}
}
transport.TLSClientConfig.InsecureSkipVerify = true
}
client := &http.Client{Transport: transport}
if timeout > 0 {
client.Timeout = timeout
}
return client
}
// vaultResponseHeaderTimeout returns the maximum time to wait for a vault's
// response headers after the request body has been written. It defaults to 5
// minutes — generous enough for the vault to persist/finalize a chunk or a full
// recording to its storage provider — and can be tuned with the
// AGENT_VAULT_RESPONSE_HEADER_TIMEOUT_SECONDS environment variable. A value of 0
// (or a negative/invalid value) disables the timeout.
func vaultResponseHeaderTimeout() time.Duration {
const def = 5 * time.Minute
v := os.Getenv("AGENT_VAULT_RESPONSE_HEADER_TIMEOUT_SECONDS")
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
if n <= 0 {
return 0
}
return time.Duration(n) * time.Second
}

View File

@@ -0,0 +1,103 @@
package livemoq
import (
"bytes"
"strings"
"github.com/bluenviron/mediacommon/pkg/codecs/h264"
"github.com/kerberos-io/agent/machinery/src/models"
)
var annexBStartCode = []byte{0x00, 0x00, 0x00, 0x01}
// H264NormalizationStats reports malformed duplication removed from an access unit.
type H264NormalizationStats struct {
DuplicateIDRNALUs int
}
// EnsureAnnexB restores the start code stripped by the Agent capture queue.
func EnsureAnnexB(payload []byte) []byte {
if hasAnnexBStartCode(payload) {
return payload
}
framed := make([]byte, 0, len(annexBStartCode)+len(payload))
framed = append(framed, annexBStartCode...)
return append(framed, payload...)
}
// NormalizeH264AccessUnit removes delimiters and exact duplicate parameter-set
// or IDR NALUs that can confuse older MoQ splitters and decoders.
func NormalizeH264AccessUnit(payload []byte) ([]byte, error) {
normalized, _, err := NormalizeH264AccessUnitWithStats(payload)
return normalized, err
}
// NormalizeH264AccessUnitWithStats also reports exact duplicate IDR NALUs.
func NormalizeH264AccessUnitWithStats(payload []byte) ([]byte, H264NormalizationStats, error) {
nalus, err := h264.AnnexBUnmarshal(EnsureAnnexB(payload))
if err != nil {
return nil, H264NormalizationStats{}, err
}
stats := H264NormalizationStats{}
normalized := make([][]byte, 0, len(nalus))
for _, nalu := range nalus {
if len(nalu) == 0 || nalu[0]&0x1f == 9 {
continue
}
naluType := nalu[0] & 0x1f
if naluType == 7 || naluType == 8 || naluType == 5 {
duplicate := false
for _, existing := range normalized {
if bytes.Equal(existing, nalu) {
duplicate = true
break
}
}
if duplicate {
if naluType == 5 {
stats.DuplicateIDRNALUs++
}
continue
}
}
normalized = append(normalized, nalu)
}
result, err := h264.AnnexBMarshal(normalized)
return result, stats, err
}
// BroadcastPath returns the relay path a quality tier is published on. Every
// tier gets its own broadcast so a viewer switches between the camera's main and
// sub stream by resubscribing to another path, without any control channel back
// to the Agent. The high tier keeps the historical ".../live.hang" path so
// existing viewers keep working; the low tier lives next to it on
// ".../live-low.hang".
func BroadcastPath(prefix string, deviceKey string, quality string) string {
prefix = strings.Trim(prefix, "/")
if prefix == "" {
prefix = "devices"
}
name := "live.hang"
if quality == models.StreamQualityLow {
name = "live-low.hang"
}
return prefix + "/" + strings.Trim(deviceKey, "/") + "/" + name
}
// TimestampUs converts the capture presentation timestamp from milliseconds.
// CompositionTime must not be added: it is already represented in the PTS and
// is only used by muxers to derive DTS for streams containing B-frames.
func TimestampUs(presentationTimeMs int64) uint64 {
if presentationTimeMs < 0 {
return 0
}
return uint64(presentationTimeMs) * 1000
}
func hasAnnexBStartCode(payload []byte) bool {
return len(payload) >= 4 && payload[0] == 0 && payload[1] == 0 &&
((payload[2] == 0 && payload[3] == 1) || payload[2] == 1)
}

View File

@@ -0,0 +1,116 @@
package livemoq
import (
"bytes"
"testing"
"github.com/kerberos-io/agent/machinery/src/models"
)
func TestEnsureAnnexB(t *testing.T) {
tests := []struct {
name string
payload []byte
want []byte
}{
{
name: "missing start code",
payload: []byte{0x41, 0x01},
want: []byte{0x00, 0x00, 0x00, 0x01, 0x41, 0x01},
},
{
name: "four byte start code",
payload: []byte{0x00, 0x00, 0x00, 0x01, 0x65},
want: []byte{0x00, 0x00, 0x00, 0x01, 0x65},
},
{
name: "three byte start code",
payload: []byte{0x00, 0x00, 0x01, 0x41},
want: []byte{0x00, 0x00, 0x01, 0x41},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := EnsureAnnexB(test.payload); !bytes.Equal(got, test.want) {
t.Fatalf("EnsureAnnexB() = %x, want %x", got, test.want)
}
})
}
}
func TestNormalizeH264AccessUnit(t *testing.T) {
startCode := []byte{0x00, 0x00, 0x00, 0x01}
sps := []byte{0x67, 0x42, 0x00, 0x1f}
pps := []byte{0x68, 0xce, 0x06, 0xe2}
aud := []byte{0x09, 0xf0}
idr := []byte{0x65, 0x88, 0x84}
payload := make([]byte, 0)
for _, nalu := range [][]byte{sps, pps, aud, sps, pps, idr} {
payload = append(payload, startCode...)
payload = append(payload, nalu...)
}
got, err := NormalizeH264AccessUnit(payload)
if err != nil {
t.Fatal(err)
}
want := make([]byte, 0)
for _, nalu := range [][]byte{sps, pps, idr} {
want = append(want, startCode...)
want = append(want, nalu...)
}
if !bytes.Equal(got, want) {
t.Fatalf("NormalizeH264AccessUnit() = %x, want %x", got, want)
}
}
func TestNormalizeH264AccessUnitRemovesOnlyExactDuplicateIDRSlices(t *testing.T) {
startCode := []byte{0x00, 0x00, 0x00, 0x01}
idrSlice1 := []byte{0x65, 0x88, 0x84}
idrSlice2 := []byte{0x65, 0x44, 0x22}
payload := make([]byte, 0)
for _, nalu := range [][]byte{idrSlice1, idrSlice1, idrSlice2} {
payload = append(payload, startCode...)
payload = append(payload, nalu...)
}
got, stats, err := NormalizeH264AccessUnitWithStats(payload)
if err != nil {
t.Fatal(err)
}
want := make([]byte, 0)
for _, nalu := range [][]byte{idrSlice1, idrSlice2} {
want = append(want, startCode...)
want = append(want, nalu...)
}
if !bytes.Equal(got, want) {
t.Fatalf("NormalizeH264AccessUnitWithStats() = %x, want %x", got, want)
}
if stats.DuplicateIDRNALUs != 1 {
t.Fatalf("DuplicateIDRNALUs = %d, want 1", stats.DuplicateIDRNALUs)
}
}
func TestBroadcastPath(t *testing.T) {
if got := BroadcastPath("/devices/", "/camera-1/", models.StreamQualityHigh); got != "devices/camera-1/live.hang" {
t.Fatalf("BroadcastPath() high = %q", got)
}
if got := BroadcastPath("", "camera-1", models.StreamQualityHigh); got != "devices/camera-1/live.hang" {
t.Fatalf("BroadcastPath() default = %q", got)
}
if got := BroadcastPath("", "camera-1", models.StreamQualityLow); got != "devices/camera-1/live-low.hang" {
t.Fatalf("BroadcastPath() low = %q", got)
}
}
func TestTimestampUs(t *testing.T) {
if got := TimestampUs(1234); got != 1_234_000 {
t.Fatalf("TimestampUs() = %d, want 1234000", got)
}
if got := TimestampUs(-1); got != 0 {
t.Fatalf("TimestampUs() negative = %d, want 0", got)
}
}

View File

@@ -0,0 +1,41 @@
package livemoq
import (
"crypto/sha256"
"time"
)
type KeyframeDeduplicator struct {
hasPrevious bool
timestampMs int64
capturedAtMs int64
observedAt time.Time
digest [sha256.Size]byte
}
func (d *KeyframeDeduplicator) Reset() {
*d = KeyframeDeduplicator{}
}
// IsDuplicate reports exact repeated keyframe access units observed close
// together. Distinct IDR slices within one access unit remain untouched.
func (d *KeyframeDeduplicator) IsDuplicate(timestampMs int64, capturedAtMs int64, payload []byte, observedAt time.Time, window time.Duration) bool {
digest := sha256.Sum256(payload)
duplicate := d.hasPrevious && d.timestampMs == timestampMs && d.digest == digest
if duplicate {
if capturedAtMs > 0 && d.capturedAtMs > 0 {
gap := time.Duration(capturedAtMs-d.capturedAtMs) * time.Millisecond
duplicate = gap >= 0 && gap <= window
} else {
gap := observedAt.Sub(d.observedAt)
duplicate = gap >= 0 && gap <= window
}
}
d.hasPrevious = true
d.timestampMs = timestampMs
d.capturedAtMs = capturedAtMs
d.observedAt = observedAt
d.digest = digest
return duplicate
}

View File

@@ -0,0 +1,60 @@
package livemoq
import (
"testing"
"time"
)
func TestKeyframeDeduplicator(t *testing.T) {
now := time.UnixMilli(10_000)
window := 500 * time.Millisecond
payload := []byte{0x00, 0x00, 0x00, 0x01, 0x65, 0x88}
deduplicator := KeyframeDeduplicator{}
if deduplicator.IsDuplicate(1_000, 10_000, payload, now, window) {
t.Fatal("first keyframe reported as duplicate")
}
if !deduplicator.IsDuplicate(1_000, 10_020, payload, now.Add(20*time.Millisecond), window) {
t.Fatal("exact repeated keyframe was not reported as duplicate")
}
if deduplicator.IsDuplicate(2_000, 11_000, payload, now.Add(time.Second), window) {
t.Fatal("same payload with a new timestamp reported as duplicate")
}
if deduplicator.IsDuplicate(2_000, 11_020, append(payload, 0x01), now.Add(1020*time.Millisecond), window) {
t.Fatal("different payload with the same timestamp reported as duplicate")
}
}
func TestKeyframeDeduplicatorAllowsTimestampReuseOutsideWindow(t *testing.T) {
now := time.UnixMilli(10_000)
payload := []byte{0x00, 0x00, 0x00, 0x01, 0x65, 0x88}
deduplicator := KeyframeDeduplicator{}
deduplicator.IsDuplicate(1_000, 10_000, payload, now, 500*time.Millisecond)
if deduplicator.IsDuplicate(1_000, 20_000, payload, now.Add(10*time.Second), 500*time.Millisecond) {
t.Fatal("later keyframe after timestamp reset reported as duplicate")
}
}
func TestKeyframeDeduplicatorFallsBackToObservationTime(t *testing.T) {
now := time.UnixMilli(10_000)
payload := []byte{0x65, 0x88}
deduplicator := KeyframeDeduplicator{}
deduplicator.IsDuplicate(1_000, 0, payload, now, 500*time.Millisecond)
if !deduplicator.IsDuplicate(1_000, 0, payload, now.Add(20*time.Millisecond), 500*time.Millisecond) {
t.Fatal("duplicate without capture time was not reported")
}
}
func TestKeyframeDeduplicatorReset(t *testing.T) {
now := time.UnixMilli(10_000)
payload := []byte{0x65, 0x88}
deduplicator := KeyframeDeduplicator{}
deduplicator.IsDuplicate(1_000, 10_000, payload, now, 500*time.Millisecond)
deduplicator.Reset()
if deduplicator.IsDuplicate(1_000, 10_020, payload, now.Add(20*time.Millisecond), 500*time.Millisecond) {
t.Fatal("first keyframe after reset reported as duplicate")
}
}

View File

@@ -0,0 +1,55 @@
package livemoq
import "time"
type FrameGateEvent uint8
const (
FrameGateEventNone FrameGateEvent = iota
FrameGateEventStarted
FrameGateEventLagging
FrameGateEventRecovered
)
// FrameGate keeps publication on a decodable, recent GOP.
type FrameGate struct {
started bool
recovering bool
}
// Reset closes the gate so publication resumes on the next keyframe. It is used
// when the publisher stopped writing for a reason unrelated to the stream health
// (no subscribers), so the next viewer never receives a partial GOP.
func (g *FrameGate) Reset() {
g.started = false
g.recovering = false
}
// Allow rejects stale frames and waits for a fresh keyframe before reopening.
func (g *FrameGate) Allow(isKeyFrame bool, capturedAtMs int64, now time.Time, maxAge time.Duration) (bool, FrameGateEvent) {
if capturedAtMs > 0 && now.Sub(time.UnixMilli(capturedAtMs)) > maxAge {
event := FrameGateEventNone
if g.started {
if !g.recovering {
event = FrameGateEventLagging
}
g.started = false
g.recovering = true
}
return false, event
}
if !g.started {
if !isKeyFrame {
return false, FrameGateEventNone
}
g.started = true
if g.recovering {
g.recovering = false
return true, FrameGateEventRecovered
}
return true, FrameGateEventStarted
}
return true, FrameGateEventNone
}

View File

@@ -0,0 +1,49 @@
package livemoq
import (
"testing"
"time"
)
func TestFrameGateRecoversAtFreshKeyframe(t *testing.T) {
now := time.UnixMilli(10_000)
maxAge := 1500 * time.Millisecond
gate := FrameGate{}
tests := []struct {
name string
isKeyFrame bool
capturedAtMs int64
wantAllowed bool
wantEvent FrameGateEvent
}{
{name: "waits for initial keyframe", capturedAtMs: 10_000},
{name: "starts at initial keyframe", isKeyFrame: true, capturedAtMs: 10_000, wantAllowed: true, wantEvent: FrameGateEventStarted},
{name: "publishes fresh delta", capturedAtMs: 10_020, wantAllowed: true},
{name: "detects stale packet", capturedAtMs: 8_000, wantEvent: FrameGateEventLagging},
{name: "rejects fresh delta while recovering", capturedAtMs: 10_040},
{name: "rejects stale keyframe without duplicate event", isKeyFrame: true, capturedAtMs: 8_000},
{name: "recovers at fresh keyframe", isKeyFrame: true, capturedAtMs: 10_060, wantAllowed: true, wantEvent: FrameGateEventRecovered},
{name: "publishes delta after recovery", capturedAtMs: 10_080, wantAllowed: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
allowed, event := gate.Allow(test.isKeyFrame, test.capturedAtMs, now, maxAge)
if allowed != test.wantAllowed {
t.Fatalf("Allow() allowed = %t, want %t", allowed, test.wantAllowed)
}
if event != test.wantEvent {
t.Fatalf("Allow() event = %d, want %d", event, test.wantEvent)
}
})
}
}
func TestFrameGateAllowsMissingCaptureTime(t *testing.T) {
gate := FrameGate{}
allowed, event := gate.Allow(true, 0, time.Now(), time.Second)
if !allowed || event != FrameGateEventStarted {
t.Fatalf("Allow() = (%t, %d), want (true, %d)", allowed, event, FrameGateEventStarted)
}
}

View File

@@ -0,0 +1,8 @@
//go:build !moq
package cloud
import "github.com/kerberos-io/agent/machinery/src/models"
// StartLiveStreamMoQ is disabled in the standard Agent build.
func StartLiveStreamMoQ(_ *models.Configuration, _ *models.Communication, _ bool) {}

View File

@@ -0,0 +1,285 @@
//go:build moq
package cloud
import (
"context"
"fmt"
"os"
"strings"
"sync/atomic"
"time"
"github.com/kerberos-io/agent/machinery/src/cloud/livemoq"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/packets"
"github.com/moq-dev/moq-go/moq"
)
const (
defaultMoQRelayURL = "https://relay.uug.ai/anon"
minMoQRetryDelay = time.Second
maxMoQRetryDelay = 30 * time.Second
maxMoQLivePacketAge = 1500 * time.Millisecond
slowMoQWriteThreshold = 100 * time.Millisecond
moQWriteWarningInterval = 10 * time.Second
duplicateKeyframeWindow = 500 * time.Millisecond
)
type liveMoQConfig struct {
relayURL string
broadcast string
quality string
sourceLabel string
queue *packets.Queue
}
// label identifies the tier in log lines, since one Agent runs a publisher per
// quality tier.
func (c liveMoQConfig) label() string {
return c.quality + " (" + c.sourceLabel + " stream)"
}
// StartLiveStreamMoQ starts the publisher only in the dedicated MoQ build and
// only when explicitly enabled by the deployment.
//
// Unlike WebRTC and HLS — where a viewer negotiates a session with the Agent and
// can therefore ask for another quality on the fly — MoQ viewers subscribe to a
// relay and never talk to the Agent. The quality selector is honoured by
// publishing each tier as its OWN broadcast (see livemoq.BroadcastPath): the
// high tier from the camera's highest-resolution stream and the low tier from
// its sub stream, so switching quality in the frontend is a resubscribe to the
// other path. Each tier only uploads while it actually has subscribers, so the
// second broadcast is close to free when nobody watches it.
func StartLiveStreamMoQ(configuration *models.Configuration, communication *models.Communication, subStreamEnabled bool) {
if os.Getenv("AGENT_LIVE_MOQ_ENABLED") != "true" {
return
}
config := configuration.Config
if config.Offline == "true" || config.Capture.Liveview == "false" {
log.Log.Info("cloud.StartLiveStreamMoQ(): disabled by Agent live-view configuration")
return
}
if config.Key == "" {
log.Log.Warning("cloud.StartLiveStreamMoQ(): AGENT_KEY is required")
return
}
// Both tiers are published by default. AGENT_LIVE_MOQ_QUALITY pins the Agent
// to a single tier for deployments that must never publish the other one
// (viewers asking for the pinned-away tier then find no broadcast).
qualities := []string{models.StreamQualityHigh, models.StreamQualityLow}
switch strings.ToLower(strings.TrimSpace(os.Getenv("AGENT_LIVE_MOQ_QUALITY"))) {
case models.StreamQualityHigh:
qualities = []string{models.StreamQualityHigh}
case models.StreamQualityLow:
qualities = []string{models.StreamQualityLow}
}
relayURL := os.Getenv("AGENT_LIVE_MOQ_URL")
if relayURL == "" {
relayURL = defaultMoQRelayURL
}
broadcastPrefix := os.Getenv("AGENT_LIVE_MOQ_BROADCAST_PREFIX")
ctx := context.Background()
if communication.Context != nil {
ctx = *communication.Context
}
for _, quality := range qualities {
queue := communication.Queue
sourceLabel := "main"
if models.SelectSubStreamForQuality(config, quality, subStreamEnabled) && communication.SubQueue != nil {
queue = communication.SubQueue
sourceLabel = "sub"
}
if queue == nil {
log.Log.Warning("cloud.StartLiveStreamMoQ(): packet queue for the " + quality + " tier is unavailable")
continue
}
go runLiveStreamMoQ(ctx, liveMoQConfig{
relayURL: relayURL,
broadcast: livemoq.BroadcastPath(broadcastPrefix, config.Key, quality),
quality: quality,
sourceLabel: sourceLabel,
queue: queue,
})
}
}
func runLiveStreamMoQ(ctx context.Context, config liveMoQConfig) {
log.Log.Info(fmt.Sprintf(
"cloud.runLiveStreamMoQ(): publishing %s stream (quality=%s) to %s/%s",
config.sourceLabel, config.quality, strings.TrimRight(config.relayURL, "/"), config.broadcast,
))
retryDelay := minMoQRetryDelay
for ctx.Err() == nil {
connectedAt := time.Now()
err := publishLiveStreamMoQ(ctx, config)
if ctx.Err() != nil {
return
}
log.Log.Warning("cloud.runLiveStreamMoQ(): publisher stopped: " + err.Error())
if time.Since(connectedAt) >= time.Minute {
retryDelay = minMoQRetryDelay
}
timer := time.NewTimer(retryDelay)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
if retryDelay < maxMoQRetryDelay {
retryDelay *= 2
if retryDelay > maxMoQRetryDelay {
retryDelay = maxMoQRetryDelay
}
}
}
}
func publishLiveStreamMoQ(ctx context.Context, config liveMoQConfig) error {
client, err := moq.Dial(ctx, config.relayURL)
if err != nil {
return fmt.Errorf("connect to relay: %w", err)
}
defer client.Close()
broadcast, err := client.CreateBroadcast(config.broadcast)
if err != nil {
return fmt.Errorf("create broadcast: %w", err)
}
defer broadcast.Finish()
stream, err := broadcast.PublishMedia("avc3", nil)
if err != nil {
return fmt.Errorf("create H.264 media stream: %w", err)
}
defer stream.Finish()
// Only upload while this tier is actually being watched. `publishing` starts
// true so the track becomes discoverable on the relay even before the first
// subscriber ever arrives; from the moment a viewer has attached once, the
// subscriber watcher takes over and idles the tier again when everybody left.
watchCtx, cancelWatch := context.WithCancel(ctx)
defer cancelWatch()
publishing := &atomic.Bool{}
publishing.Store(true)
go watchLiveStreamMoQSubscribers(watchCtx, stream, publishing, config)
cursor := config.queue.Latest()
gate := livemoq.FrameGate{}
deduplicator := livemoq.KeyframeDeduplicator{}
var lastSlowWriteWarning time.Time
var lastDuplicateKeyframeWarning time.Time
idle := false
for {
packet, err := cursor.ReadPacket()
if err != nil {
return fmt.Errorf("read packet: %w", err)
}
if !publishing.Load() {
// Keep draining the cursor so we stay at the live edge, but publish
// nothing. The gate is closed so the next viewer resumes on a keyframe.
if !idle {
gate.Reset()
deduplicator.Reset()
idle = true
}
continue
}
idle = false
if !packet.IsVideo || len(packet.Data) == 0 || !strings.EqualFold(packet.Codec, "H264") {
continue
}
allowed, event := gate.Allow(packet.IsKeyFrame, packet.CurrentTime, time.Now(), maxMoQLivePacketAge)
switch event {
case livemoq.FrameGateEventStarted:
log.Log.Info("cloud.publishLiveStreamMoQ(): first H.264 keyframe received; " + config.label() + " broadcast is live")
case livemoq.FrameGateEventLagging:
log.Log.Warning("cloud.publishLiveStreamMoQ(): " + config.label() + " stream is lagging; dropping packets until a recent keyframe")
case livemoq.FrameGateEventRecovered:
log.Log.Info("cloud.publishLiveStreamMoQ(): caught up with the " + config.label() + " live stream at a recent keyframe")
}
if !allowed {
continue
}
payload, normalizationStats, err := livemoq.NormalizeH264AccessUnitWithStats(packet.Data)
if err != nil {
return fmt.Errorf("normalize H.264 access unit: %w", err)
}
if normalizationStats.DuplicateIDRNALUs > 0 && time.Since(lastDuplicateKeyframeWarning) >= moQWriteWarningInterval {
log.Log.Warning(fmt.Sprintf(
"cloud.publishLiveStreamMoQ(): %s removed %d duplicate IDR NALU(s) from H.264 keyframe (timestamp_ms=%d)",
config.label(), normalizationStats.DuplicateIDRNALUs, packet.Time,
))
lastDuplicateKeyframeWarning = time.Now()
}
if packet.IsKeyFrame && deduplicator.IsDuplicate(packet.Time, packet.CurrentTime, payload, time.Now(), duplicateKeyframeWindow) {
if time.Since(lastDuplicateKeyframeWarning) >= moQWriteWarningInterval {
log.Log.Warning(fmt.Sprintf(
"cloud.publishLiveStreamMoQ(): %s dropping duplicate H.264 keyframe (timestamp_ms=%d)",
config.label(), packet.Time,
))
lastDuplicateKeyframeWarning = time.Now()
}
continue
}
frame := moq.Frame{
Payload: payload,
TimestampUs: livemoq.TimestampUs(packet.Time),
}
writeStartedAt := time.Now()
if err := stream.WriteFrame(frame); err != nil {
return fmt.Errorf("write H.264 access unit: %w", err)
}
writeDuration := time.Since(writeStartedAt)
if writeDuration >= slowMoQWriteThreshold && time.Since(lastSlowWriteWarning) >= moQWriteWarningInterval {
packetAge := time.Duration(0)
if packet.CurrentTime > 0 {
packetAge = time.Since(time.UnixMilli(packet.CurrentTime))
if packetAge < 0 {
packetAge = 0
}
}
log.Log.Warning(fmt.Sprintf(
"cloud.publishLiveStreamMoQ(): %s WriteFrame blocked for %s (packet_age=%s keyframe=%t)",
config.label(), writeDuration.Round(time.Millisecond), packetAge.Round(time.Millisecond), packet.IsKeyFrame,
))
lastSlowWriteWarning = time.Now()
}
}
}
// watchLiveStreamMoQSubscribers flips the publisher between uploading and idling
// as viewers subscribe to and leave this tier's broadcast. Used and Unused both
// block, so they are followed from their own goroutine.
//
// It deliberately never turns publishing off before the first subscriber has
// been observed: the relay catalog is only complete once media has flowed, so
// going idle up front could keep the tier undiscoverable. On any error it fails
// open (keeps publishing) — a stalled watcher must never take the live view down.
func watchLiveStreamMoQSubscribers(ctx context.Context, stream *moq.MediaProducer, publishing *atomic.Bool, config liveMoQConfig) {
for ctx.Err() == nil {
if err := stream.Used(ctx); err != nil {
publishing.Store(true)
return
}
if publishing.CompareAndSwap(false, true) {
log.Log.Info("cloud.watchLiveStreamMoQSubscribers(): viewer subscribed, resuming the " + config.label() + " broadcast")
}
if err := stream.Unused(ctx); err != nil {
publishing.Store(true)
return
}
publishing.Store(false)
log.Log.Info("cloud.watchLiveStreamMoQSubscribers(): no viewers left, idling the " + config.label() + " broadcast")
}
}

View File

@@ -0,0 +1,88 @@
package cloud
import (
"encoding/json"
"math"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/kerberos-io/agent/machinery/src/models"
)
const recordingFPSHeader = "X-Kerberos-Storage-Fps"
const recordingDurationHeader = "X-Kerberos-Storage-Duration"
const recordingTimestampHeader = "X-Kerberos-Storage-Timestamp"
// queuedRecordingFPS reads the FPS snapshot written into the upload marker
// when the recording was finalized. Historical empty markers intentionally
// return no value so receivers can retain their existing MP4-derived fallback.
func queuedRecordingFPS(fileName string) string {
value, ok := readRecordingUploadMetadata(fileName)
if !ok {
return ""
}
marker := strings.TrimSpace(string(value))
if strings.HasPrefix(marker, "{") {
metadata, ok := decodeRecordingUploadMetadata(value)
if !ok || metadata.FPS <= 0 || metadata.FPS > 240 || math.IsInf(metadata.FPS, 0) || math.IsNaN(metadata.FPS) {
return ""
}
return strconv.FormatFloat(metadata.FPS, 'f', -1, 64)
}
// Compatibility with markers created before upload metadata used JSON.
fps := marker
parsed, err := strconv.ParseFloat(fps, 64)
if err != nil || parsed <= 0 || parsed > 240 || math.IsInf(parsed, 0) || math.IsNaN(parsed) {
return ""
}
return fps
}
func queuedRecordingMetadata(fileName string) (models.RecordingUploadMetadata, bool) {
value, ok := readRecordingUploadMetadata(fileName)
if !ok || !strings.HasPrefix(strings.TrimSpace(string(value)), "{") {
return models.RecordingUploadMetadata{}, false
}
return decodeRecordingUploadMetadata(value)
}
func decodeRecordingUploadMetadata(value []byte) (models.RecordingUploadMetadata, bool) {
var metadata models.RecordingUploadMetadata
if err := json.Unmarshal(value, &metadata); err != nil {
return models.RecordingUploadMetadata{}, false
}
return metadata, true
}
func readRecordingUploadMetadata(fileName string) ([]byte, bool) {
markerNames := []string{
models.RecordingUploadMetadataFileName(fileName),
filepath.Base(fileName),
}
for _, markerName := range markerNames {
value, err := os.ReadFile(filepath.Join("data", "cloud", markerName))
if err == nil {
return value, true
}
}
return nil, false
}
func setQueuedRecordingMetadataHeaders(header http.Header, fileName string) {
if fps := queuedRecordingFPS(fileName); fps != "" {
header.Set(recordingFPSHeader, fps)
}
if metadata, ok := queuedRecordingMetadata(fileName); ok {
if metadata.Duration > 0 {
header.Set(recordingDurationHeader, strconv.FormatUint(metadata.Duration, 10))
}
if metadata.Timestamp > 0 {
header.Set(recordingTimestampHeader, strconv.FormatInt(metadata.Timestamp, 10))
}
}
}

View File

@@ -154,10 +154,29 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
const maxAttempts = 4
restartedAfterComplete := false
// lastStatus holds the HTTP status code of the most recent tus request. A
// value of 0 means the request failed at the transport level (no HTTP
// response at all, e.g. the vault was unreachable or the connection dropped
// because the internet went down). It lets the final "gave up" return report
// whether the vault actually answered, so the caller only advances its
// retry/back-off policy on a definitive response and transient network errors
// never consume the retry budget (matching the legacy single-POST behaviour).
lastStatus := 0
// highWaterOffset is the furthest server-acknowledged offset observed across
// all attempts (via HEAD or PATCH). It lets the retry budget be refreshed only
// on GENUINE net forward progress. Without it, a server that keeps resetting the
// offset — e.g. a persistent 409 ERR_MISMATCHED_OFFSET where HEAD reports 0 again
// while the first chunk still "succeeds" — would refresh the budget every attempt
// and loop forever, wedging the upload worker on one recording and saturating the
// uplink.
highWaterOffset := int64(0)
for attempt := 0; attempt < maxAttempts; attempt++ {
// (1) Ensure we have an active upload URL, creating one if needed.
if uploadURL == "" {
created, status, cerr := tusCreate(client, baseURL, size, metadata, setHeaders, fileName)
lastStatus = status
if cerr != nil {
if status == http.StatusNotFound || status == http.StatusMethodNotAllowed || status == http.StatusNotImplemented {
// The vault does not implement tus; let the caller fall back.
@@ -173,6 +192,7 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
// (2) Query the current server-side offset.
offset, status, herr := tusHead(client, uploadURL, setHeaders)
lastStatus = status
if herr != nil {
if status == http.StatusNotFound || status == http.StatusGone {
// The upload expired/was removed server-side; start over.
@@ -185,6 +205,15 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
continue
}
// The furthest offset any previous attempt reached. If this attempt pushes
// past it (via HEAD showing server-side progress or a successful PATCH) we made
// genuine net progress and may refresh the retry budget; if not, a repeated
// failure at the same spot must count against maxAttempts.
startHighWater := highWaterOffset
if offset > highWaterOffset {
highWaterOffset = offset
}
// (3) All bytes are present but the upload was not finalized (e.g. the
// completion hook failed). A completed tus upload cannot be re-finalized
// with another PATCH, so delete it and re-upload to force a clean finalize.
@@ -205,7 +234,6 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
// checkpointing the offset after each one so an interruption resumes from the
// last completed chunk instead of re-uploading everything.
chunkSize := tusChunkSize()
progressed := false
patchFailed := false
var lastBody string
loggedProgressBucket := tusProgressBucket(offset, size)
@@ -220,6 +248,7 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
patchLen = chunkSize
}
newOffset, status, respBody, perr := tusPatch(client, uploadURL, offset, patchLen, file, setHeaders)
lastStatus = status
if perr != nil {
if status >= 400 {
// Definitive rejection (e.g. provider push failed during finalize).
@@ -232,10 +261,10 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
patchFailed = true
break
}
if newOffset > offset {
progressed = true
}
offset = newOffset
if offset > highWaterOffset {
highWaterOffset = offset
}
lastBody = respBody
logTusUploadProgress(label, offset, size, &loggedProgressBucket)
if offset < size {
@@ -244,10 +273,13 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
}
}
if patchFailed {
if progressed {
// Forward progress refreshes the retry budget: maxAttempts bounds the
// number of consecutive failures, not the number of chunks needed for
// a large recording.
if highWaterOffset > startHighWater {
// Genuine net progress (we advanced past the furthest point any previous
// attempt reached) refreshes the retry budget: maxAttempts bounds the
// number of consecutive *non-progressing* failures, not the number of
// chunks needed for a large recording. A server that keeps rejecting the
// same offset (no net progress, e.g. a persistent ERR_MISMATCHED_OFFSET)
// therefore gives up after maxAttempts instead of retrying forever.
attempt = -1
}
continue
@@ -258,7 +290,13 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
return true, true, true, lastBody, nil
}
return false, true, true, "resumable upload did not complete after retries", errors.New(label + ": resumable upload did not complete after retries")
// Every attempt failed. Only report responded=true when the vault actually
// answered on the last attempt (lastStatus > 0). If every attempt failed at
// the transport level (lastStatus == 0, e.g. the internet was disconnected),
// report responded=false so the caller keeps the recording queued and retries
// later instead of consuming its retry budget and entering the long back-off
// timeout.
return false, lastStatus > 0, true, "resumable upload did not complete after retries", errors.New(label + ": resumable upload did not complete after retries")
}
// uploadVaultResumable uploads a recording directly to a Kerberos Vault using
@@ -267,14 +305,17 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
// is additionally carried in the tus Upload-Metadata.
func uploadVaultResumable(vault models.KStorage, publicKey, deviceKey, fileName, label, slot string) (bool, bool, bool, string, error) {
baseURL := strings.TrimRight(vault.URI, "/") + tusUploadPath
metadata := encodeTusMetadata(map[string]string{
metadataValues := map[string]string{
"filename": fileName,
"device": deviceKey,
"directory": vault.Directory,
"provider": vault.Provider,
"capture": "IPCamera",
"cloudkey": publicKey,
})
"fps": queuedRecordingFPS(fileName),
}
addRecordingTusMetadata(metadataValues, fileName)
metadata := encodeTusMetadata(metadataValues)
setHeaders := func(h http.Header, fn string) {
setVaultTusHeaders(h, vault, publicKey, deviceKey, fn)
}
@@ -288,17 +329,33 @@ func uploadVaultResumable(vault models.KStorage, publicKey, deviceKey, fileName,
// intentionally omitted from the metadata here.
func uploadHubResumable(config *models.Config, fileName, label, slot string) (bool, bool, bool, string, error) {
baseURL := strings.TrimRight(config.HubURI, "/") + tusUploadPath
metadata := encodeTusMetadata(map[string]string{
metadataValues := map[string]string{
"filename": fileName,
"device": config.Key,
"capture": "IPCamera",
})
"fps": queuedRecordingFPS(fileName),
}
addRecordingTusMetadata(metadataValues, fileName)
metadata := encodeTusMetadata(metadataValues)
setHeaders := func(h http.Header, fn string) {
setHubTusHeaders(h, config, fn)
}
return runTusUpload(baseURL, metadata, fileName, label, slot, setHeaders)
}
func addRecordingTusMetadata(values map[string]string, fileName string) {
metadata, ok := queuedRecordingMetadata(fileName)
if !ok {
return
}
if metadata.Duration > 0 {
values["duration"] = strconv.FormatUint(metadata.Duration, 10)
}
if metadata.Timestamp > 0 {
values["timestamp"] = strconv.FormatInt(metadata.Timestamp, 10)
}
}
// tusCreate performs the tus "creation" request (POST). On success it returns
// the resolved upload URL the agent should use for subsequent HEAD/PATCH calls.
func tusCreate(client *http.Client, baseURL string, size int64, metadata string, setHeaders tusHeaderFunc, fileName string) (string, int, error) {
@@ -535,10 +592,15 @@ func removeTusResumeState(path string) {
_ = os.Remove(path)
}
// tusBackoffBaseDelay is the base delay used by tusBackoff for the exponential
// back-off between resume attempts. It is a package variable (rather than a
// constant) so tests can shrink it to keep them fast.
var tusBackoffBaseDelay = 500 * time.Millisecond
// tusBackoff sleeps for an exponentially increasing duration (capped) between
// resume attempts to avoid hammering a temporarily unavailable vault.
func tusBackoff(attempt int) {
delay := time.Duration(500*(1<<uint(attempt))) * time.Millisecond
delay := tusBackoffBaseDelay * time.Duration(1<<uint(attempt))
if delay > 3*time.Second {
delay = 3 * time.Second
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
@@ -13,6 +14,7 @@ import (
"strings"
"sync"
"testing"
"time"
"github.com/kerberos-io/agent/machinery/src/models"
)
@@ -47,6 +49,14 @@ type fakeTus struct {
// after storing the bytes, simulating a failed completion hook.
failFinalize int
// loseProgress simulates a vault that never durably retains the in-progress
// upload: every PATCH is acknowledged (the response advertises the advanced
// offset) but the stored offset is immediately reset to 0. HEAD therefore
// keeps reporting 0 and the next chunk — sent at the advanced offset — is
// rejected with 409, reproducing the cross-replica ERR_MISMATCHED_OFFSET
// loop that previously wedged the agent's upload worker forever.
loseProgress bool
// requests records the headers of every received request (in order) so
// tests can assert which auth/routing headers the client sent per method.
requests []recordedRequest
@@ -153,6 +163,31 @@ func (s *fakeTus) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
return
}
if s.loseProgress {
reqOffset, _ := strconv.ParseInt(r.Header.Get("Upload-Offset"), 10, 64)
s.mu.Lock()
cur := u.offset
if reqOffset != cur {
// The offset the client resumes from no longer matches what this
// "replica" retained, so reject like a vault returning
// ERR_MISMATCHED_OFFSET.
s.mu.Unlock()
w.Header().Set("Upload-Offset", strconv.FormatInt(cur, 10))
w.WriteHeader(http.StatusConflict)
return
}
n, _ := io.Copy(io.Discard, r.Body)
s.lastPatchBytes = n
s.patchSizes = append(s.patchSizes, n)
// Advertise progress to the client, then immediately forget it so the
// next chunk (sent at the advanced offset) mismatches again.
reported := cur + n
u.offset = 0
s.mu.Unlock()
w.Header().Set("Upload-Offset", strconv.FormatInt(reported, 10))
w.WriteHeader(http.StatusNoContent)
return
}
n, _ := io.Copy(io.Discard, r.Body)
s.mu.Lock()
u.offset += n
@@ -208,6 +243,17 @@ func withRecording(t *testing.T, fileName string, payload []byte) {
}
}
func withQueuedRecordingFPS(t *testing.T, fileName, fps string) {
t.Helper()
if err := os.MkdirAll("data/cloud", 0o755); err != nil {
t.Fatalf("mkdir cloud queue: %v", err)
}
markerName := models.RecordingUploadMetadataFileName(fileName)
if err := os.WriteFile(filepath.Join("data/cloud", markerName), []byte(fps), 0o644); err != nil {
t.Fatalf("write cloud queue marker: %v", err)
}
}
func testVault(uri string) models.KStorage {
return models.KStorage{
URI: uri,
@@ -226,6 +272,7 @@ func TestUploadVaultResumable_HappyPath(t *testing.T) {
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
payload := bytes.Repeat([]byte("x"), 4096)
withRecording(t, fileName, payload)
withQueuedRecordingFPS(t, fileName, `{"filename":"recording.mp4","device_key":"device-key","timestamp":1785934709414,"duration":20452,"fps":29.97}`)
uploaded, responded, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
if err != nil {
@@ -240,6 +287,103 @@ func TestUploadVaultResumable_HappyPath(t *testing.T) {
if _, err := os.Stat(tusSidecarPath(fileName, "primary")); !os.IsNotExist(err) {
t.Fatalf("expected sidecar to be removed after success, stat err = %v", err)
}
posts := srv.requestsForMethod(http.MethodPost)
metadata := decodeTusMetadata(posts[0].header.Get("Upload-Metadata"))
if got := metadata["fps"]; got != "29.97" {
t.Fatalf("POST metadata fps = %q, want %q", got, "29.97")
}
if got := metadata["duration"]; got != "20452" {
t.Fatalf("POST metadata duration = %q, want %q", got, "20452")
}
if got := metadata["timestamp"]; got != "1785934709414" {
t.Fatalf("POST metadata timestamp = %q, want %q", got, "1785934709414")
}
}
func TestQueuedRecordingFPSValidation(t *testing.T) {
for _, test := range []struct {
name string
fps string
want string
}{
{name: "json", fps: `{"fps":29}`, want: "29"},
{name: "json fractional", fps: `{"fps":17.35}`, want: "17.35"},
{name: "json with future field", fps: `{"fps":29,"codec":"h264"}`, want: "29"},
{name: "json without fps", fps: `{}`},
{name: "json invalid fps", fps: `{"fps":241}`},
{name: "legacy fractional", fps: "29.97", want: "29.97"},
{name: "legacy trimmed", fps: " 25 \n", want: "25"},
{name: "empty"},
{name: "invalid", fps: "invalid"},
{name: "zero", fps: "0"},
{name: "negative", fps: "-1"},
{name: "nan", fps: "NaN"},
{name: "infinite", fps: "+Inf"},
{name: "unreasonable", fps: "241"},
} {
t.Run(test.name, func(t *testing.T) {
fileName := "recording.mp4"
withRecording(t, fileName, []byte("recording"))
withQueuedRecordingFPS(t, fileName, test.fps)
if got := queuedRecordingFPS(fileName); got != test.want {
t.Fatalf("queuedRecordingFPS() = %q, want %q", got, test.want)
}
header := make(http.Header)
setQueuedRecordingMetadataHeaders(header, fileName)
if got := header.Get(recordingFPSHeader); got != test.want {
t.Fatalf("legacy FPS header = %q, want %q", got, test.want)
}
})
}
}
func TestQueuedRecordingFPSAllowsMissingHistoricalMarker(t *testing.T) {
fileName := "recording.mp4"
withRecording(t, fileName, []byte("recording"))
if got := queuedRecordingFPS(fileName); got != "" {
t.Fatalf("queuedRecordingFPS() = %q, want empty for missing marker", got)
}
header := make(http.Header)
setQueuedRecordingMetadataHeaders(header, fileName)
if got := header.Get(recordingFPSHeader); got != "" {
t.Fatalf("legacy FPS header = %q, want empty for missing marker", got)
}
}
func TestQueuedRecordingMetadataHeaders(t *testing.T) {
fileName := "recording.mp4"
withRecording(t, fileName, []byte("recording"))
withQueuedRecordingFPS(t, fileName, `{"filename":"recording.mp4","device_key":"device-key","timestamp":1785934709414,"duration":20452,"fps":25}`)
header := make(http.Header)
setQueuedRecordingMetadataHeaders(header, fileName)
if got := header.Get(recordingFPSHeader); got != "25" {
t.Fatalf("FPS header = %q", got)
}
if got := header.Get(recordingDurationHeader); got != "20452" {
t.Fatalf("duration header = %q", got)
}
if got := header.Get(recordingTimestampHeader); got != "1785934709414" {
t.Fatalf("timestamp header = %q", got)
}
}
func TestQueuedRecordingFPSAllowsLegacyMarkerFileName(t *testing.T) {
fileName := "recording.mp4"
withRecording(t, fileName, []byte("recording"))
if err := os.MkdirAll("data/cloud", 0o755); err != nil {
t.Fatalf("mkdir cloud queue: %v", err)
}
if err := os.WriteFile(filepath.Join("data/cloud", fileName), []byte("25"), 0o644); err != nil {
t.Fatalf("write legacy cloud queue marker: %v", err)
}
if got := queuedRecordingFPS(fileName); got != "25" {
t.Fatalf("queuedRecordingFPS() = %q, want legacy marker FPS", got)
}
}
func TestUploadVaultResumable_Chunked(t *testing.T) {
@@ -350,6 +494,102 @@ func TestUploadVaultResumable_Unsupported(t *testing.T) {
}
}
// TestUploadVaultResumable_NetworkErrorKeepsRetryBudget verifies that when the
// vault is unreachable (mimicking the internet being disconnected) the resumable
// upload reports responded=false. That is what stops the caller
// (UploadKerberosVault) from consuming its retry budget and entering the long
// back-off timeout on a transient network outage, so the recording keeps being
// retried until connectivity returns.
func TestUploadVaultResumable_NetworkErrorKeepsRetryBudget(t *testing.T) {
// Bind then immediately release a loopback port so every connection to it is
// refused, producing a transport-level error (no HTTP response).
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := ln.Addr().String()
if cerr := ln.Close(); cerr != nil {
t.Fatalf("close listener: %v", cerr)
}
// Keep the between-attempt back-off tiny so the test stays fast.
oldDelay := tusBackoffBaseDelay
tusBackoffBaseDelay = time.Millisecond
defer func() { tusBackoffBaseDelay = oldDelay }()
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
withRecording(t, fileName, bytes.Repeat([]byte("n"), 2048))
uploaded, responded, supported, _, err := uploadVaultResumable(testVault("http://"+addr), "pk", "dev", fileName, "test", "primary")
if uploaded {
t.Fatal("expected uploaded=false when the vault is unreachable")
}
if !supported {
t.Fatal("a transport error is not a missing tus endpoint; expected supported=true")
}
if responded {
t.Fatal("expected responded=false for a pure network error so the retry budget is preserved")
}
if err == nil {
t.Fatal("expected an error when the vault is unreachable")
}
}
func TestUploadVaultResumable_MismatchedOffsetGivesUp(t *testing.T) {
// A vault that never durably retains the in-progress upload (offset resets to
// 0 between chunks) makes every resume "progress" by one chunk and then fail
// the next chunk with 409. Before the high-water gating fix this refreshed the
// retry budget every attempt and looped forever, wedging the upload worker and
// saturating the uplink (which starved heartbeats and reported the camera
// offline). The loop must now be bounded: give up after a fixed number of
// non-progressing attempts and report responded=true so the caller re-queues.
srv := newFakeTus()
srv.loseProgress = true
ts := httptest.NewServer(srv)
defer ts.Close()
// Keep the between-attempt back-off tiny so the test stays fast.
oldDelay := tusBackoffBaseDelay
tusBackoffBaseDelay = time.Millisecond
defer func() { tusBackoffBaseDelay = oldDelay }()
// Force multiple chunks so there is always a second chunk to be rejected.
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", "4096")
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
withRecording(t, fileName, bytes.Repeat([]byte("m"), 12288))
done := make(chan struct{})
var uploaded, responded bool
var upErr error
go func() {
uploaded, responded, _, _, upErr = uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
close(done)
}()
select {
case <-done:
case <-time.After(30 * time.Second):
t.Fatal("resumable upload did not terminate: the retry loop is unbounded on a persistent mismatched offset")
}
if uploaded {
t.Fatal("expected uploaded=false when the vault never retains the offset")
}
if !responded {
t.Fatal("expected responded=true (the vault answered) so the caller re-queues the recording")
}
if upErr == nil {
t.Fatal("expected an error when the upload cannot complete")
}
// The bounded retry budget must cap the number of PATCH requests. Two PATCHes
// per attempt across a handful of attempts stays comfortably below this.
if count, _ := srv.patchCounts(); count > 50 {
t.Fatalf("expected a bounded number of PATCH requests, got %d (retry loop not bounded)", count)
}
}
func TestUploadVaultResumable_FinalizeRetry(t *testing.T) {
srv := newFakeTus()
srv.failFinalize = 1
@@ -448,6 +688,7 @@ func TestUploadHubResumable_HappyPath(t *testing.T) {
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
payload := bytes.Repeat([]byte("h"), 4096)
withRecording(t, fileName, payload)
withQueuedRecordingFPS(t, fileName, "29.97")
uploaded, _, supported, _, err := uploadHubResumable(testHubConfig(ts.URL), fileName, "test", "hub")
if err != nil {
@@ -518,6 +759,9 @@ func TestUploadHubResumable_HappyPath(t *testing.T) {
if meta["capture"] != "IPCamera" {
t.Errorf("hub metadata capture = %q, want %q", meta["capture"], "IPCamera")
}
if meta["fps"] != "29.97" {
t.Errorf("hub metadata fps = %q, want %q", meta["fps"], "29.97")
}
}
func TestUploadHubResumable_Unsupported(t *testing.T) {

View File

@@ -2,7 +2,9 @@ package components
import (
"bufio"
"context"
"fmt"
"math/rand"
"os"
"time"
@@ -15,6 +17,62 @@ import (
"github.com/zaf/g711"
)
const (
backchannelSampleRate = 8000
backchannelTalkspurtGap = 500 * time.Millisecond
backchannelReconnectInitial = time.Second
backchannelReconnectMax = 30 * time.Second
)
type backchannelClient interface {
ConnectBackChannel(ctx context.Context, otelContext context.Context) error
StartBackChannel(ctx context.Context, otelContext context.Context) error
WritePacket(pkt packets.Packet) error
Close(otelContext context.Context) error
}
type backchannelPacketizer struct {
sequenceNumber uint16
timestamp uint32
ssrc uint32
lastPacketAt time.Time
}
func newBackchannelPacketizer() backchannelPacketizer {
return backchannelPacketizer{
sequenceNumber: uint16(rand.Uint32()),
timestamp: rand.Uint32(),
ssrc: rand.Uint32(),
}
}
func (p *backchannelPacketizer) packet(audio models.AudioDataPartial, now time.Time) packets.Packet {
bufferUlaw := make([]byte, len(audio.Data))
for index, sample := range audio.Data {
bufferUlaw[index] = g711.EncodeUlawFrame(sample)
}
pkt := packets.Packet{
Packet: &rtp.Packet{
Header: rtp.Header{
Version: 2,
Marker: p.lastPacketAt.IsZero() || now.Sub(p.lastPacketAt) >= backchannelTalkspurtGap,
PayloadType: 0,
SequenceNumber: p.sequenceNumber,
Timestamp: p.timestamp,
SSRC: p.ssrc,
},
Payload: bufferUlaw,
},
}
p.timestamp += uint32(len(bufferUlaw))
p.sequenceNumber++
p.lastPacketAt = now
return pkt
}
func GetBackChannelAudioCodec(streams []av.CodecData, communication *models.Communication) av.AudioCodecData {
for _, stream := range streams {
if stream.Type().IsAudio() {
@@ -31,41 +89,115 @@ func GetBackChannelAudioCodec(streams []av.CodecData, communication *models.Comm
}
func WriteAudioToBackchannel(communication *models.Communication, rtspClient capture.RTSPClient) {
log.Log.Info("Audio.WriteAudioToBackchannel(): writing to backchannel audio codec")
length := uint32(0)
sequenceNumber := uint16(0)
for audio := range communication.HandleAudio {
// Encode PCM to MULAW
var bufferUlaw []byte
for _, v := range audio.Data {
b := g711.EncodeUlawFrame(v)
bufferUlaw = append(bufferUlaw, b)
}
pkt := packets.Packet{
Packet: &rtp.Packet{
Header: rtp.Header{
Version: 2,
Marker: true, // should be true
PayloadType: 0, //packet.PayloadType, // will be owerwriten
SequenceNumber: sequenceNumber,
Timestamp: uint32(length),
SSRC: 1293847657,
},
Payload: bufferUlaw,
},
}
err := rtspClient.WritePacket(pkt)
if err != nil {
log.Log.Error("Audio.WriteAudioToBackchannel(): error writing packet to backchannel")
}
length = (length + uint32(len(bufferUlaw))) % 65536
sequenceNumber = (sequenceNumber + 1) % 65535
time.Sleep(128 * time.Millisecond)
ctx := context.Background()
if communication.Context != nil {
ctx = *communication.Context
}
log.Log.Info("Audio.WriteAudioToBackchannel(): finished")
writeAudioToBackchannel(ctx, ctx, communication.HandleAudio, rtspClient)
}
func writeAudioToBackchannel(ctx context.Context, otelContext context.Context, audioChannel <-chan models.AudioDataPartial, rtspClient backchannelClient) {
log.Log.Info("Audio.WriteAudioToBackchannel(): writing to backchannel audio codec")
if err := rtspClient.StartBackChannel(ctx, otelContext); err != nil {
log.Log.Error("Audio.WriteAudioToBackchannel(): error starting backchannel: " + err.Error())
if !reconnectBackchannel(ctx, otelContext, rtspClient) {
log.Log.Info("Audio.WriteAudioToBackchannel(): stopped while reconnecting")
return
}
}
packetizer := newBackchannelPacketizer()
for {
select {
case <-ctx.Done():
log.Log.Info("Audio.WriteAudioToBackchannel(): stopped")
return
case audio, ok := <-audioChannel:
if !ok {
log.Log.Info("Audio.WriteAudioToBackchannel(): finished")
return
}
audio = latestBackchannelAudio(audio, audioChannel)
if len(audio.Data) == 0 {
continue
}
pkt := packetizer.packet(audio, time.Now())
if err := rtspClient.WritePacket(pkt); err != nil {
log.Log.Error("Audio.WriteAudioToBackchannel(): error writing packet to backchannel: " + err.Error())
if !reconnectBackchannel(ctx, otelContext, rtspClient) {
log.Log.Info("Audio.WriteAudioToBackchannel(): stopped while reconnecting")
return
}
packetizer = newBackchannelPacketizer()
continue
}
if !waitForBackchannel(ctx, time.Duration(len(audio.Data))*time.Second/backchannelSampleRate) {
log.Log.Info("Audio.WriteAudioToBackchannel(): stopped")
return
}
}
}
}
func latestBackchannelAudio(audio models.AudioDataPartial, audioChannel <-chan models.AudioDataPartial) models.AudioDataPartial {
for {
select {
case next, ok := <-audioChannel:
if !ok {
return audio
}
audio = next
default:
return audio
}
}
}
func reconnectBackchannel(ctx context.Context, otelContext context.Context, rtspClient backchannelClient) bool {
backoff := backchannelReconnectInitial
for {
if err := rtspClient.Close(otelContext); err != nil {
log.Log.Error("Audio.WriteAudioToBackchannel(): error closing failed backchannel: " + err.Error())
}
if ctx.Err() != nil {
return false
}
err := rtspClient.ConnectBackChannel(ctx, otelContext)
if err == nil {
err = rtspClient.StartBackChannel(ctx, otelContext)
}
if err == nil {
log.Log.Info("Audio.WriteAudioToBackchannel(): reconnected backchannel")
return true
}
log.Log.Error("Audio.WriteAudioToBackchannel(): error reconnecting backchannel: " + err.Error())
if !waitForBackchannel(ctx, backoff) {
return false
}
backoff *= 2
if backoff > backchannelReconnectMax {
backoff = backchannelReconnectMax
}
}
}
func waitForBackchannel(ctx context.Context, duration time.Duration) bool {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
func WriteFileToBackChannel(infile av.DemuxCloser) {

View File

@@ -0,0 +1,209 @@
package components
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/packets"
)
type fakeBackchannelClient struct {
mutex sync.Mutex
startErrors []error
connectError error
writeErrors []error
startCalls int
connectCalls int
closeCalls int
writeCalls int
connectAttempt chan struct{}
successfulWrite chan packets.Packet
}
func (f *fakeBackchannelClient) ConnectBackChannel(context.Context, context.Context) error {
f.mutex.Lock()
f.connectCalls++
err := f.connectError
f.mutex.Unlock()
select {
case f.connectAttempt <- struct{}{}:
default:
}
return err
}
func (f *fakeBackchannelClient) StartBackChannel(context.Context, context.Context) error {
f.mutex.Lock()
defer f.mutex.Unlock()
f.startCalls++
if len(f.startErrors) == 0 {
return nil
}
err := f.startErrors[0]
f.startErrors = f.startErrors[1:]
return err
}
func (f *fakeBackchannelClient) WritePacket(pkt packets.Packet) error {
f.mutex.Lock()
f.writeCalls++
var err error
if len(f.writeErrors) != 0 {
err = f.writeErrors[0]
f.writeErrors = f.writeErrors[1:]
}
f.mutex.Unlock()
if err == nil {
select {
case f.successfulWrite <- pkt:
default:
}
}
return err
}
func (f *fakeBackchannelClient) Close(context.Context) error {
f.mutex.Lock()
f.closeCalls++
f.mutex.Unlock()
return nil
}
func (f *fakeBackchannelClient) callCounts() (start, connect, close, write int) {
f.mutex.Lock()
defer f.mutex.Unlock()
return f.startCalls, f.connectCalls, f.closeCalls, f.writeCalls
}
func TestBackchannelPacketizerUsesFullRTPClock(t *testing.T) {
packetizer := backchannelPacketizer{ssrc: 1}
audio := models.AudioDataPartial{Data: make([]int16, 1024)}
startedAt := time.Unix(1, 0)
var timestamp uint32
for index := 0; index <= 64; index++ {
pkt := packetizer.packet(audio, startedAt.Add(time.Duration(index)*128*time.Millisecond))
timestamp = pkt.Packet.Timestamp
}
if timestamp != 65536 {
t.Fatalf("timestamp after 64 frames = %d, want 65536", timestamp)
}
}
func TestBackchannelPacketizerUsesNaturalSequenceRollover(t *testing.T) {
packetizer := backchannelPacketizer{sequenceNumber: ^uint16(0), ssrc: 1}
audio := models.AudioDataPartial{Data: []int16{0}}
startedAt := time.Unix(1, 0)
last := packetizer.packet(audio, startedAt)
firstAfterRollover := packetizer.packet(audio, startedAt.Add(time.Millisecond))
if last.Packet.SequenceNumber != ^uint16(0) {
t.Fatalf("last sequence number = %d, want %d", last.Packet.SequenceNumber, ^uint16(0))
}
if firstAfterRollover.Packet.SequenceNumber != 0 {
t.Fatalf("first sequence number after rollover = %d, want 0", firstAfterRollover.Packet.SequenceNumber)
}
}
func TestBackchannelPacketizerMarksTalkspurtStart(t *testing.T) {
packetizer := backchannelPacketizer{ssrc: 1}
audio := models.AudioDataPartial{Data: []int16{0}}
startedAt := time.Unix(1, 0)
first := packetizer.packet(audio, startedAt)
continuous := packetizer.packet(audio, startedAt.Add(128*time.Millisecond))
afterGap := packetizer.packet(audio, startedAt.Add(backchannelTalkspurtGap+128*time.Millisecond))
if !first.Packet.Marker {
t.Fatal("first packet must mark the start of a talkspurt")
}
if continuous.Packet.Marker {
t.Fatal("continuous packet must not carry the marker bit")
}
if !afterGap.Packet.Marker {
t.Fatal("packet after an audio gap must mark a new talkspurt")
}
}
func TestWriteAudioToBackchannelReconnectsAfterWriteFailure(t *testing.T) {
writeFailure := errors.New("EOF")
client := &fakeBackchannelClient{
writeErrors: []error{writeFailure, nil},
connectAttempt: make(chan struct{}, 1),
successfulWrite: make(chan packets.Packet, 1),
}
audioChannel := make(chan models.AudioDataPartial, 2)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
writeAudioToBackchannel(ctx, ctx, audioChannel, client)
close(done)
}()
audioChannel <- models.AudioDataPartial{Data: make([]int16, 1024)}
select {
case <-client.connectAttempt:
case <-time.After(time.Second):
t.Fatal("backchannel was not reconnected after the write failure")
}
audioChannel <- models.AudioDataPartial{Data: make([]int16, 1024)}
select {
case pkt := <-client.successfulWrite:
if !pkt.Packet.Marker {
t.Fatal("first packet after reconnect must mark a new talkspurt")
}
case <-time.After(time.Second):
t.Fatal("fresh audio was not written after reconnect")
}
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("backchannel writer did not stop after cancellation")
}
startCalls, connectCalls, closeCalls, writeCalls := client.callCounts()
if startCalls != 2 || connectCalls != 1 || closeCalls != 1 || writeCalls != 2 {
t.Fatalf("calls (start, connect, close, write) = (%d, %d, %d, %d), want (2, 1, 1, 2)", startCalls, connectCalls, closeCalls, writeCalls)
}
}
func TestWriteAudioToBackchannelCancellationStopsReconnect(t *testing.T) {
client := &fakeBackchannelClient{
connectError: errors.New("camera unavailable"),
writeErrors: []error{errors.New("EOF")},
connectAttempt: make(chan struct{}, 1),
successfulWrite: make(chan packets.Packet, 1),
}
audioChannel := make(chan models.AudioDataPartial, 1)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
writeAudioToBackchannel(ctx, ctx, audioChannel, client)
close(done)
}()
audioChannel <- models.AudioDataPartial{Data: make([]int16, 1024)}
select {
case <-client.connectAttempt:
case <-time.After(time.Second):
t.Fatal("expected a reconnect attempt")
}
cancel()
select {
case <-done:
case <-time.After(250 * time.Millisecond):
t.Fatal("cancellation did not interrupt reconnect backoff")
}
}

View File

@@ -74,7 +74,12 @@ func Bootstrap(ctx context.Context, configDirectory string, configuration *model
communication.HandleLiveHDKeepalive = make(chan string, 1)
communication.HandleLiveHDPeers = make(chan string, 1)
communication.HandleLiveHLS = make(chan string, 1)
communication.HandleAudio = make(chan models.AudioDataPartial, 10)
communication.IsConfiguring = abool.New()
communication.IsRecordingManual = abool.New()
communication.RecordingManualHeartbeat = &atomic.Int64{}
communication.RecordingManualStart = &atomic.Int64{}
communication.RecordingManualHeartbeatSeen = abool.New()
cameraSettings := &models.Camera{}
@@ -265,11 +270,11 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
communication.MainStreamConnected = true
// Try to create backchannel
communication.HasBackChannel = false
rtspBackChannelClient := captureDevice.SetBackChannelClient(rtspUrl)
err = rtspBackChannelClient.ConnectBackChannel(ctx, ctxRunAgent)
if err == nil {
log.Log.Info("components.Kerberos.RunAgent(): opened RTSP backchannel stream: " + rtspUrl)
go rtspBackChannelClient.StartBackChannel(ctx, ctxRunAgent)
}
rtspSubClient := captureDevice.RTSPSubClient
@@ -301,6 +306,10 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
// watching.
go cloud.HandleLiveStreamHLS(configuration, communication, mqttClient, subStreamEnabled)
// MoQ is available only in the dedicated CGO/glibc build. The standard
// static Alpine build resolves this hook to a no-op.
cloud.StartLiveStreamMoQ(configuration, communication, subStreamEnabled)
// Handle livestream HD (high resolution over WEBRTC). Both the main and sub
// stream are exposed as separate broadcasters so a viewer can request the
// high (main) or low (sub) resolution per peer connection; "auto" prefers the
@@ -309,7 +318,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
go cloud.HandleLiveStreamHD(configuration, communication, mqttClient, rtspClient, rtspSubClient, subStreamEnabled)
// Handle recording, will write an mp4 to disk.
go capture.HandleRecordStream(queue, configDirectory, configuration, communication, rtspClient)
go capture.HandleRecordStream(queue, configDirectory, configuration, communication, rtspClient, mqttClient)
// Handle processing of motion
communication.HandleMotion = make(chan models.MotionDataPartial, 10)
@@ -337,7 +346,11 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
communication.HandleONVIF = make(chan models.OnvifAction, 10)
go onvif.HandleONVIFActions(configuration, communication)
communication.HandleAudio = make(chan models.AudioDataPartial, 10)
// Handle ONVIF event stream — opt-in via Capture.ONVIFMotion="true".
// Stops when the agent's shared context is cancelled. The function
// is a no-op if ONVIFMotion is not enabled.
go onvif.HandleONVIFEventStream(*communication.Context, configuration, communication)
if rtspBackChannelClient.HasBackChannel {
communication.HasBackChannel = true
go WriteAudioToBackchannel(communication, rtspBackChannelClient)
@@ -428,9 +441,6 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
close(communication.HandleMotion)
communication.HandleMotion = nil
close(communication.HandleAudio)
communication.HandleAudio = nil
close(communication.HandleONVIF)
communication.HandleONVIF = nil

View File

@@ -22,20 +22,35 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
var isPixelChangeThresholdReached = false
var changesToReturn = 0
var motionRectangle models.MotionRectangle
var motionRectangles []models.MotionRectangle
pixelThreshold := config.Capture.PixelChangeThreshold
// Might not be set in the config file, so set it to 150
if pixelThreshold == 0 {
pixelThreshold = 150
// Resolve the motion sensitivity (pixel-change threshold). Nil, zero, and
// negative values use the historical default so older configurations keep
// recording after an upgrade.
pixelThreshold := 150
if config.Capture.PixelChangeThreshold != nil && *config.Capture.PixelChangeThreshold > 0 {
pixelThreshold = *config.Capture.PixelChangeThreshold
}
// In motion mode we always run detection. In CONTINUOUS mode recording is
// 24/7 so motion detection is normally skipped, BUT if a motion region is
// configured we still run it so the live view can visualise the motion boxes
// + region. In that case we only emit the motion EVENT — no motion-triggered
// recording (continuous already records, and the recorder's motion branch
// isn't draining HandleMotion in continuous mode).
continuousMode := config.Capture.Continuous == "true"
hasMotionRegion := config.Region != nil && len(config.Region.Polygon) > 0
if config.Capture.Continuous == "true" {
if continuousMode && !hasMotionRegion {
log.Log.Info("computervision.main.ProcessMotion(): you've enabled continuous recording, so no motion detection required.")
log.Log.Info("computervision.main.ProcessMotion(): continuous recording enabled and no motion region configured, so no motion detection required.")
} else {
log.Log.Info("computervision.main.ProcessMotion(): motion detected is enabled, so starting the motion detection.")
if continuousMode {
log.Log.Info("computervision.main.ProcessMotion(): continuous recording enabled with a motion region, running motion detection for live-view visualisation only (no motion-triggered recording).")
} else {
log.Log.Info("computervision.main.ProcessMotion(): motion detected is enabled, so starting the motion detection.")
}
hubKey := config.HubKey
deviceKey := config.Key
@@ -100,20 +115,47 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
}
}
// Frame dimensions + the motion region polygon(s) in image space, shipped
// with each motion event so the live view can draw a motion-debug overlay
// (the boxes below + the detection region).
var imageCols, imageRows int
var regionPolygons [][]map[string]int
if config.Region != nil {
for _, polygon := range config.Region.Polygon {
var pts []map[string]int
for _, c := range polygon.Coordinates {
pts = append(pts, map[string]int{
"x": int(c.X * baseWidthRatio),
"y": int(c.Y * baseHeightRatio),
})
}
if len(pts) > 0 {
regionPolygons = append(regionPolygons, pts)
}
}
}
img := imageArray[0]
var coordinatesToCheck []int
var coordinatesPerRegion [][]int
totalCoordinates := 0
if img != nil {
bounds := img.Bounds()
rows := bounds.Dy()
cols := bounds.Dx()
imageCols = cols
imageRows = rows
// Make fixed size array of uinty8
// Build a SEPARATE coordinate list per region. Motion is evaluated
// independently per region: pixels are NOT shared between regions, so
// the threshold must be exceeded within a single region to trigger.
coordinatesPerRegion = make([][]int, len(polyObjects))
for y := 0; y < rows; y++ {
for x := 0; x < cols; x++ {
for _, poly := range polyObjects {
point := geo.NewPoint(float64(x), float64(y))
point := geo.NewPoint(float64(x), float64(y))
for idx, poly := range polyObjects {
if poly.Contains(point) {
coordinatesToCheck = append(coordinatesToCheck, y*cols+x)
coordinatesPerRegion[idx] = append(coordinatesPerRegion[idx], y*cols+x)
totalCoordinates++
}
}
}
@@ -121,7 +163,7 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
}
// If no region is set, we'll skip the motion detection
if len(coordinatesToCheck) > 0 {
if totalCoordinates > 0 {
// Start the motion detection
i := 0
@@ -146,12 +188,16 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
log.Log.Debug("computervision.main.ProcessMotion(): " + err.Error() + ".")
}
if config.Capture.Motion != "false" {
// Run detection when motion is enabled, OR when we're in continuous
// mode with a region: there config.Capture.Motion (the motion-RECORDING
// switch) is irrelevant, so the configured region alone is enough to
// emit motion events for the live-view overlay.
if config.Capture.Motion != "false" || continuousMode {
if detectMotion {
// Remember additional information about the result of findmotion
isPixelChangeThresholdReached, changesToReturn, motionRectangle = FindMotion(imageArray, coordinatesToCheck, pixelThreshold)
isPixelChangeThresholdReached, changesToReturn, motionRectangle, motionRectangles = FindMotion(imageArray, coordinatesPerRegion, pixelThreshold)
if isPixelChangeThresholdReached {
// If offline mode is disabled, send a message to the hub
@@ -164,6 +210,24 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
DeviceId: configuration.Config.Key,
Value: map[string]interface{}{
"timestamp": time.Now().Unix(),
// Live-view motion-debug overlay data. The boxes/region
// are in the MOTION frame's pixel space (width/height =
// the stream motion ran on, i.e. the sub stream when
// set). mainWidth/mainHeight are the MAIN stream's
// dimensions so the live view can extrapolate the
// boxes/region onto the high-res main view it shows —
// we know both, so no guessing from the <video> element.
"width": imageCols,
"height": imageRows,
"mainWidth": configuration.Config.Capture.IPCamera.Width,
"mainHeight": configuration.Config.Capture.IPCamera.Height,
"regions": motionRectangles,
"polygon": regionPolygons, // Motion sensitivity = the pixel-change threshold that must
// be exceeded before motion triggers. The live view renders
// a reference square of sqrt(threshold) px (in this MOTION
// frame's pixel space) so the user can visually gauge how
// large a moving object must be before it is detected.
"pixelChangeThreshold": pixelThreshold,
},
},
}
@@ -179,7 +243,12 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
}
}
if config.Capture.Recording != "false" {
// Trigger motion-based recording — but NOT in continuous mode:
// there the recorder runs the continuous branch and does not
// drain HandleMotion, so a (blocking) send would hang the motion
// loop. In continuous mode we only publish the motion event above
// for the live-view overlay.
if config.Capture.Recording != "false" && !continuousMode {
dataToPass := models.MotionDataPartial{
Timestamp: time.Now().Unix(),
NumberOfChanges: changesToReturn,
@@ -205,17 +274,73 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
log.Log.Debug("computervision.main.ProcessMotion(): stop the motion detection.")
}
func FindMotion(imageArray [3]*image.Gray, coordinatesToCheck []int, pixelChangeThreshold int) (thresholdReached bool, changesDetected int, motionRectangle models.MotionRectangle) {
func FindMotion(imageArray [3]*image.Gray, coordinatesPerRegion [][]int, pixelChangeThreshold int) (thresholdReached bool, changesDetected int, motionRectangle models.MotionRectangle, motionRectangles []models.MotionRectangle) {
image1 := imageArray[0]
image2 := imageArray[1]
image3 := imageArray[2]
threshold := 60
changes, motionRectangle := AbsDiffBitwiseAndThreshold(image1, image2, image3, threshold, coordinatesToCheck)
return changes > pixelChangeThreshold, changes, motionRectangle
// Evaluate each region INDEPENDENTLY — pixels are not shared between regions,
// so the threshold must be exceeded within a single region to trigger. The
// overall rectangle (recording metadata) and the per-cluster rectangles
// (live-view overlay) are aggregated across all regions.
var combinedRectangles []models.MotionRectangle
var overall models.MotionRectangle
haveOverall := false
totalChanges := 0
for _, coordinatesToCheck := range coordinatesPerRegion {
if len(coordinatesToCheck) == 0 {
continue
}
changes, rect, rects := AbsDiffBitwiseAndThreshold(image1, image2, image3, threshold, coordinatesToCheck)
totalChanges += changes
if changes > pixelChangeThreshold {
thresholdReached = true
}
combinedRectangles = append(combinedRectangles, rects...)
if changes > 0 {
if !haveOverall {
overall = rect
haveOverall = true
} else {
overall = unionMotionRectangle(overall, rect)
}
}
}
return thresholdReached, totalChanges, overall, combinedRectangles
}
func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.Gray, threshold int, coordinatesToCheck []int) (int, models.MotionRectangle) {
// unionMotionRectangle returns the smallest rectangle that contains both a and b.
func unionMotionRectangle(a, b models.MotionRectangle) models.MotionRectangle {
minX := a.X
if b.X < minX {
minX = b.X
}
minY := a.Y
if b.Y < minY {
minY = b.Y
}
maxX := a.X + a.Width
if b.X+b.Width > maxX {
maxX = b.X + b.Width
}
maxY := a.Y + a.Height
if b.Y+b.Height > maxY {
maxY = b.Y + b.Height
}
return models.MotionRectangle{
X: minX,
Y: minY,
Width: maxX - minX,
Height: maxY - minY,
}
}
func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.Gray, threshold int, coordinatesToCheck []int) (int, models.MotionRectangle, []models.MotionRectangle) {
changes := 0
cols := img1.Bounds().Dx()
rows := img1.Bounds().Dy()
var pixelList [][]int
for i := 0; i < len(coordinatesToCheck); i++ {
pixel := coordinatesToCheck[i]
@@ -224,7 +349,7 @@ func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.
if (diff > threshold || diff < -threshold) && (diff2 > threshold || diff2 < -threshold) {
changes++
// Store the pixel coordinates where the change is detected
pixelList = append(pixelList, []int{pixel % img1.Bounds().Dx(), pixel / img1.Bounds().Dx()})
pixelList = append(pixelList, []int{pixel % cols, pixel / cols})
}
}
@@ -258,5 +383,118 @@ func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.
}
log.Log.Debugf("Motion rectangle: %+v", motionRectangle)
}
return changes, motionRectangle
// Cluster the changed pixels into separate bounding boxes so the live view can
// visualise WHERE motion happened (a single overall rectangle is useless when
// two objects move in opposite corners). Cheap grid-based connected components.
motionRectangles := clusterMotionRectangles(pixelList, cols, rows)
return changes, motionRectangle, motionRectangles
}
// clusterMotionRectangles groups the changed-pixel coordinates into a handful of
// bounding boxes using connected-components on a coarse grid (8-connectivity).
// It is intentionally lightweight — it runs only when the motion threshold is
// reached and the boxes are meant for a debug overlay, not precise detection.
func clusterMotionRectangles(pixelList [][]int, cols, rows int) []models.MotionRectangle {
if len(pixelList) == 0 || cols <= 0 || rows <= 0 {
return nil
}
// ~40 cells across the longest side keeps the grid small (cheap to cluster)
// while still separating distinct motion blobs.
const gridDim = 40
cellW := cols / gridDim
if cellW < 1 {
cellW = 1
}
cellH := rows / gridDim
if cellH < 1 {
cellH = 1
}
gCols := (cols + cellW - 1) / cellW
gRows := (rows + cellH - 1) / cellH
grid := make([]bool, gCols*gRows)
for _, p := range pixelList {
cx := p[0] / cellW
cy := p[1] / cellH
if cx >= 0 && cx < gCols && cy >= 0 && cy < gRows {
grid[cy*gCols+cx] = true
}
}
visited := make([]bool, gCols*gRows)
var rectangles []models.MotionRectangle
const maxBoxes = 12
stack := make([][2]int, 0, 64)
for cy := 0; cy < gRows; cy++ {
for cx := 0; cx < gCols; cx++ {
idx := cy*gCols + cx
if !grid[idx] || visited[idx] {
continue
}
// Flood-fill this component (8-connectivity) and track its extent.
minX, minY, maxX, maxY := cx, cy, cx, cy
cellCount := 0
stack = stack[:0]
stack = append(stack, [2]int{cx, cy})
visited[idx] = true
for len(stack) > 0 {
cur := stack[len(stack)-1]
stack = stack[:len(stack)-1]
ccx, ccy := cur[0], cur[1]
cellCount++
if ccx < minX {
minX = ccx
}
if ccy < minY {
minY = ccy
}
if ccx > maxX {
maxX = ccx
}
if ccy > maxY {
maxY = ccy
}
for dy := -1; dy <= 1; dy++ {
for dx := -1; dx <= 1; dx++ {
nx, ny := ccx+dx, ccy+dy
if nx < 0 || ny < 0 || nx >= gCols || ny >= gRows {
continue
}
nIdx := ny*gCols + nx
if grid[nIdx] && !visited[nIdx] {
visited[nIdx] = true
stack = append(stack, [2]int{nx, ny})
}
}
}
}
// Skip single-cell specks (sensor noise) unless it's the only motion.
if cellCount < 2 && len(pixelList) > 4 {
continue
}
x := minX * cellW
y := minY * cellH
w := (maxX - minX + 1) * cellW
h := (maxY - minY + 1) * cellH
if x+w > cols {
w = cols - x
}
if y+h > rows {
h = rows - y
}
rectangles = append(rectangles, models.MotionRectangle{X: x, Y: y, Width: w, Height: h})
if len(rectangles) >= maxBoxes {
return rectangles
}
}
}
return rectangles
}

View File

@@ -327,6 +327,12 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
configuration.Config.MaxDirectorySize = size
}
break
case "AGENT_AUTO_CLEAN_MIN_FREE_SPACE":
size, err := strconv.ParseInt(value, 10, 64)
if err == nil {
configuration.Config.MinFreeSpace = size
}
break
/* Camera configuration */
case "AGENT_CAPTURE_IPCAMERA_RTSP":
@@ -371,6 +377,9 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
case "AGENT_CAPTURE_MOTION":
configuration.Config.Capture.Motion = value
break
case "AGENT_CAPTURE_ONVIF_MOTION":
configuration.Config.Capture.ONVIFMotion = value
break
case "AGENT_CAPTURE_SNAPSHOTS":
configuration.Config.Capture.Snapshots = value
break
@@ -395,7 +404,7 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
case "AGENT_CAPTURE_PIXEL_CHANGE":
count, err := strconv.Atoi(value)
if err == nil {
configuration.Config.Capture.PixelChangeThreshold = count
configuration.Config.Capture.PixelChangeThreshold = &count
}
break
case "AGENT_CAPTURE_FRAGMENTED":
@@ -642,6 +651,16 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
}
}
// Motion sensitivity historically used 0 to mean "use the default". Preserve
// that behaviour for configurations created before this field became a
// pointer, and also recover invalid negative values. Only apply this to the
// effective configuration so missing values can still be inherited between
// the separate global and custom layers.
if applyDefaults && (configuration.Config.Capture.PixelChangeThreshold == nil || *configuration.Config.Capture.PixelChangeThreshold <= 0) {
defaultPixelChangeThreshold := 150
configuration.Config.Capture.PixelChangeThreshold = &defaultPixelChangeThreshold
}
// Signing is a new feature, so if empty we set default values. Only applied
// for the effective configuration (applyDefaults), not for the separate
// global/custom views.

View File

@@ -0,0 +1,40 @@
package config
import (
"testing"
"github.com/kerberos-io/agent/machinery/src/models"
)
func TestApplyAgentEnvVarsPixelChangeThresholdDefault(t *testing.T) {
tests := []struct {
name string
threshold *int
want int
}{
{name: "missing", want: 150},
{name: "legacy zero", threshold: intPointer(0), want: 150},
{name: "negative", threshold: intPointer(-1), want: 150},
{name: "positive", threshold: intPointer(275), want: 275},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
configuration := &models.Configuration{}
configuration.Config.Capture.PixelChangeThreshold = test.threshold
applyAgentEnvVars(configuration, "TEST_", true)
if configuration.Config.Capture.PixelChangeThreshold == nil {
t.Fatal("PixelChangeThreshold is nil after applying defaults")
}
if got := *configuration.Config.Capture.PixelChangeThreshold; got != test.want {
t.Fatalf("PixelChangeThreshold = %d, want %d", got, test.want)
}
})
}
}
func intPointer(value int) *int {
return &value
}

View File

@@ -19,6 +19,44 @@ type CameraStreams struct {
SubRTSP string `json:"sub_rtsp"`
}
// DiscoveredDevice describes a device found on the local network during a
// discovery scan (fing/wifiman-style). It combines ONVIF WS-Discovery results
// with an active port scan and MAC/vendor lookup so cameras can be
// auto-detected and pre-filled in the configuration UI.
type DiscoveredDevice struct {
IP string `json:"ip" bson:"ip"`
Hostname string `json:"hostname,omitempty" bson:"hostname"`
MAC string `json:"mac,omitempty" bson:"mac"`
Vendor string `json:"vendor,omitempty" bson:"vendor"`
Manufacturer string `json:"manufacturer,omitempty" bson:"manufacturer"`
Model string `json:"model,omitempty" bson:"model"`
Type string `json:"type,omitempty" bson:"type"`
Server string `json:"server,omitempty" bson:"server"`
OpenPorts []int `json:"open_ports,omitempty" bson:"open_ports"`
Services []string `json:"services,omitempty" bson:"services"`
ONVIF bool `json:"onvif" bson:"onvif"`
ONVIFXAddr string `json:"onvif_xaddr,omitempty" bson:"onvif_xaddr"`
RTSPURL string `json:"rtsp_url,omitempty" bson:"rtsp_url"`
RTSPStreams []RTSPStream `json:"rtsp_streams,omitempty" bson:"rtsp_streams"`
IsCamera bool `json:"is_camera" bson:"is_camera"`
// IsAudio marks audio-only devices (e.g. IP speakers / intercoms such as
// TOA) that expose RTSP to receive/stream audio rather than video.
IsAudio bool `json:"is_audio" bson:"is_audio"`
}
// RTSPStream is a candidate RTSP stream URL for a discovered camera, derived
// from a built-in brand -> RTSP path mapping. When Verified is true the path was
// confirmed to exist on the device via an unauthenticated RTSP DESCRIBE probe
// (a 200 OK or a 401/403 "auth required" both prove the path is valid).
type RTSPStream struct {
Brand string `json:"brand,omitempty" bson:"brand"`
Stream string `json:"stream,omitempty" bson:"stream"` // "main" or "sub"
Path string `json:"path" bson:"path"`
URL string `json:"url" bson:"url"`
Verified bool `json:"verified" bson:"verified"`
RequiresAuth bool `json:"requires_auth,omitempty" bson:"requires_auth"`
}
type OnvifPanTilt struct {
OnvifCredentials OnvifCredentials `json:"onvif_credentials,omitempty" bson:"onvif_credentials"`
Pan float64 `json:"pan,omitempty" bson:"pan"`

View File

@@ -44,14 +44,38 @@ type Communication struct {
// HandleLiveHLS is the live HLS viewer keepalive. It carries the requested
// quality tier ("auto"|"high"|"low"; empty => auto) so the producer can switch
// the live session between the main and sub stream on demand.
HandleLiveHLS chan string
HandleONVIF chan OnvifAction
IsConfiguring *abool.AtomicBool
Queue *packets.Queue
SubQueue *packets.Queue
Image string
CameraConnected bool
MainStreamConnected bool
SubStreamConnected bool
HasBackChannel bool
HandleLiveHLS chan string
HandleONVIF chan OnvifAction
IsConfiguring *abool.AtomicBool
// IsRecordingManual is set while a viewer has requested a manual recording
// from the live view (the record button). While set, the motion-based
// recorder keeps recording (it does not auto-close on the post-recording
// timeout) until the viewer stops it again. It is independent of motion
// detection so it also works when nothing is moving.
IsRecordingManual *abool.AtomicBool
// RecordingManualHeartbeat holds the unix-milliseconds timestamp of the last
// heartbeat received from the live view while a manual recording is active.
// The frontend re-sends the record command every few seconds while the user
// stays on the page; if the heartbeats stop (the viewer closed the tab, went
// idle or lost connectivity) the recorder auto-stops the manual recording so
// it can't record forever when the "stop" message never arrives.
RecordingManualHeartbeat *atomic.Int64
// RecordingManualStart holds the unix-milliseconds timestamp at which the
// current manual recording started. It bounds a manual recording to a maximum
// duration (see capture.manualRecordingMaxDuration) so a forgotten record
// button can't record indefinitely even while the viewer stays active.
RecordingManualStart *atomic.Int64
// RecordingManualHeartbeatSeen is set once the current manual recording has
// received at least one heartbeat, i.e. the viewer proved it supports
// heartbeating. Only then does the recorder enforce the heartbeat timeout; a
// viewer that starts a recording but never heartbeats (an older frontend)
// still records up to the max-duration cap instead of being cut off early.
RecordingManualHeartbeatSeen *abool.AtomicBool
Queue *packets.Queue
SubQueue *packets.Queue
Image string
CameraConnected bool
MainStreamConnected bool
SubStreamConnected bool
HasBackChannel bool
}

View File

@@ -21,6 +21,7 @@ type Config struct {
AutoClean string `json:"auto_clean"`
RemoveAfterUpload string `json:"remove_after_upload"`
MaxDirectorySize int64 `json:"max_directory_size"`
MinFreeSpace int64 `json:"min_free_space,omitempty"`
Timezone string `json:"timezone"`
Capture Capture `json:"capture"`
Timetable []*Timetable `json:"timetable"`
@@ -73,7 +74,15 @@ type Capture struct {
ForwardWebRTC string `json:"forwardwebrtc"`
Fragmented string `json:"fragmented,omitempty" bson:"fragmented,omitempty"`
FragmentedDuration int64 `json:"fragmentedduration,omitempty" bson:"fragmentedduration,omitempty"`
PixelChangeThreshold int `json:"pixelChangeThreshold,omitempty"`
PixelChangeThreshold *int `json:"pixelChangeThreshold,omitempty"`
// ONVIFMotion routes the camera's ONVIF motion events into the
// agent's motion-triggered recording pipeline. When "true" the
// agent opens an event/stream against the configured ONVIF
// endpoint and forwards Motion+Active events to HandleMotion.
// Requires Capture.IPCamera.ONVIFXAddr / ONVIFUsername /
// ONVIFPassword to be set. Default empty (disabled) keeps the
// existing pixel-diff motion detection as the only source.
ONVIFMotion string `json:"onvif_motion,omitempty" bson:"onvif_motion,omitempty"`
}
// IPCamera configuration, such as the RTSP url of the IPCamera and the FPS.

View File

@@ -150,6 +150,19 @@ type AudioPayload struct {
// We received a recording request, we'll send it to the motion handler.
type RecordPayload struct {
Timestamp int64 `json:"timestamp"` // timestamp of the recording request.
// Recording toggles a manual recording from the live view: true starts a
// recording (and keeps it running), false stops it. Older clients that only
// send a timestamp default to false; the live view always sets it explicitly.
Recording bool `json:"recording"`
// Heartbeat marks a keep-alive re-send (with Recording=true) from a viewer
// that supports heartbeating, as opposed to the initial start (the record
// button). While a user stays on the page the live view re-sends the record
// command every few seconds; the agent uses this flag to (a) refresh the
// recording's keep-alive without restarting an already auto-stopped clip from
// a stray heartbeat, and (b) only enable the heartbeat-timeout auto-stop once
// it has actually seen a heartbeat — so older viewers that never heartbeat
// still record up to the max-duration cap instead of being cut off early.
Heartbeat bool `json:"heartbeat"`
}
// We received a preset position request, we'll request it through onvif and send it back.
@@ -162,6 +175,15 @@ type RequestConfigPayload struct {
Timestamp int64 `json:"timestamp"` // timestamp of the preset request.
}
// We received a verify-stream request: probe the given (or configured) RTSP
// stream and report whether it can be connected/decoded, along with the
// discovered codec/resolution/fps. Responds with action "verify-stream-result".
type VerifyStreamPayload struct {
Timestamp int64 `json:"timestamp"` // timestamp of the verify request.
Stream string `json:"stream"` // "main" or "sub".
RTSP string `json:"rtsp"` // optional RTSP url to verify; falls back to the configured one.
}
// We received a update config request, we'll update the current config and send a confirmation back.
type UpdateConfigPayload struct {
Timestamp int64 `json:"timestamp"` // timestamp of the preset request.

View File

@@ -0,0 +1,37 @@
package models
import (
"path/filepath"
"strings"
)
const RecordingUploadMetadataExtension = ".metadata"
// RecordingUploadMetadata is persisted in the upload queue marker associated
// with a recording. New optional fields can be added without changing the queue
// mechanism or breaking older agents.
type RecordingUploadMetadata struct {
FileName string `json:"filename"`
DeviceKey string `json:"device_key"`
Timestamp int64 `json:"timestamp"` // Unix milliseconds.
Duration uint64 `json:"duration"` // Milliseconds.
FPS float64 `json:"fps,omitempty"`
}
// RecordingUploadMetadataFileName returns the queue marker name associated
// with a recording, replacing the recording extension with .metadata.
func RecordingUploadMetadataFileName(recordingFileName string) string {
name := filepath.Base(recordingFileName)
extension := filepath.Ext(name)
return strings.TrimSuffix(name, extension) + RecordingUploadMetadataExtension
}
// RecordingFileNameFromUploadMarker resolves a queue entry to its recording.
// Markers created by older agents used the recording filename directly.
func RecordingFileNameFromUploadMarker(markerFileName string) string {
name := filepath.Base(markerFileName)
if strings.HasSuffix(name, RecordingUploadMetadataExtension) {
return strings.TrimSuffix(name, RecordingUploadMetadataExtension) + ".mp4"
}
return name
}

View File

@@ -0,0 +1,15 @@
package models
import "testing"
func TestRecordingUploadMetadataFileNames(t *testing.T) {
if got := RecordingUploadMetadataFileName("141245_x_x_.mp4"); got != "141245_x_x_.metadata" {
t.Fatalf("RecordingUploadMetadataFileName() = %q", got)
}
if got := RecordingFileNameFromUploadMarker("141245_x_x_.metadata"); got != "141245_x_x_.mp4" {
t.Fatalf("RecordingFileNameFromUploadMarker() = %q", got)
}
if got := RecordingFileNameFromUploadMarker("legacy.mp4"); got != "legacy.mp4" {
t.Fatalf("legacy RecordingFileNameFromUploadMarker() = %q", got)
}
}

View File

@@ -0,0 +1,523 @@
package onvif
import (
"bufio"
"net"
"strconv"
"strings"
"time"
"github.com/kerberos-io/agent/machinery/src/models"
)
// brandProfile describes a camera brand together with the RTSP URL path
// templates it exposes for its main (high quality) and sub (low quality)
// streams. The paths are the well-known, widely documented defaults for each
// vendor and are used both to identify the brand (by probing which path the
// device recognises) and to pre-fill a working RTSP URL for the user.
//
// The order of the list matters: more specific / more common brands come first
// so that when we actively probe a device the first matching profile wins.
type brandProfile struct {
Brand string
// aliases are lower-cased tokens that, when seen in a banner/realm/MAC
// vendor, map onto this brand.
Aliases []string
MainPath string
SubPath string
// extraMainPaths are alternative main-stream paths tried during active
// probing when the primary MainPath is not recognised.
extraMainPaths []string
}
// brandProfiles is the built-in brand -> RTSP path mapping. It mirrors the
// tables used by tools such as ONVIF Device Manager, iSpy/Agent DVR and
// Blue Iris.
var brandProfiles = []brandProfile{
{
Brand: "Hikvision",
Aliases: []string{"hikvision", "dvrdvs", "ds-", "hik"},
MainPath: "/Streaming/Channels/101",
SubPath: "/Streaming/Channels/102",
extraMainPaths: []string{"/h264/ch1/main/av_stream", "/ISAPI/Streaming/Channels/101"},
},
{
Brand: "Dahua",
Aliases: []string{"dahua", "dh-"},
MainPath: "/cam/realmonitor?channel=1&subtype=0",
SubPath: "/cam/realmonitor?channel=1&subtype=1",
extraMainPaths: []string{"/live"},
},
{
Brand: "Amcrest",
Aliases: []string{"amcrest"},
MainPath: "/cam/realmonitor?channel=1&subtype=0",
SubPath: "/cam/realmonitor?channel=1&subtype=1",
},
{
Brand: "Axis",
Aliases: []string{"axis"},
MainPath: "/axis-media/media.amp",
SubPath: "/axis-media/media.amp?videocodec=h264&resolution=640x480",
extraMainPaths: []string{"/mpeg4/media.amp"},
},
{
Brand: "Reolink",
Aliases: []string{"reolink", "rlc", "rln", "rlc-", "rln-", "trackmix", "duo"},
MainPath: "/h264Preview_01_main",
SubPath: "/h264Preview_01_sub",
extraMainPaths: []string{"/Preview_01_main"},
},
{
Brand: "Hanwha",
Aliases: []string{"hanwha", "wisenet", "samsung techwin"},
MainPath: "/profile2/media.smp",
SubPath: "/profile3/media.smp",
extraMainPaths: []string{"/profile1/media.smp", "/onvif/profile2/media.smp"},
},
{
Brand: "Bosch",
Aliases: []string{"bosch"},
MainPath: "/rtsp_tunnel",
SubPath: "/rtsp_tunnel?inst=2",
extraMainPaths: []string{"/rtsp_tunnel?inst=1", "/?inst=1"},
},
{
Brand: "Vivotek",
Aliases: []string{"vivotek"},
MainPath: "/live.sdp",
SubPath: "/live2.sdp",
extraMainPaths: []string{"/live1s1.sdp"},
},
{
Brand: "Foscam",
Aliases: []string{"foscam"},
MainPath: "/videoMain",
SubPath: "/videoSub",
},
{
Brand: "Uniview",
Aliases: []string{"uniview", "unv"},
MainPath: "/media/video1",
SubPath: "/media/video2",
extraMainPaths: []string{"/unicast/c1/s0/live", "/unicast/c1/s1/live"},
},
{
Brand: "TP-Link",
Aliases: []string{"tp-link", "tplink", "tapo"},
MainPath: "/stream1",
SubPath: "/stream2",
},
{
Brand: "Mobotix",
Aliases: []string{"mobotix"},
MainPath: "/cam0/mjpeg",
SubPath: "/cam1/mjpeg",
extraMainPaths: []string{"/live.sdp"},
},
{
Brand: "Ubiquiti",
Aliases: []string{"ubiquiti", "unifi"},
MainPath: "/s0",
SubPath: "/s1",
extraMainPaths: []string{"/live/ch00_0"},
},
{
Brand: "Panasonic",
Aliases: []string{"panasonic", "i-pro", "ipro"},
MainPath: "/MediaInput/h264",
SubPath: "/MediaInput/h264/stream_2",
},
{
Brand: "Sony",
Aliases: []string{"sony"},
MainPath: "/media/video1",
SubPath: "/media/video2",
},
{
// D-Link mydlink IP cameras. Older models stream MJPEG over HTTP; the
// RTSP-capable ones expose SDP-named streams, newer DCS models use
// "/live/profile.0".
Brand: "D-Link",
Aliases: []string{"d-link", "dlink", "dcs-", "dcs"},
MainPath: "/live1.sdp",
SubPath: "/live2.sdp",
extraMainPaths: []string{"/live.sdp", "/live/profile.0", "/play1.sdp"},
},
{
// TRENDnet. Newer PoE bullet/dome models (TV-IPxxxPI) use a
// Hikvision-style path; older ones expose SDP streams.
Brand: "Trendnet",
Aliases: []string{"trendnet", "tv-ip"},
MainPath: "/Streaming/Channels/101",
SubPath: "/Streaming/Channels/102",
extraMainPaths: []string{"/play1.sdp", "/play2.sdp", "/ch0_0.h264", "/live/av0"},
},
{
// Lorex is built largely on Dahua hardware, so it shares Dahua's
// realmonitor path scheme.
Brand: "Lorex",
Aliases: []string{"lorex"},
MainPath: "/cam/realmonitor?channel=1&subtype=0",
SubPath: "/cam/realmonitor?channel=1&subtype=1",
extraMainPaths: []string{"/ch01/0"},
},
{
// Honeywell ships both Dahua-OEM models (realmonitor) and in-house
// firmwares exposing "/h264" or "/media".
Brand: "Honeywell",
Aliases: []string{"honeywell"},
MainPath: "/cam/realmonitor?channel=1&subtype=0",
SubPath: "/cam/realmonitor?channel=1&subtype=1",
extraMainPaths: []string{"/h264", "/media", "/live.sdp"},
},
{
Brand: "Pelco",
Aliases: []string{"pelco"},
MainPath: "/stream1",
SubPath: "/stream2",
extraMainPaths: []string{"/1/stream1"},
},
{
// TOA network audio devices (IP horn speakers / intercoms, banner
// "TOA rtsp server") expose their stream through ONVIF rather than a
// documented fixed RTSP path. These ONVIF-style paths are a best-effort
// default; the authoritative URL should come from an ONVIF GetStreamUri
// query with credentials.
Brand: "TOA",
Aliases: []string{"toa"},
MainPath: "/ONVIF/channel1",
SubPath: "/ONVIF/channel2",
extraMainPaths: []string{"/media/video1", "/live"},
},
{
// Linksys/Cisco IP cameras (e.g. LCAD03FLN, LCAB03VLNOD, LCAM0336OD)
// run a mini_httpd server and expose ONVIF-style stream paths with a
// capitalised "ONVIF" segment (distinct from the generic "/onvif1").
Brand: "Linksys",
Aliases: []string{"linksys", "lcad", "lcab", "lcam", "lcae"},
MainPath: "/ONVIF/channel1",
SubPath: "/ONVIF/channel2",
extraMainPaths: []string{"/img/media.sav", "/live"},
},
}
// genericRTSPPaths are last-resort, vendor-neutral RTSP paths used when the
// brand is unknown. Many ONVIF/embedded cameras answer on one of these.
var genericRTSPPaths = []string{
"/ONVIF/channel1", "/ONVIF/channel2", "/onvif1", "/live", "/live/ch0", "/11", "/12",
"/stream0", "/stream1", "/h264", "/media/video1", "/ch0_0.h264",
}
// brandProfileFor returns the profile whose aliases best match the given brand
// hint (from a banner, realm or MAC vendor). It returns nil when nothing
// matches.
func brandProfileFor(hint string) *brandProfile {
hint = strings.ToLower(strings.TrimSpace(hint))
if hint == "" {
return nil
}
for i := range brandProfiles {
for _, alias := range brandProfiles[i].Aliases {
if strings.Contains(hint, alias) {
return &brandProfiles[i]
}
}
}
return nil
}
// realmBrands maps a lower-cased substring of an RTSP/HTTP WWW-Authenticate
// realm to a manufacturer. The auth realm is one of the most reliable brand
// signals because a camera advertises it even when it refuses every
// unauthenticated request (e.g. Hikvision realm "IP Camera(E3669)", Dahua realm
// "Login to <serial>"). Ordered so the most specific matches win.
var realmBrands = []struct {
Match string
Vendor string
}{
{"login to", "Dahua"},
{"surveillance server", "Dahua"},
{"real time streaming", "Dahua"},
{"dahua", "Dahua"},
{"ip camera(", "Hikvision"},
{"hikvision", "Hikvision"},
{"ds-", "Hikvision"},
{"axis", "Axis"},
{"reolink", "Reolink"},
{"amcrest", "Amcrest"},
{"wisenet", "Hanwha"},
{"hanwha", "Hanwha"},
{"uniview", "Uniview"},
{"tp-link", "TP-Link"},
{"tapo", "TP-Link"},
{"foscam", "Foscam"},
{"vivotek", "Vivotek"},
{"mobotix", "Mobotix"},
{"bosch", "Bosch"},
{"please log in with a valid username", "Bosch"},
{"d-link", "D-Link"},
{"dcs-", "D-Link"},
{"trendnet", "Trendnet"},
{"lorex", "Lorex"},
{"honeywell", "Honeywell"},
{"pelco", "Pelco"},
{"linksys", "Linksys"},
{"lcad", "Linksys"},
{"lcab", "Linksys"},
{"lcam", "Linksys"},
}
// brandFromRealm resolves a manufacturer from an auth realm string.
func brandFromRealm(realm string) string {
r := strings.ToLower(strings.TrimSpace(realm))
if r == "" {
return ""
}
for _, entry := range realmBrands {
if strings.Contains(r, entry.Match) {
return entry.Vendor
}
}
return ""
}
// modelFromRealm extracts a model/device code embedded in an auth realm, e.g.
// Hikvision's realm="IP Camera(E3669)" -> "E3669".
func modelFromRealm(realm string) string {
open := strings.Index(realm, "(")
closeIdx := strings.Index(realm, ")")
if open >= 0 && closeIdx > open+1 {
return strings.TrimSpace(realm[open+1 : closeIdx])
}
return ""
}
// firstNonEmpty returns the first non-blank value.
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
// guessRTSPStreams determines the most likely RTSP stream URLs for a camera. It
// combines the brand hint discovered from banners/MAC with an active,
// unauthenticated RTSP DESCRIBE probe and the auth realm advertised by the
// device.
//
// Detection strategy (most reliable first):
// 1. Send a control DESCRIBE for a random, non-existent path. Its 401 response
// usually carries a WWW-Authenticate realm that reveals the brand
// (Hikvision "IP Camera(...)", Dahua "Login to ..."). The realm is the
// strongest signal and works even when the device challenges auth for every
// request. The control also tells us whether the device distinguishes valid
// from invalid paths.
// 2. If the device discriminates paths, probe each brand's main path (realm
// brand first); the first the device recognises (200 or 401/403) confirms a
// working URL.
// 3. Otherwise fall back to the realm / hint / port brand's default paths and
// return them as unverified suggestions.
//
// It returns the detected brand, an optional model code parsed from the realm,
// and the ordered list of candidate streams (verified first).
func guessRTSPStreams(ip string, port int, brandHint string, openPorts []int, timeout time.Duration) (brand string, model string, streams []models.RTSPStream) {
base := "rtsp://" + net.JoinHostPort(ip, strconv.Itoa(port))
build := func(profileBrand, stream, path string, verified, requiresAuth bool) models.RTSPStream {
return models.RTSPStream{
Brand: profileBrand,
Stream: stream,
Path: path,
URL: base + path,
Verified: verified,
RequiresAuth: requiresAuth,
}
}
// 1) Control probe: distinguish behaviour + capture the auth realm.
bogusPath := "/kerberos-probe-" + strconv.FormatInt(time.Now().UnixNano(), 36)
controlStatus, controlRealm, _ := rtspDescribe(ip, port, bogusPath, timeout)
controlExists := controlStatus == 200 || controlStatus == 401 || controlStatus == 403
controlAuth := controlStatus == 401 || controlStatus == 403
discriminates := !controlExists
realmBrand := brandFromRealm(controlRealm)
model = modelFromRealm(controlRealm)
// The realm brand (when present) is authoritative and probed first.
primaryHint := firstNonEmpty(realmBrand, brandHint)
var verified []models.RTSPStream
var unverified []models.RTSPStream
detected := ""
// 2) Trustworthy active per-brand probing (device discriminates paths).
if discriminates {
for _, profile := range orderedProfiles(primaryHint) {
mainCandidates := append([]string{profile.MainPath}, profile.extraMainPaths...)
matchedMain := ""
matchedAuth := false
for _, path := range mainCandidates {
ok, requiresAuth := rtspPathExists(ip, port, path, timeout)
if ok {
matchedMain = path
matchedAuth = requiresAuth
break
}
}
if matchedMain == "" {
continue
}
detected = profile.Brand
verified = append(verified, build(profile.Brand, "main", matchedMain, true, matchedAuth))
if profile.SubPath != "" {
subOK, subAuth := rtspPathExists(ip, port, profile.SubPath, timeout)
verified = append(verified, build(profile.Brand, "sub", profile.SubPath, subOK, subAuth || matchedAuth))
}
break
}
}
// 3) Fall back to unverified suggestions from realm / hint / port signals.
if len(verified) == 0 {
profile := brandProfileFor(primaryHint)
if profile == nil {
profile = brandProfileForPorts(openPorts)
}
if profile != nil {
detected = profile.Brand
unverified = append(unverified, build(profile.Brand, "main", profile.MainPath, false, controlAuth))
if profile.SubPath != "" {
unverified = append(unverified, build(profile.Brand, "sub", profile.SubPath, false, controlAuth))
}
} else {
for _, path := range genericRTSPPaths {
unverified = append(unverified, build("Generic", "main", path, false, controlAuth))
}
}
}
// The realm brand always wins for the manufacturer name.
if realmBrand != "" {
detected = realmBrand
}
return detected, model, append(verified, unverified...)
}
// brandProfileForPorts derives a brand from vendor-specific control ports that
// were found open during the scan (used when banners give no hint).
func brandProfileForPorts(openPorts []int) *brandProfile {
if containsInt(openPorts, 37777) {
return brandProfileByName("Dahua")
}
return nil
}
// brandProfileByName returns the profile with the given brand name (nil when
// absent).
func brandProfileByName(name string) *brandProfile {
for i := range brandProfiles {
if brandProfiles[i].Brand == name {
return &brandProfiles[i]
}
}
return nil
}
// orderedProfiles returns the brand profiles with the profile matching the
// brand hint (if any) moved to the front so it is probed first.
func orderedProfiles(brandHint string) []brandProfile {
match := brandProfileFor(brandHint)
if match == nil {
return brandProfiles
}
ordered := make([]brandProfile, 0, len(brandProfiles))
ordered = append(ordered, *match)
for i := range brandProfiles {
if brandProfiles[i].Brand != match.Brand {
ordered = append(ordered, brandProfiles[i])
}
}
return ordered
}
// rtspDescribe sends an unauthenticated RTSP DESCRIBE for the given path and
// returns the response status code together with the WWW-Authenticate realm and
// Server header (when present). status is 0 when the device does not answer.
func rtspDescribe(ip string, port int, path string, timeout time.Duration) (status int, realm string, server string) {
address := net.JoinHostPort(ip, strconv.Itoa(port))
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return 0, "", ""
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
request := "DESCRIBE rtsp://" + address + path + " RTSP/1.0\r\n" +
"CSeq: 1\r\n" +
"User-Agent: KerberosDiscovery\r\n" +
"Accept: application/sdp\r\n\r\n"
if _, err := conn.Write([]byte(request)); err != nil {
return 0, "", ""
}
status, headers := readRTSPResponse(conn)
return status, parseRealm(headers["www-authenticate"]), headers["server"]
}
// rtspPathExists reports whether the device recognises the given RTSP path. A
// 200 OK means the path is publicly accessible; a 401/403 means the path is
// valid but requires credentials (still a positive match). Any other status
// (404, 400, 455, ...) means the path is not recognised.
func rtspPathExists(ip string, port int, path string, timeout time.Duration) (exists bool, requiresAuth bool) {
status, _, _ := rtspDescribe(ip, port, path, timeout)
switch status {
case 200:
return true, false
case 401, 403:
return true, true
default:
return false, false
}
}
// readRTSPResponse reads and parses the status code and headers of an RTSP
// response. Only the first occurrence of each header is kept.
func readRTSPResponse(conn net.Conn) (status int, headers map[string]string) {
headers = make(map[string]string)
reader := bufio.NewReader(conn)
line, err := reader.ReadString('\n')
if err != nil {
return 0, headers
}
fields := strings.Fields(line)
if len(fields) >= 2 && strings.HasPrefix(strings.ToUpper(fields[0]), "RTSP/") {
status, _ = strconv.Atoi(fields[1])
}
for {
hline, err := reader.ReadString('\n')
if err != nil {
break
}
hline = strings.TrimRight(hline, "\r\n")
if hline == "" {
break
}
idx := strings.Index(hline, ":")
if idx <= 0 {
continue
}
key := strings.ToLower(strings.TrimSpace(hline[:idx]))
value := strings.TrimSpace(hline[idx+1:])
if _, exists := headers[key]; !exists {
headers[key] = value
}
}
return status, headers
}

View File

@@ -0,0 +1,171 @@
package onvif
import (
"bufio"
"net"
"strconv"
"strings"
"testing"
"time"
)
// mockRTSPServer starts a TCP listener that answers RTSP DESCRIBE requests. For
// each incoming request it extracts the path and calls respond(path) to obtain
// the numeric status code and optional auth realm to return. It returns the
// listener host, port and a cleanup function.
func mockRTSPServer(t *testing.T, respond func(path string) (int, string)) (string, int, func()) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to start mock RTSP server: %v", err)
}
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
_ = c.SetDeadline(time.Now().Add(2 * time.Second))
reader := bufio.NewReader(c)
line, err := reader.ReadString('\n')
if err != nil {
return
}
path := ""
fields := strings.Fields(line)
if len(fields) >= 2 {
url := fields[1]
url = strings.TrimPrefix(url, "rtsp://")
if idx := strings.Index(url, "/"); idx >= 0 {
path = url[idx:]
}
}
status, realm := respond(path)
reason := map[int]string{200: "OK", 401: "Unauthorized", 404: "Not Found"}[status]
response := "RTSP/1.0 " + strconv.Itoa(status) + " " + reason + "\r\nCSeq: 1\r\n"
if realm != "" {
response += "WWW-Authenticate: Digest realm=\"" + realm + "\", nonce=\"abc\"\r\n"
}
response += "\r\n"
_, _ = c.Write([]byte(response))
}(conn)
}
}()
host, portStr, _ := net.SplitHostPort(listener.Addr().String())
port, _ := strconv.Atoi(portStr)
return host, port, func() { listener.Close() }
}
// TestGuessRTSPStreams_DiscriminatingHikvision verifies that a device which
// distinguishes valid from invalid paths (returning 401 only for the Hikvision
// path) is correctly identified as Hikvision with a confirmed main/sub stream.
func TestGuessRTSPStreams_DiscriminatingHikvision(t *testing.T) {
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
if strings.HasPrefix(path, "/Streaming/Channels/") {
return 401, "" // valid path, needs auth
}
return 404, "" // everything else is unknown -> device discriminates
})
defer cleanup()
brand, _, streams := guessRTSPStreams(host, port, "", nil, 2*time.Second)
if brand != "Hikvision" {
t.Fatalf("expected brand Hikvision, got %q", brand)
}
if len(streams) == 0 || !streams[0].Verified {
t.Fatalf("expected a verified main stream, got %+v", streams)
}
if !streams[0].RequiresAuth {
t.Errorf("expected main stream to require auth")
}
if streams[0].Path != "/Streaming/Channels/101" {
t.Errorf("expected main path /Streaming/Channels/101, got %q", streams[0].Path)
}
}
// TestGuessRTSPStreams_ChallengesEverything verifies that a device which returns
// 401 for *any* path (including a bogus one) does NOT get mis-detected via path
// probing, and instead falls back to the port hint (Dahua control port 37777)
// with unverified suggestions.
func TestGuessRTSPStreams_ChallengesEverything(t *testing.T) {
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
return 401, "" // challenges auth before checking the path, no realm
})
defer cleanup()
brand, _, streams := guessRTSPStreams(host, port, "", []int{37777}, 2*time.Second)
if brand != "Dahua" {
t.Fatalf("expected fallback brand Dahua from port hint, got %q", brand)
}
if len(streams) == 0 {
t.Fatalf("expected suggested streams, got none")
}
if streams[0].Verified {
t.Errorf("expected unverified suggestion for a non-discriminating device")
}
if streams[0].Path != "/cam/realmonitor?channel=1&subtype=0" {
t.Errorf("expected Dahua main path, got %q", streams[0].Path)
}
}
// TestGuessRTSPStreams_RealmDetectsHikvision verifies that a device which
// challenges auth for every path (so path probing cannot help) is still
// identified from its RTSP auth realm, and the model code is extracted.
func TestGuessRTSPStreams_RealmDetectsHikvision(t *testing.T) {
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
return 401, "IP Camera(E3669)" // Hikvision realm signature, 401 for all paths
})
defer cleanup()
brand, model, streams := guessRTSPStreams(host, port, "", nil, 2*time.Second)
if brand != "Hikvision" {
t.Fatalf("expected brand Hikvision from realm, got %q", brand)
}
if model != "E3669" {
t.Errorf("expected model E3669 from realm, got %q", model)
}
if len(streams) == 0 || streams[0].Path != "/Streaming/Channels/101" {
t.Fatalf("expected Hikvision default main path, got %+v", streams)
}
if !streams[0].RequiresAuth {
t.Errorf("expected the suggestion to be marked auth-required")
}
}
// TestGuessRTSPStreams_RealmDetectsDahua verifies Dahua detection from its
// "Login to ..." realm.
func TestGuessRTSPStreams_RealmDetectsDahua(t *testing.T) {
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
return 401, "Login to 5df61a6057b10cc99d471769516d3c11"
})
defer cleanup()
brand, _, streams := guessRTSPStreams(host, port, "", nil, 2*time.Second)
if brand != "Dahua" {
t.Fatalf("expected brand Dahua from realm, got %q", brand)
}
if len(streams) == 0 || streams[0].Path != "/cam/realmonitor?channel=1&subtype=0" {
t.Fatalf("expected Dahua default main path, got %+v", streams)
}
}
// TestGuessRTSPStreams_UnknownFallsBackToGeneric verifies that an unknown device
// (discriminating but matching no brand) yields generic suggestions.
func TestGuessRTSPStreams_UnknownFallsBackToGeneric(t *testing.T) {
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
return 404, "" // discriminates, but nothing matches
})
defer cleanup()
brand, _, streams := guessRTSPStreams(host, port, "", nil, 2*time.Second)
if brand != "" {
t.Fatalf("expected no detected brand, got %q", brand)
}
if len(streams) == 0 || streams[0].Brand != "Generic" {
t.Fatalf("expected generic suggestions, got %+v", streams)
}
}

View File

@@ -0,0 +1,557 @@
package onvif
import (
"bufio"
"context"
"net"
"net/url"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
onvifc "github.com/cedricve/go-onvif"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
)
// scanPort describes a TCP port we probe while scanning the local network,
// together with a human readable service name.
type scanPort struct {
Port int
Service string
// rtsp marks RTSP ports we can fingerprint via an OPTIONS request.
rtsp bool
// http marks HTTP ports we can fingerprint via a banner grab.
http bool
// camera marks ports that strongly hint the device is an IP camera or NVR
// (RTSP, dedicated ONVIF ports and well-known DVR/NVR control ports).
camera bool
}
// commonCameraPorts is the list of TCP ports we probe on every host. These are
// the ports most commonly exposed by IP cameras (RTSP, HTTP(S) and ONVIF).
var commonCameraPorts = []scanPort{
{Port: 554, Service: "RTSP", rtsp: true, camera: true},
{Port: 8554, Service: "RTSP (alt)", rtsp: true, camera: true},
{Port: 80, Service: "HTTP", http: true},
{Port: 8080, Service: "HTTP (alt)", http: true},
{Port: 8000, Service: "ONVIF", http: true, camera: true},
{Port: 8899, Service: "ONVIF (alt)", camera: true},
{Port: 443, Service: "HTTPS"},
{Port: 37777, Service: "Dahua", camera: true},
{Port: 34567, Service: "XMeye/Sofia", camera: true},
}
// ouiVendors maps the first three octets (OUI) of a MAC address, upper-cased and
// without separators, to a known camera/NVR vendor. This lets us flag likely
// cameras the same way tools such as Fing or WiFiman do, even when a device does
// not answer to ONVIF WS-Discovery.
var ouiVendors = map[string]string{
"BCAD01": "Hikvision", "C056E3": "Hikvision", "4CBD8F": "Hikvision",
"44A642": "Hikvision", "E0509B": "Hikvision", "ACB927": "Hikvision",
"18800C": "Hikvision", "C40BCB": "Hikvision",
"3CEF8C": "Dahua", "90020A": "Dahua", "E0509B00": "Dahua",
"08ED02": "Dahua", "3CE376": "Dahua", "38AF29": "Dahua", "E45D51": "Dahua",
"00408C": "Axis", "AABBCC": "Axis", "B8A44F": "Axis", "ACCC8E": "Axis",
"E82725": "Bosch", "000CAB": "Bosch",
"001B9E": "Hanwha", "0009D2": "Hanwha", "E44CC7": "Hanwha",
"EC7196": "Reolink", "9CA3BA": "Reolink",
"3C33F1": "Amcrest", "9C8ECD": "Amcrest",
"000FFC": "Vivotek", "0002D1": "Vivotek",
"001C27": "Mobotix", "0003C5": "Mobotix",
"00126A": "Ubiquiti", "FCECDA": "Ubiquiti", "744401": "Ubiquiti",
"F0234B": "Foscam", "00626E": "Foscam",
"C09424": "TP-Link", "50C7BF": "TP-Link",
}
// DiscoverDevices performs an advanced, Fing/WiFiman-style scan of the local
// network. It combines:
//
// 1. ONVIF WS-Discovery (multicast probe), and
// 2. an active TCP port scan of every host on the local IPv4 subnets for the
// ports typically exposed by IP cameras, and
// 3. MAC address + vendor (OUI) resolution from the local ARP table, and
// 4. best-effort reverse-DNS hostname lookup.
//
// The results are merged per IP address so a single device is reported once
// with all the information we could gather. Devices are flagged as cameras when
// they answer to ONVIF, expose an RTSP port, or have a MAC that belongs to a
// known camera vendor.
//
// Optional subnets (CIDR notation, e.g. "192.168.1.0/24") override the
// automatically detected local subnets. This is useful when the agent runs in a
// container/devcontainer whose interfaces are not on the same range as the
// cameras, but the target range is still routable from the host network.
func DiscoverDevices(timeout time.Duration, subnets ...string) []models.DiscoveredDevice {
devicesByIP := make(map[string]*models.DiscoveredDevice)
var mutex sync.Mutex
// upsert returns the (possibly newly created) device entry for an IP in a
// concurrency-safe way.
upsert := func(ip string) *models.DiscoveredDevice {
mutex.Lock()
defer mutex.Unlock()
device, ok := devicesByIP[ip]
if !ok {
device = &models.DiscoveredDevice{IP: ip}
devicesByIP[ip] = device
}
return device
}
// 1) ONVIF WS-Discovery. This is quick and reliable for ONVIF cameras.
onvifDevices, err := onvifc.StartDiscovery(timeout)
if err != nil {
log.Log.Error("onvif.DiscoverDevices(): WS-Discovery failed: " + err.Error())
} else {
for _, onvifDevice := range onvifDevices {
ip := hostFromXAddr(onvifDevice.XAddr)
if ip == "" {
continue
}
device := upsert(ip)
device.ONVIF = true
device.ONVIFXAddr = onvifDevice.XAddr
device.IsCamera = true
if hostname, hostErr := onvifDevice.GetHostname(); hostErr == nil && hostname.Name != "" {
device.Hostname = hostname.Name
}
}
}
// 2) Active port scan across the requested (or auto-detected) IPv4 subnets.
var targets []string
if len(subnets) > 0 {
targets = targetsFromSubnets(subnets)
} else {
targets = localScanTargets()
}
log.Log.Info("onvif.DiscoverDevices(): scanning " + strconv.Itoa(len(targets)) + " hosts on the local network(s)")
// Bound the amount of concurrent dials so we do not exhaust file
// descriptors on constrained devices (e.g. Raspberry Pi).
semaphore := make(chan struct{}, 128)
dialTimeout := perHostTimeout(timeout)
var waitGroup sync.WaitGroup
for _, ip := range targets {
waitGroup.Add(1)
semaphore <- struct{}{}
go func(ip string) {
defer waitGroup.Done()
defer func() { <-semaphore }()
openPorts, services, isCamera := scanHost(ip, dialTimeout)
if len(openPorts) == 0 {
return
}
// Fingerprint the host (RTSP/HTTP banner grab) to determine its
// manufacturer, model and type without any credentials.
fingerprint := fingerprintHost(ip, openPorts, dialTimeout)
// Resolve a hostname now (ONVIF WS-Discovery may already have set
// one; otherwise fall back to reverse DNS). Camera hostnames often
// encode the model (e.g. Reolink "RLC-823S2"), which is a useful
// brand hint when the RTSP/HTTP banners are anonymous.
mutex.Lock()
hostname := ""
if existing, ok := devicesByIP[ip]; ok {
hostname = existing.Hostname
}
mutex.Unlock()
if hostname == "" {
hostname = reverseDNS(ip, dialTimeout)
}
// Guess (and actively confirm) the RTSP stream URLs from a built-in
// brand -> RTSP path mapping when an RTSP port is open.
var rtspPort int
for _, port := range openPorts {
if port == 554 || port == 8554 {
rtspPort = port
break
}
}
// The banner manufacturer is most reliable; fall back to the
// hostname (model code) so devices that only reveal themselves via
// their name (e.g. Reolink RLC-*) still get the right stream paths.
brandHint := fingerprint.Manufacturer
if brandHint == "" {
brandHint = hostname
}
var rtspStreams []models.RTSPStream
detectedBrand := ""
detectedModel := ""
if rtspPort != 0 && !fingerprint.IsAudio {
detectedBrand, detectedModel, rtspStreams = guessRTSPStreams(ip, rtspPort, brandHint, openPorts, dialTimeout)
}
device := upsert(ip)
mutex.Lock()
device.OpenPorts = mergeSortedInts(device.OpenPorts, openPorts)
device.Services = mergeUniqueStrings(device.Services, services)
if hostname != "" && device.Hostname == "" {
device.Hostname = hostname
}
if isCamera || fingerprint.IsCamera {
device.IsCamera = true
}
if fingerprint.IsAudio {
device.IsAudio = true
device.IsCamera = false
}
if fingerprint.Manufacturer != "" {
device.Manufacturer = fingerprint.Manufacturer
}
// A brand derived from the RTSP auth realm, a confirmed path probe or
// a vendor-specific control port is more reliable than a banner
// string, so let it win.
if detectedBrand != "" && detectedBrand != "Generic" {
device.Manufacturer = detectedBrand
device.IsCamera = true
}
if fingerprint.Model != "" {
device.Model = fingerprint.Model
}
if device.Model == "" && detectedModel != "" {
device.Model = detectedModel
}
if fingerprint.Type != "" {
device.Type = fingerprint.Type
}
if fingerprint.Server != "" {
device.Server = fingerprint.Server
}
if len(rtspStreams) > 0 {
device.RTSPStreams = rtspStreams
// Prefer the first verified stream as the primary RTSP URL.
device.RTSPURL = rtspStreams[0].URL
for _, stream := range rtspStreams {
if stream.Verified {
device.RTSPURL = stream.URL
break
}
}
} else if rtspPort != 0 && !fingerprint.IsAudio {
device.RTSPURL = "rtsp://" + ip + ":" + strconv.Itoa(rtspPort) + "/"
}
mutex.Unlock()
}(ip)
}
waitGroup.Wait()
// 3) Enrich with MAC address / vendor from the ARP table and hostnames.
arpTable := readARPTable()
results := make([]models.DiscoveredDevice, 0, len(devicesByIP))
for ip, device := range devicesByIP {
if mac, ok := arpTable[ip]; ok {
device.MAC = mac
if vendor := vendorFromMAC(mac); vendor != "" {
device.Vendor = vendor
device.IsCamera = true
}
}
// Fall back to the MAC vendor for the manufacturer, and make sure a
// camera always carries a device type.
if device.Manufacturer == "" && device.Vendor != "" {
device.Manufacturer = device.Vendor
}
if device.IsCamera && device.Type == "" {
device.Type = "IP Camera"
}
if device.Hostname == "" {
device.Hostname = reverseDNS(ip, dialTimeout)
}
results = append(results, *device)
}
// Cameras first, then by IP, for a stable and useful ordering.
sort.Slice(results, func(i, j int) bool {
if results[i].IsCamera != results[j].IsCamera {
return results[i].IsCamera
}
return ipLess(results[i].IP, results[j].IP)
})
return results
}
// scanHost probes the common camera ports on a single host and reports the open
// ports, their service names, and whether the host looks like a camera.
func scanHost(ip string, dialTimeout time.Duration) (openPorts []int, services []string, isCamera bool) {
for _, candidate := range commonCameraPorts {
address := net.JoinHostPort(ip, strconv.Itoa(candidate.Port))
conn, err := net.DialTimeout("tcp", address, dialTimeout)
if err != nil {
continue
}
conn.Close()
openPorts = append(openPorts, candidate.Port)
services = append(services, candidate.Service)
if candidate.camera {
isCamera = true
}
}
return openPorts, services, isCamera
}
// targetsFromSubnets expands one or more explicit CIDR ranges (e.g.
// "192.168.1.0/24") into a de-duplicated list of host addresses. Invalid or
// oversized ranges (mask < /22) are skipped so scans stay bounded.
func targetsFromSubnets(subnets []string) []string {
seen := make(map[string]struct{})
var targets []string
for _, subnet := range subnets {
subnet = strings.TrimSpace(subnet)
if subnet == "" {
continue
}
// Allow passing a bare host address (e.g. "192.168.1.50") too.
if !strings.Contains(subnet, "/") {
if net.ParseIP(subnet).To4() != nil {
if _, exists := seen[subnet]; !exists {
seen[subnet] = struct{}{}
targets = append(targets, subnet)
}
} else {
log.Log.Error("onvif.targetsFromSubnets(): invalid address '" + subnet + "'")
}
continue
}
_, ipNet, err := net.ParseCIDR(subnet)
if err != nil || ipNet.IP.To4() == nil {
log.Log.Error("onvif.targetsFromSubnets(): invalid CIDR '" + subnet + "'")
continue
}
if ones, bits := ipNet.Mask.Size(); bits != 32 || ones < 22 {
log.Log.Error("onvif.targetsFromSubnets(): range '" + subnet + "' is too large to scan (use /22 or smaller)")
continue
}
for _, host := range hostsInNetwork(ipNet) {
if _, exists := seen[host]; exists {
continue
}
seen[host] = struct{}{}
targets = append(targets, host)
}
}
return targets
}
// localScanTargets enumerates every usable IPv4 host address on the local
// network interfaces. To keep scans bounded we only expand subnets with a mask
// of /22 or smaller (at most ~1022 hosts per interface).
func localScanTargets() []string {
seen := make(map[string]struct{})
var targets []string
interfaces, err := net.Interfaces()
if err != nil {
log.Log.Error("onvif.localScanTargets(): " + err.Error())
return targets
}
for _, iface := range interfaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, addrErr := iface.Addrs()
if addrErr != nil {
continue
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok || ipNet.IP.To4() == nil {
continue
}
ones, bits := ipNet.Mask.Size()
if bits != 32 || ones < 22 {
// Skip huge or non-IPv4 ranges to avoid endless scans.
continue
}
for _, host := range hostsInNetwork(ipNet) {
if _, exists := seen[host]; exists {
continue
}
seen[host] = struct{}{}
targets = append(targets, host)
}
}
}
return targets
}
// hostsInNetwork returns all assignable host addresses in the given network,
// excluding the network and broadcast addresses.
func hostsInNetwork(ipNet *net.IPNet) []string {
var hosts []string
network := ipNet.IP.Mask(ipNet.Mask).To4()
if network == nil {
return hosts
}
for ip := cloneIP(network); ipNet.Contains(ip); incrementIP(ip) {
hosts = append(hosts, ip.String())
}
// Drop network + broadcast addresses when present.
if len(hosts) > 2 {
hosts = hosts[1 : len(hosts)-1]
}
return hosts
}
func cloneIP(ip net.IP) net.IP {
dup := make(net.IP, len(ip))
copy(dup, ip)
return dup
}
func incrementIP(ip net.IP) {
for i := len(ip) - 1; i >= 0; i-- {
ip[i]++
if ip[i] != 0 {
break
}
}
}
// hostFromXAddr extracts the host (IP) part from an ONVIF XAddr URL such as
// "http://192.168.1.69:8000/onvif/device_service".
func hostFromXAddr(xaddr string) string {
parsed, err := url.Parse(xaddr)
if err != nil {
return ""
}
host := parsed.Hostname()
if host == "" {
// Fall back to a naive split for values without a scheme.
host = strings.TrimPrefix(xaddr, "//")
if idx := strings.IndexAny(host, ":/"); idx >= 0 {
host = host[:idx]
}
}
return host
}
// readARPTable parses /proc/net/arp (Linux) and returns a map of IP -> MAC. On
// non-Linux platforms or when the file is unavailable it returns an empty map.
func readARPTable() map[string]string {
table := make(map[string]string)
file, err := os.Open("/proc/net/arp")
if err != nil {
return table
}
defer file.Close()
scanner := bufio.NewScanner(file)
// Skip the header line.
if scanner.Scan() {
_ = scanner.Text()
}
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 4 {
continue
}
ip := fields[0]
mac := fields[3]
if mac == "00:00:00:00:00:00" || mac == "" {
continue
}
table[ip] = strings.ToLower(mac)
}
return table
}
// vendorFromMAC resolves a MAC address to a known camera vendor using its OUI.
func vendorFromMAC(mac string) string {
normalized := strings.ToUpper(strings.NewReplacer(":", "", "-", "", ".", "").Replace(mac))
if len(normalized) < 6 {
return ""
}
// Try a longer prefix first (some vendors share the first 3 octets).
if len(normalized) >= 8 {
if vendor, ok := ouiVendors[normalized[:8]]; ok {
return vendor
}
}
if vendor, ok := ouiVendors[normalized[:6]]; ok {
return vendor
}
return ""
}
// reverseDNS performs a best-effort, time-bounded reverse DNS lookup.
func reverseDNS(ip string, timeout time.Duration) string {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var resolver net.Resolver
names, err := resolver.LookupAddr(ctx, ip)
if err != nil || len(names) == 0 {
return ""
}
return strings.TrimSuffix(names[0], ".")
}
// perHostTimeout derives a short per-connection dial timeout from the overall
// discovery timeout, clamped to a sensible range.
func perHostTimeout(timeout time.Duration) time.Duration {
dialTimeout := timeout / 4
if dialTimeout < 300*time.Millisecond {
dialTimeout = 300 * time.Millisecond
}
if dialTimeout > 1500*time.Millisecond {
dialTimeout = 1500 * time.Millisecond
}
return dialTimeout
}
func mergeSortedInts(existing, added []int) []int {
set := make(map[int]struct{}, len(existing)+len(added))
for _, value := range existing {
set[value] = struct{}{}
}
for _, value := range added {
set[value] = struct{}{}
}
merged := make([]int, 0, len(set))
for value := range set {
merged = append(merged, value)
}
sort.Ints(merged)
return merged
}
func mergeUniqueStrings(existing, added []string) []string {
set := make(map[string]struct{}, len(existing)+len(added))
merged := make([]string, 0, len(existing)+len(added))
for _, value := range append(append([]string{}, existing...), added...) {
if _, ok := set[value]; ok {
continue
}
set[value] = struct{}{}
merged = append(merged, value)
}
return merged
}
// ipLess compares two IPv4 address strings numerically.
func ipLess(a, b string) bool {
ipA := net.ParseIP(a).To4()
ipB := net.ParseIP(b).To4()
if ipA == nil || ipB == nil {
return a < b
}
for i := 0; i < 4; i++ {
if ipA[i] != ipB[i] {
return ipA[i] < ipB[i]
}
}
return false
}

View File

@@ -0,0 +1,225 @@
package onvif
import (
"context"
"errors"
"strconv"
"strings"
"time"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/onvif/event/stream"
)
// The library handles in-stream reconnect; these guards cover the
// initial-connect path the library cannot see.
const (
initialBackoff = time.Second
maxBackoff = 5 * time.Minute
)
// HandleONVIFEventStream opens an event/stream against the configured
// ONVIF camera and routes Motion events into communication.HandleMotion.
//
// Behind the Capture.ONVIFMotion flag; the goroutine returns
// immediately when not enabled. The flag is read once at start, so
// toggling at runtime requires an agent restart. On transient
// construction failure (camera not yet ready at boot, brief network
// blip, credential reload) the goroutine retries with exponential
// backoff. Exits when ctx is cancelled.
func HandleONVIFEventStream(ctx context.Context, configuration *models.Configuration, communication *models.Communication) {
log.Log.Debug("onvif.HandleONVIFEventStream(): started")
defer log.Log.Debug("onvif.HandleONVIFEventStream(): finished")
if !isONVIFMotionEnabled(configuration.Config.Capture.ONVIFMotion) {
return
}
if configuration.Config.Capture.IPCamera.ONVIFXAddr == "" {
log.Log.Warning("onvif.HandleONVIFEventStream(): ONVIFMotion enabled but ONVIFXAddr is empty; nothing to do")
return
}
backoff := initialBackoff
for {
if ctx.Err() != nil {
return
}
recoverable := runStreamOnce(ctx, configuration, communication)
if !recoverable {
return
}
if !sleepCtx(ctx, backoff) {
return
}
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
// runStreamOnce returns true when the caller should retry construction
// (transient failure), false on clean ctx-driven shutdown.
func runStreamOnce(ctx context.Context, configuration *models.Configuration, communication *models.Communication) (retry bool) {
camera := configuration.Config.Capture.IPCamera
device, _, err := ConnectToOnvifDevice(&camera)
if err != nil {
log.Log.Error("onvif.HandleONVIFEventStream(): connect: " + err.Error())
return true
}
deviceID := resolveDeviceID(configuration.Name, camera.ONVIFXAddr)
s, err := stream.NewStream(ctx, device, stream.Options{DeviceID: deviceID})
if err != nil {
log.Log.Error("onvif.HandleONVIFEventStream(): open stream: " + err.Error())
return true
}
defer func() {
if err := s.Close(); err != nil {
log.Log.Debug("onvif.HandleONVIFEventStream(): close: " + err.Error())
}
}()
log.Log.Info("onvif.HandleONVIFEventStream(): consuming events for " + deviceID)
// recovering = the first successful event after an error streak
// logs a recovery line so on-call operators see the clear-of-
// condition for the ERROR they were paged on.
var recovering bool
for {
select {
case <-ctx.Done():
return false
case ev, ok := <-s.Events():
if !ok {
return false
}
if recovering {
log.Log.Info("onvif.HandleONVIFEventStream(): event stream recovered for " + deviceID)
recovering = false
}
dispatchEvent(ctx, ev, configuration, communication)
case e, ok := <-s.Errors():
if !ok {
return false
}
recovering = true
logStreamError(e)
}
}
}
// dispatchEvent routes motion-active events to HandleMotion.
//
// The ctx pre-check + ctx-in-select guards a shutdown race: the agent
// closes HandleMotion shortly after cancelling ctx, and a stale event
// reaching the send would otherwise panic on a closed channel.
func dispatchEvent(ctx context.Context, ev stream.Event, configuration *models.Configuration, communication *models.Communication) {
topic := sanitiseTopic(ev.Topic)
if ev.Kind != stream.KindMotion {
log.Log.Debug("onvif.dispatchEvent(): non-motion event " + ev.Kind.String() + " topic=" + topic)
return
}
if ev.State != stream.StateActive {
return
}
if !isTransition(ev.Operation) {
log.Log.Debug("onvif.dispatchEvent(): " + ev.Operation.String() + " is not a transition, not a trigger: topic=" + topic)
return
}
if configuration.Config.Capture.Recording == "false" {
return
}
if ctx.Err() != nil {
return
}
dataToPass := models.MotionDataPartial{
Timestamp: time.Now().Unix(),
NumberOfChanges: 0, // ONVIF does not quantify motion area.
}
select {
case <-ctx.Done():
case communication.HandleMotion <- dataToPass:
// Logged on the send, not before it: this line records that a
// recording started, so a dropped event must not leave one.
log.Log.Debug("onvif.dispatchEvent(): recording trigger " + ev.Kind.String() + " topic=" + topic)
default:
log.Log.Debug("onvif.dispatchEvent(): HandleMotion full, dropping ONVIF motion event")
}
}
// isTransition reports whether an operation represents a state change.
// A camera replays every property's current state as Initialized on
// each new subscription and announces removals as Deleted; neither is
// motion starting. Absent (Unknown) counts — PropertyOperation is
// optional per WS-Notification and many non-property events omit it.
func isTransition(op stream.PropertyOperation) bool {
return op == stream.PropertyChanged || op == stream.PropertyUnknown
}
// maxLoggedTopic bounds a topic in the log; the wire imposes no limit,
// and the reject path logs every event received.
const maxLoggedTopic = 256
// sanitiseTopic makes a camera-controlled topic safe to concatenate
// into a log line. logrus's coloured text formatter writes the message
// unquoted, so a raw newline would let a camera forge entries in the
// log being used to diagnose it.
func sanitiseTopic(topic string) string {
if len(topic) > maxLoggedTopic {
topic = topic[:maxLoggedTopic] + "…(truncated)"
}
quoted := strconv.Quote(topic)
return quoted[1 : len(quoted)-1]
}
// logStreamError logs at a level matching severity: recreate is loud
// because it usually means the camera is offline; pull and renew are
// debug because the library recovers from them automatically.
func logStreamError(e error) {
var recreate stream.ErrRecreateFailed
var pull stream.ErrPullFailed
var renew stream.ErrRenewFailed
switch {
case errors.As(e, &recreate):
log.Log.Error("onvif.HandleONVIFEventStream(): subscription recreate failed (camera may be offline): " + recreate.Err.Error())
case errors.As(e, &renew):
log.Log.Debug("onvif.HandleONVIFEventStream(): renew failed (will recover via pull/recreate): " + renew.Err.Error())
case errors.As(e, &pull):
log.Log.Debug("onvif.HandleONVIFEventStream(): pull failed (will retry): " + pull.Err.Error())
default:
log.Log.Info("onvif.HandleONVIFEventStream(): stream error: " + e.Error())
}
}
func isONVIFMotionEnabled(v string) bool {
return strings.EqualFold(strings.TrimSpace(v), "true")
}
// resolveDeviceID falls back from operator-supplied name to ONVIF
// endpoint to a constant placeholder so log lines always have
// something to grep.
func resolveDeviceID(configName, xaddr string) string {
if n := strings.TrimSpace(configName); n != "" {
return n
}
if x := strings.TrimSpace(xaddr); x != "" {
return x
}
return "unknown"
}
// sleepCtx returns false if ctx was cancelled, true if d elapsed.
func sleepCtx(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}

View File

@@ -0,0 +1,375 @@
package onvif
import (
"bytes"
"context"
"strings"
"testing"
"time"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/onvif/event/stream"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func makeConfig(recording, onvifMotion, name string) *models.Configuration {
return &models.Configuration{
Name: name,
Config: models.Config{
Capture: models.Capture{
Recording: recording,
ONVIFMotion: onvifMotion,
},
},
}
}
func makeCommunication(buffer int) *models.Communication {
return &models.Communication{
HandleMotion: make(chan models.MotionDataPartial, buffer),
}
}
// --- dispatchEvent ---------------------------------------------------
func TestDispatchEvent_MotionActive_SendsToHandleMotion(t *testing.T) {
cfg := makeConfig("true", "true", "cam-1")
comm := makeCommunication(1)
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateActive}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
dispatchEvent(ctx, ev, cfg, comm)
select {
case m := <-comm.HandleMotion:
assert.NotZero(t, m.Timestamp)
case <-time.After(time.Second):
t.Fatal("expected motion data on HandleMotion")
}
}
func TestDispatchEvent_MotionInactive_DoesNotSend(t *testing.T) {
cfg := makeConfig("true", "true", "cam-1")
comm := makeCommunication(1)
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateInactive}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
dispatchEvent(ctx, ev, cfg, comm)
select {
case <-comm.HandleMotion:
t.Fatal("inactive motion must not reach HandleMotion (motion-stop is a follow-up)")
case <-time.After(100 * time.Millisecond):
}
}
func TestDispatchEvent_NonMotionKindIgnored(t *testing.T) {
cfg := makeConfig("true", "true", "cam-1")
comm := makeCommunication(1)
ev := stream.Event{Kind: stream.KindDigitalInput, State: stream.StateActive}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
dispatchEvent(ctx, ev, cfg, comm)
select {
case <-comm.HandleMotion:
t.Fatal("non-motion kinds must not reach HandleMotion")
case <-time.After(100 * time.Millisecond):
}
}
// captureDebugLog redirects logrus to a buffer at debug level for the
// duration of a test and returns what was written. It mutates package
// globals, so callers must not run in parallel.
func captureDebugLog(t *testing.T) *bytes.Buffer {
t.Helper()
var buf bytes.Buffer
prevOut, prevLevel := logrus.StandardLogger().Out, logrus.GetLevel()
logrus.SetOutput(&buf)
logrus.SetLevel(logrus.DebugLevel)
t.Cleanup(func() {
logrus.SetOutput(prevOut)
logrus.SetLevel(prevLevel)
})
return &buf
}
// TestDispatchEvent_LogsTheTriggeringTopic — a dispatched event is what
// actually starts a recording, so its topic is the one an operator needs
// when a camera records for the wrong reason (or the right reason and
// nobody can prove which). Rejected events were already logged; without
// this the triggering topic is only knowable by elimination.
func TestDispatchEvent_LogsTheTriggeringTopic(t *testing.T) {
buf := captureDebugLog(t)
cfg := makeConfig("true", "true", "cam-1")
comm := makeCommunication(1)
ev := stream.Event{
Kind: stream.KindMotion,
State: stream.StateActive,
Topic: "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1",
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
dispatchEvent(ctx, ev, cfg, comm)
assert.Contains(t, buf.String(), "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1",
"the dispatched event's topic must appear in the log")
assert.Contains(t, buf.String(), "Motion",
"the dispatched event's Kind must appear in the log")
}
// TestDispatchEvent_PropertyOperation — a camera replays the current
// state of every property topic as Initialized whenever a pull-point
// subscription is created. If that counts as a trigger, every
// reconnect restarts a recording for any motion property that happens
// to be active, and a flapping subscription manufactures motion out of
// nothing. Only reject Initialized specifically: PropertyOperation is
// optional per WS-Notification and absent on many non-property events,
// which decode reports as PropertyUnknown.
func TestDispatchEvent_PropertyOperation(t *testing.T) {
tests := []struct {
name string
op stream.PropertyOperation
wantSend bool
}{
{"changed is a real transition", stream.PropertyChanged, true},
{"absent attribute still counts", stream.PropertyUnknown, true},
{"initialized is a subscription state replay", stream.PropertyInitialized, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := makeConfig("true", "true", "cam-1")
comm := makeCommunication(1)
ev := stream.Event{
Kind: stream.KindMotion,
State: stream.StateActive,
Operation: tt.op,
Topic: "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1",
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
dispatchEvent(ctx, ev, cfg, comm)
select {
case <-comm.HandleMotion:
if !tt.wantSend {
t.Fatalf("%v must not trigger a recording", tt.op)
}
case <-time.After(100 * time.Millisecond):
if tt.wantSend {
t.Fatalf("%v must trigger a recording", tt.op)
}
}
})
}
}
func TestDispatchEvent_RecordingDisabled_DoesNotSend(t *testing.T) {
cfg := makeConfig("false", "true", "cam-1")
comm := makeCommunication(1)
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateActive}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
dispatchEvent(ctx, ev, cfg, comm)
select {
case <-comm.HandleMotion:
t.Fatal("Recording=false must gate the send (matches computervision behaviour)")
case <-time.After(100 * time.Millisecond):
}
}
func TestDispatchEvent_HandleMotionFull_DropsRatherThanBlocks(t *testing.T) {
cfg := makeConfig("true", "true", "cam-1")
// Pre-fill the buffer so the next send would block.
comm := &models.Communication{HandleMotion: make(chan models.MotionDataPartial, 1)}
comm.HandleMotion <- models.MotionDataPartial{}
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateActive}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
dispatchEvent(ctx, ev, cfg, comm)
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("dispatchEvent must drop when HandleMotion is full, not block")
}
}
func TestDispatchEvent_CtxCancelledAndHandleMotionClosed_DoesNotPanic(t *testing.T) {
// Regression for the shutdown race: between cancel() and
// close(HandleMotion) the agent leaves a 3s window. If dispatchEvent
// runs in that window AFTER the channel is closed, a non-protected
// send would panic. The ctx pre-check must short-circuit before the
// send is attempted.
cfg := makeConfig("true", "true", "cam-1")
comm := &models.Communication{HandleMotion: make(chan models.MotionDataPartial, 1)}
close(comm.HandleMotion)
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateActive}
ctx, cancel := context.WithCancel(context.Background())
cancel() // already cancelled, matching the shutdown sequence
assert.NotPanics(t, func() {
dispatchEvent(ctx, ev, cfg, comm)
})
}
// --- isONVIFMotionEnabled --------------------------------------------
func TestIsONVIFMotionEnabled_CaseAndWhitespace(t *testing.T) {
tests := []struct {
in string
want bool
}{
{"true", true},
{"True", true},
{"TRUE", true},
{" true", true},
{"true ", true},
{" true ", true},
{"false", false},
{"False", false},
{"", false},
{"yes", false},
{"1", false},
}
for _, tc := range tests {
t.Run(tc.in, func(t *testing.T) {
assert.Equal(t, tc.want, isONVIFMotionEnabled(tc.in))
})
}
}
// --- resolveDeviceID -------------------------------------------------
func TestResolveDeviceID_FallbackChain(t *testing.T) {
tests := []struct {
name string
cfgName string
xaddr string
want string
}{
{"name_set", "front-door", "192.168.1.10", "front-door"},
{"name_empty_xaddr_set", "", "192.168.1.10", "192.168.1.10"},
{"name_whitespace_only_xaddr_set", " ", "192.168.1.10", "192.168.1.10"},
{"both_empty", "", "", "unknown"},
{"name_with_trailing_whitespace", "cam-2 ", "192.168.1.10", "cam-2"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, resolveDeviceID(tc.cfgName, tc.xaddr))
})
}
}
// TestDispatchEvent_OnlyRealTransitionsTrigger — a camera replays every
// property topic's state on each new subscription (Initialized) and
// announces removals (Deleted). Neither is a motion transition, and a
// flapping pull-point would otherwise manufacture recordings out of
// replayed state. PropertyOperation is optional per WS-Notification, so
// absent (Unknown) still counts — many non-property events omit it.
func TestDispatchEvent_OnlyRealTransitionsTrigger(t *testing.T) {
tests := []struct {
op stream.PropertyOperation
wantSend bool
}{
{stream.PropertyChanged, true},
{stream.PropertyUnknown, true},
{stream.PropertyInitialized, false},
{stream.PropertyDeleted, false},
}
for _, tt := range tests {
t.Run(tt.op.String(), func(t *testing.T) {
cfg := makeConfig("true", "true", "cam-1")
comm := makeCommunication(1)
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateActive, Operation: tt.op}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
dispatchEvent(ctx, ev, cfg, comm)
if tt.wantSend {
require.Len(t, comm.HandleMotion, 1, "%v must trigger a recording", tt.op)
return
}
require.Empty(t, comm.HandleMotion, "%v must not trigger a recording", tt.op)
})
}
}
// TestSanitiseTopic — ev.Topic is camera-controlled and reaches the log
// unmodified. logrus's coloured text formatter (the default) writes the
// message without quoting, so an embedded newline forges whole log
// lines: a compromised camera can fabricate ERROR entries or spoof
// another device's id, in the logs an operator is reading to diagnose
// that very camera. Length is also unbounded on the wire, and the
// reject path logs every event, so an oversized topic is a cheap way to
// evict a container's whole retained history.
func TestSanitiseTopic(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"ordinary topic passes through", "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1", "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1"},
{"newline cannot forge a line", "a\nERRO[fake] boom", `a\nERRO[fake] boom`},
{"carriage return", "a\rb", `a\rb`},
{"tab", "a\tb", `a\tb`},
{"NUL", "a\x00b", `a\x00b`},
{"empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := sanitiseTopic(tt.in)
assert.Equal(t, tt.want, got)
assert.NotContains(t, got, "\n", "no raw newline may survive")
assert.NotContains(t, got, "\r", "no raw carriage return may survive")
})
}
}
func TestSanitiseTopic_Truncates(t *testing.T) {
got := sanitiseTopic(strings.Repeat("x", maxLoggedTopic*2))
assert.LessOrEqual(t, len(got), maxLoggedTopic+len("…(truncated)"))
assert.Contains(t, got, "truncated")
}
// TestDispatchEvent_LogsTriggerOnlyWhenSent — the trigger line is the
// record that a recording started. Logging it before the send means a
// dropped event (full channel, or shutdown) leaves a line claiming a
// recording that never began.
func TestDispatchEvent_LogsTriggerOnlyWhenSent(t *testing.T) {
buf := captureDebugLog(t)
cfg := makeConfig("true", "true", "cam-1")
comm := &models.Communication{HandleMotion: make(chan models.MotionDataPartial, 1)}
comm.HandleMotion <- models.MotionDataPartial{} // full
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateActive, Topic: "tns1:VideoSource/MotionAlarm"}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
dispatchEvent(ctx, ev, cfg, comm)
assert.NotContains(t, buf.String(), "recording trigger",
"a dropped event must not be logged as a trigger")
assert.Contains(t, buf.String(), "dropping", "the drop itself must still be logged")
}

View File

@@ -0,0 +1,380 @@
package onvif
import (
"bufio"
"net"
"strconv"
"strings"
"time"
)
// deviceFingerprint holds the identifying information we can gather from a host
// without any credentials. It is populated by grabbing the RTSP and HTTP
// service banners and is then distilled into a manufacturer, model and a
// human-readable device type (e.g. "IP Camera", "DVR/NVR").
type deviceFingerprint struct {
Manufacturer string
Model string
Type string
Server string
// realm is the WWW-Authenticate realm advertised by the HTTP service. Many
// cameras expose their model or vendor here (e.g. realm="Hikvision").
realm string
// body holds a lower-cased slice of the HTTP landing page, fetched only when
// the banners are anonymous. Rebadged/OEM cameras often reveal their vendor
// there (logo filenames, embedded scripts), e.g. ADI "Capture".
body string
// IsCamera is set when the collected evidence confidently identifies the
// device as a camera, NVR or DVR.
IsCamera bool
// IsAudio is set for audio-only devices (IP speakers / intercoms, e.g. TOA)
// that use RTSP for audio rather than video.
IsAudio bool
}
// bannerVendors maps a lower-cased substring commonly found in RTSP/HTTP
// service banners or auth realms to a manufacturer. The list is ordered so the
// most specific matches win. This mirrors how tools such as Fing or ONVIF
// Device Manager fingerprint a device from its network banners.
var bannerVendors = []struct {
Match string
Vendor string
IsCamera bool
}{
{"hikvision", "Hikvision", true},
{"dahua", "Dahua", true},
{"axis", "Axis", true},
{"reolink", "Reolink", true},
{"amcrest", "Amcrest", true},
{"vivotek", "Vivotek", true},
{"mobotix", "Mobotix", true},
{"hanwha", "Hanwha", true},
{"wisenet", "Hanwha", true},
{"bosch", "Bosch", true},
{"foscam", "Foscam", true},
{"ubiquiti", "Ubiquiti", true},
{"unifi", "Ubiquiti", true},
{"uniview", "Uniview", true},
{"tp-link", "TP-Link", true},
{"tapo", "TP-Link", true},
{"linksys", "Linksys", true},
{"d-link", "D-Link", true},
{"dlink", "D-Link", true},
{"trendnet", "Trendnet", true},
{"lorex", "Lorex", true},
{"honeywell", "Honeywell", true},
{"pelco", "Pelco", true},
{"toa rtsp", "TOA", false},
{"hipcam", "Hipcam", true},
{"h264dvr", "Generic DVR", true},
{"dvrdvs", "Hikvision", true},
{"webs", "", false}, // generic embedded web server, no vendor
{"rtsp server", "", true},
{"gstreamer", "", true},
{"live555", "", true},
}
// bodyVendors maps a distinctive lower-cased substring found in a camera's HTML
// landing page (logo filename, embedded script, product string) to a
// manufacturer. Used only when the RTSP/HTTP banners are anonymous, so it can
// identify rebadged/OEM cameras (e.g. ADI "Capture") that hide their model
// behind a generic "httpd" server and an "RTSP" realm.
var bodyVendors = []struct {
Match string
Vendor string
IsCamera bool
}{
{"logo_white(capture)", "Capture", true},
{"logo_capture", "Capture", true},
}
// genericRealms are auth realms that carry no useful model/vendor information.
var genericRealms = map[string]struct{}{
"": {},
"ip camera": {},
"ipcamera": {},
"camera": {},
"login": {},
"index": {},
"streaming": {},
"realm": {},
"network video": {},
"web": {},
"protected": {},
"authorized users only": {},
"please log in with a valid username.": {},
"please log in with a valid username": {},
}
// fingerprintHost grabs the RTSP and HTTP banners for the given host (based on
// the ports found open during the scan) and classifies the device. It performs
// at most two lightweight, unauthenticated requests and is safe to run
// concurrently for every host.
func fingerprintHost(ip string, openPorts []int, timeout time.Duration) deviceFingerprint {
var fp deviceFingerprint
// 1) RTSP OPTIONS on the first open RTSP port. The Server response header of
// most camera RTSP stacks reveals the device (e.g. "Dahua Rtsp Server",
// "Hipcam RealServer/V1.0", "H264DVR 1.0").
for _, port := range openPorts {
if port == 554 || port == 8554 {
if banner := rtspServerBanner(ip, port, timeout); banner != "" {
fp.Server = banner
}
break
}
}
// 2) HTTP banner + auth realm on the first open HTTP/ONVIF port. Cameras
// frequently expose their vendor/model in the Server header or the
// WWW-Authenticate realm.
httpPort := 0
for _, port := range openPorts {
if port == 80 || port == 8080 || port == 8000 {
server, realm := httpBanner(ip, port, timeout)
if fp.Server == "" {
fp.Server = server
}
fp.realm = realm
httpPort = port
break
}
}
// 3) When the banners are anonymous (generic server, no vendor realm), fetch
// a slice of the landing page. Rebadged/OEM cameras (e.g. ADI "Capture")
// only reveal their vendor in the HTML.
if httpPort != 0 && isGenericServer(fp.Server) {
fp.body = httpBody(ip, httpPort, timeout)
}
classifyFingerprint(&fp, openPorts)
return fp
}
// isGenericServer reports whether an HTTP Server header is a generic embedded
// web server that carries no vendor information (so the HTML body is worth a
// look).
func isGenericServer(server string) bool {
s := strings.ToLower(strings.TrimSpace(server))
if s == "" {
return true
}
for _, generic := range []string{"httpd", "webs", "boa", "lighttpd", "nginx", "gsoap", "mini_httpd", "thttpd", "apache"} {
if strings.Contains(s, generic) {
return true
}
}
return false
}
// httpBody issues an unauthenticated HTTP GET / and returns a lower-cased,
// size-bounded slice of the response (headers + body). Best-effort; empty on
// error.
func httpBody(ip string, port int, timeout time.Duration) string {
address := net.JoinHostPort(ip, strconv.Itoa(port))
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return ""
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
request := "GET / HTTP/1.0\r\nHost: " + ip + "\r\nUser-Agent: KerberosDiscovery\r\nAccept: */*\r\n\r\n"
if _, err := conn.Write([]byte(request)); err != nil {
return ""
}
var builder strings.Builder
buf := make([]byte, 4096)
for builder.Len() < 65536 {
n, err := conn.Read(buf)
if n > 0 {
builder.Write(buf[:n])
}
if err != nil {
break
}
}
return strings.ToLower(builder.String())
}
// rtspServerBanner issues an unauthenticated RTSP OPTIONS request and returns
// the value of the Server response header (empty when the host does not answer
// or exposes no banner).
func rtspServerBanner(ip string, port int, timeout time.Duration) string {
address := net.JoinHostPort(ip, strconv.Itoa(port))
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return ""
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
request := "OPTIONS rtsp://" + address + " RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: KerberosDiscovery\r\n\r\n"
if _, err := conn.Write([]byte(request)); err != nil {
return ""
}
headers := readBannerHeaders(conn)
return headers["server"]
}
// httpBanner issues an unauthenticated HTTP HEAD request and returns the Server
// header and the WWW-Authenticate realm (both best-effort, empty when absent).
func httpBanner(ip string, port int, timeout time.Duration) (server string, realm string) {
address := net.JoinHostPort(ip, strconv.Itoa(port))
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return "", ""
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
request := "HEAD / HTTP/1.0\r\nHost: " + ip + "\r\nUser-Agent: KerberosDiscovery\r\nAccept: */*\r\n\r\n"
if _, err := conn.Write([]byte(request)); err != nil {
return "", ""
}
headers := readBannerHeaders(conn)
return headers["server"], parseRealm(headers["www-authenticate"])
}
// readBannerHeaders reads a status line followed by header lines from an
// RTSP/HTTP response and returns the headers keyed by their lower-cased name.
// Only the first occurrence of a header is kept.
func readBannerHeaders(conn net.Conn) map[string]string {
headers := make(map[string]string)
reader := bufio.NewReader(conn)
// Discard the status line (e.g. "RTSP/1.0 200 OK" or "HTTP/1.1 401 ...").
if _, err := reader.ReadString('\n'); err != nil {
return headers
}
for {
line, err := reader.ReadString('\n')
if err != nil {
break
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
idx := strings.Index(line, ":")
if idx <= 0 {
continue
}
key := strings.ToLower(strings.TrimSpace(line[:idx]))
value := strings.TrimSpace(line[idx+1:])
if _, exists := headers[key]; !exists {
headers[key] = value
}
}
return headers
}
// parseRealm extracts the realm token from a WWW-Authenticate header value such
// as `Digest realm="Hikvision", nonce="..."`.
func parseRealm(header string) string {
lower := strings.ToLower(header)
marker := "realm="
idx := strings.Index(lower, marker)
if idx < 0 {
return ""
}
value := header[idx+len(marker):]
value = strings.TrimSpace(value)
if strings.HasPrefix(value, "\"") {
value = value[1:]
if end := strings.Index(value, "\""); end >= 0 {
value = value[:end]
}
} else if end := strings.IndexAny(value, ", "); end >= 0 {
value = value[:end]
}
return strings.TrimSpace(value)
}
// classifyFingerprint distils the collected banners and open ports into a
// manufacturer, model and device type. It also decides whether the evidence is
// strong enough to consider the host a camera/NVR.
func classifyFingerprint(fp *deviceFingerprint, openPorts []int) {
haystack := strings.ToLower(fp.Server + " " + fp.realm)
// Manufacturer from the banner/realm.
for _, entry := range bannerVendors {
if !strings.Contains(haystack, entry.Match) {
continue
}
if entry.Vendor != "" && fp.Manufacturer == "" {
fp.Manufacturer = entry.Vendor
}
if entry.IsCamera {
fp.IsCamera = true
}
if fp.Manufacturer != "" {
break
}
}
// Model from the auth realm when it looks specific (not a generic word).
if fp.Model == "" && fp.realm != "" {
if _, generic := genericRealms[strings.ToLower(fp.realm)]; !generic {
if !strings.EqualFold(fp.realm, fp.Manufacturer) {
fp.Model = fp.realm
}
}
}
// Vendor from the HTML landing page when the banners revealed nothing.
// Rebadged/OEM cameras (e.g. ADI "Capture") only identify themselves via
// logo filenames or embedded scripts.
if fp.Manufacturer == "" && fp.body != "" {
for _, entry := range bodyVendors {
if strings.Contains(fp.body, entry.Match) {
fp.Manufacturer = entry.Vendor
if entry.IsCamera {
fp.IsCamera = true
}
break
}
}
}
// Device type from ports and banners.
hasRTSP := containsInt(openPorts, 554) || containsInt(openPorts, 8554)
hasONVIF := containsInt(openPorts, 8000) || containsInt(openPorts, 8899)
hasDVRPort := containsInt(openPorts, 37777) || containsInt(openPorts, 34567)
// Audio devices (IP speakers / intercoms) also speak RTSP, but for audio
// rather than video, so classify them separately and never as a camera.
if fp.Manufacturer == "TOA" ||
strings.Contains(haystack, "speaker") ||
strings.Contains(haystack, "sip audio") ||
strings.Contains(haystack, "audio server") {
fp.IsAudio = true
fp.IsCamera = false
fp.Type = "IP Speaker/Audio"
return
}
switch {
case strings.Contains(haystack, "nvr"):
fp.Type = "NVR"
fp.IsCamera = true
case strings.Contains(haystack, "dvr") || hasDVRPort:
fp.Type = "DVR/NVR"
fp.IsCamera = true
case hasRTSP || hasONVIF:
fp.Type = "IP Camera"
fp.IsCamera = true
case fp.IsCamera:
fp.Type = "IP Camera"
}
}
func containsInt(values []int, target int) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@@ -10,7 +10,6 @@ import (
"strings"
"time"
onvifc "github.com/cedricve/go-onvif"
"github.com/gin-gonic/gin"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
@@ -24,19 +23,81 @@ import (
xsdonvif "github.com/kerberos-io/onvif/xsd/onvif"
)
func Discover(timeout time.Duration) {
log.Log.Info("onvif.Discover(): Discovering devices")
log.Log.Info("Waiting for " + timeout.String())
devices, err := onvifc.StartDiscovery(timeout)
if err != nil {
log.Log.Error("onvif.Discover(): " + err.Error())
} else {
for _, device := range devices {
hostname, _ := device.GetHostname()
log.Log.Info("onvif.Discover(): " + hostname.Name + " (" + device.XAddr + ")")
// Discover performs an advanced Fing/WiFiman-style scan of the local network
// (ONVIF WS-Discovery + active port scan + MAC/vendor lookup) and prints a
// human readable summary of everything it finds. It is used by the
// `-action discover` CLI command. Optional subnets (CIDR, e.g.
// "192.168.1.0/24") override the auto-detected local subnets.
func Discover(timeout time.Duration, subnets ...string) {
log.Log.Info("onvif.Discover(): starting advanced network discovery")
log.Log.Info("onvif.Discover(): this may take up to " + timeout.String() + " for the ONVIF probe plus the port scan")
devices := DiscoverDevices(timeout, subnets...)
if len(devices) == 0 {
log.Log.Info("onvif.Discover(): no devices discovered on the local network")
return
}
cameraCount := 0
for _, device := range devices {
if device.IsCamera {
cameraCount++
}
if len(devices) == 0 {
log.Log.Info("onvif.Discover(): No devices descovered\n")
}
log.Log.Info("onvif.Discover(): found " + strconv.Itoa(len(devices)) + " device(s), " + strconv.Itoa(cameraCount) + " likely camera(s)")
for _, device := range devices {
label := "device"
if device.IsCamera {
label = "camera"
} else if device.IsAudio {
label = "speaker"
}
summary := "onvif.Discover(): [" + label + "] " + device.IP
if device.Hostname != "" {
summary += " (" + device.Hostname + ")"
}
if device.MAC != "" {
summary += " mac=" + device.MAC
}
if device.Vendor != "" {
summary += " vendor=" + device.Vendor
}
if device.Type != "" {
summary += " type=" + device.Type
}
if device.Manufacturer != "" {
summary += " manufacturer=" + device.Manufacturer
}
if device.Model != "" {
summary += " model=" + device.Model
}
if device.Server != "" {
summary += " server=\"" + device.Server + "\""
}
if device.ONVIF {
summary += " onvif=" + device.ONVIFXAddr
}
if len(device.Services) > 0 {
summary += " services=[" + strings.Join(device.Services, ", ") + "]"
}
if device.RTSPURL != "" {
summary += " rtsp=" + device.RTSPURL
}
log.Log.Info(summary)
// Detail the guessed RTSP stream URLs from the brand -> RTSP mapping.
for _, stream := range device.RTSPStreams {
status := "guess"
if stream.Verified {
status = "confirmed"
}
line := "onvif.Discover(): -> " + stream.Stream + " stream [" + status + "]"
if stream.RequiresAuth {
line += " (auth required)"
}
line += ": " + stream.URL
log.Log.Info(line)
}
}
}

View File

@@ -1,6 +1,10 @@
package http
import (
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
@@ -17,6 +21,38 @@ import (
// @Success 200 {object} models.Authorization
func Login() {}
// DiscoverCameras godoc
// @Router /api/camera/discover [get]
// @ID camera-discover
// @Tags onvif
// @Param timeout query int false "Discovery timeout in milliseconds (default 2000)"
// @Param subnet query string false "Optional subnet(s) to scan, e.g. '192.168.1.0/24' (comma-separated). Defaults to the local interfaces."
// @Summary Discover cameras and other devices on the local network.
// @Description Runs an advanced Fing/WiFiman-style scan (ONVIF WS-Discovery + TCP port scan + MAC/vendor lookup) and returns the devices found on the local network.
// @Success 200 {object} models.APIResponse
func DiscoverCameras(c *gin.Context) {
timeout := 2000 * time.Millisecond
if raw := c.Query("timeout"); raw != "" {
if milliseconds, err := strconv.Atoi(raw); err == nil && milliseconds > 0 {
timeout = time.Duration(milliseconds) * time.Millisecond
}
}
var subnets []string
if raw := c.Query("subnet"); raw != "" {
for _, part := range strings.Split(raw, ",") {
if trimmed := strings.TrimSpace(part); trimmed != "" {
subnets = append(subnets, trimmed)
}
}
}
devices := onvif.DiscoverDevices(timeout, subnets...)
c.JSON(200, models.APIResponse{
Data: devices,
})
}
// LoginToOnvif godoc
// @Router /api/camera/onvif/login [post]
// @ID camera-onvif-login

View File

@@ -96,6 +96,7 @@ func AddRoutes(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware, configDirect
})
// Onvif specific methods.
api.GET("/camera/discover", DiscoverCameras)
api.POST("/camera/onvif/verify", onvif.VerifyOnvifConnection)
api.POST("/camera/onvif/login", LoginToOnvif)
api.POST("/camera/onvif/capabilities", GetOnvifCapabilities)

View File

@@ -14,7 +14,10 @@ import (
"sync"
"time"
"context"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/kerberos-io/agent/machinery/src/capture"
configService "github.com/kerberos-io/agent/machinery/src/config"
"github.com/kerberos-io/agent/machinery/src/encryption"
"github.com/kerberos-io/agent/machinery/src/log"
@@ -329,7 +332,7 @@ func MQTTListenerHandler(mqttClient mqtt.Client, hubKey string, configDirectory
case "record":
go HandleRecording(mqttClient, hubKey, payload, configuration, communication)
case "get-audio-backchannel":
go HandleAudio(mqttClient, hubKey, payload, configuration, communication)
HandleAudio(mqttClient, hubKey, payload, configuration, communication)
case "get-ptz-position":
go HandleGetPTZPosition(mqttClient, hubKey, payload, configuration, communication)
case "update-ptz-position":
@@ -338,6 +341,8 @@ func MQTTListenerHandler(mqttClient mqtt.Client, hubKey string, configDirectory
go HandleNavigatePTZ(mqttClient, hubKey, payload, configuration, communication)
case "request-config":
go HandleRequestConfig(mqttClient, hubKey, payload, configuration, communication)
case "verify-stream":
go HandleVerifyStream(mqttClient, hubKey, payload, configuration, communication)
case "update-config":
go HandleUpdateConfig(mqttClient, hubKey, payload, configDirectory, configuration, communication)
case "request-sd-stream":
@@ -375,11 +380,52 @@ func HandleRecording(mqttClient mqtt.Client, hubKey string, payload models.Paylo
var recordPayload models.RecordPayload
json.Unmarshal(jsonData, &recordPayload)
if recordPayload.Timestamp != 0 {
motionDataPartial := models.MotionDataPartial{
Timestamp: recordPayload.Timestamp,
timestamp := recordPayload.Timestamp
if timestamp == 0 {
timestamp = time.Now().Unix()
}
if recordPayload.Recording {
now := time.Now().UnixMilli()
if recordPayload.Heartbeat {
// Keep-alive from a viewer that supports heartbeats. Only refresh while
// a manual recording is actually running; if it already auto-stopped
// (heartbeat timeout / max duration) we IGNORE it so a stray heartbeat
// can't restart a recording we just ended. Seeing a heartbeat also arms
// the recorder's heartbeat-timeout auto-stop.
if communication.IsRecordingManual.IsSet() {
communication.RecordingManualHeartbeat.Store(now)
communication.RecordingManualHeartbeatSeen.Set()
log.Log.Debug("routers.mqtt.main.HandleRecording(): manual recording heartbeat received.")
} else {
log.Log.Debug("routers.mqtt.main.HandleRecording(): ignoring heartbeat, no active manual recording.")
}
} else {
// Explicit start from the live view (record button). Start a manual
// recording and keep it running — the motion recorder honours
// communication.IsRecordingManual and won't auto-close on the
// post-recording timeout while it's set. We also inject a motion event
// so the recording starts immediately, even when nothing is moving.
communication.RecordingManualHeartbeat.Store(now)
if communication.IsRecordingManual.SetToIf(false, true) {
communication.RecordingManualStart.Store(now)
communication.RecordingManualHeartbeatSeen.UnSet()
log.Log.Info("routers.mqtt.main.HandleRecording(): manual recording started.")
select {
case communication.HandleMotion <- models.MotionDataPartial{Timestamp: timestamp, NumberOfChanges: 100000000}:
default:
log.Log.Warning("routers.mqtt.main.HandleRecording(): motion channel full, manual recording start not queued.")
}
}
}
communication.HandleMotion <- motionDataPartial
} else {
// Stop the manual recording; the motion recorder closes the clip once the
// post-recording window elapses. Clear the heartbeat/start markers too.
log.Log.Info("routers.mqtt.main.HandleRecording(): manual recording stopped.")
communication.IsRecordingManual.UnSet()
communication.RecordingManualHeartbeat.Store(0)
communication.RecordingManualStart.Store(0)
communication.RecordingManualHeartbeatSeen.UnSet()
}
}
@@ -396,10 +442,31 @@ func HandleAudio(mqttClient mqtt.Client, hubKey string, payload models.Payload,
Timestamp: audioPayload.Timestamp,
Data: audioPayload.Data,
}
communication.HandleAudio <- audioDataPartial
if enqueueLatestAudio(communication.HandleAudio, audioDataPartial) {
log.Log.Debug("routers.mqtt.main.HandleAudio(): dropped stale audio because the backchannel queue was full")
}
}
}
func enqueueLatestAudio(audioChannel chan models.AudioDataPartial, audio models.AudioDataPartial) bool {
select {
case audioChannel <- audio:
return false
default:
}
select {
case <-audioChannel:
default:
}
select {
case audioChannel <- audio:
default:
}
return true
}
func HandleGetPTZPosition(mqttClient mqtt.Client, hubKey string, payload models.Payload, configuration *models.Configuration, communication *models.Communication) {
value := payload.Value
@@ -505,6 +572,98 @@ func HandleRequestConfig(mqttClient mqtt.Client, hubKey string, payload models.P
}
}
// HandleVerifyStream probes an RTSP stream (the one supplied in the request, or
// the currently configured main/sub stream) and reports back whether it can be
// connected to and decoded, along with the discovered codec/resolution/fps.
func HandleVerifyStream(mqttClient mqtt.Client, hubKey string, payload models.Payload, configuration *models.Configuration, communication *models.Communication) {
value := payload.Value
// Convert map[string]interface{} to VerifyStreamPayload
jsonData, _ := json.Marshal(value)
var verifyPayload models.VerifyStreamPayload
json.Unmarshal(jsonData, &verifyPayload)
if verifyPayload.Timestamp == 0 {
return
}
stream := verifyPayload.Stream
if stream != "sub" {
stream = "main"
}
// Resolve which RTSP url to verify: prefer the one supplied in the request
// (so users can verify unsaved edits), otherwise fall back to the configured
// stream url for the requested stream type.
rtspUrl := verifyPayload.RTSP
if rtspUrl == "" {
if stream == "sub" {
rtspUrl = configuration.Config.Capture.IPCamera.SubRTSP
} else {
rtspUrl = configuration.Config.Capture.IPCamera.RTSP
}
}
success := false
errMsg := ""
width := 0
height := 0
codec := ""
fps := 0.0
if rtspUrl == "" {
errMsg = "No RTSP url configured for this stream."
} else {
// Probe the stream with a bounded timeout so a dead/unreachable camera
// can't hang the handler goroutine.
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
rtspClient := &capture.Golibrtsp{Url: rtspUrl}
errConnect := rtspClient.Connect(ctx, ctx)
if errConnect != nil {
errMsg = errConnect.Error()
} else {
videoStreams, errStreams := rtspClient.GetVideoStreams()
if errStreams != nil || len(videoStreams) == 0 {
errMsg = "Connected, but no decodable video stream was found."
} else {
success = true
vs := videoStreams[0]
width = vs.Width
height = vs.Height
codec = vs.Name
fps = vs.FPS
}
}
// Always release the connection.
rtspClient.Close(ctx)
}
message := models.Message{
Payload: models.Payload{
Action: "verify-stream-result",
DeviceId: configuration.Config.Key,
Value: map[string]interface{}{
"timestamp": verifyPayload.Timestamp,
"stream": stream,
"success": success,
"error": errMsg,
"width": width,
"height": height,
"codec": codec,
"fps": fps,
},
},
}
packagedPayload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey, 2, false, packagedPayload)
} else {
log.Log.Info("routers.mqtt.main.HandleVerifyStream(): something went wrong while sending result to hub: " + string(packagedPayload))
}
}
func HandleUpdateConfig(mqttClient mqtt.Client, hubKey string, payload models.Payload, configDirectory string, configuration *models.Configuration, communication *models.Communication) {
value := payload.Value

View File

@@ -0,0 +1,39 @@
package mqtt
import (
"testing"
"time"
"github.com/kerberos-io/agent/machinery/src/models"
)
func TestEnqueueLatestAudioReplacesOldestFrameWhenFull(t *testing.T) {
audioChannel := make(chan models.AudioDataPartial, 2)
audioChannel <- models.AudioDataPartial{Timestamp: 1}
audioChannel <- models.AudioDataPartial{Timestamp: 2}
dropped := enqueueLatestAudio(audioChannel, models.AudioDataPartial{Timestamp: 3})
if !dropped {
t.Fatal("enqueueLatestAudio() dropped = false, want true")
}
first := <-audioChannel
second := <-audioChannel
if first.Timestamp != 2 || second.Timestamp != 3 {
t.Fatalf("queued timestamps = (%d, %d), want (2, 3)", first.Timestamp, second.Timestamp)
}
}
func TestEnqueueLatestAudioDoesNotBlockNilChannel(t *testing.T) {
done := make(chan struct{})
go func() {
enqueueLatestAudio(nil, models.AudioDataPartial{Timestamp: 1})
close(done)
}()
select {
case <-done:
case <-time.After(100 * time.Millisecond):
t.Fatal("enqueueLatestAudio() blocked on a nil channel")
}
}

View File

@@ -283,6 +283,8 @@ func (mp4 *MP4) flushPendingVideoSample(nextPTS uint64) bool {
err := mp4.MultiTrackFragment.AddFullSampleToTrack(*mp4.VideoFullSample, uint32(mp4.VideoTrack))
if err != nil {
log.Log.Error("mp4.flushPendingVideoSample(): error adding sample: " + err.Error())
} else {
mp4.SampleCount++
}
if isKF {
mp4.TotalKeyframesWritten++
@@ -296,6 +298,15 @@ func (mp4 *MP4) flushPendingVideoSample(nextPTS uint64) bool {
return true
}
// AverageFPS returns the average frame rate of the video samples actually
// committed to this recording.
func (mp4 *MP4) AverageFPS() float64 {
if mp4.SampleCount == 0 || mp4.VideoTotalDuration == 0 {
return 0
}
return float64(mp4.SampleCount) * 1000 / float64(mp4.VideoTotalDuration)
}
// AddSampleToTrack appends a sample to the given track.
//
// For video, pts is the decode timestamp (DTS, in milliseconds) and

View File

@@ -2,6 +2,7 @@ package video
import (
"fmt"
"math"
"os"
"testing"
@@ -173,4 +174,10 @@ func TestMP4Duration(t *testing.T) {
t.Errorf("MISMATCH: mdhd.Duration should be 0 for fragmented MP4, got %d",
parsedFile.Moov.Traks[0].Mdia.Mdhd.Duration)
}
if mp4Video.SampleCount != sampleCount {
t.Errorf("SampleCount = %d, finalized MP4 contains %d video samples", mp4Video.SampleCount, sampleCount)
}
if fps := mp4Video.AverageFPS(); math.Abs(fps-25) > 0.001 {
t.Errorf("AverageFPS() = %.3f, want 25", fps)
}
}

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
required_glibc="2.38"
current_glibc="$(getconf GNU_LIBC_VERSION | awk '{print $2}')"
if ! dpkg --compare-versions "$current_glibc" ge "$required_glibc"; then
echo "MoQ requires glibc ${required_glibc}+; this container has ${current_glibc}." >&2
echo "Rebuild the devcontainer with the Trixie base, then run this check again." >&2
exit 1
fi
echo "==> Testing MoQ packages (glibc ${current_glibc})"
GOWORK=off go test -tags moq ./src/cloud/livemoq ./src/cloud
binary="${TMPDIR:-/tmp}/agent-moq"
trap 'rm -f "$binary"' EXIT
echo "==> Linking the MoQ Agent"
GOWORK=off go build -tags moq -o "$binary" ./main.go
echo "==> Running the linked binary"
"$binary" -action version