Files
agent/machinery/src/cloud/tus_client.go
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

397 lines
14 KiB
Go

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/log"
"github.com/kerberos-io/agent/machinery/src/models"
)
// 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"
}
// uploadVaultResumable uploads a recording to a Kerberos Vault using the tus
// resumable upload protocol.
//
// Return values:
// - uploaded: the recording was fully received and persisted by the vault.
// - responded: the vault returned a definitive HTTP response (used by the
// caller to advance its retry/secondary-failover policy).
// - supported: the vault exposes a tus endpoint. When false, the caller should
// fall back to the legacy single-POST upload (older vault deployments).
// - body: a short message for logging.
func uploadVaultResumable(vault models.KStorage, publicKey, deviceKey, fileName, label, slot string) (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.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()
baseURL := strings.TrimRight(vault.URI, "/") + tusUploadPath
client := newVaultHTTPClient(0)
metadata := encodeTusMetadata(map[string]string{
"filename": fileName,
"device": deviceKey,
"directory": vault.Directory,
"provider": vault.Provider,
"capture": "IPCamera",
"cloudkey": publicKey,
})
sidecar := tusSidecarPath(fileName, slot)
uploadURL := loadTusResumeState(sidecar, baseURL)
const maxAttempts = 4
restartedAfterComplete := false
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, vault, publicKey, deviceKey, fileName)
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.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, vault, publicKey, deviceKey)
if herr != nil {
if status == http.StatusNotFound || status == http.StatusGone {
// The upload expired/was removed server-side; start over.
removeTusResumeState(sidecar)
uploadURL = ""
continue
}
log.Log.Info(label + ": resumable head failed, " + herr.Error())
tusBackoff(attempt)
continue
}
// (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, vault, publicKey, deviceKey)
removeTusResumeState(sidecar)
uploadURL = ""
restartedAfterComplete = true
continue
}
// (4) Stream the remaining bytes in a single PATCH directly from disk.
if _, sErr := file.Seek(offset, io.SeekStart); sErr != nil {
return false, false, true, "", sErr
}
newOffset, status, respBody, perr := tusPatch(client, uploadURL, offset, size, file, vault, publicKey, deviceKey)
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.Log.Info(label + ": resumable patch rejected, " + perr.Error())
} else {
log.Log.Info(label + ": resumable patch failed, " + perr.Error())
}
tusBackoff(attempt)
continue
}
if newOffset >= size {
removeTusResumeState(sidecar)
return true, true, true, respBody, nil
}
// Partial progress: persist and continue with the next chunk.
saveTusResumeState(sidecar, tusResumeState{UploadURL: uploadURL, VaultURI: baseURL, Size: size})
}
return false, true, true, "resumable upload did not complete after retries", errors.New(label + ": resumable upload did not complete after retries")
}
// 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, vault models.KStorage, publicKey, deviceKey, 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)
}
setVaultTusHeaders(req.Header, vault, publicKey, deviceKey, 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, vault models.KStorage, publicKey, deviceKey string) (int64, int, error) {
req, err := http.NewRequest("HEAD", uploadURL, nil)
if err != nil {
return 0, 0, err
}
req.Header.Set("Tus-Resumable", tusResumableVersion)
setVaultTusHeaders(req.Header, vault, publicKey, deviceKey, "")
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 the remaining bytes of the file (from offset to size) 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, size int64, file io.Reader, vault models.KStorage, publicKey, deviceKey string) (int64, int, string, error) {
remaining := size - offset
req, err := http.NewRequest("PATCH", uploadURL, io.LimitReader(file, remaining))
if err != nil {
return offset, 0, "", err
}
req.ContentLength = remaining
req.Header.Set("Tus-Resumable", tusResumableVersion)
req.Header.Set("Content-Type", "application/offset+octet-stream")
req.Header.Set("Upload-Offset", strconv.FormatInt(offset, 10))
setVaultTusHeaders(req.Header, vault, publicKey, deviceKey, "")
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 the upload finished.
return size, 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, vault models.KStorage, publicKey, deviceKey string) {
req, err := http.NewRequest("DELETE", uploadURL, nil)
if err != nil {
return
}
req.Header.Set("Tus-Resumable", tusResumableVersion)
setVaultTusHeaders(req.Header, vault, publicKey, deviceKey, "")
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)
}
}
// 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)
}
// tusBackoff sleeps for an exponentially increasing duration (capped) between
// resume attempts to avoid hammering a temporarily unavailable vault.
func tusBackoff(attempt int) {
delay := time.Duration(500*(1<<uint(attempt))) * time.Millisecond
if delay > 3*time.Second {
delay = 3 * time.Second
}
time.Sleep(delay)
}