clusev/app/Services/DeploymentService.php

153 lines
5.5 KiB
PHP

<?php
namespace App\Services;
use App\Models\Setting;
/**
* Renders the Caddy reverse-proxy site config for the dashboard's own domain.
*
* Clusev ships behind a Caddy container (see docker-compose.prod.yml). In prod the
* proxy address + ACME email are driven by .env (SITE_ADDRESS / ACME_EMAIL) and the
* baked docker/caddy/Caddyfile is mounted read-only. This service lets the operator
* configure the panel domain from the dashboard: it persists domain + email as
* Settings and renders the matching Caddy site block (auto Let's Encrypt TLS), so the
* operator can review and drop it into Caddy's config.
*
* IMPORTANT: the app CANNOT restart or reload Caddy — Caddy runs in a separate
* container. Writing the file is the most the app can do; picking it up is an infra
* step ('caddy reload --config /config/clusev.caddy' inside the caddy container, or a
* stack recreate). renderCaddyConfig()/writeCaddyConfig() never touch the live proxy.
*
* .env IS NEVER WRITTEN. This service only persists Settings (DB) and writes a
* standalone Caddy site block to storage/app/caddy/clusev.caddy. It does not read,
* edit, or rewrite the application's .env in any way — the panel domain is a database
* value, so a change to it never mutates .env. See whyNotEnv() for the operator-facing
* explanation surfaced in the dashboard.
*/
class DeploymentService
{
/** Where writeCaddyConfig() drops the rendered block (app-writable, host-mountable). */
public const CONFIG_FILENAME = 'clusev.caddy';
public function domain(): ?string
{
return Setting::get('dashboard_domain');
}
public function email(): ?string
{
return Setting::get('dashboard_email');
}
public function setDomain(?string $domain): void
{
Setting::put('dashboard_domain', $domain !== null && $domain !== '' ? strtolower(trim($domain)) : null);
}
public function setEmail(?string $email): void
{
Setting::put('dashboard_email', $email !== null && $email !== '' ? trim($email) : null);
}
/** True once a domain is configured — i.e. TLS via Let's Encrypt is in effect. */
public function hasTls(): bool
{
return $this->domain() !== null && $this->domain() !== '';
}
/**
* Render the Caddy site block for the configured domain, mirroring the shape of
* docker/caddy/Caddyfile: a global `email` block + a site address that triggers
* automatic Let's Encrypt TLS, with the same Reverb (/app/*, /apps/*) and app
* upstreams and the same conservative hardening header set.
*
* With a domain set: `https://<domain>` → auto-TLS on :443, HTTP auto-redirect.
* Without a domain: `:80` → plain HTTP on the bare IP (no cert issued).
*/
public function renderCaddyConfig(): string
{
$domain = $this->domain();
$email = $this->email();
$siteAddress = $domain ? 'https://'.$domain : ':80';
// Caddy needs a non-empty ACME email even in bare-IP mode, where no cert is
// issued so the value is simply unused — mirror the compose default.
$acmeEmail = $email ?: 'admin@localhost';
return <<<CADDY
# Clusev reverse proxy — generated from the dashboard (System → Domain & TLS).
# Mirrors docker/caddy/Caddyfile. Reverb rides /app/* + /apps/* on the same address.
#
# https://<domain> -> auto Let's Encrypt, :443, HTTP auto-redirect
# :80 -> plain HTTP on the bare IP, no cert
{
\temail {$acmeEmail}
}
{$siteAddress} {
\t@reverb path /app/* /apps/*
\treverse_proxy @reverb reverb:8080
\treverse_proxy app:80
\tencode zstd gzip
\theader {
\t\tX-Content-Type-Options nosniff
\t\tX-Frame-Options DENY
\t\tReferrer-Policy strict-origin-when-cross-origin
\t\t-Server
\t}
}
CADDY;
}
/**
* Write the rendered site config into the app's storage dir. This is a staging
* file the operator can mount/copy into Caddy's /config — it does NOT reload the
* live proxy (separate container). Returns the absolute path written.
*/
public function writeCaddyConfig(): string
{
$dir = storage_path('app/caddy');
if (! is_dir($dir)) {
@mkdir($dir, 0775, true);
}
$path = $dir.'/'.self::CONFIG_FILENAME;
file_put_contents($path, $this->renderCaddyConfig().PHP_EOL);
return $path;
}
/** Absolute path writeCaddyConfig() targets (no write performed). */
public function configPath(): string
{
return storage_path('app/caddy/'.self::CONFIG_FILENAME);
}
/**
* Operator note: the app cannot reload Caddy. After writing, reload is an infra
* step inside the caddy container.
*/
public function reloadHint(): string
{
return 'caddy reload --config /config/'.self::CONFIG_FILENAME;
}
/**
* German operator explanation: the panel domain lives in the DATABASE (Settings),
* NOT in .env — and the app never overwrites .env. Surfaced in the dashboard to
* clear up the worry that every domain change rewrites .env. Changes take effect
* after a Caddy reload (the proxy runs in its own container).
*/
public function whyNotEnv(): string
{
return 'Die Domain liegt in der Datenbank, nicht in der .env — die .env wird nie '
.'überschrieben. Änderungen greifen nach einem Caddy-Reload (`caddy reload`).';
}
}