利用golang申请Let's Encrypt的HTTPS实现证书自动部署

一.HTTPS的好处

1.目前已经都普及用https协议了,但是还是有一些没用https,https协议可以标识网站是否安全,简单的说就是网站传输数据的时候https更安全,http可能会被窃听数据等,而https数据传输会进行加密,所以是更安全可靠的协议,更大谷歌浏览器这些都大力推进用https。

二.申请HTTPS证书

一.首先说一下流程吧申请一个HTTPS证书的简单步骤

    1.向一个颁发证书的机构(CA)发出申请
    2.CA验证你域名的控制权
    3.下发证书

二.上面说到申请的步骤很简单就3步,可以看一下Let's Encrypt这个机构的证书是免费的,有的证书颁发机构是收费的,有啥区别呢,Let's Encrypt是免费的一个颁发证书的CA机构,不好的一点就是一个证书期限只有90天,到期可以续签,都是免费的,Let's Encrypt设置90天期限也是有他道理的,毕竟免费的证书万一也发生你的密钥泄露了,然而Let's Encrypt不可能你的泄露了,还找别人麻烦,所以90天自动失效可以避免密钥的安全,申请的证书还分种类的OV,DV,EV,这里我就不介绍这些证书的种类吧可以网上查一下就知道了,申请证书的工具cerbot。

三.golang申请证书

1.需要用到的包
github.com/xenolf/lego/acme 核心的包
github.com/janeczku/rancher-letsencrypt 这个库使用的上面acme的包给他重新封装了一下

这个包是申请证书的核心用到的acme,cerbot这个工具申请证书也需要acme实现github上有大佬用go写了acme,就可以利用这个库来进行操作,向Let's Encrypt机构申请证书需要一个账号,账号可以标识你申请证书的速率限制这些。

2.使用这个包申请证书

github.com/janeczku/rancher-letsencrypt 这个包下的有个方法NewClient(...)

    client, err = letsencrypt.NewClient(
        app.Config.Cert.Email,
        letsencrypt.KeyType(app.Config.Cert.KeyType),
        letsencrypt.ApiVersion(app.Config.Cert.ApiVersion),
        app.Config.Cert.DnsResolvers,
        provider,
    )

app.Config.Cert.Email:你的邮箱(1344178872.@qq.com)
app.Config.Cert.KeyType: 加密方式(RSA-2048)
app.Config.Cert.DnsResolvers: DNS 如果使用基于DNS的质询,则使用其中一个受支持的DNS提供商的现有帐户
app.Config.Cert.ApiVersion: 环境(生产环境[Production]和测试环境[Stagin])
Production是生产环境有限制,Stagin是测试环境无限制,限制指的是速率限制

3.通过NewCilent创建好client后

1.这个client有几个方法分别是:申请证书,更新证书 这两个是最主要的方法

func (c *Client) Issue(certName string, domains []string) (*AcmeCertificate, error) {
    certRes, err := c.client.ObtainCertificate(domains, true, nil, false)
    if err != nil {
        return nil, err
    }

    dnsNames := dnsNamesIdentifier(domains)
    acmeCert, err := c.saveCertificate(certName, dnsNames, *certRes)
    if err != nil {
        logrus.Fatalf("Error saving certificate '%s': %v", certName, err)
    }

    return acmeCert, nil
}

Issue这个方法是申请证书的方法,2个参数第一是证书名称,我看了一下他的源码这个certName代表申请下来后自动存储到本地的文件夹用做的名字,为了避免混淆还是域名是啥就写啥吧,申请下来后自动创建一个文件夹的名称就是你的域名名称好记一些,第二个参数是传入切片,可同时申请多个域名。
这个函数执行申请证书他会等待你去验证这个域名你是否有控制权,这是最关键的点,这个称之为 "挑战" 需要你去挑战。
2.挑战
挑战有3种方式我简单说两种吧DNS挑战和HTTP挑战
DNS: Let's Encrypt(CA)会给你下发任务去完成,例:它给你一个字符串叫你给这个域名的DNS添加一条TXT记录的值是它给的字符串,修改好了他会去验证,验证成功后颁发证书。
HTTP: CA会给你下发任务,列:给你一个key,返回Value给它,这里绑定了80端口,CA会发一个请求过来domain.com/.well-known/acme-challenge他会发一个key过来,然后通过Key获取Value返回给它代表验证成功,这个可以代理转发一下就实现了

v1 := ginsrv.Engine.Group("")

// http挑战
v1.GET("/.well-known/acme-challenge/*token", api.ChallengeCert)

这里我写了个路由可以获取Key,ChallengeCert方法去转发去获取Value
这个路由写到你域名的服务器上它需要发请求到你这个主机上面来验证。
申请证书客户端我是这样接收挑战来验证的

// 证书挑战
func ChallengeCert(domain, token string) (value string, err error) {
    exist, key := memoryProviderServer.GetKeyAuth(domain, token)
    if !exist {
        err = errors.NewCoder(404, fmt.Sprintf("domain AND token not found: %s %s", domain, token))
        return
    }

    value = key
    return
}

上面token就是传过来的key,这边client已经有Value了,我这边挑战的时候实现一下挑战方法,把Value存到内存中,获取返回

// 将keyAuth存在内存, 通过暴露一个方法获取对应domain的证书
type MemoryProviderServer struct {
    data map[string]string // domain+token => keyAuth
    lock sync.Mutex
}

func NewMemoryProviderServer() *MemoryProviderServer {
    return &MemoryProviderServer{
        data: map[string]string{},
    }
}

func (s *MemoryProviderServer) Present(domain, token, keyAuth string) error {
    s.lock.Lock()
    s.data[domain+token] = keyAuth
    s.lock.Unlock()
    return nil
}

func (s *MemoryProviderServer) CleanUp(domain, token, keyAuth string) error {
    s.lock.Lock()
    delete(s.data, domain+token)
    s.lock.Unlock()
    return nil
}

func (s *MemoryProviderServer) GetKeyAuth(domain, token string) (exist bool, keyAuth string) {
    s.lock.Lock()
    keyAuth, exist = s.data[domain+token]
    s.lock.Unlock()
    return
}

挑战有2个接口
Present和CleanUp,到了挑战的时候会传入keyAuth这个就是value我们需要获取的
我们直接先存入map里保存在内存里等待GetKeyAuth获取 最后返回 完成了挑战
自己实现这两个接口CleanUp是挑战完后执行的方法。

DNS怎么重写挑战接口实现也是同理实现这两个方法,

const DefaultTTL = 120

type DNSProviderBestDNS struct {
    RecordId int64
    Fqdn     string
}

func NewDNSProviderBestDNS() *DNSProviderBestDNS {
    return &DNSProviderBestDNS{}
}

// 实现接口
func (d *DNSProviderBestDNS) Present(domain, token, keyAuth string) error {
    // fqdn是用于设置TXT记录的完全限定域名,value是记录的值,是记录上ttl设置的TTL
    fqdn, value, _ := acme.DNS01Record(domain, keyAuth)
    req := &pb.AddRecordRq{
        Type:   "TXT",
        Value:  value,
        Ttl:    DefaultTTL,
        Domain: domain,
        Host:   acme.UnFqdn(strings.Replace(acme.UnFqdn(fqdn), domain, "", -1)),
    }
    log.Info(req.Value, req.Value[0])
    // 发出API请求在fqdn上设置txt记录值和ttl
    c := pb.NewDnsClient(client)
    rsp, err := c.AddRecord(context.Background(), req)
    if err != nil {
        err = errors.Wrap(err, "添加DNS调用GRPC服务错误")
        return err
    }
    if rsp.RecordId == 0 {
        err = errors.Wrap(err, "返回DNS记录的ID为0")
        return err
    }
    d.RecordId = rsp.RecordId
    d.Fqdn = fqdn
    return nil
}

func (d *DNSProviderBestDNS) CleanUp(domain, token, keyAuth string) error {
    // 清除你在Present中创建的任何状态,比如请求API删除txt记录
    // 发出API请求在fqdn上设置txt记录值和ttl
    req := &pb.DeleteRecordRq{
        HostId: d.RecordId,
        Domain: domain,
    }
    c := pb.NewDnsClient(client)
    _, err := c.DeleteRecord(context.Background(), req)
    if err != nil {
        err = errors.Wrap(err, "删除DNS调用GRPC服务错误")
        return err
    }
    return nil
}

DNS01Record(...)这个方法返回一个DNS记录,完成DNS01的挑战内部处理好了
DNS的txt记录的值和host最后我这里AddRecord(...)这个是一个GRPC服务写好了操作DNS的API实现修改删除等这些方法,直接调就ok了,GRPC还是蛮方便的以前写的方法操作DNS的一些东西,没想到这里需要操作DNS,直接调就好了,复用性还是不错,嘿嘿,完成了挑战本地会给你创建一个文件夹etc/letsencrypt/production/certs目录下2个PEM文件fullchain.pem和privkey.pem,一个公钥一个私钥。

4.部署证书

我自己实现了一个自动部署证书的方法,申请完证书后我存入mysql数据库
在需要部署证书的服务器上运行一个client这个client是部署机器client和上面申请证书的client不一样我还是把它叫做deploy-client(部署客户端)吧和server保持TCP长连接,
server里我写了一个路由用做Apply-cilent(这个就是申请证书的client)申请好后通过一个API把申请好的证书给server看看发送给server的代码吧

// 请求api通知front-api
func SendToFrontApi(fullchina, privkey, domain string) (err error) {
    urls := app.Config.FrontApi + certNotice
    resp, err := http.PostForm(urls,
        url.Values{"fullchina": {fullchina}, "privkey": {privkey}, "domain": {domain}})
    if err != nil {
        err = errors.NewCodere(400, err, "请求front_api出错")
        return
    }
    defer resp.Body.Close()
    return
}

三个东西:域名,私钥,公钥。
server和deplop-client保持着TCP通信的当server拿到证书的数据后发送到deplop-client去处理,看看我怎么处理的吧

            case "cert_event":
                pem := CertPem{}
                e := json.Unmarshal([]byte(in.Raw), &pem)
                if e != nil {
                    log.Errorf("CertPem Unmarshal err:%+v", err)
                    break
                }

                e = ssl.DeployNginxSsl(pem.Fullchina, pem.Privkey, pem.Domain)
                if e != nil {
                    log.Errorf("DeployNginxSsl err:%+v", e)
                    break
                }

                log.Infof("%s 部署成功", pem.Domain)
            }

deplop-client这边等待接收数据,判断数据的类型用switch处理对应的消息,json反序列化出来,DeployNginxSsl(...)函数去在本地的nginx里去部署。我这边我是这样做的,重所周知部署证书很简单,就是Nginx的conf配置里写好你证书的路径和域名,重启nginx生效,可以看一下我怎么处理的

// 部署证书
func DeployNginxSsl(fullChain string, privKey string, domain string) (err error) {
    // 生成证书文件
    err = writePemFile(fullChain, privKey, domain)
    if err != nil {
        err = errors.Wrap(err, "writePemFile error", domain)
        return
    }
    // 写入nginx配置conf
    err = writeNginxConf(domain)
    if err != nil {
        err = errors.Wrap(err, "writeNginxConf error", domain)
        return
    }
    // 测试配置
    err = nginxTest()
    if err != nil {
        err = errors.Wrap(err, "nginxTest error", domain)
        return
    }

    // 重启nginx生效
    err = reloadNginxByDocker()
    if err != nil {
        err = errors.Wrap(err, "reloadNginxByDocker error", domain)
        return
    }

    return
}

一个完整的自动申请,部署完成
接下来就是续签的问题证书到期时间为90天

deplop-client里面我写了一个定时器每天凌晨12点检查证书是否过期,过期就去Apply-client里面拿, Apply-client里我写了一个自动检查更新,去更新证书,更新好之后放入数据库,方便deplop-client去拿,Apply-client一般要早与deplop-client的更新,避免未更新拿到原来的证书数据。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,724评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,104评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,142评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,086评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,076评论 5 370
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,914评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,220评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,871评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,318评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,834评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,951评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,574评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,162评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,162评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,383评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,349评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,652评论 2 343

推荐阅读更多精彩内容