Compare commits

..

241 Commits

Author SHA1 Message Date
cedricve
a50ed84163 Add AGENT_TUS_CHUNK_SIZE_BYTES for configurable resumable upload chunk size 2026-09-06 17:27:36 +00:00
cedricve
ed5c0c06c5 Refactor logging implementation and enhance structured logging
- Replaced the existing logging mechanism with logrus for improved logging capabilities.
- Introduced a custom logging configuration with support for different log levels and output formats (text and JSON).
- Added structured logging with component context for better traceability.
- Implemented tests for logging configuration and structured log entries.
- Updated WebRTC module to utilize the new logging system, ensuring consistent logging practices across the application.
- Added tests to verify that sensitive information is not logged in ONVIF responses and that HTTP request logs include structured fields.
2026-09-06 17:22:45 +00:00
cedricve
3e50268947 Implement AgentRun for managing concurrent operations and resource cleanup
- Introduced the AgentRun struct to encapsulate the lifecycle of an agent run, including context management and resource handling.
- Added methods for activating, shutting down, and managing resources associated with an agent run.
- Refactored Communication struct to support the new AgentRun model, including methods for attaching and detaching runs.
- Updated tests to validate the behavior of the new AgentRun implementation, ensuring proper resource management and shutdown behavior.
- Enhanced existing tests to utilize the new AgentRun structure for better isolation and concurrency handling.
2026-09-06 14:07:41 +00:00
cedricve
e2f6782499 Implement lifecycle supervisor for task management and refactor related components 2026-09-06 11:32:45 +00:00
cedricve
c825a11b92 Refactor queue packet reading and improve context handling
- Updated QueueCursor to manage context cancellation more effectively in ReadPacketContext.
- Added tests to ensure ReadPacketContext does not allocate when packets are available.
- Simplified ImageToBytes function by removing unnecessary buffer writer.
- Enhanced ImageCanvas component to handle image updates and editor data changes more efficiently.
- Implemented websocket client ID generation for consistent identification across components.
- Improved Settings component to manage live view subscriptions based on tab visibility.
- Added unit tests for new and modified functionalities across various components and utilities.
2026-09-06 09:55:42 +00:00
Cédric Verstraeten
96ac9b80e6 Merge pull request #325 from kerberos-io/fix/moq-relay-recover
fix/moq-relay-recover
2026-09-06 09:55:25 +02:00
cedricve
82c3eea1e1 Refactor communication channels and telemetry tracking
- Introduced atomic types for CameraConnected, MainStreamConnected, SubStreamConnected, and HasBackChannel in the Communication struct to ensure thread-safe access.
- Added methods to record and retrieve recovery telemetry related to MoQ reconnects, write timeouts, and dropped events.
- Updated motion and ONVIF action handling to use TrySend methods for non-blocking channel operations, logging warnings when channels are full or unavailable.
- Refactored ReadPacket and ReadPacketContext methods in QueueCursor to improve context cancellation handling.
- Added tests for communication channel lifecycle, ensuring proper behavior during concurrent access and after channel closure.
- Implemented tests for MoQ client behavior and stream restart watchdog functionality to ensure reliability under various conditions.
2026-09-05 20:11:31 +00:00
cedricve
c5a9467ffc Remove CircleCI badge from README files 2026-09-05 18:42:34 +00:00
cedricve
e5902349b5 Implement WriteWatchdog for MoQ publisher and enhance packet reading with context cancellation 2026-09-05 18:07:45 +00:00
Cédric Verstraeten
3f2880940f Merge pull request #324 from kerberos-io/improve/mqtt-and-hls-handler
improve/mqtt-and-hls-handler
2026-09-04 19:52:00 +02:00
Cédric Verstraeten
2bb389bf1b Harden live streaming startup
Report live HLS upload startup failures over MQTT with safe reason codes, include bounded Hub error details in upload failures, avoid MQTT startup without required keys, and skip empty ICE server URLs when creating WebRTC peer connections.
2026-09-04 18:37:22 +02:00
Cédric Verstraeten
bfa3c3f995 Merge pull request #323 from kerberos-io/fix/build-error-yarn
fix/build-error-yarn
2026-09-01 15:52:23 +02:00
Cédric Verstraeten
03c1458fd8 Use frozen lockfile for UI yarn install
Stop deleting yarn.lock before install and instead run yarn with --frozen-lockfile to ensure reproducible UI builds in Docker.
2026-09-01 15:45:47 +02:00
Cédric Verstraeten
4e8fd673a8 Merge pull request #322 from kerberos-io/log/report-codec-fps
log/report-codec-fps
2026-09-01 15:35:19 +02:00
Cédric Verstraeten
2a3f4baa8c Log detected main and sub stream properties
Adds informational logging for the detected main and sub video streams, including codec, resolution, and FPS, to aid debugging and diagnostics.
2026-09-01 15:34:27 +02:00
Cédric Verstraeten
203d7b5518 Merge pull request #321 from kerberos-io/fix/discard-failed-recordings
fix/discard-failed-recordings
2026-08-22 22:27:34 +02:00
Cédric Verstraeten
d0a7efff85 Skip upload of empty recording files
UploadKerberosVault now checks the file size returned by os.Stat and skips uploading (without retrying) when the recording file is empty, avoiding unnecessary requests to the vault. Adds a test covering this behavior.
2026-08-22 22:20:45 +02:00
Cédric Verstraeten
95ea92b9ce Merge pull request #320 from kerberos-io/feature/update-docs
feature/update-docs
2026-08-18 11:04:30 +02:00
Cédric Verstraeten
6890d1889c Document AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE
Update the README and RTSPS/TLS guide to describe the dedicated RTSPS CA bundle variable instead of relying on SSL_CERT_FILE. The bundle is appended to the system roots for camera RTSPS connections only, and the validation examples and env-var table are updated accordingly.
2026-08-18 11:03:15 +02:00
Cédric Verstraeten
6c71ff5039 Merge pull request #317 from kerberos-io/feature/backoff-moq-relay-session
feature/backoff-moq-relay-session
2026-08-17 12:40:49 +02:00
Cédric Verstraeten
efc90c76c9 Merge pull request #319 from kerberos-io/fix/apply-permissions-home-dir
fix/apply-permissions-home-dir
2026-08-17 11:56:01 +02:00
Cédric Verstraeten
7459eb02ee Ensure agent home is world-readable
Set `/home/agent` permissions to 0755 during image setup so its contents remain accessible as required.
2026-08-17 11:49:22 +02:00
Cédric Verstraeten
fc46da1398 Update buildFlags in launch.json to include 'moq' tag 2026-08-14 13:58:58 +00:00
Cédric Verstraeten
51a11edb71 update to AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE 2026-08-14 15:38:34 +02:00
Cédric Verstraeten
29e7f26c0e Implement heartbeat response logging and add tests for response handling 2026-08-14 13:16:03 +00:00
Cédric Verstraeten
01d270fcfa Update livemoq_enabled.go 2026-08-14 14:52:23 +02:00
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
Cédric Verstraeten
b6358ab56f Merge pull request #297 from kerberos-io/fix/bump-release-pipeline
fix/bump-release-pipeline
2026-06-27 19:15:45 +02:00
Cédric Verstraeten
bde5cf58eb Merge pull request #298 from kerberos-io/feature/adapative-streaming
feature/adapative-streaming
2026-06-27 16:49:06 +02:00
Cédric Verstraeten
6725411e8f Update communication.go 2026-06-27 16:30:29 +02:00
Cédric Verstraeten
675a8a4fb9 Implement adaptive streaming support with main and sub stream selection based on viewer quality requests 2026-06-27 14:30:20 +00:00
cedricve
a77843fffc Comment out release job in release-bump workflow 2026-06-26 11:57:23 +00:00
Cédric Verstraeten
2dd9d50954 Merge pull request #296 from kerberos-io/feature/upgrade-tus-chunk-size
feature/upgrade-tus-chunk-size
2026-06-26 13:52:34 +02:00
cedricve
9c0a9452a7 Increase default TUS chunk size from 1 MiB to 8 MiB to meet S3 multipart minimum part size requirements 2026-06-26 11:48:47 +00:00
Cédric Verstraeten
61692e8346 Merge pull request #295 from kerberos-io/feature/optimise-hls-upload
feature/optimise-hls-upload
2026-06-25 09:44:15 +02:00
Cédric Verstraeten
e12f403fb9 Implement low-latency HLS support with CMAF parts for improved streaming performance 2026-06-24 19:54:27 +00:00
Cédric Verstraeten
484de49689 Implement HLS prewarm feature for improved viewer experience 2026-06-24 18:57:28 +00:00
Cédric Verstraeten
450d10acf7 Merge pull request #293 from kerberos-io/feature/live-preview-http-transfer
feature/live-preview-http-transfer
2026-06-24 11:53:55 +02:00
Cédric Verstraeten
8a0b5337f3 Fix default TURN URI port in README
Corrects the default AGENT_TURN_URI value in the configuration table from port 348 to 3478. This fixes a typo and aligns the TURN URI with the standard/STUN port used elsewhere in the README.
2026-06-24 11:53:09 +02:00
Cédric Verstraeten
3590a0b39e Merge branch 'master' into feature/live-preview-http-transfer 2026-06-24 11:52:21 +02:00
Cédric Verstraeten
976834cdfd Enhance live preview transport logging
Add livePreviewHttp flag to the device payload and log whether HTTP preview transport is enabled or disabled. Track the transport actually used (HTTP vs MQTT) with a lastTransport variable to avoid per-frame log spam and emit informative logs only when the transport changes, including fallback reasons (Hub not configured or HTTP upload failure). Capture HTTP publish errors to include in fallback messages, and lower the per-frame MQTT publish log level from Info to Debug. Small comment added explaining the logging behavior.
2026-06-24 08:05:32 +02:00
Cédric Verstraeten
d3ede93053 Merge pull request #291 from sharedjourney/fix/liveview-makeslice-panic
fix(machinery): prevent makeslice panic when liveview dims are poisoned
2026-06-23 12:59:52 +02:00
Cédric Verstraeten
58a79f8278 Merge pull request #294 from kerberos-io/feature/update-readme-turn-info
feature/update-readme-turn-info
2026-06-23 09:27:11 +02:00
Cédric Verstraeten
422279985f Update STUN and TURN server URIs in README 2026-06-23 09:21:27 +02:00
Cédric Verstraeten
99ff750c40 Publish live preview frames to Hub over HTTP
Add an HTTP-based live snapshot publisher to send resized SD preview frames directly to hub-api, reducing MQTT broker load. Introduce livesnapshot.Publisher with credential-stripping redirect handling and a publish timeout. Split live-preview signaling into two channels (HandleLiveSD and HandleLiveSDHTTP), add a Transport field to RequestSDStreamPayload, and update the MQTT request handler to signal the correct channel. Update cloud.HandleLiveStreamSD to prefer HTTP uploads for viewers that requested it, falling back to the legacy MQTT image push when needed.
2026-06-23 09:01:22 +02:00
Cédric Verstraeten
13c84a0f36 Merge pull request #292 from kerberos-io/feature/update-turn-uri
Change STUN and TURN URIs in config.json
2026-06-22 21:21:30 +02:00
Cédric Verstraeten
cb6bbe1609 Change STUN and TURN URIs in config.json
Updated STUN and TURN URIs for improved connectivity.
2026-06-22 21:12:52 +02:00
Sebastian Norling
b839cd985b fix(machinery): prevent makeslice panic when liveview dims are poisoned
Two stacked defenses against "runtime error: makeslice: len out of range"
observed in HandleLiveStreamSD on agents publishing snapshots over MQTT.

Extract the liveview base-dimension logic into utils.ResolveBaseDimensions,
gating the aspect-ratio compute on width>0 && height>0. A camera that hasn't
probed yet has Width=0, which made the ratio +Inf and int(float*+Inf) yield
MinInt - later cast to uint at ResizeImage call sites, wrapping to ~MaxUint
and crashing nfnt/resize's allocator. The helper also de-duplicates the two
identical inline blocks in RunAgent (main and sub stream).

utils/main.go: clamp ResizeImage's newWidth/newHeight inputs above a sane
camera ceiling (8192) to 0 ("auto"). Covers all three call sites (cloud,
capture, websocket) in one place so any future caller passing a wrapped or
negative uint silently falls back to source-aspect resize instead of panicking.

Driven by tests in utils/resize_test.go (RED/GREEN).
2026-06-22 15:12:27 +02:00
Cédric Verstraeten
476207c1bf Merge pull request #290 from kerberos-io/feature/add-hls-live-streaming
feature/add-hls-live-streaming
2026-06-16 10:15:03 +02:00
Cédric Verstraeten
fcd8ef8ff4 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-16 10:09:59 +02:00
Cédric Verstraeten
645b6aa0be Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-16 10:09:49 +02:00
Cédric Verstraeten
67ee78dab5 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-16 10:09:15 +02:00
Cédric Verstraeten
5936c6eaae Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-16 10:09:02 +02:00
Cédric Verstraeten
dafcd06696 Add live HLS streaming support
Introduce live HLS streaming pipeline and wire it into the agent.

- Add cloud/livehls: HandleLiveStreamHLS reads packets, gates session lifetime by viewer keepalives, starts sessions lazily on keyframes and announces ready state over MQTT.
- Add livehls publisher and session (cloud/livehls/{publisher,session}.go) to upload init + media CMAF segments to hub-api using a header-based ingest contract; includes redirect-credential stripping and init-refresh logic.
- Add video/livehls.go: LiveSegmenter converts Annex B video into one init (ftyp+moov) and self-contained CMAF media segments (styp+moof+mdat) keyed by sequence/duration.
- Add tests for publisher and session behavior (cloud/livehls/publisher_test.go, video/livehls_test.go).
- Wire components and routing: add Communication.HandleLiveHLS channel and start HLS handler in RunAgent; add RequestHLSStreamPayload and HandleRequestHLSStream in MQTT router to treat HLS requests as viewer keepalives.

This enables short‑latency HLS streaming (CMAF segments uploaded fire‑and‑forget) with viewer keepalive semantics and minimal changes to the control plane.
2026-06-16 09:33:41 +02:00
Cédric Verstraeten
02d60c71e4 Merge pull request #289 from kerberos-io/fix/dynamic-gopsizes
fix/dynamic-gopsizes
2026-06-15 15:00:47 +02:00
Cédric Verstraeten
52647d7f1d Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-15 14:57:07 +02:00
Cédric Verstraeten
e1fa7d9d7e Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-15 14:56:46 +02:00
Cédric Verstraeten
06e2694763 Improve seam detection and add analysis tools
Refine loop/restart (seam) detection and enhance the mp4 analysis tooling.

- mp4: Replace previous previous-interval-based seam heuristic with a safer approach that (1) tracks the running minimum keyframe interval (MinKeyframeGapMs) as the reference cadence and (2) requires the buffered GOP to be genuinely truncated before dropping it. This avoids false positives on variable-GOP (smart-codec) cameras. Added fields, logging updates, and helper methods: bufferedVideoCount, expectedGopFrames, bufferedVideoFrameDuration. LastKeyframeGapMs is now diagnostic only.

- cmd/mp4analyze: Add -from/-to flags and auto-select a detailed inspection window centred on the largest keyframe gap. Limit per-sample printing to that window (and anomalies), fix sync-sample bit detection, pass window to sliceHeaders, and add a compact SUMMARY health report with medians and checks. Added inspectWindow and median helpers.

- tests: Add mp4_variablegop_test.go to verify variable-GOP streams keep healthy short GOPs and that no frames are dropped by the improved seam logic.

These changes prevent healthy GOPs from being discarded on normal short GOPs that follow long static GOPs and add better diagnostics for debugging artifacts.
2026-06-15 14:44:05 +02:00
Cédric Verstraeten
c0971ca3b2 Merge pull request #288 from kerberos-io/feature/resumable-uploads-to-hub
feature/resumable-uploads-to-hub
2026-06-14 15:21:13 +02:00
Cédric Verstraeten
1a788ebe6c Potential fix for pull request finding 'Writable file handle closed without error handling'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-06-13 22:41:50 +02:00
Cédric Verstraeten
a1b4026b4b Remove duplicate uppercase filename entries from git index
Case-only renames had committed both Camera.go and camera.go (identical
blobs) under core.ignorecase=true, causing 'case-insensitive file name
collision' in go build on CI (case-sensitive checkout). Drop the 15 stale
uppercase index entries; the lowercase files are unchanged.
2026-06-13 20:34:41 +00:00
Cédric Verstraeten
9bc9825bb1 Normalize filenames; add tus hub resumable tests
Rename many Go source files to lower_snake_case (e.g. RTSPClient.go -> rtsp_client.go, Server.go -> server.go, etc.) to follow project naming conventions. Enhance machinery/src/cloud/tus_client_test.go: add encoding/base64 import, record incoming requests (recordedRequest + requests slice), provide requestsForMethod helper, add test helpers (testHubConfig, decodeTusMetadata) and two new tests (TestUploadHubResumable_HappyPath and TestUploadHubResumable_Unsupported) that validate hub resumable upload behavior and per-method auth/metadata. Update swag.sh to point to the renamed server.go entry file.
2026-06-13 22:23:53 +02:00
Cédric Verstraeten
e9d2afa228 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-13 12:23:49 +02:00
Cédric Verstraeten
4b0e0eae9c Trim HubURI when building tus upload URL
Construct the tus upload base URL by trimming any trailing slash from config.HubURI before appending tusUploadPath. This prevents double slashes in the resulting URL when concatenating the base URI and the upload path, avoiding potential request/endpoint errors.
2026-06-13 09:55:49 +02:00
Cédric Verstraeten
e0204e1949 Add tus resumable uploads and Hub support
Prefer and support tus resumable uploads for Kerberos Vault/Hub and fall back to legacy single-POST when not available. Extract runTusUpload and a tusHeaderFunc to share the create/head/patch/terminate state machine between direct vault and hub-proxied uploads. Update tusCreate/tusHead/tusPatch/tusTerminate to accept header injection, implement uploadVaultResumable and uploadHubResumable wrappers, and add setHubTusHeaders. Also update UploadKerberosHub to attempt resumable uploads first and log fallback behavior.
2026-06-13 09:55:15 +02:00
Cédric Verstraeten
3c2a0ce0cf Merge pull request #287 from kerberos-io/feature/add-tus-progress-for-resumable-uploads
feature/add-tus-progress-for-resumable-uploads
2026-06-12 13:33:47 +02:00
Cédric Verstraeten
a5def2ccd8 Log tus resumable upload progress
Add periodic progress logging for tus resumable uploads. Introduces tusProgressBucketPercent (10%) and helper functions tusProgressBucket and logTusUploadProgress to bucket progress into 10% increments, cap at 100%, and avoid repeated logs. Initializes loggedProgressBucket in uploadVaultResumable and calls logTusUploadProgress after each successful PATCH so upload progress is reported concisely (percent and byte offsets) without excessive noise.
2026-06-12 13:29:32 +02:00
Cédric Verstraeten
6ede3c3add Merge pull request #286 from kerberos-io/feature/resumable-uploads-tusd
feature/resumable-uploads-tusd
2026-06-12 11:58:19 +02:00
Cédric Verstraeten
d5de6ae271 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-12 11:55:02 +02:00
Cédric Verstraeten
2035deaa31 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-12 11:54:16 +02:00
Cédric Verstraeten
5973ba025d Add configurable tus chunking and stability fixes
Enable configurable chunked tus uploads and related robustness changes.

- Add AGENT_TUS_CHUNK_SIZE_BYTES env (default 1 MiB, 0 disables chunking) and docs in machinery/.env; ignore machinery/go.work files and set GOWORK=off in VSCode launch to avoid go.work during debugging.
- Implement tusChunkSize() and update uploadVaultResumable to send PATCHes in configurable chunk sizes, checkpoint progress after each chunk, and handle partial failures by refreshing retry budget when progress occurs.
- Change tusPatch to accept an explicit length and return the advanced offset when a PATCH is fully accepted.
- Skip uploads when the recording file no longer exists to avoid infinite retries.
- Add tests exercising chunked uploads, chunking-disabled behavior, and tusChunkSize parsing; extend fakeTus test server to record patch sizes.
- Reduce noisy info logs to debug in AAC transcoder and WebRTC audio processing.

These changes improve resumable upload reliability, allow tuning for proxy/load-balancer limits, and reduce log spam during normal operation.
2026-06-11 23:03:05 +02:00
Cédric Verstraeten
52a54fbae1 Add tus resumable upload client and MP4 analyzer
Introduce a standalone mp4analyze CLI for inspecting fragmented MP4s and add a full-featured tus resumable upload client used by Kerberos Vault uploads.

Changes:
- Add machinery/cmd/mp4analyze/main.go: CLI tool to analyze MP4 structure, fragments, keyframes, NALs and SPS/PPS differences.
- Add machinery/src/cloud/tus_client.go: implements tus 1.0.0 client with create/head/patch/delete, sidecar resume state, metadata encoding, Location resolution, backoff, and helpers (newVaultHTTPClient, setVaultTusHeaders). Resumable uploads are enabled by default and can be disabled via AGENT_DISABLE_RESUMABLE_UPLOAD. Sidecar files are stored under data/tus.
- Add machinery/src/cloud/tus_client_test.go: in-memory fake tus server and unit tests exercising happy path, unsupported servers, finalize-retry and resume-from-sidecar behavior, and helpers.
- Refactor machinery/src/cloud/kerberos_vault.go: replace inlined POST upload logic with sendToVault which attempts resumable uploads first and falls back to legacy single-POST (uploadVaultLegacy). Improve retry semantics so retry counters only advance on definitive vault responses, centralize header setup, and use new HTTP client builder honoring AGENT_TLS_INSECURE.

The change preserves backward compatibility with vaults that do not support tus by transparently falling back to the legacy upload path. Tests cover core tus behaviors and resume semantics.
2026-06-11 21:32:43 +02:00
Cédric Verstraeten
5f828262eb Merge pull request #285 from kerberos-io/fix/githubaction-version-passthrough
fix/githubaction-version-passthrough
2026-06-11 20:00:19 +02:00
Cédric Verstraeten
17c1c5b04b Drop truncated GOPs at loop/restart seams
Buffer and conditionally drop a pending GOP when an upstream loop/restart emits a premature IDR. mp4.go: add gopBuffer and bufferedSample types, hold samples until the next video keyframe, detect a seam by comparing the new keyframe interval against the previous cadence (SeamGapDivisor) and drop the short/truncated tail GOP or commit buffered samples. Add commitBufferedGOP and commitSampleToTrack helpers and flush the final buffered GOP on Close. mp4_loopseam_test.go: update test descriptions, expectations and names to assert the truncated tail GOP is dropped exactly once across different GOP sizes. Dockerfile and Dockerfile.arm64: re-declare ARG VERSION inside the build stage and only derive git describe when VERSION is unset or the default 0.0.0 so build-arg values are respected. Add a binary MP4 fixture used by the tests.
2026-06-11 19:52:38 +02:00
Cédric Verstraeten
bd5df30de3 Merge pull request #284 from kerberos-io/feature/align-pps-sps-in-mp4-construct
feature/align-pps-sps-in-mp4-construct
2026-06-11 18:34:45 +02:00
Cédric Verstraeten
2c063c39c6 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-11 18:33:33 +02:00
Cédric Verstraeten
2f0f29ce8c Detect and isolate upstream loop seam in MP4
Add logic to detect an upstream source loop/restart seam (premature IDR) and force a fragment flush so the seam IDR starts its own fragment. Introduces SeamGapDivisor constant and two MP4 fields (LastKeyframeRawPTS, LastKeyframeGapMs) to track recent keyframe timing; when a keyframe interval is significantly shorter than the previous interval (gap*SeamGapDivisor < previousGap) a fragment boundary is forced and a warning is logged. This approach derives the threshold from the observed GOP cadence to avoid false positives on short-GOP or all-intra streams.

Also add tests (machinery/src/video/mp4_loopseam_test.go) that synthesize loop-seam scenarios across multiple GOP sizes (15, 30, 60 frames) to verify the seam is isolated, and include a sample MP4 reproducer (machinery/thales_1781183923_3-758_2top_0-0-0-0_-1_30221.mp4).
2026-06-11 18:32:23 +02:00
Cédric Verstraeten
a05acb7fc8 Fix SPS/PPS prepend and warn on missing PS
When prepending H.264 parameter sets to keyframes, build a fresh buffer instead of appending into existing slices to avoid corrupting shared backing arrays (which could produce an invalid avcC and trigger FFmpeg "non-existing PPS 0 referenced" errors). Also add explicit error logs in mp4.Close() to surface incomplete H.264/H.265 parameter sets (avcC/hvcC) so missing VPS/SPS/PPS conditions are easier to diagnose.
2026-06-11 17:06:45 +02:00
Cédric Verstraeten
2b88c0ff93 Merge pull request #283 from kerberos-io/feature/add-bump-release-workflow
feature/add-bump-release-workflow
2026-06-10 12:13:06 +02:00
Cédric Verstraeten
4aa2b6e51a Refactor release bump workflow to support multi-architecture builds and improve Docker image handling 2026-06-10 10:01:39 +00:00
Cédric Verstraeten
0c439e34c7 Merge pull request #282 from kerberos-io/feature/add-bump-release-workflow
feature/add-bump-release-workflow
2026-06-10 11:25:44 +02:00
Cédric Verstraeten
d57bea3079 Add release bump workflow for semantic versioning 2026-06-10 10:37:59 +02:00
Cédric Verstraeten
46a48db080 Merge pull request #281 from kerberos-io/feature/add-backpressure-rtsp-logging
feature/add-backpressure-rtsp-logging
2026-06-10 09:49:45 +02:00
Cédric Verstraeten
b7fe9947c2 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-10 09:43:16 +02:00
Cédric Verstraeten
f214a09826 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-10 09:42:54 +02:00
Cédric Verstraeten
7059503ac1 Expose required secrets to PR description workflow
Replace 'secrets: inherit' with an explicit secrets mapping in the PR description workflow. This exposes TOKEN and the Azure/OpenAI-related secrets (AZURE_OPENAI_API_KEY, OPENAI_MODEL, AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_VERSION) to the job so it can authenticate against the OpenAI/Azure services when generating or updating PR descriptions.
2026-06-10 09:29:10 +02:00
Cédric Verstraeten
7ee79cc063 Use reusable PR description workflow
Replace the inline OpenAI-based PR description job with a reusable workflow call (uug-ai/workflows/.github/workflows/pr-description.yml@main). Removed unused env vars and the checkout/action steps, and pass pr_number and overwrite_description inputs while inheriting repository secrets. This centralizes PR description logic and simplifies the workflow file.
2026-06-10 09:05:51 +02:00
Cédric Verstraeten
9bfbe4ee0f Add RTSP stream health and watchdog logs
Introduce streamHealth instrumentation for Golibrtsp to track per-stream metrics (write durations, gaps, lost packets, decode errors) and emit immediate warnings and periodic summaries to distinguish downstream back-pressure from upstream camera/network stalls. Wire gortsplib Client hooks (OnPacketsLost, OnDecodeError) to the health logger, label streams, and measure WritePacket blocking time around packet writes. Add packetAgeString helper and augment Kerberos ControlAgent logs with stalled counters and last-packet age to provide more context when triggering restarts.
2026-06-10 08:55:39 +02:00
Cédric Verstraeten
b8c05aa3e2 Merge pull request #280 from kerberos-io/fix/dts-pts-correction
fix/dts-pts-correction
2026-06-08 18:23:19 +02:00
Cédric Verstraeten
5f7ede40ca Update CompositionTime comment for clarity on PTS-DTS calculation 2026-06-08 16:19:38 +00:00
Cédric Verstraeten
0ef84c5288 Handle composition offsets (PTS-DTS) for MP4
Compute and propagate per-sample composition time offsets (PTS - DTS) so fragmented MP4s remain decode-timestamp-monotonic while preserving presentation order for B-frame streams. Changes include:

- Add a dtsExtractor interface and compositionOffsetMs helper to extract DTS from H264/H265 access units using mediacommon extractors (safe no-op if extraction fails).
- Compute composition offsets for H264 and H265 packet handlers and store them in Packet.CompositionTime. Preserve decoded AU for H265 before Annex-B rewriting for correct DTS extraction.
- Introduce writeSampleToMP4 helper to centralize writing logic; derive DTS = PTS - compositionOffset when present and call MP4.AddSampleToTrack accordingly.
- Change MP4.AddSampleToTrack signature to accept compositionOffset and write it into sample.CompositionTimeOffset so players (MSE) can present samples in PTS order while fragments use DTS.
- Update tests to pass the new compositionOffset argument.

This ensures proper playback of streams with frame reordering (B-frames) in browsers and other fragmented-MP4 consumers.
2026-06-08 18:12:53 +02:00
Cédric Verstraeten
1a477bf42d Merge pull request #279 from kerberos-io/fix/block-config-endpoint-on-brokenstream
fix/block-config-endpoint-on-brokenstream
2026-06-05 20:13:41 +02:00
Cédric Verstraeten
22c352e946 Improve /config endpoint responsiveness by adding timeout for snapshot retrieval 2026-06-05 18:08:48 +00:00
Cédric Verstraeten
55b0eb54fe Merge pull request #278 from kerberos-io/feature/support-configmaps
feature/support-configmaps
2026-06-04 21:33:14 +02:00
Cédric Verstraeten
68a4ca6bb9 Update main.go 2026-06-04 21:32:31 +02:00
Cédric Verstraeten
baaa3f615a Merge pull request #277 from kerberos-io/feature/support-configmaps
feature/support-configmaps
2026-06-04 18:02:23 +02:00
Cédric Verstraeten
aeb214689b Update main.go 2026-06-04 18:01:42 +02:00
Cédric Verstraeten
e353d46e73 Merge pull request #276 from kerberos-io/feature/support-configmaps
feature/support-configmaps
2026-06-04 15:22:04 +02:00
Cédric Verstraeten
4d163c4b53 Update jwt_middleware.go 2026-06-04 15:14:45 +02:00
Cédric Verstraeten
014f0e312e Merge pull request #275 from kerberos-io/feature/support-configmaps
feature/support-configmaps
2026-06-04 14:54:06 +02:00
Cédric Verstraeten
195750a01d Mirror env-injected Config to CustomConfig
When the agent configuration is provided via environment variables (e.g. Kubernetes ConfigMap in factory standalone/configmap mode) there is no separate custom config from MongoDB. Add logic in OverrideWithEnvironmentVariables to copy configuration.Config into configuration.CustomConfig when DEPLOYMENT is unset or set to "agent", so UI/consumers that read the per-agent custom configuration (such as the factory agent edit page) see the env-injected values instead of an empty config.
2026-06-04 14:52:12 +02:00
Cédric Verstraeten
d203321770 Merge commit from fork
fix(cloud): strip Hub credential headers on cross-host redirect
2026-05-28 22:02:11 +02:00
tonghuaroot
51f1a52e17 fix(cloud): strip Hub credential headers on cross-host redirect
UploadKerberosHub used a bare http.Client with no CheckRedirect policy, so
it followed redirects automatically. net/http strips the standard sensitive
headers on a cross-host redirect but not custom-named headers, so the Hub
credentials carried in X-Kerberos-Hub-PrivateKey / X-Kerberos-Hub-PublicKey
were forwarded verbatim to any host the configured HubURI redirected to,
disclosing the private key.

Add a CheckRedirect policy that deletes the Hub credential headers when the
redirect target host differs from the original request host.

Signed-off-by: tonghuaroot <tonghuaroot@gmail.com>
2026-05-29 01:35:46 +08:00
Cédric Verstraeten
6318c61323 Merge pull request #274 from kerberos-io/feature/optimize-webrtc-support
feature/optimize-webrtc-support
2026-05-27 23:49:06 +02:00
Cédric Verstraeten
5323105a60 Refactor code structure for improved readability and maintainability 2026-05-27 21:44:54 +00:00
Cédric Verstraeten
af6e75426a Refactor routing components to use Redirect instead of Navigate; update react-router-dom version and implement history for navigation 2026-05-27 07:13:17 +00:00
Cédric Verstraeten
6c2f38679b Refactor code structure for improved readability and maintainability 2026-05-26 06:39:11 +00:00
Cédric Verstraeten
9b60223300 Refactor component exports for consistency by removing unnecessary line breaks 2026-05-25 20:35:02 +00:00
Cédric Verstraeten
efdf8396ab Refactor and update dependencies for improved performance and maintainability; enhance routing and authentication components 2026-05-25 20:13:33 +00:00
Cédric Verstraeten
d0f13187a1 Refactor code structure for improved readability and maintainability 2026-05-25 19:45:32 +00:00
Cédric Verstraeten
bf46b55c92 Update sass dependency to version 1.77.8 2026-05-25 18:31:29 +00:00
Cédric Verstraeten
88edcabf98 Enhance WebRTC support by implementing session ID deduplication and increasing candidate channel buffer size 2026-05-25 09:30:19 +00:00
Cédric Verstraeten
e77af9e2c0 Merge pull request #272 from kerberos-io/revert/loopback
Revert/loopback
2026-05-18 17:08:05 +02:00
Cédric Verstraeten
cc5c0253ed Revert "Clamp implausible audio/video PTS jumps"
This reverts commit 791add83f9.
2026-05-18 12:56:20 +00:00
Cédric Verstraeten
4c5a107d29 Revert "Force fragment flush on close keyframes"
This reverts commit e8fc4e674b.
2026-05-18 12:56:19 +00:00
Cédric Verstraeten
3b07c754f8 Revert "Force fragment flush on close keyframes"
This reverts commit 3d4e37dfb9.
2026-05-18 12:56:17 +00:00
Cédric Verstraeten
d151d0ce24 Revert "Track keyframe gap to prevent flush cascades"
This reverts commit 8ea84d87db.
2026-05-18 12:56:17 +00:00
Cédric Verstraeten
a32af4fe50 Revert "Adjust MinNormalGOPMs threshold to prevent false positives on loop seams"
This reverts commit d3f53e4b6b.
2026-05-18 12:56:16 +00:00
Cédric Verstraeten
434cdf8a7f Merge pull request #271 from kerberos-io/fix/looping-gap-issue
fix/looping-gap-issue
2026-05-12 16:44:34 +02:00
Cédric Verstraeten
d3f53e4b6b Adjust MinNormalGOPMs threshold to prevent false positives on loop seams 2026-05-12 13:56:07 +00:00
Cédric Verstraeten
8ea84d87db Track keyframe gap to prevent flush cascades
Add LastKeyframeGapMs to MP4 state and update fragment-flush logic to consider the previous keyframe gap before forcing a fragment boundary. Previously any unexpectedly short keyframe gap (< MinNormalGOPMs) would force a flush, which could cascade on streams that legitimately emit short GOPs. Now we only force a flush when the current gap is short and the prior gap was healthy (or unset), and we record the current gap for future checks. Also refactor the check to use a local gap variable and preserve the existing log message.
2026-05-12 15:54:48 +02:00
Cédric Verstraeten
860acd3a6e Merge pull request #264 from 21pounder/fix/issue-256-security-disclosure
docs: add private security disclosure policy
2026-05-04 22:21:46 +02:00
Cédric Verstraeten
c7122ca025 Merge pull request #270 from kerberos-io/fix/media-looping-boundary
fix/media-looping-boundary
2026-05-04 22:19:38 +02:00
Cédric Verstraeten
3d4e37dfb9 Force fragment flush on close keyframes
Add a MinNormalGOPMs constant (950 ms) and use it to detect unusually close consecutive IDRs; when a keyframe arrives sooner than this threshold and the current fragment is still short, force a fragment flush to isolate the seam and avoid mid-fragment sync samples. Replace the previous hardcoded 500 ms check in mp4.go and add TestMP4LoopSeamIsolation to reproduce the loop-seam pattern and assert that seam IDRs are isolated into their own fragments.
2026-05-04 22:14:36 +02:00
Cédric Verstraeten
36d6591271 Merge pull request #269 from kerberos-io/fix/media-looping-boundary
fix/media-looping-boundary
2026-05-04 21:24:31 +02:00
Cédric Verstraeten
e8fc4e674b Force fragment flush on close keyframes
Track LastKeyframeRawPTS and force a fragment flush when two consecutive keyframes on the video track arrive unexpectedly close (<500ms). This detects upstream loop/restart discontinuities (e.g. ffmpeg stream_loop seams) where a fresh IDR would otherwise become a mid-fragment sync sample and cause MSE players to reject the fragment. Emits a warning when triggered and updates LastKeyframeRawPTS for video samples. Also add a sample MP4 file to machinery/data.
2026-05-04 21:07:53 +02:00
Cédric Verstraeten
011bd9936f Merge pull request #268 from kerberos-io/fix/media-looping-boundary
fix/media-looping-boundary
2026-05-04 20:14:30 +02:00
Cédric Verstraeten
791add83f9 Clamp implausible audio/video PTS jumps
Guard against large forward PTS/DTS jumps that can occur when looping source MP4s or when upstream RTSP/ffmpeg inserts offsets/stalls. In flushPendingVideoSample() clamp an excessively large video sample duration to a plausible ceiling (1s hard cap, or LastVideoSampleDTS*10 if smaller), falling back to LastVideoSampleDTS or 33ms, and log a warning. In AddSampleToTrack() clamp audio sample durations if the new dts is >10x the previous audio DTS and log a warning. These changes prevent huge sample durations that cause trun/sidx/mvhd discontinuities and browser playback errors.
2026-05-04 20:11:14 +02:00
Miles
4b935d97c8 docs: add private security disclosure policy
Add SECURITY.md and surface reporting guidance in README files.

Refs #256
2026-03-11 18:19:18 +08:00
Cédric Verstraeten
8657765e5d Merge pull request #262 from kerberos-io/feature/concurrency-webrtc
feature/concurrency-webrtc
2026-03-09 21:37:04 +01:00
Cédric Verstraeten
76a136abc9 Add trailing commas to fallback calls
Add trailing commas to the arguments passed to fallbackToSDLiveview in ui/src/pages/Dashboard/Dashboard.jsx. This is a non-functional formatting change applied to the WebRTC initialization, ICE candidate handling, and connection-state fallback calls to align with the project's code style/formatter.
2026-03-09 21:34:17 +01:00
Cédric Verstraeten
5475b79459 Remove extraneous trailing commas in Dashboard
Clean up trailing commas and minor formatting in ui/src/pages/Dashboard/Dashboard.jsx. Adjusts object/argument commas and formatting around WebRTC message handling, peer connection setup, error handling, and SD liveview fallback callbacks to avoid potential syntax/lint issues.
2026-03-09 21:32:10 +01:00
Cédric Verstraeten
2ad768780f Format Dashboard.jsx: add trailing commas
Apply consistent formatting to ui/src/pages/Dashboard/Dashboard.jsx by adding trailing commas in object literals, function call argument lists, and callbacks (primarily around WebRTC handling and error messages). This is a non-functional style change to match the project's code style (e.g., Prettier/ESLint) and should not affect runtime behavior.
2026-03-09 21:29:30 +01:00
Cédric Verstraeten
f64b5fb65b Replace uuidv4 with local createUUID
Remove the uuidv4 import and introduce a local createUUID helper that uses window.crypto.randomUUID when available and falls back to a v4-style generator. Update webrtcClientId and webrtcSessionId to use createUUID(), removing the external dependency while preserving UUID generation for WebRTC session/client IDs.
2026-03-09 21:27:01 +01:00
Cédric Verstraeten
bb773316a2 Add trailing commas and tidy Media.scss
Add trailing commas to multi-line function calls and RTCPeerConnection instantiation in Dashboard.jsx for consistent formatting. In Media.scss remove an extra blank line and relocate the .media-filters__field:first-child rule to consolidate related styles. Purely stylistic/organizational changes with no intended behavior change.
2026-03-09 21:23:16 +01:00
Cédric Verstraeten
fc6fa9d425 Format Media.jsx and add newline
Reformat code in ui/src/pages/Media/Media.jsx for readability: wrap long argument lists (getTimestampFromInput, buildEventFilter) and reflow the appliedFilter ternary onto multiple lines. Also add the missing trailing newline to ui/public/locales/en/translation.json. No functional changes.
2026-03-09 21:18:52 +01:00
Cédric Verstraeten
aa183ee0fb Enable MQTT persistent sessions and resume subs
Switch MQTT client to persistent sessions by setting CleanSession to false, enabling ResumeSubs and using an in-memory store. Previously CleanSession was true and resume/store were commented out, which could drop subscriptions on reconnect; these changes ensure subscriptions are preserved across reconnects and re-subscribed from memory.
2026-03-09 21:12:22 +01:00
Cédric Verstraeten
730b1b2a40 Add WebRTC liveview signaling and UI fallback
Introduce structured WebRTC handshake signaling and client-side fallbacks. Changes:

- machinery: replace HandleLiveHDHandshake channel to carry LiveHDHandshake (payload + signaling callbacks) and expose active WebRTC reader count in dashboard data.
- routers: MQTT and WebSocket handlers now send/receive LiveHDHandshake structs; websocket supports stream-hd and webrtc-candidate messages and uses callback-based signaling to reply over the WS connection.
- webrtc: add helper functions to send MQTT or callback answers/candidates, adapt InitializeWebRTCConnection to the new handshake type, and expose GetActivePeerConnectionCount.
- utils: minor GetMediaFormatted filtering fix and unit test for timestamp range behavior.
- ui: Dashboard gains native WebRTC liveview with fallback to SD image stream, shows active listener count, and handles signaling/candidates; Media page adds datetime range filters, infinite-scroll append behavior, and styles; reducer/action updates to support appending events; package.json scripts disable ESLint plugin during start/build/test.

These changes enable browser-based HD liveviews with dual signaling paths (websocket callbacks or MQTT), improve media filtering, and provide graceful fallback to SD streaming when WebRTC fails.
2026-03-09 21:10:18 +01:00
Cédric Verstraeten
4efc80fecb Enhance WebRTC signaling robustness
Increase HD handshake channel buffer and harden signaling flow: enlarge HandleLiveHDHandshake buffer from 10 to 100 and add a nil-check to drop and log requests when the channel is not initialized. Add publishSignalingMessageAsync to publish MQTT messages with timeout and error logging, and replace blocking Publish().Wait() calls for ICE candidates and SDP answers with the async publisher. Reintroduce the remote-candidate processor goroutine after remote description handling to avoid AddICECandidate races. These changes reduce blocking, improve error handling, and make WebRTC/MQTT signaling more resilient.
2026-03-09 20:05:00 +01:00
Cédric Verstraeten
4fbee60e9f Merge pull request #261 from kerberos-io/feature/add-webrtc-aac-transcoder
feature/add-webrtc-aac-transcoder
2026-03-09 17:46:17 +01:00
Cédric Verstraeten
d6c25df280 Add missing imports for strconv and strings in AAC transcoder stub 2026-03-09 16:42:42 +00:00
Cédric Verstraeten
72a2d28e1e Update aac_transcoder_stub.go 2026-03-09 17:41:54 +01:00
Cédric Verstraeten
eb0972084f Implement AAC transcoding for WebRTC using FFmpeg; update Dockerfiles and launch configuration 2026-03-09 16:34:52 +00:00
Cédric Verstraeten
41a1d221fc Merge pull request #260 from kerberos-io/fix/set-clean-state
fix/set-clean-state
2026-03-09 16:56:36 +01:00
Cédric Verstraeten
eaacc93d2f Set MQTT clean session to true and disable resume subscriptions 2026-03-09 15:50:40 +00:00
Cédric Verstraeten
0e6a004c23 Merge pull request #259 from kerberos-io/fix/add-grace-period
feature/add-broadcasting-feature
2026-03-09 16:20:39 +01:00
Cédric Verstraeten
617f854534 Merge branch 'master' into fix/add-grace-period 2026-03-09 16:17:35 +01:00
Cédric Verstraeten
1bf8006055 Refactor WebRTC handling to use per-peer broadcasters for video and audio tracks 2026-03-09 15:12:01 +00:00
Cédric Verstraeten
ca0e426382 Add max signaling age constant and discard stale WebRTC messages 2026-03-09 14:50:00 +00:00
Cédric Verstraeten
726d0722d9 Merge pull request #258 from kerberos-io/fix/add-grace-period
fix/add-grace-period
2026-03-09 15:20:53 +01:00
Cédric Verstraeten
d8f320b040 Add disconnect grace period handling in WebRTC connection manager 2026-03-09 14:15:50 +00:00
Cédric Verstraeten
0131b87692 Merge pull request #257 from kerberos-io/security/middleware-exposure
security/middleware-exposure
2026-03-09 14:18:11 +01:00
Cédric Verstraeten
54e8198b65 Apply suggestions from code review
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-09 14:18:00 +01:00
Cédric Verstraeten
3bfb68f950 Update port configuration and secure routes with JWT authentication middleware 2026-03-09 12:42:05 +00:00
Cédric Verstraeten
c05e59c936 Merge pull request #255 from kerberos-io/feature/improve-mqtt-concurrency
feature/improve-mqtt-concurrency
2026-03-09 13:25:26 +01:00
Cédric Verstraeten
b42d63b668 Enhance WebRTC packet processing for improved latency handling and keyframe synchronization 2026-03-09 12:17:38 +00:00
Cédric Verstraeten
0ca007e424 Refactor session key usage in ConnectionManager and enhance candidate queuing 2026-03-09 12:09:22 +00:00
Cédric Verstraeten
229d085de7 Merge pull request #253 from kerberos-io/fix/mqtt-reconnection
fix/mqtt-reconnection
2026-03-09 12:43:42 +01:00
Cédric Verstraeten
30e2b8318d Refactor build workflow to support multi-architecture builds and enhance MQTT connection handling 2026-03-09 11:40:24 +00:00
Cédric Verstraeten
dbcf4e242c Enhance MQTT reconnection handling and improve WebRTC connection cleanup
- Enable automatic reconnection for MQTT with configurable intervals and timeouts.
- Add logging for connection loss and reconnection attempts.
- Refactor WebRTC connection cleanup to ensure proper resource management on disconnection.
- Improve event handling in ImageCanvas and Dashboard components for better performance and reliability.
2026-03-09 11:04:10 +00:00
Cédric Verstraeten
ccf4034cc8 Merge pull request #252 from kerberos-io/fix/close-mp4-after-started
fix/close-mp4-after-started
2026-03-03 15:21:12 +01:00
Cédric Verstraeten
a34836e8f4 Delay MP4 creation until the first keyframe is received to ensure valid recordings 2026-03-03 14:16:39 +00:00
Cédric Verstraeten
dd1464d1be Fix recording closure condition to ensure it only triggers after recording has started 2026-03-03 14:03:11 +00:00
Cédric Verstraeten
2c02e0aeb1 Merge pull request #250 from kerberos-io/fix/add-avc-description-fallback
fix/add-avc-description-fallback
2026-02-27 11:48:34 +01:00
150 changed files with 24673 additions and 4433 deletions

View File

@@ -1,32 +0,0 @@
# Golang CircleCI 2.0 configuration file
#
# Check https://circleci.com/docs/2.0/language-go/ for more details
version: 2
jobs:
machinery:
docker:
- image: kerberos/base:0a50dc9
working_directory: /go/src/github.com/{{ORG_NAME}}/{{REPO_NAME}}
steps:
- checkout
- run: apt-get update && apt-get install -y --no-install-recommends libavcodec-dev libavformat-dev libswscale-dev libjpeg62-turbo-dev
- run: cd machinery && go mod download
- run: cd machinery && go test -v ./...
- run: cd machinery && go vet
ui:
docker:
- image: cimg/python:3.9.13-node
steps:
- checkout
- run: node --version
- run: cd ui && yarn
- run: cd ui && yarn test --passWithNoTests
- run: cd ui && yarn build
workflows:
version: 2
build:
jobs:
- machinery
- ui

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

@@ -17,15 +17,17 @@ jobs:
matrix:
#No longer supported Go versions.
#go-version: ['1.17', '1.18', '1.19', '1.20', '1.21']
go-version: ["1.24"]
go-version: ["1.25.0"]
steps:
- name: Set up Go ${{ matrix.go-version }}
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go-version }}
- name: Check out code into the Go module directory
uses: actions/checkout@v4
- name: Set up Go ${{ matrix.go-version }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
cache: true
cache-dependency-path: machinery/go.sum
- name: Set up git ownershi
run: git config --system --add safe.directory /__w/agent/agent
- name: Get dependencies

View File

@@ -15,7 +15,7 @@ jobs:
architecture: [amd64]
steps:
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
@@ -24,13 +24,14 @@ jobs:
with:
ref: master
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
id: buildx
uses: docker/setup-buildx-action@v3
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Run Buildx
run: docker buildx build --platform linux/${{matrix.architecture}} -t kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
run: docker buildx build --build-arg VERSION=$(echo $GITHUB_SHA | cut -c1-7) --platform linux/${{matrix.architecture}} -t kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
- name: Create new and append to manifest
run: docker buildx imagetools create -t kerberos/agent-nightly:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
nightly-build-other:
@@ -40,7 +41,7 @@ jobs:
architecture: [arm64, arm/v7, arm/v6]
steps:
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
@@ -49,12 +50,13 @@ jobs:
with:
ref: master
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
id: buildx
uses: docker/setup-buildx-action@v3
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Run Buildx
run: docker buildx build --platform linux/${{matrix.architecture}} -t kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
run: docker buildx build --build-arg VERSION=$(echo $GITHUB_SHA | cut -c1-7) --platform linux/${{matrix.architecture}} -t kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
- name: Create new and append to manifest
run: docker buildx imagetools create --append -t kerberos/agent-nightly:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)

View File

@@ -7,28 +7,28 @@ env:
REPO: kerberos/agent
jobs:
build-amd64:
runs-on: ubuntu-24.04
build:
runs-on: ${{ matrix.runner }}
permissions:
contents: write
strategy:
matrix:
architecture: [amd64]
include:
- architecture: amd64
runner: ubuntu-24.04
- architecture: arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v3
- uses: benjlevesque/short-sha@v2.1
id: short-sha
with:
length: 7
- name: Run Build
run: |
docker build -t ${{matrix.architecture}} .
docker build --build-arg VERSION=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7) -t ${{ matrix.architecture }} .
CID=$(docker create ${{matrix.architecture}})
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
docker rm ${CID}
@@ -39,37 +39,3 @@ jobs:
with:
name: agent-${{matrix.architecture}}.tar
path: agent-${{matrix.architecture}}.tar
build-arm64:
runs-on: ubuntu-24.04-arm
permissions:
contents: write
strategy:
matrix:
architecture: [arm64]
steps:
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v3
- uses: benjlevesque/short-sha@v2.1
id: short-sha
with:
length: 7
- name: Run Build
run: |
docker build -t ${{matrix.architecture}} -f Dockerfile.arm64 .
CID=$(docker create ${{matrix.architecture}})
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
docker rm ${CID}
- name: Strip binary
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: agent-${{matrix.architecture}}.tar
path: agent-${{matrix.architecture}}.tar

View File

@@ -2,25 +2,16 @@ name: Autofill PR description
on: pull_request
env:
ORGANIZATION: uugai
PROJECT: ${{ github.event.repository.name }}
PR_NUMBER: ${{ github.event.number }}
jobs:
openai-pr-description:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Autofill PR description if empty using OpenAI
uses: cedricve/azureopenai-pr-description@master
with:
github_token: ${{ secrets.TOKEN }}
openai_api_key: ${{ secrets.OPENAI_API_KEY }}
azure_openai_api_key: ${{ secrets.AZURE_OPENAI_API_KEY }}
azure_openai_endpoint: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
azure_openai_version: ${{ secrets.AZURE_OPENAI_VERSION }}
openai_model: ${{ secrets.OPENAI_MODEL }}
pull_request_url: https://pr${{ env.PR_NUMBER }}.api.kerberos.lol
overwrite_description: true
uses: uug-ai/workflows/.github/workflows/pr-description.yml@main
with:
pr_number: ${{ github.event.number }}
pull_request_url: ""
overwrite_description: true
secrets:
TOKEN: ${{ secrets.TOKEN }}
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AZURE_OPENAI_VERSION: ${{ secrets.AZURE_OPENAI_VERSION }}

View File

@@ -11,21 +11,22 @@ on:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x, 16.x]
node-version: [22.x]
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: yarn
cache-dependency-path: ui/yarn.lock
- name: Yarn install
run: cd ui && yarn
run: cd ui && yarn --frozen-lockfile
- name: Yarn test
run: cd ui && yarn test --passWithNoTests
- name: Yarn build

160
.github/workflows/release-bump.yml vendored Normal file
View File

@@ -0,0 +1,160 @@
name: Bump release
on:
workflow_dispatch:
inputs:
bump:
description: "Which part of the version to bump"
required: true
default: patch
type: choice
options:
- major
- minor
- patch
permissions:
contents: write
env:
REPO: kerberos/agent
jobs:
# Determine the next version, create the GitHub release and expose the tag.
bump-release:
uses: uug-ai/workflows/.github/workflows/release-bump.yml@main
with:
bump: ${{ github.event.inputs.bump }}
secrets: inherit
# Publish the platform image to the uug-ai GitHub Container Registry
# (ghcr.io/uug-ai/agent-platform).
#release:
# needs: bump-release
# uses: uug-ai/workflows/.github/workflows/release-create.yml@main
# with:
# organization: uug-ai
# project: ${{ github.event.repository.name }}
# tag: ${{ needs.bump-release.outputs.tag }}
# docker_context: "."
# create_gitops_pr: false
# runner_matrix: >-
# [
# {"architecture":"amd64","runner":"ubuntu-24.04"},
# {"architecture":"arm64","runner":"ubuntu-24.04-arm"}
# ]
# secrets: inherit
# Everything below mirrors the agent's own release-create.yml pipeline and
# publishes the multi-arch image to the kerberos/agent Docker Hub repo, driven
# by the freshly bumped tag instead of a `release: created` event.
build-amd64:
needs: bump-release
runs-on: ubuntu-24.04
permissions:
contents: write
strategy:
matrix:
architecture: [amd64]
steps:
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v4
- name: Run Build
run: |
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}
- name: Strip binary
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
- name: Build and push Docker image
run: |
docker tag ${{matrix.architecture}} $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
docker push $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: agent-${{matrix.architecture}}.tar
path: agent-${{matrix.architecture}}.tar
build-arm64:
needs: bump-release
runs-on: ubuntu-24.04-arm
permissions:
contents: write
strategy:
matrix:
architecture: [arm64]
steps:
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v4
- name: Run Build
run: |
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}
- name: Strip binary
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
- name: Build and push Docker image
run: |
docker tag ${{matrix.architecture}} $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
docker push $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: agent-${{matrix.architecture}}.tar
path: agent-${{matrix.architecture}}.tar
create-manifest:
runs-on: ubuntu-24.04
needs: [bump-release, build-amd64, build-arm64]
steps:
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Create and push multi-arch manifest
run: |
docker manifest create $REPO:${{ needs.bump-release.outputs.tag }} \
$REPO-arch:arch-amd64-${{ needs.bump-release.outputs.tag }} \
$REPO-arch:arch-arm64-${{ needs.bump-release.outputs.tag }}
docker manifest push $REPO:${{ needs.bump-release.outputs.tag }}
- name: Create and push latest manifest
run: |
docker manifest create $REPO:latest \
$REPO-arch:arch-amd64-${{ needs.bump-release.outputs.tag }} \
$REPO-arch:arch-arm64-${{ needs.bump-release.outputs.tag }}
docker manifest push $REPO:latest
create-release:
runs-on: ubuntu-24.04
needs: [bump-release, build-amd64, build-arm64]
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: Create a release
uses: ncipollo/release-action@v1
with:
latest: true
allowUpdates: true
name: ${{ needs.bump-release.outputs.tag }}
tag: ${{ needs.bump-release.outputs.tag }}
generateReleaseNotes: false
omitBodyDuringUpdate: true
artifacts: "agent-*.tar/agent-*.tar"

View File

@@ -22,16 +22,12 @@ jobs:
architecture: [amd64]
steps:
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v3
- uses: benjlevesque/short-sha@v2.1
id: short-sha
with:
length: 7
uses: actions/checkout@v4
- name: Run Build
run: |
docker build --provenance=false --build-arg VERSION=${{github.event.inputs.tag || github.ref_name}} -t ${{matrix.architecture}} .
@@ -59,19 +55,15 @@ jobs:
architecture: [arm64]
steps:
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v3
- uses: benjlevesque/short-sha@v2.1
id: short-sha
with:
length: 7
uses: actions/checkout@v4
- 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}
@@ -92,7 +84,7 @@ jobs:
needs: [build-amd64, build-arm64]
steps:
- name: Login to DockerHub
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

2
.gitignore vendored
View File

@@ -14,5 +14,7 @@ machinery/test*
machinery/init-dev.sh
machinery/.env.local
machinery/vendor
machinery/go.work
machinery/go.work.sum
deployments/docker/private-docker-compose.yaml
video.mp4

5
.vscode/launch.json vendored
View File

@@ -17,7 +17,10 @@
"8080"
],
"envFile": "${workspaceFolder}/machinery/.env.local",
"buildFlags": "--tags dynamic",
"buildFlags": "--tags dynamic,moq",
"env": {
"GOWORK": "off"
},
},
{
"name": "Launch React",

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,9 +1,16 @@
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
ENV PATH=$GOPATH/bin:$GOROOT/bin:/usr/local/lib:$PATH
@@ -12,31 +19,34 @@ 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/*
##############################################################################
# Copy all the relevant source code in the Docker image, so we can build this.
# Copy dependency metadata first so module downloads can be cached separately.
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
WORKDIR /go/src/github.com/kerberos-io/agent/machinery
COPY machinery/go.mod machinery/go.sum ./
RUN go mod download
##################################################################
# 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
##############################################################################
# Copy the rest of the source after dependencies are primed.
COPY machinery ./
RUN rm -rf .env
##################
# Build Machinery
RUN cd /go/src/github.com/kerberos-io/agent/machinery && \
go mod download && \
VERSION=$(cd /go/src/github.com/kerberos-io/agent && git describe --tags --always 2>/dev/null || echo "${VERSION}") && \
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 && \
RUN RESOLVED_VERSION="${VERSION:-0.0.0}" && \
printf '%s' "${RESOLVED_VERSION}" > version && \
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=${RESOLVED_VERSION}" main.go && \
mkdir -p /agent && \
mv main /agent && \
mv version /agent && \
@@ -60,7 +70,7 @@ RUN cp -r /agent ./
RUN /dist/agent/main version
FROM node:18.14.0-alpine3.16 AS build-ui
FROM node:22-alpine AS build-ui
RUN apk update && apk upgrade --available && sync
@@ -68,9 +78,11 @@ 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
WORKDIR /go/src/github.com/kerberos-io/agent/ui
COPY ui/package.json ui/yarn.lock ./
RUN yarn config set network-timeout 300000 && yarn --frozen-lockfile
COPY ui ./
RUN yarn build
####################################
# Let's create a /dist folder containing just the files necessary for runtime.
@@ -82,12 +94,17 @@ 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 && \
chmod 0755 /home/agent
#################################
# Copy files from previous images
@@ -95,8 +112,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 libstdc++ libc6-compat --no-cache && rm -rf /var/cache/apk/*
##################
# Try running agent
@@ -116,7 +131,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,140 +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
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 && \
VERSION=$(cd /go/src/github.com/kerberos-io/agent && git describe --tags --always 2>/dev/null || echo "${VERSION}") && \
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:18.14.0-alpine3.16 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 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"]

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

@@ -0,0 +1,645 @@
# 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 system roots and append AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE
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
AGENT_CAPTURE_IPCAMERA_RTSPS_CA_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 AGENT_CAPTURE_IPCAMERA_RTSPS_CA_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
```
`AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE` starts with the operating system's roots
and appends the camera bundle only to the gortsplib TLS configuration. Other
clients, including MoQ, Hub, and Vault, retain the normal public CA chain.
Restart the Agent after changing trust files.
Do not set `SSL_CERT_FILE` or `SSL_CERT_DIR` in production solely for camera
trust. They are process-wide and can prevent other clients from validating
public services. For a deliberate process-wide isolation test, 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`. `AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE` is appended
after this system pool is loaded; it does not replace the system roots.
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_CA_FILE= \
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 camera trust-distribution approach when possible:
1. Mount a private trust bundle and set
`AGENT_CAPTURE_IPCAMERA_RTSPS_CA_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=/dev/null \
SSL_CERT_DIR=/tmp/empty-ca-dir \
AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE=/tmp/unrelated-test-root.crt \
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.

113
README.md
View File

@@ -3,7 +3,6 @@
<a target="_blank" href="https://kerberos.io"><img src="https://img.shields.io/badge/kerberos-website-gray.svg?longCache=true&colorB=brightgreen" alt="Kerberos Agent"></a>
<a target="_blank" href="https://doc.kerberos.io"><img src="https://img.shields.io/badge/kerberos-documentation-gray.svg?longCache=true&colorB=brightgreen" alt="Kerberos Agent"></a>
<a target="_blank" href="https://circleci.com/gh/kerberos-io/agent"><img src="https://circleci.com/gh/kerberos-io/agent.svg?style=svg"/></a>
<img src="https://github.com/kerberos-io/agent/workflows/Go/badge.svg"/>
<img src="https://github.com/kerberos-io/agent/workflows/React/badge.svg"/>
<img src="https://github.com/kerberos-io/agent/workflows/CodeQL/badge.svg"/>
@@ -65,6 +64,7 @@ There are a myriad of cameras out there (USB, IP and other cameras), and it migh
### Contributing
1. [Security vulnerability reporting](#security-vulnerability-reporting)
1. [Contribute with Codespaces](#contribute-with-codespaces)
2. [Develop and build](#develop-and-build)
3. [Building from source](#building-from-source)
@@ -188,6 +188,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 `AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE` to its path inside the Agent. The bundle is appended to the system roots for camera RTSPS connections only. 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" |
@@ -202,12 +217,15 @@ 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_CA_FILE` | PEM CA bundle appended to the system roots for RTSPS camera certificate verification. | "" |
| `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. | "" |
@@ -230,9 +248,9 @@ Next to attaching the configuration file, it is also possible to override the co
| `AGENT_MQTT_PASSWORD` | Password of the MQTT broker. | "" |
| `AGENT_REALTIME_PROCESSING` | If `AGENT_REALTIME_PROCESSING` set to `true`, the agent will send key frames to the topic | "" |
| `AGENT_REALTIME_PROCESSING_TOPIC` | The topic to which keyframes will be sent in base64 encoded format. | "" |
| `AGENT_STUN_URI` | When using WebRTC, you'll need to provide a STUN server. | "stun:turn.kerberos.io:8443" |
| `AGENT_STUN_URI` | When using WebRTC, you'll need to provide a STUN server. | "stun:turn-fra1.kerberos.io:3478"|
| `AGENT_FORCE_TURN` | Force using a TURN server, by generating relay candidates only. | "false" |
| `AGENT_TURN_URI` | When using WebRTC, you'll need to provide a TURN server. | "turn:turn.kerberos.io:8443" |
| `AGENT_TURN_URI` | When using WebRTC, you'll need to provide a TURN server. | "turn:turn-fra1.kerberos.io:3478"|
| `AGENT_TURN_USERNAME` | TURN username used for WebRTC. | "username1" |
| `AGENT_TURN_PASSWORD` | TURN password used for WebRTC. | "password1" |
| `AGENT_CLOUD` | Store recordings in Kerberos Hub (s3), Kerberos Vault (kstorage), or Dropbox (dropbox). | "s3" |
@@ -242,6 +260,7 @@ Next to attaching the configuration file, it is also possible to override the co
| `AGENT_HUB_PRIVATE_KEY` | The secret access key linked to your account in Kerberos Hub. | "" |
| `AGENT_HUB_REGION` | The Kerberos Hub region, to which you want to upload. | "" |
| `AGENT_HUB_SITE` | The site ID of a site you've created in your Kerberos Hub account. | "" |
| `AGENT_TUS_CHUNK_SIZE_BYTES` | Bytes sent in each resumable-upload PATCH. Set to `0` or a negative value to send all remaining bytes in one PATCH. | "8388608" (8 MiB) |
| `AGENT_KERBEROSVAULT_URI` | The Kerberos Vault API url. | "https://vault.domain.com/api" |
| `AGENT_KERBEROSVAULT_ACCESS_KEY` | The access key of a Kerberos Vault account. | "" |
| `AGENT_KERBEROSVAULT_SECRET_KEY` | The secret key of a Kerberos Vault account. | "" |
@@ -262,6 +281,27 @@ Next to attaching the configuration file, it is also possible to override the co
| `AGENT_SIGNING` | Enable 'true' or disable 'false' for signing recordings. | "true" |
| `AGENT_SIGNING_PRIVATE_KEY` | The private key (RSA) to sign the recordings fingerprint to validate origin. | "" - uses default one if empty |
### Resumable upload chunk size
Hub and Vault resumable uploads use `AGENT_TUS_CHUNK_SIZE_BYTES` as the maximum
body size of each tus `PATCH` request. The value is a number of bytes, not a
number of chunks. If the variable is unset or invalid, the Agent uses 8 MiB:
```dotenv
# 8 MiB (default)
AGENT_TUS_CHUNK_SIZE_BYTES=8388608
# 4 MiB: more frequent checkpoints on unstable connections
AGENT_TUS_CHUNK_SIZE_BYTES=4194304
# Disable chunking and send all remaining bytes in one PATCH
AGENT_TUS_CHUNK_SIZE_BYTES=0
```
Smaller chunks provide more frequent resumable checkpoints but create more HTTP
requests. Larger chunks reduce request overhead but require more data to be
retransmitted when a request fails.
## Encryption
@@ -301,6 +341,10 @@ If we talk about video encoders and decoders (codecs) there are 2 major video co
Conclusion: depending on the use case you might choose one over the other, and you can use both at the same time. For example you can use H264 (main stream) for livestreaming, and H265 (sub stream) for recording. If you wish to play recordings in a cross-platform and cross-browser environment, you might opt for H264 for better support.
## Security vulnerability reporting
If you found a potential security vulnerability, please use the private channels described in [SECURITY.md](SECURITY.md). Avoid opening public GitHub issues for sensitive findings.
## Contribute with Codespaces
One of the major blockers for letting you contribute to an Open Source project is to set up your local development machine. Why? Because you might already have some tools and libraries installed that are used for other projects, and the libraries you would need for Kerberos Agent, for example FFmpeg, might require a different version. Welcome to dependency hell...
@@ -406,13 +450,66 @@ 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.
Two bounded duration settings tune recovery for unusual network conditions:
| Variable | Default | Allowed range |
| -------- | ------- | ------------- |
| `AGENT_LIVE_MOQ_MAX_PACKET_AGE` | `1.5s` | `250ms` to `30s` |
| `AGENT_LIVE_MOQ_WRITE_TIMEOUT` | `5s` | `1s` to `1m` |
Values use Go duration syntax and are clamped to the documented range. The
dashboard API's `recovery` object reports per-tier reconnects, last successful
frame/write timing, native write timeouts, watchdog restarts/cooldown, dropped
run-channel events, and run-worker shutdown timeouts.
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 and in-process
native QUIC lifecycle 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).

40
SECURITY.md Normal file
View File

@@ -0,0 +1,40 @@
# Security Policy
## Supported Versions
We only provide security fixes for the latest release series on the `master` branch.
## Reporting a Vulnerability
Please do **not** open a public GitHub issue for potential security vulnerabilities.
Use one of the private channels below:
1. Preferred: GitHub private vulnerability reporting
- https://github.com/kerberos-io/agent/security/advisories/new
2. Fallback: Email
- support@kerberos.io
- Optional CC: support@uug.ai
Please include:
- A short summary and impact.
- Reproduction steps or proof of concept.
- Affected version(s), commit hash, or deployment details.
- Any proposed mitigation/workaround.
- Your preferred attribution name.
For faster triage, use this subject format in email:
`[Security][Kerberos Agent] <short title>`
## Response Expectations
- Acknowledgement target: within 3 business days.
- Triage/update target: within 7 business days after acknowledgement.
If you do not receive a response in time, please resend your report and include your original timestamp.
## Disclosure and Credits
We follow coordinated disclosure. After a fix is available, we will credit reporters unless they prefer to stay anonymous.

View File

@@ -27,5 +27,12 @@ AGENT_KERBEROSVAULT_SECONDARY_DIRECTORY=
AGENT_KERBEROSVAULT_SECONDARY_ACCESS_KEY=
AGENT_KERBEROSVAULT_SECONDARY_SECRET_KEY=
# Resumable (tus) uploads to Kerberos Vault are enabled by default.
# Set to true to fall back to the legacy single-shot POST /storage upload.
#AGENT_DISABLE_RESUMABLE_UPLOAD=true
# Bytes sent per PATCH request (default 1 MiB = 1048576). 0 disables chunking
# and sends the whole file in a single PATCH.
AGENT_TUS_CHUNK_SIZE_BYTES=1048576
# Open telemetry tracing endpoint
OTEL_EXPORTER_OTLP_ENDPOINT=

View File

@@ -5,7 +5,6 @@
<a target="_blank" href="https://twitter.com/kerberosio?ref_src=twsrc%5Etfw"><img src="https://img.shields.io/twitter/url.svg?label=Follow%20%40kerberosio&style=social&url=https%3A%2F%2Ftwitter.com%2Fkerberosio" alt="Twitter Widget"></a>
<a target="_blank" href="https://join.slack.com/t/kerberosio/shared_invite/zt-1a5oj4pwm-O4qCAN9c5r2um0Ns0ge8ww"><img src="https://img.shields.io/badge/slack-@kerberosio-yellow.svg?logo=slack " alt="Kerberos.io"></a>
<a target="_blank" href="https://circleci.com/gh/kerberos-io/agent"><img src="https://circleci.com/gh/kerberos-io/agent.svg?style=svg"/></a>
<img src="https://github.com/kerberos-io/agent/workflows/Go/badge.svg"/>
<img src="https://github.com/kerberos-io/agent/workflows/CodeQL/badge.svg"/>
@@ -22,4 +21,8 @@ https://brianmacdonald.github.io/Ethonate/address#0xf4a759C9436E2280Ea9cdd23d314
[**Docker Hub**](https://hub.docker.com/r/kerberos/agent) | [**Documentation**](https://doc.kerberos.io) | [**Website**](https://kerberos.io)
Kerberos Open source (v3) is a cutting edge video surveillance management system made available as Open Source under the MIT License. This means that all the source code is available for you or your company, and you can use, transform and distribute the source code; as long you keep a reference of the original license. Kerberos Open Source (v3) can be used for commercial usage (which was not the case for v2). Read more [about the license here](LICENSE).
Kerberos Open source (v3) is a cutting edge video surveillance management system made available as Open Source under the MIT License. This means that all the source code is available for you or your company, and you can use, transform and distribute the source code; as long you keep a reference of the original license. Kerberos Open Source (v3) can be used for commercial usage (which was not the case for v2). Read more [about the license here](LICENSE).
## Security reporting
For sensitive vulnerabilities, use private disclosure channels documented in [../SECURITY.md](../SECURITY.md).

View File

@@ -0,0 +1,636 @@
package main
import (
"flag"
"fmt"
"os"
"sort"
"github.com/Eyevinn/mp4ff/avc"
mp4ff "github.com/Eyevinn/mp4ff/mp4"
)
func main() {
fromFlag := flag.Int64("from", -1, "start of the detailed inspection window (track timescale units); default auto-detects the largest keyframe gap")
toFlag := flag.Int64("to", -1, "end of the detailed inspection window (track timescale units); default auto-detected")
flag.Parse()
if flag.NArg() < 1 {
fmt.Println("usage: mp4analyze [-from N] [-to N] <file.mp4>")
os.Exit(1)
}
f, err := os.Open(flag.Arg(0))
if err != nil {
panic(err)
}
defer f.Close()
parsed, err := mp4ff.DecodeFile(f)
if err != nil {
panic(err)
}
// Movie-level info
if parsed.Init != nil && parsed.Init.Moov != nil {
moov := parsed.Init.Moov
fmt.Printf("ftyp/moov present. timescale(mvhd)=%d duration(mvhd)=%d\n",
moov.Mvhd.Timescale, moov.Mvhd.Duration)
for _, trak := range moov.Traks {
ts := trak.Mdia.Mdhd.Timescale
fmt.Printf(" trak id=%d handler=%s mdhd.timescale=%d mdhd.duration=%d\n",
trak.Tkhd.TrackID, trak.Mdia.Hdlr.HandlerType, ts, trak.Mdia.Mdhd.Duration)
}
} else {
fmt.Println("no Init/Moov (pure fragmented stream?)")
}
// sidx vs actual segment layout. MSE players use sidx to map presentation
// time -> byte ranges; if sidx references disagree with the real segment
// sizes/durations (e.g. after an early/short flush) the player fetches the
// wrong bytes and fails to decode — a failure that "heals" on seek.
fmt.Println("=== sidx references vs actual segments ===")
var sidxRefs []mp4ff.SidxRef
for _, c := range parsed.Children {
if s, ok := c.(*mp4ff.SidxBox); ok {
fmt.Printf(" sidx: timescale=%d earliestPresTime=%d firstOffset=%d refCount=%d anchor(after sidx)=%d\n",
s.Timescale, s.EarliestPresentationTime, s.FirstOffset, len(s.SidxRefs), s.AnchorPoint)
sidxRefs = s.SidxRefs
}
}
// Actual segment sizes (styp+moof+mdat) and fragment durations.
type segInfo struct {
size uint64
dur uint64
}
var actual []segInfo
for _, seg := range parsed.Segments {
var sz uint64
if seg.Styp != nil {
sz += seg.Styp.Size()
}
if seg.Sidx != nil {
sz += seg.Sidx.Size()
}
var dur uint64
for _, fr := range seg.Fragments {
sz += fr.Moof.Size()
if fr.Mdat != nil {
sz += fr.Mdat.Size()
}
for _, traf := range fr.Moof.Trafs {
if traf.Tfhd.TrackID != 1 {
continue
}
for _, trun := range traf.Truns {
for _, s := range trun.Samples {
dur += uint64(s.Dur)
}
}
}
}
actual = append(actual, segInfo{size: sz, dur: dur})
}
for i := range actual {
refStr := "(no sidx ref)"
if i < len(sidxRefs) {
r := sidxRefs[i]
mark := ""
if uint64(r.ReferencedSize) != actual[i].size {
mark += fmt.Sprintf(" SIZE MISMATCH actual=%d", actual[i].size)
}
if uint64(r.SubSegmentDuration) != actual[i].dur {
mark += fmt.Sprintf(" DUR MISMATCH actual=%d", actual[i].dur)
}
refStr = fmt.Sprintf("sidx.size=%d sidx.dur=%d type=%d sap=%d/%d%s",
r.ReferencedSize, r.SubSegmentDuration, r.ReferenceType, r.StartsWithSAP, r.SAPType, mark)
}
fmt.Printf(" seg%02d actual.size=%d actual.dur=%d | %s\n", i, actual[i].size, actual[i].dur, refStr)
}
fmt.Println("=== fragments ===")
fragIdx := 0
var allKeyGlobal []uint64 // global keyframe decode times (track timescale units)
var prevTfdtEnd = map[uint32]uint64{}
for si, seg := range parsed.Segments {
for _, fr := range seg.Fragments {
for _, traf := range fr.Moof.Trafs {
tid := traf.Tfhd.TrackID
tfdt := traf.Tfdt.BaseMediaDecodeTime()
offset := uint64(0)
var keys []uint64 // keyframe offset-from-tfdt
var durs []uint64
zeroDur := 0
nSamples := 0
for _, trun := range traf.Truns {
for _, s := range trun.Samples {
nSamples++
if (s.Flags>>24)&0x03 == 0x02 { // sample_depends_on==2 => IDR/sync
keys = append(keys, offset)
if tid == 1 {
allKeyGlobal = append(allKeyGlobal, tfdt+offset)
}
}
if s.Dur == 0 {
zeroDur++
}
durs = append(durs, uint64(s.Dur))
offset += uint64(s.Dur)
}
}
cont := ""
if pe, ok := prevTfdtEnd[tid]; ok {
if tfdt != pe {
cont = fmt.Sprintf(" <-- tfdt GAP/JUMP prev_end=%d delta=%d", pe, int64(tfdt)-int64(pe))
}
}
prevTfdtEnd[tid] = tfdt + offset
if tid == 1 {
// in-fragment keyframe gaps
var gaps []int64
for i := 1; i < len(keys); i++ {
gaps = append(gaps, int64(keys[i])-int64(keys[i-1]))
}
fmt.Printf("seg%d frag%d trk%d tfdt=%d dur=%d nSamp=%d zeroDur=%d keys=%v inFragKeyGaps=%v%s\n",
si, fragIdx, tid, tfdt, offset, nSamples, zeroDur, keys, gaps, cont)
}
}
fragIdx++
}
}
fmt.Println("=== global video keyframe decode times & gaps ===")
for i, k := range allKeyGlobal {
gap := int64(0)
if i > 0 {
gap = int64(k) - int64(allKeyGlobal[i-1])
}
seam := ""
if i > 1 {
prevGap := int64(allKeyGlobal[i-1]) - int64(allKeyGlobal[i-2])
if gap > 0 && prevGap > 0 && gap*2 < prevGap {
seam = fmt.Sprintf(" <== SEAM? gap=%d < prevGap/2=%d", gap, prevGap/2)
}
}
fmt.Printf(" kf#%02d dt=%d gap=%d%s\n", i, k, gap, seam)
}
// Choose the detailed-inspection window. By default centre it on the largest
// keyframe gap (the most likely artifact location); -from/-to override.
winLo, winHi := inspectWindow(allKeyGlobal, *fromFlag, *toFlag)
fmt.Printf("=== detailed inspection window: dts %d..%d ===\n", winLo, winHi)
// Full sample timeline: DTS, CTS (=DTS+cto), composition offset, NAL types,
// to detect PTS non-monotonicity / gaps / param-set changes at the seam.
fmt.Println("=== per-sample timeline (full) — checking PTS monotonicity & nal types ===")
var trex *mp4ff.TrexBox
if parsed.Init != nil && parsed.Init.Moov != nil && parsed.Init.Moov.Mvex != nil {
for _, t := range parsed.Init.Moov.Mvex.Trexs {
if t.TrackID == 1 {
trex = t
}
}
}
var lastCTS int64 = -1
var lastDTS int64 = -1
sampIdx := 0
fragIdx = 0
for _, seg := range parsed.Segments {
for _, fr := range seg.Fragments {
fs, err := fr.GetFullSamples(trex)
if err != nil {
fmt.Printf(" frag%d GetFullSamples err: %v\n", fragIdx, err)
fragIdx++
continue
}
for _, s := range fs {
dts := int64(s.DecodeTime)
cts := dts + int64(s.CompositionTimeOffset)
nals := nalTypes(s.Data)
anomaly := ""
if lastCTS >= 0 && cts < lastCTS {
anomaly += fmt.Sprintf(" <== CTS BACKWARDS (prev=%d)", lastCTS)
}
if lastDTS >= 0 && dts < lastDTS {
anomaly += fmt.Sprintf(" <== DTS BACKWARDS (prev=%d)", lastDTS)
}
// sample_is_non_sync_sample is bit 16 (0x00010000); a sync sample
// has it clear and sample_depends_on==2 (i.e. an I-frame).
isSync := s.Flags&0x00010000 == 0 && (s.Flags>>24)&0x03 == 0x02
// Only print inside the inspection window and any anomalies, to keep output small.
near := dts >= winLo && dts <= winHi
if near || anomaly != "" {
fmt.Printf(" s%04d frag%d dts=%d cts=%d cto=%d dur=%d size=%d sync=%v nal=%v%s\n",
sampIdx, fragIdx, dts, cts, s.CompositionTimeOffset, s.Dur, len(s.Data), isSync, nals, anomaly)
}
lastCTS = cts
lastDTS = dts
sampIdx++
}
fragIdx++
}
}
// Compare parameter sets: avcC (in moov) vs inline SPS/PPS at every IDR.
// A looping source that restarts may re-emit SPS/PPS that differ from the
// ones the player configured its decoder with from avcC — a classic cause
// of a freeze that "heals" when you seek past the seam.
fmt.Println("=== parameter set comparison (avcC vs inline IDR) ===")
var avccSPS, avccPPS [][]byte
if parsed.Init != nil && parsed.Init.Moov != nil {
for _, trak := range parsed.Init.Moov.Traks {
if trak.Mdia == nil || trak.Mdia.Minf == nil || trak.Mdia.Minf.Stbl == nil {
continue
}
stsd := trak.Mdia.Minf.Stbl.Stsd
if stsd == nil || stsd.AvcX == nil || stsd.AvcX.AvcC == nil {
continue
}
avccSPS = stsd.AvcX.AvcC.SPSnalus
avccPPS = stsd.AvcX.AvcC.PPSnalus
}
}
for i, s := range avccSPS {
fmt.Printf(" avcC SPS[%d] = %x\n", i, s)
}
for i, p := range avccPPS {
fmt.Printf(" avcC PPS[%d] = %x\n", i, p)
}
fragIdx = 0
sampIdx = 0
var baseSPS, basePPS []byte
if len(avccSPS) > 0 {
baseSPS = avccSPS[0]
}
if len(avccPPS) > 0 {
basePPS = avccPPS[0]
}
for _, seg := range parsed.Segments {
for _, fr := range seg.Fragments {
fs, err := fr.GetFullSamples(trex)
if err != nil {
fragIdx++
continue
}
for _, s := range fs {
spsList := nalsByType(s.Data, 7)
ppsList := nalsByType(s.Data, 8)
if len(spsList) > 0 || len(ppsList) > 0 {
dts := int64(s.DecodeTime)
note := ""
if len(spsList) > 0 {
if baseSPS == nil {
baseSPS = spsList[0]
} else if !bytesEqual(baseSPS, spsList[0]) {
note += " <== SPS CHANGED vs base/avcC"
}
}
if len(ppsList) > 0 {
if basePPS == nil {
basePPS = ppsList[0]
} else if !bytesEqual(basePPS, ppsList[0]) {
note += " <== PPS CHANGED vs base/avcC"
}
}
var spsHex, ppsHex string
if len(spsList) > 0 {
spsHex = fmt.Sprintf("%x", spsList[0])
}
if len(ppsList) > 0 {
ppsHex = fmt.Sprintf("%x", ppsList[0])
}
fmt.Printf(" IDR s%04d frag%d dts=%d SPS=%s PPS=%s%s\n",
sampIdx, fragIdx, dts, spsHex, ppsHex, note)
}
sampIdx++
}
fragIdx++
}
}
sliceHeaders(parsed, trex, winLo, winHi)
summary(parsed, trex)
}
func sliceHeaders(parsed *mp4ff.File, trex *mp4ff.TrexBox, winLo, winHi int64) {
// Build SPS/PPS maps from avcC.
spsMap := map[uint32]*avc.SPS{}
ppsMap := map[uint32]*avc.PPS{}
if parsed.Init != nil && parsed.Init.Moov != nil {
for _, trak := range parsed.Init.Moov.Traks {
if trak.Mdia == nil || trak.Mdia.Minf == nil || trak.Mdia.Minf.Stbl == nil {
continue
}
stsd := trak.Mdia.Minf.Stbl.Stsd
if stsd == nil || stsd.AvcX == nil || stsd.AvcX.AvcC == nil {
continue
}
for _, s := range stsd.AvcX.AvcC.SPSnalus {
if sps, err := avc.ParseSPSNALUnit(s, true); err == nil {
spsMap[uint32(sps.ParameterID)] = sps
}
}
for _, p := range stsd.AvcX.AvcC.PPSnalus {
if pps, err := avc.ParsePPSNALUnit(p, spsMap); err == nil {
ppsMap[pps.PicParameterSetID] = pps
}
}
}
}
fmt.Println("=== slice headers in inspection window (frame_num / poc / idr_pic_id) ===")
fragIdx := 0
sampIdx := 0
for _, seg := range parsed.Segments {
for _, fr := range seg.Fragments {
fs, err := fr.GetFullSamples(trex)
if err != nil {
fragIdx++
continue
}
for _, s := range fs {
dts := int64(s.DecodeTime)
if dts < winLo || dts > winHi {
sampIdx++
continue
}
for _, nal := range splitAVCC(s.Data) {
t := nal[0] & 0x1f
if t == 1 || t == 5 { // non-IDR or IDR slice
sh, err := avc.ParseSliceHeader(nal, spsMap, ppsMap)
if err != nil {
fmt.Printf(" s%04d frag%d dts=%d nalType=%d sliceHeader ERR: %v\n", sampIdx, fragIdx, dts, t, err)
break
}
fmt.Printf(" s%04d frag%d dts=%d nalType=%d sliceType=%v frameNum=%d idrPicId=%d pocLsb=%d\n",
sampIdx, fragIdx, dts, t, sh.SliceType, sh.FrameNum, sh.IDRPicID, sh.PicOrderCntLsb)
break
}
}
sampIdx++
}
fragIdx++
}
}
}
// splitAVCC splits a length-prefixed (4-byte) AVCC buffer into NAL units.
func splitAVCC(b []byte) [][]byte {
var out [][]byte
i := 0
for i+4 <= len(b) {
n := int(uint32(b[i])<<24 | uint32(b[i+1])<<16 | uint32(b[i+2])<<8 | uint32(b[i+3]))
i += 4
if n <= 0 || i+n > len(b) {
break
}
out = append(out, b[i:i+n])
i += n
}
return out
}
func bytesEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// nalTypes returns the list of H.264 NAL unit types present in an AVCC
// (length-prefixed) sample buffer.
func nalTypes(b []byte) []int {
var out []int
i := 0
for i+4 <= len(b) {
n := int(uint32(b[i])<<24 | uint32(b[i+1])<<16 | uint32(b[i+2])<<8 | uint32(b[i+3]))
i += 4
if n <= 0 || i+n > len(b) {
break
}
out = append(out, int(b[i]&0x1f))
i += n
}
return out
}
// nalsByType returns the raw NAL payloads (without length prefix) of the given
// type from an AVCC (length-prefixed) sample buffer.
func nalsByType(b []byte, want int) [][]byte {
var out [][]byte
i := 0
for i+4 <= len(b) {
n := int(uint32(b[i])<<24 | uint32(b[i+1])<<16 | uint32(b[i+2])<<8 | uint32(b[i+3]))
i += 4
if n <= 0 || i+n > len(b) {
break
}
if int(b[i]&0x1f) == want {
nal := make([]byte, n)
copy(nal, b[i:i+n])
out = append(out, nal)
}
i += n
}
return out
}
// inspectWindow returns the [lo,hi] decode-time range (track timescale units)
// for which sample-level detail is printed. Explicit -from/-to win; otherwise
// the window auto-centres on the largest gap between consecutive video
// keyframes — the most likely location of a visible artifact — with a margin on
// each side so the frames leading into and out of the gap are shown too.
func inspectWindow(keyDecodeTimes []uint64, from, to int64) (int64, int64) {
if from >= 0 || to >= 0 {
if from < 0 {
from = 0
}
if to < 0 {
to = from + 2000
}
return from, to
}
if len(keyDecodeTimes) < 2 {
return 0, 1 << 62
}
worstIdx, worstGap := 1, uint64(0)
for i := 1; i < len(keyDecodeTimes); i++ {
if g := keyDecodeTimes[i] - keyDecodeTimes[i-1]; g > worstGap {
worstGap = g
worstIdx = i
}
}
const margin = 500
lo := int64(keyDecodeTimes[worstIdx-1]) - margin
if lo < 0 {
lo = 0
}
return lo, int64(keyDecodeTimes[worstIdx]) + margin
}
// summary prints a compact, generic health report so a recording can be
// validated at a glance without reading the full per-sample dump above.
func summary(parsed *mp4ff.File, trex *mp4ff.TrexBox) {
fmt.Println("=== SUMMARY (health checks) ===")
videoTracks, audioTracks := 0, 0
var videoTimescale uint64 = 1
if parsed.Init != nil && parsed.Init.Moov != nil {
for _, trak := range parsed.Init.Moov.Traks {
switch trak.Mdia.Hdlr.HandlerType {
case "vide":
videoTracks++
if trak.Mdia.Mdhd.Timescale != 0 {
videoTimescale = uint64(trak.Mdia.Mdhd.Timescale)
}
case "soun":
audioTracks++
}
}
}
fmt.Printf(" tracks: %d video, %d audio\n", videoTracks, audioTracks)
if audioTracks == 0 {
fmt.Println(" note: no audio track is embedded in this file")
}
type fragStat struct {
idx int
tfdt uint64
dur uint64
nSamp int
nKeys int
zeroDur int
fps float64
}
var stats []fragStat
var keyTimes []uint64
var fpsArr []float64
tfdtGaps := 0
var prevEnd uint64
havePrev := false
fi := 0
for _, seg := range parsed.Segments {
for _, fr := range seg.Fragments {
for _, traf := range fr.Moof.Trafs {
if traf.Tfhd.TrackID != 1 {
continue
}
st := fragStat{idx: fi, tfdt: traf.Tfdt.BaseMediaDecodeTime()}
off := uint64(0)
for _, trun := range traf.Truns {
for _, s := range trun.Samples {
st.nSamp++
if (s.Flags>>24)&0x03 == 0x02 {
st.nKeys++
keyTimes = append(keyTimes, st.tfdt+off)
}
if s.Dur == 0 {
st.zeroDur++
}
off += uint64(s.Dur)
}
}
st.dur = off
d := st.dur
if d == 0 {
d = 1
}
st.fps = float64(st.nSamp) * float64(videoTimescale) / float64(d)
fpsArr = append(fpsArr, st.fps)
if havePrev && st.tfdt != prevEnd {
tfdtGaps++
}
prevEnd = st.tfdt + st.dur
havePrev = true
stats = append(stats, st)
}
fi++
}
}
medFps := medianFloat(fpsArr)
fmt.Printf(" fragments: %d (video timescale=%d, median %.1f fps)\n", len(stats), videoTimescale, medFps)
lowFps := 0
totalZero := 0
for _, st := range stats {
totalZero += st.zeroDur
flagStr := ""
if medFps > 0 && st.fps < medFps*0.9 {
lowFps++
flagStr = " <== LOW FRAME RATE — likely dropped frames"
}
fmt.Printf(" frag%02d tfdt=%-6d dur=%-5d samples=%-3d keyframes=%d zeroDur=%d fps=%.1f%s\n",
st.idx, st.tfdt, st.dur, st.nSamp, st.nKeys, st.zeroDur, st.fps, flagStr)
}
var gaps []uint64
for i := 1; i < len(keyTimes); i++ {
gaps = append(gaps, keyTimes[i]-keyTimes[i-1])
}
irregular := 0
if len(gaps) > 0 {
med := medianUint(gaps)
mn, mx := gaps[0], gaps[0]
for _, g := range gaps {
if g < mn {
mn = g
}
if g > mx {
mx = g
}
// Flag intervals that deviate by more than ~50% from the median GOP.
if med > 0 && (g*2 > med*3 || g*2 < med) {
irregular++
}
}
fmt.Printf(" keyframe gaps: min=%d median=%d max=%d irregular=%d/%d\n", mn, med, mx, irregular, len(gaps))
}
fmt.Printf(" tfdt discontinuities: %d\n", tfdtGaps)
fmt.Printf(" zero-duration samples: %d\n", totalZero)
fmt.Println(" verdict:")
clean := true
if audioTracks == 0 {
fmt.Println(" - no audio track (expected if this recording is video-only)")
}
if lowFps > 0 {
clean = false
fmt.Printf(" - %d fragment(s) have a reduced frame rate (dropped frames) — likely source of the artifacts\n", lowFps)
}
if irregular > 0 {
clean = false
fmt.Printf(" - %d irregular keyframe interval(s)\n", irregular)
}
if tfdtGaps > 0 {
clean = false
fmt.Printf(" - %d timeline (tfdt) discontinuity(ies)\n", tfdtGaps)
}
if totalZero > 0 {
clean = false
fmt.Printf(" - %d zero-duration sample(s)\n", totalZero)
}
if clean {
fmt.Println(" - container structure looks healthy")
}
}
func medianUint(v []uint64) uint64 {
if len(v) == 0 {
return 0
}
c := append([]uint64(nil), v...)
sort.Slice(c, func(i, j int) bool { return c[i] < c[j] })
return c[len(c)/2]
}
func medianFloat(v []float64) float64 {
if len(v) == 0 {
return 0
}
c := append([]float64(nil), v...)
sort.Float64s(c)
return c[len(c)/2]
}

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": "",
@@ -106,9 +106,9 @@
"mqtturi": "tcp://mqtt.kerberos.io:1883",
"mqtt_username": "",
"mqtt_password": "",
"stunuri": "stun:turn.kerberos.io:8443",
"turn_force": "false",
"turnuri": "turn:turn.kerberos.io:8443",
"stunuri": "stun:turn-fra1.kerberos.io:3478",
"turnuri": "turn:turn-fra1.kerberos.io:3478",
"turn_username": "username1",
"turn_password": "password1",
"heartbeaturi": "",
@@ -122,4 +122,4 @@
"signing": {},
"realtimeprocessing": "false",
"realtimeprocessing_topic": ""
}
}

View File

@@ -0,0 +1 @@
{"upload_url":"https://vault.kerberos.io/api/storage/tus/19e42fbc666a38064904caf8c46d182a","vault_uri":"https://vault.kerberos.io/api/storage/tus/","size":1591581}

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,15 @@ 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
@@ -43,7 +44,6 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0
go.opentelemetry.io/otel/sdk v1.36.0
go.opentelemetry.io/otel/trace v1.36.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
@@ -51,13 +51,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 +77,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 +94,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 +124,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=
@@ -856,46 +858,46 @@ github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.17.0 h1:9Luw4uT5HTjHTN8+aNcSThgH1vdXnmdJ8xIfZ4wyTRE=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY=
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 +957,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 +1167,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 +1237,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 +1327,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 +1407,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 +1518,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 +1586,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 +1599,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 +1674,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=
@@ -2096,8 +2104,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.42.0 h1:7N3gPTt50s8GuLortA00n8AqRTk75qOP98+mTPpgzRk=
gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -2109,7 +2115,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=

160
machinery/logging.go Normal file
View File

@@ -0,0 +1,160 @@
package main
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
const (
defaultLogLevel = log.InfoLevel
defaultLogOutput = "text"
)
type localTimeFormatter struct {
timezone *time.Location
formatter log.Formatter
}
func (f localTimeFormatter) Format(entry *log.Entry) ([]byte, error) {
entry.Time = entry.Time.In(f.timezone)
return f.formatter.Format(entry)
}
type componentHook struct{}
func (componentHook) Levels() []log.Level {
return log.AllLevels
}
func (componentHook) Fire(entry *log.Entry) error {
if _, exists := entry.Data["component"]; exists {
return nil
}
entry.Data["component"] = componentFromCaller(entry.Caller)
return nil
}
func configureLogging(levelValue string, outputValue string, timezone *time.Location) {
configureLogger(log.StandardLogger(), levelValue, outputValue, timezone)
}
func configureLogger(logger *log.Logger, levelValue string, outputValue string, timezone *time.Location) {
if timezone == nil {
timezone = time.Local
}
level, levelErr := parseLogLevel(levelValue)
output, outputErr := parseLogOutput(outputValue)
logger.SetOutput(os.Stdout)
logger.SetLevel(level)
logger.SetReportCaller(true)
logger.SetFormatter(localTimeFormatter{
timezone: timezone,
formatter: newLogFormatter(output),
})
installComponentHook(logger)
if levelErr != nil {
logger.WithFields(log.Fields{
"configured_level": levelValue,
"effective_level": level.String(),
}).WithError(levelErr).Warn("invalid log level; using default")
}
if outputErr != nil {
logger.WithFields(log.Fields{
"configured_output": outputValue,
"effective_output": output,
}).WithError(outputErr).Warn("invalid log output; using default")
}
logger.WithFields(log.Fields{
"event": "logger_configured",
"log_level": level.String(),
"output": output,
"report_caller": logger.ReportCaller,
"timezone": timezone.String(),
}).Debug("logging configured")
}
func installComponentHook(logger *log.Logger) {
for _, hooks := range logger.Hooks {
for _, hook := range hooks {
if _, ok := hook.(componentHook); ok {
return
}
}
}
logger.AddHook(componentHook{})
}
func componentFromCaller(frame *runtime.Frame) string {
if frame == nil {
return "unknown"
}
const sourceMarker = "/machinery/src/"
normalizedFile := filepath.ToSlash(frame.File)
if markerIndex := strings.Index(normalizedFile, sourceMarker); markerIndex >= 0 {
relativeFile := normalizedFile[markerIndex+len(sourceMarker):]
if directory := filepath.ToSlash(filepath.Dir(relativeFile)); directory != "." {
return directory
}
}
if strings.Contains(normalizedFile, "/machinery/") {
return "agent"
}
return "unknown"
}
func parseLogLevel(value string) (log.Level, error) {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" {
return defaultLogLevel, nil
}
if normalized == "warning" {
normalized = "warn"
}
level, err := log.ParseLevel(normalized)
if err != nil {
return defaultLogLevel, fmt.Errorf("parse LOG_LEVEL: %w", err)
}
return level, nil
}
func parseLogOutput(value string) (string, error) {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" {
return defaultLogOutput, nil
}
switch normalized {
case "json", "text":
return normalized, nil
default:
return defaultLogOutput, fmt.Errorf("unsupported LOG_OUTPUT %q", value)
}
}
func newLogFormatter(output string) log.Formatter {
callerPrettyfier := func(frame *runtime.Frame) (string, string) {
return filepath.Base(frame.Function), fmt.Sprintf("%s:%d", filepath.Base(frame.File), frame.Line)
}
if output == "json" {
return &log.JSONFormatter{
CallerPrettyfier: callerPrettyfier,
TimestampFormat: time.RFC3339Nano,
}
}
return &log.TextFormatter{
CallerPrettyfier: callerPrettyfier,
FullTimestamp: true,
TimestampFormat: time.RFC3339Nano,
}
}

165
machinery/logging_test.go Normal file
View File

@@ -0,0 +1,165 @@
package main
import (
"bytes"
"encoding/json"
"runtime"
"strings"
"testing"
"time"
log "github.com/sirupsen/logrus"
)
func TestConfigureLoggingDebugIncludesStructuredContext(t *testing.T) {
logger := log.New()
var output bytes.Buffer
configureLogger(logger, "debug", "json", time.UTC)
logger.SetOutput(&output)
logger.WithField("event", "test_event").Debug("structured debug test")
lines := strings.Split(strings.TrimSpace(output.String()), "\n")
var entry map[string]interface{}
if err := json.Unmarshal([]byte(lines[len(lines)-1]), &entry); err != nil {
t.Fatalf("decode debug log: %v; output=%q", err, output.String())
}
for key, want := range map[string]interface{}{
"component": "agent",
"event": "test_event",
"level": "debug",
"msg": "structured debug test",
} {
if got := entry[key]; got != want {
t.Fatalf("%s = %v, want %v", key, got, want)
}
}
if entry["file"] == nil || entry["func"] == nil {
t.Fatalf("debug log is missing caller metadata: %v", entry)
}
}
func TestConfigureLoggerInstallsComponentHookOnce(t *testing.T) {
logger := log.New()
configureLogger(logger, "info", "text", time.UTC)
configureLogger(logger, "debug", "json", time.UTC)
var componentHooks int
for _, hooks := range logger.Hooks {
for _, hook := range hooks {
if _, ok := hook.(componentHook); ok {
componentHooks++
}
}
}
if componentHooks != len(log.AllLevels) {
t.Fatalf("component hook registrations = %d, want %d", componentHooks, len(log.AllLevels))
}
}
func TestComponentFromCaller(t *testing.T) {
tests := []struct {
name string
frame *runtime.Frame
want string
}{
{
name: "nested runtime package",
frame: &runtime.Frame{File: "/workspace/agent/machinery/src/routers/mqtt/main.go"},
want: "routers/mqtt",
},
{
name: "top-level runtime package",
frame: &runtime.Frame{File: "/workspace/agent/machinery/src/capture/main.go"},
want: "capture",
},
{
name: "executable",
frame: &runtime.Frame{File: "/workspace/agent/machinery/main.go"},
want: "agent",
},
{name: "missing caller", want: "unknown"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := componentFromCaller(test.frame); got != test.want {
t.Fatalf("componentFromCaller() = %q, want %q", got, test.want)
}
})
}
}
func TestComponentHookAddsAndPreservesComponent(t *testing.T) {
hook := componentHook{}
entry := log.NewEntry(log.New())
entry.Caller = &runtime.Frame{File: "/workspace/agent/machinery/src/cloud/livehls/session.go"}
if err := hook.Fire(entry); err != nil {
t.Fatalf("componentHook.Fire() error = %v", err)
}
if got := entry.Data["component"]; got != "cloud/livehls" {
t.Fatalf("component = %v, want cloud/livehls", got)
}
entry.Data["component"] = "explicit"
if err := hook.Fire(entry); err != nil {
t.Fatalf("componentHook.Fire() preserving field error = %v", err)
}
if got := entry.Data["component"]; got != "explicit" {
t.Fatalf("component = %v, want explicit", got)
}
}
func TestParseLogLevel(t *testing.T) {
tests := []struct {
name string
value string
want log.Level
wantErr bool
}{
{name: "default", want: log.InfoLevel},
{name: "info", value: "INFO", want: log.InfoLevel},
{name: "warning alias", value: "warning", want: log.WarnLevel},
{name: "debug", value: "debug", want: log.DebugLevel},
{name: "trace", value: "trace", want: log.TraceLevel},
{name: "invalid", value: "verbose", want: log.InfoLevel, wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := parseLogLevel(test.value)
if (err != nil) != test.wantErr {
t.Fatalf("parseLogLevel(%q) error = %v, wantErr %t", test.value, err, test.wantErr)
}
if got != test.want {
t.Fatalf("parseLogLevel(%q) = %s, want %s", test.value, got, test.want)
}
})
}
}
func TestParseLogOutput(t *testing.T) {
tests := []struct {
name string
value string
want string
wantErr bool
}{
{name: "default", want: "text"},
{name: "text", value: "TEXT", want: "text"},
{name: "json", value: "json", want: "json"},
{name: "invalid", value: "console", want: "text", wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := parseLogOutput(test.value)
if (err != nil) != test.wantErr {
t.Fatalf("parseLogOutput(%q) error = %v, wantErr %t", test.value, err, test.wantErr)
}
if got != test.want {
t.Fatalf("parseLogOutput(%q) = %q, want %q", test.value, got, test.want)
}
})
}
}

View File

@@ -5,13 +5,14 @@ import (
"flag"
"fmt"
"os"
"strings"
"time"
"github.com/kerberos-io/agent/machinery/src/capture"
"github.com/kerberos-io/agent/machinery/src/components"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/onvif"
log "github.com/sirupsen/logrus"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
@@ -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."
@@ -96,35 +99,58 @@ func main() {
}
// Specify the timezone of the log: "UTC" or "Local".
timezone, _ := time.LoadLocation("CET")
log.Log.Init(logLevel, logOutput, configDirectory, timezone)
configureLogging(logLevel, logOutput, timezone)
log.WithFields(log.Fields{
"action": action,
"component": "agent",
"config_directory": configDirectory,
"event": "command_parsed",
"port": port,
"version": VERSION,
}).Debug("Agent command parsed")
switch action {
case "version":
{
log.Log.Info("main.Main(): You are currrently running Kerberos Agent " + VERSION)
log.WithFields(log.Fields{
"component": "agent",
"event": "version",
"version": VERSION,
}).Info("Kerberos Agent version")
}
case "discover":
{
// Convert duration to int
timeout, err := time.ParseDuration(timeout + "ms")
if err != nil {
log.Log.Fatal("main.Main(): could not parse timeout: " + err.Error())
log.WithError(err).WithField("component", "onvif").
Fatal("invalid ONVIF discovery timeout")
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":
{
log.Log.Info("main.Main(): Decrypting: " + flag.Arg(0) + " with key: " + flag.Arg(1))
log.WithFields(log.Fields{
"component": "encryption",
"event": "decrypt_started",
"path": flag.Arg(0),
}).Info("Decrypting recording")
symmetricKey := []byte(flag.Arg(1))
if len(symmetricKey) == 0 {
log.Log.Fatal("main.Main(): symmetric key should not be empty")
log.Fatal("main.Main(): symmetric key should not be empty")
return
}
if len(symmetricKey) != 32 {
log.Log.Fatal("main.Main(): symmetric key should be 32 bytes")
log.Fatal("main.Main(): symmetric key should be 32 bytes")
return
}
@@ -154,19 +180,28 @@ func main() {
// Start OpenTelemetry tracing
if otelEndpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"); otelEndpoint == "" {
log.Log.Info("main.Main(): No OpenTelemetry endpoint provided, skipping tracing")
log.WithFields(log.Fields{
"component": "tracing",
"event": "tracing_disabled",
}).Debug("OpenTelemetry tracing disabled")
} else {
log.Log.Info("main.Main(): Starting OpenTelemetry tracing with endpoint: " + otelEndpoint)
log.WithFields(log.Fields{
"component": "tracing",
"event": "tracing_starting",
}).Info("Starting OpenTelemetry tracing")
agentKey := configuration.Config.Key
traceProvider, err := startTracing(agentKey, otelEndpoint)
if err != nil {
log.Log.Error("traceprovider: " + err.Error())
log.WithError(err).WithField("component", "tracing").
Error("Failed to start OpenTelemetry tracing")
} else {
defer func() {
if err := traceProvider.Shutdown(context.Background()); err != nil {
log.WithError(err).WithField("component", "tracing").
Error("Failed to shut down OpenTelemetry tracing")
}
}()
}
defer func() {
if err := traceProvider.Shutdown(context.Background()); err != nil {
log.Log.Error("traceprovider: " + err.Error())
}
}()
}
// Printing final configuration
@@ -177,8 +212,13 @@ func main() {
utils.CheckDataDirectoryPermissions(configDirectory)
// Set timezone
timezone, _ := time.LoadLocation(configuration.Config.Timezone)
log.Log.Init(logLevel, logOutput, configDirectory, timezone)
timezone, err := time.LoadLocation(configuration.Config.Timezone)
if err != nil {
log.WithError(err).WithField("timezone", configuration.Config.Timezone).
Warn("invalid Agent timezone; using the host timezone for logs")
timezone = time.Local
}
configureLogging(logLevel, logOutput, timezone)
// Check if we have a device Key or not, if not
// we will generate one.
@@ -187,30 +227,37 @@ func main() {
configuration.Config.Key = key
err := configService.StoreConfig(configDirectory, configuration.Config)
if err == nil {
log.Log.Info("main.Main(): updated unique key for agent to: " + key)
log.WithFields(log.Fields{
"component": "configuration",
"event": "agent_key_generated",
}).Info("Generated and stored a unique Agent key")
} else {
log.Log.Info("main.Main(): something went wrong while trying to store key: " + key)
log.WithError(err).WithFields(log.Fields{
"component": "configuration",
"event": "agent_key_store_failed",
}).Error("Failed to store the generated Agent key")
}
}
// Create a cancelable context, which will be used to cancel and restart.
// This is used to restart the agent when the configuration is updated.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// We create a capture object, this will contain all the streaming clients.
// And allow us to extract media from within difference places in the agent.
capture := capture.Capture{
RTSPClient: nil,
RTSPSubClient: nil,
}
capture := capture.Capture{}
// Bootstrapping the agent
communication := models.Communication{
Context: &ctx,
CancelContext: &cancel,
HandleBootstrap: make(chan string, 1),
}
log.WithFields(log.Fields{
"component": "agent",
"event": "runtime_starting",
"port": configuration.Port,
}).Info("Starting Agent runtime")
go components.Bootstrap(ctx, configDirectory, &configuration, &communication, &capture)
// Start the REST API.
@@ -218,7 +265,7 @@ func main() {
}
default:
{
log.Log.Error("main.Main(): Sorry I don't understand :(")
log.Error("main.Main(): Sorry I don't understand :(")
}
}
}

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")
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
package capture
import (
"bytes"
"context"
"encoding/pem"
"errors"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestGolibrtspCloseBeforeClientStart(t *testing.T) {
client := &Golibrtsp{}
if err := client.Close(context.Background()); err != nil {
t.Fatalf("Close() error = %v", err)
}
}
func TestSanitizeRTSPErrorRemovesCredentialsAndQuery(t *testing.T) {
rawURL := "rtsp://camera-user:camera-password@10.0.20.15/live?access_token=secret"
got := sanitizeRTSPError(errors.New("describe "+rawURL+": bad status code"), rawURL)
for _, secret := range []string{"camera-user", "camera-password", "access_token", "secret"} {
if strings.Contains(got.Error(), secret) {
t.Fatalf("sanitizeRTSPError() exposed %q in %q", secret, got)
}
}
if !strings.Contains(got.Error(), "rtsp://10.0.20.15/live") {
t.Fatalf("sanitizeRTSPError() removed useful host/path context: %q", got)
}
}
func TestRTSPSTLSConfig(t *testing.T) {
t.Run("verifies certificates by default", func(t *testing.T) {
t.Setenv(rtspsCAFileEnv, "")
t.Setenv(rtspsInsecureEnv, "")
got, err := rtspsTLSConfig()
if err != nil {
t.Fatalf("rtspsTLSConfig() error = %v", err)
}
if got != nil {
t.Fatalf("rtspsTLSConfig() = %#v, want nil", got)
}
})
t.Run("allows explicit insecure mode", func(t *testing.T) {
t.Setenv(rtspsCAFileEnv, "/missing/ignored-in-insecure-mode.pem")
t.Setenv(rtspsInsecureEnv, "true")
got, err := rtspsTLSConfig()
if err != nil {
t.Fatalf("rtspsTLSConfig() error = %v", err)
}
if got == nil || !got.InsecureSkipVerify {
t.Fatalf("rtspsTLSConfig() = %#v, want InsecureSkipVerify enabled", got)
}
})
t.Run("adds a camera CA to system roots", func(t *testing.T) {
t.Setenv(rtspsInsecureEnv, "")
server := httptest.NewTLSServer(nil)
defer server.Close()
certificate := server.Certificate()
caFile := filepath.Join(t.TempDir(), "camera-ca.pem")
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Raw})
if err := os.WriteFile(caFile, caPEM, 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(rtspsCAFileEnv, caFile)
got, err := rtspsTLSConfig()
if err != nil {
t.Fatalf("rtspsTLSConfig() error = %v", err)
}
if got == nil || got.RootCAs == nil {
t.Fatalf("rtspsTLSConfig() = %#v, want custom RootCAs", got)
}
for _, subject := range got.RootCAs.Subjects() {
if bytes.Equal(subject, certificate.RawSubject) {
return
}
}
t.Fatal("camera CA was not added to RootCAs")
})
t.Run("rejects an invalid camera CA file", func(t *testing.T) {
t.Setenv(rtspsInsecureEnv, "")
caFile := filepath.Join(t.TempDir(), "camera-ca.pem")
if err := os.WriteFile(caFile, []byte("not a certificate"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(rtspsCAFileEnv, caFile)
if _, err := rtspsTLSConfig(); err == nil {
t.Fatal("rtspsTLSConfig() error = nil, want invalid CA error")
}
})
}

View File

@@ -4,65 +4,350 @@ 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"
"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/kerberos-io/agent/machinery/src/utils"
"github.com/kerberos-io/agent/machinery/src/video"
log "github.com/sirupsen/logrus"
"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.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.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.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.Info("capture.main.HandleRecordStream(motiondetection): auto-stopping manual recording, no viewer heartbeat within timeout.")
} else {
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.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.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.Info("HandleRecordStream: something went wrong, " + err.Error())
return
}
if err := os.Remove(recordingsDirectory + "/" + name); err != nil {
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.Warn("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.Info("HandleRecordStream: could not remove dangling upload marker " + markerName + ", " + err.Error())
}
}
} else {
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, motionEvents <-chan models.MotionDataPartial) {
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.")
log.Info("capture.main.HandleRecordStream(): disabled, we will not record anything.")
} else {
log.Log.Debug("capture.main.HandleRecordStream(): started")
log.Debug("capture.main.HandleRecordStream(): started")
preRecording := config.Capture.PreRecording * 1000
postRecording := config.Capture.PostRecording * 1000 // number of seconds to record.
@@ -75,7 +360,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
}
if maxRecordingPeriod < preRecording+postRecording {
log.Log.Error("capture.main.HandleRecordStream(): maxRecordingPeriod is less than preRecording + postRecording, this is not allowed. Setting maxRecordingPeriod to preRecording + postRecording.")
log.Error("capture.main.HandleRecordStream(): maxRecordingPeriod is less than preRecording + postRecording, this is not allowed. Setting maxRecordingPeriod to preRecording + postRecording.")
maxRecordingPeriod = preRecording + postRecording
}
@@ -109,7 +394,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
var name string
// Do not do anything!
log.Log.Info("capture.main.HandleRecordStream(continuous): start recording")
log.Info("capture.main.HandleRecordStream(continuous): start recording")
start := false
@@ -140,23 +425,8 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
if start && // If already recording and current frame is a keyframe and we should stop recording
nextPkt.IsKeyFrame && (startRecording+postRecording-now <= 0 || now-startRecording > maxRecordingPeriod-500) {
pts := convertPTS(pkt.TimeLegacy)
if pkt.IsVideo {
// Write the last packet
if err := mp4Video.AddSampleToTrack(videoTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
} else if pkt.IsAudio {
// Write the last packet
if pkt.Codec == "AAC" {
if err := mp4Video.AddSampleToTrack(audioTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
} else if pkt.Codec == "PCM_MULAW" {
// TODO: transcode to AAC, some work to do..
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
}
}
// Write the last packet before closing the recording.
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
// Close mp4
if len(mp4Video.SPSNALUs) == 0 && len(configuration.Config.Capture.IPCamera.SPSNALUs) > 0 {
@@ -170,10 +440,10 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
}
if (videoCodec == "H264" && (len(mp4Video.SPSNALUs) == 0 || len(mp4Video.PPSNALUs) == 0)) ||
(videoCodec == "H265" && (len(mp4Video.VPSNALUs) == 0 || len(mp4Video.SPSNALUs) == 0 || len(mp4Video.PPSNALUs) == 0)) {
log.Log.Warning("capture.main.HandleRecordStream(continuous): closing MP4 without full parameter sets, moov may be incomplete")
log.Warn("capture.main.HandleRecordStream(continuous): closing MP4 without full parameter sets, moov may be incomplete")
}
mp4Video.Close(&config)
log.Log.Info("capture.main.HandleRecordStream(continuous): recording finished: file save: " + name)
log.Info("capture.main.HandleRecordStream(continuous): recording finished: file save: " + name)
// Cleanup muxer
start = false
@@ -197,7 +467,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
oldName := name
name = s + ".mp4"
fullName = configDirectory + "/data/recordings/" + name
log.Log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
// Rename the file to the new name.
err := os.Rename(
@@ -205,10 +475,10 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
configDirectory+"/data/recordings/"+s+".mp4")
if err != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
}
} else {
log.Log.Info("capture.main.HandleRecordStream(continuous): no video data recorded, not renaming file.")
log.Info("capture.main.HandleRecordStream(continuous): no video data recorded, not renaming file.")
}
// Check if we need to encrypt the recording.
@@ -222,22 +492,23 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// write back to file
err := os.WriteFile(fullName, []byte(encryptedContents), 0644)
if err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): error writing file: " + err.Error())
log.Error("capture.main.HandleRecordStream(continuous): error writing file: " + err.Error())
}
} else {
log.Log.Error("capture.main.HandleRecordStream(continuous): error encrypting file: " + err.Error())
log.Error("capture.main.HandleRecordStream(continuous): error encrypting file: " + err.Error())
}
} else {
log.Log.Error("capture.main.HandleRecordStream(continuous): error reading file: " + err.Error())
log.Error("capture.main.HandleRecordStream(continuous): error reading file: " + err.Error())
}
}
// 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)
}
@@ -249,7 +520,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// We'll validate those conditions and if not valid we'll not do anything.
valid, err := conditions.Validate(loc, configuration)
if !valid && err != nil {
log.Log.Debug("capture.main.HandleRecordStream(continuous): " + err.Error() + ".")
log.Debug("capture.main.HandleRecordStream(continuous): " + err.Error() + ".")
time.Sleep(5 * time.Second)
continue
}
@@ -281,7 +552,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
fullName = configDirectory + "/data/recordings/" + name
// Running...
log.Log.Info("capture.main.HandleRecordStream(continuous): recording started")
log.Info("capture.main.HandleRecordStream(continuous): recording started")
// Get width and height from the camera.
width := configuration.Config.Capture.IPCamera.Width
@@ -293,7 +564,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
vpsNALUS := configuration.Config.Capture.IPCamera.VPSNALUs
if len(spsNALUS) == 0 || len(ppsNALUS) == 0 {
log.Log.Warning("capture.main.HandleRecordStream(continuous): missing SPS/PPS at recording start")
log.Warn("capture.main.HandleRecordStream(continuous): missing SPS/PPS at recording start")
}
// Create a video file, and set the dimensions.
mp4Video = video.NewMP4(fullName, spsNALUS, ppsNALUS, vpsNALUS, configuration.Config.Capture.MaxLengthRecording)
@@ -308,46 +579,18 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
if audioCodec == "AAC" {
audioTrack = mp4Video.AddAudioTrack("AAC")
} else if audioCodec == "PCM_MULAW" {
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
}
pts := convertPTS(pkt.TimeLegacy)
if pkt.IsVideo {
if err := mp4Video.AddSampleToTrack(videoTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
} else if pkt.IsAudio {
if pkt.Codec == "AAC" {
if err := mp4Video.AddSampleToTrack(audioTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
} else if pkt.Codec == "PCM_MULAW" {
// TODO: transcode to AAC, some work to do..
// We might need to use ffmpeg to transcode the audio to AAC.
// For now we will skip the audio track.
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
}
}
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 {
pts := convertPTS(pkt.TimeLegacy)
if pkt.IsVideo {
// New method using new mp4 library
if err := mp4Video.AddSampleToTrack(videoTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
} else if pkt.IsAudio {
if pkt.Codec == "AAC" {
if err := mp4Video.AddSampleToTrack(audioTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
} else if pkt.Codec == "PCM_MULAW" {
// TODO: transcode to AAC, some work to do..
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
}
}
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
}
pkt = nextPkt
}
@@ -357,7 +600,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
if cursorError != nil {
if recordingStatus == "started" {
log.Log.Info("capture.main.HandleRecordStream(continuous): Recording finished: file save: " + name)
log.Info("capture.main.HandleRecordStream(continuous): Recording finished: file save: " + name)
// Cleanup muxer
start = false
@@ -381,7 +624,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
oldName := name
name = s + ".mp4"
fullName = configDirectory + "/data/recordings/" + name
log.Log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
// Rename the file to the new name.
err := os.Rename(
@@ -389,10 +632,10 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
configDirectory+"/data/recordings/"+s+".mp4")
if err != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
}
} else {
log.Log.Info("capture.main.HandleRecordStream(continuous): no video data recorded, not renaming file.")
log.Info("capture.main.HandleRecordStream(continuous): no video data recorded, not renaming file.")
}
// Check if we need to encrypt the recording.
@@ -406,29 +649,30 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// write back to file
err := os.WriteFile(fullName, []byte(encryptedContents), 0644)
if err != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): error writing file: " + err.Error())
log.Error("capture.main.HandleRecordStream(motiondetection): error writing file: " + err.Error())
}
} else {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): error encrypting file: " + err.Error())
log.Error("capture.main.HandleRecordStream(motiondetection): error encrypting file: " + err.Error())
}
} else {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): error reading file: " + err.Error())
log.Error("capture.main.HandleRecordStream(motiondetection): error reading file: " + err.Error())
}
}
// 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)
}
}
} else {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): Start motion based recording ")
log.Info("capture.main.HandleRecordStream(motiondetection): Start motion based recording ")
var lastRecordingTime int64 = 0 // last recording timestamp in milliseconds
var displayTime int64 = 0 // display time in milliseconds
@@ -436,7 +680,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
var videoTrack uint32
var audioTrack uint32
for motion := range communication.HandleMotion {
for motion := range motionEvents {
// Get as much packets we need.
var cursorError error
@@ -466,7 +710,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// If startRecording is 0, we will continue as it might be we are in a state of restarting the agent.
if startRecording == 0 {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): startRecording is 0, we will continue as it might be we are in a state of restarting the agent.")
log.Info("capture.main.HandleRecordStream(motiondetection): startRecording is 0, we will continue as it might be we are in a state of restarting the agent.")
continue
}
@@ -504,7 +748,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
fullName := configDirectory + "/data/recordings/" + name
// Running...
log.Log.Info("capture.main.HandleRecordStream(motiondetection): recording started (" + name + ")" + " at " + strconv.FormatInt(displayTimeSeconds, 10) + " unix")
log.Info("capture.main.HandleRecordStream(motiondetection): recording started (" + name + ")" + " at " + strconv.FormatInt(displayTimeSeconds, 10) + " unix")
// Get width and height from the camera.
width := configuration.Config.Capture.IPCamera.Width
@@ -516,73 +760,76 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
vpsNALUS := configuration.Config.Capture.IPCamera.VPSNALUs
if len(spsNALUS) == 0 || len(ppsNALUS) == 0 {
log.Log.Warning("capture.main.HandleRecordStream(motiondetection): missing SPS/PPS at recording start")
}
// Create a video file, and set the dimensions.
mp4Video := video.NewMP4(fullName, spsNALUS, ppsNALUS, vpsNALUS, configuration.Config.Capture.MaxLengthRecording)
mp4Video.SetWidth(width)
mp4Video.SetHeight(height)
if videoCodec == "H264" {
videoTrack = mp4Video.AddVideoTrack("H264")
} else if videoCodec == "H265" {
videoTrack = mp4Video.AddVideoTrack("H265")
}
if audioCodec == "AAC" {
audioTrack = mp4Video.AddAudioTrack("AAC")
} else if audioCodec == "PCM_MULAW" {
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
log.Warn("capture.main.HandleRecordStream(motiondetection): missing SPS/PPS at recording start")
}
// Create the MP4 only once the first keyframe arrives.
var mp4Video *video.MP4
for cursorError == nil {
nextPkt, cursorError = recordingCursor.ReadPacket()
if cursorError != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): " + cursorError.Error())
log.Error("capture.main.HandleRecordStream(motiondetection): " + cursorError.Error())
}
now = time.Now().UnixMilli()
select {
case motion := <-communication.HandleMotion:
case motion := <-motionEvents:
motionTimestamp = now
log.Log.Info("capture.main.HandleRecordStream(motiondetection): motion detected while recording. Expanding recording.")
log.Info("capture.main.HandleRecordStream(motiondetection): motion detected while recording. Expanding recording.")
numberOfChanges := motion.NumberOfChanges
log.Log.Info("capture.main.HandleRecordStream(motiondetection): Received message with recording data, detected changes to save: " + strconv.Itoa(numberOfChanges))
log.Info("capture.main.HandleRecordStream(motiondetection): Received message with recording data, detected changes to save: " + strconv.Itoa(numberOfChanges))
default:
}
if (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))
log.Log.Info("capture.main.HandleRecordStream(motiondetection): closing recording (timestamp: " + strconv.FormatInt(motionTimestamp, 10) + ", postRecording: " + strconv.FormatInt(postRecording, 10) + ", now: " + strconv.FormatInt(now, 10) + ", startRecording: " + strconv.FormatInt(startRecording, 10) + ", maxRecordingPeriod: " + strconv.FormatInt(maxRecordingPeriod, 10))
// 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.Info("capture.main.HandleRecordStream(motiondetection): timestamp+postRecording-now < 0 - " + strconv.FormatInt(motionTimestamp+postRecording-now, 10) + " < 0")
log.Info("capture.main.HandleRecordStream(motiondetection): now-startRecording > maxRecordingPeriod-500 - " + strconv.FormatInt(now-startRecording, 10) + " > " + strconv.FormatInt(maxRecordingPeriod-500, 10))
log.Info("capture.main.HandleRecordStream(motiondetection): closing recording (timestamp: " + strconv.FormatInt(motionTimestamp, 10) + ", postRecording: " + strconv.FormatInt(postRecording, 10) + ", now: " + strconv.FormatInt(now, 10) + ", startRecording: " + strconv.FormatInt(startRecording, 10) + ", maxRecordingPeriod: " + strconv.FormatInt(maxRecordingPeriod, 10))
break
}
if pkt.IsKeyFrame && !start && pkt.CurrentTime >= startRecording {
// We start the recording if we have a keyframe and the last duration is 0 or less than the current packet time.
// It could be start we start from the beginning of the recording.
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): write frames")
log.Debug("capture.main.HandleRecordStream(motiondetection): write frames")
log.Debug("capture.main.HandleRecordStream(motiondetection): recording started on keyframe")
// Align duration timers with the first keyframe.
startRecording = pkt.CurrentTime
// Create a video file, and set the dimensions.
mp4Video = video.NewMP4(fullName, spsNALUS, ppsNALUS, vpsNALUS, configuration.Config.Capture.MaxLengthRecording)
mp4Video.SetWidth(width)
mp4Video.SetHeight(height)
if videoCodec == "H264" {
videoTrack = mp4Video.AddVideoTrack("H264")
} else if videoCodec == "H265" {
videoTrack = mp4Video.AddVideoTrack("H265")
}
if audioCodec == "AAC" {
audioTrack = mp4Video.AddAudioTrack("AAC")
} else if audioCodec == "PCM_MULAW" {
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 {
pts := convertPTS(pkt.TimeLegacy)
if pkt.IsVideo {
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): add video sample")
if err := mp4Video.AddSampleToTrack(videoTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): " + err.Error())
}
} else if pkt.IsAudio {
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): add audio sample")
if pkt.Codec == "AAC" {
if err := mp4Video.AddSampleToTrack(audioTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): " + err.Error())
}
} else if pkt.Codec == "PCM_MULAW" {
// TODO: transcode to AAC, some work to do..
// We might need to use ffmpeg to transcode the audio to AAC.
// For now we will skip the audio track.
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): no AAC audio codec detected, skipping audio track.")
}
}
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
}
pkt = nextPkt
@@ -592,6 +839,11 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// This is used to determine if we need to start a new recording.
lastRecordingTime = pkt.CurrentTime
if mp4Video == nil {
log.Warn("capture.main.HandleRecordStream(motiondetection): recording closed without keyframe; no MP4 created")
continue
}
// This will close the recording and write the last packet.
if len(mp4Video.SPSNALUs) == 0 && len(configuration.Config.Capture.IPCamera.SPSNALUs) > 0 {
mp4Video.SPSNALUs = configuration.Config.Capture.IPCamera.SPSNALUs
@@ -604,10 +856,22 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
}
if (videoCodec == "H264" && (len(mp4Video.SPSNALUs) == 0 || len(mp4Video.PPSNALUs) == 0)) ||
(videoCodec == "H265" && (len(mp4Video.VPSNALUs) == 0 || len(mp4Video.SPSNALUs) == 0 || len(mp4Video.PPSNALUs) == 0)) {
log.Log.Warning("capture.main.HandleRecordStream(motiondetection): closing MP4 without full parameter sets, moov may be incomplete")
log.Warn("capture.main.HandleRecordStream(motiondetection): closing MP4 without full parameter sets, moov may be incomplete")
}
mp4Video.Close(&config)
log.Log.Info("capture.main.HandleRecordStream(motiondetection): file save: " + name)
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()) {
communication.TrySendMotion(models.MotionDataPartial{Timestamp: time.Now().Unix(), NumberOfChanges: 100000000})
}
// Update the name of the recording with the duration.
// We will update the name of the recording with the duration in milliseconds.
@@ -627,7 +891,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
oldName := name
name = s + ".mp4"
fullName = configDirectory + "/data/recordings/" + name
log.Log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
// Rename the file to the new name.
err := os.Rename(
@@ -635,10 +899,10 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
configDirectory+"/data/recordings/"+s+".mp4")
if err != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
}
} else {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): no video data recorded, not renaming file.")
log.Info("capture.main.HandleRecordStream(motiondetection): no video data recorded, not renaming file.")
}
// Check if we need to encrypt the recording.
@@ -652,26 +916,24 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// write back to file
err := os.WriteFile(fullName, []byte(encryptedContents), 0644)
if err != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): error writing file: " + err.Error())
log.Error("capture.main.HandleRecordStream(motiondetection): error writing file: " + err.Error())
}
} else {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): error encrypting file: " + err.Error())
log.Error("capture.main.HandleRecordStream(motiondetection): error encrypting file: " + err.Error())
}
} else {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): error reading file: " + err.Error())
log.Error("capture.main.HandleRecordStream(motiondetection): error reading file: " + err.Error())
}
}
// 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)
}
}
log.Log.Debug("capture.main.HandleRecordStream(): finished")
log.Debug("capture.main.HandleRecordStream(): finished")
}
}
@@ -765,13 +1027,14 @@ func Base64Image(captureDevice *Capture, communication *models.Communication, co
var cursor *packets.QueueCursor
// We'll pick the right client and decoder.
rtspClient := captureDevice.RTSPSubClient
rtspClient := captureDevice.SubClient()
if rtspClient != nil {
queue = communication.SubQueue
cursor = queue.Latest()
queue = communication.SubQueue()
} else {
rtspClient = captureDevice.RTSPClient
queue = communication.Queue
rtspClient = captureDevice.MainClient()
queue = communication.MainQueue()
}
if queue != nil {
cursor = queue.Latest()
}
@@ -811,13 +1074,14 @@ func JpegImage(captureDevice *Capture, communication *models.Communication) imag
var cursor *packets.QueueCursor
// We'll pick the right client and decoder.
rtspClient := captureDevice.RTSPSubClient
rtspClient := captureDevice.SubClient()
if rtspClient != nil {
queue = communication.SubQueue
cursor = queue.Latest()
queue = communication.SubQueue()
} else {
rtspClient = captureDevice.RTSPClient
queue = communication.Queue
rtspClient = captureDevice.MainClient()
queue = communication.MainQueue()
}
if queue != nil {
cursor = queue.Latest()
}
@@ -851,6 +1115,37 @@ func convertPTS(v time.Duration) uint64 {
return uint64(v.Milliseconds())
}
/*func convertPTS2(v int64) uint64 {
return uint64(v) / 100
}*/
// writeSampleToMP4 writes a single capture packet to the fragmented MP4.
//
// For video it derives the decode timestamp (DTS) from the packet PTS using the
// per-packet composition offset (PTS - DTS), which is non-zero only for streams
// that contain B-frames. Passing the monotonic DTS as the sample timestamp keeps
// the fragment timeline (tfdt/sidx) monotonic, while the composition offset is
// forwarded so frames are still presented in PTS order.
func writeSampleToMP4(mp4Video *video.MP4, videoTrack, audioTrack uint32, pkt packets.Packet) {
if mp4Video == nil {
return
}
pts := convertPTS(pkt.TimeLegacy)
if pkt.IsVideo {
compositionOffset := pkt.CompositionTime
dts := pts
if compositionOffset > 0 && uint64(compositionOffset) <= pts {
dts = pts - uint64(compositionOffset)
}
if err := mp4Video.AddSampleToTrack(videoTrack, pkt.IsKeyFrame, pkt.Data, dts, compositionOffset); err != nil {
log.Error("capture.main.writeSampleToMP4(): " + err.Error())
}
} else if pkt.IsAudio {
if pkt.Codec == "AAC" {
if err := mp4Video.AddSampleToTrack(audioTrack, pkt.IsKeyFrame, pkt.Data, pts, 0); err != nil {
log.Error("capture.main.writeSampleToMP4(): " + err.Error())
}
} else if pkt.Codec == "PCM_MULAW" {
// TODO: transcode to AAC, some work to do..
log.Debug("capture.main.writeSampleToMP4(): no AAC audio codec detected, skipping audio track.")
}
}
}

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

@@ -3,36 +3,73 @@ package capture
import (
"context"
"image"
"sync"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/packets"
)
type Capture struct {
RTSPClient *Golibrtsp
RTSPSubClient *Golibrtsp
RTSPBackChannelClient *Golibrtsp
clientsMu sync.RWMutex
rtspClient *Golibrtsp
rtspSubClient *Golibrtsp
rtspBackChannelClient *Golibrtsp
}
func (c *Capture) SetMainClient(rtspUrl string) *Golibrtsp {
c.RTSPClient = &Golibrtsp{
client := &Golibrtsp{
Url: rtspUrl,
}
return c.RTSPClient
c.clientsMu.Lock()
c.rtspClient = client
c.clientsMu.Unlock()
return client
}
func (c *Capture) SetSubClient(rtspUrl string) *Golibrtsp {
c.RTSPSubClient = &Golibrtsp{
client := &Golibrtsp{
Url: rtspUrl,
}
return c.RTSPSubClient
c.clientsMu.Lock()
c.rtspSubClient = client
c.clientsMu.Unlock()
return client
}
func (c *Capture) SetBackChannelClient(rtspUrl string) *Golibrtsp {
c.RTSPBackChannelClient = &Golibrtsp{
client := &Golibrtsp{
Url: rtspUrl,
}
return c.RTSPBackChannelClient
c.clientsMu.Lock()
c.rtspBackChannelClient = client
c.clientsMu.Unlock()
return client
}
func (c *Capture) MainClient() *Golibrtsp {
c.clientsMu.RLock()
defer c.clientsMu.RUnlock()
return c.rtspClient
}
func (c *Capture) SubClient() *Golibrtsp {
c.clientsMu.RLock()
defer c.clientsMu.RUnlock()
return c.rtspSubClient
}
func (c *Capture) ClearClients(main, sub, backchannel *Golibrtsp) {
c.clientsMu.Lock()
defer c.clientsMu.Unlock()
if c.rtspClient == main {
c.rtspClient = nil
}
if c.rtspSubClient == sub {
c.rtspSubClient = nil
}
if c.rtspBackChannelClient == backchannel {
c.rtspBackChannelClient = nil
}
}
// RTSPClient is a interface that abstracts the RTSP client implementation.

View File

@@ -0,0 +1,62 @@
package capture
import (
"strconv"
"sync"
"testing"
)
func TestCaptureClientAccessCanRaceReplacement(t *testing.T) {
captureDevice := &Capture{}
captureDevice.SetMainClient("rtsp://main/0")
captureDevice.SetSubClient("rtsp://sub/0")
var workers sync.WaitGroup
workers.Add(2)
go func() {
defer workers.Done()
for replacement := 1; replacement <= 1000; replacement++ {
suffix := strconv.Itoa(replacement)
captureDevice.SetMainClient("rtsp://main/" + suffix)
captureDevice.SetSubClient("rtsp://sub/" + suffix)
}
}()
go func() {
defer workers.Done()
for snapshot := 0; snapshot < 1000; snapshot++ {
if captureDevice.MainClient() == nil {
t.Error("MainClient() returned nil")
return
}
if captureDevice.SubClient() == nil {
t.Error("SubClient() returned nil")
return
}
}
}()
workers.Wait()
}
func TestCaptureClearClientsOnlyClearsMatchingRun(t *testing.T) {
captureDevice := &Capture{}
oldMain := captureDevice.SetMainClient("rtsp://main/old")
oldSub := captureDevice.SetSubClient("rtsp://sub/old")
oldBackchannel := captureDevice.SetBackChannelClient("rtsp://back/old")
newMain := captureDevice.SetMainClient("rtsp://main/new")
newSub := captureDevice.SetSubClient("rtsp://sub/new")
newBackchannel := captureDevice.SetBackChannelClient("rtsp://back/new")
captureDevice.ClearClients(oldMain, oldSub, oldBackchannel)
if captureDevice.MainClient() != newMain {
t.Fatal("stale cleanup cleared the new main client")
}
if captureDevice.SubClient() != newSub {
t.Fatal("stale cleanup cleared the new sub client")
}
captureDevice.ClearClients(newMain, newSub, newBackchannel)
if captureDevice.MainClient() != nil || captureDevice.SubClient() != nil {
t.Fatal("matching cleanup did not clear current clients")
}
}

File diff suppressed because it is too large Load Diff

1947
machinery/src/cloud/cloud.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,592 @@
package cloud
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/kerberos-io/agent/machinery/src/models"
agentonvif "github.com/kerberos-io/agent/machinery/src/onvif"
goonvif "github.com/kerberos-io/onvif"
goonvifdevice "github.com/kerberos-io/onvif/device"
goonvifptz "github.com/kerberos-io/onvif/ptz"
)
func TestHeartbeatFailureLogOmitsHubResponseBody(t *testing.T) {
response := &http.Response{
StatusCode: http.StatusBadRequest,
Status: "400 Bad Request",
Body: io.NopCloser(strings.NewReader(`{"error":"invalid heartbeat"}`)),
}
responseBody, truncated, err := readHeartbeatResponseBody(response)
if err != nil {
t.Fatalf("readHeartbeatResponseBody() error = %v", err)
}
fields := heartbeatFailureLogFields(response, responseBody, truncated, 125*time.Millisecond)
for key, want := range map[string]interface{}{
"duration_ms": int64(125),
"response_body_bytes": len(responseBody),
"response_body_truncated": false,
"status_code": http.StatusBadRequest,
} {
if got := fields[key]; got != want {
t.Errorf("%s = %v, want %v", key, got, want)
}
}
for key, value := range fields {
if strings.Contains(key, "response_body") && key != "response_body_bytes" && key != "response_body_truncated" {
t.Fatalf("unexpected response body field %q=%v", key, value)
}
if strings.Contains(fmt.Sprint(value), "invalid heartbeat") {
t.Fatalf("heartbeat log fields exposed response body in %q=%v", key, value)
}
}
}
func TestReadHeartbeatResponseBodyTruncatesLargeBody(t *testing.T) {
response := &http.Response{
Body: io.NopCloser(strings.NewReader(strings.Repeat("x", heartbeatResponseBodyLogLimit+1))),
}
body, truncated, err := readHeartbeatResponseBody(response)
if err != nil {
t.Fatalf("readHeartbeatResponseBody() error = %v", err)
}
if !truncated {
t.Fatal("readHeartbeatResponseBody() truncated = false, want true")
}
if len(body) != heartbeatResponseBodyLogLimit {
t.Fatalf("len(body) = %d, want %d", len(body), heartbeatResponseBodyLogLimit)
}
}
func TestHeartbeatONVIFPayloadCachesStaticAndReusesLoopSubscription(t *testing.T) {
restoreHeartbeatONVIFStubs(t)
device := newTestONVIFDevice()
camera := models.IPCamera{
ONVIFXAddr: "http://camera/onvif",
ONVIFUsername: "operator",
ONVIFPassword: "secret",
}
initialEvents := []agentonvif.ONVIFEvents{{Key: "input-1", Type: "input", Value: "true", Timestamp: 1}}
loopEvents := []agentonvif.ONVIFEvents{{Key: "output-1", Type: "output", Value: "false", Timestamp: 2}}
wantPresets := mustJSONMarshal(t, []models.OnvifActionPreset{{Name: "Lobby", Token: "1"}})
wantInitialEvents := mustJSONMarshal(t, initialEvents)
wantLoopEvents := mustJSONMarshal(t, loopEvents)
var connectCalls, ptzConfigCalls, ptzFunctionCalls, presetCalls, createCalls, eventCalls, unsubscribeCalls int
heartbeatConnectToONVIFDevice = func(*models.IPCamera) (*goonvif.Device, goonvifdevice.GetCapabilitiesResponse, error) {
connectCalls++
return device, goonvifdevice.GetCapabilitiesResponse{}, nil
}
heartbeatGetPTZConfigurationsFromDevice = func(*goonvif.Device) (goonvifptz.GetConfigurationsResponse, error) {
ptzConfigCalls++
return goonvifptz.GetConfigurationsResponse{}, nil
}
heartbeatGetPTZFunctionsFromDevice = func(goonvifptz.GetConfigurationsResponse) ([]string, bool, bool) {
ptzFunctionCalls++
return nil, true, true
}
heartbeatGetPresetsFromDevice = func(*goonvif.Device) ([]models.OnvifActionPreset, error) {
presetCalls++
return []models.OnvifActionPreset{{Name: "Lobby", Token: "1"}}, nil
}
heartbeatCreatePullPointSubscription = func(*goonvif.Device) (string, error) {
createCalls++
switch createCalls {
case 1:
return "initial-1", nil
case 2:
return "loop", nil
case 3:
return "initial-2", nil
default:
t.Fatalf("unexpected create pull point call %d", createCalls)
return "", nil
}
}
heartbeatGetEventMessages = func(_ *goonvif.Device, pullPointAddress string) ([]agentonvif.ONVIFEvents, error) {
eventCalls++
switch pullPointAddress {
case "initial-1", "initial-2":
return initialEvents, nil
case "loop":
return loopEvents, nil
default:
t.Fatalf("unexpected pull point address %q", pullPointAddress)
return nil, nil
}
}
heartbeatUnsubscribePullPoint = func(_ *goonvif.Device, pullPointAddress string) error {
unsubscribeCalls++
if pullPointAddress != "initial-1" && pullPointAddress != "initial-2" {
t.Fatalf("unexpected unsubscribe pull point %q", pullPointAddress)
}
return nil
}
state := newHeartbeatONVIFState()
payload := getHeartbeatONVIFPayload(camera, state)
if payload.enabled != "true" || payload.zoom != "true" || payload.panTilt != "true" || payload.presets != "true" {
t.Fatalf("unexpected static payload: %+v", payload)
}
if string(payload.presetsList) != string(wantPresets) {
t.Fatalf("payload.presetsList = %s, want %s", payload.presetsList, wantPresets)
}
if string(payload.eventsList) != string(wantInitialEvents) {
t.Fatalf("payload.eventsList = %s, want %s", payload.eventsList, wantInitialEvents)
}
if connectCalls != 1 || ptzConfigCalls != 1 || ptzFunctionCalls != 1 || presetCalls != 1 {
t.Fatalf("unexpected static call counts after first cycle: connect=%d ptzConfig=%d ptzFunctions=%d presets=%d", connectCalls, ptzConfigCalls, ptzFunctionCalls, presetCalls)
}
if createCalls != 2 || eventCalls != 1 || unsubscribeCalls != 1 {
t.Fatalf("unexpected event call counts after first cycle: create=%d events=%d unsubscribe=%d", createCalls, eventCalls, unsubscribeCalls)
}
payload = getHeartbeatONVIFPayload(camera, state)
if string(payload.eventsList) != string(wantLoopEvents) {
t.Fatalf("second payload.eventsList = %s, want %s", payload.eventsList, wantLoopEvents)
}
if connectCalls != 1 {
t.Fatalf("connectCalls = %d, want 1", connectCalls)
}
if ptzConfigCalls != 1 || ptzFunctionCalls != 1 || presetCalls != 1 {
t.Fatalf("static calls were not cached: ptzConfig=%d ptzFunctions=%d presets=%d", ptzConfigCalls, ptzFunctionCalls, presetCalls)
}
if createCalls != 3 || eventCalls != 3 || unsubscribeCalls != 2 {
t.Fatalf("temporary state subscription was not refreshed or loop subscription was not reused: create=%d events=%d unsubscribe=%d", createCalls, eventCalls, unsubscribeCalls)
}
}
func TestHeartbeatONVIFPayloadRefreshesAfterCameraConfigChange(t *testing.T) {
restoreHeartbeatONVIFStubs(t)
deviceA := newTestONVIFDevice()
deviceB := newTestONVIFDevice()
cameraA := models.IPCamera{ONVIFXAddr: "http://camera-a/onvif", ONVIFUsername: "user", ONVIFPassword: "secret-a"}
cameraB := models.IPCamera{ONVIFXAddr: "http://camera-b/onvif", ONVIFUsername: "user", ONVIFPassword: "secret-b"}
var connectCalls, ptzConfigCalls, presetCalls, createCalls int
var unsubscribed []string
heartbeatConnectToONVIFDevice = func(camera *models.IPCamera) (*goonvif.Device, goonvifdevice.GetCapabilitiesResponse, error) {
connectCalls++
switch camera.ONVIFXAddr {
case cameraA.ONVIFXAddr:
return deviceA, goonvifdevice.GetCapabilitiesResponse{}, nil
case cameraB.ONVIFXAddr:
return deviceB, goonvifdevice.GetCapabilitiesResponse{}, nil
default:
t.Fatalf("unexpected camera address %q", camera.ONVIFXAddr)
return nil, goonvifdevice.GetCapabilitiesResponse{}, nil
}
}
heartbeatGetPTZConfigurationsFromDevice = func(*goonvif.Device) (goonvifptz.GetConfigurationsResponse, error) {
ptzConfigCalls++
return goonvifptz.GetConfigurationsResponse{}, nil
}
heartbeatGetPTZFunctionsFromDevice = func(goonvifptz.GetConfigurationsResponse) ([]string, bool, bool) {
return nil, false, true
}
heartbeatGetPresetsFromDevice = func(*goonvif.Device) ([]models.OnvifActionPreset, error) {
presetCalls++
return nil, nil
}
heartbeatCreatePullPointSubscription = func(*goonvif.Device) (string, error) {
createCalls++
switch createCalls {
case 1:
return "initial-a", nil
case 2:
return "loop-a", nil
case 3:
return "initial-b", nil
case 4:
return "loop-b", nil
default:
t.Fatalf("unexpected create pull point call %d", createCalls)
return "", nil
}
}
heartbeatGetEventMessages = func(_ *goonvif.Device, pullPointAddress string) ([]agentonvif.ONVIFEvents, error) {
switch pullPointAddress {
case "initial-a":
return []agentonvif.ONVIFEvents{{Key: "a", Type: "input", Value: "true", Timestamp: 1}}, nil
case "initial-b":
return []agentonvif.ONVIFEvents{{Key: "b", Type: "input", Value: "false", Timestamp: 2}}, nil
default:
t.Fatalf("unexpected pull point address %q", pullPointAddress)
return nil, nil
}
}
heartbeatUnsubscribePullPoint = func(_ *goonvif.Device, pullPointAddress string) error {
unsubscribed = append(unsubscribed, pullPointAddress)
return nil
}
state := newHeartbeatONVIFState()
_ = getHeartbeatONVIFPayload(cameraA, state)
payload := getHeartbeatONVIFPayload(cameraB, state)
if connectCalls != 2 {
t.Fatalf("connectCalls = %d, want 2", connectCalls)
}
if ptzConfigCalls != 2 || presetCalls != 2 {
t.Fatalf("camera config change did not refresh static state: ptzConfig=%d presets=%d", ptzConfigCalls, presetCalls)
}
if createCalls != 4 {
t.Fatalf("camera config change did not recreate subscriptions: create=%d", createCalls)
}
if countString(unsubscribed, "loop-a") != 1 {
t.Fatalf("unsubscribed loop-a %d times, want 1; unsubscribed=%v", countString(unsubscribed, "loop-a"), unsubscribed)
}
wantEvents := mustJSONMarshal(t, []agentonvif.ONVIFEvents{{Key: "b", Type: "input", Value: "false", Timestamp: 2}})
if string(payload.eventsList) != string(wantEvents) {
t.Fatalf("payload.eventsList = %s, want %s", payload.eventsList, wantEvents)
}
}
func TestHeartbeatONVIFPayloadRetriesStaticFetchFailuresWithoutReconnect(t *testing.T) {
restoreHeartbeatONVIFStubs(t)
device := newTestONVIFDevice()
camera := models.IPCamera{
ONVIFXAddr: "http://camera/onvif",
ONVIFUsername: "operator",
ONVIFPassword: "secret",
}
var connectCalls, ptzConfigCalls, presetCalls, createCalls, eventCalls int
heartbeatConnectToONVIFDevice = func(*models.IPCamera) (*goonvif.Device, goonvifdevice.GetCapabilitiesResponse, error) {
connectCalls++
return device, goonvifdevice.GetCapabilitiesResponse{}, nil
}
heartbeatGetPTZConfigurationsFromDevice = func(*goonvif.Device) (goonvifptz.GetConfigurationsResponse, error) {
ptzConfigCalls++
return goonvifptz.GetConfigurationsResponse{}, nil
}
heartbeatGetPTZFunctionsFromDevice = func(goonvifptz.GetConfigurationsResponse) ([]string, bool, bool) {
return nil, true, true
}
heartbeatGetPresetsFromDevice = func(*goonvif.Device) ([]models.OnvifActionPreset, error) {
presetCalls++
if presetCalls == 1 {
return nil, errors.New("temporary preset failure")
}
return []models.OnvifActionPreset{{Name: "Lobby", Token: "1"}}, nil
}
heartbeatCreatePullPointSubscription = func(*goonvif.Device) (string, error) {
createCalls++
switch createCalls {
case 1:
return "initial-1", nil
case 2:
return "loop", nil
case 3:
return "initial-2", nil
default:
t.Fatalf("unexpected create pull point call %d", createCalls)
return "", nil
}
}
heartbeatGetEventMessages = func(_ *goonvif.Device, pullPointAddress string) ([]agentonvif.ONVIFEvents, error) {
eventCalls++
switch pullPointAddress {
case "initial-1", "initial-2":
return []agentonvif.ONVIFEvents{{Key: "one", Type: "input", Value: "true", Timestamp: 1}}, nil
case "loop":
return []agentonvif.ONVIFEvents{{Key: "two", Type: "output", Value: "false", Timestamp: 2}}, nil
default:
t.Fatalf("unexpected pull point address %q", pullPointAddress)
return nil, nil
}
}
heartbeatUnsubscribePullPoint = func(*goonvif.Device, string) error { return nil }
state := newHeartbeatONVIFState()
payload := getHeartbeatONVIFPayload(camera, state)
if payload.presets != "false" {
t.Fatalf("payload.presets = %q, want false on transient preset failure", payload.presets)
}
payload = getHeartbeatONVIFPayload(camera, state)
if payload.presets != "true" {
t.Fatalf("payload.presets = %q, want true after retry", payload.presets)
}
if connectCalls != 1 {
t.Fatalf("connectCalls = %d, want 1", connectCalls)
}
if ptzConfigCalls != 2 || presetCalls != 2 {
t.Fatalf("static failures were not retried on heartbeat cadence: ptzConfig=%d presets=%d", ptzConfigCalls, presetCalls)
}
if createCalls != 3 || eventCalls != 3 {
t.Fatalf("unexpected event behavior during retry: create=%d events=%d", createCalls, eventCalls)
}
}
func TestHeartbeatONVIFStateReleasesSubscriptionWhenDisabled(t *testing.T) {
restoreHeartbeatONVIFStubs(t)
device := newTestONVIFDevice()
state := newHeartbeatONVIFState()
state.cameraConfiguration = models.IPCamera{ONVIFXAddr: "http://camera/onvif"}
state.cameraKey = heartbeatONVIFCameraKey(state.cameraConfiguration)
state.device = device
state.loopPullPoint = "loop"
var unsubscribed []string
heartbeatUnsubscribePullPoint = func(gotDevice *goonvif.Device, pullPointAddress string) error {
if gotDevice != device {
t.Fatal("unsubscribe used a different ONVIF device")
}
unsubscribed = append(unsubscribed, pullPointAddress)
return nil
}
state.prepare(models.IPCamera{})
if len(unsubscribed) != 1 || unsubscribed[0] != "loop" {
t.Fatalf("unsubscribed = %v, want [loop]", unsubscribed)
}
if state.device != nil || state.loopPullPoint != "" {
t.Fatalf("disabled state retained device or pull point: %+v", state)
}
}
func TestHeartbeatONVIFPayloadDoesNotCreateSubscriptionsWhenConnectFails(t *testing.T) {
restoreHeartbeatONVIFStubs(t)
var createCalls int
heartbeatConnectToONVIFDevice = func(*models.IPCamera) (*goonvif.Device, goonvifdevice.GetCapabilitiesResponse, error) {
return nil, goonvifdevice.GetCapabilitiesResponse{}, errors.New("connect failed")
}
heartbeatCreatePullPointSubscription = func(*goonvif.Device) (string, error) {
createCalls++
return "unexpected", nil
}
payload := getHeartbeatONVIFPayload(models.IPCamera{
ONVIFXAddr: "http://camera/onvif",
ONVIFUsername: "operator",
ONVIFPassword: "secret",
}, newHeartbeatONVIFState())
if createCalls != 0 {
t.Fatalf("createCalls = %d, want 0", createCalls)
}
assertDefaultHeartbeatONVIFPayload(t, payload)
}
func TestHeartbeatONVIFPayloadReconnectFailureInvalidatesCache(t *testing.T) {
restoreHeartbeatONVIFStubs(t)
device1 := newTestONVIFDevice()
device2 := newTestONVIFDevice()
camera := models.IPCamera{
ONVIFXAddr: "http://camera/onvif",
ONVIFUsername: "operator",
ONVIFPassword: "secret",
}
var connectCalls, ptzConfigCalls, presetCalls, createCalls int
var unsubscribed []string
heartbeatConnectToONVIFDevice = func(*models.IPCamera) (*goonvif.Device, goonvifdevice.GetCapabilitiesResponse, error) {
connectCalls++
switch connectCalls {
case 1:
return device1, goonvifdevice.GetCapabilitiesResponse{}, nil
case 2:
return device2, goonvifdevice.GetCapabilitiesResponse{}, nil
default:
t.Fatalf("unexpected connect call %d", connectCalls)
return nil, goonvifdevice.GetCapabilitiesResponse{}, nil
}
}
heartbeatGetPTZConfigurationsFromDevice = func(*goonvif.Device) (goonvifptz.GetConfigurationsResponse, error) {
ptzConfigCalls++
return goonvifptz.GetConfigurationsResponse{}, nil
}
heartbeatGetPTZFunctionsFromDevice = func(goonvifptz.GetConfigurationsResponse) ([]string, bool, bool) {
return nil, true, false
}
heartbeatGetPresetsFromDevice = func(*goonvif.Device) ([]models.OnvifActionPreset, error) {
presetCalls++
return []models.OnvifActionPreset{{Name: "Preset", Token: "1"}}, nil
}
heartbeatCreatePullPointSubscription = func(*goonvif.Device) (string, error) {
createCalls++
switch createCalls {
case 1:
return "initial-1", nil
case 2:
return "loop-1", nil
case 3:
return "initial-2", nil
case 4:
return "initial-3", nil
case 5:
return "loop-3", nil
default:
t.Fatalf("unexpected create pull point call %d", createCalls)
return "", nil
}
}
heartbeatGetEventMessages = func(_ *goonvif.Device, pullPointAddress string) ([]agentonvif.ONVIFEvents, error) {
switch pullPointAddress {
case "initial-1":
return []agentonvif.ONVIFEvents{{Key: "before", Type: "input", Value: "true", Timestamp: 1}}, nil
case "initial-2":
return []agentonvif.ONVIFEvents{{Key: "during", Type: "input", Value: "true", Timestamp: 2}}, nil
case "loop-1":
return nil, errors.New("pull failed")
case "initial-3":
return []agentonvif.ONVIFEvents{{Key: "after", Type: "input", Value: "false", Timestamp: 2}}, nil
default:
t.Fatalf("unexpected pull point address %q", pullPointAddress)
return nil, nil
}
}
heartbeatUnsubscribePullPoint = func(_ *goonvif.Device, pullPointAddress string) error {
unsubscribed = append(unsubscribed, pullPointAddress)
return nil
}
state := newHeartbeatONVIFState()
_ = getHeartbeatONVIFPayload(camera, state)
payload := getHeartbeatONVIFPayload(camera, state)
wantEventsDuringFailure := mustJSONMarshal(t, []agentonvif.ONVIFEvents{{Key: "during", Type: "input", Value: "true", Timestamp: 2}})
if string(payload.eventsList) != string(wantEventsDuringFailure) {
t.Fatalf("payload.eventsList after operation failure = %s, want %s", payload.eventsList, wantEventsDuringFailure)
}
payload = getHeartbeatONVIFPayload(camera, state)
if ptzConfigCalls != 2 || presetCalls != 2 {
t.Fatalf("reconnect did not refresh static state: ptzConfig=%d presets=%d", ptzConfigCalls, presetCalls)
}
if connectCalls != 2 {
t.Fatalf("connectCalls = %d, want 2 after reconnect", connectCalls)
}
if createCalls != 5 {
t.Fatalf("reconnect did not recreate subscriptions: create=%d", createCalls)
}
if countString(unsubscribed, "loop-1") != 1 {
t.Fatalf("operation failure cleanup mismatch, unsubscribed=%v", unsubscribed)
}
wantEvents := mustJSONMarshal(t, []agentonvif.ONVIFEvents{{Key: "after", Type: "input", Value: "false", Timestamp: 2}})
if string(payload.eventsList) != string(wantEvents) {
t.Fatalf("payload.eventsList = %s, want %s", payload.eventsList, wantEvents)
}
}
func TestHeartbeatONVIFPayloadKeepsCachedConnectionWhenInitialStateFetchFails(t *testing.T) {
restoreHeartbeatONVIFStubs(t)
device := newTestONVIFDevice()
camera := models.IPCamera{
ONVIFXAddr: "http://camera/onvif",
ONVIFUsername: "operator",
ONVIFPassword: "secret",
}
loopEvents := []agentonvif.ONVIFEvents{{Key: "input-1", Type: "input", Value: "true", Timestamp: 1}}
state := newHeartbeatONVIFState()
state.cameraConfiguration = camera
state.cameraKey = heartbeatONVIFCameraKey(camera)
state.device = device
state.loopPullPoint = "loop"
state.staticLoaded = true
state.staticPayload.enabled = "true"
heartbeatCreatePullPointSubscription = func(*goonvif.Device) (string, error) {
return "", errors.New("temporary initial-state failure")
}
heartbeatGetEventMessages = func(_ *goonvif.Device, pullPointAddress string) ([]agentonvif.ONVIFEvents, error) {
if pullPointAddress != "loop" {
t.Fatalf("unexpected pull point address %q", pullPointAddress)
}
return loopEvents, nil
}
payload := getHeartbeatONVIFPayload(camera, state)
wantEvents := mustJSONMarshal(t, loopEvents)
if string(payload.eventsList) != string(wantEvents) {
t.Fatalf("payload.eventsList = %s, want %s", payload.eventsList, wantEvents)
}
if state.device != device || state.loopPullPoint != "loop" || !state.staticLoaded {
t.Fatalf("temporary failure invalidated healthy cached state: %+v", state)
}
}
func restoreHeartbeatONVIFStubs(t *testing.T) {
t.Helper()
originalConnect := heartbeatConnectToONVIFDevice
originalCreate := heartbeatCreatePullPointSubscription
originalDigitalInputs := heartbeatGetDigitalInputs
originalEvents := heartbeatGetEventMessages
originalPresets := heartbeatGetPresetsFromDevice
originalPTZConfigurations := heartbeatGetPTZConfigurationsFromDevice
originalPTZFunctions := heartbeatGetPTZFunctionsFromDevice
originalRelayOutputs := heartbeatGetRelayOutputs
originalUnsubscribe := heartbeatUnsubscribePullPoint
t.Cleanup(func() {
heartbeatConnectToONVIFDevice = originalConnect
heartbeatCreatePullPointSubscription = originalCreate
heartbeatGetDigitalInputs = originalDigitalInputs
heartbeatGetEventMessages = originalEvents
heartbeatGetPresetsFromDevice = originalPresets
heartbeatGetPTZConfigurationsFromDevice = originalPTZConfigurations
heartbeatGetPTZFunctionsFromDevice = originalPTZFunctions
heartbeatGetRelayOutputs = originalRelayOutputs
heartbeatUnsubscribePullPoint = originalUnsubscribe
})
}
func mustJSONMarshal(t *testing.T, v interface{}) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
return b
}
func assertDefaultHeartbeatONVIFPayload(t *testing.T, payload heartbeatONVIFPayload) {
t.Helper()
defaultPayload := defaultHeartbeatONVIFPayload()
if payload.enabled != defaultPayload.enabled ||
payload.zoom != defaultPayload.zoom ||
payload.panTilt != defaultPayload.panTilt ||
payload.presets != defaultPayload.presets ||
string(payload.presetsList) != string(defaultPayload.presetsList) ||
string(payload.eventsList) != string(defaultPayload.eventsList) {
t.Fatalf("payload = %+v, want default payload", payload)
}
}
func newTestONVIFDevice() *goonvif.Device {
return &goonvif.Device{}
}
func countString(values []string, want string) int {
count := 0
for _, value := range values {
if value == want {
count++
}
}
return count
}

View File

@@ -11,8 +11,8 @@ import (
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/users"
"github.com/gin-gonic/gin"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
log "github.com/sirupsen/logrus"
)
// UploadDropbox uploads the file to your Dropbox account using the access token and directory.
@@ -30,13 +30,13 @@ func UploadDropbox(configuration *models.Configuration, fileName string) (bool,
if token == "" {
err := "UploadDropbox: Dropbox not properly configured"
log.Log.Info(err)
log.Info(err)
return false, true, errors.New(err)
}
// Upload to Dropbox
log.Log.Info("UploadDropbox: Uploading to Dropbox")
log.Log.Info("UploadDropbox: Upload started for " + fileName)
log.Info("UploadDropbox: Uploading to Dropbox")
log.Info("UploadDropbox: Upload started for " + fileName)
fullname := "data/recordings/" + fileName
dConfig := dropbox.Config{
@@ -46,7 +46,11 @@ func UploadDropbox(configuration *models.Configuration, fileName string) (bool,
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
if file != nil {
defer file.Close()
defer func() {
if cerr := file.Close(); cerr != nil {
log.Error("UploadDropbox: Error closing file: " + cerr.Error())
}
}()
}
if err == nil {
@@ -64,15 +68,15 @@ func UploadDropbox(configuration *models.Configuration, fileName string) (bool,
}, file)
if err != nil {
log.Log.Error("UploadDropbox: Error uploading file: " + err.Error())
log.Error("UploadDropbox: Error uploading file: " + err.Error())
return false, false, err
}
log.Log.Info("UploadDropbox: File uploaded successfully, " + res.Name)
log.Info("UploadDropbox: File uploaded successfully, " + res.Name)
return true, true, nil
}
log.Log.Error("UploadDropbox: Error opening file: " + err.Error())
log.Error("UploadDropbox: Error opening file: " + err.Error())
return false, true, err
}

View File

@@ -3,12 +3,13 @@ package cloud
import (
"crypto/tls"
"errors"
"fmt"
"io/ioutil"
"net/http"
"os"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
log "github.com/sirupsen/logrus"
)
func UploadKerberosHub(configuration *models.Configuration, fileName string) (bool, bool, error) {
@@ -19,7 +20,7 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
config.HubPrivateKey == "" ||
config.S3.Region == "" {
err := "UploadKerberosHub: Kerberos Hub not properly configured."
log.Log.Info(err)
log.Info(err)
return false, false, errors.New(err)
}
@@ -32,8 +33,51 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
// - Number of changes
// - Token
log.Log.Info("UploadKerberosHub: Uploading to Kerberos Hub (" + config.HubURI + ")")
log.Log.Info("UploadKerberosHub: Upload started for " + fileName)
log.WithFields(log.Fields{
"component": "kerberos_hub",
"event": "upload_started",
}).Info("Uploading recording to Kerberos Hub")
// Prefer the resumable (tus) upload when enabled (the default). Kerberos Hub
// authenticates the agent with its Hub public/private key and proxies the
// resumable upload to the Kerberos Vault. When Hub does not expose a tus
// endpoint (older deployments) we transparently fall back to the legacy
// single-POST upload below.
if resumableUploadsEnabled() {
uploaded, _, supported, body, rerr := uploadHubResumable(&config, fileName, "UploadKerberosHub", "hub")
if supported {
if uploaded {
log.WithFields(log.Fields{
"component": "kerberos_hub",
"event": "upload_completed",
"response_bytes": len(body),
"transport": "tus",
}).Info("Hub upload completed")
return true, true, nil
}
if rerr != nil {
log.WithError(rerr).WithFields(log.Fields{
"component": "kerberos_hub",
"event": "upload_failed",
"transport": "tus",
}).Error("Hub upload failed")
} else {
log.WithFields(log.Fields{
"component": "kerberos_hub",
"event": "upload_incomplete",
"response_bytes": len(body),
"transport": "tus",
}).Warn("Hub upload incomplete")
}
return false, true, rerr
}
log.WithFields(log.Fields{
"component": "kerberos_hub",
"event": "upload_transport_fallback",
"transport": "http",
}).Info("Resumable Hub upload unavailable; using legacy upload")
}
fullname := "data/recordings/" + fileName
// Check if we still have the file otherwise we abort the request.
@@ -43,7 +87,7 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
}
if err != nil {
err := "UploadKerberosHub: Upload Failed, file doesn't exists anymore."
log.Log.Info(err)
log.Info(err)
return false, false, errors.New(err)
}
@@ -51,9 +95,11 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
// There might be different reasons like (muted, read-only..)
req, err := http.NewRequest("HEAD", config.HubURI+"/storage/upload", nil)
if err != nil {
errorMessage := "UploadKerberosHub: error reading HEAD request, " + config.HubURI + "/storage: " + err.Error()
log.Log.Error(errorMessage)
return false, true, errors.New(errorMessage)
log.WithError(err).WithFields(log.Fields{
"component": "kerberos_hub",
"event": "authorization_request_creation_failed",
}).Error("Failed to create Hub upload authorization request")
return false, true, fmt.Errorf("create Hub upload authorization request: %w", err)
}
req.Header.Set("X-Kerberos-Storage-FileName", fileName)
@@ -62,15 +108,16 @@ 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" {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client = &http.Client{Transport: tr}
client = &http.Client{Transport: tr, CheckRedirect: stripHubCredentialsOnCrossHostRedirect}
} else {
client = &http.Client{}
client = &http.Client{CheckRedirect: stripHubCredentialsOnCrossHostRedirect}
}
resp, err := client.Do(req)
@@ -78,25 +125,31 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
defer resp.Body.Close()
}
if err == nil {
if resp != nil {
if err == nil {
if resp.StatusCode == 200 {
log.Log.Info("UploadKerberosHub: Upload allowed using the credentials provided (" + config.HubKey + ", " + config.HubPrivateKey + ")")
} else {
log.Log.Info("UploadKerberosHub: Upload NOT allowed using the credentials provided (" + config.HubKey + ", " + config.HubPrivateKey + ")")
return false, true, nil
}
}
if err == nil && resp != nil {
if resp.StatusCode == 200 {
log.WithFields(log.Fields{
"component": "kerberos_hub",
"event": "upload_authorized",
"status_code": resp.StatusCode,
}).Debug("Hub upload authorized")
} else {
log.WithFields(log.Fields{
"component": "kerberos_hub",
"event": "upload_rejected",
"status_code": resp.StatusCode,
}).Warn("Hub upload rejected")
return false, true, nil
}
}
// Now we know we are allowed to upload to the hub, we can start uploading.
req, err = http.NewRequest("POST", config.HubURI+"/storage/upload", file)
if err != nil {
errorMessage := "UploadKerberosHub: error reading POST request, " + config.KStorage.URI + "/storage/upload: " + err.Error()
log.Log.Error(errorMessage)
return false, true, errors.New(errorMessage)
log.WithError(err).WithFields(log.Fields{
"component": "kerberos_hub",
"event": "upload_request_creation_failed",
}).Error("Failed to create Hub upload request")
return false, true, fmt.Errorf("create Hub upload request: %w", err)
}
req.Header.Set("Content-Type", "video/mp4")
req.Header.Set("X-Kerberos-Storage-FileName", fileName)
@@ -105,6 +158,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()
@@ -115,17 +169,51 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
body, err := ioutil.ReadAll(resp.Body)
if err == nil {
if resp.StatusCode == 200 {
log.Log.Info("UploadKerberosHub: Upload Finished, " + resp.Status + ".")
log.WithFields(log.Fields{
"component": "kerberos_hub",
"event": "upload_completed",
"status_code": resp.StatusCode,
"transport": "http",
}).Info("Hub upload completed")
return true, true, nil
} else {
log.Log.Info("UploadKerberosHub: Upload Failed, " + resp.Status + ", " + string(body))
log.WithFields(log.Fields{
"component": "kerberos_hub",
"event": "upload_rejected",
"response_bytes": len(body),
"status_code": resp.StatusCode,
"transport": "http",
}).Warn("Hub upload rejected")
return false, true, nil
}
}
}
}
errorMessage := "UploadKerberosHub: Upload Failed, " + err.Error()
log.Log.Info(errorMessage)
return false, true, errors.New(errorMessage)
if err == nil {
err = errors.New("Hub upload failed without a response")
}
log.WithError(err).WithFields(log.Fields{
"component": "kerberos_hub",
"event": "upload_failed",
"transport": "http",
}).Error("Hub upload failed")
return false, true, fmt.Errorf("Hub upload failed: %w", err)
}
// stripHubCredentialsOnCrossHostRedirect removes the custom Kerberos Hub
// credential headers on a redirect that crosses to a different host. net/http
// already strips the standard sensitive headers (Authorization, Cookie,
// WWW-Authenticate) on a cross-host redirect, but it does NOT strip
// custom-named headers, so without this the Hub private/public keys would be
// forwarded to any host the configured HubURI redirects to.
func stripHubCredentialsOnCrossHostRedirect(req *http.Request, via []*http.Request) error {
if len(via) == 0 {
return nil
}
if req.URL.Host != via[0].URL.Host {
req.Header.Del("X-Kerberos-Hub-PrivateKey")
req.Header.Del("X-Kerberos-Hub-PublicKey")
}
return nil
}

View File

@@ -6,10 +6,11 @@ import (
"io"
"net/http"
"os"
"strconv"
"time"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
log "github.com/sirupsen/logrus"
)
// We will count the number of retries we have done.
@@ -26,10 +27,24 @@ func UploadKerberosVault(configuration *models.Configuration, fileName string) (
config.KStorage.Directory == "" ||
config.KStorage.URI == "" {
err := "UploadKerberosVault: Kerberos Vault not properly configured"
log.Log.Info(err)
log.Info(err)
return false, false, errors.New(err)
}
// If the recording no longer exists on disk there is nothing to upload.
// This can happen when the file was already removed (e.g. cleanup, or an
// earlier successful upload). Skip it so the watcher drops the marker
// instead of retrying indefinitely.
info, err := os.Stat("data/recordings/" + fileName)
if err != nil {
log.Info("UploadKerberosVault: skipping " + fileName + ", file doesn't exist anymore")
return false, false, nil
}
if info.Size() == 0 {
log.Warn("UploadKerberosVault: skipping " + fileName + ", recording is empty")
return false, false, nil
}
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
// - Timestamp
@@ -39,19 +54,11 @@ func UploadKerberosVault(configuration *models.Configuration, fileName string) (
// - Number of changes
// - Token
// KerberosCloud, this means storage is disabled and proxy enabled.
log.Log.Info("UploadKerberosVault: Uploading to Kerberos Vault (" + config.KStorage.URI + ")")
log.Log.Info("UploadKerberosVault: Upload started for " + fileName)
fullname := "data/recordings/" + fileName
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
if file != nil {
defer file.Close()
}
if err != nil {
err := "UploadKerberosVault: Upload Failed, file doesn't exists anymore"
log.Log.Info(err)
return false, false, errors.New(err)
}
log.WithFields(log.Fields{
"component": "kerberos_vault",
"event": "upload_started",
"storage": "primary",
}).Info("Vault upload started")
publicKey := config.KStorage.CloudKey
if config.HubKey != "" {
@@ -60,62 +67,43 @@ func UploadKerberosVault(configuration *models.Configuration, fileName string) (
// We need to check if we are in a retry timeout.
if kstorageRetryTimeout <= time.Now().Unix() {
uploaded, responded, body, err := sendToVault(*config.KStorage, publicKey, config.Key, fileName, "UploadKerberosVault", "primary")
if uploaded {
kstorageRetryCount = 0
log.WithFields(log.Fields{
"component": "kerberos_vault",
"event": "upload_completed",
"storage": "primary",
}).Info("Vault upload completed")
return true, true, nil
}
req, err := http.NewRequest("POST", config.KStorage.URI+"/storage", file)
if err != nil {
errorMessage := "UploadKerberosVault: error reading request, " + config.KStorage.URI + "/storage: " + err.Error()
log.Log.Error(errorMessage)
return false, true, errors.New(errorMessage)
}
req.Header.Set("Content-Type", "video/mp4")
req.Header.Set("X-Kerberos-Storage-CloudKey", publicKey)
req.Header.Set("X-Kerberos-Storage-AccessKey", config.KStorage.AccessKey)
req.Header.Set("X-Kerberos-Storage-SecretAccessKey", config.KStorage.SecretAccessKey)
req.Header.Set("X-Kerberos-Storage-Provider", config.KStorage.Provider)
req.Header.Set("X-Kerberos-Storage-FileName", fileName)
req.Header.Set("X-Kerberos-Storage-Device", config.Key)
req.Header.Set("X-Kerberos-Storage-Capture", "IPCamera")
req.Header.Set("X-Kerberos-Storage-Directory", config.KStorage.Directory)
var client *http.Client
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client = &http.Client{Transport: tr}
log.WithError(err).WithFields(log.Fields{
"component": "kerberos_vault",
"event": "upload_failed",
"storage": "primary",
}).Error("Vault upload failed")
} else {
client = &http.Client{}
log.WithFields(log.Fields{
"component": "kerberos_vault",
"event": "upload_rejected",
"response_bytes": len(body),
"storage": "primary",
}).Warn("Vault upload rejected")
}
resp, err := client.Do(req)
if resp != nil {
defer resp.Body.Close()
}
if err == nil {
if resp != nil {
body, err := io.ReadAll(resp.Body)
if err == nil {
if resp.StatusCode == 200 {
kstorageRetryCount = 0
log.Log.Info("UploadKerberosVault: Upload Finished, " + resp.Status + ", " + string(body))
return true, true, nil
} else {
// We increase the retry count, and set the timeout.
// If we have reached the retry policy, we set the timeout.
// This means we will not retry for the next 5 minutes.
if kstorageRetryCount < config.KStorage.MaxRetries {
kstorageRetryCount = (kstorageRetryCount + 1)
}
if kstorageRetryCount == config.KStorage.MaxRetries {
kstorageRetryTimeout = time.Now().Add(time.Duration(config.KStorage.Timeout) * time.Second).Unix()
}
log.Log.Info("UploadKerberosVault: Upload Failed, " + resp.Status + ", " + string(body))
}
}
// We only advance the retry policy when the vault gave a definitive
// response (mirroring the original behaviour where transient network
// errors did not consume retries). When the retry count reaches the
// configured maximum we back off for the configured timeout.
if responded {
if kstorageRetryCount < config.KStorage.MaxRetries {
kstorageRetryCount = (kstorageRetryCount + 1)
}
if kstorageRetryCount == config.KStorage.MaxRetries {
kstorageRetryTimeout = time.Now().Add(time.Duration(config.KStorage.Timeout) * time.Second).Unix()
}
} else {
log.Log.Info("UploadKerberosVault: Upload Failed, " + err.Error())
}
}
@@ -124,71 +112,186 @@ func UploadKerberosVault(configuration *models.Configuration, fileName string) (
config.KStorageSecondary.SecretAccessKey == "" ||
config.KStorageSecondary.Directory == "" ||
config.KStorageSecondary.URI == "" {
log.Log.Info("UploadKerberosVault (Secondary): Secondary Kerberos Vault not properly configured.")
log.Info("UploadKerberosVault (Secondary): Secondary Kerberos Vault not properly configured.")
} else {
if kstorageRetryCount < config.KStorage.MaxRetries {
log.Log.Info("UploadKerberosVault (Secondary): Do not upload to secondary storage, we are still in retry policy.")
log.Info("UploadKerberosVault (Secondary): Do not upload to secondary storage, we are still in retry policy.")
return false, true, nil
}
log.Log.Info("UploadKerberosVault (Secondary): Uploading to Secondary Kerberos Vault (" + config.KStorageSecondary.URI + ")")
log.WithFields(log.Fields{
"component": "kerberos_vault",
"event": "upload_started",
"storage": "secondary",
}).Info("Vault upload started")
file, err = os.OpenFile(fullname, os.O_RDWR, 0755)
if file != nil {
defer file.Close()
uploaded, _, body, err := sendToVault(*config.KStorageSecondary, publicKey, config.Key, fileName, "UploadKerberosVault (Secondary)", "secondary")
if uploaded {
log.WithFields(log.Fields{
"component": "kerberos_vault",
"event": "upload_completed",
"storage": "secondary",
}).Info("Vault upload completed")
return true, true, nil
}
if err != nil {
err := "UploadKerberosVault (Secondary): Upload Failed, file doesn't exists anymore"
log.Log.Info(err)
return false, false, errors.New(err)
}
req, err := http.NewRequest("POST", config.KStorageSecondary.URI+"/storage", file)
if err != nil {
errorMessage := "UploadKerberosVault (Secondary): error reading request, " + config.KStorageSecondary.URI + "/storage: " + err.Error()
log.Log.Error(errorMessage)
return false, true, errors.New(errorMessage)
}
req.Header.Set("Content-Type", "video/mp4")
req.Header.Set("X-Kerberos-Storage-CloudKey", publicKey)
req.Header.Set("X-Kerberos-Storage-AccessKey", config.KStorageSecondary.AccessKey)
req.Header.Set("X-Kerberos-Storage-SecretAccessKey", config.KStorageSecondary.SecretAccessKey)
req.Header.Set("X-Kerberos-Storage-Provider", config.KStorageSecondary.Provider)
req.Header.Set("X-Kerberos-Storage-FileName", fileName)
req.Header.Set("X-Kerberos-Storage-Device", config.Key)
req.Header.Set("X-Kerberos-Storage-Capture", "IPCamera")
req.Header.Set("X-Kerberos-Storage-Directory", config.KStorageSecondary.Directory)
var client *http.Client
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client = &http.Client{Transport: tr}
log.WithError(err).WithFields(log.Fields{
"component": "kerberos_vault",
"event": "upload_failed",
"storage": "secondary",
}).Error("Vault upload failed")
} else {
client = &http.Client{}
}
resp, err := client.Do(req)
if resp != nil {
defer resp.Body.Close()
}
if err == nil {
if resp != nil {
body, err := io.ReadAll(resp.Body)
if err == nil {
if resp.StatusCode == 200 {
log.Log.Info("UploadKerberosVault (Secondary): Upload Finished to secondary, " + resp.Status + ", " + string(body))
return true, true, nil
} else {
log.Log.Info("UploadKerberosVault (Secondary): Upload Failed to secondary, " + resp.Status + ", " + string(body))
}
}
}
log.WithFields(log.Fields{
"component": "kerberos_vault",
"event": "upload_rejected",
"response_bytes": len(body),
"storage": "secondary",
}).Warn("Vault upload rejected")
}
}
return false, true, nil
}
// sendToVault uploads a single recording to one Kerberos Vault. When resumable
// uploads are enabled (the default) it attempts the tus protocol first and, if
// the vault does not expose a tus endpoint (older deployments), transparently
// falls back to the legacy single-shot POST.
//
// It returns whether the upload succeeded, whether the vault gave a definitive
// HTTP response (so the caller can advance its retry policy), a short message
// for logging, and a transport error if any.
func sendToVault(vault models.KStorage, publicKey, deviceKey, fileName, label, slot string) (bool, bool, string, error) {
if resumableUploadsEnabled() {
uploaded, responded, supported, body, err := uploadVaultResumable(vault, publicKey, deviceKey, fileName, label, slot)
if supported {
return uploaded, responded, body, err
}
log.Info(label + ": resumable (tus) endpoint not available, falling back to legacy upload")
}
return uploadVaultLegacy(vault, publicKey, deviceKey, fileName, label)
}
// uploadVaultLegacy performs the original single-request upload: the whole file
// is sent as the body of a POST to {URI}/storage. Kept for backwards
// compatibility with vault deployments that do not support resumable uploads.
func uploadVaultLegacy(vault models.KStorage, publicKey, deviceKey, fileName, label string) (bool, bool, string, error) {
fullname := "data/recordings/" + fileName
file, err := os.Open(fullname)
if file != nil {
defer file.Close()
}
if err != nil {
msg := label + ": Upload Failed, file doesn't exists anymore"
log.Info(msg)
return false, false, "", errors.New(msg)
}
uri := vault.URI
for len(uri) > 0 && uri[len(uri)-1] == '/' {
uri = uri[:len(uri)-1]
}
req, err := http.NewRequest("POST", uri+"/storage", file)
if err != nil {
errorMessage := label + ": error reading request, " + uri + "/storage: " + err.Error()
log.Error(errorMessage)
return false, false, "", errors.New(errorMessage)
}
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)
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
return false, false, "", err
}
body, rerr := io.ReadAll(resp.Body)
if rerr != nil {
return false, false, "", rerr
}
if resp.StatusCode == 200 {
return true, true, resp.Status + ", " + string(body), nil
}
return false, true, resp.Status + ", " + string(body), nil
}
// setVaultHeaders sets the standard Kerberos Vault headers used by the legacy
// single-POST upload.
func setVaultHeaders(h http.Header, vault models.KStorage, publicKey, deviceKey, fileName string) {
h.Set("X-Kerberos-Storage-CloudKey", publicKey)
h.Set("X-Kerberos-Storage-AccessKey", vault.AccessKey)
h.Set("X-Kerberos-Storage-SecretAccessKey", vault.SecretAccessKey)
h.Set("X-Kerberos-Storage-Provider", vault.Provider)
h.Set("X-Kerberos-Storage-FileName", fileName)
h.Set("X-Kerberos-Storage-Device", deviceKey)
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
h.Set("X-Kerberos-Storage-Directory", vault.Directory)
}
// newVaultHTTPClient builds an HTTP client honouring the AGENT_TLS_INSECURE
// 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 {
// 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" {
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,403 @@
package cloud
import (
"os"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/kerberos-io/agent/machinery/src/cloud/livehls"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/packets"
log "github.com/sirupsen/logrus"
)
// hlsViewerTimeoutSeconds is how long the agent keeps shipping live HLS segments
// after the last viewer keepalive. It is a few seconds longer than the segment
// duration so a viewer whose keepalive is briefly delayed does not cause the
// session to flap. When it lapses the session is torn down to stop wasting
// upload bandwidth when nobody is watching.
const hlsViewerTimeoutSeconds = 8
// hlsReadyReannounceSeconds throttles how often the agent re-announces an
// already-ready session over MQTT in response to viewer keepalives. The initial
// "receive-hls-ready" is a one-shot fired when the first segment lands; a viewer
// that connects or hard-refreshes after that (while the session is still alive)
// missed it, so we re-announce on subsequent keepalives. Viewers dedupe by
// session id, so a re-announce for a session they already play is a no-op. ~2s
// gets a refreshed viewer playing well within its connection timeout without
// spamming the control plane.
const hlsReadyReannounceSeconds = 2
// HandleLiveStreamHLS drives the live HLS producer. It mirrors HandleLiveStreamSD:
// it reads the camera's packet stream from a Latest() cursor, and while a viewer
// is active (kept alive via communication.HandleLiveHLS) it muxes the packets
// into CMAF segments and ships them to hub-api, which stores each segment in an
// ephemeral, short-TTL live window and serves the rolling playlist to viewers.
//
// A session is created lazily on the first keyframe seen while a viewer is active
// and torn down once viewers go away, so an idle camera produces no live traffic.
//
// By default (AGENT_LIVE_HLS_PREWARM unset or != "false") the agent instead keeps
// one long-lived session muxing continuously into a small in-memory ring buffer
// while idle (uploading nothing) and, the moment a viewer arrives, flushes the
// already-encoded init + most-recent segment(s) and starts uploading live. This
// trades a little idle CPU for a near-instant "requesting stream", so viewers no
// longer wait a full GOP for the first segment to be cut. Set
// AGENT_LIVE_HLS_PREWARM=false to fall back to the lazy on-demand path above.
func HandleLiveStreamHLS(
configuration *models.Configuration,
communication *models.Communication,
mqttClient mqtt.Client,
subStreamEnabled bool,
mainQueue *packets.Queue,
subQueue *packets.Queue,
) {
log.Debug("cloud.HandleLiveStreamHLS(): started")
config := configuration.Config
if config.Offline == "true" {
log.Debug("cloud.HandleLiveStreamHLS(): stopping as Offline is enabled.")
return
}
if config.Capture.Liveview == "false" {
log.Debug("cloud.HandleLiveStreamHLS(): stopping as Liveview is disabled.")
return
}
if config.HubURI == "" || config.HubKey == "" {
log.Debug("cloud.HandleLiveStreamHLS(): stopping as the Hub is not configured (HubURI/HubKey).")
return
}
hubKey := config.HubKey
deviceId := config.Key
region := ""
if config.S3 != nil {
region = config.S3.Region
}
publisher := livehls.NewPublisher(livehls.PublisherConfig{
HubURI: config.HubURI,
HubKey: config.HubKey,
HubPrivateKey: config.HubPrivateKey,
Region: region,
DeviceKey: deviceId,
})
// The live session can be served from the main (high-resolution) or sub
// (low-resolution) stream and switched on demand. requestedQuality tracks the
// latest tier asked for over the keepalive; source holds the cursor plus the
// encoded parameter sets/dimensions for the stream currently being muxed.
// Encoded dimensions are only needed for the avcC fallback path (an SPS that
// mp4ff's strict parser rejects).
requestedQuality := models.StreamQualityAuto
useSub := models.SelectSubStreamForQuality(config, requestedQuality, subStreamEnabled)
source := buildHLSSource(config, mainQueue, subQueue, useSub)
log.Info("cloud.HandleLiveStreamHLS(): serving live HLS from the " + source.label + " stream")
// prewarm keeps a single long-lived session muxing into an in-memory ring
// buffer while idle and flushes it the instant a viewer arrives, eliminating
// the per-request GOP wait. Enabled by default; set AGENT_LIVE_HLS_PREWARM=false
// to fall back to the lazy on-demand path.
prewarm := os.Getenv("AGENT_LIVE_HLS_PREWARM") != "false"
if prewarm {
log.Info("cloud.HandleLiveStreamHLS(): live HLS prewarm ENABLED (set AGENT_LIVE_HLS_PREWARM=false to disable)")
} else {
log.Info("cloud.HandleLiveStreamHLS(): live HLS prewarm DISABLED (AGENT_LIVE_HLS_PREWARM=false)")
}
// lowLatency enables LL-HLS: each segment is sliced into CMAF parts shipped the
// instant they close and advertised via #EXT-X-PART, taking glass-to-glass HLS
// latency from ~4-6s down to ~1-2s. Enabled by default; set
// AGENT_LIVE_HLS_LOW_LATENCY=false to fall back to whole-segment HLS.
partTargetMs := uint64(0)
if os.Getenv("AGENT_LIVE_HLS_LOW_LATENCY") != "false" {
partTargetMs = livehls.DefaultPartTargetMs
log.Info("cloud.HandleLiveStreamHLS(): live HLS low-latency (LL-HLS) ENABLED (set AGENT_LIVE_HLS_LOW_LATENCY=false to disable)")
} else {
log.Info("cloud.HandleLiveStreamHLS(): live HLS low-latency (LL-HLS) DISABLED (AGENT_LIVE_HLS_LOW_LATENCY=false)")
}
var session *livehls.Session
lastViewerRequest := int64(0)
lastReadyAnnounce := int64(0)
var cursorError error
var pkt packets.Packet
for cursorError == nil {
pkt, cursorError = source.cursor.ReadPacket()
if cursorError != nil {
break
}
now := time.Now().Unix()
select {
case q := <-communication.HandleLiveHLS:
lastViewerRequest = now
if q != "" {
requestedQuality = q
}
// A keepalive may come from a viewer that just connected or hard-
// refreshed and therefore missed the one-shot readiness announcement
// fired when this session's first segment landed. Re-announce (throttled)
// so late/refreshed viewers learn the active session id; the frontend
// dedupes by session id, so this is a no-op for viewers already playing.
// UploadsActive() is always true for the on-demand path; for prewarm it
// suppresses a stale re-announce while idle (the flush-on-arrival path
// below announces once the buffer has actually been shipped).
if session != nil && session.IsReady() && session.UploadsActive() && now-lastReadyAnnounce >= hlsReadyReannounceSeconds {
publishHLSReady(configuration, mqttClient, hubKey, deviceId, session.SessionID())
lastReadyAnnounce = now
}
default:
}
// Switch the source stream when the requested quality now maps to the other
// stream. Tearing the current session down makes the producer rebuild the
// init segment and announce a fresh session id from the new stream, which the
// viewer re-attaches to.
if wantSub := models.SelectSubStreamForQuality(config, requestedQuality, subStreamEnabled); wantSub != useSub {
useSub = wantSub
if session != nil {
_ = session.Close()
session = nil
}
source = buildHLSSource(config, mainQueue, subQueue, useSub)
lastReadyAnnounce = 0
log.Info("cloud.HandleLiveStreamHLS(): switched live HLS to the " + source.label + " stream (quality=" + requestedQuality + ")")
continue
}
viewerActive := now-lastViewerRequest <= hlsViewerTimeoutSeconds
if prewarm {
// Keep one long-lived session muxing into the ring buffer. Create it on
// the first keyframe (so the buffer opens on a random-access point) and
// never tear it down for idleness; uploads, not muxing, are what we gate
// on viewer presence.
if session == nil {
if len(pkt.Data) == 0 || !pkt.IsVideo || !pkt.IsKeyFrame {
continue
}
session = livehls.NewSession(publisher, livehls.SessionOptions{
Codec: pkt.Codec,
SPSNALUs: source.sps,
PPSNALUs: source.pps,
VPSNALUs: source.vps,
Width: source.width,
Height: source.height,
PartTargetMs: partTargetMs,
StartBuffering: true,
})
session.SetOnReady(func(sessionID string) {
log.WithFields(log.Fields{
"component": "cloud/livehls",
"event": "session_ready",
"session_id": sessionID,
}).Info("Live HLS session ready")
publishHLSReady(configuration, mqttClient, hubKey, deviceId, sessionID)
lastReadyAnnounce = time.Now().Unix()
})
session.SetOnFailure(func(sessionID, reason string) {
publishHLSFailure(configuration, mqttClient, hubKey, deviceId, sessionID, reason)
})
log.WithFields(log.Fields{
"component": "cloud/livehls",
"event": "session_prewarming",
"session_id": session.SessionID(),
}).Info("Prewarming live HLS session")
}
if viewerActive {
// Activating flushes the cached init + buffered segment(s). onReady
// announces the first-ever readiness; on a later re-activation it has
// already fired, so announce here (throttled, so the first activation
// does not double up) once the buffer has actually been shipped.
if session.SetUploadsActive(true) && session.IsReady() && now-lastReadyAnnounce >= hlsReadyReannounceSeconds {
publishHLSReady(configuration, mqttClient, hubKey, deviceId, session.SessionID())
lastReadyAnnounce = now
}
} else {
// No viewer: keep muxing into the buffer but stop uploading.
session.SetUploadsActive(false)
}
if len(pkt.Data) > 0 && pkt.IsVideo {
if err := session.WritePacket(pkt); err != nil {
log.Error("cloud.HandleLiveStreamHLS(): " + err.Error())
}
}
continue
}
if !viewerActive {
// No viewer: stop and discard the session so we stop shipping segments.
if session != nil {
_ = session.Close()
log.WithFields(log.Fields{
"component": "cloud/livehls",
"event": "session_stopped",
"reason": "no_active_viewers",
"session_id": session.SessionID(),
}).Info("Live HLS session stopped")
session = nil
}
continue
}
if len(pkt.Data) == 0 || !pkt.IsVideo {
continue
}
// Start a session lazily, but only on a keyframe so the first segment opens
// on a random-access point.
if session == nil {
if !pkt.IsKeyFrame {
continue
}
session = livehls.NewSession(publisher, livehls.SessionOptions{
Codec: pkt.Codec,
SPSNALUs: source.sps,
PPSNALUs: source.pps,
VPSNALUs: source.vps,
Width: source.width,
Height: source.height,
PartTargetMs: partTargetMs,
})
session.SetOnReady(func(sessionID string) {
log.WithFields(log.Fields{
"component": "cloud/livehls",
"event": "session_ready",
"session_id": sessionID,
}).Info("Live HLS session ready")
publishHLSReady(configuration, mqttClient, hubKey, deviceId, sessionID)
lastReadyAnnounce = time.Now().Unix()
})
session.SetOnFailure(func(sessionID, reason string) {
publishHLSFailure(configuration, mqttClient, hubKey, deviceId, sessionID, reason)
})
log.WithFields(log.Fields{
"component": "cloud/livehls",
"event": "session_started",
"session_id": session.SessionID(),
}).Info("Live HLS session started")
}
if err := session.WritePacket(pkt); err != nil {
log.Error("cloud.HandleLiveStreamHLS(): " + err.Error())
}
}
if session != nil {
_ = session.Close()
}
log.Debug("cloud.HandleLiveStreamHLS(): finished")
}
// publishHLSReady announces, over MQTT, that a live HLS session is available so
// viewers can load the rolling playlist hub-api serves for {device}/{session}.
func publishHLSReady(configuration *models.Configuration, mqttClient mqtt.Client, hubKey, deviceId, sessionID string) {
valueMap := map[string]interface{}{
"session": sessionID,
"device": deviceId,
}
message := models.Message{
Payload: models.Payload{
Action: "receive-hls-ready",
DeviceId: deviceId,
Value: valueMap,
},
}
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey, 0, false, payload)
log.WithFields(log.Fields{
"component": "cloud/livehls",
"event": "session_announced",
"session_id": sessionID,
}).Info("Live HLS session announced")
} else {
log.WithError(err).WithFields(log.Fields{
"component": "cloud/livehls",
"event": "session_announcement_failed",
"session_id": sessionID,
}).Error("Failed to package live HLS session announcement")
}
}
func publishHLSFailure(configuration *models.Configuration, mqttClient mqtt.Client, hubKey, deviceId, sessionID, reason string) {
valueMap := map[string]interface{}{
"session": sessionID,
"device": deviceId,
"reason": reason,
}
message := models.Message{
Payload: models.Payload{
Action: "receive-hls-error",
DeviceId: deviceId,
Value: valueMap,
},
}
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey, 0, false, payload)
log.WithFields(log.Fields{
"component": "cloud/livehls",
"event": "session_failure_announced",
"reason": reason,
"session_id": sessionID,
}).Warn("Live HLS session failure announced")
} else {
log.WithError(err).WithFields(log.Fields{
"component": "cloud/livehls",
"event": "session_failure_announcement_failed",
"session_id": sessionID,
}).Error("Failed to package live HLS session failure")
}
}
// hlsStreamSource bundles everything the live HLS producer needs to mux one of
// the camera's streams: the packet cursor it reads from plus the encoded
// parameter sets and dimensions used to build that stream's init segment.
type hlsStreamSource struct {
cursor *packets.QueueCursor
sps [][]byte
pps [][]byte
vps [][]byte
width uint16
height uint16
label string
}
// buildHLSSource resolves the packet cursor and encoded parameter sets/dimensions
// for the selected stream. useSub picks the sub (low-resolution) stream when one
// is available; otherwise the main (high-resolution) stream is used. A fresh
// Latest() cursor is created so muxing resumes from the live edge of the chosen
// stream after a switch.
func buildHLSSource(config models.Config, mainQueue, subQueue *packets.Queue, useSub bool) hlsStreamSource {
cam := config.Capture.IPCamera
if useSub && subQueue != nil {
return hlsStreamSource{
cursor: subQueue.Latest(),
sps: cam.SubSPSNALUs,
pps: cam.SubPPSNALUs,
vps: cam.SubVPSNALUs,
width: uint16(cam.SubWidth),
height: uint16(cam.SubHeight),
label: "sub",
}
}
return hlsStreamSource{
cursor: mainQueue.Latest(),
sps: cam.SPSNALUs,
pps: cam.PPSNALUs,
vps: cam.VPSNALUs,
width: uint16(cam.Width),
height: uint16(cam.Height),
label: "main",
}
}

View File

@@ -0,0 +1,244 @@
// Package livehls implements the agent-side producer for live HLS streaming.
//
// It complements the recording pipeline: where recordings are muxed into one
// fragmented MP4 and uploaded resumably (TUS) when complete, live HLS ships a
// continuous series of small, independently-decodable CMAF segments to hub-api
// the instant each is produced, so a browser can play a near-live HLS stream
// without WebRTC/TURN (outbound HTTPS only).
//
// The wire contract (agent -> hub-api) intentionally mirrors the existing
// header-based storage convention (X-Kerberos-Storage-Device / -FileName, plus
// the Hub public/private key auth headers). hub-api authenticates the agent and
// stores each segment in an ephemeral, short-TTL live window keyed by
// {device}/{session}, which it serves straight back to the browser. The live
// window is deliberately kept out of the vault and the recordings collection;
// durable archival/DVR is a separate, later concern.
//
// Unlike recordings, live segments are NOT uploaded resumably: a 1-2s segment
// that fails to upload is stale by the time a retry would land, so the publisher
// is fire-and-forget and drops on failure (logged) rather than blocking the live
// pipeline behind a retry/handshake.
package livehls
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/kerberos-io/agent/machinery/src/video"
log "github.com/sirupsen/logrus"
)
const (
// liveIngestPath is the hub-api endpoint that accepts a single live segment
// (or the init segment) and stores it in the ephemeral live window. hub-api
// distinguishes init vs media segment and the object name via the
// X-Kerberos-Live-* headers below, keeping a single route (mirrors the
// existing /storage/upload convention).
liveIngestPath = "/storage/live"
// Object names within a session. The init segment (ftyp+moov) is uploaded
// once per session; media segments are seg-<sequence>.m4s.
initObjectName = "init.mp4"
contentTypeInit = "video/mp4"
contentTypeSegment = "video/iso.segment"
// Header names for the live ingest contract.
headerHubPublicKey = "X-Kerberos-Hub-PublicKey"
headerHubPrivateKey = "X-Kerberos-Hub-PrivateKey"
headerHubRegion = "X-Kerberos-Hub-Region"
headerStorageDevice = "X-Kerberos-Storage-Device"
headerLiveSession = "X-Kerberos-Live-Session"
headerLiveName = "X-Kerberos-Live-Name"
headerLiveSequence = "X-Kerberos-Live-Sequence"
headerLiveDuration = "X-Kerberos-Live-Duration"
// Low-latency (LL-HLS) part headers. A part belongs to media segment
// X-Kerberos-Live-Sequence and is the X-Kerberos-Live-Part-th chunk within it;
// X-Kerberos-Live-Part-Independent flags a part that starts on a keyframe.
headerLivePart = "X-Kerberos-Live-Part"
headerLivePartIndependent = "X-Kerberos-Live-Part-Independent"
// defaultPublishTimeout bounds a single segment upload. A live segment that
// cannot be delivered within roughly its own duration is stale, so the upload
// is abandoned (dropped) rather than allowed to back up the pipeline.
defaultPublishTimeout = 4 * time.Second
maxErrorResponseBytes = 4 << 10
)
// PublisherConfig carries the hub endpoint and credentials needed to ship live
// segments. It is populated from the agent's models.Config (HubURI/HubKey/...).
type PublisherConfig struct {
HubURI string // base hub-api URL, e.g. https://api.hub.example.com
HubKey string // Hub public key (X-Kerberos-Hub-PublicKey)
HubPrivateKey string // Hub private key (X-Kerberos-Hub-PrivateKey)
Region string // storage region (X-Kerberos-Hub-Region), may be empty
DeviceKey string // device/camera key (X-Kerberos-Storage-Device)
// Timeout optionally overrides defaultPublishTimeout (used by tests).
Timeout time.Duration
// HTTPClient optionally injects a client (used by tests). When nil a
// redirect-credential-stripping client is created.
HTTPClient *http.Client
}
// Publisher ships init and media segments to hub-api over plain HTTP POST.
//
// It is safe for sequential use from a single live-stream goroutine. Methods are
// fire-and-forget: they return an error for the caller to log, but the caller is
// expected to continue (drop-on-fail) rather than retry.
type Publisher struct {
cfg PublisherConfig
client *http.Client
}
// NewPublisher builds a Publisher. The HTTP client strips the Hub credential
// headers on a cross-host redirect (net/http does this for standard auth headers
// but not custom-named ones), matching the recording upload client.
func NewPublisher(cfg PublisherConfig) *Publisher {
client := cfg.HTTPClient
if client == nil {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = defaultPublishTimeout
}
client = &http.Client{
Timeout: timeout,
CheckRedirect: stripHubCredentialsOnCrossHostRedirect,
}
}
return &Publisher{cfg: cfg, client: client}
}
// PublishInit uploads the session's init segment (ftyp+moov). It must be called
// (and succeed) before the player can use any media segment, so the caller
// should treat a failure here as "session not yet established" and retry on the
// next init opportunity rather than shipping media segments blindly.
func (p *Publisher) PublishInit(ctx context.Context, sessionID string, data []byte) error {
return p.post(ctx, postParams{
sessionID: sessionID,
name: initObjectName,
contentType: contentTypeInit,
body: data,
})
}
// PublishSegment uploads one media segment (styp+moof+mdat). The segment's
// sequence number and duration travel in headers so hub-api can update the
// rolling playlist window without parsing the box structure.
func (p *Publisher) PublishSegment(ctx context.Context, sessionID string, seg video.LiveSegment) error {
return p.post(ctx, postParams{
sessionID: sessionID,
name: fmt.Sprintf("seg-%d.m4s", seg.SequenceNumber),
sequence: seg.SequenceNumber,
durationMs: seg.DurationMs,
hasSegment: true,
contentType: contentTypeSegment,
body: seg.Data,
})
}
// PublishPart uploads one CMAF partial segment (LL-HLS). The part is named
// seg-<segment>.<part>.m4s and carries its segment sequence, part index,
// independence flag and duration in headers so hub-api can advertise it via
// #EXT-X-PART and reconstruct the full segment by concatenating its parts.
func (p *Publisher) PublishPart(ctx context.Context, sessionID string, part video.LivePart) error {
return p.post(ctx, postParams{
sessionID: sessionID,
name: fmt.Sprintf("seg-%d.%d.m4s", part.SegmentSeq, part.PartIndex),
sequence: part.SegmentSeq,
durationMs: part.DurationMs,
partIndex: part.PartIndex,
independent: part.Independent,
hasPart: true,
contentType: contentTypeSegment,
body: part.Data,
})
}
type postParams struct {
sessionID string
name string
sequence uint32
durationMs uint64
hasSegment bool
partIndex uint32
independent bool
hasPart bool
contentType string
body []byte
}
// post performs a single fire-and-forget upload to the live ingest endpoint.
func (p *Publisher) post(ctx context.Context, params postParams) error {
if p.cfg.HubURI == "" {
return fmt.Errorf("livehls: HubURI not configured")
}
if params.sessionID == "" {
return fmt.Errorf("livehls: empty session id")
}
url := strings.TrimRight(p.cfg.HubURI, "/") + liveIngestPath
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(params.body))
if err != nil {
return fmt.Errorf("livehls: build request: %w", err)
}
req.Header.Set("Content-Type", params.contentType)
req.Header.Set(headerStorageDevice, p.cfg.DeviceKey)
req.Header.Set(headerLiveSession, params.sessionID)
req.Header.Set(headerLiveName, params.name)
if params.hasSegment || params.hasPart {
req.Header.Set(headerLiveSequence, strconv.FormatUint(uint64(params.sequence), 10))
req.Header.Set(headerLiveDuration, strconv.FormatUint(params.durationMs, 10))
}
if params.hasPart {
req.Header.Set(headerLivePart, strconv.FormatUint(uint64(params.partIndex), 10))
independent := "0"
if params.independent {
independent = "1"
}
req.Header.Set(headerLivePartIndependent, independent)
}
req.Header.Set(headerHubPublicKey, p.cfg.HubKey)
req.Header.Set(headerHubPrivateKey, p.cfg.HubPrivateKey)
req.Header.Set(headerHubRegion, p.cfg.Region)
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("livehls: upload %s: %w", params.name, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorResponseBytes))
return fmt.Errorf("livehls: upload %s rejected with status %d (%d response bytes)", params.name, resp.StatusCode, len(body))
}
log.WithFields(log.Fields{
"component": "cloud/livehls",
"event": "object_uploaded",
"object": params.name,
"session_id": params.sessionID,
}).Trace("Live HLS object uploaded")
return nil
}
// stripHubCredentialsOnCrossHostRedirect removes the Hub credential headers when
// a redirect crosses to a different host. net/http strips standard sensitive
// headers on a cross-host redirect but not custom-named ones, so without this the
// Hub keys could leak to a redirect target.
func stripHubCredentialsOnCrossHostRedirect(req *http.Request, via []*http.Request) error {
if len(via) == 0 {
return nil
}
if req.URL.Host != via[0].URL.Host {
req.Header.Del(headerHubPrivateKey)
req.Header.Del(headerHubPublicKey)
}
return nil
}

View File

@@ -0,0 +1,332 @@
package livehls
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/kerberos-io/agent/machinery/src/packets"
"github.com/kerberos-io/agent/machinery/src/video"
)
// captured records one received upload for assertions.
type captured struct {
path string
method string
contentType string
device string
session string
name string
sequence string
duration string
hubPublic string
hubPrivate string
region string
body []byte
}
// newCapturingServer returns an httptest server that records every upload and
// replies with the given status code.
func newCapturingServer(t *testing.T, status int) (*httptest.Server, *[]captured, *sync.Mutex) {
t.Helper()
var mu sync.Mutex
var got []captured
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
mu.Lock()
got = append(got, captured{
path: r.URL.Path,
method: r.Method,
contentType: r.Header.Get("Content-Type"),
device: r.Header.Get(headerStorageDevice),
session: r.Header.Get(headerLiveSession),
name: r.Header.Get(headerLiveName),
sequence: r.Header.Get(headerLiveSequence),
duration: r.Header.Get(headerLiveDuration),
hubPublic: r.Header.Get(headerHubPublicKey),
hubPrivate: r.Header.Get(headerHubPrivateKey),
region: r.Header.Get(headerHubRegion),
body: body,
})
mu.Unlock()
w.WriteHeader(status)
}))
t.Cleanup(srv.Close)
return srv, &got, &mu
}
func testPublisher(hubURI string) *Publisher {
return NewPublisher(PublisherConfig{
HubURI: hubURI,
HubKey: "pub-key",
HubPrivateKey: "priv-key",
Region: "eu-west",
DeviceKey: "cam-1",
Timeout: 2 * time.Second,
})
}
func TestPublisherPublishInitSendsContractHeaders(t *testing.T) {
srv, got, mu := newCapturingServer(t, http.StatusOK)
p := testPublisher(srv.URL)
if err := p.PublishInit(context.Background(), "sess-1", []byte("INITBYTES")); err != nil {
t.Fatalf("PublishInit: %v", err)
}
mu.Lock()
defer mu.Unlock()
if len(*got) != 1 {
t.Fatalf("server received %d requests, want 1", len(*got))
}
c := (*got)[0]
if c.method != http.MethodPost {
t.Errorf("method=%s, want POST", c.method)
}
if c.path != liveIngestPath {
t.Errorf("path=%s, want %s", c.path, liveIngestPath)
}
if c.contentType != contentTypeInit {
t.Errorf("content-type=%s, want %s", c.contentType, contentTypeInit)
}
if c.device != "cam-1" {
t.Errorf("device=%s, want cam-1", c.device)
}
if c.session != "sess-1" {
t.Errorf("session=%s, want sess-1", c.session)
}
if c.name != initObjectName {
t.Errorf("name=%s, want %s", c.name, initObjectName)
}
if c.hubPublic != "pub-key" || c.hubPrivate != "priv-key" || c.region != "eu-west" {
t.Errorf("auth headers wrong: pub=%q priv=%q region=%q", c.hubPublic, c.hubPrivate, c.region)
}
if string(c.body) != "INITBYTES" {
t.Errorf("body=%q, want INITBYTES", string(c.body))
}
// init must NOT carry segment-only headers.
if c.sequence != "" || c.duration != "" {
t.Errorf("init should not send sequence/duration, got seq=%q dur=%q", c.sequence, c.duration)
}
}
func TestPublisherPublishSegmentSendsSequenceAndDuration(t *testing.T) {
srv, got, mu := newCapturingServer(t, http.StatusOK)
p := testPublisher(srv.URL)
seg := video.LiveSegment{SequenceNumber: 7, DurationMs: 1960, Data: []byte("SEGMENT")}
if err := p.PublishSegment(context.Background(), "sess-9", seg); err != nil {
t.Fatalf("PublishSegment: %v", err)
}
mu.Lock()
defer mu.Unlock()
c := (*got)[0]
if c.contentType != contentTypeSegment {
t.Errorf("content-type=%s, want %s", c.contentType, contentTypeSegment)
}
if c.name != "seg-7.m4s" {
t.Errorf("name=%s, want seg-7.m4s", c.name)
}
if c.sequence != "7" {
t.Errorf("sequence=%s, want 7", c.sequence)
}
if c.duration != "1960" {
t.Errorf("duration=%s, want 1960", c.duration)
}
if string(c.body) != "SEGMENT" {
t.Errorf("body=%q, want SEGMENT", string(c.body))
}
}
func TestPublisherReturnsErrorOnNon2xx(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":true,"data":"No user found with this public and private key."}`))
}))
t.Cleanup(srv.Close)
p := testPublisher(srv.URL)
err := p.PublishSegment(context.Background(), "s", video.LiveSegment{SequenceNumber: 1, Data: []byte("x")})
if err == nil {
t.Fatal("expected an error on 400 response")
}
if !strings.Contains(err.Error(), "status 400") || !strings.Contains(err.Error(), "71 response bytes") {
t.Fatalf("error = %q, want status code and response size", err)
}
if strings.Contains(err.Error(), "No user found with this public and private key") {
t.Fatalf("error exposed Hub response body: %q", err)
}
}
func TestPublisherErrorsWithoutHubURI(t *testing.T) {
p := NewPublisher(PublisherConfig{DeviceKey: "cam"})
if err := p.PublishInit(context.Background(), "s", []byte("x")); err == nil {
t.Fatal("expected error when HubURI is empty")
}
}
// makeAnnexBVideoPacket builds a synthetic capture packet carrying one Annex B
// H.264 access unit at the given decode time (ms).
func makeAnnexBVideoPacket(isKey bool, timeMs int64) packets.Packet {
nalType := byte(0x01)
if isKey {
nalType = 0x65
}
data := []byte{0x00, 0x00, 0x00, 0x01, nalType}
for i := 0; i < 80; i++ {
data = append(data, byte(i))
}
return packets.Packet{
IsVideo: true,
IsKeyFrame: isKey,
Codec: "H264",
Data: data,
TimeLegacy: time.Duration(timeMs) * time.Millisecond,
}
}
func TestSessionShipsInitThenSegmentsAndFiresReady(t *testing.T) {
srv, got, mu := newCapturingServer(t, http.StatusOK)
p := testPublisher(srv.URL)
sess := NewSession(p, SessionOptions{
Codec: "H264",
SPSNALUs: [][]byte{liveTestSPSForSession()},
PPSNALUs: [][]byte{{0x68, 0xce, 0x38, 0x80}},
Width: 640,
Height: 480,
TargetSegmentMs: 2000,
})
var readyCalls int
var readySession string
sess.SetOnReady(func(id string) {
readyCalls++
readySession = id
})
// 4 GOPs of 25 frames @ 40ms = 1s GOPs => with 2s target, 2 segments emitted
// during streaming and a final one on Close.
const gopFrames, gops = 25, 4
for i := 0; i < gopFrames*gops; i++ {
isKey := i%gopFrames == 0
pkt := makeAnnexBVideoPacket(isKey, int64(i*40))
if err := sess.WritePacket(pkt); err != nil {
t.Fatalf("WritePacket(%d): %v", i, err)
}
}
// A non-video packet must be ignored.
if err := sess.WritePacket(packets.Packet{IsAudio: true, Data: []byte{1, 2, 3}}); err != nil {
t.Fatalf("WritePacket(audio): %v", err)
}
if err := sess.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
mu.Lock()
defer mu.Unlock()
var initCount, segCount int
for _, c := range *got {
if c.name == initObjectName {
initCount++
if string(c.body[4:8]) != "ftyp" {
t.Errorf("init body is not an ftyp box: % x", c.body[:12])
}
} else {
segCount++
if c.session != sess.SessionID() {
t.Errorf("segment session=%s, want %s", c.session, sess.SessionID())
}
}
}
if initCount != 1 {
t.Errorf("init uploaded %d times, want exactly 1", initCount)
}
if segCount < 2 {
t.Errorf("got %d segment uploads, want >= 2", segCount)
}
if readyCalls != 1 {
t.Errorf("OnReady fired %d times, want exactly 1", readyCalls)
}
if readySession != sess.SessionID() {
t.Errorf("OnReady session=%s, want %s", readySession, sess.SessionID())
}
}
func TestSessionRetriesInitWhenFirstAttemptFails(t *testing.T) {
// Server fails the first N requests, then succeeds. This proves init is
// re-attempted (not dropped) so the session can still establish.
var mu sync.Mutex
var inits, segs int
failFirst := 1
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
name := r.Header.Get(headerLiveName)
if name == initObjectName {
inits++
if inits <= failFirst {
w.WriteHeader(http.StatusBadGateway)
return
}
} else {
segs++
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
sess := NewSession(testPublisher(srv.URL), SessionOptions{
Codec: "H264",
SPSNALUs: [][]byte{liveTestSPSForSession()},
PPSNALUs: [][]byte{{0x68, 0xce, 0x38, 0x80}},
Width: 640,
Height: 480,
})
var ready int
var failures int
var failureReason string
sess.SetOnReady(func(string) { ready++ })
sess.SetOnFailure(func(_ string, reason string) {
failures++
failureReason = reason
})
for i := 0; i < 60; i++ {
isKey := i%25 == 0
if err := sess.WritePacket(makeAnnexBVideoPacket(isKey, int64(i*40))); err != nil {
t.Fatalf("WritePacket(%d): %v", i, err)
}
}
if err := sess.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
mu.Lock()
defer mu.Unlock()
if inits < 2 {
t.Errorf("init attempted %d times, want >= 2 (first failed then retried)", inits)
}
if segs < 1 {
t.Errorf("no segments delivered after init recovered (segs=%d)", segs)
}
if ready != 1 {
t.Errorf("OnReady fired %d times, want 1", ready)
}
if failures != 1 || failureReason != "init-upload-failed" {
t.Errorf("OnFailure = %d/%q, want 1/init-upload-failed", failures, failureReason)
}
}
// liveTestSPSForSession is the known-good baseline SPS reused across tests.
func liveTestSPSForSession() []byte {
return []byte{0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x00, 0xa0, 0x47, 0xfe, 0xc8}
}

View File

@@ -0,0 +1,533 @@
package livehls
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"sync"
"time"
"github.com/kerberos-io/agent/machinery/src/packets"
"github.com/kerberos-io/agent/machinery/src/video"
log "github.com/sirupsen/logrus"
)
// DefaultTargetSegmentMs is the nominal live segment length. ~2s keeps standard
// HLS latency reasonable (a player typically buffers ~3 segments) while staying
// large enough that per-segment HTTP overhead is negligible.
const DefaultTargetSegmentMs = 2000
// DefaultPartTargetMs is the nominal LL-HLS part length used when low latency is
// enabled. ~300ms parts yield ~6-7 parts per 2s segment; with the playlist's
// PART-HOLD-BACK at ~3x the part target this lands glass-to-glass latency around
// 1-2s (versus ~4-6s for whole-segment HLS).
const DefaultPartTargetMs = 300
// Session ties a video.LiveSegmenter to a Publisher: it converts capture packets
// into CMAF segments and ships each one to hub-api. Exactly one init segment is
// delivered per session (re-attempted until it lands), after which media
// segments are published and the OnReady signal fires once so the control plane
// (MQTT) can tell viewers the live playlist exists.
//
// A Session is driven from a single goroutine (the live-stream loop); its methods
// are not safe for concurrent use except SessionID, which is immutable.
type Session struct {
id string
publisher *Publisher
segmenter *video.LiveSegmenter
// newContext produces the per-upload context (timeout). Overridable in tests.
newContext func() (context.Context, context.CancelFunc)
mu sync.Mutex
initBytes []byte
initPublished bool
// lastInitAt is when the init segment was last (re)uploaded. The init is
// re-sent periodically so its short TTL in the hub live window never lapses
// mid-session; see refreshInitIfStale.
lastInitAt time.Time
readyFired bool
onReady func(sessionID string)
failureFired bool
onFailure func(sessionID, reason string)
// uploadsActive gates whether the init and completed segments are shipped to
// hub-api. It is true for the default on-demand path. The prewarm path starts
// it false so the session keeps muxing into bufferedSegments without producing
// any live traffic until a viewer actually arrives; see SetUploadsActive.
uploadsActive bool
// bufferedSegments is the in-memory ring buffer (the most recent
// prewarmMaxBufferedSegments segments) kept while uploadsActive is false, so a
// viewer that arrives can be served an already-encoded segment immediately
// instead of waiting a full GOP for the next one to be cut.
bufferedSegments []video.LiveSegment
// bufferedParts is the LL-HLS counterpart of bufferedSegments: while idle it
// retains the parts of the most recent (prewarmMaxBufferedSegments+1) segments,
// pruned a WHOLE segment at a time so a flushed segment is never partial.
bufferedParts []video.LivePart
}
// SessionOptions configures a live HLS session.
type SessionOptions struct {
Codec string // "H264" or "H265"
SPSNALUs [][]byte // parameter sets (raw or Annex B)
PPSNALUs [][]byte //
VPSNALUs [][]byte // H.265 only
Width uint16 // encoded width (for the avcC fallback path)
Height uint16 // encoded height
TargetSegmentMs uint64 // 0 => DefaultTargetSegmentMs
// PartTargetMs, when > 0, enables LL-HLS: each segment is additionally sliced
// into ~PartTargetMs CMAF parts that are published (and advertised via
// #EXT-X-PART) the instant they close, for ~1-2s glass-to-glass latency. 0
// keeps the classic whole-segment path.
PartTargetMs uint64
// StartBuffering starts the session in prewarm (buffer-only) mode: it muxes
// segments into an in-memory ring buffer but uploads nothing until
// SetUploadsActive(true) is called. Default false => uploads are live
// immediately (the on-demand path's behaviour).
StartBuffering bool
}
// NewSession builds a session with a fresh random id and wires the segmenter's
// init/segment callbacks to the publisher.
func NewSession(publisher *Publisher, opts SessionOptions) *Session {
target := opts.TargetSegmentMs
if target == 0 {
target = DefaultTargetSegmentMs
}
seg := video.NewLiveSegmenter(opts.Codec, opts.SPSNALUs, opts.PPSNALUs, opts.VPSNALUs, target)
seg.SetDimensions(opts.Width, opts.Height)
if opts.PartTargetMs > 0 {
seg.EnableLowLatency(opts.PartTargetMs)
}
s := &Session{
id: newSessionID(),
publisher: publisher,
segmenter: seg,
// Uploads are live by default; the prewarm path opts into buffer-only mode.
uploadsActive: !opts.StartBuffering,
newContext: func() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), defaultPublishTimeout)
},
}
// The segmenter emits the init segment exactly once; capture it and try to
// ship it. Failures here are non-fatal - publishInitIfNeeded re-attempts
// before the next media segment so a transient hub hiccup at startup does not
// permanently break the session.
seg.OnInit = func(initBytes []byte) error {
s.mu.Lock()
s.initBytes = append([]byte(nil), initBytes...)
active := s.uploadsActive
s.mu.Unlock()
// While prewarming we cache the init in memory but ship nothing; it is
// uploaded on the first SetUploadsActive(true) flush.
if active {
s.publishInitIfNeeded()
}
return nil
}
// Each completed media segment is shipped. We only publish a segment once the
// init segment has landed (a media segment is useless without it), and we fire
// OnReady after the first successfully shipped segment.
seg.OnSegment = func(segment video.LiveSegment) error {
s.mu.Lock()
active := s.uploadsActive
s.mu.Unlock()
if !active {
// Prewarm: retain the most recent segments in memory but upload nothing
// until a viewer arrives (SetUploadsActive flushes them).
s.bufferSegment(segment)
return nil
}
if !s.publishInitIfNeeded() {
log.WithFields(log.Fields{
"component": "cloud/livehls",
"event": "segment_dropped",
"reason": "init_unavailable",
"segment_sequence": segment.SequenceNumber,
"session_id": s.id,
}).Warn("Dropping live HLS segment")
return nil
}
ctx, cancel := s.newContext()
defer cancel()
if err := s.publisher.PublishSegment(ctx, s.id, segment); err != nil {
log.WithError(err).WithFields(log.Fields{
"component": "cloud/livehls",
"event": "segment_upload_failed",
"segment_sequence": segment.SequenceNumber,
"session_id": s.id,
}).Warn("Failed to upload live HLS segment")
s.fireFailureOnce("segment-upload-failed")
return nil
}
s.fireReadyOnce()
// Keep the (write-once) init segment from ageing out of the live window
// while the session is still producing media.
s.refreshInitIfStale()
return nil
}
// In LL-HLS mode the segmenter emits parts (not whole segments); ship each one
// the instant it closes. Mirrors OnSegment: buffer while prewarming, otherwise
// publish after the init has landed and fire OnReady on the first part.
if opts.PartTargetMs > 0 {
seg.OnPart = func(part video.LivePart) error {
s.mu.Lock()
active := s.uploadsActive
s.mu.Unlock()
if !active {
s.bufferPart(part)
return nil
}
if !s.publishInitIfNeeded() {
log.WithFields(log.Fields{
"component": "cloud/livehls",
"event": "part_dropped",
"part_index": part.PartIndex,
"reason": "init_unavailable",
"segment_sequence": part.SegmentSeq,
"session_id": s.id,
}).Warn("Dropping live HLS part")
return nil
}
ctx, cancel := s.newContext()
defer cancel()
if err := s.publisher.PublishPart(ctx, s.id, part); err != nil {
log.WithError(err).WithFields(log.Fields{
"component": "cloud/livehls",
"event": "part_upload_failed",
"part_index": part.PartIndex,
"segment_sequence": part.SegmentSeq,
"session_id": s.id,
}).Warn("Failed to upload live HLS part")
s.fireFailureOnce("part-upload-failed")
return nil
}
s.fireReadyOnce()
s.refreshInitIfStale()
return nil
}
}
return s
}
// SessionID returns the immutable session identifier used in object keys and the
// MQTT ready signal.
func (s *Session) SessionID() string { return s.id }
// IsReady reports whether the session has delivered its init segment and at
// least one media segment, i.e. the playlist hub-api serves is now playable. It
// lets the live-stream loop re-announce "receive-hls-ready" to viewers that join
// or hard-refresh after the initial one-shot signal (which they would otherwise
// never receive, leaving the stream blank until the session is recreated).
func (s *Session) IsReady() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.readyFired
}
// SetOnReady registers a callback fired exactly once, after the first media
// segment has been successfully delivered. Used to publish the MQTT
// "receive-hls-ready" signal so viewers can load the playlist.
func (s *Session) SetOnReady(fn func(sessionID string)) {
s.mu.Lock()
s.onReady = fn
s.mu.Unlock()
}
// SetOnFailure registers a one-shot callback for startup upload failures. The
// reason is a fixed code rather than an HTTP response body, so credentials or
// server details cannot leak through MQTT diagnostics.
func (s *Session) SetOnFailure(fn func(sessionID, reason string)) {
s.mu.Lock()
s.onFailure = fn
s.mu.Unlock()
}
// prewarmMaxBufferedSegments is how many of the most recent completed segments
// the prewarm path keeps in memory while idle and flushes to a viewer on arrival.
// One segment keeps startup instant (the viewer immediately gets a playable
// segment) while starting as close to the live edge as possible, so the HLS view
// tracks the WebRTC/live edge instead of opening several seconds behind; hls.js
// then converges to the edge via maxLiveSyncPlaybackRate. Raising it trades
// latency-from-live for a little more startup cushion.
const prewarmMaxBufferedSegments = 1
// SetUploadsActive toggles whether the session ships its init and segments to
// hub-api, and reports whether this call flipped it from inactive to active.
//
// While uploads are inactive the session keeps muxing capture packets into an
// in-memory ring buffer (the cached init plus the most recent
// prewarmMaxBufferedSegments segments) but uploads nothing, so an idle camera
// produces no live traffic. Switching from inactive to active immediately
// flushes the cached init and buffered segments so a viewer can start almost
// instantly instead of waiting a full GOP for the next segment to be cut.
// Switching from active to inactive resets the init-published flag so the next
// activation re-uploads the init (it may have aged out of the hub's short-TTL
// live window while idle). All other transitions are no-ops. Driven from the
// live-stream goroutine; not safe for concurrent use.
func (s *Session) SetUploadsActive(active bool) bool {
s.mu.Lock()
if s.uploadsActive == active {
s.mu.Unlock()
return false
}
s.uploadsActive = active
if !active {
// Going idle: force the next activation to re-deliver the init segment,
// which may have expired from the hub live window while nobody was watching.
s.initPublished = false
s.mu.Unlock()
return false
}
// Inactive -> active: take the cached buffered segments/parts and flush them
// outside the lock (the publish calls take their own time and re-acquire the
// mutex).
buffered := s.bufferedSegments
bufferedParts := s.bufferedParts
s.bufferedSegments = nil
s.bufferedParts = nil
s.mu.Unlock()
// Deliver the init first; media segments are useless without it.
for i := range buffered {
if !s.publishInitIfNeeded() {
break
}
ctx, cancel := s.newContext()
if err := s.publisher.PublishSegment(ctx, s.id, buffered[i]); err != nil {
log.WithError(err).WithFields(log.Fields{
"component": "cloud/livehls",
"event": "prewarm_segment_upload_failed",
"segment_sequence": buffered[i].SequenceNumber,
"session_id": s.id,
}).Warn("Failed to upload buffered live HLS segment")
s.fireFailureOnce("segment-upload-failed")
cancel()
continue
}
cancel()
s.fireReadyOnce()
s.refreshInitIfStale()
}
// LL-HLS: flush the buffered parts in order (oldest first) so the viewer gets a
// playable, near-live window immediately.
for i := range bufferedParts {
if !s.publishInitIfNeeded() {
break
}
ctx, cancel := s.newContext()
if err := s.publisher.PublishPart(ctx, s.id, bufferedParts[i]); err != nil {
log.WithError(err).WithFields(log.Fields{
"component": "cloud/livehls",
"event": "prewarm_part_upload_failed",
"part_index": bufferedParts[i].PartIndex,
"segment_sequence": bufferedParts[i].SegmentSeq,
"session_id": s.id,
}).Warn("Failed to upload buffered live HLS part")
s.fireFailureOnce("part-upload-failed")
cancel()
continue
}
cancel()
s.fireReadyOnce()
s.refreshInitIfStale()
}
return true
}
// UploadsActive reports whether the session is currently shipping segments (as
// opposed to buffering them while prewarming). Always true for the on-demand
// path.
func (s *Session) UploadsActive() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.uploadsActive
}
// bufferSegment appends a completed segment to the in-memory prewarm ring buffer,
// discarding the oldest so at most prewarmMaxBufferedSegments are retained.
func (s *Session) bufferSegment(seg video.LiveSegment) {
s.mu.Lock()
s.bufferedSegments = append(s.bufferedSegments, seg)
if overflow := len(s.bufferedSegments) - prewarmMaxBufferedSegments; overflow > 0 {
// Drop the oldest segment(s) and shrink the backing array so retained bytes
// stay bounded.
s.bufferedSegments = append([]video.LiveSegment(nil), s.bufferedSegments[overflow:]...)
}
s.mu.Unlock()
}
// bufferPart appends a part to the LL-HLS prewarm ring buffer, pruning whole
// older segments (never individual parts) so the retained window always consists
// of complete segments plus the in-progress one. Pruning on a part-0 boundary
// keeps at most prewarmMaxBufferedSegments fully-buffered segments behind the
// current one, which guarantees a flushed segment can be reconstructed in full.
func (s *Session) bufferPart(part video.LivePart) {
s.mu.Lock()
s.bufferedParts = append(s.bufferedParts, part)
if part.PartIndex == 0 && part.SegmentSeq > uint32(prewarmMaxBufferedSegments) {
minSeg := part.SegmentSeq - uint32(prewarmMaxBufferedSegments)
kept := make([]video.LivePart, 0, len(s.bufferedParts))
for _, p := range s.bufferedParts {
if p.SegmentSeq >= minSeg {
kept = append(kept, p)
}
}
s.bufferedParts = kept
}
s.mu.Unlock()
}
// WritePacket feeds one capture packet into the segmenter. Non-video packets are
// ignored (the spike is video-only). The decode timestamp is derived exactly as
// the recording muxer does: DTS = PTS - compositionOffset, with the composition
// offset forwarded for correct B-frame presentation order.
func (s *Session) WritePacket(pkt packets.Packet) error {
if !pkt.IsVideo {
return nil
}
pts := uint64(pkt.TimeLegacy.Milliseconds())
compositionOffset := pkt.CompositionTime
dts := pts
if compositionOffset > 0 && uint64(compositionOffset) <= pts {
dts = pts - uint64(compositionOffset)
} else if compositionOffset < 0 || uint64(compositionOffset) > pts {
// Guard against invalid offsets to avoid producing a CTS (DTS+CTO) jump.
compositionOffset = 0
}
return s.segmenter.WriteSample(pkt.IsKeyFrame, pkt.Data, dts, int32(compositionOffset))
}
// Close flushes any buffered sample and ships the final segment.
func (s *Session) Close() error {
return s.segmenter.Close()
}
// publishInitIfNeeded ensures the init segment has been delivered, attempting an
// upload if it has not. Returns true once init is known to be published.
func (s *Session) publishInitIfNeeded() bool {
s.mu.Lock()
if s.initPublished {
s.mu.Unlock()
return true
}
initBytes := s.initBytes
s.mu.Unlock()
if len(initBytes) == 0 {
return false
}
ctx, cancel := s.newContext()
defer cancel()
if err := s.publisher.PublishInit(ctx, s.id, initBytes); err != nil {
log.WithError(err).WithFields(log.Fields{
"component": "cloud/livehls",
"event": "init_upload_failed",
"session_id": s.id,
}).Warn("Failed to upload live HLS init segment; retrying")
s.fireFailureOnce("init-upload-failed")
return false
}
s.mu.Lock()
s.initPublished = true
s.lastInitAt = time.Now()
s.mu.Unlock()
log.WithFields(log.Fields{
"component": "cloud/livehls",
"event": "init_uploaded",
"session_id": s.id,
}).Info("Live HLS init segment uploaded")
return true
}
// initRefreshInterval is how often the init segment is re-uploaded so its TTL in
// the hub-api live window never lapses mid-session. The init segment is otherwise
// written only once per session; because the live window expires objects after a
// short TTL (LiveSegmentTTLSeconds, 45s on the hub) the init would age out after
// ~1 minute and the playlist's #EXT-X-MAP would start 404ing, stalling playback.
// Re-uploading well inside that TTL keeps the init alive for the life of the
// session while still letting it expire naturally once the session ends.
const initRefreshInterval = 15 * time.Second
// refreshInitIfStale re-uploads the init segment if it has not been refreshed
// within initRefreshInterval, keeping its created_at (and thus its TTL) current
// for as long as the session is producing segments. It is a no-op until the init
// has first been published. Failures are non-fatal: the next segment retries.
func (s *Session) refreshInitIfStale() {
s.mu.Lock()
if !s.initPublished || time.Since(s.lastInitAt) < initRefreshInterval {
s.mu.Unlock()
return
}
initBytes := s.initBytes
s.mu.Unlock()
if len(initBytes) == 0 {
return
}
ctx, cancel := s.newContext()
defer cancel()
if err := s.publisher.PublishInit(ctx, s.id, initBytes); err != nil {
log.WithError(err).WithFields(log.Fields{
"component": "cloud/livehls",
"event": "init_refresh_failed",
"session_id": s.id,
}).Warn("Failed to refresh live HLS init segment; retrying")
return
}
s.mu.Lock()
s.lastInitAt = time.Now()
s.mu.Unlock()
log.WithFields(log.Fields{
"component": "cloud/livehls",
"event": "init_refreshed",
"session_id": s.id,
}).Debug("Live HLS init segment refreshed")
}
// fireReadyOnce invokes the OnReady callback the first time it is called.
func (s *Session) fireReadyOnce() {
s.mu.Lock()
if s.readyFired || s.onReady == nil {
s.mu.Unlock()
return
}
s.readyFired = true
fn := s.onReady
s.mu.Unlock()
fn(s.id)
}
func (s *Session) fireFailureOnce(reason string) {
s.mu.Lock()
if s.failureFired || s.readyFired || s.onFailure == nil {
s.mu.Unlock()
return
}
s.failureFired = true
fn := s.onFailure
s.mu.Unlock()
fn(s.id, reason)
}
// newSessionID returns a short, unique, URL-safe session identifier of the form
// <unix-seconds>-<random-hex>.
func newSessionID() string {
b := make([]byte, 4)
if _, err := rand.Read(b); err != nil {
// rand.Read essentially never fails; fall back to a time-only id.
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return fmt.Sprintf("%d-%s", time.Now().Unix(), hex.EncodeToString(b))
}

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,84 @@
package livemoq
import (
"sync/atomic"
"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
}
// WriteWatchdog tracks the single synchronous frame write performed by a MoQ
// publisher so another goroutine can interrupt a wedged native call.
type WriteWatchdog struct {
startedAt atomic.Int64
}
func (w *WriteWatchdog) Begin(now time.Time) {
w.startedAt.Store(now.UnixNano())
}
func (w *WriteWatchdog) End() {
w.startedAt.Store(0)
}
func (w *WriteWatchdog) Elapsed(now time.Time) (time.Duration, bool) {
startedAt := w.startedAt.Load()
if startedAt == 0 {
return 0, false
}
elapsed := now.Sub(time.Unix(0, startedAt))
if elapsed < 0 {
elapsed = 0
}
return elapsed, true
}
// 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,68 @@
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)
}
}
func TestWriteWatchdogTracksActiveWrite(t *testing.T) {
now := time.Unix(10, 0)
watchdog := WriteWatchdog{}
if elapsed, active := watchdog.Elapsed(now); active || elapsed != 0 {
t.Fatalf("Elapsed() before Begin() = (%s, %t), want (0, false)", elapsed, active)
}
watchdog.Begin(now)
if elapsed, active := watchdog.Elapsed(now.Add(5 * time.Second)); !active || elapsed != 5*time.Second {
t.Fatalf("Elapsed() during write = (%s, %t), want (5s, true)", elapsed, active)
}
watchdog.End()
if elapsed, active := watchdog.Elapsed(now.Add(6 * time.Second)); active || elapsed != 0 {
t.Fatalf("Elapsed() after End() = (%s, %t), want (0, false)", elapsed, active)
}
}

View File

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

View File

@@ -0,0 +1,491 @@
//go:build moq
package cloud
import (
"context"
"fmt"
"net/url"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/kerberos-io/agent/machinery/src/cloud/livemoq"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/packets"
"github.com/moq-dev/moq-go/moq"
log "github.com/sirupsen/logrus"
)
const (
defaultMoQRelayURL = "https://relay.uug.ai/anon"
minMoQRetryDelay = time.Second
maxMoQRetryDelay = 30 * time.Second
defaultMoQLivePacketAge = 1500 * time.Millisecond
minMoQLivePacketAge = 250 * time.Millisecond
maxMoQLivePacketAge = 30 * time.Second
defaultMoQWriteTimeout = 5 * time.Second
minMoQWriteTimeout = time.Second
maxMoQWriteTimeout = time.Minute
moQWriteWatchInterval = 250 * 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
communication *models.Communication
maxPacketAge time.Duration
writeTimeout time.Duration
}
func (c liveMoQConfig) logEntry(event string) *log.Entry {
return log.WithFields(log.Fields{
"component": "moq",
"event": event,
"quality": c.quality,
"stream": c.sourceLabel,
})
}
func moQRelayHost(relayURL string) string {
parsed, err := url.Parse(relayURL)
if err != nil {
return ""
}
return parsed.Host
}
func (c liveMoQConfig) packetAgeLimit() time.Duration {
if c.maxPacketAge > 0 {
return c.maxPacketAge
}
return defaultMoQLivePacketAge
}
func (c liveMoQConfig) writeTimeoutLimit() time.Duration {
if c.writeTimeout > 0 {
return c.writeTimeout
}
return defaultMoQWriteTimeout
}
func boundedMoQDuration(name string, fallback, minimum, maximum time.Duration) time.Duration {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return fallback
}
value, err := time.ParseDuration(raw)
if err != nil {
log.WithError(err).WithFields(log.Fields{
"component": "moq",
"configured_value": raw,
"effective_value": fallback.String(),
"event": "duration_invalid",
"variable": name,
}).Warn("Invalid MoQ duration; using default")
return fallback
}
if value < minimum {
log.WithFields(log.Fields{
"component": "moq",
"configured_value": value.String(),
"effective_value": minimum.String(),
"event": "duration_clamped",
"minimum": minimum.String(),
"variable": name,
}).Warn("MoQ duration is below the supported minimum")
return minimum
}
if value > maximum {
log.WithFields(log.Fields{
"component": "moq",
"configured_value": value.String(),
"effective_value": maximum.String(),
"event": "duration_clamped",
"maximum": maximum.String(),
"variable": name,
}).Warn("MoQ duration exceeds the supported maximum")
return maximum
}
return value
}
// 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(
ctx context.Context,
configuration *models.Configuration,
communication *models.Communication,
subStreamEnabled bool,
mainQueue *packets.Queue,
subQueue *packets.Queue,
) {
if os.Getenv("AGENT_LIVE_MOQ_ENABLED") != "true" {
return
}
config := configuration.Config
if config.Offline == "true" || config.Capture.Liveview == "false" {
log.WithFields(log.Fields{
"component": "moq",
"event": "publisher_disabled",
"liveview": config.Capture.Liveview,
"offline": config.Offline,
}).Debug("MoQ publisher disabled by Agent configuration")
return
}
if config.Key == "" {
log.WithFields(log.Fields{
"component": "moq",
"event": "publisher_configuration_invalid",
"variable": "AGENT_KEY",
}).Warn("MoQ publisher requires an Agent key")
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")
maxPacketAge := boundedMoQDuration("AGENT_LIVE_MOQ_MAX_PACKET_AGE", defaultMoQLivePacketAge, minMoQLivePacketAge, maxMoQLivePacketAge)
writeTimeout := boundedMoQDuration("AGENT_LIVE_MOQ_WRITE_TIMEOUT", defaultMoQWriteTimeout, minMoQWriteTimeout, maxMoQWriteTimeout)
var publishers sync.WaitGroup
for _, quality := range qualities {
queue := mainQueue
sourceLabel := "main"
if models.SelectSubStreamForQuality(config, quality, subStreamEnabled) && subQueue != nil {
queue = subQueue
sourceLabel = "sub"
}
if queue == nil {
log.WithFields(log.Fields{
"component": "moq",
"event": "packet_queue_unavailable",
"quality": quality,
}).Warn("MoQ packet queue is unavailable")
continue
}
publisherConfig := liveMoQConfig{
relayURL: relayURL,
broadcast: livemoq.BroadcastPath(broadcastPrefix, config.Key, quality),
quality: quality,
sourceLabel: sourceLabel,
queue: queue,
communication: communication,
maxPacketAge: maxPacketAge,
writeTimeout: writeTimeout,
}
publishers.Add(1)
go func() {
defer publishers.Done()
runLiveStreamMoQ(ctx, publisherConfig)
}()
}
publishers.Wait()
}
func runLiveStreamMoQ(ctx context.Context, config liveMoQConfig) {
config.logEntry("publisher_started").Info("MoQ publisher started")
config.logEntry("publisher_configuration").WithFields(log.Fields{
"max_packet_age_ms": config.packetAgeLimit().Milliseconds(),
"relay_host": moQRelayHost(config.relayURL),
"write_timeout_ms": config.writeTimeoutLimit().Milliseconds(),
}).Debug("MoQ publisher configuration")
retryDelay := minMoQRetryDelay
for ctx.Err() == nil {
connectedAt := time.Now()
err := publishLiveStreamMoQ(ctx, config)
if ctx.Err() != nil {
return
}
if config.communication != nil {
config.communication.RecordMoQReconnect(config.quality)
}
config.logEntry("publisher_reconnecting").WithError(err).WithFields(log.Fields{
"connected_duration_ms": time.Since(connectedAt).Milliseconds(),
"retry_delay_ms": retryDelay.Milliseconds(),
}).Warn("MoQ publisher stopped; reconnecting")
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()
config.logEntry("relay_connected").WithField("relay_host", moQRelayHost(config.relayURL)).
Info("MoQ relay connected")
publisherCtx, cancelPublisher := context.WithCancel(ctx)
defer cancelPublisher()
sessionClosed := make(chan error, 1)
sessionWatchDone := make(chan struct{})
go func() {
defer close(sessionWatchDone)
sessionClosed <- client.Session().Closed(publisherCtx)
cancelPublisher()
}()
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)
}
var finishStreamOnce sync.Once
finishStream := func() {
finishStreamOnce.Do(func() {
_ = stream.Finish()
})
}
defer finishStream()
// 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.
publishing := &atomic.Bool{}
publishing.Store(true)
subscriberWatchDone := make(chan struct{})
go func() {
defer close(subscriberWatchDone)
watchLiveStreamMoQSubscribers(publisherCtx, stream, publishing, config)
}()
writeWatchdog := &livemoq.WriteWatchdog{}
writeWatchDone := make(chan struct{})
closePublisher := func() error {
finishStream()
return client.Close()
}
go func() {
defer close(writeWatchDone)
watchLiveStreamMoQWrites(publisherCtx, closePublisher, writeWatchdog, config)
}()
defer func() {
cancelPublisher()
<-writeWatchDone
<-subscriberWatchDone
<-sessionWatchDone
}()
cursor := config.queue.Latest()
gate := livemoq.FrameGate{}
deduplicator := livemoq.KeyframeDeduplicator{}
var lastSlowWriteWarning time.Time
var lastDuplicateKeyframeWarning time.Time
idle := false
for {
select {
case err := <-sessionClosed:
return fmt.Errorf("relay session closed: %w", err)
default:
}
packet, err := cursor.ReadPacketContext(publisherCtx)
if err != nil {
select {
case sessionErr := <-sessionClosed:
return fmt.Errorf("relay session closed: %w", sessionErr)
default:
}
if ctx.Err() != nil {
return ctx.Err()
}
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(), config.packetAgeLimit())
switch event {
case livemoq.FrameGateEventStarted:
config.logEntry("broadcast_live").WithFields(log.Fields{
"codec": packet.Codec,
"timestamp_ms": packet.Time,
}).Info("MoQ broadcast is live")
case livemoq.FrameGateEventLagging:
config.logEntry("stream_lagging").WithFields(log.Fields{
"max_packet_age_ms": config.packetAgeLimit().Milliseconds(),
"timestamp_ms": packet.Time,
}).Warn("MoQ stream is lagging; dropping packets until a recent keyframe")
case livemoq.FrameGateEventRecovered:
config.logEntry("stream_recovered").WithField("timestamp_ms", packet.Time).
Info("MoQ stream recovered 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 {
config.logEntry("duplicate_idr_removed").WithFields(log.Fields{
"duplicate_idr_count": normalizationStats.DuplicateIDRNALUs,
"timestamp_ms": packet.Time,
}).Warn("Removed duplicate IDR NAL units from MoQ keyframe")
lastDuplicateKeyframeWarning = time.Now()
}
if packet.IsKeyFrame && deduplicator.IsDuplicate(packet.Time, packet.CurrentTime, payload, time.Now(), duplicateKeyframeWindow) {
if time.Since(lastDuplicateKeyframeWarning) >= moQWriteWarningInterval {
config.logEntry("duplicate_keyframe_dropped").WithField("timestamp_ms", packet.Time).
Warn("Dropped duplicate MoQ keyframe")
lastDuplicateKeyframeWarning = time.Now()
}
continue
}
frame := moq.Frame{
Payload: payload,
TimestampUs: livemoq.TimestampUs(packet.Time),
}
writeStartedAt := time.Now()
writeWatchdog.Begin(writeStartedAt)
err = stream.WriteFrame(frame)
writeWatchdog.End()
if err != nil {
return fmt.Errorf("write H.264 access unit: %w", err)
}
writeDuration := time.Since(writeStartedAt)
if config.communication != nil {
config.communication.RecordMoQWrite(config.quality, writeDuration, time.Now())
}
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
}
}
config.logEntry("slow_frame_write").WithFields(log.Fields{
"duration_ms": writeDuration.Milliseconds(),
"keyframe": packet.IsKeyFrame,
"packet_age_ms": packetAge.Milliseconds(),
}).Warn("MoQ frame write was slow")
lastSlowWriteWarning = time.Now()
}
}
}
func watchLiveStreamMoQWrites(ctx context.Context, closeClient func() error, watchdog *livemoq.WriteWatchdog, config liveMoQConfig) {
ticker := time.NewTicker(moQWriteWatchInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
closeClient()
return
case now := <-ticker.C:
writeDuration, active := watchdog.Elapsed(now)
if !active || writeDuration < config.writeTimeoutLimit() {
continue
}
config.logEntry("frame_write_timeout").WithFields(log.Fields{
"duration_ms": writeDuration.Milliseconds(),
"timeout_ms": config.writeTimeoutLimit().Milliseconds(),
}).Warn("MoQ frame write timed out; reconnecting relay client")
if config.communication != nil {
config.communication.RecordMoQWriteTimeout()
}
closeClient()
return
}
}
}
// 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) {
config.logEntry("subscriber_joined").Info("MoQ subscriber joined; resuming broadcast")
}
if err := stream.Unused(ctx); err != nil {
publishing.Store(true)
return
}
publishing.Store(false)
config.logEntry("subscribers_idle").Info("MoQ broadcast idle; waiting for subscribers")
}
}

View File

@@ -0,0 +1,179 @@
//go:build moq
package cloud
import (
"context"
"sync/atomic"
"testing"
"time"
"github.com/kerberos-io/agent/machinery/src/cloud/livemoq"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/moq-dev/moq-go/moq"
)
func TestWatchLiveStreamMoQWritesClosesClientWhenContextEnds(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
closed := make(chan struct{})
var closeCalls atomic.Int32
done := make(chan struct{})
go func() {
defer close(done)
watchLiveStreamMoQWrites(ctx, func() error {
if closeCalls.Add(1) == 1 {
close(closed)
}
return nil
}, &livemoq.WriteWatchdog{}, liveMoQConfig{quality: "low", sourceLabel: "sub"})
}()
cancel()
select {
case <-closed:
case <-time.After(time.Second):
t.Fatal("watchLiveStreamMoQWrites() did not close the client after cancellation")
}
<-done
if got := closeCalls.Load(); got != 1 {
t.Fatalf("close calls = %d, want 1", got)
}
}
func TestBoundedMoQDuration(t *testing.T) {
const name = "AGENT_LIVE_MOQ_TEST_DURATION"
tests := []struct {
name string
raw string
want time.Duration
}{
{name: "unset", want: 5 * time.Second},
{name: "valid", raw: "12s", want: 12 * time.Second},
{name: "invalid", raw: "later", want: 5 * time.Second},
{name: "below minimum", raw: "100ms", want: time.Second},
{name: "above maximum", raw: "2m", want: time.Minute},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Setenv(name, test.raw)
if got := boundedMoQDuration(name, 5*time.Second, time.Second, time.Minute); got != test.want {
t.Fatalf("boundedMoQDuration() = %s, want %s", got, test.want)
}
})
}
}
func TestWatchLiveStreamMoQWritesRecordsTimeout(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
closed := make(chan struct{})
communication := &models.Communication{}
watchdog := &livemoq.WriteWatchdog{}
watchdog.Begin(time.Now().Add(-time.Second))
done := make(chan struct{})
go func() {
defer close(done)
watchLiveStreamMoQWrites(ctx, func() error {
close(closed)
return nil
}, watchdog, liveMoQConfig{
quality: models.StreamQualityLow,
sourceLabel: "sub",
communication: communication,
writeTimeout: 10 * time.Millisecond,
})
}()
select {
case <-closed:
case <-time.After(time.Second):
t.Fatal("watchLiveStreamMoQWrites() did not close the client after a write timeout")
}
<-done
if got := communication.RecoveryTelemetry().MoQWriteTimeouts; got != 1 {
t.Fatalf("MoQ write timeouts = %d, want 1", got)
}
}
func TestNativeMoQClientCloseStopsActiveWriter(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
server, err := moq.Listen(ctx, "127.0.0.1:0", moq.WithTLSGenerate("localhost"))
if err != nil {
t.Fatal(err)
}
serveDone := make(chan error, 1)
go func() {
serveDone <- server.Serve(ctx)
}()
client, err := moq.Dial(ctx, "https://"+server.LocalAddr(), moq.WithTLSVerify(false))
if err != nil {
server.Close()
t.Fatal(err)
}
broadcast, err := client.CreateBroadcast("test/native-close")
if err != nil {
client.Close()
server.Close()
t.Fatal(err)
}
stream, err := broadcast.PublishMedia("avc3", nil)
if err != nil {
client.Close()
server.Close()
t.Fatal(err)
}
firstWrite := make(chan struct{})
writerDone := make(chan error, 1)
go func() {
payload := []byte{
0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x00, 0xa0, 0x47, 0xfe, 0xc8,
0x00, 0x00, 0x00, 0x01, 0x68, 0xce, 0x38, 0x80,
0x00, 0x00, 0x00, 0x01, 0x65, 0x88,
}
for timestamp := uint64(0); ; timestamp++ {
err := stream.WriteFrame(moq.Frame{Payload: payload, TimestampUs: timestamp})
if err != nil {
writerDone <- err
return
}
if timestamp == 0 {
close(firstWrite)
}
}
}()
select {
case <-firstWrite:
case <-ctx.Done():
t.Fatal("native MoQ writer did not start")
}
if err := stream.Finish(); err != nil {
t.Fatal(err)
}
if err := client.Close(); err != nil {
t.Fatal(err)
}
select {
case err := <-writerDone:
if err == nil {
t.Fatal("native MoQ writer stopped without an error after client close")
}
case <-ctx.Done():
t.Fatal("native MoQ client close did not stop the writer")
}
_ = broadcast.Finish()
_ = server.Close()
select {
case <-serveDone:
case <-ctx.Done():
t.Fatal("native MoQ server did not stop")
}
}

View File

@@ -0,0 +1,154 @@
// Package livesnapshot implements the agent-side producer for the live-view
// "preview" (SD) mode over HTTP.
//
// Historically the preview pipeline shipped each resized keyframe (a base64
// JPEG, often chunked) to viewers over the MQTT broker. MQTT is a control plane
// for small messages, so pushing ~1 image/second of base64 image data per
// watched camera congests the broker and delays genuine control traffic. This
// package moves those frames off MQTT: the agent POSTs the latest resized JPEG
// straight to hub-api over plain HTTPS (outbound only), and viewers fetch it
// back with their session token. Only the tiny "a viewer is watching" keepalive
// stays on MQTT.
//
// The wire contract (agent -> hub-api) deliberately mirrors the live HLS ingest
// and the existing storage-upload convention (X-Kerberos-Storage-Device plus the
// Hub public/private key auth headers). hub-api authenticates the agent and
// stores the frame in an ephemeral, short-TTL per-device slot which it serves
// straight back to authorized viewers; the frame never enters the vault or the
// recordings collection.
//
// Like live HLS segments, a preview frame is worthless once stale: a frame that
// fails to upload is superseded by the next one a second later, so the publisher
// is fire-and-forget and drops on failure (logged) rather than retrying.
package livesnapshot
import (
"bytes"
"context"
"fmt"
"net/http"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
const (
// snapshotIngestPath is the hub-api endpoint that accepts the latest preview
// frame and stores it in the device's ephemeral snapshot slot (mirrors the
// /storage/live live-HLS ingest convention).
snapshotIngestPath = "/storage/snapshot"
contentTypeJPEG = "image/jpeg"
// Header names for the snapshot ingest contract (shared with live HLS / storage).
headerHubPublicKey = "X-Kerberos-Hub-PublicKey"
headerHubPrivateKey = "X-Kerberos-Hub-PrivateKey"
headerHubRegion = "X-Kerberos-Hub-Region"
headerStorageDevice = "X-Kerberos-Storage-Device"
// defaultPublishTimeout bounds a single snapshot upload. Preview frames are
// produced roughly once a second from a single goroutine, so an upload that
// cannot land in a few seconds is abandoned rather than allowed to back up the
// preview loop behind a slow request.
defaultPublishTimeout = 4 * time.Second
)
// PublisherConfig carries the hub endpoint and credentials needed to ship
// preview frames. It is populated from the agent's models.Config (the same
// HubURI/HubKey/HubPrivateKey used by recordings and live HLS).
type PublisherConfig struct {
HubURI string // base hub-api URL, e.g. https://api.hub.example.com
HubKey string // Hub public key (X-Kerberos-Hub-PublicKey)
HubPrivateKey string // Hub private key (X-Kerberos-Hub-PrivateKey)
Region string // storage region (X-Kerberos-Hub-Region), may be empty
DeviceKey string // device/camera key (X-Kerberos-Storage-Device)
// Timeout optionally overrides defaultPublishTimeout (used by tests).
Timeout time.Duration
// HTTPClient optionally injects a client (used by tests). When nil a
// redirect-credential-stripping client is created.
HTTPClient *http.Client
}
// Publisher ships the latest preview frame to hub-api over plain HTTP POST.
//
// It is safe for sequential use from a single live-stream goroutine. PublishSnapshot
// is fire-and-forget: it returns an error for the caller to log, but the caller is
// expected to continue (drop-on-fail) rather than retry.
type Publisher struct {
cfg PublisherConfig
client *http.Client
}
// NewPublisher builds a Publisher. The HTTP client strips the Hub credential
// headers on a cross-host redirect (net/http does this for standard auth headers
// but not custom-named ones), matching the recording/live-HLS upload clients.
func NewPublisher(cfg PublisherConfig) *Publisher {
client := cfg.HTTPClient
if client == nil {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = defaultPublishTimeout
}
client = &http.Client{
Timeout: timeout,
CheckRedirect: stripHubCredentialsOnCrossHostRedirect,
}
}
return &Publisher{cfg: cfg, client: client}
}
// PublishSnapshot uploads a single resized preview frame (JPEG) as the device's
// latest snapshot. It overwrites whatever frame was there before, so viewers
// always fetch the most recent frame.
func (p *Publisher) PublishSnapshot(ctx context.Context, jpeg []byte) error {
if p.cfg.HubURI == "" {
return fmt.Errorf("livesnapshot: HubURI not configured")
}
if len(jpeg) == 0 {
return fmt.Errorf("livesnapshot: empty snapshot body")
}
url := strings.TrimRight(p.cfg.HubURI, "/") + snapshotIngestPath
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jpeg))
if err != nil {
return fmt.Errorf("livesnapshot: build request: %w", err)
}
req.Header.Set("Content-Type", contentTypeJPEG)
req.Header.Set(headerStorageDevice, p.cfg.DeviceKey)
req.Header.Set(headerHubPublicKey, p.cfg.HubKey)
req.Header.Set(headerHubPrivateKey, p.cfg.HubPrivateKey)
req.Header.Set(headerHubRegion, p.cfg.Region)
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("livesnapshot: upload snapshot: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("livesnapshot: upload snapshot rejected: %s", resp.Status)
}
log.WithFields(log.Fields{
"component": "livesnapshot",
"event": "snapshot_published",
}).Debug("Live snapshot published")
return nil
}
// stripHubCredentialsOnCrossHostRedirect removes the Hub credential headers when
// a redirect crosses to a different host. net/http strips standard sensitive
// headers on a cross-host redirect but not custom-named ones, so without this the
// Hub keys could leak to a redirect target.
func stripHubCredentialsOnCrossHostRedirect(req *http.Request, via []*http.Request) error {
if len(via) == 0 {
return nil
}
if req.URL.Host != via[0].URL.Host {
req.Header.Del(headerHubPrivateKey)
req.Header.Del(headerHubPublicKey)
}
return nil
}

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

@@ -9,9 +9,9 @@ import (
"strconv"
"strings"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/minio/minio-go/v6"
log "github.com/sirupsen/logrus"
)
func UploadS3(configuration *models.Configuration, fileName string) (bool, bool, error) {
@@ -29,7 +29,7 @@ func UploadS3(configuration *models.Configuration, fileName string) (bool, bool,
if config.S3 == nil {
errorMessage := "UploadS3: Uploading Failed, as no settings found"
log.Log.Error(errorMessage)
log.Error(errorMessage)
return false, false, errors.New(errorMessage)
}
@@ -49,14 +49,14 @@ func UploadS3(configuration *models.Configuration, fileName string) (bool, bool,
// Check if we have some credentials otherwise we abort the request.
if aws_access_key_id == "" || aws_secret_access_key == "" {
errorMessage := "UploadS3: Uploading Failed, as no credentials found"
log.Log.Error(errorMessage)
log.Error(errorMessage)
return false, false, errors.New(errorMessage)
}
s3Client, err := minio.NewWithRegion("s3.amazonaws.com", aws_access_key_id, aws_secret_access_key, true, aws_region)
if err != nil {
errorMessage := "UploadS3: " + err.Error()
log.Log.Error(errorMessage)
log.Error(errorMessage)
return false, true, errors.New(errorMessage)
}
@@ -74,7 +74,7 @@ func UploadS3(configuration *models.Configuration, fileName string) (bool, bool,
fileParts := strings.Split(fileName, "_")
if len(fileParts) == 1 {
errorMessage := "UploadS3: " + fileName + " is not a valid name."
log.Log.Error(errorMessage)
log.Error(errorMessage)
return false, true, errors.New(errorMessage)
}
@@ -85,7 +85,7 @@ func UploadS3(configuration *models.Configuration, fileName string) (bool, bool,
//numberOfChanges := fileParts[4]
token, _ := strconv.Atoi(fileParts[5])
log.Log.Info("UploadS3: Upload started for " + fileName)
log.Info("UploadS3: Upload started for " + fileName)
fullname := "data/recordings/" + fileName
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
@@ -95,14 +95,14 @@ func UploadS3(configuration *models.Configuration, fileName string) (bool, bool,
if err != nil {
errorMessage := "UploadS3: " + err.Error()
log.Log.Error(errorMessage)
log.Error(errorMessage)
return false, true, errors.New(errorMessage)
}
fileInfo, err := file.Stat()
if err != nil {
errorMessage := "UploadS3: " + err.Error()
log.Log.Error(errorMessage)
log.Error(errorMessage)
return false, true, errors.New(errorMessage)
}
@@ -128,10 +128,10 @@ func UploadS3(configuration *models.Configuration, fileName string) (bool, bool,
if err != nil {
errorMessage := "UploadS3: Uploading Failed, " + err.Error()
log.Log.Error(errorMessage)
log.Error(errorMessage)
return false, true, errors.New(errorMessage)
} else {
log.Log.Info("UploadS3: Upload Finished, file has been uploaded to bucket: " + strconv.FormatInt(n, 10))
log.Info("UploadS3: Upload Finished, file has been uploaded to bucket: " + strconv.FormatInt(n, 10))
return true, true, nil
}
}

View File

@@ -0,0 +1,615 @@
package cloud
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/kerberos-io/agent/machinery/src/models"
log "github.com/sirupsen/logrus"
)
// tusResumableVersion is the tus protocol version implemented by this client.
const tusResumableVersion = "1.0.0"
// tusUploadPath is appended to the configured Kerberos Vault URI to reach the
// resumable upload endpoint. It mirrors how the legacy uploader appends
// "/storage".
const tusUploadPath = "/storage/tus/"
// tusResumeState is persisted in a sidecar file next to the agent data so an
// interrupted upload can be resumed across retries and even agent restarts.
type tusResumeState struct {
UploadURL string `json:"upload_url"`
VaultURI string `json:"vault_uri"`
Size int64 `json:"size"`
}
// resumableUploadsEnabled reports whether the resumable (tus) upload path should
// be attempted. It is enabled by default and can be disabled (falling back to
// the legacy single POST) by setting AGENT_DISABLE_RESUMABLE_UPLOAD=true.
func resumableUploadsEnabled() bool {
return os.Getenv("AGENT_DISABLE_RESUMABLE_UPLOAD") != "true"
}
// tusDefaultChunkSize is the number of bytes uploaded per PATCH request when no
// explicit size is configured. Splitting the upload into chunks keeps each HTTP
// request small enough for intermediary proxies/load balancers and checkpoints
// progress frequently, so an interruption resumes with minimal re-upload.
const tusDefaultChunkSize int64 = 8 << 20 // 8 MiB (>= S3 multipart minimum part size)
const tusProgressBucketPercent int64 = 10
// tusChunkSize returns the number of bytes to send per PATCH request. It
// defaults to tusDefaultChunkSize (8 MiB) and can be overridden with the
// AGENT_TUS_CHUNK_SIZE_BYTES environment variable. A value of 0 (or negative)
// disables chunking and sends the remaining bytes in a single PATCH.
func tusChunkSize() int64 {
v := os.Getenv("AGENT_TUS_CHUNK_SIZE_BYTES")
if v == "" {
return tusDefaultChunkSize
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return tusDefaultChunkSize
}
if n <= 0 {
return 0 // chunking disabled: send everything in one PATCH
}
return n
}
func tusProgressBucket(offset, size int64) int64 {
if size <= 0 {
return 100
}
percent := (offset * 100) / size
if percent > 100 {
percent = 100
}
return percent / tusProgressBucketPercent
}
func logTusUploadProgress(label string, offset, size int64, loggedBucket *int64) {
bucket := tusProgressBucket(offset, size)
if bucket <= *loggedBucket {
return
}
*loggedBucket = bucket
percent := bucket * tusProgressBucketPercent
if percent > 100 {
percent = 100
}
log.WithFields(log.Fields{
"bytes_sent": offset,
"bytes_total": size,
"component": "tus",
"event": "upload_progress",
"progress": percent,
"target": label,
}).Info("Resumable upload progress")
}
// tusHeaderFunc sets the authentication and routing headers required on every
// tus request for a particular upload target (Kerberos Vault directly, or
// Kerberos Hub which proxies to a vault). fileName is only meaningful on the
// creation request; it is empty on HEAD/PATCH/DELETE.
type tusHeaderFunc func(h http.Header, fileName string)
// runTusUpload performs a resumable (tus) upload of data/recordings/<fileName>
// to baseURL, sending target-specific authentication/routing headers via
// setHeaders on every request. It encapsulates the create/resume/chunk/finalize
// state machine shared by the Kerberos Vault (direct) and Kerberos Hub (proxied)
// upload paths.
//
// Return values:
// - uploaded: the recording was fully received and persisted by the server.
// - responded: the server returned a definitive HTTP response (used by the
// caller to advance its retry/secondary-failover policy).
// - supported: the server exposes a tus endpoint. When false, the caller
// should fall back to the legacy single-POST upload (older deployments).
// - body: a short message for logging.
func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tusHeaderFunc) (uploaded bool, responded bool, supported bool, body string, err error) {
fullname := "data/recordings/" + fileName
file, ferr := os.Open(fullname)
if file != nil {
defer file.Close()
}
if ferr != nil {
msg := label + ": resumable upload failed, file doesn't exist anymore"
log.Info(msg)
// The file is gone, so the legacy path cannot help either. Report it as
// "supported" to avoid a pointless fallback attempt.
return false, false, true, "", errors.New(msg)
}
info, serr := file.Stat()
if serr != nil {
return false, false, true, "", serr
}
size := info.Size()
client := newVaultHTTPClient(0)
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) == 0 {
return nil
}
if req.URL.Host != via[0].URL.Host {
for k := range req.Header {
if strings.HasPrefix(http.CanonicalHeaderKey(k), "X-Kerberos-") {
req.Header.Del(k)
}
}
}
return nil
}
sidecar := tusSidecarPath(fileName, slot)
uploadURL := loadTusResumeState(sidecar, baseURL)
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.
return false, false, false, "", cerr
}
log.Info(label + ": resumable create failed, " + cerr.Error())
tusBackoff(attempt)
continue
}
uploadURL = created
saveTusResumeState(sidecar, tusResumeState{UploadURL: uploadURL, VaultURI: baseURL, Size: size})
}
// (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.
removeTusResumeState(sidecar)
uploadURL = ""
continue
}
log.Info(label + ": resumable head failed, " + herr.Error())
tusBackoff(attempt)
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.
if offset >= size {
if restartedAfterComplete {
return false, true, true, "resumable finalize did not complete", errors.New(label + ": resumable finalize did not complete")
}
tusTerminate(client, uploadURL, setHeaders)
removeTusResumeState(sidecar)
uploadURL = ""
restartedAfterComplete = true
continue
}
// (4) Stream the remaining bytes to the vault via PATCH, reading directly
// from disk so the recording is never fully buffered in memory. When a chunk
// size is configured the data is sent across several PATCH requests,
// checkpointing the offset after each one so an interruption resumes from the
// last completed chunk instead of re-uploading everything.
chunkSize := tusChunkSize()
patchFailed := false
var lastBody string
loggedProgressBucket := tusProgressBucket(offset, size)
for offset < size {
// Re-seek every chunk so the on-disk position always matches the
// server-acknowledged offset, even if a PATCH was partially accepted.
if _, sErr := file.Seek(offset, io.SeekStart); sErr != nil {
return false, false, true, "", sErr
}
patchLen := size - offset
if chunkSize > 0 && chunkSize < patchLen {
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).
// Re-evaluate via HEAD on the next iteration to decide retry/restart.
log.Info(label + ": resumable patch rejected, " + perr.Error())
} else {
log.Info(label + ": resumable patch failed, " + perr.Error())
}
tusBackoff(attempt)
patchFailed = true
break
}
offset = newOffset
if offset > highWaterOffset {
highWaterOffset = offset
}
lastBody = respBody
logTusUploadProgress(label, offset, size, &loggedProgressBucket)
if offset < size {
// Partial progress: persist so a later retry resumes from here.
saveTusResumeState(sidecar, tusResumeState{UploadURL: uploadURL, VaultURI: baseURL, Size: size})
}
}
if patchFailed {
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
}
// All declared bytes have been sent and acknowledged: the upload is done.
removeTusResumeState(sidecar)
return true, true, true, lastBody, nil
}
// 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
// the tus resumable upload protocol. Credentials travel in the
// X-Kerberos-Storage-* headers on every request and routing (directory/provider)
// 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
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)
}
return runTusUpload(baseURL, metadata, fileName, label, slot, setHeaders)
}
// uploadHubResumable uploads a recording to Kerberos Hub's tus endpoint, which
// authenticates the agent with its Hub public/private key and proxies the
// resumable upload to the Kerberos Vault on the agent's behalf. The vault
// directory and provider are resolved and injected by Kerberos Hub, so they are
// 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
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) {
req, err := http.NewRequest("POST", baseURL, nil)
if err != nil {
return "", 0, err
}
req.Header.Set("Tus-Resumable", tusResumableVersion)
req.Header.Set("Upload-Length", strconv.FormatInt(size, 10))
if metadata != "" {
req.Header.Set("Upload-Metadata", metadata)
}
setHeaders(req.Header, fileName)
resp, err := client.Do(req)
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
return "", 0, err
}
io.Copy(io.Discard, resp.Body)
if resp.StatusCode != http.StatusCreated {
return "", resp.StatusCode, fmt.Errorf("unexpected status creating upload: %s", resp.Status)
}
location := resp.Header.Get("Location")
if location == "" {
return "", resp.StatusCode, errors.New("missing Location header in create response")
}
return resolveTusLocation(baseURL, location), resp.StatusCode, nil
}
// tusHead performs the tus "offset" request (HEAD) and returns the current
// server-side upload offset.
func tusHead(client *http.Client, uploadURL string, setHeaders tusHeaderFunc) (int64, int, error) {
req, err := http.NewRequest("HEAD", uploadURL, nil)
if err != nil {
return 0, 0, err
}
req.Header.Set("Tus-Resumable", tusResumableVersion)
setHeaders(req.Header, "")
resp, err := client.Do(req)
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
return 0, 0, err
}
io.Copy(io.Discard, resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
return 0, resp.StatusCode, fmt.Errorf("unexpected status on HEAD: %s", resp.Status)
}
offsetStr := resp.Header.Get("Upload-Offset")
offset, perr := strconv.ParseInt(offsetStr, 10, 64)
if perr != nil {
return 0, resp.StatusCode, fmt.Errorf("invalid Upload-Offset header: %q", offsetStr)
}
return offset, resp.StatusCode, nil
}
// tusPatch streams up to length bytes of the file (starting at offset) to the
// upload URL using a single PATCH request. The body is read straight from the
// *os.File, so the recording is never fully buffered in memory.
func tusPatch(client *http.Client, uploadURL string, offset, length int64, file io.Reader, setHeaders tusHeaderFunc) (int64, int, string, error) {
req, err := http.NewRequest("PATCH", uploadURL, io.LimitReader(file, length))
if err != nil {
return offset, 0, "", err
}
req.ContentLength = length
req.Header.Set("Tus-Resumable", tusResumableVersion)
req.Header.Set("Content-Type", "application/offset+octet-stream")
req.Header.Set("Upload-Offset", strconv.FormatInt(offset, 10))
setHeaders(req.Header, "")
resp, err := client.Do(req)
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
return offset, 0, "", err
}
bodyBytes, _ := io.ReadAll(resp.Body)
respBody := string(bodyBytes)
if resp.StatusCode != http.StatusNoContent {
return offset, resp.StatusCode, respBody, fmt.Errorf("unexpected status on PATCH: %s, %s", resp.Status, respBody)
}
newOffsetStr := resp.Header.Get("Upload-Offset")
newOffset, perr := strconv.ParseInt(newOffsetStr, 10, 64)
if perr != nil {
// A 204 without a parseable offset means this PATCH was fully accepted.
return offset + length, resp.StatusCode, respBody, nil
}
return newOffset, resp.StatusCode, respBody, nil
}
// tusTerminate best-effort deletes an upload server-side (DELETE).
func tusTerminate(client *http.Client, uploadURL string, setHeaders tusHeaderFunc) {
req, err := http.NewRequest("DELETE", uploadURL, nil)
if err != nil {
return
}
req.Header.Set("Tus-Resumable", tusResumableVersion)
setHeaders(req.Header, "")
resp, derr := client.Do(req)
if resp != nil {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
_ = derr
}
// setVaultTusHeaders sets the Kerberos Vault authentication and routing headers
// on every tus request. Credentials are sent on each request (and never stored
// server-side in the upload metadata). When fileName is empty it is omitted, as
// it is only useful on the creation request (routing also travels in the tus
// Upload-Metadata).
func setVaultTusHeaders(h http.Header, vault models.KStorage, publicKey, deviceKey, fileName string) {
h.Set("X-Kerberos-Storage-CloudKey", publicKey)
h.Set("X-Kerberos-Storage-AccessKey", vault.AccessKey)
h.Set("X-Kerberos-Storage-SecretAccessKey", vault.SecretAccessKey)
h.Set("X-Kerberos-Storage-Provider", vault.Provider)
h.Set("X-Kerberos-Storage-Device", deviceKey)
h.Set("X-Kerberos-Storage-Directory", vault.Directory)
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
if fileName != "" {
h.Set("X-Kerberos-Storage-FileName", fileName)
}
}
// setHubTusHeaders sets the Kerberos Hub authentication headers on every tus
// request of a hub-proxied resumable upload. The agent authenticates with its
// Hub public/private key (exactly as the legacy single-POST hub upload does);
// Kerberos Hub validates the subscription and injects the vault credentials and
// directory/provider on the agent's behalf.
func setHubTusHeaders(h http.Header, config *models.Config, fileName string) {
h.Set("X-Kerberos-Hub-PublicKey", config.HubKey)
h.Set("X-Kerberos-Hub-PrivateKey", config.HubPrivateKey)
h.Set("X-Kerberos-Hub-Region", config.S3.Region)
h.Set("X-Kerberos-Storage-Device", config.Key)
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
if fileName != "" {
h.Set("X-Kerberos-Storage-FileName", fileName)
}
}
// encodeTusMetadata serializes a map into the tus Upload-Metadata header format:
// a comma separated list of "key base64(value)" pairs. Keys are sorted for a
// deterministic header value. Empty values are skipped.
func encodeTusMetadata(pairs map[string]string) string {
parts := make([]string, 0, len(pairs))
for k, v := range pairs {
if v == "" {
continue
}
parts = append(parts, k+" "+base64.StdEncoding.EncodeToString([]byte(v)))
}
sort.Strings(parts)
return strings.Join(parts, ",")
}
// resolveTusLocation turns the Location header returned by the create request
// into an absolute URL. To keep talking to the agent's configured vault host
// (and avoid issues when the vault sits behind a proxy that rewrites the host),
// it keeps the configured base URL and only appends the server-assigned upload
// id taken from the Location.
func resolveTusLocation(baseURL, location string) string {
if ref, err := url.Parse(location); err == nil {
trimmed := strings.Trim(ref.Path, "/")
if trimmed != "" {
segments := strings.Split(trimmed, "/")
id := segments[len(segments)-1]
if id != "" {
return strings.TrimRight(baseURL, "/") + "/" + id
}
}
}
// Fallback: resolve the reference against the base URL as-is.
if base, err := url.Parse(baseURL); err == nil {
if ref, err := url.Parse(location); err == nil {
return base.ResolveReference(ref).String()
}
}
return location
}
// tusSidecarDir is the directory where resume state files are kept. It is
// intentionally separate from data/cloud (which is scanned for recordings to
// upload) so the sidecar files are never mistaken for recordings.
func tusSidecarDir() string {
return "data/tus"
}
func tusSidecarPath(fileName, slot string) string {
safe := strings.ReplaceAll(fileName, "/", "_")
safe = strings.ReplaceAll(safe, string(os.PathSeparator), "_")
return filepath.Join(tusSidecarDir(), safe+"."+slot+".json")
}
// loadTusResumeState returns a previously stored upload URL for the given
// sidecar, but only if it was created against the same vault base URL. Any
// mismatch or read/parse error yields an empty string (start fresh).
func loadTusResumeState(path, baseURL string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
var state tusResumeState
if err := json.Unmarshal(b, &state); err != nil {
return ""
}
if state.UploadURL == "" || state.VaultURI != baseURL {
return ""
}
return state.UploadURL
}
func saveTusResumeState(path string, state tusResumeState) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return
}
b, err := json.Marshal(state)
if err != nil {
return
}
_ = os.WriteFile(path, b, 0o644)
}
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 := tusBackoffBaseDelay * time.Duration(1<<uint(attempt))
if delay > 3*time.Second {
delay = 3 * time.Second
}
time.Sleep(delay)
}

View File

@@ -0,0 +1,882 @@
package cloud
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/kerberos-io/agent/machinery/src/models"
)
// fakeUpload tracks the state of a single resumable upload on the fake server.
type fakeUpload struct {
size int64
offset int64
}
// recordedRequest captures the method and headers of a request received by the
// fake tus server, so tests can assert the client's per-method auth headers.
type recordedRequest struct {
method string
header http.Header
}
// fakeTus is a tiny in-memory implementation of the tus 1.0.0 server protocol,
// sufficient to exercise the agent's resumable client.
type fakeTus struct {
mu sync.Mutex
uploads map[string]*fakeUpload
counter int
creates int
lastPatchBytes int64
patchSizes []int64
// unsupported makes the creation endpoint return 404, simulating an older
// vault without a tus endpoint.
unsupported bool
// failFinalize causes the next N completing PATCH requests to return 502
// 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
}
func newFakeTus() *fakeTus {
return &fakeTus{uploads: map[string]*fakeUpload{}}
}
func (s *fakeTus) seed(size, offset int64) string {
s.mu.Lock()
defer s.mu.Unlock()
s.counter++
id := fmt.Sprintf("seed-%d", s.counter)
s.uploads[id] = &fakeUpload{size: size, offset: offset}
return id
}
func (s *fakeTus) totalBytes() int64 {
s.mu.Lock()
defer s.mu.Unlock()
var total int64
for _, u := range s.uploads {
total += u.offset
}
return total
}
func (s *fakeTus) lastPatch() int64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.lastPatchBytes
}
// patchCounts returns the number of PATCH requests received and the size of each.
func (s *fakeTus) patchCounts() (int, []int64) {
s.mu.Lock()
defer s.mu.Unlock()
sizes := make([]int64, len(s.patchSizes))
copy(sizes, s.patchSizes)
return len(s.patchSizes), sizes
}
func (s *fakeTus) createCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.creates
}
// requestsForMethod returns the recorded requests for the given HTTP method.
func (s *fakeTus) requestsForMethod(method string) []recordedRequest {
s.mu.Lock()
defer s.mu.Unlock()
var out []recordedRequest
for _, req := range s.requests {
if req.method == method {
out = append(out, req)
}
}
return out
}
func (s *fakeTus) ServeHTTP(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, tusUploadPath)
w.Header().Set("Tus-Resumable", tusResumableVersion)
s.mu.Lock()
s.requests = append(s.requests, recordedRequest{method: r.Method, header: r.Header.Clone()})
s.mu.Unlock()
switch r.Method {
case http.MethodPost:
if s.unsupported {
w.WriteHeader(http.StatusNotFound)
return
}
length, _ := strconv.ParseInt(r.Header.Get("Upload-Length"), 10, 64)
s.mu.Lock()
s.counter++
s.creates++
newID := fmt.Sprintf("up-%d", s.counter)
s.uploads[newID] = &fakeUpload{size: length}
s.mu.Unlock()
w.Header().Set("Location", tusUploadPath+newID)
w.WriteHeader(http.StatusCreated)
case http.MethodHead:
s.mu.Lock()
u, ok := s.uploads[id]
s.mu.Unlock()
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Upload-Offset", strconv.FormatInt(u.offset, 10))
w.Header().Set("Upload-Length", strconv.FormatInt(u.size, 10))
w.WriteHeader(http.StatusOK)
case http.MethodPatch:
s.mu.Lock()
u, ok := s.uploads[id]
s.mu.Unlock()
if !ok {
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
s.lastPatchBytes = n
s.patchSizes = append(s.patchSizes, n)
complete := u.offset >= u.size
failNow := complete && s.failFinalize > 0
if failNow {
s.failFinalize--
}
offset := u.offset
s.mu.Unlock()
w.Header().Set("Upload-Offset", strconv.FormatInt(offset, 10))
if failNow {
// Bytes are stored but the (simulated) completion hook failed.
w.WriteHeader(http.StatusBadGateway)
return
}
w.WriteHeader(http.StatusNoContent)
case http.MethodDelete:
s.mu.Lock()
delete(s.uploads, id)
s.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// withRecording switches into a fresh temp working directory containing a
// recording at data/recordings/<fileName>. The working directory is restored on
// cleanup. Tests using this helper must not run in parallel.
func withRecording(t *testing.T, fileName string, payload []byte) {
t.Helper()
dir := t.TempDir()
old, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
if err := os.MkdirAll("data/recordings", 0o755); err != nil {
t.Fatalf("mkdir recordings: %v", err)
}
if err := os.WriteFile(filepath.Join("data/recordings", fileName), payload, 0o644); err != nil {
t.Fatalf("write recording: %v", err)
}
}
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,
AccessKey: "ak",
SecretAccessKey: "sk",
Provider: "gcp",
Directory: "dir",
}
}
func TestUploadKerberosVaultSkipsEmptyRecording(t *testing.T) {
fileName := "1787015373_3-654_office-camera17_0-0-0-0_-1_1960.mp4"
withRecording(t, fileName, nil)
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
vault := testVault(server.URL)
configuration := &models.Configuration{Config: models.Config{
Key: "device-key",
KStorage: &vault,
KStorageSecondary: &models.KStorage{},
}}
uploaded, configured, err := UploadKerberosVault(configuration, fileName)
if err != nil {
t.Fatalf("UploadKerberosVault() error = %v", err)
}
if uploaded || configured {
t.Fatalf("UploadKerberosVault() uploaded/configured = %v/%v, want false/false", uploaded, configured)
}
if requestCount != 0 {
t.Fatalf("Vault received %d requests, want 0", requestCount)
}
}
func TestUploadVaultResumable_HappyPath(t *testing.T) {
srv := newFakeTus()
ts := httptest.NewServer(srv)
defer ts.Close()
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 {
t.Fatalf("unexpected error: %v", err)
}
if !uploaded || !responded || !supported {
t.Fatalf("uploaded/responded/supported = %v/%v/%v, want all true", uploaded, responded, supported)
}
if got := srv.totalBytes(); got != int64(len(payload)) {
t.Fatalf("server received %d bytes, want %d", got, len(payload))
}
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) {
srv := newFakeTus()
ts := httptest.NewServer(srv)
defer ts.Close()
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
// 10 KiB payload uploaded in 4 KiB chunks => 3 PATCH requests (4096+4096+2048).
payload := bytes.Repeat([]byte("c"), 10240)
withRecording(t, fileName, payload)
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", "4096")
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !uploaded || !supported {
t.Fatalf("expected chunked upload success, got uploaded=%v supported=%v", uploaded, supported)
}
if got := srv.totalBytes(); got != int64(len(payload)) {
t.Fatalf("server received %d bytes, want %d", got, len(payload))
}
count, sizes := srv.patchCounts()
if count != 3 {
t.Fatalf("expected 3 chunked PATCH requests, got %d (sizes=%v)", count, sizes)
}
want := []int64{4096, 4096, 2048}
for i, w := range want {
if sizes[i] != w {
t.Fatalf("chunk %d size = %d, want %d (sizes=%v)", i, sizes[i], w, sizes)
}
}
if _, err := os.Stat(tusSidecarPath(fileName, "primary")); !os.IsNotExist(err) {
t.Fatalf("expected sidecar removed after success, stat err = %v", err)
}
}
func TestUploadVaultResumable_ChunkingDisabled(t *testing.T) {
srv := newFakeTus()
ts := httptest.NewServer(srv)
defer ts.Close()
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
payload := bytes.Repeat([]byte("d"), 10240)
withRecording(t, fileName, payload)
// 0 disables chunking: the whole file should go out in a single PATCH.
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", "0")
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !uploaded || !supported {
t.Fatalf("expected success, got uploaded=%v supported=%v", uploaded, supported)
}
count, sizes := srv.patchCounts()
if count != 1 {
t.Fatalf("expected a single PATCH when chunking is disabled, got %d (sizes=%v)", count, sizes)
}
if sizes[0] != int64(len(payload)) {
t.Fatalf("single PATCH size = %d, want %d", sizes[0], len(payload))
}
}
func TestTusChunkSize(t *testing.T) {
cases := []struct {
name string
env string
set bool
want int64
}{
{name: "default when unset", set: false, want: tusDefaultChunkSize},
{name: "default on invalid", env: "notanumber", set: true, want: tusDefaultChunkSize},
{name: "explicit value", env: "65536", set: true, want: 65536},
{name: "zero disables", env: "0", set: true, want: 0},
{name: "negative disables", env: "-5", set: true, want: 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.set {
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", tc.env)
} else {
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", "")
}
if got := tusChunkSize(); got != tc.want {
t.Fatalf("tusChunkSize() = %d, want %d", got, tc.want)
}
})
}
}
func TestUploadVaultResumable_Unsupported(t *testing.T) {
srv := newFakeTus()
srv.unsupported = true
ts := httptest.NewServer(srv)
defer ts.Close()
fileName := "f.mp4"
withRecording(t, fileName, []byte("hello"))
uploaded, _, supported, _, _ := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
if uploaded {
t.Fatal("expected uploaded=false against a vault without a tus endpoint")
}
if supported {
t.Fatal("expected supported=false so the caller falls back to the legacy upload")
}
}
// 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
ts := httptest.NewServer(srv)
defer ts.Close()
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
payload := bytes.Repeat([]byte("y"), 2048)
withRecording(t, fileName, payload)
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !uploaded || !supported {
t.Fatalf("expected success after a failed finalize + restart, got uploaded=%v supported=%v", uploaded, supported)
}
if got := srv.createCount(); got < 2 {
t.Fatalf("expected at least 2 create requests (restart after failed finalize), got %d", got)
}
}
func TestUploadVaultResumable_ResumeFromSidecar(t *testing.T) {
srv := newFakeTus()
ts := httptest.NewServer(srv)
defer ts.Close()
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
total := 8192
half := 4096
payload := bytes.Repeat([]byte("z"), total)
withRecording(t, fileName, payload)
// Simulate a previous run that uploaded half the file before being interrupted.
id := srv.seed(int64(total), int64(half))
baseURL := strings.TrimRight(ts.URL, "/") + tusUploadPath
saveTusResumeState(tusSidecarPath(fileName, "primary"), tusResumeState{
UploadURL: strings.TrimRight(baseURL, "/") + "/" + id,
VaultURI: baseURL,
Size: int64(total),
})
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !uploaded || !supported {
t.Fatalf("expected resume success, got uploaded=%v supported=%v", uploaded, supported)
}
if got := srv.lastPatch(); got != int64(total-half) {
t.Fatalf("resume should only send the remaining %d bytes, sent %d", total-half, got)
}
if srv.createCount() != 0 {
t.Fatalf("resume should not create a new upload, got %d creates", srv.createCount())
}
}
func testHubConfig(hubURI string) *models.Config {
return &models.Config{
Key: "device-key",
HubURI: hubURI,
HubKey: "hubpub",
HubPrivateKey: "hubpriv",
S3: &models.S3{Region: "eu-west"},
}
}
// decodeTusMetadata parses a tus Upload-Metadata header value ("key b64,key b64")
// back into a map of decoded key/value pairs.
func decodeTusMetadata(meta string) map[string]string {
out := map[string]string{}
if meta == "" {
return out
}
for _, pair := range strings.Split(meta, ",") {
parts := strings.SplitN(strings.TrimSpace(pair), " ", 2)
if parts[0] == "" {
continue
}
val := ""
if len(parts) == 2 {
if b, err := base64.StdEncoding.DecodeString(parts[1]); err == nil {
val = string(b)
}
}
out[parts[0]] = val
}
return out
}
func TestUploadHubResumable_HappyPath(t *testing.T) {
srv := newFakeTus()
ts := httptest.NewServer(srv)
defer ts.Close()
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 {
t.Fatalf("unexpected error: %v", err)
}
if !uploaded || !supported {
t.Fatalf("uploaded/supported = %v/%v, want both true", uploaded, supported)
}
if got := srv.totalBytes(); got != int64(len(payload)) {
t.Fatalf("server received %d bytes, want %d", got, len(payload))
}
// The Hub auth headers must be present on every request type (POST/HEAD/PATCH),
// because Kerberos Hub validates them on each proxied request. Conversely the
// vault credentials/routing are injected by Kerberos Hub on the agent's behalf
// and must never be sent by the agent on the hub path.
for _, method := range []string{http.MethodPost, http.MethodHead, http.MethodPatch} {
reqs := srv.requestsForMethod(method)
if len(reqs) == 0 {
t.Fatalf("expected at least one %s request", method)
}
for _, req := range reqs {
if got := req.header.Get("X-Kerberos-Hub-PublicKey"); got != "hubpub" {
t.Errorf("%s: X-Kerberos-Hub-PublicKey = %q, want %q", method, got, "hubpub")
}
if got := req.header.Get("X-Kerberos-Hub-PrivateKey"); got != "hubpriv" {
t.Errorf("%s: X-Kerberos-Hub-PrivateKey = %q, want %q", method, got, "hubpriv")
}
if got := req.header.Get("X-Kerberos-Hub-Region"); got != "eu-west" {
t.Errorf("%s: X-Kerberos-Hub-Region = %q, want %q", method, got, "eu-west")
}
if got := req.header.Get("X-Kerberos-Storage-Device"); got != "device-key" {
t.Errorf("%s: X-Kerberos-Storage-Device = %q, want %q", method, got, "device-key")
}
for _, h := range []string{
"X-Kerberos-Storage-AccessKey",
"X-Kerberos-Storage-SecretAccessKey",
"X-Kerberos-Storage-CloudKey",
"X-Kerberos-Storage-Provider",
"X-Kerberos-Storage-Directory",
} {
if got := req.header.Get(h); got != "" {
t.Errorf("%s: %s should be empty on the hub path, got %q", method, h, got)
}
}
}
}
// The creation request carries the upload metadata; on the hub path it must
// omit directory/provider/cloudkey (Hub resolves those) but include
// filename/device/capture. The filename header is also set on create.
posts := srv.requestsForMethod(http.MethodPost)
if got := posts[0].header.Get("X-Kerberos-Storage-FileName"); got != fileName {
t.Errorf("POST X-Kerberos-Storage-FileName = %q, want %q", got, fileName)
}
meta := decodeTusMetadata(posts[0].header.Get("Upload-Metadata"))
for _, omitted := range []string{"directory", "provider", "cloudkey"} {
if _, ok := meta[omitted]; ok {
t.Errorf("hub metadata must omit %q, got %v", omitted, meta)
}
}
if meta["filename"] != fileName {
t.Errorf("hub metadata filename = %q, want %q", meta["filename"], fileName)
}
if meta["device"] != "device-key" {
t.Errorf("hub metadata device = %q, want %q", meta["device"], "device-key")
}
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) {
srv := newFakeTus()
srv.unsupported = true
ts := httptest.NewServer(srv)
defer ts.Close()
fileName := "f.mp4"
withRecording(t, fileName, []byte("hello"))
uploaded, _, supported, _, _ := uploadHubResumable(testHubConfig(ts.URL), fileName, "test", "hub")
if uploaded {
t.Fatal("expected uploaded=false against a hub without a tus endpoint")
}
if supported {
t.Fatal("expected supported=false so the caller falls back to the legacy upload")
}
}
func TestEncodeTusMetadata(t *testing.T) {
got := encodeTusMetadata(map[string]string{
"b": "2",
"a": "1",
"empty": "",
})
// keys sorted, empty values skipped, values base64-encoded.
want := "a MQ==,b Mg=="
if got != want {
t.Fatalf("encodeTusMetadata = %q, want %q", got, want)
}
}
func TestResolveTusLocation(t *testing.T) {
cases := []struct {
name string
base string
location string
want string
}{
{
name: "absolute path location",
base: "http://host/storage/tus/",
location: "/storage/tus/abc",
want: "http://host/storage/tus/abc",
},
{
name: "absolute url keeps configured host",
base: "http://host/storage/tus/",
location: "http://internal:8080/storage/tus/xyz",
want: "http://host/storage/tus/xyz",
},
{
name: "relative id",
base: "http://host/api/storage/tus/",
location: "abc",
want: "http://host/api/storage/tus/abc",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := resolveTusLocation(tc.base, tc.location); got != tc.want {
t.Fatalf("resolveTusLocation(%q, %q) = %q, want %q", tc.base, tc.location, got, tc.want)
}
})
}
}
func TestTusResumeStateRoundTrip(t *testing.T) {
dir := t.TempDir()
old, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
defer os.Chdir(old)
path := tusSidecarPath("file.mp4", "primary")
state := tusResumeState{UploadURL: "http://host/storage/tus/abc", VaultURI: "http://host/storage/tus/", Size: 123}
saveTusResumeState(path, state)
if got := loadTusResumeState(path, state.VaultURI); got != state.UploadURL {
t.Fatalf("loadTusResumeState = %q, want %q", got, state.UploadURL)
}
// A mismatched vault URI must not be reused.
if got := loadTusResumeState(path, "http://other/storage/tus/"); got != "" {
t.Fatalf("loadTusResumeState with mismatched vault = %q, want empty", got)
}
}

View File

@@ -1,788 +0,0 @@
package components
import (
"context"
"os"
"strconv"
"sync/atomic"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel"
"github.com/kerberos-io/agent/machinery/src/capture"
"github.com/kerberos-io/agent/machinery/src/cloud"
"github.com/kerberos-io/agent/machinery/src/computervision"
configService "github.com/kerberos-io/agent/machinery/src/config"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/onvif"
"github.com/kerberos-io/agent/machinery/src/packets"
routers "github.com/kerberos-io/agent/machinery/src/routers/mqtt"
"github.com/kerberos-io/agent/machinery/src/utils"
"github.com/tevino/abool"
)
var tracer = otel.Tracer("github.com/kerberos-io/agent/machinery/src/components")
func Bootstrap(ctx context.Context, configDirectory string, configuration *models.Configuration, communication *models.Communication, captureDevice *capture.Capture) {
log.Log.Debug("components.Kerberos.Bootstrap(): bootstrapping the kerberos agent.")
bootstrapContext := context.Background()
_, span := tracer.Start(bootstrapContext, "Bootstrap")
// We will keep track of the Kerberos Agent up time
// This is send to Kerberos Hub in a heartbeat.
uptimeStart := time.Now()
// Initiate the packet counter, this is being used to detect
// if a camera is going blocky, or got disconnected.
var packageCounter atomic.Value
packageCounter.Store(int64(0))
communication.PackageCounter = &packageCounter
var packageCounterSub atomic.Value
packageCounterSub.Store(int64(0))
communication.PackageCounterSub = &packageCounterSub
// This is used when the last packet was received (timestamp),
// this metric is used to determine if the camera is still online/connected.
var lastPacketTimer atomic.Value
packageCounter.Store(int64(0))
communication.LastPacketTimer = &lastPacketTimer
var lastPacketTimerSub atomic.Value
packageCounterSub.Store(int64(0))
communication.LastPacketTimerSub = &lastPacketTimerSub
// This is used to understand if we have a working Kerberos Hub connection
// cloudTimestamp will be updated when successfully sending heartbeats.
var cloudTimestamp atomic.Value
cloudTimestamp.Store(int64(0))
communication.CloudTimestamp = &cloudTimestamp
communication.HandleStream = make(chan string, 1)
communication.HandleSubStream = make(chan string, 1)
communication.HandleUpload = make(chan string, 1)
communication.HandleHeartBeat = make(chan string, 1)
communication.HandleLiveSD = make(chan int64, 1)
communication.HandleLiveHDKeepalive = make(chan string, 1)
communication.HandleLiveHDPeers = make(chan string, 1)
communication.IsConfiguring = abool.New()
cameraSettings := &models.Camera{}
// Before starting the agent, we have a control goroutine, that might
// do several checks to see if the agent is still operational.
go ControlAgent(communication)
// Handle heartbeats
go cloud.HandleHeartBeat(configuration, communication, uptimeStart)
// We'll create a MQTT handler, which will be used to communicate with Kerberos Hub.
// Configure a MQTT client which helps for a bi-directional communication
mqttClient := routers.ConfigureMQTT(configDirectory, configuration, communication)
span.End()
// Run the agent and fire up all the other
// goroutines which do image capture, motion detection, onvif, etc.
for {
// This will blocking until receiving a signal to be restarted, reconfigured, stopped, etc.
status := RunAgent(configDirectory, configuration, communication, mqttClient, uptimeStart, cameraSettings, captureDevice)
if status == "stop" {
log.Log.Info("components.Kerberos.Bootstrap(): shutting down the agent in 3 seconds.")
time.Sleep(time.Second * 3)
os.Exit(0)
}
if status == "not started" {
// We will re open the configuration, might have changed :O!
configService.OpenConfig(configDirectory, configuration)
// We will override the configuration with the environment variables
configService.OverrideWithEnvironmentVariables(configuration)
}
// Reset the MQTT client, might have provided new information, so we need to reconnect.
if routers.HasMQTTClientModified(configuration) {
routers.DisconnectMQTT(mqttClient, &configuration.Config)
mqttClient = routers.ConfigureMQTT(configDirectory, configuration, communication)
}
// We will create a new cancelable context, which will be used to cancel and restart.
// This is used to restart the agent when the configuration is updated.
ctx, cancel := context.WithCancel(context.Background())
communication.Context = &ctx
communication.CancelContext = &cancel
}
}
func RunAgent(configDirectory string, configuration *models.Configuration, communication *models.Communication, mqttClient mqtt.Client, uptimeStart time.Time, cameraSettings *models.Camera, captureDevice *capture.Capture) string {
ctx := context.Background()
ctxRunAgent, span := tracer.Start(ctx, "RunAgent")
log.Log.Info("components.Kerberos.RunAgent(): Creating camera and processing threads.")
config := configuration.Config
status := "not started"
// Currently only support H264 encoded cameras, this will change.
// Establishing the camera connection without backchannel if no substream
rtspUrl := config.Capture.IPCamera.RTSP
rtspClient := captureDevice.SetMainClient(rtspUrl)
if rtspUrl != "" {
err := rtspClient.Connect(ctx, ctxRunAgent)
if err != nil {
log.Log.Error("components.Kerberos.RunAgent(): error connecting to RTSP stream: " + err.Error())
rtspClient.Close(ctxRunAgent)
rtspClient = nil
time.Sleep(time.Second * 3)
return status
}
} else {
log.Log.Error("components.Kerberos.RunAgent(): no rtsp url found in config, please provide one.")
rtspClient = nil
time.Sleep(time.Second * 3)
return status
}
log.Log.Info("components.Kerberos.RunAgent(): opened RTSP stream: " + rtspUrl)
// Get the video streams from the RTSP server.
videoStreams, err := rtspClient.GetVideoStreams()
if err != nil || len(videoStreams) == 0 {
log.Log.Error("components.Kerberos.RunAgent(): no video stream found, might be the wrong codec (we only support H264 for the moment)")
rtspClient.Close(ctxRunAgent)
time.Sleep(time.Second * 3)
return status
}
// Get the video stream from the RTSP server.
videoStream := videoStreams[0]
// Get some information from the video stream.
width := videoStream.Width
height := videoStream.Height
// Set config values as well
configuration.Config.Capture.IPCamera.Width = width
configuration.Config.Capture.IPCamera.Height = height
// Set the liveview width and height, this is used for the liveview and motion regions (drawing on the hub).
baseWidth := config.Capture.IPCamera.BaseWidth
baseHeight := config.Capture.IPCamera.BaseHeight
// If the liveview height is not set, we will calculate it based on the width and aspect ratio of the camera.
if baseWidth > 0 && baseHeight == 0 {
widthAspectRatio := float64(baseWidth) / float64(width)
configuration.Config.Capture.IPCamera.BaseHeight = int(float64(height) * widthAspectRatio)
} else if baseHeight > 0 && baseWidth > 0 {
configuration.Config.Capture.IPCamera.BaseHeight = baseHeight
configuration.Config.Capture.IPCamera.BaseWidth = baseWidth
} else {
configuration.Config.Capture.IPCamera.BaseHeight = height
configuration.Config.Capture.IPCamera.BaseWidth = width
}
// Set the SPS and PPS values in the configuration.
configuration.Config.Capture.IPCamera.SPSNALUs = [][]byte{videoStream.SPS}
configuration.Config.Capture.IPCamera.PPSNALUs = [][]byte{videoStream.PPS}
configuration.Config.Capture.IPCamera.VPSNALUs = [][]byte{videoStream.VPS}
// Define queues for the main and sub stream.
var queue *packets.Queue
var subQueue *packets.Queue
// Create a packet queue, which is filled by the HandleStream routing
// and consumed by all other routines: motion, livestream, etc.
if config.Capture.PreRecording <= 0 {
config.Capture.PreRecording = 1
log.Log.Warning("components.Kerberos.RunAgent(): Prerecording value not found in config or invalid value! Found: " + strconv.FormatInt(config.Capture.PreRecording, 10))
}
// We might have a secondary rtsp url, so we might need to use that for livestreaming let us check first!
subStreamEnabled := false
subRtspUrl := config.Capture.IPCamera.SubRTSP
var videoSubStreams []packets.Stream
if subRtspUrl != "" && subRtspUrl != rtspUrl {
// For the sub stream we will not enable backchannel.
subStreamEnabled = true
rtspSubClient := captureDevice.SetSubClient(subRtspUrl)
captureDevice.RTSPSubClient = rtspSubClient
err := rtspSubClient.Connect(ctx, ctxRunAgent)
if err != nil {
log.Log.Error("components.Kerberos.RunAgent(): error connecting to RTSP sub stream: " + err.Error())
time.Sleep(time.Second * 3)
return status
}
log.Log.Info("components.Kerberos.RunAgent(): opened RTSP sub stream: " + subRtspUrl)
// Get the video streams from the RTSP server.
videoSubStreams, err = rtspSubClient.GetVideoStreams()
if err != nil || len(videoSubStreams) == 0 {
log.Log.Error("components.Kerberos.RunAgent(): no video sub stream found, might be the wrong codec (we only support H264 for the moment)")
rtspSubClient.Close(ctxRunAgent)
time.Sleep(time.Second * 3)
return status
}
// Get the video stream from the RTSP server.
videoSubStream := videoSubStreams[0]
width := videoSubStream.Width
height := videoSubStream.Height
// Set config values as well
configuration.Config.Capture.IPCamera.SubWidth = width
configuration.Config.Capture.IPCamera.SubHeight = height
// If we have a substream, we need to set the width and height of the substream. (so we will override above information)
// Set the liveview width and height, this is used for the liveview and motion regions (drawing on the hub).
baseWidth := config.Capture.IPCamera.BaseWidth
baseHeight := config.Capture.IPCamera.BaseHeight
// If the liveview height is not set, we will calculate it based on the width and aspect ratio of the camera.
if baseWidth > 0 && baseHeight == 0 {
widthAspectRatio := float64(baseWidth) / float64(width)
configuration.Config.Capture.IPCamera.BaseHeight = int(float64(height) * widthAspectRatio)
} else if baseHeight > 0 && baseWidth > 0 {
configuration.Config.Capture.IPCamera.BaseHeight = baseHeight
configuration.Config.Capture.IPCamera.BaseWidth = baseWidth
} else {
configuration.Config.Capture.IPCamera.BaseHeight = height
configuration.Config.Capture.IPCamera.BaseWidth = width
}
}
// We are creating a queue to store the RTSP frames in, these frames will be
// processed by the different consumers: motion detection, recording, etc.
queue = packets.NewQueue()
communication.Queue = queue
// Set the maximum GOP count, this is used to determine the pre-recording time.
log.Log.Info("components.Kerberos.RunAgent(): SetMaxGopCount was set with: " + strconv.Itoa(int(config.Capture.PreRecording)+1))
queue.SetMaxGopCount(1) // We will adjust this later on, when we have the GOP size.
queue.WriteHeader(videoStreams)
go rtspClient.Start(ctx, "main", queue, configuration, communication)
// Main stream is connected and ready to go.
communication.MainStreamConnected = true
// Try to create backchannel
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
if subStreamEnabled && rtspSubClient != nil {
subQueue = packets.NewQueue()
communication.SubQueue = subQueue
subQueue.SetMaxGopCount(1) // GOP time frame is set to 1 for motion detection and livestreaming.
subQueue.WriteHeader(videoSubStreams)
go rtspSubClient.Start(ctx, "sub", subQueue, configuration, communication)
// Sub stream is connected and ready to go.
communication.SubStreamConnected = true
}
// Handle livestream SD (low resolution over MQTT)
if subStreamEnabled {
livestreamCursor := subQueue.Latest()
go cloud.HandleLiveStreamSD(livestreamCursor, configuration, communication, mqttClient, rtspSubClient)
} else {
livestreamCursor := queue.Latest()
go cloud.HandleLiveStreamSD(livestreamCursor, configuration, communication, mqttClient, rtspClient)
}
// Handle livestream HD (high resolution over WEBRTC)
communication.HandleLiveHDHandshake = make(chan models.RequestHDStreamPayload, 10)
if subStreamEnabled {
livestreamHDCursor := subQueue.Latest()
go cloud.HandleLiveStreamHD(livestreamHDCursor, configuration, communication, mqttClient, rtspSubClient)
} else {
livestreamHDCursor := queue.Latest()
go cloud.HandleLiveStreamHD(livestreamHDCursor, configuration, communication, mqttClient, rtspClient)
}
// Handle recording, will write an mp4 to disk.
go capture.HandleRecordStream(queue, configDirectory, configuration, communication, rtspClient)
// Handle processing of motion
communication.HandleMotion = make(chan models.MotionDataPartial, 10)
if subStreamEnabled {
motionCursor := subQueue.Latest()
go computervision.ProcessMotion(motionCursor, configuration, communication, mqttClient, rtspSubClient)
} else {
motionCursor := queue.Latest()
go computervision.ProcessMotion(motionCursor, configuration, communication, mqttClient, rtspClient)
}
// Handle realtime processing if enabled.
if subStreamEnabled {
realtimeProcessingCursor := subQueue.Latest()
go cloud.HandleRealtimeProcessing(realtimeProcessingCursor, configuration, communication, mqttClient, rtspClient)
} else {
realtimeProcessingCursor := queue.Latest()
go cloud.HandleRealtimeProcessing(realtimeProcessingCursor, configuration, communication, mqttClient, rtspClient)
}
// Handle Upload to cloud provider (Kerberos Hub, Kerberos Vault and others)
go cloud.HandleUpload(configDirectory, configuration, communication)
// Handle ONVIF actions
communication.HandleONVIF = make(chan models.OnvifAction, 10)
go onvif.HandleONVIFActions(configuration, communication)
communication.HandleAudio = make(chan models.AudioDataPartial, 10)
if rtspBackChannelClient.HasBackChannel {
communication.HasBackChannel = true
go WriteAudioToBackchannel(communication, rtspBackChannelClient)
}
// If we reach this point, we have a working RTSP connection.
communication.CameraConnected = true
// Otel end span
span.End()
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// This will go into a blocking state, once this channel is triggered
// the agent will cleanup and restart.
status = <-communication.HandleBootstrap
// If we reach this point, we are stopping the stream.
communication.CameraConnected = false
communication.MainStreamConnected = false
communication.SubStreamConnected = false
// Cancel the main context, this will stop all the other goroutines.
(*communication.CancelContext)()
// We will re open the configuration, might have changed :O!
configService.OpenConfig(configDirectory, configuration)
// We will override the configuration with the environment variables
configService.OverrideWithEnvironmentVariables(configuration)
// Here we are cleaning up everything!
if configuration.Config.Offline != "true" {
select {
case communication.HandleUpload <- "stop":
log.Log.Info("components.Kerberos.RunAgent(): stopping upload")
case <-time.After(1 * time.Second):
log.Log.Info("components.Kerberos.RunAgent(): stopping upload timed out")
}
}
select {
case communication.HandleStream <- "stop":
log.Log.Info("components.Kerberos.RunAgent(): stopping stream")
case <-time.After(1 * time.Second):
log.Log.Info("components.Kerberos.RunAgent(): stopping stream timed out")
}
// We use the steam channel to stop both main and sub stream.
//if subStreamEnabled {
// communication.HandleSubStream <- "stop"
//}
time.Sleep(time.Second * 3)
err = rtspClient.Close(ctxRunAgent)
if err != nil {
log.Log.Error("components.Kerberos.RunAgent(): error closing RTSP stream: " + err.Error())
time.Sleep(time.Second * 3)
return status
}
queue.Close()
queue = nil
communication.Queue = nil
if subStreamEnabled {
err = rtspSubClient.Close(ctxRunAgent)
if err != nil {
log.Log.Error("components.Kerberos.RunAgent(): error closing RTSP sub stream: " + err.Error())
time.Sleep(time.Second * 3)
return status
}
subQueue.Close()
subQueue = nil
communication.SubQueue = nil
}
err = rtspBackChannelClient.Close(ctxRunAgent)
if err != nil {
log.Log.Error("components.Kerberos.RunAgent(): error closing RTSP backchannel stream: " + err.Error())
}
time.Sleep(time.Second * 3)
close(communication.HandleLiveHDHandshake)
communication.HandleLiveHDHandshake = nil
close(communication.HandleMotion)
communication.HandleMotion = nil
close(communication.HandleAudio)
communication.HandleAudio = nil
close(communication.HandleONVIF)
communication.HandleONVIF = nil
// Waiting for some seconds to make sure everything is properly closed.
log.Log.Info("components.Kerberos.RunAgent(): waiting 3 seconds to make sure everything is properly closed.")
time.Sleep(time.Second * 3)
return status
}
// ControlAgent will check if the camera is still connected, if not it will restart the agent.
// In the other thread we are keeping track of the number of packets received, and particular the keyframe packets.
// Once we are not receiving any packets anymore, we will restart the agent.
func ControlAgent(communication *models.Communication) {
log.Log.Debug("components.Kerberos.ControlAgent(): started")
packageCounter := communication.PackageCounter
packageSubCounter := communication.PackageCounterSub
go func() {
// A channel to check the camera activity
var previousPacket int64 = 0
var previousPacketSub int64 = 0
var occurence = 0
var occurenceSub = 0
for {
// If camera is connected, we'll check if we are still receiving packets.
if communication.CameraConnected {
// First we'll check the main stream.
packetsR := packageCounter.Load().(int64)
if packetsR == previousPacket {
// If we are already reconfiguring,
// we dont need to check if the stream is blocking.
if !communication.IsConfiguring.IsSet() {
occurence = occurence + 1
}
} else {
occurence = 0
}
log.Log.Info("components.Kerberos.ControlAgent(): Number of packets read from mainstream: " + strconv.FormatInt(packetsR, 10))
// After 15 seconds without activity this is thrown..
if occurence == 3 {
log.Log.Info("components.Kerberos.ControlAgent(): Restarting machinery because of blocking mainstream.")
select {
case communication.HandleBootstrap <- "restart":
log.Log.Info("components.Kerberos.ControlAgent(): Restarting machinery because of blocking substream.")
case <-time.After(1 * time.Second):
log.Log.Info("components.Kerberos.ControlAgent(): Restarting machinery because of blocking substream timed out")
}
occurence = 0
}
// Now we'll check the sub stream.
packetsSubR := packageSubCounter.Load().(int64)
if communication.SubStreamConnected {
if packetsSubR == previousPacketSub {
// If we are already reconfiguring,
// we dont need to check if the stream is blocking.
if !communication.IsConfiguring.IsSet() {
occurenceSub = occurenceSub + 1
}
} else {
occurenceSub = 0
}
log.Log.Info("components.Kerberos.ControlAgent(): Number of packets read from substream: " + strconv.FormatInt(packetsSubR, 10))
// After 15 seconds without activity this is thrown..
if occurenceSub == 3 {
select {
case communication.HandleBootstrap <- "restart":
log.Log.Info("components.Kerberos.ControlAgent(): Restarting machinery because of blocking substream.")
case <-time.After(1 * time.Second):
log.Log.Info("components.Kerberos.ControlAgent(): Restarting machinery because of blocking substream timed out")
}
occurenceSub = 0
}
}
previousPacket = packageCounter.Load().(int64)
previousPacketSub = packageSubCounter.Load().(int64)
}
time.Sleep(5 * time.Second)
}
}()
log.Log.Debug("components.Kerberos.ControlAgent(): finished")
}
// GetDashboard godoc
// @Router /api/dashboard [get]
// @ID dashboard
// @Tags general
// @Summary Get all information showed on the dashboard.
// @Description Get all information showed on the dashboard.
// @Success 200
func GetDashboard(c *gin.Context, configDirectory string, configuration *models.Configuration, communication *models.Communication) {
// Check if camera is online.
cameraIsOnline := communication.CameraConnected
// If an agent is properly setup with Kerberos Hub, we will send
// a ping to Kerberos Hub every 15seconds. On receiving a positive response
// it will update the CloudTimestamp value.
cloudIsOnline := false
if communication.CloudTimestamp != nil && communication.CloudTimestamp.Load() != nil {
timestamp := communication.CloudTimestamp.Load().(int64)
if timestamp > 0 {
cloudIsOnline = true
}
}
// The total number of recordings stored in the directory.
recordingDirectory := configDirectory + "/data/recordings"
numberOfRecordings := utils.NumberOfMP4sInDirectory(recordingDirectory)
// All days stored in this agent.
days := []string{}
latestEvents := []models.Media{}
files, err := utils.ReadDirectory(recordingDirectory)
if err == nil {
events := utils.GetSortedDirectory(files)
// Get All days
days = utils.GetDays(events, recordingDirectory, configuration)
// Get all latest events
var eventFilter models.EventFilter
eventFilter.NumberOfElements = 5
latestEvents = utils.GetMediaFormatted(events, recordingDirectory, configuration, eventFilter) // will get 5 latest recordings.
}
c.JSON(200, gin.H{
"offlineMode": configuration.Config.Offline,
"cameraOnline": cameraIsOnline,
"cloudOnline": cloudIsOnline,
"numberOfRecordings": numberOfRecordings,
"days": days,
"latestEvents": latestEvents,
})
}
// GetLatestEvents godoc
// @Router /api/latest-events [post]
// @ID latest-events
// @Tags general
// @Param eventFilter body models.EventFilter true "Event filter"
// @Summary Get the latest recordings (events) from the recordings directory.
// @Description Get the latest recordings (events) from the recordings directory.
// @Success 200
func GetLatestEvents(c *gin.Context, configDirectory string, configuration *models.Configuration, communication *models.Communication) {
var eventFilter models.EventFilter
err := c.BindJSON(&eventFilter)
if err == nil {
// Default to 10 if no limit is set.
if eventFilter.NumberOfElements == 0 {
eventFilter.NumberOfElements = 10
}
recordingDirectory := configDirectory + "/data/recordings"
files, err := utils.ReadDirectory(recordingDirectory)
if err == nil {
events := utils.GetSortedDirectory(files)
// We will get all recordings from the directory (as defined by the filter).
fileObjects := utils.GetMediaFormatted(events, recordingDirectory, configuration, eventFilter)
c.JSON(200, gin.H{
"events": fileObjects,
})
} else {
c.JSON(400, gin.H{
"data": "Something went wrong: " + err.Error(),
})
}
} else {
c.JSON(400, gin.H{
"data": "Something went wrong: " + err.Error(),
})
}
}
// GetDays godoc
// @Router /api/days [get]
// @ID days
// @Tags general
// @Summary Get all days stored in the recordings directory.
// @Description Get all days stored in the recordings directory.
// @Success 200
func GetDays(c *gin.Context, configDirectory string, configuration *models.Configuration, communication *models.Communication) {
recordingDirectory := configDirectory + "/data/recordings"
files, err := utils.ReadDirectory(recordingDirectory)
if err == nil {
events := utils.GetSortedDirectory(files)
days := utils.GetDays(events, recordingDirectory, configuration)
c.JSON(200, gin.H{
"events": days,
})
} else {
c.JSON(400, gin.H{
"data": "Something went wrong: " + err.Error(),
})
}
}
// StopAgent godoc
// @Router /api/camera/stop [post]
// @ID camera-stop
// @Tags camera
// @Summary Stop the agent.
// @Description Stop the agent.
// @Success 200 {object} models.APIResponse
func StopAgent(c *gin.Context, communication *models.Communication) {
log.Log.Info("components.Kerberos.StopAgent(): sending signal to stop agent, this will os.Exit(0).")
select {
case communication.HandleBootstrap <- "stop":
log.Log.Info("components.Kerberos.StopAgent(): Stopping machinery.")
case <-time.After(1 * time.Second):
log.Log.Info("components.Kerberos.StopAgent(): Stopping machinery timed out")
}
c.JSON(200, gin.H{
"stopped": true,
})
}
// RestartAgent godoc
// @Router /api/camera/restart [post]
// @ID camera-restart
// @Tags camera
// @Summary Restart the agent.
// @Description Restart the agent.
// @Success 200 {object} models.APIResponse
func RestartAgent(c *gin.Context, communication *models.Communication) {
log.Log.Info("components.Kerberos.RestartAgent(): sending signal to restart agent.")
select {
case communication.HandleBootstrap <- "restart":
log.Log.Info("components.Kerberos.RestartAgent(): Restarting machinery.")
case <-time.After(1 * time.Second):
log.Log.Info("components.Kerberos.RestartAgent(): Restarting machinery timed out")
}
c.JSON(200, gin.H{
"restarted": true,
})
}
// MakeRecording godoc
// @Router /api/camera/record [post]
// @ID camera-record
// @Tags camera
// @Summary Make a recording.
// @Description Make a recording.
// @Success 200 {object} models.APIResponse
func MakeRecording(c *gin.Context, communication *models.Communication) {
log.Log.Info("components.Kerberos.MakeRecording(): sending signal to start recording.")
dataToPass := models.MotionDataPartial{
Timestamp: time.Now().Unix(),
NumberOfChanges: 100000000, // hack set the number of changes to a high number to force recording
}
communication.HandleMotion <- dataToPass //Save data to the channel
c.JSON(200, gin.H{
"recording": true,
})
}
// GetSnapshotBase64 godoc
// @Router /api/camera/snapshot/base64 [get]
// @ID snapshot-base64
// @Tags camera
// @Summary Get a snapshot from the camera in base64.
// @Description Get a snapshot from the camera in base64.
// @Success 200
func GetSnapshotBase64(c *gin.Context, captureDevice *capture.Capture, configuration *models.Configuration, communication *models.Communication) {
// We'll try to get a snapshot from the camera.
base64Image := capture.Base64Image(captureDevice, communication, configuration)
if base64Image != "" {
communication.Image = base64Image
}
c.JSON(200, gin.H{
"base64": communication.Image,
})
}
// GetSnapshotJpeg godoc
// @Router /api/camera/snapshot/jpeg [get]
// @ID snapshot-jpeg
// @Tags camera
// @Summary Get a snapshot from the camera in jpeg format.
// @Description Get a snapshot from the camera in jpeg format.
// @Success 200
func GetSnapshotRaw(c *gin.Context, captureDevice *capture.Capture, configuration *models.Configuration, communication *models.Communication) {
// We'll try to get a snapshot from the camera.
image := capture.JpegImage(captureDevice, communication)
// encode image to jpeg
imageResized, _ := utils.ResizeImage(&image, uint(configuration.Config.Capture.IPCamera.BaseWidth), uint(configuration.Config.Capture.IPCamera.BaseHeight))
bytes, _ := utils.ImageToBytes(imageResized)
// Return image/jpeg
c.Data(200, "image/jpeg", bytes)
}
// GetConfig godoc
// @Router /api/config [get]
// @ID config
// @Tags config
// @Summary Get the current configuration.
// @Description Get the current configuration.
// @Success 200
func GetConfig(c *gin.Context, captureDevice *capture.Capture, configuration *models.Configuration, communication *models.Communication) {
// We'll try to get a snapshot from the camera.
base64Image := capture.Base64Image(captureDevice, communication, configuration)
if base64Image != "" {
communication.Image = base64Image
}
c.JSON(200, gin.H{
"config": configuration.Config,
"custom": configuration.CustomConfig,
"global": configuration.GlobalConfig,
"snapshot": communication.Image,
})
}
// UpdateConfig godoc
// @Router /api/config [post]
// @ID config
// @Tags config
// @Param config body models.Config true "Configuration"
// @Summary Update the current configuration.
// @Description Update the current configuration.
// @Success 200
func UpdateConfig(c *gin.Context, configDirectory string, configuration *models.Configuration, communication *models.Communication) {
var config models.Config
err := c.BindJSON(&config)
if err == nil {
err := configService.SaveConfig(configDirectory, config, configuration, communication)
if err == nil {
c.JSON(200, gin.H{
"data": "☄ Reconfiguring",
})
} else {
c.JSON(200, gin.H{
"data": "☄ Reconfiguring",
})
}
} else {
c.JSON(400, gin.H{
"data": "Something went wrong: " + err.Error(),
})
}
}

View File

@@ -2,26 +2,83 @@ package components
import (
"bufio"
"fmt"
"context"
"math/rand"
"os"
"time"
"github.com/kerberos-io/agent/machinery/src/capture"
"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/kerberos-io/joy4/av"
"github.com/pion/rtp"
log "github.com/sirupsen/logrus"
"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() {
if stream.Type().String() == "PCM_MULAW" {
pcmuCodec := stream.(av.AudioCodecData)
if pcmuCodec.IsBackChannel() {
communication.HasBackChannel = true
communication.HasBackChannel.Store(true)
return pcmuCodec
}
}
@@ -30,49 +87,123 @@ func GetBackChannelAudioCodec(streams []av.CodecData, communication *models.Comm
return nil
}
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)
}
func WriteAudioToBackchannel(ctx context.Context, communication *models.Communication, rtspClient capture.RTSPClient) {
writeAudioToBackchannel(ctx, ctx, communication.HandleAudio, rtspClient)
}
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")
}
func writeAudioToBackchannel(ctx context.Context, otelContext context.Context, audioChannel <-chan models.AudioDataPartial, rtspClient backchannelClient) {
log.Info("Audio.WriteAudioToBackchannel(): writing to backchannel audio codec")
length = (length + uint32(len(bufferUlaw))) % 65536
sequenceNumber = (sequenceNumber + 1) % 65535
time.Sleep(128 * time.Millisecond)
if err := rtspClient.StartBackChannel(ctx, otelContext); err != nil {
log.Error("Audio.WriteAudioToBackchannel(): error starting backchannel: " + err.Error())
if !reconnectBackchannel(ctx, otelContext, rtspClient) {
log.Info("Audio.WriteAudioToBackchannel(): stopped while reconnecting")
return
}
}
log.Log.Info("Audio.WriteAudioToBackchannel(): finished")
packetizer := newBackchannelPacketizer()
for {
select {
case <-ctx.Done():
log.Info("Audio.WriteAudioToBackchannel(): stopped")
return
case audio, ok := <-audioChannel:
if !ok {
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.Error("Audio.WriteAudioToBackchannel(): error writing packet to backchannel: " + err.Error())
if !reconnectBackchannel(ctx, otelContext, rtspClient) {
log.Info("Audio.WriteAudioToBackchannel(): stopped while reconnecting")
return
}
packetizer = newBackchannelPacketizer()
continue
}
if !waitForBackchannel(ctx, time.Duration(len(audio.Data))*time.Second/backchannelSampleRate) {
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.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.Info("Audio.WriteAudioToBackchannel(): reconnected backchannel")
return true
}
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) {
// Do the warmup!
file, err := os.Open("./audiofile.bye")
if err != nil {
fmt.Println("WriteFileToBackChannel: error opening audiofile.bye file")
log.WithError(err).WithFields(log.Fields{
"component": "backchannel",
"event": "audio_file_open_failed",
"path": "./audiofile.bye",
}).Error("Failed to open backchannel audio file")
return
}
defer file.Close()

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")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
package components
import (
"context"
"testing"
"time"
)
func TestWaitForRunRetryStopsOnCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
started := time.Now()
if waitForRunRetry(ctx) {
t.Fatal("waitForRunRetry() completed the retry delay after cancellation")
}
if elapsed := time.Since(started); elapsed > 100*time.Millisecond {
t.Fatalf("waitForRunRetry() took %s after cancellation", elapsed)
}
}
func TestStreamRestartWatchdogCoalescesStallsAndBacksOff(t *testing.T) {
now := time.Unix(1_000, 0)
watchdog := newStreamRestartWatchdog()
if _, restart := watchdog.Observe(now, 1, 1, true, false); restart {
t.Fatal("initial observation requested a restart")
}
for check := 1; check < streamWatchdogStallChecks; check++ {
now = now.Add(streamWatchdogInterval)
if _, restart := watchdog.Observe(now, 1, 1, true, false); restart {
t.Fatalf("check %d requested an early restart", check)
}
}
now = now.Add(streamWatchdogInterval)
reason, restart := watchdog.Observe(now, 1, 1, true, false)
if !restart || reason != "main and sub streams" {
t.Fatalf("Observe() = (%q, %t), want coalesced restart", reason, restart)
}
watchdog.MarkRestart(now)
if got := watchdog.Backoff(); got != 30*time.Second {
t.Fatalf("Backoff() = %s, want 30s", got)
}
for now = now.Add(streamWatchdogInterval); now.Before(watchdog.nextRestart); now = now.Add(streamWatchdogInterval) {
if _, restart := watchdog.Observe(now, 1, 1, true, false); restart {
t.Fatal("restart requested during cooldown")
}
}
}
func TestStreamRestartWatchdogResetsAfterHealthyMinute(t *testing.T) {
now := time.Unix(2_000, 0)
watchdog := newStreamRestartWatchdog()
watchdog.backoff = streamWatchdogMaxBackoff
watchdog.nextRestart = now.Add(streamWatchdogMaxBackoff)
watchdog.Observe(now, 1, 1, true, false)
for elapsed := streamWatchdogInterval; elapsed <= streamWatchdogHealthyReset+streamWatchdogInterval; elapsed += streamWatchdogInterval {
now = now.Add(streamWatchdogInterval)
watchdog.Observe(now, int64(elapsed), int64(elapsed), true, false)
}
if got := watchdog.Backoff(); got != streamWatchdogBaseBackoff {
t.Fatalf("Backoff() = %s, want %s", got, streamWatchdogBaseBackoff)
}
if !watchdog.nextRestart.IsZero() {
t.Fatalf("nextRestart = %s, want zero", watchdog.nextRestart)
}
}
func TestStreamRestartWatchdogPausesWhileConfiguring(t *testing.T) {
now := time.Unix(3_000, 0)
watchdog := newStreamRestartWatchdog()
watchdog.Observe(now, 1, 0, false, false)
for check := 0; check < streamWatchdogStallChecks+1; check++ {
now = now.Add(streamWatchdogInterval)
if _, restart := watchdog.Observe(now, 1, 0, false, true); restart {
t.Fatal("restart requested while configuring")
}
}
}

View File

@@ -8,34 +8,49 @@ import (
geo "github.com/kellydunn/golang-geo"
"github.com/kerberos-io/agent/machinery/src/capture"
"github.com/kerberos-io/agent/machinery/src/conditions"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/packets"
log "github.com/sirupsen/logrus"
)
func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Configuration, communication *models.Communication, mqttClient mqtt.Client, rtspClient capture.RTSPClient) {
log.Log.Debug("computervision.main.ProcessMotion(): start motion detection")
log.Debug("computervision.main.ProcessMotion(): start motion detection")
config := configuration.Config
loc, _ := time.LoadLocation(config.Timezone)
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.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.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.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
@@ -143,15 +185,19 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
// We'll validate those conditions and if not valid we'll not do anything.
detectMotion, err := conditions.Validate(loc, configuration)
if !detectMotion && err != nil {
log.Log.Debug("computervision.main.ProcessMotion(): " + err.Error() + ".")
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,
},
},
}
@@ -171,7 +235,7 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey, 2, false, payload)
} else {
log.Log.Info("computervision.main.ProcessMotion(): failed to package MQTT message: " + err.Error())
log.Info("computervision.main.ProcessMotion(): failed to package MQTT message: " + err.Error())
}
} else {
mqttClient.Publish("kerberos/agent/"+deviceKey, 2, false, "motion")
@@ -179,13 +243,20 @@ 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,
Rectangle: motionRectangle,
}
communication.HandleMotion <- dataToPass //Save data to the channel
if !communication.TrySendMotion(dataToPass) {
log.Warn("computervision.main.ProcessMotion(): motion channel unavailable or full, dropping recording trigger")
}
}
}
}
@@ -202,20 +273,76 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
}
}
log.Log.Debug("computervision.main.ProcessMotion(): stop the motion detection.")
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 +351,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})
}
}
@@ -249,14 +376,141 @@ func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.
endY = pixel[1]
}
}
log.Log.Debugf("Rectangle of changes detected: startX: %d, startY: %d, endX: %d, endY: %d", startX, startY, endX, endY)
log.WithFields(log.Fields{
"component": "computer_vision",
"end_x": endX,
"end_y": endY,
"event": "motion_bounds_detected",
"start_x": startX,
"start_y": startY,
}).Debug("Motion bounds detected")
motionRectangle = models.MotionRectangle{
X: startX,
Y: startY,
Width: endX - startX,
Height: endY - startY,
}
log.Log.Debugf("Motion rectangle: %+v", motionRectangle)
log.WithFields(log.Fields{
"component": "computer_vision",
"event": "motion_rectangle_created",
"height": motionRectangle.Height,
"width": motionRectangle.Width,
"x": motionRectangle.X,
"y": motionRectangle.Y,
}).Debug("Motion rectangle created")
}
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

@@ -3,8 +3,8 @@ package conditions
import (
"time"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
log "github.com/sirupsen/logrus"
)
func IsWithinTimeInterval(loc *time.Location, configuration *models.Configuration) (enabled bool) {
@@ -27,9 +27,9 @@ func IsWithinTimeInterval(loc *time.Location, configuration *models.Configuratio
currentTimeInSeconds := hour*60*60 + minute*60 + second
if (currentTimeInSeconds >= start1 && currentTimeInSeconds <= end1) ||
(currentTimeInSeconds >= start2 && currentTimeInSeconds <= end2) {
log.Log.Debug("conditions.timewindow.IsWithinTimeInterval(): time interval valid, enabling recording.")
log.Debug("conditions.timewindow.IsWithinTimeInterval(): time interval valid, enabling recording.")
} else {
log.Log.Info("conditions.timewindow.IsWithinTimeInterval(): time interval not valid, disabling recording.")
log.Info("conditions.timewindow.IsWithinTimeInterval(): time interval not valid, disabling recording.")
enabled = false
}
}

View File

@@ -3,13 +3,26 @@ package conditions
import (
"bytes"
"crypto/tls"
"fmt"
"encoding/json"
"io"
"net/http"
"os"
"time"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
log "github.com/sirupsen/logrus"
)
const conditionHTTPTimeout = 10 * time.Second
var (
conditionHTTPClient = &http.Client{Timeout: conditionHTTPTimeout}
conditionInsecureHTTPClient = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec G402 -- explicit operator opt-in
},
Timeout: conditionHTTPTimeout,
}
)
func IsValidUriResponse(configuration *models.Configuration) (enabled bool) {
@@ -17,41 +30,66 @@ func IsValidUriResponse(configuration *models.Configuration) (enabled bool) {
conditionURI := config.ConditionURI
enabled = true
if conditionURI != "" {
// We will send a POST request to the conditionURI, and expect a 200 response.
// In the payload we will send some information, so the other end can decide
// if it should enable or disable recording.
var client *http.Client
client := conditionHTTPClient
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client = &http.Client{Transport: tr}
} else {
client = &http.Client{}
client = conditionInsecureHTTPClient
}
var object = fmt.Sprintf(`{
"camera_id" : "%s",
"camera_name" : "%s",
"site_id" : "%s",
"hub_key" : "%s",
"timestamp" : "%s",
}`, config.Key, config.FriendlyName, config.HubSite, config.HubKey, time.Now().Format("2006-01-02 15:04:05"))
payload := struct {
CameraID string `json:"camera_id"`
CameraName string `json:"camera_name"`
SiteID string `json:"site_id"`
HubKey string `json:"hub_key"`
Timestamp string `json:"timestamp"`
}{
CameraID: config.Key,
CameraName: config.FriendlyName,
SiteID: config.HubSite,
HubKey: config.HubKey,
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
}
jsonBody, err := json.Marshal(payload)
if err != nil {
log.WithError(err).WithFields(log.Fields{
"component": "conditions/uri",
"event": "request_encoding_failed",
}).Error("Failed to encode condition request")
return false
}
var jsonStr = []byte(object)
buffy := bytes.NewBuffer(jsonStr)
req, _ := http.NewRequest("POST", conditionURI, buffy)
req, err := http.NewRequest(http.MethodPost, conditionURI, bytes.NewReader(jsonBody))
if err != nil {
log.WithError(err).WithFields(log.Fields{
"component": "conditions/uri",
"event": "request_creation_failed",
}).Error("Failed to create condition request")
return false
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if resp != nil {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
if err == nil && resp.StatusCode == 200 {
log.Log.Info("conditions.uri.IsValidUriResponse(): response 200, enabling recording.")
if err == nil && resp != nil && resp.StatusCode == http.StatusOK {
log.WithFields(log.Fields{
"component": "conditions/uri",
"event": "recording_enabled",
"status_code": resp.StatusCode,
}).Info("Condition request enabled recording")
} else {
log.Log.Info("conditions.uri.IsValidUriResponse(): response not 200, disabling recording.")
if err != nil {
log.WithError(err).WithFields(log.Fields{
"component": "conditions/uri",
"event": "request_failed",
}).Error("Condition request failed")
} else {
log.WithFields(log.Fields{
"component": "conditions/uri",
"event": "recording_disabled",
"status_code": resp.StatusCode,
}).Info("Condition request disabled recording")
}
enabled = false
}
}

View File

@@ -0,0 +1,63 @@
package conditions
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/kerberos-io/agent/machinery/src/models"
)
func TestIsValidUriResponseReusesClientAndEncodesPayload(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
requests.Add(1)
if request.Method != http.MethodPost {
t.Errorf("method = %s, want POST", request.Method)
}
if got := request.Header.Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want application/json", got)
}
var payload struct {
CameraID string `json:"camera_id"`
CameraName string `json:"camera_name"`
}
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
t.Errorf("decode request: %v", err)
}
if payload.CameraID != "camera-1" || payload.CameraName != `Front "Door"` {
t.Errorf("payload = %+v", payload)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
configuration := &models.Configuration{}
configuration.Config.ConditionURI = server.URL
configuration.Config.Key = "camera-1"
configuration.Config.FriendlyName = `Front "Door"`
for i := 0; i < 2; i++ {
if !IsValidUriResponse(configuration) {
t.Fatal("IsValidUriResponse() = false, want true")
}
}
if got := requests.Load(); got != 2 {
t.Fatalf("requests = %d, want 2", got)
}
}
func TestIsValidUriResponseRejectsNonOK(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
}))
defer server.Close()
configuration := &models.Configuration{}
configuration.Config.ConditionURI = server.URL
if IsValidUriResponse(configuration) {
t.Fatal("IsValidUriResponse() = true, want false")
}
}

View File

@@ -13,11 +13,36 @@ import (
"github.com/InVisionApp/conjungo"
"github.com/kerberos-io/agent/machinery/src/database"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
log "github.com/sirupsen/logrus"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
)
const factoryConfigRetryDelay = 5 * time.Second
func newFactoryConfigReadContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), database.TIMEOUT)
}
func isRetryableFactoryConfigReadError(err error) bool {
return errors.Is(err, context.DeadlineExceeded) || mongo.IsTimeout(err) || mongo.IsNetworkError(err)
}
func readFactoryConfig(collection *mongo.Collection, filter bson.M, destination *models.Config) error {
for {
ctx, cancel := newFactoryConfigReadContext()
err := collection.FindOne(ctx, filter).Decode(destination)
cancel()
if !isRetryableFactoryConfigReadError(err) {
return err
}
log.Warn("Factory configuration read timed out or lost its database connection; retrying.")
time.Sleep(factoryConfigRetryDelay)
}
}
// ReadUserConfig Reads the user configuration of the Kerberos Open Source instance.
// This will return a models.User struct including the username, password,
// selected language, and if the installation was completed or not.
@@ -25,14 +50,14 @@ func ReadUserConfig(configDirectory string) (userConfig models.User) {
for {
jsonFile, err := os.Open(configDirectory + "/data/config/user.json")
if err != nil {
log.Log.Error("Config file is not found " + configDirectory + "/data/config/user.json, trying again in 5s: " + err.Error())
log.Error("Config file is not found " + configDirectory + "/data/config/user.json, trying again in 5s: " + err.Error())
time.Sleep(5 * time.Second)
} else {
log.Log.Info("Successfully Opened user.json")
log.Info("Successfully Opened user.json")
byteValue, _ := ioutil.ReadAll(jsonFile)
err = json.Unmarshal(byteValue, &userConfig)
if err != nil {
log.Log.Error("JSON file not valid: " + err.Error())
log.Error("JSON file not valid: " + err.Error())
} else {
jsonFile.Close()
break
@@ -63,21 +88,15 @@ func OpenConfig(configDirectory string, configuration *models.Configuration) {
collection := db.Collection("configuration")
var globalConfig models.Config
res := collection.FindOne(context.Background(), bson.M{
err := readFactoryConfig(collection, bson.M{
"type": "global",
})
if res.Err() != nil {
log.Log.Error("Could not find global configuration, using default configuration.")
panic("Could not find global configuration, using default configuration.")
}
err := res.Decode(&globalConfig)
}, &globalConfig)
if err != nil {
log.Log.Error("Could not find global configuration, using default configuration.")
log.Error("Could not find global configuration, using default configuration.")
panic("Could not find global configuration, using default configuration.")
}
if globalConfig.Type != "global" {
log.Log.Error("Could not find global configuration, might missed the mongodb connection.")
log.Error("Could not find global configuration, might missed the mongodb connection.")
panic("Could not find global configuration, might missed the mongodb connection.")
}
@@ -85,21 +104,16 @@ func OpenConfig(configDirectory string, configuration *models.Configuration) {
var customConfig models.Config
deploymentName := os.Getenv("DEPLOYMENT_NAME")
res = collection.FindOne(context.Background(), bson.M{
err = readFactoryConfig(collection, bson.M{
"type": "config",
"name": deploymentName,
})
if res.Err() != nil {
log.Log.Error("Could not find configuration for " + deploymentName + ", using global configuration.")
}
err = res.Decode(&customConfig)
}, &customConfig)
if err != nil {
log.Log.Error("Could not find configuration for " + deploymentName + ", using global configuration.")
}
if customConfig.Type != "config" {
log.Log.Error("Could not find custom configuration, might missed the mongodb connection.")
panic("Could not find custom configuration, might missed the mongodb connection.")
log.Error("Could not find configuration for " + deploymentName + ", using global configuration.")
customConfig = models.Config{}
} else if customConfig.Type != "config" {
log.Error("Custom configuration has an invalid type, using global configuration.")
customConfig = models.Config{}
}
configuration.CustomConfig = customConfig
@@ -166,21 +180,29 @@ func OpenConfig(configDirectory string, configuration *models.Configuration) {
// Open device config
for {
jsonFile, err := os.Open(configDirectory + "/data/config/config.json")
configPath := configDirectory + "/data/config/config.json"
jsonFile, err := os.Open(configPath)
if err != nil {
log.Log.Error("Config file is not found " + configDirectory + "/data/config/config.json" + ", trying again in 5s.")
log.WithError(err).WithFields(log.Fields{
"component": "config",
"event": "configuration_open_failed",
"retry_delay_ms": 5000,
}).Warn("Configuration file unavailable; retrying")
time.Sleep(5 * time.Second)
} else {
log.Log.Info("Successfully Opened config.json from " + configuration.Name)
log.WithFields(log.Fields{
"component": "config",
"event": "configuration_opened",
}).Info("Configuration file opened")
byteValue, _ := ioutil.ReadAll(jsonFile)
err = json.Unmarshal(byteValue, &configuration.Config)
jsonFile.Close()
if err != nil {
log.Log.Error("JSON file not valid: " + err.Error())
log.Error("JSON file not valid: " + err.Error())
} else {
err = json.Unmarshal(byteValue, &configuration.CustomConfig)
if err != nil {
log.Log.Error("JSON file not valid: " + err.Error())
log.Error("JSON file not valid: " + err.Error())
} else {
break
}
@@ -193,8 +215,104 @@ func OpenConfig(configDirectory string, configuration *models.Configuration) {
return
}
// This function will override the configuration with environment variables.
// OverrideWithEnvironmentVariables builds the effective configuration from the
// environment variables.
//
// In ConfigMap/standalone mode (DEPLOYMENT empty or "agent") the global
// configuration is delivered as GLOBAL_AGENT_* environment variables and the
// per-agent configuration as AGENT_* environment variables. We parse them into
// the separate global and custom configurations and build the effective
// configuration as "global overridden by custom", mirroring the MongoDB-backed
// factory behaviour. This keeps the global and per-agent (custom) configuration
// separated so the factory edit page can distinguish inherited global settings
// from per-agent overrides.
func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
if os.Getenv("DEPLOYMENT") == "" || os.Getenv("DEPLOYMENT") == "agent" {
initConfigPointers(&configuration.Config)
// Parse the global configuration from the GLOBAL_AGENT_* variables.
globalWrap := &models.Configuration{Config: configuration.GlobalConfig}
initConfigPointers(&globalWrap.Config)
applyAgentEnvVars(globalWrap, "GLOBAL_", false)
configuration.GlobalConfig = globalWrap.Config
// Parse the per-agent (custom) configuration from the AGENT_* variables.
// In ConfigMap mode the per-agent overrides are delivered exclusively
// through AGENT_* environment variables, so we must start from an empty
// configuration rather than the bundled config.json that OpenConfig loaded
// into CustomConfig. Otherwise defaults from that file (e.g. cloud="s3")
// would leak into the custom config and be mistaken for explicit per-agent
// overrides, hiding inherited global settings (the factory edit page would
// show the local default instead of the inherited global persistence).
customBase := configuration.CustomConfig
if isConfigMapMode() {
customBase = models.Config{}
}
customWrap := &models.Configuration{Config: customBase}
initConfigPointers(&customWrap.Config)
applyAgentEnvVars(customWrap, "", false)
configuration.CustomConfig = customWrap.Config
// Build the effective configuration: global base, then per-agent
// overrides on top. Defaults (e.g. signing) are applied on the last
// pass only.
applyAgentEnvVars(configuration, "GLOBAL_", false)
applyAgentEnvVars(configuration, "", true)
} else {
// Factory/MongoDB mode: the global and custom configurations are already
// loaded and merged from MongoDB; we only override the effective
// configuration with any AGENT_* environment variables.
applyAgentEnvVars(configuration, "", true)
}
}
// isConfigMapMode reports whether the agent is running in ConfigMap mode, i.e.
// whether a global configuration layer is delivered separately through
// GLOBAL_AGENT_* environment variables. In that mode the per-agent (custom)
// configuration must be built solely from the AGENT_* overrides and must not be
// seeded with the bundled config.json defaults, so that inherited global
// settings remain distinguishable from explicit per-agent overrides.
func isConfigMapMode() bool {
for _, env := range os.Environ() {
if strings.HasPrefix(env, "GLOBAL_AGENT_") {
return true
}
}
return false
}
// initConfigPointers ensures all pointer sub-structs are non-nil so that the
// environment-variable parsing can assign into them without dereferencing a nil
// pointer.
func initConfigPointers(config *models.Config) {
if config.KStorage == nil {
config.KStorage = &models.KStorage{}
}
if config.KStorageSecondary == nil {
config.KStorageSecondary = &models.KStorage{}
}
if config.S3 == nil {
config.S3 = &models.S3{}
}
if config.Encryption == nil {
config.Encryption = &models.Encryption{}
}
if config.Signing == nil {
config.Signing = &models.Signing{}
}
if config.Dropbox == nil {
config.Dropbox = &models.Dropbox{}
}
if config.Region == nil {
config.Region = &models.Region{}
}
}
// applyAgentEnvVars applies the AGENT_* environment variables (optionally
// carrying the given prefix, e.g. "GLOBAL_") onto configuration.Config. When
// applyDefaults is true, defaults (such as the signing key) are applied after
// parsing; this should only be done for the effective configuration.
func applyAgentEnvVars(configuration *models.Configuration, prefix string, applyDefaults bool) {
environmentVariables := os.Environ()
// Initialize the configuration for some new fields.
@@ -203,9 +321,10 @@ func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
}
for _, env := range environmentVariables {
if strings.Contains(env, "AGENT_") {
key := strings.Split(env, "=")[0]
value := os.Getenv(key)
fullKey := strings.SplitN(env, "=", 2)[0]
if strings.HasPrefix(fullKey, prefix+"AGENT_") && !(prefix == "" && strings.HasPrefix(fullKey, "GLOBAL_AGENT_")) {
key := strings.TrimPrefix(fullKey, prefix)
value := os.Getenv(fullKey)
switch key {
/* General configuration */
@@ -230,6 +349,12 @@ func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
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":
@@ -274,6 +399,9 @@ func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
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
@@ -298,7 +426,7 @@ func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
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":
@@ -545,13 +673,30 @@ func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
}
}
// Signing is a new feature, so if empty we set default values.
if configuration.Config.Signing == nil || configuration.Config.Signing.PrivateKey == "" {
// 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.
if applyDefaults && (configuration.Config.Signing == nil || configuration.Config.Signing.PrivateKey == "") {
configuration.Config.Signing = &models.Signing{
Enabled: "true",
PrivateKey: "-----BEGIN PRIVATE KEY-----\nMIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQDoSxjyw08lRxF4Yoqmcaewjq3XjB55dMy4tlN5MGLdr8aAPuNR9Mwh3jlh1bDpwQXNgZkHDV/q9bpdPGGi7SQo2xw+rDuo5Y1f3wdzz+iuCTPbzoGFalE+1PZlU5TEtUtlbt7MRc4pxTaLP3u0P3EtW3KnzcUarcJWZJYxzv7gqVNCA/47BN+1ptqjwz3LAlah5yaftEvVjkaANOsafUswbS4VT44XfSlbKgebORCKDuNgQiyhuV5gU+J0TOaqRWwwMAWV0UoScyJLfhHRBCrUwrCUTwqH9jfkB7pgRFsYoZJd4MKMeHJjFSum+QXCBqInSnwu8c2kJChiLMWqJ+mhpTdfUAmSkeUSStfbbcavIPbDABvMgzOcmYMIVXXe57twU0xdu3AqWLtc9kw1BkUgZblM9pSSpYrIDheEyMs2/hiLgXsIaM0nVQtqwrA7rbeEGuPblzA6hvHgwN9K6HaBqdlGSlpYZ0v3SWIMwmxRB+kIojlyuggm8Qa4mqL97GFDGl6gOBGlNUFTBUVEa3EaJ7NJpGobRGsh/9dXzcW4aYmT9WxlzTlIKksI1ro6KdRfuVWfEs4AnG8bVEJmofK8EUrueB9IdXlcJZB49xolnOZPFohtMe/0U7evQOQP3sZnX+KotCsE7OXJvL09oF58JKoqmK9lPp0+pFBU4g6NjQIDAQABAoICAA+RSWph1t+q5R3nxUxFTYMrhv5IjQe2mDxJpF3B409zolC9OHxgGUisobTY3pBqs0DtKbxUeH2A0ehUH/axEosWHcz3cmIbgxHE9kdlJ9B3Lmss6j/uw+PWutu1sgm5phaIFIvuNNRWhPB6yXUwU4sLRat1+Z9vTmIQiKdtLIrtJz/n2VDvrJxn1N+yAsE20fnrksFKyZuxVsJaZPiX/t5Yv1/z0LjFjVoL7GUA5/Si7csN4ftqEhUrkNr2BvcZlTyffrF4lZCXrtl76RNUaxhqIu3H0gFbV2UfBpuckkfAhNRpXJ4iFSxm4nQbk4ojV8+l21RFOBeDN2Z7Ocu6auP5MnzpopR66vmDCmPoid498VGgDzFQEVkOar8WAa4v9h85QgLKrth6FunmaWJUT6OggQD3yY58GSwp5+ARMETMBP2x6Eld+PGgqoJvPT1+l/e9gOw7/SJ+Wz6hRXZAm/eiXMppHtB7sfea5rscNanPjJkK9NvPM0MX9cq/iA6QjXuETkMbubjo+Cxk3ydZiIQmWQDAx/OgxTyHbeRCVhLPcAphX0clykCuHZpI9Mvvj643/LoE0mjTByWJXf/WuGJA8ElHkjSdokVJ7jumz8OZZHfq0+V7+la2opsObeQANHW5MLWrnHlRVzTGV0IRZDXh7h1ptUJ4ubdvw/GJ2NeTAoIBAQD0lXXdjYKWC4uZ4YlgydP8b1CGda9cBV5RcPt7q9Ya1R2E4ieYyohmzltopvdaOXdsTZzhtdzOzKF+2qNcbBKhBTleYZ8GN5RKbo7HwXWpzfCTjseKHOD/QPwvBKXzLVWNtXn1NrLR79Rv0wbkYF6DtoqpEPf5kMs4bx79yW+mz8FUgdEeMjKphx6Jd5RYlTUxS64K6bnK7gjHNCF2cwdxsh4B6EB649GKeNz4JXi+oQBmOcX5ncXnkJrbju+IjtCkQ40HINVNdX7XeEaaw6KGaImVjw61toPUuDaioYUojufayoyXaUJnDbHQ2tNekEpq5iwnenZCbUKWmSeRe7dLAoIBAQDzIscYujsrmPxiTj2prhG0v36NRNP99mShnnJGowiIs+UBS0EMdOmBFa2sC9uFs/VnreQNYPDJdfr7O5VK9kfbH/PSiiKJ+wVebfdAlWkJYH27JN2Kl2l/OsvRVelNvF3BWIYF46qzGxIM0axaz3T2ZAJ9SrUgeAYhak6uyM4fbexEWXxDgPGu6C0jB6IAzmHJnnh+j5+4ZXqjVyUxBYtUsWXF/TXomVcT9jxj7aUmS2/Us0XTVOVNpALqqYcekrzsX/wX0OEi5HkivYXHcNaDHx3NuUf6KdYof5DwPUM76qe+5/kWlSIHP3M6rIFK3pYFUnkHn2E8jNWcO97Aio+HAoIBAA+bcff/TbPxbKkXIUMR3fsfx02tONFwbkJYKVQM9Q6lRsrx+4Dee7HDvUWCUgpp3FsG4NnuVvbDTBLiNMZzBwVLZgvFwvYMmePeBjJs/+sj/xQLamQ/z4O6S91cOJK589mlGPEy2lpXKYExQCFWnPFetp5vPMOqH62sOZgMQJmubDHOTt/UaDM1Mhenj8nPS6OnpqV/oKF4awr7Ip+CW5k/unZ4sZSl8PsbF06mZXwUngfn6+Av1y8dpSQZjONz6ZBx1w/7YmEc/EkXnbnGfhqBlTX7+P5TdTofvyzFjc+2vsjRYANRbjFRSGWBcTd5kaYcpfim8eDvQ+6EO2gnMt0CggEAH2ln1Y8B5AEQ4lZ/avOdP//ZhsDUrqPtnl/NHckkahzrwj4JumVEYbP+SxMBGoYEd4+kvgG/OhfvBBRPlm65G9tF8fZ8vdzbdba5UfO7rUV1GP+LS8OCErjy6imySaPDbR5Vul8Oh7NAor1YCidxUf/bvnovanF3QUvtvHEfCDp4YuA4yLPZBaLjaforePUw9w5tPNSravRZYs74dBvmQ1vj7S9ojpN5B5AxfyuNwaPPX+iFZec69MvywISEe3Ozysof1Kfc3lgsOkvIA9tVK32SqSh93xkWnQbWH+OaUxxe7bAko0FDMzKEXZk53wVg1nEwR8bUljEPy+6EOdXs8wKCAQEAsEOWYMY5m7HkeG2XTTvX7ECmmdGl/c4ZDVwzB4IPxqUG7XfLmtsON8YoKOEUpJoc4ANafLXzmU+esUGbH4Ph22IWgP9jzws7jxaN/Zoku64qrSjgEZFTRIpKyhFk/ImWbS9laBW4l+m0tqTTRqoE0QEJf/2uv/04q65zrA70X9z2+KTrAtqOiRQPWl/IxRe9U4OEeGL+oD+YlXKCDsnJ3rwUIOZgJx0HWZg7K35DKwqs1nVi56FBdljiTRKAjVLRedjgDCSfGS1yUZ3krHzpaPt1qgnT3rdtYcIdbYDr66V2/gEEaz6XMGHuTk/ewjzUJxq9UTVeXOCbkRPXgVJg1w==\n-----END PRIVATE KEY-----",
}
}
// When the agent is configured through environment variables the global and
// custom configurations were already parsed separately (see
// OverrideWithEnvironmentVariables), so there is no need to mirror the
// effective configuration into CustomConfig anymore.
}
func SaveConfig(configDirectory string, config models.Config, configuration *models.Configuration, communication *models.Communication) error {
@@ -564,12 +709,12 @@ func SaveConfig(configDirectory string, config models.Config, configuration *mod
return err
}
if communication.CameraConnected {
if communication.CameraConnected.Load() {
select {
case communication.HandleBootstrap <- "restart":
log.Log.Info("config.main.SaveConfig(): update config, restart agent.")
log.Info("config.main.SaveConfig(): update config, restart agent.")
case <-time.After(1 * time.Second):
log.Log.Info("config.main.SaveConfig(): update config, restart agent.")
log.Info("config.main.SaveConfig(): update config, restart agent.")
}
}

View File

@@ -0,0 +1,78 @@
package config
import (
"context"
"errors"
"testing"
"time"
"github.com/kerberos-io/agent/machinery/src/database"
"github.com/kerberos-io/agent/machinery/src/models"
"go.mongodb.org/mongo-driver/mongo"
)
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
}
func TestNewFactoryConfigReadContextUsesDatabaseTimeout(t *testing.T) {
ctx, cancel := newFactoryConfigReadContext()
defer cancel()
deadline, ok := ctx.Deadline()
if !ok {
t.Fatal("expected read context deadline")
}
remaining := time.Until(deadline)
if remaining <= 0 {
t.Fatalf("deadline already expired: %v", remaining)
}
if remaining > database.TIMEOUT {
t.Fatalf("remaining deadline = %v, want <= %v", remaining, database.TIMEOUT)
}
if remaining < database.TIMEOUT-time.Second {
t.Fatalf("remaining deadline = %v, want close to %v", remaining, database.TIMEOUT)
}
}
func TestFactoryConfigRetryableErrors(t *testing.T) {
if !isRetryableFactoryConfigReadError(context.DeadlineExceeded) {
t.Fatal("context deadline should be retryable")
}
if isRetryableFactoryConfigReadError(mongo.ErrNoDocuments) {
t.Fatal("missing configuration should not retry")
}
if isRetryableFactoryConfigReadError(errors.New("invalid BSON")) {
t.Fatal("decode errors should not retry")
}
}

View File

@@ -7,6 +7,7 @@ import (
"sync"
"time"
log "github.com/sirupsen/logrus"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
@@ -44,18 +45,34 @@ func New() *DB {
// We can also apply the complete URI
// e.g. "mongodb+srv://<username>:<password>@kerberos-hub.shhng.mongodb.net/?retryWrites=true&w=majority&appName=kerberos-hub"
if mongodbURI != "" {
log.WithFields(log.Fields{
"component": "database",
"database": DatabaseName,
"event": "client_configuring",
"uri_configured": true,
}).Debug("Configuring MongoDB client")
serverAPI := options.ServerAPI(options.ServerAPIVersion1)
opts := options.Client().ApplyURI(mongodbURI).SetServerAPIOptions(serverAPI)
// Create a new client and connect to the server
client, err := mongo.Connect(ctx, opts)
if err != nil {
fmt.Printf("Error setting up mongodb connection: %+v\n", err)
os.Exit(1)
log.WithError(err).WithFields(log.Fields{
"component": "database",
"database": DatabaseName,
"event": "client_configuration_failed",
}).Fatal("Failed to configure MongoDB client")
}
_instance.Client = client
} else {
log.WithFields(log.Fields{
"component": "database",
"database": DatabaseName,
"event": "client_configuring",
"replicaset_configured": replicaset != "",
"uri_configured": false,
}).Debug("Configuring MongoDB client")
// New MongoDB driver
mongodbURI := fmt.Sprintf("mongodb://%s:%s@%s", username, password, host)
@@ -69,8 +86,11 @@ func New() *DB {
Password: password,
}))
if err != nil {
fmt.Printf("Error setting up mongodb connection: %+v\n", err)
os.Exit(1)
log.WithError(err).WithFields(log.Fields{
"component": "database",
"database": DatabaseName,
"event": "client_configuration_failed",
}).Fatal("Failed to configure MongoDB client")
}
_instance.Client = client
}

View File

@@ -0,0 +1,336 @@
package lifecycle
import (
"context"
"errors"
"fmt"
"runtime/debug"
"sort"
"sync"
"time"
)
var (
ErrSupervisorSealed = errors.New("supervisor is sealed")
ErrSupervisorStopped = errors.New("supervisor is shutting down")
ErrTaskExists = errors.New("task already exists")
)
type TaskFunc func(context.Context) error
type TaskPolicy struct {
Required bool
LongRunning bool
}
type TaskStatus string
const (
TaskRunning TaskStatus = "running"
TaskSucceeded TaskStatus = "succeeded"
TaskFailed TaskStatus = "failed"
TaskPanicked TaskStatus = "panicked"
)
type TaskSnapshot struct {
Name string
Policy TaskPolicy
Status TaskStatus
StartedAt time.Time
EndedAt time.Time
Error string
Panic string
Stack []byte
}
type Failure struct {
Task string
Cause error
Panic string
Stack []byte
}
func (f Failure) Error() string {
if f.Cause == nil {
return fmt.Sprintf("task %q failed", f.Task)
}
return f.Cause.Error()
}
type ShutdownReport struct {
Complete bool
Cause error
Tasks []TaskSnapshot
Running []TaskSnapshot
}
type supervisorPhase uint8
const (
phaseStarting supervisorPhase = iota
phaseRunning
phaseDraining
)
type taskState struct {
TaskSnapshot
}
type Supervisor struct {
ctx context.Context
cancel context.CancelCauseFunc
mu sync.Mutex
phase supervisorPhase
sealed bool
active int
tasks map[string]*taskState
allDone chan struct{}
doneOnce sync.Once
start chan struct{}
startOnce sync.Once
failures chan Failure
failureOnce sync.Once
stopParentCancel func() bool
}
func NewSupervisor(parent context.Context) *Supervisor {
if parent == nil {
parent = context.Background()
}
ctx, cancel := context.WithCancelCause(parent)
supervisor := &Supervisor{
ctx: ctx,
cancel: cancel,
phase: phaseStarting,
tasks: make(map[string]*taskState),
allDone: make(chan struct{}),
start: make(chan struct{}),
failures: make(chan Failure, 1),
}
stopParentCancel := context.AfterFunc(parent, func() {
supervisor.BeginShutdown(context.Cause(parent))
})
supervisor.mu.Lock()
if supervisor.sealed && supervisor.active == 0 {
supervisor.mu.Unlock()
stopParentCancel()
} else {
supervisor.stopParentCancel = stopParentCancel
supervisor.mu.Unlock()
}
return supervisor
}
func (s *Supervisor) Context() context.Context {
return s.ctx
}
func (s *Supervisor) Failures() <-chan Failure {
return s.failures
}
func (s *Supervisor) Go(name string, policy TaskPolicy, task TaskFunc) error {
if name == "" {
return errors.New("task name is required")
}
if task == nil {
return errors.New("task function is required")
}
s.mu.Lock()
if s.sealed {
s.mu.Unlock()
return fmt.Errorf("%w: %s", ErrSupervisorSealed, name)
}
if s.ctx.Err() != nil {
s.mu.Unlock()
return fmt.Errorf("%w: %s", ErrSupervisorStopped, name)
}
if _, exists := s.tasks[name]; exists {
s.mu.Unlock()
return fmt.Errorf("%w: %s", ErrTaskExists, name)
}
state := &taskState{TaskSnapshot: TaskSnapshot{
Name: name,
Policy: policy,
Status: TaskRunning,
StartedAt: time.Now(),
}}
s.tasks[name] = state
s.active++
s.mu.Unlock()
go s.run(state, task)
return nil
}
func (s *Supervisor) run(state *taskState, task TaskFunc) {
var taskErr error
panicked := true
var panicValue string
var panicStack []byte
defer func() {
recovered := recover()
if panicked {
panicValue = fmt.Sprint(recovered)
panicStack = debug.Stack()
taskErr = fmt.Errorf("task %q panicked: %s", state.Name, panicValue)
}
s.finish(state, taskErr, panicked, panicValue, panicStack)
}()
select {
case <-s.start:
case <-s.ctx.Done():
panicked = false
return
}
taskErr = task(s.ctx)
panicked = false
}
func (s *Supervisor) finish(
state *taskState,
taskErr error,
panicked bool,
panicValue string,
panicStack []byte,
) {
var failure *Failure
s.mu.Lock()
state.EndedAt = time.Now()
state.Error = ""
state.Panic = panicValue
state.Stack = append([]byte(nil), panicStack...)
switch {
case panicked:
state.Status = TaskPanicked
state.Error = taskErr.Error()
case taskErr != nil:
state.Status = TaskFailed
state.Error = taskErr.Error()
default:
state.Status = TaskSucceeded
}
shuttingDown := s.phase == phaseDraining || s.ctx.Err() != nil
unexpected := panicked || (!shuttingDown &&
(state.Policy.Required && (taskErr != nil || state.Policy.LongRunning)))
if unexpected {
cause := taskErr
if cause == nil {
cause = fmt.Errorf("required long-running task %q exited", state.Name)
}
failure = &Failure{
Task: state.Name,
Cause: cause,
Panic: panicValue,
Stack: append([]byte(nil), panicStack...),
}
s.phase = phaseDraining
s.sealed = true
s.cancel(failure.Cause)
s.failureOnce.Do(func() {
s.failures <- *failure
})
}
s.active--
s.closeDoneIfReadyLocked()
s.mu.Unlock()
}
func (s *Supervisor) Seal() {
s.mu.Lock()
s.sealed = true
if s.phase == phaseStarting {
s.phase = phaseRunning
}
s.startOnce.Do(func() {
close(s.start)
})
s.closeDoneIfReadyLocked()
s.mu.Unlock()
}
func (s *Supervisor) BeginShutdown(cause error) {
if cause == nil {
cause = context.Canceled
}
s.mu.Lock()
s.phase = phaseDraining
s.sealed = true
s.cancel(cause)
s.closeDoneIfReadyLocked()
s.mu.Unlock()
}
func (s *Supervisor) Wait(ctx context.Context) ShutdownReport {
if ctx == nil {
ctx = context.Background()
}
select {
case <-s.allDone:
case <-ctx.Done():
}
return s.report()
}
func (s *Supervisor) Snapshot() []TaskSnapshot {
return s.report().Tasks
}
func (s *Supervisor) closeDoneIfReadyLocked() {
if !s.sealed || s.active != 0 {
return
}
if s.stopParentCancel != nil {
s.stopParentCancel()
s.stopParentCancel = nil
}
s.doneOnce.Do(func() {
close(s.allDone)
})
}
func (s *Supervisor) report() ShutdownReport {
s.mu.Lock()
defer s.mu.Unlock()
tasks := make([]TaskSnapshot, 0, len(s.tasks))
running := make([]TaskSnapshot, 0)
for _, state := range s.tasks {
snapshot := state.TaskSnapshot
snapshot.Stack = append([]byte(nil), state.Stack...)
tasks = append(tasks, snapshot)
if snapshot.Status == TaskRunning {
running = append(running, snapshot)
}
}
sort.Slice(tasks, func(i, j int) bool {
return tasks[i].Name < tasks[j].Name
})
sort.Slice(running, func(i, j int) bool {
return running[i].Name < running[j].Name
})
return ShutdownReport{
Complete: s.sealed && s.active == 0,
Cause: context.Cause(s.ctx),
Tasks: tasks,
Running: running,
}
}

View File

@@ -0,0 +1,300 @@
package lifecycle
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
)
func TestSupervisorWaitsForSeal(t *testing.T) {
supervisor := NewSupervisor(context.Background())
started := make(chan struct{})
if err := supervisor.Go("quick", TaskPolicy{}, func(context.Context) error {
close(started)
return nil
}); err != nil {
t.Fatal(err)
}
waitContext, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if report := supervisor.Wait(waitContext); report.Complete {
t.Fatal("Wait() completed before Seal()")
}
select {
case <-started:
t.Fatal("task started before Seal()")
default:
}
supervisor.Seal()
<-started
if report := supervisor.Wait(context.Background()); !report.Complete {
t.Fatal("Wait() did not complete after Seal()")
}
}
func TestSupervisorSealsEmptyGroup(t *testing.T) {
supervisor := NewSupervisor(context.Background())
supervisor.Seal()
report := supervisor.Wait(context.Background())
if !report.Complete || len(report.Tasks) != 0 {
t.Fatalf("Wait() report = %+v, want complete empty report", report)
}
}
func TestSupervisorReportsRunningTasksOnTimeout(t *testing.T) {
supervisor := NewSupervisor(context.Background())
release := make(chan struct{})
if err := supervisor.Go("blocked", TaskPolicy{}, func(context.Context) error {
<-release
return nil
}); err != nil {
t.Fatal(err)
}
supervisor.Seal()
waitContext, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
report := supervisor.Wait(waitContext)
if report.Complete {
t.Fatal("Wait() unexpectedly completed")
}
if len(report.Running) != 1 || report.Running[0].Name != "blocked" {
t.Fatalf("Wait() running tasks = %+v, want blocked", report.Running)
}
close(release)
if report = supervisor.Wait(context.Background()); !report.Complete {
t.Fatal("Wait() did not complete after releasing task")
}
}
func TestSupervisorFailsWhenRequiredLongRunningTaskExits(t *testing.T) {
supervisor := NewSupervisor(context.Background())
release := make(chan struct{})
if err := supervisor.Go("recorder", TaskPolicy{Required: true, LongRunning: true}, func(context.Context) error {
<-release
return nil
}); err != nil {
t.Fatal(err)
}
supervisor.Seal()
close(release)
select {
case failure := <-supervisor.Failures():
if failure.Task != "recorder" || !strings.Contains(failure.Error(), "exited") {
t.Fatalf("failure = %+v", failure)
}
case <-time.After(time.Second):
t.Fatal("required task exit did not emit a failure")
}
}
func TestSupervisorAllowsRequiredOneShotTaskToSucceed(t *testing.T) {
supervisor := NewSupervisor(context.Background())
if err := supervisor.Go("setup", TaskPolicy{Required: true}, func(context.Context) error {
return nil
}); err != nil {
t.Fatal(err)
}
supervisor.Seal()
if report := supervisor.Wait(context.Background()); !report.Complete {
t.Fatal("Wait() did not complete")
}
select {
case failure := <-supervisor.Failures():
t.Fatalf("successful one-shot task emitted failure: %+v", failure)
default:
}
}
func TestSupervisorFailsWhenRequiredOneShotTaskReturnsError(t *testing.T) {
supervisor := NewSupervisor(context.Background())
wantErr := errors.New("setup failed")
if err := supervisor.Go("setup", TaskPolicy{Required: true}, func(context.Context) error {
return wantErr
}); err != nil {
t.Fatal(err)
}
supervisor.Seal()
select {
case failure := <-supervisor.Failures():
if !errors.Is(failure.Cause, wantErr) {
t.Fatalf("failure cause = %v, want %v", failure.Cause, wantErr)
}
case <-time.After(time.Second):
t.Fatal("required task error did not emit a failure")
}
}
func TestSupervisorRecoversPanicAndCapturesStack(t *testing.T) {
supervisor := NewSupervisor(context.Background())
if err := supervisor.Go("panic", TaskPolicy{}, func(context.Context) error {
panic("boom")
}); err != nil {
t.Fatal(err)
}
supervisor.Seal()
select {
case failure := <-supervisor.Failures():
if failure.Task != "panic" || failure.Panic != "boom" || len(failure.Stack) == 0 {
t.Fatalf("failure = %+v, want captured panic and stack", failure)
}
case <-time.After(time.Second):
t.Fatal("panic did not emit a failure")
}
report := supervisor.Wait(context.Background())
if !report.Complete || len(report.Tasks) != 1 || len(report.Tasks[0].Stack) == 0 {
t.Fatalf("report = %+v, want completed task with captured stack", report)
}
}
func TestSupervisorCapturesEmptyPanicDuringShutdown(t *testing.T) {
supervisor := NewSupervisor(context.Background())
started := make(chan struct{})
if err := supervisor.Go("panic", TaskPolicy{}, func(ctx context.Context) error {
close(started)
<-ctx.Done()
panic("")
}); err != nil {
t.Fatal(err)
}
supervisor.Seal()
<-started
supervisor.BeginShutdown(errors.New("requested shutdown"))
select {
case failure := <-supervisor.Failures():
if failure.Task != "panic" || len(failure.Stack) == 0 {
t.Fatalf("failure = %+v, want captured panic and stack", failure)
}
case <-time.After(time.Second):
t.Fatal("panic during shutdown did not emit a failure")
}
if report := supervisor.Wait(context.Background()); !report.Complete || report.Tasks[0].Status != TaskPanicked {
t.Fatalf("report = %+v, want completed panicked task", report)
}
}
func TestSupervisorPublishesFailureBeforeCompletion(t *testing.T) {
supervisor := NewSupervisor(context.Background())
wantErr := errors.New("setup failed")
if err := supervisor.Go("setup", TaskPolicy{Required: true}, func(context.Context) error {
return wantErr
}); err != nil {
t.Fatal(err)
}
supervisor.Seal()
report := supervisor.Wait(context.Background())
if !report.Complete || !errors.Is(report.Cause, wantErr) {
t.Fatalf("report = %+v, want complete report caused by task failure", report)
}
select {
case failure := <-supervisor.Failures():
if !errors.Is(failure.Cause, wantErr) {
t.Fatalf("failure cause = %v, want %v", failure.Cause, wantErr)
}
default:
t.Fatal("failure was not published before completion")
}
}
func TestSupervisorCompletedWaitWinsOverCanceledContext(t *testing.T) {
supervisor := NewSupervisor(context.Background())
supervisor.Seal()
waitContext, cancel := context.WithCancel(context.Background())
cancel()
if report := supervisor.Wait(waitContext); !report.Complete {
t.Fatalf("report = %+v, want authoritative completion state", report)
}
}
func TestSupervisorRejectsTaskAfterParentCancellation(t *testing.T) {
parent, cancel := context.WithCancel(context.Background())
supervisor := NewSupervisor(parent)
cancel()
err := supervisor.Go("late", TaskPolicy{}, func(context.Context) error { return nil })
if !errors.Is(err, ErrSupervisorStopped) && !errors.Is(err, ErrSupervisorSealed) {
t.Fatalf("Go() error = %v, want stopped or sealed supervisor", err)
}
supervisor.BeginShutdown(context.Canceled)
if report := supervisor.Wait(context.Background()); !report.Complete {
t.Fatalf("report = %+v, want complete canceled supervisor", report)
}
}
func TestSupervisorShutdownBeforeSealDoesNotStartTasks(t *testing.T) {
supervisor := NewSupervisor(context.Background())
started := make(chan struct{})
if err := supervisor.Go("worker", TaskPolicy{}, func(context.Context) error {
close(started)
return nil
}); err != nil {
t.Fatal(err)
}
supervisor.BeginShutdown(errors.New("startup aborted"))
if report := supervisor.Wait(context.Background()); !report.Complete {
t.Fatalf("report = %+v, want completed aborted startup", report)
}
select {
case <-started:
t.Fatal("task started after startup was aborted")
default:
}
}
func TestSupervisorRejectsDuplicateAndLateTasks(t *testing.T) {
supervisor := NewSupervisor(context.Background())
release := make(chan struct{})
if err := supervisor.Go("worker", TaskPolicy{}, func(context.Context) error {
<-release
return nil
}); err != nil {
t.Fatal(err)
}
if err := supervisor.Go("worker", TaskPolicy{}, func(context.Context) error { return nil }); !errors.Is(err, ErrTaskExists) {
t.Fatalf("duplicate Go() error = %v, want ErrTaskExists", err)
}
supervisor.Seal()
if err := supervisor.Go("late", TaskPolicy{}, func(context.Context) error { return nil }); !errors.Is(err, ErrSupervisorSealed) {
t.Fatalf("late Go() error = %v, want ErrSupervisorSealed", err)
}
close(release)
}
func TestSupervisorConcurrentStartAndSeal(t *testing.T) {
supervisor := NewSupervisor(context.Background())
var starters sync.WaitGroup
for index := 0; index < 100; index++ {
starters.Add(1)
go func(index int) {
defer starters.Done()
_ = supervisor.Go(
"worker-"+time.Unix(0, int64(index)).Format("150405.000000000"),
TaskPolicy{},
func(context.Context) error { return nil },
)
}(index)
}
supervisor.Seal()
starters.Wait()
if report := supervisor.Wait(context.Background()); !report.Complete {
t.Fatal("Wait() did not complete after concurrent Start and Seal")
}
}

View File

@@ -1,179 +0,0 @@
package log
import (
"os"
"time"
"github.com/op/go-logging"
"github.com/sirupsen/logrus"
"gopkg.in/natefinch/lumberjack.v2"
)
// The logging library being used everywhere.
var Log = Logging{
Logger: "logrus",
}
// -----------------
// This a gologging
// -> github.com/op/go-logging
var gologging = logging.MustGetLogger("gologger")
func ConfigureGoLogging(configDirectory string, timezone *time.Location) {
// Logging
var format = logging.MustStringFormatter(
`%{color}%{time:15:04:05.000} %{shortfunc} ▶ %{level:.4s} %{id:03x}%{color:reset} %{message}`,
)
var format2 = logging.MustStringFormatter(
`%{time:15:04:05.000} %{shortfunc} ▶ %{level:.4s} %{id:03x}%{color:reset} %{message}`,
)
stdBackend := logging.NewLogBackend(os.Stderr, "", 0)
stdBackendLeveled := logging.NewBackendFormatter(stdBackend, format)
fileBackend := logging.NewLogBackend(&lumberjack.Logger{
Filename: configDirectory + "/data/log/machinery.txt",
MaxSize: 2, // megabytes
Compress: true, // disabled by default
}, "", 0)
fileBackendLeveled := logging.NewBackendFormatter(fileBackend, format2)
logging.SetBackend(stdBackendLeveled, fileBackendLeveled)
logging.SetLevel(logging.DEBUG, "")
}
// -----------------
// This a logrus
// -> github.com/sirupsen/logrus
func ConfigureLogrus(level string, output string, timezone *time.Location) {
if output == "json" {
// Log as JSON instead of the default ASCII formatter.
logrus.SetFormatter(LocalTimeZoneFormatter{
Timezone: timezone,
Formatter: &logrus.JSONFormatter{},
})
} else if output == "text" {
// Log as text with colors.
formatter := logrus.TextFormatter{
ForceColors: true,
FullTimestamp: true,
}
logrus.SetFormatter(LocalTimeZoneFormatter{
Timezone: timezone,
Formatter: &formatter,
})
}
// Use local timezone for providing datetime in logs!
// Output to stdout instead of the default stderr
// Can be any io.Writer, see below for File example
logrus.SetOutput(os.Stdout)
// Only log the warning severity or above.
logLevel := logrus.InfoLevel
if level == "error" {
logLevel = logrus.ErrorLevel
} else if level == "debug" {
logLevel = logrus.DebugLevel
} else if level == "fatal" {
logLevel = logrus.FatalLevel
} else if level == "warning" {
logLevel = logrus.WarnLevel
} // Add this line for logging filename and line number!
logrus.SetLevel(logLevel)
}
type LocalTimeZoneFormatter struct {
Timezone *time.Location
Formatter logrus.Formatter
}
func (u LocalTimeZoneFormatter) Format(e *logrus.Entry) ([]byte, error) {
e.Time = e.Time.In(u.Timezone)
return u.Formatter.Format(e)
}
type Logging struct {
Logger string
}
func (self *Logging) Init(level string, logoutput string, configDirectory string, timezone *time.Location) {
switch self.Logger {
case "go-logging":
ConfigureGoLogging(configDirectory, timezone)
case "logrus":
ConfigureLogrus(level, logoutput, timezone)
default:
}
}
func (self *Logging) Info(sentence string) {
switch self.Logger {
case "go-logging":
gologging.Info(sentence)
case "logrus":
logrus.Info(sentence)
default:
}
}
func (self *Logging) Infof(format string, args ...interface{}) {
switch self.Logger {
case "go-logging":
gologging.Infof(format, args...)
case "logrus":
logrus.Infof(format, args...)
default:
}
}
func (self *Logging) Warning(sentence string) {
switch self.Logger {
case "go-logging":
gologging.Warning(sentence)
case "logrus":
logrus.Warn(sentence)
default:
}
}
func (self *Logging) Debug(sentence string) {
switch self.Logger {
case "go-logging":
gologging.Debug(sentence)
case "logrus":
logrus.Debug(sentence)
default:
}
}
func (self *Logging) Debugf(format string, args ...interface{}) {
switch self.Logger {
case "go-logging":
gologging.Debugf(format, args...)
case "logrus":
logrus.Debugf(format, args...)
default:
}
}
func (self *Logging) Error(sentence string) {
switch self.Logger {
case "go-logging":
gologging.Error(sentence)
case "logrus":
logrus.Error(sentence)
default:
}
}
func (self *Logging) Fatal(sentence string) {
switch self.Logger {
case "go-logging":
gologging.Fatal(sentence)
case "logrus":
logrus.Fatal(sentence)
default:
}
}

View File

@@ -1,41 +0,0 @@
package models
import (
"context"
"sync/atomic"
"github.com/kerberos-io/agent/machinery/src/packets"
"github.com/tevino/abool"
)
// The communication struct that is managing
// all the communication between the different goroutines.
type Communication struct {
Context *context.Context
CancelContext *context.CancelFunc
PackageCounter *atomic.Value
LastPacketTimer *atomic.Value
PackageCounterSub *atomic.Value
LastPacketTimerSub *atomic.Value
CloudTimestamp *atomic.Value
HandleBootstrap chan string
HandleStream chan string
HandleSubStream chan string
HandleMotion chan MotionDataPartial
HandleAudio chan AudioDataPartial
HandleUpload chan string
HandleHeartBeat chan string
HandleLiveSD chan int64
HandleLiveHDKeepalive chan string
HandleLiveHDHandshake chan RequestHDStreamPayload
HandleLiveHDPeers chan string
HandleONVIF chan OnvifAction
IsConfiguring *abool.AtomicBool
Queue *packets.Queue
SubQueue *packets.Queue
Image string
CameraConnected bool
MainStreamConnected bool
SubStreamConnected bool
HasBackChannel bool
}

View File

@@ -0,0 +1,477 @@
package models
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/kerberos-io/agent/machinery/src/lifecycle"
"github.com/kerberos-io/agent/machinery/src/packets"
log "github.com/sirupsen/logrus"
)
var (
ErrAgentRunActive = errors.New("another agent run is active")
ErrAgentRunStarted = errors.New("agent run already started")
ErrAgentRunStopped = errors.New("agent run is stopping")
nextAgentRunID atomic.Uint64
)
type AgentRunClient interface {
Close(context.Context) error
}
type AgentRunResourceError struct {
Resource string
Err error
}
func (e AgentRunResourceError) Error() string {
return fmt.Sprintf("close %s: %v", e.Resource, e.Err)
}
func (e AgentRunResourceError) Unwrap() error {
return e.Err
}
type AgentRunShutdownReport struct {
lifecycle.ShutdownReport
ResourceErrors []AgentRunResourceError
UploadStopDelivered bool
StreamStopDelivered bool
}
type AgentRun struct {
id uint64
ctx context.Context
cancel context.CancelCauseFunc
supervisor *lifecycle.Supervisor
communication *Communication
stopUpload bool
stateMu sync.Mutex
activated bool
stopping bool
resourcesMu sync.RWMutex
mainClient AgentRunClient
subClient AgentRunClient
backchannelClient AgentRunClient
mainQueue *packets.Queue
subQueue *packets.Queue
releaseClients func()
channelsMu sync.RWMutex
channelsClosed bool
liveHDHandshakes chan LiveHDHandshake
motionEvents chan MotionDataPartial
onvifActions chan OnvifAction
shutdownOnce sync.Once
shutdownReport AgentRunShutdownReport
}
func NewAgentRun(parent context.Context, communication *Communication, stopUpload bool) *AgentRun {
if parent == nil {
parent = context.Background()
}
ctx, cancel := context.WithCancelCause(parent)
run := &AgentRun{
id: nextAgentRunID.Add(1),
ctx: ctx,
cancel: cancel,
supervisor: lifecycle.NewSupervisor(ctx),
communication: communication,
stopUpload: stopUpload,
liveHDHandshakes: make(chan LiveHDHandshake, 100),
motionEvents: make(chan MotionDataPartial, 10),
onvifActions: make(chan OnvifAction, 10),
}
log.WithFields(log.Fields{
"component": "agent_run",
"event": "run_created",
"run_id": run.id,
"stop_upload": stopUpload,
}).Debug("Agent run created")
return run
}
func (r *AgentRun) ID() uint64 {
return r.id
}
func (r *AgentRun) Context() context.Context {
return r.ctx
}
func (r *AgentRun) Go(name string, policy lifecycle.TaskPolicy, task lifecycle.TaskFunc) error {
if task == nil {
return r.supervisor.Go(name, policy, task)
}
fields := log.Fields{
"component": "agent_run",
"event": "task_lifecycle",
"long_running": policy.LongRunning,
"required": policy.Required,
"run_id": r.id,
"task": name,
}
wrappedTask := func(ctx context.Context) error {
startedAt := time.Now()
log.WithFields(fields).Debug("Agent task started")
err := task(ctx)
entry := log.WithFields(fields).
WithField("duration_ms", time.Since(startedAt).Milliseconds())
if err != nil {
entry = entry.WithError(err)
if ctx.Err() == nil {
entry.Warn("Agent task stopped with an error")
} else {
entry.Debug("Agent task stopped during shutdown")
}
} else {
entry.Debug("Agent task stopped")
}
return err
}
if err := r.supervisor.Go(name, policy, wrappedTask); err != nil {
log.WithError(err).WithFields(fields).Error("Failed to register Agent task")
return err
}
log.WithFields(fields).Debug("Agent task registered")
return nil
}
func (r *AgentRun) Seal() {
r.supervisor.Seal()
log.WithFields(log.Fields{
"component": "agent_run",
"event": "run_sealed",
"run_id": r.id,
"task_count": len(r.supervisor.Snapshot()),
}).Debug("Agent run sealed")
}
func (r *AgentRun) Failures() <-chan lifecycle.Failure {
return r.supervisor.Failures()
}
func (r *AgentRun) Snapshot() []lifecycle.TaskSnapshot {
return r.supervisor.Snapshot()
}
func (r *AgentRun) Wait(ctx context.Context) lifecycle.ShutdownReport {
return r.supervisor.Wait(ctx)
}
func (r *AgentRun) Activate() error {
r.stateMu.Lock()
defer r.stateMu.Unlock()
if r.stopping {
return ErrAgentRunStopped
}
if r.ctx.Err() != nil {
return ErrAgentRunStopped
}
if r.activated {
return ErrAgentRunStarted
}
if r.communication == nil {
return errors.New("agent run communication is required")
}
if err := r.communication.attachRun(r); err != nil {
return err
}
r.activated = true
log.WithFields(log.Fields{
"component": "agent_run",
"event": "run_activated",
"run_id": r.id,
}).Info("Agent run activated")
return nil
}
func (r *AgentRun) SetMainClient(client AgentRunClient) {
r.resourcesMu.Lock()
r.mainClient = client
r.resourcesMu.Unlock()
}
func (r *AgentRun) SetSubClient(client AgentRunClient) {
r.resourcesMu.Lock()
r.subClient = client
r.resourcesMu.Unlock()
}
func (r *AgentRun) SetBackchannelClient(client AgentRunClient) {
r.resourcesMu.Lock()
r.backchannelClient = client
r.resourcesMu.Unlock()
}
func (r *AgentRun) SetQueues(mainQueue, subQueue *packets.Queue) {
r.resourcesMu.Lock()
r.mainQueue = mainQueue
r.subQueue = subQueue
r.resourcesMu.Unlock()
}
func (r *AgentRun) SetMainQueue(queue *packets.Queue) {
r.resourcesMu.Lock()
r.mainQueue = queue
r.resourcesMu.Unlock()
}
func (r *AgentRun) SetSubQueue(queue *packets.Queue) {
r.resourcesMu.Lock()
r.subQueue = queue
r.resourcesMu.Unlock()
}
func (r *AgentRun) SetClientRelease(release func()) {
r.resourcesMu.Lock()
r.releaseClients = release
r.resourcesMu.Unlock()
}
func (r *AgentRun) MainQueue() *packets.Queue {
if r.isStopping() {
return nil
}
r.resourcesMu.RLock()
defer r.resourcesMu.RUnlock()
return r.mainQueue
}
func (r *AgentRun) SubQueue() *packets.Queue {
if r.isStopping() {
return nil
}
r.resourcesMu.RLock()
defer r.resourcesMu.RUnlock()
return r.subQueue
}
func (r *AgentRun) LiveHDHandshakes() <-chan LiveHDHandshake {
return r.liveHDHandshakes
}
func (r *AgentRun) MotionEvents() <-chan MotionDataPartial {
return r.motionEvents
}
func (r *AgentRun) ONVIFActions() <-chan OnvifAction {
return r.onvifActions
}
func (r *AgentRun) TrySendLiveHDHandshake(handshake LiveHDHandshake) bool {
if r.isStopping() {
return false
}
r.channelsMu.RLock()
defer r.channelsMu.RUnlock()
if r.channelsClosed {
return false
}
select {
case r.liveHDHandshakes <- handshake:
return true
default:
return false
}
}
func (r *AgentRun) PendingLiveHDHandshakes() int {
if r.isStopping() {
return 0
}
r.channelsMu.RLock()
defer r.channelsMu.RUnlock()
if r.channelsClosed {
return 0
}
return len(r.liveHDHandshakes)
}
func (r *AgentRun) TrySendMotion(motion MotionDataPartial) bool {
if r.isStopping() {
return false
}
r.channelsMu.RLock()
defer r.channelsMu.RUnlock()
if r.channelsClosed {
return false
}
select {
case r.motionEvents <- motion:
return true
default:
return false
}
}
func (r *AgentRun) TrySendONVIF(action OnvifAction) bool {
if r.isStopping() {
return false
}
r.channelsMu.RLock()
defer r.channelsMu.RUnlock()
if r.channelsClosed {
return false
}
select {
case r.onvifActions <- action:
return true
default:
return false
}
}
func (r *AgentRun) Shutdown(ctx context.Context, cause error) AgentRunShutdownReport {
if ctx == nil {
ctx = context.Background()
}
if cause == nil {
cause = context.Canceled
}
r.shutdownOnce.Do(func() {
startedAt := time.Now()
log.WithError(cause).WithFields(log.Fields{
"component": "agent_run",
"event": "shutdown_started",
"run_id": r.id,
}).Info("Agent run shutdown started")
r.stateMu.Lock()
r.stopping = true
activated := r.activated
r.stateMu.Unlock()
r.cancel(cause)
r.supervisor.BeginShutdown(cause)
if activated && r.stopUpload {
r.shutdownReport.UploadStopDelivered = sendRunStop(ctx, r.communication.HandleUpload)
}
if activated {
r.shutdownReport.StreamStopDelivered = sendRunStop(ctx, r.communication.HandleStream)
}
r.resourcesMu.RLock()
mainClient := r.mainClient
subClient := r.subClient
backchannelClient := r.backchannelClient
mainQueue := r.mainQueue
subQueue := r.subQueue
releaseClients := r.releaseClients
r.resourcesMu.RUnlock()
r.closeClient(ctx, "main RTSP client", mainClient)
if mainQueue != nil {
_ = mainQueue.Close()
}
r.closeClient(ctx, "sub RTSP client", subClient)
if subQueue != nil {
_ = subQueue.Close()
}
r.closeClient(ctx, "RTSP backchannel client", backchannelClient)
r.closeChannels()
if releaseClients != nil {
releaseClients()
}
r.shutdownReport.ShutdownReport = r.supervisor.Wait(ctx)
if r.shutdownReport.Complete && r.communication != nil {
r.communication.detachRun(r)
}
runningTasks := make([]string, 0, len(r.shutdownReport.Running))
for _, task := range r.shutdownReport.Running {
runningTasks = append(runningTasks, task.Name)
}
entry := log.WithFields(log.Fields{
"component": "agent_run",
"duration_ms": time.Since(startedAt).Milliseconds(),
"resource_error_count": len(r.shutdownReport.ResourceErrors),
"run_id": r.id,
"running_tasks": runningTasks,
"task_count": len(r.shutdownReport.Tasks),
"upload_stop_sent": r.shutdownReport.UploadStopDelivered,
"stream_stop_sent": r.shutdownReport.StreamStopDelivered,
})
if r.shutdownReport.Complete {
entry.WithField("event", "shutdown_completed").Info("Agent run shutdown completed")
} else {
entry.WithField("event", "shutdown_timed_out").Error("Agent run shutdown timed out")
}
})
report := r.shutdownReport
report.ResourceErrors = append([]AgentRunResourceError(nil), report.ResourceErrors...)
return report
}
func (r *AgentRun) isStopping() bool {
r.stateMu.Lock()
defer r.stateMu.Unlock()
return r.stopping
}
func (r *AgentRun) closeClient(ctx context.Context, name string, client AgentRunClient) {
if client == nil {
return
}
if err := client.Close(ctx); err != nil {
r.shutdownReport.ResourceErrors = append(r.shutdownReport.ResourceErrors, AgentRunResourceError{
Resource: name,
Err: err,
})
log.WithError(err).WithFields(log.Fields{
"component": "agent_run",
"event": "resource_close_failed",
"resource": name,
"run_id": r.id,
}).Warn("Failed to close Agent run resource")
return
}
log.WithFields(log.Fields{
"component": "agent_run",
"event": "resource_closed",
"resource": name,
"run_id": r.id,
}).Debug("Agent run resource closed")
}
func (r *AgentRun) closeChannels() {
r.channelsMu.Lock()
defer r.channelsMu.Unlock()
if r.channelsClosed {
return
}
r.channelsClosed = true
close(r.liveHDHandshakes)
close(r.motionEvents)
close(r.onvifActions)
}
func sendRunStop(ctx context.Context, channel chan<- string) bool {
if channel == nil {
return false
}
timer := time.NewTimer(time.Second)
defer timer.Stop()
select {
case channel <- "stop":
return true
case <-ctx.Done():
return false
case <-timer.C:
return false
}
}

View File

@@ -0,0 +1,303 @@
package models
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/kerberos-io/agent/machinery/src/lifecycle"
"github.com/kerberos-io/agent/machinery/src/packets"
)
type fakeAgentRunClient struct {
mu sync.Mutex
name string
order *[]string
calls int
closeErr error
}
func (c *fakeAgentRunClient) Close(context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
c.calls++
*c.order = append(*c.order, c.name)
return c.closeErr
}
func (c *fakeAgentRunClient) Calls() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.calls
}
func TestAgentRunOwnsAndShutsDownResources(t *testing.T) {
communication := &Communication{
HandleStream: make(chan string, 1),
HandleUpload: make(chan string, 1),
}
run := NewAgentRun(context.Background(), communication, true)
mainQueue := packets.NewQueue()
subQueue := packets.NewQueue()
run.SetQueues(mainQueue, subQueue)
var order []string
mainClient := &fakeAgentRunClient{name: "main", order: &order}
subClient := &fakeAgentRunClient{name: "sub", order: &order}
backchannelClient := &fakeAgentRunClient{name: "backchannel", order: &order}
run.SetMainClient(mainClient)
run.SetSubClient(subClient)
run.SetBackchannelClient(backchannelClient)
run.SetClientRelease(func() {
order = append(order, "release")
})
taskStarted := make(chan struct{})
if err := run.Go("worker", lifecycle.TaskPolicy{}, func(ctx context.Context) error {
close(taskStarted)
<-ctx.Done()
return nil
}); err != nil {
t.Fatal(err)
}
if err := run.Activate(); err != nil {
t.Fatal(err)
}
run.Seal()
<-taskStarted
report := run.Shutdown(context.Background(), errors.New("test shutdown"))
if !report.Complete {
t.Fatalf("shutdown report = %+v, want complete", report)
}
if communication.CurrentRun() != nil {
t.Fatal("shutdown left the run attached")
}
if got, want := order, []string{"main", "sub", "backchannel", "release"}; !equalStrings(got, want) {
t.Fatalf("resource order = %v, want %v", got, want)
}
if !report.UploadStopDelivered || !report.StreamStopDelivered {
t.Fatalf("stop delivery = upload:%t stream:%t, want both", report.UploadStopDelivered, report.StreamStopDelivered)
}
select {
case value := <-communication.HandleUpload:
if value != "stop" {
t.Fatalf("upload stop = %q, want stop", value)
}
default:
t.Fatal("upload stop was not delivered")
}
select {
case value := <-communication.HandleStream:
if value != "stop" {
t.Fatalf("stream stop = %q, want stop", value)
}
default:
t.Fatal("stream stop was not delivered")
}
if _, err := mainQueue.Latest().ReadPacket(); err == nil {
t.Fatal("main queue remained open")
}
if _, err := subQueue.Latest().ReadPacket(); err == nil {
t.Fatal("sub queue remained open")
}
if _, ok := <-run.LiveHDHandshakes(); ok {
t.Fatal("handshake channel remained open")
}
if _, ok := <-run.MotionEvents(); ok {
t.Fatal("motion channel remained open")
}
if _, ok := <-run.ONVIFActions(); ok {
t.Fatal("ONVIF channel remained open")
}
}
func TestAgentRunShutdownIsConcurrentAndIdempotent(t *testing.T) {
communication := &Communication{}
run := NewAgentRun(context.Background(), communication, false)
var order []string
client := &fakeAgentRunClient{name: "main", order: &order}
run.SetMainClient(client)
if err := run.Activate(); err != nil {
t.Fatal(err)
}
run.Seal()
var callers sync.WaitGroup
reports := make(chan AgentRunShutdownReport, 20)
for index := 0; index < 20; index++ {
callers.Add(1)
go func() {
defer callers.Done()
reports <- run.Shutdown(context.Background(), errors.New("test shutdown"))
}()
}
callers.Wait()
close(reports)
for report := range reports {
if !report.Complete {
t.Fatalf("shutdown report = %+v, want complete", report)
}
}
if got := client.Calls(); got != 1 {
t.Fatalf("client Close() calls = %d, want 1", got)
}
}
func TestAgentRunStaleShutdownDoesNotDetachNewRun(t *testing.T) {
communication := &Communication{}
oldRun := NewAgentRun(context.Background(), communication, false)
if err := oldRun.Activate(); err != nil {
t.Fatal(err)
}
if !communication.detachRun(oldRun) {
t.Fatal("failed to detach old run during test setup")
}
newRun := NewAgentRun(context.Background(), communication, false)
if err := newRun.Activate(); err != nil {
t.Fatal(err)
}
newRun.Seal()
t.Cleanup(func() {
newRun.Shutdown(context.Background(), errors.New("test complete"))
})
oldRun.Shutdown(context.Background(), errors.New("stale shutdown"))
if got := communication.CurrentRun(); got != newRun {
t.Fatalf("current run = %p, want new run %p", got, newRun)
}
}
func TestAgentRunRejectsOverlap(t *testing.T) {
communication := &Communication{}
first := NewAgentRun(context.Background(), communication, false)
if err := first.Activate(); err != nil {
t.Fatal(err)
}
first.Seal()
t.Cleanup(func() {
first.Shutdown(context.Background(), errors.New("test complete"))
})
second := NewAgentRun(context.Background(), communication, false)
if err := second.Activate(); !errors.Is(err, ErrAgentRunActive) {
t.Fatalf("Activate() error = %v, want ErrAgentRunActive", err)
}
second.Shutdown(context.Background(), errors.New("test complete"))
}
func TestAgentRunRetainsOwnershipUntilWorkersStop(t *testing.T) {
communication := &Communication{}
run := NewAgentRun(context.Background(), communication, false)
release := make(chan struct{})
started := make(chan struct{})
if err := run.Go("blocked", lifecycle.TaskPolicy{}, func(context.Context) error {
close(started)
<-release
return nil
}); err != nil {
t.Fatal(err)
}
if err := run.Activate(); err != nil {
t.Fatal(err)
}
run.Seal()
<-started
shutdownDone := make(chan AgentRunShutdownReport, 1)
go func() {
shutdownDone <- run.Shutdown(context.Background(), errors.New("test shutdown"))
}()
deadline := time.Now().Add(time.Second)
for run.Context().Err() == nil && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if run.Context().Err() == nil {
t.Fatal("run context was not canceled")
}
if communication.CurrentRun() != run {
t.Fatal("run ownership was released before its worker stopped")
}
if communication.TrySendMotion(MotionDataPartial{}) {
t.Fatal("stopping run accepted new input")
}
if communication.MainQueue() != nil {
t.Fatal("stopping run exposed its queue")
}
replacement := NewAgentRun(context.Background(), communication, false)
if err := replacement.Activate(); !errors.Is(err, ErrAgentRunActive) {
t.Fatalf("replacement Activate() error = %v, want ErrAgentRunActive", err)
}
replacement.Shutdown(context.Background(), errors.New("test complete"))
close(release)
report := <-shutdownDone
if !report.Complete {
t.Fatalf("shutdown report = %+v, want complete", report)
}
if communication.CurrentRun() != nil {
t.Fatal("completed shutdown left the run attached")
}
}
func TestAgentRunShutdownReportsStuckTask(t *testing.T) {
communication := &Communication{}
run := NewAgentRun(context.Background(), communication, false)
release := make(chan struct{})
started := make(chan struct{})
if err := run.Go("blocked", lifecycle.TaskPolicy{}, func(context.Context) error {
close(started)
<-release
return nil
}); err != nil {
t.Fatal(err)
}
if err := run.Activate(); err != nil {
t.Fatal(err)
}
run.Seal()
<-started
waitContext, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
report := run.Shutdown(waitContext, errors.New("test shutdown"))
if report.Complete {
t.Fatal("shutdown unexpectedly completed")
}
if len(report.Running) != 1 || report.Running[0].Name != "blocked" {
t.Fatalf("running tasks = %+v, want blocked", report.Running)
}
if communication.CurrentRun() != run {
t.Fatal("timed-out shutdown released run ownership")
}
replacement := NewAgentRun(context.Background(), communication, false)
if err := replacement.Activate(); !errors.Is(err, ErrAgentRunActive) {
t.Fatalf("replacement Activate() error = %v, want ErrAgentRunActive", err)
}
replacement.Shutdown(context.Background(), errors.New("test complete"))
close(release)
waitContext, waitCancel := context.WithTimeout(context.Background(), time.Second)
defer waitCancel()
if followup := run.Wait(waitContext); !followup.Complete {
t.Fatalf("follow-up report = %+v, want complete", followup)
}
communication.detachRun(run)
}
func equalStrings(left, right []string) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}

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

@@ -0,0 +1,246 @@
package models
import (
"sync"
"sync/atomic"
"time"
"github.com/kerberos-io/agent/machinery/src/packets"
"github.com/tevino/abool"
)
type LiveHDSignalingCallbacks struct {
SendAnswer func(sessionID string, sdp string) error
SendCandidate func(sessionID string, candidate string) error
SendError func(sessionID string, message string) error
}
type LiveHDHandshake struct {
Payload RequestHDStreamPayload
Signaling *LiveHDSignalingCallbacks
}
type MoQRecoveryTelemetry struct {
Reconnects uint64 `json:"reconnects"`
LastFrameUnixMillis int64 `json:"lastFrameUnixMillis"`
LastWriteMillis int64 `json:"lastWriteMillis"`
}
type RecoveryTelemetry struct {
MoQHigh MoQRecoveryTelemetry `json:"moqHigh"`
MoQLow MoQRecoveryTelemetry `json:"moqLow"`
MoQWriteTimeouts uint64 `json:"moqWriteTimeouts"`
DroppedLiveHDHandshakes uint64 `json:"droppedLiveHDHandshakes"`
DroppedMotionEvents uint64 `json:"droppedMotionEvents"`
DroppedONVIFActions uint64 `json:"droppedOnvifActions"`
WatchdogRestarts uint64 `json:"watchdogRestarts"`
WatchdogCooldownSeconds int64 `json:"watchdogCooldownSeconds"`
RunWorkerShutdownTimeouts uint64 `json:"runWorkerShutdownTimeouts"`
}
type recoveryTelemetry struct {
moqHighReconnects atomic.Uint64
moqHighLastFrameUnixMillis atomic.Int64
moqHighLastWriteMillis atomic.Int64
moqLowReconnects atomic.Uint64
moqLowLastFrameUnixMillis atomic.Int64
moqLowLastWriteMillis atomic.Int64
moqWriteTimeouts atomic.Uint64
droppedLiveHDHandshakes atomic.Uint64
droppedMotionEvents atomic.Uint64
droppedONVIFActions atomic.Uint64
watchdogRestarts atomic.Uint64
watchdogCooldownSeconds atomic.Int64
runWorkerShutdownTimeouts atomic.Uint64
}
// The communication struct that is managing
// all the communication between the different goroutines.
type Communication struct {
currentRunMu sync.RWMutex
currentRun *AgentRun
PackageCounter *atomic.Value
LastPacketTimer *atomic.Value
PackageCounterSub *atomic.Value
LastPacketTimerSub *atomic.Value
CloudTimestamp *atomic.Value
HandleBootstrap chan string
HandleStream chan string
HandleSubStream chan string
HandleAudio chan AudioDataPartial
HandleUpload chan string
HandleHeartBeat chan string
HandleLiveSD chan int64
HandleLiveSDHTTP chan int64
HandleLiveHDKeepalive chan string
HandleLiveHDPeers chan string
// 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
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
Image string
CameraConnected atomic.Bool
MainStreamConnected atomic.Bool
SubStreamConnected atomic.Bool
HasBackChannel atomic.Bool
recovery recoveryTelemetry
}
func (c *Communication) RecordMoQReconnect(quality string) {
if quality == StreamQualityLow {
c.recovery.moqLowReconnects.Add(1)
return
}
c.recovery.moqHighReconnects.Add(1)
}
func (c *Communication) RecordMoQWrite(quality string, duration time.Duration, at time.Time) {
if quality == StreamQualityLow {
c.recovery.moqLowLastWriteMillis.Store(duration.Milliseconds())
c.recovery.moqLowLastFrameUnixMillis.Store(at.UnixMilli())
return
}
c.recovery.moqHighLastWriteMillis.Store(duration.Milliseconds())
c.recovery.moqHighLastFrameUnixMillis.Store(at.UnixMilli())
}
func (c *Communication) RecordMoQWriteTimeout() {
c.recovery.moqWriteTimeouts.Add(1)
}
func (c *Communication) RecordWatchdogRestart(cooldown time.Duration) {
c.recovery.watchdogRestarts.Add(1)
c.SetWatchdogCooldown(cooldown)
}
func (c *Communication) SetWatchdogCooldown(cooldown time.Duration) {
c.recovery.watchdogCooldownSeconds.Store(int64(cooldown / time.Second))
}
func (c *Communication) RecordRunWorkerShutdownTimeout() {
c.recovery.runWorkerShutdownTimeouts.Add(1)
}
func (c *Communication) RecoveryTelemetry() RecoveryTelemetry {
return RecoveryTelemetry{
MoQHigh: MoQRecoveryTelemetry{
Reconnects: c.recovery.moqHighReconnects.Load(),
LastFrameUnixMillis: c.recovery.moqHighLastFrameUnixMillis.Load(),
LastWriteMillis: c.recovery.moqHighLastWriteMillis.Load(),
},
MoQLow: MoQRecoveryTelemetry{
Reconnects: c.recovery.moqLowReconnects.Load(),
LastFrameUnixMillis: c.recovery.moqLowLastFrameUnixMillis.Load(),
LastWriteMillis: c.recovery.moqLowLastWriteMillis.Load(),
},
MoQWriteTimeouts: c.recovery.moqWriteTimeouts.Load(),
DroppedLiveHDHandshakes: c.recovery.droppedLiveHDHandshakes.Load(),
DroppedMotionEvents: c.recovery.droppedMotionEvents.Load(),
DroppedONVIFActions: c.recovery.droppedONVIFActions.Load(),
WatchdogRestarts: c.recovery.watchdogRestarts.Load(),
WatchdogCooldownSeconds: c.recovery.watchdogCooldownSeconds.Load(),
RunWorkerShutdownTimeouts: c.recovery.runWorkerShutdownTimeouts.Load(),
}
}
func (c *Communication) attachRun(run *AgentRun) error {
c.currentRunMu.Lock()
defer c.currentRunMu.Unlock()
if c.currentRun != nil {
return ErrAgentRunActive
}
c.currentRun = run
return nil
}
func (c *Communication) detachRun(run *AgentRun) bool {
c.currentRunMu.Lock()
defer c.currentRunMu.Unlock()
if c.currentRun != run {
return false
}
c.currentRun = nil
return true
}
func (c *Communication) CurrentRun() *AgentRun {
c.currentRunMu.RLock()
defer c.currentRunMu.RUnlock()
return c.currentRun
}
func (c *Communication) MainQueue() *packets.Queue {
run := c.CurrentRun()
if run == nil {
return nil
}
return run.MainQueue()
}
func (c *Communication) SubQueue() *packets.Queue {
run := c.CurrentRun()
if run == nil {
return nil
}
return run.SubQueue()
}
func (c *Communication) TrySendLiveHDHandshake(handshake LiveHDHandshake) bool {
run := c.CurrentRun()
if run == nil || !run.TrySendLiveHDHandshake(handshake) {
c.recovery.droppedLiveHDHandshakes.Add(1)
return false
}
return true
}
func (c *Communication) PendingLiveHDHandshakes() int {
run := c.CurrentRun()
if run == nil {
return 0
}
return run.PendingLiveHDHandshakes()
}
func (c *Communication) TrySendMotion(motion MotionDataPartial) bool {
run := c.CurrentRun()
if run == nil || !run.TrySendMotion(motion) {
c.recovery.droppedMotionEvents.Add(1)
return false
}
return true
}
func (c *Communication) TrySendONVIF(action OnvifAction) bool {
run := c.CurrentRun()
if run == nil || !run.TrySendONVIF(action) {
c.recovery.droppedONVIFActions.Add(1)
return false
}
return true
}

View File

@@ -0,0 +1,161 @@
package models
import (
"context"
"errors"
"sync"
"testing"
"time"
)
func TestCommunicationRunChannelDispatchAfterClose(t *testing.T) {
communication := &Communication{}
run := NewAgentRun(context.Background(), communication, false)
if err := run.Activate(); err != nil {
t.Fatal(err)
}
if !communication.TrySendLiveHDHandshake(LiveHDHandshake{}) {
t.Fatal("TrySendLiveHDHandshake() rejected an available channel")
}
if !communication.TrySendMotion(MotionDataPartial{}) {
t.Fatal("TrySendMotion() rejected an available channel")
}
if !communication.TrySendONVIF(OnvifAction{}) {
t.Fatal("TrySendONVIF() rejected an available channel")
}
report := run.Shutdown(context.Background(), errors.New("test complete"))
if !report.Complete {
t.Fatalf("shutdown report = %+v, want complete", report)
}
if communication.TrySendLiveHDHandshake(LiveHDHandshake{}) {
t.Fatal("TrySendLiveHDHandshake() accepted a closed run")
}
if communication.TrySendMotion(MotionDataPartial{}) {
t.Fatal("TrySendMotion() accepted a closed run")
}
if communication.TrySendONVIF(OnvifAction{}) {
t.Fatal("TrySendONVIF() accepted a closed run")
}
}
func TestCommunicationDispatchCanRaceRunChannelClose(t *testing.T) {
communication := &Communication{}
run := NewAgentRun(context.Background(), communication, false)
if err := run.Activate(); err != nil {
t.Fatal(err)
}
start := make(chan struct{})
var senders sync.WaitGroup
for sender := 0; sender < 20; sender++ {
senders.Add(1)
go func() {
defer senders.Done()
<-start
for attempt := 0; attempt < 100; attempt++ {
communication.TrySendLiveHDHandshake(LiveHDHandshake{})
communication.TrySendMotion(MotionDataPartial{})
communication.TrySendONVIF(OnvifAction{})
}
}()
}
close(start)
run.Shutdown(context.Background(), errors.New("test complete"))
senders.Wait()
}
func TestCommunicationRunChannelLifecycleSoak(t *testing.T) {
communication := &Communication{}
for cycle := 0; cycle < 100; cycle++ {
run := NewAgentRun(context.Background(), communication, false)
if err := run.Activate(); err != nil {
t.Fatalf("cycle %d Activate() error = %v", cycle, err)
}
handshakes := run.LiveHDHandshakes()
motion := run.MotionEvents()
onvif := run.ONVIFActions()
var consumers sync.WaitGroup
consumers.Add(3)
go func() {
defer consumers.Done()
for range handshakes {
}
}()
go func() {
defer consumers.Done()
for range motion {
}
}()
go func() {
defer consumers.Done()
for range onvif {
}
}()
var producers sync.WaitGroup
for producer := 0; producer < 4; producer++ {
producers.Add(1)
go func() {
defer producers.Done()
for attempt := 0; attempt < 50; attempt++ {
communication.TrySendLiveHDHandshake(LiveHDHandshake{})
communication.TrySendMotion(MotionDataPartial{})
communication.TrySendONVIF(OnvifAction{})
}
}()
}
run.Shutdown(context.Background(), errors.New("test complete"))
producers.Wait()
done := make(chan struct{})
go func() {
consumers.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatalf("cycle %d consumers did not stop after channel close", cycle)
}
}
}
func TestCommunicationRecoveryTelemetry(t *testing.T) {
communication := &Communication{}
communication.RecordMoQReconnect(StreamQualityHigh)
communication.RecordMoQReconnect(StreamQualityLow)
communication.RecordMoQWrite(StreamQualityHigh, 1250*time.Millisecond, time.UnixMilli(1234))
communication.RecordMoQWrite(StreamQualityLow, 2500*time.Millisecond, time.UnixMilli(5678))
communication.RecordMoQWriteTimeout()
communication.RecordWatchdogRestart(30 * time.Second)
communication.RecordRunWorkerShutdownTimeout()
if communication.TrySendLiveHDHandshake(LiveHDHandshake{}) {
t.Fatal("TrySendLiveHDHandshake() accepted an unavailable channel")
}
if communication.TrySendMotion(MotionDataPartial{}) {
t.Fatal("TrySendMotion() accepted an unavailable channel")
}
if communication.TrySendONVIF(OnvifAction{}) {
t.Fatal("TrySendONVIF() accepted an unavailable channel")
}
got := communication.RecoveryTelemetry()
if got.MoQHigh != (MoQRecoveryTelemetry{Reconnects: 1, LastFrameUnixMillis: 1234, LastWriteMillis: 1250}) {
t.Fatalf("high MoQ telemetry = %+v", got.MoQHigh)
}
if got.MoQLow != (MoQRecoveryTelemetry{Reconnects: 1, LastFrameUnixMillis: 5678, LastWriteMillis: 2500}) {
t.Fatalf("low MoQ telemetry = %+v", got.MoQLow)
}
if got.MoQWriteTimeouts != 1 || got.WatchdogRestarts != 1 || got.WatchdogCooldownSeconds != 30 || got.RunWorkerShutdownTimeouts != 1 {
t.Fatalf("recovery telemetry = %+v", got)
}
if got.DroppedLiveHDHandshakes != 1 || got.DroppedMotionEvents != 1 || got.DroppedONVIFActions != 1 {
t.Fatalf("drop telemetry = %+v", got)
}
}

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.
@@ -99,8 +108,14 @@ type IPCamera struct {
SPSNALUs [][]byte `json:"sps_nalus,omitempty" bson:"sps_nalus,omitempty"`
PPSNALUs [][]byte `json:"pps_nalus,omitempty" bson:"pps_nalus,omitempty"`
VPSNALUs [][]byte `json:"vps_nalus,omitempty" bson:"vps_nalus,omitempty"`
SampleRate int `json:"sample_rate,omitempty" bson:"sample_rate,omitempty"`
Channels int `json:"channels,omitempty" bson:"channels,omitempty"`
// Sub stream parameter sets, captured separately from the main stream so the
// live HLS muxer can build a correct init segment when a viewer switches the
// live view to the sub (low-resolution) stream.
SubSPSNALUs [][]byte `json:"sub_sps_nalus,omitempty" bson:"sub_sps_nalus,omitempty"`
SubPPSNALUs [][]byte `json:"sub_pps_nalus,omitempty" bson:"sub_pps_nalus,omitempty"`
SubVPSNALUs [][]byte `json:"sub_vps_nalus,omitempty" bson:"sub_vps_nalus,omitempty"`
SampleRate int `json:"sample_rate,omitempty" bson:"sample_rate,omitempty"`
Channels int `json:"channels,omitempty" bson:"channels,omitempty"`
}
// USBCamera configuration, such as the device path (/dev/video*)

View File

@@ -6,20 +6,96 @@ import (
"encoding/base64"
"encoding/json"
"encoding/pem"
"io"
"strings"
"errors"
"sync"
"time"
"github.com/gofrs/uuid"
"github.com/kerberos-io/agent/machinery/src/encryption"
"github.com/kerberos-io/agent/machinery/src/log"
log "github.com/sirupsen/logrus"
)
var (
errMQTTPrivateKeyPEMDecode = errors.New("error decoding PEM block containing private key")
errMQTTPrivateKeyNotRSA = errors.New("private key is not RSA")
rsaPrivateKeyCache = newRSAPrivateKeyCache()
parsePKCS8PrivateKey = x509.ParsePKCS8PrivateKey
)
type rsaPrivateKeyCacheState struct {
mu sync.Mutex
cond *sync.Cond
pem string
key *rsa.PrivateKey
err error
parsing bool
ready bool
}
func newRSAPrivateKeyCache() *rsaPrivateKeyCacheState {
cache := &rsaPrivateKeyCacheState{}
cache.cond = sync.NewCond(&cache.mu)
return cache
}
func (cache *rsaPrivateKeyCacheState) get(privateKey string) (*rsa.PrivateKey, error) {
cache.mu.Lock()
for {
if cache.ready && cache.pem == privateKey && !cache.parsing {
key, err := cache.key, cache.err
cache.mu.Unlock()
return key, err
}
if cache.parsing {
cache.cond.Wait()
continue
}
cache.pem = privateKey
cache.key = nil
cache.err = nil
cache.parsing = true
cache.ready = false
cache.mu.Unlock()
key, err := parseRSAPrivateKey(privateKey)
cache.mu.Lock()
cache.key = key
cache.err = err
cache.parsing = false
cache.ready = true
cache.cond.Broadcast()
cache.mu.Unlock()
return key, err
}
}
func parseRSAPrivateKey(privateKey string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(privateKey))
if block == nil {
return nil, errMQTTPrivateKeyPEMDecode
}
key, err := parsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, errMQTTPrivateKeyNotRSA
}
return rsaKey, nil
}
func PackageMQTTMessage(configuration *Configuration, msg Message) ([]byte, error) {
// Create a Version 4 UUID.
u2, err := uuid.NewV4()
if err != nil {
log.Log.Error("failed to generate UUID: " + err.Error())
log.Error("failed to generate UUID: " + err.Error())
}
// We'll generate an unique id, and encrypt / decrypt it using the private key if available.
@@ -45,27 +121,25 @@ func PackageMQTTMessage(configuration *Configuration, msg Message) ([]byte, erro
// Pload to base64
data, err := json.Marshal(pload)
if err != nil {
log.Log.Error("models.mqtt.PackageMQTTMessage(): failed to marshal payload: " + err.Error())
log.WithError(err).WithFields(log.Fields{
"component": "models/mqtt",
"event": "payload_marshal_failed",
}).Error("Failed to marshal MQTT payload")
}
// Encrypt the value
privateKey := configuration.Config.Encryption.PrivateKey
r := strings.NewReader(privateKey)
pemBytes, _ := io.ReadAll(r)
block, _ := pem.Decode(pemBytes)
if block == nil {
log.Log.Error("models.mqtt.PackageMQTTMessage(): error decoding PEM block containing private key")
} else {
// Parse private key
b := block.Bytes
key, err := x509.ParsePKCS8PrivateKey(b)
if err != nil {
log.Log.Error("models.mqtt.PackageMQTTMessage(): error parsing private key: " + err.Error())
rsaKey, err := rsaPrivateKeyCache.get(privateKey)
if err != nil {
if errors.Is(err, errMQTTPrivateKeyPEMDecode) {
log.Error("models.mqtt.PackageMQTTMessage(): error decoding PEM block containing private key")
} else {
log.WithError(err).WithFields(log.Fields{
"component": "models/mqtt",
"event": "private_key_parse_failed",
}).Error("Failed to parse MQTT private key")
}
// Conver key to *rsa.PrivateKey
rsaKey, _ := key.(*rsa.PrivateKey)
} else {
// Create a 16bit key random
if config.Encryption != nil && config.Encryption.SymmetricKey != "" {
k := config.Encryption.SymmetricKey
@@ -150,6 +224,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 +249,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.
@@ -171,6 +267,34 @@ type UpdateConfigPayload struct {
// We received a request SD stream request
type RequestSDStreamPayload struct {
Timestamp int64 `json:"timestamp"` // timestamp
// Transport selects how the agent should deliver the preview frames for this
// viewer. "http" asks the agent to POST frames to hub-api (keeping them off
// MQTT); empty/absent means the legacy MQTT image push. Older agents simply
// ignore this unknown field and keep doing MQTT, and older frontends never set
// it — so new/old agents and frontends interoperate in every combination.
Transport string `json:"transport,omitempty"`
}
// Stream quality tiers a viewer can request for the live (HD) view. The agent
// maps these onto the camera's main (high-resolution) or sub (low-resolution)
// RTSP stream, so a viewer can pick the resolution it needs instead of the agent
// always preferring the sub stream. Empty/unknown values are treated as "auto"
// for backward compatibility: older frontends that never set a quality keep the
// previous behaviour (sub stream when available, otherwise main).
const (
StreamQualityAuto = "auto" // agent decides based on availability/resolution
StreamQualityHigh = "high" // main stream (highest resolution)
StreamQualityLow = "low" // sub stream (lowest resolution)
)
// We received a live HLS stream request. Like SD it is a simple viewer
// keepalive: the agent owns the live HLS session, so the request only needs to
// signal "a viewer is watching" to keep the segment pipeline alive. Quality lets
// the viewer ask for the main (high) or sub (low) stream on demand; the agent
// switches the live session's source stream when it changes.
type RequestHLSStreamPayload struct {
Timestamp int64 `json:"timestamp"` // timestamp
Quality string `json:"quality,omitempty"` // "auto" | "high" | "low" (empty => auto)
}
// We received a request HD stream request
@@ -179,6 +303,7 @@ type RequestHDStreamPayload struct {
HubKey string `json:"hub_key"` // hub key
SessionID string `json:"session_id"` // session id
SessionDescription string `json:"session_description"` // session description
Quality string `json:"quality,omitempty"` // "auto" | "high" | "low" (empty => auto)
}
// We received a receive HD candidates request

View File

@@ -0,0 +1,199 @@
package models
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestPackageMQTTMessageCachesCurrentSigningKey(t *testing.T) {
resetMQTTPrivateKeyCache(t)
privateKey := generatePKCS8RSAPrivateKeyPEM(t)
var parseCalls atomic.Int32
restore := hookPKCS8PrivateKeyParser(t, func(der []byte) (any, error) {
parseCalls.Add(1)
return x509.ParsePKCS8PrivateKey(der)
})
defer restore()
msg := Message{
Payload: Payload{
DeviceId: "device-1",
Value: map[string]interface{}{
"hello": "world",
},
},
}
first, err := PackageMQTTMessage(encryptedConfiguration(privateKey), msg)
if err != nil {
t.Fatalf("first PackageMQTTMessage() error = %v", err)
}
assertEncryptedMQTTMessage(t, first)
second, err := PackageMQTTMessage(encryptedConfiguration(privateKey), msg)
if err != nil {
t.Fatalf("second PackageMQTTMessage() error = %v", err)
}
assertEncryptedMQTTMessage(t, second)
if got := parseCalls.Load(); got != 1 {
t.Fatalf("cached parse calls = %d, want 1", got)
}
}
func TestPackageMQTTMessageReplacesCachedSigningKeyOnRotation(t *testing.T) {
resetMQTTPrivateKeyCache(t)
privateKey := generatePKCS8RSAPrivateKeyPEM(t)
rotatedPrivateKey := generatePKCS8RSAPrivateKeyPEM(t)
var parseCalls atomic.Int32
restore := hookPKCS8PrivateKeyParser(t, func(der []byte) (any, error) {
parseCalls.Add(1)
return x509.ParsePKCS8PrivateKey(der)
})
defer restore()
msg := Message{
Payload: Payload{
DeviceId: "device-1",
Value: map[string]interface{}{
"hello": "world",
},
},
}
for _, key := range []string{privateKey, rotatedPrivateKey, privateKey} {
payload, err := PackageMQTTMessage(encryptedConfiguration(key), msg)
if err != nil {
t.Fatalf("PackageMQTTMessage() error = %v", err)
}
assertEncryptedMQTTMessage(t, payload)
}
if got := parseCalls.Load(); got != 3 {
t.Fatalf("parse calls across old/new/old rotation = %d, want 3", got)
}
}
func TestPackageMQTTMessageConcurrentCallsReuseCachedSigningKey(t *testing.T) {
resetMQTTPrivateKeyCache(t)
privateKey := generatePKCS8RSAPrivateKeyPEM(t)
var parseCalls atomic.Int32
restore := hookPKCS8PrivateKeyParser(t, func(der []byte) (any, error) {
parseCalls.Add(1)
time.Sleep(10 * time.Millisecond)
return x509.ParsePKCS8PrivateKey(der)
})
defer restore()
msg := Message{
Payload: Payload{
DeviceId: "device-1",
Value: map[string]interface{}{
"hello": "world",
},
},
}
start := make(chan struct{})
var workers sync.WaitGroup
for i := 0; i < 16; i++ {
workers.Add(1)
go func() {
defer workers.Done()
<-start
payload, err := PackageMQTTMessage(encryptedConfiguration(privateKey), msg)
if err != nil {
t.Errorf("PackageMQTTMessage() error = %v", err)
return
}
assertEncryptedMQTTMessage(t, payload)
}()
}
close(start)
workers.Wait()
if got := parseCalls.Load(); got != 1 {
t.Fatalf("concurrent parse calls = %d, want 1", got)
}
}
func encryptedConfiguration(privateKey string) *Configuration {
return &Configuration{
Config: Config{
Encryption: &Encryption{
Enabled: "true",
PrivateKey: privateKey,
SymmetricKey: "secret",
},
},
}
}
func generatePKCS8RSAPrivateKeyPEM(t *testing.T) string {
t.Helper()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey() error = %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
t.Fatalf("x509.MarshalPKCS8PrivateKey() error = %v", err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
}
func assertEncryptedMQTTMessage(t *testing.T, payload []byte) {
t.Helper()
var got Message
if err := json.Unmarshal(payload, &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if !got.Encrypted {
t.Fatal("message is not marked encrypted")
}
if got.Payload.EncryptedValue == "" {
t.Fatal("encrypted value is empty")
}
if got.Payload.Signature == "" {
t.Fatal("signature is empty")
}
if len(got.Payload.Value) != 0 {
t.Fatalf("payload value = %#v, want cleared map", got.Payload.Value)
}
}
func hookPKCS8PrivateKeyParser(t *testing.T, parser func([]byte) (any, error)) func() {
t.Helper()
original := parsePKCS8PrivateKey
parsePKCS8PrivateKey = parser
return func() {
parsePKCS8PrivateKey = original
}
}
func resetMQTTPrivateKeyCache(t *testing.T) {
t.Helper()
rsaPrivateKeyCache = newRSAPrivateKeyCache()
}

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,40 @@
package models
// SelectSubStreamForQuality decides whether the live (HD) view should be served
// from the sub (secondary) RTSP stream for the requested quality tier.
//
// It is resolution-aware: "high" picks whichever configured stream has the
// higher resolution and "low" whichever has the lower resolution, regardless of
// which one is wired as main vs sub. "auto" — the default, also used for the
// empty/unknown value sent by older frontends that never set a quality — keeps
// the historical behaviour of preferring the sub stream when one is available
// (lower bitrate, browser friendly), falling back to the main stream otherwise.
//
// When no sub stream is configured the main stream is always used.
func SelectSubStreamForQuality(config Config, quality string, subStreamEnabled bool) bool {
if !subStreamEnabled {
return false
}
cam := config.Capture.IPCamera
mainPixels := cam.Width * cam.Height
subPixels := cam.SubWidth * cam.SubHeight
switch quality {
case StreamQualityHigh:
// Highest resolution available. If the sub stream is (unusually) larger,
// use it; otherwise use the main stream. When dimensions are not yet known
// (0), default to the main stream for "high".
return subPixels > mainPixels
case StreamQualityLow:
// Lowest resolution available. If the main stream is (unusually) the
// smaller of the two, use it; otherwise use the sub stream. When the sub
// dimensions are unknown, still prefer the sub stream for "low".
if mainPixels > 0 && subPixels > 0 && mainPixels < subPixels {
return false
}
return true
default: // StreamQualityAuto, empty, or any unknown value
return true
}
}

View File

@@ -0,0 +1,53 @@
package models
import "testing"
func cfgWithDims(mainW, mainH, subW, subH int) Config {
c := Config{}
c.Capture.IPCamera.Width = mainW
c.Capture.IPCamera.Height = mainH
c.Capture.IPCamera.SubWidth = subW
c.Capture.IPCamera.SubHeight = subH
return c
}
func TestSelectSubStreamForQuality(t *testing.T) {
tests := []struct {
name string
config Config
quality string
subStreamEnabled bool
wantSub bool
}{
// No sub stream configured -> always the main stream.
{"no sub, auto", cfgWithDims(1920, 1080, 0, 0), StreamQualityAuto, false, false},
{"no sub, high", cfgWithDims(1920, 1080, 0, 0), StreamQualityHigh, false, false},
{"no sub, low", cfgWithDims(1920, 1080, 0, 0), StreamQualityLow, false, false},
// Typical config: main is the bigger stream, sub the smaller one.
{"auto prefers sub", cfgWithDims(1920, 1080, 640, 480), StreamQualityAuto, true, true},
{"empty prefers sub", cfgWithDims(1920, 1080, 640, 480), "", true, true},
{"unknown prefers sub", cfgWithDims(1920, 1080, 640, 480), "potato", true, true},
{"high picks main", cfgWithDims(1920, 1080, 640, 480), StreamQualityHigh, true, false},
{"low picks sub", cfgWithDims(1920, 1080, 640, 480), StreamQualityLow, true, true},
// Dimensions not probed yet (0): high defaults to main, low/auto to sub.
{"unknown dims, high", cfgWithDims(0, 0, 0, 0), StreamQualityHigh, true, false},
{"unknown dims, low", cfgWithDims(0, 0, 0, 0), StreamQualityLow, true, true},
{"unknown dims, auto", cfgWithDims(0, 0, 0, 0), StreamQualityAuto, true, true},
// Inverted config: sub is (unusually) the higher-resolution stream.
{"inverted high picks sub", cfgWithDims(640, 480, 1920, 1080), StreamQualityHigh, true, true},
{"inverted low picks main", cfgWithDims(640, 480, 1920, 1080), StreamQualityLow, true, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := SelectSubStreamForQuality(tt.config, tt.quality, tt.subStreamEnabled)
if got != tt.wantSub {
t.Errorf("SelectSubStreamForQuality(quality=%q, subEnabled=%v) = %v, want %v",
tt.quality, tt.subStreamEnabled, got, tt.wantSub)
}
})
}
}

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/models"
log "github.com/sirupsen/logrus"
)
// 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.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.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.Error("onvif.targetsFromSubnets(): invalid address '" + subnet + "'")
}
continue
}
_, ipNet, err := net.ParseCIDR(subnet)
if err != nil || ipNet.IP.To4() == nil {
log.Error("onvif.targetsFromSubnets(): invalid CIDR '" + subnet + "'")
continue
}
if ones, bits := ipNet.Mask.Size(); bits != 32 || ones < 22 {
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.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
}

Some files were not shown because too many files have changed in this diff Show More