Trying to get Fly, Tailscale, and Caddy working together - Running into issue with Tailscale nginx-auth library trying to authenticate

I’m using the Caddy forward_auth feature with tailscale running in a Debian container that launches on Flyio. There’s the nginx-auth library and the Tailscale article that goes with it. Also been following the Tailscale guide for Flyio although this guide uses Alpine and not Debian.

I think this question is better suited for Tailscale but my first post on their forums got picked up by the spam filter there and I know you all are very good with wireguard. So maybe this set up helps out others trying to do the same on Fly :slight_smile:

Getting into it… Fly is taking care of assigning an IP address and SSL certificate to the Caddy machine.

The entrypoint for the container looks like this:

/app/tailscaled --state=/var/lib/tailscale/tailscaled.state --socket=/var/run/tailscale/tailscaled.sock &

until /app/tailscale up --authkey=${TAILSCALE_AUTHKEY} --hostname=caddy
do
  sleep 0.1
done

# This is just directly running the binary from nginx-auth library. In the library they use systemctl.
# Maybe systemctl is doing something that is not happening here?
/usr/sbin/tailscale.nginx-auth --sockpath /var/run/tailscale.nginx-auth.sock &

caddy run --config /config/caddy/caddy.json

This uses an auth token to login to tailscale from the Caddy machine. Everything seems to boot up alright but when I point a domain at the Fly IP that’s pointing to Caddy and traffic starts to hit the server I get an error in Fly logs that looks like this:

2023-03-01T07:06:35.488 app[328711db600485] sjc [info] 2023/03/01 07:06:35 can't look up 172.16.131.50:41492: 404 Not Found: no match for IP:port

When I look at the Tailscale admin and click on the new Fly Tailscale Caddy machine that’s created I can see in the endpoints section that the IP 172.16.131.50 (same IP as in error) with port 40983 (different port as in error) show up there but the port in the error is different from the port in the endpoint section. In subsequent errors for each request, the IP address matches but the port is always a new random port.

2023-03-01T07:06:40.358 app[328711db600485] sjc [info] 2023/03/01 07:06:40 can't look up 172.16.131.50:50204: 404 Not Found: no match for IP:port

2023-03-01T07:06:41.401 app[328711db600485] sjc [info] 2023/03/01 07:06:41 can't look up 172.16.131.50:42947: 404 Not Found: no match for IP:port

2023-03-01T07:06:42.102 app[328711db600485] sjc [info] 2023/03/01 07:06:42 can't look up 172.16.131.50:48571: 404 Not Found: no match for IP:port

Is the mismatched port possibly the reason for the error? Is the IP that it’s using for look up for each request the right one? One that should be showing up in Tailscale machine endpoints?

In Caddy we are passing the http.request.remote.host and http.request.remote.port placeholder values to nginx-auth. Why would the host always be the same IP and the port always be different? How would I make the port a static one, like the request from nginx-auth seems to be expecting?

Another possibility I thought of might be that I need to use Tailscale for IP address and SSL that the domain then points to? Is that where I’m going wrong?

I know this might be an adjustment I need to make with Caddy but I wanted to check first if my Tailscale set up looks alright.

This is all very new to me and likely some foundational misunderstandings on my part. Any help is appreciated!

Going to try this next in my journey: GitHub - tailscale/caddy-tailscale: A highly experimental exploration of integrating Tailscale and Caddy.

I’m thinking the listener part may be what I’m missing. This will also use Tailscale for SSL certification instead of Fly it seems. Still very confused but I’ll be testing all of this. I’ll update here as I go :slight_smile:

2 Likes

The listener is not the part I needed here but the plugin seems to work better so far.

I maybe could use some clarification here but my understanding is that since the request is coming through the fly proxy, Caddy needs to listen locally, because the fly proxy passes the request to 0.0.0.0:internal_port.

And since we’re not using the listener, the plugin uses a locally initiated instance of Tailscale, so not much different than nginx-auth in that sense. Basically caddy-tailscale uses Golang code directly with Caddy packages and nginx-auth uses the unix socket directly with OS.

I needed to do a manual build of Caddy to get it to work with the plugin dependencies. Here’s some instructions I wrote up for doing that if anyone is interested: Trouble building with xcaddy - both 2.5.3 and 2.6.4 · Issue #10 · tailscale/caddy-tailscale · GitHub

With the plugin built, now I can start testing and see how this works. One nice advantage to doing a Caddy manual build is that it’s very flexible and very easy to do local forks and adjust plugins or even Caddy itself so I’m optimistic that there’s a solution here somewhere… :slight_smile:

Edit: False alarm. Once I got certification working I started getting the 404 Not Found: no match for IP:port error again. But only got to test this once and then started having issues with certification again. Going to let some time pass in case it’s rate limits and then test some more. The build does at least work and boots Tailscale and Caddy without error.


This is a little early for me to be sure because I haven’t tested this with my whole set up but did get an isolated test fully working. I am expecting it to work for me but I believe I ran into this issue where I have tested this too many times and issued too many Let’s Encrypt certificates for the same domains. I have done this before, just gotta wait it out :slight_smile:

Anyways, here we go. If Caddy is the entry to all your other apps, then this will let you authenticate all traffic on Caddy with Tailscale, only allowing authorized traffic to access any apps. Each app behind Caddy does not need to set up Tailscale this way, fly private networking takes over after that.

At least for now you need to do a manual build of Caddy to build it with the caddy-tailscale plugin because of conflicts with dependencies.

First follow the step 1 instructions here and then instead of using the TAILSCALE_AUTHKEY environment variable we’re going to use TS_AUTH because that’s what caddy-tailscale plugin uses.

Copy main.go as instructed in the comment in main.go. Also run go mod init caddy before building the dockerfile. You need to be able to copy the files this command creates to the dockerfile or use a volume if using docker compose.

main.go

// Copyright 2015 Matthew Holt and The Caddy Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package main is the entry point of the Caddy application.
// Most of Caddy's functionality is provided through modules,
// which can be plugged in by adding their import below.
//
// There is no need to modify the Caddy source code to customize your
// builds. You can easily build a custom Caddy with these simple steps:
//
//  1. Copy this file (main.go) into a new folder
//  2. Edit the imports below to include the modules you want plugged in
//  3. Run `go mod init caddy`
//  4. Run `go install` or `go build` - you now have a custom binary!
//
// Or you can use xcaddy which does it all for you as a command:
// https://github.com/caddyserver/xcaddy
package main

import (
	caddycmd "github.com/caddyserver/caddy/v2/cmd"

	// plug in Caddy modules here
	_ "github.com/caddyserver/caddy/v2/modules/standard"
	_ "github.com/tailscale/caddy-tailscale"
)

func main() {
	caddycmd.Main()
}

This is all you need in go.mod. The rest of the require statements will be set by the go get commands in the Dockerfile. go mod init caddy should create this file but you can double check that it’s set to go 1.19. I used go version 1.19 because 1.20 ran into issues with the gvisor dependency from caddy-tailscale.

go.mod

module caddy

go 1.19

Dockerfile

FROM golang:1.19-bullseye as caddy

# https://github.com/caddyserver/caddy/releases
ENV CADDY_VERSION v2.6.4

WORKDIR /app/caddy

COPY ./where/you/copied/main.go/on/your/local /app/caddy

RUN go get github.com/caddyserver/caddy/v2/cmd
RUN go get github.com/caddyserver/caddy/v2/modules/standard
RUN go get github.com/tailscale/caddy-tailscale

RUN go build
RUN ls -al

FROM bitnami/minideb:bullseye as tailscale

ENV TSFILE=tailscale_1.36.2_amd64.tgz

WORKDIR /app

RUN install_packages wget ca-certificates

RUN wget https://pkgs.tailscale.com/stable/${TSFILE} && \
  tar xzf ${TSFILE} --strip-components=1
  
FROM bitnami/minideb:bullseye

RUN install_packages ca-certificates iptables iproute2

# iptables-legacy is needed to fix this error with Tailscale:
# "health("router"): error: setting up filter/ts-input: running [/usr/sbin/iptables -t filter -N ts-input --wait]: exit status 4: iptables v1.8.7 (nf_tables): Could not fetch rule set generation id: Invalid argument"
RUN update-alternatives --set iptables /usr/sbin/iptables-legacy
RUN update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy

RUN mkdir --parents \
  /config/caddy \
  /data/caddy/caddy \
  /etc/caddy \
  /usr/share/caddy \
  /var/run/tailscale \
  /var/cache/tailscale \
  /var/lib/tailscale \
  ;
  
COPY --from=tailscale /app/tailscaled /usr/bin/tailscaled
COPY --from=tailscale /app/tailscale /usr/bin/tailscale

COPY --from=builder /app/caddy/caddy /usr/bin/caddy
COPY ./path/to/local/caddy.json /config/caddy/caddy.json

COPY ./path/to/start.sh /start.sh
RUN chmod +x /start.sh

# https://github.com/caddyserver/caddy/releases
ENV CADDY_VERSION v2.6.4

# See https://caddyserver.com/docs/conventions#file-locations for details
ENV XDG_CONFIG_HOME /config
ENV XDG_DATA_HOME /data/caddy

EXPOSE 8080 8443 2019

ENTRYPOINT ["/start.sh"]

And then if you used go mod init caddy you’ll see the “caddy” binary in the docker build logs.

#14 [8/8] RUN ls -al
#14 0.472 total 74348
#14 0.472 drwxr-xr-x 1 root root     4096 Mar  4 07:03 .
#14 0.472 drwxr-xr-x 1 root root     4096 Mar  4 06:34 ..
#14 0.472 -rwxr-xr-x 1 root root 75976074 Mar  4 07:03 caddy
#14 0.472 -rw-r--r-- 1 root root     9619 Mar  4 07:02 go.mod
#14 0.472 -rw-r--r-- 1 root root   126558 Mar  4 06:13 go.sum
#14 0.472 -rw-rw-r-- 1 root root     1507 Mar  4 06:26 main.go

The entrypoint needs to boot up tailscale since we’re using a local client with the caddy-tailscale plugin.

start.sh

#!/usr/bin/env bash

tailscaled --state=/var/lib/tailscale/tailscaled.state --socket=/var/run/tailscale/tailscaled.sock &

tailscale up --authkey=${TS_AUTHKEY} --hostname=your-fly-caddy-app-name

caddy run --config /config/caddy/caddy.json

And then the Caddy file something like this. This has some access logging set up so it’s easier to debug and see what’s going on in Fly logs.

{
  "admin": {
    "disabled": true
  },
  "logging": {
    "logs": {
      "platform": {
        "level": "DEBUG",
        "writer": {
          "output": "stdout"
        },
        "include": [
          "http.log.access.platform"
        ]
      }
    }
  },
  "apps": {
    "http": {
      "http_port": 80,
      "https_port": 443,
      "servers": {
        "services": {
          "listen": [
            ":8080",
            ":8443"
          ],
          "logs": {
            "default_logger_name": "platform"
          },
          "automatic_https": {
            "disable_certificates": true,
            "disable_redirects": false
          },
          "routes": [
            {
              "handle": [
                {
                  "handler": "subroute",
                  "routes": [
                    {
                      "handle": [
                        {
                          "handler": "authentication",
                          "providers": {
                            "tailscale": {}
                          }
                        }
                      ]
                    },
                    {
                      "match": [
                        {
                          "host": [
                            "your.real.host.com"
                          ]
                        }
                      ],
                      "handle": [
                        {
                          "handler": "reverse_proxy",
                          "upstreams": [
                            {
                              "dial": "fly-app.internal:7777"
                            }
                          ]
                        }
                      ]
                    }
                    // ... Your other apps here
                  ]
                }
              ],
              "terminal": true
            }
          ]
        }
      }
    }
  }
}

Then on the Fly configuration side your services just need to look like this. And make sure to set the environment variable for TS_AUTH here depending how you’re managing that. You can also set TS_VERBOSE=1 for additional Tailscale logging.

services = [
  {
    ports = [
      {
        port     = 443
        handlers = ["tls", "http"]
      },
      {
        port     = 80
        handlers = ["http"]
      }
    ]
    "protocol" : "tcp",
    "internal_port" : 8080
  }
]

This Caddy app should also have an IPv4, IPv6, and SSL certification for the domain you are going to test this with. Now you’ll be able to change the DNS records for your domain to point to the IPs and set up the Let’s Encrypt challenge.

Aaaand that’s it. In monitoring you should be able to see Tailscale and Caddy boot up. Then should be able to test your domain and see in the fly logs if you’re authenticating correctly as well as seeing the bots get rejected.

Continuing forward… The error 404 Not Found: no match for IP:port comes from this part of the caddy-tailscale plugin which actually does the authorization.

In caddy-tailscale plugin the value passed to Tailscale WhoIs is the http.request.RemoteAddr value. This value ends up being the fly-proxy IP address and a randomized port. Our real IP that Tailscale WhoIs should be looking up is the one in the Fly-Client-Ip header. So I think this is the kind of problem a trust proxy set up possibly solves and it seems like maybe a trust proxy set up with Caddy could solve this problem?

I’ve tried forking the caddy-tailscale plugin and passing the Fly-Client-Ip header directly as a test but the WhoIs lookup also requires the port. Passing the IP without a port returns a 500 response. And since the port is coming from a domain it’s always 80 or 443, so the port never matches the one listed in the Endpoints section of the Tailscale machine page.

And just now after just doing a test while I was writing this, with a hard coded port that matches up with Tailscale Endpoints just to see if it works, still resulted in a failed lookup. So if the WhoIs lookup is expecting the Tailscale machine IP then this may not be the intended use case here.

Next will be messing around with the tsnet library to see if I can manually get an authorization through the WhoIs function and better understand what it’s expecting. I know I have access to the details provided by tailscale status --json, the underlying Go http client provided by Caddy, and the tsnet library so hopefully between all of this there’s a way. It’s seeming more and more like there might need to be a Tailscale subnet or some kind of new proxy or both.

Also just to note one major difference between the caddy-tailscale plugin and nginx-auth library is that to do the WhoIs lookup the nginx-auth library has you forward the IP and port using headers instead of using the RemoteAddr value that caddy-tailscale plugin has access to.

1 Like

I, for one, am enjoying this adventure. Even though I don’t have any good information to add!

1 Like

So turns out the listener is exactly what I wanted from the beginning :slight_smile:

So, the listener in caddy-tailscale creates a Tailscale server that it listens on and it’s able to get a client from there. So that’s the client that gets used with the listener. This is much better than starting our own client on the socket. This uses Tailscales Golang library which seems to have some special features that their other API’s don’t have. So since Caddy and Tailscale both use these Go libraries it’s allowing for better and more secure integration it seems, as opposed to nginx for example needing to rely on a client that runs off a socket on the machine.

caddy.json

"listen": [
  ":8080",
  ":8443",
  "tailscale/:8080"
]

Everything is building and connecting really well, now it’s all coming down to whether Tailscale supports authentication when all you have is the client IP, since that’s all we really get from a public internet connection through a domain through the Fly-Client-IP header from fly-proxy.

I have a question about this in the caddy-tailscale github issues but I think this is what it all comes down to. This one line in caddy-tailscale and whether it allows authorization with only a client IP. Also I’m linking to the latest version of Tailscale which is 1.36.2 but caddy-tailscale actually uses 1.31.0-dev-t which I’m having trouble finding the code for.

Edit: After getting a response to the github issue it looks like it’s necessary to pass your Tailscale IP to caddy for this to work. The next step is looking into some options for how that might be possible… but I do believe this is down to adding something extra here to be able to do that.

This topic was automatically closed 7 days after the last reply. New replies are no longer allowed.