Files
homey/modules/caddy.nix
T
Aner Zakobar 0b73d493d8 Working NixOS port: all core services operational
- Fix Caddy cfProxy helper for cloudflared http:// vhosts (X-Forwarded-Proto)
- Fix Authelia LDAP bind (readonly user ACL + password sync)
- Add gitea-admin-setup oneshot service to survive rebuilds
- Update Authelia forward_auth with header_up X-Forwarded-Proto https
- Update TODO.org with completed tasks and LDAP config details
- Remove old Helm/k8s artifacts (Chart.yaml, templates/, values/, scripts)
- Add result to .gitignore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:46:21 +03:00

247 lines
9.2 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.
# copy_headers makes Authelia's Remote-* headers available downstream.
autheliaForwardAuth = ''
forward_auth localhost:9091 {
uri /api/verify?rd=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
# On auth failure, redirect to the authelia login page
@goauth status 401
handle_response @goauth {
redir https://auth.${domain}?rm={method} 302
}
}
'';
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.";
};
};
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.
#
# Each service gets two vhost entries:
# - "host" (no scheme) → Caddy handles HTTPS + auto cert (for LAN access)
# - "http://host" → plain HTTP for cloudflared on loopback (no redirect)
#
# Caddy auto-redirects HTTP→HTTPS only when no explicit http:// vhost exists.
# By defining http:// explicitly we suppress that redirect so cloudflared
# (which talks plain HTTP on port 80) gets a direct response.
virtualHosts = {
# ------------------------------------------------------------------
# Authelia — public, no auth gate (it IS the auth gate)
# ------------------------------------------------------------------
"auth.${domain}" = {
extraConfig = ''
reverse_proxy localhost:9091
'';
};
"http://auth.${domain}" = {
extraConfig = cfProxy 9091;
};
# ------------------------------------------------------------------
# Gitea — no forward_auth; git HTTP clients can't handle SSO redirects.
# Access control is handled by Gitea itself (LDAP auth + private repos).
# ------------------------------------------------------------------
"git.${domain}" = {
extraConfig = ''
reverse_proxy localhost:3000
'';
};
"http://git.${domain}" = {
extraConfig = cfProxy 3000;
};
# ------------------------------------------------------------------
# Nextcloud — public auth (Nextcloud manages its own users + LDAP)
# ------------------------------------------------------------------
"nextcloud.${domain}" = {
extraConfig = ''
# Redirect CardDAV/CalDAV discovery
redir /.well-known/carddav /remote.php/dav/ 301
redir /.well-known/caldav /remote.php/dav/ 301
# Large uploads (5 GB)
request_body {
max_size 5GB
}
reverse_proxy localhost:8080
'';
};
"http://nextcloud.${domain}" = {
extraConfig = ''
redir /.well-known/carddav /remote.php/dav/ 301
redir /.well-known/caldav /remote.php/dav/ 301
request_body {
max_size 5GB
}
reverse_proxy localhost:8080 {
header_up X-Forwarded-Proto https
}
'';
};
# ------------------------------------------------------------------
# phpLDAPadmin — two_factor, admins only (enforced by authelia policy)
# ------------------------------------------------------------------
"ldapadmin.${domain}" = {
extraConfig = ''
${autheliaForwardAuth}
reverse_proxy localhost:8081
'';
};
"http://ldapadmin.${domain}" = {
extraConfig = ''
${autheliaForwardAuth}
${cfProxy 8081}
'';
};
# ------------------------------------------------------------------
# Jellyfin — no forward_auth; Jellyfin has its own login UI and
# native app clients can't handle SSO redirects.
# ------------------------------------------------------------------
"jellyfin.${domain}" = {
extraConfig = ''
reverse_proxy localhost:8096
'';
};
"http://jellyfin.${domain}" = {
extraConfig = cfProxy 8096;
};
# ------------------------------------------------------------------
# Transmission — two_factor, admins only (enforced by authelia policy)
# ------------------------------------------------------------------
"torrent.${domain}" = {
extraConfig = ''
${autheliaForwardAuth}
reverse_proxy localhost:9092
'';
# NOTE: transmission is bound to 9092 to avoid clash with authelia on 9091.
};
"http://torrent.${domain}" = {
extraConfig = ''
${autheliaForwardAuth}
${cfProxy 9092}
'';
};
};
};
# -----------------------------------------------------------------------
# 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 ];
};
}