Compare commits

...

11 Commits

Author SHA1 Message Date
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
5 changed files with 229 additions and 1 deletions

View File

@@ -65,6 +65,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)
@@ -301,6 +302,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...

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

@@ -22,4 +22,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

@@ -32,6 +32,24 @@ const MacEpochOffset uint64 = 2082844800
// resulting in ~3 second fragments (assuming a typical GOP interval).
const FragmentDurationMs = 3000
// MinNormalGOPMs is the maximum spacing between two consecutive IDRs that
// we still consider an anomalous "loop/restart seam". When two keyframes
// arrive closer than this, we treat the second one as an upstream
// restart/loop-seam and force a fresh fragment so the seam IDR cannot end
// up as a mid-fragment sync sample. The check only runs when the current
// fragment has not yet reached FragmentDurationMs.
//
// This must be set well below the smallest plausible *legitimate* GOP
// length. Typical IP cameras use GOP intervals of 1000-2000 ms, and the
// arrival timing of consecutive IDRs can jitter by a few hundred ms due to
// network/RTSP buffering. A threshold close to 1 s (e.g. 950) caused
// false positives on cameras with ~1 s GOPs (warnings like
// "gap=800 ms / 300 ms / 200 ms" while the stream itself was healthy).
// 400 ms is comfortably below any realistic GOP yet still catches the
// virtual-rtsp / ffmpeg loop-seam pattern (seam IDRs typically arrive
// 100-200 ms after the prior IDR).
const MinNormalGOPMs = 400
type MP4 struct {
// FileName is the name of the file
FileName string
@@ -56,6 +74,8 @@ type MP4 struct {
FreeBoxSize int64
FragmentStartRawPTS uint64 // Raw PTS for timing when to flush fragments
FragmentStartDTS uint64 // Accumulated VideoTotalDuration at fragment start (matches tfdt)
LastKeyframeRawPTS uint64 // Raw PTS of the most recently seen keyframe (in any fragment)
LastKeyframeGapMs uint64 // Gap (ms) between the previous two consecutive keyframes
MoofBoxes int64 // Number of moof boxes in the file
MoofBoxSizes []int64 // Sizes of each moof box
SegmentDurations []uint64 // Duration of each segment in timescale units
@@ -233,6 +253,24 @@ func (mp4 *MP4) flushPendingVideoSample(nextPTS uint64) bool {
var duration uint64
if nextPTS > 0 && nextPTS > mp4.VideoFullSample.DecodeTime {
duration = nextPTS - mp4.VideoFullSample.DecodeTime
// Guard against forward PTS jumps (e.g. when looping a source MP4
// through virtual-rtsp the upstream ffmpeg may insert a large offset
// at the loop boundary, or the RTSP stream may stall briefly).
// Without this clamp the sample gets a huge duration which appears
// as a discontinuity in the trun/sidx/mvhd and causes browsers
// (Video.js / MSE) to abort playback with a "media corruption"
// error around the loop boundary.
var maxPlausible uint64 = 1000 // 1 second hard ceiling
if mp4.LastVideoSampleDTS > 0 && mp4.LastVideoSampleDTS*10 < maxPlausible {
maxPlausible = mp4.LastVideoSampleDTS * 10
}
if duration > maxPlausible {
log.Log.Warning(fmt.Sprintf("mp4.flushPendingVideoSample(): video PTS jumped forward (nextPTS=%d, prevDTS=%d, gap=%d ms) - clamping to %d ms (likely source loop/stall discontinuity)", nextPTS, mp4.VideoFullSample.DecodeTime, duration, maxPlausible))
duration = mp4.LastVideoSampleDTS
if duration == 0 {
duration = 33
}
}
} else {
// No valid nextPTS (Close case) or PTS went backwards (jitter/discontinuity)
if nextPTS > 0 {
@@ -289,6 +327,37 @@ func (mp4 *MP4) AddSampleToTrack(trackID uint32, isKeyframe bool, data []byte, p
}
shouldFlush := !mp4.Start || elapsed >= FragmentDurationMs
// Detect upstream source-loop / restart discontinuity. When an MP4 is
// looped through virtual-rtsp (ffmpeg `-stream_loop -1 -re`) the loop
// seam emits a fresh IDR much sooner than a normal GOP would. PTS keeps
// growing monotonically, so the timing-only `elapsed` check above does
// not catch it and the seam IDR ends up as a mid-fragment sync sample.
// MSE-based players (Video.js / Chromium / Firefox) reject the resulting
// fragment with a "media corruption" error because the inner IDR resets
// frame_num/POC inside what they expect to be a single GOP. Force a
// fragment boundary whenever two consecutive keyframes arrive much
// closer than a normal GOP.
//
// We only flag this as a seam when it is a *sudden* anomaly: the
// previous keyframe gap must have been healthy (>= MinNormalGOPMs).
// This avoids false positives on cameras that legitimately emit
// short-interval IDRs (short GOP, motion-triggered recovery IDRs,
// all-intra streams) where every keyframe would otherwise be flagged
// in a cascade, producing many tiny fragments and log spam.
if trackID == uint32(mp4.VideoTrack) && mp4.Start &&
mp4.LastKeyframeRawPTS > 0 && pts > mp4.LastKeyframeRawPTS {
gap := pts - mp4.LastKeyframeRawPTS
if !shouldFlush && gap < MinNormalGOPMs &&
(mp4.LastKeyframeGapMs == 0 || mp4.LastKeyframeGapMs >= MinNormalGOPMs) {
log.Log.Warning(fmt.Sprintf("mp4.AddSampleToTrack(): forcing fragment flush at unexpectedly close keyframe (gap=%d ms, fragment elapsed=%d ms) - likely upstream loop/restart discontinuity", gap, elapsed))
shouldFlush = true
}
mp4.LastKeyframeGapMs = gap
}
if trackID == uint32(mp4.VideoTrack) {
mp4.LastKeyframeRawPTS = pts
}
if shouldFlush {
// Write the previous segment to the file
if mp4.Start {
@@ -393,6 +462,14 @@ func (mp4 *MP4) AddSampleToTrack(trackID uint32, isKeyframe bool, data []byte, p
if started {
dts = 1
}
// Guard against forward PTS jumps (e.g. virtual-rtsp loop
// boundary or upstream stalls). Without this clamp the
// audio trun would carry an enormous sample duration that
// renders the recording unplayable in browsers.
if mp4.LastAudioSampleDTS > 0 && dts > mp4.LastAudioSampleDTS*10 {
log.Log.Warning(fmt.Sprintf("mp4.AddSampleToTrack(): audio PTS jumped forward (pts=%d, prevDTS=%d, gap=%d) - clamping to last known duration", pts, mp4.AudioFullSample.DecodeTime, dts))
dts = mp4.LastAudioSampleDTS
}
mp4.LastAudioSampleDTS = dts
//fmt.Printf("Adding sample to track %d, PTS: %d, Duration: %d, size: %d\n", trackID, pts, dts, len(aac[7:]))
mp4.AudioTotalDuration += dts

View File

@@ -0,0 +1,102 @@
package video
import (
"fmt"
"os"
"testing"
mp4ff "github.com/Eyevinn/mp4ff/mp4"
"github.com/kerberos-io/agent/machinery/src/models"
)
// TestMP4LoopSeamIsolation reproduces the loop-seam pattern from the
// failing virtual-rtsp recordings: ~1s GOPs, but at the source-MP4
// loop boundary an IDR arrives prematurely (~200-870ms after the
// previous IDR). Without the fix this seam IDR ends up bunched into
// the same fragment as the prior GOP's IDR which trips macOS
// VideoToolbox (kVTVideoDecoderBadDataErr / -12909). The fix forces
// a fragment flush whenever two IDRs arrive closer than MinNormalGOPMs.
func TestMP4LoopSeamIsolation(t *testing.T) {
tmpFile := "/tmp/test_loop_seam.mp4"
defer os.Remove(tmpFile)
sps := []byte{0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x00, 0xa0, 0x47, 0xfe, 0xc8}
pps := []byte{0x68, 0xce, 0x38, 0x80}
mp4Video := NewMP4(tmpFile, [][]byte{sps}, [][]byte{pps}, nil, 30)
mp4Video.SetWidth(1920)
mp4Video.SetHeight(1080)
v := mp4Video.AddVideoTrack("H264")
mk := func(k bool) []byte {
nt := byte(0x01)
if k {
nt = 0x65
}
f := []byte{0, 0, 0, 1, nt}
for i := 0; i < 200; i++ {
f = append(f, byte(i))
}
return f
}
frameDur := uint64(33)
pts := uint64(0)
emit := func(n int, gopLen int) {
for f := 0; f < n; f++ {
isKey := (f % gopLen) == 0
mp4Video.AddSampleToTrack(v, isKey, mk(isKey), pts)
pts += frameDur
}
}
// 17 seconds of normal content (last "good" IDR at sec 17).
emit(17*30, 30)
// Seam: IDR arrives ~150ms after previous (vs normal ~1000ms).
// This matches the realistic virtual-rtsp / ffmpeg `-stream_loop`
// loop boundary, where the new clip's first IDR is emitted shortly
// after the previous clip's final IDR.
pts -= 820
emit(13*30, 30)
mp4Video.Close(&models.Config{Signing: &models.Signing{PrivateKey: ""}})
f, _ := os.Open(tmpFile)
defer f.Close()
parsed, err := mp4ff.DecodeFile(f)
if err != nil {
t.Fatalf("decode: %v", err)
}
fragIdx := 0
for _, seg := range parsed.Segments {
for _, fr := range seg.Fragments {
for _, traf := range fr.Moof.Trafs {
if traf.Tfhd.TrackID != 1 {
continue
}
tfdt := traf.Tfdt.BaseMediaDecodeTime()
offset := uint64(0)
var keys []uint64
for _, trun := range traf.Truns {
for _, s := range trun.Samples {
if (s.Flags>>24)&0x03 == 0x02 {
keys = append(keys, offset)
}
offset += uint64(s.Dur)
}
}
fmt.Printf("frag %d tfdt=%d samples_dur=%d keys@%v\n",
fragIdx, tfdt, offset, keys)
for i := 1; i < len(keys); i++ {
gap := keys[i] - keys[i-1]
if gap < MinNormalGOPMs {
t.Errorf("frag %d (tfdt=%d): two IDRs only %d ms apart "+
"in same fragment (< %d) - seam was not isolated",
fragIdx, tfdt, gap, MinNormalGOPMs)
}
}
fragIdx++
}
}
}
}