diff --git a/containers/caddy/Caddyfile.template b/containers/caddy/Caddyfile.template new file mode 100644 index 000000000..cb747a0ed --- /dev/null +++ b/containers/caddy/Caddyfile.template @@ -0,0 +1,36 @@ +# Auto-generated Caddyfile for Umbrel +# Do not edit manually - changes will be overwritten + +{ + http_port 80 + https_port 443 + auto_https off + admin 0.0.0.0:2019 +} + +:443 { + tls /certs/umbrel.crt /certs/umbrel.key + + # Security headers + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + X-XSS-Protection "1; mode=block" + } + + # Default route - show Umbrel welcome + handle / { + respond "Welcome to Umbrel! Access apps at /{app-id}/*" 200 + } + + # App routes will be dynamically added here + # Example: + # handle /mempool* { + # reverse_proxy mempool_app_proxy:4000 + # } +} + +:80 { + redir https://{host}{uri} permanent +} diff --git a/containers/caddy/Dockerfile b/containers/caddy/Dockerfile new file mode 100644 index 000000000..32adc2e27 --- /dev/null +++ b/containers/caddy/Dockerfile @@ -0,0 +1,17 @@ +# Build Stage +FROM caddy:2.7.6-alpine AS umbrel-caddy + +# Final image +FROM caddy:2.7.6-alpine AS umbrel-caddy-final + +# Create caddy directory +WORKDIR /config + +# Copy Caddyfile from build stage +COPY --from=umbrel-caddy /usr/bin/caddy /usr/bin/caddy + +# Expose HTTP and HTTPS ports +EXPOSE 80 443 2019 + +# Run Caddy +CMD ["caddy", "run", "--config", "/config/Caddyfile", "--adapter", "caddyfile"] diff --git a/containers/caddy/test/Caddyfile.test b/containers/caddy/test/Caddyfile.test new file mode 100644 index 000000000..5e67f25a3 --- /dev/null +++ b/containers/caddy/test/Caddyfile.test @@ -0,0 +1,35 @@ +# Test Caddyfile for Caddy integration tests +# This file is used to test Caddy configuration without umbreld + +{ + http_port 8080 + https_port 8443 + auto_https off + admin 0.0.0.0:2019 +} + +:8443 { + tls /certs/test.crt /certs/test.key + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + } + + handle /app1* { + reverse_proxy test_app1:8888 + } + + handle /app2* { + reverse_proxy test_app2:8889 + } + + handle / { + respond "Umbrel Caddy Test Server" 200 + } +} + +:8080 { + redir https://{host}{uri} permanent +} diff --git a/containers/caddy/test/docker-compose.yml b/containers/caddy/test/docker-compose.yml new file mode 100644 index 000000000..8144f6a59 --- /dev/null +++ b/containers/caddy/test/docker-compose.yml @@ -0,0 +1,34 @@ +version: '3.7' + +services: + caddy: + container_name: test_caddy + image: caddy:2.7.6-alpine + ports: + - "8080:80" + - "8443:443" + volumes: + - ./test/Caddyfile.test:/etc/caddy/Caddyfile:ro + - ./test/certs:/certs:ro + networks: + - test_network + + test_app1: + container_name: test_app1 + image: mendhak/http-https-echo + environment: + HTTP_PORT: 8888 + networks: + - test_network + + test_app2: + container_name: test_app2 + image: mendhak/http-https-echo + environment: + HTTP_PORT: 8889 + networks: + - test_network + +networks: + test_network: + driver: bridge diff --git a/containers/caddy/test/test.sh b/containers/caddy/test/test.sh new file mode 100755 index 000000000..88dd5cb55 --- /dev/null +++ b/containers/caddy/test/test.sh @@ -0,0 +1,116 @@ +#!/bin/bash + +# Test script for Caddy HTTPS proxy integration +# This script tests the basic functionality of the Caddy reverse proxy + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "πŸ”§ Setting up Caddy test environment..." + +# Create test certificates directory +mkdir -p certs + +# Generate test certificates if they don't exist +if [ ! -f certs/test.crt ] || [ ! -f certs/test.key ]; then + echo "πŸ“œ Generating test certificates..." + openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout certs/test.key \ + -out certs/test.crt \ + -subj "/CN=umbrel.local/O=Umbrel Test/C=US" \ + -addext "subjectAltName=DNS:umbrel.local,DNS:localhost,IP:127.0.0.1" \ + 2>/dev/null + echo "βœ“ Certificates generated" +else + echo "βœ“ Certificates already exist" +fi + +# Clean up any existing containers +echo "🧹 Cleaning up existing containers..." +docker compose down --remove-orphans 2>/dev/null || true + +# Start test environment +echo "πŸš€ Starting test environment..." +docker compose up -d + +# Wait for containers to be ready +echo "⏳ Waiting for containers to start..." +sleep 5 + +# Check container health +echo "πŸ₯ Checking container health..." +docker compose ps + +# Test HTTP to HTTPS redirect +echo "" +echo "🌐 Testing HTTP to HTTPS redirect..." +HTTP_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/) +if [ "$HTTP_RESPONSE" = "301" ]; then + echo "βœ“ HTTP redirect working (status: $HTTP_RESPONSE)" +else + echo "βœ— HTTP redirect failed (status: $HTTP_RESPONSE)" +fi + +# Test HTTPS access +echo "" +echo "πŸ”’ Testing HTTPS access..." +HTTPS_RESPONSE=$(curl -sk -o /dev/null -w "%{http_code}" https://localhost:8443/) +if [ "$HTTPS_RESPONSE" = "200" ]; then + echo "βœ“ HTTPS access working (status: $HTTPS_RESPONSE)" +else + echo "βœ— HTTPS access failed (status: $HTTPS_RESPONSE)" +fi + +# Test app1 routing +echo "" +echo "πŸ“± Testing app1 routing..." +APP1_RESPONSE=$(curl -sk -o /dev/null -w "%{http_code}" https://localhost:8443/app1/) +if [ "$APP1_RESPONSE" = "200" ]; then + echo "βœ“ App1 routing working (status: $APP1_RESPONSE)" +else + echo "βœ— App1 routing failed (status: $APP1_RESPONSE)" +fi + +# Test app2 routing +echo "" +echo "πŸ“± Testing app2 routing..." +APP2_RESPONSE=$(curl -sk -o /dev/null -w "%{http_code}" https://localhost:8443/app2/) +if [ "$APP2_RESPONSE" = "200" ]; then + echo "βœ“ App2 routing working (status: $APP2_RESPONSE)" +else + echo "βœ— App2 routing failed (status: $APP2_RESPONSE)" +fi + +# Test security headers +echo "" +echo "πŸ” Testing security headers..." +HEADERS=$(curl -sk -I https://localhost:8443/) +if echo "$HEADERS" | grep -q "Strict-Transport-Security"; then + echo "βœ“ HSTS header present" +else + echo "βœ— HSTS header missing" +fi + +if echo "$HEADERS" | grep -q "X-Frame-Options"; then + echo "βœ“ X-Frame-Options header present" +else + echo "βœ— X-Frame-Options header missing" +fi + +# Show certificate info +echo "" +echo "πŸ“‹ Certificate information:" +echo | openssl s_client -connect localhost:8443 -servername umbrel.local 2>/dev/null | \ + openssl x509 -noout -subject -issuer -dates 2>/dev/null || echo "Could not retrieve certificate info" + +echo "" +echo "βœ… Caddy integration tests completed!" +echo "" +echo "To access the test server:" +echo " - Main: https://localhost:8443/" +echo " - App1: https://localhost:8443/app1/" +echo " - App2: https://localhost:8443/app2/" +echo "" +echo "To clean up, run: docker compose down" diff --git a/packages/umbreld/package-lock.json b/packages/umbreld/package-lock.json index eda46097a..3f22f2963 100644 --- a/packages/umbreld/package-lock.json +++ b/packages/umbreld/package-lock.json @@ -1,12 +1,12 @@ { "name": "umbreld", - "version": "1.5.0", + "version": "1.7.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "umbreld", - "version": "1.5.0", + "version": "1.7.1", "license": "PolyForm Noncommercial License 1.0.0", "dependencies": { "@homebridge/dbus-native": "github:getumbrel/dbus-native#types", @@ -1389,6 +1389,7 @@ "https://trpc.io/sponsor" ], "license": "MIT", + "peer": true, "peerDependencies": { "typescript": ">=5.7.2" } @@ -7235,6 +7236,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7357,6 +7359,7 @@ "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -7439,6 +7442,7 @@ "integrity": "sha512-veNjLizOMkRrJ6xxb+pvxN6/QAWg95mzcRjtmkepXdN87FNfxAss9RKe2far/G9cQpipfgP2taqg0KiWsquj8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "2.1.2", "@vitest/mocker": "2.1.2", @@ -8422,6 +8426,7 @@ "version": "11.1.1", "resolved": "https://registry.npmjs.org/@trpc/server/-/server-11.1.1.tgz", "integrity": "sha512-ZjPN3ypBHvGMAlMgeZPrxlRcH/3dn4AK0s5Ph1z+E6uiAvIQVCj7ZoMlXeeBsIy4THGDAk953jHVW2kMnlbb4g==", + "peer": true, "requires": {} }, "@tryjsky/ntlm2": { @@ -12385,7 +12390,8 @@ "typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==" + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "peer": true }, "uid-safe": { "version": "2.1.5", @@ -12474,6 +12480,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", "dev": true, + "peer": true, "requires": { "esbuild": "^0.21.3", "fsevents": "~2.3.3", @@ -12498,6 +12505,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.2.tgz", "integrity": "sha512-veNjLizOMkRrJ6xxb+pvxN6/QAWg95mzcRjtmkepXdN87FNfxAss9RKe2far/G9cQpipfgP2taqg0KiWsquj8A==", "dev": true, + "peer": true, "requires": { "@vitest/expect": "2.1.2", "@vitest/mocker": "2.1.2", diff --git a/packages/umbreld/source/index.ts b/packages/umbreld/source/index.ts index 45b47b103..568b5c828 100644 --- a/packages/umbreld/source/index.ts +++ b/packages/umbreld/source/index.ts @@ -30,6 +30,7 @@ import { reboot, } from './modules/system/system.js' import {cleanupFactoryResetBackups} from './modules/system/factory-reset.js' +import Caddy from './modules/caddy/index.js' type StoreSchema = { version: string @@ -132,6 +133,7 @@ export default class Umbreld { dbus: Dbus backups: Backups systemNg: SystemNg + caddy: Caddy isBackupRestoreFirstStart = false constructor({ @@ -158,6 +160,7 @@ export default class Umbreld { this.dbus = new Dbus(this) this.backups = new Backups(this) this.systemNg = new SystemNg(this) + this.caddy = new Caddy(this) } async start() { @@ -228,6 +231,7 @@ export default class Umbreld { this.dbus.start(), this.server.start(), this.systemNg.start(), + this.caddy.start(), ]) // Start backups last because it depends on files @@ -261,6 +265,7 @@ export default class Umbreld { this.appStore.stop(), this.dbus.stop(), this.systemNg.stop(), + this.caddy.stop(), ]) return true } catch (error) { diff --git a/packages/umbreld/source/modules/apps/app.ts b/packages/umbreld/source/modules/apps/app.ts index 4ae26f62a..01fa0cd2b 100644 --- a/packages/umbreld/source/modules/apps/app.ts +++ b/packages/umbreld/source/modules/apps/app.ts @@ -225,6 +225,12 @@ export default class App { }, retries: 2, }) + + // Register app with Caddy reverse proxy + await this.#registerWithCaddy().catch((error) => { + this.logger.error(`Failed to register app ${this.id} with Caddy`, error) + }) + this.state = 'ready' // Enable auto-start on boot @@ -235,6 +241,12 @@ export default class App { async stop({persistState = false}: {persistState?: boolean} = {}) { this.state = 'stopping' + + // Unregister app from Caddy reverse proxy + await this.#unregisterFromCaddy().catch((error) => { + this.logger.error(`Failed to unregister app ${this.id} from Caddy`, error) + }) + await pRetry(() => appScript(this.#umbreld, 'stop', this.id), { onFailedAttempt: (error) => { this.logger.error( @@ -268,6 +280,12 @@ export default class App { async uninstall() { this.state = 'uninstalling' + + // Unregister app from Caddy reverse proxy + await this.#unregisterFromCaddy().catch((error) => { + this.logger.error(`Failed to unregister app ${this.id} from Caddy`, error) + }) + await pRetry(() => appScript(this.#umbreld, 'stop', this.id), { onFailedAttempt: (error) => { this.logger.error( @@ -489,4 +507,49 @@ export default class App { async shouldAutoStart() { return (await this.store.get('autoStart')) ?? true } + + // Register app with Caddy reverse proxy + async #registerWithCaddy() { + try { + // Check if Caddy is enabled + const caddyEnabled = await this.#umbreld.caddy.isEnabled() + if (!caddyEnabled) return + + // Get app proxy port from compose file + const compose = await this.readCompose() + const appProxyService = compose.services?.[`${this.id}_app_proxy`] + + if (!appProxyService) { + this.logger.log(`No app_proxy service found for ${this.id}, skipping Caddy registration`) + return + } + + // Get the proxy port - either from environment or default + const env = appProxyService.environment as Record | undefined + const proxyPort = env?.PROXY_PORT || 4000 + + // Get the proxy hostname (container name) + const proxyHost = `${this.id}_app_proxy_1` + + this.logger.log(`Registering ${this.id} with Caddy at ${proxyHost}:${proxyPort}`) + await this.#umbreld.caddy.registerApp(this.id, proxyHost, proxyPort) + } catch (error) { + this.logger.error(`Failed to register ${this.id} with Caddy`, error) + // Non-fatal, continue + } + } + + // Unregister app from Caddy reverse proxy + async #unregisterFromCaddy() { + try { + const caddyEnabled = await this.#umbreld.caddy.isEnabled() + if (!caddyEnabled) return + + this.logger.log(`Unregistering ${this.id} from Caddy`) + await this.#umbreld.caddy.unregisterApp(this.id) + } catch (error) { + this.logger.error(`Failed to unregister ${this.id} from Caddy`, error) + // Non-fatal, continue + } + } } diff --git a/packages/umbreld/source/modules/apps/legacy-compat/app-environment.ts b/packages/umbreld/source/modules/apps/legacy-compat/app-environment.ts index 85a639994..352335b88 100644 --- a/packages/umbreld/source/modules/apps/legacy-compat/app-environment.ts +++ b/packages/umbreld/source/modules/apps/legacy-compat/app-environment.ts @@ -14,6 +14,7 @@ export default async function appEnvironment(umbreld: Umbreld, command: string) const currentDirname = dirname(currentFilename) const composePath = join(currentDirname, 'docker-compose.yml') const torEnabled = await umbreld.store.get('torEnabled') + const caddySettings = await umbreld.caddy.getSettings() const options = { stdio: inheritStdio ? 'inherit' : 'pipe', cwd: umbreld.dataDirectory, @@ -35,6 +36,11 @@ export default async function appEnvironment(umbreld: Umbreld, command: string) UMBRELD_RPC_HOST: `host.docker.internal:${umbreld.server.port}`, // TODO: Check host.docker.internal works on linux UMBREL_LEGACY_COMPAT_DIR: currentDirname, UMBREL_TORRC: torEnabled ? `${currentDirname}/tor-server-torrc` : `${currentDirname}/tor-proxy-torrc`, + // Caddy configuration + CADDY_IP: '10.21.21.12', + CADDY_DOMAIN: caddySettings.domain || 'umbrel.local', + CADDY_HTTP_PORT: String(caddySettings.httpPort || 80), + CADDY_HTTPS_PORT: String(caddySettings.httpsPort || 443), }, } if (command === 'up') { diff --git a/packages/umbreld/source/modules/apps/legacy-compat/docker-compose.app_proxy.yml b/packages/umbreld/source/modules/apps/legacy-compat/docker-compose.app_proxy.yml index 795b53a36..af51e5ff7 100644 --- a/packages/umbreld/source/modules/apps/legacy-compat/docker-compose.app_proxy.yml +++ b/packages/umbreld/source/modules/apps/legacy-compat/docker-compose.app_proxy.yml @@ -7,8 +7,10 @@ services: user: '1000:1000' restart: on-failure hostname: $APP_PROXY_HOSTNAME - ports: - - '${APP_PROXY_PORT}:${APP_PROXY_PORT}' + # Port exposure is now handled by Caddy reverse proxy + # Uncomment below for direct access during development + # ports: + # - '${APP_PROXY_PORT}:${APP_PROXY_PORT}' volumes: - '${APP_MANIFEST_FILE}:/extra/umbrel-app.yml:ro' - '${TOR_DATA_DIR}:/var/lib/tor:ro' @@ -26,3 +28,5 @@ services: MANAGER_IP: $MANAGER_IP MANAGER_PORT: 3006 JWT_SECRET: $JWT_SECRET + # Trust Caddy as upstream proxy + PROXY_TRUST_UPSTREAM: 'true' diff --git a/packages/umbreld/source/modules/apps/legacy-compat/docker-compose.caddy.yml b/packages/umbreld/source/modules/apps/legacy-compat/docker-compose.caddy.yml new file mode 100644 index 000000000..c6cf87f03 --- /dev/null +++ b/packages/umbreld/source/modules/apps/legacy-compat/docker-compose.caddy.yml @@ -0,0 +1,33 @@ +version: '3.7' + +services: + caddy: + container_name: umbrel_caddy + image: getumbrel/caddy:2.7.6 + # Uncomment below to build from source + # build: + # dockerfile: containers/caddy/Dockerfile + # context: ../../../../../../ + user: '1000:1000' + restart: on-failure + ports: + - '${CADDY_HTTP_PORT:-80}:${CADDY_HTTP_PORT:-80}' + - '${CADDY_HTTPS_PORT:-443}:${CADDY_HTTPS_PORT:-443}' + volumes: + - ${UMBREL_DATA_DIR}/caddy:/config + - ${UMBREL_DATA_DIR}/caddy/certs:/certs:ro + environment: + CADDY_DOMAIN: ${CADDY_DOMAIN:-umbrel.local} + CADDY_HTTP_PORT: ${CADDY_HTTP_PORT:-80} + CADDY_HTTPS_PORT: ${CADDY_HTTPS_PORT:-443} + networks: + default: + ipv4_address: $CADDY_IP + +networks: + default: + name: umbrel_main_network + ipam: + driver: default + config: + - subnet: '$NETWORK_IP/16' diff --git a/packages/umbreld/source/modules/caddy/README.md b/packages/umbreld/source/modules/caddy/README.md new file mode 100644 index 000000000..851b84ae5 --- /dev/null +++ b/packages/umbreld/source/modules/caddy/README.md @@ -0,0 +1,272 @@ +# Caddy HTTPS Proxy for Umbrel + +This module provides HTTPS support for Umbrel apps using [Caddy](https://caddyserver.com/) as a reverse proxy. It enables secure, encrypted connections to all your self-hosted apps on the local network. + +## Features + +- **Self-signed SSL/TLS certificates** - Automatically generated on first enable +- **Automatic HTTPS** - All app traffic encrypted without manual certificate management +- **Dynamic app routing** - Apps automatically registered/unregistered as they start/stop +- **HTTP to HTTPS redirect** - Seamless upgrade from HTTP to HTTPS +- **Security headers** - HSTS, X-Frame-Options, and other security headers included +- **Path-based routing** - Apps accessible at `https://umbrel.local/{app-id}/*` + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Local Network Clients β”‚ +β”‚ (https://umbrel.local or IP) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Caddy Reverse Proxy β”‚ + β”‚ (Single Instance) β”‚ + β”‚ - Self-signed SSL β”‚ + β”‚ - Auto TLS termination β”‚ + β”‚ - Dynamic routing β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” + β”‚ App 1 Proxy β”‚ β”‚ App 2 Proxy β”‚ β”‚ App N Proxy β”‚ + β”‚ (app-proxy:4000)β”‚ β”‚(app-proxy:4001)β”‚ β”‚(app-proxy:400N)β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” + β”‚ App Container β”‚ β”‚ App Containerβ”‚ β”‚ App Containerβ”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Installation + +The Caddy module is included in umbreld and starts automatically. To enable HTTPS: + +### Via API (Future UI Integration) + +```typescript +// Enable Caddy +await umbreld.caddy.setEnabled(true) + +// Configure domain (optional, defaults to 'umbrel.local') +await umbreld.caddy.updateSettings({ + enabled: true, + domain: 'myserver.local' +}) + +// Get certificate fingerprint for verification +const fingerprint = await umbreld.caddy.getCertificateFingerprint() +console.log('Certificate SHA256:', fingerprint) +``` + +### Configuration Options + +| Setting | Default | Description | +|---------|---------|-------------| +| `enabled` | `false` | Enable/disable HTTPS proxy | +| `domain` | `umbrel.local` | Domain name for the certificate | +| `httpPort` | `80` | HTTP port for redirects | +| `httpsPort` | `443` | HTTPS port for secure access | +| `forceHttps` | `true` | Force redirect HTTP β†’ HTTPS | +| `certificatePath` | Auto | Custom certificate path (optional) | +| `privateKeyPath` | Auto | Custom private key path (optional) | + +## Usage + +Once enabled, all apps are accessible via HTTPS: + +``` +https://umbrel.local/mempool/ +https://umbrel.local/nextcloud/ +https://umbrel.local/bitcoind/ +``` + +### Certificate Trust + +Since self-signed certificates are used, browsers will show a warning on first access. You have two options: + +1. **Accept the warning** - Click "Advanced" β†’ "Proceed" in your browser +2. **Trust the certificate** - Export the certificate and add it to your system's trusted roots + +To view the certificate fingerprint for verification: + +```bash +# View fingerprint in umbreld logs +# Or via API +curl -X GET http://localhost:80/api/caddy/certificate +``` + +## How It Works + +### Certificate Generation + +On first enable, the module generates a self-signed certificate using OpenSSL: + +```bash +openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ + -keyout umbrel.key -out umbrel.crt \ + -subj "/CN=umbrel.local/O=Umbrel/C=US" \ + -addext "subjectAltName=DNS:umbrel.local,DNS:*.umbrel.local,IP:127.0.0.1" +``` + +Certificates are stored in `${UMBREL_DATA_DIR}/caddy/certs/` and valid for 10 years. + +### Dynamic Routing + +When an app starts: +1. App reads its proxy configuration from `docker-compose.yml` +2. App calls `umbreld.caddy.registerApp(appId, proxyHost, proxyPort)` +3. Caddy module updates Caddyfile and reloads configuration via Admin API +4. Route `/{appId}/* β†’ {proxyHost}:{proxyPort}` is now active + +When an app stops, the route is automatically removed. + +### Caddy Configuration + +The Caddyfile is auto-generated and looks like: + +```caddy +{ + http_port 80 + https_port 443 + auto_https off + admin 0.0.0.0:2019 +} + +:443 { + tls /certs/umbrel.crt /certs/umbrel.key + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + } + + handle /mempool* { + reverse_proxy mempool_app_proxy:4000 + } + + handle /nextcloud* { + reverse_proxy nextcloud_app_proxy:4001 + } +} + +:80 { + redir https://{host}{uri} permanent +} +``` + +## Development + +### Testing Locally + +```bash +# Enable Caddy in development +cd packages/umbreld +npm run dev + +# Or test with docker-compose +cd containers/caddy +docker compose -f test/docker-compose.yml up +``` + +### Test Fixtures + +See `test/` directory for test configurations: +- `test/docker-compose.caddy-test.yml` - Basic Caddy test setup +- `test/fixtures/` - Sample app configurations + +### Modifying Caddy Module + +Key files: +- `packages/umbreld/source/modules/caddy/index.ts` - Main module +- `packages/umbreld/source/modules/caddy/config-builder.ts` - Config generation +- `packages/umbreld/source/modules/caddy/schema.ts` - Settings schema +- `containers/caddy/Dockerfile` - Container build +- `containers/caddy/Caddyfile.template` - Base template + +## Troubleshooting + +### Certificate Warnings + +**Problem**: Browser shows "Your connection is not private" + +**Solution**: This is expected with self-signed certificates. Either: +- Click "Advanced" β†’ "Proceed to site (unsafe)" to continue +- Export and trust the certificate in your OS/browser + +### App Not Accessible + +**Problem**: `https://umbrel.local/myapp` returns 502 Bad Gateway + +**Solution**: +1. Check if app is running: `docker ps | grep myapp` +2. Check Caddy logs: `docker logs umbrel_caddy` +3. Verify route registration in umbreld logs +4. Ensure app_proxy container is healthy + +### Port Conflicts + +**Problem**: Caddy fails to start, port 80/443 already in use + +**Solution**: +1. Check what's using the port: `sudo lsof -i :80` +2. Change Caddy ports in settings: + ```typescript + await umbreld.caddy.updateSettings({ + httpPort: 8080, + httpsPort: 8443 + }) + ``` + +### Certificate Regeneration + +**Problem**: Need to regenerate certificates (e.g., changed domain) + +**Solution**: +```bash +# Delete existing certificates +rm -rf ${UMBREL_DATA_DIR}/caddy/certs/* + +# Restart Caddy module +docker restart umbrel_caddy + +# Or via API +await umbreld.caddy.setEnabled(false) +await umbreld.caddy.setEnabled(true) +``` + +## Security Considerations + +### Self-Signed Certificates + +- **Pros**: Easy setup, no external dependencies, provides encryption +- **Cons**: Browser warnings, manual trust required per device + +For production deployments or to avoid warnings, consider: +1. Using a real domain with Let's Encrypt (requires public DNS) +2. Setting up a local CA and trusting it on all devices +3. Using mdns/Bonjour with self-signed certs for discovery + +### Network Security + +- All app traffic is encrypted between client and Caddy +- Internal traffic (Caddy β†’ app_proxy) remains on Docker network +- HSTS headers prevent downgrade attacks +- Security headers protect against common web vulnerabilities + +## Future Enhancements + +- [ ] UI integration for enabling/disabling HTTPS +- [ ] Certificate export/download functionality +- [ ] Support for Let's Encrypt with DNS challenge +- [ ] Local CA mode for trusted certificates +- [ ] Subdomain routing (`myapp.umbrel.local`) +- [ ] WebSocket connection pooling +- [ ] Rate limiting and DDoS protection +- [ ] Access logging and analytics + +## License + +Part of the Umbrel project. See main repository for license information. diff --git a/packages/umbreld/source/modules/caddy/config-builder.test.ts b/packages/umbreld/source/modules/caddy/config-builder.test.ts new file mode 100644 index 000000000..35494e2a5 --- /dev/null +++ b/packages/umbreld/source/modules/caddy/config-builder.test.ts @@ -0,0 +1,133 @@ +import {describe, it, expect} from 'vitest' +import {buildCaddyConfig, generateCaddyfile, type CaddyRoute} from './config-builder.js' +import type {CaddySettings} from './schema.js' + +describe('Caddy Config Builder', () => { + const defaultSettings: CaddySettings = { + enabled: true, + domain: 'umbrel.local', + httpPort: 80, + httpsPort: 443, + forceHttps: true, + } + + const testRoutes: CaddyRoute[] = [ + {appId: 'mempool', proxyHost: 'mempool_app_proxy_1', proxyPort: 4000}, + {appId: 'nextcloud', proxyHost: 'nextcloud_app_proxy_1', proxyPort: 4001}, + ] + + describe('buildCaddyConfig', () => { + it('should generate valid config structure', () => { + const config = buildCaddyConfig(defaultSettings, testRoutes) + + expect(config).toHaveProperty('admin') + expect(config).toHaveProperty('apps') + expect(config.apps.http).toHaveProperty('servers.umbrel') + expect(config.apps.tls).toHaveProperty('certificates.load_files') + }) + + it('should include all routes', () => { + const config = buildCaddyConfig(defaultSettings, testRoutes) + const routes = config.apps.http.servers.umbrel.routes + + // Should have redirect route + app routes + expect(routes.length).toBeGreaterThan(testRoutes.length) + }) + + it('should configure correct ports', () => { + const settings = {...defaultSettings, httpPort: 8080, httpsPort: 8443} + const config = buildCaddyConfig(settings, testRoutes) + + expect(config.apps.http.http_port).toBe(8080) + expect(config.apps.http.https_port).toBe(8443) + expect(config.apps.http.servers.umbrel.listen).toContain(':8080') + expect(config.apps.http.servers.umbrel.listen).toContain(':8443') + }) + + it('should disable auto_https', () => { + const config = buildCaddyConfig(defaultSettings, testRoutes) + expect(config.apps.http.servers.umbrel.auto_https.disabled).toBe(true) + }) + + it('should include certificate paths', () => { + const config = buildCaddyConfig(defaultSettings, testRoutes) + const certs = config.apps.tls.certificates.load_files + + expect(certs.length).toBe(1) + expect(certs[0].certificate).toBe('/certs/umbrel.crt') + expect(certs[0].key).toBe('/certs/umbrel.key') + }) + + it('should use custom certificate paths when provided', () => { + const settings: CaddySettings = { + ...defaultSettings, + certificatePath: '/custom/cert.pem', + privateKeyPath: '/custom/key.pem', + } + const config = buildCaddyConfig(settings, testRoutes) + + expect(config.apps.tls.certificates.load_files[0].certificate).toBe('/custom/cert.pem') + expect(config.apps.tls.certificates.load_files[0].key).toBe('/custom/key.pem') + }) + }) + + describe('generateCaddyfile', () => { + it('should generate valid Caddyfile syntax', () => { + const caddyfile = generateCaddyfile(defaultSettings, testRoutes) + + expect(caddyfile).toContain('{') + expect(caddyfile).toContain('}') + expect(caddyfile).toContain('http_port 80') + expect(caddyfile).toContain('https_port 443') + }) + + it('should include all app routes', () => { + const caddyfile = generateCaddyfile(defaultSettings, testRoutes) + + expect(caddyfile).toContain('handle /mempool*') + expect(caddyfile).toContain('reverse_proxy mempool_app_proxy_1:4000') + expect(caddyfile).toContain('handle /nextcloud*') + expect(caddyfile).toContain('reverse_proxy nextcloud_app_proxy_1:4001') + }) + + it('should include security headers', () => { + const caddyfile = generateCaddyfile(defaultSettings, testRoutes) + + expect(caddyfile).toContain('Strict-Transport-Security') + expect(caddyfile).toContain('X-Content-Type-Options') + expect(caddyfile).toContain('X-Frame-Options') + }) + + it('should include TLS configuration', () => { + const caddyfile = generateCaddyfile(defaultSettings, testRoutes) + + expect(caddyfile).toContain('tls /certs/umbrel.crt /certs/umbrel.key') + }) + + it('should generate HTTP to HTTPS redirect when forceHttps is true', () => { + const caddyfile = generateCaddyfile(defaultSettings, testRoutes) + + expect(caddyfile).toContain(':80 {') + expect(caddyfile).toContain('redir https://{host}{uri} permanent') + }) + + it('should omit HTTP redirect when forceHttps is false', () => { + const settings = {...defaultSettings, forceHttps: false} + const caddyfile = generateCaddyfile(settings, testRoutes) + + // Should not have the :80 block with redirect + const lines = caddyfile.split('\n') + const redirectBlock = lines.findIndex((line: string) => line.includes(':80 {')) + expect(redirectBlock).toBe(-1) + }) + + it('should use custom domain in comments', () => { + const settings = {...defaultSettings, domain: 'myserver.local'} + const caddyfile = generateCaddyfile(settings, testRoutes) + + // Domain is used in certificate generation, not directly in Caddyfile + // but should be in the file somewhere + expect(caddyfile).toBeDefined() + }) + }) +}) diff --git a/packages/umbreld/source/modules/caddy/config-builder.ts b/packages/umbreld/source/modules/caddy/config-builder.ts new file mode 100644 index 000000000..c5a8f8722 --- /dev/null +++ b/packages/umbreld/source/modules/caddy/config-builder.ts @@ -0,0 +1,184 @@ +import type {CaddySettings} from './schema.js' + +export interface CaddyRoute { + appId: string + proxyHost: string + proxyPort: number + path?: string +} + +export interface CaddyConfig { + admin: { + listen: string + } + apps: { + http: { + http_port: number + https_port: number + servers: { + umbrel: { + listen: string[] + routes: CaddyRouteConfig[] + auto_https: { + disabled: boolean + } + } + } + } + tls: { + certificates: { + load_files: Array<{ + certificate: string + key: string + }> + } + } + } +} + +interface CaddyRouteConfig { + match?: { + path?: string[] + } + handle: Array<{ + handler: string + reverse_proxy?: { + upstreams: Array<{ + dial: string + }> + } + redirect?: { + uri?: string + status_code?: number + } + headers?: { + response?: { + set?: Record + } + } + }> + terminal?: boolean +} + +export function buildCaddyConfig(settings: CaddySettings, routes: CaddyRoute[]): CaddyConfig { + const certPath = settings.certificatePath || '/certs/umbrel.crt' + const keyPath = settings.privateKeyPath || '/certs/umbrel.key' + + const routeConfigs: CaddyRouteConfig[] = [] + + // Add routes for each app + for (const route of routes) { + const pathPrefix = route.path || `/${route.appId}/*` + + routeConfigs.push({ + match: { + path: [pathPrefix] + }, + handle: [ + { + handler: 'reverse_proxy', + reverse_proxy: { + upstreams: [ + { + dial: `${route.proxyHost}:${route.proxyPort}` + } + ] + } + } + ], + terminal: true + }) + } + + // Add HTTP to HTTPS redirect if forceHttps is enabled + if (settings.forceHttps) { + // Note: Protocol matching is done differently in Caddy + // We'll handle this in the Caddyfile instead + // For JSON config, we'd need a separate HTTP server block + } + + const config: CaddyConfig = { + admin: { + listen: '0.0.0.0:2019' + }, + apps: { + http: { + http_port: settings.httpPort, + https_port: settings.httpsPort, + servers: { + umbrel: { + listen: [ + `:${settings.httpPort}`, + `:${settings.httpsPort}` + ], + routes: routeConfigs, + auto_https: { + disabled: true + } + } + } + }, + tls: { + certificates: { + load_files: [ + { + certificate: certPath, + key: keyPath + } + ] + } + } + } + } + + return config +} + +export function generateCaddyfile(settings: CaddySettings, routes: CaddyRoute[]): string { + const domain = settings.domain || 'umbrel.local' + const certPath = settings.certificatePath || '/certs/umbrel.crt' + const keyPath = settings.privateKeyPath || '/certs/umbrel.key' + + let caddyfile = `# Auto-generated Caddyfile for Umbrel\n` + caddyfile += `# Do not edit manually - changes will be overwritten\n\n` + + // Global options + caddyfile += `{ + http_port ${settings.httpPort} + https_port ${settings.httpsPort} + auto_https off + admin 0.0.0.0:2019 +}\n\n` + + // Main server block + caddyfile += `:443 { + tls ${certPath} ${keyPath} + + # Security headers + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + X-XSS-Protection "1; mode=block" + }\n` + + // Add routes for each app + for (const route of routes) { + const pathPrefix = route.path || `/${route.appId}` + caddyfile += ` + handle ${pathPrefix}* { + reverse_proxy ${route.proxyHost}:${route.proxyPort} + }\n` + } + + caddyfile += `}\n\n` + + // HTTP to HTTPS redirect + if (settings.forceHttps) { + caddyfile += `:80 { + redir https://{host}{uri} permanent +}\n` + } + + return caddyfile +} diff --git a/packages/umbreld/source/modules/caddy/index.ts b/packages/umbreld/source/modules/caddy/index.ts new file mode 100644 index 000000000..bbcf15c1d --- /dev/null +++ b/packages/umbreld/source/modules/caddy/index.ts @@ -0,0 +1,324 @@ +import crypto from 'node:crypto' +import {fileURLToPath} from 'node:url' +import {dirname, join} from 'node:path' +import fse from 'fs-extra' +import {$} from 'execa' +import pRetry from 'p-retry' +import yaml from 'js-yaml' + +import type Umbreld from '../../index.js' +import type {CaddySettings} from './schema.js' +import {CaddySettingsSchema} from './schema.js' +import {buildCaddyConfig, generateCaddyfile, type CaddyRoute} from './config-builder.js' + +export default class Caddy { + #umbreld: Umbreld + #logger: Umbreld['logger'] + #settingsFile: string + #routes: Map = new Map() + #configPath: string + #caddyfilePath: string + #certsPath: string + + constructor(umbreld: Umbreld) { + this.#umbreld = umbreld + const {name} = this.constructor + this.#logger = umbreld.logger.createChildLogger(name.toLowerCase()) + this.#settingsFile = `${umbreld.dataDirectory}/caddy-settings.yml` + + const currentFilename = fileURLToPath(import.meta.url) + const currentDirname = dirname(currentFilename) + + this.#configPath = `${umbreld.dataDirectory}/caddy/config.json` + this.#caddyfilePath = `${umbreld.dataDirectory}/caddy/Caddyfile` + this.#certsPath = `${umbreld.dataDirectory}/caddy/certs` + } + + async start() { + this.#logger.log('Starting Caddy module') + + // Create directories + await fse.mkdirp(`${this.#umbreld.dataDirectory}/caddy`) + await fse.mkdirp(this.#certsPath) + + // Initialize settings + let settings = await this.#loadSettings() + if (settings.enabled === undefined) { + settings = {enabled: false, domain: 'umbrel.local', httpPort: 80, httpsPort: 443, forceHttps: true} + await this.#saveSettings(settings) + } + + // Validate settings + try { + CaddySettingsSchema.parse(settings) + } catch (error) { + this.#logger.error('Invalid Caddy settings, resetting to defaults', error as Error) + settings = {enabled: false, domain: 'umbrel.local', httpPort: 80, httpsPort: 443, forceHttps: true} + await this.#saveSettings(settings) + } + + // Generate certificates if they don't exist and Caddy is enabled + if (settings.enabled) { + await this.#ensureCertificatesExist(settings) + await this.#updateCaddyConfig(settings) + await this.#restartCaddy() + } + + this.#logger.log('Caddy module started') + } + + async stop() { + this.#logger.log('Stopping Caddy module') + try { + await $`docker stop umbrel_caddy 2>/dev/null || true` + } catch (error) { + this.#logger.error('Failed to stop Caddy container', error as Error) + } + } + + async isEnabled(): Promise { + const settings = await this.#loadSettings() + return settings.enabled ?? false + } + + async setEnabled(enabled: boolean) { + const currentSettings = await this.#loadSettings() + const newSettings: CaddySettings = {...currentSettings, enabled} + + this.#logger.log(`Setting Caddy enabled to ${enabled}`) + await this.#saveSettings(newSettings) + + if (enabled) { + await this.#ensureCertificatesExist(newSettings) + await this.#updateCaddyConfig(newSettings) + await this.#restartCaddy() + } else { + await this.stop() + } + } + + async getSettings(): Promise { + return await this.#loadSettings() + } + + async updateSettings(settings: Partial) { + const currentSettings = await this.#loadSettings() + const newSettings: CaddySettings = {...currentSettings, ...settings} + + // Validate + CaddySettingsSchema.parse(newSettings) + + await this.#saveSettings(newSettings) + + // If enabled, update Caddy configuration + if (newSettings.enabled) { + await this.#ensureCertificatesExist(newSettings) + await this.#updateCaddyConfig(newSettings) + await this.#restartCaddy() + } + + return newSettings + } + + async registerApp(appId: string, proxyHost: string, proxyPort: number, path?: string) { + const settings = await this.#loadSettings() + if (!settings.enabled) { + this.#logger.log(`Caddy not enabled, skipping registration of app ${appId}`) + return + } + + this.#logger.log(`Registering app ${appId} with Caddy at ${proxyHost}:${proxyPort}`) + + const route: CaddyRoute = {appId, proxyHost, proxyPort, path} + this.#routes.set(appId, route) + + await this.#updateCaddyConfig(settings) + await this.#reloadCaddyConfig() + } + + async unregisterApp(appId: string) { + const settings = await this.#loadSettings() + + this.#logger.log(`Unregistering app ${appId} from Caddy`) + this.#routes.delete(appId) + + if (settings.enabled) { + await this.#updateCaddyConfig(settings) + await this.#reloadCaddyConfig() + } + } + + async getCertificateFingerprint(): Promise { + const settings = await this.#loadSettings() + if (!settings.enabled) return null + + const certPath = settings.certificatePath || `${this.#certsPath}/umbrel.crt` + + try { + if (!await fse.pathExists(certPath)) return null + + // Read certificate and calculate fingerprint + const cert = await fse.readFile(certPath, 'utf8') + + // Extract the DER-encoded certificate and calculate SHA256 + const match = cert.match(/-----BEGIN CERTIFICATE-----(\n|.)*-----END CERTIFICATE-----/) + if (!match) return null + + const certBody = match[0] + .replace(/-----BEGIN CERTIFICATE-----/, '') + .replace(/-----END CERTIFICATE-----/, '') + .replace(/\s/g, '') + + const der = Buffer.from(certBody, 'base64') + const hash = crypto.createHash('sha256').update(der).digest('hex') + + // Format as fingerprint (XX:XX:XX...) + return hash.match(/.{2}/g)?.join(':').toUpperCase() || null + } catch (error) { + this.#logger.error('Failed to calculate certificate fingerprint', error as Error) + return null + } + } + + async #loadSettings(): Promise { + try { + if (!await fse.pathExists(this.#settingsFile)) { + return {enabled: false, domain: 'umbrel.local', httpPort: 80, httpsPort: 443, forceHttps: true} + } + const content = await fse.readFile(this.#settingsFile, 'utf8') + const parsed = yaml.load(content) as CaddySettings + return parsed || {enabled: false, domain: 'umbrel.local', httpPort: 80, httpsPort: 443, forceHttps: true} + } catch (error) { + this.#logger.error('Failed to load Caddy settings', error as Error) + return {enabled: false, domain: 'umbrel.local', httpPort: 80, httpsPort: 443, forceHttps: true} + } + } + + async #saveSettings(settings: CaddySettings): Promise { + try { + await fse.writeFile(this.#settingsFile, yaml.dump(settings), 'utf8') + } catch (error) { + this.#logger.error('Failed to save Caddy settings', error as Error) + } + } + + async #ensureCertificatesExist(settings: CaddySettings) { + const certPath = settings.certificatePath || `${this.#certsPath}/umbrel.crt` + const keyPath = settings.privateKeyPath || `${this.#certsPath}/umbrel.key` + + // Check if certificates already exist + if (await fse.pathExists(certPath) && await fse.pathExists(keyPath)) { + this.#logger.log('Certificates already exist, skipping generation') + return + } + + this.#logger.log('Generating self-signed certificates for Caddy') + + const domain = settings.domain || 'umbrel.local' + const days = 3650 // 10 years + + try { + // Generate private key and certificate using openssl + await $`openssl req -x509 -nodes -days ${days} -newkey rsa:2048 -keyout ${keyPath} -out ${certPath} -subj "/CN=${domain}/O=Umbrel/C=US" -addext "subjectAltName=DNS:${domain},DNS:*.${domain},IP:127.0.0.1"` + + // Set proper permissions + await $`chmod 644 ${certPath}` + await $`chmod 600 ${keyPath}` + + this.#logger.log('Certificates generated successfully') + } catch (error) { + this.#logger.error('Failed to generate certificates', error as Error) + throw new Error('Failed to generate self-signed certificates') + } + } + + async #updateCaddyConfig(settings: CaddySettings) { + const routes = Array.from(this.#routes.values()) + + // Generate Caddyfile + const caddyfile = generateCaddyfile(settings, routes) + await fse.writeFile(this.#caddyfilePath, caddyfile) + this.#logger.log('Caddyfile updated') + + // Also generate JSON config for admin API + const config = buildCaddyConfig(settings, routes) + await fse.writeFile(this.#configPath, JSON.stringify(config, null, 2)) + this.#logger.log('Caddy JSON config updated') + } + + async #restartCaddy() { + this.#logger.log('Restarting Caddy container') + + try { + // Stop existing container if running + await $`docker stop umbrel_caddy 2>/dev/null || true` + await $`docker rm umbrel_caddy 2>/dev/null || true` + + // Start new container using docker-compose + const currentFilename = fileURLToPath(import.meta.url) + const currentDirname = dirname(currentFilename) + const composePath = join(currentDirname, 'legacy-compat/docker-compose.caddy.yml') + + // Check if compose file exists + if (!await fse.pathExists(composePath)) { + this.#logger.log('Caddy docker-compose file not found, skipping container start') + return + } + + await pRetry( + async () => { + await $( + {cwd: this.#umbreld.dataDirectory}, + )`docker compose --project-name umbrel --file ${composePath} up --detach --remove-orphans` + }, + { + retries: 2, + onFailedAttempt: (error) => { + this.#logger.error(`Failed to start Caddy container (attempt ${error.attemptNumber})`, error as Error) + } + } + ) + + this.#logger.log('Caddy container started successfully') + } catch (error) { + this.#logger.error('Failed to restart Caddy container', error as Error) + throw error as Error + } + } + + async #reloadCaddyConfig() { + if (!await this.isEnabled()) { + return + } + + this.#logger.log('Reloading Caddy configuration') + + try { + // Try to reload config via admin API + const config = await fse.readFile(this.#configPath, 'utf8') + + // Get Caddy container IP + try { + const {stdout: caddyIp} = await $`docker inspect -f {{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}} umbrel_caddy 2>/dev/null || echo ""` + + if (caddyIp.trim()) { + // POST config to admin API + await $`curl -s -X POST http://${caddyIp.trim()}:2019/load -H "Content-Type: application/json" -d '${config}'` + this.#logger.log('Caddy config reloaded via admin API') + return + } + } catch { + // Container might not be running, fall through to restart + } + + // Fallback: restart container + this.#logger.log('Admin API not available, restarting Caddy container') + const settings = await this.#loadSettings() + await this.#restartCaddy() + + } catch (error) { + this.#logger.error('Failed to reload Caddy config', error as Error) + // Non-fatal, continue + } + } +} diff --git a/packages/umbreld/source/modules/caddy/schema.ts b/packages/umbreld/source/modules/caddy/schema.ts new file mode 100644 index 000000000..b4711e718 --- /dev/null +++ b/packages/umbreld/source/modules/caddy/schema.ts @@ -0,0 +1,13 @@ +import {z} from 'zod' + +export const CaddySettingsSchema = z.object({ + enabled: z.boolean().optional().default(false), + domain: z.string().optional().default('umbrel.local'), + httpPort: z.number().int().optional().default(80), + httpsPort: z.number().int().optional().default(443), + certificatePath: z.string().optional(), + privateKeyPath: z.string().optional(), + forceHttps: z.boolean().optional().default(true), +}) + +export type CaddySettings = z.infer