因为受不了 OHTTPS 每个季度手动更新 SSL 证书的流程,笔者难得的勤快了一次搭建了一套自动部署的服务。

方案选择 链接到标题

OHTTPS 提供了多种多样的自动部署方案,如果服务器用的是云服务商提供应用系统的话选择对应云服务商方式是最佳选择,但可惜笔者的服务器选的是最普通的 Ubuntu 22.04 系统,没办法在云服务商的控制台上设置自动部署。

于是留给笔者的路就只剩下 SSH 和 webhook 了,前者笔者懒得建一个专用用户,用日常使用的用户又有权限过大的风险,恰好也没尝试过 webhook 的流程,因此笔者便顺理成章的选择了这一方案。

什么是 webhook 链接到标题

webhook 很通俗的定义就是一个反向的 API,本来应该是客户端定时访问服务端上的某一个服务,现在变成了服务端定时访问客户端上的某一个服务,是不是就和反过来的 API 一模一样。

GPT 给的定义如下:

Webhook 是一种使得一个应用能够向另一个应用提供实时信息的方法。它通常用于在特定事件发生时自动触发和传递消息。Webhook 是一种基于 HTTP 的推送机制,允许应用在定义的事件发生时向预定的 URL 发送信息,而不需要客户端定期检查或轮询更新。

细读文档 链接到标题

在 OHTTPS 的官方文档里可以找到回调的具体形式:

method: POST,
content-type: application/json
data: {  
	timestamp, // 请求时间戳  
	payload: {  
	certificateName, // 证书ID  
	certificateDomains, // 证书关联域名  
	certificateCertKey, // 证书私钥(PEM格式)  
	certificateFullchainCerts, // 证书(包含证书和中间证书)(PEM格式)  
	certificateExpireAt // 证书过期时间  
	},  
	sign // 请求签名,`${timestamp}:${回调令牌}`的32位小写md5值  
}

此外还能在配置 webhook 的页面看到所需的参数:

img1

回调地址即我们到时候将服务启动后用于的 API 地址,回调令牌则是在网页上生成后用于服务端与客户端鉴权用的。

梳理逻辑 链接到标题

一切都准备就绪,下面我们就可以开始梳理逻辑了。

首先我们需要在服务器上启动一个服务,服务的参数要和 OHTTPS 官网文档中写的一致,这个服务的 API 地址需要在配置自动部署节点时填写。

这个服务的具体流程如下:

  1. 接收请求后按参数格式解析载荷。
  2. 根据参数所带的时间戳与配置节点时官网上生成的回调令牌计算 md5,并于参数中的请求签名进行匹配,不一致就返回请求失败。
  3. 新建临时路径用于存储新的证书文件。
  4. 将请求参数中证书相关的数据保存到文件中。
  5. 将旧的证书文件备份,将临时目录修改为正式目录名称。

具体代码 链接到标题

main.go

package main  
  
import (  
    "github.com/gin-gonic/gin"  
    "log")  
  
func main() {  
    log.Println("==== ssl-webhook ====")  
  
    err := InitConst()  
    if err != nil {  
       log.Fatal(err)  
    }  
  
    r := gin.Default()  
    InitRoutes(r)  
  
    _ = r.Run(":" + RouterPort)  
}

consts.go

package main  
  
import (  
    "errors"  
    "github.com/joho/godotenv"    "log"    "os")  
  
var (  
    ContextPath       string  
    CallbackToken     string  
    NginxCertBasePath string  
    RouterPort        string  
)  
  
func init() {  
    err := godotenv.Load()  
    if err != nil {  
       log.Fatal(err)  
    }  
}  
  
func InitConst() error {  
    if os.Getenv("CONTEXT_PATH") == "" {  
       return errors.New("[ERROR] CONTEXT_PATH is not set")  
    }  
    if os.Getenv("CALLBACK_TOKEN") == "" {  
       return errors.New("[ERROR] CALLBACK_TOKEN is not set")  
    }  
    if os.Getenv("NGINX_CERT_BASE_PATH") == "" {  
       return errors.New("[ERROR] NGINX_CERT_BASE_PATH is not set")  
    }  
    if os.Getenv("ROUTER_PORT") == "" {  
       return errors.New("[ERROR] ROUTER_PORT is not set")  
    }  
    ContextPath = os.Getenv("CONTEXT_PATH")  
    CallbackToken = os.Getenv("CALLBACK_TOKEN")  
    NginxCertBasePath = os.Getenv("NGINX_CERT_BASE_PATH")  
    RouterPort = os.Getenv("ROUTER_PORT")  
    return nil  
}

routers.go

package main  
  
import (  
    "crypto/md5"  
    "encoding/hex"    "fmt"    "github.com/gin-gonic/gin"    "io"    "log"    "net/http"    "os"    "strconv"    "strings"    "time")  
  
type OhttpsSslDeployRequest struct {  
    Timestamp int64 `form:"timestamp"` // 请求时间戳  
    Payload   struct {  
       CertificateName           string   `form:"certificateName"`           // 证书ID  
       CertificateDomains        []string `form:"certificateDomains"`        // 证书关联域名  
       CertificateCertKey        string   `form:"certificateCertKey"`        // 证书私钥(PEM格式)  
       CertificateFullchainCerts string   `form:"certificateFullchainCerts"` // 证书(包含证书和中间证书)(PEM格式)  
       CertificateExpireAt       int64    `form:"certificateExpireAt"`       // 证书过期时间  
    } `form:"payload"`  
    Sign string `form:"sign"` // 请求签名,`${timestamp}:${回调令牌}`的32位小写md5值  
}  
  
func InitRoutes(engine *gin.Engine) {  
    engine.GET(ContextPath+"/hello", func(c *gin.Context) {  
       c.JSON(http.StatusOK, gin.H{  
          "message": "world",  
       })  
    })  
  
    ohttps := engine.Group(ContextPath + "/ohttps")  
    {  
       ohttps.GET("/hello", func(c *gin.Context) {  
          c.JSON(http.StatusOK, gin.H{  
             "success": "hello ohttps",  
          })  
       })  
       // [OHTTPS - 免费HTTPS证书、自动更新、自动部署](https://ohttps.com/docs/cloud/webhook/webhook)  
       ohttps.POST("/deploy", func(c *gin.Context) {  
          var request OhttpsSslDeployRequest  
          fmt.Println(c.Query("a")) // url 参数  
          err := c.ShouldBind(&request)  
          if err != nil {  
             c.JSON(http.StatusInternalServerError, gin.H{  
                "success": false,  
                "error":   err.Error(),  
             })  
             log.Panic("[ERROR] ", err)  
             return  
          }  
  
          log.Println(request.Payload.CertificateName, request.Payload.CertificateDomains)  
          // 验签  
          md5Obj := md5.New()  
          _, err = io.WriteString(md5Obj, strconv.FormatInt(request.Timestamp, 10)+":"+CallbackToken)  
          if err != nil {  
             log.Fatal("[ERROR] ", err)  
             return  
          }  
          md5sum := hex.EncodeToString(md5Obj.Sum(nil))  
          if md5sum != request.Sign {  
             c.JSON(http.StatusInternalServerError, gin.H{  
                "success": false,  
                "error":   "签名验证不通过",  
             })  
             log.Panic("[ERROR] 签名验证不通过, request.Sign: ", request.Sign, ", request.Timestamp: ", request.Timestamp, ", md5sum: ", md5sum)  
             return  
          }  
  
          // 按关联的域名, 生成对应的目录 先带 .tmp 后缀,  
          // 然后, 正在使用的目录更名带 日期后缀 备份, 新的去掉 tmp 后缀  
          for _, domain := range request.Payload.CertificateDomains {  
             domainCertPath := domain  
             if strings.HasPrefix(domain, "*") {  
                // 泛域名  
                domainCertPath = domain[2:]  
             }  
             tmpCertPath := NginxCertBasePath + "/" + domainCertPath + ".tmp"  
             err = os.MkdirAll(tmpCertPath, os.ModePerm)  
             if err != nil {  
                log.Fatal("[ERROR] ", err)  
                return  
             }  
  
             certKeyFile, _ := os.OpenFile(tmpCertPath+"/cert.key", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)  
             defer func(certKeyFile *os.File) {  
                err = certKeyFile.Close()  
                if err != nil {  
                   log.Fatal("[ERROR] ", err)  
                   return  
                }  
             }(certKeyFile)  
             _, err = certKeyFile.WriteString(request.Payload.CertificateCertKey)  
             if err != nil {  
                log.Fatal("[ERROR] ", err)  
                return  
             }  
  
             fullchainFile, _ := os.OpenFile(tmpCertPath+"/fullchain.cer", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)  
             defer func(fullchainFile *os.File) {  
                err = fullchainFile.Close()  
                if err != nil {  
                   log.Fatal("[ERROR] ", err)  
                   return  
                }  
             }(fullchainFile)  
             _, err = fullchainFile.WriteString(request.Payload.CertificateFullchainCerts)  
             if err != nil {  
                log.Fatal("[ERROR] ", err)  
                return  
             }  
  
             // 原目录备份  
             err = os.Rename(NginxCertBasePath+"/"+domainCertPath, NginxCertBasePath+"/"+domainCertPath+"."+time.Now().Format("20060102150405"))  
             if err != nil {  
                log.Fatal("[ERROR] ", err)  
                return  
             }  
             // 启用新的  
             err = os.Rename(tmpCertPath, tmpCertPath[:len(tmpCertPath)-4])  
             if err != nil {  
                log.Fatal("[ERROR] ", err)  
                return  
             }  
             log.Printf("部署 %s 成功, 路径: %s\n", domain, tmpCertPath[:len(tmpCertPath)-4])  
          }  
  
          c.JSON(http.StatusOK, gin.H{  
             "success": true,  
          })  
  
       })  
    }  
}

.env

ROUTER_PORT=10000  
  
CONTEXT_PATH=/sslwebhook  
CALLBACK_TOKEN=xxx
NGINX_CERT_BASE_PATH=/etc/nginx/cert