note

This article was last updated on February 3, 2024, 10 months ago. The content may be out of date.

Previous post discusses the basics of a Caddy plugin and shows an example of a bare-bone plugin. In this post we implement a Caddy module that listens to a TCP socket and replies Hello world to every connected client.

Plugin Considerations

Before we begin, we need to think about what our plugin can do and how its behavior can be customized by its user. Depending on the function, the full configuration may be complex, but we should provide a reasonable default to the user.

As said before, the native configuration is in JSON format, every configuration option can be modified on this level. Caddyfile, however, provides an easy way to configure our plugin.

Depending on the function, the plugin must implement certain interface(s) and should assume an appropriate namespace. For standalone modules, they should be a caddy.App.

note

For our example, the functionality is very simple. The only way its behavior can be customized is which address the listening socket will be on.

Implementation

Core Functionality Implementation

First, we’ll make the plugin functional. Creating a Caddy plugin is creating a golang module, run:

mkdir caddy-hello && cd caddy-hello
go mod init caddy-hello

Because the plugin is simple, we put all the codes related to the operation of the plugin in the app.go file:

package caddy_hello

import (
	"context"
	"net"

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

type Hello struct {
	Address string `json:"address"`

	listener net.Listener
}

func (Hello) CaddyModule() caddy.ModuleInfo {
	return caddy.ModuleInfo{
		ID:  "caddy_hello",
		New: func() caddy.Module { return new(Hello) },
	}
}

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

func (h *Hello) Start() error {
	network, err := caddy.ParseNetworkAddress(h.Address)
	if err != nil {
		return err
	}

	listener, err := network.Listen(context.Background(), 0, net.ListenConfig{})
	if err != nil {
		return err
	}
	h.listener = listener.(net.Listener)
	go h.loop()
	return nil
}

func (h *Hello) Stop() error {
	return h.listener.Close()
}

func (h *Hello) loop() {
	for {
		c, err := h.listener.Accept()
		if err != nil {
			break
		}
		go h.handleConn(c)
	}
}

func (h *Hello) handleConn(c net.Conn) {
	_, _ = c.Write([]byte("Hello world"))
	_ = c.Close()
}

info

For best practice, we should add interface guards to make sure our plugin implements the necessary interface(s). In addition, we should also add a logger to help us debug potential problems.

This plugin is already functional. Compiling Caddy with it and modify the resulting JSON configuration is a viable choice. To improve its usability, we’ll go to the next step to allow it to be configured using Caddyfile.

Caddyfile

Caddyfile is a format invented by Caddy. It’s explained here. Because our plugin is standalone, we configure it in the global options block.

The configuration is like this:

caddy_hello {
    address val
}

We’ll write these codes in a separate file, caddyfile.go:

package caddy_hello

import (
	"github.com/caddyserver/caddy/v2/caddyconfig"
	"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
	"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
)

func init() {
	httpcaddyfile.RegisterGlobalOption("caddy_hello", func(d *caddyfile.Dispenser, existingVal any) (any, error) {
		h := new(Hello)
		err := h.UnmarshalCaddyfile(d)
		return httpcaddyfile.App{
			Name:  "caddy_hello",
			Value: caddyconfig.JSON(h, nil),
		}, err
	})
}

func (h *Hello) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
	for d.Next() {
		// No same-line options are supported
		if d.NextArg() {
			return d.ArgErr()
		}

		for d.NextBlock(0) {
			switch d.Val() {
			case "address":
				if !d.NextArg() {
					return d.ArgErr()
				}
				h.Address = d.Val()
			default:
				return d.ArgErr()
			}
		}
	}
	return nil
}

note

For this specific example, we may want to put options after the directive instead of opening a new block, we can use NextArg in this case.

info

As long as we implement caddyfile.Unmarshaler interface, interface(s) required for its operation and choose an appropriate module ID for its function, we can use our plugin using the name without the namespace and the preceding dot in places where this type of plugin is expected. This is because Caddy uses the namespace contained in the module ID to get the plugin.

There are several exceptions though:

  1. For caddy.App we need to register a global option.
  2. For caddyhttp.MiddlewareHandler we need to register its handler directive. When enabling such HTTP directives, we need to order them explicitly using the order global option or manually specify its order using route directive. There is an open pr that will address this issue.

Testing Out

We now compile Caddy with this plugin. xcaddy is a tool built to compile Caddy with plugins.

First, we’ll install it:

go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest

Because our plugin is only local for now, we need to specify its path.

xcaddy build --with caddy-hello=./caddy-hello

We can see now our Caddy is compiled with our plugin:

caddy list-modules
Caddy with Our Plugin

Now, we configure Caddy use this configuration and test it using netcat,

Caddyfile
Running netcat

Summary

Now that we have introduced the basics of writing a Caddy plugin, we can implement most of the functionality we want as long as we choose a correct namespace for our plugin and implement the necessary interface(s). We also talked about how to configure our plugin using Caddyfile. In the next post, we’ll talk about plugin interactions.