Map directive: basicauth credentials in placeholders

1. Caddy version (caddy version):

2.4.6, built with the following modules:

  • caddy-maxmind-geolocation
  • format-encoder
  • duckdns

2. How I run Caddy:

docker-compose (non-root user) on a VPS, proxying requests to my home server over a Wireguard tunnel, and also to some docker containers running on-premise.

a. System environment:

Debian 11, docker-compose version 1.25.0

b. Command:

docker-compose up -d --build

c. Service/unit/compose file:

docker-compose.yml:

version: "3"

networks:
  default:
    external:
      name: caddy_net

services:
  caddy:
    # image: "caddy:latest"
    build: .
    container_name: "caddy"
    hostname: "caddy"
    restart: unless-stopped
    user: 1000:1000
    ports:
      - "443:8443"
    environment:
      - ALLOWED_COUNTRIES=$allowed_countries
      - ALT_DOMAIN=$alt_domain
      - EMAIL=$email
      - DUCKDNS_TOKEN=$duckdns_token
      - DOMAIN=$domain
      - INVIDIOUS_USER=$invidious_user
      - INVIDIOUS_PASSWORD=$invidious_password
      - IP_HOME=$ip_home
      - IP_SELF=$ip_self
      - LIBREDDIT_USER=$libreddit_user
      - LIBREDDIT_PASSWORD=$libreddit_password
      - NITTER_USER=$nitter_user
      - NITTER_PASSWORD=$nitter_password
      - TM_USER=$tm_user
      - TM_PASSWORD=$tm_password
      - WHOOGLE_USER=$whoogle_user
      - WHOOGLE_PASSWORD=$whoogle_password
      - WIREGUARD_TUNNEL=$wireguard_tunnel
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./GeoLite2-Country.mmdb:/etc/caddy/GeoLite2-Country.mmdb
      - ./data:/data
      - ./config:/config
      - ./log:/var/log

Dockerfile:

FROM caddy:builder AS builder

RUN xcaddy build \
    --with github.com/porech/caddy-maxmind-geolocation \
    --with github.com/caddyserver/format-encoder \
    --with github.com/caddy-dns/duckdns

FROM caddy:latest
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

d. My complete Caddyfile or JSON config:

{
    email {$EMAIL}
    skip_install_trust

    https_port 8443
    acme_dns duckdns {$DUCKDNS_TOKEN} {
        override_domain {$ALT_DOMAIN}
    }

    log {
        # level debug
        format console {
            time_format wall
        }
    }
}

(auth_untrusted) {
    @untrusted_ips {
        not remote_ip {$IP_HOME}
    }

    basicauth @untrusted_ips {
        {args.0} {args.1}
    }
}

(logging) {
    log {
        format formatted
        output file /var/log/access.log {
            roll_keep 1
            roll_keep_for 7d
        }
    }
}

(geofilter) {
    @geofilter {
        maxmind_geolocation {
            db_path "/etc/caddy/GeoLite2-Country.mmdb"
            deny_countries {$DENIED_COUNTRIES}
        }
        not remote_ip {$IP_SELF} {$IP_HOME}
    }

    respond @geofilter 403
}

(security) {
    header {
        Permissions-Policy interest-cohort=()
        Strict-Transport-Security "max-age=31536000;"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "SAMEORIGIN"
        X-Robots-Tag "none"
        X-Permitted-Cross-Domain-Policies "none"
        X-XSS-Protection "1; mode=block"
        Referrer-Policy "no-referrer-when-downgrade"
    }
}

*.{$DOMAIN} {
    map {labels.2} {upstream} {auth_user} {auth_pass} {
        collabora   {$WIREGUARD_TUNNEL}:80
        gotify      {$WIREGUARD_TUNNEL}:80
        grocy       {$WIREGUARD_TUNNEL}:80
        jellyfin    {$WIREGUARD_TUNNEL}:80
        nextcloud   {$WIREGUARD_TUNNEL}:80
        tm          {$WIREGUARD_TUNNEL}:80  {$TM_USER}          {$TM_PASSWORD}
        git         gitea-app:3000
        libreddit   libreddit:8080          {$LIBREDDIT_USER}   {$LIBREDDIT_PASSWORD}
        nitter      nitter:8080             {$NITTER_USER}      {$TM_PASSWORD}
        whoogle     whoogle:5000            {$WHOOGLE_USER}     {$WHOOGLE_PASSWORD}
        yt          invidious-app:3000      {$INVIDIOUS_USER}   {$INVIDIOUS_PASSWORD}
    }

    encode zstd gzip

    import logging
    import geofilter

    @security not expression `{labels.2} == "nextcloud"`
    route @security {
        import security
    }

    @auth expression `{labels.2}.matches("libreddit|nitter|tm|whoogle|yt")`
    route @auth {
            import auth_untrusted {auth_user} {auth_pass}
    }

    reverse_proxy {upstream}
}

3. The problem I’m having:

This is more of an aesthetic question, and perhaps a call for a general sanity check on my configuration. I recently redesigned my Caddyfile to use the map directive with a wildcard domain, and I’m very happy with how this works. I do have a (very) minor issue with the handful of subdomains that I want to put behind basic authentication.

Ideally, I would have liked to map the basicauth credentials to placeholders, as in the (non-functional) Caddyfile I included above. This, however, gives me the error message seen in section 4 (“username and password are required” being the relevant part), which I assume is because the {auth_user} and {auth_pass} placeholders are being parsed as empty. The environmental variables are all good and set, so that’s not the issue, but I realize I might be trying to over-optimize here and am straining the possibilities of what placeholders are meant to do.

So, like I said, this is a minor issue, since I’m able to work around it quite easily with a couple of route directives (see section 5). Still, though, I was wondering:

  1. Is there perhaps some more elegant way of doing what I want without having to import the auth_untrusted snippet five times?

And also:

  1. Am I using the route directives correctly here? See, e.g., the way I’m routing the security snippet for all the subdomains that aren’t nextcloud. That is, I’m using route more or less only as a non-mutually exclusive handle, but I’m not sure that’s quite the intention. It seems to work fine, though.

PS. I really want to take the opportunity to commend you all on what a fantastic piece of software Caddy is. I’m having an amazing time using it!

4. Error messages and/or full log output:

2022/02/11 12:15:43	info	admin	admin endpoint started	{"address": "tcp/localhost:2019", "enforce_origin": false, "origins": ["127.0.0.1:2019", "localhost:2019", "[::1]:2019"]}
2022/02/11 12:15:43	info	http	server is listening only on the HTTPS port but has no TLS connection policies; adding one to enable TLS	{"server_name": "srv0", "https_port": 8443}
2022/02/11 12:15:43	info	http	enabling automatic HTTP->HTTPS redirects	{"server_name": "srv0"}
2022/02/11 12:15:43	info	tls.cache.maintenance	started background certificate maintenance	{"cache": "0xc0004329a0"}
2022/02/11 12:15:44	info	tls.cache.maintenance	stopped background certificate maintenance	{"cache": "0xc0004329a0"}
run: loading initial config: loading new config: loading http app module: provision http: server srv0: setting up route handlers: route 1: loading handler modules: position 0: loading module 'subroute': provision http.handlers.subroute: setting up subroutes: route 2: loading handler modules: position 0: loading module 'subroute': provision http.handlers.subroute: setting up subroutes: route 0: loading handler modules: position 0: loading module 'authentication': provision http.handlers.authentication: loading authentication providers: module name 'http_basic': provision http.authentication.providers.http_basic: account 0: username and password are required

5. What I already tried:

This works, but feels a bit clunky:

*.{$DOMAIN} {
    map {labels.2} {upstream} {
        collabora   {$WIREGUARD_TUNNEL}:80
        gotify      {$WIREGUARD_TUNNEL}:80
        grocy       {$WIREGUARD_TUNNEL}:80
        jellyfin    {$WIREGUARD_TUNNEL}:80
        nextcloud   {$WIREGUARD_TUNNEL}:80
        tm          {$WIREGUARD_TUNNEL}:80
        git         gitea-app:3000
        libreddit   libreddit:8080
        nitter      nitter:8080
        whoogle     whoogle:5000
        yt          invidious-app:3000
    }

    encode zstd gzip

    import logging
    import geofilter

    @security not expression `{labels.2} == "nextcloud"`
    route @security {
        import security
    }

    @auth expression `{labels.2}.matches("libreddit|nitter|tm|whoogle|yt")`
    route @auth {
        @libreddit expression `{labels.2} == "libreddit"`
        route @libreddit {
            import auth_untrusted {$LIBREDDIT_USER} {$LIBREDDIT_PASSWORD}
        }

        @nitter expression `{labels.2} == "nitter"`
        route @nitter {
            import auth_untrusted {$NITTER_USER} {$NITTER_PASSWORD}
        }

        @tm expression `{labels.2} == "tm"`
        route @tm {
            import auth_untrusted {$TM_USER} {$TM_PASSWORD}
        }

        @whoogle expression `{labels.2} == "whoogle"`
        route @whoogle {
            import auth_untrusted {$WHOOGLE_USER} {$WHOOGLE_PASSWORD}
        }

        @yt expression `{labels.2} == "yt"`
        route @yt {
            import auth_untrusted {$INVIDIOUS_USER} {$INVIDIOUS_PASSWORD}
        }
    }

    reverse_proxy {upstream}
}

6. Links to relevant resources:

1 Like

Yeah unfortunately basicauth doesn’t support runtime placeholders for user/pass. It only parses the placeholders once at the start, not on every request. This is so it can have a more optimized lookup table readied, by username.

What you could do though is make multiple import lines, one per upstream that you want to set up auth for, and pass the credentials in that way – import expands out the config at adapt time, before Caddy is running, but map runs on every request.

You can try something like this:


(auth_by_label) {
	@auth{args.0} {
		expression `{labels.2} == "{args.0}"`
		not remote_ip {$IP_HOME}
	}

	basicauth @auth{args.0} {
		{args.1} {args.2}
	}
}

*.{$DOMAIN} {
	map {labels.2}  {upstream} {
		collabora   {$WIREGUARD_TUNNEL}:80
		tm          {$WIREGUARD_TUNNEL}:80
		libreddit   libreddit:8080
		...
	}

	...

	import auth_by_label tm {$TM_USER} {$TM_PASSWORD}
	import auth_by_label libreddit {$LIBREDDIT_USER} {$LIBREDDIT_PASSWORD}

	...
}

Key to this is using the {arg.0} for making sure the named matchers always have different names each time you invoke the snippet, because you can’t have two named matchers with the same name.

It’s essentially similar to what you posted at the end, but lines reduced significantly to only one line per subdomain that needs auth.

Yeah, that’s fine. You could pass in the named matcher as the first arg to the snippet if you want, and use that as the matcher inside the snippet.

The other side effect route has is forcing directive order (preventing the Caddyfile adapter’s sorting logic), but in your case that probably won’t make a difference for your security snippet etc.

:blush:

Thanks for the kind words!

2 Likes

Yeah unfortunately basicauth doesn’t support runtime placeholders for user/pass. It only parses the placeholders once at the start, not on every request. This is so it can have a more optimized lookup table readied, by username.

Ah, I see. That makes sense.

What you could do though is make multiple import lines, one per upstream that you want to set up auth for, and pass the credentials in that way – import expands out the config at adapt time, before Caddy is running, but map runs on every request.

Ooo, that’s definitely a much cleaner solution. Thanks, Francis!

2 Likes

This topic was automatically closed after 30 days. New replies are no longer allowed.