140 lines
4.8 KiB
PHP
140 lines
4.8 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.
|
|
*/
|
|
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;
|
|
}
|
|
}
|