Caddyに自作モジュールを組み込んで最強のマイWebサーバをサクッと作ってみる

今回はGoで出来たWebサーバーである[Caddy](https://caddyserver.com/)の自作モジュール（プラグイン）を書いてみたいと思います。（Caddy 2.8.4 で検証）

この記事で使用しているコードは以下で入手可能です。

https://gitlab.com/ksaitou/2024-12-26_caddycustommod#


## どうやってCaddyで自作モジュールを実装すればいいのか？

以下の公式ドキュメントで詳しく説明されているのですが、要は**自作パッケージの `init()` 関数にモジュール登録のコード `func init () {caddy.RegisterModule(...)}` を仕込んでおき、それをxcaddyというツールでCaddy側からパッケージimportしてもらえば自作モジュール組み込み完了**です。

https://caddyserver.com/docs/extending-caddy

詳しく書くと以下のようになります。

1. 自作モジュールの `init` 関数で `caddy.RegisterModule(Moduleインタフェースを実装したインスタンス)` を呼び出す
    - `func CaddyModule() caddy.ModuleInfo` を実装した構造体を登録すればOKです
2. 上記の `init` 関数を呼び出すため（＝パッケージインポートで読み込ませるため）に自作モジュールを組み込んだCaddyを [xcaddy](https://github.com/caddyserver/xcaddy) でビルドする
    - 「xcaddyとはなんぞや？」と思われるかもしれませんが、これはCaddyの補助ツールのようなもので、純Caddyに対して外部モジュールをインポートするコードを追加してくれます（[参考](https://github.com/caddyserver/xcaddy/blob/master/cmd/main.go)）
        - たとえば、 `xcaddy build --with github.com/caddy-dns/route53@v1.4.0` とすればRoute53プラグインを同梱したCaddyがビルドされます[^1]
    - Caddy自体を改造して自作パッケージを呼び出すように修正してもOKそうです

[^1]: 内部的にはGoコマンドを呼び出して既存のCaddyの依存性 + `--with` で指定したモジュールを組み合わせて、go build のお膳立てをした後にビルドをしているようです https://github.com/caddyserver/xcaddy/blob/2977c7faa42817239cf6bb63bb0dd30790c96674/cmd/commands.go#L76

プラグインといいつつ`*.so`のロードやIPCやgRPCといった難しい要素はありません。（逆に言うとビルド後、他のWebサーバーのように動的に後でプラグインを組み込む方法がないとも言えます）

とはいえ、デバッグ時にxcaddy読んで云々はちょっと面倒なのでこの記事ではいくつかショートカットする方法について述べていきます。

では、さっそく自作モジュールを書いてみましょう！


## すごく簡単なCaddyモジュールの実装

Goプロジェクトを新規作成します。

```bash: プロジェクトの作成
$ mkdir my-caddy-mod
$ cd my-caddy-mod
$ go mod init my-caddy-mod
```

まずは最小の自作Caddyモジュールを定義します。

```go:mycaddymod/mycaddymod.go
package mycaddymod

import "github.com/caddyserver/caddy/v2"

func init() {
	caddy.RegisterModule(MyCaddyMod{})
}

type MyCaddyMod struct {
}

func (MyCaddyMod) CaddyModule() caddy.ModuleInfo {
	return caddy.ModuleInfo{
		ID:  "mycaddymod",
		New: func() caddy.Module { return new(MyCaddyMod) },
	}
}
```

あとはxcaddyで上記パッケージimportをCaddyに組み込んでもらえばOKですが、いちいち自作プロジェクトをcaddyプロジェクト側から組み込むのは面倒くさいので、Caddy自体を自作プロジェクトから呼び出して検証します。

```go:main.go
package main

import (
    // 自作モジュールの呼び出し
	_ "my-caddy-mod/mycaddymod"

    // Caddyのコア
	caddycmd "github.com/caddyserver/caddy/v2/cmd"
    // Caddyfileのサポートなど標準モジュールの組み込み
    _ "github.com/caddyserver/caddy/v2/modules/standard"
)

func main() {
    // https://github.com/caddyserver/caddy/blob/master/cmd/caddy/main.go と内容は同じ
	caddycmd.Main()
}
```

そもそも `import` すら省略して `main.go` にモジュール定義そのものを直接書くこともできます。 **この記事では分かりやすさ優先でこの方法を採用します。**

```main.go
package main

import (
	"github.com/caddyserver/caddy/v2"
	caddycmd "github.com/caddyserver/caddy/v2/cmd"
    _ "github.com/caddyserver/caddy/v2/modules/standard"
)

func main() {
	caddy.RegisterModule(MyCaddyMod{})
	caddycmd.Main()
}


type MyCaddyMod struct {
}

func (MyCaddyMod) CaddyModule() caddy.ModuleInfo {
	return caddy.ModuleInfo{
		ID:  "mycaddymod",
		New: func() caddy.Module { return new(MyCaddyMod) },
	}
}
```

ではこれを実行してみましょう。`caddy list-modules` コマンド相当を動かし、自作モジュールが組み込まれているか確認してみます。 `Non-standard modules` として `mycaddymod` が認識されていることが分かります。

```bash
# go mod tidy は適宜動かしてください
$ go mod tidy

# go run main.go list-modules でも可
$ go run . list-modules
admin.api.load
caddy.config_loaders.http
caddy.logging.writers.discard
caddy.logging.writers.stderr
caddy.logging.writers.stdout

  Standard modules: 5

mycaddymod

  Non-standard modules: 1

  Unknown modules: 0
```

自作モジュールが組み込まれたCaddyをWebサーバーとして稼働させてみましょう。設定ファイル `Caddyfile` を用意して `run` コマンドを実行するだけです。[^3]

[^3]: 途中のソースコードにも記述していますが、設定ファイルである `Caddyfile` は `main.go` で `import _ "github.com/caddyserver/caddy/v2/modules/standard"` していないと `Caddyfile` アダプタの不在で読み込むことができません（**裸のCaddyが対応している設定ファイルはJSONフォーマットだけです**）

```Caddyfile:Caddyfile
http://localhost:18080 {
    respond "hello!"
}
```

```bash:自作Caddyを設定ファイル指定で動かしてみる
$ go run . run --config Caddyfile

2024/11/20 05:41:31.802 INFO    using config from file  {"file": "Caddyfile"}
2024/11/20 05:41:31.805 INFO    adapted config to JSON  {"adapter": "caddyfile"}
...
```

```bash:Caddyに対してcurlでリクエストを発行してみる
$ curl localhost:18080
hello!
```

無事HTTPサーバーとして応答してくれました。


## 設定ファイルを読み込んだり、Webサーバーとしての機能を追加してみる

何もしないモジュールではつまらないので、何か機能を追加したいと思います。

ここでは特定のディレクティブを書いたパスに自作APIサーバーを生やしたいと思います。応答は設定の `message = "...."` の内容を返すこととしましょう。

```Caddyfile:Caddyfile
http://localhost:18080 {
    mycaddymod {
        message = "hello from mycaddymod Caddyfile config!"
    }
}
// => curl http://localhost:18080 にアクセスすると↑のメッセージが返ってくる
```

### 設定ファイルを読み込む実装を追加する

`mycaddymod { message = "something" }` を設定として読み込むには以下のコードを追加します。

```go:main.go
func main() {
	caddy.RegisterModule(MyCaddyMod{})

    // 以下の2行を追加
    // 1行目: `mycaddymod` というキーワードをparseCaddyfileHandler関数に結びつけるようにhttpcaddyfile上に登録
    // 2行目: そのままだと適用優先順位が分からないので `route` ディレクティブ上にしか書けないがとりあえず `header` ディレクティブの後ぐらいと適当に宣言
	httpcaddyfile.RegisterHandlerDirective("mycaddymod", parseCaddyfileHandler)
	httpcaddyfile.RegisterDirectiveOrder("mycaddymod", httpcaddyfile.After, "header")

	caddycmd.Main()
}

type MyCaddyMod struct {
    // 設定値を保存するフィールド
	Message string `json:"message_on_json,omitempty"`
}
```

`parseCaddyfileHandler` 関連は以下の定義を追加します。

```go:main.go
// parseCaddyfileHandler は設定ファイルの値が設定された構造体を返す
func parseCaddyfileHandler(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
	var m MyCaddyMod
	err := m.UnmarshalCaddyfile(h.Dispenser)
	return m, err
}

// UnmarshalCaddyfile は設定ファイルをパースして設定ファイルで出会った値をフィールドにコピーする
func (m *MyCaddyMod) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
	d.Next() // `mycaddymod` 部分を読み飛ばす

	for d.NextBlock(0) {
		switch d.Val() {
		case "message": // `message = "..."` の部分の読み込み
			if !d.AllArgs(&m.Message) {
				return d.ArgErr()
			}
		default:
			return d.Errf("unrecognized subdirective '%s'", d.Val())
		}
	}
	return nil
}
```

上記のコードで設定ファイルの読み込みを行い、モジュールのフィールドに設定値を保存できるようになりました。

ところで、Caddyの設定ファイル（設定モデル）は本来的にはCaddyfileではなくJSONです。上記の設定ファイルをJSONで出してみると……

```js:
// `go run . adapt --pretty --config Caddyfile` コマンドで設定ファイルをJSONにしたものを出力
// （記事掲載のため整形済）
{
  "apps": { "http": { "servers": { "srv0": { "listen": [ ":18080" ],
  "routes": [ { "match": [ { "host": [ "localhost" ] } ],
    "handle": [ { "handler": "subroute", "routes": [
    { "handle": [
      // Caddyfile 上の設定が反映されていることがわかる
      {
         "handler": "mycaddymod",
         "message_on_json": "hello from mycaddymod Caddyfile config!"
      }
// 以下省略
```

構造体で指定されている `json:"message_on_json,omitempty"` タグが使われていることがわかります。

### `http.handlers` に属するミドルウェアとしてモジュールを追加する

CaddyモジュールでHTTPリクエストを扱いたい場合はどうすればよいのでしょうか？

そういったHTTPミドルウェアを書く際はモジュールの所属先となるCaddy上の `http.handlers` 名前空間に所属しつつ、モジュール自体も `caddyhttp.MiddlewareHandler` (`ServeHTTP()`) を実装する必要があります。

```go:main.go
func (MyCaddyMod) CaddyModule() caddy.ModuleInfo {
	return caddy.ModuleInfo{
		ID:  "http.handlers.mycaddymod", // http.handlers.~~ に名前を変更
		New: func() caddy.Module { return new(MyCaddyMod) },
	}
}

func (m MyCaddyMod) ServeHTTP(w http.ResponseWriter, req *http.Request, next caddyhttp.Handler) error {
    // 実際のHTTPリクエスト処理
	w.Write([]byte(m.Message)) // 設定ファイルのメッセージが書き出されるはず
	return nil
}


var (
    _ caddyhttp.MiddlewareHandler = (*MyCaddyMod)(nil)
)
```

これで準備は整いました。

### 動かしてみる

それでは実際に自作APIサーバーを組み込んだCaddyを動かしてみましょう。

```bash:自作Caddyを設定ファイル指定で動かしてみる
$ go run . run --config Caddyfile
...
```

```bash:Caddyに対してcurlでリクエストを発行してみる
$ curl localhost:18080
hello from mycaddymod Caddyfile config!
```

設定ファイルに書いた内容で応答が返ってきました！

他のディレクティブに処理を委譲することも可能です。50%の確率で下流のモジュールに処理を委譲するように書き換えてみましょう。

```go:main.go
func (m MyCaddyMod) ServeHTTP(w http.ResponseWriter, req *http.Request, next caddyhttp.Handler) error {
	// 50%の確率で next を呼ぶ
	if rand.Intn(2) == 0 {
		return next.ServeHTTP(w, req)
	}

	w.Write([]byte(m.Message))
    return nil
}
```

```Caddyfile:Caddyfile
http://localhost:18080 {
    mycaddymod {
        message "50% hit!"
    }
    respond "delegated"
}
```

実行結果は以下の通りです。半々ぐらいで処理が委譲されています。

```bash:実行結果
$ curl localhost:18080
delegated
$ curl localhost:18080
50% hit!
$ curl localhost:18080
delegated
$ curl localhost:18080
50% hit!
$ curl localhost:18080
50% hit!
$ curl localhost:18080
delegated
```

## Caddyに[Gin](https://github.com/gin-gonic/gin)を直接組み込んで最強のマイWebサーバーを誕生させる

[Gin](https://github.com/gin-gonic/gin)はGoの有名なWebフレームワークです。

https://github.com/gin-gonic/gin

これをCaddyに直接組み込んで、 **ただのリバースプロキシを脱却し、最強のWebサーバーに進化してもらいます。**

とはいってもやることは簡単です。単純にGinのハンドラにCaddyからのリクエストを横流しすればいいだけです。

```go:main.go
type MyCaddyMod struct {
	Message string `json:"message_on_json,omitempty"`
    // 追加
	handler http.Handler
}

// Provision でモジュールの初期化を行う
func (m *MyCaddyMod) Provision(ctx caddy.Context) error {
    // Ginのハンドラを初期化する
	r := gin.Default()
	r.GET("/message", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"message": m.Message,
		})
	})
	m.handler = r.Handler()

	return nil
}

func (m MyCaddyMod) ServeHTTP(w http.ResponseWriter, req *http.Request, next caddyhttp.Handler) error {
    // Ginのハンドラに横流しするだけ
	m.handler.ServeHTTP(w, req)
	return nil
}

var (
	_ caddy.Provisioner           = (*MyCaddyMod)(nil)
	_ caddyhttp.MiddlewareHandler = (*MyCaddyMod)(nil)
)
```

```Caddyfile:Caddyfile
http://localhost:18080 {
    mycaddymod {
        message "GIN on Caddy"
    }
}
```

実行してみましょう。

```bash
$ go run . run --config Caddyfile
```

```bash
$ curl localhost:18080     
404 page not found
$ curl localhost:18080/message
{"message":"GIN on Caddy"}
```

Ginが応答を返していることがわかります。（実際に組み込む際はCaddy側のロガーの流儀に合わせたりなど課題はあります）

## まとめ

`main.go` から起動するプログラムにCaddyと自作モジュールを組み込み、設定ファイルを読み込んだり、HTTPミドルウェアとして面白い機能を追加する方法を見てきました。この記事の知識があればCaddyのモジュールのソースコードをある程度理解できると思います。

最後のGinと合成するような方法は通常のHTTPハンドラが整理されているGoならではの何かしら使い方がありそうかなと思います。Caddy本来のLet's Encrypt(ACME)へのネイティブ対応やいろんなディレクティブと自作サーバーを組み合わせて**柔軟に処理ができるシングルバイナリの最強マイWebサーバーも作れるでしょう。**