diff --git a/.gitignore b/.gitignore
index ed6d1d2..b491d6a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -180,15 +180,15 @@ DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
-publish/
+[Pp]ublish/
# 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
+#*.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
diff --git a/ApiClient/ApiClient.csproj b/ApiClient/ApiClient.csproj
new file mode 100644
index 0000000..6cc66f4
--- /dev/null
+++ b/ApiClient/ApiClient.csproj
@@ -0,0 +1,45 @@
+
+
+
+ WinExe
+ net10.0-windows
+ enable
+ enable
+ true
+
+ ClientApiPoC.ApiClient
+
+ true
+ true
+ true
+ win-x64
+ true
+ false
+
+ Mike Schumann
+ Copyright © 2026 $(Company)
+ 0.1.0
+ $(AssemblyVersion)
+ en-US
+
+
+
+ portable
+
+
+
+ none
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ApiClient/App.xaml b/ApiClient/App.xaml
new file mode 100644
index 0000000..2059c88
--- /dev/null
+++ b/ApiClient/App.xaml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/ApiClient/App.xaml.cs b/ApiClient/App.xaml.cs
new file mode 100644
index 0000000..b003b04
--- /dev/null
+++ b/ApiClient/App.xaml.cs
@@ -0,0 +1,33 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using System.Windows;
+
+namespace ClientApiPoC.ApiClient {
+ public partial class App : Application {
+ private IHost _host;
+
+ public App() {
+ _host = Host.CreateDefaultBuilder().ConfigureServices(services => {
+ // ViewModels:
+ services.AddTransient();
+ // Views:
+ services.AddSingleton();
+ }).Build();
+ }
+
+ protected override async void OnStartup(StartupEventArgs e) {
+ await _host.StartAsync();
+
+ var window = _host.Services.GetRequiredService();
+ window.Show();
+
+ base.OnStartup(e);
+ }
+
+ protected override async void OnExit(ExitEventArgs e) {
+ await _host.StopAsync();
+ _host.Dispose();
+ base.OnExit(e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/ApiClient/AssemblyInfo.cs b/ApiClient/AssemblyInfo.cs
new file mode 100644
index 0000000..b0ec827
--- /dev/null
+++ b/ApiClient/AssemblyInfo.cs
@@ -0,0 +1,10 @@
+using System.Windows;
+
+[assembly: ThemeInfo(
+ ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
+ //(used if a resource is not found in the page,
+ // or application resource dictionaries)
+ ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
+ //(used if a resource is not found in the page,
+ // app, or any theme specific resource dictionaries)
+)]
diff --git a/ApiClient/MainWindow.xaml b/ApiClient/MainWindow.xaml
new file mode 100644
index 0000000..12fdf09
--- /dev/null
+++ b/ApiClient/MainWindow.xaml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ApiClient/MainWindow.xaml.cs b/ApiClient/MainWindow.xaml.cs
new file mode 100644
index 0000000..6720d26
--- /dev/null
+++ b/ApiClient/MainWindow.xaml.cs
@@ -0,0 +1,10 @@
+using ClientApiPoC.Shared.Wpf;
+using System.Windows;
+
+namespace ClientApiPoC.ApiClient {
+ public partial class MainWindow : MvvmWindow {
+ public MainWindow(MainWindowViewModel viewModel) : base(viewModel) {
+ InitializeComponent();
+ }
+ }
+}
\ No newline at end of file
diff --git a/ApiClient/MainWindowViewModel.cs b/ApiClient/MainWindowViewModel.cs
new file mode 100644
index 0000000..ca20ade
--- /dev/null
+++ b/ApiClient/MainWindowViewModel.cs
@@ -0,0 +1,92 @@
+using ClientApiPoC.Shared;
+using ClientApiPoC.Shared.Models;
+using ClientApiPoC.Shared.Wpf;
+using CommunityToolkit.Mvvm.Input;
+using System.Net.Http;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Windows.Input;
+
+namespace ClientApiPoC.ApiClient {
+ public class MainWindowViewModel : BaseViewModel {
+ #region Properties
+ private string _apiServiceUrl = "http://localhost:5228/";
+ public string ApiServiceUrl {
+ get => _apiServiceUrl;
+ set {
+ _apiServiceUrl = value;
+ OnPropertyChanged(nameof(ApiServiceUrl));
+ }
+ }
+
+ private bool _isFetchingData = false;
+ public bool IsFetchingData {
+ get => _isFetchingData;
+ set {
+ _isFetchingData = value;
+ OnPropertyChanged(nameof(IsFetchingData));
+ OnPropertyChanged(nameof(ControlsEnabled));
+ }
+ }
+
+ public bool ControlsEnabled => !this.IsFetchingData;
+
+ private ExampleRequestResultModel? _requestResultData = null;
+ public ExampleRequestResultModel? RequestResultData {
+ get => _requestResultData;
+ set {
+ _requestResultData = value;
+ OnPropertyChanged(nameof(RequestResultData));
+ OnPropertyChanged(nameof(RequestResultDataString));
+ }
+ }
+
+ public string RequestResultDataString {
+ get {
+ if (this.RequestResultData == null) return "NULL";
+ var options = new JsonSerializerOptions() {
+ IndentCharacter = ' ',
+ IndentSize = 2,
+ NewLine = "\n",
+ WriteIndented = true
+ };
+ return JsonSerializer.Serialize(this.RequestResultData, options);
+ }
+ }
+ #endregion
+
+ #region Commands
+ public ICommand HttpGetButtonCommand { get; }
+ #endregion
+
+ public MainWindowViewModel() {
+ this.HttpGetButtonCommand = new AsyncRelayCommand(HttpGetButtonActionAsync);
+ }
+
+ private async Task HttpGetButtonActionAsync() {
+ if (this.IsFetchingData) return;
+ try {
+ this.IsFetchingData = true;
+ var url = this.ApiServiceUrl;
+ if (string.IsNullOrWhiteSpace(url)) throw new InvalidOperationException("No base url specified.");
+ while (url.EndsWith('/')) url = url[..^1];
+ url += Routes.EXAMPLE_REQUEST;
+ using (var httpClient = Tools.CreateHttpClient()) {
+ var request = new HttpRequestMessage(HttpMethod.Get, url);
+ var response = await httpClient.SendAsync(request);
+ response.EnsureSuccessStatusCode();
+ var responseString = await response.Content.ReadAsStringAsync();
+ var options = new JsonSerializerOptions() {
+ PropertyNameCaseInsensitive = true
+ };
+ var result = JsonSerializer.Deserialize(responseString, options);
+ this.RequestResultData = result;
+ }
+ } catch (Exception ex) {
+ await HandleErrorAsync(ex);
+ } finally {
+ this.IsFetchingData = false;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/ApiClient/Properties/PublishProfiles/FolderProfile.pubxml b/ApiClient/Properties/PublishProfiles/FolderProfile.pubxml
new file mode 100644
index 0000000..365cb40
--- /dev/null
+++ b/ApiClient/Properties/PublishProfiles/FolderProfile.pubxml
@@ -0,0 +1,15 @@
+
+
+
+
+ Release
+ Any CPU
+ ..\Publish\
+ FileSystem
+ <_TargetId>Folder
+ net10.0-windows
+ win-x64
+ true
+ false
+
+
\ No newline at end of file
diff --git a/ApiService/ApiService.csproj b/ApiService/ApiService.csproj
new file mode 100644
index 0000000..6a3a16c
--- /dev/null
+++ b/ApiService/ApiService.csproj
@@ -0,0 +1,41 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+ ClientApiPoC.ApiService
+
+ true
+ true
+ true
+ win-x64
+ true
+ false
+
+ Mike Schumann
+ Copyright © 2026 $(Company)
+ 0.1.0
+ $(AssemblyVersion)
+ en-US
+
+
+
+ portable
+
+
+
+ none
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ApiService/Controllers/ExampleRequestController.cs b/ApiService/Controllers/ExampleRequestController.cs
new file mode 100644
index 0000000..3d5f9cc
--- /dev/null
+++ b/ApiService/Controllers/ExampleRequestController.cs
@@ -0,0 +1,27 @@
+using ClientApiPoC.Shared;
+using ClientApiPoC.Shared.Models;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ClientApiPoC.ApiService.Controllers {
+ [ApiController]
+ [Route(Routes.EXAMPLE_REQUEST)]
+ public class ExampleRequestController : ControllerBase {
+ private readonly TunnelServer _tunnel;
+
+ public ExampleRequestController(TunnelServer tunnel) {
+ if (tunnel == null) throw new ArgumentNullException(nameof(tunnel));
+ _tunnel = tunnel;
+ }
+
+ [HttpGet]
+ public async Task Get() {
+ // Daten der Clients abfragen...
+ var clients = await _tunnel.GetDataFromAllClientsAsync();
+ var result = new ExampleRequestResultModel() {
+ ServerMessage = "Hello, world!",
+ Clients = clients
+ };
+ return result;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ApiService/Program.cs b/ApiService/Program.cs
new file mode 100644
index 0000000..0704b67
--- /dev/null
+++ b/ApiService/Program.cs
@@ -0,0 +1,34 @@
+using ClientApiPoC.Shared;
+using ClientApiPoC.Shared.SignalR;
+
+namespace ClientApiPoC.ApiService {
+ public class Program {
+ public static void Main(string[] args) {
+ var builder = WebApplication.CreateBuilder(args);
+
+ builder.Services.AddControllers();
+ builder.Services.AddOpenApi();
+
+ builder.Services.AddTunnelHub();
+ builder.Services.AddTransient();
+
+ var app = builder.Build();
+
+ // Configure the HTTP request pipeline.
+ if (app.Environment.IsDevelopment()) {
+ app.MapOpenApi();
+ app.UseSwaggerUI(options => {
+ options.SwaggerEndpoint("/openapi/v1.json", "ClientApiPoC");
+ });
+ }
+
+ app.UseAuthorization();
+
+ app.MapControllers();
+
+ app.MapTunnelHub(Routes.TUNNEL_PATH);
+
+ app.Run();
+ }
+ }
+}
\ No newline at end of file
diff --git a/ApiService/Properties/PublishProfiles/FolderProfile.pubxml b/ApiService/Properties/PublishProfiles/FolderProfile.pubxml
new file mode 100644
index 0000000..1f38def
--- /dev/null
+++ b/ApiService/Properties/PublishProfiles/FolderProfile.pubxml
@@ -0,0 +1,20 @@
+
+
+
+
+ true
+ false
+ true
+ Release
+ Any CPU
+ FileSystem
+ ..\Publish\ApiService\
+ FileSystem
+ <_TargetId>Folder
+
+ net10.0
+ win-x64
+ bb6e670a-3d3d-430e-4dd4-25ad2c7d9c26
+ true
+
+
\ No newline at end of file
diff --git a/ApiService/Properties/launchSettings.json b/ApiService/Properties/launchSettings.json
new file mode 100644
index 0000000..a4a08e9
--- /dev/null
+++ b/ApiService/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "launchUrl": "swagger",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "dotnetRunMessages": true,
+ "applicationUrl": "http://*:5228"
+ }
+ },
+ "$schema": "https://json.schemastore.org/launchsettings.json"
+}
\ No newline at end of file
diff --git a/ApiService/TunnelServer.cs b/ApiService/TunnelServer.cs
new file mode 100644
index 0000000..f0dc833
--- /dev/null
+++ b/ApiService/TunnelServer.cs
@@ -0,0 +1,24 @@
+using Microsoft.AspNetCore.SignalR;
+using ClientApiPoC.Shared.SignalR;
+using ClientApiPoC.Shared.Models;
+
+namespace ClientApiPoC.ApiService {
+ public class TunnelServer : TunnelServerBase {
+ public TunnelServer(IHubContext hubContext, ClientTracker clientTracker) : base(hubContext, clientTracker) { }
+
+ public async Task> GetDataFromAllClientsAsync() {
+ var timestampServer = DateTime.UtcNow;
+ var results = new List();
+ var clients = this.GetAllClients();
+ foreach (var client in clients) {
+ var clientData = await client.Value.InvokeAsync("GetClientData", timestampServer, CancellationToken.None);
+ var result = new ClientResultModel() {
+ ClientId = client.Key,
+ ClientData = clientData
+ };
+ results.Add(result);
+ }
+ return results;
+ }
+ }
+}
\ No newline at end of file
diff --git a/ApiService/appsettings.Development.json b/ApiService/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/ApiService/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/ApiService/appsettings.json b/ApiService/appsettings.json
new file mode 100644
index 0000000..99e592b
--- /dev/null
+++ b/ApiService/appsettings.json
@@ -0,0 +1,16 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "Kestrel": {
+ "Endpoints": {
+ "Http": {
+ "Url": "http://*:5228"
+ }
+ }
+ }
+}
diff --git a/ApiService/dotnet-tools.json b/ApiService/dotnet-tools.json
new file mode 100644
index 0000000..b6f4c2f
--- /dev/null
+++ b/ApiService/dotnet-tools.json
@@ -0,0 +1,13 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "dotnet-ef": {
+ "version": "10.0.5",
+ "commands": [
+ "dotnet-ef"
+ ],
+ "rollForward": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/ClientApiPoC.slnx b/ClientApiPoC.slnx
new file mode 100644
index 0000000..20bb135
--- /dev/null
+++ b/ClientApiPoC.slnx
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/OnPremiseApp/App.xaml b/OnPremiseApp/App.xaml
new file mode 100644
index 0000000..4483be4
--- /dev/null
+++ b/OnPremiseApp/App.xaml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/OnPremiseApp/App.xaml.cs b/OnPremiseApp/App.xaml.cs
new file mode 100644
index 0000000..f0e5d72
--- /dev/null
+++ b/OnPremiseApp/App.xaml.cs
@@ -0,0 +1,36 @@
+using System.Windows;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using ClientApiPoC.OnPremiseApp.Services;
+
+namespace ClientApiPoC.OnPremiseApp {
+ public partial class App : Application {
+ private IHost _host;
+
+ public App() {
+ _host = Host.CreateDefaultBuilder().ConfigureServices(services => {
+ // Services:
+ services.AddSingleton();
+ // ViewModels:
+ services.AddTransient();
+ // Views:
+ services.AddSingleton();
+ }).Build();
+ }
+
+ protected override async void OnStartup(StartupEventArgs e) {
+ await _host.StartAsync();
+
+ var window = _host.Services.GetRequiredService();
+ window.Show();
+
+ base.OnStartup(e);
+ }
+
+ protected override async void OnExit(ExitEventArgs e) {
+ await _host.StopAsync();
+ _host.Dispose();
+ base.OnExit(e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/OnPremiseApp/AssemblyInfo.cs b/OnPremiseApp/AssemblyInfo.cs
new file mode 100644
index 0000000..b0ec827
--- /dev/null
+++ b/OnPremiseApp/AssemblyInfo.cs
@@ -0,0 +1,10 @@
+using System.Windows;
+
+[assembly: ThemeInfo(
+ ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
+ //(used if a resource is not found in the page,
+ // or application resource dictionaries)
+ ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
+ //(used if a resource is not found in the page,
+ // app, or any theme specific resource dictionaries)
+)]
diff --git a/OnPremiseApp/MainWindow.xaml b/OnPremiseApp/MainWindow.xaml
new file mode 100644
index 0000000..6f2189d
--- /dev/null
+++ b/OnPremiseApp/MainWindow.xaml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OnPremiseApp/MainWindow.xaml.cs b/OnPremiseApp/MainWindow.xaml.cs
new file mode 100644
index 0000000..7658c76
--- /dev/null
+++ b/OnPremiseApp/MainWindow.xaml.cs
@@ -0,0 +1,9 @@
+using ClientApiPoC.Shared.Wpf;
+
+namespace ClientApiPoC.OnPremiseApp {
+ public partial class MainWindow : MvvmWindow {
+ public MainWindow(MainWindowViewModel viewModel) : base(viewModel) {
+ InitializeComponent();
+ }
+ }
+}
\ No newline at end of file
diff --git a/OnPremiseApp/MainWindowViewModel.cs b/OnPremiseApp/MainWindowViewModel.cs
new file mode 100644
index 0000000..7515fe5
--- /dev/null
+++ b/OnPremiseApp/MainWindowViewModel.cs
@@ -0,0 +1,114 @@
+using System.ComponentModel;
+using System.Windows.Input;
+using CommunityToolkit.Mvvm.Input;
+using ClientApiPoC.OnPremiseApp.Services;
+using ClientApiPoC.Shared;
+using ClientApiPoC.Shared.Wpf;
+using ClientApiPoC.Shared.SignalR;
+
+namespace ClientApiPoC.OnPremiseApp {
+ public class MainWindowViewModel : BaseViewModel {
+ private ClientDataService _clientDataService;
+ private TunnelClient _tunnelClient;
+
+ #region Properties
+ private string _apiServiceUrl = "http://localhost:5228/";
+ public string ApiServiceUrl {
+ get => _apiServiceUrl;
+ set {
+ _apiServiceUrl = value;
+ OnPropertyChanged(nameof(ApiServiceUrl));
+ }
+ }
+
+ private bool _tunnelClientIsConnecting = false;
+ public bool TunnelClientIsConnecting {
+ get => _tunnelClientIsConnecting;
+ set {
+ _tunnelClientIsConnecting = value;
+ OnPropertyChanged(nameof(TunnelClientIsConnecting));
+ OnPropertyChanged(nameof(ConnectButtonEnabled));
+ OnPropertyChanged(nameof(UrlTextFieldEnabled));
+ }
+ }
+
+ public bool TunnelClientConnected => (_tunnelClient.IsConnected || _tunnelClient.IsConnecting);
+
+ public string ConnectButtonText => (this.TunnelClientConnected ? "Disconnect" : "Connect");
+
+ public bool ConnectButtonEnabled => !this.TunnelClientIsConnecting;
+
+ public bool UrlTextFieldEnabled => !this.TunnelClientIsConnecting && !this.TunnelClientConnected;
+
+ private string _localClientData = "";
+ public string LocalClientData {
+ get => _localClientData;
+ set {
+ _localClientData = value;
+ OnPropertyChanged(nameof(LocalClientData));
+ OnPropertyChanged(nameof(SetLocalClientDataButtonEnabled));
+ }
+ }
+
+ public bool SetLocalClientDataButtonEnabled {
+ get {
+ if (string.IsNullOrEmpty(this.LocalClientData) && string.IsNullOrEmpty(_clientDataService.ClientData)) return false;
+ return !string.Equals(this.LocalClientData, _clientDataService.ClientData);
+ }
+ }
+ #endregion
+
+ #region Commands
+ public ICommand ToggleConnectionCommand { get; }
+
+ public ICommand SetLocalClientDataCommand { get; }
+ #endregion
+
+ public MainWindowViewModel(ClientDataService clientDataService) {
+ if (clientDataService == null) throw new ArgumentNullException(nameof(clientDataService));
+ _clientDataService = clientDataService;
+ _tunnelClient = new TunnelClient();
+ _tunnelClient.PropertyChanged += TunnelClient_PropertyChanged;
+ this.ToggleConnectionCommand = new AsyncRelayCommand(ToggleConnectionAsync);
+ this.SetLocalClientDataCommand = new AsyncRelayCommand(SetLocalClientDataAsync);
+ }
+
+ private void TunnelClient_PropertyChanged(object? sender, PropertyChangedEventArgs e) {
+ OnPropertyChanged(nameof(TunnelClientConnected));
+ OnPropertyChanged(nameof(ConnectButtonText));
+ OnPropertyChanged(nameof(UrlTextFieldEnabled));
+ }
+
+ private async Task ToggleConnectionAsync() {
+ if (this.TunnelClientIsConnecting) return;
+ try {
+ this.TunnelClientIsConnecting = true;
+ if (this.TunnelClientConnected) {
+ await _tunnelClient.DisconnectAsync();
+ } else {
+ var url = this.ApiServiceUrl;
+ if (string.IsNullOrWhiteSpace(url)) throw new Exception("Please provide a base url.");
+ while (url.EndsWith('/')) url = url[..^1];
+ url += Routes.TUNNEL_PATH;
+ await _tunnelClient.ConnectAsync(url, _clientDataService.ConfigureTunnelActions);
+ }
+ } catch (Exception ex) {
+ await HandleErrorAsync(ex);
+ } finally {
+ this.TunnelClientIsConnecting = false;
+ }
+ }
+
+ private async Task SetLocalClientDataAsync() {
+ try {
+ string? newClientData = this.LocalClientData;
+ if (string.IsNullOrEmpty(newClientData)) newClientData = null;
+ _clientDataService.ClientData = newClientData;
+ } catch (Exception ex) {
+ await HandleErrorAsync(ex);
+ } finally {
+ OnPropertyChanged(nameof(SetLocalClientDataButtonEnabled));
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/OnPremiseApp/OnPremiseApp.csproj b/OnPremiseApp/OnPremiseApp.csproj
new file mode 100644
index 0000000..fb02090
--- /dev/null
+++ b/OnPremiseApp/OnPremiseApp.csproj
@@ -0,0 +1,45 @@
+
+
+
+ WinExe
+ net10.0-windows
+ enable
+ enable
+ true
+
+ ClientApiPoC.OnPremiseApp
+
+ true
+ true
+ true
+ win-x64
+ true
+ false
+
+ Mike Schumann
+ Copyright © 2026 $(Company)
+ 0.1.0
+ $(AssemblyVersion)
+ en-US
+
+
+
+ portable
+
+
+
+ none
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/OnPremiseApp/Properties/PublishProfiles/FolderProfile.pubxml b/OnPremiseApp/Properties/PublishProfiles/FolderProfile.pubxml
new file mode 100644
index 0000000..365cb40
--- /dev/null
+++ b/OnPremiseApp/Properties/PublishProfiles/FolderProfile.pubxml
@@ -0,0 +1,15 @@
+
+
+
+
+ Release
+ Any CPU
+ ..\Publish\
+ FileSystem
+ <_TargetId>Folder
+ net10.0-windows
+ win-x64
+ true
+ false
+
+
\ No newline at end of file
diff --git a/OnPremiseApp/Services/ClientDataService.cs b/OnPremiseApp/Services/ClientDataService.cs
new file mode 100644
index 0000000..d458383
--- /dev/null
+++ b/OnPremiseApp/Services/ClientDataService.cs
@@ -0,0 +1,21 @@
+using Microsoft.AspNetCore.SignalR.Client;
+using ClientApiPoC.Shared.Models;
+
+namespace ClientApiPoC.OnPremiseApp.Services {
+ public class ClientDataService {
+ public string? ClientData { get; set; } = null;
+
+ public async Task GetClientDataAsync(DateTime timestampServer) {
+ return new ClientDataModel() {
+ Data = this.ClientData,
+ TimestampServerUtc = timestampServer,
+ TimestampClientUtc = DateTime.UtcNow
+ };
+ }
+
+ public void ConfigureTunnelActions(HubConnection tunnelConnection) {
+ if (tunnelConnection == null) throw new ArgumentNullException(nameof(tunnelConnection));
+ tunnelConnection.On("GetClientData", GetClientDataAsync);
+ }
+ }
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index a742386..ba131de 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,21 @@
-# ClientApiPoC
+# ClientApiPoC
+Proof-of-Concept for transferring local data from a client to a public web service without port-forwarding, using SignalR.
+
+## ApiService
+
+Web service, serving HTTP-API for Clients (*ApiClient*). It also serves a SignalR server that local clients (*OnPremiseApp*) can connect to.
+
+This service can call a function on all connected local clients (*OnPremiseApp*) to receive local data. The local client doesn't have any HTTP-API or what so ever, nor is port-forwarding needed.
+
+API clients (*ApiClient*) can receive data from all connected local clients (*OnPremiseApp*) using the standard HTTP-API.
+
+## ApiClient
+
+This application only connects to the public HTTP-API (*ApiService*) to receive data from local clients (*OnPremiseApp*) and shows the request result.
+
+## OnPremiseApp
+
+This application connects to the *ApiService* via SignalR and transfers local data via the SignalR tunnel when the API service requests this.
+
+No Data ist actively sent to the service; the service requests data if needed.
diff --git a/Shared.Wpf/BaseViewModel.cs b/Shared.Wpf/BaseViewModel.cs
new file mode 100644
index 0000000..977fc0a
--- /dev/null
+++ b/Shared.Wpf/BaseViewModel.cs
@@ -0,0 +1,11 @@
+using System.Windows;
+
+namespace ClientApiPoC.Shared.Wpf {
+ public abstract class BaseViewModel : NotifyPropertyChangedBase {
+ protected async Task HandleErrorAsync(Exception ex) {
+ if (ex == null) return;
+ MessageBox.Show(ex.Message, "Fehler", MessageBoxButton.OK, MessageBoxImage.Error);
+ await Task.CompletedTask;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Shared.Wpf/MvvmWindow.cs b/Shared.Wpf/MvvmWindow.cs
new file mode 100644
index 0000000..fee09b1
--- /dev/null
+++ b/Shared.Wpf/MvvmWindow.cs
@@ -0,0 +1,13 @@
+using System.Windows;
+
+namespace ClientApiPoC.Shared.Wpf {
+ public abstract class MvvmWindow : Window where TViewModel : BaseViewModel {
+ protected TViewModel ViewModel { get; }
+
+ public MvvmWindow(TViewModel viewModel) : base() {
+ if (viewModel == null) throw new ArgumentNullException(nameof(viewModel));
+ this.ViewModel = viewModel;
+ this.DataContext = this.ViewModel;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Shared.Wpf/Shared.Wpf.csproj b/Shared.Wpf/Shared.Wpf.csproj
new file mode 100644
index 0000000..804d4c0
--- /dev/null
+++ b/Shared.Wpf/Shared.Wpf.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net10.0-windows
+ enable
+ true
+ enable
+ ClientApiPoC.Shared.Wpf
+
+
+
+ portable
+
+
+
+ none
+
+
+
+
+
+
+
diff --git a/Shared/Models/ClientDataModel.cs b/Shared/Models/ClientDataModel.cs
new file mode 100644
index 0000000..8db4eac
--- /dev/null
+++ b/Shared/Models/ClientDataModel.cs
@@ -0,0 +1,9 @@
+namespace ClientApiPoC.Shared.Models {
+ public class ClientDataModel {
+ public string? Data { get; set; } = null;
+
+ public DateTime TimestampClientUtc { get; set; } = DateTime.UtcNow;
+
+ public DateTime TimestampServerUtc { get; set; } = DateTime.MinValue;
+ }
+}
\ No newline at end of file
diff --git a/Shared/Models/ClientResultModel.cs b/Shared/Models/ClientResultModel.cs
new file mode 100644
index 0000000..50cae89
--- /dev/null
+++ b/Shared/Models/ClientResultModel.cs
@@ -0,0 +1,7 @@
+namespace ClientApiPoC.Shared.Models {
+ public class ClientResultModel {
+ public string ClientId { get; set; } = "";
+
+ public ClientDataModel? ClientData { get; set; } = null;
+ }
+}
\ No newline at end of file
diff --git a/Shared/Models/ExampleRequestResultModel.cs b/Shared/Models/ExampleRequestResultModel.cs
new file mode 100644
index 0000000..e2d68eb
--- /dev/null
+++ b/Shared/Models/ExampleRequestResultModel.cs
@@ -0,0 +1,7 @@
+namespace ClientApiPoC.Shared.Models {
+ public class ExampleRequestResultModel {
+ public string? ServerMessage { get; set; }
+
+ public IEnumerable Clients { get; set; } = [];
+ }
+}
\ No newline at end of file
diff --git a/Shared/NotifyPropertyChangedBase.cs b/Shared/NotifyPropertyChangedBase.cs
new file mode 100644
index 0000000..da3a646
--- /dev/null
+++ b/Shared/NotifyPropertyChangedBase.cs
@@ -0,0 +1,12 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace ClientApiPoC.Shared {
+ public abstract class NotifyPropertyChangedBase : INotifyPropertyChanged {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+ }
+}
\ No newline at end of file
diff --git a/Shared/Routes.cs b/Shared/Routes.cs
new file mode 100644
index 0000000..dc3e588
--- /dev/null
+++ b/Shared/Routes.cs
@@ -0,0 +1,9 @@
+namespace ClientApiPoC.Shared {
+ public static class Routes {
+ private const string BASE_PATH = "/api/";
+
+ public const string TUNNEL_PATH = "/tunnel";
+
+ public const string EXAMPLE_REQUEST = $"{BASE_PATH}ExampleRequest";
+ }
+}
\ No newline at end of file
diff --git a/Shared/Shared.csproj b/Shared/Shared.csproj
new file mode 100644
index 0000000..b35a4ef
--- /dev/null
+++ b/Shared/Shared.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net10.0
+ enable
+ enable
+ ClientApiPoC.Shared
+
+
+
+ portable
+
+
+
+ none
+
+
+
+
+
+
+
+
diff --git a/Shared/SignalR/ClientTracker.cs b/Shared/SignalR/ClientTracker.cs
new file mode 100644
index 0000000..f01916d
--- /dev/null
+++ b/Shared/SignalR/ClientTracker.cs
@@ -0,0 +1,18 @@
+using System.Collections.Concurrent;
+
+namespace ClientApiPoC.Shared.SignalR {
+ public class ClientTracker {
+ private ConcurrentDictionary _knownClientIds = new();
+
+ public ICollection CurrentClientIds => _knownClientIds.Values;
+
+ public bool TryAddClientId(string clientId) {
+ if (string.IsNullOrWhiteSpace(clientId)) throw new ArgumentNullException(nameof(clientId));
+ return _knownClientIds.TryAdd(clientId, clientId);
+ }
+
+ public bool TryRemoveClientId(string clientId) {
+ return _knownClientIds.TryRemove(clientId, out _);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Shared/SignalR/TunnelClient.cs b/Shared/SignalR/TunnelClient.cs
new file mode 100644
index 0000000..6be7db3
--- /dev/null
+++ b/Shared/SignalR/TunnelClient.cs
@@ -0,0 +1,84 @@
+using Microsoft.AspNetCore.SignalR.Client;
+
+namespace ClientApiPoC.Shared.SignalR {
+ public class TunnelClient : NotifyPropertyChangedBase, IDisposable, IAsyncDisposable {
+ #region IDisposable-Implementierung
+ private bool _disposed;
+
+ protected virtual void Dispose(bool disposing) {
+ if (!_disposed) {
+ if (disposing) {
+ _ = this.DisposeAsync();
+ }
+ _disposed = true;
+ }
+ }
+
+ public void Dispose() {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ public async ValueTask DisposeAsync() {
+ await DisconnectAsync();
+ }
+ #endregion
+
+ private HubConnection? _connection = null;
+
+ public bool IsConnected => (this.ConnectionState == HubConnectionState.Connected);
+
+ public bool IsConnecting => (this.ConnectionState == HubConnectionState.Connecting || this.ConnectionState == HubConnectionState.Reconnecting);
+
+ protected HubConnectionState ConnectionState => (_connection?.State ?? HubConnectionState.Disconnected);
+
+ public async Task ConnectAsync(string url, Action configureConnection) {
+ try {
+ if (string.IsNullOrWhiteSpace(url)) throw new ArgumentNullException(nameof(url));
+ if (this.IsConnected) throw new InvalidOperationException("SignalR connection is already established.");
+ _connection = new HubConnectionBuilder().WithUrl(url).WithAutomaticReconnect().Build();
+ _connection.Reconnecting += Connection_Reconnecting;
+ _connection.Reconnected += Connection_Reconnected;
+ _connection.Closed += Connection_Closed;
+ configureConnection(_connection);
+ await _connection.StartAsync();
+ } finally {
+ await UpdateStateAsync();
+ }
+ }
+
+ public async Task DisconnectAsync() {
+ try {
+ if (_connection == null) return;
+ try {
+ if (this.IsConnected) await _connection.StopAsync();
+ } catch { }
+ _connection.Reconnecting -= Connection_Reconnecting;
+ _connection.Reconnected -= Connection_Reconnected;
+ _connection.Closed -= Connection_Closed;
+ await _connection.DisposeAsync();
+ _connection = null;
+ } finally {
+ await UpdateStateAsync();
+ }
+ }
+
+ private async Task Connection_Reconnecting(Exception? arg) {
+ await UpdateStateAsync();
+ }
+
+ private async Task Connection_Reconnected(string? arg) {
+ await UpdateStateAsync();
+ }
+
+ private async Task Connection_Closed(Exception? arg) {
+ await UpdateStateAsync();
+ }
+
+ private async Task UpdateStateAsync() {
+ OnPropertyChanged(nameof(IsConnected));
+ OnPropertyChanged(nameof(IsConnecting));
+ await Task.CompletedTask;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Shared/SignalR/TunnelHub.cs b/Shared/SignalR/TunnelHub.cs
new file mode 100644
index 0000000..7130aff
--- /dev/null
+++ b/Shared/SignalR/TunnelHub.cs
@@ -0,0 +1,20 @@
+using Microsoft.AspNetCore.SignalR;
+
+namespace ClientApiPoC.Shared.SignalR {
+ public sealed class TunnelHub : Hub {
+ private ClientTracker _clientTracker;
+
+ public TunnelHub(ClientTracker clientTracker) : base() {
+ if (clientTracker == null) throw new ArgumentNullException(nameof(clientTracker));
+ _clientTracker = clientTracker;
+ }
+
+ public override sealed async Task OnConnectedAsync() {
+ _ = _clientTracker.TryAddClientId(this.Context.ConnectionId);
+ }
+
+ public override sealed async Task OnDisconnectedAsync(Exception exception) {
+ _ = _clientTracker.TryRemoveClientId(this.Context.ConnectionId);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Shared/SignalR/TunnelHubExtensions.cs b/Shared/SignalR/TunnelHubExtensions.cs
new file mode 100644
index 0000000..36ed24e
--- /dev/null
+++ b/Shared/SignalR/TunnelHubExtensions.cs
@@ -0,0 +1,21 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ClientApiPoC.Shared.SignalR {
+ public static class TunnelHubExtensions {
+ public static IServiceCollection AddTunnelHub(this IServiceCollection services) {
+ services.AddSignalR(options => {
+ // Nachrichtengröße auf max. 16MB bringen (Standard = 32KB).
+ options.MaximumReceiveMessageSize = (16 * 1024 * 1024);
+ });
+ services.AddSingleton();
+ return services;
+ }
+
+ public static HubEndpointConventionBuilder MapTunnelHub(this IEndpointRouteBuilder app, [StringSyntax("Route")] string pattern) {
+ return app.MapHub(pattern);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Shared/SignalR/TunnelServerBase.cs b/Shared/SignalR/TunnelServerBase.cs
new file mode 100644
index 0000000..fc3072d
--- /dev/null
+++ b/Shared/SignalR/TunnelServerBase.cs
@@ -0,0 +1,36 @@
+using Microsoft.AspNetCore.SignalR;
+
+namespace ClientApiPoC.Shared.SignalR {
+ public abstract class TunnelServerBase {
+ protected IHubContext HubContext { get; private set; }
+
+ protected ClientTracker Clients { get; private set; }
+
+ public TunnelServerBase(IHubContext hubContext, ClientTracker clientTracker) {
+ if (hubContext == null) throw new ArgumentNullException(nameof(hubContext));
+ if (clientTracker == null) throw new ArgumentNullException(nameof(clientTracker));
+ this.HubContext = hubContext;
+ this.Clients = clientTracker;
+ }
+
+ protected ISingleClientProxy? TryGetClient(string clientId) {
+ ISingleClientProxy? client;
+ try {
+ client = this.HubContext.Clients.Client(clientId);
+ } catch {
+ client = null;
+ }
+ return client;
+ }
+
+ protected IDictionary GetAllClients() {
+ var results = new Dictionary();
+ var allClientIds = this.Clients.CurrentClientIds;
+ foreach (var clientId in allClientIds) {
+ var client = TryGetClient(clientId);
+ if (client != null) results.Add(clientId, client);
+ }
+ return results;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Shared/Tools.cs b/Shared/Tools.cs
new file mode 100644
index 0000000..90775e1
--- /dev/null
+++ b/Shared/Tools.cs
@@ -0,0 +1,15 @@
+using System.Net.Http;
+
+namespace ClientApiPoC.Shared {
+ public static class Tools {
+ public static HttpClient CreateHttpClient() {
+ var httpClientHandler = new HttpClientHandler() {
+ ServerCertificateCustomValidationCallback = (message, cert, chain, sslPolicyErrors) => {
+ // HACK: Allen SSL-Zertifikaten vertrauen!
+ return true;
+ }
+ };
+ return new HttpClient(httpClientHandler);
+ }
+ }
+}
\ No newline at end of file