Add caddy-plugins.md
This commit is contained in:
parent
37a7efb12d
commit
1eb07a2afd
1 changed files with 175 additions and 0 deletions
175
site/blog/caddy-plugins.md
Normal file
175
site/blog/caddy-plugins.md
Normal file
|
@ -0,0 +1,175 @@
|
|||
---
|
||||
title: Writing a Caddy Plugin is Surprisingly Easy
|
||||
description: Not content with the existing Tailscale integration in Caddy, I decided to add another
|
||||
publishedDate: 2024-10-12
|
||||
tags:
|
||||
- projects
|
||||
- sysadmin
|
||||
- networking
|
||||
---
|
||||
In order to get my fleet of devices and servers working together nicely, from a couple of VPSes in the public cloud to a small ThinkStation server on my home network and my laptop, I use [Tailscale](https://tailscale.com). It's a delightful piece of software - it allows you to make direct network connections between machines that wouldn't normally be accessible without using things like port forwarding, thanks to its use of an [overlay network](https://en.wikipedia.org/wiki/Overlay_network). They call this network a "tailnet"; every collection of devices falls into a tailnet, and multiple users can be a part of a tailnet.
|
||||
|
||||
On this network, I run various different services. Most of these are exposed to the public internet and access controlled with [Authentik](https://goauthentik.io) in various ways, usually with OIDC or using Authentik's proxy authentication. When using proxy authentication, a copy of each request made to the service is forwarded to Authentik by Caddy, the HTTP server I use, allowing Authentik to decide if the request is authenticated and authorised or not.[^2] If not, it redirects the client to a login page, but otherwise it lets the request continue as normal, setting headers like `X-Authentik-User` and `X-Authentik-Email` to let the services we're protecting know who's logged in.
|
||||
|
||||
I also run some services that are only accessible through my tailnet. If you can access them, you're already authenticated as one user or another[^1], so there's no point going through Authentik for these (plus, authentication to my tailnet is done by Authentik anyway). The problem lies in the fact that, despite Tailscale having mechanisms to work out who's authenticated based on a connection's remote IP address[^3], there's not an easy, pre-existing way of letting a service know that - you'd typically have to modify the source code to get that to work.
|
||||
|
||||
Lots of services already support the header-based authentication detailed above, though. And since Caddy is written in Go and is supposedly very easy to extend, and since Tailscale has a pre-made Go client library, it'd be crazy to not try and throw a plugin together to do implement some header-based authentication backed by Tailscale.
|
||||
|
||||
# Writing the Core Caddy Plugin
|
||||
|
||||
The [Caddy developer's documentation](https://caddyserver.com/docs/extending-caddy) is, on the whole, pretty good. It will walk you through the fundamentals of getting a plugin compiling nicely. This is as simple as creating a type that holds some configuration, throwing a single method on that type, giving your plugin an ID and tossing a function call in `init` to auto-register the plugin when the Go module is loaded.
|
||||
|
||||
```go
|
||||
package caddy_tailscale
|
||||
|
||||
import (
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
)
|
||||
|
||||
func init() {
|
||||
caddy.RegisterModule(TailscaleAuth{})
|
||||
}
|
||||
|
||||
type TailscaleAuth struct {
|
||||
lc *tailscale.LocalClient
|
||||
}
|
||||
|
||||
func (TailscaleAuth) CaddyModule() caddy.ModuleInfo {
|
||||
return caddy.ModuleInfo{
|
||||
ID: "http.authentication.providers.tailscale",
|
||||
New: func() caddy.Module { return new(TailscaleAuth) },
|
||||
}
|
||||
}
|
||||
|
||||
func (ta *TailscaleAuth) Provision(caddy.Context) error {
|
||||
ta.lc = new(tailscale.LocalClient)
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
The ID you give your plugin can't be arbitrary - in fact, it describes the type of plugin that you have. I chose `http.authentication.providers.tailscale` for mine. The first three sections of this ID describes the type of plugin we're writing, which makes Caddy expect the module will implement the [`caddyauth.Authenticator`](https://pkg.go.dev/github.com/caddyserver/caddy/v2/modules/caddyhttp/caddyauth#Authenticator) interface, and the last section is a unique name for this plugin.
|
||||
|
||||
However, the namespaces and interface specifications are the one weak spot of the Caddy developer documentation, in my opinion. While it's easy to work out [what namespaces correspond to what interfaces](https://caddyserver.com/docs/extending-caddy/namespaces), it's not clear what each method actually *does* and what context it'll be executed in and when. Given a small pause for thought, you can work this out, but doing so was the hardest part of the process for me and left me a bit uncertain as to if I was doing something completely wrong while writing the initial implementation.
|
||||
|
||||
In this case it's pretty simple: implement an `Authenticate` method that, given a request object, will check the authentication of a request and return information about the user if it is authenticated.
|
||||
|
||||
```go
|
||||
func (ta *TailscaleAuth) Authenticate(_ http.ResponseWriter, req *http.Request) (caddyauth.User, bool, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
|
||||
defer cancel()
|
||||
|
||||
whois, err := ta.lc.WhoIs(ctx, req.RemoteAddr) // ta.lc = new(tailscale.LocalClient)
|
||||
if err != nil {
|
||||
if errors.Is(err, tailscale.ErrPeerNotFound) { // happens when a non-tailnet IP is tested, eg. from the wider internet
|
||||
return caddyauth.User{}, false, nil
|
||||
}
|
||||
return caddyauth.User{}, false, err
|
||||
}
|
||||
|
||||
return caddyauth.User{
|
||||
ID: strconv.FormatInt(int64(whois.UserProfile.ID), 10),
|
||||
Metadata: map[string]string{
|
||||
"display_name": whois.UserProfile.DisplayName,
|
||||
"login_name": whois.UserProfile.LoginName, // ie. an email address
|
||||
},
|
||||
}, true, nil
|
||||
}
|
||||
```
|
||||
|
||||
If we want to limit the set of allowed users to a smaller subset of the entire tailnet's userbase, we can do that too. If we add a field to our `TailscaleAuth` struct, Caddy will unmarshal the relevant sections of Caddy JSON config into it for us. We'll add a `[]string` field to hold Tailscale login names, along with a section in the `Provision` function (which is called immediately after the plugin is loaded) to decode the slice into a map so it's a bit more efficient to access at scale.
|
||||
|
||||
```go
|
||||
type TailscaleAuth struct {
|
||||
lc *tailscale.LocalClient
|
||||
|
||||
AllowedUsers []string `json:"allowed_users"`
|
||||
allowedUsersMap map[string]struct{}
|
||||
}
|
||||
|
||||
func (ta *TailscaleAuth) Provision(caddy.Context) error {
|
||||
// ...
|
||||
ta.allowedUsersMap = make(map[string]struct{})
|
||||
for _, u := range ta.AllowedUsers {
|
||||
ta.allowedUsersMap[strings.ToLower(u)] = struct{}{}
|
||||
}
|
||||
// ...
|
||||
}
|
||||
|
||||
func (ta *TailscaleAuth) Authenticate(_ http.ResponseWriter, req *http.Request) (caddyauth.User, bool, error) {
|
||||
// ...
|
||||
if _, found := ta.allowedUsersMap[strings.ToLower(whois.UserProfile.LoginName)]; len(ta.allowedUsersMap) != 0 && !found {
|
||||
return caddyauth.User{}, false, nil
|
||||
}
|
||||
// ...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
# Using the Plugin in a Caddyfile
|
||||
|
||||
Right now, we have a functional plugin that doesn't have Caddyfile support, meaning we'd have to use it exclusively in a JSON config file. I don't use Caddy's JSON config, so leaving it as-is isn't an option.
|
||||
|
||||
Caddy works with Caddyfiles by first converting a given Caddyfile into a JSON config structure, then parsing that JSON to get a usable config. To add Caddyfile support, we write some code to turn a set of Caddyfile tokens (representing our custom "directive") into a JSON blob and pass that on.
|
||||
|
||||
In our case, we'll also use it to implement the header-setting behaviour that's similar to that seen during proxy authentication.[^5] We can't do this in our main plugin implementation due to the limits of the `caddyauth.Authenticator` interface we were given, but by taking one Caddyfile statement as input and outputting into two config items - one doing the authentication and one doing the header-setting - we can still make it happen in one swift line motion from the end-user pespective.
|
||||
|
||||
We add two more statements in `init` for our module to register our new Caddyfile directive, and put its execution ordering after `basic_auth`[^4].
|
||||
|
||||
```go
|
||||
func init() {
|
||||
httpcaddyfile.RegisterDirective("tailscale_auth", parseCaddyfile)
|
||||
httpcaddyfile.RegisterDirectiveOrder("tailscale_auth", httpcaddyfile.After, "basic_auth")
|
||||
}
|
||||
|
||||
func parseCaddyfile(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
A lot of the parsing logic is a bit boring and fiddly, so I've omitted it. You can see it [here](https://git.tdpain.net/pkg/caddy-tailscale/-/blob/3d5f17078972a0d363913168d6cea05ddc30fb02/caddyfile.go#L30-119).
|
||||
|
||||
# Compiling and Registering
|
||||
|
||||
With Caddyfile integration complete, we have a useful plugin written, all in about [190 lines of code](https://git.tdpain.net/pkg/caddy-tailscale).
|
||||
|
||||
Caddy's build process is a little bit unusual. To compile a copy of Caddy manually, you have to write your own `main.go` that imports all the plugins you want, the standard Caddy plugins and runs the Caddy entrypoint - a little like this.
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
caddycmd "github.com/caddyserver/caddy/v2/cmd"
|
||||
_ "github.com/caddyserver/caddy/v2/modules/standard"
|
||||
_ "go.akpain.net/caddy-tailscale-auth"
|
||||
)
|
||||
|
||||
func main() {
|
||||
caddycmd.Main()
|
||||
}
|
||||
```
|
||||
|
||||
That's a little bit of a hassle, but there's an easier way - `xcaddy`:
|
||||
|
||||
```
|
||||
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
|
||||
xcaddy build --with go.akpain.net/caddy-tailscale-auth=$(pwd)
|
||||
```
|
||||
|
||||
This command automatically generates something very similar to the above, with the added bouns of replacing the online content of our new plugin with the contents of the current working directory. It spits out a binary that we can test and, once happy with, we can publish our Caddy plugin just like any other Go module and it becomes instantly available to use in a production Caddy deployment.
|
||||
|
||||
It's also possible to publish our plugin on the [Caddy download page](https://caddyserver.com/download) by registering an account and the letting it know the import path.
|
||||
|
||||
# Conclusion
|
||||
|
||||
Overall, from start to finish, it only took me a few hours to write this plugin. I expected it to be more complicated and involved than it ended up being, but instead it was pleasant and quick and definitely something I'd consider doing again in the future if needs be. If you think of something you might want to write a plugin for in Caddy, I'd recommend taking a stab at it.
|
||||
|
||||
You can see the complete source code of my plugin here, along with installation and usage instructions: [https://git.tdpain.net/pkg/caddy-tailscale](https://git.tdpain.net/pkg/caddy-tailscale)
|
||||
|
||||
---
|
||||
|
||||
[^1]: I do not subscribe to the ideas of zero-trust computing in my personal network. Feel free to send any related hate [here](/#contact) :)
|
||||
[^2]: See [https://caddyserver.com/docs/caddyfile/directives/forward_auth](https://caddyserver.com/docs/caddyfile/directives/forward_auth) and [https://docs.goauthentik.io/docs/add-secure-apps/providers/proxy/](https://docs.goauthentik.io/docs/add-secure-apps/providers/proxy/) for details about how this works
|
||||
[^3]: Specifically, you can use the `WhoIs` function of the Tailscale local API. This is a HTTP API that's provided by `tailscaled`, the Tailscale daemon that runs on every machine connected to a tailnet. It's not really documented anywhere apart from in the docs for the Go client that Tailscale have: [https://pkg.go.dev/tailscale.com@v1.76.0/client/tailscale#LocalClient.WhoIs](https://pkg.go.dev/tailscale.com@v1.76.0/client/tailscale#LocalClient.WhoIs)
|
||||
[^4]: Statements in a Caddyfile do not have an inherent order to them and do not always execute in the same order they are written - I believe the reasoning behind this is to prevent mistakes due to misconfiguration. Instead, they execute in a predefined order that's hardcoded into Caddy, and if we want to add a directive, we can insert our directive somewhere into that order too.
|
||||
[^5]: Caddy's `forward_auth` directive actually is entirely implemented as a Caddyfile directive that unwraps into the a specially-configured instance of the standard reverse proxy.
|
Loading…
Add table
Add a link
Reference in a new issue