diff --git a/HealtRegistry/Containerfile b/HealtRegistry/Containerfile
new file mode 100644
index 0000000..fb37304
--- /dev/null
+++ b/HealtRegistry/Containerfile
@@ -0,0 +1,15 @@
+FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env
+WORKDIR /build
+
+# Copy everything
+COPY . ./
+# Restore as distinct layers
+RUN dotnet restore
+# Build and publish a release
+RUN dotnet publish -c Release -o out
+
+# Build runtime image
+FROM mcr.microsoft.com/dotnet/aspnet:8.0
+WORKDIR /app
+COPY --from=build-env /build/out .
+ENTRYPOINT ["dotnet", "HealtRegistry.dll"]
diff --git a/HealtRegistry/HealtRegistry.csproj b/HealtRegistry/HealtRegistry.csproj
new file mode 100644
index 0000000..fc1d56d
--- /dev/null
+++ b/HealtRegistry/HealtRegistry.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
diff --git a/HealtRegistry/HealtRegistry.http b/HealtRegistry/HealtRegistry.http
new file mode 100644
index 0000000..bf1f543
--- /dev/null
+++ b/HealtRegistry/HealtRegistry.http
@@ -0,0 +1,18 @@
+### Send health pdate for service 'test'
+POST http://127.0.0.1:5081/health/test HTTP/1.0
+
+### Set health of service 'test' to unhealthy
+PUT http://127.0.0.1:5081/health/test HTTP/1.0
+Content-Type: application/json
+
+{
+ "health": false
+}
+
+### Set health of service 'test' to unhealthy
+PUT https://healthr.adix.link/health/doorbell HTTP/1.0
+Content-Type: application/json
+
+{
+ "health": false
+}
\ No newline at end of file
diff --git a/HealtRegistry/Models/HealthRequest.cs b/HealtRegistry/Models/HealthRequest.cs
new file mode 100644
index 0000000..8b4a29d
--- /dev/null
+++ b/HealtRegistry/Models/HealthRequest.cs
@@ -0,0 +1,3 @@
+namespace HealtRegistry.Models;
+
+public record HealthRequest(bool? Health);
\ No newline at end of file
diff --git a/HealtRegistry/Models/ServiceConfiguration.cs b/HealtRegistry/Models/ServiceConfiguration.cs
new file mode 100644
index 0000000..cbc89c9
--- /dev/null
+++ b/HealtRegistry/Models/ServiceConfiguration.cs
@@ -0,0 +1,3 @@
+namespace HealtRegistry.Models;
+
+public record ServiceConfiguration(bool DefaultValue, TimeSpan Timeout);
\ No newline at end of file
diff --git a/HealtRegistry/Models/ServiceHealthData.cs b/HealtRegistry/Models/ServiceHealthData.cs
new file mode 100644
index 0000000..60c973e
--- /dev/null
+++ b/HealtRegistry/Models/ServiceHealthData.cs
@@ -0,0 +1,7 @@
+namespace HealtRegistry.Models;
+
+public class ServiceHealthData
+{
+ public required bool Healthy { get; set; }
+ public required DateTimeOffset LastUpdated { get; set; }
+}
\ No newline at end of file
diff --git a/HealtRegistry/Models/ServiceHealthes.cs b/HealtRegistry/Models/ServiceHealthes.cs
new file mode 100644
index 0000000..f0c3af3
--- /dev/null
+++ b/HealtRegistry/Models/ServiceHealthes.cs
@@ -0,0 +1,4 @@
+namespace HealtRegistry.Models;
+
+public record ServiceHealth(bool Health);
+public record ServiceHealthes(IReadOnlyDictionary Healthes);
\ No newline at end of file
diff --git a/HealtRegistry/Models/ServicesConfiguration.cs b/HealtRegistry/Models/ServicesConfiguration.cs
new file mode 100644
index 0000000..70883e3
--- /dev/null
+++ b/HealtRegistry/Models/ServicesConfiguration.cs
@@ -0,0 +1,8 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace HealtRegistry.Models;
+
+public class ServicesConfiguration
+{
+ [Required] public required Dictionary Services { get; init; }
+}
\ No newline at end of file
diff --git a/HealtRegistry/Program.cs b/HealtRegistry/Program.cs
new file mode 100644
index 0000000..cbed02d
--- /dev/null
+++ b/HealtRegistry/Program.cs
@@ -0,0 +1,60 @@
+using HealtRegistry.Models;
+using HealtRegistry.Services;
+using Microsoft.AspNetCore.Mvc;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen();
+
+builder.Services.AddSingleton();
+
+builder.Services.AddHostedService();
+
+builder.Services.AddOptions()
+ .Bind(builder.Configuration)
+ .ValidateDataAnnotations();
+
+if (builder.Environment.IsProduction())
+{
+ builder.Configuration.AddJsonFile("/config/appsettings.json", optional: true, reloadOnChange: true);
+}
+
+var app = builder.Build();
+
+// Configure the HTTP request pipeline.
+if (app.Environment.IsDevelopment())
+{
+ app.UseSwagger();
+ app.UseSwaggerUI();
+}
+
+app.MapPost("/health/{service}", ([FromRoute] string service, IHealthService healthService) =>
+{
+ healthService.SetServiceHealth(service, true);
+ return Results.Ok();
+})
+.WithOpenApi();
+
+app.MapPut("/health/{service}", ([FromRoute] string service, [FromBody] HealthRequest requestBody, IHealthService healthService, [FromServices] ILoggerFactory loggerFactory) =>
+{
+ if (requestBody.Health.HasValue)
+ {
+ healthService.SetServiceHealth(service, requestBody.Health.Value);
+ }
+ else
+ {
+ var logger = loggerFactory.CreateLogger("Health");
+ logger.LogDebug("Health request body does not contains 'Health' property");
+ }
+ return Results.Ok();
+})
+.WithOpenApi();
+
+app.MapGet("/status", (IHealthService healthService) =>
+{
+ return healthService.GetServiceHealthes();
+})
+.WithOpenApi();
+
+app.Run();
diff --git a/HealtRegistry/Properties/launchSettings.json b/HealtRegistry/Properties/launchSettings.json
new file mode 100644
index 0000000..633bb37
--- /dev/null
+++ b/HealtRegistry/Properties/launchSettings.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:48758",
+ "sslPort": 0
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "launchUrl": "swagger",
+ "applicationUrl": "http://localhost:5081",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": false,
+ "launchUrl": "swagger",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/HealtRegistry/Services/HealthService.cs b/HealtRegistry/Services/HealthService.cs
new file mode 100644
index 0000000..752e429
--- /dev/null
+++ b/HealtRegistry/Services/HealthService.cs
@@ -0,0 +1,34 @@
+using HealtRegistry.Models;
+using Microsoft.Extensions.Options;
+
+namespace HealtRegistry.Services;
+
+public class HealthService : IHealthService
+{
+
+ public Dictionary ServiceHealthes { get; } = new();
+
+ public HealthService(IOptions servicesConfiguration)
+ {
+ foreach (var service in servicesConfiguration.Value.Services)
+ {
+ ServiceHealthes.Add(service.Key, new ServiceHealthData()
+ {
+ Healthy = service.Value.DefaultValue,
+ LastUpdated = DateTimeOffset.Now
+ });
+ }
+ }
+
+ public ServiceHealthes GetServiceHealthes()
+ {
+ var serviceHealthes = ServiceHealthes.ToDictionary(x => x.Key, x => new ServiceHealth(x.Value.Healthy));
+ return new ServiceHealthes(serviceHealthes);
+ }
+
+ public void SetServiceHealth(string serviceName, bool health)
+ {
+ ServiceHealthes[serviceName].Healthy = health;
+ ServiceHealthes[serviceName].LastUpdated = DateTimeOffset.Now;
+ }
+}
\ No newline at end of file
diff --git a/HealtRegistry/Services/HealthTimeoutService.cs b/HealtRegistry/Services/HealthTimeoutService.cs
new file mode 100644
index 0000000..f31a865
--- /dev/null
+++ b/HealtRegistry/Services/HealthTimeoutService.cs
@@ -0,0 +1,51 @@
+
+using HealtRegistry.Models;
+using Microsoft.Extensions.Options;
+
+namespace HealtRegistry.Services;
+
+public class HealthTimeoutService : BackgroundService
+{
+ private readonly IHealthService _healthService;
+ private readonly IOptionsMonitor _servicesConfiguration;
+ private readonly ILogger _logger;
+
+ public HealthTimeoutService(
+ IHealthService healthService,
+ IOptionsMonitor servicesConfiguration,
+ ILogger logger
+ )
+ {
+ _healthService = healthService;
+ _servicesConfiguration = servicesConfiguration;
+ _logger = logger;
+ }
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ var now = DateTimeOffset.Now;
+ foreach (var service in _healthService.ServiceHealthes)
+ {
+ if (!service.Value.Healthy) continue;
+
+ var serviceConfiguration = _servicesConfiguration.CurrentValue.Services.TryGetValue(service.Key, out var configuration)
+ ? configuration
+ : null;
+
+ if (serviceConfiguration is not null &&
+ now - service.Value.LastUpdated > serviceConfiguration.Timeout)
+ {
+ _logger.LogInformation("Service {ServiceName} is timed out", service.Key);
+ _healthService.SetServiceHealth(service.Key, false);
+ }
+ }
+
+ try
+ {
+ await Task.Delay(1000, stoppingToken);
+ }
+ catch { }
+ }
+ }
+}
\ No newline at end of file
diff --git a/HealtRegistry/Services/IHealthService.cs b/HealtRegistry/Services/IHealthService.cs
new file mode 100644
index 0000000..b455b1d
--- /dev/null
+++ b/HealtRegistry/Services/IHealthService.cs
@@ -0,0 +1,10 @@
+using HealtRegistry.Models;
+
+namespace HealtRegistry.Services;
+
+public interface IHealthService
+{
+ Dictionary ServiceHealthes { get; }
+ ServiceHealthes GetServiceHealthes();
+ void SetServiceHealth(string serviceName, bool health);
+}
\ No newline at end of file
diff --git a/HealtRegistry/appsettings.Development.json b/HealtRegistry/appsettings.Development.json
new file mode 100644
index 0000000..3b7204b
--- /dev/null
+++ b/HealtRegistry/appsettings.Development.json
@@ -0,0 +1,14 @@
+{
+ "Services": {
+ "test": {
+ "DefaultValue": false,
+ "Timeout": "00:00:30"
+ }
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/HealtRegistry/appsettings.json b/HealtRegistry/appsettings.json
new file mode 100644
index 0000000..10f68b8
--- /dev/null
+++ b/HealtRegistry/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/healthr.sln b/healthr.sln
new file mode 100644
index 0000000..67a9e01
--- /dev/null
+++ b/healthr.sln
@@ -0,0 +1,25 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.5.002.0
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HealtRegistry", "HealtRegistry\HealtRegistry.csproj", "{68A78D32-0CD2-4D54-84BB-4D49651965F7}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {68A78D32-0CD2-4D54-84BB-4D49651965F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {68A78D32-0CD2-4D54-84BB-4D49651965F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {68A78D32-0CD2-4D54-84BB-4D49651965F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {68A78D32-0CD2-4D54-84BB-4D49651965F7}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {4FF4B2FD-839C-49E5-B796-39DD55A59171}
+ EndGlobalSection
+EndGlobal