From 35e9c43c537962a674f9d94ee17d5d5321dfea2e Mon Sep 17 00:00:00 2001 From: Juan Manuel Servera <8036360+jmservera@users.noreply.github.com> Date: Tue, 23 Jul 2024 00:51:20 +0200 Subject: [PATCH] Add OCPP 1.6 subprotocol support (#3) * start apim demo * infra setup * chore: basic websocket and debug files * now websockets work in a webapp * use ocpp1.6 subprotocol * chore: Update Makefile with build, test, clean, restore, watch, start, deploy, and publish commands * chore: Update WebSocketController.cs with WebSocket protocol handling * add station * add station to protocol * enable application insights * app gateway deployment * link app gw to pubsub * Add token capture in the rewriteRules * add hub * use private endpoint for webpubsub * fix unused var * use keyvault for certificate * show ip address * chore: Update DNS configuration for Web PubSub service * make web private too * fix typo * fix service links * Add storage for zip deployment * add some sleep * chore: Increase request timeout for app gateway to prevent immediate draining of websocket * Add pingpong for local test socket * Set the right timeouts for the backend settings * chore: Update DNS configuration for Web PubSub service * chore: Update DNS configuration for Web PubSub service and fix url template for pubsub * chore: Add vnet integration to the web app * add ip security restrictions * chore: Rename apim-custom directory to ocpp-server and update README Add ocpp1.6 subprotocol check * chore: Update DNS configuration for Web PubSub service and fix url template for pubsub * chore: Replace newline characters in log messages for WebSocketController and Program.cs --- ocpp-server/.gitignore | 399 ++++++++++++++++++ ocpp-server/.vscode/launch.json | 35 ++ ocpp-server/.vscode/settings.json | 4 + ocpp-server/.vscode/tasks.json | 41 ++ ocpp-server/Makefile | 64 +++ ocpp-server/README.md | 60 +++ .../Controller/WebSocketController.cs | 122 ++++++ ocpp-server/api/OcppServer/OcppServer.csproj | 17 + ocpp-server/api/OcppServer/OcppServer.http | 6 + ocpp-server/api/OcppServer/Program.cs | 92 ++++ .../OcppServer/Properties/launchSettings.json | 41 ++ .../api/OcppServer/PubSub/OcppService.cs | 60 +++ .../OcppServer/appsettings.Development.json | 8 + ocpp-server/api/OcppServer/appsettings.json | 9 + ocpp-server/api/OcppServer/wwwroot/index.html | 233 ++++++++++ .../api/OcppServer/wwwroot/testws.html | 227 ++++++++++ ocpp-server/api/Test/Test.csproj | 27 ++ ocpp-server/api/Test/UnitTest1.cs | 10 + ocpp-server/api/api.sln | 28 ++ ocpp-server/infra/main.bicep | 118 ++++++ ocpp-server/infra/modules/appgw.bicep | 346 +++++++++++++++ ocpp-server/infra/modules/dns.bicep | 18 + ocpp-server/infra/modules/ipAddress.bicep | 26 ++ .../infra/modules/privateEndpoint.bicep | 71 ++++ ocpp-server/infra/modules/storage.bicep | 39 ++ .../infra/modules/virtualNetwork.bicep | 55 +++ ocpp-server/infra/modules/webPubSub.bicep | 51 +++ ocpp-server/infra/modules/webPubSubHub.bicep | 40 ++ ocpp-server/infra/modules/webapp.bicep | 122 ++++++ 29 files changed, 2369 insertions(+) create mode 100644 ocpp-server/.gitignore create mode 100644 ocpp-server/.vscode/launch.json create mode 100644 ocpp-server/.vscode/settings.json create mode 100644 ocpp-server/.vscode/tasks.json create mode 100644 ocpp-server/Makefile create mode 100644 ocpp-server/README.md create mode 100644 ocpp-server/api/OcppServer/Controller/WebSocketController.cs create mode 100644 ocpp-server/api/OcppServer/OcppServer.csproj create mode 100644 ocpp-server/api/OcppServer/OcppServer.http create mode 100644 ocpp-server/api/OcppServer/Program.cs create mode 100644 ocpp-server/api/OcppServer/Properties/launchSettings.json create mode 100644 ocpp-server/api/OcppServer/PubSub/OcppService.cs create mode 100644 ocpp-server/api/OcppServer/appsettings.Development.json create mode 100644 ocpp-server/api/OcppServer/appsettings.json create mode 100644 ocpp-server/api/OcppServer/wwwroot/index.html create mode 100644 ocpp-server/api/OcppServer/wwwroot/testws.html create mode 100644 ocpp-server/api/Test/Test.csproj create mode 100644 ocpp-server/api/Test/UnitTest1.cs create mode 100644 ocpp-server/api/api.sln create mode 100644 ocpp-server/infra/main.bicep create mode 100644 ocpp-server/infra/modules/appgw.bicep create mode 100644 ocpp-server/infra/modules/dns.bicep create mode 100644 ocpp-server/infra/modules/ipAddress.bicep create mode 100644 ocpp-server/infra/modules/privateEndpoint.bicep create mode 100644 ocpp-server/infra/modules/storage.bicep create mode 100644 ocpp-server/infra/modules/virtualNetwork.bicep create mode 100644 ocpp-server/infra/modules/webPubSub.bicep create mode 100644 ocpp-server/infra/modules/webPubSubHub.bicep create mode 100644 ocpp-server/infra/modules/webapp.bicep diff --git a/ocpp-server/.gitignore b/ocpp-server/.gitignore new file mode 100644 index 0000000..c9808bf --- /dev/null +++ b/ocpp-server/.gitignore @@ -0,0 +1,399 @@ +## 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/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# 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 +nunit-*.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/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.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 + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# 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 +# NuGet Symbol Packages +*.snupkg +# 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 +*.appxbundle +*.appxupload + +# 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 +*- [Bb]ackup.rdl +*- [Bb]ackup [0-9]*.rdl + +# 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 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# 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/ + +# CodeRush personal settings +.cr/personal + +# 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/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml + +*parameters.json diff --git a/ocpp-server/.vscode/launch.json b/ocpp-server/.vscode/launch.json new file mode 100644 index 0000000..8d44dc5 --- /dev/null +++ b/ocpp-server/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md. + "name": ".NET Core Launch (web)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/api/OcppServer/bin/Debug/net8.0/OcppServer.dll", + "args": [], + "cwd": "${workspaceFolder}/api/OcppServer", + "stopAtEntry": false, + // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/api/Views" + } + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} \ No newline at end of file diff --git a/ocpp-server/.vscode/settings.json b/ocpp-server/.vscode/settings.json new file mode 100644 index 0000000..df47b75 --- /dev/null +++ b/ocpp-server/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "appService.defaultWebAppToDeploy": "/subscriptions/6564135d-97a9-407b-b366-552b4c89ef7b/resourceGroups/jmapim/providers/Microsoft.Web/sites/wapp-webapp-mgprxziwmupcg", + "appService.deploySubpath": "/api/OcppServer/bin/Release/net8.0/publish" +} \ No newline at end of file diff --git a/ocpp-server/.vscode/tasks.json b/ocpp-server/.vscode/tasks.json new file mode 100644 index 0000000..afa6a6a --- /dev/null +++ b/ocpp-server/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/api/api.sln", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/api/OcppServer/OcppServer.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/api/api.sln" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/ocpp-server/Makefile b/ocpp-server/Makefile new file mode 100644 index 0000000..54bf2b3 --- /dev/null +++ b/ocpp-server/Makefile @@ -0,0 +1,64 @@ +THIS_FILE := $(lastword $(MAKEFILE_LIST)) +RG_NAME := OCPP +RG_LOCATION := switzerlandnorth +WEBAPP_NAME := $(shell az webapp list -g $(RG_NAME) --query "[0].name" -o tsv) +STORAGE_NAME := $(shell az storage account list -g $(RG_NAME) --query "[?starts_with(name,'webdeploy')].name" -o tsv) +EXPIRY := $(shell date -u -d "15 minutes" '+%Y-%m-%dT%H:%MZ') + +preparecli: + az upgrade + az extension add --name webpubsub + az bicep upgrade + npm install -g @azure/web-pubsub-tunnel-tool + @echo "Tools prepared, now you can start using the makefile" +build: $(wildcard api/**/*.cs) + @echo "Building" + dotnet build api/api.sln +test: + @echo "Testing" + dotnet test api/api.sln +clean: + @echo "Cleaning" + dotnet clean api/api.sln +restore: + @echo "Restoring packages" + dotnet restore api/api.sln +watch: + @echo "Starting OcppServer" + dotnet watch --project api/OcppServer/OcppServer.csproj run +start: + @echo "Starting OcppServer" + dotnet run --project api/OcppServer/OcppServer.csproj +start-tunnel: + WebPubSubConnectionString='$(shell az webpubsub list -g $(RG_NAME) --query "[0].name" -o tsv | az webpubsub key show -g $(RG_NAME) -n @- --query "primaryConnectionString" -o tsv)'; \ + SubId='$(shell az account show --query "id" -o tsv)'; \ + awps-tunnel run --hub OcppService -c "$$WebPubSubConnectionString" -s $$SubId -g $(RG_NAME) --upstream http://localhost:5110 +stop-tunnel: + ps axf | grep awps-tunnel | grep -v grep | awk '{print "kill -9 " $$1}' | sh +infra: $(wildcard infra/**/*.bicep) $(wildcard infra/**/*.parameters.json) + @echo "Deploying infra to Azure" + az group create -n $(RG_NAME) -l $(RG_LOCATION) + az deployment group create -g $(RG_NAME) --template-file infra/main.bicep --parameters infra/main.parameters.json + @echo "Setting up user secrets" + CONNECTION_STRING='$(shell az webpubsub list -g $(RG_NAME) --query "[0].name" -o tsv | az webpubsub key show -g $(RG_NAME) -n @- --query "primaryConnectionString" -o tsv)'; \ + dotnet user-secrets set 'WEBPUBSUB_SERVICE_CONNECTION_STRING' "$$CONNECTION_STRING" --project api/OcppServer/OcppServer.csproj +secrets: + CONNECTION_STRING='$(shell az webpubsub list -g $(RG_NAME) --query "[0].name" -o tsv | az webpubsub key show -g $(RG_NAME) -n @- --query "primaryConnectionString" -o tsv)'; \ + dotnet user-secrets set 'WEBPUBSUB_SERVICE_CONNECTION_STRING' "$$CONNECTION_STRING" --project api/OcppServer/OcppServer.csproj +publish: $(wildcard api/**/*.cs) $(wildcard api/**/wwwroot/*) + @echo "Creating publish files" + dotnet publish api/OcppServer/OcppServer.csproj -c Release + @echo "Zip files" + cd api/OcppServer/bin/Release/net8.0/publish && zip -r /tmp/ocppserver.zip . + @echo "Publish files created" + az storage blob upload --account-name $(STORAGE_NAME) -c default -f /tmp/ocppserver.zip -n ocppserver.zip --overwrite; + APP_URL='$(shell az storage blob generate-sas --full-uri --permissions r --expiry '$(EXPIRY)' --account-name $(STORAGE_NAME) -c default -n ocppserver.zip -o tsv)'; \ + az webapp deploy -g $(RG_NAME) -n $(WEBAPP_NAME) --src-url $$APP_URL --type zip; +deploy: + @echo "Deploying all to Azure" + @$(MAKE) -f $(THIS_FILE) infra + # wait a little bit for the infra to be ready, to avoid publish errors with the gateway. + @echo "Waiting for infra to be ready" + sleep 60 + @$(MAKE) -f $(THIS_FILE) publish +.PHONY: test clean watch start secrets diff --git a/ocpp-server/README.md b/ocpp-server/README.md new file mode 100644 index 0000000..70155f2 --- /dev/null +++ b/ocpp-server/README.md @@ -0,0 +1,60 @@ +# README + +This is a PoC to use the Azure Web PubSub service to act as a WebSocket proxy for an OCPP 1.6 server. It demonstrates the +usage of a secured environment by using an Application Gateway connected to a private endpoint of the Web PubSub service, and +a private endpoint for the Web App that hosts the OCPP server. + +## Requirements + +You need: + +* An **Azure KeyVault** with a wildcard certificate for the domain you will assign to the Application Gateway. The App Gateway uses it to publish the Web PubSub endpoint and the Web App. +* A **User Assigned Managed Identity** with read access to the KeyVault where the SSL certificate is stored. +* A **Public DNS Zone in Azure**, where the script will create or update the A records for the Web PubSub service. Your user needs modify permissions to these DNS records, and they will be created with a resource reference. +* A **main.parameters.json** file in the root of the project with the following content: + + ```json + { + "parameters": { + "keyVaultSecretId": { + "value": "" + }, + "keyVaultIdentityName": { + "value": "" + }, + "keyVaultIdentityRG": { + "value": "" + }, + "customDnsZoneName": { + "value": "" + }, + "pubsubARecordName": { + "value": "" + }, + "dnsZoneRG": { + "value": "" + } + } + } + ``` + +## Project Structure + +The project is divided into two main parts: + +* Infra: Contains the Bicep templates to deploy the Azure resources. +* Api: Contains the OCPP server implementation. + +All these parts are managed by a Makefile that orchestrates the deployment and the compilation of the OCPP server. + +## How to deploy this project into Azure + +You can run the `deploy` *make* recipe with the following optional parameters: + +```bash +make deploy [RG_NAME=] [LOCATION=] [APP_NAME=] +``` + +This makefile recipe deploys the infra into your Azure subscription, compiles the OcppServer source code and publishes it into the created App Service via a private Storage Account. It doesn't use the direct zip upload method because the Web App is protected with a Private Endpoint, and the *az cli* cannot upload the zip file directly to the service. + +The App Service and Web Pub Sub endpoints are protected with Private Endpoints, and published through an Application Gateway. diff --git a/ocpp-server/api/OcppServer/Controller/WebSocketController.cs b/ocpp-server/api/OcppServer/Controller/WebSocketController.cs new file mode 100644 index 0000000..4e5d16b --- /dev/null +++ b/ocpp-server/api/OcppServer/Controller/WebSocketController.cs @@ -0,0 +1,122 @@ +using System.Net.WebSockets; +using System.Text; +using Microsoft.AspNetCore.Mvc; + +namespace OcppServer.Api +{ + public class WebSocketController(ILogger logger) : ControllerBase + { + static readonly CancellationTokenSource _cts = new(); + + /// + /// Gracefully stops all websockets + /// + /// Task + public static async Task StopAsync() + { + foreach (var socket in _sockets.Values) + { + if (socket.State == WebSocketState.Open) + { + try + { + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Server shutting down", CancellationToken.None); + } + catch (Exception) + { + // ignore, we are closing the server + } + } + } + _cts.Cancel(); + } + + private readonly ILogger _logger = logger; + private static readonly Dictionary _sockets = []; + + [ProducesResponseType(StatusCodes.Status101SwitchingProtocols)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Route("/ws")] + [ApiExplorerSettings(IgnoreApi = true)] // Hide from Swagger because CONNECT is not supported + public async Task HandleWebSocketRequest(string station) + { + _logger.LogInformation("Websocket request received for {station}.", station.Replace(Environment.NewLine, "")); + if (HttpContext.WebSockets.IsWebSocketRequest) + { + foreach (var protocol in HttpContext.WebSockets.WebSocketRequestedProtocols) + { + _logger.LogInformation("Requested protocol: {protocol}", protocol); + } + WebSocket? socket; + + if (HttpContext.WebSockets.WebSocketRequestedProtocols.Count == 0) + { + socket = await HttpContext.WebSockets.AcceptWebSocketAsync(); + } + else + { + if (HttpContext.WebSockets.WebSocketRequestedProtocols.Contains("ocpp1.6")) + { + socket = await HttpContext.WebSockets.AcceptWebSocketAsync("ocpp1.6"); + } + else + { + _logger.LogError("Requested protocol is not supported. Ocpp1.6 expected."); + HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + + } + } + var socketId = Guid.NewGuid().ToString(); + _sockets.Add(socketId, socket); + try + { + await Echo(socket, station); + } + finally + { + _sockets.Remove(socketId); + } + } + else + { + //HttpContext.Response.Headers.Append("Expected", "Websocket connection"); + HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + } + } + + private async Task Echo(WebSocket webSocket, string? station) + { + var buffer = new byte[1024 * 4]; + var receiveResult = await webSocket.ReceiveAsync( + new ArraySegment(buffer), _cts.Token); + + while (!receiveResult.CloseStatus.HasValue) + { + string message = Encoding.UTF8.GetString(buffer, 0, receiveResult.Count); + + message = $"Station: {station}, Message: {message}"; + + var sendBuffer = new ReadOnlyMemory(Encoding.UTF8.GetBytes(message)); + await Parallel.ForEachAsync(_sockets.Values, _cts.Token, async (socket, token) => + { + await socket.SendAsync( + sendBuffer, + receiveResult.MessageType, + receiveResult.EndOfMessage, + token); + }); + + receiveResult = await webSocket.ReceiveAsync( + new ArraySegment(buffer), _cts.Token); + } + if (webSocket.State == WebSocketState.Open) + { + await webSocket.CloseAsync( + receiveResult.CloseStatus.Value, + receiveResult.CloseStatusDescription, + _cts.Token); + } + } + } +} \ No newline at end of file diff --git a/ocpp-server/api/OcppServer/OcppServer.csproj b/ocpp-server/api/OcppServer/OcppServer.csproj new file mode 100644 index 0000000..90c2a06 --- /dev/null +++ b/ocpp-server/api/OcppServer/OcppServer.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + 51f2d88f-2bc7-4452-a08e-8237e8ffdeb6 + + + + + + + + + + diff --git a/ocpp-server/api/OcppServer/OcppServer.http b/ocpp-server/api/OcppServer/OcppServer.http new file mode 100644 index 0000000..db4d599 --- /dev/null +++ b/ocpp-server/api/OcppServer/OcppServer.http @@ -0,0 +1,6 @@ +@OcppServer_HostAddress = http://localhost:5110 + +GET {{OcppServer_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/ocpp-server/api/OcppServer/Program.cs b/ocpp-server/api/OcppServer/Program.cs new file mode 100644 index 0000000..ccc582c --- /dev/null +++ b/ocpp-server/api/OcppServer/Program.cs @@ -0,0 +1,92 @@ +using Microsoft.Azure.WebPubSub.AspNetCore; +using Microsoft.Extensions.Azure; +using OcppServer.Api; +using OcppServer.PubSub; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddControllers(); + +var pubSubConnectionString = builder.Configuration["WEBPUBSUB_SERVICE_CONNECTION_STRING"] ?? Environment.GetEnvironmentVariable("WEBPUBSUB_SERVICE_CONNECTION_STRING"); + +builder.Services.AddWebPubSub(o => o.ServiceEndpoint = new WebPubSubServiceEndpoint( + pubSubConnectionString +)); + +builder.Services.AddWebPubSubServiceClient(); + + +var app = builder.Build(); + + +app.MapWebPubSubHub("/eventhandler/{*path}").AddEndpointFilter(async (context, next) => +{ + var logger = app.Services.GetRequiredService>(); + + try + { + logger.LogInformation("EndpointFilter called with context path: {context}", + context.HttpContext.Request.Path.ToString().Replace(Environment.NewLine, "")); + if (!context.HttpContext.Response.Headers["WebHook-Allowed-Origin"].Contains("*")) + _ = context.HttpContext.Response.Headers["WebHook-Allowed-Origin"].Append("*"); + } + catch (Exception ex) + { + logger.LogError("Error in EndpointFilter: {error}", ex.Message); + } + return await next(context); +}); + + +app.Services.GetRequiredService().ApplicationStopping.Register( + async () => + { + app.Services.GetRequiredService>().LogInformation("Stopping WebSockets"); + await WebSocketController.StopAsync(); + } +); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); + app.UseHttpsRedirection(); +} + +app.UseWebSockets(); +app.UseDefaultFiles(); +app.UseStaticFiles(); +app.MapControllers(); + + +var summaries = new[] +{ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" +}; + +app.MapGet("/weatherforecast", () => +{ + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; +}) +.WithName("GetWeatherForecast") +.WithOpenApi(); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/ocpp-server/api/OcppServer/Properties/launchSettings.json b/ocpp-server/api/OcppServer/Properties/launchSettings.json new file mode 100644 index 0000000..930a3f9 --- /dev/null +++ b/ocpp-server/api/OcppServer/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:36443", + "sslPort": 44325 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5110", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7011;http://localhost:5110", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ocpp-server/api/OcppServer/PubSub/OcppService.cs b/ocpp-server/api/OcppServer/PubSub/OcppService.cs new file mode 100644 index 0000000..48c760c --- /dev/null +++ b/ocpp-server/api/OcppServer/PubSub/OcppService.cs @@ -0,0 +1,60 @@ +using Azure.Core; +using Microsoft.Azure.WebPubSub.AspNetCore; +using Microsoft.Azure.WebPubSub.Common; + +namespace OcppServer.PubSub; + +public sealed class OcppService(WebPubSubServiceClient serviceClient, ILogger logger) : WebPubSubHub +{ + private readonly WebPubSubServiceClient _serviceClient = serviceClient; + private readonly ILogger _logger = logger; + + public override ValueTask OnConnectAsync(ConnectEventRequest request, CancellationToken cancellationToken) + { + _logger.LogInformation("[SYSTEM] new user connecting."); + if (request.Query.TryGetValue("id", out var id)) + { + _logger.LogInformation("[SYSTEM] new user found {userId} connecting.", id); + if (request.Subprotocols.Count > 0) + { + _logger.LogInformation("[SYSTEM] connecting with subprotocol {subprotocol}.", request.Subprotocols[0]); + if (request.Subprotocols[0] == "ocpp1.6") + return new ValueTask(request.CreateResponse(userId: id.FirstOrDefault(), null, request.Subprotocols[0], null)); + else + { + _logger.LogError("[SYSTEM] subprotocol not supported."); + throw new HttpProtocolException(426, "Subprotocol not supported", null); + } + } + return new ValueTask(request.CreateResponse(userId: id.FirstOrDefault(), null, null, null)); + } + + // The SDK catches this exception and returns 401 to the caller + throw new UnauthorizedAccessException("Request missing id"); + } + public override Task OnConnectedAsync(ConnectedEventRequest request) + { + _logger.LogInformation("[SYSTEM] {userId} joined.", request.ConnectionContext.UserId); + return Task.CompletedTask; + } + + public override async ValueTask OnMessageReceivedAsync(UserEventRequest request, CancellationToken cancellationToken) + { + _logger.LogInformation("[{from}] {message}", request.ConnectionContext.UserId, request.Data.ToString()); + await _serviceClient.SendToAllAsync(RequestContent.Create( + new + { + from = request.ConnectionContext.UserId, + message = request.Data.ToString() + }), + ContentType.ApplicationJson); + + return new UserEventResponse(); + } + + public override Task OnDisconnectedAsync(DisconnectedEventRequest request) + { + _logger.LogInformation("[SYSTEM] {userId} left.", request.ConnectionContext.UserId); + return Task.CompletedTask; + } +} diff --git a/ocpp-server/api/OcppServer/appsettings.Development.json b/ocpp-server/api/OcppServer/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/ocpp-server/api/OcppServer/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ocpp-server/api/OcppServer/appsettings.json b/ocpp-server/api/OcppServer/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/ocpp-server/api/OcppServer/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/ocpp-server/api/OcppServer/wwwroot/index.html b/ocpp-server/api/OcppServer/wwwroot/index.html new file mode 100644 index 0000000..0093a5b --- /dev/null +++ b/ocpp-server/api/OcppServer/wwwroot/index.html @@ -0,0 +1,233 @@ + + + + + + + + + + +

WebSocket Sample Application

+

Ready to connect...

+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ +
+ +
+
+
+ +
+

+
+ +
+ +
+ + +
+ +

Communication Log

+ + + + + + + + + + +
FromToData
+ + + + + \ No newline at end of file diff --git a/ocpp-server/api/OcppServer/wwwroot/testws.html b/ocpp-server/api/OcppServer/wwwroot/testws.html new file mode 100644 index 0000000..066d2c2 --- /dev/null +++ b/ocpp-server/api/OcppServer/wwwroot/testws.html @@ -0,0 +1,227 @@ + + + + + + + + + + +

WebSocket Sample Application

+

Ready to connect...

+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+
+ +
+ +
+
+
+ +
+

+
+ +
+ +
+ + +
+ +

Communication Log

+ + + + + + + + + + +
FromToData
+ + + + + \ No newline at end of file diff --git a/ocpp-server/api/Test/Test.csproj b/ocpp-server/api/Test/Test.csproj new file mode 100644 index 0000000..b3cf0a5 --- /dev/null +++ b/ocpp-server/api/Test/Test.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/ocpp-server/api/Test/UnitTest1.cs b/ocpp-server/api/Test/UnitTest1.cs new file mode 100644 index 0000000..aa96002 --- /dev/null +++ b/ocpp-server/api/Test/UnitTest1.cs @@ -0,0 +1,10 @@ +namespace Test; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + + } +} \ No newline at end of file diff --git a/ocpp-server/api/api.sln b/ocpp-server/api/api.sln new file mode 100644 index 0000000..d5e16be --- /dev/null +++ b/ocpp-server/api/api.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OcppServer", "OcppServer\OcppServer.csproj", "{1C701F68-89A8-49B8-8D27-2813EE861A1E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Test", "Test\Test.csproj", "{554F44F4-C8B9-4978-903F-97F03EFB8318}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1C701F68-89A8-49B8-8D27-2813EE861A1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1C701F68-89A8-49B8-8D27-2813EE861A1E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1C701F68-89A8-49B8-8D27-2813EE861A1E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1C701F68-89A8-49B8-8D27-2813EE861A1E}.Release|Any CPU.Build.0 = Release|Any CPU + {554F44F4-C8B9-4978-903F-97F03EFB8318}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {554F44F4-C8B9-4978-903F-97F03EFB8318}.Debug|Any CPU.Build.0 = Debug|Any CPU + {554F44F4-C8B9-4978-903F-97F03EFB8318}.Release|Any CPU.ActiveCfg = Release|Any CPU + {554F44F4-C8B9-4978-903F-97F03EFB8318}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/ocpp-server/infra/main.bicep b/ocpp-server/infra/main.bicep new file mode 100644 index 0000000..e65da0b --- /dev/null +++ b/ocpp-server/infra/main.bicep @@ -0,0 +1,118 @@ +@secure() +@description('Secret ID for the TLS Certificate stored in Key Vault') +param keyVaultSecretId string +@description('User Assigned Managed Identity name with Get permissions fo the Key Vault Certificate') +param keyVaultIdentityName string +@description('Resource Group name where the User Assigned Managed Identity was created') +param keyVaultIdentityRG string = resourceGroup().name +@description('Custom DNS Zone Name used for publishing the Web PubSub service endpoint securely') +param customDnsZoneName string = 'jmservera.online' +@description('A Record Name for the Web PubSub service endpoint, used as prefix of the DnsZoneName') +param pubsubARecordName string = 'wss' +@description('A Record Name for the Web service endpoint, used as prefix of the DnsZoneName') +param webARecordName string = 'www' +@description('Resource Group name where the DNS Zone was created') +param dnsZoneRG string = 'domainnames' +@description('The name for your new Web PubSub Hub. It should be the same name than the class implementing it in the asp.net core project') +param pubSubHubName string = 'OcppService' + +var pubsubHostName = '${pubsubARecordName}.${customDnsZoneName}' +var webHostName = '${webARecordName}.${customDnsZoneName}' + +// Creates a VNet with 3 subnets: default, gateway and private endpoints +module virtualNetwork './modules/virtualNetwork.bicep' = { + name: 'vNet' + params: { + virtualNetworkName: 'vnet-${uniqueString(resourceGroup().id)}' + } +} + +// creates a private web pub sub service +module webPubSub './modules/webPubSub.bicep' = { + name: 'webPubSubService' + params: { + serviceName: 'webpubsub-${uniqueString(resourceGroup().id)}' + } +} + +module hub './modules/webPubSubHub.bicep' = { + name: 'webPubSubHub' + params: { + serviceName: webPubSub.outputs.serviceName + hubName: 'OcppService' + webAppName: webApp.outputs.webSiteName + } +} + +// creates a private endpoint for the web pub sub service +// to be used by the app gateway +module webPubSubPrivateEndpoint './modules/privateEndpoint.bicep' = { + name: 'webPubSubPrivateEndpoint' + params: { + privateLinkResource: webPubSub.outputs.serviceId + subnetId: virtualNetwork.outputs.privateSubnetId + vnetId: virtualNetwork.outputs.vnetId + endpointName: 'wssprivate${uniqueString(resourceGroup().id)}' + } +} + +module webApp './modules/webapp.bicep' = { + name: 'webAppService' + params: { + webAppName: 'webapp-${uniqueString(resourceGroup().id)}' + sku: 'B1' + linuxFxVersion: 'DOTNETCORE|8.0' + pubSubName: webPubSub.outputs.serviceName + vnetName: virtualNetwork.outputs.vnetName + subnetName: virtualNetwork.outputs.defaultSubnetName + } +} + +module webPrivateEndpoint './modules/privateEndpoint.bicep' = { + name: 'webPrivateEndpoint' + params: { + privateLinkResource: webApp.outputs.appServiceId + subnetId: virtualNetwork.outputs.privateSubnetId + vnetId: virtualNetwork.outputs.vnetId + targetSubResource: 'sites' + endpointName: 'wwwprivate${uniqueString(resourceGroup().id)}' + } +} + +module appGw './modules/appgw.bicep' = { + name: 'appGwService' + params: { + appgwName: 'appgw-${uniqueString(resourceGroup().id)}' + location: resourceGroup().location + pubSubServiceName: webPubSub.outputs.serviceName + gwSubnetId: virtualNetwork.outputs.gwSubnetId + keyVaultSecretId: keyVaultSecretId + webHostName: webHostName + pubsubHostName: pubsubHostName + keyVaultIdentityName: keyVaultIdentityName + keyVaultIdentityRG: keyVaultIdentityRG + webServiceName: webApp.outputs.webSiteName + pubsubHubName: pubSubHubName + } +} + +// update A record with appGW public IP +module wssdns './modules/dns.bicep' = { + name: 'dnsServicePubSub' + scope: resourceGroup(dnsZoneRG) + params: { + dnszoneName: customDnsZoneName + aRecordName: pubsubARecordName + ipTargetResourceId: appGw.outputs.publicIPAddressId + } +} + +module wwwdns './modules/dns.bicep' = { + name: 'dnsServiceWeb' + scope: resourceGroup(dnsZoneRG) + params: { + dnszoneName: customDnsZoneName + aRecordName: webARecordName + ipTargetResourceId: appGw.outputs.publicIPAddressId + } +} diff --git a/ocpp-server/infra/modules/appgw.bicep b/ocpp-server/infra/modules/appgw.bicep new file mode 100644 index 0000000..a7486dd --- /dev/null +++ b/ocpp-server/infra/modules/appgw.bicep @@ -0,0 +1,346 @@ +@description('The name for your new Api Gateway instance.') +@maxLength(50) +@minLength(3) +param appgwName string = 'appgw-${uniqueString(resourceGroup().id)}' +param location string = resourceGroup().location +param pubSubServiceName string +param gwSubnetId string +param zones array = [1, 2, 3] + +param skuSize string = 'Standard_v2' +param skuTier string = 'Standard_v2' +param skuCapacity int = 1 +@secure() +param keyVaultSecretId string +param pubsubHostName string +param webHostName string +param keyVaultIdentityName string +param keyVaultIdentityRG string +param webServiceName string +param pubsubHubName string + +var ocppRuleSetName = 'ocppRuleSet' +var pubsubBackendPoolName = 'pubsubBackend' +var pubsubBackendSettingsName = 'pubsubBackendSettings' +var pubsubProbeName = 'pubsubProbe' +var pubsubListenerName = 'pubsubListener' +var webBackendPoolName = 'webBackend' +var webListenerName = 'webListener' +var webBackendSettingsName = 'webBackendSettings' + +resource webPubSub 'Microsoft.SignalRService/webPubSub@2021-10-01' existing = { + name: pubSubServiceName +} + +resource webApp 'Microsoft.Web/sites@2021-02-01' existing = { + name: webServiceName +} + +resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' existing = { + name: keyVaultIdentityName + scope: resourceGroup(keyVaultIdentityRG) +} + +module publicIpAddress './ipAddress.bicep' = { + name: 'publicIpAddress' + params: { + publicIpAddressName: 'ip-${uniqueString(resourceGroup().id)}' + domainNameLabel: 'd${uniqueString(resourceGroup().id)}' // ensure domain name starts with a letter + } +} + +resource appGw 'Microsoft.Network/applicationGateways@2023-02-01' = { + name: appgwName + location: location + zones: zones + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${identity.id}': {} + } + } + properties: { + sku: { + name: skuSize + tier: skuTier + capacity: skuCapacity + } + gatewayIPConfigurations: [ + { + name: 'appGatewayIpConfig' + properties: { + subnet: { + id: gwSubnetId + } + } + } + ] + frontendIPConfigurations: [ + { + name: 'appGwPublicFrontendIPv4' + properties: { + publicIPAddress: { + id: publicIpAddress.outputs.publicIpAddressId + } + } + } + ] + frontendPorts: [ + { + name: 'port_80' + properties: { + port: 80 + } + } + { + name: 'port_443' + properties: { + port: 443 + } + } + ] + backendAddressPools: [ + { + name: pubsubBackendPoolName + properties: { + backendAddresses: [ + { + fqdn: webPubSub.properties.hostName + } + ] + } + } + { + name: webBackendPoolName + properties: { + backendAddresses: [ + { + fqdn: webApp.properties.defaultHostName + } + ] + } + } + ] + probes: [ + { + name: pubsubProbeName + properties: { + protocol: 'Https' + host: webPubSub.properties.hostName + path: '/client' + interval: 30 + timeout: 30 + unhealthyThreshold: 3 + pickHostNameFromBackendHttpSettings: false + minServers: 0 + match: { + body: 'Invalid value of \'hub\'.' + statusCodes: [ + '400' + ] + } + } + } + ] + backendHttpSettingsCollection: [ + { + name: pubsubBackendSettingsName + properties: { + port: 443 + protocol: 'Https' + cookieBasedAffinity: 'Disabled' + pickHostNameFromBackendAddress: true + requestTimeout: 80 //default KeepAliveInterval in PubSub is 40 seconds, doubling to 80 seconds to ensure it doesn't timeout + probe: { + id: resourceId('Microsoft.Network/applicationGateways/probes', appgwName, pubsubProbeName) + } + } + } + { + name: webBackendSettingsName + properties: { + port: 80 + protocol: 'Http' + cookieBasedAffinity: 'Disabled' + pickHostNameFromBackendAddress: true + requestTimeout: 180 //default KeepAliveInterval for websockets is 2 minutes, setting 3 minutes for app gateway + } + } + ] + sslCertificates: [ + { + name: 'pubsubtls' + properties: { + keyVaultSecretId: keyVaultSecretId + } + } + ] + httpListeners: [ + { + name: pubsubListenerName + properties: { + frontendIPConfiguration: { + id: resourceId( + 'Microsoft.Network/applicationGateways/frontendIPConfigurations', + appgwName, + 'appGwPublicFrontendIPv4' + ) + } + frontendPort: { + id: resourceId('Microsoft.Network/applicationGateways/frontendPorts', appgwName, 'port_443') + } + protocol: 'Https' + sslCertificate: { + id: resourceId('Microsoft.Network/applicationGateways/sslCertificates', appgwName, 'pubsubtls') + } + hostName: pubsubHostName + customErrorConfigurations: [] + requireServerNameIndication: true + } + } + { + name: webListenerName + properties: { + frontendIPConfiguration: { + id: resourceId( + 'Microsoft.Network/applicationGateways/frontendIPConfigurations', + appgwName, + 'appGwPublicFrontendIPv4' + ) + } + frontendPort: { + id: resourceId('Microsoft.Network/applicationGateways/frontendPorts', appgwName, 'port_443') + } + protocol: 'Https' + sslCertificate: { + id: resourceId('Microsoft.Network/applicationGateways/sslCertificates', appgwName, 'pubsubtls') + } + hostName: webHostName + customErrorConfigurations: [] + requireServerNameIndication: true + } + } + ] + rewriteRuleSets: [ + { + name: ocppRuleSetName + properties: { + rewriteRules: [ + { + ruleSequence: 100 + conditions: [ + { + variable: 'var_uri_path' + pattern: '\\/ocpp\\/(.+)' + ignoreCase: true + negate: false + } + { + variable: 'var_request_query' + pattern: '(?:^|\\&)OCPP_TOKEN=([^&]+)' + ignoreCase: true + negate: false + } + ] + name: 'ocpp to pubsub with token' + actionSet: { + requestHeaderConfigurations: [ + { + headerName: 'device' + headerValue: '{var_uri_path_1}' + } + ] + responseHeaderConfigurations: [] + urlConfiguration: { + modifiedPath: '/client/hubs/${pubsubHubName}' + //TODO: This is a placeholder for the access token, retrieve it from the query headers + modifiedQueryString: 'access_token={http_req_OCPP_TOKEN_1}&id={var_uri_path_1}' + reroute: false + } + } + } + { + ruleSequence: 200 + conditions: [ + { + variable: 'var_uri_path' + pattern: '\\/ocpp\\/(.+)' + ignoreCase: true + negate: false + } + ] + name: 'ocpp to pubsub unauthenticated' + actionSet: { + requestHeaderConfigurations: [ + { + headerName: 'device' + headerValue: '{var_uri_path_1}' + } + ] + responseHeaderConfigurations: [] + urlConfiguration: { + modifiedPath: '/client/hubs/${pubsubHubName}' + //TODO: This is a placeholder for the access token, retrieve it from the query headers + modifiedQueryString: 'id={var_uri_path_1}' + reroute: false + } + } + } + ] + } + } + ] + requestRoutingRules: [ + { + name: 'pubsubRequestRule' + properties: { + ruleType: 'Basic' + priority: 200 + httpListener: { + id: resourceId('Microsoft.Network/applicationGateways/httpListeners', appgwName, pubsubListenerName) + } + backendAddressPool: { + id: resourceId( + 'Microsoft.Network/applicationGateways/backendAddressPools', + appgwName, + pubsubBackendPoolName + ) + } + backendHttpSettings: { + id: resourceId( + 'Microsoft.Network/applicationGateways/backendHttpSettingsCollection', + appgwName, + pubsubBackendSettingsName + ) + } + rewriteRuleSet: { + id: resourceId('Microsoft.Network/applicationGateways/rewriteRuleSets', appgwName, ocppRuleSetName) + } + } + } + { + name: 'webRequestRule' + properties: { + ruleType: 'Basic' + priority: 100 + httpListener: { + id: resourceId('Microsoft.Network/applicationGateways/httpListeners', appgwName, webListenerName) + } + backendAddressPool: { + id: resourceId('Microsoft.Network/applicationGateways/backendAddressPools', appgwName, webBackendPoolName) + } + backendHttpSettings: { + id: resourceId( + 'Microsoft.Network/applicationGateways/backendHttpSettingsCollection', + appgwName, + webBackendSettingsName + ) + } + } + } + ] + } +} + +output publicIPAddress string = publicIpAddress.outputs.publicIpAddress +output publicIPAddressId string = publicIpAddress.outputs.publicIpAddressId diff --git a/ocpp-server/infra/modules/dns.bicep b/ocpp-server/infra/modules/dns.bicep new file mode 100644 index 0000000..7fe44a8 --- /dev/null +++ b/ocpp-server/infra/modules/dns.bicep @@ -0,0 +1,18 @@ +param dnszoneName string +param aRecordName string = 'wss' +param ipTargetResourceId string + +resource dnsZone 'Microsoft.Network/dnszones@2023-07-01-preview' existing = { + name: dnszoneName +} + +resource dnsZoneARecord 'Microsoft.Network/dnszones/A@2023-07-01-preview' = { + parent: dnsZone + name: aRecordName + properties: { + TTL: 300 + targetResource: { + id: ipTargetResourceId + } + } +} diff --git a/ocpp-server/infra/modules/ipAddress.bicep b/ocpp-server/infra/modules/ipAddress.bicep new file mode 100644 index 0000000..ae8f993 --- /dev/null +++ b/ocpp-server/infra/modules/ipAddress.bicep @@ -0,0 +1,26 @@ +param publicIpAddressName string = 'ip-${uniqueString(resourceGroup().id)}' +param location string = resourceGroup().location +param sku string = 'Standard' +param publicIpZones array = [1, 2, 3] +param ipAddressVersion string = 'IPv4' +param allocationMethod string = 'Static' +param domainNameLabel string + +resource publicIpAddress 'Microsoft.Network/publicIPAddresses@2020-08-01' = { + name: publicIpAddressName + location: location + sku: { + name: sku + } + zones: publicIpZones + properties: { + publicIPAddressVersion: ipAddressVersion + publicIPAllocationMethod: allocationMethod + dnsSettings: { + domainNameLabel: domainNameLabel + } + } +} + +output publicIpAddressId string = publicIpAddress.id +output publicIpAddress string = publicIpAddress.properties.ipAddress diff --git a/ocpp-server/infra/modules/privateEndpoint.bicep b/ocpp-server/infra/modules/privateEndpoint.bicep new file mode 100644 index 0000000..164f11b --- /dev/null +++ b/ocpp-server/infra/modules/privateEndpoint.bicep @@ -0,0 +1,71 @@ +@description('The region in which to create the new instance, defaults to the same location as the resource group.') +param location string = resourceGroup().location +param endpointName string = 'privateep${uniqueString(resourceGroup().id)}' +param vnetId string +param subnetId string +@description('The resource id of the resource to link with this private link.') +param privateLinkResource string +@allowed([ + 'webpubsub' + 'sites' +]) +param targetSubResource string = 'webpubsub' + +var dnsByTarget = { + webpubsub: 'privatelink.webpubsub.azure.com' + sites: 'privatelink.azurewebsites.net' +} + +resource privateEndpoint 'Microsoft.Network/privateEndpoints@2021-05-01' = { + location: location + name: endpointName + properties: { + subnet: { + id: subnetId + } + customNetworkInterfaceName: '${endpointName}-nic' + privateLinkServiceConnections: [ + { + name: endpointName + properties: { + privateLinkServiceId: privateLinkResource + groupIds: [targetSubResource] + } + } + ] + } +} + +resource privateDnsZone 'Microsoft.Network/privateDnsZones@2018-09-01' = { + // it is important to set the right location + // https://learn.microsoft.com/en-us/azure/private-link/private-endpoint-dns + name: dnsByTarget[targetSubResource] + location: 'global' +} + +resource virtualNetworkLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = { + name: '${endpointName}-link' + location: 'global' + parent: privateDnsZone + properties: { + registrationEnabled: false + virtualNetwork: { + id: vnetId + } + } +} + +resource privateDnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2023-11-01' = { + name: '${endpointName}-group' + parent: privateEndpoint + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink-${endpointName}' + properties: { + privateDnsZoneId: privateDnsZone.id + } + } + ] + } +} diff --git a/ocpp-server/infra/modules/storage.bicep b/ocpp-server/infra/modules/storage.bicep new file mode 100644 index 0000000..fa8e3b5 --- /dev/null +++ b/ocpp-server/infra/modules/storage.bicep @@ -0,0 +1,39 @@ +param name string = 'storage${uniqueString(resourceGroup().id)}' +param location string = resourceGroup().location +param sku string = 'Standard_LRS' +param kind string = 'StorageV2' +param accessTier string = 'Hot' + +var storageAccountName = replace(toLower(name), '-', '') +var storageAccountName24 = substring(storageAccountName, 0, min(24, length(storageAccountName))) + +resource storageAccount 'Microsoft.Storage/storageAccounts@2021-04-01' = { + name: storageAccountName24 + location: location + sku: { + name: sku + } + kind: kind + properties: { + accessTier: accessTier + } +} + +// add a blob services +resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2021-04-01' = { + name: 'default' + parent: storageAccount +} + +// add a blob container +resource blobContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2021-04-01' = { + name: 'default' + parent: blobService + properties: { + publicAccess: 'None' + } +} + +output storageAccountName string = storageAccount.name +output blobContaineId string = blobContainer.id + diff --git a/ocpp-server/infra/modules/virtualNetwork.bicep b/ocpp-server/infra/modules/virtualNetwork.bicep new file mode 100644 index 0000000..233a5f5 --- /dev/null +++ b/ocpp-server/infra/modules/virtualNetwork.bicep @@ -0,0 +1,55 @@ +param virtualNetworkName string = 'vnet-${uniqueString(resourceGroup().id)}' +param location string = resourceGroup().location +param virtualNetworkPrefix string = '10.1.0.0/16' +param subnetName string = 'default' +param subnetPrefix string = '10.1.0.0/24' +param privateSubnetName string = 'private-endpoints' +param privateSubnetPrefix string = '10.1.1.0/24' +param gatewaySubnetName string = 'gateway' +param gatewaySubnetPrefix string = '10.1.2.0/24' + +resource virtualNetwork 'Microsoft.Network/virtualNetworks@2019-09-01' = { + name: virtualNetworkName + location: location + properties: { + addressSpace: { + addressPrefixes: [virtualNetworkPrefix] + } + subnets: [ + { + name: subnetName + properties: { + addressPrefix: subnetPrefix + delegations: [ + { + name: 'Microsoft.Web/serverFarms' + properties: { + serviceName: 'Microsoft.Web/serverFarms' + } + } + ] + } + } + { + name: privateSubnetName + properties: { + addressPrefix: privateSubnetPrefix + } + } + { + name: gatewaySubnetName + properties: { + addressPrefix: gatewaySubnetPrefix + } + } + ] + } +} + +output vnetId string = virtualNetwork.id +output vnetName string = virtualNetwork.name +output subnets array = virtualNetwork.properties.subnets +output gwSubnetId string = virtualNetwork.properties.subnets[2].id +output privateSubnetId string = virtualNetwork.properties.subnets[1].id +output defaultSubnetId string = virtualNetwork.properties.subnets[0].id +output defaultSubnetName string = virtualNetwork.properties.subnets[0].name diff --git a/ocpp-server/infra/modules/webPubSub.bicep b/ocpp-server/infra/modules/webPubSub.bicep new file mode 100644 index 0000000..85780e0 --- /dev/null +++ b/ocpp-server/infra/modules/webPubSub.bicep @@ -0,0 +1,51 @@ +// an azure webpubsub service +@description('The name for your new Web PubSub instance.') +@maxLength(63) +@minLength(3) +param serviceName string = 'webpubsub-${uniqueString(resourceGroup().id)}' + +@description('The region in which to create the new instance, defaults to the same location as the resource group.') +param location string = resourceGroup().location + +@description('Unit count') +@allowed([ + 1 + 2 + 5 + 10 + 20 + 50 + 100 +]) +param unitCount int = 1 + +@description('SKU name') +@allowed([ + 'Standard_S1' + 'Free_F1' +]) +param sku string = 'Standard_S1' + +@description('Pricing tier') +@allowed([ + 'Free' + 'Standard' +]) +param pricingTier string = 'Standard' + +resource webPubSub 'Microsoft.SignalRService/webPubSub@2021-10-01' = { + name: serviceName + location: location + sku: { + capacity: unitCount + name: sku + tier: pricingTier + } + properties: { + publicNetworkAccess: 'Disabled' + } +} + +output serviceId string = webPubSub.id +output serviceName string = webPubSub.name +output serviceEndpoint string = webPubSub.properties.hostName diff --git a/ocpp-server/infra/modules/webPubSubHub.bicep b/ocpp-server/infra/modules/webPubSubHub.bicep new file mode 100644 index 0000000..0e755bd --- /dev/null +++ b/ocpp-server/infra/modules/webPubSubHub.bicep @@ -0,0 +1,40 @@ +param serviceName string +param hubName string +param webAppName string + +resource webApp 'Microsoft.Web/sites@2023-12-01' existing = { + name: webAppName +} + +resource webPubSub 'Microsoft.SignalRService/webPubSub@2021-10-01' existing = { + name: serviceName +} + +resource hub 'Microsoft.SignalRService/WebPubSub/hubs@2024-01-01-preview' = { + parent: webPubSub + name: hubName + properties: { + eventHandlers: [ + { + urlTemplate: 'https://${webApp.properties.defaultHostName}/eventhandler/{event}' + userEventPattern: '*' + systemEvents: [ + 'connected' + 'connect' + 'disconnected' + ] + } + { + urlTemplate: 'tunnel:///eventhandler' + userEventPattern: '*' + systemEvents: [ + 'connect' + 'connected' + 'disconnected' + ] + } + ] + eventListeners: [] + anonymousConnectPolicy: 'allow' + } +} diff --git a/ocpp-server/infra/modules/webapp.bicep b/ocpp-server/infra/modules/webapp.bicep new file mode 100644 index 0000000..ac24406 --- /dev/null +++ b/ocpp-server/infra/modules/webapp.bicep @@ -0,0 +1,122 @@ +param webAppName string = uniqueString(resourceGroup().id) // Generate unique String for web app name +param sku string = 'B1' // The SKU of App Service Plan +param linuxFxVersion string = 'DOTNETCORE|8.0' // The runtime stack of web app +param location string = resourceGroup().location // Location for all resources +param pubSubName string +param subnetName string +param vnetName string + +var appServicePlanName = toLower('AppServicePlan-${webAppName}') +var webSiteName = toLower('wapp-${webAppName}') +var appInsightsName = 'appInsights-${uniqueString(resourceGroup().id)}' + +resource appInsights 'Microsoft.Insights/components@2020-02-02-preview' = { + name: appInsightsName + location: resourceGroup().location + kind: 'web' + properties: { + Application_Type: 'web' + } +} + +resource pubSub 'Microsoft.SignalRService/webPubSub@2024-04-01-preview' existing = { + name: pubSubName +} + +resource appServicePlan 'Microsoft.Web/serverfarms@2020-06-01' = { + name: appServicePlanName + location: location + properties: { + reserved: true + } + sku: { + name: sku + } + kind: 'linux' +} + +module storage 'storage.bicep' = { + name: 'storage' + params: { + name: 'webdeploy-${uniqueString(resourceGroup().id)}' + } +} + +resource appService 'Microsoft.Web/sites@2020-06-01' = { + name: webSiteName + location: location + properties: { + serverFarmId: appServicePlan.id + siteConfig: { + linuxFxVersion: linuxFxVersion + appSettings: [ + // Application Insights needs these three settings to be activated + // APPLICATIONINSIGHTS_CONNECTION_STRING, ApplicationInsightsAgent_EXTENSION_VERSION + // with the value ~3 for Linux apps, and XDT_MicrosoftApplicationInsights_Mode with the value recommended + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: appInsights.properties.ConnectionString + } + { + name: 'ApplicationInsightsAgent_EXTENSION_VERSION' + value: '~3' + } + { + name: 'XDT_MicrosoftApplicationInsights_Mode' + value: 'recommended' + } + { + name: 'WEBPUBSUB_SERVICE_CONNECTION_STRING' + value: pubSub.listKeys().primaryConnectionString + } + ] + } + } +} + +resource vNet 'Microsoft.Network/virtualNetworks@2024-01-01' existing = { + name: vnetName +} + +resource subNet 'Microsoft.Network/virtualNetworks/subnets@2021-02-01' existing = { + name: subnetName + parent: vNet +} + +resource vnetconfig 'Microsoft.Web/sites/networkConfig@2022-09-01' = { + name: 'virtualNetwork' + parent: appService + properties: { + subnetResourceId: subNet.id + swiftSupported: true + } +} + +// enable ipSecurityRestrictions for the appService +resource ipSecurityRestrictions 'Microsoft.Web/sites/config@2023-12-01' = { + name: 'web' + parent: appService + properties: { + publicNetworkAccess: 'Enabled' + ipSecurityRestrictions: [ + { + ipAddress: 'AzureWebPubSub' + action: 'Allow' + tag: 'ServiceTag' + priority: 100 + name: 'pubsub' + } + { + ipAddress: 'Any' + action: 'Deny' + priority: 2147483647 + name: 'Deny all' + description: 'Deny all access' + } + ] + } +} + +output webSiteName string = appService.name +output appServiceId string = appService.id +output storageName string = storage.outputs.storageAccountName