Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save OwnageIsMagic/4d77e4dd4da9d281ce4a5a8172347a89 to your computer and use it in GitHub Desktop.
Save OwnageIsMagic/4d77e4dd4da9d281ce4a5a8172347a89 to your computer and use it in GitHub Desktop.
TcpListnerHttpHealthCheck
using Microsoft.Extensions.Options;
namespace XXX.HealthCheck;
public static class HealthCheckListenerServiceCollectionExtensions
{
public static IServiceCollection AddHealthCheckListener(this IServiceCollection services, IConfiguration config)
{
// var useConforming = config.GetValue($"{nameof(StatusListenerOptions)}:{nameof(StatusListenerOptions.UseConformingHttpListener)}",
// // if not set explicitly, disable on Windows -- it requires ACL configuration for non localhost bind
// defaultValue: !OperatingSystem.IsWindows());
new OptionsBuilder<StatusListenerOptions>(services, Options.DefaultName)
.BindConfiguration(nameof(StatusListenerOptions), static o => o.ErrorOnUnknownConfiguration = true);
// .PostConfigure(o =>
// {
// // o.UseConformingHttpListener = useConforming;
// if (useConforming && !o.ListenOn.StartsWith("http", StringComparison.OrdinalIgnoreCase))
// o.ListenOn = "http://" + o.ListenOn;
// });
services.AddHealthChecks();
// if (useConforming)
// services.AddHostedService<HttpStatusListener>();
// else
services.AddHostedService<TcpHttpStatusListener>();
return services;
}
}
// using System.Diagnostics.CodeAnalysis;
// using System.Net;
// using System.Net.Http.Headers;
// using System.Net.Mime;
// using System.Text.Json;
// using Microsoft.Extensions.Diagnostics.HealthChecks;
// using Microsoft.Extensions.Options;
//
// namespace XXX.HealthCheck;
//
// public sealed class HttpStatusListener : BackgroundService
// {
// private static readonly string JsonContentType =
// new MediaTypeHeaderValue(MediaTypeNames.Application.Json) { CharSet = "utf-8" }.ToString();
//
// private readonly ILogger<HttpStatusListener> logger;
// private readonly HealthCheckService checkService;
// private readonly StatusListenerOptions options;
//
// public HttpStatusListener(ILogger<HttpStatusListener> logger, HealthCheckService checkService,
// IOptions<StatusListenerOptions> options)
// {
// this.logger = logger;
// this.checkService = checkService;
// this.options = options.Value;
// if (!this.options.ListenOn.StartsWith("http", StringComparison.OrdinalIgnoreCase))
// this.options.ListenOn = "http://" + this.options.ListenOn;
// if (this.options.ListenOn[^1] != '/')
// this.options.ListenOn += '/';
// }
//
// protected override async Task ExecuteAsync(CancellationToken stoppingToken)
// {
// using var httpListener = new HttpListener { Prefixes = { options.ListenOn } };
// httpListener.Start();
// await using var tokenRegistration = stoppingToken.UnsafeRegister(x => ((HttpListener)x!).Abort(), httpListener);
//
// logger.LogInformation("Status endpoint listening: {urls}", httpListener.Prefixes);
//
// while (!stoppingToken.IsCancellationRequested)
// {
// try
// {
// var ctx = await httpListener.GetContextAsync();
// var response = ctx.Response;
// try
// {
// if (Match(ctx, response) is { } task)
// await task;
// else
// response.StatusCode = (int)HttpStatusCode.NotFound;
// }
// catch
// {
// try
// {
// if (response.OutputStream.CanWrite)
// response.StatusCode = (int)HttpStatusCode.InternalServerError;
// }
// catch
// {
// // ignored
// }
//
// throw;
// }
// finally
// {
// response.Close();
// }
// }
// // catch (ObjectDisposedException) when (stoppingToken.IsCancellationRequested) { return; }
// catch (Exception e)
// {
// if (e is HttpListenerException { ErrorCode : 995 }) // ERROR_OPERATION_ABORTED application requested abort
// return;
// logger.LogError(e, "Exception while processing status request");
// }
// }
// }
//
// [SuppressMessage("Reliability", "CA2012:Use ValueTasks correctly")]
// private ValueTask? Match(HttpListenerContext ctx, HttpListenerResponse response)
// {
// switch (ctx.Request.Url!.Segments.AsSpan())
// {
// case ["/"]:
// return HandleRoot(response, options);
// case ["/", "health/" or "health", .. var rest]:
// if (rest.Length > 1)
// return null;
// return HandleHealth(response, options, checkService, rest.Length == 0 ? null : rest[0]);
// case ["/", "version/" or "version"]:
// return HandleVersion(response, options);
// }
//
// return null;
// }
//
// private static async ValueTask HandleRoot(HttpListenerResponse response, StatusListenerOptions options)
// {
// response.ContentType = MediaTypeNames.Text.Plain;
// await using (response.OutputStream)
// await response.OutputStream.WriteAsync(options.RootBytes);
// }
//
// private static async ValueTask HandleVersion(HttpListenerResponse response, StatusListenerOptions options)
// {
// response.ContentType = JsonContentType;
// await using (response.OutputStream)
// await response.OutputStream.WriteAsync(options.VersionBytes);
// }
//
// private static async ValueTask HandleHealth(HttpListenerResponse response, StatusListenerOptions options, HealthCheckService checkService,
// string? tag)
// {
// var result = await checkService.CheckHealthAsync(tag == null ? null : check => check.Tags.Contains(tag), CancellationToken.None);
//
// response.StatusCode = (int)(result.Status switch
// {
// HealthStatus.Healthy => HttpStatusCode.OK,
// HealthStatus.Degraded => HttpStatusCode.TooManyRequests,
// HealthStatus.Unhealthy => HttpStatusCode.ServiceUnavailable,
// });
//
// // Similar to: https://github.com/aspnet/Security/blob/7b6c9cf0eeb149f2142dedd55a17430e7831ea99/src/Microsoft.AspNetCore.Authentication.Cookies/CookieAuthenticationHandler.cs#L377-L379
// var headers = response.Headers;
// headers.Add(HttpResponseHeader.CacheControl, "no-store, no-cache");
// headers.Add(HttpResponseHeader.Pragma, "no-cache");
// headers.Add(HttpResponseHeader.Expires, "Thu, 01 Jan 1970 00:00:00 GMT");
//
// response.ContentType = JsonContentType;
// await using (response.OutputStream)
// await JsonSerializer.SerializeAsync(response.OutputStream, result, options.SerializerOptions, CancellationToken.None);
// }
// }
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text;
using System.Text.Json;
namespace XXX.HealthCheck;
public class StatusListenerOptions
{
public StatusListenerOptions() : this(null) { }
public StatusListenerOptions(string? endpoint = null, string? rootResponse = null, string? version = null,
JsonSerializerOptions? serializerOptions = null /*, bool useConformingHttpListener = false*/)
{
ListenOn = endpoint ?? "0:8080";
SerializerOptions = serializerOptions ?? JsonSerializerOptions.Default;
Assembly? entryAssembly = null;
if (version == null || rootResponse == null)
entryAssembly = Assembly.GetEntryAssembly();
Version = version ?? entryAssembly!.GetCustomAttribute<AssemblyInformationalVersionAttribute>()!.InformationalVersion;
Root = rootResponse ?? entryAssembly!.GetName().Name!;
// UseConformingHttpListener = useConformingHttpListener;
}
// /// <summary>Can not be configured from code.</summary>
// public bool UseConformingHttpListener { get; set; }
public string ListenOn { get; set; }
public JsonSerializerOptions SerializerOptions { get; set; }
[MemberNotNull(nameof(VersionBytes))]
public string Version { set => VersionBytes = JsonSerializer.SerializeToUtf8Bytes(new { version = value }, SerializerOptions); }
/// application/json; charset=utf-8
public byte[] VersionBytes { get; set; }
[MemberNotNull(nameof(RootBytes))]
public string Root { set => RootBytes = Encoding.UTF8.GetBytes(value); }
/// text/plain
public byte[] RootBytes { get; set; }
}
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using Common;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
namespace XXX.HealthCheck;
public sealed class TcpHttpStatusListener : BackgroundService
{
private readonly ILogger<TcpHttpStatusListener> logger;
private readonly HealthCheckService checkService;
private readonly StatusListenerOptions options;
public TcpHttpStatusListener(ILogger<TcpHttpStatusListener> logger, HealthCheckService checkService,
IOptions<StatusListenerOptions> options)
{
this.logger = logger;
this.checkService = checkService;
this.options = options.Value;
if (this.options.ListenOn.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"{nameof(TcpHttpStatusListener)} does not support https scheme");
if (this.options.ListenOn.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
this.options.ListenOn = this.options.ListenOn["http://".Length..];
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var listener = new TcpListener(IPEndPoint.Parse(options.ListenOn));
logger.LogInformation("Status endpoint listening: {urls}", listener.LocalEndpoint);
listener.Start();
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var ctx = await listener.AcceptTcpClientAsync(stoppingToken);
await using var stream = ctx.GetStream();
await HandleRequest(stream);
}
catch (Exception e)
{
if (e.IsNotCancellation(stoppingToken))
logger.LogError(e, "Exception while processing status request");
}
}
listener.Stop();
}
private static readonly Uri DummyBase = new("http://a");
private async ValueTask HandleRequest(NetworkStream stream)
{
try
{
var path = ParseRequestLine(stream);
if (path != null)
{
logger.LogInformation("status: GET {path}", path);
if (Uri.TryCreate(DummyBase, path, out var uri)) // uri must be absolute
{
if (ExecuteHandler(uri, stream) is { } task)
{
await task;
return;
}
}
}
logger.LogWarning("status: invalid request");
stream.Write("HTTP/1.0 400 Bad Request\r\n\r\n"u8);
}
catch when (stream.CanWrite)
{
try { stream.Write("HTTP/1.0 500 Internal Service Error\r\n\r\n"u8); }
catch
{
// ignored
}
throw;
}
}
[SuppressMessage("Reliability", "CA2012:Use ValueTasks correctly")]
private ValueTask? ExecuteHandler(Uri uri, NetworkStream stream)
{
switch (uri.Segments.AsSpan())
{
case ["/"]:
return HandleRoot(stream, options);
case ["/", "health/" or "health", .. var rest]:
if (rest.Length > 1)
return null;
return HandleHealth(stream, options, checkService, rest.Length == 0 ? null : rest[0]);
case ["/", "version/" or "version"]:
return HandleVersion(stream, options);
}
return null;
}
private static string? ParseRequestLine(NetworkStream stream)
{
// minimal is "GET / HTTP/1.0\r\n\r\n" -- 18
Span<byte> buf = stackalloc byte[64];
int i, totalRead = 0;
scoped Span<byte> line;
do
{
// any sane client will send it in 1 packet, so we will use sync
int read = stream.Read(buf[totalRead..]);
if (read == 0)
return null;
if ((i = buf.Slice(totalRead, read).IndexOf((byte)'\n')) != -1)
{
line = buf[..(totalRead + i - 1)];
// totalRead += read;
break;
}
totalRead += read;
} while (true);
if (!line.StartsWith("GET /"u8))
return null;
line = line[4..]; // strip "GET "
if ((i = line.IndexOf((byte)' ')) == -1)
return null;
var path = line[..i];
#pragma warning disable RS0030
return Encoding.ASCII.GetString(path);
#pragma warning restore RS0030
}
private static ValueTask HandleRoot(NetworkStream stream, StatusListenerOptions options)
{
stream.Write("HTTP/1.0 200 Ok\r\n"u8 +
"Content-Type: text/plain\r\n"u8 +
"\r\n"u8);
stream.Write(options.RootBytes);
return default;
}
private static ValueTask HandleVersion(NetworkStream stream, StatusListenerOptions options)
{
stream.Write("HTTP/1.0 200 Ok\r\n"u8 +
"Content-Type: application/json; charset=utf-8\r\n"u8 +
"\r\n"u8);
stream.Write(options.VersionBytes);
return default;
}
private static async ValueTask HandleHealth(NetworkStream stream, StatusListenerOptions options, HealthCheckService checkService,
string? tag)
{
var result = await checkService.CheckHealthAsync(tag == null ? null : check => check.Tags.Contains(tag), CancellationToken.None);
stream.Write("HTTP/1.0 "u8);
stream.Write(result.Status switch
{
HealthStatus.Healthy => "200 Ok"u8,
HealthStatus.Degraded => "429 Too Many Requests"u8,
HealthStatus.Unhealthy => "503 Service Unavailable"u8,
});
stream.Write("\r\n"u8 +
// Similar to: https://github.com/aspnet/Security/blob/7b6c9cf0eeb149f2142dedd55a17430e7831ea99/src/Microsoft.AspNetCore.Authentication.Cookies/CookieAuthenticationHandler.cs#L377-L379
"Cache-Control: no-store, no-cache\r\n"u8 +
"Pragma: no-cache\r\n"u8 +
"Expires: Thu, 01 Jan 1970 00:00:00 GMT\r\n"u8 +
"Content-Type: application/json; charset=utf-8\r\n"u8 +
"\r\n"u8);
await JsonSerializer.SerializeAsync(stream, result, options.SerializerOptions, CancellationToken.None);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment