dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
34.81k stars 9.84k forks source link

Lack of proxy-protocol support prevents use of Kestrel with HAproxy in TCP mode #28526

Open ststeiger opened 3 years ago

ststeiger commented 3 years ago

One of the advantages of using haproxy over nginx is, that you don't need nginx-PLUS (commercial offering) if you want to add SNI-hosts dynamically. Another advantages of using HAproxy over nginx is that HAproxy can do SSL/TLS-passthrough forwarding. Meaning HAproxy then doesn't need the ssl certificate etc, which is great, because then I only need to configure the SSL-key on Kestrel - especially if the SSL/TLS certificate changes at runtime (LetsEncrypt).

A further advantage is, that a TCP-level-proxy will not be limited by a proxy-pipeline, e.g. interfering with HTTP 1.1/2.0.

Now, I have multiple sites running on the same IP. The way to do this with HAproxy is sni-header inspection.
But because Kestrel does not support the proxy protocol, it's impossible to get the client's IP .

GoLang has support for that since at least 4 years... By breaking the ability to get the client's IP, you're breaking the ability to geolocate - which breaks the ability to adapt content to a user's region ...

When an intermediary service such as a proxy server or load balancer forwards an HTTP request, it appends the source address of the connection to the request’s "Forwarded" header in order to provide this information to subsequent intermediaries and to the back-end service to which the request is ultimately forwarded. However, if the connection is encrypted, intermediaries cannot modify the "Forwarded" header. In this case, the HTTP header will not accurately communicate the original source address when the request is forwarded.

To solve this problem, some load balancers encapsulate HTTP requests using the PROXY protocol as an alternative to simply forwarding HTTP. Encapsulation enables the load balancer to add information to the request without modifying the forwarded request itself. In particular, this means that the load balancer can communicate the source address even when forwarding an encrypted connection.

If Kestrel could be configured to accept PROXY-protocol connections, it could decapsulate the HTTP request. Since Kestrel is the route termination, it could decrypt the request, and update the "Forwarded" HTTP header (and related HTTP headers) appending any source address that is communicated using the PROXY protocol.

e.g.

frontend https
    bind :443
    mode tcp
    option tcplog
    tcp-request inspect-delay 5s
    tcp-request content accept if { req.ssl_hello_type 1 }

    use_backend awx_example_com if { req_ssl_sni -i awx.example.com }
    use_backend goren_example_com if { req_ssl_sni -i goren.example.com }
    use_backend tower_example_com if { req_ssl_sni -i tower.example.com }

backend awx_example_com
    server awx 192.168.101.182:5001 send-proxy-v2

backend goren_example_com
    server goren 192.168.101.182:5002 send-proxy-v2

backend tower_example_com
    server tower 192.168.101.182:443 se5003nd-proxy-v2
Tratcher commented 3 years ago

It's not natively supported, but you can plug it in. Here's an example: https://github.com/aspnet/AspLabs/blob/452634b6aff935dba07181fe70f614d5788076e0/src/ProxyProtocol/ProxyProtocol.Sample/Program.cs#L38

Notes for triage: This makes the third request for this feature (the other two were internal). It's not a lot, but it's slowly gaining interest.

davidfowl commented 3 years ago

Golang doesn't have this built in or maybe you meant something else because that link above is a library written in go, not the core libraries...

ghost commented 3 years ago

We've moved this issue to the Backlog milestone. This means that it is not going to be worked on for the coming release. We will reassess the backlog following the current release and consider this item at that time. To learn more about our issue management process and to have better expectation regarding different types of issues you can read our Triage Process.

ststeiger commented 3 years ago

@Tratcher: Thanks for that ! Had a fight with HAproxy config file. And the result is - WoW, cool, it looks like it works, even with wildcard SNI ! I will check that in detail this evening.

@davidfowl: What I meant by that was that there's a library out there to support proxy-protocol since 4 years. I searched for a .NET/Core library on google and found nothing. But the code from AspLabs seems to work for what I have tested so far - so this is solved now, for the time being anyway. Don't know if this works for gzip/br content, but I will find out. Now whatever the finding is, I have something to build on.

ststeiger commented 3 years ago

@Tratcher: There was another public one: https://github.com/dotnet/aspnetcore/issues/10645 unless strainovic works for msft. Since that issue had no solution, I opened this issue.

Tratcher commented 3 years ago

@ststeiger Thanks, I for got about that one.

dferretti commented 3 years ago

@Tratcher 👋 I'll add one more to the feature request count if I can. I'll try out the example you posted but it would be awesome if it was built in. In AWS using the Network Load Balancers, if you want to see the client's IP and you are registering targets using IP address (as is the case when using Fargate) they suggest enabling Proxy Protocol v2 as well.

davidfowl commented 3 years ago

It doesn't need to be built in. I'd like to see the mileage we get with that sample code before we prioritize it. I don't this this is built into many platform webservers out there.

gitanuj commented 2 years ago

It'll be nice to have this officially supported in .NET Core. We're trying to use Azure Private Link Service with TCP Proxy v2 in some backend services for Azure Cosmos DB and that requires supporting proxy-protocol. For now I'll give this sample a try and see if it works.

ststeiger commented 2 years ago

It works with nginx. It doesn't work with SSL passthrough in HAproxy, for unknown reason. Maybe I don't need it in HAproxy when I do SSL passthrough, though.

ststeiger commented 2 years ago

@Tratcher: One more thing: That doesn't work with SSL passthrough in HAproxy. It works with nginx. I guess it's a bug or missing-feature somewhere.

Here my HAproxy config, for reproduction:


# /etc/haproxy/haproxy.cfg

# Validate: 
# haproxy -c -V -f /etc/haproxy/haproxy.cfg
# Another way is to 
# sudo service haproxy configtest

global
    log /dev/log    local0
    log /dev/log    local1 notice
    chroot /var/lib/haproxy
    stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
    stats timeout 30s
    user haproxy
    group haproxy
    daemon

    # Default SSL material locations
    ca-base /etc/ssl/certs
    crt-base /etc/ssl/private

    # See: https://ssl-config.mozilla.org/#server=haproxy&server-version=2.0.3&config=intermediate
        ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
        ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
        ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets

defaults
    log global
    mode    http
    option  httplog
    option  dontlognull
        timeout connect 5000
        timeout client  50000
        timeout server  50000
    errorfile 400 /etc/haproxy/errors/400.http
    errorfile 403 /etc/haproxy/errors/403.http
    errorfile 408 /etc/haproxy/errors/408.http
    errorfile 500 /etc/haproxy/errors/500.http
    errorfile 502 /etc/haproxy/errors/502.http
    errorfile 503 /etc/haproxy/errors/503.http
    errorfile 504 /etc/haproxy/errors/504.http

frontend http
    bind *:80
    mode http
    option forwardfor
    # option httpchk /check.cfm
    # use-server  server1 if { hdr(host) -i server1.domain.net }
    # use-server  server2 if { hdr(host) -i server2.domain.net }
    # server server1 localhost:22201 check
    # server server2 localhost:22202 check
    # default_backend nodes
    # redirect scheme https code 301 if !{ ssl_fc }

    # http://192.168.1.2/.well-known/acme-challenge/token.txt
    # http://88.84.21.77/.well-known/acme-challenge/token.txt
    # http://daniel-steiger.ch/.well-known/acme-challenge/token.txt
    # http://henri-bernhard.ch/.well-known/acme-challenge/token.txt

    # https://www.haproxy.com/documentation/aloha/12-5/traffic-management/lb-layer7/acls/  
    # For ACLs sharing the same name, the following rules apply:
    # It is possible to use the same <aclname> for many ACLs, even if they do not have the same matching criterion
    # A logical OR applies between all of them

    # acl daniel_steiger_ch dst 192.168.1.2
    # acl daniel_steiger_ch dst 88.84.21.77

    acl daniel_steiger_ch  hdr(host)     -i 88.84.21.77
    acl daniel_steiger_ch  hdr(host)     -i 192.168.1.2

    acl daniel_steiger_ch  hdr(host)     -i daniel-steiger.ch
    acl daniel_steiger_ch  hdr(host)     -m end .daniel-steiger.ch

    acl henri_bernhard_ch  hdr(host)     -i henri-bernhard.ch
    acl henri_bernhard_ch  hdr(host)     -m end .henri-bernhard.ch

    #use_backend http_daniel_steiger_ch if { hdr(host) -i daniel-steiger.ch }
    #use_backend http_daniel_steiger_ch if { hdr(host) -m end .daniel-steiger.ch }

    use_backend http_daniel_steiger_ch if daniel_steiger_ch 
    use_backend http_henri_bernhard_ch if henri_bernhard_ch 

backend http_daniel_steiger_ch
    mode http
    balance roundrobin
    server web0 127.0.0.1:5006

backend http_henri_bernhard_ch
    mode http
    balance roundrobin
    server web0 127.0.0.1:5008

#backend nodes
#    mode http
#    balance roundrobin
#    option forwardfor
#    reqirep ^Host: Host:\ node1.myapp.mycompany.com
#    server web01 node1.myapp.mycompany.com:80

# sudo systemctl stop nginx
# sudo systemctl disable nginx

# sudo systemctl enable haproxy
# service haproxy start
# sudo haproxy -c -V -f /etc/haproxy/haproxy.cfg
# service haproxy restart

frontend https
    bind *:443
    mode tcp
    option tcplog
    tcp-request inspect-delay 5s
    tcp-request content accept if { req.ssl_hello_type 1 }
    #tcp-request content accept if { req_ssl_hello_type 1 }

    # https://datamakes.com/2018/02/17/high-intensity-port-sharing-with-haproxy/
    # systemctl restart sshd
    # systemctl disable sshd
    # systemctl enable sshd
    # sudo apt-get install openssh-server
    # sudo systemctl status ssh
    # sudo ufw allow ssh
    # sudo ufw enable
    # sudo ufw status
    # ufw allow 443/tcp
    # ufw allow 8443/tcp

    # /mnt/sshfs/var/www/.dotnet/corefx/cryptography/crls/
    # sudo apt-get install exfat-utils exfat-fuse

    # https://192.168.1.2/.well-known/acme-challenge/token.txt
    # https://88.84.21.77/.well-known/acme-challenge/token.txt
    # http://daniel-steiger.ch/.well-known/acme-challenge/token.txt
    # http://henri-bernhard.ch/.well-known/acme-challenge/token.txt

    # https://www.haproxy.com/documentation/aloha/12-5/traffic-management/lb-layer7/acls/  
    # For ACLs sharing the same name, the following rules apply:
    # It is possible to use the same <aclname> for many ACLs, even if they do not have the same matching criterion
    # A logical OR applies between all of them

  # sequence matters ! 

    # having these two lines here blocks ssh if use_backend openssh comes afterwards ...
    # also, this fucks up SNI ...
    # acl daniel_steiger_ch dst 192.168.1.2
    # acl daniel_steiger_ch dst 88.84.21.77

    acl daniel_steiger_ch req_ssl_sni -i daniel-steiger.ch
    acl daniel_steiger_ch req.ssl_sni -m end .daniel-steiger.ch

    acl henri_bernhard_ch req_ssl_sni -i henri-bernhard.ch
    acl henri_bernhard_ch req.ssl_sni -m end .henri-bernhard.ch

    # wildcard
    use_backend https_daniel_steiger_ch if daniel_steiger_ch
    use_backend https_henri_bernhard_ch if henri_bernhard_ch

    # use_backend example_int if { req_ssl_sni -i example.int }
    # use_backend example_int if { req_ssl_sni -m end .example.int }

    # use_backend example_int if { req_ssl_sni -i example.int }
    # use_backend foo_int if { req_ssl_sni   -i foo.int }
    # use_backend bar_int if { req_ssl_sni -i bar.int }

# sudo haproxy -c -V -f /etc/haproxy/haproxy.cfg

backend https_daniel_steiger_ch
    mode tcp
    balance roundrobin
    server web0 127.0.0.1:5005 # send-proxy-v2

backend https_henri_bernhard_ch
    mode tcp
    balance roundrobin
    server web0 127.0.0.1:5007 # send-proxy-v2

backend foo_int
    balance roundrobin
    server web1 127.0.0.1:5005 send-proxy

backend bar_int 
    balance roundrobin
    server web2 127.0.0.1:5005 ##send-proxy
Tratcher commented 2 years ago

@ststeiger You'll need to figure out which to process first, the TLS or the PPv2. The order you add them is the order they're applied.

TLS first:

                        options.ListenAnyIP(5000, listenOptions =>
                        {
                            listenOptions.UseHttps(...);
                            listenOptions.Use(async (connectionContext, next) =>
                            {
                                await ProxyProtocol.ProcessAsync(connectionContext, next, logger);
                            });
                        });

PPv2 first:

                        options.ListenAnyIP(5000, listenOptions =>
                        {
                            listenOptions.Use(async (connectionContext, next) =>
                            {
                                await ProxyProtocol.ProcessAsync(connectionContext, next, logger);
                            });
                            listenOptions.UseHttps(...);
                        });
georgiosd commented 2 years ago

@ststeiger @Tratcher very illuminating! This is exciting. What is the recommended setup to make it work with HTTP2? Any references welcome.

ststeiger commented 2 years ago

@georgiosd: I think this would be TCP/UDP level. On my test-website, it already works with HTTP 2.0 and gzip/brotli/deflate. See https://henri-bernhard.ch. Chrome shows protocol as h2.

@Tratcher: Thanks in hindsight for that tip. It was exactly that - nginx first decrypted, then proxied, then re-encryptedn, then forwarded. HAproxy instead takes the encrypted - then proxies - then forwards - in passthrough-proxy mode.

If you think about it, it's logical. Finding that one without knowing what you're looking for would have been hard. So thanks again - much obliged !

ststeiger commented 2 years ago

@georgiosd: Well, I can give you what I have, but it's not necessarely a recommended setup. I think the major pitfalls are LetsEncrypt, HSTS with HTTPS-Redirection (the port), and SSL-certificate synchronization with the web-server, if you don't use SSL-passthrough (seriously, use SSL-passthrough, otherwise it's f***ed).

I think the important part is

Here My Startup.cs


using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

using System;
using System.Linq;
using System.Threading.Tasks;
using System.Collections.Generic;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.AspNetCore.Rewrite;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Http;

using AspNetCore.SEOHelper;

namespace HomepageHenri
{

    public class Startup
    {

        protected IConfiguration m_configuration;
        protected Microsoft.AspNetCore.Hosting.IWebHostEnvironment m_environment;

        public Startup(IConfiguration configuration, Microsoft.AspNetCore.Hosting.IWebHostEnvironment env)
        {
            this.m_configuration = configuration;
            this.m_environment = env;

            HomepageHenri.WebTools.Configure(env);

            //var builder = new ConfigurationBuilder()
            //    .SetBasePath(env.ContentRootPath)
            //    .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
            //    .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
            //    .AddEnvironmentVariables();
            //Configuration = builder.Build();

        }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddSingleton<IConfigureOptions<KestrelServerOptions>, KestrelOptionsSetup>(); // Why transient ? 

            services.AddHttpsRedirection(
                delegate(HttpsRedirectionOptions options)
                {
                    options.RedirectStatusCode = Microsoft.AspNetCore.Http.StatusCodes.Status308PermanentRedirect;
                    bool isWindows = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows);
                    bool behindReverseProxy = "INSERT_PRODUCTION_SERVER_HOST_NAME_HERE".Equals(System.Environment.MachineName, System.StringComparison.InvariantCultureIgnoreCase);
                    bool useKestrel = true;
                    // https://stackoverflow.com/questions/42272021/check-if-asp-netcore-application-is-hosted-in-iis
                    // https://docs.microsoft.com/en-us/previous-versions/iis/6.0-sdk/ms524602%28v%3Dvs.90%29
                    if (System.Environment.GetEnvironmentVariable("APP_POOL_ID") is string)
                        useKestrel = false;

                    if (useKestrel || !isWindows)
                    {
                        PseudoUrl url = this.m_configuration.GetValue<string>("Kestrel:EndPoints:Https:Url", null);
                        if (url != null)
                            options.HttpsPort = url.Port;
                        else
                            options.HttpsPort = 17005;

                        // if(!isWindows || !this.m_environment.IsDevelopment())
                        // { options.HttpsPort = 443; }

                    }
                    else
                    {
                        // PseudoUrl url = this.Configuration.GetValue<string>("iisSettings:iisExpress:applicationUrl", null);
                        options.HttpsPort = this.m_configuration.GetValue<int>("iisSettings:iisExpress:sslPort", 443);
                    }

                    if (useKestrel && behindReverseProxy)
                        options.HttpsPort = 443;
                }
            );

            //services.AddHsts(options =>
            //{
            //    options.Preload = true;
            //    options.IncludeSubDomains = true;
            //    options.MaxAge = TimeSpan.FromDays(60);
            //    options.ExcludedHosts.Add("example.com");
            //    options.ExcludedHosts.Add("www.example.com");
            //});

            services.AddControllersWithViews();

            // https://dotnetcoretutorials.com/2017/01/24/using-gzip-compression-asp-net-core/
            // services.AddResponseCompression();
            services.AddResponseCompression(options =>
            {
                options.EnableForHttps = true;

                options.Providers.Add(new BrotliCompressionProvider());
                options.Providers.Add(new GzipCompressionProvider());
                options.Providers.Add(new DeflateCompressionProvider());

                options.MimeTypes = new[] { 
                     "text/plain", "text/html", "text/css"
                    ,"application/javascript", "application/json", "application/xml"
                    ,"image/x-icon", "image/png", "image/gif", "image/jpeg", "image/webp", "image/tiff", "image/svg+xml"
                };
            });

            services.AddSingleton<Microsoft.AspNetCore.Http.IHttpContextAccessor, Microsoft.AspNetCore.Http.HttpContextAccessor>();
            services.AddSingleton<Microsoft.AspNetCore.Mvc.Infrastructure.IActionContextAccessor, Microsoft.AspNetCore.Mvc.Infrastructure.ActionContextAccessor>();

            // For use in _layout.cs
            services.AddTransient<HomepageHenri.Model.Navigation.MainNavigationData>();

            // Quartz.IJob
            services.AddTransient<CertificateRefreshJob>();
            services.AddSingleton<JobScheduler>();
        }

        public void Configure(IApplicationBuilder app
            , IWebHostEnvironment env
            , Microsoft.Extensions.Logging.ILoggerFactory loggerFactory)
        {
            string virtual_directory = "/Virt_DIR";
            virtual_directory = "/";

            if (virtual_directory.EndsWith("/"))
                virtual_directory = virtual_directory.Substring(0, virtual_directory.Length - 1);

            if (string.IsNullOrWhiteSpace(virtual_directory))
                ConfigureMapped(app, env, loggerFactory); // Don't map if you don't have to 
            else
                // app.Map("/Virt_DIR", (mappedApp) => Configure1(mappedApp, env, loggerFactory));
                app.Map(virtual_directory, delegate (IApplicationBuilder mappedApp)
                {
                    ConfigureMapped(mappedApp, env, loggerFactory);
                }
                );
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void ConfigureMapped(IApplicationBuilder app, IWebHostEnvironment env
            , Microsoft.Extensions.Logging.ILoggerFactory loggerFactory
            )
        {
            //loggerFactory.AddConsole(Configuration.GetSection("Logging"));
            //loggerFactory.AddDebug();

            System.Web.HttpContext.Configure(app.ApplicationServices.
                GetRequiredService<Microsoft.AspNetCore.Http.IHttpContextAccessor>()
            );

            System.Web.HttpContext.ConfigureActionContext(app.ApplicationServices.
                GetRequiredService<Microsoft.AspNetCore.Mvc.Infrastructure.IActionContextAccessor>()
            );

            app.UseForwardedHeaders(new ForwardedHeadersOptions
            {
                ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor
                | Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto
                | Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedHost
            });

            app.UseMiddleware<LetsEncryptChallengeApprovalMiddleware>();

            app.UseResponseCompression();

            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
                // app.UseDeveloperExceptionPage(new DeveloperExceptionPageOptions() { SourceCodeLineCount = 100 });
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
                // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
                app.UseHsts();
            }

            // app.UseStatusCodePages();

            // https://docs.microsoft.com/en-us/aspnet/core/fundamentals/error-handling?view=aspnetcore-6.0#:~:text=The%20Developer%20Exception%20Page%20displays,running%20in%20the%20Development%20environment.&text=Detailed%20exception%20information%20shouldn't,runs%20in%20the%20Production%20environment.
            app.UseStatusCodePages(
                async delegate(Microsoft.AspNetCore.Diagnostics.StatusCodeContext statusCodeContext )
                {
                    // using static System.Net.Mime.MediaTypeNames;
                    statusCodeContext.HttpContext.Response.ContentType = System.Net.Mime.MediaTypeNames.Text.Html;

                    string errorDesc = HttpErrorHelper.GetStatusDescription(statusCodeContext.HttpContext.Response.StatusCode);

                    await statusCodeContext.HttpContext.Response.WriteAsync(
                        $@"<!DOCTYPE html>
<html>
<head>
    <title>Error: {errorDesc}
</title>
</head>
<body>
<h1>HTTP-Status-Code: {statusCodeContext.HttpContext.Response.StatusCode}</h1>
<a href=""https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#{statusCodeContext.HttpContext.Response.StatusCode}""><p>{errorDesc}</p></a>
<p>Please move on</p>
</body>
</html>");
                }
            );

            // app.UseMiddleware<CustomMiddleware>();
            // // app.UseMiddleware<ErrorMiddleware>();

            // app.UseHttpsRedirection();
            // https://stackoverflow.com/questions/52347936/exclude-route-from-middleware-net-core
            app.UseWhen(
                delegate (Microsoft.AspNetCore.Http.HttpContext httpContext)
                {
                    // https://localhost:44396/.well-known/acme-challenge/token.txt
                    // https://localhost:44396/Profile

                    // http://localhost:17006/.well-known/acme-challenge/token.txt
                    // https://localhost:17005/.well-known/acme-challenge/token.txt
                    // https://localhost:17005/Profile

                    return !httpContext.Request.Path.StartsWithSegments("/.well-known/acme-challenge");
                }
                ,
                delegate (IApplicationBuilder appBuilder)
                {
                    // appBuilder.UseHttpsRedirection();
                    appBuilder.UseDebugHttpsRedirection();

                    appBuilder.UseMiddleware<DebugHttpsRedirectionMiddleware>();

                }
            );

            // Permanently redirect www to top-level-domain
            RewriteOptions options = new RewriteOptions();
            options.AddRedirectToNonWwwPermanent();
            options.AddRedirectToHttps();
            app.UseRewriter(options);

            app.UseRobotsTxt(env.WebRootPath);

            SitemapHelper.CreateSitemap(env.WebRootPath);
            app.UseXMLSitemap(env.WebRootPath);

            app.UseRouting();

            // https://stackoverflow.com/questions/60791843/changing-routedata-in-asp-net-core-3-1-in-middleware
            // Note: Sequence matters ! After app.UseRouting, but before app.UseEndpoints
            app.UseHostHeaderData();

            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllerRoute(
                    name: "default",
                    pattern: "{controller=Home}/{action=Index}/{id?}");
            });

            app.UseCaptchaMiddleWare();

            app.UseDefaultFiles(new DefaultFilesOptions()
            {
                DefaultFileNames = new System.Collections.Generic.List<string>()
                {
                    "index.htm", "index.html"
                }
            });

            // https://stackoverflow.com/questions/38231739/how-to-disable-browser-cache-in-asp-net-core-rc2
            // https://stackoverflow.com/questions/33342643/how-does-javascript-version-asp-append-version-work-in-asp-net-core-mvc
            app.UseStaticFiles(new StaticFileOptions()
            {
                ServeUnknownFileTypes = true,
                DefaultContentType = "application/octet-stream",
                ContentTypeProvider = new ExtensionContentTypeProvider(),

                OnPrepareResponse = delegate (Microsoft.AspNetCore.StaticFiles.StaticFileResponseContext context)
                {
                    // https://stackoverflow.com/questions/49547/how-do-we-control-web-page-caching-across-all-browsers

                    // The Cache-Control is per the HTTP 1.1 spec for clients and proxies
                    // If you don't care about IE6, then you could omit Cache-Control: no-cache.
                    // (some browsers observe no-store and some observe must-revalidate)
                    context.Context.Response.Headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0";
                    // Other Cache-Control parameters such as max-age are irrelevant 
                    // if the abovementioned Cache-Control parameters (no-cache,no-store,must-revalidate) are specified.

                    // Expires is per the HTTP 1.0 and 1.1 specs for clients and proxies. 
                    // In HTTP 1.1, the Cache-Control takes precedence over Expires, so it's after all for HTTP 1.0 proxies only.
                    // If you don't care about HTTP 1.0 proxies, then you could omit Expires.
                    context.Context.Response.Headers["Expires"] = "-1, 0, Tue, 01 Jan 1980 1:00:00 GMT";

                    // The Pragma is per the HTTP 1.0 spec for prehistoric clients, such as Java WebClient
                    // If you don't care about IE6 nor HTTP 1.0 clients 
                    // (HTTP 1.1 was introduced 1997), then you could omit Pragma.
                    context.Context.Response.Headers["pragma"] = "no-cache";

                    // On the other hand, if the server auto-includes a valid Date header, 
                    // then you could theoretically omit Cache-Control too and rely on Expires only.

                    // Date: Wed, 24 Aug 2016 18:32:02 GMT
                    // Expires: 0

                    // But that may fail if e.g. the end-user manipulates the operating system date 
                    // and the client software is relying on it.
                    // https://stackoverflow.com/questions/21120882/the-date-time-format-used-in-http-headers
                }

            });

            JobScheduler scheduler = app.ApplicationServices.GetRequiredService<JobScheduler>();
            _ = scheduler.StartJobs();
        }// End Sub 

    } // End Class Startup 

} // End Namespace HomepageHenri 

And this is the haproxy config file

# /etc/haproxy/haproxy.cfg

# Validate:
# haproxy -c -V -f /etc/haproxy/haproxy.cfg
# Another way is to
# sudo service haproxy configtest

global
        log /dev/log    local0
        log /dev/log    local1 notice
        chroot /var/lib/haproxy
        stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
        stats timeout 30s
        user haproxy
        group haproxy
        daemon

        # Default SSL material locations
        ca-base /etc/ssl/certs
        crt-base /etc/ssl/private

        # See: https://ssl-config.mozilla.org/#server=haproxy&server-version=2.0.3&config=intermediate
        ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
        ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
        ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets

defaults
        log     global
        mode    http
        option  httplog
        option  dontlognull
        timeout connect 5000
        timeout client  50000
        timeout server  50000
        errorfile 400 /etc/haproxy/errors/400.http
        errorfile 403 /etc/haproxy/errors/403.http
        errorfile 408 /etc/haproxy/errors/408.http
        errorfile 500 /etc/haproxy/errors/500.http
        errorfile 502 /etc/haproxy/errors/502.http
        errorfile 503 /etc/haproxy/errors/503.http
        errorfile 504 /etc/haproxy/errors/504.http

frontend http
    bind *:80
    mode http
    option forwardfor
    # option httpchk /check.cfm
    # use-server  server1 if { hdr(host) -i server1.domain.net }
    # use-server  server2 if { hdr(host) -i server2.domain.net }
    # server server1 localhost:22201 check
    # server server2 localhost:22202 check
    # default_backend nodes
    # redirect scheme https code 301 if !{ ssl_fc }

    # http://10.0.1.2/.well-known/acme-challenge/token.txt
    # http://88.84.21.77/.well-known/acme-challenge/token.txt
    # http://daniel-steiger.ch/.well-known/acme-challenge/token.txt
    # http://henri-bernhard.ch/.well-known/acme-challenge/token.txt

    # https://www.haproxy.com/documentation/aloha/12-5/traffic-management/lb-layer7/acls/
    # For ACLs sharing the same name, the following rules apply:
    # It is possible to use the same <aclname> for many ACLs, even if they do not have the same matching criterion
    # A logical OR applies between all of them

    # acl daniel_steiger_ch dst 10.0.1.2
    # acl daniel_steiger_ch dst 88.84.21.77

    acl daniel_steiger_ch  hdr(host)     -i 88.84.21.77
    acl daniel_steiger_ch  hdr(host)     -i 10.0.1.2

    acl daniel_steiger_ch  hdr(host)     -i daniel-steiger.ch
    acl daniel_steiger_ch  hdr(host)     -m end .daniel-steiger.ch

    acl henri_bernhard_ch  hdr(host)     -i henri-bernhard.ch
    acl henri_bernhard_ch  hdr(host)     -m end .henri-bernhard.ch

    #use_backend http_daniel_steiger_ch if { hdr(host) -i daniel-steiger.ch }
    #use_backend http_daniel_steiger_ch if { hdr(host) -m end .daniel-steiger.ch }

    use_backend http_daniel_steiger_ch if daniel_steiger_ch
    use_backend http_henri_bernhard_ch if henri_bernhard_ch

backend http_daniel_steiger_ch
    mode http
    balance roundrobin
    server web0 127.0.0.1:17006

backend http_henri_bernhard_ch
    mode http
    balance roundrobin
    server web0 127.0.0.1:17008

#backend nodes
#    mode http
#    balance roundrobin
#    option forwardfor
#    reqirep ^Host: Host:\ node1.myapp.mycompany.com
#    server web01 node1.myapp.mycompany.com:80

# sudo systemctl stop nginx
# sudo systemctl disable nginx

# sudo systemctl enable haproxy
# service haproxy start
# sudo haproxy -c -V -f /etc/haproxy/haproxy.cfg
# service haproxy restart

frontend https
    bind *:443
    mode tcp
    option tcplog
    tcp-request inspect-delay 5s
    tcp-request content accept if { req.ssl_hello_type 1 }
    #tcp-request content accept if { req_ssl_hello_type 1 }

    # https://datamakes.com/2018/02/17/high-intensity-port-sharing-with-haproxy/
    # systemctl restart sshd
    # systemctl disable sshd
    # systemctl enable sshd
    # sudo apt-get install openssh-server
    # sudo systemctl status ssh
    # sudo ufw allow ssh
    # sudo ufw enable
    # sudo ufw status
    # ufw allow 443/tcp
    # ufw allow 8443/tcp
    # /etc/ssh/sshd_config  ==> PermitRootLogin yes  + PasswordAuthentication no + ChallengeResponseAuthentication no  ~/.ssh/id_rsa.pub ==> ~/.ssh/authorized_keys
    acl ssh_payload payload(0,7) -m bin 5353482d322e30

    # /mnt/sshfs/var/www/.dotnet/corefx/cryptography/crls/
    # sudo apt-get install exfat-utils exfat-fuse

    # https://10.0.1.2/.well-known/acme-challenge/token.txt
    # https://88.84.21.77/.well-known/acme-challenge/token.txt
    # http://daniel-steiger.ch/.well-known/acme-challenge/token.txt
    # http://henri-bernhard.ch/.well-known/acme-challenge/token.txt

    # https://www.haproxy.com/documentation/aloha/12-5/traffic-management/lb-layer7/acls/
    # For ACLs sharing the same name, the following rules apply:
    # It is possible to use the same <aclname> for many ACLs, even if they do not have the same matching criterion
    # A logical OR applies between all of them

  # sequence matters !
    use_backend openssh if ssh_payload
    use_backend openssh if !{ req.ssl_hello_type 1 } { req.len 0 }

    # having these two lines here blocks ssh if use_backend openssh comes afterwards ...
    # also, this fucks up SNI ...
    # acl daniel_steiger_ch dst 10.0.1.2
    # acl daniel_steiger_ch dst 88.84.21.77

    acl daniel_steiger_ch req_ssl_sni -i daniel-steiger.ch
    acl daniel_steiger_ch req.ssl_sni -m end .daniel-steiger.ch

    acl henri_bernhard_ch req_ssl_sni -i henri-bernhard.ch
    acl henri_bernhard_ch req.ssl_sni -m end .henri-bernhard.ch

    # wildcard
    use_backend https_daniel_steiger_ch if daniel_steiger_ch
    use_backend https_henri_bernhard_ch if henri_bernhard_ch

    # use_backend example_int if { req_ssl_sni -i example.int }
    # use_backend example_int if { req_ssl_sni -m end .example.int }

    # use_backend example_int if { req_ssl_sni -i example.int }
    # use_backend foo_int if { req_ssl_sni       -i foo.int }
    # use_backend bar_int if { req_ssl_sni -i bar.int }

# sudo haproxy -c -V -f /etc/haproxy/haproxy.cfg

backend https_daniel_steiger_ch
    mode tcp
    balance roundrobin
    server web0 127.0.0.1:17005 send-proxy-v2

backend https_henri_bernhard_ch
    mode tcp
    balance roundrobin
    server web0 127.0.0.1:17007 send-proxy-v2

backend foo_int
    balance roundrobin
    server web1 127.0.0.1:17005 send-proxy

backend bar_int
    balance roundrobin
    server web2 127.0.0.1:17005 ##send-proxy

backend openssh
        mode tcp
        # option tcplog
        # option tcp-check
        # tcp-check expect string SSH-2.0-
        timeout server 3h
        # server openssh 127.0.0.1:22 check
        server openssh 127.0.0.1:22

You might also be able to use nginx instead of haproxy. Someone posted a solution here: https://serverfault.com/questions/1049158/nginx-how-to-combine-ssl-preread-protocol-with-ssl-preread-server-name-ssh-mul I didn't test that, so I'm not sure if that works, however. But according to the answering person, it works.

Then you'll need the KestrelOptionsSetup.cs


namespace HomepageHenri
{

    internal class KestrelOptionsSetup 
        : Microsoft.Extensions.Options.IConfigureOptions<Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions>
    {

        protected readonly Microsoft.Extensions.Logging.ILogger<KestrelOptionsSetup> m_logger;
        protected System.IO.FileSystemWatcher m_watcher;

        public KestrelOptionsSetup(Microsoft.Extensions.Logging.ILogger<KestrelOptionsSetup> logger)
        {
            this.m_logger = logger;
            this.m_watcher = new System.IO.FileSystemWatcher();
        } // End Constructor 

        ~KestrelOptionsSetup()
        {
            if (this.m_watcher != null)
            {
                this.m_watcher.Dispose();
                this.m_watcher = null;
            } // End if (this.m_watcher != null) 

        } // End Destructor 

        void Microsoft.Extensions.Options.IConfigureOptions<Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions>.Configure(
            Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions options)
        {
            options.AddServerHeader = false;

            // with HAproxy, it must be here ? 
            options.ConfigureEndpointDefaults(Configuration.Kestrel.Https.ConfigureEndpointDefaults);

            options.ConfigureHttpsDefaults(
                delegate (Microsoft.AspNetCore.Server.Kestrel.Https.HttpsConnectionAdapterOptions listenOptions)
                {
                    Configuration.Kestrel.Https.HttpsDefaults(options, listenOptions, this.m_watcher);
                }
            );

            // with NGINX, it must be HERE:
            // options.ConfigureEndpointDefaults(Configuration.Kestrel.Https.ConfigureEndpointDefaults);
        } // End Sub Configure 

    } // End Class KestrelOptionsSetup 

} // End Namespace TestApplicationHttps 

And the https-configuration with ServerCertificateSelector.


using Microsoft.AspNetCore.Connections; // for listenOptions.Use
using Microsoft.Extensions.DependencyInjection; // for GetRequiredService 

namespace HomepageHenri.Configuration.Kestrel
{

    // https://github.com/ffMathy/FluffySpoon.AspNet.EncryptWeMust
    public static class Https
    {

        public static void ConfigureEndpointDefaults(Microsoft.AspNetCore.Server.Kestrel.Core.ListenOptions listenOptions)
        {
            Microsoft.Extensions.Logging.ILogger<Program> logger =
                listenOptions.ApplicationServices.GetRequiredService<Microsoft.Extensions.Logging.ILogger<Program>>();

            listenOptions.Use(async (connectionContext, next) =>
            {
                await ProxyProtocol.ProxyProtocol.ProcessAsync(connectionContext, next, logger);
            });

        } // End Sub ConfigureEndpointDefaults 

        public static void HttpsDefaults(
              Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions kestrelOptions
            , Microsoft.AspNetCore.Server.Kestrel.Https.HttpsConnectionAdapterOptions listenOptions
            , System.IO.FileSystemWatcher watcher
            )
        {
            bool isNotWindows = !System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows);

            LetsEncryptCertificateService certificateService =
                kestrelOptions.ApplicationServices.GetRequiredService<LetsEncryptCertificateService>();

            // watcher.Filters.Add("localhost.yml");
            // watcher.Filters.Add("example.com.yaml");
            // watcher.Filters.Add("sub.example.com.yaml");

            System.IO.FileSystemEventHandler onChange = delegate (object sender, System.IO.FileSystemEventArgs e)
            {
                CertificateFileChanged(certificateService.SniCertificateStore, sender, e);
            };

            watcher.Changed += new System.IO.FileSystemEventHandler(onChange);
            watcher.Created += new System.IO.FileSystemEventHandler(onChange);
            watcher.Deleted += new System.IO.FileSystemEventHandler(onChange);
            // watcher.Renamed += new System.IO.RenamedEventHandler(OnRenamed);

            if (!System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows))
                watcher.EnableRaisingEvents = true;

            listenOptions.ServerCertificateSelector =
                      delegate (Microsoft.AspNetCore.Connections.ConnectionContext connectionContext, string name)
                      {
                          return ServerCertificateSelector(certificateService.SniCertificateStore, connectionContext, name);
                      };

#if NO_NGINX_FUCKUP
            listenOptions.OnAuthenticate =
                delegate (Microsoft.AspNetCore.Connections.ConnectionContext connectionContext, System.Net.Security.SslServerAuthenticationOptions sslOptions)
                {
                    // not supported on Windoze 
                    if (isNotWindows)
                    {
                        sslOptions.CipherSuitesPolicy = new System.Net.Security.CipherSuitesPolicy(
                           new System.Net.Security.TlsCipherSuite[]
                           {
                                System.Net.Security.TlsCipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
                                System.Net.Security.TlsCipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
                                System.Net.Security.TlsCipherSuite.TLS_CHACHA20_POLY1305_SHA256,
                               // ...
                           });
                    } // End if (!isWindows) 

                } // End Delegate 
            ; // End OnAuthenticate 
#endif
        } // End Sub HttpsDefaults 

        public static void CertificateFileChanged(
              System.Collections.Concurrent.ConcurrentDictionary<string, LetsEncryptData> certs
            , object sender
            , System.IO.FileSystemEventArgs e
            )
        {
            System.Console.WriteLine(e.FullPath.ToString() + " is changed!");
            // TODO: Swap certificate...
            // certs["localhost"] = localhostCert;
        } // End Sub CertificateFileChanged 

        public static System.Security.Cryptography.X509Certificates.X509Certificate2 ServerCertificateSelector(
              System.Collections.Concurrent.ConcurrentDictionary<string, LetsEncryptData> certs
            , Microsoft.AspNetCore.Connections.ConnectionContext connectionContext
            , string name)
        {
            if (certs != null && certs.Count > 0)
            {
                if (string.IsNullOrEmpty(name))
                {
                    System.Net.IPEndPoint ipe = (System.Net.IPEndPoint)connectionContext.LocalEndPoint;
                    if (ipe.Address.IsIPv4MappedToIPv6)
                        name = ipe.Address.MapToIPv4().ToString();
                    else
                        name = ipe.Address.ToString();
                }

                if (certs.ContainsKey(name))
                    return certs[name].Certificate;

                foreach (var kvp in certs)
                {
                    string altname = kvp.Key;

                    // https://serverfault.com/questions/104160/wildcard-ssl-certificate-for-second-level-subdomain/946120
                    // According to the RFC 6125, only a single wildcard is allowed in the most left fragment.
                    // Valid:
                    //   - *.sub.domain.tld
                    //   - *.domain.tld

                    // Invalid:
                    // sub.*.domain.tld
                    // *.*.domain.tld
                    // domain.*
                    // *.tld
                    // f*.com
                    // sub.*.*

                    // Also, note that 
                    // *.domain.com does not cover domain.com

                    if (altname.StartsWith("*"))
                    {
                        altname = altname.Substring(1); // .foo.int from *.foo.int 
                        if (name.EndsWith(altname, System.StringComparison.InvariantCultureIgnoreCase))
                            return kvp.Value.Certificate;

                        altname = altname.Substring(1); // foo.int from *.foo.int 
                        if (altname.Equals(name, System.StringComparison.InvariantCultureIgnoreCase))
                            return kvp.Value.Certificate;
                    }
                }

                // throw new System.IO.InvalidDataException("No certificate for name \"" + name + "\".");
                return null;
            } // End if (certs != null && certs.Count > 0) 

            throw new System.IO.InvalidDataException("No certificate for name \"" + name + "\".");
        } // End Function ServerCertificateSelector 

    } // End Class Https 

} // End Namespace HomepageHenri.Configuration.Kestrel 

And also, you'll need to add the letsencryptcertificate service, i think before your startup.cs is executed. e.g. serviceCollection.AddSingleton(); in Program.cs

Program.cs


using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting; // for ConfigureWebHostDefaults, Run
using Microsoft.AspNetCore.Hosting; // for UseLinuxTransport, UseIISIntegration, UseKestrel, UseStartup
using Microsoft.AspNetCore.Connections; // for listenOptions.Use

namespace HomepageHenri
{

    public class Program
    {

        // https://localhost:5005/
        // http://localhost:5006/.well-known/acme-challenge/token.txt

        // http://daniel-steiger.ch/.well-known/acme-challenge/token.txt
        // https://daniel-steiger.ch/.well-known/acme-challenge/token.txt

        // https://letsencrypt.org/docs/staging-environment/
        public static void Main(string[] args)
        {
            // CreateHostBuilder(args).Build().Run();

            // ln -s /etc/nginx/sites-available/example.int example.int
            // cat cert.pem ca.pem > fullchain.pem
            // cat ./obelix.pem ./../skynet/skynet.crt > fullchain.pem
            using (System.IO.FileSystemWatcher watcher = new System.IO.FileSystemWatcher())
            {
                // listenOptions.UseHttps("testCert.pfx", "testPassword");                                
                watcher.NotifyFilter = System.IO.NotifyFilters.Size
                                       | System.IO.NotifyFilters.LastWrite
                                       | System.IO.NotifyFilters.CreationTime
                                       | System.IO.NotifyFilters
                                           .FileName // Needed if text-file is changed with Visual Studio ...
                    ;

                CreateHostBuilder(args, watcher).Build().Run();
            } // End Using watcher 

        }

        //public static IHostBuilder CreateHostBuilder(string[] args) =>
        //    Host.CreateDefaultBuilder(args)
        //        .ConfigureWebHostDefaults(webBuilder =>
        //        {
        //            webBuilder.UseStartup<Startup>();
        //        });

        // https://github.com/dotnet/aspnetcore/discussions/28238
        // https://github.com/aspnet/KestrelHttpServer/issues/2103
        // https://ayende.com/blog/181281-A/building-a-lets-encrypt-acme-v2-client
        // https://weblog.west-wind.com/posts/2016/feb/22/using-lets-encrypt-with-iis-on-windows
        // https://medium.com/@MaartenSikkema/automatically-request-and-use-lets-encrypt-certificates-in-dotnet-core-9d0d152a59b5
        // https://github.com/dotnet/aspnetcore/issues/1190
        // https://docs.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel?view=aspnetcore-5.0#code-try-30
        public static Microsoft.Extensions.Hosting.IHostBuilder CreateHostBuilder(
            string[] args, System.IO.FileSystemWatcher watcher)
        {
            // Microsoft.AspNetCore.Server.IIS
            return Microsoft.Extensions.Hosting.Host
                .CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(
                    delegate (Microsoft.AspNetCore.Hosting.IWebHostBuilder webBuilder)
                    {
                        // https://developers.redhat.com/blog/2018/07/24/improv-net-core-kestrel-performance-linux/
                        webBuilder.UseLinuxTransport();

                        if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime
                            .InteropServices.OSPlatform.Windows))
                        {
                            webBuilder.UseIISIntegration();
                        }
                        else webBuilder.UseKestrel(
                            delegate (Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions options) 
                            {
                                options.AddServerHeader = false;
                            }
                        );

                        webBuilder.UseUrls();

                        webBuilder.UseStartup<Startup>();
                    }) // End ConfigureWebHostDefaults 
                    //  These get created on build in CreateHostBuilder 
                    .ConfigureHostConfiguration(delegate (IConfigurationBuilder builder)
                     {
                         // https://codingblast.com/asp-net-core-2-preview/
                         // https://andrewlock.net/5-ways-to-set-the-urls-for-an-aspnetcore-app/
                         builder.AddJsonFile("hosting.json", optional: true, reloadOnChange: true);

                         string launchSettings = System.IO.Path.Combine(System.IO.Directory.GetCurrentDirectory()
                             , "Properties", "launchSettings.json");

                         builder.AddJsonFile(launchSettings, optional: true);
                     })
                    .ConfigureServices(delegate (HostBuilderContext context, IServiceCollection serviceCollection)
                    {
                        serviceCollection.AddSingleton<LetsEncryptCertificateService>();
                    });
        } // End Function CreateHostBuilder 

    } // End Class Program 

} // End Namespace HomepageHenri 

With LetsEncryptCertificateService.cs


using Certes; // Dns, http, toPem, etc.
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;

namespace HomepageHenri
{

    public class LetsEncryptCertificateService
    {

        public System.Uri LetsEncryptContext;
        public System.Collections.Concurrent.ConcurrentDictionary<string, LetsEncryptData> SniCertificateStore;

        public string AccountFileName
        {
            get
            {
                string domain = "LetsEncryptAccount";

                if (this.LetsEncryptContext == Certes.Acme.WellKnownServers.LetsEncryptStagingV2)
                    return string.Concat(domain, "_staging_v2");

                if (this.LetsEncryptContext == Certes.Acme.WellKnownServers.LetsEncryptV2)
                    return string.Concat(domain, "_v2");

                if (this.LetsEncryptContext == Certes.Acme.WellKnownServers.LetsEncryptStaging)
                    return string.Concat(domain, "_staging_v1");

                if (this.LetsEncryptContext == Certes.Acme.WellKnownServers.LetsEncrypt)
                    return string.Concat(domain, "_v1");

                return string.Concat(domain, "_undefined");
            }
        } // End Property AccountFileName 

        public string GetCertificateFileName(string domain)
        {
            if (this.LetsEncryptContext == Certes.Acme.WellKnownServers.LetsEncryptStagingV2)
                return string.Concat(domain, "_staging_v2");

            if (this.LetsEncryptContext == Certes.Acme.WellKnownServers.LetsEncryptV2)
                return string.Concat(domain, "_v2");

            if (this.LetsEncryptContext == Certes.Acme.WellKnownServers.LetsEncryptStaging)
                return string.Concat(domain, "_staging_v1");

            if (this.LetsEncryptContext == Certes.Acme.WellKnownServers.LetsEncrypt)
                return string.Concat(domain, "_v1");

            return string.Concat(domain, "_undefined");
        } // End Function GetCertificateFileName 

        public LetsEncryptCertificateService(
            Microsoft.AspNetCore.Hosting.IWebHostEnvironment env 
            //, Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.HttpsPolicy.HttpsRedirectionOptions> options
            )
        {
            bool isDev = Microsoft.Extensions.Hosting.HostEnvironmentEnvExtensions.IsDevelopment(env);

            // Prodesk for testing on production machine 
            if ("prodesk".Equals(System.Environment.MachineName, System.StringComparison.InvariantCultureIgnoreCase))
            {
                isDev = false;
            }

            bool override_dev = false;

            if (isDev || override_dev)
                this.LetsEncryptContext = Certes.Acme.WellKnownServers.LetsEncryptStagingV2;
            else
                this.LetsEncryptContext = Certes.Acme.WellKnownServers.LetsEncryptV2;

            this.SniCertificateStore = new System.Collections.Concurrent.ConcurrentDictionary<string, LetsEncryptData>(
                    System.StringComparer.OrdinalIgnoreCase
            );

            // string pfxBase64 = SecretManager.GetSecret<string>("henri-bernhard.ch");
            // byte[] pfx = System.Convert.FromBase64String(pfxBase64);

            string[] letsEncryptDomains = new string[] {
                  "henri-bernhard.ch"
                , "www.henri-bernhard.ch"
            };

            string[] altNames = SelfSigned.GetAlternativeNames(isDev ? letsEncryptDomains : new string[0]);
            byte[] pfx = null;
            try
            {
                pfx = SelfSigned.CreateSelfSignedCertificate(altNames, LetsEncryptData.DEFAULT_PASSWORD);
            }
            catch (System.Exception ex)
            {
                // if SSL Handled by IIS 
                if (System.Environment.GetEnvironmentVariable("APP_POOL_ID") is string)
                    return;

                System.Console.WriteLine(ex.Message);
                System.Console.WriteLine(ex.StackTrace);
                System.Environment.Exit(666);
            }

            foreach (string altName in altNames)
            {
                LetsEncryptData certData = new LetsEncryptData(altName, false);
                this.SniCertificateStore[altName] = certData.FromPfx(pfx, certData.PfxPassword);
                // this.SniCertificateStore[altName] = certData.FromPem(cert, key); 
            } // Next altName 

            if (!isDev || override_dev)
            {
                foreach (string thisDomain in letsEncryptDomains)
                {
                    this.SniCertificateStore[thisDomain] = new LetsEncryptData(thisDomain, true);
                } // Next thisDomain 

            } // End if (!isDev) 

            // string cert = SecretManager.GetSecret<string>("ssl_cert");
            // string key = SecretManager.GetSecret<string>("ssl_key");
            // LetsEncryptData henri_bernhard_ch = new LetsEncryptData();
            // this.SniCertificateStore["henri-bernhard"] = henri_bernhard_ch.FromPfx(pfx);

        } // End Constructor 

        protected async System.Threading.Tasks.Task<Certes.AcmeContext> CreateAccountAsync(string domain, string accountMail)
        {
            if (accountMail == null)
                throw new System.ArgumentNullException(nameof(accountMail));

            Certes.AcmeContext acme = new Certes.AcmeContext(this.LetsEncryptContext);
            Certes.Acme.IAccountContext account = await acme.NewAccount(accountMail, true);

            // Save the account key for later use
            string pemKey = acme.AccountKey.ToPem();

            // System.IO.File.WriteAllText(domain + ".account", pemKey, new System.Text.UTF8Encoding(false));

            SecretManager.SetSecret("All", this.AccountFileName, pemKey);

            return acme;
        } // End Task CreateAccountAsync 

        protected async System.Threading.Tasks.Task<Certes.AcmeContext> CreateAccountAsync(string domain)
        {
            string accountMail = "webmaster@" + domain;

            return await CreateAccountAsync(domain, accountMail);
        } // End Task CreateAccountAsync 

        protected async System.Threading.Tasks.Task<Certes.AcmeContext> GetContextAsync(string pemKey)
        {
            Certes.IKey accountKey = Certes.KeyFactory.FromPem(pemKey);
            Certes.AcmeContext acme = new Certes.AcmeContext(this.LetsEncryptContext, accountKey);
            // Certes.Acme.IAccountContext account = await acme.Account();

            await System.Threading.Tasks.Task.CompletedTask; // Calm Warnings 
            return acme;
        } // End Task UseExistingAccount 

        public async System.Threading.Tasks.Task<Certes.AcmeContext> GetOrCreateContextAsync(string domain)
        {
            string pem = SecretManager.GetSecret<string>("All", this.AccountFileName);

            if (pem != null)
            {
                return await GetContextAsync(pem);
            }

            Certes.AcmeContext context = await CreateAccountAsync(domain);
            // System.Uri tos = await context.TermsOfService();

            return context;
        } // End Task GetOrCreateContextAsync 

        protected async System.Threading.Tasks.Task<Certes.Acme.IOrderContext> NewWildcardOrderAsync(
            Certes.AcmeContext acme, string domain = "*.your.domain.name")
        {
            Certes.Acme.IOrderContext order = await acme.NewOrder(new[] { domain });

            Certes.Acme.IAuthorizationContext authz = System.Linq.Enumerable.First(await order.Authorizations());
            Certes.Acme.IChallengeContext dnsChallenge = await authz.Dns();
            string dnsTxt = acme.AccountKey.DnsTxt(dnsChallenge.Token);

            return order;
        } // End Task NewWildcardOrder 

        protected async System.Threading.Tasks.Task<Certes.Acme.IOrderContext> NewNonWildCardOrderAsync(
            Certes.AcmeContext acme, string domain = "your.domain.name")
        {
            Certes.Acme.IOrderContext order = await acme.NewOrder(new string[] { domain });
            return order;
        } // End Task NewNonWildCardOrder 

        // https://github.com/fszlin/certes/issues/77
        protected async System.Threading.Tasks.Task<Certes.Acme.IChallengeContext> GetHttpChallengeAsync(
            Certes.Acme.IOrderContext order)
        {
            Certes.Acme.IAuthorizationContext authz = System.Linq.Enumerable.First(await order.Authorizations());
            Certes.Acme.IChallengeContext httpChallenge = await authz.Http();
            // string keyAuthz = httpChallenge.KeyAuthz;

            return httpChallenge;
        } // End Task GetToken 

        // https://github.com/fszlin/certes/issues/186
        protected async System.Threading.Tasks.Task<Certes.Acme.IChallengeContext> GetDnsChallengeAsync(
            Certes.Acme.IOrderContext order)
        {
            Certes.Acme.IAuthorizationContext authz = System.Linq.Enumerable.First(await order.Authorizations());
            Certes.Acme.IChallengeContext dnsChallenge = await authz.Dns();

            return dnsChallenge;
        }

        protected async System.Threading.Tasks.Task<Certes.Acme.Resource.Challenge> ValidateAsync(
            Certes.Acme.IChallengeContext challenge)
        {
            Certes.Acme.Resource.Challenge val = await challenge.Validate();
            return val;
        } // End Task Validate 

        protected async System.Threading.Tasks.Task DownloadCertificateAsync(
            Certes.Acme.IOrderContext order, string domain = "your.domain.name")
        {
            Certes.IKey privateKey = Certes.KeyFactory.NewKey(Certes.KeyAlgorithm.ES256);

            Certes.Acme.CertificateChain cert = await order.Generate(new Certes.CsrInfo
            {
                CountryName = "CH",
                State = "Bern",
                Locality = "Muensingen",
                Organization = "SVP Bern",
                OrganizationUnit = "SVP Muensingen",
                CommonName = domain,
            }, privateKey);

            string pk = privateKey.ToPem();
            string certPem = cert.ToPem();

            // System.IO.File.WriteAllText(domain + ".key", pk, new System.Text.UTF8Encoding(false));
            // System.IO.File.WriteAllText(domain + ".pem", certPem, new System.Text.UTF8Encoding(false));

            this.SniCertificateStore[domain].FromPem(certPem, pk);

            // System.IO.File.WriteAllBytes(domain + ".pfx", pfx);
            Certes.Pkcs.PfxBuilder pfxBuilder = cert.ToPfx(privateKey);
            byte[] pfx = pfxBuilder.Build(domain, this.SniCertificateStore[domain].PfxPassword);
            string pfxBase64 = System.Convert.ToBase64String(pfx);

            string certFileName = this.GetCertificateFileName(domain);
            SecretManager.SetSecret(certFileName, pfxBase64);
        } // End Task DownloadCertificate 

        protected async System.Threading.Tasks.Task GetOrRenewCertificateAsync(string tokenFolder, string domain)
        {
            if (!this.SniCertificateStore.ContainsKey(domain))
                this.SniCertificateStore[domain] = new LetsEncryptData(domain, true);

            Certes.AcmeContext context = await GetOrCreateContextAsync(domain);

            Certes.Acme.IOrderContext order = await NewNonWildCardOrderAsync(context, domain);
            Certes.Acme.IChallengeContext challenge = await GetHttpChallengeAsync(order);
            this.SniCertificateStore[domain].Token = challenge.Token;
            this.SniCertificateStore[domain].SignedNonce = challenge.KeyAuthz;

            // IChallengeContext dnsChallenge = await GetDnsChallengeAsync(order);
            // string dnsTxt = context.AccountKey.DnsTxt(dnsChallenge.Token);
            // LoopiaRPCCalls.createTxtRecord(dnsTxt);

            // At this point _acme-challenge.xyz.se is created
            // System.Threading.Thread.Sleep(60 * 1000);
            // I realized this is caused by the TTL on the TXT record. 
            // The DNS service was most likely providing a cached version to let's encrypt.
            // What I've done in the past is write code to loop doing DNS lookups 
            // until the correct TXT value is returned. Waiting for X minutes isn't reliable.

            // string url = "http://" + domain + "/.well-known/acme-challenge/"+ challenge.Token;
            // System.Console.WriteLine(url);

            // string path = System.IO.Path.Combine(tokenFolder, challenge.Token);
            // byte[] data = System.Text.Encoding.ASCII.GetBytes(challenge.KeyAuthz);
            // System.IO.File.WriteAllText(path, ctx.KeyAuthz, System.Text.Encoding.UTF8);
            // System.IO.File.WriteAllBytes(path, data);

            Certes.Acme.Resource.Challenge c = await ValidateAsync(challenge);

            if (c.Status == Certes.Acme.Resource.ChallengeStatus.Invalid)
            {
                System.Console.WriteLine(c.Error.Detail);
                throw new System.Exception("Invalid Challenge", new System.Exception(c.Error.Detail));
            }
            else
                await System.Threading.Tasks.Task.Delay(15000);

            await DownloadCertificateAsync(order, domain);
            // System.Console.WriteLine(this.SniCertificateStore[domain].Certificate);

            // The path at which the resource is provisioned is comprised of the
            // fixed prefix "/.well-known/acme-challenge/", followed by the "token"
            // value in the challenge.  
            // The value of the resource MUST be the ASCII representation of the key authorization.
        } // End Task GetOrRenewCertificateAsync 

        public async System.Threading.Tasks.Task GetOrRenewCertificateAsync(string domain)
        {
            string certFileName = this.GetCertificateFileName(domain);
            string base64 = SecretManager.GetSecret<string>(certFileName);

            bool doRenew = true;

            if (base64 != null)
            {
                byte[] ba = System.Convert.FromBase64String(base64);

                LetsEncryptData leData = new LetsEncryptData(domain, true);
                leData.FromPfx(ba, leData.PfxPassword);
                // System.Console. WriteLine(leData.Certificate.NotBefore);
                // System.Console. WriteLine(leData.Certificate.NotAfter);

                System.TimeSpan diff = leData.Certificate.NotAfter - System.DateTime.Now;
                // System.Console. WriteLine(diff.TotalDays);

                if (diff.TotalDays > 57)
                {
                    this.SniCertificateStore[domain] = leData;
                    doRenew = false;
                } // End if (diff.TotalDays > 57) 
            } // End if (base64 != null) 

            if (doRenew)
                await GetOrRenewCertificateAsync(null, domain);
        } // End Task GetOrRenewCertificateAsync 

        // https://security.stackexchange.com/questions/107640/how-does-letsencrypt-orgs-acme-work
        // DNS Challenge: 
        // https://github.com/fszlin/certes/issues/186

        // find port of program  netstat -tulpn
        // set gopath: https://stackoverflow.com/questions/21001387/how-do-i-set-the-gopath-environment-variable-on-ubuntu-what-file-must-i-edit/53026674#53026674
        // https://kifarunix.com/install-and-setup-gvm-11-on-ubuntu-20-04/
        // https://github.com/moovweb/gvm

        // https://github.com/fszlin/certes
        // https://security.stackexchange.com/questions/107640/how-does-letsencrypt-orgs-acme-work
        // https://letsencrypt.org/docs/staging-environment/
        // https://letsencrypt.org/docs/rate-limits/

        // https://github.com/letsencrypt/pebble
        // https://hub.docker.com/r/letsencrypt/pebble
        // https://github.com/letsencrypt/pebble/blob/master/cmd/pebble-challtestsrv/README.md

        internal static async System.Threading.Tasks.Task Test()
        {
            string domain = "henri-bernhard.ch";
            string tokenFolder = @"/var/www/" + domain.ToLowerInvariant() + "/.well-known/acme-challenge/";
            tokenFolder = System.IO.Path.Combine(@"/var/www", domain.ToLowerInvariant(), ".well-known", "acme-challenge");

            if (System.Environment.OSVersion.Platform != System.PlatformID.Unix)
                tokenFolder = System.IO.Path.Combine(@"D:\inetpub\www", domain.ToLowerInvariant(), ".well-known", "acme-challenge");

            if (!System.IO.Directory.Exists(tokenFolder))
                System.IO.Directory.CreateDirectory(tokenFolder);

            Microsoft.AspNetCore.Hosting.IWebHostEnvironment environment = new MockEnvironment();

            // Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.HttpsPolicy.HttpsRedirectionOptions> opts = 
            //     Microsoft.Extensions.Options.Options.Create(new Microsoft.AspNetCore.HttpsPolicy.HttpsRedirectionOptions());

            LetsEncryptCertificateService acmeService = new LetsEncryptCertificateService(environment);
            await acmeService.GetOrRenewCertificateAsync(tokenFolder, domain);
        } // End Sub Main 

    } // End Class Program 

    public class MockEnvironment
        : Microsoft.AspNetCore.Hosting.IWebHostEnvironment
    {
        string IWebHostEnvironment.WebRootPath { get => "./wwwroot"; set => throw new System.NotImplementedException(); }
        IFileProvider IWebHostEnvironment.WebRootFileProvider { get => throw new System.NotImplementedException(); set => throw new System.NotImplementedException(); }
        string IHostEnvironment.ApplicationName { get => nameof(HomepageHenri); set => throw new System.NotImplementedException(); }
        IFileProvider IHostEnvironment.ContentRootFileProvider { get => throw new System.NotImplementedException(); set => throw new System.NotImplementedException(); }
        string IHostEnvironment.ContentRootPath { get => "./"; set => throw new System.NotImplementedException(); }
        string IHostEnvironment.EnvironmentName { get => "Development"; set => throw new System.NotImplementedException(); }
    }

}
Tratcher commented 1 year ago

Reconsidering for 8. We've had multiple first party requests.

arontsang commented 8 months ago

Assuming this didn't make it into 8?

davidfowl commented 8 months ago

Nope

RufusJWB commented 5 months ago

Any update on this Feature Request?