Created
March 12, 2024 16:29
-
-
Save OwnageIsMagic/4d77e4dd4da9d281ce4a5a8172347a89 to your computer and use it in GitHub Desktop.
TcpListnerHttpHealthCheck
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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); | |
// } | |
// } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; } | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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