mirror of
https://github.com/kerberos-io/agent.git
synced 2026-11-05 20:20:23 +00:00
Compare commits
35
Commits
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"forwardPorts": [
|
||||
3000,
|
||||
8080
|
||||
8082
|
||||
],
|
||||
"postCreateCommand": "cd ui && yarn install && yarn build && cd ../machinery && go mod download && bash ./verify-moq-devcontainer.sh"
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# Only application sources are needed by the production build.
|
||||
**
|
||||
!Dockerfile
|
||||
!machinery/
|
||||
!machinery/**
|
||||
!ui/
|
||||
!ui/**
|
||||
!deployments/
|
||||
deployments/*
|
||||
!deployments/docker/
|
||||
deployments/docker/*
|
||||
!deployments/docker/assemble-rootfs.sh
|
||||
|
||||
**/.git
|
||||
**/.env
|
||||
**/.env.*
|
||||
**/.git-credentials
|
||||
**/.netrc
|
||||
**/.npmrc
|
||||
**/.yarnrc.yml
|
||||
**/.aws
|
||||
**/.ssh
|
||||
**/.kube
|
||||
**/node_modules
|
||||
**/.DS_Store
|
||||
**/*.test
|
||||
**/*.pprof
|
||||
**/coverage.out
|
||||
machinery/main
|
||||
machinery/vendor
|
||||
machinery/go.work
|
||||
machinery/go.work.sum
|
||||
machinery/www
|
||||
machinery/data/**
|
||||
!machinery/data/config/
|
||||
!machinery/data/config/config.json
|
||||
!machinery/data/config/user.json
|
||||
!machinery/data/test-480p.mp4
|
||||
ui/build
|
||||
ui/coverage
|
||||
ui/.npmrc
|
||||
ui/.yarnrc
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate BuildKit provenance and retain resolved base-image materials."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def materials(value):
|
||||
if isinstance(value, dict):
|
||||
if "uri" in value and value["uri"].startswith(("pkg:docker/", "docker-image://")):
|
||||
digest = value.get("digest", {}).get("sha256", "")
|
||||
if not re.fullmatch(r"[a-f0-9]{64}", digest):
|
||||
raise ValueError(f"Missing base-image digest: {value['uri']}")
|
||||
yield value
|
||||
for child in value.values():
|
||||
yield from materials(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
yield from materials(child)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
directory = Path(sys.argv[1])
|
||||
provenance = json.loads((directory / "provenance.json").read_text())
|
||||
bases = list(materials(provenance))
|
||||
if not bases:
|
||||
raise SystemExit("Missing provenance/base-image materials")
|
||||
(directory / "base-image-digests.json").write_text(json.dumps(bases, indent=2) + "\n")
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed source/application and image scans with redacted evidence."""
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def secret_identity(target, finding):
|
||||
return (target, finding.get("RuleID"), finding.get("Category"),
|
||||
finding.get("Severity"), finding.get("StartLine"), finding.get("EndLine"))
|
||||
|
||||
|
||||
def approve_shared_key(report, source_root, policy, now=None):
|
||||
"""Bind the one reviewed exception to an unchanged file and exact finding."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
records = policy.get("exceptions")
|
||||
if not isinstance(records, list) or len(records) != 1:
|
||||
raise ValueError("Exactly one shared-key exception record is required")
|
||||
record = records[0]
|
||||
if not isinstance(record, dict) or record.get("id") != "shared-recording-signing-key":
|
||||
raise ValueError("Unexpected scan exception")
|
||||
if record.get("status") != "approved" or not all(
|
||||
isinstance(record.get(key), str) and record[key].strip()
|
||||
for key in ("owner", "approved_by", "reason", "approval_url", "expires")
|
||||
):
|
||||
raise ValueError("Shared-key exception lacks approval/owner/reason/evidence/expiry")
|
||||
if record["approval_url"] != "https://github.com/kerberos-io/agent/pull/350":
|
||||
raise ValueError("Shared-key exception must reference its reviewed PR record")
|
||||
expires = datetime.fromisoformat(record["expires"].replace("Z", "+00:00"))
|
||||
if expires.tzinfo is None or expires <= now:
|
||||
raise ValueError("Shared-key exception expired or lacks timezone")
|
||||
if (record.get("target") != "machinery/src/config/main.go"
|
||||
or record.get("rule_id") != "private-key"
|
||||
or record.get("category") != "AsymmetricPrivateKey"
|
||||
or record.get("severity") != "HIGH"
|
||||
or type(record.get("start_line")) is not int
|
||||
or record["start_line"] < 1
|
||||
or record.get("end_line") != record["start_line"]):
|
||||
raise ValueError("Shared-key exception has an unexpected finding scope")
|
||||
root = Path(source_root).resolve()
|
||||
path = root / record["target"]
|
||||
if path.resolve() != path or hashlib.sha256(path.read_bytes()).hexdigest() != record.get("file_sha256"):
|
||||
raise ValueError("Shared-key source file changed or is a symlink; renewed review required")
|
||||
expected = (record["target"], record["rule_id"], record["category"],
|
||||
record["severity"], record["start_line"], record["end_line"])
|
||||
matches = [
|
||||
finding for result in report["Results"]
|
||||
for finding in result.get("Secrets", [])
|
||||
if secret_identity(result.get("Target"), finding) == expected
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise ValueError("Shared-key exception must match exactly one existing source finding")
|
||||
return [dict(record)]
|
||||
|
||||
|
||||
def check_report(report, image=False, source=False, approved_exceptions=()):
|
||||
if report.get("SchemaVersion") != 2 or not isinstance(report.get("Results"), list):
|
||||
raise ValueError("Missing or unsupported Trivy report")
|
||||
failures = []
|
||||
approved_secrets = {
|
||||
(record["target"], record["rule_id"], record["category"], record["severity"],
|
||||
record["start_line"], record["end_line"])
|
||||
for record in approved_exceptions
|
||||
} if source and not image else set()
|
||||
for result in report["Results"]:
|
||||
if result.get("Errors"):
|
||||
failures.append(f"Scan errors in {result.get('Target')}: {result['Errors']}")
|
||||
if result.get("MisconfSummary", {}).get("Exceptions", 0):
|
||||
failures.append(f"Unapproved misconfiguration exceptions in {result.get('Target')}")
|
||||
for kind in ("Vulnerabilities", "Secrets", "Misconfigurations"):
|
||||
for finding in result.get(kind, []):
|
||||
if kind == "Misconfigurations" and finding.get("Status") == "PASS":
|
||||
continue
|
||||
if kind == "Misconfigurations" and finding.get("Status") != "FAIL":
|
||||
failures.append(f"Unexpected/suppressed misconfiguration status: {finding.get('Status')}")
|
||||
severity = finding.get("Severity", "UNKNOWN")
|
||||
if kind == "Secrets" and secret_identity(result.get("Target"), finding) in approved_secrets:
|
||||
continue
|
||||
if severity not in ("LOW", "MEDIUM"):
|
||||
failures.append(f"{result.get('Target')}: {kind}: "
|
||||
f"{finding.get('VulnerabilityID', finding.get('ID', finding.get('RuleID')))} "
|
||||
f"{severity}")
|
||||
if image:
|
||||
# A scratch image without package metadata must not yield a false clean OS scan.
|
||||
if not report.get("Metadata", {}).get("OS", {}).get("Family"):
|
||||
failures.append("No OS detected: preserve runtime OS/package metadata")
|
||||
if not any(r.get("Class") == "os-pkgs" and r.get("Packages")
|
||||
for r in report["Results"]):
|
||||
failures.append("No OS package inventory detected")
|
||||
if not any(r.get("Type") == "gobinary" and r.get("Packages")
|
||||
for r in report["Results"]):
|
||||
failures.append("No Go application package inventory detected")
|
||||
if source and not any(
|
||||
r.get("Type") == "yarn" and r.get("Target", "").replace("\\", "/").endswith("ui/yarn.lock")
|
||||
and r.get("Packages") for r in report["Results"]
|
||||
):
|
||||
failures.append("Missing production UI yarn.lock package inventory")
|
||||
return failures
|
||||
|
||||
|
||||
def redact_report(value):
|
||||
"""Keep findings actionable without persisting matches, excerpts or image env/history."""
|
||||
if isinstance(value, list):
|
||||
return [redact_report(item) for item in value]
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
result = {}
|
||||
for key, child in value.items():
|
||||
if key.lower() in {"imageconfig", "buildinfo"}:
|
||||
continue
|
||||
if key.lower() in {
|
||||
"match", "code", "lines", "source", "sourcecode", "snippet", "context",
|
||||
"content",
|
||||
}:
|
||||
result[key] = "[REDACTED]"
|
||||
elif key == "Errors":
|
||||
result[key] = ["[REDACTED scanner error]"] if child else []
|
||||
elif key in ("Secrets", "Misconfigurations"):
|
||||
allowed = {
|
||||
"ID", "AVDID", "RuleID", "Type", "Category", "Title", "Severity",
|
||||
"Status", "StartLine", "EndLine", "References",
|
||||
}
|
||||
result[key] = [
|
||||
{field: finding[field] for field in allowed if field in finding}
|
||||
| {"SourceExcerpt": "[REDACTED]"}
|
||||
for finding in child
|
||||
]
|
||||
else:
|
||||
result[key] = redact_report(child)
|
||||
return result
|
||||
|
||||
|
||||
def scan(target, output, platform=None):
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
config = output / "scanner.yaml"
|
||||
ignore = output / "empty.ignore"
|
||||
secret_config = output / "secret.yaml"
|
||||
config.write_text("{}\n")
|
||||
secret_config.write_text("{}\n")
|
||||
ignore.write_text("")
|
||||
# Do not consume repository trivy.yaml/.trivyignore or TRIVY_* overrides.
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith("TRIVY_")}
|
||||
command = [
|
||||
"trivy", "image" if platform else "fs", "--config", str(config),
|
||||
"--cache-dir", ".ci-tools/trivy-cache",
|
||||
"--ignorefile", str(ignore), "--secret-config", str(secret_config),
|
||||
"--format", "json", "--exit-code", "0",
|
||||
"--scanners", "vuln,secret,misconfig",
|
||||
"--severity", "UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL", "--timeout", "20m",
|
||||
"--list-all-pkgs", "--detection-priority", "comprehensive",
|
||||
]
|
||||
if platform:
|
||||
command += ["--platform", platform, "--pkg-types", "os,library",
|
||||
"--image-config-scanners", "secret,misconfig"]
|
||||
else:
|
||||
# Include development/build dependencies too: none are silently exempted.
|
||||
# Trivy marks Dev packages using the adjacent package.json and yarn.lock.
|
||||
command += ["--pkg-types", "library", "--include-dev-deps"]
|
||||
command.append(target)
|
||||
# Never write raw scanner output, even on scanner failure/interruption. stderr
|
||||
# may contain source excerpts too; retain only a safe exit-status diagnostic.
|
||||
completed = subprocess.run(command, env=env, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, check=False)
|
||||
(output / "scanner.log").write_text(
|
||||
f"Trivy exit status: {completed.returncode}\n"
|
||||
"Raw scanner stdout/stderr omitted; scan.json is redacted before persistence.\n")
|
||||
if completed.returncode:
|
||||
raise ValueError(f"Trivy failed with exit status {completed.returncode}; raw diagnostics withheld")
|
||||
try:
|
||||
report = redact_report(json.loads(completed.stdout))
|
||||
except ValueError:
|
||||
raise ValueError("Invalid scanner JSON; raw output withheld") from None
|
||||
(output / "scan.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
approved = []
|
||||
exception_failures = []
|
||||
if not platform:
|
||||
try:
|
||||
policy = json.loads(Path(__file__).with_name("image-scan-exceptions.json").read_text())
|
||||
(output / "exception-policy.json").write_text(json.dumps(policy, indent=2) + "\n")
|
||||
approved = approve_shared_key(report, target, policy)
|
||||
except (OSError, ValueError, TypeError, KeyError) as error:
|
||||
exception_failures.append(f"Shared-key exception invalid: {error}")
|
||||
(output / "exception-decisions.json").write_text(json.dumps({
|
||||
"approved_exceptions": approved, "failures": exception_failures,
|
||||
}, indent=2) + "\n")
|
||||
failures = exception_failures + check_report(
|
||||
report, image=bool(platform), source=not platform, approved_exceptions=approved)
|
||||
if not platform:
|
||||
inventory = [{
|
||||
"target": result.get("Target"), "type": result.get("Type"),
|
||||
"packages": [{"name": pkg.get("Name"), "version": pkg.get("Version"),
|
||||
"scope": "development" if pkg.get("Dev") else "production-or-unclassified"}
|
||||
for pkg in result.get("Packages", [])],
|
||||
} for result in report["Results"] if result.get("Packages")]
|
||||
(output / "application-inventory.json").write_text(json.dumps({
|
||||
"policy": "All production and development/build dependencies are gated; "
|
||||
"only the reviewed, exact shared-key secret exception may apply.",
|
||||
"inventories": inventory,
|
||||
}, indent=2) + "\n")
|
||||
converted = subprocess.run([
|
||||
"trivy", "convert", "--cache-dir", ".ci-tools/trivy-cache",
|
||||
"--format", "spdx-json", "--output", str(output / "sbom.spdx.json"),
|
||||
str(output / "scan.json"),
|
||||
], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
|
||||
if converted.returncode:
|
||||
raise ValueError(f"SPDX conversion failed with exit status {converted.returncode}")
|
||||
sbom = json.loads((output / "sbom.spdx.json").read_text())
|
||||
if not sbom.get("spdxVersion") or not sbom.get("packages"):
|
||||
failures.append("Missing/empty SPDX SBOM")
|
||||
return failures
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("target", help="Repository root, or final image reference with --platform")
|
||||
parser.add_argument("output", type=Path, help="Directory for redacted reports and SPDX SBOM")
|
||||
parser.add_argument("--platform", help="Image platform, e.g. linux/amd64; omit for source/UI scanning")
|
||||
args = parser.parse_args()
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
approved = []
|
||||
try:
|
||||
failures = scan(args.target, args.output, args.platform)
|
||||
approved = json.loads((args.output / "exception-decisions.json").read_text())["approved_exceptions"]
|
||||
except (OSError, ValueError, subprocess.CalledProcessError) as error:
|
||||
failures = [f"Scanner/evidence error: {error}"]
|
||||
(args.output / "policy.json").write_text(json.dumps({
|
||||
"target": args.target, "platform": args.platform, "failures": failures,
|
||||
"approved_exceptions": approved, "passed": not failures,
|
||||
}, indent=2) + "\n")
|
||||
for failure in failures:
|
||||
print(failure, file=sys.stderr)
|
||||
return int(bool(failures))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"required_exceptions": [
|
||||
{
|
||||
"id": "remote-terminal-pty",
|
||||
"status": "pending",
|
||||
"reason": "The product retains functional remote terminal/PTY support, contrary to the no-shell baseline.",
|
||||
"owner": null,
|
||||
"expires": null,
|
||||
"approved_by": null,
|
||||
"approval_url": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Release-only policy gate. Approval records require a reviewed repository change."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
def check_policy(policy, now=None):
|
||||
now = now or datetime.now(timezone.utc)
|
||||
records = policy.get("required_exceptions")
|
||||
if not isinstance(records, list) or not records:
|
||||
return ["Required exception records are missing"]
|
||||
if {record.get("id") for record in records} != {"remote-terminal-pty"}:
|
||||
return ["Required remote-terminal-pty exception record is missing or unexpected"]
|
||||
failures = []
|
||||
for record in records:
|
||||
name = record["id"]
|
||||
if record.get("status") != "approved":
|
||||
failures.append(f"{name}: required exception is PENDING, not approved")
|
||||
continue
|
||||
if not all(isinstance(record.get(key), str) and record[key].strip()
|
||||
for key in ("owner", "approved_by", "reason", "approval_url", "expires")):
|
||||
failures.append(f"{name}: missing owner/approver/reason/approval evidence/expiry")
|
||||
continue
|
||||
if not record["approval_url"].startswith("https://github.com/kerberos-io/agent/"):
|
||||
failures.append(f"{name}: approval must reference this repository's reviewed record")
|
||||
try:
|
||||
expires = datetime.fromisoformat(record["expires"].replace("Z", "+00:00"))
|
||||
if expires.tzinfo is None or expires <= now:
|
||||
failures.append(f"{name}: approval expired or lacks timezone")
|
||||
except ValueError:
|
||||
failures.append(f"{name}: invalid approval expiry")
|
||||
return failures
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
failures = check_policy(json.loads(Path(sys.argv[1]).read_text()))
|
||||
except (OSError, ValueError, TypeError, KeyError) as error:
|
||||
failures = [f"Invalid release policy: {error}"]
|
||||
for failure in failures:
|
||||
print(failure, file=sys.stderr)
|
||||
sys.exit(bool(failures))
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"exceptions": [
|
||||
{
|
||||
"id": "shared-recording-signing-key",
|
||||
"status": "approved",
|
||||
"owner": "@cedricve",
|
||||
"approved_by": "@cedricve",
|
||||
"approval_url": "https://github.com/kerberos-io/agent/pull/350",
|
||||
"expires": "2026-12-05T23:59:59Z",
|
||||
"reason": "User approved temporary retention of the existing shared recording-signing key to preserve recording verification trust and startup behavior. Anyone possessing this shared private key can forge signatures under that identity.",
|
||||
"target": "machinery/src/config/main.go",
|
||||
"file_sha256": "e28a0bf6ae892559709e8ec534cfc7b7cb55180e33415461d966c1afe53ca25b",
|
||||
"rule_id": "private-key",
|
||||
"category": "AsymmetricPrivateKey",
|
||||
"severity": "HIGH",
|
||||
"start_line": 710,
|
||||
"end_line": 710
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# Public OCI index pins the platform-specific executable without GitHub API access.
|
||||
version=0.76.1
|
||||
image=ghcr.io/aquasecurity/trivy:0.76.1@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
|
||||
evidence_dir="${1:-evidence/scanner-install}"
|
||||
mkdir -p "$evidence_dir" .ci-tools
|
||||
stage=host-platform
|
||||
platform=
|
||||
cid=
|
||||
finish() {
|
||||
result=$?
|
||||
trap - EXIT
|
||||
if [[ -n "$cid" ]]; then
|
||||
docker rm -f "$cid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -f .ci-tools/trivy.new
|
||||
python3 - "$evidence_dir/install.json" "$result" "$stage" "$image" "$platform" "$version" <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
path, code, stage, image, platform, version = sys.argv[1:]
|
||||
record = {
|
||||
"status": "installed" if code == "0" else "failed",
|
||||
"exit_code": int(code), "stage": stage, "image": image, "platform": platform,
|
||||
"binary_source": "/usr/local/bin/trivy", "expected_version": version,
|
||||
}
|
||||
if code == "0":
|
||||
record["binary_sha256"] = hashlib.sha256(Path(".ci-tools/trivy").read_bytes()).hexdigest()
|
||||
Path(path).write_text(json.dumps(record, indent=2) + "\n")
|
||||
PY
|
||||
exit "$result"
|
||||
}
|
||||
trap finish EXIT
|
||||
case "$(uname -m)" in
|
||||
x86_64) platform=linux/amd64 ;;
|
||||
aarch64|arm64) platform=linux/arm64 ;;
|
||||
*) echo "Unsupported scanner host architecture" >&2; exit 1 ;;
|
||||
esac
|
||||
stage=pull
|
||||
docker pull --platform "$platform" "$image"
|
||||
stage=create
|
||||
cid=$(docker create --platform "$platform" "$image")
|
||||
stage=extract
|
||||
docker cp "$cid:/usr/local/bin/trivy" .ci-tools/trivy.new
|
||||
chmod 0755 .ci-tools/trivy.new
|
||||
stage=verify-version
|
||||
.ci-tools/trivy.new --version | grep -Fx "Version: $version"
|
||||
mv .ci-tools/trivy.new .ci-tools/trivy
|
||||
stage=publish-path
|
||||
if [[ -n "${GITHUB_PATH:-}" ]]; then
|
||||
echo "$PWD/.ci-tools" >> "$GITHUB_PATH"
|
||||
fi
|
||||
stage=complete
|
||||
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Focused release guard tests; no registry, signing, or scanner network access."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import copy
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
|
||||
|
||||
def load(name):
|
||||
spec = importlib.util.spec_from_file_location(name, ROOT / f"{name}.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
scanner = load("image-policy")
|
||||
release = load("image-release-policy")
|
||||
evidence = load("image-evidence")
|
||||
|
||||
|
||||
class ScanPolicyTests(unittest.TestCase):
|
||||
def report(self, severity=None, kind="Vulnerabilities"):
|
||||
result = {"Class": "os-pkgs", "Packages": [{"Name": "libc6"}]}
|
||||
if severity:
|
||||
result[kind] = [{"Severity": severity, "Status": "FAIL", "ID": "fixture"}]
|
||||
return {"SchemaVersion": 2, "Metadata": {"OS": {"Family": "debian"}},
|
||||
"Results": [result, {"Class": "lang-pkgs", "Type": "gobinary",
|
||||
"Packages": [{"Name": "stdlib"}]}]}
|
||||
|
||||
def test_low_and_medium_are_not_blocking(self):
|
||||
for severity in ("LOW", "MEDIUM"):
|
||||
self.assertEqual(scanner.check_report(self.report(severity), image=True), [])
|
||||
|
||||
def test_high_critical_unknown_and_unrecognized_block(self):
|
||||
for kind in ("Vulnerabilities", "Secrets", "Misconfigurations"):
|
||||
for severity in ("HIGH", "CRITICAL", "UNKNOWN", "invalid"):
|
||||
with self.subTest(kind=kind, severity=severity):
|
||||
self.assertTrue(scanner.check_report(self.report(severity, kind), image=True))
|
||||
|
||||
def test_missing_inventory_blocks_false_clean_scratch_scan(self):
|
||||
report = self.report()
|
||||
report["Metadata"] = {}
|
||||
report["Results"] = []
|
||||
self.assertEqual(len(scanner.check_report(report, image=True)), 3)
|
||||
|
||||
def test_empty_os_inventory_blocks(self):
|
||||
report = self.report()
|
||||
report["Results"][0]["Packages"] = []
|
||||
self.assertTrue(scanner.check_report(report, image=True))
|
||||
|
||||
def test_report_errors_and_suppression_summary_block(self):
|
||||
report = self.report()
|
||||
report["Results"][0]["Errors"] = ["fixture scanner error"]
|
||||
report["Results"][0]["MisconfSummary"] = {"Exceptions": 1}
|
||||
self.assertEqual(len(scanner.check_report(report)), 2)
|
||||
|
||||
def test_missing_or_unsupported_report_blocks(self):
|
||||
for report in ({}, {"SchemaVersion": 1, "Results": []}):
|
||||
with self.assertRaises(ValueError):
|
||||
scanner.check_report(report)
|
||||
|
||||
def test_suppressed_misconfiguration_blocks(self):
|
||||
report = self.report("LOW", "Misconfigurations")
|
||||
report["Results"][0]["Misconfigurations"][0]["Status"] = "EXCEPTION"
|
||||
self.assertTrue(scanner.check_report(report))
|
||||
|
||||
def test_scanner_errors_are_not_ignored(self):
|
||||
directory = unittest.mock.MagicMock()
|
||||
with patch.object(scanner.subprocess, "run",
|
||||
side_effect=subprocess.CalledProcessError(1, ["trivy"])):
|
||||
with self.assertRaises(subprocess.CalledProcessError):
|
||||
scanner.scan("fixture", directory, "linux/amd64")
|
||||
|
||||
def test_source_requires_ui_lockfile_inventory(self):
|
||||
report = self.report()
|
||||
self.assertTrue(scanner.check_report(report, source=True))
|
||||
report["Results"].append({"Type": "yarn", "Target": "ui/yarn.lock",
|
||||
"Packages": [{"Name": "react", "Version": "17.0.2"}]})
|
||||
self.assertEqual(scanner.check_report(report, source=True), [])
|
||||
report["Results"][-1]["Vulnerabilities"] = [{"Severity": "HIGH", "VulnerabilityID": "fixture"}]
|
||||
self.assertTrue(scanner.check_report(report, source=True))
|
||||
|
||||
def test_secret_and_source_excerpts_are_removed_without_waiving_findings(self):
|
||||
marker = "sensitive-fixture-text-not-a-real-credential"
|
||||
report = self.report("HIGH", "Secrets")
|
||||
report["Metadata"]["ImageConfig"] = {"config": {"Env": [marker]}}
|
||||
report["Metadata"]["BuildInfo"] = {"attrs": {"secret": marker}}
|
||||
report["Results"][0]["Secrets"][0].update(
|
||||
Match=marker, Code={"Lines": [{"Content": marker}]}, Message=marker,
|
||||
UnexpectedFutureExcerptField=marker, RuleID="private-key-fixture")
|
||||
report["Results"][0]["Misconfigurations"] = [{
|
||||
"ID": "fixture", "Severity": "HIGH", "Status": "FAIL",
|
||||
"Message": marker, "CauseMetadata": {"Code": {"Lines": [{"Content": marker}]}}
|
||||
}]
|
||||
report["Results"][0]["Errors"] = [marker]
|
||||
redacted = scanner.redact_report(report)
|
||||
self.assertNotIn(marker, json.dumps(redacted))
|
||||
self.assertNotIn("ImageConfig", redacted["Metadata"])
|
||||
self.assertTrue(scanner.check_report(redacted, image=True))
|
||||
self.assertEqual(redacted["Results"][0]["Secrets"][0]["RuleID"], "private-key-fixture")
|
||||
|
||||
def test_source_scan_captures_output_in_memory_and_generates_sbom(self):
|
||||
report = self.report()
|
||||
report["Results"].append({"Type": "yarn", "Target": "ui/yarn.lock",
|
||||
"Packages": [{"Name": "fixture", "Version": "1.0", "Dev": True}]})
|
||||
directory = unittest.mock.MagicMock()
|
||||
directory.__truediv__.return_value.read_text.return_value = json.dumps({
|
||||
"spdxVersion": "SPDX-2.3", "packages": [{"name": "fixture"}]})
|
||||
completed = subprocess.CompletedProcess(["trivy"], 0, stdout=json.dumps(report), stderr="")
|
||||
with patch.object(scanner.subprocess, "run", return_value=completed) as run, \
|
||||
patch.object(scanner, "approve_shared_key", return_value=[]):
|
||||
self.assertEqual(scanner.scan("fixture", directory), [])
|
||||
command = run.call_args_list[0].args[0]
|
||||
self.assertIn("--include-dev-deps", command)
|
||||
self.assertIn("vuln,secret,misconfig", command)
|
||||
self.assertNotIn("--output", command)
|
||||
self.assertEqual(run.call_args_list[0].kwargs["stdout"], subprocess.PIPE)
|
||||
self.assertEqual(run.call_args_list[1].args[0][1], "convert")
|
||||
|
||||
def test_raw_scanner_error_diagnostics_are_not_persisted(self):
|
||||
directory = unittest.mock.MagicMock()
|
||||
marker = "sensitive-fixture-text-not-a-real-credential"
|
||||
completed = subprocess.CompletedProcess(["trivy"], 1, stdout=marker, stderr=marker)
|
||||
with patch.object(scanner.subprocess, "run", return_value=completed):
|
||||
with self.assertRaisesRegex(ValueError, "exit status 1"):
|
||||
scanner.scan("fixture", directory)
|
||||
self.assertNotIn(marker, str(directory.mock_calls))
|
||||
|
||||
|
||||
class SharedKeyExceptionTests(unittest.TestCase):
|
||||
NOW = datetime(2026, 11, 5, tzinfo=timezone.utc)
|
||||
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.root = Path(self.directory.name)
|
||||
self.path = self.root / "machinery/src/config/main.go"
|
||||
self.path.parent.mkdir(parents=True)
|
||||
self.path.write_text("non-secret source fixture\n")
|
||||
self.policy = json.loads((ROOT / "image-scan-exceptions.json").read_text())
|
||||
self.record = self.policy["exceptions"][0]
|
||||
self.record["file_sha256"] = hashlib.sha256(self.path.read_bytes()).hexdigest()
|
||||
self.finding = {
|
||||
"RuleID": self.record["rule_id"], "Category": self.record["category"],
|
||||
"Severity": "HIGH", "StartLine": 710, "EndLine": 710,
|
||||
}
|
||||
self.report = {"SchemaVersion": 2, "Results": [
|
||||
{"Target": self.record["target"], "Secrets": [self.finding]},
|
||||
{"Target": "ui/yarn.lock", "Type": "yarn", "Packages": [{"Name": "react"}]},
|
||||
]}
|
||||
|
||||
def approved(self):
|
||||
return scanner.approve_shared_key(self.report, self.root, self.policy, self.NOW)
|
||||
|
||||
def test_exact_key_is_approved_but_finding_is_preserved(self):
|
||||
original = copy.deepcopy(self.report)
|
||||
approved = self.approved()
|
||||
self.assertTrue(scanner.check_report(self.report, source=True))
|
||||
self.assertEqual(scanner.check_report(
|
||||
self.report, source=True, approved_exceptions=approved), [])
|
||||
self.assertEqual(self.report, original)
|
||||
self.assertEqual(approved[0]["id"], "shared-recording-signing-key")
|
||||
|
||||
def test_other_secrets_and_vulnerabilities_still_block(self):
|
||||
approved = self.approved()
|
||||
result = self.report["Results"][0]
|
||||
result["Secrets"].append(dict(self.finding, StartLine=711, EndLine=711))
|
||||
result["Vulnerabilities"] = [{"VulnerabilityID": "fixture", "Severity": "CRITICAL"}]
|
||||
self.assertEqual(len(scanner.check_report(
|
||||
self.report, source=True, approved_exceptions=approved)), 2)
|
||||
|
||||
def test_exception_never_applies_to_image_or_non_source_scans(self):
|
||||
approved = self.approved()
|
||||
self.assertTrue(scanner.check_report(self.report, approved_exceptions=approved))
|
||||
self.assertTrue(scanner.check_report(
|
||||
self.report, image=True, source=True, approved_exceptions=approved))
|
||||
|
||||
def test_expiry_is_enforced_at_boundary(self):
|
||||
expiry = datetime(2026, 12, 5, 23, 59, 59, tzinfo=timezone.utc)
|
||||
with self.assertRaisesRegex(ValueError, "expired"):
|
||||
scanner.approve_shared_key(self.report, self.root, self.policy, expiry)
|
||||
self.record["expires"] = "2026-12-05"
|
||||
with self.assertRaisesRegex(ValueError, "timezone"):
|
||||
self.approved()
|
||||
|
||||
def test_incomplete_or_unapproved_metadata_blocks(self):
|
||||
for field in ("owner", "approved_by", "reason", "approval_url", "expires", "status"):
|
||||
with self.subTest(field=field):
|
||||
original = self.record[field]
|
||||
self.record[field] = ""
|
||||
with self.assertRaises(ValueError):
|
||||
self.approved()
|
||||
self.record[field] = original
|
||||
self.record["approval_url"] = "https://example.com/unreviewed"
|
||||
with self.assertRaises(ValueError):
|
||||
self.approved()
|
||||
|
||||
def test_changed_source_or_symlink_requires_review(self):
|
||||
self.path.write_text("changed source fixture\n")
|
||||
with self.assertRaisesRegex(ValueError, "changed"):
|
||||
self.approved()
|
||||
target = self.root / "other.go"
|
||||
self.path.rename(target)
|
||||
self.path.symlink_to(target)
|
||||
with self.assertRaisesRegex(ValueError, "symlink"):
|
||||
self.approved()
|
||||
|
||||
def test_changed_finding_does_not_match(self):
|
||||
for field, value in (("RuleID", "other"), ("Category", "other"),
|
||||
("Severity", "CRITICAL"), ("StartLine", 711), ("EndLine", 711)):
|
||||
with self.subTest(field=field):
|
||||
original = self.finding[field]
|
||||
self.finding[field] = value
|
||||
with self.assertRaisesRegex(ValueError, "exactly one"):
|
||||
self.approved()
|
||||
self.finding[field] = original
|
||||
|
||||
def test_duplicate_missing_or_broadened_records_block(self):
|
||||
self.policy["exceptions"].append(dict(self.record))
|
||||
with self.assertRaises(ValueError):
|
||||
self.approved()
|
||||
self.policy["exceptions"].pop()
|
||||
self.report["Results"][0]["Secrets"].append(dict(self.finding))
|
||||
with self.assertRaisesRegex(ValueError, "exactly one"):
|
||||
self.approved()
|
||||
self.report["Results"][0]["Secrets"] = []
|
||||
with self.assertRaisesRegex(ValueError, "exactly one"):
|
||||
self.approved()
|
||||
self.record["target"] = "../other.go"
|
||||
with self.assertRaisesRegex(ValueError, "scope"):
|
||||
self.approved()
|
||||
|
||||
def test_checked_in_record_matches_unchanged_source(self):
|
||||
report = copy.deepcopy(self.report)
|
||||
policy = json.loads((ROOT / "image-scan-exceptions.json").read_text())
|
||||
self.assertEqual(len(scanner.approve_shared_key(
|
||||
report, ROOT.parent.parent, policy, self.NOW)), 1)
|
||||
|
||||
|
||||
class ReleasePolicyTests(unittest.TestCase):
|
||||
NOW = datetime(2026, 11, 4, tzinfo=timezone.utc)
|
||||
|
||||
def test_checked_in_state_blocks_publication(self):
|
||||
state = json.loads((ROOT / "image-release-policy.json").read_text())
|
||||
self.assertTrue(release.check_policy(state, self.NOW))
|
||||
|
||||
def test_missing_records_block(self):
|
||||
for state in ({}, {"required_exceptions": []},
|
||||
{"required_exceptions": [{"id": "unrelated"}]}):
|
||||
self.assertTrue(release.check_policy(state, self.NOW))
|
||||
|
||||
def test_expired_or_incomplete_approval_blocks(self):
|
||||
state = json.loads((ROOT / "image-release-policy.json").read_text())
|
||||
record = state["required_exceptions"][0]
|
||||
record["status"] = "approved"
|
||||
self.assertTrue(release.check_policy(state, self.NOW))
|
||||
record.update(owner="test-fixture", approved_by="test-fixture",
|
||||
approval_url="https://github.com/kerberos-io/agent/issues/1",
|
||||
expires="2020-01-01T00:00:00Z")
|
||||
self.assertTrue(release.check_policy(state, self.NOW))
|
||||
record["expires"] = "invalid"
|
||||
self.assertTrue(release.check_policy(state, self.NOW))
|
||||
record["expires"] = "2027-01-01"
|
||||
self.assertTrue(release.check_policy(state, self.NOW))
|
||||
|
||||
|
||||
class ProvenanceTests(unittest.TestCase):
|
||||
def test_base_digest_retained(self):
|
||||
material = {"uri": "pkg:docker/debian@trixie", "digest": {"sha256": "a" * 64}}
|
||||
self.assertEqual(list(evidence.materials({"SLSA": {"materials": [material]}})), [material])
|
||||
|
||||
def test_missing_digest_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
list(evidence.materials({"uri": "pkg:docker/debian@trixie"}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25.x"
|
||||
go-version: "1.26.10"
|
||||
check-latest: true
|
||||
cache: true
|
||||
cache-dependency-path: machinery/go.sum
|
||||
@@ -32,9 +32,9 @@ jobs:
|
||||
libswscale-dev \
|
||||
pkg-config
|
||||
- name: Get dependencies
|
||||
run: cd machinery && go mod download
|
||||
run: cd machinery && go mod download && go mod verify
|
||||
- name: Build
|
||||
run: cd machinery && go build -v ./...
|
||||
run: cd machinery && go build -mod=readonly -trimpath -buildvcs=false -v ./...
|
||||
- name: Vet
|
||||
run: cd machinery && go vet -v ./...
|
||||
- name: Test
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
name: Verified image publication
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
type: string
|
||||
default: ""
|
||||
source_ref:
|
||||
type: string
|
||||
required: true
|
||||
nightly:
|
||||
type: boolean
|
||||
default: false
|
||||
publish_latest:
|
||||
type: boolean
|
||||
default: false
|
||||
secrets:
|
||||
DOCKER_USERNAME:
|
||||
required: true
|
||||
DOCKER_PASSWORD:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# No parallel run can overwrite a customer tag while another run verifies it.
|
||||
concurrency:
|
||||
group: image-publication-${{ inputs.nightly && 'nightly' || 'release' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
resolve:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
sha: ${{ steps.source.outputs.sha }}
|
||||
tag: ${{ steps.source.outputs.tag }}
|
||||
created: ${{ steps.source.outputs.created }}
|
||||
matrix: ${{ steps.source.outputs.matrix }}
|
||||
repository: ${{ steps.source.outputs.repository }}
|
||||
staging_repository: ${{ steps.source.outputs.staging_repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Validate requested tag
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
NIGHTLY: ${{ inputs.nightly }}
|
||||
run: |
|
||||
if [[ "$NIGHTLY" != true && ! "$TAG" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$ ]]; then
|
||||
echo "Invalid Docker tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: ${{ inputs.source_ref }}
|
||||
path: source
|
||||
persist-credentials: false
|
||||
- name: Resolve once for every architecture
|
||||
id: source
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
NIGHTLY: ${{ inputs.nightly }}
|
||||
run: |
|
||||
sha=$(git -C source rev-parse HEAD)
|
||||
[[ "$sha" =~ ^[a-f0-9]{40}$ ]]
|
||||
created=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
repository=kerberos/agent
|
||||
staging_repository=kerberos/agent-arch
|
||||
matrix='{"include":[{"architecture":"amd64","runner":"ubuntu-24.04"},{"architecture":"arm64","runner":"ubuntu-24.04-arm"}]}'
|
||||
if [[ "$NIGHTLY" == true ]]; then
|
||||
TAG="${sha:0:7}"
|
||||
repository=kerberos/agent-nightly
|
||||
staging_repository="$repository"
|
||||
# Keep arm/v6: the pinned Go trixie base currently lacks this platform.
|
||||
# Its build must fail rather than publishing an incomplete nightly index.
|
||||
matrix='{"include":[{"architecture":"amd64","runner":"ubuntu-24.04"},{"architecture":"arm64","runner":"ubuntu-24.04-arm"},{"architecture":"arm/v7","runner":"ubuntu-24.04"},{"architecture":"arm/v6","runner":"ubuntu-24.04"}]}'
|
||||
fi
|
||||
{
|
||||
echo "sha=$sha"
|
||||
echo "tag=$TAG"
|
||||
echo "created=$created"
|
||||
echo "repository=$repository"
|
||||
echo "staging_repository=$staging_repository"
|
||||
echo "matrix=$matrix"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
mkdir -p evidence
|
||||
printf '%s\n' "$sha" > evidence/source-revision.txt
|
||||
printf '%s\n' "$created" > evidence/created.txt
|
||||
printf '%s\n' "$TAG" > evidence/version.txt
|
||||
cp .github/scripts/image-release-policy.json evidence/runtime-policy.json
|
||||
- name: Validate fail-closed policy helpers
|
||||
run: python3 .github/scripts/test-image-policy.py
|
||||
- name: Keep existing GitHub release unpublished until gates pass
|
||||
if: ${{ !inputs.nightly }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ steps.source.outputs.tag }}
|
||||
run: |
|
||||
# A release:created event may refer to an already public release.
|
||||
# Return it to draft immediately; do not imply this run has verified it.
|
||||
gh api --paginate "repos/$GITHUB_REPOSITORY/releases" --jq '.[]' | jq -s . > evidence/releases.json
|
||||
release_id=$(jq -r --arg tag "$TAG" '.[] | select(.tag_name == $tag) | .id' evidence/releases.json)
|
||||
if [[ -n "$release_id" ]]; then
|
||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$release_id" -F draft=true > evidence/draft-release.json
|
||||
fi
|
||||
- name: Install pinned public OCI scanner
|
||||
run: bash .github/scripts/install-trivy.sh
|
||||
- name: Source/UI dependency vulnerability, secret and misconfiguration gate with SPDX SBOM
|
||||
run: python3 .github/scripts/image-policy.py source evidence/source
|
||||
- name: Retain source policy evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: image-source-evidence
|
||||
path: evidence/
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
|
||||
build:
|
||||
needs: resolve
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.resolve.outputs.matrix) }}
|
||||
env:
|
||||
ARCH: ${{ matrix.architecture }}
|
||||
VERSION: ${{ needs.resolve.outputs.tag }}
|
||||
REVISION: ${{ needs.resolve.outputs.sha }}
|
||||
CREATED: ${{ needs.resolve.outputs.created }}
|
||||
STAGING_REPOSITORY: ${{ needs.resolve.outputs.staging_repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
path: source
|
||||
persist-credentials: false
|
||||
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
||||
if: ${{ startsWith(matrix.architecture, 'arm/') }}
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build immutable staging image with maximal provenance
|
||||
id: build
|
||||
run: |
|
||||
mkdir -p evidence
|
||||
arch="${ARCH//\//-}"
|
||||
echo "arch=$arch" >> "$GITHUB_OUTPUT"
|
||||
staging="$STAGING_REPOSITORY:ci-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-$arch"
|
||||
printf '%s\n' "$REVISION" > evidence/source-revision.txt
|
||||
docker buildx build --platform "linux/$ARCH" --provenance=mode=max \
|
||||
--build-arg "VERSION=$VERSION" --build-arg "REVISION=$REVISION" --build-arg "CREATED=$CREATED" \
|
||||
--label "org.opencontainers.image.version=$VERSION" \
|
||||
--label "org.opencontainers.image.revision=$REVISION" \
|
||||
--label "org.opencontainers.image.created=$CREATED" \
|
||||
--label "org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY" \
|
||||
--metadata-file evidence/build-metadata.json --tag "$staging" --push source
|
||||
digest=$(jq -er '."containerimage.digest"' evidence/build-metadata.json)
|
||||
[[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]
|
||||
image="$STAGING_REPOSITORY@$digest"
|
||||
printf '%s\n' "$image" > evidence/image.txt
|
||||
docker buildx imagetools inspect "$image" --format '{{json .Provenance}}' > evidence/provenance.json
|
||||
python3 .github/scripts/image-evidence.py evidence
|
||||
- name: Extract release payload and smoke test the exact staged image
|
||||
run: |
|
||||
image=$(cat evidence/image.txt)
|
||||
docker pull --platform "linux/$ARCH" "$image"
|
||||
cid=$(docker create --platform "linux/$ARCH" "$image")
|
||||
trap 'docker rm -f "$cid" >/dev/null' EXIT
|
||||
docker cp "$cid:/home/agent" output
|
||||
# Run the existing smoke interface, including emulated nightly architectures.
|
||||
DOCKER_DEFAULT_PLATFORM="linux/$ARCH" sh source/deployments/docker/test-image.sh "$image"
|
||||
arch="${ARCH//\//-}"
|
||||
tar -cf "agent-$arch.tar" -C output .
|
||||
rm -rf output
|
||||
- name: Retain build evidence even on failure
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: image-build-${{ steps.build.outputs.arch || strategy.job-index }}
|
||||
path: evidence/
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
- name: Retain existing binary artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: agent-${{ steps.build.outputs.arch }}.tar
|
||||
path: agent-*.tar
|
||||
if-no-files-found: error
|
||||
|
||||
verify-publish:
|
||||
needs: [resolve, build]
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
env:
|
||||
REPOSITORY: ${{ needs.resolve.outputs.repository }}
|
||||
STAGING_REPOSITORY: ${{ needs.resolve.outputs.staging_repository }}
|
||||
VERSION: ${{ needs.resolve.outputs.tag }}
|
||||
REVISION: ${{ needs.resolve.outputs.sha }}
|
||||
PUBLISH_LATEST: ${{ inputs.publish_latest }}
|
||||
MATRIX: ${{ needs.resolve.outputs.matrix }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
path: artifacts
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Install scanner
|
||||
run: bash .github/scripts/install-trivy.sh
|
||||
- uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: v3.1.4
|
||||
- name: Assemble candidate index without publishing version/latest
|
||||
run: |
|
||||
mkdir -p evidence
|
||||
images=()
|
||||
while IFS= read -r arch; do
|
||||
directory="artifacts/image-build-${arch//\//-}"
|
||||
[[ "$(cat "$directory/source-revision.txt")" == "$REVISION" ]]
|
||||
image=$(cat "$directory/image.txt")
|
||||
[[ "$image" =~ ^${STAGING_REPOSITORY}@sha256:[a-f0-9]{64}$ ]]
|
||||
images+=("$image")
|
||||
done < <(jq -r '.include[].architecture' <<< "$MATRIX")
|
||||
candidate="$REPOSITORY:ci-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
|
||||
docker buildx imagetools create --tag "$candidate" "${images[@]}"
|
||||
digest=$(docker buildx imagetools inspect "$candidate" --format '{{.Manifest.Digest}}')
|
||||
[[ "$digest" =~ ^sha256:[a-f0-9]{64}$ ]]
|
||||
image="$REPOSITORY@$digest"
|
||||
printf '%s\n' "$image" > evidence/final-image.txt
|
||||
printf '%s\n' "$REVISION" > evidence/source-revision.txt
|
||||
docker buildx imagetools inspect "$image" --raw > evidence/index.json
|
||||
docker buildx imagetools inspect "$image" --format '{{json .Provenance}}' > evidence/provenance.json
|
||||
python3 .github/scripts/image-evidence.py evidence
|
||||
# Every requested runnable platform must be present, with no silent omissions.
|
||||
jq -r '.manifests[] | select(.platform.os == "linux") |
|
||||
.platform | .architecture + (if .variant and .architecture == "arm" then "/" + .variant else "" end)' \
|
||||
evidence/index.json | sort > evidence/actual-platforms.txt
|
||||
jq -r '.include[].architecture' <<< "$MATRIX" | sort > evidence/expected-platforms.txt
|
||||
diff -u evidence/expected-platforms.txt evidence/actual-platforms.txt
|
||||
- name: Scan every final-index platform and enforce findings/errors
|
||||
run: |
|
||||
image=$(cat evidence/final-image.txt)
|
||||
failed=0
|
||||
while IFS= read -r arch; do
|
||||
# Generation uses exit-code 0 only for findings; the helper explicitly
|
||||
# blocks HIGH/CRITICAL/UNKNOWN and any scanner/inventory/SBOM error.
|
||||
if ! python3 .github/scripts/image-policy.py "$image" "evidence/${arch//\//-}" --platform "linux/$arch"; then
|
||||
failed=1
|
||||
fi
|
||||
done < <(jq -r '.include[].architecture' <<< "$MATRIX")
|
||||
exit "$failed"
|
||||
- name: Required release policy exception gate
|
||||
run: |
|
||||
# The product deliberately retains remote terminal/PTY support.
|
||||
# No approval exists. Keep this release-only gate separate from scans;
|
||||
# there is no dispatch input/environment variable that can waive it.
|
||||
cp .github/scripts/image-release-policy.json evidence/runtime-policy.json
|
||||
python3 .github/scripts/image-release-policy.py evidence/runtime-policy.json
|
||||
- name: Sign and verify the FINAL multi-platform digest using GitHub OIDC
|
||||
run: |
|
||||
image=$(cat evidence/final-image.txt)
|
||||
# Fulcio's SAN is job_workflow_ref for this local reusable workflow,
|
||||
# not the caller's filename. Bind the exact repository and executing ref.
|
||||
# For example: https://github.com/kerberos-io/agent/.github/workflows/image-release.yml@refs/heads/master
|
||||
identity="https://github.com/$GITHUB_REPOSITORY/.github/workflows/image-release.yml@$GITHUB_REF"
|
||||
printf '%s\n' "$identity" > evidence/signing-identity.txt
|
||||
cosign sign --yes --bundle evidence/signature.sigstore.json "$image"
|
||||
cosign verify --certificate-identity "$identity" \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
"$image" > evidence/signature-verification.json
|
||||
- name: Publish only the already verified and signed digest
|
||||
run: |
|
||||
image=$(cat evidence/final-image.txt)
|
||||
while IFS= read -r arch; do
|
||||
arch="${arch//\//-}"
|
||||
staged=$(cat "artifacts/image-build-$arch/image.txt")
|
||||
# Preserve the existing architecture tag names, after all gates.
|
||||
docker buildx imagetools create --tag "$STAGING_REPOSITORY:arch-$arch-$VERSION" "$staged"
|
||||
done < <(jq -r '.include[].architecture' <<< "$MATRIX")
|
||||
docker buildx imagetools create --tag "$REPOSITORY:$VERSION" "$image"
|
||||
[[ "$(docker buildx imagetools inspect "$REPOSITORY:$VERSION" --format '{{.Manifest.Digest}}')" == "${image##*@}" ]]
|
||||
if [[ "$PUBLISH_LATEST" == true ]]; then
|
||||
docker buildx imagetools create --tag "$REPOSITORY:latest" "$image"
|
||||
[[ "$(docker buildx imagetools inspect "$REPOSITORY:latest" --format '{{.Manifest.Digest}}')" == "${image##*@}" ]]
|
||||
fi
|
||||
- name: Retain final reports/SBOM/provenance/signature even on failure
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: image-final-evidence
|
||||
path: evidence/
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
- name: Attach binaries and all evidence, then publish GitHub release
|
||||
if: ${{ !inputs.nightly }}
|
||||
run: |
|
||||
tar -czf image-security-evidence.tar.gz evidence artifacts/image-*
|
||||
# Create a draft only if absent. API errors must not masquerade as absence.
|
||||
gh api --paginate "repos/$GITHUB_REPOSITORY/releases" --jq '.[]' | jq -s . > releases.json
|
||||
if ! jq -e --arg tag "$VERSION" '.[] | select(.tag_name == $tag)' releases.json >/dev/null; then
|
||||
gh release create "$VERSION" --target "$REVISION" --title "$VERSION" --notes "" --draft
|
||||
fi
|
||||
gh release upload "$VERSION" artifacts/agent-*.tar/agent-*.tar image-security-evidence.tar.gz --clobber
|
||||
gh release edit "$VERSION" --draft=false --latest --title "$VERSION"
|
||||
@@ -1,62 +1,19 @@
|
||||
name: Nightly build
|
||||
|
||||
on:
|
||||
# Triggers the workflow every day at 9PM (CET).
|
||||
schedule:
|
||||
- cron: "0 22 * * *"
|
||||
# Allows manual triggering from the Actions tab.
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
nightly-build-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
with:
|
||||
ref: master
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Available platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
- name: Run Buildx
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [arm64, arm/v7, arm/v6]
|
||||
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
|
||||
with:
|
||||
ref: master
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Available platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
- name: Run Buildx
|
||||
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)
|
||||
nightly:
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/image-release.yml
|
||||
with:
|
||||
source_ref: master
|
||||
nightly: true
|
||||
secrets: inherit
|
||||
|
||||
+100
-22
@@ -1,41 +1,119 @@
|
||||
name: Build pull request
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
env:
|
||||
REPO: kerberos/agent
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- architecture: amd64
|
||||
runner: ubuntu-24.04
|
||||
- architecture: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
env:
|
||||
ARCH: ${{ matrix.architecture }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Run Build
|
||||
persist-credentials: false
|
||||
- name: Initialize retained runtime/scanner state
|
||||
run: |
|
||||
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}
|
||||
- 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
|
||||
mkdir -p evidence/scanner-install
|
||||
printf '%s\n' '{"status":"not_started","reason":"waiting_for_native_build_and_smoke"}' \
|
||||
> evidence/scanner-install/install.json
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
name: agent-${{matrix.architecture}}.tar
|
||||
path: agent-${{matrix.architecture}}.tar
|
||||
ref: ${{ github.sha }}
|
||||
path: source
|
||||
persist-credentials: false
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
- name: Validate fail-closed policy helpers
|
||||
run: python3 .github/scripts/test-image-policy.py
|
||||
- name: Build once, populating OCI metadata from CI
|
||||
env:
|
||||
BUILDX_METADATA_PROVENANCE: max
|
||||
run: |
|
||||
mkdir -p evidence/build
|
||||
revision=$(git -C source rev-parse HEAD)
|
||||
version="${revision:0:7}"
|
||||
created=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
printf '%s\n' "$revision" > evidence/build/source-revision.txt
|
||||
# Classic Docker cannot load an attested index. Disable attestations
|
||||
# only for this local export; BUILDX_METADATA_PROVENANCE=max retains
|
||||
# full build provenance/materials separately, verified below.
|
||||
docker buildx build --platform "linux/$ARCH" --provenance=false \
|
||||
--build-arg "VERSION=$version" --build-arg "REVISION=$revision" --build-arg "CREATED=$created" \
|
||||
--label "org.opencontainers.image.version=$version" \
|
||||
--label "org.opencontainers.image.revision=$revision" \
|
||||
--label "org.opencontainers.image.created=$created" \
|
||||
--label "org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY" \
|
||||
--metadata-file evidence/build/build-metadata.json --tag "$ARCH" \
|
||||
--output type=docker source
|
||||
jq -e '."buildx.build.provenance" |
|
||||
select(type == "object" and length > 0) |
|
||||
select(.buildConfig | type == "object" and length > 0)' \
|
||||
evidence/build/build-metadata.json > evidence/build/provenance.json
|
||||
python3 .github/scripts/image-evidence.py evidence/build
|
||||
- name: Extract release payload
|
||||
run: |
|
||||
cid=$(docker create "$ARCH")
|
||||
trap 'docker rm -f "$cid" >/dev/null' EXIT
|
||||
docker cp "$cid:/home/agent" "output-$ARCH"
|
||||
- name: Test production image
|
||||
id: smoke
|
||||
run: sh source/deployments/docker/test-image.sh "$ARCH"
|
||||
- name: Strip binary
|
||||
run: tar -cf "agent-$ARCH.tar" -C "output-$ARCH" . && rm -rf "output-$ARCH"
|
||||
- name: Install pinned public OCI scanner after runtime tests
|
||||
run: bash .github/scripts/install-trivy.sh
|
||||
- name: Source/UI dependency and final image gates with SPDX SBOMs
|
||||
run: |
|
||||
failed=0
|
||||
if ! python3 .github/scripts/image-policy.py source evidence/source; then
|
||||
failed=1
|
||||
fi
|
||||
if ! python3 .github/scripts/image-policy.py "$ARCH" "evidence/$ARCH" --platform "linux/$ARCH"; then
|
||||
failed=1
|
||||
fi
|
||||
exit "$failed"
|
||||
- name: Upload artifact only after secret and vulnerability gates pass
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: agent-${{ matrix.architecture }}.tar
|
||||
path: agent-${{ matrix.architecture }}.tar
|
||||
if-no-files-found: error
|
||||
- name: Record runtime result independently of scanner availability
|
||||
if: always()
|
||||
env:
|
||||
SMOKE_OUTCOME: ${{ steps.smoke.outcome }}
|
||||
run: |
|
||||
mkdir -p evidence
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
Path("evidence/runtime-smoke.json").write_text(json.dumps({
|
||||
"architecture": os.environ["ARCH"],
|
||||
"outcome": os.environ["SMOKE_OUTCOME"],
|
||||
"workflow_revision": os.environ["GITHUB_SHA"],
|
||||
}, indent=2) + "\n")
|
||||
PY
|
||||
- name: Retain reports on failure
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: image-pr-evidence-${{ matrix.architecture }}
|
||||
path: evidence/
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -1,160 +1,36 @@
|
||||
name: Bump release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: "Which part of the version to bump"
|
||||
description: Which part of the version to bump
|
||||
required: true
|
||||
default: patch
|
||||
type: choice
|
||||
options:
|
||||
- major
|
||||
- minor
|
||||
- patch
|
||||
options: [major, minor, patch]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
REPO: kerberos/agent
|
||||
contents: read
|
||||
|
||||
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
|
||||
permissions:
|
||||
contents: write
|
||||
# Verified revision supports reserving a tag with an unpublished draft.
|
||||
uses: uug-ai/workflows/.github/workflows/release-bump.yml@f31d4260a833e82228ca0e9b9163231a970feeb8
|
||||
with:
|
||||
bump: ${{ github.event.inputs.bump }}
|
||||
bump: ${{ inputs.bump }}
|
||||
draft: true
|
||||
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:
|
||||
release:
|
||||
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"
|
||||
|
||||
|
||||
id-token: write
|
||||
uses: ./.github/workflows/image-release.yml
|
||||
with:
|
||||
tag: ${{ needs.bump-release.outputs.tag }}
|
||||
source_ref: ${{ format('refs/tags/{0}', needs.bump-release.outputs.tag) }}
|
||||
publish_latest: true
|
||||
secrets: inherit
|
||||
|
||||
@@ -5,118 +5,21 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag for the Docker image"
|
||||
description: Tag for the Docker image
|
||||
required: true
|
||||
default: "test"
|
||||
default: test
|
||||
|
||||
env:
|
||||
REPO: kerberos/agent
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-amd64:
|
||||
runs-on: ubuntu-24.04
|
||||
release:
|
||||
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=${{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}
|
||||
- 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}}-${{github.event.inputs.tag || github.ref_name}}
|
||||
docker push $REPO-arch:arch-${{matrix.architecture}}-${{github.event.inputs.tag || github.ref_name}}
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
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@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=${{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}
|
||||
- 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}}-${{github.event.inputs.tag || github.ref_name}}
|
||||
docker push $REPO-arch:arch-${{matrix.architecture}}-${{github.event.inputs.tag || github.ref_name}}
|
||||
- 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: [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:${{ github.event.inputs.tag || github.ref_name }} \
|
||||
$REPO-arch:arch-amd64-${{github.event.inputs.tag || github.ref_name}} \
|
||||
$REPO-arch:arch-arm64-${{github.event.inputs.tag || github.ref_name}}
|
||||
docker manifest push $REPO:${{ github.event.inputs.tag || github.ref_name }}
|
||||
- name: Create and push latest manifest
|
||||
run: |
|
||||
docker manifest create $REPO:latest \
|
||||
$REPO-arch:arch-amd64-${{github.event.inputs.tag || github.ref_name}} \
|
||||
$REPO-arch:arch-arm64-${{github.event.inputs.tag || github.ref_name}}
|
||||
docker manifest push $REPO:latest
|
||||
if: github.event.inputs.tag == 'test'
|
||||
|
||||
create-release:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [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: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
tag: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
generateReleaseNotes: false
|
||||
omitBodyDuringUpdate: true
|
||||
artifacts: "agent-*.tar/agent-*.tar"
|
||||
id-token: write
|
||||
uses: ./.github/workflows/image-release.yml
|
||||
with:
|
||||
tag: ${{ inputs.tag || github.event.release.tag_name }}
|
||||
source_ref: ${{ github.event_name == 'release' && format('refs/tags/{0}', github.event.release.tag_name) || github.sha }}
|
||||
publish_latest: ${{ github.event_name == 'workflow_dispatch' && inputs.tag == 'test' }}
|
||||
secrets: inherit
|
||||
|
||||
Vendored
+1
-1
@@ -14,7 +14,7 @@
|
||||
"-action",
|
||||
"run",
|
||||
"-port",
|
||||
"8080"
|
||||
"8082"
|
||||
],
|
||||
"envFile": "${workspaceFolder}/machinery/.env.local",
|
||||
"buildFlags": "--tags dynamic,moq",
|
||||
|
||||
+42
-39
@@ -1,6 +1,6 @@
|
||||
|
||||
ARG GO_IMAGE=golang:1.25-trixie
|
||||
ARG RUNTIME_IMAGE=debian:trixie-slim
|
||||
ARG GO_IMAGE=golang:1.26.10-trixie@sha256:a050dcceee6eb9bf110151662dfac7662f3bec40927c7dee48731e3d92083d42
|
||||
ARG RUNTIME_IMAGE=debian:trixie-slim@sha256:f6b08b09a05725f9431502d3b5714f5c4576e82c1a84fa1debe0c24a7e0c3ec4
|
||||
ARG VERSION=0.0.0
|
||||
FROM ${GO_IMAGE} AS build-machinery
|
||||
LABEL AUTHOR=uug.ai
|
||||
@@ -14,12 +14,13 @@ ARG TARGETARCH
|
||||
ENV GOROOT=/usr/local/go
|
||||
ENV GOPATH=/go
|
||||
ENV PATH=$GOPATH/bin:$GOROOT/bin:/usr/local/lib:$PATH
|
||||
ENV GOSUMDB=off
|
||||
ENV GOTOOLCHAIN=local
|
||||
ENV CGO_ENABLED=1
|
||||
|
||||
##########################################
|
||||
# Installing some additional dependencies.
|
||||
|
||||
RUN apt-get update && apt-get install -y --fix-missing --no-install-recommends \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git build-essential cmake pkg-config unzip libgtk2.0-dev \
|
||||
curl ca-certificates libavcodec-dev libavutil-dev libcurl4-openssl-dev \
|
||||
libssl-dev libjpeg62-turbo-dev libswscale-dev && \
|
||||
@@ -31,13 +32,12 @@ RUN apt-get update && apt-get install -y --fix-missing --no-install-recommends \
|
||||
RUN mkdir -p /go/src/github.com/kerberos-io/agent
|
||||
WORKDIR /go/src/github.com/kerberos-io/agent/machinery
|
||||
COPY machinery/go.mod machinery/go.sum ./
|
||||
RUN go mod download
|
||||
RUN go version && go mod download && go mod verify
|
||||
|
||||
##############################################################################
|
||||
# Copy the rest of the source after dependencies are primed.
|
||||
|
||||
COPY machinery ./
|
||||
RUN rm -rf .env
|
||||
|
||||
##################
|
||||
# Build Machinery
|
||||
@@ -46,12 +46,13 @@ 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 -o main -tags "${BUILD_TAGS}" --ldflags "-s -w -X github.com/kerberos-io/agent/machinery/src/utils.VERSION=${RESOLVED_VERSION}" . && \
|
||||
go build -mod=readonly -trimpath -buildvcs=false -o main -tags "${BUILD_TAGS}" --ldflags "-s -w -X github.com/kerberos-io/agent/machinery/src/utils.VERSION=${RESOLVED_VERSION}" . && \
|
||||
mkdir -p /agent && \
|
||||
mv main /agent && \
|
||||
mv version /agent && \
|
||||
mv data /agent && \
|
||||
mkdir -p /agent/data/cloud && \
|
||||
mkdir -p /agent/data/tus && \
|
||||
mkdir -p /agent/data/snapshots && \
|
||||
mkdir -p /agent/data/log && \
|
||||
mkdir -p /agent/data/recordings && \
|
||||
@@ -70,7 +71,7 @@ RUN cp -r /agent ./
|
||||
|
||||
RUN /dist/agent/main version
|
||||
|
||||
FROM node:22-alpine AS build-ui
|
||||
FROM node:22-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914 AS build-ui
|
||||
|
||||
RUN apk update && apk upgrade --available && sync
|
||||
|
||||
@@ -94,59 +95,61 @@ RUN mkdir -p ./agent && cp -r /go/src/github.com/kerberos-io/agent/machinery/www
|
||||
############################################
|
||||
# Publish main binary to GitHub release
|
||||
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
|
||||
############################
|
||||
# Protect by non-root user.
|
||||
FROM ${RUNTIME_IMAGE} AS runtime-rootfs
|
||||
|
||||
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
|
||||
ca-certificates ffmpeg libatomic1 libstdc++6 \
|
||||
dash coreutils findutils grep sed tzdata ncurses-base && \
|
||||
groupadd --system --gid 999 kerberosio && \
|
||||
useradd --system --uid 999 --gid kerberosio --groups video \
|
||||
--home-dir /home/agent --no-create-home agent
|
||||
|
||||
#################################
|
||||
# Copy files from previous images
|
||||
|
||||
COPY --chown=0:0 --from=build-machinery /dist /
|
||||
COPY --chown=0:0 --from=build-ui /dist /
|
||||
COPY --chown=0:0 --from=build-machinery /dist/agent/ /runtime/home/agent/
|
||||
COPY --chown=0:0 --from=build-ui /dist/agent/ /runtime/home/agent/
|
||||
COPY deployments/docker/assemble-rootfs.sh /usr/local/bin/assemble-rootfs
|
||||
|
||||
##################
|
||||
# Try running agent
|
||||
RUN chmod 0755 /runtime/home/agent /runtime/home/agent/main && \
|
||||
cp /runtime/home/agent/data/config/config.json /runtime/home/agent/data/config.template.json && \
|
||||
chown -R agent:kerberosio /runtime/home/agent/data && \
|
||||
sh /usr/local/bin/assemble-rootfs
|
||||
|
||||
RUN mv /agent/* /home/agent/
|
||||
RUN /home/agent/main version
|
||||
FROM scratch
|
||||
|
||||
#######################
|
||||
# Make template config
|
||||
COPY --from=runtime-rootfs /runtime/ /
|
||||
|
||||
RUN cp /home/agent/data/config/config.json /home/agent/data/config.template.json
|
||||
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
ENV HOME=/home/agent
|
||||
ENV XDG_CACHE_HOME=/tmp/agent-cache
|
||||
ENV AGENT_PORT=8080
|
||||
|
||||
###########################
|
||||
# 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 setcap 'cap_net_bind_service=+ep' /home/agent/main
|
||||
ARG VERSION
|
||||
ARG REVISION=unknown
|
||||
ARG CREATED
|
||||
LABEL org.opencontainers.image.title="Kerberos Agent" \
|
||||
org.opencontainers.image.description="Camera and media agent with a minimal dynamic scratch runtime" \
|
||||
org.opencontainers.image.source="https://github.com/kerberos-io/agent" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
org.opencontainers.image.revision="${REVISION}" \
|
||||
org.opencontainers.image.created="${CREATED}" \
|
||||
org.opencontainers.image.vendor="UUG.AI"
|
||||
|
||||
###################
|
||||
# Run non-root user
|
||||
|
||||
USER agent
|
||||
USER 999:999
|
||||
|
||||
######################################
|
||||
# By default the app runs on port 80
|
||||
# The image uses an unprivileged HTTP port.
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 8080/tcp
|
||||
|
||||
######################################
|
||||
# Check if agent is still running
|
||||
|
||||
HEALTHCHECK CMD curl --fail "http://localhost:${AGENT_PORT:-80}/health" || exit 1
|
||||
HEALTHCHECK --timeout=10s CMD ["/home/agent/main", "-action", "healthcheck"]
|
||||
|
||||
###################################################
|
||||
# Leeeeettttt'ssss goooooo!!!
|
||||
|
||||
@@ -76,16 +76,109 @@ There are a myriad of cameras out there (USB, IP and other cameras), and it migh
|
||||
1. [What is new?](#what-is-new)
|
||||
1. [Contributors](#contributors)
|
||||
|
||||
### Direct S3 recording layout
|
||||
|
||||
Direct S3 uploads store recordings as
|
||||
`<s3.username>/yyyy/mm/dd/<timestamp>-<deviceKey>.mp4`. The timestamp is the
|
||||
recording's Unix timestamp in milliseconds, and the date is its UTC date, not the upload
|
||||
date. Timestamp and device key come from the recording's `.metadata` upload
|
||||
checkpoint, which stores the timestamp in milliseconds. The uploader also
|
||||
preserves checkpoint duration, FPS (when available), and encryption status as S3
|
||||
object metadata. The device key must be non-empty and contain no path separators.
|
||||
Retried uploads use the same object key, even after midnight. Millisecond
|
||||
precision keeps recordings starting within the same second distinct.
|
||||
|
||||
This replaces the previous `<s3.username>/<filename>.mp4` layout for new uploads.
|
||||
Existing objects are not moved; consumers of direct S3 uploads must support both
|
||||
layouts when reading historical recordings. Updated Kerberos Vault deployments
|
||||
use the same filename and dated layout under the authenticated account directory
|
||||
for both direct Vault uploads and Hub-proxied uploads.
|
||||
|
||||
Only historical recordings with missing, empty, or FPS-only queue markers fall
|
||||
back to the legacy filename format and configured Agent key. A structured
|
||||
checkpoint takes precedence over both the filename and current configured key;
|
||||
an invalid or unreadable checkpoint is reported as an upload error, not silently
|
||||
replaced with filename-derived values.
|
||||
For older structured checkpoints, missing camera name, region, and change count
|
||||
are recovered from a validated legacy filename. Populated checkpoint fields
|
||||
take precedence, and the checkpoint timestamp and device key remain authoritative.
|
||||
|
||||
The shorter filename cannot be decoded by legacy filename-only consumers.
|
||||
Integrations must preserve structured recording metadata, including the device
|
||||
key (`productid`) and recording timestamp (`event-timestamp`), as well as the full
|
||||
object key.
|
||||
|
||||
### Local recording layout
|
||||
|
||||
New local recordings are stored under
|
||||
`data/recordings/yyyy/mm/dd/<timestamp>-<deviceKey>.mp4`, using the recording's UTC
|
||||
start date and Unix-millisecond timestamp. For example:
|
||||
`data/recordings/2026/01/02/1767355200123-device-key.mp4`.
|
||||
The filename no longer embeds the camera name, region, change count, or duration,
|
||||
and it does not change when the recording is finalized.
|
||||
Those attributes, together with FPS and encryption status, are captured in the
|
||||
`.metadata` upload checkpoint and sent as structured HTTP/tus metadata.
|
||||
|
||||
Existing flat recordings and dated recordings with older long filenames remain
|
||||
supported without a migration. Recording lists, day filters, playback, uploaders,
|
||||
and automatic cleanup handle both filename formats and layouts. Upload queue
|
||||
checkpoints remain flat in `data/cloud/`. Successful-upload removal also resolves
|
||||
both local layouts. Deploy the updated Vault alongside Agent for canonical
|
||||
`<account-directory>/yyyy/mm/dd/<timestamp>-<deviceKey>.mp4` storage keys and
|
||||
metadata-driven Hub integration events.
|
||||
Directory decryption also processes dated subdirectories and writes each output
|
||||
to a sibling `decrypted/` directory; those outputs are excluded from recording
|
||||
lists and automatic cleanup.
|
||||
|
||||
## Quickstart - Docker
|
||||
|
||||
The easiest way to get your Kerberos Agent up and running is to use our public image on [Docker hub](https://hub.docker.com/r/kerberos/agent). Once you have selected a specific tag, run `docker` command below, which will open the web interface of your Kerberos agent on port `80`, and off you go. For a more configurable and persistent deployment have a look at [Running and automating a Kerberos Agent](#running-and-automating-a-kerberos-agent).
|
||||
|
||||
docker run -p 80:80 --name mycamera -d --restart=always kerberos/agent:latest
|
||||
**Forthcoming hardened image** (use this mapping only after upgrading to a
|
||||
release containing these changes):
|
||||
|
||||
docker run -p 80:8080 --name mycamera -d --restart=always kerberos/agent:latest
|
||||
|
||||
The hardened image listens on **8080 inside the container**, not 80. The mapping
|
||||
`-p 80:8080` keeps the browser URL `http://localhost/` unchanged. When upgrading,
|
||||
change the container side of existing `80:80` or `8082:80` mappings to
|
||||
`80:8080` or `8082:8080`. Kubernetes Services must use `targetPort: 8080`.
|
||||
Older published images still use their original port: these examples require a
|
||||
release containing the hardening changes; pin its published digest in production.
|
||||
For the currently published `v3.14.1`, retain the old mapping:
|
||||
|
||||
docker run -p 80:80 --name mycamera -d --restart=always kerberos/agent:v3.14.1
|
||||
|
||||
The image uses a Go 1.26 scratch runtime with only required dynamic/media
|
||||
dependencies, numeric non-root UID/GID `999:999`, no file capabilities or
|
||||
setuid/setgid programs, and an application healthcheck (no curl-based probe).
|
||||
UI assets are immutable; demo mode selects its environment without rewriting them.
|
||||
Source builds require the patched Go 1.26.10 toolchain or newer.
|
||||
It supports dropping all capabilities and preventing privilege escalation:
|
||||
|
||||
```sh
|
||||
docker run --cap-drop=ALL --security-opt=no-new-privileges:true \
|
||||
-p 80:8080 --name mycamera -d kerberos/agent:latest
|
||||
```
|
||||
|
||||
See the [image contract](deployments/docker/README.md#image-contract) for writable
|
||||
paths, health checks, supported architectures, signature/evidence requirements,
|
||||
and release-policy gaps. Functional remote-shell support remains a **pending
|
||||
policy exception**, so this is not a claim of full image-hardening baseline or
|
||||
CIS compliance.
|
||||
Release promotion is also blocked by remaining native-library scan findings;
|
||||
this PR is not release-qualified. The existing recording-signing key and trust
|
||||
behavior are retained under the user's narrowly scoped exception through
|
||||
December 5, 2026 (23:59:59 UTC), recorded for review in this PR. The finding
|
||||
remains visible in scan reports; other keys and findings are not exempted.
|
||||
|
||||
If you want to connect to a USB or Raspberry Pi camera, [you'll need to run our side car container](https://github.com/kerberos-io/camera-to-rtsp) which proxies the camera to an RTSP stream. In that case you'll want to configure the Kerberos Agent container to run in the host network, so it can connect directly to the RTSP sidecar.
|
||||
|
||||
docker run --network=host --name mycamera -d --restart=always kerberos/agent:latest
|
||||
|
||||
With host networking, no port mapping applies: the hardened Agent is reachable
|
||||
on host port `8080` by default.
|
||||
|
||||
## Quickstart - Balena
|
||||
|
||||
Run Kerberos Agent with [Balena Cloud](https://www.balena.io/) super powers. Monitor your Kerberos Agent with seamless remote access, over the air updates, an encrypted public `https` endpoint and much more. Checkout our application `video-surveillance` on [Balena Hub](https://hub.balena.io/apps/2064752/video-surveillance), and create your first or fleet of Kerberos Agent(s).
|
||||
@@ -114,7 +207,7 @@ This repository contains everything you'll need to know about our core product,
|
||||
|
||||
- Low memory and CPU usage.
|
||||
- Simplified and modern user interface.
|
||||
- Multi architecture (ARMv6, ARMv7, ARM64, AMD64)
|
||||
- Multi architecture (AMD64/ARM64 release images; [legacy ARM build limitations](deployments/docker/README.md#building-and-checking-the-image)).
|
||||
- Multi stream, for example recording in H265, live streaming and motion detection in H264.
|
||||
- Multi camera support: IP Cameras (H264 and H265), USB cameras and Raspberry Pi Cameras [through a RTSP proxy](https://github.com/kerberos-io/camera-to-rtsp).
|
||||
- Single camera per instance (e.g. one container per camera).
|
||||
@@ -167,21 +260,26 @@ An example of how to mount a host directory is shown below using `docker`, but i
|
||||
|
||||
You attach a volume to your container by leveraging the `-v` option. To mount your own configuration file and recordings folder, run the following commands:
|
||||
|
||||
docker run -p 80:80 --name mycamera \
|
||||
docker run -p 80:8080 --name mycamera \
|
||||
-v $(pwd)/agent/config:/home/agent/data/config \
|
||||
-v $(pwd)/agent/recordings:/home/agent/data/recordings \
|
||||
-d --restart=always kerberos/agent:latest
|
||||
|
||||
More examples for each deployment and automation tool [can be found in the deployment section](https://github.com/kerberos-io/agent/tree/master/deployments). Be sure to verify the permissions of the directory/volume you are attaching. More information in [this issue](https://github.com/kerberos-io/agent/issues/80).
|
||||
|
||||
chmod -R 755 kerberos-agent/
|
||||
chown 100:101 kerberos-agent/ -R
|
||||
The current image runs as UID `999`, GID `999`. Give that user ownership of
|
||||
the mounted directories (older image tags may use different IDs):
|
||||
|
||||
sudo chown -R 999:999 agent/config agent/recordings
|
||||
|
||||
See [container permissions and hardening](deployments/docker/README.md#container-permissions-and-hardening)
|
||||
for reduced-capability deployment and migration guidance.
|
||||
|
||||
## Configure with environment variables
|
||||
|
||||
Next to attaching the configuration file, it is also possible to override the configuration with environment variables. This makes deploying with `docker compose` or `kubernetes` much easier and more scalable. Using this approach, we simplify automation through `ansible` and `terraform`.
|
||||
|
||||
docker run -p 80:80 --name mycamera \
|
||||
docker run -p 80:8080 --name mycamera \
|
||||
-e AGENT_NAME=mycamera \
|
||||
-e AGENT_TIMEZONE=Europe/Brussels \
|
||||
-e AGENT_CAPTURE_IPCAMERA_RTSP=rtsp://fake.kerberos.io/stream \
|
||||
@@ -304,7 +402,7 @@ See [RTSPS and TLS certificates](README-RTSPS-TLS.md) for the complete Bosch UI,
|
||||
| --------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------ |
|
||||
| `LOG_LEVEL` | Level for logging, could be "info", "warning", "debug", "error" or "fatal". | "info" |
|
||||
| `LOG_OUTPUT` | Logging output format "json" or "text". | "text" |
|
||||
| `AGENT_PORT` | HTTP web server port. Overrides the `-port` command-line value and must be between 1 and 65535. | "80" |
|
||||
| `AGENT_PORT` | HTTP web server port. Overrides `-port`; use a port above 1024 in the hardened image. | "8080" hardened Docker; "80" legacy images/binaries |
|
||||
| `AGENT_MODE` | You can choose to run this in 'release' for production, and or 'demo' for showcasing. | "release" |
|
||||
| `AGENT_TLS_INSECURE` | Specify if you want to use `InsecureSkipVerify` for the internal HTTP client. | "false" |
|
||||
| `AGENT_USERNAME` | The username used to authenticate against the Kerberos Agent login page. | "root" |
|
||||
@@ -330,6 +428,7 @@ See [RTSPS and TLS certificates](README-RTSPS-TLS.md) for the complete Bosch UI,
|
||||
| `AGENT_CAPTURE_IPCAMERA_ONVIF_XADDR` | ONVIF endpoint/address running on the camera. | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_ONVIF_USERNAME` | ONVIF username to authenticate against. | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_ONVIF_PASSWORD` | ONVIF password to authenticate against. | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_ONVIF_CHANNEL` | 1-based channel on multi-channel ONVIF devices (encoders, NVRs) used for PTZ, presets and focus. | "0" (first profile) |
|
||||
| `AGENT_CAPTURE_MOTION` | Toggle for enabling or disabling motion. | "true" |
|
||||
| `AGENT_CAPTURE_LIVEVIEW` | Toggle for enabling or disabling liveview. | "true" |
|
||||
| `AGENT_CAPTURE_SNAPSHOTS` | Toggle for enabling or disabling snapshot generation. | "true" |
|
||||
@@ -410,6 +509,17 @@ 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.
|
||||
|
||||
An HTTP 500 during resumable creation is a server/provider failure, not proof
|
||||
that the recording filename is invalid. Creation errors include up to 4 KiB of
|
||||
the server response, and the final retry error retains the latest failure cause.
|
||||
Created upload sessions remain usable if only the optional creation response
|
||||
body is interrupted; the successful response's `Location` is retained.
|
||||
Check the corresponding Hub/Vault logs and storage-provider configuration.
|
||||
As a temporary diagnostic workaround, set `AGENT_DISABLE_RESUMABLE_UPLOAD=true`
|
||||
and restart Agent to use ordinary uploads. This does not change the recording
|
||||
filename or remove its structured metadata, but interrupted uploads restart
|
||||
from the beginning. Remove the override after fixing the provider issue.
|
||||
|
||||
### Custom metadata headers for Kerberos Vault
|
||||
|
||||
Use `AGENT_KERBEROSVAULT_CUSTOM_HEADERS` to attach your own metadata (for
|
||||
@@ -436,7 +546,7 @@ The value must be valid JSON on a single line, so wrap it in single quotes in a
|
||||
shell:
|
||||
|
||||
```bash
|
||||
docker run -p 80:80 --name mycamera \
|
||||
docker run -p 80:8080 --name mycamera \
|
||||
-e AGENT_CLOUD=kstorage \
|
||||
-e AGENT_KERBEROSVAULT_URI=https://vault.domain.com/api \
|
||||
-e AGENT_KERBEROSVAULT_ACCESS_KEY=xxx \
|
||||
@@ -577,7 +687,7 @@ Go and open two terminals: one for the `ui` project and one for the `machinery`
|
||||
1. Terminal A:
|
||||
|
||||
cd machinery/
|
||||
go run main.go -action run -port 80
|
||||
go run . -action run -port 80
|
||||
|
||||
2. Terminal B:
|
||||
|
||||
@@ -602,7 +712,10 @@ The `web` is a **React** project which is the main entry point for an end user t
|
||||
cd ui
|
||||
yarn start
|
||||
|
||||
This will start a webserver and launches the web app on port `3000`.
|
||||
This starts the web app on port `3000`. In development, the UI connects to the
|
||||
Agent API on port `8082`. Override that port when needed:
|
||||
|
||||
REACT_APP_AGENT_API_PORT=9090 yarn start
|
||||
|
||||

|
||||
|
||||
@@ -618,9 +731,14 @@ You can simply run the `machinery` using following commands.
|
||||
|
||||
git clone https://github.com/kerberos-io/agent
|
||||
cd machinery
|
||||
go run main.go -action run -port 80
|
||||
go run . -action run -port 8082
|
||||
|
||||
This will launch the Kerberos Agent and run a webserver on port `80`. You can change the port by your own preference. We strongly support the usage of [Goland](https://www.jetbrains.com/go/) or [Visual Studio Code](https://code.visualstudio.com/), as it comes with all the debugging and linting features built in.
|
||||
This will launch the Kerberos Agent and run a webserver on port `8082`, matching
|
||||
the UI development default. You can change the port together with
|
||||
`REACT_APP_AGENT_API_PORT`. We strongly support the usage of
|
||||
[Goland](https://www.jetbrains.com/go/) or
|
||||
[Visual Studio Code](https://code.visualstudio.com/), as it comes with all the
|
||||
debugging and linting features built in.
|
||||
|
||||

|
||||
|
||||
@@ -658,7 +776,7 @@ 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 \
|
||||
docker run --rm -p 80:8080 \
|
||||
-e AGENT_LIVE_MOQ_ENABLED=true \
|
||||
-e AGENT_LIVE_MOQ_URL=https://relay.uug.ai/anon \
|
||||
kerberos/agent
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
Kubernetes is great, but you might love OpenShift even more. In this directory you'll find some resources to deploy your Kerberos Agent in an OpenShift cluster using Ansible playbook. We'll review the different tasks of the Ansible playbook step by step; find the complete `playbook.yaml` here.
|
||||
|
||||
The playbook's port mapping targets the forthcoming hardened image:
|
||||
`containerPort: 8080` and `targetPort: 8080`, with external Service port `80`
|
||||
unchanged. Previously published images, including `v3.14.1`, still need the old
|
||||
container port 80. Upgrade the image and mapping together and pin its published
|
||||
digest. See the [image contract](../docker/README.md#image-contract) for numeric
|
||||
UID/GID `999:999`, writable data/cache paths and release blockers. OpenShift SCCs
|
||||
may override the UID; provision matching volume permissions and verify your
|
||||
cluster's policy rather than granting privileged access. This legacy playbook's
|
||||
credential-debugging/certificate-validation settings also require production
|
||||
review; the image/port update does not certify the cluster deployment.
|
||||
|
||||
## Variabeles
|
||||
|
||||
We'll have a few `variables` in our `playbook.yml` that will help us to setup secure connection with the OpenShift cluster. We need the `cluster_url` and the `username` and `password` of the OpenShift cluster. If you don't know where to find this, you can find this in the OpenShift web ui.
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
mountPath: /home/agent/data/cloud
|
||||
subPath: cloud
|
||||
ports:
|
||||
- containerPort: 80
|
||||
- containerPort: 8080
|
||||
protocol: TCP
|
||||
|
||||
- name: Expose Kerberos Agent
|
||||
@@ -136,5 +136,5 @@
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 80
|
||||
targetPort: 8080
|
||||
protocol: TCP
|
||||
|
||||
@@ -4,17 +4,258 @@ The easiest, and let's say most natural, deployment is done [by utilising `docke
|
||||
|
||||
## 1. Running a single container
|
||||
|
||||
We are creating Docker images as part of our CI/CD process. You'll find our Docker images on [Docker hub](https://hub.docker.com/r/kerberos/agent). Pick a specific tag of choice, or use latest. Once done run below command, this will open the web interface of your Kerberos agent on port 80.
|
||||
We publish production images on [Docker Hub](https://hub.docker.com/r/kerberos/agent).
|
||||
The following examples target the forthcoming hardened image, which listens on
|
||||
**8080 inside the container**. They preserve host port 80 and the browser URL
|
||||
`http://localhost/`:
|
||||
|
||||
docker run -p 80:80 --name mycamera -d kerberos/agent:latest
|
||||
docker run -p 80:8080 --name mycamera -d kerberos/agent:latest
|
||||
|
||||
Or for a develop build:
|
||||
**Migration:** releases published before this change, including `v3.14.1`, still
|
||||
listen on container port 80 and require `-p 80:80`. Do not change their mappings
|
||||
until upgrading the image too. The separate `kerberos/agent-dev` image is not
|
||||
migrated by this production-image change:
|
||||
|
||||
docker run -p 80:80 --name mycamera -d kerberos/agent-dev:latest
|
||||
|
||||
Feel free to use another port if your host system already has a workload running on `80`. For example `8082`.
|
||||
|
||||
docker run -p 8082:80 --name mycamera -d kerberos/agent:latest
|
||||
docker run -p 8082:8080 --name mycamera -d kerberos/agent:latest
|
||||
|
||||
## Image contract
|
||||
|
||||
This contract describes the hardened production Dockerfile, not an already
|
||||
published release or a claim of full
|
||||
[image-hardening baseline](https://learn.uug.ai/guides/security/image-hardening/)
|
||||
compliance. Use a release's actual published multi-platform digest, for example
|
||||
`kerberos/agent:<version>@sha256:<digest>`, rather than treating mutable `latest`
|
||||
as immutable. No new release digest or signature is asserted by this PR.
|
||||
|
||||
| Property | Contract |
|
||||
|---|---|
|
||||
| Identity | Fixed numeric `USER 999:999`, account `agent`, primary group `kerberosio`; not root and no privilege-changing startup. |
|
||||
| Startup | Working directory `/home/agent`; exec-form default command `./main -action run`. Standalone binary CLI defaults remain unchanged. |
|
||||
| Inbound HTTP | TCP `8080`, configurable with `AGENT_PORT` (overrides `-port`). Use an unprivileged port. Map host `80:8080`, or Service port `80` to `targetPort: 8080`. Terminate inbound TLS at a trusted proxy; the default listener is HTTP. |
|
||||
| Privileges | No bundled setuid/setgid programs or file capabilities. Run with all capabilities dropped and `no-new-privileges`; no `NET_BIND_SERVICE` is required. |
|
||||
| Writable application paths | Only `/home/agent/data/` (configuration, recordings, snapshots, cloud, tus checkpoints, capture tests and logs). Binary, UI assets and application home remain root-owned/read-only to UID 999. Demo mode serves its environment without rewriting UI assets. |
|
||||
| Temporary state | `/tmp`, `/var/tmp`; user font/media caches under `XDG_CACHE_HOME=/tmp/agent-cache`, owned by `999:999` with mode `0700`. Recreate that directory with these permissions when mounting a fresh tmpfs over `/tmp`. |
|
||||
| Health | Exec `/home/agent/main -action healthcheck`; five-second bounded localhost `/health` probe on the resolved port, no proxy/redirects, validates the Agent JSON envelope, exits nonzero on failure. Docker timeout is ten seconds. |
|
||||
| Readiness and metrics | `/health` is HTTP liveness, not proof of camera, MQTT or Hub readiness. Camera/Hub details are diagnostic. This image does not add a Prometheus metrics listener. |
|
||||
| Certificates/time | Debian CA bundle, OpenSSL configuration and timezone data retained. Mount a prepared CA bundle read-only and use `SSL_CERT_FILE` where appropriate for Go clients; native FFmpeg/OpenSSL clients may require their own CA configuration. Do not disable TLS verification as a substitute. |
|
||||
| Secrets | Inject application credentials/keys at runtime using the orchestrator's existing environment/configuration mechanisms. Change default credentials before exposing HTTP. Never build production config, `.env`, private keys or registry credentials into an image. No new generic `*_FILE` interface is provided. |
|
||||
| Outbound traffic | Configured RTSP/RTSPS/ONVIF camera endpoints, MQTT, Hub/Vault/storage HTTPS endpoints, DNS and optional telemetry; WebRTC may require configured STUN/TURN and negotiated UDP/TCP connectivity. Apply an allowlist suited to the actual enabled integrations. |
|
||||
| Architecture | Native AMD64 and ARM64 build/runtime checks; ARMv7 remains a legacy build target needing its own evidence. ARMv6 is blocked by the default builder manifest; see below. |
|
||||
|
||||
### Container permissions and hardening
|
||||
|
||||
The final image uses `FROM scratch` with an explicitly assembled Debian/glibc
|
||||
runtime rather than the whole Debian filesystem. FFmpeg and its required native
|
||||
libraries/plugins remain for camera/media functionality, including AAC-to-PCMU.
|
||||
Apt/dpkg executables, account/capability-management tools, curl, and bundled
|
||||
setuid/setgid permissions are absent. An application subcommand, not shell/curl,
|
||||
implements the healthcheck.
|
||||
|
||||
```sh
|
||||
docker run --cap-drop=ALL --security-opt=no-new-privileges:true \
|
||||
-p 80:8080 --name mycamera -d kerberos/agent:latest
|
||||
```
|
||||
|
||||
Do not use `--privileged` for ordinary IP cameras. Local cameras still use the
|
||||
separate RTSP sidecar; any device access belongs to that sidecar. Host networking
|
||||
remains supported, but without a mapping the Agent is reachable on host port
|
||||
`8080`, not 80.
|
||||
|
||||
The numeric `USER 999:999` does **not** automatically inherit supplementary video
|
||||
membership from `/etc/group`. If a deployment deliberately passes a video device
|
||||
to Agent, explicitly add the device's host GID: `--group-add 44` for GID 44,
|
||||
Compose `group_add: ["44"]`, or Kubernetes `supplementalGroups: [44]`. The runtime
|
||||
retains the `video` group record. Match the actual device permissions; this does
|
||||
not grant arbitrary device access or replace the separate local-camera sidecar.
|
||||
|
||||
**Pending policy exception:** functional remote terminal sessions require
|
||||
`/bin/sh`, PTY devices, terminal data and basic GNU utilities. These remain for
|
||||
that feature, not healthchecks or debugging convenience. The user chose to
|
||||
preserve the feature, but no policy approver, exception owner or expiry has been
|
||||
provided. This is not an approved waiver or full baseline/CIS compliance.
|
||||
Baseline-qualified release promotion must remain blocked until the exception is
|
||||
formally resolved; do not silently remove the feature or manufacture approval.
|
||||
|
||||
Bind mounts must be writable by the image's UID/GID; the container does not
|
||||
change host ownership at startup. Before migrating an older volume, check the
|
||||
user of the exact old and new image with `docker run --rm --entrypoint id IMAGE`
|
||||
and adjust only the mounted directories as necessary. For example:
|
||||
|
||||
```sh
|
||||
sudo chown -R 999:999 agent/config agent/recordings
|
||||
```
|
||||
|
||||
A read-only root filesystem is not enabled by default: `data/` needs initialized
|
||||
writable storage (and FFmpeg or shell sessions may need `/tmp` and `/var/tmp`).
|
||||
Empty bind mounts hide packaged configuration/media files; initialize
|
||||
them before use. Docker named volumes populate packaged contents on first use.
|
||||
Customers enabling read-only root must supply those writable mounts and cache
|
||||
ownership, keep the runtime's device/PTY mounts, and validate their enabled media
|
||||
and remote-access features. Apply CPU/memory/PID limits, seccomp/AppArmor and
|
||||
network policies appropriate to the deployment.
|
||||
|
||||
### Building and checking the image
|
||||
|
||||
Build from a clean checkout: the default configuration files under
|
||||
`machinery/data/config/` are included in the image, so do not replace them with
|
||||
production credentials before building. `.dockerignore` excludes local `.env`
|
||||
files, credential directories/configuration, test/profiling outputs, recordings,
|
||||
upload checkpoints, dependency directories, and unrelated
|
||||
repository files from the build context. The builder uses Go `1.26.10` with
|
||||
`GOTOOLCHAIN=local`, checksum database verification, and `go mod verify`. Builds
|
||||
use `-mod=readonly` to reject implicit module changes, `-trimpath` to omit local
|
||||
source paths, and `-buildvcs=false` to omit checkout metadata. CGO remains enabled
|
||||
for the native camera/media dependencies.
|
||||
|
||||
Source builds require at least Go 1.26.10, matching the pinned security patch floor.
|
||||
The Go, Node, and Debian bases are pinned to verified multi-platform manifest
|
||||
digests, including AMD64 and ARM64 (and ARMv7 where previously supported).
|
||||
The pinned Debian image is used only to assemble the scratch runtime with
|
||||
glibc-compatible FFmpeg, CGO, and MoQ dependencies. Its native package dependency
|
||||
metadata selects the architecture's dynamic loader, shared libraries, media
|
||||
plugins/drivers, and associated data files. This is intentionally broader than
|
||||
an `ldd`-only copy, which misses dynamically loaded plugins. Certificates,
|
||||
OpenSSL configuration, time zones, font/MIME/pixbuf caches, account records, and
|
||||
standard writable `/tmp` and device mount points are retained. The runtime
|
||||
uses `XDG_CACHE_HOME=/tmp/agent-cache` for disposable user caches without making
|
||||
the application home writable. It contains package provenance in
|
||||
`/var/lib/dpkg/status` and license notices, but
|
||||
not the package manager. Update it by rebuilding and replacing the image rather
|
||||
than installing packages in a running container.
|
||||
|
||||
```sh
|
||||
docker build --pull -t kerberos/agent:local .
|
||||
sh deployments/docker/test-image.sh kerberos/agent:local
|
||||
```
|
||||
|
||||
The smoke check uses temporary containers and a Docker volume, cleans them up,
|
||||
and checks permissions, FFmpeg audio transcoding, H264/sample-video decoding,
|
||||
H265 encoding/decoding, font rendering, shell/PTY devices, volume
|
||||
initialization/writes, default and custom HTTP ports, health probes, and
|
||||
demo-mode startup with a read-only root and immutable UI assets. It enforces
|
||||
privileged low ports while running on 8080 with no capabilities and
|
||||
`no-new-privileges`, and also checks an overridden port `8082`. PR builds
|
||||
run it on native AMD64 and ARM64 runners. It does not replace testing against a
|
||||
real camera, hardware codecs/devices, an MQTT-mediated remote shell session, or
|
||||
all supported ARM variants. Review and refresh the
|
||||
base-image digests regularly for security updates; `--pull` alone does not update
|
||||
a pinned digest. `GO_IMAGE` and `RUNTIME_IMAGE` remain overrideable build arguments
|
||||
for compatible alternatives. The package repositories are not snapshot-pinned,
|
||||
so base digest pins do not make the entire build bit-for-bit reproducible.
|
||||
|
||||
Architecture selections are unchanged by this hardening.
|
||||
The pinned `golang:1.26.10-trixie` manifest, like the previous Go 1.25 trixie base,
|
||||
has no `linux/arm/v6` variant, so the existing ARMv6 nightly target cannot be
|
||||
validated with the default base.
|
||||
ARMv6 needs a separately reviewed compatible builder/runtime combination; do
|
||||
not assume an ARMv7 image runs on ARMv6 hardware.
|
||||
|
||||
### Release evidence, vulnerabilities and lifecycle
|
||||
|
||||
The hardened release path must build/test the supported release architectures,
|
||||
block HIGH/CRITICAL vulnerability/secret/misconfiguration findings, retain scan
|
||||
reports and an SBOM, publish build provenance, and sign the **final
|
||||
multi-platform index digest**, not just one child manifest. A pending policy
|
||||
exception is not an approved scan exception. There are no approved
|
||||
vulnerability exceptions in this change; findings require remediation or a
|
||||
reviewed owner/expiry/approval record. Scanner/database failures must fail
|
||||
closed. Workflow definitions are not evidence that registry publication,
|
||||
signing, or scans have already succeeded.
|
||||
|
||||
Source scans cover UI production **and build/development** dependencies and retain
|
||||
their application inventory/SBOM, because compiled browser assets hide lockfile
|
||||
metadata from final-rootfs scanners. Final-image evidence separately covers native
|
||||
packages and the Go binary. Current native-library findings remain release
|
||||
blockers. The existing embedded recording-signing key and trust behavior are
|
||||
retained under the user's time-limited source-secret exception, recorded for
|
||||
review in this PR in
|
||||
[image-scan-exceptions.json](../../.github/scripts/image-scan-exceptions.json).
|
||||
Owner and approver: `@cedricve`; expiry: **December 5, 2026, 23:59:59 UTC**.
|
||||
Anyone possessing this shared private key can forge signatures under its
|
||||
identity; the exception accepts that risk temporarily, it does not fix it.
|
||||
|
||||
The exception matches only that source file's SHA-256, exact scanner rule,
|
||||
category, HIGH severity and line location, and requires exactly one match.
|
||||
Changes, missing/incomplete approval, expiry or duplicate findings fail closed.
|
||||
It cannot exempt an image finding, another secret, a vulnerability or a
|
||||
misconfiguration. The policy is loaded from the executing workflow's checkout,
|
||||
not the source directory being scanned. Retained `scan.json` still contains the
|
||||
redacted HIGH finding; `exception-policy.json`, `exception-decisions.json` and
|
||||
`policy.json` record the approval/application, without key material. Review the
|
||||
record in PR #350 before merging; repository owners must enforce required reviews.
|
||||
This exception does not approve the separate pending remote-terminal policy.
|
||||
|
||||
The build jobs run compilation and production-image smoke tests before the
|
||||
security gates. A job that fails at **Source/UI dependency and final image gates
|
||||
with SPDX SBOMs** is not a compiler or startup failure; inspect its retained
|
||||
reports rather than disabling the gate. Patched Go dependencies are checked
|
||||
with `govulncheck` as well as the image/source scanner. The pinned
|
||||
`golang.org/x/crypto` revision is the official reviewed OpenPGP module split
|
||||
for [GO-2026-5932](https://pkg.go.dev/vuln/GO-2026-5932); Agent does not import the
|
||||
retired OpenPGP packages.
|
||||
|
||||
Debian 13 stable, security and backports currently provide no patched FFmpeg
|
||||
7.1.x or libxml2 packages for the blocking findings on either AMD64 or ARM64.
|
||||
For example, Debian defers [CVE-2026-58049](https://security-tracker.debian.org/tracker/CVE-2026-58049)
|
||||
until an upstream 7.1.x fix; a fix exists in FFmpeg 9. A Wolfi FFmpeg 7 prototype
|
||||
had no vendor-scanner findings but still used the same affected upstream
|
||||
7.1.5 code and lacked the existing hardware accelerators. It was **not**
|
||||
adopted to obtain a misleading clean scan. A newer media stack needs matched
|
||||
CGO headers/libraries and validation of codecs, hardware acceleration and
|
||||
supported architectures, not an unreviewed mix of testing-distribution packages.
|
||||
The user has chosen to keep the default recording-signing key; no trust
|
||||
migration or startup change is implemented. Its source-secret finding becomes
|
||||
release-blocking again if the scoped exception expires or no longer matches.
|
||||
Any future removal would require a separately
|
||||
authorized migration: per-installation keys need verifier/public-key provisioning,
|
||||
while requiring an injected key changes startup for existing keyless installations.
|
||||
|
||||
Before deploying, obtain the release's digest, source revision, supported
|
||||
platforms, per-platform SBOM/scan reports, provenance, signature certificate
|
||||
identity and policy approvals from its retained release evidence. Verify the
|
||||
signature against the **exact trusted workflow identity** and GitHub OIDC issuer,
|
||||
not an unrestricted identity regexp:
|
||||
|
||||
Workflow artifacts `image-source-evidence` and `image-final-evidence` retain
|
||||
sanitized reports, source/application and per-platform SBOMs, provenance, base
|
||||
digests and the source revision for 90 days. Successful releases also attach
|
||||
`image-security-evidence.tar.gz`. After successful signing,
|
||||
`signing-identity.txt` records the exact
|
||||
`https://github.com/kerberos-io/agent/.github/workflows/image-release.yml@...`
|
||||
identity; the suffix is the executing workflow ref, not necessarily the source
|
||||
ref. Require the expected trusted ref, not merely any identity found in an
|
||||
artifact. Secret matches/source excerpts are redacted; findings are not waived.
|
||||
|
||||
```sh
|
||||
# Set these from the trusted release's real evidence, not from this example.
|
||||
cosign verify --certificate-identity="$COSIGN_IDENTITY" \
|
||||
--certificate-oidc-issuer=https://token.actions.githubusercontent.com \
|
||||
"$AGENT_IMAGE"
|
||||
```
|
||||
|
||||
`AGENT_IMAGE` must contain the immutable registry digest. Reject missing,
|
||||
mismatched, expired or unapproved evidence; neither a successful local build nor
|
||||
`FROM scratch` certifies the image is vulnerability-free. Registry credentials,
|
||||
OIDC permissions and repository release protections must be configured by the
|
||||
repository owner; this PR does not invent them or certify those external controls.
|
||||
|
||||
Review pinned digests and native/package/module findings for every release and
|
||||
regularly between releases. Debian 13 has
|
||||
[full support until August 9, 2028 and LTS until June 30, 2030](https://www.debian.org/releases/trixie/),
|
||||
with reduced LTS architecture/package coverage. The Go 1.26 line is supported
|
||||
[until two newer major Go releases exist](https://go.dev/doc/devel/release#policy);
|
||||
update its patch pin and replace it before that boundary. The build-only Node 22
|
||||
line has [scheduled EOL April 30, 2027](https://github.com/nodejs/Release/blob/main/schedule.json).
|
||||
The pinned UI builder is Node `22.23.4` on Alpine `3.24.3`;
|
||||
[Alpine 3.24 main support ends June 1, 2028](https://alpinelinux.org/releases/),
|
||||
while community support ends at the next stable release. Track both when
|
||||
refreshing that build-only base. Rebuilding does not refresh immutable
|
||||
base pins automatically, and APT repositories are not snapshot-pinned. Retain the
|
||||
actual installed-package/SBOM evidence per architecture and release.
|
||||
|
||||
### Attach a volume
|
||||
|
||||
@@ -34,7 +275,7 @@ You attach a volume to your container by leveraging the `-v` option. To mount yo
|
||||
|
||||
3. Run the docker command as following to attach your config directory and recording directory.
|
||||
|
||||
docker run -p 80:80 --name mycamera \
|
||||
docker run -p 80:8080 --name mycamera \
|
||||
-v $(pwd)/agent/config:/home/agent/data/config \
|
||||
-v $(pwd)/agent/recordings:/home/agent/data/recordings \
|
||||
-d --restart=always kerberos/agent:latest
|
||||
@@ -52,7 +293,7 @@ You'll find [an example `docker-compose.yaml` file here](https://github.com/kerb
|
||||
kerberos-agent2:
|
||||
image: "kerberos/agent:latest"
|
||||
ports:
|
||||
- "8082:80"
|
||||
- "8082:8080"
|
||||
environment:
|
||||
- AGENT_NAME=agent2
|
||||
- AGENT_CAPTURE_IPCAMERA_RTSP=rtsp://x.x.x.x:554/Streaming/Channels/101
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
root=/runtime
|
||||
mkdir -p "$root/usr/bin" "$root/usr/sbin" "$root/usr/lib" "$root/etc" \
|
||||
"$root/var/lib/dpkg" "$root/dev/pts" "$root/proc" "$root/sys" "$root/run"
|
||||
ln -s usr/bin "$root/bin"
|
||||
ln -s usr/sbin "$root/sbin"
|
||||
ln -s usr/lib "$root/lib"
|
||||
if [ -L /lib64 ]; then
|
||||
mkdir -p "$root/usr/lib64"
|
||||
ln -s usr/lib64 "$root/lib64"
|
||||
fi
|
||||
|
||||
# Package dependencies include dlopen-loaded codecs/drivers and their data, which
|
||||
# ldd alone cannot discover. Debian resolves the native architecture's loader too.
|
||||
apt-cache depends --recurse --installed --no-recommends --no-suggests \
|
||||
--no-conflicts --no-breaks --no-replaces --no-enhances \
|
||||
ffmpeg libatomic1 libstdc++6 dash coreutils findutils grep sed tzdata ncurses-base \
|
||||
> /tmp/runtime-dependencies
|
||||
sed -n '/^[a-z0-9][a-z0-9+.:_-]*$/p' /tmp/runtime-dependencies \
|
||||
| grep -vx debconf | sort -u > /tmp/runtime-candidates
|
||||
dpkg-query -W -f='${Package} ${db:Status-Status}\n' > /tmp/package-status
|
||||
awk '$2 == "installed" { print $1 }' /tmp/package-status \
|
||||
| sort -u > /tmp/installed-packages
|
||||
comm -12 /tmp/runtime-candidates /tmp/installed-packages > /tmp/runtime-packages
|
||||
|
||||
set --
|
||||
while IFS= read -r package; do
|
||||
set -- "$@" "$package"
|
||||
done < /tmp/runtime-packages
|
||||
dpkg-query -L "$@" > /tmp/runtime-package-paths
|
||||
sort -u /tmp/runtime-package-paths > /tmp/runtime-paths
|
||||
while IFS= read -r path; do
|
||||
case "$path" in
|
||||
"") continue ;;
|
||||
/usr/share/doc/*/copyright|/usr/share/common-licenses/*) ;;
|
||||
/usr/share/doc/*|/usr/share/man/*|/usr/share/info/*|/usr/share/locale/*|\
|
||||
/usr/share/lintian/*|/usr/share/bug/*|/usr/share/debconf/*|\
|
||||
/usr/share/bash-completion/*|/usr/share/fish/*|\
|
||||
/usr/lib/systemd/*|/lib/systemd/*|/etc/init.d/*|/etc/cron.*/*) continue ;;
|
||||
esac
|
||||
if [ -d "$path" ] && [ ! -L "$path" ]; then
|
||||
continue
|
||||
fi
|
||||
if [ ! -f "$path" ] && [ ! -L "$path" ]; then
|
||||
printf 'Missing selected runtime file: %s\n' "$path" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$path"
|
||||
done < /tmp/runtime-paths > /tmp/runtime-files
|
||||
tar --create --file /tmp/runtime.tar --no-recursion --files-from /tmp/runtime-files
|
||||
tar --extract --file /tmp/runtime.tar --directory "$root"
|
||||
|
||||
# Preserve generated trust stores, font configuration/caches and pixbuf loaders.
|
||||
cp -a --parents /etc/ssl /usr/lib/ssl /usr/share/ca-certificates /etc/fonts \
|
||||
/var/cache/fontconfig /usr/share/mime /usr/share/common-licenses "$root"
|
||||
find /usr/lib -path '*/gdk-pixbuf-2.0/*/loaders.cache' \
|
||||
-exec cp -a --parents {} "$root" \;
|
||||
cp -a --parents /etc/nsswitch.conf /etc/gai.conf /etc/localtime \
|
||||
/etc/ld.so.conf /etc/ld.so.conf.d /etc/os-release /etc/debian_version \
|
||||
/usr/lib/os-release /usr/lib/locale "$root"
|
||||
|
||||
# Some public SONAME links (BLAS/LAPACK, for example) are generated by Debian
|
||||
# alternatives rather than owned by a package. Keep only links to selected files.
|
||||
find /usr/lib -type l > /tmp/runtime-links
|
||||
if [ -d /usr/lib64 ]; then
|
||||
find /usr/lib64 -type l >> /tmp/runtime-links
|
||||
fi
|
||||
while IFS= read -r link; do
|
||||
target=$(readlink -f "$link")
|
||||
if [ -e "$root$target" ]; then
|
||||
while [ -L "$link" ]; do
|
||||
cp -a --parents "$link" "$root"
|
||||
target=$(readlink "$link")
|
||||
case "$target" in
|
||||
/*) link=$target ;;
|
||||
*) link=$(dirname "$link")/$target ;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
done < /tmp/runtime-links
|
||||
|
||||
getent passwd root agent nobody > "$root/etc/passwd"
|
||||
getent group root kerberosio video nogroup > "$root/etc/group"
|
||||
mkdir -p "$root/tmp" "$root/var/tmp"
|
||||
chmod 1777 "$root/tmp" "$root/var/tmp"
|
||||
install -d -m 0700 -o 999 -g 999 "$root/tmp/agent-cache"
|
||||
|
||||
# Retain package provenance for scanners without shipping apt/dpkg executables.
|
||||
dpkg-query -s "$@" ca-certificates base-files > "$root/var/lib/dpkg/status"
|
||||
mkdir -p "$root/usr/share/doc/ca-certificates"
|
||||
cp /usr/share/doc/ca-certificates/copyright "$root/usr/share/doc/ca-certificates/"
|
||||
ldconfig -r "$root"
|
||||
chroot "$root" /usr/bin/fc-cache --force
|
||||
find "$root" -type f -perm /6000 -exec chmod a-s {} +
|
||||
|
||||
# Execute against the assembled loader/libraries, not the assembly-stage rootfs.
|
||||
chroot --userspec=999:999 "$root" /bin/sh -ec '
|
||||
/home/agent/main -action version
|
||||
ffmpeg -version
|
||||
ffprobe -version
|
||||
test -s /etc/ssl/certs/ca-certificates.crt
|
||||
test ! -e /usr/bin/curl
|
||||
test ! -e /usr/bin/apt
|
||||
test ! -e /usr/bin/dpkg
|
||||
'
|
||||
@@ -3,7 +3,7 @@ services:
|
||||
kerberos-agent1:
|
||||
image: "kerberos/agent:latest"
|
||||
ports:
|
||||
- "8081:80"
|
||||
- "8081:8080"
|
||||
environment:
|
||||
- AGENT_NAME=agent1
|
||||
# You can still override the configuration with environment variables, but might not makes sense if you are attaching a host config.
|
||||
@@ -14,14 +14,14 @@ services:
|
||||
kerberos-agent2:
|
||||
image: "kerberos/agent:latest"
|
||||
ports:
|
||||
- "8082:80"
|
||||
- "8082:8080"
|
||||
volumes:
|
||||
- ./agent2/config:/home/agent/data/config
|
||||
- ./agent2/recordings:/home/agent/data/recordings
|
||||
kerberos-agent3:
|
||||
image: "kerberos/agent:latest"
|
||||
ports:
|
||||
- "8083:80"
|
||||
- "8083:8080"
|
||||
volumes:
|
||||
- ./agent3/config:/home/agent/data/config
|
||||
- ./agent3/recordings:/home/agent/data/recordings
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
kerberos-agent1:
|
||||
image: "kerberos/agent:latest"
|
||||
ports:
|
||||
- "8081:80"
|
||||
- "8081:8080"
|
||||
environment:
|
||||
<<: *common-variables
|
||||
AGENT_NAME: agent1
|
||||
@@ -17,7 +17,7 @@ services:
|
||||
kerberos-agent2:
|
||||
image: "kerberos/agent:latest"
|
||||
ports:
|
||||
- "8082:80"
|
||||
- "8082:8080"
|
||||
environment:
|
||||
<<: *common-variables
|
||||
AGENT_NAME: agent2
|
||||
@@ -26,7 +26,7 @@ services:
|
||||
kerberos-agent3:
|
||||
image: "kerberos/agent:latest"
|
||||
ports:
|
||||
- "8083:80"
|
||||
- "8083:8080"
|
||||
environment:
|
||||
<<: *common-variables
|
||||
AGENT_NAME: agent3
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
image=${1:?Usage: sh deployments/docker/test-image.sh IMAGE}
|
||||
container=
|
||||
volume=
|
||||
temporary_volume=
|
||||
cleanup() {
|
||||
if [ -n "$container" ]; then docker rm -f "$container" >/dev/null; fi
|
||||
if [ -n "$volume" ]; then docker volume rm "$volume" >/dev/null; fi
|
||||
if [ -n "$temporary_volume" ]; then docker volume rm "$temporary_volume" >/dev/null; fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 1' HUP INT TERM
|
||||
|
||||
docker run --rm --entrypoint /bin/sh "$image" -ec '
|
||||
test "$(id -u)" = 999
|
||||
test "$(id -g)" = 999
|
||||
test ! -w /home/agent
|
||||
test ! -w /home/agent/main
|
||||
test -w /home/agent/data/config
|
||||
test -w /home/agent/data/recordings
|
||||
test -w /home/agent/data/tus
|
||||
test ! -w /home/agent/www
|
||||
test ! -w /home/agent/www/env.js
|
||||
test -z "$(find / -xdev -type f -perm /6000 -print 2>/dev/null)"
|
||||
! command -v setcap
|
||||
! command -v capsh
|
||||
! command -v apt
|
||||
! command -v apt-get
|
||||
! command -v dpkg
|
||||
! command -v curl
|
||||
test -s /var/lib/dpkg/status
|
||||
test -r /etc/os-release
|
||||
test -r /usr/share/terminfo/x/xterm-256color
|
||||
test -s /etc/ssl/certs/ca-certificates.crt
|
||||
test "$(id -Gn)" = "kerberosio"
|
||||
test "$(stat -c %a /tmp)" = 1777
|
||||
test -w "$XDG_CACHE_HOME"
|
||||
test ! -e /home/agent/.env
|
||||
test -z "$(find /home/agent/data/tus -type f -print)"
|
||||
./main -action version
|
||||
ffmpeg -nostdin -v error -f lavfi -i sine=frequency=1000:sample_rate=8000 \
|
||||
-t 0.1 -c:a aac -f adts /tmp/test.aac
|
||||
ffmpeg -nostdin -v error -i /tmp/test.aac -ar 8000 -ac 1 \
|
||||
-c:a pcm_mulaw -f mulaw /tmp/test.pcm
|
||||
test -s /tmp/test.pcm
|
||||
ffprobe -v error -show_entries stream=codec_name data/test-480p.mp4
|
||||
ffmpeg -nostdin -v error -i data/test-480p.mp4 -frames:v 1 -f null -
|
||||
ffmpeg -nostdin -v error -i data/test-480p.mp4 -frames:v 1 \
|
||||
-c:v libx265 -x265-params pools=none:frame-threads=1:log-level=error \
|
||||
-f hevc /tmp/test.hevc
|
||||
ffmpeg -nostdin -v error -i /tmp/test.hevc -frames:v 1 -f null -
|
||||
ffmpeg -nostdin -v error -f lavfi -i color=black:s=64x64 \
|
||||
-vf drawtext=text=scratch:fontcolor=white -frames:v 1 -f null -
|
||||
'
|
||||
|
||||
# Exercise the same shell and PTY devices used by remote-access sessions.
|
||||
docker run --rm -t --entrypoint /bin/sh "$image" -ec '
|
||||
test -t 0
|
||||
test -t 1
|
||||
test -c /dev/ptmx
|
||||
test -d /dev/pts
|
||||
stty size
|
||||
printf "Shell and PTY ready\n"
|
||||
'
|
||||
docker run --rm --group-add 44 --entrypoint /bin/sh "$image" -ec '
|
||||
test "$(id -Gn)" = "kerberosio video"
|
||||
'
|
||||
wait_healthy() {
|
||||
attempt=0
|
||||
while [ "$attempt" -lt 60 ]; do
|
||||
status=$(docker inspect --format '{{.State.Health.Status}}' "$container")
|
||||
if [ "$status" = healthy ]; then return; fi
|
||||
if [ "$(docker inspect --format '{{.State.Running}}' "$container")" != true ]; then
|
||||
break
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep 1
|
||||
done
|
||||
docker logs "$container"
|
||||
docker inspect --format '{{json .State.Health}}' "$container"
|
||||
echo "Agent did not become healthy" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Exercise the unprivileged default port without any capabilities.
|
||||
volume=$(docker volume create)
|
||||
container=$(docker run -d --cap-drop ALL --security-opt no-new-privileges=true \
|
||||
--sysctl net.ipv4.ip_unprivileged_port_start=1024 \
|
||||
--health-interval=1s --health-start-period=0s \
|
||||
--mount "type=volume,source=$volume,target=/home/agent/data" "$image")
|
||||
wait_healthy
|
||||
docker exec "$container" sh -ec '
|
||||
test "$AGENT_PORT" = 8080
|
||||
./main -action healthcheck
|
||||
test -r data/config/config.json
|
||||
touch data/config/.permission-test data/recordings/.permission-test data/tus/.permission-test
|
||||
rm data/config/.permission-test data/recordings/.permission-test data/tus/.permission-test
|
||||
'
|
||||
if docker exec -e AGENT_PORT=65534 "$container" ./main -action healthcheck; then
|
||||
echo "Healthcheck incorrectly succeeded on a port with no Agent" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker stop --time 10 "$container" >/dev/null
|
||||
docker rm "$container" >/dev/null
|
||||
container=
|
||||
|
||||
# Demo UI selection must work with immutable assets and a read-only root.
|
||||
temporary_volume=$(docker volume create)
|
||||
container=$(docker run -d --read-only --cap-drop ALL \
|
||||
--security-opt no-new-privileges=true \
|
||||
--sysctl net.ipv4.ip_unprivileged_port_start=1024 \
|
||||
--health-interval=1s --health-start-period=0s \
|
||||
--mount "type=volume,source=$volume,target=/home/agent/data" \
|
||||
--mount "type=volume,source=$temporary_volume,target=/tmp" \
|
||||
-e AGENT_PORT=8082 -e AGENT_MODE=demo \
|
||||
-e http_proxy=http://127.0.0.1:9 -e HTTP_PROXY=http://127.0.0.1:9 \
|
||||
-e no_proxy= -e NO_PROXY= "$image")
|
||||
wait_healthy
|
||||
docker exec "$container" sh -ec '
|
||||
test -f www/env.js
|
||||
test -f www/env.demo.js
|
||||
test ! -w www
|
||||
ffmpeg -nostdin -v error -f lavfi -i color=black:s=64x64 \
|
||||
-vf drawtext=text=readonly:fontcolor=white -frames:v 1 -f null -
|
||||
'
|
||||
echo "Scratch image permissions, media, shell/PTY, volume writes, default/custom ports, and demo startup passed."
|
||||
@@ -6,6 +6,27 @@ Kubernetes can help you to build a scalable, flexible and resilient deployment.
|
||||
|
||||
We've provided an example deployment `deployment-agent.yml` in this directory, which show case you have to create a deployment (and under the hood a pod), to run a Kerberos Agent workload.
|
||||
|
||||
These manifests target the forthcoming hardened image: **containerPort and
|
||||
Service targetPort are 8080**, while external Service/Ingress port 80 stays
|
||||
unchanged. Pin a published hardened release digest before production use.
|
||||
Previously published port-80 images (including `v3.14.1`) require the old
|
||||
container/target ports; upgrade the image and mapping together. Host-network
|
||||
deployments use host port 8080 without Service port translation.
|
||||
|
||||
The examples use UID/GID `999:999`, `fsGroup: 999`, a read-only root filesystem,
|
||||
no privilege escalation, all capabilities dropped and runtime-default seccomp.
|
||||
A non-root init container copies missing packaged data to the writable volume
|
||||
without overwriting existing files and initializes the user cache. Temporary
|
||||
storage is ephemeral. The PVC example preserves the existing `config/`,
|
||||
`recordings/`, `snapshots/` and `cloud/` directories under the same PVC root,
|
||||
mounting that root at `/home/agent/data` to cover all writable data paths.
|
||||
Ensure the storage driver honors fsGroup or pre-provision UID/GID permissions.
|
||||
If deliberately passing video devices, add their actual GID with
|
||||
`supplementalGroups`; ordinary IP cameras do not need that group or devices.
|
||||
See the [image contract](../docker/README.md#image-contract), including the pending
|
||||
remote-terminal policy exception and release blockers. These examples do not
|
||||
assert a running cluster or security-policy approval.
|
||||
|
||||
## Create a Kerberos Agent deployment
|
||||
|
||||
It's always a best practices to isolate and structure your workloads in Kubernetes. To achieve this we are utilising the concept of namespaces. For this example we will create a new namespace `demo`.
|
||||
@@ -61,8 +82,7 @@ Depending on where and how you are hosting the Kubernetes cluster, you may need
|
||||
image: kerberos/agent:latest
|
||||
volumeMounts:
|
||||
- name: kerberos-data
|
||||
mountPath: /home/agent/data/config
|
||||
subPath: config
|
||||
mountPath: /home/agent/data
|
||||
...
|
||||
|
||||
## Expose with Ingress
|
||||
@@ -76,6 +96,7 @@ In the first example `deployment-agent.yml` we are using a `LoadBalancer` to exp
|
||||
type: LoadBalancer
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
...
|
||||
|
||||
An alternative to `LoadBalancer` is `Ingress`. By leveraging an ingress such as `ingress-nginx` or `traefik` you setup a gateway (single point of contact), through which all communication to your apps (services) will flow.
|
||||
|
||||
@@ -14,41 +14,56 @@ spec:
|
||||
labels:
|
||||
app: agent
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 999
|
||||
runAsGroup: 999
|
||||
fsGroup: 999
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
volumes:
|
||||
- name: kerberos-data
|
||||
persistentVolumeClaim:
|
||||
claimName: kerberos-data
|
||||
- name: temporary
|
||||
emptyDir: {}
|
||||
|
||||
initContainers:
|
||||
- name: download-config
|
||||
image: kerberos/agent:latest
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: kerberos-data
|
||||
mountPath: /home/agent/data/config
|
||||
subPath: config
|
||||
command:
|
||||
[
|
||||
"cp",
|
||||
"/home/agent/data/config.template.json",
|
||||
"/home/agent/data/config/config.json",
|
||||
]
|
||||
mountPath: /runtime-data
|
||||
- name: temporary
|
||||
mountPath: /runtime-tmp
|
||||
command: ["/bin/sh", "-ec", "cp -R --update=none /home/agent/data/. /runtime-data/; install -d -m 0700 /runtime-tmp/agent-cache"]
|
||||
|
||||
containers:
|
||||
- name: agent
|
||||
image: kerberos/agent:latest
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["/home/agent/main", "-action", "healthcheck"]
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 10
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: kerberos-data
|
||||
mountPath: /home/agent/data/config
|
||||
subPath: config
|
||||
- name: kerberos-data
|
||||
mountPath: /home/agent/data/recordings
|
||||
subPath: recordings
|
||||
- name: kerberos-data
|
||||
mountPath: /home/agent/data/snapshots
|
||||
subPath: snapshots
|
||||
- name: kerberos-data
|
||||
mountPath: /home/agent/data/cloud
|
||||
subPath: cloud
|
||||
mountPath: /home/agent/data
|
||||
- name: temporary
|
||||
mountPath: /tmp
|
||||
- name: temporary
|
||||
mountPath: /var/tmp
|
||||
ports:
|
||||
- containerPort: 80
|
||||
- containerPort: 8080
|
||||
protocol: TCP
|
||||
@@ -14,11 +14,55 @@ spec:
|
||||
labels:
|
||||
app: agent
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 999
|
||||
runAsGroup: 999
|
||||
fsGroup: 999
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
volumes:
|
||||
- name: data
|
||||
emptyDir: {}
|
||||
- name: temporary
|
||||
emptyDir: {}
|
||||
initContainers:
|
||||
- name: initialize-data
|
||||
image: kerberos/agent:latest
|
||||
command: ["/bin/sh", "-ec", "cp -R --update=none /home/agent/data/. /runtime-data/; install -d -m 0700 /runtime-tmp/agent-cache"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /runtime-data
|
||||
- name: temporary
|
||||
mountPath: /runtime-tmp
|
||||
containers:
|
||||
- name: agent
|
||||
image: kerberos/agent:latest
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["/home/agent/main", "-action", "healthcheck"]
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 10
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /home/agent/data
|
||||
- name: temporary
|
||||
mountPath: /tmp
|
||||
- name: temporary
|
||||
mountPath: /var/tmp
|
||||
ports:
|
||||
- containerPort: 80
|
||||
- containerPort: 8080
|
||||
protocol: TCP
|
||||
resources:
|
||||
limits:
|
||||
@@ -48,7 +92,7 @@ spec:
|
||||
#type: LoadBalancer
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
targetPort: 8080
|
||||
selector:
|
||||
app: agent
|
||||
---
|
||||
|
||||
@@ -14,11 +14,55 @@ spec:
|
||||
labels:
|
||||
app: agent
|
||||
spec:
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 999
|
||||
runAsGroup: 999
|
||||
fsGroup: 999
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
volumes:
|
||||
- name: data
|
||||
emptyDir: {}
|
||||
- name: temporary
|
||||
emptyDir: {}
|
||||
initContainers:
|
||||
- name: initialize-data
|
||||
image: kerberos/agent:latest
|
||||
command: ["/bin/sh", "-ec", "cp -R --update=none /home/agent/data/. /runtime-data/; install -d -m 0700 /runtime-tmp/agent-cache"]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /runtime-data
|
||||
- name: temporary
|
||||
mountPath: /runtime-tmp
|
||||
containers:
|
||||
- name: agent
|
||||
image: kerberos/agent:3.2.3
|
||||
image: kerberos/agent:latest
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["/home/agent/main", "-action", "healthcheck"]
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 10
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /home/agent/data
|
||||
- name: temporary
|
||||
mountPath: /tmp
|
||||
- name: temporary
|
||||
mountPath: /var/tmp
|
||||
ports:
|
||||
- containerPort: 80
|
||||
- containerPort: 8080
|
||||
protocol: TCP
|
||||
resources:
|
||||
limits:
|
||||
@@ -48,6 +92,6 @@ spec:
|
||||
type: LoadBalancer
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
targetPort: 8080
|
||||
selector:
|
||||
app: agent
|
||||
|
||||
@@ -4,6 +4,13 @@ If you are using Terraform as part of your DevOps stack, you might utilise it to
|
||||
|
||||
For this example we will install Kerberos Agent using `docker` on a remote `linux` machine. Therefore we'll make sure we have the `TelkomIndonesia/linux` provider initialised.
|
||||
|
||||
The example targets the forthcoming hardened image and maps the configured host
|
||||
port (80 by default) to **container port 8080**. Pin a published hardened release
|
||||
digest before deployment. Older releases, including `v3.14.1`, still require
|
||||
container port 80; change the image and mapping together. See the
|
||||
[image contract](../docker/README.md#image-contract) for runtime permissions,
|
||||
writable paths and release-policy limitations.
|
||||
|
||||
terraform init
|
||||
|
||||
Once initialised you should see similar output:
|
||||
|
||||
@@ -34,9 +34,9 @@ resource "linux_script" "install_docker" {
|
||||
|
||||
resource "linux_script" "install_docker_kerberos_agent" {
|
||||
lifecycle_commands {
|
||||
create = "docker pull $IMAGE:$VERSION && docker run -d -p $PORT:80 --name agent $IMAGE:$VERSION"
|
||||
create = "docker pull $IMAGE:$VERSION && docker run -d -p $PORT:8080 --name agent $IMAGE:$VERSION"
|
||||
read = "docker inspect agent"
|
||||
update = "docker pull $IMAGE:$VERSION && docker rm agent --force && docker run -d -p $PORT:80 --name agent $IMAGE:$VERSION"
|
||||
update = "docker pull $IMAGE:$VERSION && docker rm agent --force && docker run -d -p $PORT:8080 --name agent $IMAGE:$VERSION"
|
||||
delete = "docker rm agent --force"
|
||||
}
|
||||
environment = {
|
||||
|
||||
+27
-26
@@ -1,6 +1,6 @@
|
||||
module github.com/kerberos-io/agent/machinery
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.10
|
||||
|
||||
replace google.golang.org/genproto => google.golang.org/genproto v0.0.0-20250519155744-55703ea1f237
|
||||
|
||||
@@ -14,7 +14,7 @@ require (
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/dromara/carbon/v2 v2.6.8
|
||||
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||
github.com/elastic/go-sysinfo v1.15.3
|
||||
github.com/gin-contrib/cors v1.7.5
|
||||
github.com/gin-contrib/pprof v1.5.3
|
||||
@@ -39,12 +39,13 @@ require (
|
||||
github.com/swaggo/swag v1.16.4
|
||||
github.com/tevino/abool v1.2.0
|
||||
github.com/zaf/g711 v1.4.0
|
||||
go.mongodb.org/mongo-driver v1.17.3
|
||||
go.opentelemetry.io/otel v1.36.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0
|
||||
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
|
||||
go.mongodb.org/mongo-driver v1.17.7
|
||||
go.opentelemetry.io/otel v1.45.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0
|
||||
go.opentelemetry.io/otel/sdk v1.45.0
|
||||
go.opentelemetry.io/otel/trace v1.45.0
|
||||
golang.org/x/net v0.62.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -55,7 +56,8 @@ require (
|
||||
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/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // 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
|
||||
@@ -65,7 +67,7 @@ require (
|
||||
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
||||
@@ -77,11 +79,11 @@ require (
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
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/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // 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
|
||||
github.com/klauspost/compress v1.16.7 // indirect
|
||||
github.com/klauspost/compress v1.18.7 // indirect
|
||||
github.com/klauspost/cpuid v1.2.3 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/kylelemons/go-gypsy v1.0.0 // indirect
|
||||
@@ -121,22 +123,21 @@ require (
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
github.com/ziutek/mymysql v1.5.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.45.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||
golang.org/x/arch v0.16.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.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/crypto v0.58.1-0.20261009181718-2f1f834330b2 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.24.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/text v0.43.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
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
golang.org/x/tools v0.51.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
|
||||
google.golang.org/grpc v1.83.2 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.42.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
+76
-35
@@ -410,12 +410,15 @@ github.com/cedricve/go-onvif v0.0.0-20200222191200-567e8ce298f6 h1:bzFZYgZD5vf4P
|
||||
github.com/cedricve/go-onvif v0.0.0-20200222191200-567e8ce298f6/go.mod h1:nBrjN2nMHendp0Cvb/6GaJ1v92Qv/kzqxWtNBnKJEK0=
|
||||
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
|
||||
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
|
||||
@@ -470,6 +473,8 @@ github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25Kn
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
|
||||
github.com/elastic/go-sysinfo v1.15.3 h1:W+RnmhKFkqPTCRoFq2VCTmsT4p/fwpo+3gKNQsn1XU0=
|
||||
github.com/elastic/go-sysinfo v1.15.3/go.mod h1:K/cNrqYTDrSoMh2oDkYEMS2+a72GRxMvNP+GC+vRIlo=
|
||||
github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI=
|
||||
@@ -548,8 +553,11 @@ github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpx
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
@@ -748,6 +756,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||
github.com/hamba/avro/v2 v2.17.2/go.mod h1:Q9YK+qxAhtVrNqOhwlZTATLgLA8qxG2vtvkhK8fJ7Jo=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
@@ -781,8 +791,9 @@ github.com/kerberos-io/onvif v1.2.3/go.mod h1:XSgEQXmEDjUQTbdXvsaRJt6Az8YPGj7L+j
|
||||
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=
|
||||
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
|
||||
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
|
||||
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid v1.2.3 h1:CCtW0xUnWGVINKvE/WWOYKdsPV6mawAtvQuSl8guwQs=
|
||||
github.com/klauspost/cpuid v1.2.3/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
@@ -924,8 +935,9 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
|
||||
github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk=
|
||||
github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo=
|
||||
@@ -1011,6 +1023,8 @@ go.einride.tech/aip v0.67.1/go.mod h1:ZGX4/zKw8dcgzdLsrvpOOGxfxI2QSk12SlP7d6c0/X
|
||||
go.einride.tech/aip v0.68.1/go.mod h1:XaFtaj4HuA3Zwk9xoBtTWgNubZ0ZZXv9BZJCkuKuWbg=
|
||||
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
|
||||
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
go.mongodb.org/mongo-driver v1.17.7 h1:a9w+U3Vt67eYzcfq3k/OAv284/uUUkL0uP75VE5rCOU=
|
||||
go.mongodb.org/mongo-driver v1.17.7/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
@@ -1019,8 +1033,9 @@ go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.28.0/go.mod h1:9BIqH22qyHWAiZxQh0whuJygro59z+nbMVuc7ciiGug=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.29.0/go.mod h1:GW2aWZNwR2ZxDLdv8OyC2G8zkRoQBuURgV7RPQgcPoU=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.31.0/go.mod h1:tzQL6E1l+iV44YFTkcAeNQqzXUiekSYP9jjJjXwEd00=
|
||||
@@ -1059,12 +1074,18 @@ go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGi
|
||||
go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
|
||||
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
|
||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
|
||||
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
|
||||
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0/go.mod h1:BLbf7zbNIONBLPwvFnwNHGj4zge8uTCM/UPIVW1Mq2I=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY=
|
||||
go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM=
|
||||
@@ -1080,8 +1101,10 @@ go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzau
|
||||
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
|
||||
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
|
||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
|
||||
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
|
||||
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
|
||||
go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E=
|
||||
go.opentelemetry.io/otel/sdk v1.22.0/go.mod h1:iu7luyVGYovrRpe2fmj3CVKouQNdTOkxtLzPvPz1DOc=
|
||||
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
|
||||
@@ -1093,16 +1116,20 @@ go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJC
|
||||
go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM=
|
||||
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
||||
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
|
||||
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.28.0/go.mod h1:cWPjykihLAPvXKi4iZc1dpER3Jdq2Z0YLse3moQUCpg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.29.0/go.mod h1:6zZLdCl2fkauYoZIOn/soQIDSWFmNSRcICarHfuhNJQ=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.30.0/go.mod h1:waS6P3YqFNzeP01kuo/MBBYqaoBJl7efRQHOaydhy1Y=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
|
||||
go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ=
|
||||
go.opentelemetry.io/otel/trace v1.22.0/go.mod h1:RbbHXVqKES9QhzZq/fE5UnOSILqRt40a21sPw2He1xo=
|
||||
go.opentelemetry.io/otel/trace v1.23.0/go.mod h1:GSGTbIClEsuZrGIzoEHqsVfxgn5UkggkflQwDScNUsk=
|
||||
@@ -1116,14 +1143,18 @@ go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L
|
||||
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
|
||||
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
|
||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
|
||||
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
|
||||
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
|
||||
go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
|
||||
go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
|
||||
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
||||
go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
|
||||
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
|
||||
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
|
||||
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
|
||||
@@ -1169,8 +1200,10 @@ 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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/crypto v0.58.1-0.20261009181718-2f1f834330b2 h1:zGDaB+/Mus2mScKvZ23k+W9NAUbP56sNM9SptgvWI5A=
|
||||
golang.org/x/crypto v0.58.1-0.20261009181718-2f1f834330b2/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
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=
|
||||
@@ -1240,8 +1273,8 @@ 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/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/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
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=
|
||||
@@ -1329,8 +1362,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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
|
||||
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||
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=
|
||||
@@ -1380,8 +1413,9 @@ golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbht
|
||||
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -1410,8 +1444,8 @@ 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/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/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
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=
|
||||
@@ -1520,8 +1554,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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
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=
|
||||
@@ -1588,8 +1622,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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
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=
|
||||
@@ -1677,8 +1711,8 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
|
||||
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/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/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
|
||||
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=
|
||||
@@ -1693,6 +1727,8 @@ gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJ
|
||||
gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0=
|
||||
gonum.org/v1/gonum v0.9.3/go.mod h1:TZumC3NeyVQskjXqmyWt4S3bINhy7B4eYwW69EbyX+0=
|
||||
gonum.org/v1/gonum v0.12.0/go.mod h1:73TDxJfAAHeA8Mk9mf8NlIppyhQNo5GLTcYeqgo2lvY=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
|
||||
gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
|
||||
gonum.org/v1/plot v0.9.0/go.mod h1:3Pcqqmp6RHvJI72kgb8fThyUnav364FOsdDo2aGW5lY=
|
||||
@@ -1866,8 +1902,10 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250425173222-7b384671a197/go.
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250428153025-10db94c68c34/go.mod h1:0awUlEkap+Pb1UMeJwJQQAdJQrt3moU7J2moTy69irI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250512202823-5a2f75b736a9/go.mod h1:W3S/3np0/dPWsWLi1h/UymYctGXaGBM2StwzD0y140U=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig=
|
||||
google.golang.org/genproto/googleapis/bytestream v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:ylj+BE99M198VPbBh6A8d9n3w8fChvyLK3wwBOjXBFA=
|
||||
google.golang.org/genproto/googleapis/bytestream v0.0.0-20231012201019-e917dd12ba7a/go.mod h1:+34luvCflYKiKylNwGJfn9cFBbcL/WrkciMmDmsTQ/A=
|
||||
google.golang.org/genproto/googleapis/bytestream v0.0.0-20231030173426-d783a09b4405/go.mod h1:GRUCuLdzVqZte8+Dl/D4N25yLzcGqqWaYkeVOwulFqw=
|
||||
@@ -1986,8 +2024,10 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250425173222-7b384671a197/go.
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
@@ -2062,8 +2102,8 @@ google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40Rmc
|
||||
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
||||
google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
||||
google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
|
||||
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
|
||||
google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
|
||||
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.3.0/go.mod h1:Dk1tviKTvMCz5tvh7t+fh94dhmQVHuCt2OzJB3CTW9Y=
|
||||
google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20/go.mod h1:Nr5H8+MlGWr5+xX/STzdoEqJrO+YteqFbMyCsrb6mH0=
|
||||
@@ -2096,8 +2136,9 @@ google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojt
|
||||
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func checkAgentHealth(ctx context.Context, port string) error {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:"+port+"/health", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating health request: %w", err)
|
||||
}
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{DisableKeepAlives: true},
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
defer client.CloseIdleConnections()
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("requesting Agent health: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("Agent health returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
var health struct {
|
||||
HTTPStatusCode int `json:"httpStatusCode"`
|
||||
EntityStatusCode string `json:"entityStatusCode"`
|
||||
Metadata struct {
|
||||
ApplicationName string `json:"applicationName"`
|
||||
} `json:"metadata"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 64*1024)).Decode(&health); err != nil {
|
||||
return fmt.Errorf("decoding Agent health: %w", err)
|
||||
}
|
||||
if health.HTTPStatusCode != http.StatusOK || health.EntityStatusCode != "healthy" || health.Metadata.ApplicationName != "agent" {
|
||||
return fmt.Errorf("Agent health returned an unexpected response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCheckAgentHealth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
wantOK bool
|
||||
}{
|
||||
{"healthy", 200, `{"httpStatusCode":200,"entityStatusCode":"healthy","metadata":{"applicationName":"agent"}}`, true},
|
||||
{"unhealthy status", 503, `{}`, false},
|
||||
{"redirect", 302, `{}`, false},
|
||||
{"invalid JSON", 200, `not JSON`, false},
|
||||
{"wrong service", 200, `{"httpStatusCode":200,"entityStatusCode":"healthy","metadata":{"applicationName":"other"}}`, false},
|
||||
{"unhealthy body", 200, `{"httpStatusCode":200,"entityStatusCode":"unhealthy","metadata":{"applicationName":"agent"}}`, false},
|
||||
}
|
||||
t.Setenv("HTTP_PROXY", "http://127.0.0.1:1")
|
||||
t.Setenv("NO_PROXY", "")
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/health" {
|
||||
t.Errorf("path = %q, want /health", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Location", "/unexpected-redirect-target")
|
||||
w.WriteHeader(test.status)
|
||||
_, _ = w.Write([]byte(test.body))
|
||||
}))
|
||||
defer server.Close()
|
||||
_, port, err := net.SplitHostPort(server.Listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = checkAgentHealth(context.Background(), port)
|
||||
if (err == nil) != test.wantOK {
|
||||
t.Fatalf("checkAgentHealth() = %v, wantOK %t", err, test.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAgentHealthTimesOut(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer server.Close()
|
||||
_, port, err := net.SplitHostPort(server.Listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := checkAgentHealth(ctx, port); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("healthcheck error = %v, want context deadline exceeded", err)
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -96,7 +96,7 @@ func main() {
|
||||
var timeout string
|
||||
var subnet string
|
||||
|
||||
flag.StringVar(&action, "action", "version", "Tell us what you want do 'run' or 'version'")
|
||||
flag.StringVar(&action, "action", "version", "Action: run, version, healthcheck, discover, or decrypt")
|
||||
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")
|
||||
@@ -117,7 +117,7 @@ func main() {
|
||||
// Specify the timezone of the log: "UTC" or "Local".
|
||||
timezone, _ := time.LoadLocation("CET")
|
||||
configureLogging(logLevel, logOutput, timezone)
|
||||
if action == "run" {
|
||||
if action == "run" || action == "healthcheck" {
|
||||
resolvedPort, err := resolveServerPort(port, os.Getenv("AGENT_PORT"))
|
||||
if err != nil {
|
||||
log.WithError(err).WithFields(log.Fields{
|
||||
@@ -139,6 +139,13 @@ func main() {
|
||||
|
||||
switch action {
|
||||
|
||||
case "healthcheck":
|
||||
if err := checkAgentHealth(context.Background(), port); err != nil {
|
||||
log.WithError(err).WithFields(log.Fields{
|
||||
"component": "healthcheck",
|
||||
"event": "healthcheck_failed",
|
||||
}).Fatal("Agent healthcheck failed")
|
||||
}
|
||||
case "version":
|
||||
{
|
||||
log.WithFields(log.Fields{
|
||||
|
||||
@@ -10,11 +10,58 @@ import (
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
func TestPickRecordingToCleanupAcrossDatedDirectories(t *testing.T) {
|
||||
for _, legacyMarker := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "metadata", true: "legacy"}[legacyMarker], func(t *testing.T) {
|
||||
recordingsDir, cloudDir := newCleanupDirs(t)
|
||||
pendingName := "2026/01/01/oldest_pending.mp4"
|
||||
safeName := "2026/01/02/uploaded.mp4"
|
||||
writeRecording(t, recordingsDir, pendingName, 30)
|
||||
if legacyMarker {
|
||||
if err := os.WriteFile(filepath.Join(cloudDir, filepath.Base(pendingName)), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
markPending(t, cloudDir, pendingName)
|
||||
}
|
||||
writeRecording(t, recordingsDir, safeName, 20)
|
||||
writeRecording(t, recordingsDir, "flat_uploaded.mp4", 10)
|
||||
name, pending, err := pickRecordingToCleanup(recordingsDir, cloudDir)
|
||||
if err != nil || pending || name != safeName {
|
||||
t.Fatalf("cleanup = (%q, %v, %v), want (%q, false, nil)", name, pending, err, safeName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupDatedPendingRecordingRemovesFlatMarker(t *testing.T) {
|
||||
recordingsDir, cloudDir := newCleanupDirs(t)
|
||||
name := "2026/01/01/pending.mp4"
|
||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(recordingsDir, name)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeSizedRecording(t, recordingsDir, name, 2*1000*1000)
|
||||
markPending(t, cloudDir, name)
|
||||
config := &models.Configuration{Config: models.Config{AutoClean: "true", MaxDirectorySize: 1}}
|
||||
CleanupRecordingDirectory(filepath.Dir(filepath.Dir(recordingsDir)), config)
|
||||
for _, filePath := range []string{
|
||||
filepath.Join(recordingsDir, name),
|
||||
filepath.Join(cloudDir, models.RecordingUploadMetadataFileName(name)),
|
||||
} {
|
||||
if _, err := os.Stat(filePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected %s to be removed, got %v", filePath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("mkdir recording directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte("data"), 0o644); err != nil {
|
||||
t.Fatalf("write recording %s: %v", name, err)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format/rtplpcm"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format/rtpmpeg4audio"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format/rtpsimpleaudio"
|
||||
"github.com/bluenviron/mediacommon/pkg/bits"
|
||||
"github.com/bluenviron/mediacommon/pkg/codecs/h264"
|
||||
"github.com/bluenviron/mediacommon/pkg/codecs/h265"
|
||||
"github.com/bluenviron/mediacommon/pkg/codecs/mpeg4audio"
|
||||
@@ -159,13 +160,10 @@ type Golibrtsp struct {
|
||||
}
|
||||
|
||||
// streamHealth instruments the RTSP read path. gortsplib delivers every RTP
|
||||
// packet on a single read goroutine; queue.WritePacket() is synchronous, so if
|
||||
// a downstream consumer (recording, muxing, WebRTC) is slow or the process is
|
||||
// CPU-starved, WritePacket() blocks, the TCP socket is not drained, and the
|
||||
// camera advances RTP sequence numbers -> "RTP packets lost". This type makes
|
||||
// the two failure modes distinguishable:
|
||||
// - large writeMax / writeAvg => downstream back-pressure (our side).
|
||||
// - large gapMax with fast writes => upstream network / camera stall.
|
||||
// packet on a single read goroutine, so prolonged queue lock contention or
|
||||
// process starvation can delay socket reads. Queue consumers process packets
|
||||
// after releasing the lock; a slow recorder therefore falls behind its cursor
|
||||
// instead of directly blocking WritePacket.
|
||||
type streamHealth struct {
|
||||
mu sync.Mutex
|
||||
windowStart time.Time
|
||||
@@ -178,6 +176,152 @@ type streamHealth struct {
|
||||
decodeErrs int64
|
||||
}
|
||||
|
||||
type videoReferenceState struct {
|
||||
haveSequence bool
|
||||
lastSequence uint16
|
||||
valid bool
|
||||
droppedAccessUnits uint64
|
||||
}
|
||||
|
||||
type h264FrameNumTracker struct {
|
||||
sps *h264.SPS
|
||||
haveFrameNum bool
|
||||
lastFrameNum uint32
|
||||
}
|
||||
|
||||
const maxH264SliceHeaderBytes = 64
|
||||
|
||||
func (t *h264FrameNumTracker) setSPS(sps *h264.SPS) {
|
||||
if sps == nil {
|
||||
return
|
||||
}
|
||||
if t.sps == nil ||
|
||||
t.sps.Log2MaxFrameNumMinus4 != sps.Log2MaxFrameNumMinus4 ||
|
||||
t.sps.SeparateColourPlaneFlag != sps.SeparateColourPlaneFlag ||
|
||||
t.sps.FrameMbsOnlyFlag != sps.FrameMbsOnlyFlag {
|
||||
t.haveFrameNum = false
|
||||
}
|
||||
copy := *sps
|
||||
t.sps = ©
|
||||
}
|
||||
|
||||
func (t *h264FrameNumTracker) observe(au [][]byte) (uint32, uint32, bool, bool) {
|
||||
if t.sps == nil || !t.sps.FrameMbsOnlyFlag {
|
||||
return 0, 0, false, false
|
||||
}
|
||||
|
||||
for _, nalu := range au {
|
||||
if len(nalu) == 0 {
|
||||
continue
|
||||
}
|
||||
naluType := h264.NALUType(nalu[0] & 0x1F)
|
||||
if naluType != h264.NALUTypeIDR && naluType != h264.NALUTypeNonIDR {
|
||||
continue
|
||||
}
|
||||
|
||||
frameNum, err := h264SliceFrameNum(nalu, t.sps)
|
||||
if err != nil {
|
||||
return 0, 0, false, false
|
||||
}
|
||||
if naluType == h264.NALUTypeIDR {
|
||||
t.haveFrameNum = true
|
||||
t.lastFrameNum = frameNum
|
||||
return frameNum, frameNum, false, true
|
||||
}
|
||||
|
||||
// Non-reference pictures do not advance the reference-picture frame_num
|
||||
// sequence and therefore cannot prove that a reference picture is missing.
|
||||
if (nalu[0]>>5)&0x03 == 0 {
|
||||
return frameNum, frameNum, false, true
|
||||
}
|
||||
if !t.haveFrameNum {
|
||||
t.haveFrameNum = true
|
||||
t.lastFrameNum = frameNum
|
||||
return frameNum, frameNum, false, true
|
||||
}
|
||||
|
||||
frameNumBits := t.sps.Log2MaxFrameNumMinus4 + 4
|
||||
if frameNumBits > 16 {
|
||||
return 0, 0, false, false
|
||||
}
|
||||
maxFrameNum := uint32(1) << frameNumBits
|
||||
expected := (t.lastFrameNum + 1) & (maxFrameNum - 1)
|
||||
// When the SPS permits frame_num gaps, the decoder is required to
|
||||
// synthesize non-existing pictures and the discontinuity is not evidence
|
||||
// of transport loss. RTP/depacketizer errors still invalidate references.
|
||||
gap := frameNum != expected && !t.sps.GapsInFrameNumValueAllowedFlag
|
||||
t.lastFrameNum = frameNum
|
||||
return expected, frameNum, gap, true
|
||||
}
|
||||
|
||||
return 0, 0, false, false
|
||||
}
|
||||
|
||||
func h264SliceFrameNum(nalu []byte, sps *h264.SPS) (uint32, error) {
|
||||
if len(nalu) < 2 || sps == nil {
|
||||
return 0, errors.New("H264 slice or SPS is missing")
|
||||
}
|
||||
frameNumBits := sps.Log2MaxFrameNumMinus4 + 4
|
||||
if frameNumBits > 16 {
|
||||
return 0, fmt.Errorf("invalid H264 frame_num width: %d", frameNumBits)
|
||||
}
|
||||
header := nalu[1:]
|
||||
if len(header) > maxH264SliceHeaderBytes {
|
||||
header = header[:maxH264SliceHeaderBytes]
|
||||
}
|
||||
header = h264.EmulationPreventionRemove(header)
|
||||
pos := 0
|
||||
|
||||
for range 3 {
|
||||
if _, err := bits.ReadGolombUnsigned(header, &pos); err != nil {
|
||||
return 0, fmt.Errorf("read H264 slice header: %w", err)
|
||||
}
|
||||
}
|
||||
if sps.SeparateColourPlaneFlag {
|
||||
if _, err := bits.ReadBits(header, &pos, 2); err != nil {
|
||||
return 0, fmt.Errorf("read H264 colour plane: %w", err)
|
||||
}
|
||||
}
|
||||
frameNum, err := bits.ReadBits(header, &pos, int(frameNumBits))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read H264 frame_num: %w", err)
|
||||
}
|
||||
return uint32(frameNum), nil
|
||||
}
|
||||
|
||||
func (s *videoReferenceState) observeSequence(sequence uint16) (uint16, bool) {
|
||||
expected := sequence
|
||||
gap := false
|
||||
if s.haveSequence {
|
||||
expected = s.lastSequence + 1
|
||||
gap = sequence != expected
|
||||
if gap {
|
||||
s.valid = false
|
||||
}
|
||||
}
|
||||
s.haveSequence = true
|
||||
s.lastSequence = sequence
|
||||
return expected, gap
|
||||
}
|
||||
|
||||
func (s *videoReferenceState) invalidate() {
|
||||
s.valid = false
|
||||
}
|
||||
|
||||
func (s *videoReferenceState) accept(randomAccess bool) (bool, uint64) {
|
||||
if randomAccess {
|
||||
dropped := s.droppedAccessUnits
|
||||
s.droppedAccessUnits = 0
|
||||
s.valid = true
|
||||
return true, dropped
|
||||
}
|
||||
if !s.valid {
|
||||
s.droppedAccessUnits++
|
||||
return false, 0
|
||||
}
|
||||
return true, 0
|
||||
}
|
||||
|
||||
const (
|
||||
streamHealthWindow = 10 * time.Second
|
||||
streamHealthWriteWarn = 150 * time.Millisecond
|
||||
@@ -190,9 +334,9 @@ func newStreamHealth() *streamHealth {
|
||||
}
|
||||
|
||||
// observePacket records one processed video frame: the wall-clock gap since the
|
||||
// previous frame (arrival cadence) and how long WritePacket() blocked
|
||||
// (back-pressure). It emits an immediate warning when either side stalls and a
|
||||
// periodic summary every streamHealthWindow.
|
||||
// previous frame and how long WritePacket spent acquiring and updating the
|
||||
// queue. It emits an immediate warning when either side stalls and a periodic
|
||||
// summary every streamHealthWindow.
|
||||
func (h *streamHealth) observePacket(streamType string, writeDur time.Duration) {
|
||||
if h == nil {
|
||||
return
|
||||
@@ -264,9 +408,9 @@ func (h *streamHealth) observePacket(streamType string, writeDur time.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
// observeLost is invoked by gortsplib when RTP sequence numbers skip. On a TCP
|
||||
// transport this means the sender (camera) dropped packets because we were not
|
||||
// reading fast enough, not loss on the wire.
|
||||
// observeLost is invoked by gortsplib when RTP sequence numbers skip. With the
|
||||
// configured TCP transport, the sender or an upstream RTSP component omitted
|
||||
// those packets; sender-side pressure is one possible cause.
|
||||
func (h *streamHealth) observeLost(streamType string, lost uint64) {
|
||||
if h == nil {
|
||||
return
|
||||
@@ -887,6 +1031,14 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
// called when a video RTP packet arrives for H264
|
||||
var filteredAU [][]byte
|
||||
if g.VideoH264Media != nil && g.VideoH264Forma != nil {
|
||||
referenceState := &videoReferenceState{}
|
||||
frameNumTracker := &h264FrameNumTracker{}
|
||||
if len(g.VideoH264Forma.SPS) > 0 {
|
||||
var sps h264.SPS
|
||||
if sps.Unmarshal(g.VideoH264Forma.SPS) == nil {
|
||||
frameNumTracker.setSPS(&sps)
|
||||
}
|
||||
}
|
||||
|
||||
// Extracts DTS from the bitstream to support B-frame H264 streams.
|
||||
// Created once per stream (tracks reorder state across access units).
|
||||
@@ -902,6 +1054,16 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
default:
|
||||
}
|
||||
|
||||
if expected, gap := referenceState.observeSequence(rtppkt.SequenceNumber); gap {
|
||||
log.WithFields(log.Fields{
|
||||
"component": "capture",
|
||||
"event": "video_rtp_sequence_gap",
|
||||
"expected_sequence_number": expected,
|
||||
"received_sequence_number": rtppkt.SequenceNumber,
|
||||
"stream": streamType,
|
||||
}).Warn("RTP sequence gap invalidated video reference chain")
|
||||
}
|
||||
|
||||
if len(rtppkt.Payload) > 0 {
|
||||
|
||||
// decode timestamps — validate each call separately
|
||||
@@ -917,6 +1079,9 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
// this is a keyframe.
|
||||
au, errDecode := g.VideoH264Decoder.Decode(rtppkt)
|
||||
if errDecode != nil {
|
||||
if errDecode != rtph264.ErrMorePacketsNeeded {
|
||||
referenceState.invalidate()
|
||||
}
|
||||
if errDecode != rtph264.ErrNonStartingPacketAndNoPrevious && errDecode != rtph264.ErrMorePacketsNeeded {
|
||||
log.Error("capture.golibrtsp.Start(): " + errDecode.Error())
|
||||
}
|
||||
@@ -961,6 +1126,7 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
if errSPS == nil {
|
||||
// Debug SPS information
|
||||
g.debugSPSInfo(&sps, streamType)
|
||||
frameNumTracker.setSPS(&sps)
|
||||
|
||||
// Get width
|
||||
g.Streams[g.VideoH264Index].Width = sps.Width()
|
||||
@@ -1022,6 +1188,39 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
return
|
||||
}
|
||||
|
||||
if expected, received, gap, ok := frameNumTracker.observe(au); ok && gap {
|
||||
referenceState.invalidate()
|
||||
log.WithFields(log.Fields{
|
||||
"component": "capture",
|
||||
"event": "h264_frame_num_gap",
|
||||
"expected_frame_num": expected,
|
||||
"received_frame_num": received,
|
||||
"stream": streamType,
|
||||
}).Warn("H264 frame_num gap invalidated video reference chain")
|
||||
}
|
||||
|
||||
accepted, recoveredDropped := referenceState.accept(idrPresent)
|
||||
if !accepted {
|
||||
if referenceState.droppedAccessUnits == 1 {
|
||||
log.WithFields(log.Fields{
|
||||
"codec": "H264",
|
||||
"component": "capture",
|
||||
"event": "dependent_access_units_dropped",
|
||||
"stream": streamType,
|
||||
}).Warn("Dropping dependent video frames until the next IDR")
|
||||
}
|
||||
return
|
||||
}
|
||||
if recoveredDropped > 0 {
|
||||
log.WithFields(log.Fields{
|
||||
"codec": "H264",
|
||||
"component": "capture",
|
||||
"dropped_access_units": recoveredDropped,
|
||||
"event": "video_reference_chain_recovered",
|
||||
"stream": streamType,
|
||||
}).Info("Video reference chain recovered at IDR")
|
||||
}
|
||||
|
||||
if idrPresent {
|
||||
log.WithFields(log.Fields{
|
||||
"component": "capture",
|
||||
@@ -1165,6 +1364,7 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
|
||||
// called when a video RTP packet arrives for H265
|
||||
if g.VideoH265Media != nil && g.VideoH265Forma != nil {
|
||||
referenceState := &videoReferenceState{}
|
||||
|
||||
// Extracts DTS from the bitstream to support B-frame H265 streams.
|
||||
// Created once per stream (tracks reorder state across access units).
|
||||
@@ -1180,6 +1380,16 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
default:
|
||||
}
|
||||
|
||||
if expected, gap := referenceState.observeSequence(rtppkt.SequenceNumber); gap {
|
||||
log.WithFields(log.Fields{
|
||||
"component": "capture",
|
||||
"event": "video_rtp_sequence_gap",
|
||||
"expected_sequence_number": expected,
|
||||
"received_sequence_number": rtppkt.SequenceNumber,
|
||||
"stream": streamType,
|
||||
}).Warn("RTP sequence gap invalidated video reference chain")
|
||||
}
|
||||
|
||||
if len(rtppkt.Payload) > 0 {
|
||||
|
||||
// decode timestamps — validate each call separately
|
||||
@@ -1195,6 +1405,9 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
// this is a keyframe.
|
||||
au, errDecode := g.VideoH265Decoder.Decode(rtppkt)
|
||||
if errDecode != nil {
|
||||
if errDecode != rtph265.ErrMorePacketsNeeded {
|
||||
referenceState.invalidate()
|
||||
}
|
||||
if errDecode != rtph265.ErrNonStartingPacketAndNoPrevious && errDecode != rtph265.ErrMorePacketsNeeded {
|
||||
log.Error("capture.golibrtsp.Start(): " + errDecode.Error())
|
||||
}
|
||||
@@ -1244,6 +1457,28 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
return
|
||||
}
|
||||
|
||||
accepted, recoveredDropped := referenceState.accept(isRandomAccess)
|
||||
if !accepted {
|
||||
if referenceState.droppedAccessUnits == 1 {
|
||||
log.WithFields(log.Fields{
|
||||
"codec": "H265",
|
||||
"component": "capture",
|
||||
"event": "dependent_access_units_dropped",
|
||||
"stream": streamType,
|
||||
}).Warn("Dropping dependent video frames until the next random-access frame")
|
||||
}
|
||||
return
|
||||
}
|
||||
if recoveredDropped > 0 {
|
||||
log.WithFields(log.Fields{
|
||||
"codec": "H265",
|
||||
"component": "capture",
|
||||
"dropped_access_units": recoveredDropped,
|
||||
"event": "video_reference_chain_recovered",
|
||||
"stream": streamType,
|
||||
}).Info("Video reference chain recovered at random-access frame")
|
||||
}
|
||||
|
||||
// add VPS, SPS and PPS before random access access unit
|
||||
if isRandomAccess {
|
||||
au = append([][]byte{
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/bluenviron/mediacommon/pkg/codecs/h264"
|
||||
)
|
||||
|
||||
func TestPreRecordingGOPCount(t *testing.T) {
|
||||
@@ -134,3 +136,114 @@ func TestRTSPSTLSConfig(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVideoReferenceStateDropsDependentFramesUntilRandomAccess(t *testing.T) {
|
||||
state := &videoReferenceState{}
|
||||
|
||||
accepted, recovered := state.accept(false)
|
||||
if accepted || recovered != 0 || state.droppedAccessUnits != 1 {
|
||||
t.Fatalf("initial dependent frame = (accepted=%t, recovered=%d, dropped=%d), want (false, 0, 1)",
|
||||
accepted, recovered, state.droppedAccessUnits)
|
||||
}
|
||||
|
||||
accepted, recovered = state.accept(true)
|
||||
if !accepted || recovered != 1 || !state.valid {
|
||||
t.Fatalf("random-access recovery = (accepted=%t, recovered=%d, valid=%t), want (true, 1, true)",
|
||||
accepted, recovered, state.valid)
|
||||
}
|
||||
|
||||
if expected, gap := state.observeSequence(100); gap || expected != 100 {
|
||||
t.Fatalf("first sequence observation = (expected=%d, gap=%t), want (100, false)", expected, gap)
|
||||
}
|
||||
if expected, gap := state.observeSequence(102); !gap || expected != 101 || state.valid {
|
||||
t.Fatalf("sequence gap = (expected=%d, gap=%t, valid=%t), want (101, true, false)",
|
||||
expected, gap, state.valid)
|
||||
}
|
||||
|
||||
accepted, recovered = state.accept(false)
|
||||
if accepted || recovered != 0 || state.droppedAccessUnits != 1 {
|
||||
t.Fatalf("post-loss dependent frame = (accepted=%t, recovered=%d, dropped=%d), want (false, 0, 1)",
|
||||
accepted, recovered, state.droppedAccessUnits)
|
||||
}
|
||||
|
||||
accepted, recovered = state.accept(true)
|
||||
if !accepted || recovered != 1 || !state.valid {
|
||||
t.Fatalf("post-loss recovery = (accepted=%t, recovered=%d, valid=%t), want (true, 1, true)",
|
||||
accepted, recovered, state.valid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoReferenceStateAcceptsSequenceWraparound(t *testing.T) {
|
||||
state := &videoReferenceState{valid: true}
|
||||
|
||||
if _, gap := state.observeSequence(^uint16(0)); gap {
|
||||
t.Fatal("first sequence observation reported a gap")
|
||||
}
|
||||
if expected, gap := state.observeSequence(0); gap || expected != 0 || !state.valid {
|
||||
t.Fatalf("sequence wraparound = (expected=%d, gap=%t, valid=%t), want (0, false, true)",
|
||||
expected, gap, state.valid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestH264FrameNumTrackerDetectsMissingIDR(t *testing.T) {
|
||||
tracker := &h264FrameNumTracker{}
|
||||
tracker.setSPS(&h264.SPS{
|
||||
Log2MaxFrameNumMinus4: 1,
|
||||
FrameMbsOnlyFlag: true,
|
||||
})
|
||||
|
||||
if _, _, gap, ok := tracker.observe([][]byte{testH264Slice(h264.NALUTypeIDR, 0)}); !ok || gap {
|
||||
t.Fatalf("initial IDR observation = (ok=%t, gap=%t), want (true, false)", ok, gap)
|
||||
}
|
||||
for frameNum := uint32(1); frameNum <= 24; frameNum++ {
|
||||
if expected, received, gap, ok := tracker.observe([][]byte{testH264Slice(h264.NALUTypeNonIDR, frameNum)}); !ok || gap {
|
||||
t.Fatalf("frame_num %d observation = (expected=%d, received=%d, ok=%t, gap=%t), want no gap",
|
||||
frameNum, expected, received, ok, gap)
|
||||
}
|
||||
}
|
||||
|
||||
expected, received, gap, ok := tracker.observe([][]byte{testH264Slice(h264.NALUTypeNonIDR, 1)})
|
||||
if !ok || !gap || expected != 25 || received != 1 {
|
||||
t.Fatalf("missing IDR transition = (expected=%d, received=%d, ok=%t, gap=%t), want (25, 1, true, true)",
|
||||
expected, received, ok, gap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestH264FrameNumTrackerResetsAtIDR(t *testing.T) {
|
||||
tracker := &h264FrameNumTracker{}
|
||||
tracker.setSPS(&h264.SPS{
|
||||
Log2MaxFrameNumMinus4: 1,
|
||||
FrameMbsOnlyFlag: true,
|
||||
})
|
||||
|
||||
tracker.observe([][]byte{testH264Slice(h264.NALUTypeIDR, 0)})
|
||||
tracker.observe([][]byte{testH264Slice(h264.NALUTypeNonIDR, 1)})
|
||||
if expected, received, gap, ok := tracker.observe([][]byte{testH264Slice(h264.NALUTypeIDR, 0)}); !ok || gap {
|
||||
t.Fatalf("IDR reset = (expected=%d, received=%d, ok=%t, gap=%t), want no gap",
|
||||
expected, received, ok, gap)
|
||||
}
|
||||
if expected, received, gap, ok := tracker.observe([][]byte{testH264Slice(h264.NALUTypeNonIDR, 1)}); !ok || gap || expected != 1 || received != 1 {
|
||||
t.Fatalf("post-IDR frame = (expected=%d, received=%d, ok=%t, gap=%t), want (1, 1, true, false)",
|
||||
expected, received, ok, gap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestH264FrameNumTrackerHonorsPermittedGaps(t *testing.T) {
|
||||
tracker := &h264FrameNumTracker{}
|
||||
tracker.setSPS(&h264.SPS{
|
||||
Log2MaxFrameNumMinus4: 1,
|
||||
FrameMbsOnlyFlag: true,
|
||||
GapsInFrameNumValueAllowedFlag: true,
|
||||
})
|
||||
|
||||
tracker.observe([][]byte{testH264Slice(h264.NALUTypeIDR, 0)})
|
||||
expected, received, gap, ok := tracker.observe([][]byte{testH264Slice(h264.NALUTypeNonIDR, 2)})
|
||||
if !ok || gap || expected != 1 || received != 2 {
|
||||
t.Fatalf("permitted frame_num gap = (expected=%d, received=%d, ok=%t, gap=%t), want (1, 2, true, false)",
|
||||
expected, received, ok, gap)
|
||||
}
|
||||
}
|
||||
|
||||
func testH264Slice(naluType h264.NALUType, frameNum uint32) []byte {
|
||||
return []byte{0x60 | byte(naluType), 0xE0 | byte(frameNum&0x1F)}
|
||||
}
|
||||
|
||||
+35
-161
@@ -53,13 +53,16 @@ func publishRecordingState(mqttClient mqtt.Client, hubKey string, configuration
|
||||
}
|
||||
}
|
||||
|
||||
func recordingUploadMetadata(name, deviceKey string, timestamp int64, mp4Video *video.MP4, encrypted bool) models.RecordingUploadMetadata {
|
||||
func recordingUploadMetadata(name string, config models.Config, timestamp int64, mp4Video *video.MP4, encrypted bool, coordinates string, changes int) models.RecordingUploadMetadata {
|
||||
metadata := models.RecordingUploadMetadata{
|
||||
FileName: filepath.Base(name),
|
||||
DeviceKey: deviceKey,
|
||||
Timestamp: timestamp,
|
||||
Duration: mp4Video.VideoTotalDuration,
|
||||
Encrypted: encrypted,
|
||||
FileName: filepath.Base(name),
|
||||
DeviceKey: config.Key,
|
||||
Timestamp: timestamp,
|
||||
Duration: mp4Video.VideoTotalDuration,
|
||||
Encrypted: encrypted,
|
||||
DeviceName: config.Name,
|
||||
RegionCoordinates: coordinates,
|
||||
NumberOfChanges: strconv.Itoa(changes),
|
||||
}
|
||||
value := mp4Video.AverageFPS()
|
||||
if value > 0 && value <= 240 && !math.IsInf(value, 0) && !math.IsNaN(value) {
|
||||
@@ -190,7 +193,7 @@ func CleanupRecordingDirectory(configDirectory string, configuration *models.Con
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Remove(recordingsDirectory + "/" + name); err != nil {
|
||||
if err := utils.RemoveRecording(recordingsDirectory, name); err != nil {
|
||||
log.Info("HandleRecordStream: something went wrong, " + err.Error())
|
||||
return
|
||||
}
|
||||
@@ -275,7 +278,7 @@ func defaultReserveMB(totalMB int64) int64 {
|
||||
// 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)
|
||||
entries, err := utils.ReadRecordingDirectory(recordingsDirectory)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
@@ -284,14 +287,7 @@ func pickRecordingToCleanup(recordingsDirectory, cloudDirectory string) (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()
|
||||
modTime := entry.ModTime()
|
||||
|
||||
if oldestAnyName == "" || modTime.Before(oldestAnyTime) {
|
||||
oldestAnyName = entry.Name()
|
||||
@@ -449,39 +445,6 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
// Cleanup muxer
|
||||
start = false
|
||||
|
||||
// Update the name of the recording with the duration.
|
||||
// We will update the name of the recording with the duration in milliseconds.
|
||||
if mp4Video.VideoTotalDuration > 0 {
|
||||
duration := mp4Video.VideoTotalDuration
|
||||
// Update the name with the duration in milliseconds.
|
||||
startRecordingSeconds := startRecording / 1000 // convert to seconds
|
||||
startRecordingMilliseconds := startRecording % 1000 // convert to milliseconds
|
||||
s := strconv.FormatInt(startRecordingSeconds, 10) + "_" +
|
||||
strconv.Itoa(len(strconv.FormatInt(startRecordingMilliseconds, 10))) + "-" +
|
||||
strconv.FormatInt(startRecordingMilliseconds, 10) + "_" +
|
||||
config.Name + "_" +
|
||||
"0-0-0-0" + "_" + // region coordinates, we
|
||||
"-1" + "_" + // token
|
||||
strconv.FormatInt(int64(duration), 10) // + "_" + // duration of recording
|
||||
//utils.VERSION // version of the agent
|
||||
|
||||
oldName := name
|
||||
name = s + ".mp4"
|
||||
fullName = configDirectory + "/data/recordings/" + name
|
||||
log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
|
||||
|
||||
// Rename the file to the new name.
|
||||
err := os.Rename(
|
||||
configDirectory+"/data/recordings/"+oldName,
|
||||
configDirectory+"/data/recordings/"+s+".mp4")
|
||||
|
||||
if err != nil {
|
||||
log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
|
||||
}
|
||||
} else {
|
||||
log.Info("capture.main.HandleRecordStream(continuous): no video data recorded, not renaming file.")
|
||||
}
|
||||
|
||||
encrypted := false
|
||||
// Check if we need to encrypt the recording.
|
||||
if config.Encryption != nil && config.Encryption.Enabled == "true" && config.Encryption.Recordings == "true" && config.Encryption.SymmetricKey != "" {
|
||||
@@ -506,7 +469,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
}
|
||||
}
|
||||
|
||||
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config.Key, startRecording, mp4Video, encrypted))
|
||||
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config, startRecording, mp4Video, encrypted, "0-0-0-0", -1))
|
||||
|
||||
recordingStatus = "idle"
|
||||
|
||||
@@ -531,29 +494,17 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
|
||||
start = true
|
||||
|
||||
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
|
||||
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
|
||||
// - Timestamp
|
||||
// - Size + - + microseconds
|
||||
// - device
|
||||
// - Region
|
||||
// - Number of changes
|
||||
// - Token
|
||||
|
||||
startRecording = pkt.CurrentTime
|
||||
startRecordingSeconds := startRecording / 1000 // convert to seconds
|
||||
startRecordingMilliseconds := startRecording % 1000 // convert to milliseconds
|
||||
s := strconv.FormatInt(startRecordingSeconds, 10) + "_" + // start timestamp in seconds
|
||||
strconv.Itoa(len(strconv.FormatInt(startRecordingMilliseconds, 10))) + "-" + // length of milliseconds
|
||||
strconv.FormatInt(startRecordingMilliseconds, 10) + "_" + // milliseconds
|
||||
config.Name + "_" + // device name
|
||||
"0-0-0-0" + "_" + // region coordinates, we will not use this for continuous recording
|
||||
"0" + "_" + // token
|
||||
"0" + "_" //+ // duration of recording in milliseconds
|
||||
//utils.VERSION // version of the agent
|
||||
|
||||
name = s + ".mp4"
|
||||
fullName = configDirectory + "/data/recordings/" + name
|
||||
name, err = utils.RecordingFileName(startRecording, config.Key)
|
||||
if err == nil {
|
||||
fullName, err = utils.CreateRecordingPath(configDirectory+"/data/recordings", name, time.UnixMilli(startRecording))
|
||||
}
|
||||
if err != nil {
|
||||
log.WithError(err).Error("capture: could not create continuous recording path")
|
||||
start = false
|
||||
pkt = nextPkt
|
||||
continue
|
||||
}
|
||||
|
||||
// Running...
|
||||
log.Info("capture.main.HandleRecordStream(continuous): recording started")
|
||||
@@ -609,39 +560,6 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
// Cleanup muxer
|
||||
start = false
|
||||
|
||||
// Update the name of the recording with the duration.
|
||||
// We will update the name of the recording with the duration in milliseconds.
|
||||
if mp4Video.VideoTotalDuration > 0 {
|
||||
duration := mp4Video.VideoTotalDuration
|
||||
// Update the name with the duration in milliseconds.
|
||||
startRecordingSeconds := startRecording / 1000 // convert to seconds
|
||||
startRecordingMilliseconds := startRecording % 1000 // convert to milliseconds
|
||||
s := strconv.FormatInt(startRecordingSeconds, 10) + "_" +
|
||||
strconv.Itoa(len(strconv.FormatInt(startRecordingMilliseconds, 10))) + "-" +
|
||||
strconv.FormatInt(startRecordingMilliseconds, 10) + "_" +
|
||||
config.Name + "_" +
|
||||
"0-0-0-0" + "_" + // region coordinates, we
|
||||
"-1" + "_" + // token
|
||||
strconv.FormatInt(int64(duration), 10) // + "_" + // duration of recording
|
||||
//utils.VERSION // version of the agent
|
||||
|
||||
oldName := name
|
||||
name = s + ".mp4"
|
||||
fullName = configDirectory + "/data/recordings/" + name
|
||||
log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
|
||||
|
||||
// Rename the file to the new name.
|
||||
err := os.Rename(
|
||||
configDirectory+"/data/recordings/"+oldName,
|
||||
configDirectory+"/data/recordings/"+s+".mp4")
|
||||
|
||||
if err != nil {
|
||||
log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
|
||||
}
|
||||
} else {
|
||||
log.Info("capture.main.HandleRecordStream(continuous): no video data recorded, not renaming file.")
|
||||
}
|
||||
|
||||
encrypted := false
|
||||
// Check if we need to encrypt the recording.
|
||||
if config.Encryption != nil && config.Encryption.Enabled == "true" && config.Encryption.Recordings == "true" && config.Encryption.SymmetricKey != "" {
|
||||
@@ -666,7 +584,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
}
|
||||
}
|
||||
|
||||
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config.Key, startRecording, mp4Video, encrypted))
|
||||
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config, startRecording, mp4Video, encrypted, "0-0-0-0", -1))
|
||||
|
||||
recordingStatus = "idle"
|
||||
|
||||
@@ -721,17 +639,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
continue
|
||||
}
|
||||
|
||||
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
|
||||
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
|
||||
// - Timestamp
|
||||
// - Size + - + microseconds
|
||||
// - device
|
||||
// - Region
|
||||
// - Number of changes
|
||||
// - Token
|
||||
|
||||
displayTimeSeconds := displayTime / 1000 // convert to seconds
|
||||
displayTimeMilliseconds := displayTime % 1000 // convert to milliseconds
|
||||
displayTimeSeconds := displayTime / 1000
|
||||
motionRectangleString := "0-0-0-0"
|
||||
if motion.Rectangle.X != 0 || motion.Rectangle.Y != 0 ||
|
||||
motion.Rectangle.Width != 0 || motion.Rectangle.Height != 0 {
|
||||
@@ -742,17 +650,15 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
// Get the number of changes from the motion detection.
|
||||
numberOfChanges := motion.NumberOfChanges
|
||||
|
||||
s := strconv.FormatInt(displayTimeSeconds, 10) + "_" + // start timestamp in seconds
|
||||
strconv.Itoa(len(strconv.FormatInt(displayTimeMilliseconds, 10))) + "-" + // length of milliseconds
|
||||
strconv.FormatInt(displayTimeMilliseconds, 10) + "_" + // milliseconds
|
||||
config.Name + "_" + // device name
|
||||
motionRectangleString + "_" + // region coordinates, we will not use this for continuous recording
|
||||
strconv.Itoa(numberOfChanges) + "_" + // number of changes
|
||||
"0" // + "_" + // duration of recording in milliseconds
|
||||
//utils.VERSION // version of the agent
|
||||
|
||||
name := s + ".mp4"
|
||||
fullName := configDirectory + "/data/recordings/" + name
|
||||
name, err := utils.RecordingFileName(displayTime, config.Key)
|
||||
var fullName string
|
||||
if err == nil {
|
||||
fullName, err = utils.CreateRecordingPath(configDirectory+"/data/recordings", name, time.UnixMilli(displayTime))
|
||||
}
|
||||
if err != nil {
|
||||
log.WithError(err).Error("capture: could not create motion recording path")
|
||||
continue
|
||||
}
|
||||
|
||||
// Running...
|
||||
log.Info("capture.main.HandleRecordStream(motiondetection): recording started (" + name + ")" + " at " + strconv.FormatInt(displayTimeSeconds, 10) + " unix")
|
||||
@@ -880,38 +786,6 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
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.
|
||||
if mp4Video.VideoTotalDuration > 0 {
|
||||
duration := mp4Video.VideoTotalDuration
|
||||
|
||||
// Update the name with the duration in milliseconds.
|
||||
s := strconv.FormatInt(displayTimeSeconds, 10) + "_" +
|
||||
strconv.Itoa(len(strconv.FormatInt(displayTimeMilliseconds, 10))) + "-" +
|
||||
strconv.FormatInt(displayTimeMilliseconds, 10) + "_" +
|
||||
config.Name + "_" +
|
||||
motionRectangleString + "_" +
|
||||
strconv.Itoa(numberOfChanges) + "_" + // number of changes
|
||||
strconv.FormatInt(int64(duration), 10) // + "_" + // duration of recording in milliseconds
|
||||
//utils.VERSION // version of the agent
|
||||
|
||||
oldName := name
|
||||
name = s + ".mp4"
|
||||
fullName = configDirectory + "/data/recordings/" + name
|
||||
log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
|
||||
|
||||
// Rename the file to the new name.
|
||||
err := os.Rename(
|
||||
configDirectory+"/data/recordings/"+oldName,
|
||||
configDirectory+"/data/recordings/"+s+".mp4")
|
||||
|
||||
if err != nil {
|
||||
log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
|
||||
}
|
||||
} else {
|
||||
log.Info("capture.main.HandleRecordStream(motiondetection): no video data recorded, not renaming file.")
|
||||
}
|
||||
|
||||
encrypted := false
|
||||
// Check if we need to encrypt the recording.
|
||||
if config.Encryption != nil && config.Encryption.Enabled == "true" && config.Encryption.Recordings == "true" && config.Encryption.SymmetricKey != "" {
|
||||
@@ -936,7 +810,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
}
|
||||
}
|
||||
|
||||
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config.Key, displayTime, mp4Video, encrypted))
|
||||
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config, displayTime, mp4Video, encrypted, motionRectangleString, numberOfChanges))
|
||||
|
||||
// Clean up the recording directory if necessary.
|
||||
CleanupRecordingDirectory(configDirectory, configuration)
|
||||
|
||||
@@ -42,10 +42,11 @@ func TestQueueRecordingForUploadStoresFinalizedMetadata(t *testing.T) {
|
||||
}
|
||||
|
||||
mp4Video := &video.MP4{VideoTotalDuration: 20452, SampleCount: 613}
|
||||
metadata := recordingUploadMetadata("recording.mp4", "device-key", 1785934709414, mp4Video, true)
|
||||
config := models.Config{Key: "device-key", Name: "camera-name"}
|
||||
metadata := recordingUploadMetadata("1785934709414-device-key.mp4", config, 1785934709414, mp4Video, true, "1-2-3-4", 57)
|
||||
queueRecordingForUpload(configDirectory, metadata)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(configDirectory, "data", "cloud", "recording.metadata"))
|
||||
got, err := os.ReadFile(filepath.Join(configDirectory, "data", "cloud", "1785934709414-device-key.metadata"))
|
||||
if err != nil {
|
||||
t.Fatalf("read upload marker: %v", err)
|
||||
}
|
||||
@@ -54,7 +55,8 @@ func TestQueueRecordingForUploadStoresFinalizedMetadata(t *testing.T) {
|
||||
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 || !stored.Encrypted {
|
||||
if stored.FileName != "1785934709414-device-key.mp4" || stored.DeviceKey != "device-key" || stored.Timestamp != 1785934709414 || stored.Duration != 20452 || math.Abs(stored.FPS-expectedFPS) > 1e-9 || !stored.Encrypted ||
|
||||
stored.DeviceName != "camera-name" || stored.RegionCoordinates != "1-2-3-4" || stored.NumberOfChanges != "57" {
|
||||
t.Fatalf("upload marker = %+v", stored)
|
||||
}
|
||||
if stored.FPS == math.Floor(stored.FPS) {
|
||||
|
||||
@@ -125,7 +125,7 @@ func HandleUpload(configDirectory string, configuration *models.Configuration, c
|
||||
// Check if we need to remove the original recording
|
||||
// removeAfterUpload is set to false by default
|
||||
if config.RemoveAfterUpload != "false" {
|
||||
err := os.Remove(configDirectory + "/data/recordings/" + fileName)
|
||||
err := utils.RemoveRecording(configDirectory+"/data/recordings", fileName)
|
||||
if err != nil {
|
||||
log.Error("HandleUpload: " + err.Error())
|
||||
}
|
||||
@@ -319,6 +319,7 @@ func heartbeatONVIFCameraKey(camera models.IPCamera) string {
|
||||
camera.ONVIFXAddr,
|
||||
camera.ONVIFUsername,
|
||||
camera.ONVIFPassword,
|
||||
strconv.Itoa(camera.ONVIFChannel),
|
||||
}, "\x00")
|
||||
}
|
||||
|
||||
@@ -413,7 +414,7 @@ func (state *heartbeatONVIFState) refreshStaticPayload(device *goonvif.Device) h
|
||||
payload.panTilt = "true"
|
||||
}
|
||||
|
||||
presets, err := heartbeatGetPresetsFromDevice(device)
|
||||
presets, err := heartbeatGetPresetsFromDevice(device, state.cameraConfiguration.ONVIFChannel)
|
||||
if err == nil && len(presets) > 0 {
|
||||
payload.presets = "true"
|
||||
presetsList, marshalErr := json.Marshal(presets)
|
||||
@@ -1379,6 +1380,10 @@ func HandleRealtimeProcessing(processingCursor *packets.QueueCursor, configurati
|
||||
|
||||
// Check if we need to enable the realtime processing
|
||||
if config.RealtimeProcessing == "true" {
|
||||
if mqttClient == nil {
|
||||
log.Warn("cloud.RealtimeProcessing(): stopping as MQTT client is not configured.")
|
||||
return
|
||||
}
|
||||
|
||||
hubKey := ""
|
||||
if config.Cloud == "s3" && config.S3 != nil && config.S3.Publickey != "" {
|
||||
@@ -1454,13 +1459,19 @@ func HandleRealtimeProcessing(processingCursor *packets.QueueCursor, configurati
|
||||
// @Summary Will verify the hub connectivity.
|
||||
// @Description Will verify the hub connectivity.
|
||||
// @Success 200 {object} models.APIResponse
|
||||
func VerifyHub(c *gin.Context) {
|
||||
func VerifyHub(c *gin.Context, configuredHubURI string) {
|
||||
|
||||
var config models.Config
|
||||
err := c.BindJSON(&config)
|
||||
|
||||
if err == nil {
|
||||
hubURI := config.HubURI
|
||||
hubURI, err := configuredHubVerificationURI(config.HubURI, configuredHubURI)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.APIResponse{
|
||||
Data: "cloud.VerifyHub(): " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
publicKey := config.HubKey
|
||||
privateKey := config.HubPrivateKey
|
||||
|
||||
@@ -1473,9 +1484,9 @@ func VerifyHub(c *gin.Context) {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client = &http.Client{Transport: tr}
|
||||
client = &http.Client{Transport: tr, CheckRedirect: rejectHubVerificationRedirect}
|
||||
} else {
|
||||
client = &http.Client{}
|
||||
client = &http.Client{CheckRedirect: rejectHubVerificationRedirect}
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
@@ -1524,7 +1535,7 @@ func VerifyHub(c *gin.Context) {
|
||||
// @Summary Will verify the persistence.
|
||||
// @Description Will verify the persistence.
|
||||
// @Success 200 {object} models.APIResponse
|
||||
func VerifyPersistence(c *gin.Context, configDirectory string) {
|
||||
func VerifyPersistence(c *gin.Context, configDirectory, configuredHubURI string) {
|
||||
|
||||
var config models.Config
|
||||
err := c.BindJSON(&config)
|
||||
@@ -1537,6 +1548,7 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
|
||||
if config.HubURI == "" ||
|
||||
config.HubKey == "" ||
|
||||
config.HubPrivateKey == "" ||
|
||||
config.S3 == nil ||
|
||||
config.S3.Region == "" {
|
||||
msg := "cloud.VerifyPersistence(kerberoshub): Kerberos Hub not properly configured."
|
||||
log.Error(msg)
|
||||
@@ -1544,25 +1556,24 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
|
||||
Data: msg,
|
||||
})
|
||||
} else {
|
||||
|
||||
// Open test-480p.mp4
|
||||
file, err := os.Open(configDirectory + "/data/test-480p.mp4")
|
||||
hubURI, err := configuredHubVerificationURI(config.HubURI, configuredHubURI)
|
||||
if err != nil {
|
||||
msg := "cloud.VerifyPersistence(kerberoshub): error reading test-480p.mp4: " + err.Error()
|
||||
msg := "cloud.VerifyPersistence(kerberoshub): " + err.Error()
|
||||
log.Error(msg)
|
||||
c.JSON(400, models.APIResponse{
|
||||
c.JSON(http.StatusBadRequest, models.APIResponse{
|
||||
Data: msg,
|
||||
})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
req, err := http.NewRequest("POST", config.HubURI+"/storage/upload", file)
|
||||
req, err := http.NewRequest(http.MethodHead, hubURI+"/storage/upload", nil)
|
||||
if err != nil {
|
||||
msg := "cloud.VerifyPersistence(kerberoshub): error reading Kerberos Hub HEAD request, " + config.HubURI + "/storage: " + err.Error()
|
||||
msg := "cloud.VerifyPersistence(kerberoshub): error creating Kerberos Hub upload authorization request: " + err.Error()
|
||||
log.Error(msg)
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: msg,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().Unix()
|
||||
@@ -1580,9 +1591,9 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client = &http.Client{Transport: tr}
|
||||
client = &http.Client{Transport: tr, CheckRedirect: rejectHubVerificationRedirect}
|
||||
} else {
|
||||
client = &http.Client{}
|
||||
client = &http.Client{CheckRedirect: rejectHubVerificationRedirect}
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
@@ -1598,14 +1609,17 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
|
||||
Data: msg,
|
||||
})
|
||||
} else {
|
||||
msg := "cloud.VerifyPersistence(kerberoshub): upload not allowed using the configured credentials"
|
||||
msg := hubPersistenceVerificationError(resp.StatusCode)
|
||||
log.Error(msg)
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: msg,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
msg := "cloud.VerifyPersistence(kerberoshub): Error creating Kerberos Hub request"
|
||||
msg := "cloud.VerifyPersistence(kerberoshub): Kerberos Hub upload authorization request failed"
|
||||
if err != nil {
|
||||
msg += ": " + err.Error()
|
||||
}
|
||||
log.Error(msg)
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: msg,
|
||||
@@ -1774,6 +1788,35 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
|
||||
}
|
||||
}
|
||||
|
||||
func configuredHubVerificationURI(requested, configured string) (string, error) {
|
||||
requested = strings.TrimRight(strings.TrimSpace(requested), "/")
|
||||
configured = strings.TrimRight(strings.TrimSpace(configured), "/")
|
||||
if configured == "" {
|
||||
return "", errors.New("save the Hub URI before verifying it")
|
||||
}
|
||||
if requested != configured {
|
||||
return "", errors.New("the Hub URI does not match the saved configuration; save it before verifying")
|
||||
}
|
||||
return configured, nil
|
||||
}
|
||||
|
||||
func rejectHubVerificationRedirect(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
|
||||
func hubPersistenceVerificationError(statusCode int) string {
|
||||
reason := "Kerberos Hub rejected upload authorization"
|
||||
switch statusCode {
|
||||
case http.StatusBadRequest:
|
||||
reason += ": the credentials or device/project assignment are invalid, or the storage limit was reached"
|
||||
case http.StatusUnauthorized:
|
||||
reason += ": the account has no active subscription or the subscription expired"
|
||||
case http.StatusForbidden:
|
||||
reason += ": the device is muted"
|
||||
}
|
||||
return "cloud.VerifyPersistence(kerberoshub): " + reason + " (HTTP " + strconv.Itoa(statusCode) + ")"
|
||||
}
|
||||
|
||||
// VerifySecondaryPersistence godoc
|
||||
// @Router /api/persistence/secondary/verify [post]
|
||||
// @ID verify-secondary-persistence
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
agentonvif "github.com/kerberos-io/agent/machinery/src/onvif"
|
||||
goonvif "github.com/kerberos-io/onvif"
|
||||
@@ -18,6 +21,202 @@ import (
|
||||
goonvifxsd "github.com/kerberos-io/onvif/xsd/onvif"
|
||||
)
|
||||
|
||||
func TestVerifyPersistenceKerberosHubUsesAuthorizationHead(t *testing.T) {
|
||||
var requestMethod string
|
||||
var requestBody []byte
|
||||
var requestHeaders http.Header
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestMethod = r.Method
|
||||
requestHeaders = r.Header.Clone()
|
||||
requestBody, _ = io.ReadAll(r.Body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := models.Config{
|
||||
Cloud: "kerberoshub",
|
||||
Key: "camera-1",
|
||||
Name: "front-door",
|
||||
HubURI: server.URL,
|
||||
HubKey: "project-public",
|
||||
HubPrivateKey: "project-private",
|
||||
S3: &models.S3{Region: "eu"},
|
||||
}
|
||||
payload, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/persistence/verify", bytes.NewReader(payload))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
VerifyPersistence(ctx, t.TempDir(), server.URL)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if requestMethod != http.MethodHead {
|
||||
t.Fatalf("Hub request method = %q, want HEAD", requestMethod)
|
||||
}
|
||||
if len(requestBody) != 0 {
|
||||
t.Fatalf("Hub request body has %d bytes, want none", len(requestBody))
|
||||
}
|
||||
for header, want := range map[string]string{
|
||||
"X-Kerberos-Storage-Device": "camera-1",
|
||||
"X-Kerberos-Hub-PublicKey": "project-public",
|
||||
"X-Kerberos-Hub-PrivateKey": "project-private",
|
||||
"X-Kerberos-Hub-Region": "eu",
|
||||
"X-Kerberos-Storage-Capture": "IPCamera",
|
||||
} {
|
||||
if got := requestHeaders.Get(header); got != want {
|
||||
t.Errorf("%s = %q, want %q", header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPersistenceKerberosHubRejectsCrossHostRedirect(t *testing.T) {
|
||||
var redirectedHeaders http.Header
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
redirectedHeaders = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer target.Close()
|
||||
|
||||
var originalHeaders http.Header
|
||||
hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
originalHeaders = r.Header.Clone()
|
||||
http.Redirect(w, r, target.URL+"/storage/upload", http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer hub.Close()
|
||||
|
||||
config := models.Config{
|
||||
Cloud: "kerberoshub",
|
||||
Key: "camera-1",
|
||||
Name: "front-door",
|
||||
HubURI: hub.URL,
|
||||
HubKey: "project-public",
|
||||
HubPrivateKey: "project-private",
|
||||
S3: &models.S3{Region: "eu"},
|
||||
}
|
||||
payload, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/persistence/verify", bytes.NewReader(payload))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
VerifyPersistence(ctx, t.TempDir(), hub.URL)
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
|
||||
}
|
||||
for _, header := range []string{"X-Kerberos-Hub-PublicKey", "X-Kerberos-Hub-PrivateKey"} {
|
||||
if originalHeaders.Get(header) == "" {
|
||||
t.Errorf("%s was not sent to the configured Hub", header)
|
||||
}
|
||||
if redirectedHeaders != nil {
|
||||
t.Errorf("redirect target received a request with %s=%q", header, redirectedHeaders.Get(header))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPersistenceKerberosHubRejectsUnsavedURI(t *testing.T) {
|
||||
requestCount := 0
|
||||
untrusted := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
requestCount++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer untrusted.Close()
|
||||
|
||||
config := models.Config{
|
||||
Cloud: "kerberoshub",
|
||||
Key: "camera-1",
|
||||
Name: "front-door",
|
||||
HubURI: untrusted.URL,
|
||||
HubKey: "project-public",
|
||||
HubPrivateKey: "project-private",
|
||||
S3: &models.S3{Region: "eu"},
|
||||
}
|
||||
payload, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/persistence/verify", bytes.NewReader(payload))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
VerifyPersistence(ctx, t.TempDir(), "https://saved-hub.example.com")
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
|
||||
}
|
||||
if requestCount != 0 {
|
||||
t.Fatalf("untrusted Hub received %d requests, want none", requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyHubRejectsUnsavedURI(t *testing.T) {
|
||||
requestCount := 0
|
||||
untrusted := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
requestCount++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer untrusted.Close()
|
||||
|
||||
config := models.Config{
|
||||
HubURI: untrusted.URL,
|
||||
HubKey: "project-public",
|
||||
HubPrivateKey: "project-private",
|
||||
}
|
||||
payload, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/hub/verify", bytes.NewReader(payload))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
VerifyHub(ctx, "https://saved-hub.example.com")
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d; body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
|
||||
}
|
||||
if requestCount != 0 {
|
||||
t.Fatalf("untrusted Hub received %d requests, want none", requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubPersistenceVerificationErrorExplainsStatus(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
status int
|
||||
want string
|
||||
}{
|
||||
{status: http.StatusBadRequest, want: "credentials or device/project assignment"},
|
||||
{status: http.StatusUnauthorized, want: "no active subscription"},
|
||||
{status: http.StatusForbidden, want: "device is muted"},
|
||||
{status: http.StatusBadGateway, want: "HTTP 502"},
|
||||
} {
|
||||
t.Run(http.StatusText(test.status), func(t *testing.T) {
|
||||
if got := hubPersistenceVerificationError(test.status); !strings.Contains(got, test.want) {
|
||||
t.Fatalf("hubPersistenceVerificationError(%d) = %q, want it to contain %q", test.status, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatFailureLogOmitsHubResponseBody(t *testing.T) {
|
||||
response := &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
@@ -97,7 +296,7 @@ func TestHeartbeatONVIFPayloadCachesStaticAndReusesLoopSubscription(t *testing.T
|
||||
ptzFunctionCalls++
|
||||
return nil, true, true
|
||||
}
|
||||
heartbeatGetPresetsFromDevice = func(*goonvif.Device) ([]models.OnvifActionPreset, error) {
|
||||
heartbeatGetPresetsFromDevice = func(*goonvif.Device, int) ([]models.OnvifActionPreset, error) {
|
||||
presetCalls++
|
||||
return []models.OnvifActionPreset{{Name: "Lobby", Token: "1"}}, nil
|
||||
}
|
||||
@@ -198,7 +397,7 @@ func TestHeartbeatONVIFPayloadRefreshesAfterCameraConfigChange(t *testing.T) {
|
||||
heartbeatGetPTZFunctionsFromDevice = func(goonvifptz.GetConfigurationsResponse) ([]string, bool, bool) {
|
||||
return nil, false, true
|
||||
}
|
||||
heartbeatGetPresetsFromDevice = func(*goonvif.Device) ([]models.OnvifActionPreset, error) {
|
||||
heartbeatGetPresetsFromDevice = func(*goonvif.Device, int) ([]models.OnvifActionPreset, error) {
|
||||
presetCalls++
|
||||
return nil, nil
|
||||
}
|
||||
@@ -278,7 +477,7 @@ func TestHeartbeatONVIFPayloadRetriesStaticFetchFailuresWithoutReconnect(t *test
|
||||
heartbeatGetPTZFunctionsFromDevice = func(goonvifptz.GetConfigurationsResponse) ([]string, bool, bool) {
|
||||
return nil, true, true
|
||||
}
|
||||
heartbeatGetPresetsFromDevice = func(*goonvif.Device) ([]models.OnvifActionPreset, error) {
|
||||
heartbeatGetPresetsFromDevice = func(*goonvif.Device, int) ([]models.OnvifActionPreset, error) {
|
||||
presetCalls++
|
||||
if presetCalls == 1 {
|
||||
return nil, errors.New("temporary preset failure")
|
||||
@@ -419,7 +618,7 @@ func TestHeartbeatONVIFPayloadReconnectFailureInvalidatesCache(t *testing.T) {
|
||||
heartbeatGetPTZFunctionsFromDevice = func(goonvifptz.GetConfigurationsResponse) ([]string, bool, bool) {
|
||||
return nil, true, false
|
||||
}
|
||||
heartbeatGetPresetsFromDevice = func(*goonvif.Device) ([]models.OnvifActionPreset, error) {
|
||||
heartbeatGetPresetsFromDevice = func(*goonvif.Device, int) ([]models.OnvifActionPreset, error) {
|
||||
presetCalls++
|
||||
return []models.OnvifActionPreset{{Name: "Preset", Token: "1"}}, nil
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ package cloud
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox"
|
||||
"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/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -37,14 +37,12 @@ func UploadDropbox(configuration *models.Configuration, fileName string) (bool,
|
||||
// Upload to Dropbox
|
||||
log.Info("UploadDropbox: Uploading to Dropbox")
|
||||
log.Info("UploadDropbox: Upload started for " + fileName)
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
dConfig := dropbox.Config{
|
||||
Token: token,
|
||||
LogLevel: dropbox.LogInfo, // if needed, set the desired logging level. Default is off
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
|
||||
file, err := utils.OpenRecording("data/recordings", fileName)
|
||||
if file != nil {
|
||||
defer func() {
|
||||
if cerr := file.Close(); cerr != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -78,10 +79,8 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
|
||||
}).Info("Resumable Hub upload unavailable; using legacy upload")
|
||||
}
|
||||
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
// Check if we still have the file otherwise we abort the request.
|
||||
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
|
||||
file, err := utils.OpenRecording("data/recordings", fileName)
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -35,10 +36,23 @@ func UploadKerberosVault(configuration *models.Configuration, fileName string) (
|
||||
// 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)
|
||||
filePath, err := utils.ResolveRecordingPath("data/recordings", fileName)
|
||||
if err != nil {
|
||||
log.Info("UploadKerberosVault: skipping " + fileName + ", file doesn't exist anymore")
|
||||
return false, false, nil
|
||||
if os.IsNotExist(err) {
|
||||
log.Info("UploadKerberosVault: skipping " + fileName + ", file doesn't exist anymore")
|
||||
return false, false, nil
|
||||
}
|
||||
log.WithError(err).Error("UploadKerberosVault: could not resolve recording path")
|
||||
return false, true, err
|
||||
}
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Info("UploadKerberosVault: skipping " + fileName + ", file doesn't exist anymore")
|
||||
return false, false, nil
|
||||
}
|
||||
log.WithError(err).Error("UploadKerberosVault: could not stat recording")
|
||||
return false, true, err
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
log.Warn("UploadKerberosVault: skipping " + fileName + ", recording is empty")
|
||||
@@ -182,9 +196,7 @@ func uploadVaultLegacy(vault models.KStorage, publicKey, deviceKey, fileName, la
|
||||
if err != nil {
|
||||
return false, false, "", err
|
||||
}
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
file, err := os.Open(fullname)
|
||||
file, err := utils.OpenRecording("data/recordings", fileName)
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
)
|
||||
|
||||
func TestHandleRealtimeProcessingWithoutMQTTClient(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config models.Config
|
||||
wantWarning bool
|
||||
}{
|
||||
{
|
||||
name: "enabled without Hub credentials",
|
||||
config: models.Config{RealtimeProcessing: "true"},
|
||||
wantWarning: true,
|
||||
},
|
||||
{
|
||||
name: "disabled",
|
||||
config: models.Config{RealtimeProcessing: "false"},
|
||||
},
|
||||
{
|
||||
name: "offline",
|
||||
config: models.Config{RealtimeProcessing: "true", Offline: "true"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hook := test.NewGlobal()
|
||||
defer hook.Reset()
|
||||
|
||||
configuration := &models.Configuration{Config: tt.config}
|
||||
// No cursor or decoder should be accessed without an MQTT client.
|
||||
HandleRealtimeProcessing(nil, configuration, nil, nil, nil)
|
||||
|
||||
warnings := 0
|
||||
for _, entry := range hook.AllEntries() {
|
||||
if entry.Level == log.WarnLevel {
|
||||
warnings++
|
||||
if entry.Message != "cloud.RealtimeProcessing(): stopping as MQTT client is not configured." {
|
||||
t.Errorf("unexpected warning: %s", entry.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
wantWarnings := 0
|
||||
if tt.wantWarning {
|
||||
wantWarnings = 1
|
||||
}
|
||||
if warnings != wantWarnings {
|
||||
t.Fatalf("warnings = %d, want %d", warnings, wantWarnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package cloud
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -10,12 +11,16 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const recordingFPSHeader = "X-Kerberos-Storage-Fps"
|
||||
const recordingDurationHeader = "X-Kerberos-Storage-Duration"
|
||||
const recordingTimestampHeader = "X-Kerberos-Storage-Timestamp"
|
||||
const recordingEncryptedHeader = "X-Kerberos-Storage-Encrypted"
|
||||
const recordingDeviceNameHeader = "X-Kerberos-Storage-DeviceName"
|
||||
const recordingRegionHeader = "X-Kerberos-Storage-RegionCoordinates"
|
||||
const recordingChangesHeader = "X-Kerberos-Storage-NumberOfChanges"
|
||||
|
||||
// queuedRecordingFPS reads the FPS snapshot written into the upload marker
|
||||
// when the recording was finalized. Historical empty markers intentionally
|
||||
@@ -61,6 +66,14 @@ func decodeRecordingUploadMetadata(value []byte) (models.RecordingUploadMetadata
|
||||
}
|
||||
|
||||
func readRecordingUploadMetadata(fileName string) ([]byte, bool) {
|
||||
value, err := readRecordingUploadMetadataFile(fileName)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
log.WithError(err).Error("Failed to read recording upload metadata")
|
||||
}
|
||||
return value, err == nil
|
||||
}
|
||||
|
||||
func readRecordingUploadMetadataFile(fileName string) ([]byte, error) {
|
||||
markerNames := []string{
|
||||
models.RecordingUploadMetadataFileName(fileName),
|
||||
filepath.Base(fileName),
|
||||
@@ -68,10 +81,13 @@ func readRecordingUploadMetadata(fileName string) ([]byte, bool) {
|
||||
for _, markerName := range markerNames {
|
||||
value, err := os.ReadFile(filepath.Join("data", "cloud", markerName))
|
||||
if err == nil {
|
||||
return value, true
|
||||
return value, nil
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("read recording upload metadata %s: %w", markerName, err)
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
func setQueuedRecordingMetadataHeaders(header http.Header, fileName string) {
|
||||
@@ -79,6 +95,18 @@ func setQueuedRecordingMetadataHeaders(header http.Header, fileName string) {
|
||||
header.Set(recordingFPSHeader, fps)
|
||||
}
|
||||
if metadata, ok := queuedRecordingMetadata(fileName); ok {
|
||||
if metadata.DeviceKey != "" {
|
||||
header.Set("X-Kerberos-Storage-Device", metadata.DeviceKey)
|
||||
}
|
||||
if metadata.DeviceName != "" {
|
||||
header.Set(recordingDeviceNameHeader, metadata.DeviceName)
|
||||
}
|
||||
if metadata.RegionCoordinates != "" {
|
||||
header.Set(recordingRegionHeader, metadata.RegionCoordinates)
|
||||
}
|
||||
if metadata.NumberOfChanges != "" {
|
||||
header.Set(recordingChangesHeader, metadata.NumberOfChanges)
|
||||
}
|
||||
if metadata.Duration > 0 {
|
||||
header.Set(recordingDurationHeader, strconv.FormatUint(metadata.Duration, 10))
|
||||
}
|
||||
|
||||
+155
-36
@@ -2,14 +2,20 @@ package cloud
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/utils"
|
||||
"github.com/minio/minio-go/v6"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -18,15 +24,6 @@ func UploadS3(configuration *models.Configuration, fileName string) (bool, bool,
|
||||
|
||||
config := configuration.Config
|
||||
|
||||
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
|
||||
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
|
||||
// - Timestamp
|
||||
// - Size + - + microseconds
|
||||
// - device
|
||||
// - Region
|
||||
// - Number of changes
|
||||
// - Token
|
||||
|
||||
if config.S3 == nil {
|
||||
errorMessage := "UploadS3: Uploading Failed, as no settings found"
|
||||
log.Error(errorMessage)
|
||||
@@ -71,24 +68,14 @@ func UploadS3(configuration *models.Configuration, fileName string) (bool, bool,
|
||||
s3Client.SetCustomTransport(transport)
|
||||
}
|
||||
|
||||
fileParts := strings.Split(fileName, "_")
|
||||
if len(fileParts) == 1 {
|
||||
errorMessage := "UploadS3: " + fileName + " is not a valid name."
|
||||
log.Error(errorMessage)
|
||||
return false, true, errors.New(errorMessage)
|
||||
objectName, userMetadata, err := s3RecordingUploadDetails(config, fileName, aws_access_key_id)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("UploadS3: invalid recording upload metadata")
|
||||
return false, true, err
|
||||
}
|
||||
|
||||
deviceKey := config.Key
|
||||
startRecording, _ := strconv.ParseInt(fileParts[0], 10, 64)
|
||||
devicename := fileParts[2]
|
||||
coordinates := fileParts[3]
|
||||
//numberOfChanges := fileParts[4]
|
||||
token, _ := strconv.Atoi(fileParts[5])
|
||||
|
||||
log.Info("UploadS3: Upload started for " + fileName)
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
|
||||
file, err := utils.OpenRecording("data/recordings", fileName)
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
@@ -107,23 +94,13 @@ func UploadS3(configuration *models.Configuration, fileName string) (bool, bool,
|
||||
}
|
||||
|
||||
n, err := s3Client.PutObject(config.S3.Bucket,
|
||||
config.S3.Username+"/"+fileName,
|
||||
objectName,
|
||||
file,
|
||||
fileInfo.Size(),
|
||||
minio.PutObjectOptions{
|
||||
ContentType: "video/mp4",
|
||||
StorageClass: "ONEZONE_IA",
|
||||
UserMetadata: map[string]string{
|
||||
"event-timestamp": strconv.FormatInt(startRecording, 10),
|
||||
"event-microseconds": deviceKey,
|
||||
"event-instancename": devicename,
|
||||
"event-regioncoordinates": coordinates,
|
||||
"event-numberofchanges": deviceKey,
|
||||
"event-token": strconv.Itoa(token),
|
||||
"productid": deviceKey,
|
||||
"publickey": aws_access_key_id,
|
||||
"uploadtime": "now",
|
||||
},
|
||||
UserMetadata: userMetadata,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -135,3 +112,145 @@ func UploadS3(configuration *models.Configuration, fileName string) (bool, bool,
|
||||
return true, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func s3RecordingObjectName(username, deviceKey string, startRecording int64) string {
|
||||
return username + "/" + time.UnixMilli(startRecording).UTC().Format("2006/01/02") +
|
||||
"/" + strconv.FormatInt(startRecording, 10) + "-" + deviceKey + ".mp4"
|
||||
}
|
||||
|
||||
func s3RecordingUploadDetails(config models.Config, fileName, publicKey string) (string, map[string]string, error) {
|
||||
value, err := readRecordingUploadMetadataFile(fileName)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
deviceKey := config.Key
|
||||
var startRecording int64
|
||||
userMetadata := map[string]string{
|
||||
"publickey": publicKey,
|
||||
"uploadtime": "now",
|
||||
}
|
||||
marker := strings.TrimSpace(string(value))
|
||||
_, fpsErr := strconv.ParseFloat(marker, 64)
|
||||
if err == nil && marker != "" && fpsErr != nil {
|
||||
var metadata models.RecordingUploadMetadata
|
||||
if err := json.Unmarshal(value, &metadata); err != nil {
|
||||
return "", nil, fmt.Errorf("decode recording upload metadata for %s: %w", fileName, err)
|
||||
}
|
||||
if metadata.FileName != "" && metadata.FileName != filepath.Base(fileName) {
|
||||
return "", nil, fmt.Errorf("recording upload metadata filename does not match %s", fileName)
|
||||
}
|
||||
if metadata.Timestamp <= 0 {
|
||||
return "", nil, fmt.Errorf("invalid recording timestamp in upload metadata for %s", fileName)
|
||||
}
|
||||
deviceKey = metadata.DeviceKey
|
||||
startRecording = metadata.Timestamp
|
||||
userMetadata["event-instancename"] = config.Name
|
||||
if metadata.DeviceName == "" || metadata.RegionCoordinates == "" || metadata.NumberOfChanges == "" {
|
||||
if description, ok := s3LegacyRecordingDescription(fileName); ok {
|
||||
for key, value := range description {
|
||||
userMetadata[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
if metadata.DeviceName != "" {
|
||||
userMetadata["event-instancename"] = metadata.DeviceName
|
||||
}
|
||||
if metadata.RegionCoordinates != "" {
|
||||
userMetadata["event-regioncoordinates"] = metadata.RegionCoordinates
|
||||
}
|
||||
if metadata.NumberOfChanges != "" {
|
||||
userMetadata["event-numberofchanges"] = metadata.NumberOfChanges
|
||||
}
|
||||
userMetadata["event-microseconds"] = strconv.FormatInt(metadata.Timestamp%1000*1000, 10)
|
||||
userMetadata["duration"] = strconv.FormatUint(metadata.Duration, 10)
|
||||
userMetadata["encrypted"] = strconv.FormatBool(metadata.Encrypted)
|
||||
if metadata.FPS > 0 && metadata.FPS <= 240 {
|
||||
userMetadata["fps"] = strconv.FormatFloat(metadata.FPS, 'f', -1, 64)
|
||||
}
|
||||
} else {
|
||||
// Empty and FPS-only markers predate structured recording metadata.
|
||||
fileParts := strings.Split(fileName, "_")
|
||||
if len(fileParts) < 6 {
|
||||
return "", nil, fmt.Errorf("%s is not a valid legacy recording name", fileName)
|
||||
}
|
||||
startRecording, err = strconv.ParseInt(fileParts[0], 10, 64)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("invalid recording timestamp in %s: %w", fileName, err)
|
||||
}
|
||||
if startRecording <= 0 || startRecording > math.MaxInt64/1000 {
|
||||
return "", nil, fmt.Errorf("invalid recording timestamp in %s", fileName)
|
||||
}
|
||||
startRecording *= 1000
|
||||
_, precision, found := strings.Cut(fileParts[1], "-")
|
||||
if found {
|
||||
fraction, err := strconv.ParseInt(precision, 10, 64)
|
||||
if err != nil || fraction < 0 || fraction >= 1000000 {
|
||||
return "", nil, fmt.Errorf("invalid recording precision in %s", fileName)
|
||||
}
|
||||
if len(precision) > 3 {
|
||||
fraction /= 1000
|
||||
}
|
||||
startRecording += fraction
|
||||
}
|
||||
token, _ := strconv.Atoi(fileParts[5])
|
||||
userMetadata["event-microseconds"] = deviceKey
|
||||
userMetadata["event-instancename"] = fileParts[2]
|
||||
userMetadata["event-regioncoordinates"] = fileParts[3]
|
||||
userMetadata["event-numberofchanges"] = deviceKey
|
||||
userMetadata["event-token"] = strconv.Itoa(token)
|
||||
}
|
||||
if deviceKey == "" || strings.ContainsAny(deviceKey, "/\\") {
|
||||
return "", nil, errors.New("missing or invalid recording device key")
|
||||
}
|
||||
if startRecording <= 0 {
|
||||
return "", nil, fmt.Errorf("invalid recording timestamp for %s", fileName)
|
||||
}
|
||||
userMetadata["event-timestamp"] = strconv.FormatInt(startRecording/1000, 10)
|
||||
userMetadata["productid"] = deviceKey
|
||||
return s3RecordingObjectName(config.S3.Username, deviceKey, startRecording), userMetadata, nil
|
||||
}
|
||||
|
||||
func s3LegacyRecordingDescription(fileName string) (map[string]string, bool) {
|
||||
base := filepath.Base(fileName)
|
||||
if filepath.Ext(base) != ".mp4" {
|
||||
return nil, false
|
||||
}
|
||||
parts := strings.Split(strings.TrimSuffix(base, ".mp4"), "_")
|
||||
if len(parts) != 6 || parts[2] == "" {
|
||||
return nil, false
|
||||
}
|
||||
seconds, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || seconds <= 0 || seconds > math.MaxInt64/1000 {
|
||||
return nil, false
|
||||
}
|
||||
digits, fraction, found := strings.Cut(parts[1], "-")
|
||||
precision, err := strconv.Atoi(digits)
|
||||
if !found || err != nil || precision < 1 || precision > 6 || len(fraction) != precision {
|
||||
return nil, false
|
||||
}
|
||||
if _, err := strconv.ParseUint(fraction, 10, 64); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
coordinates := strings.Split(parts[3], "-")
|
||||
if len(coordinates) != 4 {
|
||||
return nil, false
|
||||
}
|
||||
for _, coordinate := range coordinates {
|
||||
if _, err := strconv.ParseUint(coordinate, 10, 64); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
changes, err := strconv.ParseInt(parts[4], 10, 64)
|
||||
if err != nil || changes < -1 {
|
||||
return nil, false
|
||||
}
|
||||
if _, err := strconv.ParseUint(parts[5], 10, 64); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return map[string]string{
|
||||
"event-instancename": parts[2],
|
||||
"event-regioncoordinates": parts[3],
|
||||
"event-numberofchanges": parts[4],
|
||||
}, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
func TestS3RecordingUploadDetailsUsesCheckpoint(t *testing.T) {
|
||||
for _, fileName := range []string{
|
||||
"1767355200123-checkpoint-key.mp4",
|
||||
"1564859471_6-474162_old-camera_0-0-0-0_1153_27.mp4",
|
||||
} {
|
||||
t.Run(fileName, func(t *testing.T) {
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
recorded := time.Date(2026, time.January, 2, 12, 0, 0, 123000000, time.UTC)
|
||||
metadata := models.RecordingUploadMetadata{
|
||||
FileName: fileName,
|
||||
DeviceKey: "checkpoint-key",
|
||||
Timestamp: recorded.UnixMilli(),
|
||||
Duration: 20452,
|
||||
FPS: 29.97,
|
||||
Encrypted: true,
|
||||
DeviceName: "checkpoint-camera",
|
||||
RegionCoordinates: "1-2-3-4",
|
||||
NumberOfChanges: "57",
|
||||
}
|
||||
payload, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withQueuedRecordingFPS(t, fileName, string(payload))
|
||||
config := models.Config{
|
||||
Key: "changed-config-key",
|
||||
Name: "camera-name",
|
||||
S3: &models.S3{Username: "user"},
|
||||
}
|
||||
objectName, userMetadata, err := s3RecordingUploadDetails(config, fileName, "public-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantObjectName := "user/2026/01/02/" + strconv.FormatInt(recorded.UnixMilli(), 10) + "-checkpoint-key.mp4"
|
||||
if objectName != wantObjectName {
|
||||
t.Fatalf("object name = %q, want %q", objectName, wantObjectName)
|
||||
}
|
||||
wantMetadata := map[string]string{
|
||||
"publickey": "public-key",
|
||||
"uploadtime": "now",
|
||||
"event-instancename": "checkpoint-camera",
|
||||
"event-regioncoordinates": "1-2-3-4",
|
||||
"event-numberofchanges": "57",
|
||||
"event-microseconds": "123000",
|
||||
"event-timestamp": strconv.FormatInt(recorded.Unix(), 10),
|
||||
"productid": "checkpoint-key",
|
||||
"duration": "20452",
|
||||
"fps": "29.97",
|
||||
"encrypted": "true",
|
||||
}
|
||||
if !reflect.DeepEqual(userMetadata, wantMetadata) {
|
||||
t.Fatalf("metadata = %#v, want %#v", userMetadata, wantMetadata)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3RecordingUploadDetailsBackfillsLegacyCheckpointDescriptions(t *testing.T) {
|
||||
const legacyName = "1564859471_6-474162_recorded-camera_1-2-3-4_1153_27.mp4"
|
||||
tests := []struct {
|
||||
name string
|
||||
fileName string
|
||||
camera string
|
||||
region string
|
||||
changes string
|
||||
wantCamera string
|
||||
wantRegion string
|
||||
wantChanges string
|
||||
}{
|
||||
{"pre-upgrade checkpoint", legacyName, "", "", "", "recorded-camera", "1-2-3-4", "1153"},
|
||||
{"checkpoint camera", legacyName, "snapshot-camera", "", "", "snapshot-camera", "1-2-3-4", "1153"},
|
||||
{"checkpoint region", legacyName, "", "5-6-7-8", "", "recorded-camera", "5-6-7-8", "1153"},
|
||||
{"checkpoint zero changes", legacyName, "", "", "0", "recorded-camera", "1-2-3-4", "0"},
|
||||
{"dated reference", "2019/08/03/" + legacyName, "", "", "", "recorded-camera", "1-2-3-4", "1153"},
|
||||
{"continuous recording", "1564859471_3-474_recorded-camera_0-0-0-0_-1_27.mp4", "", "", "", "recorded-camera", "0-0-0-0", "-1"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
withRecording(t, filepath.Base(tt.fileName), []byte("recording"))
|
||||
metadata := models.RecordingUploadMetadata{
|
||||
FileName: filepath.Base(tt.fileName),
|
||||
DeviceKey: "checkpoint-key",
|
||||
Timestamp: 1767355200123,
|
||||
Duration: 20452,
|
||||
FPS: 29.97,
|
||||
Encrypted: true,
|
||||
DeviceName: tt.camera,
|
||||
RegionCoordinates: tt.region,
|
||||
NumberOfChanges: tt.changes,
|
||||
}
|
||||
payload, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withQueuedRecordingFPS(t, tt.fileName, string(payload))
|
||||
config := models.Config{Key: "current-key", Name: "current-camera", S3: &models.S3{Username: "user"}}
|
||||
objectName, got, err := s3RecordingUploadDetails(config, tt.fileName, "public-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := "user/2026/01/02/1767355200123-checkpoint-key.mp4"; objectName != want {
|
||||
t.Fatalf("object name = %q, want %q", objectName, want)
|
||||
}
|
||||
want := map[string]string{
|
||||
"publickey": "public-key",
|
||||
"uploadtime": "now",
|
||||
"event-instancename": tt.wantCamera,
|
||||
"event-regioncoordinates": tt.wantRegion,
|
||||
"event-numberofchanges": tt.wantChanges,
|
||||
"event-microseconds": "123000",
|
||||
"event-timestamp": "1767355200",
|
||||
"productid": "checkpoint-key",
|
||||
"duration": "20452",
|
||||
"fps": "29.97",
|
||||
"encrypted": "true",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("metadata = %#v, want %#v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3RecordingUploadDetailsDoesNotBackfillInvalidLegacyNames(t *testing.T) {
|
||||
for _, fileName := range []string{
|
||||
"1767355200123-checkpoint-key.mp4",
|
||||
"invalid_6-474162_camera_0-0-0-0_1153_27.mp4",
|
||||
"1564859471_6-invalid_camera_0-0-0-0_1153_27.mp4",
|
||||
"1564859471_3-474162_camera_0-0-0-0_1153_27.mp4",
|
||||
"1564859471_6-474162__0-0-0-0_1153_27.mp4",
|
||||
"1564859471_6-474162_camera_invalid_1153_27.mp4",
|
||||
"1564859471_6-474162_camera_0-0-0-0_invalid_27.mp4",
|
||||
"1564859471_6-474162_camera_0-0-0-0_1153_invalid.mp4",
|
||||
"1564859471_6-474162_camera_0-0-0-0_1153_27_extra.mp4",
|
||||
} {
|
||||
t.Run(fileName, func(t *testing.T) {
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
metadata := models.RecordingUploadMetadata{
|
||||
FileName: fileName, DeviceKey: "checkpoint-key", Timestamp: 1767355200123, Duration: 20452,
|
||||
}
|
||||
payload, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withQueuedRecordingFPS(t, fileName, string(payload))
|
||||
config := models.Config{Key: "current-key", Name: "current-camera", S3: &models.S3{Username: "user"}}
|
||||
objectName, got, err := s3RecordingUploadDetails(config, fileName, "public-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if objectName != "user/2026/01/02/1767355200123-checkpoint-key.mp4" ||
|
||||
got["event-instancename"] != "current-camera" || got["event-regioncoordinates"] != "" ||
|
||||
got["event-numberofchanges"] != "" || got["event-timestamp"] != "1767355200" ||
|
||||
got["productid"] != "checkpoint-key" {
|
||||
t.Fatalf("unexpected fallback for invalid legacy name: %q, %#v", objectName, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3RecordingUploadDetailsLegacyMarkers(t *testing.T) {
|
||||
for _, marker := range []string{"missing", "empty", "fps-only", "old-marker-name"} {
|
||||
t.Run(marker, func(t *testing.T) {
|
||||
const fileName = "1564859471_6-474162_camera_0-0-0-0_1153_27.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
switch marker {
|
||||
case "empty":
|
||||
withQueuedRecordingFPS(t, fileName, "")
|
||||
case "fps-only", "old-marker-name":
|
||||
withQueuedRecordingFPS(t, fileName, "29.97")
|
||||
if marker == "old-marker-name" {
|
||||
err := os.Rename(filepath.Join("data/cloud", models.RecordingUploadMetadataFileName(fileName)),
|
||||
filepath.Join("data/cloud", fileName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
config := models.Config{Key: "legacy-key", S3: &models.S3{Username: "user"}}
|
||||
objectName, metadata, err := s3RecordingUploadDetails(config, fileName, "public-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := "user/2019/08/03/1564859471474-legacy-key.mp4"; objectName != want {
|
||||
t.Fatalf("object name = %q, want %q", objectName, want)
|
||||
}
|
||||
if metadata["productid"] != "legacy-key" || metadata["event-instancename"] != "camera" ||
|
||||
metadata["event-regioncoordinates"] != "0-0-0-0" || metadata["event-timestamp"] != "1564859471" {
|
||||
t.Fatalf("unexpected legacy metadata: %#v", metadata)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3RecordingUploadDetailsRejectsInvalidCheckpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
marker string
|
||||
want string
|
||||
}{
|
||||
{"malformed JSON", `{"timestamp":`, "decode"},
|
||||
{"missing timestamp", `{"device_key":"camera"}`, "timestamp"},
|
||||
{"missing device key", `{"timestamp":1787015373000}`, "device key"},
|
||||
{"invalid device key", `{"timestamp":1787015373000,"device_key":"camera/key"}`, "device key"},
|
||||
{"mismatched filename", `{"timestamp":1787015373000,"device_key":"camera","filename":"other.mp4"}`, "filename"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
const fileName = "1564859471_6-474162_camera_0-0-0-0_1153_27.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
withQueuedRecordingFPS(t, fileName, tt.marker)
|
||||
config := models.Config{Key: "legacy-key", S3: &models.S3{Username: "user"}}
|
||||
_, _, err := s3RecordingUploadDetails(config, fileName, "public-key")
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("error = %v, want error containing %q instead of legacy fallback", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3RecordingUploadDetailsReportsCheckpointReadError(t *testing.T) {
|
||||
const fileName = "1564859471_6-474162_camera_0-0-0-0_1153_27.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
if err := os.MkdirAll(filepath.Join("data/cloud", models.RecordingUploadMetadataFileName(fileName)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := models.Config{Key: "legacy-key", S3: &models.S3{Username: "user"}}
|
||||
_, _, err := s3RecordingUploadDetails(config, fileName, "public-key")
|
||||
if err == nil || !strings.Contains(err.Error(), "read recording upload metadata") {
|
||||
t.Fatalf("error = %v, want checkpoint read error instead of legacy fallback", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3RecordingObjectName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
recorded time.Time
|
||||
directory string
|
||||
}{
|
||||
{
|
||||
name: "zero padded month and day",
|
||||
recorded: time.Date(2026, time.January, 2, 12, 0, 0, 0, time.UTC),
|
||||
directory: "2026/01/02",
|
||||
},
|
||||
{
|
||||
name: "UTC date across year boundary",
|
||||
recorded: time.Date(2026, time.January, 1, 1, 0, 0, 0, time.FixedZone("UTC+2", 2*60*60)),
|
||||
directory: "2025/12/31",
|
||||
},
|
||||
{
|
||||
name: "leap day",
|
||||
recorded: time.Date(2024, time.February, 29, 23, 59, 59, 0, time.UTC),
|
||||
directory: "2024/02/29",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
const deviceKey = "camera-key-123"
|
||||
want := "user/" + tt.directory + "/" + strconv.FormatInt(tt.recorded.UnixMilli(), 10) + "-" + deviceKey + ".mp4"
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if got := s3RecordingObjectName("user", deviceKey, tt.recorded.UnixMilli()); got != want {
|
||||
t.Fatalf("s3RecordingObjectName() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadS3RejectsInvalidRecordingNames(t *testing.T) {
|
||||
configuration := &models.Configuration{
|
||||
Config: models.Config{
|
||||
Key: "camera-key-123",
|
||||
S3: &models.S3{
|
||||
Publickey: "test-public-key",
|
||||
Secretkey: "test-secret-key",
|
||||
Region: "eu-west-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, fileName := range []string{
|
||||
"recording.mp4",
|
||||
"1787015373_camera.mp4",
|
||||
"invalid_6-474162_camera_0-0-0-0_1153_27.mp4",
|
||||
} {
|
||||
t.Run(fileName, func(t *testing.T) {
|
||||
uploaded, configured, err := UploadS3(configuration, fileName)
|
||||
if uploaded || !configured || err == nil {
|
||||
t.Fatalf("UploadS3() = (%v, %v, %v), want (false, true, error)", uploaded, configured, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadS3RejectsInvalidDeviceKeys(t *testing.T) {
|
||||
for _, deviceKey := range []string{"", "camera/key", `camera\key`} {
|
||||
t.Run(deviceKey, func(t *testing.T) {
|
||||
configuration := &models.Configuration{
|
||||
Config: models.Config{
|
||||
Key: deviceKey,
|
||||
S3: &models.S3{
|
||||
Publickey: "test-public-key",
|
||||
Secretkey: "test-secret-key",
|
||||
Region: "eu-west-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
uploaded, configured, err := UploadS3(configuration, "1787015373_6-474162_camera_0-0-0-0_1153_27.mp4")
|
||||
if uploaded || !configured || err == nil || !strings.Contains(err.Error(), "device key") {
|
||||
t.Fatalf("UploadS3() = (%v, %v, %v), want (false, true, device key error)", uploaded, configured, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -106,7 +107,7 @@ func logTusUploadProgress(label string, offset, size int64, loggedBucket *int64)
|
||||
// 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>
|
||||
// runTusUpload performs a resumable (tus) upload of a flat or dated recording.
|
||||
// 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)
|
||||
@@ -120,9 +121,7 @@ type tusHeaderFunc func(h http.Header, fileName string)
|
||||
// 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)
|
||||
file, ferr := utils.OpenRecording("data/recordings", fileName)
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
@@ -165,6 +164,7 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
|
||||
// and loop forever, wedging the upload worker on one recording and saturating the
|
||||
// uplink.
|
||||
highWaterOffset := int64(0)
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
// (1) Ensure we have an active upload URL, creating one if needed.
|
||||
@@ -172,6 +172,7 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
|
||||
created, status, cerr := tusCreate(client, baseURL, size, metadata, setHeaders, fileName)
|
||||
lastStatus = status
|
||||
if cerr != nil {
|
||||
lastErr = cerr
|
||||
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
|
||||
@@ -188,6 +189,7 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
|
||||
offset, status, herr := tusHead(client, uploadURL, setHeaders)
|
||||
lastStatus = status
|
||||
if herr != nil {
|
||||
lastErr = herr
|
||||
if status == http.StatusNotFound || status == http.StatusGone {
|
||||
// The upload expired/was removed server-side; start over.
|
||||
removeTusResumeState(sidecar)
|
||||
@@ -244,6 +246,7 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
|
||||
newOffset, status, respBody, perr := tusPatch(client, uploadURL, offset, patchLen, file, setHeaders)
|
||||
lastStatus = status
|
||||
if perr != nil {
|
||||
lastErr = perr
|
||||
if status >= 400 {
|
||||
// Definitive rejection (e.g. provider push failed during finalize).
|
||||
// Re-evaluate via HEAD on the next iteration to decide retry/restart.
|
||||
@@ -290,7 +293,12 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
|
||||
// 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")
|
||||
message := label + ": resumable upload did not complete after retries"
|
||||
err = errors.New(message)
|
||||
if lastErr != nil {
|
||||
err = fmt.Errorf("%s: %w", message, lastErr)
|
||||
}
|
||||
return false, lastStatus > 0, true, "resumable upload did not complete after retries", err
|
||||
}
|
||||
|
||||
// uploadVaultResumable uploads a recording directly to a Kerberos Vault using
|
||||
@@ -358,6 +366,12 @@ func addRecordingTusMetadata(values map[string]string, fileName string) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if metadata.DeviceKey != "" {
|
||||
values["device"] = metadata.DeviceKey
|
||||
}
|
||||
values["device_name"] = metadata.DeviceName
|
||||
values["region_coordinates"] = metadata.RegionCoordinates
|
||||
values["number_of_changes"] = metadata.NumberOfChanges
|
||||
if metadata.Duration > 0 {
|
||||
values["duration"] = strconv.FormatUint(metadata.Duration, 10)
|
||||
}
|
||||
@@ -390,11 +404,29 @@ func tusCreate(client *http.Client, baseURL string, size int64, metadata string,
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
const maxErrorBodyBytes = 4096
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes+1))
|
||||
if readErr != nil {
|
||||
return "", resp.StatusCode, fmt.Errorf("unexpected status creating upload: %s; read error response: %w", resp.Status, readErr)
|
||||
}
|
||||
truncated := len(body) > maxErrorBodyBytes
|
||||
if truncated {
|
||||
body = body[:maxErrorBodyBytes]
|
||||
}
|
||||
message := strings.TrimSpace(string(body))
|
||||
if truncated {
|
||||
message += " [truncated]"
|
||||
}
|
||||
if message != "" {
|
||||
return "", resp.StatusCode, fmt.Errorf("unexpected status creating upload: %s: %q", resp.Status, message)
|
||||
}
|
||||
return "", resp.StatusCode, fmt.Errorf("unexpected status creating upload: %s", resp.Status)
|
||||
}
|
||||
// Location identifies the created upload; its response body is optional.
|
||||
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
|
||||
log.WithError(err).WithField("component", "tus").Debug("Could not drain optional upload creation response body")
|
||||
}
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
return "", resp.StatusCode, errors.New("missing Location header in create response")
|
||||
@@ -500,6 +532,7 @@ func setVaultTusHeaders(h http.Header, vault models.KStorage, publicKey, deviceK
|
||||
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
|
||||
if fileName != "" {
|
||||
h.Set("X-Kerberos-Storage-FileName", fileName)
|
||||
setQueuedRecordingMetadataHeaders(h, fileName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,6 +549,7 @@ func setHubTusHeaders(h http.Header, config *models.Config, fileName string) {
|
||||
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
|
||||
if fileName != "" {
|
||||
h.Set("X-Kerberos-Storage-FileName", fileName)
|
||||
setQueuedRecordingMetadataHeaders(h, fileName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,231 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/utils"
|
||||
)
|
||||
|
||||
func TestResumableEOFRestartPreservesLatestFailure(t *testing.T) {
|
||||
previousDelay := tusBackoffBaseDelay
|
||||
tusBackoffBaseDelay = 0
|
||||
t.Cleanup(func() { tusBackoffBaseDelay = previousDelay })
|
||||
const fileName = "1767355200123-device-key.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
fake := newFakeTus()
|
||||
fake.failFinalize = 1
|
||||
var heads atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodHead && heads.Add(1) <= 2 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
fake.ServeHTTP(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
uploaded, responded, supported, _, err := uploadVaultResumable(testVault(server.URL), "public", "device-key", fileName, "test", "primary")
|
||||
if uploaded || !responded || !supported || err == nil ||
|
||||
!strings.Contains(err.Error(), "unexpected status on PATCH: 502 Bad Gateway") ||
|
||||
strings.Contains(err.Error(), "503 Service Unavailable") {
|
||||
t.Fatalf("upload = %t/%t/%t, %v; want the latest finalization failure after EOF restart", uploaded, responded, supported, err)
|
||||
}
|
||||
patches, _ := fake.patchCounts()
|
||||
if heads.Load() != 4 || fake.createCount() != 1 || patches != 1 ||
|
||||
len(fake.requestsForMethod(http.MethodDelete)) != 1 {
|
||||
t.Fatalf("unexpected retry sequence: heads=%d, creates=%d, patches=%d", heads.Load(), fake.createCount(), patches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumableCreationKeepsLocationAfterBodyReadError(t *testing.T) {
|
||||
previousDelay := tusBackoffBaseDelay
|
||||
tusBackoffBaseDelay = 0
|
||||
t.Cleanup(func() { tusBackoffBaseDelay = previousDelay })
|
||||
const fileName = "1767355200123-device-key.mp4"
|
||||
payload := []byte("recording")
|
||||
withRecording(t, fileName, payload)
|
||||
fake := newFakeTus()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
w.Header().Set("Content-Length", "100")
|
||||
fake.ServeHTTP(w, r)
|
||||
_, _ = io.WriteString(w, "short")
|
||||
return
|
||||
}
|
||||
fake.ServeHTTP(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
uploaded, responded, supported, _, err := uploadVaultResumable(testVault(server.URL), "public", "device-key", fileName, "test", "primary")
|
||||
if !uploaded || !responded || !supported || err != nil {
|
||||
t.Fatalf("upload = %t/%t/%t, %v; want success despite the interrupted optional creation body", uploaded, responded, supported, err)
|
||||
}
|
||||
if fake.createCount() != 1 || fake.totalBytes() != int64(len(payload)) {
|
||||
t.Fatalf("created %d slots and uploaded %d bytes; want one reused slot and %d bytes", fake.createCount(), fake.totalBytes(), len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTusCreateStillRequiresLocationAfterBodyReadError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", "100")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = io.WriteString(w, "short")
|
||||
}))
|
||||
defer server.Close()
|
||||
location, status, err := tusCreate(server.Client(), server.URL, 8, "",
|
||||
func(http.Header, string) {}, "1767355200123-device-key.mp4")
|
||||
if location != "" || status != http.StatusCreated || err == nil ||
|
||||
!strings.Contains(err.Error(), "missing Location") {
|
||||
t.Fatalf("creation = %q, %d, %v; want missing Location error", location, status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTusCreateReportsBoundedServerErrors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"server error", "ERR_INTERNAL_SERVER_ERROR: A region must be set when sending requests to S3.\n", "A region must be set"},
|
||||
{"empty error", "", "500 Internal Server Error"},
|
||||
{"large error", strings.Repeat("x", 10000), "[truncated]"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(w, tc.body)
|
||||
}))
|
||||
defer server.Close()
|
||||
location, status, err := tusCreate(server.Client(), server.URL, 8, "",
|
||||
func(http.Header, string) {}, "1767355200123-device-key.mp4")
|
||||
if location != "" || status != http.StatusInternalServerError || err == nil ||
|
||||
!strings.Contains(err.Error(), tc.want) || len(err.Error()) > 4300 {
|
||||
t.Fatalf("creation = %q, %d, %v; want bounded server error", location, status, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumableCreateFailureRetainsServerCause(t *testing.T) {
|
||||
previousDelay := tusBackoffBaseDelay
|
||||
tusBackoffBaseDelay = 0
|
||||
t.Cleanup(func() { tusBackoffBaseDelay = previousDelay })
|
||||
for _, integration := range []string{"vault", "hub"} {
|
||||
t.Run(integration, func(t *testing.T) {
|
||||
const fileName = "1767355200123-device-key.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
if r.Method != http.MethodPost || r.URL.Path != tusUploadPath {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(w, "ERR_INTERNAL_SERVER_ERROR: A region must be set when sending requests to S3.")
|
||||
}))
|
||||
defer server.Close()
|
||||
var uploaded, responded, supported bool
|
||||
var err error
|
||||
if integration == "vault" {
|
||||
uploaded, responded, supported, _, err = uploadVaultResumable(testVault(server.URL), "public", "device-key", fileName, "test", "primary")
|
||||
} else {
|
||||
uploaded, responded, supported, _, err = uploadHubResumable(testHubConfig(server.URL), fileName, "test", "hub")
|
||||
}
|
||||
if uploaded || !responded || !supported || err == nil ||
|
||||
!strings.Contains(err.Error(), "resumable upload did not complete after retries") ||
|
||||
!strings.Contains(err.Error(), "A region must be set") {
|
||||
t.Fatalf("upload = %t/%t/%t, %v; want original server cause after retries", uploaded, responded, supported, err)
|
||||
}
|
||||
if requests.Load() != 4 {
|
||||
t.Fatalf("creation attempts = %d, want 4 without legacy fallback", requests.Load())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("data/recordings", fileName)); err != nil {
|
||||
t.Fatalf("failed upload must retain the local recording: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadKerberosVaultReadsDatedLocalRecordings(t *testing.T) {
|
||||
previousCount, previousTimeout := kstorageRetryCount, kstorageRetryTimeout
|
||||
t.Cleanup(func() { kstorageRetryCount, kstorageRetryTimeout = previousCount, previousTimeout })
|
||||
for _, transport := range []string{"resumable", "legacy"} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
const fileName = "1564859471123-device-key.mp4"
|
||||
payload := bytes.Repeat([]byte("video"), 1024)
|
||||
withRecording(t, fileName, payload)
|
||||
datedPath, err := utils.CreateRecordingPath("data/recordings", fileName, time.Unix(1564859471, 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(filepath.Join("data/recordings", fileName), datedPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withQueuedRecordingFPS(t, fileName, `{"filename":"1564859471123-device-key.mp4","device_key":"device-key","timestamp":1564859471123,"duration":27,"fps":25,"device_name":"camera-name","region_coordinates":"1-2-3-4","number_of_changes":"57"}`)
|
||||
fake := newFakeTus()
|
||||
var legacyBody []byte
|
||||
var legacyHeaders http.Header
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if transport == "resumable" {
|
||||
fake.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
body, readErr := io.ReadAll(r.Body)
|
||||
if readErr != nil {
|
||||
t.Error(readErr)
|
||||
}
|
||||
legacyBody = body
|
||||
legacyHeaders = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
if transport == "legacy" {
|
||||
t.Setenv("AGENT_DISABLE_RESUMABLE_UPLOAD", "true")
|
||||
} else {
|
||||
t.Setenv("AGENT_DISABLE_RESUMABLE_UPLOAD", "false")
|
||||
}
|
||||
kstorageRetryCount, kstorageRetryTimeout = 0, 0
|
||||
vault := testVault(server.URL)
|
||||
config := &models.Configuration{Config: models.Config{
|
||||
Key: "changed-config-key",
|
||||
KStorage: &vault,
|
||||
KStorageSecondary: &models.KStorage{},
|
||||
}}
|
||||
uploaded, configured, err := UploadKerberosVault(config, fileName)
|
||||
if err != nil || !uploaded || !configured {
|
||||
t.Fatalf("UploadKerberosVault() = (%v, %v, %v), want success", uploaded, configured, err)
|
||||
}
|
||||
if transport == "resumable" {
|
||||
if got := fake.totalBytes(); got != int64(len(payload)) {
|
||||
t.Fatalf("uploaded %d bytes, want %d", got, len(payload))
|
||||
}
|
||||
posts := fake.requestsForMethod(http.MethodPost)
|
||||
if len(posts) != 1 {
|
||||
t.Fatalf("got %d creation requests, want 1", len(posts))
|
||||
}
|
||||
metadata := decodeTusMetadata(posts[0].header.Get("Upload-Metadata"))
|
||||
if metadata["filename"] != fileName || metadata["directory"] != "dir" || metadata["timestamp"] != "1564859471123" ||
|
||||
metadata["device"] != "device-key" || metadata["device_name"] != "camera-name" ||
|
||||
metadata["region_coordinates"] != "1-2-3-4" || metadata["number_of_changes"] != "57" {
|
||||
t.Fatalf("unexpected upload metadata: %#v", metadata)
|
||||
}
|
||||
} else if !bytes.Equal(legacyBody, payload) ||
|
||||
legacyHeaders.Get("X-Kerberos-Storage-FileName") != fileName ||
|
||||
legacyHeaders.Get("X-Kerberos-Storage-Directory") != "dir" ||
|
||||
legacyHeaders.Get("X-Kerberos-Storage-Device") != "device-key" ||
|
||||
legacyHeaders.Get(recordingDeviceNameHeader) != "camera-name" ||
|
||||
legacyHeaders.Get(recordingRegionHeader) != "1-2-3-4" ||
|
||||
legacyHeaders.Get(recordingChangesHeader) != "57" ||
|
||||
legacyHeaders.Get(recordingTimestampHeader) != "1564859471123" {
|
||||
t.Fatalf("unexpected legacy upload: %d bytes, headers %#v", len(legacyBody), legacyHeaders)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fakeUpload tracks the state of a single resumable upload on the fake server.
|
||||
type fakeUpload struct {
|
||||
size int64
|
||||
|
||||
@@ -964,7 +964,7 @@ func GetDashboard(c *gin.Context, configDirectory string, configuration *models.
|
||||
// All days stored in this agent.
|
||||
days := []string{}
|
||||
latestEvents := []models.Media{}
|
||||
files, err := utils.ReadDirectory(recordingDirectory)
|
||||
files, err := utils.ReadRecordingDirectory(recordingDirectory)
|
||||
if err == nil {
|
||||
events := utils.GetSortedDirectory(files)
|
||||
|
||||
@@ -975,6 +975,8 @@ func GetDashboard(c *gin.Context, configDirectory string, configuration *models.
|
||||
var eventFilter models.EventFilter
|
||||
eventFilter.NumberOfElements = 5
|
||||
latestEvents = utils.GetMediaFormatted(events, recordingDirectory, configuration, eventFilter) // will get 5 latest recordings.
|
||||
} else {
|
||||
log.WithError(err).Error("Could not list recordings for Agent status")
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
@@ -1007,7 +1009,7 @@ func GetLatestEvents(c *gin.Context, configDirectory string, configuration *mode
|
||||
eventFilter.NumberOfElements = 10
|
||||
}
|
||||
recordingDirectory := configDirectory + "/data/recordings"
|
||||
files, err := utils.ReadDirectory(recordingDirectory)
|
||||
files, err := utils.ReadRecordingDirectory(recordingDirectory)
|
||||
if err == nil {
|
||||
events := utils.GetSortedDirectory(files)
|
||||
// We will get all recordings from the directory (as defined by the filter).
|
||||
@@ -1036,7 +1038,7 @@ func GetLatestEvents(c *gin.Context, configDirectory string, configuration *mode
|
||||
// @Success 200
|
||||
func GetDays(c *gin.Context, configDirectory string, configuration *models.Configuration, communication *models.Communication) {
|
||||
recordingDirectory := configDirectory + "/data/recordings"
|
||||
files, err := utils.ReadDirectory(recordingDirectory)
|
||||
files, err := utils.ReadRecordingDirectory(recordingDirectory)
|
||||
if err == nil {
|
||||
events := utils.GetSortedDirectory(files)
|
||||
days := utils.GetDays(events, recordingDirectory, configuration)
|
||||
|
||||
@@ -394,6 +394,9 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
|
||||
case "AGENT_CAPTURE_IPCAMERA_ONVIF_PASSWORD":
|
||||
configuration.Config.Capture.IPCamera.ONVIFPassword = value
|
||||
break
|
||||
case "AGENT_CAPTURE_IPCAMERA_ONVIF_CHANNEL":
|
||||
configuration.Config.Capture.IPCamera.ONVIFChannel, _ = strconv.Atoi(value)
|
||||
break
|
||||
|
||||
/* Recording mode */
|
||||
case "AGENT_CAPTURE_RECORDING":
|
||||
|
||||
@@ -12,6 +12,7 @@ type OnvifCredentials struct {
|
||||
ONVIFXAddr string `json:"onvif_xaddr,omitempty" bson:"onvif_xaddr"`
|
||||
ONVIFUsername string `json:"onvif_username,omitempty" bson:"onvif_username"`
|
||||
ONVIFPassword string `json:"onvif_password,omitempty" bson:"onvif_password"`
|
||||
ONVIFChannel int `json:"onvif_channel,omitempty" bson:"onvif_channel,omitempty"` // 1-based; 0 = first profile
|
||||
}
|
||||
|
||||
type CameraStreams struct {
|
||||
@@ -68,6 +69,11 @@ type OnvifZoom struct {
|
||||
Zoom float64 `json:"zoom,omitempty" bson:"zoom"`
|
||||
}
|
||||
|
||||
type OnvifFocus struct {
|
||||
OnvifCredentials OnvifCredentials `json:"onvif_credentials,omitempty" bson:"onvif_credentials"`
|
||||
Focus float64 `json:"focus,omitempty" bson:"focus"`
|
||||
}
|
||||
|
||||
type OnvifPreset struct {
|
||||
OnvifCredentials OnvifCredentials `json:"onvif_credentials,omitempty" bson:"onvif_credentials"`
|
||||
Preset string `json:"preset,omitempty" bson:"preset"`
|
||||
|
||||
@@ -105,6 +105,7 @@ type IPCamera struct {
|
||||
ONVIFXAddr string `json:"onvif_xaddr" bson:"onvif_xaddr"`
|
||||
ONVIFUsername string `json:"onvif_username" bson:"onvif_username"`
|
||||
ONVIFPassword string `json:"onvif_password" bson:"onvif_password"`
|
||||
ONVIFChannel int `json:"onvif_channel,omitempty" bson:"onvif_channel,omitempty"` // 1-based video input on multi-channel devices (encoders, NVRs); 0 = first profile
|
||||
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"`
|
||||
|
||||
@@ -12,6 +12,7 @@ type OnvifActionPTZ struct {
|
||||
Down int `json:"down" bson:"down"`
|
||||
Center int `json:"center" bson:"center"`
|
||||
Zoom float64 `json:"zoom" bson:"zoom"`
|
||||
Focus float64 `json:"focus" bson:"focus"`
|
||||
X float64 `json:"x" bson:"x"`
|
||||
Y float64 `json:"y" bson:"y"`
|
||||
Z float64 `json:"z" bson:"z"`
|
||||
|
||||
@@ -11,12 +11,15 @@ const RecordingUploadMetadataExtension = ".metadata"
|
||||
// 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"`
|
||||
Encrypted bool `json:"encrypted,omitempty"`
|
||||
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"`
|
||||
Encrypted bool `json:"encrypted,omitempty"`
|
||||
DeviceName string `json:"device_name,omitempty"`
|
||||
RegionCoordinates string `json:"region_coordinates,omitempty"`
|
||||
NumberOfChanges string `json:"number_of_changes,omitempty"`
|
||||
}
|
||||
|
||||
// RecordingUploadMetadataFileName returns the queue marker name associated
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/kerberos-io/onvif"
|
||||
"github.com/kerberos-io/onvif/media"
|
||||
xsdonvif "github.com/kerberos-io/onvif/xsd/onvif"
|
||||
)
|
||||
|
||||
// Multi-channel devices (encoders, NVRs) expose several media profiles per
|
||||
// video input, e.g. a Hanwha SPE-420 returns DefaultProfile-01-0..03-0 for
|
||||
// channel 1 and DefaultProfile-01-1..03-1 for channel 2. PTZ, presets and
|
||||
// focus must be addressed with a profile (or video source) of the channel the
|
||||
// camera is wired to, otherwise commands drive the wrong camera.
|
||||
//
|
||||
// A channel is 1-based, counted in the order the device first lists each video
|
||||
// source in GetProfiles. Channel 0 (unset) keeps the historic behaviour of
|
||||
// using the first profile, so single-sensor cameras need no configuration.
|
||||
|
||||
// GetTokenFromChannel returns the media profile token to use for PTZ commands
|
||||
// on the given 1-based channel.
|
||||
func GetTokenFromChannel(device *onvif.Device, channel int) (xsdonvif.ReferenceToken, error) {
|
||||
if channel <= 0 {
|
||||
return GetTokenFromProfile(device, 0)
|
||||
}
|
||||
profiles, err := getMediaProfiles(device)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
profile, err := selectChannelProfile(profiles, channel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return profile.Token, nil
|
||||
}
|
||||
|
||||
// GetVideoSourceTokenFromChannel returns the video source token of the given
|
||||
// 1-based channel, as used by the imaging (focus) service.
|
||||
func GetVideoSourceTokenFromChannel(device *onvif.Device, channel int) (xsdonvif.ReferenceToken, error) {
|
||||
if channel <= 0 {
|
||||
return GetVideoSourceTokenFromProfile(device, 0)
|
||||
}
|
||||
profiles, err := getMediaProfiles(device)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
profile, err := selectChannelProfile(profiles, channel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return *profile.VideoSourceConfiguration.SourceToken, nil
|
||||
}
|
||||
|
||||
func getMediaProfiles(device *onvif.Device) ([]xsdonvif.Profile, error) {
|
||||
resp, err := device.CallMethod(media.GetProfiles{})
|
||||
if err != nil {
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decodedXML, et, err := getXMLNode(string(b), "GetProfilesResponse")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var profilesResp media.GetProfilesResponse
|
||||
if err := decodedXML.DecodeElement(&profilesResp, et); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return profilesResp.Profiles, nil
|
||||
}
|
||||
|
||||
// selectChannelProfile groups profiles by video source and returns a profile
|
||||
// of the channel-th source (1-based). Within a channel it prefers the first
|
||||
// profile that has a PTZ configuration attached.
|
||||
func selectChannelProfile(profiles []xsdonvif.Profile, channel int) (xsdonvif.Profile, error) {
|
||||
if channel <= 0 {
|
||||
return xsdonvif.Profile{}, errors.New("onvif channel must be 1 or higher")
|
||||
}
|
||||
|
||||
var sources []xsdonvif.ReferenceToken
|
||||
bySource := map[xsdonvif.ReferenceToken][]xsdonvif.Profile{}
|
||||
for _, profile := range profiles {
|
||||
if profile.VideoSourceConfiguration == nil || profile.VideoSourceConfiguration.SourceToken == nil {
|
||||
continue
|
||||
}
|
||||
source := *profile.VideoSourceConfiguration.SourceToken
|
||||
if _, seen := bySource[source]; !seen {
|
||||
sources = append(sources, source)
|
||||
}
|
||||
bySource[source] = append(bySource[source], profile)
|
||||
}
|
||||
|
||||
if channel > len(sources) {
|
||||
return xsdonvif.Profile{}, fmt.Errorf("onvif channel %d not found: device exposes %d video source(s)", channel, len(sources))
|
||||
}
|
||||
|
||||
candidates := bySource[sources[channel-1]]
|
||||
for _, profile := range candidates {
|
||||
if profile.PTZConfiguration != nil {
|
||||
return profile, nil
|
||||
}
|
||||
}
|
||||
return candidates[0], nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
xsdonvif "github.com/kerberos-io/onvif/xsd/onvif"
|
||||
)
|
||||
|
||||
func testProfile(token string, source string, ptz bool) xsdonvif.Profile {
|
||||
profile := xsdonvif.Profile{Token: xsdonvif.ReferenceToken(token)}
|
||||
if source != "" {
|
||||
sourceToken := xsdonvif.ReferenceToken(source)
|
||||
profile.VideoSourceConfiguration = &xsdonvif.VideoSourceConfiguration{SourceToken: &sourceToken}
|
||||
}
|
||||
if ptz {
|
||||
profile.PTZConfiguration = &xsdonvif.PTZConfiguration{}
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
// spe420Profiles mirrors a Hanwha SPE-420: three profiles per channel, token
|
||||
// suffix -(N-1), listed grouped by profile type rather than by channel.
|
||||
func spe420Profiles() []xsdonvif.Profile {
|
||||
var profiles []xsdonvif.Profile
|
||||
for kind := 1; kind <= 3; kind++ {
|
||||
for ch := 0; ch < 4; ch++ {
|
||||
profiles = append(profiles, testProfile(
|
||||
fmt.Sprintf("DefaultProfile-0%d-%d", kind, ch),
|
||||
fmt.Sprintf("VideoSourceToken-%d", ch),
|
||||
true,
|
||||
))
|
||||
}
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
func TestSelectChannelProfileMapsEncoderChannels(t *testing.T) {
|
||||
profiles := spe420Profiles()
|
||||
for channel := 1; channel <= 4; channel++ {
|
||||
profile, err := selectChannelProfile(profiles, channel)
|
||||
if err != nil {
|
||||
t.Fatalf("channel %d: %v", channel, err)
|
||||
}
|
||||
want := fmt.Sprintf("DefaultProfile-01-%d", channel-1)
|
||||
if string(profile.Token) != want {
|
||||
t.Fatalf("channel %d: got %s, want %s", channel, profile.Token, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectChannelProfilePrefersProfileWithPTZ(t *testing.T) {
|
||||
profiles := []xsdonvif.Profile{
|
||||
testProfile("mjpeg", "src-0", false),
|
||||
testProfile("main", "src-0", true),
|
||||
}
|
||||
profile, err := selectChannelProfile(profiles, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if profile.Token != "main" {
|
||||
t.Fatalf("got %s, want main", profile.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectChannelProfileFallsBackToFirstProfileOfChannel(t *testing.T) {
|
||||
profiles := []xsdonvif.Profile{
|
||||
testProfile("a", "src-0", false),
|
||||
testProfile("b", "src-1", false),
|
||||
testProfile("c", "src-1", false),
|
||||
}
|
||||
profile, err := selectChannelProfile(profiles, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if profile.Token != "b" {
|
||||
t.Fatalf("got %s, want b", profile.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectChannelProfileSkipsProfilesWithoutVideoSource(t *testing.T) {
|
||||
profiles := []xsdonvif.Profile{
|
||||
testProfile("audio-only", "", false),
|
||||
testProfile("video", "src-0", false),
|
||||
}
|
||||
profile, err := selectChannelProfile(profiles, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if profile.Token != "video" {
|
||||
t.Fatalf("got %s, want video", profile.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectChannelProfileRejectsUnknownChannel(t *testing.T) {
|
||||
if _, err := selectChannelProfile(spe420Profiles(), 5); err == nil {
|
||||
t.Fatal("expected error for channel beyond the device's video sources")
|
||||
}
|
||||
if _, err := selectChannelProfile(spe420Profiles(), 0); err == nil {
|
||||
t.Fatal("expected error for channel 0")
|
||||
}
|
||||
if _, err := selectChannelProfile(nil, 1); err == nil {
|
||||
t.Fatal("expected error when the device has no profiles")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/onvif"
|
||||
"github.com/kerberos-io/onvif/imaging"
|
||||
"github.com/kerberos-io/onvif/media"
|
||||
xsdonvif "github.com/kerberos-io/onvif/xsd/onvif"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Focus control goes through the ONVIF imaging service. The request types of
|
||||
// the onvif library serialise every field (all three move types, every imaging
|
||||
// setting), which cameras reject or which would overwrite unrelated imaging
|
||||
// settings. The types below only carry the fields we want to send. Schema
|
||||
// elements use the "onvif" prefix, which the library declares on the envelope
|
||||
// as http://www.onvif.org/ver10/schema.
|
||||
|
||||
const (
|
||||
focusModeAuto = "AUTO"
|
||||
focusModeManual = "MANUAL"
|
||||
|
||||
// Same move duration as ContinuousZoom.
|
||||
continuousFocusDuration = 500 * time.Millisecond
|
||||
// Step for cameras that only support relative focus moves.
|
||||
relativeFocusDistance = 0.1
|
||||
// Step at full speed for cameras that only support absolute focus moves
|
||||
// (e.g. Axis); scaled by the requested speed.
|
||||
absoluteFocusStep = 0.1
|
||||
// Time to let the camera settle in MANUAL before re-enabling AUTO.
|
||||
autoFocusRetriggerDelay = 300 * time.Millisecond
|
||||
)
|
||||
|
||||
type imagingMoveRequest struct {
|
||||
XMLName xml.Name `xml:"timg:Move"`
|
||||
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
|
||||
Focus focusMove `xml:"timg:Focus"`
|
||||
}
|
||||
|
||||
type focusMove struct {
|
||||
Absolute *absoluteFocusMove `xml:"onvif:Absolute,omitempty"`
|
||||
Relative *relativeFocusMove `xml:"onvif:Relative,omitempty"`
|
||||
Continuous *continuousFocusMove `xml:"onvif:Continuous,omitempty"`
|
||||
}
|
||||
|
||||
type absoluteFocusMove struct {
|
||||
Position float64 `xml:"onvif:Position"`
|
||||
}
|
||||
|
||||
type relativeFocusMove struct {
|
||||
Distance float64 `xml:"onvif:Distance"`
|
||||
Speed float64 `xml:"onvif:Speed,omitempty"`
|
||||
}
|
||||
|
||||
type continuousFocusMove struct {
|
||||
Speed float64 `xml:"onvif:Speed"`
|
||||
}
|
||||
|
||||
type imagingStopRequest struct {
|
||||
XMLName xml.Name `xml:"timg:Stop"`
|
||||
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
|
||||
}
|
||||
|
||||
type imagingGetSettingsRequest struct {
|
||||
XMLName xml.Name `xml:"timg:GetImagingSettings"`
|
||||
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
|
||||
}
|
||||
|
||||
type imagingGetSettingsResponse struct {
|
||||
ImagingSettings struct {
|
||||
Brightness *float64 `xml:"Brightness"`
|
||||
Focus struct {
|
||||
AutoFocusMode string `xml:"AutoFocusMode"`
|
||||
} `xml:"Focus"`
|
||||
} `xml:"ImagingSettings"`
|
||||
}
|
||||
|
||||
type imagingSetFocusModeRequest struct {
|
||||
XMLName xml.Name `xml:"timg:SetImagingSettings"`
|
||||
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
|
||||
ImagingSettings struct {
|
||||
Brightness *float64 `xml:"onvif:Brightness,omitempty"`
|
||||
Focus struct {
|
||||
AutoFocusMode string `xml:"onvif:AutoFocusMode"`
|
||||
} `xml:"onvif:Focus"`
|
||||
} `xml:"timg:ImagingSettings"`
|
||||
}
|
||||
|
||||
type imagingGetOptionsRequest struct {
|
||||
XMLName xml.Name `xml:"timg:GetOptions"`
|
||||
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
|
||||
}
|
||||
|
||||
type imagingGetOptionsResponse struct {
|
||||
ImagingOptions struct {
|
||||
Focus struct {
|
||||
AutoFocusModes []string `xml:"AutoFocusModes"`
|
||||
} `xml:"Focus"`
|
||||
} `xml:"ImagingOptions"`
|
||||
}
|
||||
|
||||
type imagingGetMoveOptionsRequest struct {
|
||||
XMLName xml.Name `xml:"timg:GetMoveOptions"`
|
||||
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
|
||||
}
|
||||
|
||||
type imagingGetMoveOptionsResponse struct {
|
||||
MoveOptions struct {
|
||||
Absolute *struct {
|
||||
Position struct {
|
||||
Min float64 `xml:"Min"`
|
||||
Max float64 `xml:"Max"`
|
||||
} `xml:"Position"`
|
||||
} `xml:"Absolute"`
|
||||
Relative *struct{} `xml:"Relative"`
|
||||
Continuous *struct{} `xml:"Continuous"`
|
||||
} `xml:"MoveOptions"`
|
||||
}
|
||||
|
||||
type imagingGetStatusRequest struct {
|
||||
XMLName xml.Name `xml:"timg:GetStatus"`
|
||||
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
|
||||
}
|
||||
|
||||
type imagingGetStatusResponse struct {
|
||||
Status struct {
|
||||
FocusStatus20 *struct {
|
||||
Position *float64 `xml:"Position"`
|
||||
} `xml:"FocusStatus20"`
|
||||
FocusStatus *struct {
|
||||
Position *float64 `xml:"Position"`
|
||||
} `xml:"FocusStatus"`
|
||||
} `xml:"Status"`
|
||||
}
|
||||
|
||||
// focusMoveOptions lists the focus move types a camera supports.
|
||||
type focusMoveOptions struct {
|
||||
Continuous bool
|
||||
Relative bool
|
||||
Absolute bool
|
||||
AbsoluteMin float64
|
||||
AbsoluteMax float64
|
||||
}
|
||||
|
||||
// allFocusMoveOptions is used when a camera does not report its move options:
|
||||
// every move type is tried in turn.
|
||||
var allFocusMoveOptions = focusMoveOptions{Continuous: true, Relative: true, Absolute: true, AbsoluteMin: 0, AbsoluteMax: 1}
|
||||
|
||||
// focusController abstracts the imaging calls so the focus logic can be tested
|
||||
// without a camera.
|
||||
type focusController interface {
|
||||
FocusMode() (string, error)
|
||||
FocusModes() ([]string, error)
|
||||
SetFocusMode(mode string) error
|
||||
MoveOptions() (focusMoveOptions, error)
|
||||
FocusPosition() (float64, error)
|
||||
MoveFocus(move focusMove) error
|
||||
StopFocus() error
|
||||
}
|
||||
|
||||
type onvifFocusClient struct {
|
||||
device *onvif.Device
|
||||
endpoint string
|
||||
token xsdonvif.ReferenceToken
|
||||
}
|
||||
|
||||
func newONVIFFocusClient(device *onvif.Device, channel int) (*onvifFocusClient, error) {
|
||||
endpoint, err := device.GetEndpointByRequestStruct(&imaging.Move{})
|
||||
if err != nil {
|
||||
return nil, errors.New("camera does not expose the ONVIF imaging service: " + err.Error())
|
||||
}
|
||||
token, err := GetVideoSourceTokenFromChannel(device, channel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &onvifFocusClient{device: device, endpoint: endpoint, token: token}, nil
|
||||
}
|
||||
|
||||
var soapFaultReason = regexp.MustCompile(`<(?:[\w.-]+:)?Text[^>]*>([^<]+)</`)
|
||||
|
||||
// call sends an imaging request and returns the response body. A rejected
|
||||
// request returns an error carrying the camera's SOAP fault reason.
|
||||
func (client *onvifFocusClient) call(operation string, request interface{}) ([]byte, error) {
|
||||
body, err := xml.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.device.SendSoap(client.endpoint, string(body))
|
||||
if resp == nil {
|
||||
if err == nil {
|
||||
err = errors.New("no response")
|
||||
}
|
||||
return nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
b, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
reason := http.StatusText(resp.StatusCode)
|
||||
if m := soapFaultReason.FindSubmatch(b); m != nil {
|
||||
reason = string(m[1])
|
||||
}
|
||||
return nil, fmt.Errorf("%s: camera returned HTTP %d: %s", operation, resp.StatusCode, reason)
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("%s: %w", operation, readErr)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (client *onvifFocusClient) query(operation string, request interface{}, responseNode string, response interface{}) error {
|
||||
b, err := client.call(operation, request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decodedXML, et, err := getXMLNode(string(b), responseNode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
if err := decodedXML.DecodeElement(response, et); err != nil {
|
||||
return fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *onvifFocusClient) imagingSettings() (imagingGetSettingsResponse, error) {
|
||||
var settings imagingGetSettingsResponse
|
||||
err := client.query("get_imaging_settings", imagingGetSettingsRequest{VideoSourceToken: client.token}, "GetImagingSettingsResponse", &settings)
|
||||
return settings, err
|
||||
}
|
||||
|
||||
func (client *onvifFocusClient) FocusMode() (string, error) {
|
||||
settings, err := client.imagingSettings()
|
||||
return settings.ImagingSettings.Focus.AutoFocusMode, err
|
||||
}
|
||||
|
||||
func (client *onvifFocusClient) SetFocusMode(mode string) error {
|
||||
request := imagingSetFocusModeRequest{VideoSourceToken: client.token}
|
||||
request.ImagingSettings.Focus.AutoFocusMode = mode
|
||||
_, err := client.call("set_focus_mode", request)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Some cameras (seen on Axis) fail on a SetImagingSettings that only
|
||||
// carries Focus. Retry with the current brightness, which leaves it as is.
|
||||
settings, settingsErr := client.imagingSettings()
|
||||
if settingsErr != nil || settings.ImagingSettings.Brightness == nil {
|
||||
return err
|
||||
}
|
||||
request.ImagingSettings.Brightness = settings.ImagingSettings.Brightness
|
||||
if _, retryErr := client.call("set_focus_mode", request); retryErr != nil {
|
||||
return errors.Join(err, retryErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *onvifFocusClient) FocusModes() ([]string, error) {
|
||||
var response imagingGetOptionsResponse
|
||||
if err := client.query("get_imaging_options", imagingGetOptionsRequest{VideoSourceToken: client.token}, "GetOptionsResponse", &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.ImagingOptions.Focus.AutoFocusModes, nil
|
||||
}
|
||||
|
||||
func (client *onvifFocusClient) MoveOptions() (focusMoveOptions, error) {
|
||||
var response imagingGetMoveOptionsResponse
|
||||
if err := client.query("get_focus_move_options", imagingGetMoveOptionsRequest{VideoSourceToken: client.token}, "GetMoveOptionsResponse", &response); err != nil {
|
||||
return focusMoveOptions{}, err
|
||||
}
|
||||
moveOptions := response.MoveOptions
|
||||
options := focusMoveOptions{
|
||||
Continuous: moveOptions.Continuous != nil,
|
||||
Relative: moveOptions.Relative != nil,
|
||||
Absolute: moveOptions.Absolute != nil,
|
||||
AbsoluteMin: 0,
|
||||
AbsoluteMax: 1,
|
||||
}
|
||||
if moveOptions.Absolute != nil && moveOptions.Absolute.Position.Max > moveOptions.Absolute.Position.Min {
|
||||
options.AbsoluteMin = moveOptions.Absolute.Position.Min
|
||||
options.AbsoluteMax = moveOptions.Absolute.Position.Max
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func (client *onvifFocusClient) FocusPosition() (float64, error) {
|
||||
var response imagingGetStatusResponse
|
||||
if err := client.query("get_focus_status", imagingGetStatusRequest{VideoSourceToken: client.token}, "GetStatusResponse", &response); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
status := response.Status
|
||||
if status.FocusStatus20 != nil && status.FocusStatus20.Position != nil {
|
||||
return *status.FocusStatus20.Position, nil
|
||||
}
|
||||
if status.FocusStatus != nil && status.FocusStatus.Position != nil {
|
||||
return *status.FocusStatus.Position, nil
|
||||
}
|
||||
return 0, errors.New("get_focus_status: camera did not report a focus position")
|
||||
}
|
||||
|
||||
func (client *onvifFocusClient) MoveFocus(move focusMove) error {
|
||||
_, err := client.call("focus_move", imagingMoveRequest{VideoSourceToken: client.token, Focus: move})
|
||||
return err
|
||||
}
|
||||
|
||||
func (client *onvifFocusClient) StopFocus() error {
|
||||
_, err := client.call("focus_stop", imagingStopRequest{VideoSourceToken: client.token})
|
||||
return err
|
||||
}
|
||||
|
||||
// ContinuousFocus moves the focus motor a short step, the focus equivalent of
|
||||
// ContinuousZoom. Speed is in [-1, 1]: positive focuses near, negative focuses
|
||||
// far. Cameras with continuous focus move for a moment and stop; cameras with
|
||||
// only relative or absolute focus (e.g. Axis) step by an amount scaled to the
|
||||
// speed. The camera is switched to MANUAL focus first, because cameras ignore
|
||||
// manual moves while autofocus is active; use AutoFocus to hand control back.
|
||||
func ContinuousFocus(device *onvif.Device, channel int, speed float64) error {
|
||||
client, err := newONVIFFocusClient(device, channel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return continuousFocus(client, speed, continuousFocusDuration)
|
||||
}
|
||||
|
||||
// AutoFocus triggers the camera's native ONVIF autofocus. Setting AUTO while
|
||||
// already in AUTO is a no-op on most cameras, so we toggle through MANUAL to
|
||||
// force a fresh focus cycle.
|
||||
func AutoFocus(device *onvif.Device, channel int) error {
|
||||
client, err := newONVIFFocusClient(device, channel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return autoFocus(client, autoFocusRetriggerDelay)
|
||||
}
|
||||
|
||||
func continuousFocus(client focusController, speed float64, duration time.Duration) error {
|
||||
speed = math.Max(-1, math.Min(1, speed))
|
||||
if speed == 0 {
|
||||
return client.StopFocus()
|
||||
}
|
||||
|
||||
if err := ensureFocusMode(client, focusModeManual); err != nil {
|
||||
// Some cameras have no focus mode setting but still accept moves.
|
||||
log.Debug("onvif.continuousFocus(): could not switch to manual focus: " + err.Error())
|
||||
}
|
||||
|
||||
options, err := client.MoveOptions()
|
||||
if err != nil {
|
||||
log.Debug("onvif.continuousFocus(): could not read focus move options, trying all move types: " + err.Error())
|
||||
options = allFocusMoveOptions
|
||||
}
|
||||
|
||||
var moveErrs []error
|
||||
if options.Continuous {
|
||||
err := client.MoveFocus(focusMove{Continuous: &continuousFocusMove{Speed: speed}})
|
||||
if err == nil {
|
||||
time.Sleep(duration)
|
||||
return client.StopFocus()
|
||||
}
|
||||
moveErrs = append(moveErrs, err)
|
||||
}
|
||||
if options.Relative {
|
||||
// A single relative step needs no stop.
|
||||
err := client.MoveFocus(focusMove{Relative: &relativeFocusMove{
|
||||
Distance: math.Copysign(relativeFocusDistance, speed),
|
||||
Speed: math.Abs(speed),
|
||||
}})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
moveErrs = append(moveErrs, err)
|
||||
}
|
||||
if options.Absolute {
|
||||
position, err := client.FocusPosition()
|
||||
if err == nil {
|
||||
target := math.Max(options.AbsoluteMin, math.Min(options.AbsoluteMax, position+speed*absoluteFocusStep))
|
||||
err = client.MoveFocus(focusMove{Absolute: &absoluteFocusMove{Position: target}})
|
||||
}
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
moveErrs = append(moveErrs, err)
|
||||
}
|
||||
|
||||
if len(moveErrs) == 0 {
|
||||
return errors.New("camera does not support any focus move")
|
||||
}
|
||||
return errors.Join(moveErrs...)
|
||||
}
|
||||
|
||||
func ensureFocusMode(client focusController, mode string) error {
|
||||
current, err := client.FocusMode()
|
||||
if err == nil && current == mode {
|
||||
return nil
|
||||
}
|
||||
return client.SetFocusMode(mode)
|
||||
}
|
||||
|
||||
var errNoNativeAutoFocus = errors.New("camera does not support native ONVIF autofocus")
|
||||
|
||||
func autoFocus(client focusController, delay time.Duration) error {
|
||||
// Some cameras (seen on Dahua) only advertise MANUAL, yet accept a switch
|
||||
// to AUTO and silently ignore it.
|
||||
if modes, err := client.FocusModes(); err == nil && !slices.Contains(modes, focusModeAuto) {
|
||||
return errNoNativeAutoFocus
|
||||
}
|
||||
|
||||
mode, err := client.FocusMode()
|
||||
if err == nil && mode == focusModeAuto {
|
||||
if err := client.SetFocusMode(focusModeManual); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(delay)
|
||||
}
|
||||
if err := client.SetFocusMode(focusModeAuto); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if mode, err := client.FocusMode(); err == nil && mode != focusModeAuto {
|
||||
return fmt.Errorf("%w: camera accepted AUTO but reports %s", errNoNativeAutoFocus, mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetVideoSourceTokenFromProfile returns the video source token of a media
|
||||
// profile; the imaging service is addressed by video source, not by profile.
|
||||
func GetVideoSourceTokenFromProfile(device *onvif.Device, profileId int) (xsdonvif.ReferenceToken, error) {
|
||||
var sourceToken xsdonvif.ReferenceToken
|
||||
|
||||
resp, err := device.CallMethod(media.GetProfiles{})
|
||||
if err != nil {
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
return sourceToken, err
|
||||
}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return sourceToken, err
|
||||
}
|
||||
|
||||
decodedXML, et, err := getXMLNode(string(b), "GetProfilesResponse")
|
||||
if err != nil {
|
||||
return sourceToken, err
|
||||
}
|
||||
var mProfilesResp media.GetProfilesResponse
|
||||
if err := decodedXML.DecodeElement(&mProfilesResp, et); err != nil {
|
||||
return sourceToken, err
|
||||
}
|
||||
|
||||
for i, profile := range mProfilesResp.Profiles {
|
||||
if i == profileId && profile.VideoSourceConfiguration != nil && profile.VideoSourceConfiguration.SourceToken != nil {
|
||||
return *profile.VideoSourceConfiguration.SourceToken, nil
|
||||
}
|
||||
}
|
||||
return sourceToken, errors.New("no video source token found for profile")
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeFocusClient struct {
|
||||
mode string
|
||||
modeErr error
|
||||
focusModes []string
|
||||
focusModesErr error
|
||||
ignoreAuto bool
|
||||
setModeErr error
|
||||
options *focusMoveOptions
|
||||
optionsErr error
|
||||
position float64
|
||||
positionErr error
|
||||
continuousErr error
|
||||
relativeErr error
|
||||
absoluteErr error
|
||||
modes []string
|
||||
moves []focusMove
|
||||
stopCalls int
|
||||
}
|
||||
|
||||
func (client *fakeFocusClient) FocusMode() (string, error) {
|
||||
return client.mode, client.modeErr
|
||||
}
|
||||
|
||||
func (client *fakeFocusClient) FocusModes() ([]string, error) {
|
||||
if client.focusModes == nil && client.focusModesErr == nil {
|
||||
return []string{focusModeManual, focusModeAuto}, nil
|
||||
}
|
||||
return client.focusModes, client.focusModesErr
|
||||
}
|
||||
|
||||
func (client *fakeFocusClient) SetFocusMode(mode string) error {
|
||||
client.modes = append(client.modes, mode)
|
||||
if client.setModeErr != nil {
|
||||
return client.setModeErr
|
||||
}
|
||||
if mode == focusModeAuto && client.ignoreAuto {
|
||||
return nil
|
||||
}
|
||||
client.mode = mode
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *fakeFocusClient) MoveOptions() (focusMoveOptions, error) {
|
||||
if client.optionsErr != nil {
|
||||
return focusMoveOptions{}, client.optionsErr
|
||||
}
|
||||
if client.options == nil {
|
||||
return allFocusMoveOptions, nil
|
||||
}
|
||||
return *client.options, nil
|
||||
}
|
||||
|
||||
func (client *fakeFocusClient) FocusPosition() (float64, error) {
|
||||
return client.position, client.positionErr
|
||||
}
|
||||
|
||||
func (client *fakeFocusClient) MoveFocus(move focusMove) error {
|
||||
client.moves = append(client.moves, move)
|
||||
switch {
|
||||
case move.Continuous != nil:
|
||||
return client.continuousErr
|
||||
case move.Relative != nil:
|
||||
return client.relativeErr
|
||||
default:
|
||||
return client.absoluteErr
|
||||
}
|
||||
}
|
||||
|
||||
func (client *fakeFocusClient) StopFocus() error {
|
||||
client.stopCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestContinuousFocusSwitchesToManualMovesAndStops(t *testing.T) {
|
||||
client := &fakeFocusClient{mode: focusModeAuto}
|
||||
|
||||
if err := continuousFocus(client, 0.5, 0); err != nil {
|
||||
t.Fatalf("continuousFocus() error = %v", err)
|
||||
}
|
||||
|
||||
if len(client.modes) != 1 || client.modes[0] != focusModeManual {
|
||||
t.Fatalf("modes = %v, want [MANUAL]", client.modes)
|
||||
}
|
||||
if len(client.moves) != 1 || client.moves[0].Continuous == nil || client.moves[0].Continuous.Speed != 0.5 {
|
||||
t.Fatalf("moves = %+v, want one continuous move at 0.5", client.moves)
|
||||
}
|
||||
if client.stopCalls != 1 {
|
||||
t.Fatalf("stopCalls = %d, want 1", client.stopCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusKeepsManualMode(t *testing.T) {
|
||||
client := &fakeFocusClient{mode: focusModeManual}
|
||||
|
||||
if err := continuousFocus(client, 0.5, 0); err != nil {
|
||||
t.Fatalf("continuousFocus() error = %v", err)
|
||||
}
|
||||
if len(client.modes) != 0 {
|
||||
t.Fatalf("modes = %v, want no mode change when already MANUAL", client.modes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusClampsSpeed(t *testing.T) {
|
||||
client := &fakeFocusClient{}
|
||||
|
||||
if err := continuousFocus(client, -3, 0); err != nil {
|
||||
t.Fatalf("continuousFocus() error = %v", err)
|
||||
}
|
||||
if got := client.moves[0].Continuous.Speed; got != -1 {
|
||||
t.Fatalf("speed = %v, want -1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusIgnoresUnsupportedFocusMode(t *testing.T) {
|
||||
client := &fakeFocusClient{setModeErr: errors.New("not supported")}
|
||||
|
||||
if err := continuousFocus(client, 0.5, 0); err != nil {
|
||||
t.Fatalf("continuousFocus() error = %v", err)
|
||||
}
|
||||
if len(client.moves) != 1 || client.stopCalls != 1 {
|
||||
t.Fatalf("moves = %d stopCalls = %d, want 1 and 1", len(client.moves), client.stopCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusFallsBackToRelative(t *testing.T) {
|
||||
client := &fakeFocusClient{continuousErr: errors.New("continuous not supported")}
|
||||
|
||||
if err := continuousFocus(client, -0.4, 0); err != nil {
|
||||
t.Fatalf("continuousFocus() error = %v", err)
|
||||
}
|
||||
if len(client.moves) != 2 || client.moves[1].Relative == nil {
|
||||
t.Fatalf("moves = %+v, want continuous then relative", client.moves)
|
||||
}
|
||||
relative := client.moves[1].Relative
|
||||
if relative.Distance != -relativeFocusDistance || relative.Speed != 0.4 {
|
||||
t.Fatalf("relative = %+v, want distance %v speed 0.4", relative, -relativeFocusDistance)
|
||||
}
|
||||
if client.stopCalls != 0 {
|
||||
t.Fatalf("stopCalls = %d, want 0 for a relative move", client.stopCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusUsesAbsoluteWhenOnlyAbsoluteSupported(t *testing.T) {
|
||||
// Axis cameras report only absolute focus moves.
|
||||
client := &fakeFocusClient{
|
||||
mode: focusModeManual,
|
||||
options: &focusMoveOptions{Absolute: true, AbsoluteMin: 0, AbsoluteMax: 1},
|
||||
position: 0.48,
|
||||
}
|
||||
|
||||
if err := continuousFocus(client, 0.5, 0); err != nil {
|
||||
t.Fatalf("continuousFocus() error = %v", err)
|
||||
}
|
||||
if len(client.moves) != 1 || client.moves[0].Absolute == nil {
|
||||
t.Fatalf("moves = %+v, want a single absolute move", client.moves)
|
||||
}
|
||||
if got, want := client.moves[0].Absolute.Position, 0.48+0.5*absoluteFocusStep; math.Abs(got-want) > 1e-9 {
|
||||
t.Fatalf("position = %v, want %v", got, want)
|
||||
}
|
||||
if client.stopCalls != 0 {
|
||||
t.Fatalf("stopCalls = %d, want 0 for an absolute move", client.stopCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusClampsAbsoluteToRange(t *testing.T) {
|
||||
client := &fakeFocusClient{
|
||||
options: &focusMoveOptions{Absolute: true, AbsoluteMin: 0, AbsoluteMax: 1},
|
||||
position: 0.02,
|
||||
}
|
||||
|
||||
if err := continuousFocus(client, -1, 0); err != nil {
|
||||
t.Fatalf("continuousFocus() error = %v", err)
|
||||
}
|
||||
if got := client.moves[0].Absolute.Position; got != 0 {
|
||||
t.Fatalf("position = %v, want clamped to 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusTriesAllMovesWithoutOptions(t *testing.T) {
|
||||
client := &fakeFocusClient{
|
||||
optionsErr: errors.New("no move options"),
|
||||
continuousErr: errors.New("continuous not supported"),
|
||||
relativeErr: errors.New("relative not supported"),
|
||||
position: 0.5,
|
||||
}
|
||||
|
||||
if err := continuousFocus(client, 0.5, 0); err != nil {
|
||||
t.Fatalf("continuousFocus() error = %v", err)
|
||||
}
|
||||
if len(client.moves) != 3 || client.moves[2].Absolute == nil {
|
||||
t.Fatalf("moves = %+v, want continuous, relative then absolute", client.moves)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusReturnsErrorWhenAllMovesFail(t *testing.T) {
|
||||
client := &fakeFocusClient{
|
||||
continuousErr: errors.New("continuous not supported"),
|
||||
relativeErr: errors.New("relative not supported"),
|
||||
positionErr: errors.New("no position"),
|
||||
}
|
||||
|
||||
err := continuousFocus(client, 0.5, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "continuous") || !strings.Contains(err.Error(), "relative") || !strings.Contains(err.Error(), "position") {
|
||||
t.Fatalf("continuousFocus() error = %v, want every move error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusReturnsErrorWhenNoMoveSupported(t *testing.T) {
|
||||
client := &fakeFocusClient{options: &focusMoveOptions{}}
|
||||
|
||||
if err := continuousFocus(client, 0.5, 0); err == nil {
|
||||
t.Fatal("continuousFocus() error = nil, want an error")
|
||||
}
|
||||
if len(client.moves) != 0 {
|
||||
t.Fatalf("moves = %+v, want none", client.moves)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuousFocusZeroSpeedStops(t *testing.T) {
|
||||
client := &fakeFocusClient{}
|
||||
|
||||
if err := continuousFocus(client, 0, 0); err != nil {
|
||||
t.Fatalf("continuousFocus() error = %v", err)
|
||||
}
|
||||
if len(client.moves) != 0 || len(client.modes) != 0 || client.stopCalls != 1 {
|
||||
t.Fatalf("moves = %d modes = %v stopCalls = %d, want only a stop", len(client.moves), client.modes, client.stopCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoFocusRetriggersWhenAlreadyAuto(t *testing.T) {
|
||||
client := &fakeFocusClient{mode: focusModeAuto}
|
||||
|
||||
if err := autoFocus(client, 0); err != nil {
|
||||
t.Fatalf("autoFocus() error = %v", err)
|
||||
}
|
||||
if strings.Join(client.modes, ",") != "MANUAL,AUTO" {
|
||||
t.Fatalf("modes = %v, want [MANUAL AUTO]", client.modes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoFocusFromManual(t *testing.T) {
|
||||
client := &fakeFocusClient{mode: focusModeManual}
|
||||
|
||||
if err := autoFocus(client, 0); err != nil {
|
||||
t.Fatalf("autoFocus() error = %v", err)
|
||||
}
|
||||
if strings.Join(client.modes, ",") != "AUTO" {
|
||||
t.Fatalf("modes = %v, want [AUTO]", client.modes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoFocusWhenModeUnreadable(t *testing.T) {
|
||||
client := &fakeFocusClient{modeErr: errors.New("unreadable")}
|
||||
|
||||
if err := autoFocus(client, 0); err != nil {
|
||||
t.Fatalf("autoFocus() error = %v", err)
|
||||
}
|
||||
if strings.Join(client.modes, ",") != "AUTO" {
|
||||
t.Fatalf("modes = %v, want [AUTO]", client.modes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoFocusRejectsCameraWithoutAutoMode(t *testing.T) {
|
||||
// Dahua cameras advertise only MANUAL.
|
||||
client := &fakeFocusClient{mode: focusModeManual, focusModes: []string{focusModeManual}}
|
||||
|
||||
if err := autoFocus(client, 0); !errors.Is(err, errNoNativeAutoFocus) {
|
||||
t.Fatalf("autoFocus() error = %v, want errNoNativeAutoFocus", err)
|
||||
}
|
||||
if len(client.modes) != 0 {
|
||||
t.Fatalf("modes = %v, want no mode change", client.modes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoFocusDetectsIgnoredAutoMode(t *testing.T) {
|
||||
client := &fakeFocusClient{mode: focusModeManual, ignoreAuto: true}
|
||||
|
||||
if err := autoFocus(client, 0); !errors.Is(err, errNoNativeAutoFocus) {
|
||||
t.Fatalf("autoFocus() error = %v, want errNoNativeAutoFocus", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoFocusWhenFocusModesUnreadable(t *testing.T) {
|
||||
client := &fakeFocusClient{mode: focusModeManual, focusModesErr: errors.New("no options")}
|
||||
|
||||
if err := autoFocus(client, 0); err != nil {
|
||||
t.Fatalf("autoFocus() error = %v", err)
|
||||
}
|
||||
if strings.Join(client.modes, ",") != "AUTO" {
|
||||
t.Fatalf("modes = %v, want [AUTO]", client.modes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImagingRequestsOnlyCarryFocusFields(t *testing.T) {
|
||||
move, err := xml.Marshal(imagingMoveRequest{
|
||||
VideoSourceToken: "source",
|
||||
Focus: focusMove{Continuous: &continuousFocusMove{Speed: 0.5}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal move: %v", err)
|
||||
}
|
||||
wantMove := "<timg:Move><timg:VideoSourceToken>source</timg:VideoSourceToken><timg:Focus><onvif:Continuous><onvif:Speed>0.5</onvif:Speed></onvif:Continuous></timg:Focus></timg:Move>"
|
||||
if string(move) != wantMove {
|
||||
t.Fatalf("move XML = %s\nwant %s", move, wantMove)
|
||||
}
|
||||
|
||||
absolute, err := xml.Marshal(imagingMoveRequest{
|
||||
VideoSourceToken: "source",
|
||||
Focus: focusMove{Absolute: &absoluteFocusMove{Position: 0.53}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal absolute move: %v", err)
|
||||
}
|
||||
wantAbsolute := "<timg:Move><timg:VideoSourceToken>source</timg:VideoSourceToken><timg:Focus><onvif:Absolute><onvif:Position>0.53</onvif:Position></onvif:Absolute></timg:Focus></timg:Move>"
|
||||
if string(absolute) != wantAbsolute {
|
||||
t.Fatalf("absolute move XML = %s\nwant %s", absolute, wantAbsolute)
|
||||
}
|
||||
|
||||
request := imagingSetFocusModeRequest{VideoSourceToken: "source"}
|
||||
request.ImagingSettings.Focus.AutoFocusMode = focusModeManual
|
||||
settings, err := xml.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal settings: %v", err)
|
||||
}
|
||||
wantSettings := "<timg:SetImagingSettings><timg:VideoSourceToken>source</timg:VideoSourceToken><timg:ImagingSettings><onvif:Focus><onvif:AutoFocusMode>MANUAL</onvif:AutoFocusMode></onvif:Focus></timg:ImagingSettings></timg:SetImagingSettings>"
|
||||
if string(settings) != wantSettings {
|
||||
t.Fatalf("settings XML = %s\nwant %s", settings, wantSettings)
|
||||
}
|
||||
|
||||
// Brightness must come before Focus (schema order) for the Axis retry.
|
||||
brightness := 50.0
|
||||
request.ImagingSettings.Brightness = &brightness
|
||||
settings, err = xml.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal settings with brightness: %v", err)
|
||||
}
|
||||
wantSettings = "<timg:SetImagingSettings><timg:VideoSourceToken>source</timg:VideoSourceToken><timg:ImagingSettings><onvif:Brightness>50</onvif:Brightness><onvif:Focus><onvif:AutoFocusMode>MANUAL</onvif:AutoFocusMode></onvif:Focus></timg:ImagingSettings></timg:SetImagingSettings>"
|
||||
if string(settings) != wantSettings {
|
||||
t.Fatalf("settings XML = %s\nwant %s", settings, wantSettings)
|
||||
}
|
||||
}
|
||||
|
||||
const testImagingEnvelope = `<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope" xmlns:timg="http://www.onvif.org/ver20/imaging/wsdl" xmlns:tt="http://www.onvif.org/ver10/schema"><SOAP-ENV:Body>%s</SOAP-ENV:Body></SOAP-ENV:Envelope>`
|
||||
|
||||
func decodeTestImagingResponse(t *testing.T, body string, node string, out interface{}) {
|
||||
t.Helper()
|
||||
decoder, start, err := getXMLNode(strings.Replace(testImagingEnvelope, "%s", body, 1), node)
|
||||
if err != nil {
|
||||
t.Fatalf("getXMLNode() error = %v", err)
|
||||
}
|
||||
if err := decoder.DecodeElement(out, start); err != nil {
|
||||
t.Fatalf("DecodeElement() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImagingSettingsResponseDecodesFocusModeAndBrightness(t *testing.T) {
|
||||
var settings imagingGetSettingsResponse
|
||||
decodeTestImagingResponse(t, `<timg:GetImagingSettingsResponse><timg:ImagingSettings><tt:Brightness>50</tt:Brightness><tt:Focus><tt:AutoFocusMode>AUTO</tt:AutoFocusMode></tt:Focus></timg:ImagingSettings></timg:GetImagingSettingsResponse>`, "GetImagingSettingsResponse", &settings)
|
||||
|
||||
if settings.ImagingSettings.Focus.AutoFocusMode != focusModeAuto {
|
||||
t.Fatalf("AutoFocusMode = %q, want AUTO", settings.ImagingSettings.Focus.AutoFocusMode)
|
||||
}
|
||||
if settings.ImagingSettings.Brightness == nil || *settings.ImagingSettings.Brightness != 50 {
|
||||
t.Fatalf("Brightness = %v, want 50", settings.ImagingSettings.Brightness)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImagingOptionsResponseDecodesFocusModes(t *testing.T) {
|
||||
// Response captured from a Dahua camera.
|
||||
var response imagingGetOptionsResponse
|
||||
decodeTestImagingResponse(t, `<timg:GetOptionsResponse><timg:ImagingOptions><tt:Brightness><tt:Min>0</tt:Min><tt:Max>100</tt:Max></tt:Brightness><tt:Focus><tt:AutoFocusModes>MANUAL</tt:AutoFocusModes><tt:DefaultSpeed><tt:Min>-1</tt:Min><tt:Max>1</tt:Max></tt:DefaultSpeed></tt:Focus></timg:ImagingOptions></timg:GetOptionsResponse>`, "GetOptionsResponse", &response)
|
||||
|
||||
if got := strings.Join(response.ImagingOptions.Focus.AutoFocusModes, ","); got != "MANUAL" {
|
||||
t.Fatalf("AutoFocusModes = %q, want MANUAL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveOptionsResponseDecodesAbsoluteOnly(t *testing.T) {
|
||||
// Response captured from an Axis camera.
|
||||
var response imagingGetMoveOptionsResponse
|
||||
decodeTestImagingResponse(t, `<timg:GetMoveOptionsResponse><timg:MoveOptions><tt:Absolute><tt:Position><tt:Min>0</tt:Min><tt:Max>1</tt:Max></tt:Position></tt:Absolute></timg:MoveOptions></timg:GetMoveOptionsResponse>`, "GetMoveOptionsResponse", &response)
|
||||
|
||||
options := response.MoveOptions
|
||||
if options.Absolute == nil || options.Relative != nil || options.Continuous != nil {
|
||||
t.Fatalf("options = %+v, want only absolute", options)
|
||||
}
|
||||
if options.Absolute.Position.Min != 0 || options.Absolute.Position.Max != 1 {
|
||||
t.Fatalf("absolute range = %+v, want 0..1", options.Absolute.Position)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusResponseDecodesFocusPosition(t *testing.T) {
|
||||
// Response captured from an Axis camera.
|
||||
var response imagingGetStatusResponse
|
||||
decodeTestImagingResponse(t, `<timg:GetStatusResponse><timg:Status><tt:FocusStatus20><tt:Position>0.479226589</tt:Position><tt:MoveStatus>IDLE</tt:MoveStatus></tt:FocusStatus20></timg:Status></timg:GetStatusResponse>`, "GetStatusResponse", &response)
|
||||
|
||||
if response.Status.FocusStatus20 == nil || response.Status.FocusStatus20.Position == nil || *response.Status.FocusStatus20.Position != 0.479226589 {
|
||||
t.Fatalf("status = %+v, want position 0.479226589", response.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSOAPFaultReasonExtraction(t *testing.T) {
|
||||
fault := `<SOAP-ENV:Fault><SOAP-ENV:Reason><SOAP-ENV:Text xml:lang="en">Argument value invalid</SOAP-ENV:Text></SOAP-ENV:Reason></SOAP-ENV:Fault>`
|
||||
m := soapFaultReason.FindStringSubmatch(fault)
|
||||
if m == nil || m[1] != "Argument value invalid" {
|
||||
t.Fatalf("fault reason = %v, want Argument value invalid", m)
|
||||
}
|
||||
}
|
||||
+35
-14
@@ -119,10 +119,30 @@ func HandleONVIFActions(configuration *models.Configuration, communication *mode
|
||||
// Connect to Onvif device
|
||||
cameraConfiguration := configuration.Config.Capture.IPCamera
|
||||
device, _, err := ConnectToOnvifDevice(&cameraConfiguration)
|
||||
if err == nil {
|
||||
if err == nil && (onvifAction.Action == "focus" || onvifAction.Action == "autofocus") {
|
||||
|
||||
// Get token from the first profile
|
||||
token, err := GetTokenFromProfile(device, 0)
|
||||
// Focus goes through the imaging service, so it also works on
|
||||
// cameras without PTZ (e.g. fixed cameras with a motorised lens).
|
||||
if onvifAction.Action == "focus" {
|
||||
err := ContinuousFocus(device, cameraConfiguration.ONVIFChannel, ptzAction.Focus)
|
||||
if err != nil {
|
||||
log.Error("onvif.HandleONVIFActions() - ContinuousFocus: " + err.Error())
|
||||
} else {
|
||||
log.Info("onvif.HandleONVIFActions() - ContinuousFocus: successfully focused camera")
|
||||
}
|
||||
} else {
|
||||
err := AutoFocus(device, cameraConfiguration.ONVIFChannel)
|
||||
if err != nil {
|
||||
log.Error("onvif.HandleONVIFActions() - AutoFocus: " + err.Error())
|
||||
} else {
|
||||
log.Info("onvif.HandleONVIFActions() - AutoFocus: successfully triggered autofocus")
|
||||
}
|
||||
}
|
||||
|
||||
} else if err == nil {
|
||||
|
||||
// Get the profile token of the configured channel
|
||||
token, err := GetTokenFromChannel(device, cameraConfiguration.ONVIFChannel)
|
||||
if err == nil {
|
||||
|
||||
// Get the configurations from the device
|
||||
@@ -154,7 +174,7 @@ func HandleONVIFActions(configuration *models.Configuration, communication *mode
|
||||
|
||||
// Execute the preset
|
||||
preset := ptzAction.Preset
|
||||
err := GoToPresetFromDevice(device, preset)
|
||||
err := GoToPresetFromDevice(device, cameraConfiguration.ONVIFChannel, preset)
|
||||
if err != nil {
|
||||
log.Debug("onvif.HandleONVIFActions() - GotoPreset: " + err.Error())
|
||||
} else {
|
||||
@@ -349,8 +369,8 @@ func GetPositionFromDevice(configuration models.Configuration) (xsdonvif.PTZVect
|
||||
device, _, err := ConnectToOnvifDevice(&cameraConfiguration)
|
||||
if err == nil {
|
||||
|
||||
// Get token from the first profile
|
||||
token, err := GetTokenFromProfile(device, 0)
|
||||
// Get the profile token of the configured channel
|
||||
token, err := GetTokenFromChannel(device, cameraConfiguration.ONVIFChannel)
|
||||
if err == nil {
|
||||
// Get the PTZ configurations from the device
|
||||
position, err := GetPosition(device, token)
|
||||
@@ -863,12 +883,12 @@ func GetCapabilitiesFromDevice(dev *onvif.Device) []string {
|
||||
return capabilities
|
||||
}
|
||||
|
||||
func GetPresetsFromDevice(device *onvif.Device) ([]models.OnvifActionPreset, error) {
|
||||
func GetPresetsFromDevice(device *onvif.Device, channel int) ([]models.OnvifActionPreset, error) {
|
||||
var presets []models.OnvifActionPreset
|
||||
var presetsResponse ptz.GetPresetsResponse
|
||||
|
||||
// Get token from the first profile
|
||||
token, err := GetTokenFromProfile(device, 0)
|
||||
// Get the profile token of the requested channel
|
||||
token, err := GetTokenFromChannel(device, channel)
|
||||
if err == nil {
|
||||
resp, err := device.CallMethod(ptz.GetPresets{
|
||||
ProfileToken: token,
|
||||
@@ -913,11 +933,11 @@ func GetPresetsFromDevice(device *onvif.Device) ([]models.OnvifActionPreset, err
|
||||
return presets, err
|
||||
}
|
||||
|
||||
func GoToPresetFromDevice(device *onvif.Device, presetName string) error {
|
||||
func GoToPresetFromDevice(device *onvif.Device, channel int, presetName string) error {
|
||||
var goToPresetResponse ptz.GotoPresetResponse
|
||||
|
||||
// Get token from the first profile
|
||||
token, err := GetTokenFromProfile(device, 0)
|
||||
// Get the profile token of the requested channel
|
||||
token, err := GetTokenFromChannel(device, channel)
|
||||
if err == nil {
|
||||
preset := xsdonvif.ReferenceToken(presetName)
|
||||
resp, err := device.CallMethod(ptz.GotoPreset{
|
||||
@@ -1020,6 +1040,7 @@ func VerifyOnvifConnection(c *gin.Context) {
|
||||
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1028,8 +1049,8 @@ func VerifyOnvifConnection(c *gin.Context) {
|
||||
cameraConfiguration := configuration.Config.Capture.IPCamera
|
||||
device, capabilities, err := ConnectToOnvifDevice(&cameraConfiguration)
|
||||
if err == nil {
|
||||
// Get token from the first profile
|
||||
token, err := GetTokenFromProfile(device, 0)
|
||||
// Get the profile token of the requested channel
|
||||
token, err := GetTokenFromChannel(device, cameraConfiguration.ONVIFChannel)
|
||||
if err == nil {
|
||||
c.JSON(200, gin.H{
|
||||
"device": device,
|
||||
|
||||
@@ -40,6 +40,9 @@ func NewQueue() *Queue {
|
||||
}
|
||||
|
||||
func (self *Queue) SetMaxGopCount(n int) {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
self.lock.Lock()
|
||||
self.maxgopcount = n
|
||||
self.lock.Unlock()
|
||||
@@ -97,20 +100,31 @@ func (self *Queue) WritePacket(pkt Packet) (err error) {
|
||||
self.lock.Lock()
|
||||
|
||||
self.buf.Push(pkt)
|
||||
if pkt.Idx == int8(self.videoidx) && pkt.IsKeyFrame {
|
||||
isVideoKeyframe := pkt.Idx == int8(self.videoidx) && pkt.IsKeyFrame
|
||||
if isVideoKeyframe {
|
||||
self.curgopcount++
|
||||
}
|
||||
|
||||
for self.curgopcount >= self.maxgopcount && self.buf.Count > 1 {
|
||||
pkt := self.buf.Pop()
|
||||
if pkt.Idx == int8(self.videoidx) && pkt.IsKeyFrame {
|
||||
self.curgopcount--
|
||||
}
|
||||
if self.curgopcount < self.maxgopcount {
|
||||
break
|
||||
for self.curgopcount > self.maxgopcount && self.buf.Count > 0 {
|
||||
for self.buf.Count > 0 {
|
||||
oldest := self.buf.Pop()
|
||||
if oldest.Idx == int8(self.videoidx) && oldest.IsKeyFrame {
|
||||
self.curgopcount--
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Discard the dependent frames from the evicted GOP as well. Keeping
|
||||
// them would let an overrun cursor resume on a P-frame whose reference
|
||||
// keyframe is no longer in the queue.
|
||||
for self.buf.Count > 0 {
|
||||
oldest := self.buf.Get(self.buf.Head)
|
||||
if oldest.Idx == int8(self.videoidx) && oldest.IsKeyFrame {
|
||||
break
|
||||
}
|
||||
self.buf.Pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
//println("shrink", self.curgopcount, self.maxgopcount, self.buf.Head, self.buf.Tail, "count", self.buf.Count, "size", self.buf.Size)
|
||||
|
||||
self.cond.Broadcast()
|
||||
|
||||
|
||||
@@ -107,3 +107,102 @@ func TestQueueCursorReadPacketContextDoesNotAllocateWhenPacketIsAvailable(t *tes
|
||||
t.Fatalf("ReadPacketContext() allocations = %.2f, want 0", allocations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueEvictionKeepsKeyframeAtHead(t *testing.T) {
|
||||
queue := NewQueue()
|
||||
if err := queue.WriteHeader([]Stream{{Index: 0, IsVideo: true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queue.SetMaxGopCount(2)
|
||||
|
||||
for _, packet := range []Packet{
|
||||
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-0")},
|
||||
{Idx: 1, IsAudio: true, Data: []byte("audio-0")},
|
||||
{Idx: 0, IsVideo: true, Data: []byte("P-0")},
|
||||
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-1")},
|
||||
{Idx: 0, IsVideo: true, Data: []byte("P-1")},
|
||||
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-2")},
|
||||
} {
|
||||
if err := queue.WritePacket(packet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
oldest, err := queue.Oldest().ReadPacket()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !oldest.IsKeyFrame || string(oldest.Data) != "IDR-1" {
|
||||
t.Fatalf("oldest packet = %q (keyframe=%t), want IDR-1 keyframe", oldest.Data, oldest.IsKeyFrame)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueWithSingleGOPRetainsCurrentKeyframe(t *testing.T) {
|
||||
queue := NewQueue()
|
||||
if err := queue.WriteHeader([]Stream{{Index: 0, IsVideo: true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queue.SetMaxGopCount(1)
|
||||
|
||||
for _, packet := range []Packet{
|
||||
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-0")},
|
||||
{Idx: 0, IsVideo: true, Data: []byte("P-0")},
|
||||
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-1")},
|
||||
} {
|
||||
if err := queue.WritePacket(packet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
oldest, err := queue.Oldest().ReadPacket()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !oldest.IsKeyFrame || string(oldest.Data) != "IDR-1" {
|
||||
t.Fatalf("oldest packet = %q (keyframe=%t), want IDR-1 keyframe", oldest.Data, oldest.IsKeyFrame)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaggingQueueCursorResumesAtKeyframe(t *testing.T) {
|
||||
queue := NewQueue()
|
||||
if err := queue.WriteHeader([]Stream{{Index: 0, IsVideo: true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queue.SetMaxGopCount(2)
|
||||
|
||||
for _, packet := range []Packet{
|
||||
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-0")},
|
||||
{Idx: 0, IsVideo: true, Data: []byte("P-0")},
|
||||
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-1")},
|
||||
} {
|
||||
if err := queue.WritePacket(packet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
cursor := queue.Oldest()
|
||||
first, err := cursor.ReadPacket()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(first.Data) != "IDR-0" {
|
||||
t.Fatalf("first packet = %q, want IDR-0", first.Data)
|
||||
}
|
||||
|
||||
for _, packet := range []Packet{
|
||||
{Idx: 0, IsVideo: true, Data: []byte("P-1")},
|
||||
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-2")},
|
||||
} {
|
||||
if err := queue.WritePacket(packet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
resumed, err := cursor.ReadPacket()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !resumed.IsKeyFrame || string(resumed.Data) != "IDR-1" {
|
||||
t.Fatalf("resumed packet = %q (keyframe=%t), want IDR-1 keyframe", resumed.Data, resumed.IsKeyFrame)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -74,6 +75,7 @@ func LoginToOnvif(c *gin.Context) {
|
||||
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -82,8 +84,8 @@ func LoginToOnvif(c *gin.Context) {
|
||||
cameraConfiguration := configuration.Config.Capture.IPCamera
|
||||
device, capabilities, err := onvif.ConnectToOnvifDevice(&cameraConfiguration)
|
||||
if err == nil {
|
||||
// Get token from the first profile
|
||||
token, err := onvif.GetTokenFromProfile(device, 0)
|
||||
// Get the profile token of the requested channel
|
||||
token, err := onvif.GetTokenFromChannel(device, cameraConfiguration.ONVIFChannel)
|
||||
if err == nil {
|
||||
c.JSON(200, gin.H{
|
||||
"device": device,
|
||||
@@ -128,6 +130,7 @@ func GetOnvifCapabilities(c *gin.Context) {
|
||||
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -172,6 +175,7 @@ func DoOnvifPanTilt(c *gin.Context) {
|
||||
ONVIFXAddr: onvifPanTilt.OnvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifPanTilt.OnvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifPanTilt.OnvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifPanTilt.OnvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -181,8 +185,8 @@ func DoOnvifPanTilt(c *gin.Context) {
|
||||
device, _, err := onvif.ConnectToOnvifDevice(&cameraConfiguration)
|
||||
|
||||
if err == nil {
|
||||
// Get token from the first profile
|
||||
token, err := onvif.GetTokenFromProfile(device, 0)
|
||||
// Get the profile token of the requested channel
|
||||
token, err := onvif.GetTokenFromChannel(device, cameraConfiguration.ONVIFChannel)
|
||||
|
||||
if err == nil {
|
||||
|
||||
@@ -246,6 +250,7 @@ func DoOnvifZoom(c *gin.Context) {
|
||||
ONVIFXAddr: onvifZoom.OnvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifZoom.OnvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifZoom.OnvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifZoom.OnvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -255,8 +260,8 @@ func DoOnvifZoom(c *gin.Context) {
|
||||
device, _, err := onvif.ConnectToOnvifDevice(&cameraConfiguration)
|
||||
|
||||
if err == nil {
|
||||
// Get token from the first profile
|
||||
token, err := onvif.GetTokenFromProfile(device, 0)
|
||||
// Get the profile token of the requested channel
|
||||
token, err := onvif.GetTokenFromChannel(device, cameraConfiguration.ONVIFChannel)
|
||||
|
||||
if err == nil {
|
||||
|
||||
@@ -298,6 +303,54 @@ func DoOnvifZoom(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// DoOnvifFocus godoc
|
||||
// @Router /api/camera/onvif/focus [post]
|
||||
// @ID camera-onvif-focus
|
||||
// @Tags onvif
|
||||
// @Param focus body models.OnvifFocus true "OnvifFocus"
|
||||
// @Summary Focusing the camera near or far.
|
||||
// @Description Focusing the camera near (positive) or far (negative).
|
||||
// @Success 200 {object} models.APIResponse
|
||||
func DoOnvifFocus(c *gin.Context) {
|
||||
var onvifFocus models.OnvifFocus
|
||||
err := c.BindJSON(&onvifFocus)
|
||||
if err == nil && onvifFocus.OnvifCredentials.ONVIFXAddr == "" {
|
||||
err = errors.New("missing onvif_xaddr")
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
|
||||
cameraConfiguration := models.IPCamera{
|
||||
ONVIFXAddr: onvifFocus.OnvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifFocus.OnvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifFocus.OnvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifFocus.OnvifCredentials.ONVIFChannel,
|
||||
}
|
||||
device, _, err := onvif.ConnectToOnvifDevice(&cameraConfiguration)
|
||||
|
||||
if err == nil {
|
||||
err := onvif.ContinuousFocus(device, cameraConfiguration.ONVIFChannel, onvifFocus.Focus)
|
||||
if err == nil {
|
||||
c.JSON(200, models.APIResponse{
|
||||
Message: "Successfully focused the camera",
|
||||
})
|
||||
} else {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Message: "Something went wrong: " + err.Error(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Message: "Something went wrong: " + err.Error(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Message: "Something went wrong: " + err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetOnvifPresets godoc
|
||||
// @Router /api/camera/onvif/presets [post]
|
||||
// @ID camera-onvif-presets
|
||||
@@ -319,6 +372,7 @@ func GetOnvifPresets(c *gin.Context) {
|
||||
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -327,7 +381,7 @@ func GetOnvifPresets(c *gin.Context) {
|
||||
cameraConfiguration := configuration.Config.Capture.IPCamera
|
||||
device, _, err := onvif.ConnectToOnvifDevice(&cameraConfiguration)
|
||||
if err == nil {
|
||||
presets, err := onvif.GetPresetsFromDevice(device)
|
||||
presets, err := onvif.GetPresetsFromDevice(device, cameraConfiguration.ONVIFChannel)
|
||||
if err == nil {
|
||||
c.JSON(200, gin.H{
|
||||
"presets": presets,
|
||||
@@ -370,6 +424,7 @@ func GoToOnvifPreset(c *gin.Context) {
|
||||
ONVIFXAddr: onvifPreset.OnvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifPreset.OnvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifPreset.OnvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifPreset.OnvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -378,7 +433,7 @@ func GoToOnvifPreset(c *gin.Context) {
|
||||
cameraConfiguration := configuration.Config.Capture.IPCamera
|
||||
device, _, err := onvif.ConnectToOnvifDevice(&cameraConfiguration)
|
||||
if err == nil {
|
||||
err := onvif.GoToPresetFromDevice(device, onvifPreset.Preset)
|
||||
err := onvif.GoToPresetFromDevice(device, cameraConfiguration.ONVIFChannel, onvifPreset.Preset)
|
||||
if err == nil {
|
||||
c.JSON(200, gin.H{
|
||||
"data": "Camera preset activated: " + onvifPreset.Preset,
|
||||
@@ -425,6 +480,7 @@ func DoGetDigitalInputs(c *gin.Context) {
|
||||
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -514,6 +570,7 @@ func DoGetRelayOutputs(c *gin.Context) {
|
||||
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -587,6 +644,7 @@ func DoTriggerRelayOutput(c *gin.Context) {
|
||||
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
|
||||
ONVIFUsername: onvifCredentials.ONVIFUsername,
|
||||
ONVIFPassword: onvifCredentials.ONVIFPassword,
|
||||
ONVIFChannel: onvifCredentials.ONVIFChannel,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kerberos-io/agent/machinery/src/encryption"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
func TestFilesServesFlatAndDatedRecordings(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, layout := range []string{"flat", "dated"} {
|
||||
for _, encrypted := range []bool{false, true} {
|
||||
name := layout + "-plain"
|
||||
if encrypted {
|
||||
name = layout + "-encrypted"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
const fileName = "1767355200123-device-key.mp4"
|
||||
directory := t.TempDir()
|
||||
relativePath := fileName
|
||||
if layout == "dated" {
|
||||
relativePath = "2026/01/02/" + fileName
|
||||
}
|
||||
filePath := filepath.Join(directory, "data/recordings", relativePath)
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := &models.Configuration{Config: models.Config{Encryption: &models.Encryption{}}}
|
||||
content := []byte("recording-payload")
|
||||
if encrypted {
|
||||
config.Config.Encryption.Recordings = "true"
|
||||
config.Config.Encryption.SymmetricKey = "0123456789abcdef0123456789abcdef"
|
||||
ciphertext, err := encryption.AesEncrypt(content, config.Config.Encryption.SymmetricKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content = []byte(ciphertext)
|
||||
}
|
||||
if err := os.WriteFile(filePath, content, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := gin.New()
|
||||
router.GET("/file/*filepath", func(c *gin.Context) { Files(c, directory, config) })
|
||||
for _, reference := range []string{relativePath, fileName} {
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequest("GET", "/file/"+reference, nil))
|
||||
if response.Code != 200 || response.Body.String() != "recording-payload" {
|
||||
t.Fatalf("GET %s = %d %q", reference, response.Code, response.Body.String())
|
||||
}
|
||||
if got := response.Header().Get("Content-Type"); got != "video/mp4" {
|
||||
t.Fatalf("Content-Type = %q, want video/mp4", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesRejectsMissingAndOutsideRecordings(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(directory, "data/recordings"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, "data/outside.mp4"), []byte("not a recording"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := gin.New()
|
||||
router.GET("/file/*filepath", func(c *gin.Context) { Files(c, directory, &models.Configuration{}) })
|
||||
for _, reference := range []string{"missing.mp4", "../outside.mp4", "2026/01/02/missing.mp4"} {
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequest("GET", "/file/"+reference, nil))
|
||||
if response.Code != 404 {
|
||||
t.Fatalf("GET %s = %d, want 404", reference, response.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,12 +65,12 @@ func AddRoutes(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware, configDirect
|
||||
|
||||
// Will verify the hub settings.
|
||||
api.POST("/hub/verify", func(c *gin.Context) {
|
||||
cloud.VerifyHub(c)
|
||||
cloud.VerifyHub(c, configuration.Config.HubURI)
|
||||
})
|
||||
|
||||
// Will verify the persistence settings.
|
||||
api.POST("/persistence/verify", func(c *gin.Context) {
|
||||
cloud.VerifyPersistence(c, configDirectory)
|
||||
cloud.VerifyPersistence(c, configDirectory, configuration.Config.HubURI)
|
||||
})
|
||||
|
||||
// Will verify the secondary persistence settings.
|
||||
@@ -108,6 +108,7 @@ func AddRoutes(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware, configDirect
|
||||
api.POST("/camera/onvif/gotopreset", GoToOnvifPreset)
|
||||
api.POST("/camera/onvif/pantilt", DoOnvifPanTilt)
|
||||
api.POST("/camera/onvif/zoom", DoOnvifZoom)
|
||||
api.POST("/camera/onvif/focus", DoOnvifFocus)
|
||||
api.POST("/camera/onvif/inputs", DoGetDigitalInputs)
|
||||
api.POST("/camera/onvif/outputs", DoGetRelayOutputs)
|
||||
api.POST("/camera/onvif/outputs/:output", DoTriggerRelayOutput)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
jwt "github.com/appleboy/gin-jwt/v2"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"github.com/kerberos-io/agent/machinery/src/capture"
|
||||
"github.com/kerberos-io/agent/machinery/src/encryption"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/utils"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
)
|
||||
@@ -81,18 +83,11 @@ func StartServer(configDirectory string, configuration *models.Configuration, co
|
||||
// Add all routes
|
||||
AddRoutes(r, authMiddleware, configDirectory, configuration, communication, captureDevice)
|
||||
|
||||
// Update environment variables
|
||||
environmentVariables := configDirectory + "/www/env.js"
|
||||
if os.Getenv("AGENT_MODE") == "demo" {
|
||||
demoEnvironmentVariables := configDirectory + "/www/env.demo.js"
|
||||
// Move demo environment variables to environment variables
|
||||
err := os.Rename(demoEnvironmentVariables, environmentVariables)
|
||||
if err != nil {
|
||||
log.WithError(err).WithFields(log.Fields{
|
||||
"component": "http",
|
||||
"event": "demo_environment_move_failed",
|
||||
}).Fatal("Failed to activate demo UI environment")
|
||||
}
|
||||
if err := addUIEnvironmentRoute(r, configDirectory); err != nil {
|
||||
log.WithError(err).WithFields(log.Fields{
|
||||
"component": "http",
|
||||
"event": "ui_environment_unavailable",
|
||||
}).Fatal("Failed to load UI environment")
|
||||
}
|
||||
|
||||
// Add static routes to UI
|
||||
@@ -121,6 +116,20 @@ func StartServer(configDirectory string, configuration *models.Configuration, co
|
||||
}
|
||||
}
|
||||
|
||||
func addUIEnvironmentRoute(router *gin.Engine, configDirectory string) error {
|
||||
environmentFile := configDirectory + "/www/env.js"
|
||||
if os.Getenv("AGENT_MODE") == "demo" {
|
||||
environmentFile = configDirectory + "/www/env.demo.js"
|
||||
}
|
||||
if _, err := os.Stat(environmentFile); err != nil {
|
||||
return fmt.Errorf("loading UI environment: %w", err)
|
||||
}
|
||||
router.Match([]string{"GET", "HEAD"}, "/env.js", func(c *gin.Context) {
|
||||
c.File(environmentFile)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func requestLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
startedAt := time.Now()
|
||||
@@ -160,9 +169,9 @@ func requestLogger() gin.HandlerFunc {
|
||||
func Files(c *gin.Context, configDirectory string, configuration *models.Configuration) {
|
||||
|
||||
// Get File
|
||||
filePath := configDirectory + "/data/recordings" + c.Param("filepath")
|
||||
_, err := os.Open(filePath)
|
||||
filePath, err := utils.ResolveRecordingPath(configDirectory+"/data/recordings", strings.TrimPrefix(c.Param("filepath"), "/"))
|
||||
if err != nil {
|
||||
log.WithError(err).Warn("Could not resolve recording for playback")
|
||||
c.JSON(404, gin.H{"error": "File not found"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestUIEnvironmentIsServedWithoutChangingAssets(t *testing.T) {
|
||||
for _, mode := range []string{"", "demo"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
t.Setenv("AGENT_MODE", mode)
|
||||
directory := t.TempDir()
|
||||
www := filepath.Join(directory, "www")
|
||||
if err := os.Mkdir(www, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, content := range map[string]string{"env.js": "normal", "env.demo.js": "demo"} {
|
||||
if err := os.WriteFile(filepath.Join(www, name), []byte(content), 0444); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
router := gin.New()
|
||||
if err := addUIEnvironmentRoute(router, directory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "normal"
|
||||
if mode == "demo" {
|
||||
want = "demo"
|
||||
}
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead} {
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, httptest.NewRequest(method, "/env.js", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("%s /env.js: status %d", method, response.Code)
|
||||
}
|
||||
if method == http.MethodGet && response.Body.String() != want {
|
||||
t.Fatalf("environment = %q, want %q", response.Body.String(), want)
|
||||
}
|
||||
if method == http.MethodHead && response.Body.Len() != 0 {
|
||||
t.Fatal("HEAD returned a response body")
|
||||
}
|
||||
}
|
||||
for name, want := range map[string]string{"env.js": "normal", "env.demo.js": "demo"} {
|
||||
got, err := os.ReadFile(filepath.Join(www, name))
|
||||
if err != nil || string(got) != want {
|
||||
t.Fatalf("asset %s changed: %q, %v", name, got, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIEnvironmentRejectsMissingAsset(t *testing.T) {
|
||||
t.Setenv("AGENT_MODE", "demo")
|
||||
if err := addUIEnvironmentRoute(gin.New(), t.TempDir()); err == nil {
|
||||
t.Fatal("missing demo environment was accepted")
|
||||
}
|
||||
}
|
||||
+45
-80
@@ -191,51 +191,32 @@ func GetMediaFormatted(files []os.FileInfo, recordingDirectory string, configura
|
||||
count := 0
|
||||
for _, file := range files {
|
||||
fileName := file.Name()
|
||||
fileParts := strings.Split(fileName, "_")
|
||||
if len(fileParts) == 6 {
|
||||
timestamp := fileParts[0]
|
||||
timestampInt, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err == nil {
|
||||
|
||||
if eventFilter.TimestampOffsetStart > 0 {
|
||||
// TimestampOffsetStart represents the newest lower bound to include.
|
||||
if timestampInt < eventFilter.TimestampOffsetStart {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// If we have an offset we will check if we should skip or not
|
||||
if eventFilter.TimestampOffsetEnd > 0 {
|
||||
// Medias are sorted from new to older. TimestampOffsetEnd holds the oldest
|
||||
// timestamp of the previous batch of events. By doing this check, we make sure
|
||||
// to skip the previous batch.
|
||||
if timestampInt >= eventFilter.TimestampOffsetEnd {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation(configuration.Config.Timezone)
|
||||
time := time.Unix(timestampInt, 0).In(loc)
|
||||
day := time.Format("02-01-2006")
|
||||
timeString := time.Format("15:04:05")
|
||||
shortDay := time.Format("Jan _2")
|
||||
|
||||
media := models.Media{
|
||||
Key: fileName,
|
||||
Path: recordingDirectory + "/" + fileName,
|
||||
CameraName: configuration.Config.Name,
|
||||
CameraKey: configuration.Config.Key,
|
||||
Day: day,
|
||||
ShortDay: shortDay,
|
||||
Time: timeString,
|
||||
Timestamp: timestamp,
|
||||
}
|
||||
filePaths = append(filePaths, media)
|
||||
count = count + 1
|
||||
if eventFilter.NumberOfElements > 0 && count >= eventFilter.NumberOfElements {
|
||||
break
|
||||
}
|
||||
}
|
||||
recordedAt, err := RecordingStartTime(fileName)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
timestamp := recordedAt.Unix()
|
||||
if eventFilter.TimestampOffsetStart > 0 && timestamp < eventFilter.TimestampOffsetStart {
|
||||
continue
|
||||
}
|
||||
if eventFilter.TimestampOffsetEnd > 0 && timestamp >= eventFilter.TimestampOffsetEnd {
|
||||
continue
|
||||
}
|
||||
loc, _ := time.LoadLocation(configuration.Config.Timezone)
|
||||
localTime := recordedAt.In(loc)
|
||||
filePaths = append(filePaths, models.Media{
|
||||
Key: fileName,
|
||||
Path: recordingDirectory + "/" + fileName,
|
||||
CameraName: configuration.Config.Name,
|
||||
CameraKey: configuration.Config.Key,
|
||||
Day: localTime.Format("02-01-2006"),
|
||||
ShortDay: localTime.Format("Jan _2"),
|
||||
Time: localTime.Format("15:04:05"),
|
||||
Timestamp: strconv.FormatInt(timestamp, 10),
|
||||
})
|
||||
count++
|
||||
if eventFilter.NumberOfElements > 0 && count >= eventFilter.NumberOfElements {
|
||||
break
|
||||
}
|
||||
}
|
||||
return filePaths
|
||||
@@ -244,18 +225,12 @@ func GetMediaFormatted(files []os.FileInfo, recordingDirectory string, configura
|
||||
func GetDays(files []os.FileInfo, recordingDirectory string, configuration *models.Configuration) []string {
|
||||
days := []string{}
|
||||
for _, file := range files {
|
||||
fileName := file.Name()
|
||||
fileParts := strings.Split(fileName, "_")
|
||||
if len(fileParts) == 6 {
|
||||
timestamp := fileParts[0]
|
||||
timestampInt, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err == nil {
|
||||
loc, _ := time.LoadLocation(configuration.Config.Timezone)
|
||||
time := time.Unix(timestampInt, 0).In(loc)
|
||||
day := time.Format("02-01-2006")
|
||||
days = append(days, day)
|
||||
}
|
||||
recordedAt, err := RecordingStartTime(file.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
loc, _ := time.LoadLocation(configuration.Config.Timezone)
|
||||
days = append(days, recordedAt.In(loc).Format("02-01-2006"))
|
||||
}
|
||||
uniqueDays := Unique(days)
|
||||
return uniqueDays
|
||||
@@ -278,10 +253,13 @@ func NumberOfFilesInDirectory(path string) int {
|
||||
return len(files)
|
||||
}
|
||||
|
||||
// NumberOfMP4sInDirectory returns the count of all files with mp4 extension in current directory
|
||||
// NumberOfMP4sInDirectory counts MP4 files, including dated subdirectories.
|
||||
func NumberOfMP4sInDirectory(path string) int {
|
||||
pattern := filepath.Join(path, "*.mp4")
|
||||
files, _ := filepath.Glob(pattern)
|
||||
files, err := ReadRecordingDirectory(path)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Could not count recordings")
|
||||
return 0
|
||||
}
|
||||
return len(files)
|
||||
}
|
||||
|
||||
@@ -409,25 +387,13 @@ func Decrypt(directoryOrFile string, symmetricKey []byte) {
|
||||
|
||||
var files []string
|
||||
if fileInfo.IsDir() {
|
||||
// Create decrypted directory
|
||||
err = os.MkdirAll(directoryOrFile+"/decrypted", 0755)
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
return
|
||||
}
|
||||
dir, err := os.ReadDir(directoryOrFile)
|
||||
dir, err := ReadRecordingDirectory(directoryOrFile)
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
return
|
||||
}
|
||||
for _, file := range dir {
|
||||
// Check if file is not a directory
|
||||
if !file.IsDir() {
|
||||
// Check if an mp4 file
|
||||
if strings.HasSuffix(file.Name(), ".mp4") {
|
||||
files = append(files, directoryOrFile+"/"+file.Name())
|
||||
}
|
||||
}
|
||||
files = append(files, filepath.Join(directoryOrFile, file.Name()))
|
||||
}
|
||||
} else {
|
||||
files = append(files, directoryOrFile)
|
||||
@@ -449,13 +415,12 @@ func Decrypt(directoryOrFile string, symmetricKey []byte) {
|
||||
return
|
||||
}
|
||||
|
||||
// Write decrypted content to file with appended .decrypted
|
||||
// Get filename split by / and get last element.
|
||||
fileParts := strings.Split(file, "/")
|
||||
fileName := fileParts[len(fileParts)-1]
|
||||
pathToFile := strings.Join(fileParts[:len(fileParts)-1], "/")
|
||||
|
||||
err = os.WriteFile(pathToFile+"/decrypted/"+fileName, []byte(decrypted), 0644)
|
||||
outputDirectory := filepath.Join(filepath.Dir(file), "decrypted")
|
||||
if err := os.MkdirAll(outputDirectory, 0o755); err != nil {
|
||||
log.Fatal(err.Error())
|
||||
return
|
||||
}
|
||||
err = os.WriteFile(filepath.Join(outputDirectory, filepath.Base(file)), []byte(decrypted), 0644)
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
return
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func RecordingFileName(timestamp int64, deviceKey string) (string, error) {
|
||||
if timestamp <= 0 {
|
||||
return "", fmt.Errorf("invalid recording timestamp %d", timestamp)
|
||||
}
|
||||
if deviceKey == "" || strings.ContainsAny(deviceKey, "/\\") {
|
||||
return "", fmt.Errorf("missing or invalid recording device key")
|
||||
}
|
||||
return strconv.FormatInt(timestamp, 10) + "-" + deviceKey + ".mp4", nil
|
||||
}
|
||||
|
||||
func RecordingStartTime(name string) (time.Time, error) {
|
||||
base := filepath.Base(name)
|
||||
if !strings.HasSuffix(base, ".mp4") {
|
||||
return time.Time{}, fmt.Errorf("invalid recording filename %q", name)
|
||||
}
|
||||
timestamp, deviceKey, short := strings.Cut(strings.TrimSuffix(base, ".mp4"), "-")
|
||||
if short && deviceKey != "" {
|
||||
if millis, err := strconv.ParseInt(timestamp, 10, 64); err == nil && millis > 0 {
|
||||
return time.UnixMilli(millis), nil
|
||||
}
|
||||
}
|
||||
parts := strings.Split(base, "_")
|
||||
if len(parts) == 6 {
|
||||
seconds, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err == nil && seconds > 0 {
|
||||
return time.Unix(seconds, 0), nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("invalid recording filename %q", name)
|
||||
}
|
||||
|
||||
func CreateRecordingPath(directory, fileName string, recordedAt time.Time) (string, error) {
|
||||
if !filepath.IsLocal(fileName) || fileName == "." || filepath.Base(fileName) != fileName || strings.ContainsAny(fileName, "/\\") {
|
||||
return "", fmt.Errorf("invalid recording filename %q", fileName)
|
||||
}
|
||||
if recordedAt.UnixMilli() <= 0 {
|
||||
return "", fmt.Errorf("invalid recording start time %s", recordedAt)
|
||||
}
|
||||
directory = filepath.Join(directory, recordedAt.UTC().Format("2006/01/02"))
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
return "", fmt.Errorf("create recording directory: %w", err)
|
||||
}
|
||||
return filepath.Join(directory, fileName), nil
|
||||
}
|
||||
|
||||
// ResolveRecordingPath accepts relative paths from listings and historical
|
||||
// basename-only references from upload markers and playback URLs.
|
||||
func ResolveRecordingPath(directory, name string) (string, error) {
|
||||
if !filepath.IsLocal(name) {
|
||||
return "", fmt.Errorf("invalid recording path %q", name)
|
||||
}
|
||||
filePath := filepath.Join(directory, name)
|
||||
info, err := os.Stat(filePath)
|
||||
if os.IsNotExist(err) && filepath.Base(name) == name {
|
||||
recordedAt, parseErr := RecordingStartTime(name)
|
||||
if parseErr == nil {
|
||||
filePath = filepath.Join(directory, recordedAt.UTC().Format("2006/01/02"), name)
|
||||
info, err = os.Stat(filePath)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", fmt.Errorf("recording %q is not a regular file", name)
|
||||
}
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
func OpenRecording(directory, name string) (*os.File, error) {
|
||||
filePath, err := ResolveRecordingPath(directory, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.Open(filePath)
|
||||
}
|
||||
|
||||
func RemoveRecording(directory, name string) error {
|
||||
filePath, err := ResolveRecordingPath(directory, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(filePath)
|
||||
}
|
||||
|
||||
type recordingFileInfo struct {
|
||||
os.FileInfo
|
||||
relativePath string
|
||||
}
|
||||
|
||||
func (file recordingFileInfo) Name() string {
|
||||
return file.relativePath
|
||||
}
|
||||
|
||||
// ReadRecordingDirectory returns MP4s with root-relative names, preserving the
|
||||
// file information used by the existing sorting and media formatting helpers.
|
||||
func ReadRecordingDirectory(directory string) ([]os.FileInfo, error) {
|
||||
return readRecordingDirectory(directory, filepath.WalkDir)
|
||||
}
|
||||
|
||||
func readRecordingDirectory(directory string, walkDir func(string, fs.WalkDirFunc) error) ([]os.FileInfo, error) {
|
||||
files := []os.FileInfo{}
|
||||
err := walkDir(directory, func(filePath string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) && filePath != directory {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() && entry.Name() == "decrypted" && filePath != directory {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if !entry.Type().IsRegular() || filepath.Ext(entry.Name()) != ".mp4" {
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
name, err := filepath.Rel(directory, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, recordingFileInfo{FileInfo: info, relativePath: filepath.ToSlash(name)})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/encryption"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
func TestShortRecordingNamePreservesMillisecondsAndDeviceKey(t *testing.T) {
|
||||
const timestamp = int64(1767355200123)
|
||||
const deviceKey = "device-key_with-hyphens"
|
||||
name, err := RecordingFileName(timestamp, deviceKey)
|
||||
if err != nil || name != "1767355200123-device-key_with-hyphens.mp4" {
|
||||
t.Fatalf("RecordingFileName() = %q, %v", name, err)
|
||||
}
|
||||
other, err := RecordingFileName(timestamp+1, deviceKey)
|
||||
if err != nil || other == name {
|
||||
t.Fatalf("recordings within the same second must have distinct names: %q, %v", other, err)
|
||||
}
|
||||
directory := t.TempDir()
|
||||
filePath, err := CreateRecordingPath(directory, name, time.UnixMilli(timestamp))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := filepath.Join(directory, "2026/01/02", name); filePath != want {
|
||||
t.Fatalf("path = %q, want %q", filePath, want)
|
||||
}
|
||||
if err := os.WriteFile(filePath, []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := ResolveRecordingPath(directory, name)
|
||||
if err != nil || resolved != filePath {
|
||||
t.Fatalf("ResolveRecordingPath() = %q, %v", resolved, err)
|
||||
}
|
||||
files, err := ReadRecordingDirectory(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := &models.Configuration{Config: models.Config{Timezone: "UTC"}}
|
||||
media := GetMediaFormatted(files, directory, config, models.EventFilter{TimestampOffsetStart: timestamp / 1000})
|
||||
if len(media) != 1 || media[0].Key != "2026/01/02/"+name || media[0].Timestamp != "1767355200" {
|
||||
t.Fatalf("unexpected short-name media: %+v", media)
|
||||
}
|
||||
if days := GetDays(files, directory, config); !reflect.DeepEqual(days, []string{"02-01-2026"}) {
|
||||
t.Fatalf("unexpected short-name days: %v", days)
|
||||
}
|
||||
if err := RemoveRecording(directory, name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, invalidKey := range []string{"", "camera/key", `camera\key`} {
|
||||
if _, err := RecordingFileName(timestamp, invalidKey); err == nil {
|
||||
t.Errorf("accepted invalid device key %q", invalidKey)
|
||||
}
|
||||
}
|
||||
if _, err := RecordingFileName(0, deviceKey); err == nil {
|
||||
t.Fatal("accepted invalid recording timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRecordingPathUsesRecordingUTCDate(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
recordedAt := time.Date(2026, time.January, 1, 1, 0, 0, 0, time.FixedZone("UTC+2", 2*60*60))
|
||||
name := strconv.FormatInt(recordedAt.Unix(), 10) + "_3-123_camera_0-0-0-0_1_1000.mp4"
|
||||
filePath, err := CreateRecordingPath(directory, name, recordedAt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := filepath.Join(directory, "2025/12/31", name); filePath != want {
|
||||
t.Fatalf("path = %q, want %q", filePath, want)
|
||||
}
|
||||
if err := os.WriteFile(filePath, []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, reference := range []string{name, "2025/12/31/" + name} {
|
||||
got, err := ResolveRecordingPath(directory, reference)
|
||||
if err != nil || got != filePath {
|
||||
t.Fatalf("ResolveRecordingPath(%q) = %q, %v; want %q", reference, got, err, filePath)
|
||||
}
|
||||
file, err := OpenRecording(directory, reference)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := io.ReadAll(file)
|
||||
file.Close()
|
||||
if err != nil || string(content) != "video" {
|
||||
t.Fatalf("recording content = %q, %v", content, err)
|
||||
}
|
||||
}
|
||||
if err := RemoveRecording(directory, name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("recording still exists after removal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordingPathsKeepFlatFilesAccessible(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
name := "legacy-recording.mp4"
|
||||
filePath := filepath.Join(directory, name)
|
||||
if err := os.WriteFile(filePath, []byte("legacy"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ResolveRecordingPath(directory, name)
|
||||
if err != nil || got != filePath {
|
||||
t.Fatalf("ResolveRecordingPath() = %q, %v; want %q", got, err, filePath)
|
||||
}
|
||||
if err := RemoveRecording(directory, name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordingPathsReportInvalidAndMissingPaths(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
for _, name := range []string{"", ".", "..", "../outside.mp4", "/outside.mp4", "nested/recording.mp4", `nested\recording.mp4`} {
|
||||
if _, err := CreateRecordingPath(directory, name, time.Now()); err == nil {
|
||||
t.Errorf("CreateRecordingPath(%q) should reject invalid filename", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"", ".", "../outside.mp4", "/outside.mp4"} {
|
||||
if _, err := ResolveRecordingPath(directory, name); err == nil {
|
||||
t.Errorf("ResolveRecordingPath(%q) should reject invalid path", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"missing.mp4", "1700000000_missing.mp4", "2026/01/01/missing.mp4"} {
|
||||
if _, err := ResolveRecordingPath(directory, name); !os.IsNotExist(err) {
|
||||
t.Errorf("ResolveRecordingPath(%q) error = %v, want not-exist", name, err)
|
||||
}
|
||||
}
|
||||
blocked := filepath.Join(directory, "not-a-directory")
|
||||
if err := os.WriteFile(blocked, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CreateRecordingPath(blocked, "recording.mp4", time.Now()); err == nil {
|
||||
t.Fatal("expected directory creation error")
|
||||
}
|
||||
if _, err := CreateRecordingPath(directory, "recording.mp4", time.Time{}); err == nil {
|
||||
t.Fatal("expected invalid recording start time error")
|
||||
}
|
||||
if _, err := ReadRecordingDirectory(filepath.Join(directory, "missing")); !os.IsNotExist(err) {
|
||||
t.Fatalf("ReadRecordingDirectory() error = %v, want not-exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRecordingDirectoryIncludesFlatAndDatedMedia(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
names := []string{
|
||||
"1700000000_6_7_8_9_10.mp4",
|
||||
"2026/01/02/1767355200_6_7_8_9_10.mp4",
|
||||
}
|
||||
for index, name := range names {
|
||||
filePath := filepath.Join(directory, name)
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filePath, []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
modified := time.Unix(int64(index+1), 0)
|
||||
if err := os.Chtimes(filePath, modified, modified); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, "2026/01/02/ignore.txt"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(directory, "2026/01/02/decrypted"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, "2026/01/02/decrypted/output.mp4"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files, err := ReadRecordingDirectory(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(files) != 2 || NumberOfMP4sInDirectory(directory) != 2 {
|
||||
t.Fatalf("expected 2 MP4 recordings, got %d", len(files))
|
||||
}
|
||||
config := &models.Configuration{Config: models.Config{Timezone: "UTC", Name: "camera", Key: "device"}}
|
||||
files = GetSortedDirectory(files)
|
||||
media := GetMediaFormatted(files, directory, config, models.EventFilter{})
|
||||
if len(media) != 2 || media[0].Key != names[1] || media[1].Key != names[0] {
|
||||
t.Fatalf("unexpected media ordering or relative keys: %+v", media)
|
||||
}
|
||||
if media[0].Path != filepath.Join(directory, names[1]) || media[0].Timestamp != "1767355200" {
|
||||
t.Fatalf("unexpected nested media path or timestamp: %+v", media[0])
|
||||
}
|
||||
if got, want := GetDays(files, directory, config), []string{"02-01-2026", "14-11-2023"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("days = %v, want %v", got, want)
|
||||
}
|
||||
filtered := GetMediaFormatted(files, directory, config, models.EventFilter{TimestampOffsetStart: 1767355200})
|
||||
if len(filtered) != 1 || filtered[0].Key != names[1] {
|
||||
t.Fatalf("unexpected filtered nested media: %+v", filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRecordingDirectoryContinuesAfterConcurrentDeletion(t *testing.T) {
|
||||
for _, origin := range []string{"Info", "WalkDir"} {
|
||||
t.Run(origin, func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
for _, name := range []string{"deleted.mp4", "surviving.mp4"} {
|
||||
if err := os.WriteFile(filepath.Join(directory, name), []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files, err := readRecordingDirectory(directory, func(root string, visit fs.WalkDirFunc) error {
|
||||
deleted := filepath.Join(root, entries[0].Name())
|
||||
if err := os.Remove(deleted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var err error
|
||||
if origin == "Info" {
|
||||
err = visit(deleted, entries[0], nil)
|
||||
} else {
|
||||
err = visit(deleted, nil, &fs.PathError{Op: "lstat", Path: deleted, Err: fs.ErrNotExist})
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return visit(filepath.Join(root, entries[1].Name()), entries[1], nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(files) != 1 || files[0].Name() != "surviving.mp4" || files[0].Size() != 5 {
|
||||
t.Fatalf("expected surviving recording after concurrent deletion, got %v", files)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type recordingDirEntryInfoError struct {
|
||||
fs.DirEntry
|
||||
err error
|
||||
}
|
||||
|
||||
func (entry recordingDirEntryInfoError) Info() (fs.FileInfo, error) {
|
||||
return nil, entry.err
|
||||
}
|
||||
|
||||
func TestReadRecordingDirectoryReportsOtherScanErrors(t *testing.T) {
|
||||
for _, origin := range []string{"Info", "WalkDir"} {
|
||||
t.Run(origin, func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
filePath := filepath.Join(directory, "recording.mp4")
|
||||
if err := os.WriteFile(filePath, []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantErr := &fs.PathError{Op: "lstat", Path: filePath, Err: fs.ErrPermission}
|
||||
files, err := readRecordingDirectory(directory, func(root string, visit fs.WalkDirFunc) error {
|
||||
if origin == "Info" {
|
||||
return visit(filePath, recordingDirEntryInfoError{DirEntry: entries[0], err: wantErr}, nil)
|
||||
}
|
||||
return visit(filePath, nil, wantErr)
|
||||
})
|
||||
if !errors.Is(err, fs.ErrPermission) || files != nil {
|
||||
t.Fatalf("scan = %v, %v; want permission error without partial results", files, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptDirectoryIncludesDatedRecordingsAndSkipsOutputs(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
key := []byte("0123456789abcdef0123456789abcdef")
|
||||
payload := []byte("recording-payload")
|
||||
ciphertext, err := encryption.AesEncrypt(payload, string(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := []string{"legacy.mp4", "2026/01/02/recording.mp4"}
|
||||
for _, name := range names {
|
||||
filePath := filepath.Join(directory, name)
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filePath, []byte(ciphertext), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Decrypt(directory, key)
|
||||
Decrypt(directory, key)
|
||||
for _, name := range names {
|
||||
output := filepath.Join(directory, filepath.Dir(name), "decrypted", filepath.Base(name))
|
||||
content, err := os.ReadFile(output)
|
||||
if err != nil || string(content) != string(payload) {
|
||||
t.Fatalf("decrypted %q = %q, %v", name, content, err)
|
||||
}
|
||||
}
|
||||
if got := NumberOfMP4sInDirectory(directory); got != len(names) {
|
||||
t.Fatalf("recording count = %d, want %d (excluding decrypted outputs)", got, len(names))
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,83 @@ See the section about [running tests](https://facebook.github.io/create-react-ap
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build script then replaces `../machinery/www` with the generated assets.
|
||||
Run it in an isolated checkout or container when validating dependencies.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
## Dependency maintenance
|
||||
|
||||
Use the Node 22 image pinned in the root Dockerfile and Yarn Classic:
|
||||
|
||||
```sh
|
||||
yarn install --frozen-lockfile
|
||||
CI=true yarn test --watchAll=false --runInBand
|
||||
yarn test:dependencies
|
||||
yarn build
|
||||
```
|
||||
|
||||
Keep the targeted `underscore` resolution: `jsonpath` pins a vulnerable version.
|
||||
That resolution stays within the same major version. Axios remains on
|
||||
its maintained 0.x line; `src/api/api.test.js` checks the HTTP behavior used by
|
||||
the app without mocking Axios. Localization uses `i18next-xhr-backend`, not the
|
||||
removed, unused `i18next-http-backend` dependency.
|
||||
|
||||
Additional targeted build-time upgrades are validated against the pinned
|
||||
Node 22 / webpack 5 toolchain:
|
||||
|
||||
- [`resolve-url-loader` 5](https://github.com/bholloway/resolve-url-loader/releases/tag/5.0.0)
|
||||
upgrades PostCSS to 8, drops Node <12 / webpack <4, and removes the deprecated
|
||||
`rework` engine. CRA uses the supported PostCSS engine and unchanged `root` /
|
||||
`sourceMap` options. Dependency tests check relative SCSS asset URLs, query
|
||||
strings and fragments with source maps enabled and disabled.
|
||||
- [`serialize-javascript` 7](https://github.com/yahoo/serialize-javascript/releases/tag/v7.0.0)
|
||||
requires Node >=20, matching this UI's declared minimum. Its CommonJS serializer
|
||||
API remains compatible with CSS minimizer and Rollup worker options.
|
||||
Versions 5/6 changed sparse-array and URL serialization, neither of which these
|
||||
callers use. Tests cover both consumers' function / regular-expression
|
||||
serialization and the real Workbox Rollup minification worker; the production
|
||||
build exercises CSS minimization. The targeted resolutions use patched 7.1.4.
|
||||
- [`@svgr/webpack` 6](https://react-svgr.com/docs/migrate/) supports webpack 5 and
|
||||
the existing `file-loader` combination. CRA disables SVGO and does not use a
|
||||
custom template or call `@svgr/core`, so the documented breaking changes do
|
||||
not affect this pipeline. Tests use CRA's actual SVG rule to check asset URLs,
|
||||
React 17 rendering, viewBox preservation, titles and forwarded refs.
|
||||
- [`webpack-dev-middleware` 7](https://github.com/webpack/webpack-dev-middleware/releases/tag/v7.0.0)
|
||||
raises the Node minimum and upgrades its internal memory filesystem; version 6
|
||||
raised the webpack minimum to 5. Both requirements match this toolchain, and
|
||||
webpack-dev-server remains on CRA's 4.x line. Tests exercise real server
|
||||
compilation, in-memory asset serving, missing assets and invalidation with
|
||||
patched middleware 7.4.6. No build artifacts are written by this test.
|
||||
|
||||
Refreshing the lockfile does not resolve every legacy-toolchain advisory.
|
||||
Create React App 5, `@kerberos-io/ui`'s SVG tooling, and their transitive
|
||||
dependencies still require upstream upgrades or explicit, tested migrations.
|
||||
Do not force incompatible transitive major versions just to clear a scan.
|
||||
|
||||
The obsolete `svg-react-loader` dependency declared by `@kerberos-io/ui` is
|
||||
replaced with the maintained `@svgr/webpack` 8.1.0 through a Yarn package alias.
|
||||
This removes its `css -> source-map-resolve -> decode-uri-component` chain;
|
||||
it does not force an incompatible ESM version of the decoder into a CommonJS
|
||||
caller. The shared UI ships precompiled SVG components and does not invoke that
|
||||
loader. CRA's active SVG rule remains on the separately tested SVGR 6 path.
|
||||
SVGR 8.1.0 is also pinned explicitly as a development dependency, so dependency
|
||||
scanners inventory and check its actual package identity even if their Yarn
|
||||
alias parser reports an additional `svg-react-loader@8.1.0` entry. Tests verify
|
||||
that both names resolve to identical package metadata; the alias is not used
|
||||
to conceal an unscanned replacement.
|
||||
|
||||
This is a scoped consumer migration, **not** a general drop-in implementation of
|
||||
the old loader's `raw`, `filters`, `propsMap` or other query options. Dependency
|
||||
tests guard against shared UI code starting to call the legacy API, resolve the
|
||||
alias from the shared package, and compile/render its maintained replacement
|
||||
with React 17, titles, forwarded refs, viewBox and caller props. Rendering is
|
||||
compared to CRA's actual SVG pipeline. Recheck these assumptions on shared UI
|
||||
upgrades; use SVGR's supported options for any new loader calls.
|
||||
|
||||
### `yarn eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
+16
-2
@@ -9,7 +9,7 @@
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^11.2.7",
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"axios": "^0.21.4",
|
||||
"axios": "^0.35.0",
|
||||
"classnames": "^2.3.1",
|
||||
"connected-react-router": "^6.9.3",
|
||||
"eslint-config-prettier": "8.5.*",
|
||||
@@ -18,7 +18,6 @@
|
||||
"history": "4.10.1",
|
||||
"i18next": "^21.9.1",
|
||||
"i18next-browser-languagedetector": "^6.1.5",
|
||||
"i18next-http-backend": "^1.4.1",
|
||||
"i18next-xhr-backend": "^3.2.2",
|
||||
"md5": "^2.3.0",
|
||||
"prettier": "^2.7.1",
|
||||
@@ -41,6 +40,19 @@
|
||||
"vott-ct": "^2.3.3",
|
||||
"web-vitals": "^0.2.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"**/svg-react-loader": "npm:@svgr/webpack@8.1.0",
|
||||
"**/jsonpath/underscore": "^1.13.8",
|
||||
"**/resolve-url-loader": "^5.0.0",
|
||||
"**/css-minimizer-webpack-plugin/serialize-javascript": "^7.1.4",
|
||||
"**/rollup-plugin-terser/serialize-javascript": "^7.1.4",
|
||||
"**/react-scripts/@svgr/webpack": "^6.5.1",
|
||||
"**/@kerberos-io/ui/@svgr/webpack": "^6.5.1",
|
||||
"**/webpack-dev-server/webpack-dev-middleware": "^7.4.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"browser": {
|
||||
"crypto": false
|
||||
},
|
||||
@@ -48,6 +60,7 @@
|
||||
"start": "DISABLE_ESLINT_PLUGIN=true react-scripts start",
|
||||
"build": "DISABLE_ESLINT_PLUGIN=true GENERATE_SOURCEMAP=false REACT_APP_ENVIRONMENT=production react-scripts build && rm -rf ../machinery/www && mv build ../machinery/www",
|
||||
"test": "DISABLE_ESLINT_PLUGIN=true react-scripts test",
|
||||
"test:dependencies": "node --test scripts/dependency-compatibility.test.cjs",
|
||||
"eject": "react-scripts eject",
|
||||
"lint": "eslint --debug 'src/**/*.{js,jsx,ts,tsx}'",
|
||||
"format": "prettier --write \"**/*.{js,jsx,json,md}\""
|
||||
@@ -65,6 +78,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-airbnb": "^18.2.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const { createRequire } = require('node:module');
|
||||
const { test } = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const requireFromCRA = createRequire(
|
||||
require.resolve('react-scripts/package.json')
|
||||
);
|
||||
const resolveUrlLoader = requireFromCRA('resolve-url-loader');
|
||||
const { SourceMapGenerator } = createRequire(
|
||||
requireFromCRA.resolve('resolve-url-loader')
|
||||
)('source-map');
|
||||
|
||||
for (const sourceMap of [false, true]) {
|
||||
test(`SCSS URL resolution preserves nested assets and query strings (sourceMap=${sourceMap})`, async () => {
|
||||
const root = path.resolve(__dirname, '../src');
|
||||
const content =
|
||||
'.fixture { background: url("../../header-minimal-logo-36x36.svg?theme=dark#icon"); }';
|
||||
const map = new SourceMapGenerator({ file: path.join(root, 'App.scss') });
|
||||
map.addMapping({
|
||||
generated: { line: 1, column: 0 },
|
||||
original: { line: 1, column: 0 },
|
||||
source: path.join(root, 'components/LanguageSelect/LanguageSelect.scss'),
|
||||
});
|
||||
map.setSourceContent(
|
||||
path.join(root, 'components/LanguageSelect/LanguageSelect.scss'),
|
||||
content
|
||||
);
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
resolveUrlLoader.call(
|
||||
{
|
||||
resourcePath: path.join(root, 'App.scss'),
|
||||
context: root,
|
||||
rootContext: path.resolve(__dirname, '..'),
|
||||
fs,
|
||||
sourceMap,
|
||||
query: { sourceMap, root },
|
||||
getOptions() {
|
||||
return this.query;
|
||||
},
|
||||
cacheable() {},
|
||||
emitWarning: reject,
|
||||
async() {
|
||||
return (error, css, outputMap) => {
|
||||
if (error) reject(error);
|
||||
else resolve({ css, outputMap });
|
||||
};
|
||||
},
|
||||
},
|
||||
content,
|
||||
map.toJSON()
|
||||
);
|
||||
});
|
||||
|
||||
assert.match(
|
||||
result.css,
|
||||
/url\("\.\/header-minimal-logo-36x36\.svg\?theme=dark#icon"\)/
|
||||
);
|
||||
assert.equal(Boolean(result.outputMap), sourceMap);
|
||||
});
|
||||
}
|
||||
|
||||
for (const parent of ['css-minimizer-webpack-plugin', 'rollup-plugin-terser']) {
|
||||
test(`${parent} serializes worker options with functions and regular expressions`, () => {
|
||||
const requireFromParent = createRequire(requireFromCRA.resolve(parent));
|
||||
const serialize = requireFromParent('serialize-javascript');
|
||||
const version = requireFromParent(
|
||||
'serialize-javascript/package.json'
|
||||
).version;
|
||||
assert.match(version, /^7\./);
|
||||
|
||||
const options = {
|
||||
comments: /^keep/,
|
||||
minify: (input) => input.trim(),
|
||||
nested: { passes: 2 },
|
||||
};
|
||||
const restored = vm.runInNewContext(`(${serialize(options)})`);
|
||||
|
||||
assert.equal(restored.comments.test('keep license'), true);
|
||||
assert.equal(restored.comments.test('discard comment'), false);
|
||||
assert.equal(restored.minify(' body '), 'body');
|
||||
assert.equal(restored.nested.passes, 2);
|
||||
});
|
||||
}
|
||||
|
||||
test('Workbox Rollup minification passes serialized options through its real worker', async () => {
|
||||
const { rollup } = requireFromCRA('rollup');
|
||||
const { terser } = requireFromCRA('rollup-plugin-terser');
|
||||
const bundle = await rollup({
|
||||
input: 'compatibility-entry',
|
||||
plugins: [
|
||||
{
|
||||
name: 'compatibility-fixture',
|
||||
resolveId: (id) => (id === 'compatibility-entry' ? id : null),
|
||||
load: () =>
|
||||
'/*! keep license */ export const double = (value) => value * 2;',
|
||||
},
|
||||
terser({
|
||||
numWorkers: 1,
|
||||
mangle: false,
|
||||
compress: { passes: 2 },
|
||||
format: { comments: /keep/ },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const { output } = await bundle.generate({
|
||||
format: 'cjs',
|
||||
sourcemap: true,
|
||||
});
|
||||
const exports = {};
|
||||
vm.runInNewContext(output[0].code, { exports });
|
||||
|
||||
assert.equal(exports.double(21), 42);
|
||||
assert.match(output[0].code, /2\*value/);
|
||||
assert.ok(output[0].map);
|
||||
} finally {
|
||||
await bundle.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('shared UI ships precompiled SVGs without legacy loader calls', () => {
|
||||
const packagePath = require.resolve('@kerberos-io/ui/package.json');
|
||||
const requireFromUI = createRequire(packagePath);
|
||||
const replacement = requireFromUI('svg-react-loader/package.json');
|
||||
assert.equal(replacement.name, '@svgr/webpack');
|
||||
assert.equal(replacement.version, '8.1.0');
|
||||
assert.deepEqual(replacement, require('@svgr/webpack/package.json'));
|
||||
assert.equal(requireFromCRA('@svgr/webpack/package.json').version, '6.5.1');
|
||||
|
||||
const lib = path.join(path.dirname(packagePath), 'lib');
|
||||
const files = fs
|
||||
.readdirSync(lib, { recursive: true })
|
||||
.filter((file) => /\.[cm]?js$/.test(file));
|
||||
assert.ok(files.length > 0);
|
||||
for (const file of files) {
|
||||
assert.doesNotMatch(
|
||||
fs.readFileSync(path.join(lib, file), 'utf8'),
|
||||
/svg-react-loader/,
|
||||
`Shared UI now uses the legacy loader API in ${file}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('CRA and replacement SVG loaders preserve rendering through webpack-dev-server', async () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
const webpack = requireFromCRA('webpack');
|
||||
const DevServer = requireFromCRA('webpack-dev-server');
|
||||
const React = require('react');
|
||||
const { renderToStaticMarkup } = require('react-dom/server');
|
||||
const craConfig = requireFromCRA('./config/webpack.config.js')('production');
|
||||
const svgRule = craConfig.module.rules
|
||||
.find((rule) => rule.oneOf)
|
||||
.oneOf.find(
|
||||
(rule) => rule.test instanceof RegExp && rule.test.test('icon.svg')
|
||||
);
|
||||
assert.ok(svgRule);
|
||||
|
||||
const logoPath = path.resolve(
|
||||
__dirname,
|
||||
'../src/header-minimal-logo-36x36.svg'
|
||||
);
|
||||
const requireFromUI = createRequire(
|
||||
require.resolve('@kerberos-io/ui/package.json')
|
||||
);
|
||||
const replacementRequest = `!!${requireFromUI.resolve(
|
||||
'svg-react-loader'
|
||||
)}?${JSON.stringify({
|
||||
svgo: false,
|
||||
titleProp: true,
|
||||
ref: true,
|
||||
})}!${logoPath}`;
|
||||
const entry = `import url, { ReactComponent as Logo } from ${JSON.stringify(
|
||||
logoPath
|
||||
)}; import ReplacementLogo from ${JSON.stringify(
|
||||
replacementRequest
|
||||
)}; export { url, Logo, ReplacementLogo };`;
|
||||
const compiler = webpack({
|
||||
mode: 'development',
|
||||
target: 'node',
|
||||
entry: `data:text/javascript,${encodeURIComponent(entry)}`,
|
||||
output: {
|
||||
path: path.resolve(__dirname, '.dependency-webpack-output'),
|
||||
filename: 'compatibility.js',
|
||||
publicPath: '/compiled/',
|
||||
library: { type: 'commonjs2' },
|
||||
},
|
||||
externals: { react: 'commonjs react' },
|
||||
module: { rules: [{ ...svgRule, issuer: undefined }] },
|
||||
infrastructureLogging: { level: 'error' },
|
||||
stats: 'errors-only',
|
||||
});
|
||||
const server = new DevServer(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
static: false,
|
||||
hot: false,
|
||||
client: false,
|
||||
liveReload: false,
|
||||
webSocketServer: false,
|
||||
setupExitSignals: false,
|
||||
devMiddleware: { publicPath: '/compiled/', stats: 'errors-only' },
|
||||
},
|
||||
compiler
|
||||
);
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const stats = await new Promise((resolve) =>
|
||||
server.middleware.waitUntilValid(resolve)
|
||||
);
|
||||
assert.equal(
|
||||
stats.hasErrors(),
|
||||
false,
|
||||
stats.toString({ all: false, errors: true })
|
||||
);
|
||||
|
||||
const baseURL = `http://127.0.0.1:${
|
||||
server.server.address().port
|
||||
}/compiled/`;
|
||||
const response = await fetch(`${baseURL}compatibility.js`);
|
||||
assert.equal(response.status, 200);
|
||||
const module = { exports: {} };
|
||||
vm.runInNewContext(await response.text(), {
|
||||
module,
|
||||
exports: module.exports,
|
||||
require,
|
||||
});
|
||||
const { Logo, ReplacementLogo, url } = module.exports;
|
||||
assert.equal(Logo.$$typeof, Symbol.for('react.forward_ref'));
|
||||
assert.equal(ReplacementLogo.$$typeof, Symbol.for('react.forward_ref'));
|
||||
const props = {
|
||||
title: 'Camera icon',
|
||||
titleId: 'camera-title',
|
||||
className: 'camera-icon',
|
||||
width: 48,
|
||||
ref: React.createRef(),
|
||||
};
|
||||
const markup = renderToStaticMarkup(React.createElement(Logo, props));
|
||||
assert.equal(
|
||||
renderToStaticMarkup(React.createElement(ReplacementLogo, props)),
|
||||
markup
|
||||
);
|
||||
assert.match(markup, /<svg/);
|
||||
assert.match(markup, /<title id="camera-title">Camera icon<\/title>/);
|
||||
assert.match(markup, /viewBox=/);
|
||||
assert.match(markup, /<path/);
|
||||
assert.match(url, /^\/compiled\/static\/media\/.*\.svg$/);
|
||||
assert.equal((await fetch(new URL(url, baseURL))).status, 200);
|
||||
assert.equal((await fetch(`${baseURL}missing.js`)).status, 404);
|
||||
await new Promise((resolve) => server.middleware.invalidate(resolve));
|
||||
assert.equal((await fetch(`${baseURL}compatibility.js`)).status, 200);
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/* eslint-env jest */
|
||||
import API from './api';
|
||||
import config from '../config';
|
||||
|
||||
jest.mock('../config', () => ({
|
||||
API_URL: 'http://localhost/api',
|
||||
}));
|
||||
|
||||
describe('HTTP client compatibility', () => {
|
||||
let originalAdapter;
|
||||
let adapter;
|
||||
|
||||
beforeEach(() => {
|
||||
originalAdapter = API.defaults.adapter;
|
||||
adapter = jest.fn((request) => {
|
||||
const response = {
|
||||
config: request,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
data: '{"ok":true}',
|
||||
};
|
||||
return Promise.resolve(response);
|
||||
});
|
||||
API.defaults.adapter = adapter;
|
||||
localStorage.setItem('token', 'test-token');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
API.defaults.adapter = originalAdapter;
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('preserves API URLs, bearer authentication, timeouts and JSON responses', async () => {
|
||||
const response = await API.get('dashboard', { timeout: 15000 });
|
||||
const request = adapter.mock.calls[0][0];
|
||||
|
||||
expect(request.baseURL).toBe(config.API_URL);
|
||||
expect(request.url).toBe('dashboard');
|
||||
expect(request.method).toBe('get');
|
||||
expect(request.headers.Authorization).toBe('Bearer test-token');
|
||||
expect(request.timeout).toBe(15000);
|
||||
expect(response.data).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('serializes saved configuration as JSON and reads refreshed credentials', async () => {
|
||||
localStorage.setItem('token', 'refreshed-token');
|
||||
const settings = { capture: { ipcamera: { rtsp: 'rtsp://camera/live' } } };
|
||||
|
||||
await API.post('config', settings);
|
||||
const request = adapter.mock.calls[0][0];
|
||||
|
||||
expect(request.method).toBe('post');
|
||||
expect(request.headers.Authorization).toBe('Bearer refreshed-token');
|
||||
expect(request.headers['Content-Type']).toMatch(/^application\/json/);
|
||||
expect(JSON.parse(request.data)).toEqual(settings);
|
||||
});
|
||||
|
||||
it('preserves rejected requests for API error handlers', async () => {
|
||||
const error = new Error('Request timed out');
|
||||
adapter.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(API.get('dashboard', { timeout: 15000 })).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
+4
-3
@@ -1,5 +1,6 @@
|
||||
const { hostname, host, protocol } = window.location;
|
||||
const websocketprotocol = protocol === 'http:' ? 'ws:' : 'wss:';
|
||||
const devAgentPort = process.env.REACT_APP_AGENT_API_PORT || '8082';
|
||||
|
||||
// Uncomment this when using codespaces or other special DNS names (which you can't control)
|
||||
// replace this with the DNS name of the kerberos agent server (the codespace url)
|
||||
@@ -9,9 +10,9 @@ const dev = {
|
||||
ENV: 'dev',
|
||||
// Comment the below lines, when using codespaces or other special DNS names (which you can't control)
|
||||
HOSTNAME: hostname,
|
||||
API_URL: `${protocol}//${hostname}:8080/api`,
|
||||
URL: `${protocol}//${hostname}:8080`,
|
||||
WS_URL: `${websocketprotocol}//${hostname}:8080/ws`,
|
||||
API_URL: `${protocol}//${hostname}:${devAgentPort}/api`,
|
||||
URL: `${protocol}//${hostname}:${devAgentPort}`,
|
||||
WS_URL: `${websocketprotocol}//${hostname}:${devAgentPort}/ws`,
|
||||
MODE: window['env']['mode'],
|
||||
// Uncomment, and comment the above lines, when using codespaces or other special DNS names (which you can't control)
|
||||
// HOSTNAME: externalHost,
|
||||
|
||||
+4845
-3765
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user