184 lines
7.4 KiB
Nix
184 lines
7.4 KiB
Nix
{ config, lib, pkgs, homeyConfig, ... }:
|
|
|
|
# Caddy reverse proxy.
|
|
#
|
|
# Features:
|
|
# - DNS-01 ACME via Cloudflare API → real wildcard cert for *.zakobar.com
|
|
# - forward_auth to Authelia for protected vhosts
|
|
# - Plain reverse_proxy for public vhosts (authelia itself, nextcloud)
|
|
# - Listens on :80 (redirect) and :443 (TLS)
|
|
#
|
|
# Because nixpkgs ships Caddy without the cloudflare DNS plugin by default,
|
|
# we build a custom Caddy with it using the xcaddy wrapper from nixpkgs.
|
|
#
|
|
# Secrets consumed from sops:
|
|
# cloudflare/api_token
|
|
|
|
let
|
|
cfg = config.homey.caddy;
|
|
domain = homeyConfig.domain;
|
|
|
|
# Build Caddy with the Cloudflare DNS plugin using the nixos-25.05 API.
|
|
# `withPlugins` is a passthru function on the caddy package; it uses xcaddy
|
|
# under the hood to produce a fixed-output derivation.
|
|
caddyWithCloudflare = pkgs.caddy.withPlugins {
|
|
plugins = [
|
|
# v0.2.4 tag points to commit a8737d0 which includes the fix for
|
|
# cfut_/cfat_ token format validation (PR #123).
|
|
"github.com/caddy-dns/cloudflare@v0.2.4"
|
|
];
|
|
hash = "sha256-pRrLBlYRaAyMYwPXeTy4WqWNRu/L9K6Mn2src11dGh8=";
|
|
};
|
|
|
|
# Reverse-proxy snippet for cloudflared http:// vhosts.
|
|
# Cloudflare terminates TLS; cloudflared connects to Caddy over plain HTTP.
|
|
# We must override X-Forwarded-Proto so upstream services (especially
|
|
# Authelia) know the client is actually on HTTPS.
|
|
cfProxy = port: ''
|
|
reverse_proxy localhost:${toString port} {
|
|
header_up X-Forwarded-Proto https
|
|
}
|
|
'';
|
|
|
|
# Reusable Authelia forward_auth snippet
|
|
# Returns a Caddyfile snippet block that applies forward_auth.
|
|
# Uses the v4.38+ /api/authz/forward-auth endpoint which correctly honours
|
|
# one_factor policy without forcing TOTP enrollment on new users.
|
|
# copy_headers makes Authelia's Remote-* headers available downstream.
|
|
autheliaForwardAuth = ''
|
|
forward_auth localhost:9091 {
|
|
uri /api/authz/forward-auth?authelia_url=https://auth.${domain}
|
|
copy_headers Remote-User Remote-Name Remote-Groups Remote-Email
|
|
# Always tell Authelia the scheme is https (cloudflared terminates TLS
|
|
# externally; Caddy's http:// vhosts are only for the tunnel loopback).
|
|
header_up X-Forwarded-Proto https
|
|
}
|
|
'';
|
|
|
|
in
|
|
{
|
|
options.homey.caddy = {
|
|
enable = lib.mkEnableOption "Caddy reverse proxy";
|
|
|
|
acmeEmail = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "admin@zakobar.com";
|
|
description = "Email for Let's Encrypt ACME registration.";
|
|
};
|
|
|
|
virtualHosts = lib.mkOption {
|
|
type = lib.types.listOf (lib.types.submodule {
|
|
options = {
|
|
subdomain = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = "Subdomain under homeyConfig.domain (e.g. \"mealie\" → mealie.zakobar.com).";
|
|
};
|
|
port = lib.mkOption {
|
|
type = lib.types.port;
|
|
description = "Host port to reverse-proxy to.";
|
|
};
|
|
auth = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = true;
|
|
description = "Prepend Authelia forward_auth to this vhost.";
|
|
};
|
|
extraConfig = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "";
|
|
description = "Replaces the auto-generated 'reverse_proxy localhost:<port>' for HTTPS. Empty = use default.";
|
|
};
|
|
extraHttpConfig = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "";
|
|
description = "Replaces the auto-generated cfProxy for the HTTP loopback vhost. Empty = use default.";
|
|
};
|
|
};
|
|
});
|
|
default = [];
|
|
description = "Virtual hosts to generate. Each service module contributes its own entries.";
|
|
};
|
|
};
|
|
|
|
config = lib.mkIf cfg.enable {
|
|
# -----------------------------------------------------------------------
|
|
# Secrets
|
|
# -----------------------------------------------------------------------
|
|
sops.secrets."cloudflare/api_token" = {
|
|
owner = config.services.caddy.user;
|
|
};
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Caddy service
|
|
# -----------------------------------------------------------------------
|
|
services.caddy = {
|
|
enable = true;
|
|
package = caddyWithCloudflare;
|
|
|
|
# Global options
|
|
globalConfig = ''
|
|
email ${cfg.acmeEmail}
|
|
# Use Cloudflare DNS-01 challenge for wildcard cert
|
|
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
|
|
'';
|
|
|
|
# Each virtual host is generated from homey.caddy.virtualHosts entries.
|
|
# Each service module contributes its own entries to that list.
|
|
#
|
|
# Each entry produces two Caddy vhosts:
|
|
# - "subdomain.domain" → HTTPS (LAN access + Let's Encrypt cert)
|
|
# - "http://subdomain.domain" → plain HTTP for cloudflared loopback
|
|
virtualHosts = lib.listToAttrs (
|
|
lib.concatMap (vh:
|
|
let
|
|
d = "${vh.subdomain}.${domain}";
|
|
authSnip = lib.optionalString vh.auth autheliaForwardAuth;
|
|
httpsBody = if vh.extraConfig != "" then vh.extraConfig
|
|
else "reverse_proxy localhost:${toString vh.port}\n";
|
|
httpBody = if vh.extraHttpConfig != "" then vh.extraHttpConfig
|
|
else cfProxy vh.port;
|
|
in [
|
|
{ name = d; value.extraConfig = "${authSnip}${httpsBody}"; }
|
|
{ name = "http://${d}"; value.extraConfig = "${authSnip}${httpBody}"; }
|
|
]
|
|
) cfg.virtualHosts
|
|
);
|
|
};
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Pass Cloudflare token as env var to the caddy systemd unit.
|
|
#
|
|
# The caddy-dns/cloudflare plugin reads CLOUDFLARE_API_TOKEN directly.
|
|
# sops decrypts the secret to a file at runtime; we write a transient
|
|
# env file to /run/ in ExecStartPre so systemd picks it up via
|
|
# EnvironmentFile. The file is removed in ExecStopPost.
|
|
# -----------------------------------------------------------------------
|
|
systemd.services.caddy = {
|
|
serviceConfig = {
|
|
# LoadCredential stages the sops-decrypted secret into a
|
|
# per-invocation directory ($CREDENTIALS_DIRECTORY) before any
|
|
# Exec* step. ExecStart then reads the file contents and exports
|
|
# CLOUDFLARE_API_TOKEN before exec-ing caddy, so there is no
|
|
# intermediate env file and no ordering race with EnvironmentFile.
|
|
LoadCredential = "cloudflare_api_token:${config.sops.secrets."cloudflare/api_token".path}";
|
|
# Systemd requires clearing ExecStart= before setting a new value for
|
|
# non-oneshot services. The empty string resets the list; the second
|
|
# entry is the actual start command.
|
|
ExecStart = lib.mkForce [
|
|
""
|
|
(pkgs.writeShellScript "caddy-start" ''
|
|
export CLOUDFLARE_API_TOKEN=$(cat "$CREDENTIALS_DIRECTORY/cloudflare_api_token")
|
|
exec ${caddyWithCloudflare}/bin/caddy run --environ --config /etc/caddy/caddy_config --adapter caddyfile
|
|
'')
|
|
];
|
|
};
|
|
after = lib.mkAfter [ "podman-authelia.service" ];
|
|
wants = lib.mkAfter [ "podman-authelia.service" ];
|
|
};
|
|
|
|
# -----------------------------------------------------------------------
|
|
# Firewall — open HTTP + HTTPS (already in common.nix, explicit here too)
|
|
# -----------------------------------------------------------------------
|
|
networking.firewall.allowedTCPPorts = [ 80 443 ];
|
|
};
|
|
}
|