1. 用测试 API 体验如何申请通配符证书
通配符证书(Wildcard Certificate)
--server https://acme-v02.api.letsencrypt.org/directoryDNS TXT

在你申请证书时,Let's Encrypt 需要确保你拥有此域名,比如你不可能申请到包含 google.com 域名的证书。Certbot 支持如下 插件 来验证你是否拥有域名:

Plugin Authenticator Installer Notes Challenge types (and port)
Y Y Automates obtaining and installing a certificate with Apache.
Y Y Automates obtaining and installing a certificate with Nginx.
Y N Obtains a certificate by writing to the webroot directory of an already running webserver.
Y N Uses a “standalone” webserver to obtain a certificate. Requires port 80 to be available. This is useful on systems with no webserver, or when direct integration with the local webserver is not supported or not desired.
Y N This category of plugins automates obtaining a certificate by modifying DNS records to prove you have control over a domain. Doing domain validation in this way is the only way to obtain wildcard certificates from Let’s Encrypt.
Y N Helps you obtain a certificate by giving you instructions to perform domain validation yourself. Additionally allows you to specify scripts to automate the validation task in a customized way. http-01 (80) or dns-01 (53)
manual--preferred-challenges dns-01
[root@CentOS ~]# certbot certonly \
  --manual --preferred-challenges dns-01 \
  -d *.madmalls.com -d madmalls.com \
  --server https://acme-staging-v02.api.letsencrypt.org/directory

Saving debug log to /var/log/letsencrypt/letsencrypt.log
Plugins selected: Authenticator manual, Installer None
Enter email address (used for urgent renewal and security notices) (Enter 'c' to
cancel): wangy8961@163.com  # 用于接收证书快过期的提醒邮件或安全邮件
Starting new HTTPS connection (1): acme-staging-v02.api.letsencrypt.org

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Please read the Terms of Service at
https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf. You must
agree in order to register with the ACME server at
https://acme-staging-v02.api.letsencrypt.org/directory
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
(A)gree/(C)ancel: A  # 同意服务条款

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Would you be willing to share your email address with the Electronic Frontier
Foundation, a founding partner of the Let's Encrypt project and the non-profit
organization that develops Certbot? We'd like to send you email about our work
encrypting the web, EFF news, campaigns, and ways to support digital freedom.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
(Y)es/(N)o: N  # 不公开我的邮箱地址
Obtaining a new certificate
Performing the following challenges:
dns-01 challenge for madmalls.com
dns-01 challenge for madmalls.com

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NOTE: The IP of this machine will be publicly logged as having requested this
certificate. If you're running certbot in manual mode on a machine that is not
your server, please ensure you're okay with that.

Are you OK with your IP being logged?
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
(Y)es/(N)o: Y

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Please deploy a DNS TXT record under the name
_acme-challenge.madmalls.com with the following value:

gVC175mlzBZU--D7yFmbEhoZOUbPX6JSE8Tjmz_1kN0  # 到你的域名下添加一条 DNS TXT 记录

Before continuing, verify the record is deployed.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Press Enter to Continue

切记: 要先到你的域名下添加 DNS TXT 记录,并等它生效后才能敲回车继续

再打开一个新的 Shell 会话,确认 DNS 记录已生效:

[root@CentOS ~]# dig -t txt _acme-challenge.madmalls.com

; <<>> DiG 9.9.4-RedHat-9.9.4-37.el7 <<>> -t txt _acme-challenge.madmalls.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 26864
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;_acme-challenge.madmalls.com.  IN  TXT

;; ANSWER SECTION:
_acme-challenge.madmalls.com. 600 IN    TXT "gVC175mlzBZU--D7yFmbEhoZOUbPX6JSE8Tjmz_1kN0"  # 注意返回值是否一致
-d
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Press Enter to Continue
Waiting for verification...
Cleaning up challenges
Resetting dropped connection: acme-staging-v02.api.letsencrypt.org
Starting new HTTPS connection (2): acme-staging-v02.api.letsencrypt.org

IMPORTANT NOTES:
 - Congratulations! Your certificate and chain have been saved at:
   /etc/letsencrypt/live/madmalls.com/fullchain.pem
   Your key file has been saved at:
   /etc/letsencrypt/live/madmalls.com/privkey.pem
   Your cert will expire on 2019-09-05. To obtain a new or tweaked
   version of this certificate in the future, simply run certbot
   again. To non-interactively renew *all* of your certificates, run
   "certbot renew"
 - If you like Certbot, please consider supporting our work by:

   Donating to ISRG / Let's Encrypt:   https://letsencrypt.org/donate
   Donating to EFF:                    https://eff.org/donate-le

申请完证书后,请删除对应的 DNS TXT 记录!

2. 自动化申请
APIcertbot-dns-cloudflarecertbot-dns-google

国内的 Aliyun 和腾讯云 DNS 也提供了 API 接口,我的 DNS 解析是用的阿里云的(控制台: https://dns.console.aliyun.com/ ),我们只需要调用如下两个接口即可:

Golang
AccessKeyRAM

我写好的代码放在 Github 上了:

package main

import (
    "encoding/json"
    "flag"
    "fmt"
    "log"
    "os"
    "time"

    "github.com/aliyun/alibaba-cloud-sdk-go/services/alidns"
)

// Config 保存 accesskey 的结构体
type Config struct {
    AccessKeyID     string `json:"accessKeyID"`
    AccessKeySecret string `json:"accessKeySecret"`
}

// 从 JSON 配置文件中读取 accesskey
func readJSONFile(filename string) Config {
    var config Config

    f, err := os.Open(filename)
    defer f.Close()
    if err != nil {
        log.Fatalf("Faild to open the JSON file: %s", err)
    }

    dec := json.NewDecoder(f)
    if err = dec.Decode(&config); err != nil {
        log.Fatalf("Faild to parse the JSON file: %s", err)
    }

    return config
}

// 通过阿里云的 SDK 添加一条 DNS TXT 解析记录,返回记录的 RecordId,后续删除时需要用到它
func addDomainRecord(client *alidns.Client, domainName string, value string) {
    request := alidns.CreateAddDomainRecordRequest()

    request.DomainName = domainName
    request.Type = "TXT"
    request.RR = "_acme-challenge"
    request.Value = value

    response, err := client.AddDomainRecord(request)
    if err != nil {
        fmt.Print(err.Error())
    }
    fmt.Printf("[%s] Response from 'addDomainRecord()' is %v\n", time.Now().Format("2006-01-02 15:04:05"), response)
}

// 列出所有记录类型为 TXT,且记录名包含 '_acme-challenge' 的所有记录,返回 recordID 组成的切片,后续删除它们
func listDomainRecords(client *alidns.Client, domainName string) []string {
    request := alidns.CreateDescribeDomainRecordsRequest()

    request.DomainName = domainName
    request.TypeKeyWord = "TXT"
    request.RRKeyWord = "_acme-challenge"

    response, err := client.DescribeDomainRecords(request)
    if err != nil {
        fmt.Print(err.Error())
    }
    fmt.Printf("[%s] Response from 'listDomainRecords()' is %v\n", time.Now().Format("2006-01-02 15:04:05"), response)

    var recordIds []string
    for _, r := range response.DomainRecords.Record {
        recordIds = append(recordIds, r.RecordId)
    }

    return recordIds
}

// 删除解析记录
func deleteDomainRecord(client *alidns.Client, recordID string) {
    request := alidns.CreateDeleteDomainRecordRequest()

    request.RecordId = recordID

    response, err := client.DeleteDomainRecord(request)
    if err != nil {
        fmt.Print(err.Error())
    }
    fmt.Printf("[%s] Response from 'deleteDomainRecord()' is %v\n", time.Now().Format("2006-01-02 15:04:05"), response)
}

func main() {
    // 提供 -c 选项,用户可以指定JSON配置文件。注意,cfg 是一个指针
    cfg := flag.String("c", "config.json", "Assign the JSON config file")
    // 操作类型,authenticator: 域名认证,添加 DNS TXT 记录; cleanup: 认证通过后,删除此 DNS TXT 记录
    opt := flag.String("o", "authenticator", "Operate: authenticator or cleanup")
    // 提供 -h 选项,查看命令行帮助信息
    help := flag.Bool("h", false, "show help infomation")
    flag.Parse()

    if *help {
        flag.Usage()
        os.Exit(0)
    }

    // 解析JSON配置文件
    config := readJSONFile(*cfg)

    // Client for Aliyun DNS SDK
    client, err := alidns.NewClientWithAccessKey("cn-hangzhou", config.AccessKeyID, config.AccessKeySecret)
    if err != nil {
        log.Fatal(err.Error())
    }

    // 判断操作类型
    switch *opt {
    case "authenticator":
        // CERTBOT_DOMAIN 和 CERTBOT_VALIDATION 是 Certbot Hooks 传过来的环境变量
        domainName := os.Getenv("CERTBOT_DOMAIN")
        value := os.Getenv("CERTBOT_VALIDATION")

        if domainName == "" || value == "" {
            log.Fatal("Error: This plugin can only be used for 'certbot' (Let's Encrypt)")
        }

        addDomainRecord(client, domainName, value)

        // Sleep to make sure the change has time to propagate over to DNS
        time.Sleep(30 * time.Second)
        /* 否则报错:
        Attempting to renew cert (madmalls.com) from /etc/letsencrypt/renewal/madmalls.com.conf produced an unexpected error: Failed authorization procedure. madmalls.com (dns-01): urn:ietf:params:acme:error:dns :: DNS problem: NXDOMAIN looking up TXT for _acme-challenge.madmalls.com - check that a DNS record exists for this domain. Skipping.All renewal attempts failed. The following certs could not be renewed:
        /etc/letsencrypt/live/madmalls.com/fullchain.pem (failure)
        */

    case "cleanup":
        // 先获取所有记录类型为 TXT,且记录名包含 '_acme-challenge' 的记录 ID
        recordIds := listDomainRecords(client, os.Getenv("CERTBOT_DOMAIN"))
        fmt.Printf("[%s] All record Ids that need to delete is: %v\n", time.Now().Format("2006-01-02 15:04:05"), recordIds)

        // 循环,删除它们
        for _, id := range recordIds {
            deleteDomainRecord(client, id)
        }
    }
}

编译:

[root@CentOS ~]# go build -o certbot-dns-aliyun main.go
certbot-dns-aliyun/etc/letsencrypt/config.json
{
    "accessKeyID": "你的AccessKeyID",
    "accessKeySecret": "你的AccessKeySecret"
}

2.1 RSA 通配符证书

[root@CentOS ~]# certbot certonly \
  --non-interactive \
  --email wangy8961@163.com \
  --agree-tos \
  --manual-public-ip-logging-ok \
  --manual --preferred-challenges dns-01 \
  --manual-auth-hook "/etc/letsencrypt/certbot-dns-aliyun -o authenticator" \
  --manual-cleanup-hook "/etc/letsencrypt/certbot-dns-aliyun -o cleanup" \
  -d *.madmalls.com -d madmalls.com \
  --server https://acme-v02.api.letsencrypt.org/directory
--email-d
--server https://acme-staging-v02.api.letsencrypt.org/directory

验证证书的内容:

[root@CentOS ~]# openssl x509 -text -in /etc/letsencrypt/live/madmalls.com/fullchain.pem -noout

Certificate:
    Data:
        Version: 3 (0x2)