From 115f7e108a02ebc0bbc037cba513cb2e359b8005 Mon Sep 17 00:00:00 2001 From: Gaurav Gahlot Date: Sun, 29 Mar 2026 12:23:37 +0200 Subject: [PATCH 1/4] move onvif-video-broker Signed-off-by: Gaurav Gahlot --- brokers/onvif-video-broker/Akri.cs | 198 ++++++++++++++++++ brokers/onvif-video-broker/CredentialStore.cs | 198 ++++++++++++++++++ brokers/onvif-video-broker/Program.cs | 179 ++++++++++++++++ .../Properties/launchSettings.json | 12 ++ brokers/onvif-video-broker/README.md | 23 ++ brokers/onvif-video-broker/Startup.cs | 48 +++++ brokers/onvif-video-broker/UsernameToken.cs | 61 ++++++ .../appsettings.Development.json | 10 + brokers/onvif-video-broker/appsettings.json | 15 ++ brokers/onvif-video-broker/camera.proto | 19 ++ .../onvif-video-broker.csproj | 29 +++ 11 files changed, 792 insertions(+) create mode 100644 brokers/onvif-video-broker/Akri.cs create mode 100644 brokers/onvif-video-broker/CredentialStore.cs create mode 100644 brokers/onvif-video-broker/Program.cs create mode 100644 brokers/onvif-video-broker/Properties/launchSettings.json create mode 100644 brokers/onvif-video-broker/README.md create mode 100644 brokers/onvif-video-broker/Startup.cs create mode 100644 brokers/onvif-video-broker/UsernameToken.cs create mode 100644 brokers/onvif-video-broker/appsettings.Development.json create mode 100644 brokers/onvif-video-broker/appsettings.json create mode 100644 brokers/onvif-video-broker/camera.proto create mode 100644 brokers/onvif-video-broker/onvif-video-broker.csproj diff --git a/brokers/onvif-video-broker/Akri.cs b/brokers/onvif-video-broker/Akri.cs new file mode 100644 index 0000000..2be51ee --- /dev/null +++ b/brokers/onvif-video-broker/Akri.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.XPath; + +namespace Akri +{ + public static class Akri + { + private static string PostSoapRequest(String requestUri, String action, String soapMessage) + { + var request = (HttpWebRequest) WebRequest.CreateDefault(new Uri(requestUri)); + request.ContentType = "application/soap+xml; charset=utf-8"; + request.Method = HttpMethod.Post.ToString(); + request.Headers.Add("SOAPAction", action); + using (var stream = new StreamWriter(request.GetRequestStream(), Encoding.UTF8)) + { + stream.Write(soapMessage); + } + + Console.WriteLine($"[Akri] ONVIF request {requestUri} {action}"); + using (WebResponse requestResponse = request.GetResponse()) + { + using (StreamReader responseReader = new StreamReader(requestResponse.GetResponseStream())) + { + return responseReader.ReadToEnd(); + } + } + } + + private const String MEDIA_WSDL = "http://www.onvif.org/ver10/media/wsdl"; + private const String DEVICE_WSDL = "http://www.onvif.org/ver10/device/wsdl"; + private const String GET_SERVICE_SOAP = @""; + private const String GET_PROFILES_SOAP_TEMPLATE = @" + + {0} + + + + + "; + private const String GET_STREAMING_URI_SOAP_TEMPLATE = @" + + {0} + + + + + RTP-Unicast + + RTSP + + + {1} + + + "; + + // Regular expression pattern of environment variables that hold OPC UA DiscoveryURL + // The pattern is ONVIF_DEVICE_SERVICE_URL_ followed by 6 digit digest. e.g. + // ONVIF_DEVICE_SERVICE_URL_123456, ONVIF_DEVICE_SERVICE_URL_ABCDEF + private const string OnvifDeviceServiceUrlLabelPattern = "^ONVIF_DEVICE_SERVICE_URL_[A-F0-9]{6,6}$"; + private const string OnvifDeviceUuidLabelPattern = "^ONVIF_DEVICE_UUID_[A-F0-9]{6,6}$"; + + private static string GetMediaUrl(String device_service_url) + { + var servicesResult = PostSoapRequest( + device_service_url, + String.Format("{0}/{1}", DEVICE_WSDL, "GetService"), + GET_SERVICE_SOAP + ); + var document = new XPathDocument(new XmlTextReader(new StringReader(servicesResult))); + var navigator = document.CreateNavigator(); + var xpath = String.Format("//*[local-name()='GetServicesResponse']/*[local-name()='Service' and *[local-name()='Namespace']/text() ='{0}']/*[local-name()='XAddr']/text()", MEDIA_WSDL); + var media_url = navigator.SelectSingleNode(xpath).ToString(); + Console.WriteLine($"[Akri] ONVIF media url {media_url}"); + return media_url; + } + + private static string GetProfile(String media_url, UsernameToken usernameToken) + { + var soapSecurityHeader = usernameToken.ToXml(); + var soapMessage = String.Format(GET_PROFILES_SOAP_TEMPLATE, soapSecurityHeader); + var servicesResult = PostSoapRequest( + media_url, + String.Format("{0}/{1}", MEDIA_WSDL, "GetProfiles"), + soapMessage + ); + var document = new XPathDocument(new XmlTextReader(new StringReader(servicesResult))); + var navigator = document.CreateNavigator(); + var xpath = String.Format("//*[local-name()='GetProfilesResponse']/*[local-name()='Profiles']/@token"); + var profileNodesIterator = navigator.Select(xpath); + var profiles = (from XPathNavigator @group in profileNodesIterator select @group.Value).ToList(); + profiles.Sort(); + foreach (var p in profiles) { + Console.WriteLine($"[Akri] ONVIF profile list contains: {p}"); + } + // randomly choose first profile + var profile = profiles.First(); + Console.WriteLine($"[Akri] ONVIF profile list {profile}"); + return profile; + } + + private static string GetStreamingUri(String media_url, String profile_token, UsernameToken usernameToken) + { + var soapSecurityHeader = usernameToken.ToXml(); + var soapMessage = String.Format(GET_STREAMING_URI_SOAP_TEMPLATE, soapSecurityHeader, profile_token); + var servicesResult = PostSoapRequest( + media_url, + String.Format("{0}/{1}", MEDIA_WSDL, "GetStreamUri"), + soapMessage + ); + var document = new XPathDocument(new XmlTextReader(new StringReader(servicesResult))); + var navigator = document.CreateNavigator(); + var xpath = String.Format("//*[local-name()='GetStreamUriResponse']/*[local-name()='MediaUri']/*[local-name()='Uri']/text()"); + var profileNodesIterator = navigator.Select(xpath); + var streaming_uri_list = (from XPathNavigator @group in profileNodesIterator select @group.Value).ToList(); + foreach (var u in streaming_uri_list) { + Console.WriteLine($"[Akri] ONVIF streaming uri list contains: {u}"); + } + // randomly choose first profile + var streaming_uri = streaming_uri_list.First(); + Console.WriteLine($"[Akri] ONVIF streaming uri {streaming_uri}"); + + const string rtspPrefix = "rtsp://"; + if (streaming_uri.StartsWith(rtspPrefix)) { + if (!String.IsNullOrEmpty(usernameToken.Username)) { + var password = usernameToken.Password ?? ""; + var credential_string = String.Format("{0}:{1}@", usernameToken.Username, password); + streaming_uri = streaming_uri.Substring(rtspPrefix.Length); + streaming_uri = String.Format("{0}{1}{2}", rtspPrefix, credential_string, streaming_uri); + } + } + return streaming_uri; + } + + private static List GetDeviceServiceUrls() + { + var values = new List(); + foreach (DictionaryEntry de in Environment.GetEnvironmentVariables()) + { + if (Regex.IsMatch(de.Key.ToString(), OnvifDeviceServiceUrlLabelPattern)) + { + values.Add(de.Value.ToString()); + } + } + return values; + } + + private static List GetDeviceUuids() + { + var values = new List(); + foreach (DictionaryEntry de in Environment.GetEnvironmentVariables()) + { + if (Regex.IsMatch(de.Key.ToString(), OnvifDeviceUuidLabelPattern)) + { + values.Add(de.Value.ToString()); + } + } + return values; + } + + public static string GetRtspUrl() + { + var device_uuids = GetDeviceUuids(); + var device_uuid = (device_uuids.Count != 0) ? device_uuids[0] : ""; + Credential credential = null; + if (!string.IsNullOrEmpty(device_uuid)) + { + var credentialDirectory = Environment.GetEnvironmentVariable("CREDENTIAL_DIRECTORY"); + var credentialConfigMapDirectory = Environment.GetEnvironmentVariable("CREDENTIAL_CONFIGMAP_DIRECTORY"); + var credentialStore = new CredentialStore(credentialDirectory, credentialConfigMapDirectory); + credential = credentialStore.Get(device_uuid); + } + var userNameToken = new UsernameToken(credential?.Username, credential?.Password); + + // Get the first found Onvif device service url and use it + var device_service_urls = GetDeviceServiceUrls(); + var device_service_url = (device_service_urls.Count != 0) ? device_service_urls[0] : ""; + if (string.IsNullOrEmpty(device_service_url)) + { + throw new ArgumentNullException("ONVIF_DEVICE_SERVICE_URL undefined"); + } + + var media_url = GetMediaUrl(device_service_url); + var profile = GetProfile(media_url, userNameToken); + var streaming_url = GetStreamingUri(media_url, profile, userNameToken); + return streaming_url; + } + } +} diff --git a/brokers/onvif-video-broker/CredentialStore.cs b/brokers/onvif-video-broker/CredentialStore.cs new file mode 100644 index 0000000..46d83b4 --- /dev/null +++ b/brokers/onvif-video-broker/CredentialStore.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; + +namespace Akri +{ + class Credential { + public string Username { get; set; } + public string Password { get; set; } + } + + class CredentialData { + public string Username { get; set; } + public string Password { get; set; } + public bool Base64encoded { get; set; } + } + + class CredentialRefData { + public string Username_ref { get; set; } + public string Password_ref { get; set; } + } + + class CredentialStore { + private readonly string secretDirectory; + private readonly string configMapDirectory; + + public CredentialStore(string secretDirectory, string configMapDirectory) { + this.secretDirectory = secretDirectory; + this.configMapDirectory = configMapDirectory; + } + + public Credential Get(string id) { + if (String.IsNullOrEmpty(this.secretDirectory)) { + return null; + } + + Credential defaultCredential = null; + // get credential from secrets + var (credential, isDefault) = GetCredentialFromSecret(id); + if ((credential != null) && !isDefault) { + return credential; + } + defaultCredential ??= credential; + + // get credential from credential ref list + (credential, isDefault) = GetCredentialFromCredentialRefList(id); + if ((credential != null) && !isDefault) { + return credential; + } + defaultCredential ??= credential; + + // get credential from credential list + (credential, isDefault) = GetCredentialFromCredentialList(id); + if ((credential != null) && !isDefault) { + return credential; + } + defaultCredential ??= credential; + + return defaultCredential; + } + + private (Credential, bool) GetCredentialFromCredentialRefList(string id) { + var credentialRefList = GetListContent>("device_credential_ref_list"); + if (credentialRefList == null) { + return (null, false); + } + + var allRefDictionary = new Dictionary(); + foreach (var refList in credentialRefList) { + var refEntries = GetListContent>(refList); + if (refEntries != null) { + refEntries.ToList().ForEach(x => allRefDictionary[x.Key] = x.Value); + } + } + + var isDefault = false; + if (!allRefDictionary.TryGetValue(id, out CredentialRefData credentialRefData)) { + if (!allRefDictionary.TryGetValue("default", out credentialRefData)) { + return (null, false); + } + isDefault = true; + } + Credential credential = null; + string username = ReadStringFromFile(this.secretDirectory, credentialRefData.Username_ref); + if (!String.IsNullOrEmpty(username)) { + string password = ReadStringFromFile(this.secretDirectory, credentialRefData.Password_ref); + credential = new Credential() { + Username = username, + Password = password + }; + } + + return (credential, isDefault); + } + + private (Credential, bool) GetCredentialFromCredentialList(string id) { + var credentialList = GetListContent>("device_credential_list"); + if (credentialList == null) { + return (null, false); + } + + var allCredentialDictionary = new Dictionary(); + foreach (var refList in credentialList) { + var credentialRef = ReadStringFromFile(this.secretDirectory, refList); + if (!String.IsNullOrEmpty(credentialRef)) { + var result = Deserialize>(credentialRef); + if (result != null) { + result.ToList().ForEach(x => allCredentialDictionary[x.Key] = x.Value); + } + } + } + + var isDefault = false; + if (!allCredentialDictionary.TryGetValue(id, out CredentialData credentialData)) { + if (!allCredentialDictionary.TryGetValue("default", out credentialData)) { + return (null, false); + } + isDefault = true; + } + var decodedPassword = credentialData.Password; + if (credentialData.Base64encoded) { + byte[] data = Convert.FromBase64String(credentialData.Password); + decodedPassword = System.Text.Encoding.UTF8.GetString(data); + } + + Credential credential = new Credential() { + Username = credentialData.Username, + Password = decodedPassword + }; + + return (credential, isDefault); + } + + private (Credential, bool) GetCredentialFromSecret(string id) { + // Secret uses underscore as key name, replace all dashes with underscore + id = id.Replace('-', '_'); + var credential = GetCredentialFromSecretById(id); + if (credential != null) { + return (credential, false); + } + var defaultCredential = GetCredentialFromSecretById("default"); + return (defaultCredential, true); + } + + private Credential GetCredentialFromSecretById(string id) { + string usernameFilename = String.Format("username_{0}", id); + string username = ReadStringFromFile(this.secretDirectory, usernameFilename); + if (String.IsNullOrEmpty(username)) { + return null; + } + string passwordFilename = String.Format("password_{0}", id); + string password = ReadStringFromFile(this.secretDirectory, passwordFilename); + return new Credential() { + Username = username, + Password = password + }; + } + + private TValue GetListContent(string listName) { + string listContent = null; + if (!String.IsNullOrEmpty(this.configMapDirectory)) { + listContent = ReadStringFromFile(this.configMapDirectory, listName); + } + if (String.IsNullOrEmpty(listContent)) { + listContent = ReadStringFromFile(this.secretDirectory, listName); + } + if (String.IsNullOrEmpty(listContent)) { + return default(TValue); + } + + return Deserialize(listContent); + } + + private string ReadStringFromFile(string path, string filename) { + string fileFullPath = Path.Combine(path, filename); + try { + return File.ReadAllText(fileFullPath); + } catch (Exception) { + return null; + } + } + + private TValue Deserialize(string jsonString) + { + try { + var options = new JsonSerializerOptions() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + return JsonSerializer.Deserialize(jsonString, options); + } catch (Exception) { + return default(TValue); + } + } + } +} diff --git a/brokers/onvif-video-broker/Program.cs b/brokers/onvif-video-broker/Program.cs new file mode 100644 index 0000000..18cb510 --- /dev/null +++ b/brokers/onvif-video-broker/Program.cs @@ -0,0 +1,179 @@ +using Camera; +using Grpc.Core; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; +using OpenCvSharp; +using Prometheus; +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace FrameServer +{ + public class CameraService : Camera.Camera.CameraBase + { + public override Task GetFrame( + NotifyRequest request, ServerCallContext context) + { + // Mask credential information in Program.RtspUrl to prevent the credential info shown in console output + var rtspUrlWithMaskedCredential = RtspUrlHelper.GetMaskedCredentialUrl(Program.RtspUrl); + byte[] frame = null; + lock (Program.Frames) + { + if (Program.Frames.Any()) + { + frame = Program.Frames.Pop(); + Program.JobsInQueue.Set(Program.Frames.Count); + } + + if (frame == null) + { + Console.WriteLine("No frame available for {0}", rtspUrlWithMaskedCredential); + } + else + { + Console.WriteLine("Sending frame for {0}, Q size: {1}", rtspUrlWithMaskedCredential, Program.Frames.Count); + } + } + + return Task.FromResult(new NotifyResponse + { + Camera = Program.RtspUrl, + Frame = (frame == null ? Google.Protobuf.ByteString.Empty : Google.Protobuf.ByteString.CopyFrom(frame)) + }); + } + } + + // based on https://stackoverflow.com/questions/14101310/limit-the-size-of-a-generic-collection + public class LimitedSizeStack : LinkedList + { + private readonly int _maxSize; + public LimitedSizeStack(int maxSize) + { + _maxSize = maxSize; + } + + public void Push(T item) + { + this.AddFirst(item); + + if (this.Count > _maxSize) + this.RemoveLast(); + } + + public T Pop() + { + var item = this.First.Value; + this.RemoveFirst(); + return item; + } + } + + public static class RtspUrlHelper { + public static string GetMaskedCredentialUrl(string rtspUrl) { + const string rtspPrefix = "rtsp://"; + var maskedRtspUrl = rtspUrl; + if (rtspUrl.StartsWith(rtspPrefix)) { + var atPos = rtspUrl.IndexOf('@', rtspPrefix.Length); + if (atPos != -1) { + maskedRtspUrl = rtspUrl.Substring(atPos); + maskedRtspUrl = String.Format("{0}----:----{1}", rtspPrefix, maskedRtspUrl); + } + } + return maskedRtspUrl; + } + } + + class Program + { + public static Task FrameTask; + public static string RtspUrl; + public static LimitedSizeStack Frames; + + static void Main(string[] args) + { + var frameBufferSizeSetting = Environment.GetEnvironmentVariable("FRAME_BUFFER_SIZE"); + int frameBufferSize = + string.IsNullOrEmpty(frameBufferSizeSetting) ? 2 : int.Parse(frameBufferSizeSetting); + Frames = new LimitedSizeStack(frameBufferSize); + if (Frames == null) { + throw new ArgumentNullException("Unable to create Frames"); + } + + RtspUrl = Environment.GetEnvironmentVariable("RTSP_URL"); + if (string.IsNullOrEmpty(RtspUrl)) { + RtspUrl = Akri.Akri.GetRtspUrl(); + } + if (string.IsNullOrEmpty(RtspUrl)) + { + throw new ArgumentNullException("Unable to find RTSP URL"); + } + + CamerasCounter.Inc(); + + FrameTask = Task.Run(() => Process(RtspUrl)); + + var metricServer = new KestrelMetricServer(port: 8080); + metricServer.Start(); + + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + + public static readonly Gauge JobsInQueue = Metrics.CreateGauge( + "cached_frames", + "Number of cached camera frames."); + + private static readonly Counter CamerasCounter = Metrics.CreateCounter( + "cameras", + "Number of connected cameras."); + + private static readonly Counter CameraDisconnectCounter = Metrics.CreateCounter( + "camera_disconnects", + "Number of times camera connection had to be restablished."); + + static void Process(string videoPath) + { + // Mask credential information in videoPath to prevent the credential info shown in console output + var videoPathWithMaskedCredential = RtspUrlHelper.GetMaskedCredentialUrl(videoPath); + Console.WriteLine($"[VideoProcessor] Processing RTSP stream: {videoPathWithMaskedCredential}"); + + while (true) + { + var capture = new VideoCapture(videoPath); + Console.WriteLine("Ready " + capture.IsOpened()); + + using (var image = new Mat()) // Frame image buffer + { + // Loop while we can read an image (aka: image.Empty is not true) + while (capture.Read(image) && !image.Empty()) + { + lock (Frames) + { + var imageBytes = image.ToBytes(); + Frames.Push(imageBytes); + JobsInQueue.Set(Frames.Count); + Console.WriteLine("Adding frame from {0}, Q size: {1}, frame size: {2}", videoPathWithMaskedCredential, Program.Frames.Count, imageBytes.Length); + } + } + } + + CameraDisconnectCounter.Inc(); + Console.WriteLine($"[VideoProcessor] Reopening"); + } + } + } +} + diff --git a/brokers/onvif-video-broker/Properties/launchSettings.json b/brokers/onvif-video-broker/Properties/launchSettings.json new file mode 100644 index 0000000..53ea44d --- /dev/null +++ b/brokers/onvif-video-broker/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "onvif-video-broker": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "https://localhost:5001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/brokers/onvif-video-broker/README.md b/brokers/onvif-video-broker/README.md new file mode 100644 index 0000000..e92968b --- /dev/null +++ b/brokers/onvif-video-broker/README.md @@ -0,0 +1,23 @@ +# ONVIF Video Broker + +Sample broker for for Akri's [ONVIF Configuration](https://docs.akri.sh/discovery-handlers/onvif). +Pulls video frames from the rtsp stream of the ONVIF camera at `ONVIF_DEVICE_SERVICE_URL`. +Then, it serves these frames over a gRPC interface. + +## Running + +1. Install .NET according to [.NET instructions](https://docs.microsoft.com/dotnet/install/linux-ubuntu) +1. Install [opencvsharp](https://github.com/shimat/opencvsharp), the OpenCV wrapper for .NET +1. Build + + ```sh + cd ./brokers/onvif-video-broker + dotnet build + ``` + +1. Run the broker, passing in the ONVIF service URL for the camera it should pull frames from. + + ```sh + ONVIF_DEVICE_SERVICE_URL_ABCDEF=http://10.1.2.3:1000/onvif/device_service dotnet run + ``` + diff --git a/brokers/onvif-video-broker/Startup.cs b/brokers/onvif-video-broker/Startup.cs new file mode 100644 index 0000000..4eb5544 --- /dev/null +++ b/brokers/onvif-video-broker/Startup.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Prometheus; + +namespace FrameServer +{ + public class Startup + { + // This method gets called by the runtime. Use this method to add services to the container. + // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 + public void ConfigureServices(IServiceCollection services) + { + services.AddGrpc(); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseRouting(); + app.UseHttpMetrics(); + + app.UseEndpoints(endpoints => + { + endpoints.MapGrpcService(); + + endpoints.MapMetrics(); + + endpoints.MapGet("/", async context => + { + await context.Response.WriteAsync("Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909"); + }); + }); + } + } +} + diff --git a/brokers/onvif-video-broker/UsernameToken.cs b/brokers/onvif-video-broker/UsernameToken.cs new file mode 100644 index 0000000..1cba080 --- /dev/null +++ b/brokers/onvif-video-broker/UsernameToken.cs @@ -0,0 +1,61 @@ +using System; +using System.Text; +using System.Security.Cryptography; + +namespace Akri +{ + class UsernameToken { + public string Username { get; } + public string Password { get; } + + public UsernameToken(string username, string password) + { + this.Username = username; + this.Password = password; + } + + public string ToXml() { + if (this.Username is null) { + return ""; + } + var password = this.Password?? ""; + + var nonce = CalculateNonce(); + var created = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + var passwordDigest = CalculatePasswordDigest(nonce, created, password); + return String.Format(SOAP_SECURITY_HEADER_TEMPLATE, this.Username, passwordDigest, nonce, created); + } + + private const String SOAP_SECURITY_HEADER_TEMPLATE = @" + + {0} + {1} + {2} + {3} + + "; + + private string CalculateNonce() + { + var buffer = new byte[16]; + using (var r = RandomNumberGenerator.Create()) + { + r.GetBytes(buffer); + } + return Convert.ToBase64String(buffer); + } + + private string CalculatePasswordDigest(string nonceStr, string created, string password) + { + var nonce = Convert.FromBase64String(nonceStr); + var createdBytes = Encoding.UTF8.GetBytes(created); + var passwordBytes = Encoding.UTF8.GetBytes(password); + var combined = new byte[createdBytes.Length + nonce.Length + passwordBytes.Length]; + Buffer.BlockCopy(nonce, 0, combined, 0, nonce.Length); + Buffer.BlockCopy(createdBytes, 0, combined, nonce.Length, createdBytes.Length); + Buffer.BlockCopy(passwordBytes, 0, combined, nonce.Length + createdBytes.Length, passwordBytes.Length); + + return Convert.ToBase64String(SHA1.Create().ComputeHash(combined)); + } + } +} diff --git a/brokers/onvif-video-broker/appsettings.Development.json b/brokers/onvif-video-broker/appsettings.Development.json new file mode 100644 index 0000000..eec2e4c --- /dev/null +++ b/brokers/onvif-video-broker/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "System": "Information", + "Grpc": "Information", + "Microsoft": "Information" + } + } +} diff --git a/brokers/onvif-video-broker/appsettings.json b/brokers/onvif-video-broker/appsettings.json new file mode 100644 index 0000000..1f29241 --- /dev/null +++ b/brokers/onvif-video-broker/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*", + "Kestrel": { + "EndpointDefaults": { + "Protocols": "Http2" + } + } +} diff --git a/brokers/onvif-video-broker/camera.proto b/brokers/onvif-video-broker/camera.proto new file mode 100644 index 0000000..e3b7c44 --- /dev/null +++ b/brokers/onvif-video-broker/camera.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +option csharp_namespace = "Camera"; + +package camera; + +service Camera { + rpc GetFrame (NotifyRequest) returns (NotifyResponse); +} + +message NotifyRequest { +} + +message NotifyResponse { + bytes frame = 1; + string camera = 2; +} + + diff --git a/brokers/onvif-video-broker/onvif-video-broker.csproj b/brokers/onvif-video-broker/onvif-video-broker.csproj new file mode 100644 index 0000000..60cbd8a --- /dev/null +++ b/brokers/onvif-video-broker/onvif-video-broker.csproj @@ -0,0 +1,29 @@ + + + + Exe + netcoreapp3.1 + Linux + . + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + From 730eebd56355c19a4d0e4f7199f71969e329b434 Mon Sep 17 00:00:00 2001 From: Gaurav Gahlot Date: Sun, 29 Mar 2026 12:34:32 +0200 Subject: [PATCH 2/4] add the workflow Signed-off-by: Gaurav Gahlot --- .../build-onvif-video-broker-container.yml | 64 +++++++++++++++++++ Makefile | 28 +++++++- build/brokers/Dockerfile.onvif-video-broker | 33 ++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/build-onvif-video-broker-container.yml create mode 100644 build/brokers/Dockerfile.onvif-video-broker diff --git a/.github/workflows/build-onvif-video-broker-container.yml b/.github/workflows/build-onvif-video-broker-container.yml new file mode 100644 index 0000000..088861e --- /dev/null +++ b/.github/workflows/build-onvif-video-broker-container.yml @@ -0,0 +1,64 @@ +name: Build ONVIF Broker (.NET) + +on: + push: + branches: [ main ] + paths: + - .github/workflows/build-onvif-video-broker-container.yml + - build/brokers/Dockerfile.onvif-video-broker + - brokers/onvif-video-broker/** + - version.txt + - Makefile + pull_request: + branches: [ main ] + paths: + - .github/workflows/build-onvif-video-broker-container.yml + - build/brokers/Dockerfile.onvif-video-broker + - brokers/onvif-video-broker/** + - version.txt + - Makefile + release: + types: + - published + +env: + AKRI_COMPONENT: onvif-video-broker + MAKEFILE_COMPONENT: onvif + +jobs: + build-broker: + runs-on: ubuntu-latest + timeout-minutes: 20 + + # Allow GITHUB_TOKEN to push to GHCR + permissions: + contents: read + packages: write + + steps: + - name: Checkout the head commit of the branch + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Get version.txt + id: version-string + run: | + echo "version=$(cat version.txt)" >> $GITHUB_OUTPUT + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + if: github.event_name == 'push' || github.event_name == 'release' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + run: | + make onvif-video-broker PREFIX=ghcr.io/project-akri/akri $(${{ github.event_name != 'pull_request' }} && echo "PUSH=1") diff --git a/Makefile b/Makefile index 86e7dfb..f4ba0bf 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,7 @@ apps: anomaly-detection-app video-streaming-app --file build/apps/Dockerfile.python-app . .PHONY: brokers -brokers: udev-video-broker +brokers: udev-video-broker onvif-video-broker udev-video-broker: docker buildx build $(COMMON_DOCKER_BUILD_ARGS) \ @@ -50,3 +50,29 @@ udev-video-broker: --build-arg EXTRA_CARGO_ARGS="$(if $(BUILD_RELEASE_FLAG), --release)" \ --file build/brokers/Dockerfile.rust \ brokers/$@ + +# Still use old-ish style for onvif-video-broker as app uses .NET 3.1 that doesn't have multi-arch manifest +onvif-video-broker: onvif-video-broker-multiarch + +onvif-video-broker-multiarch: onvif-video-broker-amd64 onvif-video-broker-arm64 onvif-video-broker-arm32 +ifeq (1, $(PUSH)) + docker buildx imagetools create --tag "$(PREFIX)/onvif-video-broker:$(LABEL_PREFIX)" +endif + +ONVIF_BUILDX_PUSH_OUTPUT = type=image,name=$(PREFIX)/onvif-video-broker,push-by-digest=true,name-canonical=true,push=true +ONVIF_BUILDX_ARGS = $(if $(LOAD), --load --tag $(PREFIX)/onvif-video-broker:$(LABEL_PREFIX)) $(if $(PUSH), --output $(ONVIF_BUILDX_PUSH_OUTPUT)) -f build/brokers/Dockerfile.onvif-video-broker + +onvif-video-broker-amd64: +ifneq (,or(findstring(amd64,$(PLATFORMS)), findstring(x86_64,$(PLATFORMS)))) + docker buildx build $(ONVIF_BUILDX_ARGS) $(if $(PUSH), --iidfile onvif-video-broker.sha-amd64) --build-arg OUTPUT_PLATFORM_TAG=$(USE_OPENCV_BASE_VERSION)-$(AMD64_SUFFIX) --build-arg DOTNET_PUBLISH_RUNTIME=linux-x64 . +endif + +onvif-video-broker-arm32: +ifneq (,findstring(arm/v7,$(PLATFORMS))) + docker buildx build $(ONVIF_BUILDX_ARGS) $(if $(PUSH), --iidfile onvif-video-broker.sha-arm32) --build-arg OUTPUT_PLATFORM_TAG=$(USE_OPENCV_BASE_VERSION)-$(ARM32V7_SUFFIX) --build-arg DOTNET_PUBLISH_RUNTIME=linux-arm . +endif + +onvif-video-broker-arm64: +ifneq (,or(findstring(aarch64,$(PLATFORMS)),findstring(arm64,$(PLATFORMS)))) + docker buildx build $(ONVIF_BUILDX_ARGS) $(if $(PUSH), --iidfile onvif-video-broker.sha-arm64) --build-arg OUTPUT_PLATFORM_TAG=$(USE_OPENCV_BASE_VERSION)-$(ARM64V8_SUFFIX) --build-arg DOTNET_PUBLISH_RUNTIME=linux-arm64 . +endif diff --git a/build/brokers/Dockerfile.onvif-video-broker b/build/brokers/Dockerfile.onvif-video-broker new file mode 100644 index 0000000..2299f2f --- /dev/null +++ b/build/brokers/Dockerfile.onvif-video-broker @@ -0,0 +1,33 @@ +ARG BUILD_PLATFORM_TAG=3.1-bullseye +ARG OUTPUT_PLATFORM_TAG=3.1-bullseye-slim +ARG DOTNET_PUBLISH_RUNTIME=linux-x64 + +FROM mcr.microsoft.com/dotnet/sdk:${BUILD_PLATFORM_TAG} AS build +ARG BUILD_PLATFORM_TAG +RUN echo "Build base image: mcr.microsoft.com/dotnet/sdk:${BUILD_PLATFORM_TAG}" + +WORKDIR /src +COPY ["brokers/onvif-video-broker/onvif-video-broker.csproj", "."] +RUN find . && dotnet restore "onvif-video-broker.csproj" +COPY ["brokers/onvif-video-broker", "."] +RUN find . && dotnet build "onvif-video-broker.csproj" -c Release -o /app/build + +FROM build AS publish +ARG DOTNET_PUBLISH_RUNTIME +RUN echo "Publishing to: ${DOTNET_PUBLISH_RUNTIME}" && \ + dotnet publish -r ${DOTNET_PUBLISH_RUNTIME} "onvif-video-broker.csproj" -c Release -o /app/publish + +FROM ghcr.io/project-akri/akri/opencvsharp-build:${OUTPUT_PLATFORM_TAG} AS final +ARG OUTPUT_PLATFORM_TAG +RUN echo "Output base image: ghcr.io/project-akri/akri/opencvsharp-build:${OUTPUT_PLATFORM_TAG}" + +WORKDIR /app +COPY --from=publish /app/publish . + +# Link the container to the Akri repository +LABEL org.opencontainers.image.source https://github.com/project-akri/akri + +EXPOSE 8083 +ENV ASPNETCORE_URLS=http://*:8083 + +CMD dotnet onvif-video-broker.dll From 80f9080e58cdb8903e86a72772f592a9978eff39 Mon Sep 17 00:00:00 2001 From: Gaurav Gahlot Date: Sun, 29 Mar 2026 12:42:04 +0200 Subject: [PATCH 3/4] add missing varible Signed-off-by: Gaurav Gahlot --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index f4ba0bf..3e629b7 100644 --- a/Makefile +++ b/Makefile @@ -59,6 +59,7 @@ ifeq (1, $(PUSH)) docker buildx imagetools create --tag "$(PREFIX)/onvif-video-broker:$(LABEL_PREFIX)" endif +USE_OPENCV_BASE_VERSION = 0.0.11 ONVIF_BUILDX_PUSH_OUTPUT = type=image,name=$(PREFIX)/onvif-video-broker,push-by-digest=true,name-canonical=true,push=true ONVIF_BUILDX_ARGS = $(if $(LOAD), --load --tag $(PREFIX)/onvif-video-broker:$(LABEL_PREFIX)) $(if $(PUSH), --output $(ONVIF_BUILDX_PUSH_OUTPUT)) -f build/brokers/Dockerfile.onvif-video-broker From 482f410509d02a877bd3444c6424127fc64af4e8 Mon Sep 17 00:00:00 2001 From: Gaurav Gahlot Date: Sun, 29 Mar 2026 12:44:10 +0200 Subject: [PATCH 4/4] add missing varible Signed-off-by: Gaurav Gahlot --- Makefile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 3e629b7..916fa5f 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,11 @@ TIMESTAMP := $(shell date +"%Y%m%d_%H%M%S") VERSION_LABEL=v$(VERSION)-$(TIMESTAMP) LABEL_PREFIX ?= $(VERSION_LABEL) +USE_OPENCV_BASE_VERSION = 0.0.11 +AMD64_SUFFIX = amd64 +ARM32V7_SUFFIX = arm32v7 +ARM64V8_SUFFIX = arm64v8 + .PHONY: all all: apps brokers @@ -59,7 +64,6 @@ ifeq (1, $(PUSH)) docker buildx imagetools create --tag "$(PREFIX)/onvif-video-broker:$(LABEL_PREFIX)" endif -USE_OPENCV_BASE_VERSION = 0.0.11 ONVIF_BUILDX_PUSH_OUTPUT = type=image,name=$(PREFIX)/onvif-video-broker,push-by-digest=true,name-canonical=true,push=true ONVIF_BUILDX_ARGS = $(if $(LOAD), --load --tag $(PREFIX)/onvif-video-broker:$(LABEL_PREFIX)) $(if $(PUSH), --output $(ONVIF_BUILDX_PUSH_OUTPUT)) -f build/brokers/Dockerfile.onvif-video-broker