├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── images ├── migration-scenario.png └── network-server-scenario.png └── src ├── IoTHubGateway.Server ├── Constants.cs ├── Controllers │ └── GatewayController.cs ├── IoTHubGateway.Server.csproj ├── Program.cs ├── ServerOptions.cs ├── Services │ ├── CloudToMessageListenerJobHostedService.cs │ ├── DeviceConnectionException.cs │ ├── GatewayService.cs │ ├── IGatewayService.cs │ └── RegisteredDevices.cs ├── Startup.cs ├── appsettings.Development.json └── appsettings.json ├── IoTHubGateway.sln ├── SampleClients └── CSharpClient │ ├── CSharpClient.csproj │ └── Program.cs └── Tests └── IoTHubGateway.Server.Tests ├── GatewayControllerTest.cs └── IoTHubGateway.Server.Tests.csproj /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # MSTest test Results 33 | [Tt]est[Rr]esult*/ 34 | [Bb]uild[Ll]og.* 35 | 36 | # NUNIT 37 | *.VisualState.xml 38 | TestResult.xml 39 | 40 | # Build Results of an ATL Project 41 | [Dd]ebugPS/ 42 | [Rr]eleasePS/ 43 | dlldata.c 44 | 45 | # .NET Core 46 | project.lock.json 47 | project.fragment.lock.json 48 | artifacts/ 49 | **/Properties/launchSettings.json 50 | 51 | *_i.c 52 | *_p.c 53 | *_i.h 54 | *.ilk 55 | *.meta 56 | *.obj 57 | *.pch 58 | *.pdb 59 | *.pgc 60 | *.pgd 61 | *.rsp 62 | *.sbr 63 | *.tlb 64 | *.tli 65 | *.tlh 66 | *.tmp 67 | *.tmp_proj 68 | *.log 69 | *.vspscc 70 | *.vssscc 71 | .builds 72 | *.pidb 73 | *.svclog 74 | *.scc 75 | 76 | # Chutzpah Test files 77 | _Chutzpah* 78 | 79 | # Visual C++ cache files 80 | ipch/ 81 | *.aps 82 | *.ncb 83 | *.opendb 84 | *.opensdf 85 | *.sdf 86 | *.cachefile 87 | *.VC.db 88 | *.VC.VC.opendb 89 | 90 | # Visual Studio profiler 91 | *.psess 92 | *.vsp 93 | *.vspx 94 | *.sap 95 | 96 | # TFS 2012 Local Workspace 97 | $tf/ 98 | 99 | # Guidance Automation Toolkit 100 | *.gpState 101 | 102 | # ReSharper is a .NET coding add-in 103 | _ReSharper*/ 104 | *.[Rr]e[Ss]harper 105 | *.DotSettings.user 106 | 107 | # JustCode is a .NET coding add-in 108 | .JustCode 109 | 110 | # TeamCity is a build add-in 111 | _TeamCity* 112 | 113 | # DotCover is a Code Coverage Tool 114 | *.dotCover 115 | 116 | # Visual Studio code coverage results 117 | *.coverage 118 | *.coveragexml 119 | 120 | # NCrunch 121 | _NCrunch_* 122 | .*crunch*.local.xml 123 | nCrunchTemp_* 124 | 125 | # MightyMoose 126 | *.mm.* 127 | AutoTest.Net/ 128 | 129 | # Web workbench (sass) 130 | .sass-cache/ 131 | 132 | # Installshield output folder 133 | [Ee]xpress/ 134 | 135 | # DocProject is a documentation generator add-in 136 | DocProject/buildhelp/ 137 | DocProject/Help/*.HxT 138 | DocProject/Help/*.HxC 139 | DocProject/Help/*.hhc 140 | DocProject/Help/*.hhk 141 | DocProject/Help/*.hhp 142 | DocProject/Help/Html2 143 | DocProject/Help/html 144 | 145 | # Click-Once directory 146 | publish/ 147 | 148 | # Publish Web Output 149 | *.[Pp]ublish.xml 150 | *.azurePubxml 151 | # TODO: Comment the next line if you want to checkin your web deploy settings 152 | # but database connection strings (with potential passwords) will be unencrypted 153 | *.pubxml 154 | *.publishproj 155 | 156 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 157 | # checkin your Azure Web App publish settings, but sensitive information contained 158 | # in these scripts will be unencrypted 159 | PublishScripts/ 160 | 161 | # NuGet Packages 162 | *.nupkg 163 | # The packages folder can be ignored because of Package Restore 164 | **/packages/* 165 | # except build/, which is used as an MSBuild target. 166 | !**/packages/build/ 167 | # Uncomment if necessary however generally it will be regenerated when needed 168 | #!**/packages/repositories.config 169 | # NuGet v3's project.json files produces more ignorable files 170 | *.nuget.props 171 | *.nuget.targets 172 | 173 | # Microsoft Azure Build Output 174 | csx/ 175 | *.build.csdef 176 | 177 | # Microsoft Azure Emulator 178 | ecf/ 179 | rcf/ 180 | 181 | # Windows Store app package directories and files 182 | AppPackages/ 183 | BundleArtifacts/ 184 | Package.StoreAssociation.xml 185 | _pkginfo.txt 186 | 187 | # Visual Studio cache files 188 | # files ending in .cache can be ignored 189 | *.[Cc]ache 190 | # but keep track of directories ending in .cache 191 | !*.[Cc]ache/ 192 | 193 | # Others 194 | ClientBin/ 195 | ~$* 196 | *~ 197 | *.dbmdl 198 | *.dbproj.schemaview 199 | *.jfm 200 | *.pfx 201 | *.publishsettings 202 | orleans.codegen.cs 203 | 204 | # Since there are multiple workflows, uncomment next line to ignore bower_components 205 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 206 | #bower_components/ 207 | 208 | # RIA/Silverlight projects 209 | Generated_Code/ 210 | 211 | # Backup & report files from converting an old project file 212 | # to a newer Visual Studio version. Backup files are not needed, 213 | # because we have git ;-) 214 | _UpgradeReport_Files/ 215 | Backup*/ 216 | UpgradeLog*.XML 217 | UpgradeLog*.htm 218 | 219 | # SQL Server files 220 | *.mdf 221 | *.ldf 222 | *.ndf 223 | 224 | # Business Intelligence projects 225 | *.rdl.data 226 | *.bim.layout 227 | *.bim_*.settings 228 | 229 | # Microsoft Fakes 230 | FakesAssemblies/ 231 | 232 | # GhostDoc plugin setting file 233 | *.GhostDoc.xml 234 | 235 | # Node.js Tools for Visual Studio 236 | .ntvs_analysis.dat 237 | node_modules/ 238 | 239 | # Typescript v1 declaration files 240 | typings/ 241 | 242 | # Visual Studio 6 build log 243 | *.plg 244 | 245 | # Visual Studio 6 workspace options file 246 | *.opt 247 | 248 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 249 | *.vbw 250 | 251 | # Visual Studio LightSwitch build output 252 | **/*.HTMLClient/GeneratedArtifacts 253 | **/*.DesktopClient/GeneratedArtifacts 254 | **/*.DesktopClient/ModelManifest.xml 255 | **/*.Server/GeneratedArtifacts 256 | **/*.Server/ModelManifest.xml 257 | _Pvt_Extensions 258 | 259 | # Paket dependency manager 260 | .paket/paket.exe 261 | paket-files/ 262 | 263 | # FAKE - F# Make 264 | .fake/ 265 | 266 | # JetBrains Rider 267 | .idea/ 268 | *.sln.iml 269 | 270 | # CodeRush 271 | .cr/ 272 | 273 | # Python Tools for Visual Studio (PTVS) 274 | __pycache__/ 275 | *.pyc 276 | 277 | # Cake - Uncomment if you are using it 278 | # tools/** 279 | # !tools/packages.config 280 | 281 | # Telerik's JustMock configuration file 282 | *.jmconfig 283 | 284 | # BizTalk build output 285 | *.btp.cs 286 | *.btm.cs 287 | *.odx.cs 288 | *.xsd.cs 289 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: csharp 2 | dist: trusty 3 | mono: none 4 | dotnet: 2.0.0 5 | 6 | install: 7 | - dotnet restore src/ 8 | 9 | script: 10 | - dotnet build src/ 11 | - dotnet test src/Tests/IoTHubGateway.Server.Tests -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Francisco Beltrao 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Azure IoT Hub AMQP Server Gateway Sample 2 | 3 | Travis: [![Travis](https://travis-ci.org/fbeltrao/IoTHubGateway.svg?branch=master)](https://travis-ci.org/fbeltrao/IoTHubGateway) 4 | 5 | ## Introduction 6 | 7 | The best way to connect any device to IoT Hub when building an IoT solution is to directly connect it using one of the provided [Microsoft Azure IoT Device SDKs](https://github.com/Azure/azure-iot-sdks). 8 | 9 | Azure IoT Hub supports multiple protocols and offers extensive developer integration from REST API to [full device and service SDKs](https://github.com/Azure/azure-iot-sdks) in C, Python, Node.js, Java and .NET. In most cases, there is no need to build your own integration code from scratch. 10 | 11 | In some cases however, for example when you need to do protocol translation or your client devices are simply not capable to connect directly to IoT hub, you need a gateway to bridge the gap. 12 | 13 | If you just need to do protocol translation the right approach is to create a gateway as documented here: https://docs.microsoft.com/en-us/azure/iot-hub/iot-hub-protocol-gateway 14 | 15 | If you need a gateway on the edge then the best way to implement this gateway scenario is to use IoT Edge as outlined in these [use cases and patterns](https://docs.microsoft.com/en-us/azure/iot-edge/iot-edge-as-gateway). 16 | 17 | There is another scenario which we want to discuss here and which is not covered in the articles above, where you need to implement an application gateway at the server level and not at the Edge or the gateway may not support IoT Edge due to processing power or memory constraints or due to the lack of container support. 18 | 19 | One of these cases could be a migration scenario where you are unable to change the code running on the devices. In this case you need to implement an intermediary gateway that the devices can connect to without knowing about IoT hub. 20 | 21 | ![Migration Scenario](./images/migration-scenario.png "Migration Scenario") 22 | 23 | Another use for the server gateway for example is to act as a [LoRaWAN](https://www.lora-alliance.org/technology) to IoT Hub connector. The LoRaWAN network server could include this gateway code to forward all the messages from the connected LoRa devices to IoT Hub. 24 | 25 | ![Network Server Scenario](./images/network-server-scenario.png "Network Server Scenario") 26 | 27 | ## Note 28 | 29 | The code provided in this sample is not production ready and was built for demonstration and illustration purposes. Hence, the code has sample quality. We have done some basic and scalability testing but no in depth validation. 30 | 31 | ## Solution Approach 32 | 33 | In this sample, we created an ASP.NET Core solution to serve as our gateway. 34 | 35 | It is crucial that a gateway solution should be able to multiplex device connections instead of creating single connections to IoT Hub per device. IoT Hub supports multiplexing only over HTTP and AMQP. For our sample gateway, we decided to use AMQP for its efficiency. The [Azure IoT Hub SDK for .NET](https://github.com/Azure/azure-iot-sdk-csharp) already implements support for connection pooling. Support for the other language SDKs are planned. 36 | 37 | An alternative solution is to implement the same pattern without the use of the SDK and working directly at the AMQP level, here you can find a Node.js implementation: https://github.com/vjrantal/azure-iot-multiplexing-gateway 38 | 39 | ## Flow 40 | 41 | When a device first wants to send a message through the gateway, in our example it calls a REST API, but this could be achieved using any accessible endpoint or protocol that the gateway supports. At this point, the gateway creates a new [DeviceClient](https://docs.microsoft.com/en-us/dotnet/api/microsoft.azure.devices.client.deviceclient?view=azure-dotnet) in order to connect to IoT Hub using the connection pool settings "Pooling = true" in [AmqpConnectionPoolSettings](https://docs.microsoft.com/en-us/dotnet/api/microsoft.azure.devices.client.amqpconnectionpoolsettings?view=azure-dotnet). 42 | 43 | ```csharp 44 | var newDeviceClient = DeviceClient.Create( 45 | this.gatewayOptions.IoTHubHostName, 46 | auth, 47 | new ITransportSettings[] 48 | { 49 | new AmqpTransportSettings(TransportType.Amqp_Tcp_Only) 50 | { 51 | AmqpConnectionPoolSettings = new AmqpConnectionPoolSettings() 52 | { 53 | Pooling = true 54 | } 55 | } 56 | } 57 | ); 58 | ``` 59 | 60 | This forces every device client to share the same connection. By default, 995 devices are supported per connection. The SDK will add additional connections to the pool automatically if needed. 61 | 62 | At this point, we are ready to send the message from the gateway to IoT Hub. 63 | 64 | To further optimize our sample, we don't want to create a new [DeviceClient](https://docs.microsoft.com/en-us/dotnet/api/microsoft.azure.devices.client.deviceclient?view=azure-dotnet) for every subsequent message the device wants to send. Instead, we keep it in a cache with a time to live (TTL) we can specify. If no new message comes in within this specified time, 1 hour in our case, the cache system will remove the [DeviceClient](https://docs.microsoft.com/en-us/dotnet/api/microsoft.azure.devices.client.deviceclient?view=azure-dotnet) from the cache. 65 | 66 | ## Authentication 67 | 68 | In our example, we support two authentication mechanisms. In both cases, Iot Hub will see the message as being sent by the device itself rather than by the gateway. 69 | 70 | ### Authentication using a Shared Access Policy Key 71 | 72 | Using the authentication method [DeviceAuthenticationWithSharedAccessPolicyKey](https://docs.microsoft.com/en-us/dotnet/api/microsoft.azure.devices.client.deviceauthenticationwithsharedaccesspolicykey?view=azure-dotnet) A single connection string allows the gateway to forward messages from all clients to IoT hub without individual authentication for each of the devices. In this case, the gateway authenticates in behalf of the devices but does not impersonate them. This is the simplest solution, but it's not secure. 73 | 74 | You need to have a custom authentication mechanism in place between the devices and the gateway in this scenario to ensure security. This is useful in the scenario of a LoRaWAN network server, where an authenticaiton mechanism is already in place between the LoRa devices and their LoRaWAN gateway or server. 75 | 76 | ### Authentication using Tokens 77 | 78 | Using the authentication method [DeviceAuthenticationWithToken](https://docs.microsoft.com/en-us/dotnet/api/microsoft.azure.devices.client.deviceauthenticationwithtoken?view=azure-dotnet) allows every device to authenticate to IoT hub directly, while still retaining the ability to share the connection among devices with pooling. In this case, the device has the SAS Key and generates a temporary token that it sends through the gateway to IoT Hub. IoT Hub is authenticating the device and not the gateway itself, effectively making it an end-to-end authentication. The time-to-live set for the caching needs to be the same as the one for the SAS token. 79 | 80 | ## Sending messages from Cloud to Devices 81 | 82 | ### Direct Method 83 | 84 | IoT Hub allows to do a callback to the connected device by calling a method on the device. For more information see ["Use direct methods"](https://docs.microsoft.com/en-us/azure/iot-hub/iot-hub-csharp-csharp-direct-methods). 85 | 86 | In the gateway it's possible to listen for this event called by IoT hub. This call is synchronous. Depending on the protocol and the connection between device and our gateway, you need to implement your own callback solution which varies greatly based on the solution architecture and hence is out of the scope of this sample. For demonstration purposes, we simply log the method call to the console. For example, you could call the REST API of the LoRaWAN server from this event handler and pass the message downstream to the device. 87 | 88 | ### Cloud to Device Messages 89 | 90 | For details Cloud to Device messages, see ["Send messages from the cloud to your device with IoT Hub"](https://docs.microsoft.com/en-us/azure/iot-hub/iot-hub-csharp-csharp-c2d) 91 | 92 | We have implemented a basic Cloud to Device message handling, but this implementation is not optimal as the more devices that are connected the slower it will become. We have run a quick test with 5’000 DeviceClient instances. It is an approach that might not be advised for large device deployments. 93 | 94 | ### Device Twins 95 | 96 | For details on how Device Twins work, see ["Understand and use device twins in IoT Hub"](https://docs.microsoft.com/en-us/azure/iot-hub/iot-hub-devguide-device-twins) 97 | 98 | Support is not implemented in this gateway sample at the moment. 99 | 100 | ## Scaling 101 | 102 | This solution was tested in a single instance, connecting 20'000 devices, sending and receiving messages. No problems were found. Since we open a device connection and keep in memory you should use affinity if you deploy the application in multiple instances, so that each device always communicates with the same instance. 103 | 104 | If you enable cloud to device messages pay attention to the option "CloudMessageParallelism", as it dictate how fast your deployment will handle cloud messages. Direct methods don't have the same problem as the IoT SDK notifies when a direct method call is received. 105 | 106 | ## Configuration Options 107 | 108 | Customization of the gateway is available through the configuration. The available options are: 109 | 110 | |Setting|Description|Default| 111 | |--|--|--| 112 | |DeviceOperationTimeout|Device operation timeout (in milliseconds)|10 seconds (10'000)| 113 | |IoTHubHostName|IoT Hub host name. Something like xxxxx.azure-devices.net|""| 114 | |AccessPolicyName|The IoT Hub access policy name. A common value is iothubowner|""| 115 | |AccessPolicyKey|The IoT Hub access policy key. Get it from Azure Portal|""| 116 | |MaxPoolSize|The maximum pool size|ushort.MaxValue| 117 | |SharedAccessPolicyKeyEnabled|Allows or not the usage of shared access policy keys.If you enable it make sure that access to the API is protected otherwise anyone will be able to impersonate devices|false| 118 | |DefaultDeviceCacheInMinutes|Default device client cache in duration in minutes|60 minutes| 119 | |DirectMethodEnabled|Enable/disables direct method (cloud -> device)|false| 120 | |DirectMethodCallback|Gets/sets the callback to handle device direct methods|null| 121 | |CloudMessagesEnabled|Enable/disables cloud messages in the gateway. Cloud messages are retrieved in a background job|false| 122 | |CloudMessageParallelism|Degree of parallelism used to check for cloud messages|10| 123 | |CloudMessageCallback|Gets/sets the callback to handle cloud messages|null| 124 | ## Contributors 125 | 126 | Sascha Corti, Ronnie Saurenmann, Francisco Beltrao 127 | -------------------------------------------------------------------------------- /images/migration-scenario.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fbeltrao/IoTHubGateway/94e8bd18e9407470532acede42ed1b7337e13cf2/images/migration-scenario.png -------------------------------------------------------------------------------- /images/network-server-scenario.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fbeltrao/IoTHubGateway/94e8bd18e9407470532acede42ed1b7337e13cf2/images/network-server-scenario.png -------------------------------------------------------------------------------- /src/IoTHubGateway.Server/Constants.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace IoTHubGateway.Server 7 | { 8 | public static class Constants 9 | { 10 | /// 11 | /// Name of request header containing the device sas token 12 | /// 13 | public const string SasTokenHeaderName = "sas_token"; 14 | 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/IoTHubGateway.Server/Controllers/GatewayController.cs: -------------------------------------------------------------------------------- 1 | using IoTHubGateway.Server.Services; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.Extensions.Options; 4 | using System; 5 | using System.Threading.Tasks; 6 | 7 | namespace IoTHubGateway.Server.Controllers 8 | { 9 | [Route("api")] 10 | public class GatewayController : Controller 11 | { 12 | private readonly IGatewayService gatewayService; 13 | private readonly ServerOptions options; 14 | private static readonly DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); 15 | 16 | public GatewayController(IGatewayService gatewayService, ServerOptions options) 17 | { 18 | this.gatewayService = gatewayService; 19 | this.options = options; 20 | } 21 | 22 | /// 23 | /// Sends a message for the given device 24 | /// 25 | /// Device identifier 26 | /// Payload (JSON format) 27 | /// 28 | [HttpPost("{deviceId}")] 29 | public async Task Send(string deviceId, [FromBody] dynamic payload) 30 | { 31 | if (string.IsNullOrEmpty(deviceId)) 32 | return BadRequest(new { error = "Missing deviceId" }); 33 | 34 | if (payload == null) 35 | return BadRequest(new { error = "Missing payload" }); 36 | 37 | var sasToken = this.ControllerContext.HttpContext.Request.Headers[Constants.SasTokenHeaderName].ToString(); 38 | if (!string.IsNullOrEmpty(sasToken)) 39 | { 40 | var tokenExpirationDate = ResolveTokenExpiration(sasToken); 41 | if (!tokenExpirationDate.HasValue) 42 | tokenExpirationDate = DateTime.UtcNow.AddMinutes(20); 43 | 44 | await gatewayService.SendDeviceToCloudMessageByToken(deviceId, payload.ToString(), sasToken, tokenExpirationDate.Value); 45 | } 46 | else 47 | { 48 | if (!this.options.SharedAccessPolicyKeyEnabled) 49 | return BadRequest(new { error = "Shared access is not enabled" }); 50 | await gatewayService.SendDeviceToCloudMessageBySharedAccess(deviceId, payload.ToString()); 51 | } 52 | 53 | return Ok(); 54 | } 55 | 56 | /// 57 | /// Expirations is available as parameter "se" as a unix time in our sample application 58 | /// 59 | /// 60 | /// 61 | private DateTime? ResolveTokenExpiration(string sasToken) 62 | { 63 | // TODO: Implement in more reliable way (regex or another built-in class) 64 | const string field = "se="; 65 | var index = sasToken.LastIndexOf(field); 66 | if (index >= 0) 67 | { 68 | var unixTime = sasToken.Substring(index + field.Length); 69 | if (int.TryParse(unixTime, out var unixTimeInt)) 70 | { 71 | return epoch.AddSeconds(unixTimeInt); 72 | } 73 | } 74 | 75 | return null; 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/IoTHubGateway.Server/IoTHubGateway.Server.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp2.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/IoTHubGateway.Server/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.Extensions.Configuration; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace IoTHubGateway.Server 12 | { 13 | public class Program 14 | { 15 | public static void Main(string[] args) 16 | { 17 | BuildWebHost(args).Run(); 18 | } 19 | 20 | public static IWebHost BuildWebHost(string[] args) => 21 | WebHost.CreateDefaultBuilder(args) 22 | .UseStartup() 23 | .Build(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/IoTHubGateway.Server/ServerOptions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Azure.Devices.Client; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | 7 | namespace IoTHubGateway.Server 8 | { 9 | /// 10 | /// Defines the server options 11 | /// 12 | public class ServerOptions 13 | { 14 | 15 | /// 16 | /// Device operation timeout (in milliseconds) 17 | /// Default: 10000 (10 seconds) 18 | /// 19 | public int DeviceOperationTimeout { get; set; } = 1000 * 10; 20 | 21 | /// 22 | /// IoT Hub host name. Something like xxxxx.azure-devices.net 23 | /// 24 | public string IoTHubHostName { get; set; } 25 | 26 | /// 27 | /// The IoT Hub access policy name. A common value is iothubowner 28 | /// 29 | public string AccessPolicyName { get; set; } 30 | 31 | /// 32 | /// The IoT Hub access policy key. Get it from Azure Portal 33 | /// 34 | public string AccessPolicyKey { get; set; } 35 | 36 | /// 37 | /// The maximum pool size (default is ) 38 | /// 39 | public int MaxPoolSize { get; set; } = ushort.MaxValue; 40 | 41 | /// 42 | /// Allows or not the usage of shared access policy keys 43 | /// If you enable it make sure that access to the API is protected otherwise anyone will be able to impersonate devices 44 | /// 45 | public bool SharedAccessPolicyKeyEnabled { get; set; } 46 | 47 | /// 48 | /// Default device client cache in duration in minutes 49 | /// 60 minutes by default 50 | /// 51 | public int DefaultDeviceCacheInMinutes = 60; 52 | 53 | /// 54 | /// Enable/disables direct method (cloud -> device) 55 | /// 56 | public bool DirectMethodEnabled { get; set; } = false; 57 | 58 | /// 59 | /// Gets/sets the callback to handle device direct methods 60 | /// 61 | public MethodCallback DirectMethodCallback { get; set; } 62 | 63 | /// 64 | /// Enable/disables cloud messages in the gateway 65 | /// Cloud messages are retrieved in a background job 66 | /// Default: false / disabled 67 | /// 68 | public bool CloudMessagesEnabled { get; set; } 69 | 70 | /// 71 | /// Degree of parallelism used to check for cloud messages 72 | /// 73 | public int CloudMessageParallelism { get; set; } = 10; 74 | 75 | 76 | /// 77 | /// Gets/sets the callback to handle cloud messages 78 | /// 79 | public Action CloudMessageCallback { get; set; } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/IoTHubGateway.Server/Services/CloudToMessageListenerJobHostedService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Azure.Devices.Client; 2 | using Microsoft.Extensions.Caching.Memory; 3 | using Microsoft.Extensions.Hosting; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Options; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | 12 | namespace IoTHubGateway.Server.Services 13 | { 14 | public class CloudToMessageListenerJobHostedService : IHostedService 15 | { 16 | private readonly IMemoryCache cache; 17 | private readonly ILogger logger; 18 | private readonly RegisteredDevices registeredDevices; 19 | private readonly ServerOptions serverOptions; 20 | 21 | public CloudToMessageListenerJobHostedService( 22 | IMemoryCache cache, 23 | ILogger logger, 24 | RegisteredDevices registeredDevices, 25 | ServerOptions serverOptions) 26 | { 27 | this.cache = cache; 28 | this.logger = logger; 29 | this.registeredDevices = registeredDevices; 30 | this.serverOptions = serverOptions; 31 | } 32 | 33 | 34 | /// 35 | /// Checks for device messages 36 | /// 37 | /// 38 | private void CheckDeviceMessages() 39 | { 40 | var deviceIdList = this.registeredDevices.GetDeviceIdList(); 41 | Parallel.ForEach(deviceIdList, new ParallelOptions() { MaxDegreeOfParallelism = serverOptions.CloudMessageParallelism }, (deviceId) => 42 | { 43 | try 44 | { 45 | if (this.cache.TryGetValue(deviceId, out var deviceClient)) 46 | { 47 | var message = deviceClient.ReceiveAsync(TimeSpan.FromMilliseconds(1)).GetAwaiter().GetResult(); 48 | if (message != null) 49 | { 50 | try 51 | { 52 | this.serverOptions.CloudMessageCallback(deviceId, message); 53 | 54 | deviceClient.CompleteAsync(message).GetAwaiter().GetResult(); 55 | } 56 | catch (Exception handlingMessageException) 57 | { 58 | logger.LogError(handlingMessageException, $"Error handling message from {deviceId}"); 59 | } 60 | } 61 | } 62 | } 63 | catch (Exception ex) 64 | { 65 | logger.LogError(ex, $"Error receiving message from {deviceId}"); 66 | } 67 | }); 68 | } 69 | 70 | /// 71 | /// Starts job 72 | /// 73 | /// 74 | /// 75 | public Task StartAsync(CancellationToken stoppingToken) 76 | { 77 | logger.LogDebug($"{nameof(CloudToMessageListenerJobHostedService)} is starting."); 78 | 79 | if (this.serverOptions.CloudMessageCallback == null) 80 | { 81 | logger.LogInformation($"{nameof(CloudToMessageListenerJobHostedService)} not executing as no handler was defined in {nameof(ServerOptions)}.{nameof(ServerOptions.CloudMessageCallback)}."); 82 | } 83 | else 84 | { 85 | stoppingToken.Register(() => logger.LogDebug($" {nameof(CloudToMessageListenerJobHostedService)} background task is stopping.")); 86 | 87 | while (!stoppingToken.IsCancellationRequested) 88 | { 89 | CheckDeviceMessages(); 90 | } 91 | } 92 | 93 | logger.LogDebug($"{nameof(CloudToMessageListenerJobHostedService)} background task is stopping."); 94 | 95 | return Task.CompletedTask; 96 | } 97 | 98 | /// 99 | /// Ends the job 100 | /// 101 | /// 102 | /// 103 | public Task StopAsync(CancellationToken stoppingToken) 104 | { 105 | return Task.CompletedTask; 106 | } 107 | 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/IoTHubGateway.Server/Services/DeviceConnectionException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.Serialization; 3 | 4 | namespace IoTHubGateway.Server.Services 5 | { 6 | /// 7 | /// Exception raised when the device could not connected 8 | /// 9 | [Serializable] 10 | public class DeviceConnectionException : Exception 11 | { 12 | public DeviceConnectionException() 13 | { 14 | } 15 | 16 | public DeviceConnectionException(string message) : base(message) 17 | { 18 | } 19 | 20 | public DeviceConnectionException(string message, Exception innerException) : base(message, innerException) 21 | { 22 | } 23 | 24 | protected DeviceConnectionException(SerializationInfo info, StreamingContext context) : base(info, context) 25 | { 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /src/IoTHubGateway.Server/Services/GatewayService.cs: -------------------------------------------------------------------------------- 1 | 2 | using Microsoft.Azure.Devices.Client; 3 | using Microsoft.Extensions.Caching.Memory; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Options; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | using System.Text; 10 | using System.Threading; 11 | using System.Threading.Tasks; 12 | using System.Web; 13 | 14 | namespace IoTHubGateway.Server.Services 15 | { 16 | /// 17 | /// IoT Hub Device client multiplexer based on AMQP 18 | /// 19 | public class GatewayService : IGatewayService 20 | { 21 | private readonly ServerOptions serverOptions; 22 | private readonly IMemoryCache cache; 23 | private readonly ILogger logger; 24 | RegisteredDevices registeredDevices; 25 | 26 | /// 27 | /// Sliding expiration for each device client connection 28 | /// Default: 30 minutes 29 | /// 30 | public TimeSpan DeviceConnectionCacheSlidingExpiration { get; set; } 31 | 32 | /// 33 | /// Constructor 34 | /// 35 | public GatewayService(ServerOptions serverOptions, IMemoryCache cache, ILogger logger, RegisteredDevices registeredDevices) 36 | { 37 | this.serverOptions = serverOptions; 38 | this.cache = cache; 39 | this.logger = logger; 40 | this.registeredDevices = registeredDevices; 41 | this.DeviceConnectionCacheSlidingExpiration = TimeSpan.FromMinutes(serverOptions.DefaultDeviceCacheInMinutes); 42 | } 43 | 44 | public async Task SendDeviceToCloudMessageByToken(string deviceId, string payload, string sasToken, DateTime tokenExpiration) 45 | { 46 | var deviceClient = await ResolveDeviceClient(deviceId, sasToken, tokenExpiration); 47 | if (deviceClient == null) 48 | throw new DeviceConnectionException($"Failed to connect to device {deviceId}"); 49 | 50 | try 51 | { 52 | await deviceClient.SendEventAsync(new Message(Encoding.UTF8.GetBytes(payload)) 53 | { 54 | ContentEncoding = "utf-8", 55 | ContentType = "application/json" 56 | }); 57 | 58 | this.logger.LogInformation($"Event sent to device {deviceId} using device token. Payload: {payload}"); 59 | } 60 | catch (Exception ex) 61 | { 62 | this.logger.LogError(ex, $"Could not send device message to IoT Hub (device: {deviceId})"); 63 | throw; 64 | } 65 | 66 | } 67 | 68 | public async Task SendDeviceToCloudMessageBySharedAccess(string deviceId, string payload) 69 | { 70 | var deviceClient = await ResolveDeviceClient(deviceId); 71 | 72 | try 73 | { 74 | await deviceClient.SendEventAsync(new Message(Encoding.UTF8.GetBytes(payload)) 75 | { 76 | ContentEncoding = "utf-8", 77 | ContentType = "application/json" 78 | }); 79 | 80 | this.logger.LogInformation($"Event sent to device {deviceId} using shared access. Payload: {payload}"); 81 | } 82 | catch (Exception ex) 83 | { 84 | this.logger.LogError(ex, $"Could not send device message to IoT Hub (device: {deviceId})"); 85 | throw; 86 | } 87 | 88 | } 89 | 90 | private async Task ResolveDeviceClient(string deviceId, string sasToken = null, DateTime? tokenExpiration = null) 91 | { 92 | try 93 | { 94 | var deviceClient = await cache.GetOrCreateAsync(deviceId, async (cacheEntry) => 95 | { 96 | IAuthenticationMethod auth = null; 97 | if (string.IsNullOrEmpty(sasToken)) 98 | { 99 | auth = new DeviceAuthenticationWithSharedAccessPolicyKey(deviceId, this.serverOptions.AccessPolicyName, this.serverOptions.AccessPolicyKey); 100 | } 101 | else 102 | { 103 | auth = new DeviceAuthenticationWithToken(deviceId, sasToken); 104 | } 105 | 106 | var newDeviceClient = DeviceClient.Create( 107 | this.serverOptions.IoTHubHostName, 108 | auth, 109 | new ITransportSettings[] 110 | { 111 | new AmqpTransportSettings(TransportType.Amqp_Tcp_Only) 112 | { 113 | AmqpConnectionPoolSettings = new AmqpConnectionPoolSettings() 114 | { 115 | Pooling = true, 116 | MaxPoolSize = (uint)this.serverOptions.MaxPoolSize, 117 | } 118 | } 119 | } 120 | ); 121 | 122 | newDeviceClient.OperationTimeoutInMilliseconds = (uint)this.serverOptions.DeviceOperationTimeout; 123 | 124 | await newDeviceClient.OpenAsync(); 125 | 126 | if (this.serverOptions.DirectMethodEnabled) 127 | await newDeviceClient.SetMethodDefaultHandlerAsync(this.serverOptions.DirectMethodCallback, deviceId); 128 | 129 | if (!tokenExpiration.HasValue) 130 | tokenExpiration = DateTime.UtcNow.AddMinutes(this.serverOptions.DefaultDeviceCacheInMinutes); 131 | cacheEntry.SetAbsoluteExpiration(tokenExpiration.Value); 132 | cacheEntry.RegisterPostEvictionCallback(this.CacheEntryRemoved, deviceId); 133 | 134 | this.logger.LogInformation($"Connection to device {deviceId} has been established, valid until {tokenExpiration.Value.ToString()}"); 135 | 136 | 137 | registeredDevices.AddDevice(deviceId); 138 | 139 | return newDeviceClient; 140 | }); 141 | 142 | 143 | return deviceClient; 144 | } 145 | catch (Exception ex) 146 | { 147 | this.logger.LogError(ex, $"Could not connect device {deviceId}"); 148 | } 149 | 150 | return null; 151 | } 152 | 153 | private void CacheEntryRemoved(object key, object value, EvictionReason reason, object state) 154 | { 155 | this.registeredDevices.RemoveDevice(key); 156 | } 157 | } 158 | } -------------------------------------------------------------------------------- /src/IoTHubGateway.Server/Services/IGatewayService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace IoTHubGateway.Server.Services 7 | { 8 | /// 9 | /// Gateway to IoT Hub service 10 | /// 11 | public interface IGatewayService 12 | { 13 | /// 14 | /// Sends device to cloud message using device token 15 | /// 16 | /// 17 | /// 18 | /// 19 | /// 20 | /// 21 | Task SendDeviceToCloudMessageByToken(string deviceId, string payload, string sasToken, DateTime dateTime); 22 | 23 | /// 24 | /// Sends device to cloud message using shared access token 25 | /// 26 | /// 27 | /// 28 | /// 29 | Task SendDeviceToCloudMessageBySharedAccess(string deviceId, string payload); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/IoTHubGateway.Server/Services/RegisteredDevices.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace IoTHubGateway.Server.Services 7 | { 8 | /// 9 | /// Maintains a list of registered devices to enabled cloud messages background job to query them 10 | /// 11 | public class RegisteredDevices 12 | { 13 | System.Collections.Concurrent.ConcurrentDictionary devices = new System.Collections.Concurrent.ConcurrentDictionary(); 14 | 15 | public void AddDevice(string deviceId) 16 | { 17 | devices.AddOrUpdate(deviceId, deviceId, (key, existing) => 18 | { 19 | return deviceId; 20 | }); 21 | } 22 | 23 | public void RemoveDevice(object key) 24 | { 25 | devices.Remove(key.ToString(), out var value); 26 | } 27 | 28 | public ICollection GetDeviceIdList() 29 | { 30 | return this.devices.Keys; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/IoTHubGateway.Server/Startup.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using IoTHubGateway.Server.Services; 7 | using Microsoft.AspNetCore.Builder; 8 | using Microsoft.AspNetCore.Hosting; 9 | using Microsoft.Azure.Devices.Client; 10 | using Microsoft.Extensions.Configuration; 11 | using Microsoft.Extensions.DependencyInjection; 12 | using Microsoft.Extensions.Hosting; 13 | using Microsoft.Extensions.Logging; 14 | using Microsoft.Extensions.Options; 15 | 16 | namespace IoTHubGateway.Server 17 | { 18 | public class Startup 19 | { 20 | public Startup(IConfiguration configuration) 21 | { 22 | Configuration = configuration; 23 | } 24 | 25 | public IConfiguration Configuration { get; } 26 | 27 | // This method gets called by the runtime. Use this method to add services to the container. 28 | public void ConfigureServices(IServiceCollection services) 29 | { 30 | services.AddOptions(); 31 | services.AddMemoryCache(); 32 | 33 | // A single instance of registered devices must be kept 34 | services.AddSingleton(); 35 | 36 | // Resolve server options 37 | var options = new ServerOptions(); 38 | Configuration.GetSection(nameof(ServerOptions)).Bind(options); 39 | services.AddSingleton(options); 40 | 41 | 42 | if (options.CloudMessagesEnabled) 43 | { 44 | services.AddSingleton(); 45 | } 46 | 47 | #if DEBUG 48 | SetupDebugListeners(options); 49 | 50 | #endif 51 | 52 | services.AddSingleton(); 53 | services.AddMvc(); 54 | } 55 | 56 | #if DEBUG 57 | private void SetupDebugListeners(ServerOptions options) 58 | { 59 | if (options.DirectMethodEnabled && options.DirectMethodCallback == null) 60 | { 61 | options.DirectMethodCallback = (methodRequest, userContext) => 62 | { 63 | var deviceId = (string)userContext; 64 | Console.WriteLine($"[{DateTime.Now.ToString()}] Device method for {deviceId}.{methodRequest.Name}({methodRequest.DataAsJson}) received"); 65 | 66 | var responseBody = "{ succeeded: true }"; 67 | MethodResponse methodResponse = new MethodResponse(Encoding.UTF8.GetBytes(responseBody), 200); 68 | 69 | return Task.FromResult(methodResponse); 70 | }; 71 | } 72 | 73 | if (options.CloudMessagesEnabled && options.CloudMessageCallback == null) 74 | { 75 | options.CloudMessageCallback = (deviceId, message) => 76 | { 77 | Console.WriteLine($"[{DateTime.Now.ToString()}] Cloud message for {deviceId} received"); 78 | }; 79 | } 80 | } 81 | #endif 82 | 83 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. 84 | public void Configure(IApplicationBuilder app, IHostingEnvironment env) 85 | { 86 | if (env.IsDevelopment()) 87 | { 88 | app.UseDeveloperExceptionPage(); 89 | } 90 | 91 | 92 | app.UseMvc(); 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/IoTHubGateway.Server/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "IncludeScopes": false, 4 | "LogLevel": { 5 | "Default": "Debug", 6 | "System": "Information", 7 | "Microsoft": "Information" 8 | } 9 | }, 10 | 11 | "ServerOptions": { 12 | "IoTHubHostName": "xxx.azure-devices.net", 13 | "AccessPolicyName": "iothubowner", 14 | "AccessPolicyKey": "xxxxxxx", 15 | "SharedAccessPolicyKeyEnabled": true, 16 | "DirectMethodEnabled": true, 17 | "CloudMessagesEnabled": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/IoTHubGateway.Server/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "IncludeScopes": false, 4 | "Debug": { 5 | "LogLevel": { 6 | "Default": "Warning" 7 | } 8 | }, 9 | "Console": { 10 | "LogLevel": { 11 | "Default": "Warning" 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/IoTHubGateway.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.27428.1 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IoTHubGateway.Server", "IoTHubGateway.Server\IoTHubGateway.Server.csproj", "{B274B563-F76B-46D2-B5E9-FCEEA34A3C59}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SampleClients", "SampleClients", "{BBBB9EA1-FD6A-4F5B-89B6-3CB4775897C5}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{D57B650E-3B97-426D-B057-17D73853817E}" 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IoTHubGateway.Server.Tests", "Tests\IoTHubGateway.Server.Tests\IoTHubGateway.Server.Tests.csproj", "{B65CC92E-61D7-414A-9E8E-554BE4F12910}" 13 | EndProject 14 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSharpClient", "SampleClients\CSharpClient\CSharpClient.csproj", "{07873032-0046-4BFE-A40D-251AA805168C}" 15 | EndProject 16 | Global 17 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 18 | Debug|Any CPU = Debug|Any CPU 19 | Release|Any CPU = Release|Any CPU 20 | EndGlobalSection 21 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 22 | {B274B563-F76B-46D2-B5E9-FCEEA34A3C59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {B274B563-F76B-46D2-B5E9-FCEEA34A3C59}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {B274B563-F76B-46D2-B5E9-FCEEA34A3C59}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {B274B563-F76B-46D2-B5E9-FCEEA34A3C59}.Release|Any CPU.Build.0 = Release|Any CPU 26 | {B65CC92E-61D7-414A-9E8E-554BE4F12910}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {B65CC92E-61D7-414A-9E8E-554BE4F12910}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {B65CC92E-61D7-414A-9E8E-554BE4F12910}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {B65CC92E-61D7-414A-9E8E-554BE4F12910}.Release|Any CPU.Build.0 = Release|Any CPU 30 | {07873032-0046-4BFE-A40D-251AA805168C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 31 | {07873032-0046-4BFE-A40D-251AA805168C}.Debug|Any CPU.Build.0 = Debug|Any CPU 32 | {07873032-0046-4BFE-A40D-251AA805168C}.Release|Any CPU.ActiveCfg = Release|Any CPU 33 | {07873032-0046-4BFE-A40D-251AA805168C}.Release|Any CPU.Build.0 = Release|Any CPU 34 | EndGlobalSection 35 | GlobalSection(SolutionProperties) = preSolution 36 | HideSolutionNode = FALSE 37 | EndGlobalSection 38 | GlobalSection(NestedProjects) = preSolution 39 | {B65CC92E-61D7-414A-9E8E-554BE4F12910} = {D57B650E-3B97-426D-B057-17D73853817E} 40 | {07873032-0046-4BFE-A40D-251AA805168C} = {BBBB9EA1-FD6A-4F5B-89B6-3CB4775897C5} 41 | EndGlobalSection 42 | GlobalSection(ExtensibilityGlobals) = postSolution 43 | EnterpriseLibraryConfigurationToolBinariesPathV6 = packages\EnterpriseLibrary.TransientFaultHandling.6.0.1304.0\lib\portable-net45+win+wp8 44 | SolutionGuid = {1F383F61-9ECE-48E3-913B-0C55B9AF3629} 45 | EndGlobalSection 46 | EndGlobal 47 | -------------------------------------------------------------------------------- /src/SampleClients/CSharpClient/CSharpClient.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | netcoreapp2.0 6 | latest 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/SampleClients/CSharpClient/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Azure.Devices.Client; 2 | using System; 3 | using System.Net.Http; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace CSharpClient 8 | { 9 | class Program 10 | { 11 | static async Task Main(string[] args) 12 | { 13 | Console.WriteLine(""); 14 | Console.ReadLine(); 15 | var hostName = ""; 16 | var deviceId = ""; 17 | 18 | var sasToken = new SharedAccessSignatureBuilder() 19 | { 20 | Key = "", 21 | Target = $"{hostName}.azure-devices.net/devices/{deviceId}", 22 | TimeToLive = TimeSpan.FromMinutes(20) 23 | } 24 | .ToSignature(); 25 | 26 | using (var client = new HttpClient()) 27 | { 28 | client.DefaultRequestHeaders.Add("sas_token", sasToken); 29 | while (true) 30 | { 31 | var postResponse = await client.PostAsync($"http://localhost:32527/api/{deviceId}", new StringContent("{ content: 'from_rest_call' }", Encoding.UTF8, "application/json")); 32 | Console.WriteLine($"Response: {postResponse.StatusCode.ToString()}"); 33 | 34 | await Task.Delay(200); 35 | 36 | } 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Tests/IoTHubGateway.Server.Tests/GatewayControllerTest.cs: -------------------------------------------------------------------------------- 1 | using IoTHubGateway.Server.Controllers; 2 | using IoTHubGateway.Server.Services; 3 | using Microsoft.AspNetCore.Http; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.Extensions.Options; 6 | using Moq; 7 | using System; 8 | using System.Collections.Generic; 9 | using System.Net.Http; 10 | using System.Text; 11 | using System.Threading.Tasks; 12 | using Xunit; 13 | 14 | 15 | namespace IoTHubGateway.Server.Tests 16 | { 17 | public class GatewayControllerTest 18 | { 19 | private static readonly DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); 20 | 21 | GatewayController SetupWithSasToken(GatewayController controller, string sas_token) 22 | { 23 | var controllerContext = new ControllerContext(); 24 | var httpContext = new DefaultHttpContext(); 25 | httpContext.Request.Headers.Add(Constants.SasTokenHeaderName, sas_token); 26 | controller.ControllerContext.HttpContext = httpContext; 27 | 28 | return controller; 29 | } 30 | 31 | [Fact] 32 | public async Task Send_WithoutDeviceToken_ReturnsBadRequest_If_SharedAccessPolicy_Is_Not_Enabled() 33 | { 34 | var gatewayService = new Mock(); 35 | var options = new ServerOptions() 36 | { 37 | SharedAccessPolicyKeyEnabled = false, 38 | }; 39 | 40 | var target = new GatewayController(gatewayService.Object, options); 41 | target.ControllerContext.HttpContext = new DefaultHttpContext(); 42 | 43 | var result = await target.Send("device-1", new { payload = 1 }); 44 | Assert.IsType(result); 45 | } 46 | 47 | 48 | [Fact] 49 | public async Task Send_WithDeviceToken_ReturnsOk_If_SharedAccessPolicy_Is_Not_Enabled() 50 | { 51 | var gatewayService = new Mock(); 52 | var options = new ServerOptions() 53 | { 54 | SharedAccessPolicyKeyEnabled = false, 55 | }; 56 | 57 | var target = new GatewayController(gatewayService.Object, options); 58 | target.ControllerContext.HttpContext = new DefaultHttpContext(); 59 | target.ControllerContext.HttpContext.Request.Headers[Constants.SasTokenHeaderName] = "a-token"; 60 | 61 | var result = await target.Send("device-1", new { payload = 1 }); 62 | Assert.IsType(result); 63 | } 64 | 65 | 66 | [Fact] 67 | public async Task Send_WithoutDeviceToken_SendsUsingSharedAccessPolicy() 68 | { 69 | var gatewayService = new Mock(); 70 | gatewayService.Setup(x => x.SendDeviceToCloudMessageBySharedAccess(It.IsAny(), It.IsAny())) 71 | .Returns(Task.CompletedTask) 72 | .Verifiable(); 73 | var options = new ServerOptions() 74 | { 75 | SharedAccessPolicyKeyEnabled = true, 76 | }; 77 | 78 | var target = new GatewayController(gatewayService.Object, options); 79 | target.ControllerContext.HttpContext = new DefaultHttpContext(); 80 | 81 | var result = await target.Send("device-1", new { payload = 1 }); 82 | Assert.IsType(result); 83 | gatewayService.Verify(); 84 | } 85 | 86 | [Fact] 87 | public async Task Send_WithDeviceToken_SendsUsingSharedAccessPolicy() 88 | { 89 | var gatewayService = new Mock(); 90 | gatewayService.Setup(x => x.SendDeviceToCloudMessageByToken(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) 91 | .Returns(Task.CompletedTask) 92 | .Verifiable(); 93 | 94 | var options = new ServerOptions() 95 | { 96 | SharedAccessPolicyKeyEnabled = true, 97 | }; 98 | 99 | var target = new GatewayController(gatewayService.Object, options); 100 | target.ControllerContext.HttpContext = new DefaultHttpContext(); 101 | target.ControllerContext.HttpContext.Request.Headers.Add(Constants.SasTokenHeaderName, "a-token"); 102 | 103 | var result = await target.Send("device-1", new { payload = 1 }); 104 | Assert.IsType(result); 105 | gatewayService.Verify(); 106 | } 107 | 108 | 109 | /// 110 | /// Gateway accepts expired tokens as it needs to take into account grace period / clock disparities 111 | /// 112 | /// 113 | [Fact] 114 | public async Task Send_WithDeviceToken_And_TokenExpirationDateInPast_Returns_Ok() 115 | { 116 | var gatewayService = new Mock(); 117 | var options = new ServerOptions() 118 | { 119 | SharedAccessPolicyKeyEnabled = false, 120 | }; 121 | 122 | var target = new GatewayController(gatewayService.Object, options); 123 | target.ControllerContext.HttpContext = new DefaultHttpContext(); 124 | target.ControllerContext.HttpContext.Request.Headers.Add(Constants.SasTokenHeaderName, $"a-token&se={((long)DateTime.UtcNow.AddDays(-1).Subtract(epoch).TotalSeconds).ToString()}"); 125 | 126 | var result = await target.Send("device-1", new { payload = 1 }); 127 | Assert.IsType(result); 128 | } 129 | 130 | 131 | [Fact] 132 | public async Task Send_WithDeviceToken_And_ValidTokenExpirationDate_Returns_OK() 133 | { 134 | var gatewayService = new Mock(); 135 | 136 | var tokenExpirationDate = DateTime.UtcNow.AddMinutes(10); 137 | 138 | gatewayService.Setup(x => x.SendDeviceToCloudMessageByToken(It.IsAny(), It.IsAny(), It.IsAny(), It.Is(v => IsInSameSecond(v, tokenExpirationDate)))) 139 | .Returns(Task.CompletedTask) 140 | .Verifiable(); 141 | 142 | var options = new ServerOptions() 143 | { 144 | SharedAccessPolicyKeyEnabled = false, 145 | }; 146 | 147 | var target = new GatewayController(gatewayService.Object, options); 148 | target.ControllerContext.HttpContext = new DefaultHttpContext(); 149 | target.ControllerContext.HttpContext.Request.Headers.Add(Constants.SasTokenHeaderName, $"a-token&se={((long)tokenExpirationDate.Subtract(epoch).TotalSeconds).ToString()}"); 150 | 151 | var result = await target.Send("device-1", new { payload = 1 }); 152 | Assert.IsType(result); 153 | gatewayService.Verify(); 154 | } 155 | 156 | private bool IsInSameSecond(DateTime date1, DateTime date2) 157 | { 158 | return date1.Subtract(date2).TotalSeconds < 1.0; 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/Tests/IoTHubGateway.Server.Tests/IoTHubGateway.Server.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp2.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | --------------------------------------------------------------------------------