From 786c9743d5772b4a54b82a776ef67aedb1079f83 Mon Sep 17 00:00:00 2001 From: Vidya Kukke Date: Fri, 25 Oct 2019 10:49:30 -0700 Subject: [PATCH] Azure Event Grid Edge Samples, SDK --- .gitignore | 330 +++++++++++++++ IoTModules/c#/auth/Auth.csproj | 23 + IoTModules/c#/auth/HttpBufferedStream.cs | 80 ++++ IoTModules/c#/auth/HttpSerializer.cs | 157 +++++++ IoTModules/c#/auth/HttpUdsMessageHandler.cs | 52 +++ IoTModules/c#/auth/IoTAuthTypes.cs | 63 +++ IoTModules/c#/auth/IoTEdgeConstants.cs | 17 + IoTModules/c#/auth/IoTSecurity.cs | 397 ++++++++++++++++++ .../c#/publisher/CustomHttpClientFactory.cs | 67 +++ IoTModules/c#/publisher/GlobalSuppressions.cs | 10 + IoTModules/c#/publisher/GridConfiguration.cs | 32 ++ IoTModules/c#/publisher/HostSettings.json | 18 + IoTModules/c#/publisher/Program.cs | 308 ++++++++++++++ IoTModules/c#/publisher/Publisher.csproj | 37 ++ .../publisher/docker/Dockerfile.alpine-amd64 | 10 + .../docker/Dockerfile.bionic-arm32v7 | 11 + .../docker/Dockerfile.nanoserver-amd64 | 11 + .../c#/subscriber/CustomHttpClientFactory.cs | 67 +++ .../subscriber/EventsHandler/EventSchema.cs | 10 + .../subscriber/EventsHandler/EventsHandler.cs | 31 ++ .../c#/subscriber/GlobalSuppressions.cs | 11 + IoTModules/c#/subscriber/GridConfiguration.cs | 30 ++ IoTModules/c#/subscriber/HostSettings.json | 40 ++ IoTModules/c#/subscriber/Program.cs | 241 +++++++++++ IoTModules/c#/subscriber/Subscriber.csproj | 35 ++ IoTModules/c#/subscriber/SubscriberHost.cs | 86 ++++ .../c#/subscriber/WebHost/HostBuilder.cs | 56 +++ .../c#/subscriber/WebHost/HostStartup.cs | 57 +++ .../subscriber/docker/Dockerfile.alpine-amd64 | 16 + .../docker/Dockerfile.bionic-arm32v7 | 14 + .../docker/Dockerfile.nanoserver-amd64 | 14 + LICENSE | 21 + Microsoft.Azure.EventGridEdge.Samples.sln | 76 ++++ QuickStart/c#/publisher/Program.cs | 108 +++++ QuickStart/c#/publisher/Publisher.csproj | 29 ++ .../publisher/docker/Dockerfile.alpine-amd64 | 11 + .../docker/Dockerfile.bionic-arm32v7 | 12 + .../docker/Dockerfile.nanoserver-amd64 | 11 + QuickStart/c#/subscriber/Program.cs | 29 ++ QuickStart/c#/subscriber/Subscriber.csproj | 30 ++ .../subscriber/docker/Dockerfile.alpine-amd64 | 15 + .../docker/Dockerfile.bionic-arm32v7 | 15 + .../docker/Dockerfile.nanoserver-amd64 | 15 + README.md | 14 + SDK/Contracts/AdvancedFilter.cs | 222 ++++++++++ SDK/Contracts/AdvancedFilterJsonConverter.cs | 51 +++ SDK/Contracts/AdvancedFilterOperatorType.cs | 23 + SDK/Contracts/AdvancedFilterTypeConverter.cs | 110 +++++ .../CaseInsensitiveDictionaryConverter.cs | 36 ++ SDK/Contracts/CloudEvent.cs | 78 ++++ .../CustomEventSubscriptionDestination.cs | 21 + SDK/Contracts/EndpointTypes.cs | 13 + SDK/Contracts/EventDeliverySchema.cs | 28 ++ SDK/Contracts/EventGridEvent.cs | 71 ++++ .../EventGridEventSubscriptionDestination.cs | 20 + ...dEventSubscriptionDestinationProperties.cs | 35 ++ SDK/Contracts/EventSubscription.cs | 21 + SDK/Contracts/EventSubscriptionDestination.cs | 12 + .../EventSubscriptionDestinationConverter.cs | 61 +++ SDK/Contracts/EventSubscriptionFilter.cs | 44 ++ SDK/Contracts/EventSubscriptionProperties.cs | 48 +++ SDK/Contracts/InputSchema.cs | 28 ++ SDK/Contracts/RetryPolicy.cs | 20 + SDK/Contracts/Topic.cs | 17 + SDK/Contracts/TopicProperties.cs | 23 + .../WebHookEventSubscriptionDestination.cs | 20 + ...kEventSubscriptionDestinationProperties.cs | 30 ++ SDK/EventGridApiException.cs | 46 ++ SDK/EventGridEdgeClient.cs | 105 +++++ SDK/EventsAPI.cs | 97 +++++ SDK/Extensions.cs | 32 ++ SDK/SDK.csproj | 44 ++ SDK/SubscriptionsAPI.cs | 66 +++ SDK/TopicsAPI.cs | 66 +++ .../Contracts/CertificateResponse.cs | 18 + .../Contracts/IdentityCertificateRequest.cs | 14 + SecurityDaemonClient/Contracts/PrivateKey.cs | 20 + .../Contracts/PrivateKeyType.cs | 13 + .../Contracts/ServerCertificateRequest.cs | 16 + .../Contracts/TrustBundleResponse.cs | 12 + SecurityDaemonClient/SecurityDaemonClient.cs | 287 +++++++++++++ .../SecurityDaemonClient.csproj | 19 + .../SecurityDaemonHttpClientFactory.cs | 57 +++ .../Uds/HttpBufferedStream.cs | 82 ++++ .../Uds/HttpRequestResponseSerializer.cs | 160 +++++++ .../Uds/HttpUdsMessageHandler.cs | 57 +++ SecurityDaemonClient/Validate.cs | 32 ++ 87 files changed, 4992 insertions(+) create mode 100644 .gitignore create mode 100644 IoTModules/c#/auth/Auth.csproj create mode 100644 IoTModules/c#/auth/HttpBufferedStream.cs create mode 100644 IoTModules/c#/auth/HttpSerializer.cs create mode 100644 IoTModules/c#/auth/HttpUdsMessageHandler.cs create mode 100644 IoTModules/c#/auth/IoTAuthTypes.cs create mode 100644 IoTModules/c#/auth/IoTEdgeConstants.cs create mode 100644 IoTModules/c#/auth/IoTSecurity.cs create mode 100644 IoTModules/c#/publisher/CustomHttpClientFactory.cs create mode 100644 IoTModules/c#/publisher/GlobalSuppressions.cs create mode 100644 IoTModules/c#/publisher/GridConfiguration.cs create mode 100644 IoTModules/c#/publisher/HostSettings.json create mode 100644 IoTModules/c#/publisher/Program.cs create mode 100644 IoTModules/c#/publisher/Publisher.csproj create mode 100644 IoTModules/c#/publisher/docker/Dockerfile.alpine-amd64 create mode 100644 IoTModules/c#/publisher/docker/Dockerfile.bionic-arm32v7 create mode 100644 IoTModules/c#/publisher/docker/Dockerfile.nanoserver-amd64 create mode 100644 IoTModules/c#/subscriber/CustomHttpClientFactory.cs create mode 100644 IoTModules/c#/subscriber/EventsHandler/EventSchema.cs create mode 100644 IoTModules/c#/subscriber/EventsHandler/EventsHandler.cs create mode 100644 IoTModules/c#/subscriber/GlobalSuppressions.cs create mode 100644 IoTModules/c#/subscriber/GridConfiguration.cs create mode 100644 IoTModules/c#/subscriber/HostSettings.json create mode 100644 IoTModules/c#/subscriber/Program.cs create mode 100644 IoTModules/c#/subscriber/Subscriber.csproj create mode 100644 IoTModules/c#/subscriber/SubscriberHost.cs create mode 100644 IoTModules/c#/subscriber/WebHost/HostBuilder.cs create mode 100644 IoTModules/c#/subscriber/WebHost/HostStartup.cs create mode 100644 IoTModules/c#/subscriber/docker/Dockerfile.alpine-amd64 create mode 100644 IoTModules/c#/subscriber/docker/Dockerfile.bionic-arm32v7 create mode 100644 IoTModules/c#/subscriber/docker/Dockerfile.nanoserver-amd64 create mode 100644 LICENSE create mode 100644 Microsoft.Azure.EventGridEdge.Samples.sln create mode 100644 QuickStart/c#/publisher/Program.cs create mode 100644 QuickStart/c#/publisher/Publisher.csproj create mode 100644 QuickStart/c#/publisher/docker/Dockerfile.alpine-amd64 create mode 100644 QuickStart/c#/publisher/docker/Dockerfile.bionic-arm32v7 create mode 100644 QuickStart/c#/publisher/docker/Dockerfile.nanoserver-amd64 create mode 100644 QuickStart/c#/subscriber/Program.cs create mode 100644 QuickStart/c#/subscriber/Subscriber.csproj create mode 100644 QuickStart/c#/subscriber/docker/Dockerfile.alpine-amd64 create mode 100644 QuickStart/c#/subscriber/docker/Dockerfile.bionic-arm32v7 create mode 100644 QuickStart/c#/subscriber/docker/Dockerfile.nanoserver-amd64 create mode 100644 README.md create mode 100644 SDK/Contracts/AdvancedFilter.cs create mode 100644 SDK/Contracts/AdvancedFilterJsonConverter.cs create mode 100644 SDK/Contracts/AdvancedFilterOperatorType.cs create mode 100644 SDK/Contracts/AdvancedFilterTypeConverter.cs create mode 100644 SDK/Contracts/CaseInsensitiveDictionaryConverter.cs create mode 100644 SDK/Contracts/CloudEvent.cs create mode 100644 SDK/Contracts/CustomEventSubscriptionDestination.cs create mode 100644 SDK/Contracts/EndpointTypes.cs create mode 100644 SDK/Contracts/EventDeliverySchema.cs create mode 100644 SDK/Contracts/EventGridEvent.cs create mode 100644 SDK/Contracts/EventGridEventSubscriptionDestination.cs create mode 100644 SDK/Contracts/EventGridEventSubscriptionDestinationProperties.cs create mode 100644 SDK/Contracts/EventSubscription.cs create mode 100644 SDK/Contracts/EventSubscriptionDestination.cs create mode 100644 SDK/Contracts/EventSubscriptionDestinationConverter.cs create mode 100644 SDK/Contracts/EventSubscriptionFilter.cs create mode 100644 SDK/Contracts/EventSubscriptionProperties.cs create mode 100644 SDK/Contracts/InputSchema.cs create mode 100644 SDK/Contracts/RetryPolicy.cs create mode 100644 SDK/Contracts/Topic.cs create mode 100644 SDK/Contracts/TopicProperties.cs create mode 100644 SDK/Contracts/WebHookEventSubscriptionDestination.cs create mode 100644 SDK/Contracts/WebHookEventSubscriptionDestinationProperties.cs create mode 100644 SDK/EventGridApiException.cs create mode 100644 SDK/EventGridEdgeClient.cs create mode 100644 SDK/EventsAPI.cs create mode 100644 SDK/Extensions.cs create mode 100644 SDK/SDK.csproj create mode 100644 SDK/SubscriptionsAPI.cs create mode 100644 SDK/TopicsAPI.cs create mode 100644 SecurityDaemonClient/Contracts/CertificateResponse.cs create mode 100644 SecurityDaemonClient/Contracts/IdentityCertificateRequest.cs create mode 100644 SecurityDaemonClient/Contracts/PrivateKey.cs create mode 100644 SecurityDaemonClient/Contracts/PrivateKeyType.cs create mode 100644 SecurityDaemonClient/Contracts/ServerCertificateRequest.cs create mode 100644 SecurityDaemonClient/Contracts/TrustBundleResponse.cs create mode 100644 SecurityDaemonClient/SecurityDaemonClient.cs create mode 100644 SecurityDaemonClient/SecurityDaemonClient.csproj create mode 100644 SecurityDaemonClient/SecurityDaemonHttpClientFactory.cs create mode 100644 SecurityDaemonClient/Uds/HttpBufferedStream.cs create mode 100644 SecurityDaemonClient/Uds/HttpRequestResponseSerializer.cs create mode 100644 SecurityDaemonClient/Uds/HttpUdsMessageHandler.cs create mode 100644 SecurityDaemonClient/Validate.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e759b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,330 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ diff --git a/IoTModules/c#/auth/Auth.csproj b/IoTModules/c#/auth/Auth.csproj new file mode 100644 index 0000000..c701a56 --- /dev/null +++ b/IoTModules/c#/auth/Auth.csproj @@ -0,0 +1,23 @@ + + + + Microsoft.Azure.EventGridEdge.Samples.Common + Microsoft.EventGridEdge.Samples.Common + + netcoreapp2.1 + 7.3 + 2.1.4 + + + + + + + + + true + + $(NoWarn),1573,1591,1712 + + + diff --git a/IoTModules/c#/auth/HttpBufferedStream.cs b/IoTModules/c#/auth/HttpBufferedStream.cs new file mode 100644 index 0000000..2de4e31 --- /dev/null +++ b/IoTModules/c#/auth/HttpBufferedStream.cs @@ -0,0 +1,80 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Azure.EventGridEdge.Samples.Auth +{ + public class HttpBufferedStream : Stream + { + private const char CR = '\r'; + private const char LF = '\n'; + private readonly BufferedStream innerStream; + + public HttpBufferedStream(Stream stream) + { + this.innerStream = new BufferedStream(stream); + } + + public override bool CanRead => this.innerStream.CanRead; + + public override bool CanSeek => this.innerStream.CanSeek; + + public override bool CanWrite => this.innerStream.CanWrite; + + public override long Length => this.innerStream.Length; + + public override long Position + { + get => this.innerStream.Position; + set => this.innerStream.Position = value; + } + + public override void Flush() => this.innerStream.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) => this.innerStream.FlushAsync(cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) => this.innerStream.Read(buffer, offset, count); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => this.innerStream.ReadAsync(buffer, offset, count, cancellationToken); + + public async Task ReadLineAsync(CancellationToken cancellationToken) + { + int position = 0; + byte[] buffer = new byte[1]; + bool crFound = false; + var builder = new StringBuilder(); + while (true) + { + int length = await this.innerStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken); + + if (length == 0) + { + throw new IOException("Unexpected end of stream."); + } + + if (crFound && (char)buffer[position] == LF) + { + builder.Remove(builder.Length - 1, 1); + return builder.ToString(); + } + + builder.Append((char)buffer[position]); + crFound = (char)buffer[position] == CR; + } + } + + public override long Seek(long offset, SeekOrigin origin) => this.innerStream.Seek(offset, origin); + + public override void SetLength(long value) => this.innerStream.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => this.innerStream.Write(buffer, offset, count); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => this.innerStream.WriteAsync(buffer, offset, count, cancellationToken); + + protected override void Dispose(bool disposing) => this.innerStream.Dispose(); + } +} diff --git a/IoTModules/c#/auth/HttpSerializer.cs b/IoTModules/c#/auth/HttpSerializer.cs new file mode 100644 index 0000000..0a52ad9 --- /dev/null +++ b/IoTModules/c#/auth/HttpSerializer.cs @@ -0,0 +1,157 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Azure.EventGridEdge.Samples.Auth +{ + public class HttpSerializer + { + private const char SP = ' '; + private const char CR = '\r'; + private const char LF = '\n'; + private const char ProtocolVersionSeparator = '/'; + private const string Protocol = "HTTP"; + private const char HeaderSeparator = ':'; + private const string ContentLengthHeaderName = "content-length"; + + public byte[] SerializeRequest(HttpRequestMessage request) + { + this.PreProcessRequest(request); + + var builder = new StringBuilder(); + // request-line = method SP request-target SP HTTP-version CRLF + builder.Append(request.Method); + builder.Append(SP); + builder.Append(request.RequestUri.IsAbsoluteUri ? request.RequestUri.PathAndQuery : Uri.EscapeUriString(request.RequestUri.ToString())); + builder.Append(SP); + builder.Append($"{Protocol}{ProtocolVersionSeparator}"); + builder.Append(new Version(1, 1).ToString(2)); + builder.Append(CR); + builder.Append(LF); + + // Headers + builder.Append(request.Headers); + + if (request.Content != null) + { + long? contentLength = request.Content.Headers.ContentLength; + if (contentLength.HasValue) + { + request.Content.Headers.ContentLength = contentLength.Value; + } + + builder.Append(request.Content.Headers); + } + + // Headers end + builder.Append(CR); + builder.Append(LF); + + return Encoding.ASCII.GetBytes(builder.ToString()); + } + + public async Task DeserializeResponseAsync(HttpBufferedStream bufferedStream, CancellationToken cancellationToken) + { + var httpResponse = new HttpResponseMessage(); + + await this.SetResponseStatusLineAsync(httpResponse, bufferedStream, cancellationToken); + await this.SetHeadersAndContentAsync(httpResponse, bufferedStream, cancellationToken); + + return httpResponse; + } + + private async Task SetHeadersAndContentAsync(HttpResponseMessage httpResponse, HttpBufferedStream bufferedStream, CancellationToken cancellationToken) + { + IList headers = new List(); + string line = await bufferedStream.ReadLineAsync(cancellationToken); + while (!string.IsNullOrWhiteSpace(line)) + { + headers.Add(line); + line = await bufferedStream.ReadLineAsync(cancellationToken); + } + + httpResponse.Content = new StreamContent(bufferedStream); + foreach (string header in headers) + { + if (string.IsNullOrWhiteSpace(header)) + { + // headers end + break; + } + + int headerSeparatorPosition = header.IndexOf(HeaderSeparator, StringComparison.OrdinalIgnoreCase); + if (headerSeparatorPosition <= 0) + { + throw new HttpRequestException($"Header is invalid {header}."); + } + + string headerName = header.Substring(0, headerSeparatorPosition).Trim(); + string headerValue = header.Substring(headerSeparatorPosition + 1).Trim(); + + bool headerAdded = httpResponse.Headers.TryAddWithoutValidation(headerName, headerValue); + if (!headerAdded) + { + if (string.Equals(headerName, ContentLengthHeaderName, StringComparison.InvariantCultureIgnoreCase)) + { + if (!long.TryParse(headerValue, out long contentLength)) + { + throw new HttpRequestException($"Header value is invalid for {headerName}."); + } + + await httpResponse.Content.LoadIntoBufferAsync(contentLength); + } + + httpResponse.Content.Headers.TryAddWithoutValidation(headerName, headerValue); + } + } + } + + private async Task SetResponseStatusLineAsync(HttpResponseMessage httpResponse, HttpBufferedStream bufferedStream, CancellationToken cancellationToken) + { + string statusLine = await bufferedStream.ReadLineAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(statusLine)) + { + throw new HttpRequestException("Response is empty."); + } + + string[] statusLineParts = statusLine.Split(new[] { SP }, 3); + if (statusLineParts.Length < 3) + { + throw new HttpRequestException("Status line is not valid."); + } + + string[] httpVersion = statusLineParts[0].Split(new[] { ProtocolVersionSeparator }, 2); + if (httpVersion.Length < 2 || !Version.TryParse(httpVersion[1], out Version versionNumber)) + { + throw new HttpRequestException($"Version is not valid {statusLineParts[0]}."); + } + + httpResponse.Version = versionNumber; + + if (!Enum.TryParse(statusLineParts[1], out HttpStatusCode statusCode)) + { + throw new HttpRequestException($"StatusCode is not valid {statusLineParts[1]}."); + } + + httpResponse.StatusCode = statusCode; + httpResponse.ReasonPhrase = statusLineParts[2]; + } + + private void PreProcessRequest(HttpRequestMessage request) + { + if (string.IsNullOrEmpty(request.Headers.Host)) + { + request.Headers.Host = $"{request.RequestUri.DnsSafeHost}:{request.RequestUri.Port}"; + } + + request.Headers.ConnectionClose = true; + } + } +} diff --git a/IoTModules/c#/auth/HttpUdsMessageHandler.cs b/IoTModules/c#/auth/HttpUdsMessageHandler.cs new file mode 100644 index 0000000..2fa29b4 --- /dev/null +++ b/IoTModules/c#/auth/HttpUdsMessageHandler.cs @@ -0,0 +1,52 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Net.Http; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Azure.EventGridEdge.Samples.Auth +{ + /// + /// Unix domain message handler. + /// + public class HttpUdsMessageHandler : HttpMessageHandler + { + private readonly Uri providerUri; + + public HttpUdsMessageHandler(Uri providerUri) + { + this.providerUri = providerUri; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + using (Socket socket = await this.GetConnectedSocketAsync()) + { + using (var stream = new HttpBufferedStream(new NetworkStream(socket, true))) + { + var serializer = new HttpSerializer(); + byte[] requestBytes = serializer.SerializeRequest(request); + + await stream.WriteAsync(requestBytes, 0, requestBytes.Length, cancellationToken); + if (request.Content != null) + { + await request.Content.CopyToAsync(stream); + } + + return await serializer.DeserializeResponseAsync(stream, cancellationToken); + } + } + } + + private async Task GetConnectedSocketAsync() + { + var endpoint = new UnixDomainSocketEndPoint(this.providerUri.LocalPath); + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + await socket.ConnectAsync(endpoint); + return socket; + } + } +} diff --git a/IoTModules/c#/auth/IoTAuthTypes.cs b/IoTModules/c#/auth/IoTAuthTypes.cs new file mode 100644 index 0000000..2d9b3eb --- /dev/null +++ b/IoTModules/c#/auth/IoTAuthTypes.cs @@ -0,0 +1,63 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using Newtonsoft.Json; + +namespace Microsoft.Azure.EventGridEdge.Samples.Auth +{ + public enum PrivateKeyType + { + [System.Runtime.Serialization.EnumMember(Value = "ref")] + Ref = 0, + + [System.Runtime.Serialization.EnumMember(Value = "key")] + Key = 1, + } + + public class IdentityCertificateRequest + { + [JsonProperty("expiration", Required = Newtonsoft.Json.Required.Always)] + public DateTime Expiration { get; set; } + } + + public class ServerCertificateRequest + { + [JsonProperty("commonName", Required = Newtonsoft.Json.Required.Always)] + public string CommonName { get; set; } + + [JsonProperty("expiration", Required = Newtonsoft.Json.Required.Always)] + public DateTime Expiration { get; set; } + } + + public class CertificateResponse + { + [JsonProperty("privateKey", Required = Newtonsoft.Json.Required.Always)] + public PrivateKey PrivateKey { get; set; } + + [JsonProperty("certificate", Required = Newtonsoft.Json.Required.Always)] + public string Certificate { get; set; } + + [JsonProperty("expiration", Required = Newtonsoft.Json.Required.Always)] + public DateTime Expiration { get; set; } + } + + public class PrivateKey + { + [JsonProperty("type", Required = Required.Always)] + [JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] + public PrivateKeyType Type { get; set; } + + [JsonProperty("ref", Required = Required.Default, NullValueHandling = NullValueHandling.Ignore)] + public string Ref { get; set; } + + [JsonProperty("bytes", Required = Required.Default, NullValueHandling = NullValueHandling.Ignore)] + public string Bytes { get; set; } + } + + public class TrustBundleResponse + { + [Newtonsoft.Json.JsonProperty("certificate", Required = Newtonsoft.Json.Required.Always)] + public string Certificate { get; set; } + } +} diff --git a/IoTModules/c#/auth/IoTEdgeConstants.cs b/IoTModules/c#/auth/IoTEdgeConstants.cs new file mode 100644 index 0000000..f3e861e --- /dev/null +++ b/IoTModules/c#/auth/IoTEdgeConstants.cs @@ -0,0 +1,17 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.Azure.EventGridEdge.Samples.Auth +{ + internal class IoTEdgeConstants + { + public const string ModuleGenerationId = "IOTEDGE_MODULEGENERATIONID"; + public const string ModuleId = "IOTEDGE_MODULEID"; + public const string WorkloadUri = "IOTEDGE_WORKLOADURI"; + public const string WorkloadApiVersion = "IOTEDGE_APIVERSION"; + public const string EdgeGatewayHostName = "IOTEDGE_GATEWAYHOSTNAME"; + public const string UnixScheme = "unix"; + public const int DefaultServerCertificateValidityInDays = 90; + public const int DefaultIdentityCertificateValidityInDays = 7; + } +} diff --git a/IoTModules/c#/auth/IoTSecurity.cs b/IoTModules/c#/auth/IoTSecurity.cs new file mode 100644 index 0000000..1c37ff6 --- /dev/null +++ b/IoTModules/c#/auth/IoTSecurity.cs @@ -0,0 +1,397 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Runtime.InteropServices; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; + +namespace Microsoft.Azure.EventGridEdge.Samples.Auth +{ + public class IoTSecurity + { + public void ImportCertificate(IEnumerable certificates) + { + if (certificates != null) + { + StoreName storeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? StoreName.CertificateAuthority : StoreName.Root; + StoreLocation storeLocation = StoreLocation.CurrentUser; + using (var store = new X509Store(storeName, storeLocation)) + { + store.Open(OpenFlags.ReadWrite); + foreach (X509Certificate2 cert in certificates) + { + store.Add(cert); + } + } + } + } + + public async Task<(X509Certificate2, IEnumerable)> GetClientCertificateAsync() + { + Uri workloadUri = this.GetWorkloadUri(); + string moduleId = this.GetIoTEdgeEnvironmentVariable(IoTEdgeConstants.ModuleId); + Uri workloadRequestUri = this.GetIdentityCertificateRequestUri(workloadUri); + + int certificateValidityInDays = IoTEdgeConstants.DefaultIdentityCertificateValidityInDays; + DateTime expirationTime = DateTime.UtcNow.AddDays(certificateValidityInDays); + var identityCertificateRequest = new IdentityCertificateRequest() { Expiration = expirationTime }; + + var errorMessage = "Failed to retrieve ClientCertificate from IoTEdge Security Daemon."; + + try + { + using (HttpClient httpClient = this.GetHttpClient(workloadUri)) + { + string requestString = JsonConvert.SerializeObject(identityCertificateRequest); + var content = new StringContent(requestString); + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, workloadRequestUri)) + { + httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + httpRequest.Content = content; + + using (HttpResponseMessage httpResponse = await httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, CancellationToken.None)) + { + if (httpResponse.StatusCode == HttpStatusCode.Created) + { + string responseData = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); + CertificateResponse cr = JsonConvert.DeserializeObject(responseData); + + IEnumerable rawCerts = this.ParseResponse(cr.Certificate); + if (rawCerts.FirstOrDefault() == null) + { + throw new Exception("Did not receive an identity certificate from IoTEdge daemon!"); + } + + return this.CreateX509Certificates(rawCerts, cr.PrivateKey.Bytes, moduleId); + } + + errorMessage = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new Exception(errorMessage); + } + } + } + } + catch (Exception e) + { + throw new Exception($"Failed to retrieve client certificate from IoTEdge Security Daemon. Reason: {e.Message}"); + } + } + + public async Task<(X509Certificate2 serverCertificate, IEnumerable certificateChain)> GetServerCertificateAsync() + { + Uri workloadUri = this.GetWorkloadUri(); + string moduleId = this.GetIoTEdgeEnvironmentVariable(IoTEdgeConstants.ModuleId); + Uri workloadRequestUri = this.GetServerCertificateRequestUri(workloadUri); + + ServerCertificateRequest scRequest = this.GetServerCertificateRequest(IoTEdgeConstants.DefaultServerCertificateValidityInDays); + try + { + using (HttpClient httpClient = this.GetHttpClient(workloadUri)) + { + string scrString = JsonConvert.SerializeObject(scRequest); + var content = new StringContent(scrString); + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, workloadRequestUri)) + { + httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + httpRequest.Content = content; + + using (HttpResponseMessage httpResponse = await httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false)) + { + if (httpResponse.StatusCode == HttpStatusCode.Created) + { + string responseData = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); + CertificateResponse cr = JsonConvert.DeserializeObject(responseData); + IEnumerable rawCerts = this.ParseResponse(cr.Certificate); + if (rawCerts.FirstOrDefault() == null) + { + throw new Exception($"Failed to retrieve serverCertificate from IoTEdge Security daemon. Reason: Security daemon return empty response."); + } + + (X509Certificate2 serverCertificate, IEnumerable certificateChain) = this.CreateX509Certificates(rawCerts, cr.PrivateKey.Bytes, moduleId); + return (serverCertificate, certificateChain); + } + + string errorData = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new Exception(errorData); + } + } + } + } + catch (Exception e) + { + throw new Exception($"Failed to retrieve server certificate from IoTEdge Security Daemon. Reason: {e.Message}"); + } + } + + private Uri GetWorkloadUri() => new Uri(Environment.GetEnvironmentVariable(IoTEdgeConstants.WorkloadUri)); + + private string GetIoTEdgeEnvironmentVariable(string envVarName) => Environment.GetEnvironmentVariable(envVarName); + + private Uri GetIdentityCertificateRequestUri(Uri workloadUri) + { + string workloadApiVersion = this.GetIoTEdgeEnvironmentVariable(IoTEdgeConstants.WorkloadApiVersion); + string moduleId = this.GetIoTEdgeEnvironmentVariable(IoTEdgeConstants.ModuleId); + + string urlEncodedModuleId = WebUtility.UrlEncode(moduleId); + string urlEncodedWorkloadApiVersion = WebUtility.UrlEncode(workloadApiVersion); + + string workloadBaseUrl = this.GetBaseUrl(workloadUri).TrimEnd('/'); + var workloadRequestUriBuilder = new StringBuilder(workloadBaseUrl); + workloadRequestUriBuilder.Append($"/modules/{urlEncodedModuleId}/certificate/identity?api-version={urlEncodedWorkloadApiVersion}"); + return new Uri(workloadRequestUriBuilder.ToString()); + } + + private Uri GetServerCertificateRequestUri(Uri workloadUri) + { + string workloadApiVersion = this.GetIoTEdgeEnvironmentVariable(IoTEdgeConstants.WorkloadApiVersion); + string moduleId = this.GetIoTEdgeEnvironmentVariable(IoTEdgeConstants.ModuleId); + string moduleGenerationId = this.GetIoTEdgeEnvironmentVariable(IoTEdgeConstants.ModuleGenerationId); + + string urlEncodedModuleId = WebUtility.UrlEncode(moduleId); + string urlEncodedModuleGenerationId = WebUtility.UrlEncode(moduleGenerationId); + string urlEncodedWorkloadApiVersion = WebUtility.UrlEncode(workloadApiVersion); + + string workloadBaseUrl = this.GetBaseUrl(workloadUri).TrimEnd('/'); + var workloadRequestUriBuilder = new StringBuilder(workloadBaseUrl); + workloadRequestUriBuilder.Append($"/modules/{urlEncodedModuleId}/genid/{urlEncodedModuleGenerationId}/certificate/server?api-version={urlEncodedWorkloadApiVersion}"); + return new Uri(workloadRequestUriBuilder.ToString()); + } + + private Uri GetTrustBundleRequestUri(Uri workloadUri) + { + string workloadApiVersion = this.GetIoTEdgeEnvironmentVariable(IoTEdgeConstants.WorkloadApiVersion); + string urlEncodedWorkloadApiVersion = WebUtility.UrlEncode(workloadApiVersion); + + string workloadBaseUrl = this.GetBaseUrl(workloadUri).TrimEnd('/'); + var workloadRequestUriBuilder = new StringBuilder(workloadBaseUrl); + workloadRequestUriBuilder.Append($"/trust-bundle?api-version={urlEncodedWorkloadApiVersion}"); + return new Uri(workloadRequestUriBuilder.ToString()); + } + + private ServerCertificateRequest GetServerCertificateRequest(int validityInDays = 90) + { + string edgeDeviceHostName = this.GetIoTEdgeEnvironmentVariable(IoTEdgeConstants.EdgeGatewayHostName); + DateTime expirationTime = DateTime.UtcNow.AddDays(validityInDays); + + return new ServerCertificateRequest() + { + CommonName = edgeDeviceHostName, + Expiration = expirationTime, + }; + } + + private string GetBaseUrl(Uri workloadUri) + { + if (workloadUri.Scheme.Equals(IoTEdgeConstants.UnixScheme, StringComparison.OrdinalIgnoreCase)) + { + return $"http://{workloadUri.Segments.Last()}"; + } + + return workloadUri.OriginalString; + } + + private HttpClient GetHttpClient(Uri workloadUri) + { + if (workloadUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || workloadUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return new HttpClient(); + } + else if (workloadUri.Scheme.Equals(IoTEdgeConstants.UnixScheme, StringComparison.OrdinalIgnoreCase)) + { + return new HttpClient(new HttpUdsMessageHandler(workloadUri)); + } + + throw new Exception($"Unknow workloadUri schema specified. {workloadUri}"); + } + + private IList ParseResponse(string certificateChain) + { + if (string.IsNullOrEmpty(certificateChain)) + { + throw new InvalidOperationException("Trusted certificates can not be null or empty."); + } + + // Extract each certificate's string. The final string from the split will either be empty + // or a non-certificate entry, so it is dropped. + string delimiter = "-----END CERTIFICATE-----"; + string[] rawCerts = certificateChain.Split(new[] { delimiter }, StringSplitOptions.None); + return rawCerts.Take(count: rawCerts.Count() - 1).Select(c => $"{c}{delimiter}").ToList(); + } + + private (X509Certificate2 serverCertificate, IEnumerable certificateChain) CreateX509Certificates(IEnumerable rawCerts, string privateKey, string moduleId) + { + string primaryCert = rawCerts.First(); + RsaPrivateCrtKeyParameters keyParams = null; + + IEnumerable x509CertsChain = this.ConvertToX509(rawCerts.Skip(1)); + + IList chainCertEntries = new List(); + Pkcs12Store store = new Pkcs12StoreBuilder().Build(); + // note: the seperator between the certificate and private key is added for safety to delinate the cert and key boundary + var sr = new StringReader(primaryCert + "\r\n" + privateKey); + var pemReader = new PemReader(sr); + object certObject = pemReader.ReadObject(); + while (certObject != null) + { + if (certObject is Org.BouncyCastle.X509.X509Certificate x509Cert) + { + chainCertEntries.Add(new X509CertificateEntry(x509Cert)); + } + + // when processing certificates generated via openssl certObject type is of AsymmetricCipherKeyPair + if (certObject is AsymmetricCipherKeyPair) + { + certObject = ((AsymmetricCipherKeyPair)certObject).Private; + } + + if (certObject is RsaPrivateCrtKeyParameters) + { + keyParams = (RsaPrivateCrtKeyParameters)certObject; + } + + certObject = pemReader.ReadObject(); + } + + if (keyParams == null) + { + throw new InvalidOperationException("Private key is required"); + } + + store.SetKeyEntry(moduleId, new AsymmetricKeyEntry(keyParams), chainCertEntries.ToArray()); + using (var p12File = new MemoryStream()) + { + store.Save(p12File, Array.Empty(), new SecureRandom()); + var x509PrimaryCert = new X509Certificate2(p12File.ToArray()); + return (x509PrimaryCert, x509CertsChain); + } + } + + public async Task> GetTrustBundleAsync() + { + Uri workloadUri = this.GetWorkloadUri(); + using (HttpClient httpClient = this.GetHttpClient(workloadUri)) + { + Uri workloadRequestUri = this.GetTrustBundleRequestUri(workloadUri); + + using (var httpRequest = new HttpRequestMessage(HttpMethod.Get, workloadRequestUri)) + { + httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + using (HttpResponseMessage httpResponse = await httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, CancellationToken.None)) + { + if (httpResponse.StatusCode == HttpStatusCode.OK) + { + string responseData = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); + + TrustBundleResponse trustBundleResponse = JsonConvert.DeserializeObject(responseData); + IEnumerable rawCerts = this.ParseResponse(trustBundleResponse.Certificate); + if (rawCerts.FirstOrDefault() == null) + { + throw new Exception($"Failed to retrieve trustbundle from security daemon."); + } + + return this.ConvertToX509(rawCerts); + } + + string errorData = await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new Exception($"Failed to retrieve trustbundle from security daemon. Reason: {errorData}"); + } + } + } + } + + public async Task ValidateClientCertificateAsync(X509Certificate2 clientCertificate) + { + // Please add validation more validations as appropriate + + if (this.IsCACertificate(clientCertificate)) + { + throw new Exception("Cannot use CA certificate for client authentication!"); + } + + IEnumerable trustedCertificates = await this.GetTrustBundleAsync(); + + using (X509Chain chain = new X509Chain()) + { + foreach (X509Certificate2 trustedClientCert in trustedCertificates) + { + chain.ChainPolicy.ExtraStore.Add(trustedClientCert); + } + + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // IoTEdge generates a self-signed certificate by default, that is not rooted in a root certificate that is trusted by the trust provider hence this flag is needed + // so that build returns true if root terminates in a self-signed certificate + chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority; + } + + if (!chain.Build(clientCertificate)) + { + var errorMessageBuilder = new StringBuilder(); + foreach (X509ChainStatus cs in chain.ChainStatus) + { + errorMessageBuilder.AppendFormat(CultureInfo.InvariantCulture, $"ChainStatus: {cs.Status}, ChainStatusInfo: {cs.StatusInformation}"); + errorMessageBuilder.AppendLine(); + } + + throw new Exception($"ClientCertificate is not valid! Reason: Failed chain validation. Details: {errorMessageBuilder}"); + } + } + } + + private X509Certificate2[] ConvertToX509(IEnumerable rawCerts) + { + return rawCerts + .Select(c => Encoding.UTF8.GetBytes(c)) + .Select(c => new X509Certificate2(c)) + .ToArray(); + } + + private bool IsCACertificate(X509Certificate2 certificate) + { + // https://tools.ietf.org/html/rfc3280#section-4.2.1.3 + // The keyCertSign bit is asserted when the subject public key is + // used for verifying a signature on public key certificates. If the + // keyCertSign bit is asserted, then the cA bit in the basic + // constraints extension (section 4.2.1.10) MUST also be asserted. + + // https://tools.ietf.org/html/rfc3280#section-4.2.1.10 + // The cA boolean indicates whether the certified public key belongs to + // a CA. If the cA boolean is not asserted, then the keyCertSign bit in + // the key usage extension MUST NOT be asserted. + X509ExtensionCollection extensionCollection = certificate.Extensions; + foreach (X509Extension extension in extensionCollection) + { + if (extension is X509BasicConstraintsExtension basicConstraintExtension) + { + if (basicConstraintExtension.CertificateAuthority) + { + return true; + } + } + } + + return false; + } + } +} diff --git a/IoTModules/c#/publisher/CustomHttpClientFactory.cs b/IoTModules/c#/publisher/CustomHttpClientFactory.cs new file mode 100644 index 0000000..9811649 --- /dev/null +++ b/IoTModules/c#/publisher/CustomHttpClientFactory.cs @@ -0,0 +1,67 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Globalization; +using System.Net.Http; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace Microsoft.Azure.EventGridEdge.Samples.Publisher +{ + public class CustomHttpClientFactory : IHttpClientFactory + { + private readonly X509Certificate2 trustedRootCA; + private readonly X509Certificate2 clientCert; + + public CustomHttpClientFactory(X509Certificate2 trustedRootCA, X509Certificate2 clientCert) + { + this.trustedRootCA = trustedRootCA; + this.clientCert = clientCert; + } + + public HttpClient CreateClient(string name) + { + var httpClientHandler = new HttpClientHandler() + { + ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => this.ValidateCertificate(trustedRootCA, cert, chain, errors), + }; + + if (this.clientCert != null) + { + httpClientHandler.ClientCertificateOptions = ClientCertificateOption.Manual; + httpClientHandler.ClientCertificates.Add(this.clientCert); + } + + return new HttpClient(httpClientHandler); + } + + private bool ValidateCertificate(X509Certificate2 trustedCertificateRoot, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslErrors) + { + SslPolicyErrors terminatingErrors = sslErrors & ~SslPolicyErrors.RemoteCertificateChainErrors; + if (terminatingErrors != SslPolicyErrors.None) + { + Console.WriteLine($"Server certificate validation failed due to {terminatingErrors}"); + return false; + } + + chain.ChainPolicy.ExtraStore.Add(trustedCertificateRoot); + chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority; + if (!chain.Build(new X509Certificate2(certificate))) + { + var errorMessageBuilder = new StringBuilder(); + foreach (X509ChainStatus cs in chain.ChainStatus) + { + errorMessageBuilder.AppendFormat(CultureInfo.InvariantCulture, $"ChainStatus: {cs.Status}, ChainStatusInfo: {cs.StatusInformation}"); + errorMessageBuilder.AppendLine(); + } + + Console.WriteLine($"Server certificate failed chain validation due to {errorMessageBuilder}"); + return false; + } + + return true; + } + } +} diff --git a/IoTModules/c#/publisher/GlobalSuppressions.cs b/IoTModules/c#/publisher/GlobalSuppressions.cs new file mode 100644 index 0000000..b4549f2 --- /dev/null +++ b/IoTModules/c#/publisher/GlobalSuppressions.cs @@ -0,0 +1,10 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "Reviewied")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1200:Using directives should be placed correctly", Justification = "Reviewed")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1602:Enumeration items should be documented", Justification = "Reviewed")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name", Justification = "Reviewed")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Reviewed")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2007:Do not directly await a Task", Justification = "Reviewed")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1056:Uri properties should not be strings", Justification = "Reviewed")] \ No newline at end of file diff --git a/IoTModules/c#/publisher/GridConfiguration.cs b/IoTModules/c#/publisher/GridConfiguration.cs new file mode 100644 index 0000000..eda387d --- /dev/null +++ b/IoTModules/c#/publisher/GridConfiguration.cs @@ -0,0 +1,32 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.Azure.EventGridEdge.Samples.Publisher +{ + public class GridConfiguration + { + public string Url { get; set; } + + public int PublishIntervalInSeconds { get; set; } + + public int InitialDelayInSeconds { get; set; } + + public ClientAuthOptions ClientAuth { get; set; } + + public TopicOptions Topic { get; set; } + } + + public class ClientAuthOptions + { + public string Source { get; set; } + + public string Token1 { get; set; } + } + + public class TopicOptions + { + public string Name { get; set; } + + public string Schema { get; set; } + } +} diff --git a/IoTModules/c#/publisher/HostSettings.json b/IoTModules/c#/publisher/HostSettings.json new file mode 100644 index 0000000..f8dcaa8 --- /dev/null +++ b/IoTModules/c#/publisher/HostSettings.json @@ -0,0 +1,18 @@ +{ + "configuration": { + "enableEventGrid": true, + "eventGrid": { + "url": "https://eventgridmodule:4438", + "publishIntervalInSeconds": 30, + "initialDelayInSeconds": 60, + "topic": { + "name": "egtopic", + "schema": "eventgridschema" + }, + "clientAuth": { + "source": "IoTEdge", + "token1": null + } + } + } +} \ No newline at end of file diff --git a/IoTModules/c#/publisher/Program.cs b/IoTModules/c#/publisher/Program.cs new file mode 100644 index 0000000..77141be --- /dev/null +++ b/IoTModules/c#/publisher/Program.cs @@ -0,0 +1,308 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Globalization; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.EventGridEdge.SDK; +using Microsoft.Azure.EventGridEdge.Samples.Auth; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; +using System.Linq; +using System.Collections.Generic; + +namespace Microsoft.Azure.EventGridEdge.Samples.Publisher +{ + public static class Program + { + private static readonly MediaTypeHeaderValue ApplicationJsonMTHV = new MediaTypeHeaderValue("application/json"); + + public static async Task Main() + { + var resetEvent = new ManualResetEventSlim(); + + // signals to long running components when to power down (either due to a Ctrl+C, or Ctrl-Break, or SIGTERM, or SIGKILL) + CancellationTokenSource lifetimeCts = SetupGracefulShutdown(resetEvent); + + GridConfiguration gridConfig = GetGridConfiguration(); + EventGridEdgeClient egClient = GetEventGridClientAsync(gridConfig).GetAwaiter().GetResult(); + + // certificate issued by IoT Edge takes a while to be current so will wait for a bit + int delay = gridConfig.InitialDelayInSeconds * 1000; + Thread.Sleep(delay); + + // wait for eventgrid module to come up + await WaitUntilEventGridModuleIsUpAndTopicExistsAsync(gridConfig, egClient, lifetimeCts.Token).ConfigureAwait(false); + + // setup eventgrid topic and publish + await PublishEventsAsync(gridConfig, egClient, lifetimeCts.Token).ConfigureAwait(false); + + resetEvent.Set(); + } + + private static GridConfiguration GetGridConfiguration() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("HostSettings.json", optional: false, reloadOnChange: true) + .AddEnvironmentVariables() + .Build(); + + IConfigurationSection hostConfigurationSection = configuration.GetSection("configuration"); + if (!hostConfigurationSection.GetValue("enableEventGrid", true)) + { + throw new Exception("Need to set configuration:enableEventGrid=true to come up!"); + } + + IConfigurationSection eventGridSection = hostConfigurationSection.GetSection("eventGrid"); + GridConfiguration gridConfig = eventGridSection.Get(); + ValidateConfiguration(gridConfig); + + return gridConfig; + } + + private static void ValidateConfiguration(GridConfiguration gridConfig) + { + if (gridConfig == null) + { + throw new Exception("GridConfiguration is null. Please configure the section configuration:eventgrid"); + } + + if (string.IsNullOrEmpty(gridConfig.Url)) + { + throw new Exception("Please configure the section configuration:eventgrid:url"); + } + + if (gridConfig.Topic == null || + string.IsNullOrEmpty(gridConfig.Topic.Name) || + string.IsNullOrEmpty(gridConfig.Topic.Schema)) + { + throw new Exception("Please configure configuration:eventgrid:topic:name, configuration:eventgrid:topic:schema"); + } + + if (!Enum.TryParse(gridConfig.Topic.Schema, ignoreCase: true, out InputSchema inputSchema)) + { + throw new Exception("Unknown value specified in configuration:eventgrid:topic:schema"); + } + + if (gridConfig.ClientAuth == null) + { + throw new Exception("Please configure configuration:eventgrid:clientAuth"); + } + + if (string.IsNullOrEmpty(gridConfig.ClientAuth.Source)) + { + throw new Exception("Please configure configuration:eventgrid:clientAuth:source"); + } + + if (gridConfig.ClientAuth.Source.Equals("IoTEdge", StringComparison.OrdinalIgnoreCase)) + { + // nothing to configure more + } + else + if (gridConfig.ClientAuth.Source.Equals("BearerToken", StringComparison.OrdinalIgnoreCase) && + string.IsNullOrEmpty(gridConfig.ClientAuth.Token1)) + { + throw new Exception("Please configure configuration:eventgrid:clientAuth:token1"); + } + else + { + throw new Exception("Unknown value configured for configuration:eventgrid:clientAuth:token1"); + } + } + + private static async Task PublishEventsAsync( + GridConfiguration gridConfig, + EventGridEdgeClient egClient, + CancellationToken cancellationToken) + { + Console.WriteLine($"Will publish events every {gridConfig.PublishIntervalInSeconds} seconds"); + + string topicName = gridConfig.Topic.Name; + InputSchema inputSchema = GetTopicInputSchema(gridConfig); + int publishIntervalInSeconds = gridConfig.PublishIntervalInSeconds; + + while (true) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + try + { + using (CancellationTokenSource cts = new CancellationTokenSource(30 * 1000)) + { + EventGridEvent evtPayload = (EventGridEvent)CreateEvent(topicName, inputSchema); + await egClient.Events.PublishJsonAsync(topicName: topicName, evtPayload.Id, payload: evtPayload, contentType: ApplicationJsonMTHV, cts.Token).ConfigureAwait(false); + Console.WriteLine($"Published event {JsonConvert.SerializeObject(evtPayload)} to eventgrid module ..."); + Console.WriteLine(); + } + } + catch (Exception e) + { + Console.WriteLine($"Failed to publish event to topic {topicName}. Reason: {e.ToString()}"); + } + + Thread.Sleep(publishIntervalInSeconds * 1000); + } + } + + private static async Task GetEventGridClientAsync(GridConfiguration gridConfig) + { + string[] urlTokens = gridConfig.Url.Split(":"); + if (urlTokens.Length != 3) + { + throw new Exception($"URL should be of the form '://:' "); + } + + string baseUrl = urlTokens[0] + ":" + urlTokens[1]; + int port = int.Parse(urlTokens[2], CultureInfo.InvariantCulture); + + if (gridConfig.ClientAuth.Source.Equals("IoTEdge", StringComparison.OrdinalIgnoreCase)) + { + IoTSecurity iotSecurity = new IoTSecurity(); + (X509Certificate2 identityCertificate, IEnumerable chain) = await iotSecurity.GetClientCertificateAsync(); + return new EventGridEdgeClient(baseUrl, port, new CustomHttpClientFactory(chain.First(), identityCertificate)); + } + else if (gridConfig.ClientAuth.Source.Equals("BearerToken", StringComparison.OrdinalIgnoreCase)) + { + EventGridEdgeClient egClient = new EventGridEdgeClient(baseUrl, port); + + HttpRequestHeaders defaultMgmtRequestHeaders = egClient.HttpClient.DefaultRequestHeaders; + defaultMgmtRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", $"{gridConfig.ClientAuth.Token1}"); + + HttpRequestHeaders defaultRuntimeRequestHeaders = egClient.HttpClient.DefaultRequestHeaders; + defaultRuntimeRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", $"{gridConfig.ClientAuth.Token1}"); + } + + throw new Exception("Cannot create eventgrid client!"); + } + + private static async Task WaitUntilEventGridModuleIsUpAndTopicExistsAsync( + GridConfiguration gridConfig, + EventGridEdgeClient egClient, + CancellationToken cancellationToken) + { + InputSchema inputSchema = GetTopicInputSchema(gridConfig); + Topic topic = new Topic + { + Name = gridConfig.Topic.Name, + Properties = new TopicProperties() + { + InputSchema = inputSchema, + }, + }; + + while (true) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + try + { + using (CancellationTokenSource cts = new CancellationTokenSource(30 * 1000)) + { + var createdTopic = await egClient.Topics.PutTopicAsync(topicName: topic.Name, topic: topic, cts.Token).ConfigureAwait(false); + Console.WriteLine($"Successfully created topic with name {topic.Name} so event grid must be up..."); + break; + } + } + catch (EventGridApiException e) + { + LogAndBackoff(topic.Name, e); + } + catch (HttpRequestException e) + { + LogAndBackoff(topic.Name, e); + } + } + } + + private static object CreateEvent(string topicName, InputSchema inputSchema) + { + Random random = new Random(); + switch (inputSchema) + { + case InputSchema.EventGridSchema: + string subject = $"sensor:{random.Next(1, 100)}"; + double temperature = random.NextDouble(); + double pressure = random.NextDouble(); + double humidity = random.Next(1, 25); + return new EventGridEvent() + { + Id = Guid.NewGuid().ToString(), + Topic = topicName, + Subject = subject, + EventType = "sensor.temperature", + DataVersion = "1.0", + EventTime = DateTime.UtcNow, + Data = new + { + Machine = new { Temperature = temperature, Pressure = pressure }, + Ambient = new { Temperature = temperature, Humidity= humidity }, + }, + }; + + default: + throw new NotImplementedException(); + } + } + + private static void LogAndBackoff(string topicName, Exception e) + { + Console.WriteLine($"Failed to create topic with name {topicName}. Reason: {e.ToString()}"); + Console.WriteLine("Retrying in 30 seconds..."); + Thread.Sleep(30 * 1000); + } + + private static CancellationTokenSource SetupGracefulShutdown(ManualResetEventSlim resetEvent) + { + var cts = new CancellationTokenSource(); + + AppDomain.CurrentDomain.ProcessExit += (sender, args) => Shutdown(); + + Console.CancelKeyPress += (sender, args) => + { + // Cancel this event so that the process doesn't get killed immediately, and we wait for graceful shutdown. + args.Cancel = true; + + Shutdown(); + }; + + return cts; + + void Shutdown() + { + if (!cts.IsCancellationRequested) + { + try + { + cts.Cancel(throwOnFirstException: false); + } + catch (Exception ex) + { + Console.WriteLine($"Cancelling gracefulShutdownCts failed, Swallowing the exception. Ex:\n{ex}"); + } + } + + resetEvent.Wait(); + } + } + + private static InputSchema GetTopicInputSchema(GridConfiguration gridConfig) + { + if (gridConfig.Topic == null || string.IsNullOrEmpty(gridConfig.Topic.Schema)) + { + throw new Exception("Need to configure eventgrid's topic:schema"); + } + + return (InputSchema)Enum.Parse(typeof(InputSchema), gridConfig.Topic.Schema, ignoreCase: true); + } + } +} diff --git a/IoTModules/c#/publisher/Publisher.csproj b/IoTModules/c#/publisher/Publisher.csproj new file mode 100644 index 0000000..e3313df --- /dev/null +++ b/IoTModules/c#/publisher/Publisher.csproj @@ -0,0 +1,37 @@ + + + + Exe + Microsoft.Azure.EventGridEdge.Samples.Publisher + aegp + + netcoreapp2.1 + 7.3 + 2.1.4 + + + + + + + + + + + + + + + + + Always + + + + + true + + $(NoWarn),1573,1591,1712 + + + diff --git a/IoTModules/c#/publisher/docker/Dockerfile.alpine-amd64 b/IoTModules/c#/publisher/docker/Dockerfile.alpine-amd64 new file mode 100644 index 0000000..8458847 --- /dev/null +++ b/IoTModules/c#/publisher/docker/Dockerfile.alpine-amd64 @@ -0,0 +1,10 @@ +FROM microsoft/dotnet:2.1-sdk AS build-env +WORKDIR /src +COPY . ./ +RUN dotnet restore /src/IoTModules/c#/publisher/*.csproj +RUN dotnet publish /src/IoTModules/c#/publisher/*.csproj -c Release -o out + +FROM mcr.microsoft.com/dotnet/core/runtime:2.1-alpine3.7 +WORKDIR /app +COPY --from=build-env /src/IoTModules/c#/publisher/out/ . +CMD ["dotnet", "aegp.dll"] \ No newline at end of file diff --git a/IoTModules/c#/publisher/docker/Dockerfile.bionic-arm32v7 b/IoTModules/c#/publisher/docker/Dockerfile.bionic-arm32v7 new file mode 100644 index 0000000..2adcfdf --- /dev/null +++ b/IoTModules/c#/publisher/docker/Dockerfile.bionic-arm32v7 @@ -0,0 +1,11 @@ +FROM microsoft/dotnet:2.1-sdk AS build-env +WORKDIR /src +COPY . ./ +RUN dotnet restore /src/IoTModules/c#/publisher/*.csproj +RUN dotnet publish /src/IoTModules/c#/publisher/*.csproj -c Release -o out + +FROM mcr.microsoft.com/dotnet/core/runtime:2.1-bionic-arm32v7 +WORKDIR /app +COPY --from=build-env /src/IoTModules/c#/publisher/out/ . + +CMD ["dotnet", "aegp.dll"] \ No newline at end of file diff --git a/IoTModules/c#/publisher/docker/Dockerfile.nanoserver-amd64 b/IoTModules/c#/publisher/docker/Dockerfile.nanoserver-amd64 new file mode 100644 index 0000000..87a35db --- /dev/null +++ b/IoTModules/c#/publisher/docker/Dockerfile.nanoserver-amd64 @@ -0,0 +1,11 @@ +FROM microsoft/dotnet:2.1-sdk AS build-env +WORKDIR /src +COPY . ./ +RUN dotnet restore \src\IoTModules\c#\publisher\Publisher.csproj +RUN dotnet publish \src\IoTModules\c#\publisher\Publisher.csproj -c Release -o out + +FROM mcr.microsoft.com/dotnet/core/runtime:2.1.10-nanoserver-1809 +WORKDIR /app +COPY --from=build-env /src/IoTModules/c#/publisher/out/ . + +CMD ["dotnet", "aegp.dll"] \ No newline at end of file diff --git a/IoTModules/c#/subscriber/CustomHttpClientFactory.cs b/IoTModules/c#/subscriber/CustomHttpClientFactory.cs new file mode 100644 index 0000000..c6c2bcd --- /dev/null +++ b/IoTModules/c#/subscriber/CustomHttpClientFactory.cs @@ -0,0 +1,67 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Globalization; +using System.Net.Http; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace Microsoft.Azure.EventGridEdge.Samples.Subscriber +{ + public class CustomHttpClientFactory : IHttpClientFactory + { + private readonly X509Certificate2 rootCA; + private readonly X509Certificate2 clientCert; + + public CustomHttpClientFactory(X509Certificate2 rootCA, X509Certificate2 clientCert) + { + this.rootCA = rootCA; + this.clientCert = clientCert; + } + + public HttpClient CreateClient(string name) + { + var httpClientHandler = new HttpClientHandler() + { + ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => ValidateCertificate(this.rootCA, cert, chain, errors), + }; + + if (this.clientCert != null) + { + httpClientHandler.ClientCertificateOptions = ClientCertificateOption.Manual; + httpClientHandler.ClientCertificates.Add(this.clientCert); + } + + return new HttpClient(httpClientHandler); + } + + private bool ValidateCertificate(X509Certificate2 trustedCertificateRoot, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslErrors) + { + SslPolicyErrors terminatingErrors = sslErrors & ~SslPolicyErrors.RemoteCertificateChainErrors; + if (terminatingErrors != SslPolicyErrors.None) + { + Console.WriteLine($"Server certificate validation failed due to {terminatingErrors}"); + return false; + } + + chain.ChainPolicy.ExtraStore.Add(trustedCertificateRoot); + chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority; + if (!chain.Build(new X509Certificate2(certificate))) + { + var errorMessageBuilder = new StringBuilder(); + foreach (X509ChainStatus cs in chain.ChainStatus) + { + errorMessageBuilder.AppendFormat(CultureInfo.InvariantCulture, $"ChainStatus: {cs.Status}, ChainStatusInfo: {cs.StatusInformation}"); + errorMessageBuilder.AppendLine(); + } + + Console.WriteLine($"Server certificate failed chain validation due to {errorMessageBuilder}"); + return false; + } + + return true; + } + } +} diff --git a/IoTModules/c#/subscriber/EventsHandler/EventSchema.cs b/IoTModules/c#/subscriber/EventsHandler/EventSchema.cs new file mode 100644 index 0000000..5990d4e --- /dev/null +++ b/IoTModules/c#/subscriber/EventsHandler/EventSchema.cs @@ -0,0 +1,10 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.Azure.EventGridEdge.Samples.Subscriber +{ + public enum EventSchema + { + EventGridSchema = 0, + } +} diff --git a/IoTModules/c#/subscriber/EventsHandler/EventsHandler.cs b/IoTModules/c#/subscriber/EventsHandler/EventsHandler.cs new file mode 100644 index 0000000..a26d1c9 --- /dev/null +++ b/IoTModules/c#/subscriber/EventsHandler/EventsHandler.cs @@ -0,0 +1,31 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Microsoft.Azure.EventGridEdge.SDK; +using Newtonsoft.Json; + +namespace Microsoft.Azure.EventGridEdge.Samples.Subscriber +{ + public class EventsHandler + { + private readonly JsonSerializer jsonSerializer = new JsonSerializer(); + + public void HandleEvents(Stream requestStream) + { + using (var sr = new StreamReader(requestStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) + using (var jtr = new JsonTextReader(sr)) + { + List outputEvents = this.jsonSerializer.Deserialize>(jtr); + foreach (EventGridEvent outputEvent in outputEvents) + { + Console.WriteLine($"Received Event: {JsonConvert.SerializeObject(outputEvent)}"); + Console.WriteLine(); + } + } + } + } +} diff --git a/IoTModules/c#/subscriber/GlobalSuppressions.cs b/IoTModules/c#/subscriber/GlobalSuppressions.cs new file mode 100644 index 0000000..6d4ad4d --- /dev/null +++ b/IoTModules/c#/subscriber/GlobalSuppressions.cs @@ -0,0 +1,11 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "Reviewied")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1200:Using directives should be placed correctly", Justification = "Reviewed")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1602:Enumeration items should be documented", Justification = "Reviewed")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1056:Uri properties should not be strings", Justification = "Reviewed")] +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Reviewed")] \ No newline at end of file diff --git a/IoTModules/c#/subscriber/GridConfiguration.cs b/IoTModules/c#/subscriber/GridConfiguration.cs new file mode 100644 index 0000000..67bc19d --- /dev/null +++ b/IoTModules/c#/subscriber/GridConfiguration.cs @@ -0,0 +1,30 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.Azure.EventGridEdge.Samples.Subscriber +{ + public class GridConfiguration + { + public string Url { get; set; } + + public int InitialDelayInSeconds { get; set; } + + public TopicOptions Topic { get; set; } + + public SubscriptionOptions Subscription { get; set; } + } + + public class TopicOptions + { + public string Name { get; set; } + } + + public class SubscriptionOptions + { + public string Name { get; set; } + + public string EventSchema { get; set; } + + public string Url { get; set; } + } +} \ No newline at end of file diff --git a/IoTModules/c#/subscriber/HostSettings.json b/IoTModules/c#/subscriber/HostSettings.json new file mode 100644 index 0000000..46255fc --- /dev/null +++ b/IoTModules/c#/subscriber/HostSettings.json @@ -0,0 +1,40 @@ +{ + "configuration": { + "enableEventGrid": true, + "eventGrid": { + "url": "https://eventgridmodule:4438", + "initialDelayInSeconds": 60, + "topic": { + "name": "egtopic" + }, + "subscription": { + "name": "egsubscribermodule", + "eventSchema": "eventgridschema", + "url": "https://egsubscribermodule:4430" + } + } + }, + "api": { + "requestTimeoutInSeconds": 30, + "captureStartupErrors": "true", + "detailedErrors": "true", + "kestrel": { + "addServerHeader": false, + "keepAliveTimeoutInSeconds": 300, + "endpoints": { + "http": { + "url": "http://*:8080" + }, + "https": { + "url": "https://*:4430" + } + } + }, + "logging": { + "LogLevel": { + "Default": "Warning", + "System.Net.Http.HttpClient": "Error" + } + } + } +} diff --git a/IoTModules/c#/subscriber/Program.cs b/IoTModules/c#/subscriber/Program.cs new file mode 100644 index 0000000..f26300f --- /dev/null +++ b/IoTModules/c#/subscriber/Program.cs @@ -0,0 +1,241 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Net.Http; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.EventGridEdge.SDK; +using Microsoft.Azure.EventGridEdge.Samples.Auth; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; +using System.Linq; + +namespace Microsoft.Azure.EventGridEdge.Samples.Subscriber +{ + public static class Program + { + public static async Task Main() + { + var resetEvent = new ManualResetEventSlim(); + + // signals to long running components when to power down (either due to a Ctrl+C, or Ctrl-Break, or SIGTERM, or SIGKILL) + CancellationTokenSource lifetimeCts = SetupGracefulShutdown(resetEvent); + + GridConfiguration gridConfig = GetGridConfiguration(); + EventGridEdgeClient egClient = GetEventGridClientAsync(gridConfig).GetAwaiter().GetResult(); + SubscriberHost host = SetupSubscriberHostAsync(lifetimeCts).GetAwaiter().GetResult(); + + // certificate issued by IoT Edge takes a while to be current so will wait for a bit + Thread.Sleep(120 * 1000); + + // wait for topic to exist + await WaitUntilEventGridModuleIsUpAndTopicExistsAsync(egClient, gridConfig.Topic.Name).ConfigureAwait(false); + + // register subscription + await RegisterSubscriptionAsync(egClient, gridConfig).ConfigureAwait(false); + + // wait until shutdown + await host.WaitForShutdownAsync().ConfigureAwait(false); + + // signal to gracefully shutdown + resetEvent.Set(); + } + + private static GridConfiguration GetGridConfiguration() + { + IConfiguration configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("HostSettings.json", optional: false, reloadOnChange: true) + .AddEnvironmentVariables() + .Build(); + + IConfigurationSection hostConfigurationSection = configuration.GetSection("configuration"); + if (!hostConfigurationSection.GetValue("enableEventGrid", true)) + { + throw new Exception("Need to set configuration:enableEventGrid=true to come up!"); + } + + IConfigurationSection eventGridSection = hostConfigurationSection.GetSection("eventGrid"); + GridConfiguration gridConfig = eventGridSection.Get(); + ValidateConfiguration(gridConfig); + return gridConfig; + } + + private static async Task SetupSubscriberHostAsync(CancellationTokenSource lifetimeCts) + { + IoTSecurity iotSecurity = new IoTSecurity(); + + // get server certificate to configure with + (X509Certificate2 serverCertificate, IEnumerable certificateChain) = + await iotSecurity.GetServerCertificateAsync().ConfigureAwait(false); + iotSecurity.ImportCertificate(new List() { serverCertificate }); + iotSecurity.ImportCertificate(certificateChain); + + Console.WriteLine($"Server Certificate issue is valid from {serverCertificate.NotBefore}, {serverCertificate.NotAfter}"); + + // start subscriber webhost + SubscriberHost host = new SubscriberHost(serverCertificate, lifetimeCts); + await host.StartAsync().ConfigureAwait(false); + return host; + } + + private static async Task RegisterSubscriptionAsync(EventGridEdgeClient egClient, GridConfiguration gridConfig) + { + string topicName = gridConfig.Topic.Name; + + // create subscription + EventSubscription eventSubscription = CreateEventSubscription(gridConfig); + using (CancellationTokenSource cts = new CancellationTokenSource(30 * 1000)) + { + await egClient.Subscriptions.PutSubscriptionAsync(topicName: topicName, subscriptionName: eventSubscription.Name, eventSubscription: eventSubscription, cts.Token).ConfigureAwait(false); + Console.WriteLine($"Successfully created subscription {JsonConvert.SerializeObject(eventSubscription)} for topic {topicName}"); + } + } + + private static async Task WaitUntilEventGridModuleIsUpAndTopicExistsAsync(EventGridEdgeClient egClient, string topicName) + { + while (true) + { + try + { + using (CancellationTokenSource cts = new CancellationTokenSource(30 * 1000)) + { + var topic = await egClient.Topics.GetTopicAsync(topicName: topicName, cts.Token).ConfigureAwait(false); + Console.WriteLine($"Successfully retrieved topic with name {topicName} so event grid must be up..."); + break; + } + } + catch (EventGridApiException e) + { + LogAndBackoff(topicName, e); + } + catch (HttpRequestException e) + { + LogAndBackoff(topicName, e); + } + } + } + + private static async Task GetEventGridClientAsync(GridConfiguration gridConfig) + { + IoTSecurity iotSecurity = new IoTSecurity(); + + // get the client certificate to use when communicating with eventgrid + (X509Certificate2 clientCertificate, IEnumerable chain) = await iotSecurity.GetClientCertificateAsync().ConfigureAwait(false); + Console.WriteLine($"Client Certificate issue is valid from {clientCertificate.NotBefore}, {clientCertificate.NotAfter}"); + string[] urlTokens = gridConfig.Url.Split(":"); + if (urlTokens.Length != 3) + { + throw new Exception($"URL should be of the form '://:' "); + } + + string baseUrl = urlTokens[0] + ":" + urlTokens[1]; + int port = int.Parse(urlTokens[2], CultureInfo.InvariantCulture); + + return new EventGridEdgeClient(baseUrl, port, new CustomHttpClientFactory(chain.First(), clientCertificate)); + } + + private static void LogAndBackoff(string topicName, Exception e) + { + Console.WriteLine($"Failed to retrieve topic with name {topicName}. Reason: {e.ToString()}"); + Console.WriteLine("Retrying in 30 seconds..."); + Thread.Sleep(30 * 1000); + } + + private static CancellationTokenSource SetupGracefulShutdown(ManualResetEventSlim resetEvent) + { + var cts = new CancellationTokenSource(); + + AppDomain.CurrentDomain.ProcessExit += (sender, args) => Shutdown(); + + Console.CancelKeyPress += (sender, args) => + { + // Cancel this event so that the process doesn't get killed immediately, and we wait for graceful shutdown. + args.Cancel = true; + + Shutdown(); + }; + + return cts; + + void Shutdown() + { + if (!cts.IsCancellationRequested) + { + try + { + cts.Cancel(throwOnFirstException: false); + } + catch (Exception ex) + { + Console.WriteLine($"Cancelling gracefulShutdownCts failed, Swallowing the exception. Ex:\n{ex}"); + } + } + + resetEvent.Wait(); + } + } + + private static EventSubscription CreateEventSubscription(GridConfiguration gridConfig) + { + string subscriptionName = gridConfig.Subscription.Name; + string subscriptionEventSchema = gridConfig.Subscription.EventSchema; + string subscriptionUrl = gridConfig.Subscription.Url; + + return new EventSubscription() + { + Name = subscriptionName, + Properties = new EventSubscriptionProperties() + { + EventDeliverySchema = (EventDeliverySchema)Enum.Parse(typeof(EventDeliverySchema), subscriptionEventSchema, true), + Destination = new WebHookEventSubscriptionDestination() + { + EndpointType = "WebHook", + Properties = new WebHookEventSubscriptionDestinationProperties() + { + EndpointUrl = subscriptionUrl, + }, + }, + + Topic = gridConfig.Topic.Name, + }, + }; + } + + private static void ValidateConfiguration(GridConfiguration gridConfig) + { + if (gridConfig == null) + { + throw new Exception("GridConfiguration is null. Please configure the section configuration:eventGrid"); + } + + if (string.IsNullOrEmpty(gridConfig.Url)) + { + throw new Exception("Please configure the section configuration:eventGrid:url"); + } + + if (gridConfig.Topic == null || + string.IsNullOrEmpty(gridConfig.Topic.Name)) + { + throw new Exception("Please configure configuration:eventGrid:topic:name"); + } + + if (gridConfig.Subscription == null) + { + throw new Exception("Please configure configuration:eventGrid:subscription"); + } + + if (string.IsNullOrEmpty(gridConfig.Subscription.Name) || + string.IsNullOrEmpty(gridConfig.Subscription.EventSchema) || + string.IsNullOrEmpty(gridConfig.Subscription.Url)) + { + throw new Exception("Please configure configuration:eventGrid:subscription:name, configuration:eventGrid:subscription:url, configuration:eventGrid:subscription:eventSchema"); + } + } + } +} diff --git a/IoTModules/c#/subscriber/Subscriber.csproj b/IoTModules/c#/subscriber/Subscriber.csproj new file mode 100644 index 0000000..fad2903 --- /dev/null +++ b/IoTModules/c#/subscriber/Subscriber.csproj @@ -0,0 +1,35 @@ + + + + Exe + Microsoft.Azure.EventGridEdge.Samples.Subscriber + aegs + + netcoreapp2.1 + 7.3 + 2.1.4 + + + + + + + + + + + + + + + Always + + + + + true + + $(NoWarn),1573,1591,1712 + + + diff --git a/IoTModules/c#/subscriber/SubscriberHost.cs b/IoTModules/c#/subscriber/SubscriberHost.cs new file mode 100644 index 0000000..d76ca19 --- /dev/null +++ b/IoTModules/c#/subscriber/SubscriberHost.cs @@ -0,0 +1,86 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Azure.EventGridEdge.Samples.Subscriber +{ + public class SubscriberHost + { + private readonly CancellationTokenSource lifetimeCts; + private readonly IWebHost subscriberHost; + + public SubscriberHost(X509Certificate2 serverCertificate, CancellationTokenSource lifetimeCts) + { + this.lifetimeCts = lifetimeCts; + + IConfiguration configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("HostSettings.json", optional: false, reloadOnChange: true) + .AddEnvironmentVariables() + .Build(); + + this.subscriberHost = HostBuilder + .GetHostBuilder(serverCertificate, configuration.GetSection("api")) + .Build(); + } + + public async Task StartAsync() + { + await this.subscriberHost.StartAsync().ConfigureAwait(false); + PrintAsciiArt(); + } + + public async Task WaitForShutdownAsync() + { + var shutdownTasks = new List + { + Task.Run(() => this.subscriberHost.WaitForShutdownAsync(this.lifetimeCts.Token)), + }; + + while (shutdownTasks.Count > 0) + { + Task completedTask = await Task.WhenAny(shutdownTasks).ConfigureAwait(false); + if (completedTask.IsFaulted) + { + Console.WriteLine($"WaitForShutdown faulted with exception:{completedTask.Exception}"); + + if (!this.lifetimeCts.IsCancellationRequested) + { + try + { + this.lifetimeCts.Cancel(throwOnFirstException: false); + } + catch (Exception ex) + { + Console.WriteLine($"Cancelling lifetimeCts failed. Swallowing the exception. Ex:\n{ex}"); + } + } + } + + shutdownTasks.Remove(completedTask); + } + } + + private static void PrintAsciiArt() + { + string asciiArt = @" +************************************************************************************************************************************************* +| ___ ______ __ ______ _ __ _____ __ _ __ | +| / |____ __ __________ / ____/ _____ ____ / /_ / ____/____(_)___/ / / ___/__ __/ /_ _______________(_) /_ ___ _____ | +| / /| /_ / / / / / ___/ _ \ / __/ | | / / _ \/ __ \/ __/ / / __/ ___/ / __ / \__ \/ / / / __ \/ ___/ ___/ ___/ / __ \/ _ \/ ___/ | +| / ___ |/ /_/ /_/ / / / __/ / /___ | |/ / __/ / / / /_ / /_/ / / / / /_/ / ___/ / /_/ / /_/ (__ ) /__/ / / / /_/ / __/ / | +| /_/ |_/___/\__,_/_/ \___/ /_____/ |___/\___/_/ /_/\__/ \____/_/ /_/\__,_/ /____/\__,_/_.___/____/\___/_/ /_/_.___/\___/_/ | +| | +*************************************************************************************************************************************************"; + Console.WriteLine(asciiArt); + } + } +} diff --git a/IoTModules/c#/subscriber/WebHost/HostBuilder.cs b/IoTModules/c#/subscriber/WebHost/HostBuilder.cs new file mode 100644 index 0000000..22f7b1a --- /dev/null +++ b/IoTModules/c#/subscriber/WebHost/HostBuilder.cs @@ -0,0 +1,56 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.IO; +using System.Net.Security; +using System.Runtime.InteropServices; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Https; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Azure.EventGridEdge.Samples.Subscriber +{ + public static class HostBuilder + { + public static IWebHostBuilder GetHostBuilder(X509Certificate2 serverCertificate, IConfiguration configuration) + { + IWebHostBuilder hostBuilder = new WebHostBuilder() + .UseContentRoot(Directory.GetCurrentDirectory()) + .UseConfiguration(configuration) + .UseKestrel((KestrelServerOptions options) => + { + IConfigurationSection kestrelConfig = configuration.GetSection("kestrel"); + options.Configure(kestrelConfig); + options.AddServerHeader = kestrelConfig.GetValue("addServerHeader", false); + options.Limits.MaxRequestBodySize = kestrelConfig.GetValue("maxRequestBodySize", 1024 * 1034); // allow 10 extra KB over the 1 MB payload + options.Limits.KeepAliveTimeout = TimeSpan.FromSeconds(kestrelConfig.GetValue("keepAliveTimeoutInSeconds", 120)); // default of 120 seconds + options.ConfigureHttpsDefaults((HttpsConnectionAdapterOptions o) => + { + o.ServerCertificate = serverCertificate; + o.ClientCertificateMode = ClientCertificateMode.AllowCertificate; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // this is needed because IoTEdge generates a self signed certificate that is not rooted in a root certificate that is trusted by the trust provider. + // Kestrel rejects the request automatically because of this. We return true here so that client validation can happen when routing requests. + o.ClientCertificateValidation = (X509Certificate2 arg1, X509Chain arg2, SslPolicyErrors arg3) => true; + } + }); + }) + .ConfigureLogging((ILoggingBuilder logging) => + { + IConfigurationSection loggingConfig = configuration.GetSection("logging"); + logging + .AddConfiguration(loggingConfig) + .AddConsole(); + }) + .UseStartup(); + + return hostBuilder; + } + } +} diff --git a/IoTModules/c#/subscriber/WebHost/HostStartup.cs b/IoTModules/c#/subscriber/WebHost/HostStartup.cs new file mode 100644 index 0000000..d3dded4 --- /dev/null +++ b/IoTModules/c#/subscriber/WebHost/HostStartup.cs @@ -0,0 +1,57 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.EventGridEdge.Samples.Auth; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Azure.EventGridEdge.Samples.Subscriber +{ + public class HostStartup : IStartup + { + private readonly EventsHandler eventsHandler = new EventsHandler(); + private readonly IoTSecurity iotSecurity = new IoTSecurity(); + + public void Configure(IApplicationBuilder app) + { + app.Use(this.RouteRequestAsync); + } + + public IServiceProvider ConfigureServices(IServiceCollection services) + { + return services.BuildServiceProvider(); + } + + private async Task RouteRequestAsync(HttpContext context, Func next) + { + string method = context.Request.Method; + if (method.Equals(HttpMethods.Post, StringComparison.OrdinalIgnoreCase)) + { + HttpRequest request = context.Request; + X509Certificate2 clientCert = await request.HttpContext.Connection.GetClientCertificateAsync().ConfigureAwait(false); + if (clientCert == null) + { + throw new Exception("Client certificate not provided!"); + } + + await iotSecurity.ValidateClientCertificateAsync(clientCert); + + // TODO: Verify it is eventgrid instance indeed! + using (var cts = new CancellationTokenSource(1000 * 30)) + { + this.eventsHandler.HandleEvents(request.Body); + } + } + else + { + await next().ConfigureAwait(false); + } + } + } +} diff --git a/IoTModules/c#/subscriber/docker/Dockerfile.alpine-amd64 b/IoTModules/c#/subscriber/docker/Dockerfile.alpine-amd64 new file mode 100644 index 0000000..c049759 --- /dev/null +++ b/IoTModules/c#/subscriber/docker/Dockerfile.alpine-amd64 @@ -0,0 +1,16 @@ +ARG base_tag=2.1.10-alpine3.7 + +FROM microsoft/dotnet:2.1-sdk AS build-env +WORKDIR /src +COPY . ./ +RUN dotnet restore /src/IoTModules/c#/subscriber/*.csproj +RUN dotnet publish /src/IoTModules/c#/subscriber/*.csproj -c Release -o out + +FROM mcr.microsoft.com/dotnet/core/aspnet:${base_tag} + +WORKDIR /app +COPY --from=build-env /src/IoTModules/c#/subscriber/out/ . +EXPOSE 8080/tcp +EXPOSE 4430/tcp + +CMD ["dotnet", "aegs.dll"] \ No newline at end of file diff --git a/IoTModules/c#/subscriber/docker/Dockerfile.bionic-arm32v7 b/IoTModules/c#/subscriber/docker/Dockerfile.bionic-arm32v7 new file mode 100644 index 0000000..bb0532b --- /dev/null +++ b/IoTModules/c#/subscriber/docker/Dockerfile.bionic-arm32v7 @@ -0,0 +1,14 @@ +ARG base_tag=2.1.10-bionic-arm32v7 +FROM microsoft/dotnet:2.1-sdk AS build-env +WORKDIR /src +COPY . ./ +RUN dotnet restore /src/IoTModules/c#/subscriber/*.csproj +RUN dotnet publish /src/IoTModules/c#/subscriber/*.csproj -c Release -o out + +FROM mcr.microsoft.com/dotnet/core/aspnet:${base_tag} + +WORKDIR /app +COPY --from=build-env /src/IoTModules/c#/subscriber/out/ . +EXPOSE 8080/tcp +EXPOSE 4430/tcp +CMD ["dotnet", "aegs.dll"] \ No newline at end of file diff --git a/IoTModules/c#/subscriber/docker/Dockerfile.nanoserver-amd64 b/IoTModules/c#/subscriber/docker/Dockerfile.nanoserver-amd64 new file mode 100644 index 0000000..f45d283 --- /dev/null +++ b/IoTModules/c#/subscriber/docker/Dockerfile.nanoserver-amd64 @@ -0,0 +1,14 @@ +FROM microsoft/dotnet:2.1-sdk AS build-env +WORKDIR /src +COPY . ./ +RUN dotnet restore \src\IoTModules\c#\subscriber\Subscriber.csproj +RUN dotnet publish \src\IoTModules\c#\subscriber\Subscriber.csproj -c Release -o out + +FROM mcr.microsoft.com/dotnet/core/aspnet:2.1.10-nanoserver-1809 +WORKDIR /app +COPY --from=build-env /src/IoTModules/c#/subscriber/out/ . + +EXPOSE 8080/tcp +EXPOSE 4430/tcp + +CMD ["dotnet", "aegs.dll"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4b1ad51 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/Microsoft.Azure.EventGridEdge.Samples.sln b/Microsoft.Azure.EventGridEdge.Samples.sln new file mode 100644 index 0000000..888bf1e --- /dev/null +++ b/Microsoft.Azure.EventGridEdge.Samples.sln @@ -0,0 +1,76 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.28307.572 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Publisher", "IoTModules\c#\publisher\Publisher.csproj", "{BF992658-AF4A-4C41-A6C5-1F075F5D51E7}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Subscriber", "IoTModules\c#\subscriber\Subscriber.csproj", "{5CA757E4-191B-4025-8BF7-24A25D8F882E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Auth", "IoTModules\c#\auth\Auth.csproj", "{C5BBA16E-48AE-4C2E-A840-BD75BD35EF13}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "IoTModules", "IoTModules", "{C8E6EBAA-DA12-4ACC-897B-E674F24615E7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "QuickStart", "QuickStart", "{1A29FBDF-CDBA-4BD0-9FE5-D40D95FF988D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Publisher", "QuickStart\c#\publisher\Publisher.csproj", "{D337DD41-196D-49A9-B903-77F2E4F319D3}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Subscriber", "QuickStart\c#\subscriber\Subscriber.csproj", "{3814D171-75B4-4324-8478-DBCCC9CBB5E3}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SDK", "SDK", "{AEE9042C-0BC5-4302-9582-9AFA340EF555}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SDK", "SDK\SDK.csproj", "{83EC7C9C-AB4E-480C-BC14-1BF688EAA28A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SecurityDaemonClient", "SecurityDaemonClient\SecurityDaemonClient.csproj", "{00E8B3CB-A051-4A13-B965-BE4836E1AFD8}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {BF992658-AF4A-4C41-A6C5-1F075F5D51E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF992658-AF4A-4C41-A6C5-1F075F5D51E7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF992658-AF4A-4C41-A6C5-1F075F5D51E7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF992658-AF4A-4C41-A6C5-1F075F5D51E7}.Release|Any CPU.Build.0 = Release|Any CPU + {5CA757E4-191B-4025-8BF7-24A25D8F882E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5CA757E4-191B-4025-8BF7-24A25D8F882E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5CA757E4-191B-4025-8BF7-24A25D8F882E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5CA757E4-191B-4025-8BF7-24A25D8F882E}.Release|Any CPU.Build.0 = Release|Any CPU + {C5BBA16E-48AE-4C2E-A840-BD75BD35EF13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C5BBA16E-48AE-4C2E-A840-BD75BD35EF13}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C5BBA16E-48AE-4C2E-A840-BD75BD35EF13}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C5BBA16E-48AE-4C2E-A840-BD75BD35EF13}.Release|Any CPU.Build.0 = Release|Any CPU + {D337DD41-196D-49A9-B903-77F2E4F319D3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D337DD41-196D-49A9-B903-77F2E4F319D3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D337DD41-196D-49A9-B903-77F2E4F319D3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D337DD41-196D-49A9-B903-77F2E4F319D3}.Release|Any CPU.Build.0 = Release|Any CPU + {3814D171-75B4-4324-8478-DBCCC9CBB5E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3814D171-75B4-4324-8478-DBCCC9CBB5E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3814D171-75B4-4324-8478-DBCCC9CBB5E3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3814D171-75B4-4324-8478-DBCCC9CBB5E3}.Release|Any CPU.Build.0 = Release|Any CPU + {83EC7C9C-AB4E-480C-BC14-1BF688EAA28A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {83EC7C9C-AB4E-480C-BC14-1BF688EAA28A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {83EC7C9C-AB4E-480C-BC14-1BF688EAA28A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {83EC7C9C-AB4E-480C-BC14-1BF688EAA28A}.Release|Any CPU.Build.0 = Release|Any CPU + {00E8B3CB-A051-4A13-B965-BE4836E1AFD8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00E8B3CB-A051-4A13-B965-BE4836E1AFD8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00E8B3CB-A051-4A13-B965-BE4836E1AFD8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00E8B3CB-A051-4A13-B965-BE4836E1AFD8}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {BF992658-AF4A-4C41-A6C5-1F075F5D51E7} = {C8E6EBAA-DA12-4ACC-897B-E674F24615E7} + {5CA757E4-191B-4025-8BF7-24A25D8F882E} = {C8E6EBAA-DA12-4ACC-897B-E674F24615E7} + {C5BBA16E-48AE-4C2E-A840-BD75BD35EF13} = {C8E6EBAA-DA12-4ACC-897B-E674F24615E7} + {D337DD41-196D-49A9-B903-77F2E4F319D3} = {1A29FBDF-CDBA-4BD0-9FE5-D40D95FF988D} + {3814D171-75B4-4324-8478-DBCCC9CBB5E3} = {1A29FBDF-CDBA-4BD0-9FE5-D40D95FF988D} + {83EC7C9C-AB4E-480C-BC14-1BF688EAA28A} = {AEE9042C-0BC5-4302-9582-9AFA340EF555} + {00E8B3CB-A051-4A13-B965-BE4836E1AFD8} = {AEE9042C-0BC5-4302-9582-9AFA340EF555} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {475D2ECB-88BD-47E1-B351-D17F2ACF95B8} + EndGlobalSection +EndGlobal diff --git a/QuickStart/c#/publisher/Program.cs b/QuickStart/c#/publisher/Program.cs new file mode 100644 index 0000000..dec55b3 --- /dev/null +++ b/QuickStart/c#/publisher/Program.cs @@ -0,0 +1,108 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.EventGridEdge.SDK; +using Newtonsoft.Json; + +namespace Microsoft.Azure.EventGridEdge.QuickStart.Publisher +{ + public static class Program + { + private static readonly string eventGridBaseAddress = "http://eventgridmodule"; + private static readonly int eventGridPort = 5888; + private static readonly TimeSpan initialDelay = TimeSpan.FromMinutes(1); + + private static readonly string topicName = "quickstarttopic"; + private static readonly InputSchema topicSchema = InputSchema.CustomEventSchema; + + private static readonly string subscriptionName= "quickstartsub"; + private static readonly EventDeliverySchema deliverySchema = EventDeliverySchema.CustomEventSchema; + private static readonly string subscriberUrl = "http://subscriber:80/api/subscriber"; + + private static readonly MediaTypeHeaderValue ApplicationJsonMTHV = new MediaTypeHeaderValue("application/json"); + + public static async Task Main() + { + Console.WriteLine($"\nWaiting a few minute(s) to create topic '{topicName}' ...\n"); + Thread.Sleep(initialDelay); + + Console.WriteLine($"EventGrid Module's URL: {eventGridBaseAddress}:{eventGridPort}"); + EventGridEdgeClient egClient = new EventGridEdgeClient(eventGridBaseAddress, eventGridPort); + + // create topic + Topic topic = new Topic() + { + Name = topicName, + Properties = new TopicProperties() + { + InputSchema = topicSchema + } + }; + + var createdTopic = await egClient.Topics.PutTopicAsync(topicName: topicName, topic: topic, CancellationToken.None).ConfigureAwait(false); + Console.WriteLine($"Created topic with Name: {topic.Name}, Schema: {topic.Properties.InputSchema} ..."); + + // the recommendation is to create subscribers from subscription modules or a "management" module. for the purposes of quickstart we are creating it here. + EventSubscription eventSubscription = new EventSubscription() + { + Name = subscriptionName, + Properties = new EventSubscriptionProperties + { + Topic = topicName, + EventDeliverySchema = deliverySchema, + Destination = new WebHookEventSubscriptionDestination() + { + EndpointType = "Webhook", + Properties = new WebHookEventSubscriptionDestinationProperties() + { + EndpointUrl = subscriberUrl, + } + } + } + }; + + var createdSubscription = await egClient.Subscriptions.PutSubscriptionAsync(topicName: topicName, subscriptionName: subscriptionName, eventSubscription: eventSubscription, CancellationToken.None).ConfigureAwait(false); + Console.WriteLine($"Created subscription with Name: {createdSubscription.Name}, Schema: {topic.Properties.InputSchema}, EndpointUrl: {subscriberUrl} for topic: {topic.Name} ..."); + + Console.WriteLine($"\nWaiting a few minute(s) before publishing events ...\n"); + Thread.Sleep(initialDelay); + + // keep publishing events + while (true) + { + EventGridEvent evt = GetEvent(); + Console.WriteLine($"\nPublishing event: {JsonConvert.SerializeObject(evt)}"); + egClient.Events.PublishJsonAsync(topicName: topicName, (new List() { evt }), ApplicationJsonMTHV, CancellationToken.None).GetAwaiter().GetResult(); + } + } + + private static EventGridEvent GetEvent() + { + Random random = new Random(); + string subject = $"sensor:{random.Next(1, 100)}"; + double temperature = random.NextDouble(); + double pressure = random.NextDouble(); + double humidity = random.Next(1, 25); + return new EventGridEvent() + { + Id = Guid.NewGuid().ToString(), + Topic = topicName, + Subject = subject, + EventType = "sensor.temperature", + DataVersion = "1.0", + EventTime = DateTime.UtcNow, + Data = new + { + Machine = new { Temperature = temperature, Pressure = pressure }, + Ambient = new { Temperature = temperature, Humidity = humidity }, + }, + }; + } + } +} diff --git a/QuickStart/c#/publisher/Publisher.csproj b/QuickStart/c#/publisher/Publisher.csproj new file mode 100644 index 0000000..39490f3 --- /dev/null +++ b/QuickStart/c#/publisher/Publisher.csproj @@ -0,0 +1,29 @@ + + + + Exe + Microsoft.Azure.EventGridEdge.QuickStart.Publisher + aegp + + netcoreapp2.1 + 7.3 + 2.1.4 + + + + + + + + + Always + + + + + true + + $(NoWarn),1573,1591,1712 + + + diff --git a/QuickStart/c#/publisher/docker/Dockerfile.alpine-amd64 b/QuickStart/c#/publisher/docker/Dockerfile.alpine-amd64 new file mode 100644 index 0000000..8c6f520 --- /dev/null +++ b/QuickStart/c#/publisher/docker/Dockerfile.alpine-amd64 @@ -0,0 +1,11 @@ +FROM microsoft/dotnet:2.1-sdk AS build-env +WORKDIR /src +COPY . ./ +RUN dotnet restore /src/QuickStart/c#/publisher/*.csproj +RUN dotnet publish /src/QuickStart/c#/publisher/*.csproj -c Release -o out + +FROM mcr.microsoft.com/dotnet/core/runtime:2.1-alpine3.7 +WORKDIR /app +COPY --from=build-env /src/QuickStart/c#/publisher/out/ . + +CMD ["dotnet", "aegp.dll"] \ No newline at end of file diff --git a/QuickStart/c#/publisher/docker/Dockerfile.bionic-arm32v7 b/QuickStart/c#/publisher/docker/Dockerfile.bionic-arm32v7 new file mode 100644 index 0000000..cdd6bb8 --- /dev/null +++ b/QuickStart/c#/publisher/docker/Dockerfile.bionic-arm32v7 @@ -0,0 +1,12 @@ +FROM microsoft/dotnet:2.1-sdk AS build-env +WORKDIR /src +COPY . ./ +RUN dotnet restore /src/QuickStart/c#/publisher/*.csproj +RUN dotnet publish /src/QuickStart/c#/publisher/*.csproj -c Release -o out + +FROM mcr.microsoft.com/dotnet/core/runtime:2.1-bionic-arm32v7 + +WORKDIR /app +COPY --from=build-env /src/QuickStart/c#/publisher/out/ . + +CMD ["dotnet", "aegp.dll"] \ No newline at end of file diff --git a/QuickStart/c#/publisher/docker/Dockerfile.nanoserver-amd64 b/QuickStart/c#/publisher/docker/Dockerfile.nanoserver-amd64 new file mode 100644 index 0000000..fd88b87 --- /dev/null +++ b/QuickStart/c#/publisher/docker/Dockerfile.nanoserver-amd64 @@ -0,0 +1,11 @@ +FROM microsoft/dotnet:2.1-sdk AS build-env +WORKDIR /src +COPY . ./ +RUN dotnet restore \src\QuickStart\c#\publisher\publisher.csproj +RUN dotnet publish \src\QuickStart\c#\publisher\publisher.csproj -c Release -o out + +FROM mcr.microsoft.com/dotnet/core/runtime:2.1.10-nanoserver-1809 +WORKDIR /app +COPY --from=build-env /src/QuickStart/c#/publisher/out/ . + +CMD ["dotnet", "aegp.dll"] \ No newline at end of file diff --git a/QuickStart/c#/subscriber/Program.cs b/QuickStart/c#/subscriber/Program.cs new file mode 100644 index 0000000..e3b4822 --- /dev/null +++ b/QuickStart/c#/subscriber/Program.cs @@ -0,0 +1,29 @@ +// Copyright(c) Microsoft Corporation. +// Licensed under the MIT license. + +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.Extensions.Http; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace Microsoft.Azure.EventGridEdge.QuickStart.Subscriber +{ + public static class Subscriber + { + [FunctionName("subscriber")] + public static async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] + HttpRequest req, ILogger log) + { + + string requestBody = await new StreamReader(req.Body).ReadToEndAsync(); + dynamic data = JsonConvert.DeserializeObject(requestBody); + log.LogInformation($"Received event data {data}\n"); + return new OkResult(); + } + } +} diff --git a/QuickStart/c#/subscriber/Subscriber.csproj b/QuickStart/c#/subscriber/Subscriber.csproj new file mode 100644 index 0000000..3575e94 --- /dev/null +++ b/QuickStart/c#/subscriber/Subscriber.csproj @@ -0,0 +1,30 @@ + + + netstandard2.0 + Microsoft.Azure.EventGridEdge.QuickStart.Subscriber + + subscriber + + + True + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + \ No newline at end of file diff --git a/QuickStart/c#/subscriber/docker/Dockerfile.alpine-amd64 b/QuickStart/c#/subscriber/docker/Dockerfile.alpine-amd64 new file mode 100644 index 0000000..3424bc3 --- /dev/null +++ b/QuickStart/c#/subscriber/docker/Dockerfile.alpine-amd64 @@ -0,0 +1,15 @@ +FROM microsoft/dotnet:2.1-sdk AS build-env +WORKDIR /src +COPY . ./ +RUN dotnet restore /src/QuickStart/c#/subscriber/*.csproj +RUN dotnet publish /src/QuickStart/c#/subscriber/*.csproj -c Release -o out + +FROM mcr.microsoft.com/azure-functions/dotnet:2.0-iot-edge +WORKDIR /app + +ENV AzureWebJobsScriptRoot=/app +ENV AzureFunctionsJobHost__Logging__Console__IsEnabled=true + +EXPOSE 80/tcp + +COPY --from=build-env /src/QuickStart/c#/subscriber/out/ . diff --git a/QuickStart/c#/subscriber/docker/Dockerfile.bionic-arm32v7 b/QuickStart/c#/subscriber/docker/Dockerfile.bionic-arm32v7 new file mode 100644 index 0000000..45a862e --- /dev/null +++ b/QuickStart/c#/subscriber/docker/Dockerfile.bionic-arm32v7 @@ -0,0 +1,15 @@ +FROM microsoft/dotnet:2.1-sdk AS build-env +WORKDIR /src +COPY . ./ +RUN dotnet restore /src/QuickStart/c#/subscriber/*.csproj +RUN dotnet publish /src/QuickStart/c#/subscriber/*.csproj -c Release -o out + +FROM mcr.microsoft.com/azure-functions/dotnet:2.0-arm32v7 +WORKDIR /app + +ENV AzureWebJobsScriptRoot=/app +ENV AzureFunctionsJobHost__Logging__Console__IsEnabled=true + +EXPOSE 80/tcp + +COPY --from=build-env /src/QuickStart/c#/subscriber/out/ . \ No newline at end of file diff --git a/QuickStart/c#/subscriber/docker/Dockerfile.nanoserver-amd64 b/QuickStart/c#/subscriber/docker/Dockerfile.nanoserver-amd64 new file mode 100644 index 0000000..ce53774 --- /dev/null +++ b/QuickStart/c#/subscriber/docker/Dockerfile.nanoserver-amd64 @@ -0,0 +1,15 @@ +FROM microsoft/dotnet:2.1-sdk AS build-env +WORKDIR /src +COPY . ./ +RUN dotnet restore \src\QuickStart\c#\subscriber\Subscriber.csproj +RUN dotnet publish \src\QuickStart\c#\subscriber\Subscriber.csproj -c Release -o out + +FROM mcr.microsoft.com/azure-functions/dotnet:2.0-nanoserver-1809 +WORKDIR /app + +ENV AzureWebJobsScriptRoot=/app +ENV AzureFunctionsJobHost__Logging__Console__IsEnabled=true + +EXPOSE 80/tcp + +COPY --from=build-env /src/QuickStart/c#/subscriber/out/ . \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..29f7ebd --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ + +# Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit https://cla.microsoft.com. + +When you submit a pull request, a CLA-bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. diff --git a/SDK/Contracts/AdvancedFilter.cs b/SDK/Contracts/AdvancedFilter.cs new file mode 100644 index 0000000..48a57f2 --- /dev/null +++ b/SDK/Contracts/AdvancedFilter.cs @@ -0,0 +1,222 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class AdvancedFilter + { + [JsonConverter(typeof(StringEnumConverter))] + public AdvancedFilterOperatorType OperatorType { get; set; } + + public string Key { get; set; } + } + + public class BoolEqualsAdvancedFilter : AdvancedFilter + { + public BoolEqualsAdvancedFilter(string key, bool value) + : this() + { + this.Key = key; + this.Value = value; + } + + public BoolEqualsAdvancedFilter() + { + this.OperatorType = AdvancedFilterOperatorType.BoolEquals; + } + + public bool Value { get; set; } + } + + public class NumberLessThanAdvancedFilter : AdvancedFilter + { + public NumberLessThanAdvancedFilter(string key, decimal value) + : this() + { + this.Key = key; + this.Value = value; + } + + public NumberLessThanAdvancedFilter() + { + this.OperatorType = AdvancedFilterOperatorType.NumberLessThan; + } + + public decimal Value { get; set; } + } + + public class NumberGreaterThanAdvancedFilter : AdvancedFilter + { + public NumberGreaterThanAdvancedFilter(string key, decimal value) + : this() + { + this.Key = key; + this.Value = value; + } + + public NumberGreaterThanAdvancedFilter() + { + this.OperatorType = AdvancedFilterOperatorType.NumberGreaterThan; + } + + public decimal Value { get; set; } + } + + public class NumberLessThanOrEqualsAdvancedFilter : AdvancedFilter + { + public NumberLessThanOrEqualsAdvancedFilter(string key, decimal value) + : this() + { + this.Key = key; + this.Value = value; + } + + public NumberLessThanOrEqualsAdvancedFilter() + { + this.OperatorType = AdvancedFilterOperatorType.NumberLessThanOrEquals; + } + + public decimal Value { get; set; } + } + + public class NumberGreaterThanOrEqualsAdvancedFilter : AdvancedFilter + { + public NumberGreaterThanOrEqualsAdvancedFilter(string key, decimal value) + : this() + { + this.Key = key; + this.Value = value; + } + + public NumberGreaterThanOrEqualsAdvancedFilter() + { + this.OperatorType = AdvancedFilterOperatorType.NumberGreaterThanOrEquals; + } + + public decimal Value { get; set; } + } + + public class NumberInAdvancedFilter : AdvancedFilter + { + public NumberInAdvancedFilter(string key, params decimal[] values) + : this() + { + this.Key = key; + this.Values = values; + } + + public NumberInAdvancedFilter() + { + this.OperatorType = AdvancedFilterOperatorType.NumberIn; + } + + public decimal[] Values { get; set; } + } + + public class NumberNotInAdvancedFilter : AdvancedFilter + { + public NumberNotInAdvancedFilter(string key, params decimal[] values) + : this() + { + this.Key = key; + this.Values = values; + } + + public NumberNotInAdvancedFilter() + { + this.OperatorType = AdvancedFilterOperatorType.NumberNotIn; + } + + public decimal[] Values { get; set; } + } + + public class StringInAdvancedFilter : AdvancedFilter + { + public StringInAdvancedFilter(string key, params string[] values) + : this() + { + this.Key = key; + this.Values = values; + } + + public StringInAdvancedFilter() + { + this.OperatorType = AdvancedFilterOperatorType.StringIn; + } + + public string[] Values { get; set; } + } + + public class StringNotInAdvancedFilter : AdvancedFilter + { + public StringNotInAdvancedFilter(string key, params string[] values) + : this() + { + this.Key = key; + this.Values = values; + } + + public StringNotInAdvancedFilter() + { + this.OperatorType = AdvancedFilterOperatorType.StringNotIn; + } + + public string[] Values { get; set; } + } + + public class StringBeginsWithAdvancedFilter : AdvancedFilter + { + public StringBeginsWithAdvancedFilter(string key, params string[] values) + : this() + { + this.Key = key; + this.Values = values; + } + + public StringBeginsWithAdvancedFilter() + { + this.OperatorType = AdvancedFilterOperatorType.StringBeginsWith; + } + + public string[] Values { get; set; } + } + + public class StringEndsWithAdvancedFilter : AdvancedFilter + { + public StringEndsWithAdvancedFilter(string key, params string[] values) + : this() + { + this.Key = key; + this.Values = values; + } + + public StringEndsWithAdvancedFilter() + { + this.OperatorType = AdvancedFilterOperatorType.StringEndsWith; + } + + public string[] Values { get; set; } + } + + public class StringContainsAdvancedFilter : AdvancedFilter + { + public StringContainsAdvancedFilter(string key, params string[] values) + : this() + { + this.Key = key; + this.Values = values; + } + + public StringContainsAdvancedFilter() + { + this.OperatorType = AdvancedFilterOperatorType.StringContains; + } + + public string[] Values { get; set; } + } +} diff --git a/SDK/Contracts/AdvancedFilterJsonConverter.cs b/SDK/Contracts/AdvancedFilterJsonConverter.cs new file mode 100644 index 0000000..9a8928d --- /dev/null +++ b/SDK/Contracts/AdvancedFilterJsonConverter.cs @@ -0,0 +1,51 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class AdvancedFilterJsonConverter : JsonConverter + { + public override bool CanWrite => false; + + public override bool CanConvert(Type objectType) => typeof(AdvancedFilter[]).IsAssignableFrom(objectType); + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType != JsonToken.StartArray) + { + return Array.Empty(); + } + + var array = JArray.Load(reader); + if (array.Count == 0) + { + return Array.Empty(); + } + + var result = new List(); + foreach (JToken token in array) + { + if (token.Type != JTokenType.Object) + { + continue; + } + + var obj = (JObject)token; + var operatorType = (AdvancedFilterOperatorType)Enum.Parse(typeof(AdvancedFilterOperatorType), obj.GetValue("operatorType", StringComparison.OrdinalIgnoreCase).ToString(), true); + Type deserializeClass = AdvancedFilterTypeConverter.OperatorTypeToAdvancedFilter(operatorType); + var filter = (AdvancedFilter)obj.ToObject(deserializeClass, serializer); + result.Add(filter); + } + + return result.ToArray(); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) => throw new NotImplementedException(); + } +} diff --git a/SDK/Contracts/AdvancedFilterOperatorType.cs b/SDK/Contracts/AdvancedFilterOperatorType.cs new file mode 100644 index 0000000..3afc43b --- /dev/null +++ b/SDK/Contracts/AdvancedFilterOperatorType.cs @@ -0,0 +1,23 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public enum AdvancedFilterOperatorType + { + NumberIn, + NumberNotIn, + NumberLessThan, + NumberGreaterThan, + NumberLessThanOrEquals, + NumberGreaterThanOrEquals, + BoolEquals, + StringIn, + StringNotIn, + StringBeginsWith, + StringEndsWith, + StringContains, + } +} diff --git a/SDK/Contracts/AdvancedFilterTypeConverter.cs b/SDK/Contracts/AdvancedFilterTypeConverter.cs new file mode 100644 index 0000000..272b1b8 --- /dev/null +++ b/SDK/Contracts/AdvancedFilterTypeConverter.cs @@ -0,0 +1,110 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- +using System; + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public static class AdvancedFilterTypeConverter + { + public static Type OperatorTypeToAdvancedFilter(AdvancedFilterOperatorType operatorType) + { + switch (operatorType) + { + case AdvancedFilterOperatorType.NumberIn: + return typeof(NumberInAdvancedFilter); + + case AdvancedFilterOperatorType.NumberNotIn: + return typeof(NumberNotInAdvancedFilter); + + case AdvancedFilterOperatorType.NumberLessThan: + return typeof(NumberLessThanAdvancedFilter); + + case AdvancedFilterOperatorType.NumberLessThanOrEquals: + return typeof(NumberLessThanOrEqualsAdvancedFilter); + + case AdvancedFilterOperatorType.NumberGreaterThan: + return typeof(NumberGreaterThanAdvancedFilter); + + case AdvancedFilterOperatorType.NumberGreaterThanOrEquals: + return typeof(NumberGreaterThanOrEqualsAdvancedFilter); + + case AdvancedFilterOperatorType.BoolEquals: + return typeof(BoolEqualsAdvancedFilter); + + case AdvancedFilterOperatorType.StringIn: + return typeof(StringInAdvancedFilter); + + case AdvancedFilterOperatorType.StringNotIn: + return typeof(StringNotInAdvancedFilter); + + case AdvancedFilterOperatorType.StringBeginsWith: + return typeof(StringBeginsWithAdvancedFilter); + + case AdvancedFilterOperatorType.StringEndsWith: + return typeof(StringEndsWithAdvancedFilter); + + case AdvancedFilterOperatorType.StringContains: + return typeof(StringContainsAdvancedFilter); + + default: + throw new ArgumentException($"Advanced Filter operatorType {operatorType} not supported"); + } + } + + public static AdvancedFilterOperatorType AdvancedFilterToOperatorType(Type type) + { + if (type == typeof(NumberInAdvancedFilter)) + { + return AdvancedFilterOperatorType.NumberIn; + } + else if (type == typeof(NumberNotInAdvancedFilter)) + { + return AdvancedFilterOperatorType.NumberNotIn; + } + else if (type == typeof(NumberLessThanAdvancedFilter)) + { + return AdvancedFilterOperatorType.NumberLessThan; + } + else if (type == typeof(NumberLessThanOrEqualsAdvancedFilter)) + { + return AdvancedFilterOperatorType.NumberLessThanOrEquals; + } + else if (type == typeof(NumberGreaterThanAdvancedFilter)) + { + return AdvancedFilterOperatorType.NumberGreaterThan; + } + else if (type == typeof(NumberGreaterThanOrEqualsAdvancedFilter)) + { + return AdvancedFilterOperatorType.NumberGreaterThanOrEquals; + } + else if (type == typeof(BoolEqualsAdvancedFilter)) + { + return AdvancedFilterOperatorType.BoolEquals; + } + else if (type == typeof(StringInAdvancedFilter)) + { + return AdvancedFilterOperatorType.StringIn; + } + else if (type == typeof(StringNotInAdvancedFilter)) + { + return AdvancedFilterOperatorType.StringNotIn; + } + else if (type == typeof(StringBeginsWithAdvancedFilter)) + { + return AdvancedFilterOperatorType.StringBeginsWith; + } + else if (type == typeof(StringEndsWithAdvancedFilter)) + { + return AdvancedFilterOperatorType.StringEndsWith; + } + else if (type == typeof(StringContainsAdvancedFilter)) + { + return AdvancedFilterOperatorType.StringContains; + } + + throw new InvalidOperationException($"Unknown type {type.Name}. Cannot map it to an {nameof(AdvancedFilterOperatorType)}."); + } + } +} diff --git a/SDK/Contracts/CaseInsensitiveDictionaryConverter.cs b/SDK/Contracts/CaseInsensitiveDictionaryConverter.cs new file mode 100644 index 0000000..5361703 --- /dev/null +++ b/SDK/Contracts/CaseInsensitiveDictionaryConverter.cs @@ -0,0 +1,36 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class CaseInsensitiveDictionaryConverter : JsonConverter + { + public override bool CanWrite => false; + + public override bool CanRead => true; + + public override bool CanConvert(Type objectType) => typeof(Dictionary).IsAssignableFrom(objectType); + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) => throw new NotImplementedException(); + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + { + return null; + } + + string path = reader.Path; + var obj = JObject.Load(reader); + Dictionary defaultDictionary = obj.ToObject>(serializer); + return new Dictionary(defaultDictionary, StringComparer.OrdinalIgnoreCase); + } + } +} diff --git a/SDK/Contracts/CloudEvent.cs b/SDK/Contracts/CloudEvent.cs new file mode 100644 index 0000000..9977545 --- /dev/null +++ b/SDK/Contracts/CloudEvent.cs @@ -0,0 +1,78 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System; + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class CloudEvent : CloudEvent + { + } + + public class CloudEvent : IEquatable> + { + public string Id { get; set; } + + public string Source { get; set; } + + public string SpecVersion { get; set; } + + public string Type { get; set; } + + public string DataContentType { get; set; } + + public string DataSchema { get; set; } + + public string Subject { get; set; } + + public DateTime? Time { get; set; } + + public T Data { get; set; } + +#pragma warning disable CA1707 // Identifiers should not contain underscores + public T Data_Base64 { get; set; } +#pragma warning restore CA1707 // Identifiers should not contain underscores + + public bool Equals(CloudEvent other) + { + return StringEquals(this.Id, other.Id) && + StringEquals(this.Source, other.Source) && + StringEquals(this.SpecVersion, other.SpecVersion) && + StringEquals(this.Type, other.Type) && + StringEquals(this.DataContentType, other.DataContentType) && + StringEquals(this.DataSchema, other.DataSchema) && + StringEquals(this.Subject, other.Subject) && + this.Time.Equals(other.Time); + } + + public override bool Equals(object obj) + { + if (obj == null) + { + return false; + } + else if (object.ReferenceEquals(this, obj)) + { + return true; + } + else if (obj is CloudEvent ce) + { + return this.Equals(ce); + } + + return false; + } + +#pragma warning disable CA1307 // Specify StringComparison + public override int GetHashCode() => this.Id?.GetHashCode() ?? base.GetHashCode(); +#pragma warning restore CA1307 // Specify StringComparison + + private static bool StringEquals(string str1, string str2) + { + return (str1 is null && str2 is null) || + (!(str1 is null) && !(str2 is null) && str1.Equals(str2, StringComparison.Ordinal)); + } + } +} diff --git a/SDK/Contracts/CustomEventSubscriptionDestination.cs b/SDK/Contracts/CustomEventSubscriptionDestination.cs new file mode 100644 index 0000000..e24e81c --- /dev/null +++ b/SDK/Contracts/CustomEventSubscriptionDestination.cs @@ -0,0 +1,21 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class CustomEventSubscriptionDestination : EventSubscriptionDestination + { + public CustomEventSubscriptionDestination(string endpointType) + { + this.EndpointType = endpointType; + } + + [JsonConverter(typeof(CaseInsensitiveDictionaryConverter))] + public Dictionary Properties { get; set; } + } +} diff --git a/SDK/Contracts/EndpointTypes.cs b/SDK/Contracts/EndpointTypes.cs new file mode 100644 index 0000000..3eee906 --- /dev/null +++ b/SDK/Contracts/EndpointTypes.cs @@ -0,0 +1,13 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public static class EndpointTypes + { + public const string WebHook = nameof(WebHook); + public const string EventGrid = nameof(EventGrid); + } +} diff --git a/SDK/Contracts/EventDeliverySchema.cs b/SDK/Contracts/EventDeliverySchema.cs new file mode 100644 index 0000000..a0073d5 --- /dev/null +++ b/SDK/Contracts/EventDeliverySchema.cs @@ -0,0 +1,28 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public enum EventDeliverySchema + { + /// + /// Events are delivered to the destination using the Event Grid event schema. + /// + EventGridSchema = 0, + + /// + /// Event Payloads are treated as byte arrays without any assumptions about their structure/format, + /// and thus not validated / parsed / checked for errors. + /// + CustomEventSchema, + +#pragma warning disable CA1707 // Identifiers should not contain underscores + /// + /// Events are delivered in the CloudEvent_1_0 schema + /// + CloudEventSchemaV1_0, +#pragma warning restore CA1707 // Identifiers should not contain underscores + } +} diff --git a/SDK/Contracts/EventGridEvent.cs b/SDK/Contracts/EventGridEvent.cs new file mode 100644 index 0000000..43693e0 --- /dev/null +++ b/SDK/Contracts/EventGridEvent.cs @@ -0,0 +1,71 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System; + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class EventGridEvent : EventGridEvent + { + } + + public class EventGridEvent : IEquatable> + { + public string Id { get; set; } + + public string Topic { get; set; } + + public string Subject { get; set; } + + public string EventType { get; set; } + + public string DataVersion { get; set; } + + public string MetadataVersion { get; set; } + + public DateTime EventTime { get; set; } + + public T Data { get; set; } + + public bool Equals(EventGridEvent other) + { + return StringEquals(this.Id, other.Id) && + StringEquals(this.Topic, other.Topic) && + StringEquals(this.Subject, other.Subject) && + StringEquals(this.EventType, other.EventType) && + StringEquals(this.DataVersion, other.DataVersion) && + StringEquals(this.MetadataVersion, other.MetadataVersion) && + this.EventTime.Equals(other.EventTime); + } + + public override bool Equals(object obj) + { + if (obj == null) + { + return false; + } + else if (object.ReferenceEquals(this, obj)) + { + return true; + } + else if (obj is EventGridEvent ege) + { + return this.Equals(ege); + } + + return false; + } + +#pragma warning disable CA1307 // Specify StringComparison + public override int GetHashCode() => this.Id?.GetHashCode() ?? base.GetHashCode(); +#pragma warning restore CA1307 // Specify StringComparison + + private static bool StringEquals(string str1, string str2) + { + return (str1 is null && str2 is null) || + (!(str1 is null) && !(str2 is null) && str1.Equals(str2, StringComparison.Ordinal)); + } + } +} diff --git a/SDK/Contracts/EventGridEventSubscriptionDestination.cs b/SDK/Contracts/EventGridEventSubscriptionDestination.cs new file mode 100644 index 0000000..230e177 --- /dev/null +++ b/SDK/Contracts/EventGridEventSubscriptionDestination.cs @@ -0,0 +1,20 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class EventGridEventSubscriptionDestination : EventSubscriptionDestination + { + public EventGridEventSubscriptionDestination() + { + this.EndpointType = EndpointTypes.EventGrid; + } + + /// + /// WebHook Properties of the event subscription destination. + /// + public EventGridEventSubscriptionDestinationProperties Properties { get; set; } + } +} diff --git a/SDK/Contracts/EventGridEventSubscriptionDestinationProperties.cs b/SDK/Contracts/EventGridEventSubscriptionDestinationProperties.cs new file mode 100644 index 0000000..f528327 --- /dev/null +++ b/SDK/Contracts/EventGridEventSubscriptionDestinationProperties.cs @@ -0,0 +1,35 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class EventGridEventSubscriptionDestinationProperties + { + /// + /// The URL that represents the endpoint of the destination of an event subscription. + /// + public string EndpointUrl { get; set; } + + /// + /// The authentication key to the event grid user topic. + /// + public string SasKey { get; set; } + + /// + /// The name of the Event Grid User Topic / Domain Topic. + /// + public string TopicName { get; set; } + + /// + /// Controls the max events to batch to this subscription. + /// + public int? MaxEventsPerBatch { get; set; } + + /// + /// Controls the preferred batch size in Kilobytes to be used to deliver to this subscription. + /// + public int? PreferredBatchSizeInKilobytes { get; set; } + } +} diff --git a/SDK/Contracts/EventSubscription.cs b/SDK/Contracts/EventSubscription.cs new file mode 100644 index 0000000..05f0d74 --- /dev/null +++ b/SDK/Contracts/EventSubscription.cs @@ -0,0 +1,21 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class EventSubscription + { + public string Id { get; set; } + + public string Type { get; set; } + + /// + /// Name of the resource. + /// + public string Name { get; set; } + + public EventSubscriptionProperties Properties { get; set; } + } +} diff --git a/SDK/Contracts/EventSubscriptionDestination.cs b/SDK/Contracts/EventSubscriptionDestination.cs new file mode 100644 index 0000000..fcd4183 --- /dev/null +++ b/SDK/Contracts/EventSubscriptionDestination.cs @@ -0,0 +1,12 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public abstract class EventSubscriptionDestination + { + public string EndpointType { get; set; } + } +} diff --git a/SDK/Contracts/EventSubscriptionDestinationConverter.cs b/SDK/Contracts/EventSubscriptionDestinationConverter.cs new file mode 100644 index 0000000..d0711eb --- /dev/null +++ b/SDK/Contracts/EventSubscriptionDestinationConverter.cs @@ -0,0 +1,61 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class EventSubscriptionDestinationConverter : JsonConverter + { + public override bool CanWrite => false; + + public override bool CanConvert(Type objectType) => typeof(EventSubscriptionDestination).IsAssignableFrom(objectType); + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.StartObject) + { + var obj = JObject.Load(reader); + if (obj.TryGetValue("endpointType", StringComparison.OrdinalIgnoreCase, out JToken token) && + token != null && + token.Type == JTokenType.String) + { + string endpointType = token.ToString(); + Type deserializeClass = GetEventSubscriptionDestinationType(endpointType); + var destination = (EventSubscriptionDestination)obj.ToObject(deserializeClass, serializer); + return destination; + } + else + { + throw new InvalidOperationException("Couldn't find an endpointType value on the subscription destination."); + } + } + + return null; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) => throw new NotImplementedException(); + + private static Type GetEventSubscriptionDestinationType(string endpointType) + { + if (endpointType.Equals(EndpointTypes.WebHook, StringComparison.OrdinalIgnoreCase)) + { + return typeof(WebHookEventSubscriptionDestination); + } + else if (endpointType.Equals(EndpointTypes.EventGrid, StringComparison.OrdinalIgnoreCase)) + { + return typeof(EventGridEventSubscriptionDestination); + } + else if (!string.IsNullOrWhiteSpace(endpointType)) + { + return typeof(CustomEventSubscriptionDestination); + } + + throw new InvalidOperationException($"Unknown endpoint type: {endpointType}"); + } + } +} diff --git a/SDK/Contracts/EventSubscriptionFilter.cs b/SDK/Contracts/EventSubscriptionFilter.cs new file mode 100644 index 0000000..cb478fe --- /dev/null +++ b/SDK/Contracts/EventSubscriptionFilter.cs @@ -0,0 +1,44 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class EventSubscriptionFilter + { + /// + /// An optional string to filter events for an event subscription based on a resource path prefix. + /// The format of this depends on the publisher of the events. + /// Wildcard characters are not supported in this path. + /// + // e.g. "blobservices/default/containers/blobContainer1/folder1/folder2" + public string SubjectBeginsWith { get; set; } + + /// + /// An optional string to filter events for an event subscription based on a resource path suffix. + /// Wildcard characters are not supported in this path. + /// + // e.g. ".jpg" + public string SubjectEndsWith { get; set; } + + /// + /// A list of applicable event types that need to be part of the event subscription. + /// If it is desired to subscribe to all event types, the string "all" needs to be specified as an element in this list. + /// + // e.g. "*" or "resourceCreated" + public List IncludedEventTypes { get; set; } + + /// + /// Specifies if the SubjectBeginsWith and SubjectEndsWith properties of the filter + /// should be compared in a case sensitive manner. + /// + public bool IsSubjectCaseSensitive { get; set; } + + [JsonConverter(typeof(AdvancedFilterJsonConverter))] + public AdvancedFilter[] AdvancedFilters { get; set; } + } +} diff --git a/SDK/Contracts/EventSubscriptionProperties.cs b/SDK/Contracts/EventSubscriptionProperties.cs new file mode 100644 index 0000000..bd205a1 --- /dev/null +++ b/SDK/Contracts/EventSubscriptionProperties.cs @@ -0,0 +1,48 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class EventSubscriptionProperties + { + /// + /// Name of the topic of the event subscription. + /// + public string Topic { get; set; } + + /// + /// Information about the destination where events have to be delivered for the event subscription. + /// + [JsonConverter(typeof(EventSubscriptionDestinationConverter))] + public EventSubscriptionDestination Destination { get; set; } + + /// + /// Information about the filter for the event subscription. + /// + public EventSubscriptionFilter Filter { get; set; } + + /// + /// The event delivery schema for the event subscription. + /// + [JsonConverter(typeof(StringEnumConverter))] + public EventDeliverySchema? EventDeliverySchema { get; set; } + + // The following two properties aren't wired up yet, so commenting it out. + + ///// + ///// Expiration time of the event subscription. + ///// + // public DateTime? ExpirationTimeUtc { get; set; } + + ///// + ///// The retry policy for events. This can be used to configure maximum number of delivery attempts + ///// and time to live for events. + ///// + public RetryPolicy RetryPolicy { get; set; } + } +} diff --git a/SDK/Contracts/InputSchema.cs b/SDK/Contracts/InputSchema.cs new file mode 100644 index 0000000..7c2b789 --- /dev/null +++ b/SDK/Contracts/InputSchema.cs @@ -0,0 +1,28 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public enum InputSchema + { + /// + /// Events will be published in the Event Grid event schema. + /// + EventGridSchema = 0, + + /// + /// Event Payloads are treated as byte arrays without any assumptions about their structure/format, + /// and thus not validated / parsed / checked for errors. + /// + CustomEventSchema, + +#pragma warning disable CA1707 // Identifiers should not contain underscores + /// + /// Events will be published in the CloudEventSchemaV1_0 + /// + CloudEventSchemaV1_0, +#pragma warning restore CA1707 // Identifiers should not contain underscores + } +} diff --git a/SDK/Contracts/RetryPolicy.cs b/SDK/Contracts/RetryPolicy.cs new file mode 100644 index 0000000..0b84b35 --- /dev/null +++ b/SDK/Contracts/RetryPolicy.cs @@ -0,0 +1,20 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class RetryPolicy + { + /// + /// Maximum number of delivery retry attempts for events. + /// + public int? MaxDeliveryAttempts { get; set; } + + /// + /// Time To Live (in minutes) for events. + /// + public int? EventExpiryInMinutes { get; set; } + } +} diff --git a/SDK/Contracts/Topic.cs b/SDK/Contracts/Topic.cs new file mode 100644 index 0000000..c7224e6 --- /dev/null +++ b/SDK/Contracts/Topic.cs @@ -0,0 +1,17 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class Topic + { + public string Id { get; set; } + + public string Name { get; set; } + + public string Type { get; set; } + + public TopicProperties Properties { get; set; } + } +} diff --git a/SDK/Contracts/TopicProperties.cs b/SDK/Contracts/TopicProperties.cs new file mode 100644 index 0000000..b9b4cb4 --- /dev/null +++ b/SDK/Contracts/TopicProperties.cs @@ -0,0 +1,23 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class TopicProperties + { + /// + /// Endpoint for the topic. + /// + public string Endpoint { get; set; } + + /// + /// This determines the format that Event Grid should expect for incoming events published to the topic. + /// + [JsonConverter(typeof(StringEnumConverter))] + public InputSchema? InputSchema { get; set; } + } +} diff --git a/SDK/Contracts/WebHookEventSubscriptionDestination.cs b/SDK/Contracts/WebHookEventSubscriptionDestination.cs new file mode 100644 index 0000000..a363721 --- /dev/null +++ b/SDK/Contracts/WebHookEventSubscriptionDestination.cs @@ -0,0 +1,20 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class WebHookEventSubscriptionDestination : EventSubscriptionDestination + { + public WebHookEventSubscriptionDestination() + { + this.EndpointType = EndpointTypes.WebHook; + } + + /// + /// WebHook Properties of the event subscription destination. + /// + public WebHookEventSubscriptionDestinationProperties Properties { get; set; } + } +} diff --git a/SDK/Contracts/WebHookEventSubscriptionDestinationProperties.cs b/SDK/Contracts/WebHookEventSubscriptionDestinationProperties.cs new file mode 100644 index 0000000..c3ac6fc --- /dev/null +++ b/SDK/Contracts/WebHookEventSubscriptionDestinationProperties.cs @@ -0,0 +1,30 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class WebHookEventSubscriptionDestinationProperties + { + /// + /// The URL that represents the endpoint of the destination of an event subscription. + /// + public string EndpointUrl { get; set; } + + /// + /// The base URL that represents the endpoint of the destination of an event subscription. + /// + public string EndpointBaseUrl { get; } + + /// + /// Controls the max events to batch to this subscription. + /// + public int? MaxEventsPerBatch { get; set; } + + /// + /// Controls the preferred batch size in Kilobytes to be used to deliver to this subscription. + /// + public int? PreferredBatchSizeInKilobytes { get; set; } + } +} diff --git a/SDK/EventGridApiException.cs b/SDK/EventGridApiException.cs new file mode 100644 index 0000000..e71216a --- /dev/null +++ b/SDK/EventGridApiException.cs @@ -0,0 +1,46 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System; +using System.Net; +using System.Net.Http; + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public sealed class EventGridApiException : Exception + { + internal EventGridApiException(HttpRequestMessage request, HttpResponseMessage response, string responsePayload) + : base(GetMessage(request, response, responsePayload)) + { + this.RequestUri = request.RequestUri; + this.RequestMethod = request.Method.Method; + this.ResponseStatusCode = response?.StatusCode; + this.ResponseReasonPhrase = response?.ReasonPhrase; + this.ResponsePayload = responsePayload; + } + + public Uri RequestUri { get; } + + public string RequestMethod { get; } + + public HttpStatusCode? ResponseStatusCode { get; } + + public string ResponseReasonPhrase { get; } + + public string ResponsePayload { get; } + + private static string GetMessage(HttpRequestMessage request, HttpResponseMessage response, string responsePayload) + { + if (response != null) + { + return $"REQUEST: Method={request.Method.Method} Url={request.RequestUri} \nRESPONSE: statusCode={response.StatusCode} reasonPhrase=<{response.ReasonPhrase}> payload={responsePayload}"; + } + else + { + return $"REQUEST: Method={request.Method.Method} Url={request.RequestUri} \nRESPONSE: "; + } + } + } +} diff --git a/SDK/EventGridEdgeClient.cs b/SDK/EventGridEdgeClient.cs new file mode 100644 index 0000000..ee77fb9 --- /dev/null +++ b/SDK/EventGridEdgeClient.cs @@ -0,0 +1,105 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System; +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using Microsoft.IO; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public sealed class EventGridEdgeClient + { + private static readonly MediaTypeHeaderValue JsonContentType = MediaTypeHeaderValue.Parse("application/json; charset=utf-8"); + private readonly JsonSerializer jsonSerializer; + private readonly RecyclableMemoryStreamManager recyclableMemoryStreamManager; + private readonly UTF8Encoding encoding; + + public EventGridEdgeClient(string baseAddress, int port) + : this(baseAddress, port, null) + { + } + + public EventGridEdgeClient(string baseAddress, int port, IHttpClientFactory httpClientFactory) + { + this.BaseUri = new Uri($"{baseAddress}:{port}"); + + this.encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + this.HttpClient = httpClientFactory?.CreateClient() ?? new HttpClient(); + this.HttpClient.BaseAddress = this.BaseUri; + + this.jsonSerializer = JsonSerializer.Create(new JsonSerializerSettings + { + Formatting = Formatting.None, + NullValueHandling = NullValueHandling.Ignore, + FloatParseHandling = FloatParseHandling.Decimal, + Converters = new JsonConverter[] + { + new StringEnumConverter(), + }, + }); + + this.recyclableMemoryStreamManager = new RecyclableMemoryStreamManager(); + + this.Topics = new TopicsAPI(this); + this.Subscriptions = new SubscriptionsAPI(this); + this.Events = new EventsAPI(this); + } + + public Uri BaseUri { get; } + + public TopicsAPI Topics { get; } + + public SubscriptionsAPI Subscriptions { get; } + + public EventsAPI Events { get; } + + public HttpClient HttpClient { get; } + + public StreamContent CreateJsonContent(T item, [CallerMemberName] string callerMemberName = "", MediaTypeHeaderValue contentType = null) + { + var stream = new RecyclableMemoryStream(this.recyclableMemoryStreamManager, callerMemberName); + using (var sw = new StreamWriter(stream, this.encoding, 1024, leaveOpen: true)) + using (var jw = new JsonTextWriter(sw)) + { + this.jsonSerializer.Serialize(jw, item); + sw.Flush(); + } + + long finalPosition = stream.Position; + stream.Position = 0; + + // the stream will get disposed when streamContent is disposed off. + var streamContent = new StreamContent(stream); + streamContent.Headers.ContentType = contentType == null ? JsonContentType : contentType; + streamContent.Headers.ContentLength = finalPosition; + return streamContent; + } + + public async Task DeserializeAsync(HttpResponseMessage response) + { + using (Stream stream = await response.Content.ReadAsStreamAsync()) + { + return this.Deserialize(stream); + } + } + + internal T Deserialize(Stream stream) + { + using (var sr = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) + using (var jr = new JsonTextReader(sr)) + { + return this.jsonSerializer.Deserialize(jr); + } + } + } +} diff --git a/SDK/EventsAPI.cs b/SDK/EventsAPI.cs new file mode 100644 index 0000000..40adc7b --- /dev/null +++ b/SDK/EventsAPI.cs @@ -0,0 +1,97 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Encodings.Web; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class EventsAPI + { + private const string ApiVersionSuffix = "?api-version=2019-01-01-preview"; + private readonly EventGridEdgeClient client; + + internal EventsAPI(EventGridEdgeClient client) + { + this.client = client; + } + + public async Task PublishJsonAsync(string topicName, string eventId, T payload, MediaTypeHeaderValue contentType, CancellationToken token) + { + using (StreamContent streamContent = this.client.CreateJsonContent(payload, nameof(this.PublishJsonAsync), contentType)) + using (var request = new HttpRequestMessage(HttpMethod.Post, $"topics/{UrlEncoder.Default.Encode(topicName)}/events/{eventId}{ApiVersionSuffix}") { Content = streamContent }) + { + using (HttpResponseMessage response = await this.client.HttpClient.SendAsync(request, token)) + { + await response.ThrowIfFailedAsync(request); + } + } + } + + public async Task PublishJsonAsync(string topicName, IEnumerable payload, MediaTypeHeaderValue contentType, CancellationToken token) + { + using (StreamContent streamContent = this.client.CreateJsonContent(payload, nameof(this.PublishJsonAsync), contentType)) + using (var request = new HttpRequestMessage(HttpMethod.Post, $"topics/{UrlEncoder.Default.Encode(topicName)}/events{ApiVersionSuffix}") { Content = streamContent }) + { + using (HttpResponseMessage response = await this.client.HttpClient.SendAsync(request, token)) + { + await response.ThrowIfFailedAsync(request); + } + } + } + + public async Task PublishRawAsync(string topicName, string eventId, byte[] payload, MediaTypeHeaderValue contentType, Dictionary httpHeaders, CancellationToken token) + { + using (var byteArrayContent = new ByteArrayContent(payload)) + { + byteArrayContent.Headers.ContentType = contentType; + byteArrayContent.Headers.ContentLength = payload.Length; + using (var request = new HttpRequestMessage(HttpMethod.Post, $"topics/{UrlEncoder.Default.Encode(topicName)}/events/{eventId}{ApiVersionSuffix}") { Content = byteArrayContent }) + { + if (httpHeaders != null) + { + foreach (KeyValuePair httpHeader in httpHeaders) + { + request.Headers.Add(httpHeader.Key, httpHeader.Value); + } + } + + using (HttpResponseMessage response = await this.client.HttpClient.SendAsync(request, token)) + { + await response.ThrowIfFailedAsync(request); + } + } + } + } + + public async Task PublishRawAsync(string topicName, byte[] payload, MediaTypeHeaderValue contentType, Dictionary httpHeaders, CancellationToken token) + { + using (var byteArrayContent = new ByteArrayContent(payload)) + { + byteArrayContent.Headers.ContentType = contentType; + byteArrayContent.Headers.ContentLength = payload.Length; + using (var request = new HttpRequestMessage(HttpMethod.Post, $"topics/{UrlEncoder.Default.Encode(topicName)}/events{ApiVersionSuffix}") { Content = byteArrayContent }) + { + if (httpHeaders != null) + { + foreach (KeyValuePair httpHeader in httpHeaders) + { + request.Headers.Add(httpHeader.Key, httpHeader.Value); + } + } + + using (HttpResponseMessage response = await this.client.HttpClient.SendAsync(request, token)) + { + await response.ThrowIfFailedAsync(request); + } + } + } + } + } +} diff --git a/SDK/Extensions.cs b/SDK/Extensions.cs new file mode 100644 index 0000000..8047c6f --- /dev/null +++ b/SDK/Extensions.cs @@ -0,0 +1,32 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public static class SdkExtensions + { + public static async Task ThrowIfFailedAsync(this HttpResponseMessage response, HttpRequestMessage request) + { + if (!response.IsSuccessStatusCode) + { + string responsePayload = "!!!EMPTY PAYLOAD"; + try + { + responsePayload = await response.Content.ReadAsStringAsync(); + } + catch (Exception ex) + { + responsePayload = $"!!!payload-read-failed with inner exception:{ex}"; + } + + throw new EventGridApiException(request, response, responsePayload); + } + } + } +} diff --git a/SDK/SDK.csproj b/SDK/SDK.csproj new file mode 100644 index 0000000..6955296 --- /dev/null +++ b/SDK/SDK.csproj @@ -0,0 +1,44 @@ + + + + Library + $(RootNamespacePrefix)SDK + $(RootNamespace) + netcoreapp2.1;netstandard2.0 + $(TargetsForTfmSpecificBuildOutput);IncludeP2PAssets + + + Microsoft + © Microsoft Corporation. All rights reserved. + MIT + true + https://azure.microsoft.com/en-us/services/event-grid/ + + http://go.microsoft.com/fwlink/?LinkID=288890 + Azure;EventGrid;IoT;Edge + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SDK/SubscriptionsAPI.cs b/SDK/SubscriptionsAPI.cs new file mode 100644 index 0000000..abf14bf --- /dev/null +++ b/SDK/SubscriptionsAPI.cs @@ -0,0 +1,66 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Encodings.Web; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class SubscriptionsAPI + { + private const string ApiVersionSuffix = "?api-version=2019-01-01-preview"; + private readonly EventGridEdgeClient client; + + internal SubscriptionsAPI(EventGridEdgeClient client) + { + this.client = client; + } + + public async Task PutSubscriptionAsync(string topicName, string subscriptionName, EventSubscription eventSubscription, CancellationToken token) + { + using (StreamContent streamContent = this.client.CreateJsonContent(eventSubscription)) + using (var request = new HttpRequestMessage(HttpMethod.Put, $"topics/{UrlEncoder.Default.Encode(topicName)}/eventSubscriptions/{UrlEncoder.Default.Encode(subscriptionName)}{ApiVersionSuffix}") { Content = streamContent }) + { + using (HttpResponseMessage response = await this.client.HttpClient.SendAsync(request, token)) + { + await response.ThrowIfFailedAsync(request); + return await this.client.DeserializeAsync(response); + } + } + } + + public async Task GetEventSubscriptionAsync(string topicName, string subscriptionName, CancellationToken token) + { + using (var request = new HttpRequestMessage(HttpMethod.Get, $"topics/{UrlEncoder.Default.Encode(topicName)}/eventSubscriptions/{UrlEncoder.Default.Encode(subscriptionName)}{ApiVersionSuffix}")) + using (HttpResponseMessage response = await this.client.HttpClient.SendAsync(request, token)) + { + await response.ThrowIfFailedAsync(request); + return await this.client.DeserializeAsync(response); + } + } + + public async Task> GetEventSubscriptionsAsync(string topicName, CancellationToken token) + { + using (var request = new HttpRequestMessage(HttpMethod.Get, $"topics/{UrlEncoder.Default.Encode(topicName)}/eventSubscriptions/{ApiVersionSuffix}")) + using (HttpResponseMessage response = await this.client.HttpClient.SendAsync(request, token)) + { + await response.ThrowIfFailedAsync(request); + return await this.client.DeserializeAsync>(response); + } + } + + public async Task DeleteEventSubscriptionAsync(string topicName, string subscriptionName, CancellationToken token) + { + using (var request = new HttpRequestMessage(HttpMethod.Delete, $"topics/{UrlEncoder.Default.Encode(topicName)}/eventSubscriptions/{UrlEncoder.Default.Encode(subscriptionName)}{ApiVersionSuffix}")) + using (HttpResponseMessage response = await this.client.HttpClient.SendAsync(request, token)) + { + await response.ThrowIfFailedAsync(request); + } + } + } +} diff --git a/SDK/TopicsAPI.cs b/SDK/TopicsAPI.cs new file mode 100644 index 0000000..e69d54a --- /dev/null +++ b/SDK/TopicsAPI.cs @@ -0,0 +1,66 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Encodings.Web; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Azure.EventGridEdge.SDK +{ + public class TopicsAPI + { + public const string ApiVersionSuffix = "?api-version=2019-01-01-preview"; + private readonly EventGridEdgeClient client; + + internal TopicsAPI(EventGridEdgeClient client) + { + this.client = client; + } + + public async Task PutTopicAsync(string topicName, Topic topic, CancellationToken token) + { + using (StreamContent streamContent = this.client.CreateJsonContent(topic)) + using (var request = new HttpRequestMessage(HttpMethod.Put, $"topics/{UrlEncoder.Default.Encode(topicName)}{ApiVersionSuffix}") { Content = streamContent }) + { + using (HttpResponseMessage response = await this.client.HttpClient.SendAsync(request, token)) + { + await response.ThrowIfFailedAsync(request); + return await this.client.DeserializeAsync(response); + } + } + } + + public async Task GetTopicAsync(string topicName, CancellationToken token) + { + using (var request = new HttpRequestMessage(HttpMethod.Get, $"topics/{UrlEncoder.Default.Encode(topicName)}{ApiVersionSuffix}")) + using (HttpResponseMessage response = await this.client.HttpClient.SendAsync(request, token)) + { + await response.ThrowIfFailedAsync(request); + return await this.client.DeserializeAsync(response); + } + } + + public async Task> GetTopicsAsync(CancellationToken token) + { + using (var request = new HttpRequestMessage(HttpMethod.Get, $"topics{ApiVersionSuffix}")) + using (HttpResponseMessage response = await this.client.HttpClient.SendAsync(request, token)) + { + await response.ThrowIfFailedAsync(request); + return await this.client.DeserializeAsync>(response); + } + } + + public async Task DeleteTopicAsync(string topicName, CancellationToken token) + { + using (var request = new HttpRequestMessage(HttpMethod.Delete, $"topics/{UrlEncoder.Default.Encode(topicName)}{ApiVersionSuffix}")) + using (HttpResponseMessage response = await this.client.HttpClient.SendAsync(request, token)) + { + await response.ThrowIfFailedAsync(request); + } + } + } +} diff --git a/SecurityDaemonClient/Contracts/CertificateResponse.cs b/SecurityDaemonClient/Contracts/CertificateResponse.cs new file mode 100644 index 0000000..2b97096 --- /dev/null +++ b/SecurityDaemonClient/Contracts/CertificateResponse.cs @@ -0,0 +1,18 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System; + +namespace Microsoft.Azure.EventGridEdge.IotEdge +{ + public class CertificateResponse + { + public PrivateKey PrivateKey { get; set; } + + public string Certificate { get; set; } + + public DateTime? Expiration { get; set; } + } +} diff --git a/SecurityDaemonClient/Contracts/IdentityCertificateRequest.cs b/SecurityDaemonClient/Contracts/IdentityCertificateRequest.cs new file mode 100644 index 0000000..08c8583 --- /dev/null +++ b/SecurityDaemonClient/Contracts/IdentityCertificateRequest.cs @@ -0,0 +1,14 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System; + +namespace Microsoft.Azure.EventGridEdge.IotEdge +{ + public class IdentityCertificateRequest + { + public DateTime Expiration { get; set; } + } +} diff --git a/SecurityDaemonClient/Contracts/PrivateKey.cs b/SecurityDaemonClient/Contracts/PrivateKey.cs new file mode 100644 index 0000000..6df6318 --- /dev/null +++ b/SecurityDaemonClient/Contracts/PrivateKey.cs @@ -0,0 +1,20 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Microsoft.Azure.EventGridEdge.IotEdge +{ + public class PrivateKey + { + [JsonConverter(typeof(StringEnumConverter))] + public PrivateKeyType? Type { get; set; } + + public string Ref { get; set; } + + public string Bytes { get; set; } + } +} diff --git a/SecurityDaemonClient/Contracts/PrivateKeyType.cs b/SecurityDaemonClient/Contracts/PrivateKeyType.cs new file mode 100644 index 0000000..ceb8fbd --- /dev/null +++ b/SecurityDaemonClient/Contracts/PrivateKeyType.cs @@ -0,0 +1,13 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +namespace Microsoft.Azure.EventGridEdge.IotEdge +{ + public enum PrivateKeyType + { + Ref = 0, + Key = 1, + } +} diff --git a/SecurityDaemonClient/Contracts/ServerCertificateRequest.cs b/SecurityDaemonClient/Contracts/ServerCertificateRequest.cs new file mode 100644 index 0000000..420c3cc --- /dev/null +++ b/SecurityDaemonClient/Contracts/ServerCertificateRequest.cs @@ -0,0 +1,16 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System; + +namespace Microsoft.Azure.EventGridEdge.IotEdge +{ + public class ServerCertificateRequest + { + public string CommonName { get; set; } + + public DateTime Expiration { get; set; } + } +} diff --git a/SecurityDaemonClient/Contracts/TrustBundleResponse.cs b/SecurityDaemonClient/Contracts/TrustBundleResponse.cs new file mode 100644 index 0000000..fc2c5b8 --- /dev/null +++ b/SecurityDaemonClient/Contracts/TrustBundleResponse.cs @@ -0,0 +1,12 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +namespace Microsoft.Azure.EventGridEdge.IotEdge +{ + public class TrustBundleResponse + { + public string Certificate { get; set; } + } +} diff --git a/SecurityDaemonClient/SecurityDaemonClient.cs b/SecurityDaemonClient/SecurityDaemonClient.cs new file mode 100644 index 0000000..d5b80e2 --- /dev/null +++ b/SecurityDaemonClient/SecurityDaemonClient.cs @@ -0,0 +1,287 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.OpenSsl; +using Org.BouncyCastle.Pkcs; +using Org.BouncyCastle.Security; + +namespace Microsoft.Azure.EventGridEdge.IotEdge +{ + [Obsolete("This is a pubternal API that's being made public as a stop-gap measure. It will be removed from the Event Grid SDK nuget package as soon IoT Edge SDK ships with a built-in a security daemon client.")] + public sealed class SecurityDaemonClient : IDisposable + { + private const string UnixScheme = "unix"; + private const int DefaultServerCertificateValidityInDays = 90; + private const int DefaultIdentityCertificateValidityInDays = 7; + private readonly JsonSerializerSettings jsonSettings = new JsonSerializerSettings + { + Formatting = Formatting.None, + NullValueHandling = NullValueHandling.Ignore, + FloatParseHandling = FloatParseHandling.Decimal, + ContractResolver = new CamelCasePropertyNamesContractResolver(), + Converters = new JsonConverter[] { new StringEnumConverter() }, + }; + + private readonly string moduleGenerationId; + private readonly string edgeGatewayHostName; + private readonly string workloadApiVersion; + + private readonly HttpClient httpClient; + private readonly Uri getTrustBundleUri; + private readonly Uri postIdentityCertificateRequestUri; + private readonly Uri postServerCertificateRequestUri; + private readonly string asString; + + public SecurityDaemonClient() + { + this.ModuleId = Environment.GetEnvironmentVariable("IOTEDGE_MODULEID"); + this.DeviceId = Environment.GetEnvironmentVariable("IOTEDGE_DEVICEID"); + string iotHubHostName = Environment.GetEnvironmentVariable("IOTEDGE_IOTHUBHOSTNAME"); + this.IotHubName = iotHubHostName.Split('.').FirstOrDefault(); + + this.moduleGenerationId = Environment.GetEnvironmentVariable("IOTEDGE_MODULEGENERATIONID"); + this.edgeGatewayHostName = Environment.GetEnvironmentVariable("IOTEDGE_GATEWAYHOSTNAME"); + this.workloadApiVersion = Environment.GetEnvironmentVariable("IOTEDGE_APIVERSION"); + string workloadUriString = Environment.GetEnvironmentVariable("IOTEDGE_WORKLOADURI"); + + Validate.ArgumentNotNullOrEmpty(this.ModuleId, nameof(this.ModuleId)); + Validate.ArgumentNotNullOrEmpty(this.DeviceId, nameof(this.DeviceId)); + Validate.ArgumentNotNullOrEmpty(this.IotHubName, nameof(this.IotHubName)); + Validate.ArgumentNotNullOrEmpty(this.moduleGenerationId, nameof(this.moduleGenerationId)); + Validate.ArgumentNotNullOrEmpty(this.edgeGatewayHostName, nameof(this.edgeGatewayHostName)); + Validate.ArgumentNotNullOrEmpty(this.workloadApiVersion, nameof(this.workloadApiVersion)); + Validate.ArgumentNotNullOrEmpty(workloadUriString, nameof(workloadUriString)); + + var workloadUri = new Uri(workloadUriString); + + string baseUrlForRequests; + if (workloadUri.Scheme.Equals(SecurityDaemonClient.UnixScheme, StringComparison.OrdinalIgnoreCase)) + { + baseUrlForRequests = $"http://{workloadUri.Segments.Last()}"; + this.httpClient = new HttpClient(new HttpUdsMessageHandler(workloadUri)); + } + else if (workloadUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || + workloadUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + baseUrlForRequests = workloadUriString; + this.httpClient = new HttpClient(); + } + else + { + throw new InvalidOperationException($"Unknown workloadUri scheme specified. {workloadUri}"); + } + + baseUrlForRequests = baseUrlForRequests.TrimEnd(); + this.httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + string encodedApiVersion = UrlEncoder.Default.Encode(this.workloadApiVersion); + string encodedModuleId = UrlEncoder.Default.Encode(this.ModuleId); + string encodedModuleGenerationId = UrlEncoder.Default.Encode(this.moduleGenerationId); + + this.getTrustBundleUri = new Uri($"{baseUrlForRequests}/trust-bundle?api-version={encodedApiVersion}"); + this.postIdentityCertificateRequestUri = new Uri($"{baseUrlForRequests}/modules/{encodedModuleId}/certificate/identity?api-version={encodedApiVersion}"); + this.postServerCertificateRequestUri = new Uri($"{baseUrlForRequests}/modules/{encodedModuleId}/genid/{encodedModuleGenerationId}/certificate/server?api-version={encodedApiVersion}"); + + var settings = new + { + this.ModuleId, + this.DeviceId, + IotHubHostName = iotHubHostName, + ModuleGenerationId = this.moduleGenerationId, + GatewayHostName = this.edgeGatewayHostName, + WorkloadUri = workloadUriString, + WorkloadApiVersion = this.workloadApiVersion, + }; + this.asString = $"{nameof(SecurityDaemonClient)}{JsonConvert.SerializeObject(settings, Formatting.None, this.jsonSettings)}"; + } + + public string IotHubName { get; } + + public string DeviceId { get; } + + public string ModuleId { get; } + + public void Dispose() => this.httpClient.Dispose(); + + public override string ToString() => this.asString; + + public Task<(X509Certificate2 serverCert, X509Certificate2[] certChain)> GetServerCertificateAsync(CancellationToken token = default) + { + return this.GetServerCertificateAsync(TimeSpan.FromDays(SecurityDaemonClient.DefaultServerCertificateValidityInDays), token); + } + + public async Task<(X509Certificate2 serverCert, X509Certificate2[] certChain)> GetServerCertificateAsync(TimeSpan validity, CancellationToken token = default) + { + var request = new ServerCertificateRequest + { + CommonName = this.edgeGatewayHostName, + Expiration = DateTime.UtcNow.Add(validity), + }; + + string requestString = JsonConvert.SerializeObject(request, Formatting.None, this.jsonSettings); + using (var content = new StringContent(requestString, Encoding.UTF8, "application/json")) + using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, this.postServerCertificateRequestUri) { Content = content }) + using (HttpResponseMessage httpResponse = await this.httpClient.SendAsync(httpRequest, token)) + { + string responsePayload = await httpResponse.Content.ReadAsStringAsync(); + if (httpResponse.StatusCode == HttpStatusCode.Created) + { + CertificateResponse cr = JsonConvert.DeserializeObject(responsePayload, this.jsonSettings); + return this.CreateX509Certificates(cr); + } + + throw new InvalidOperationException($"Failed to retrieve server certificate from IoTEdge security daemon. StatusCode={httpResponse.StatusCode} ReasonPhrase='{httpResponse.ReasonPhrase}' ResponsePayload='{responsePayload}' Request={requestString} This={this}"); + } + } + + public Task<(X509Certificate2 identityCert, X509Certificate2[] certChain)> GetIdentityCertificateAsync(CancellationToken token = default) + { + return this.GetIdentityCertificateAsync(TimeSpan.FromDays(SecurityDaemonClient.DefaultIdentityCertificateValidityInDays), token); + } + + public async Task<(X509Certificate2 identityCert, X509Certificate2[] certChain)> GetIdentityCertificateAsync(TimeSpan validity, CancellationToken token = default) + { + var request = new IdentityCertificateRequest + { + Expiration = DateTime.UtcNow.Add(validity), + }; + + string requestString = JsonConvert.SerializeObject(request, Formatting.None, this.jsonSettings); + using (var content = new StringContent(requestString, Encoding.UTF8, "application/json")) + using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, this.postIdentityCertificateRequestUri) { Content = content }) + using (HttpResponseMessage httpResponse = await this.httpClient.SendAsync(httpRequest, token)) + { + string responsePayload = await httpResponse.Content.ReadAsStringAsync(); + if (httpResponse.StatusCode == HttpStatusCode.Created) + { + CertificateResponse cr = JsonConvert.DeserializeObject(responsePayload, this.jsonSettings); + return this.CreateX509Certificates(cr); + } + + throw new InvalidOperationException($"Failed to retrieve identity certificate from IoTEdge security daemon. StatusCode={httpResponse.StatusCode} ReasonPhrase='{httpResponse.ReasonPhrase}' ResponsePayload='{responsePayload}' Request={requestString} This={this}"); + } + } + + public async Task GetTrustBundleAsync(CancellationToken token = default) + { + using (var httpRequest = new HttpRequestMessage(HttpMethod.Get, this.getTrustBundleUri)) + using (HttpResponseMessage httpResponse = await this.httpClient.SendAsync(httpRequest, token)) + { + string responsePayload = await httpResponse.Content.ReadAsStringAsync(); + if (httpResponse.StatusCode == HttpStatusCode.OK) + { + TrustBundleResponse trustBundleResponse = JsonConvert.DeserializeObject(responsePayload, this.jsonSettings); + Validate.ArgumentNotNullOrEmpty(trustBundleResponse.Certificate, nameof(trustBundleResponse.Certificate)); + + string[] rawCerts = ParseCertificateResponse(trustBundleResponse.Certificate); + if (rawCerts.FirstOrDefault() == null) + { + throw new InvalidOperationException($"Failed to retrieve the certificate trust bundle from IoTEdge security daemon. StatusCode={httpResponse.StatusCode} ReasonPhrase='{httpResponse.ReasonPhrase}' Reason='Security daemon returned an empty response' This={this}"); + } + + return ConvertToX509(rawCerts); + } + + throw new InvalidOperationException($"Failed to retrieve the certificate trust bundle from IoTEdge security daemon. StatusCode={httpResponse.StatusCode} ReasonPhrase='{httpResponse.ReasonPhrase}' ResponsePayload='{responsePayload}' This={this}"); + } + } + + private static X509Certificate2[] ConvertToX509(IEnumerable rawCerts) => rawCerts.Select(c => new X509Certificate2(Encoding.UTF8.GetBytes(c))).ToArray(); + + private static string[] ParseCertificateResponse(string certificateChain, [CallerMemberName] string callerMemberName = default) + { + if (string.IsNullOrEmpty(certificateChain)) + { + throw new InvalidOperationException($"Trusted certificates can not be null or empty for {callerMemberName}."); + } + + // Extract each certificate's string. The final string from the split will either be empty + // or a non-certificate entry, so it is dropped. + string delimiter = "-----END CERTIFICATE-----"; + string[] rawCerts = certificateChain.Split(new[] { delimiter }, StringSplitOptions.None); + return rawCerts.Take(count: rawCerts.Length - 1).Select(c => $"{c}{delimiter}").ToArray(); + } + + private (X509Certificate2 primaryCert, X509Certificate2[] certChain) CreateX509Certificates(CertificateResponse cr, [CallerMemberName] string callerMemberName = default) + { + Validate.ArgumentNotNullOrEmpty(cr.Certificate, nameof(cr.Certificate)); + Validate.ArgumentNotNull(cr.Expiration, nameof(cr.Expiration)); + Validate.ArgumentNotNull(cr.PrivateKey, nameof(cr.PrivateKey)); + Validate.ArgumentNotNull(cr.PrivateKey.Type, nameof(cr.PrivateKey.Type)); + Validate.ArgumentNotNull(cr.PrivateKey.Bytes, nameof(cr.PrivateKey.Bytes)); + + string[] rawCerts = ParseCertificateResponse(cr.Certificate); + if (rawCerts.Length == 0 || + string.IsNullOrWhiteSpace(rawCerts[0])) + { + throw new InvalidOperationException($"Failed to retrieve certificate from IoTEdge Security daemon for {callerMemberName}. Reason: Security daemon returned an empty response."); + } + + string primaryCert = rawCerts[0]; + X509Certificate2[] certChain = ConvertToX509(rawCerts.Skip(1)); + + RsaPrivateCrtKeyParameters keyParams = null; + + var chainCertEntries = new List(); + Pkcs12Store store = new Pkcs12StoreBuilder().Build(); + + // note: the seperator between the certificate and private key is added for safety to delineate the cert and key boundary + using (var sr = new StringReader(primaryCert + "\r\n" + cr.PrivateKey.Bytes)) + { + var pemReader = new PemReader(sr); + object certObject; + while ((certObject = pemReader.ReadObject()) != null) + { + if (certObject is Org.BouncyCastle.X509.X509Certificate x509Cert) + { + chainCertEntries.Add(new X509CertificateEntry(x509Cert)); + } + + // when processing certificates generated via openssl certObject type is of AsymmetricCipherKeyPair + if (certObject is AsymmetricCipherKeyPair ackp) + { + certObject = ackp.Private; + } + + if (certObject is RsaPrivateCrtKeyParameters rpckp) + { + keyParams = rpckp; + } + } + } + + if (keyParams == null) + { + throw new InvalidOperationException($"Private key was not found for {callerMemberName}"); + } + + store.SetKeyEntry(this.ModuleId, new AsymmetricKeyEntry(keyParams), chainCertEntries.ToArray()); + using (var ms = new MemoryStream()) + { + store.Save(ms, Array.Empty(), new SecureRandom()); + var x509PrimaryCert = new X509Certificate2(ms.ToArray()); + return (x509PrimaryCert, certChain); + } + } + } +} diff --git a/SecurityDaemonClient/SecurityDaemonClient.csproj b/SecurityDaemonClient/SecurityDaemonClient.csproj new file mode 100644 index 0000000..a625be1 --- /dev/null +++ b/SecurityDaemonClient/SecurityDaemonClient.csproj @@ -0,0 +1,19 @@ + + + + Library + $(RootNamespacePrefix)SecurityDaemonClient + $(RootNamespace) + netcoreapp2.1 + 7.3 + + + + + + + + + + + diff --git a/SecurityDaemonClient/SecurityDaemonHttpClientFactory.cs b/SecurityDaemonClient/SecurityDaemonHttpClientFactory.cs new file mode 100644 index 0000000..e0d6a4f --- /dev/null +++ b/SecurityDaemonClient/SecurityDaemonHttpClientFactory.cs @@ -0,0 +1,57 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Azure.EventGridEdge.IotEdge +{ + [Obsolete("This is a pubternal API that's being made public as a stop-gap measure. It will be removed from the Event Grid SDK nuget package as soon IoT Edge SDK ships with a built-in a security daemon client.")] + public class SecurityDaemonHttpClientFactory : IHttpClientFactory + { + private readonly Func callback; + + public SecurityDaemonHttpClientFactory(X509Certificate2 identityCertificate) + : this(identityCertificate, ServiceCertificateValidationCallback) + { + } + + public SecurityDaemonHttpClientFactory(X509Certificate2 identityCertificate, Func serverCertificateCallback) + { + this.IdentityCertificate = identityCertificate; + this.callback = serverCertificateCallback; + } + + public X509Certificate2 IdentityCertificate { get; } + + public static async Task CreateAsync(CancellationToken token = default) + { + using (var iotEdgeClient = new SecurityDaemonClient()) + { + (X509Certificate2 identityCertificate, _) = await iotEdgeClient.GetIdentityCertificateAsync(token); + return new SecurityDaemonHttpClientFactory(identityCertificate); + } + } + + [SuppressMessage("Microsoft.Reliability", "CA2000: DisposeObjectsBeforeLosingScope", Justification = "The HttpClient owns the lifetime of the handler")] + public HttpClient CreateClient(string name) + { + var httpClientHandler = new HttpClientHandler { ServerCertificateCustomValidationCallback = this.callback }; + httpClientHandler.ClientCertificateOptions = ClientCertificateOption.Manual; + httpClientHandler.ClientCertificates.Add(this.IdentityCertificate); + return new HttpClient(httpClientHandler, disposeHandler: true); + } + + private static bool ServiceCertificateValidationCallback(HttpRequestMessage request, X509Certificate2 certificate, X509Chain chain, SslPolicyErrors errors) + { + return true; + } + } +} diff --git a/SecurityDaemonClient/Uds/HttpBufferedStream.cs b/SecurityDaemonClient/Uds/HttpBufferedStream.cs new file mode 100644 index 0000000..0b04ed6 --- /dev/null +++ b/SecurityDaemonClient/Uds/HttpBufferedStream.cs @@ -0,0 +1,82 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Azure.EventGridEdge.IotEdge +{ + internal class HttpBufferedStream : Stream + { + private const char CR = '\r'; + private const char LF = '\n'; + private readonly BufferedStream innerStream; + + public HttpBufferedStream(Stream stream) + { + this.innerStream = new BufferedStream(stream); + } + + public override bool CanRead => this.innerStream.CanRead; + + public override bool CanSeek => this.innerStream.CanSeek; + + public override bool CanWrite => this.innerStream.CanWrite; + + public override long Length => this.innerStream.Length; + + public override long Position + { + get => this.innerStream.Position; + set => this.innerStream.Position = value; + } + + public override void Flush() => this.innerStream.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) => this.innerStream.FlushAsync(cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) => this.innerStream.Read(buffer, offset, count); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => this.innerStream.ReadAsync(buffer, offset, count, cancellationToken); + + public async Task ReadLineAsync(CancellationToken cancellationToken) + { + int position = 0; + byte[] buffer = new byte[1]; + bool crFound = false; + var builder = new StringBuilder(); + while (true) + { + int length = await this.innerStream.ReadAsync(buffer, 0, buffer.Length, cancellationToken); + + if (length == 0) + { + throw new IOException("Unexpected end of stream."); + } + + if (crFound && (char)buffer[position] == LF) + { + builder.Remove(builder.Length - 1, 1); + return builder.ToString(); + } + + builder.Append((char)buffer[position]); + crFound = (char)buffer[position] == CR; + } + } + + public override long Seek(long offset, SeekOrigin origin) => this.innerStream.Seek(offset, origin); + + public override void SetLength(long value) => this.innerStream.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => this.innerStream.Write(buffer, offset, count); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => this.innerStream.WriteAsync(buffer, offset, count, cancellationToken); + + protected override void Dispose(bool disposing) => this.innerStream.Dispose(); + } +} diff --git a/SecurityDaemonClient/Uds/HttpRequestResponseSerializer.cs b/SecurityDaemonClient/Uds/HttpRequestResponseSerializer.cs new file mode 100644 index 0000000..38fe7e6 --- /dev/null +++ b/SecurityDaemonClient/Uds/HttpRequestResponseSerializer.cs @@ -0,0 +1,160 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Azure.EventGridEdge.IotEdge +{ + internal class HttpRequestResponseSerializer + { + private const char SP = ' '; + private const char CR = '\r'; + private const char LF = '\n'; + private const char ProtocolVersionSeparator = '/'; + private const string Protocol = "HTTP"; + private const string HeaderSeparator = ":"; + private const string ContentLengthHeaderName = "content-length"; + + public byte[] SerializeRequest(HttpRequestMessage request) + { + Validate.ArgumentNotNull(request, nameof(request)); + Validate.ArgumentNotNull(request.RequestUri, nameof(request.RequestUri)); + + PreProcessRequest(request); + + var builder = new StringBuilder(); + // request-line = method SP request-target SP HTTP-version CRLF + builder.Append(request.Method); + builder.Append(SP); + builder.Append(request.RequestUri.IsAbsoluteUri ? request.RequestUri.PathAndQuery : Uri.EscapeUriString(request.RequestUri.ToString())); + builder.Append(SP); + builder.Append($"{Protocol}{ProtocolVersionSeparator}"); + builder.Append(new Version(1, 1).ToString(2)); + builder.Append(CR); + builder.Append(LF); + + // Headers + builder.Append(request.Headers); + + if (request.Content != null) + { + long? contentLength = request.Content.Headers.ContentLength; + if (contentLength.HasValue) + { + request.Content.Headers.ContentLength = contentLength.Value; + } + + builder.Append(request.Content.Headers); + } + + // Headers end + builder.Append(CR); + builder.Append(LF); + + return Encoding.ASCII.GetBytes(builder.ToString()); + } + + public async Task DeserializeResponseAsync(HttpBufferedStream bufferedStream, CancellationToken cancellationToken) + { + var httpResponse = new HttpResponseMessage(); + await SetResponseStatusLineAsync(httpResponse, bufferedStream, cancellationToken); + await SetHeadersAndContentAsync(httpResponse, bufferedStream, cancellationToken); + return httpResponse; + } + + private static async Task SetHeadersAndContentAsync(HttpResponseMessage httpResponse, HttpBufferedStream bufferedStream, CancellationToken cancellationToken) + { + IList headers = new List(); + string line = await bufferedStream.ReadLineAsync(cancellationToken); + while (!string.IsNullOrWhiteSpace(line)) + { + headers.Add(line); + line = await bufferedStream.ReadLineAsync(cancellationToken); + } + + httpResponse.Content = new StreamContent(bufferedStream); + foreach (string header in headers) + { + if (string.IsNullOrWhiteSpace(header)) + { + // headers end + break; + } + + int headerSeparatorPosition = header.IndexOf(HeaderSeparator, StringComparison.OrdinalIgnoreCase); + if (headerSeparatorPosition <= 0) + { + throw new HttpRequestException($"Header is invalid {header}."); + } + + string headerName = header.Substring(0, headerSeparatorPosition).Trim(); + string headerValue = header.Substring(headerSeparatorPosition + 1).Trim(); + + bool headerAdded = httpResponse.Headers.TryAddWithoutValidation(headerName, headerValue); + if (!headerAdded) + { + if (string.Equals(headerName, ContentLengthHeaderName, StringComparison.InvariantCultureIgnoreCase)) + { + if (!long.TryParse(headerValue, out long contentLength)) + { + throw new HttpRequestException($"Header value is invalid for {headerName}."); + } + + await httpResponse.Content.LoadIntoBufferAsync(contentLength); + } + + httpResponse.Content.Headers.TryAddWithoutValidation(headerName, headerValue); + } + } + } + + private static async Task SetResponseStatusLineAsync(HttpResponseMessage httpResponse, HttpBufferedStream bufferedStream, CancellationToken cancellationToken) + { + string statusLine = await bufferedStream.ReadLineAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(statusLine)) + { + throw new HttpRequestException("Response is empty."); + } + + string[] statusLineParts = statusLine.Split(new[] { SP }, 3); + if (statusLineParts.Length < 3) + { + throw new HttpRequestException("Status line is not valid."); + } + + string[] httpVersion = statusLineParts[0].Split(new[] { ProtocolVersionSeparator }, 2); + if (httpVersion.Length < 2 || !Version.TryParse(httpVersion[1], out Version versionNumber)) + { + throw new HttpRequestException($"Version is not valid {statusLineParts[0]}."); + } + + httpResponse.Version = versionNumber; + + if (!Enum.TryParse(statusLineParts[1], out HttpStatusCode statusCode)) + { + throw new HttpRequestException($"StatusCode is not valid {statusLineParts[1]}."); + } + + httpResponse.StatusCode = statusCode; + httpResponse.ReasonPhrase = statusLineParts[2]; + } + + private static void PreProcessRequest(HttpRequestMessage request) + { + if (string.IsNullOrEmpty(request.Headers.Host)) + { + request.Headers.Host = $"{request.RequestUri.DnsSafeHost}:{request.RequestUri.Port}"; + } + + request.Headers.ConnectionClose = true; + } + } +} diff --git a/SecurityDaemonClient/Uds/HttpUdsMessageHandler.cs b/SecurityDaemonClient/Uds/HttpUdsMessageHandler.cs new file mode 100644 index 0000000..ec8d127 --- /dev/null +++ b/SecurityDaemonClient/Uds/HttpUdsMessageHandler.cs @@ -0,0 +1,57 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System; +using System.Net.Http; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Azure.EventGridEdge.IotEdge +{ + /// + /// Unix domain message handler. + /// + internal class HttpUdsMessageHandler : HttpMessageHandler + { + private readonly Uri providerUri; + + public HttpUdsMessageHandler(Uri providerUri) + { + Validate.ArgumentNotNull(providerUri, nameof(providerUri)); + this.providerUri = providerUri; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Validate.ArgumentNotNull(request, nameof(request)); + + using (Socket socket = await this.GetConnectedSocketAsync()) + { + using (var stream = new HttpBufferedStream(new NetworkStream(socket, true))) + { + var serializer = new HttpRequestResponseSerializer(); + byte[] requestBytes = serializer.SerializeRequest(request); + + await stream.WriteAsync(requestBytes, 0, requestBytes.Length, cancellationToken); + if (request.Content != null) + { + await request.Content.CopyToAsync(stream); + } + + return await serializer.DeserializeResponseAsync(stream, cancellationToken); + } + } + } + + private async Task GetConnectedSocketAsync() + { + var endpoint = new UnixDomainSocketEndPoint(this.providerUri.LocalPath); + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + await socket.ConnectAsync(endpoint); + return socket; + } + } +} diff --git a/SecurityDaemonClient/Validate.cs b/SecurityDaemonClient/Validate.cs new file mode 100644 index 0000000..f7bdc0d --- /dev/null +++ b/SecurityDaemonClient/Validate.cs @@ -0,0 +1,32 @@ +// ----------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// ----------------------------------------------------------------------- + +using System; + +namespace Microsoft.Azure.EventGridEdge.IotEdge +{ + internal static class Validate + { + public static void ArgumentNotNull(object value, string paramName) + { + if (value == null) + { + throw new ArgumentNullException(paramName, $"The argument {paramName} is null."); + } + } + + public static void ArgumentNotNullOrEmpty(string value, string paramName) + { + if (value == null) + { + throw new ArgumentNullException(paramName, $"The argument {paramName} is null."); + } + else if (value.Length == 0) + { + throw new ArgumentException(paramName, $"The argument {paramName} is empty."); + } + } + } +}