负载均衡器后面的 .NET Core VMS 上的 SSL

SSL on .NET Core VMS behind load balancer

我目前正在设置一个高可用性 (HA) 环境,其中有两个 Azure 虚拟机 运行 Ubuntu 位于标准 Azure 负载均衡器后面。现在我知道标准负载均衡器只有第 4 层,这意味着它不能进行 SSL 卸载。

这两个 VM 都是 运行 .NET Core Web API。他们显然都需要 SSL 证书来处理来自负载均衡器的 SSL 连接。

我知道我可以购买 SSL 证书并设置 Kestrel 以在 Web API 本身上使用该证书,但我想要免费证书。我知道另一种选择是使用 nginx 服务器生成证书,然后将证书复制到 Web API 但这意味着我需要每 3 个月重复一次该过程,这非常麻烦,因为这意味着我当我使 HA 集群脱机以更新证书时会有停机时间。

有谁知道在负载均衡器后面的两个虚拟机上使用 Lets Encrypt 的方法吗?

前言

好吧,我上面说的是对的。它要求我编写一个实用程序,使用 DNS 验证自动更新我的 Lets Encrypt 证书。它使用 Azure DNS 或具有 API 的其他 DNS 提供商非常重要,因为您需要能够使用 API 或与您的提供商的其他接口直接修改您的 DNS 记录。

我正在使用 Azure DNS,它为我管理整个域,因此下面的代码适用于 Azure DNS,但您可以修改 API 以与您选择的具有某种形式的任何提供商合作API.

第二部分是在我的高可用性 (HA) 集群中不要有任何停机时间。所以我所做的是,将证书写入数据库,然后在我的 VM 启动时动态读取它。所以基本上每次 Kestrel 启动时,它都会从数据库中读取证书,然后使用它。


代码

数据库模型

您需要将以下模型添加到您的数据库中,以便您可以在某处存储实际的证书详情。

public class Certificate
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public long Id { get; set; }
    public string FullChainPem { get; set; }
    public string CertificatePfx { get; set; }
    public string CertificatePassword { get; set; }
    public DateTime CertificateExpiry { get; set; }
    public DateTime? CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }
}

创建模型后,您需要将其放置在上下文中,如下所示:

public DbSet<Certificate> Certificates { get; set; }

应用服务器

在您的应用程序服务器上,您可能希望使用 Kestrel 作为 Web 服务器,然后从数据库动态加载证书。因此,将以下内容添加到您的 CreateWebHostBuilder 方法中。重要的是,这是在 .UseStartup<Startup>()

之后
.UseKestrel(opt = >{
    //Get the application services
    var applicationServices = opt.ApplicationServices;
    //Create and use scope
    using(var scope = applicationServices.CreateScope()) {
        //Get the database context to work with
        var context = scope.ServiceProvider.GetService < DBContext > ();

        //Get the certificate
        var certificate = context.Certificates.Last();
        var pfxBytes = Convert.FromBase64String(certificate.CertificatePfx);
        var pfxPassword = certificate.CertificatePassword;

        //Create the certificate
        var cert = new X509Certificate2(pfxBytes, pfxPassword);

        //Listen on the specified IP and port
        opt.Listen(IPAddress.Any, 443, listenOpts = >{
            //Use HTTPS
            listenOpts.UseHttps(cert);
        });
    }
});

让加密实用程序

这就是解决方案的核心。它处理证书请求、质询、D​​NS 验证以及证书的存储。它还将自动重启 Azure 中使用证书的每个 VM 实例,以便它们提取新证书。

Main逻辑如下,会检查证书是否需要更新。

static void Main(string[] args) {
    while (true) {
        //Get the latest certificate in the DB for the servers
        var lastCertificate = _db.Certificates.LastOrDefault();

        //Check if the expiry date of last certificate is more than a month away
        if (lastCertificate != null && (lastCertificate.CertificateExpiry - DateTime.Now).TotalDays > 31) {
            //Log out some info
            Console.WriteLine($ "[{DateTime.Now}] - Certificate still valid, sleeping for a day.");
            //Sleep the thread
            Thread.Sleep(TimeSpan.FromDays(1));
        }
        else {
            //Renew the certificates
            RenewCertificates();
        }
    }
}

好吧,这是很多事情,但如果你把它分解的话,其实很简单

  1. 创建帐户
  2. 获取账户密钥
  3. 为域创建新订单
  4. 遍历所有组织
  5. 对它们中的每一个执行 DNS 验证
  6. 生成证书
  7. 将证书保存到数据库
  8. 重启虚拟机

实际RenewCertificates方法如下:

/// <summary>
/// Method that will renew the domain certificates and update the database with them
/// </summary>
public static void RenewCertificates() {
    Console.WriteLine($ "[{DateTime.Now}] - Starting certificate renewal.");
    //Instantiate variables
    AcmeContext acme;
    IAccountContext account;

    //Try and get the setting value for ACME Key
    var acmeKey = _db.Settings.FirstOrDefault(s = >s.Key == "ACME");

    //Check if acme key is null
    if (acmeKey == null) {
        //Set the ACME servers to use
    #if DEBUG
         acme = new AcmeContext(WellKnownServers.LetsEncryptStagingV2);
    #else 
         acme = new AcmeContext(WellKnownServers.LetsEncryptV2);
    #endif
        //Create the new account
        account = acme.NewAccount("yourname@yourdomain.tld", true).Result;
        //Save the key to the DB to be used
        _db.Settings.Add(new Setting {
            Key = "ACME",
            Value = acme.AccountKey.ToPem()
        });
        //Save DB changes
        _db.SaveChanges();
    }
    else {
        //Get the account key from PEM
        var accountKey = KeyFactory.FromPem(acmeKey.Value);

        //Set the ACME servers to use
    #if DEBUG 
             acme = new AcmeContext(WellKnownServers.LetsEncryptStagingV2, accountKey);
    #else 
             acme = new AcmeContext(WellKnownServers.LetsEncryptV2, accountKey);
    #endif
        //Get the actual account
        account = acme.Account().Result;
    }

    //Create an order for wildcard domain and normal domain
    var order = acme.NewOrder(new[] {
        "*.yourdomain.tld",
        "yourdomain.tld"
    }).Result;

    //Generate the challenges for the domains
    var authorizations = order.Authorizations().Result;

    //Error flag
    var hasFailed = false;

    foreach(var authorization in authorizations) {
        //Get the DNS challenge for the authorization
        var dnsChallenge = authorization.Dns().Result;
        //Get the DNS TXT
        var dnsTxt = acme.AccountKey.DnsTxt(dnsChallenge.Token);

        Console.WriteLine($ "[{DateTime.Now}] - Received DNS challenge data.");

        //Set the DNS record
        Azure.SetAcmeTxtRecord(dnsTxt);

        Console.WriteLine($ "[{DateTime.Now}] - Updated DNS challenge data.");
        Console.WriteLine($ "[{DateTime.Now}] - Waiting 1 minute before checking status.");

        dnsChallenge.Validate();

        //Wait 1 minute
        Thread.Sleep(TimeSpan.FromMinutes(1));

        //Check the DNS challenge
        var valid = dnsChallenge.Validate().Result;

        //If the verification fails set failed flag
        if (valid.Status != ChallengeStatus.Valid) hasFailed = true;
    }

    //Check whether challenges failed
    if (hasFailed) {
        Console.WriteLine($ "[{DateTime.Now}] - DNS challenge(s) failed, retrying.");
        //Recurse
        RenewCertificates();
        return;
    }
    else {
        Console.WriteLine($ "[{DateTime.Now}] - DNS challenge(s) successful.");

        //Generate a private key
        var privateKey = KeyFactory.NewKey(KeyAlgorithm.ES256);

        //Generate certificate
        var cert = order.Generate(new CsrInfo {
            CountryName = "ZA",
            State = "Gauteng",
            Locality = "Pretoria",
            Organization = "Your Organization",
            OrganizationUnit = "Production",
        },
        privateKey).Result;

        Console.WriteLine($ "[{DateTime.Now}] - Certificate generated successfully.");

        //Get the full chain
        var fullChain = cert.ToPem();

        //Generate password
        var pass = Guid.NewGuid().ToString();

        //Export the pfx
        var pfxBuilder = cert.ToPfx(privateKey);
        var pfx = pfxBuilder.Build("yourdomain.tld", pass);

        //Create database entry
        _db.Certificates.Add(new Certificate {
            FullChainPem = fullChain,
            CertificatePfx = Convert.ToBase64String(pfx),
            CertificatePassword = pass,
            CertificateExpiry = DateTime.Now.AddMonths(2)
        });

        //Save changes
        _db.SaveChanges();

        Console.WriteLine($ "[{DateTime.Now}] - Database updated with new certificate.");

        Console.WriteLine($ "[{DateTime.Now}] - Restarting VMs.");

        //Restart the VMS
        Azure.RestartAllVms();
    }
}

Azure 集成

无论我在哪里调用 Azure,您都需要编写 API 包装器来设置 DNS TXT 记录,然后才能从您的托管服务提供商处重新启动 VM。我的全部使用 Azure,所以操作起来非常简单。这是 Azure 代码:

/// <summary>
/// Method that will set the TXT record value of the ACME challenge
/// </summary>
/// <param name="txtValue">Value for the TXT record</param>
/// <returns>Whether call was successful or not</returns>
public static bool SetAcmeTxtRecord(string txtValue) {
    //Set the zone endpoint
    const string url = "https://management.azure.com/subscriptions/{subId}/resourceGroups/{resourceGroup}/providers/Microsoft.Network/dnsZones/{dnsZone}/txt/_acme-challenge?api-version=2018-03-01-preview";

    //Authenticate API
    AuthenticateApi();

    //Build up the body to put
    var body = $ "{{\"properties\": {{\"metadata\": {{}},\"TTL\": 225,\"TXTRecords\": [{{\"value\": [\"{txtValue}\"]}}]}}}}";

    //Build up the string content
    var content = new StringContent(body, Encoding.UTF8, "application/json");

    //Create the response
    var response = client.PutAsync(url, content).Result;

    //Return the response
    return response.IsSuccessStatusCode;
}

我希望这能够帮助到和我有同样困境的其他人。