Go言語製Let's Encryptクライアントlegoをライブラリとして使う

ピリカではほとんどのサービスでGCPやAWSが発行するHTTPS証明書を使っていますが、見える化ページと呼ばれる、自治体や企業など、一定の範囲内でのごみ拾い活動を集約しているサービスで使っているワイルドカード証明書はLet's Encryptで発行しています。

Let's Encryptで証明書発行するためのクライアントとしては標準のcertbotの他にもいろいろなものがあります。
個人的に、[lego](https://github.com/go-acme/lego)を気に入って使っています。お気に入りポイントはこんな感じです。

- Go言語で作られていて、Pythonの言語環境に依存せずに使える
- いろいろなDNSサービスに対応している
- コマンドラインがcertbotよりもわかりやすい

コマンドとしてずっと使っていて、このコマンドを内部的に使用する形で証明書を自動更新をするCloud Runを作ろうとしていたのですが、改めてリファレンスを読んでいたところ[ライブラリとしても運用できる](https://go-acme.github.io/lego/usage/library/)ことがわかりました。

今回はlegoライブラリでLet's Encryptクライアントを書いてみたので、使い方を紹介します。

<!-- more -->

## ユーザーオブジェクトの作成

legoクライアントライブラリでは、クライアントの作成時にユーザー情報を返すinterfaceを与える必要があります。

```go
type User interface {
	GetEmail() string
	GetRegistration() *Resource
	GetPrivateKey() crypto.PrivateKey
}
```

ユーザー情報は、秘密鍵も含めてJSONにシリアライズしてSecret Managerに保存しておきたいので、このようにJSONにシリアライズできる形にしてみました。

```go
package main

import (
	"crypto"
	"crypto/ecdsa"
	"crypto/x509"
	"encoding/base64"
	"encoding/json"
	"github.com/go-acme/lego/v4/registration"
)

type User struct {
	Email        string                 `json:"email"`
	Registration *registration.Resource `json:"registration"`
	Key          *ecdsa.PrivateKey      `json:"-"`
}

func (u *User) GetEmail() string {
	return u.Email
}
func (u User) GetRegistration() *registration.Resource {
	return u.Registration
}
func (u *User) GetPrivateKey() crypto.PrivateKey {
	return u.Key
}

func (u *User) MarshalJSON() ([]byte, error) {
	x509Key, err := x509.MarshalECPrivateKey(u.Key)
	if err != nil {
		return []byte{}, err
	}

	type Alias User
	return json.Marshal(&struct {
		*Alias
		AliasKey string `json:"key"`
	}{
		Alias:    (*Alias)(u),
		AliasKey: base64.StdEncoding.EncodeToString(x509Key),
	})
}

func (u *User) UnmarshalJSON(b []byte) error {
	type Alias User

	// JSONからデコード
	aux := &struct {
		*Alias
		AliasKey string `json:"key"`
	}{
		Alias: (*Alias)(u),
	}
	if err := json.Unmarshal(b, &aux); err != nil {
		return err
	}

	der, err := base64.StdEncoding.DecodeString(aux.AliasKey)
	if err != nil {
		return nil
	}
	key, err := x509.ParseECPrivateKey(der)
	if err != nil {
		return nil
	}

	u.Key = key
	return nil
}
```

## アカウントの作成

Let's Encryptへアカウントを登録します。

```go
// 秘密鍵を作成
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
	log.Fatal(err)
}

// ユーザーを作成
user := User{
	Email: mailAddress,
	Key:   privateKey,
}

// 設定を作成
config := lego.NewConfig(user)
config.CADirURL = directory  // Let's Encrypt の directory API　の URL
config.Certificate.KeyType = certcrypto.RSA2048

// クライアントを作成
client, err := lego.NewClient(config)
if err != nil {
	log.Fatal(err)
}

// アカウント登録
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
	log.Fatal(err)
}

// ユーザーの登録情報を更新
user.Registration = reg

// 作成したユーザー情報をJSONにする
userJson, err := json.Marshal(user)
if err != nil {
	log.Fatal(err)
}

// ここでuserJsonをSecret Managerに保管
```

## 証明書の発行

登録したアカウントを使ってSSL証明書を発行します。

```go
// アカウント作成で作ったJSONがuserにUnmarshalされている

// 設定を作成
config := lego.NewConfig(user)
config.CADirURL = directory  // Let's Encrypt の directory API　の URL
config.Certificate.KeyType = certcrypto.RSA2048

// クライアントを作成
client, err := lego.NewClient(config)
if err != nil {
	log.Fatal(err)
}

// ドメイン検証プロバイダを設定
// gcloud: Google Cloud DNS
// NewDNSProviderCredential は Application Default Credential 認証
// project は GCP プロジェクト名を指定する
provider, err := gcloud.NewDNSProviderCredentials(project)
if err != nil {
	log.Fatal(err)
}
err = client.Challenge.SetDNS01Provider(provider)
if err != nil {
	log.Fatal(err)
}

// 証明書発行
certificates, err := client.Certificate.Obtain(certificate.ObtainRequest{
	Domains: []string{"test1.example.com", "test2.example.com"},
	Bundle:  true,
})
if err != nil {
	log.Fatal(err)
}

// 以下の場所に証明書と秘密鍵ができるので、適宜Cloud StorageやSecret Manager等に保存する
// certificates.Certificate -> 証明書
// certificates.PrivateKey -> 秘密鍵
```

## 実際の運用

ピリカではほとんどのドメインが1つのHostedZoneにまとめられています。

そのため、各プロジェクトの証明書発行を、DNSを管理しているGCPプロジェクトで担い、Cloud Storageに保存し、利用先のプロジェクトのサービスアカウントに読み込み権限を与えて使用することにしました。

![20220408230641.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/609129/80bdbc8b-717f-50cf-1481-928a927e7afe.png)[^1]

legoライブラリで作った証明書発行をCloud RunにラップしてCloud Storageに証明書を保存します。利用側のプロジェクトから保存された証明書をフェッチします。
Cloud Schedulerでこれを定期的に実行すれば完成です。

どのドメインの証明書を発行するかはCloud Runのパラメータで渡すため、Cloud Schedulerで実行パラメータをいじるだけで対象ドメインを簡単に増やすことができるようにしています。

----

サーバーレスな証明書発行をするべく、Cloud Functions上でPythonやnode.jsでやろうとしていろいろ試行錯誤しようとしていましたが、複雑だったり、使い方が不明瞭なライブラリが多くてなかなかうまくいきませんでしたが、最終的にlegoのライブラリという方法が見つかってよかったです。

[^1]: 昨今はCloud Runが主流かと思いますが、ピリカではランタイム・コンテナイメージの管理の手間を省く意図でCloud Functionsを使うことが多いです。今回もCloud Functionsにしようとしたのですが、もともとRunで作りかけていたことや、テスト利用を兼ねてRunで実装しました。