note

This article was last updated on March 10, 2024, 1 month ago. The content may be out of date.

Previously, we talked about how to implement a simple Caddy plugin. In this post, we’ll talk about some of the more powerful features we can use.

Registering New Types of Network

Sometimes, we may want to securely expose a socket on a machine to a remote machine. We can set up an SSH tunnel to do so.

The following example from the official documents shows how to set up an HTTP server this way.

package main

import (
	"fmt"
	"log"
	"net/http"

	"golang.org/x/crypto/ssh"
)

func main() {
	var hostKey ssh.PublicKey
	config := &ssh.ClientConfig{
		User: "username",
		Auth: []ssh.AuthMethod{
			ssh.Password("password"),
		},
		HostKeyCallback: ssh.FixedHostKey(hostKey),
	}
	// Dial your ssh server.
	conn, err := ssh.Dial("tcp", "localhost:22", config)
	if err != nil {
		log.Fatal("unable to connect: ", err)
	}
	defer conn.Close()

	// Request the remote side to open port 8080 on all interfaces.
	l, err := conn.Listen("tcp", "0.0.0.0:8080")
	if err != nil {
		log.Fatal("unable to register tcp forward: ", err)
	}
	defer l.Close()

	// Serve HTTP with your SSH server acting as a reverse proxy.
	http.Serve(l, http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
		fmt.Fprintf(resp, "Hello world!\n")
	}))
}

We can achieve so by registering a network.

The most obvious way to do so is this:

caddy.RegisterNetwork("ssh", func(ctx context.Context, network, addr string, cfg net.ListenConfig) (any, error) {
    var hostKey ssh.PublicKey
    config := &ssh.ClientConfig{
        User: "username",
        Auth: []ssh.AuthMethod{
            ssh.Password("password"),
        },
        HostKeyCallback: ssh.FixedHostKey(hostKey),
    }
    // Dial your ssh server.
    var dialer = net.Dialer{
        KeepAlive: cfg.KeepAlive,
        Control:   cfg.Control,
    }
    conn, err := dialer.DialContext(ctx, "tcp", "localhost:22")
    if err != nil {
        return nil, err
    }
    c, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
    if err != nil {
        return nil, err
    }
    client:= ssh.NewClient(c, chans, reqs)
    return client.Listen("tcp", addr)
})

info

Aside from non-configurable SSH parameters, this naive approach suffers from non-overlappable listeners, i.e., when the configuration is reloaded, old and new configurations can’t be both active at the same time.

We should use a usage pool to manage listeners. The examples can be found here for platforms that don’t support SO_REUSEPORT.

We can either use this for HTTP servers, or use it with caddy-l4 to do anything that’s possible with a plain socket.

Using Apps

To expose a socket securely via SSH tunneling is convenient. Being network addresses makes them impossible to embed SSH tunnel information. However, it’s not good to hardcode these parameters because if the setup changes, we have to modify these parameters and recompile Caddy.

We can use environmental variables, but we need to restart Caddy to make sure new environmental variables take effect.

Caddy apps are useful in this case, as we can use them to store SSH tunnel configurations and retrieve them as needed.

We can retrieve an app using Context.App or Context.AppIfConfigured depending on if an unconfigured app is acceptable.

note

Refer to previous post to learn how to implement a Caddy app along with Caddyfile parsing.

Namespaces

Sometimes our module needs to be extensible, i.e., giving other developers the means to customize our plugin. Namespaces in the standard distribution allow us to add custom behaviors.

It’s called host module, but the document never mentions how to deal with them when parsing Caddyfile.

Take a look at reverse_proxy:

729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
case "transport":
    if !d.NextArg() {
        return d.ArgErr()
    }
    if h.TransportRaw != nil {
        return d.Err("transport already specified")
    }
    transportModuleName = d.Val()
    modID := "http.reverse_proxy.transport." + transportModuleName
    unm, err := caddyfile.UnmarshalModule(d, modID)
    if err != nil {
        return err
    }
    rt, ok := unm.(http.RoundTripper)
    if !ok {
        return d.Errf("module %s (%T) is not a RoundTripper", modID, unm)
    }
    transport = rt

It’s assumed users will use the last part of the module ID to refer to the specific module, so we unmarshal the module using the full module ID and check if it implements the necessary interface.

Then the module is marshaled for persistence.

826
h.TransportRaw = caddyconfig.JSONModuleObject(transport, "protocol", transportModuleName, nil)

protocol is the inline key that’s used to find out which guest module to unmarshal.