note
This article was last updated on February 3, 2024, 8 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:
- For
caddy.App
we need to register a global option. - For
caddyhttp.MiddlewareHandler
we need to register its handler directive. When enabling such HTTP directives, we need to order them explicitly using theorder
global option or manually specify its order usingroute
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
Now, we configure Caddy use this configuration and test it using 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.