65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 SNI 和 Let's Encrypt 证书在 IIS 中自托管多个 HTTPS 网站

starIconstarIconstarIconstarIconstarIcon

5.00/5 (30投票s)

2017 年 7 月 4 日

CPOL

12分钟阅读

viewsIcon

68265

downloadIcon

940

无需支付托管和证书费用即可自托管多个 HTTPS 网站的能力

目标

我在本文中介绍的个人目标是实现自托管多个 HTTPS 网站的能力,这些网站即使处于原型阶段,仍然可供他人使用,因此我希望这些网站能够拥有互联网存在,但无需支付托管和证书费用。

消除成本

这是我首先要解决的问题。我目前为几台最低配置的 Amazon EC2 实例每月支付 60 美元,而从 SSL.com、GeoTrust、GoDaddy 和 Comodo 等地方购买一个 SSL 证书一年,费用从每年 17 美元到 69 美元不等。当我想自托管大约 5 个网站时,这很快就会累积起来。请记住,我每月已经为宽带接入支付了 130 美元。因此,与消除成本相关的两个问题是:

  1. 不支付托管费用
  2. 不支付 SSL 证书费用

注意事项

在托管自己的服务器之前,请查看与您的提供商的合同。例如,Verizon FiOS 明确禁止托管服务器,除非您获得他们的商务服务。虽然我的有线提供商有商务计划,但我在合同中没有发现任何禁止我托管自己网站的内容。

另一个问题是,您有静态公共 IP 吗?如果没有,那么托管自己的网站将无法实现,除非您使用 noip.com 或 duckdns.org 等服务,它们都使用子域名,因此您的 URL 将是 [yourname].no-ip.org 或 [yourname].duckdns.org,这两者都不是理想的。移除 no-ip.org,您猜对了,是要花钱的!

更好的硬件

除非您使用专用硬件(我曾经每月为此支付 100 美元,但即使在那时,设备也相当低端),否则托管是在虚拟机上完成的,例如亚马逊的最低配置非常低——30GB 磁盘空间,1GB RAM。操作系统本身、SQL Server、电子邮件服务器等很快就会消耗掉这些资源。我有很多闲置的笔记本电脑和台式机,它们的规格要好得多,而且,嗯,只是闲置着。

注意事项

托管提供商应提供您的虚拟机的备份,自动处理物理设备故障以及其他好处。鉴于这些是原型网站,这并非我优先考虑的首要事项,可以由其他方式处理。当然,一辆卡车经过,意外地扯掉了街对面杆子上的电缆,导致完全中断了 5 天,这种停机肯定是一个因素!当然,还有在硬件故障、停电、猫咬电缆以及(通常)托管提供商会处理的其他问题发生时,拥有冗余设备。

简洁和控制

我喜欢控制(这就是我不喜欢飞行的原因),并且我喜欢在网站本地运行时更新网站的便利性,而不是必须通过发布实用程序、FTP 或其他远程部署机制。听起来可能很傻,但是(我拥有自动化工具来完成此操作的)复制粘贴 Web 应用程序可以让生活更轻松。是的,Visual Studio 中的一键“发布”功能非常简单,但有时我只需要对数据库运行迁移,对网页进行简单调整,或者想设置一个 Beta 测试站点,而不必经历在托管提供商那里创建另一个虚拟机的整个过程。

注意事项

非标准发布过程,可能更容易出错,更多本地工具。

问题 1:运行多个 HTTPS 网站

托管一个网站相对容易,尽管让

netsh http add sslcert ipport=0.0.0.0:443 certstorename=Root certhash=[hash] 
appid={[some GUID]} 

工作可能有点棘手。最恼人的问题是,当您复制粘贴哈希值时,您没有粘贴隐藏字符,这是一个常见问题。

问题是,使用 netsh,您只能将一个证书与端口 443 关联。而且由于

  1. 证书与域名相关联;
  2. 证书(握手过程)是根据端口上的传入流量进行验证,而不是由域名限定;
  3. 获取可以应用于多个域名的证书成本高昂,不可行;

这不是一个可行的解决方案。作为一个迟到的采用者,我很快发现,在 Windows 7 上,在 IIS 中托管多个 HTTPS 网站也是不可能的。

解决方案:服务器名称指示 (SNI) 和 IIS 8 或更高版本

解决方案是使用服务器名称指示

服务器名称指示 (SNI) 是 TLS 计算机网络协议的扩展,通过该协议,客户端在握手过程开始时指示它正在尝试连接到哪个主机名。这使得服务器可以在同一 IP 地址和 TCP 端口号上呈现多个证书,从而允许同一 IP 地址托管多个安全 (HTTPS) 网站(或任何其他基于 TLS 的服务),而无需所有这些网站使用相同的证书。它相当于 HTTP/1.1 基于名称的虚拟主机,但用于 HTTPS。所需的the hostname未加密,因此窃听者可以看到正在请求的站点。

SNI 支持 IIS 8 及更高版本。

这意味着终于下定决心升级到 Windows 10。

问题 2:免费 SSL 证书

有几个免费的 SSL 证书提供商:letsencrypt、comodo、sslforfree 等。它们提供基本的 SSL 证书,但通常每 90 天过期一次。Let's Encrypt 使用自动证书管理环境协议。

...以自动化证书颁发机构与其用户 Web 服务器之间的交互,从而以非常低的成本自动化公钥基础设施的部署。它由 Internet Security Research Group (ISRG) 为其 Let's Encrypt 服务设计。该协议基于通过 HTTPS 传递 JSON 格式的消息,已由其自己的特许 IETF 工作组发布为 Internet Draft。

InstantSsl (Comodo) 要求您生成 CSR 并安装证书。

相反,使用 ACME 需要设置一个“ACME 挑战”的处理程序,这是一个通过 HTTP 发送到您的域上特定路径 /.well-known/acme-challenge 的 GET 请求,该请求要求您使用 Let's Encrypt 握手过程提供的挑战令牌进行响应。

注意事项

正如 F-ES Sitecore 在这里的 CP 上所指出的

Let's Encrypt 已向超过 10,000 个 PayPal 钓鱼网站颁发了证书,安全专家表示,这正在破坏 HTTPS,因为它消除了 HTTPS 的可信度,使人们更容易受到攻击。

快速搜索找到了这个安全新闻摘要

在过去一年中,Let's Encrypt 总共颁发了 15,270 个 SSL 证书,这些证书在域名或证书标识中包含“PayPal”一词。根据 The SSL Store 加密专家 Vincent Lynch 对 1,000 个域的小样本进行的分析,其中约 14,766 个(96.7%)是为托管钓鱼网站的域颁发的。

重点是,您可能会对使用 Let's Encrypt 等服务以及间接支持我们数字社区中不那么道德的成员的这种行为感到不安。

解决方案,步骤 1:acme.net

有很多工具(包括这个 CodeProject 文章)用于在 *nix 系统上使用 Let's Encrypt(这似乎很合理,不是吗?)很少有工具用于将 Let's Encrypt 与 IIS 集成。我最终在 GitHub 上找到了这个项目,但问题是作者使用的是 xproj(已经“消失了”),而不是 csproj 文件。查看 oocx 的 GitHub 仓库的 fork,我发现了frankhommers 的 acme.net fork,它使用 csproj 文件。太棒了!

解决方案,步骤 2:处理 ACME 挑战

控制台应用程序 acme.net 完成与 Let's Encrypt 的所有握手以获取令牌,然后(在手动模式下)它会等待您设置服务器来响应 Let's Encrypt 发送到您的域的验证请求。在自动模式下,它假定您的服务器已设置为响应 Let's Encrypt 发送的任何挑战请求。我想使用第二种模式,尽可能地自动化整个过程。

acme.net 为您创建的文件包含 Let's Encrypt 期望您响应的完整令牌。以下是一些示例文件名(每次请求 SSL 证书时,Let's Encrypt 都会使用一个完全不同的挑战令牌)

为了好玩,让我们看看第一个文件的内容

这就是 Let's Encrypt 期望您响应的内容。当它发出 GET 请求时,URL 会附加“.”左侧的所有内容,因此对于此特定的 ACME 挑战,URL 路径将如下所示:

/.well-known/acme-challenge/-1mU4FF8ssT3TyFrsPf1r0tZ0mzra1krIFz4sDbzTb8 

因此,自动响应挑战的步骤是:

  1. 查找以 /.well-known/acme-challenge 开头的 URL 路径。
  2. 提取部分令牌。
  3. 从同名文件中加载完整令牌。
  4. 将其作为文本响应发送到 GET 请求。

一个简单的服务器可以做到这一点:

using System;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;

namespace acme
{
  public class ServerExceptionEventArgs : EventArgs
  {
    public Exception Exception { get; set; }
  }

  public class AcmeChallengeServer
  {
    public event EventHandler<ServerExceptionEventArgs> ServerException;

    protected bool running = true;
    protected HttpListener listener;

    public void Start(string localIP)
    {
      listener = new HttpListener();
      listener.Prefixes.Add("http://" + localIP + "/");
      listener.Start();
      Task.Run(() => WaitForConnection(listener));
    }

    public void Stop()
    {
      running = false;
      listener.Stop();
    }

    private void WaitForConnection(object objListener)
    {
      while (running)
      {
        HttpListenerContext context;

        try
        {
          context = listener.GetContext();
        }
        catch (HttpListenerException)
        {
          // Occurs when we stop the listener.
          break;
        }
        catch (Exception ex)
        {
          ServerException?.Invoke(this, new ServerExceptionEventArgs() { Exception = ex });
          // Other exceptions should be handled elsewhere.
          break;
        }

        if (context.Request.RawUrl.StartsWith("/.well-known/acme-challenge"))
        {
          string challengeFile = context.Request.RawUrl.RightOfRightmostOf('/');

          if (File.Exists(challengeFile))
          {
            string data = File.ReadAllText(challengeFile);

            context.Response.StatusCode = 200;
            context.Response.ContentType = "text/text";
            context.Response.ContentEncoding = Encoding.UTF8;

            byte[] byteData = Encoding.ASCII.GetBytes(data);
            context.Response.ContentLength64 = byteData.Length;
            context.Response.OutputStream.Write(byteData, 0, byteData.Length);
          }
        }

        context.Response.Close();
      }
    }
  }
}

为什么有效

有几个原因:

  1. 挑战会发送到 http://[yourdomain],因此要接收挑战,域名将指向您的公共 IP。
  2. 当然,任何人都可以向 /.well-known/acme-challenge/ 追加一些随机的部分令牌,但如果您没有 acme.net 创建的具有完整令牌的匹配挑战文件,那么您就知道有人在做不法之事。将其列入黑名单!
  3. 那又怎样?即使他们获得了挑战响应的完整令牌,这与您的证书也毫无关系,它只是证明了您的服务器是 Let's Encrypt 将为其生成 SSL 证书的服务器。一旦认证,Let's Encrypt 就会将包含您证书的 pfx 文件发送给 acme.net。

其他工具

既然提到了 acme.net,Rick Strahl 在他的博客上讨论了其他用于处理 ACME 挑战的工具。就个人而言,我认为我在这里提出的 UI 应用程序是最简单、最灵活的,但这只是我并非谦虚的看法。

问题 3:自动化流程

我想在 IIS 中注册证书,或者,如果我只运行一个网站并且不想使用 IIS,可以使用 netsh。事实上,您仍然可以使用 netsh 端口 443 绑定运行一个网站,并在 IIS 中运行其他 HTTPS 网站!为了将所有部分组合在一起,我们需要一个执行此操作的工作流:

解决方案,步骤 1:调用 acme.net

启动 acme.net 作为进程很直接,除了这行隐藏了光标的代码:

private void GetPfxPasswordFromUser()
{
  System.Console.CursorVisible = false;

当在无窗口进程中启动时,这会引发异常,因此代码需要进行小的调整。感谢开源!

需要传递很多参数给 acme.net,其中几个取决于您是手动注册证书还是使用 IIS。这由前端通过以下单选按钮处理:

如果您只想获取 pfx 文件,但不想使用 IIS 或 netsh 绑定,那么“无”是一个选项。

注释描述了我使用的参数。

private Process LaunchAcmeDotNet(string domainName, string certPassword)
{
  // -a: accept terms of service
  // -j: accept instructions (since we're running a server that will accept the challenge
  // -d: the domain
  // -p: the password for the cert.
  // -c: challenge provider (manual, not iis)
  // -i: server configuration provider (manual, not iis)
  // -s: letsencrypt server. For staging server, use: 
  // <a href="https://acme-staging.api.letsencrypt.org">
  // https://acme-staging.api.letsencrypt.org</a>

  string staging = rbStaging.Checked ? "-s https://acme-staging.api.letsencrypt.org " : "";
  string manualOptions = "-c manual-http-01 -i manual"; // challenge is 
    // handled by our micro server, not IIS, and manual configuration of certificate
  string iisOptions = "-c manual-http-01 -i iis"; // challenge is handled by 
                        // our micro server but cert is installed and configured in IIS
  string options = rbIIS.Checked ? iisOptions : manualOptions;
  Process p = Helpers.LaunchProcess(@"acme.net\acme.exe", 
     String.Format(staging + "-a -j -d {0} -p {1} {2}", domainName, certPassword, options),
  stdout => this.Invoke(() => tbLog.AppendText(stdout + CRLF)),
  stderr => this.Invoke(() => tbLog.AppendText(stderr + CRLF)));

  return p;
}

为了简单起见,尤其是因为在为尚未运行的服务器向 IIS 注册证书时,IIS 服务器只监听端口 80,这是一个有点本末倒置的问题,我在 netsh 和 IIS 注册中都使用了上面描述的小型迷你服务器来处理 ACME 挑战。

解决方案,步骤 2:测试 - 生产与暂存

Let's Encrypt 不喜欢您过于频繁地访问其生产服务器以获取新证书,因此他们提供了一个暂存服务器供您测试。请使用它!我发现我必须进行大量的完整工作流程测试才能使一切正常。这在上面的代码中进行了处理,并在 UI 中向您公开,如所示。

解决方案,步骤 3:netsh

acme.net 会为您处理 IIS 绑定,但执行 netsh 绑定需要以编程方式导入证书并调用 netsh。

netsh:以编程方式导入证书

很简单,但是如果您查看我注释掉的所有代码,您可以看到我曾尝试过的各种方法,例如将证书导入“My”存储、导出它、进行修复、重新导入它。事实证明所有这些都不是必需的,但有一段时间,在我调试整个过程时,它们似乎是必需的!并且正确设置这些标志是一个反复试验的过程!

static string ImportCert(StoreName storeName, string certFile, 
       string password, out string certHash)
{
  // Must specify MachineKeySet otherwise you'll get a "SSL Certificate add failed, 
  // Error 1312" error.

  X509Certificate2 certToImport = new X509Certificate2(certFile, password, 
    X509KeyStorageFlags.Exportable | X509KeyStorageFlags.MachineKeySet | 
                                     X509KeyStorageFlags.PersistKeySet);
  X509Store store = new X509Store(storeName, StoreLocation.LocalMachine);
  store.Open(OpenFlags.MaxAllowed);
  store.Add(certToImport);
  store.Close();

  certHash = certToImport.Thumbprint;

  return certToImport.SerialNumber;
}

netsh:绑定

将证书绑定到端口 443 也非常简单(只要您没有遇到可怕的“错误 1312”!)。

static void RemoveBinding(Action<string> log)
{
  // netsh http delete sslcert ipport=0.0.0.0:443
  Process p = Helpers.LaunchProcess("netsh", "http delete sslcert ipport=0.0.0.0:443",
  (stdout) => log(stdout),
  (stderr) => log(stderr));

  p.WaitForExit();
}

static void AddNewBinding(string certHash, Guid appId, Action<string> log)
{
  // netsh http add sslcert ipport=0.0.0.0:443 certstorename=Root certhash=[] appid={}
  // Why root?
  // https://stackoverflow.com/questions/
  // 13076915/ssl-certificate-add-failed-when-binding-to-port/
  // 19766650#19766650 (see Fredy Wenger's response)
  Process p = Helpers.LaunchProcess("netsh", 
              "http add sslcert ipport=0.0.0.0:443 certstorename=Root certhash=" + 
              certHash + " appid={" + appId.ToString() + "}",
  (stdout) => log(stdout),
  (stderr) => log(stderr));

  p.WaitForExit();
}

再次,注意代码注释,尤其是在添加绑定部分。

清理旧证书

我注意到与绑定到 IIS 时不同的是,acme.net 不会删除旧证书。在我的实现中,我在获取新证书之前会清理证书。**警告!** 如果发生错误,或者您的网站流量持续不断,在成功安装新证书之前删除旧证书可能会导致用户遇到证书错误!

public static void RemoveCert(StoreName storeName, string subjectName)
{
  // <a href="http://stackoverflow.com/questions/7632757/
  // how-to-remove-certificate-from-store-cleanly">
  // http://stackoverflow.com/questions/7632757/how-to-remove-certificate-from-store-cleanly</a>
  X509Store store = new X509Store(storeName, StoreLocation.LocalMachine);
  store.Open(OpenFlags.ReadWrite | OpenFlags.IncludeArchived);
  X509Certificate2Collection certCollection = store.Certificates.Find
                    (X509FindType.FindBySubjectName, subjectName, false);

  foreach(var cert in certCollection)
  {
    store.Remove(certCollection[0]);
  }

  store.Close();
}

解决方案,步骤 4:我的证书何时到期?

(有几个我不想公开的网站与一些客户工作相关。)

了解证书何时到期也很有用。将来的一个有用做法是设置一个服务,该服务会自动续订将在,例如,15 天内到期的证书,但这超出了本文的范围。

protected void GetCertificatesIn(StoreName storeName, List<CertificateExpiration> certs)
{
  X509Store store = new X509Store(storeName, StoreLocation.LocalMachine);
  store.Open(OpenFlags.OpenExistingOnly);

  foreach (var cert in store.Certificates)
  {
    if (cert.Issuer.Contains("Let's Encrypt Authority"))
    {
      certs.Add(new CertificateExpiration()
      {
        Subject = cert.Subject.RightOf("CN="),
        ExpirationDate = cert.NotAfter,
      });
    }
  }

  store.Close();
}

上面文本框的一些花哨的格式(如果为文本框分配了像 Consolas 这样的等宽字体会有帮助)

var certExpDates = GetCertificateExpirationDates();
int maxSubjectLength = certExpDates.Max(c => c.Subject.Length);
tbExpirationDates.AppendText(
  String.Join(
   CRLF, 
   certExpDates.OrderBy(c => c.ExpirationDate).
   Select(c => c.Subject.PadRight(maxSubjectLength) + " : " + c.ExpirationDate)));

问题 4:我讨厌命令行

正如您到现在可能已经猜到的,我是一个 UI 人,而不是命令行/脚本/PowerShell 人。

解决方案

因此,整个东西都被打包在一个漂亮的 UI 中。

除了显示一些有用的信息,如您的本地和公共 IP 地址之外,输入您的域名和证书密码也非常简单。

结论

有一点需要提及——您必须以管理员身份运行应用程序!否则,您将无法享受 Let's Encrypt 和 IIS 多网站托管的乐趣!

此外,可执行文件还包含 acme.net 的二进制文件(已根据上述说明进行修改),因此您无需单独下载该应用程序。

历史

  • 2017 年 7 月 4 日:初始版本
© . All rights reserved.