From 0a0e3192d8843e0c0e1c919ee4edd6c4c3c83f0e Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Mon, 25 May 2026 09:44:38 +0100 Subject: [PATCH 1/6] apache wip --- app/Actions/Site/UpdateBasicAuth.php | 7 +- .../Webserver/AbstractGenerateConfig.php | 11 +- .../Webserver/GenerateApacheConfig.php | 183 ++++++++++++ .../Controllers/SiteSettingController.php | 13 +- app/Models/Site.php | 7 +- app/Providers/ServiceTypeServiceProvider.php | 15 + app/Services/Webserver/Apache.php | 263 ++++++++++++++++++ app/Services/Webserver/Caddy.php | 8 +- app/Services/Webserver/Nginx.php | 8 +- app/Services/Webserver/Webserver.php | 3 + resources/js/pages/site-settings/index.tsx | 2 +- .../apache/create-custom-ssl.blade.php | 13 + .../apache/create-letsencrypt-ssl.blade.php | 3 + .../webserver/apache/create-path.blade.php | 5 + .../webserver/apache/create-vhost.blade.php | 11 + .../webserver/apache/default-vhost.blade.php | 15 + .../webserver/apache/delete-site.blade.php | 9 + .../webserver/apache/get-vhost.blade.php | 1 + .../webserver/apache/install-apache.blade.php | 33 +++ .../apache/uninstall-apache.blade.php | 12 + .../services/webserver/apache/vhost.mustache | 125 +++++++++ tests/Feature/SiteSettings/BasicAuthTest.php | 25 ++ .../SiteSettings/VhostTemplateTest.php | 53 ++++ tests/Unit/Actions/Service/InstallTest.php | 24 ++ .../Webserver/GenerateApacheConfigTest.php | 111 ++++++++ .../Services/Webserver/DeploySplashTest.php | 32 +++ 26 files changed, 971 insertions(+), 21 deletions(-) create mode 100644 app/Actions/Webserver/GenerateApacheConfig.php create mode 100644 app/Services/Webserver/Apache.php create mode 100644 resources/views/ssh/services/webserver/apache/create-custom-ssl.blade.php create mode 100644 resources/views/ssh/services/webserver/apache/create-letsencrypt-ssl.blade.php create mode 100644 resources/views/ssh/services/webserver/apache/create-path.blade.php create mode 100644 resources/views/ssh/services/webserver/apache/create-vhost.blade.php create mode 100644 resources/views/ssh/services/webserver/apache/default-vhost.blade.php create mode 100644 resources/views/ssh/services/webserver/apache/delete-site.blade.php create mode 100644 resources/views/ssh/services/webserver/apache/get-vhost.blade.php create mode 100644 resources/views/ssh/services/webserver/apache/install-apache.blade.php create mode 100644 resources/views/ssh/services/webserver/apache/uninstall-apache.blade.php create mode 100644 resources/views/ssh/services/webserver/apache/vhost.mustache create mode 100644 tests/Feature/SiteSettings/VhostTemplateTest.php create mode 100644 tests/Unit/Actions/Webserver/GenerateApacheConfigTest.php diff --git a/app/Actions/Site/UpdateBasicAuth.php b/app/Actions/Site/UpdateBasicAuth.php index a628ee59b..769b25564 100644 --- a/app/Actions/Site/UpdateBasicAuth.php +++ b/app/Actions/Site/UpdateBasicAuth.php @@ -4,6 +4,7 @@ use App\Helpers\Apr1Hasher; use App\Models\Site; +use App\Services\Webserver\Apache; use App\Services\Webserver\Nginx; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; @@ -11,8 +12,6 @@ class UpdateBasicAuth { - private const NGINX_AUTH_DIR = '/etc/nginx/auth'; - /** * @param array $input */ @@ -111,7 +110,7 @@ private function validate(Site $site, array $input): void */ private function writeAuthFile(Site $site, array $users): void { - if ($site->webserver()::id() !== Nginx::id()) { + if (! in_array($site->webserver()::id(), [Nginx::id(), Apache::id()], true)) { return; } @@ -132,7 +131,7 @@ private function writeAuthFile(Site $site, array $users): void $site->server->ssh()->exec( view('ssh.services.webserver.nginx.write-basic-auth-file', [ - 'dir' => self::NGINX_AUTH_DIR, + 'dir' => dirname($path), 'path' => $path, 'lines' => $lines, 'userCount' => count($users), diff --git a/app/Actions/Webserver/AbstractGenerateConfig.php b/app/Actions/Webserver/AbstractGenerateConfig.php index c2ce7d2a1..cc9b1774e 100644 --- a/app/Actions/Webserver/AbstractGenerateConfig.php +++ b/app/Actions/Webserver/AbstractGenerateConfig.php @@ -26,7 +26,16 @@ public function generate(Site $site, ?string $template = null): string 'escape' => fn ($value) => $value, ]); - return format_webserver_config($engine->render($template, $data)); + return $this->formatConfig($engine->render($template, $data)); + } + + /** + * Format the rendered config. Brace-indented webservers use the default; + * tag-based webservers (e.g. Apache) override this. + */ + protected function formatConfig(string $config): string + { + return format_webserver_config($config); } /** diff --git a/app/Actions/Webserver/GenerateApacheConfig.php b/app/Actions/Webserver/GenerateApacheConfig.php new file mode 100644 index 000000000..5ae8c5bc2 --- /dev/null +++ b/app/Actions/Webserver/GenerateApacheConfig.php @@ -0,0 +1,183 @@ + Collected during server block building */ + private array $forceSSLDomains = []; + + public function defaultTemplate(): string + { + return file_get_contents(resource_path('views/ssh/services/webserver/apache/vhost.mustache')); + } + + protected function buildServerBlockKeys(bool $hasSsl, string $sslCertPath, string $sslKeyPath, Site $site): array + { + return [ + 'listen_80' => $hasSsl ? ! $site->force_ssl : true, + 'listen_443' => $hasSsl, + 'ssl_certificate_path' => $sslCertPath, + 'ssl_certificate_key_path' => $sslKeyPath, + ]; + } + + protected function buildPhpSocket(Site $site): string + { + if ($site->isIsolated()) { + return "unix:/run/php/php{$site->php_version}-fpm-{$site->user}.sock|fcgi://localhost"; + } + + return "unix:/var/run/php/php{$site->php_version}-fpm.sock|fcgi://localhost"; + } + + protected function buildLoadBalancerData(Site $site): array + { + $balancerName = preg_replace('/[^A-Za-z0-9]/', '', $site->domain).'_balancer'; + $isLoadBalancer = $site->type === 'load-balancer'; + + $data = [ + 'balancer_name' => $balancerName, + 'lb_method' => 'byrequests', + 'lb_servers' => [], + ]; + + if ($isLoadBalancer) { + $method = $site->type_data['method'] ?? LoadBalancerMethod::ROUND_ROBIN->value; + $data['lb_method'] = match ($method) { + LoadBalancerMethod::LEAST_CONNECTIONS->value => 'bybusyness', + default => 'byrequests', + }; + + $data['lb_servers'] = $site->loadBalancerServers->map(fn ($s) => [ + 'address' => $s->ip.':'.$s->port, + ])->all(); + } + + return $data; + } + + protected function buildRedirectEntry(object $redirect, bool $isProxy): array + { + return [ + 'from' => $redirect->from, + 'to' => $redirect->to, + 'mode' => $redirect->mode, + 'is_proxy' => $isProxy, + ]; + } + + protected function transformDomains(array $domains, bool $httpOnly): array + { + return $domains; + } + + protected function enrichServerBlock(array $block, array $data): array + { + $names = array_map(fn (array $domain) => $domain['name'], $block['domains']); + + $block['server_name'] = $names[0] ?? $data['primary_domain']; + $block['server_aliases'] = array_map(fn (string $name) => ['name' => $name], array_slice($names, 1)); + $block['balancer_name'] = $data['balancer_name']; + $block['lb_method'] = $data['lb_method']; + $block['lb_servers'] = $data['lb_servers']; + + return $block; + } + + protected function buildRedirectBlock(HostedDomain $hd, string $primaryDomain, Site $site): array + { + $hasSsl = $hd->ssl_id && $hd->ssl; + $redirectScheme = $site->ssl_enabled ? 'https' : 'http'; + + return [ + 'listen_80' => true, + 'listen_443' => (bool) $hasSsl, + 'ssl_certificate_path' => $hasSsl ? $hd->ssl->certificate_path : '', + 'ssl_certificate_key_path' => $hasSsl ? $hd->ssl->pk_path : '', + 'server_name' => $hd->domain, + 'redirect_target' => $primaryDomain, + 'redirect_scheme' => $redirectScheme, + ]; + } + + protected function buildData(Site $site): array + { + $this->forceSSLDomains = []; + + return parent::buildData($site); + } + + protected function finalizeData(array $data, Site $site): array + { + if ($site->force_ssl && $site->ssl_enabled) { + foreach ($data['server_blocks'] as $block) { + if ($block['listen_443'] ?? false) { + foreach ($block['domains'] as $domain) { + $this->forceSSLDomains[] = $domain['name']; + } + } + } + } + + $this->forceSSLDomains = array_values(array_unique($this->forceSSLDomains)); + + $data['vhosts'] = $this->expandVhosts($data['server_blocks']); + $data['redirect_vhosts'] = $this->expandVhosts($data['redirect_blocks']); + + $data['has_force_ssl_redirect'] = ! empty($this->forceSSLDomains); + $data['force_ssl_server_name'] = $this->forceSSLDomains[0] ?? ''; + $data['force_ssl_aliases'] = array_map(fn (string $name) => ['name' => $name], array_slice($this->forceSSLDomains, 1)); + $data['force_ssl_root'] = $site->getWebDirectoryPath(); + + return $data; + } + + protected function formatConfig(string $config): string + { + $lines = explode("\n", trim($config)); + $formatted = []; + $lastWasEmpty = false; + + foreach ($lines as $line) { + $line = rtrim($line); + $isEmpty = trim($line) === ''; + + if ($isEmpty && $lastWasEmpty) { + continue; + } + + $formatted[] = $line; + $lastWasEmpty = $isEmpty; + } + + return implode("\n", $formatted)."\n"; + } + + /** + * Expand brace-style server blocks into one entry per listen port. + * + * @param array> $blocks + * @return array> + */ + private function expandVhosts(array $blocks): array + { + $vhosts = []; + + foreach ($blocks as $block) { + if ($block['listen_80'] ?? false) { + $vhosts[] = ['listen_port' => 80, 'has_ssl' => false] + $block; + } + + if ($block['listen_443'] ?? false) { + $vhosts[] = ['listen_port' => 443, 'has_ssl' => true] + $block; + } + } + + return $vhosts; + } +} diff --git a/app/Http/Controllers/SiteSettingController.php b/app/Http/Controllers/SiteSettingController.php index 981b96df8..840f45b9a 100644 --- a/app/Http/Controllers/SiteSettingController.php +++ b/app/Http/Controllers/SiteSettingController.php @@ -12,8 +12,6 @@ use App\Actions\Site\UpdateVhostGeneration; use App\Actions\Site\UpdateVhostTemplate; use App\Actions\Site\UpdateWebDirectory; -use App\Actions\Webserver\GenerateCaddyConfig; -use App\Actions\Webserver\GenerateNginxConfig; use App\Exceptions\SSHError; use App\Http\Resources\SourceControlResource; use App\Models\Server; @@ -128,10 +126,8 @@ public function vhostTemplate(Server $server, Site $site): JsonResponse { $this->authorize('update', [$site, $server]); - $generator = $this->getVhostGenerator($site); - return response()->json([ - 'template' => $site->vhost_template ?? $generator->defaultTemplate(), + 'template' => $site->vhost_template ?? $site->webserver()->configGenerator()->defaultTemplate(), ]); } @@ -180,13 +176,6 @@ public function updateVhostGeneration(Request $request, Server $server, Site $si return back()->with('success', 'VHost generation setting updated successfully.'); } - private function getVhostGenerator(Site $site): GenerateNginxConfig|GenerateCaddyConfig - { - return $site->webserver()::id() === 'caddy' - ? app(GenerateCaddyConfig::class) - : app(GenerateNginxConfig::class); - } - #[Post('/force-ssl/enable', name: 'site-settings.enable-force-ssl')] public function enableForceSsl(Server $server, Site $site): RedirectResponse { diff --git a/app/Models/Site.php b/app/Models/Site.php index 7224fbd8b..b59b69440 100755 --- a/app/Models/Site.php +++ b/app/Models/Site.php @@ -8,6 +8,7 @@ use App\Exceptions\SourceControlIsNotConnected; use App\Exceptions\SSHError; use App\Jobs\SSL\DeleteSiteSslJob; +use App\Services\Webserver\Apache; use App\Services\Webserver\Webserver; use App\SiteFeatures\ActionInterface; use App\SiteTypes\SiteType; @@ -610,7 +611,11 @@ public function basePath(): string public function htpasswdPath(): string { - return '/etc/nginx/auth/site-'.$this->id.'.htpasswd'; + $dir = $this->server->webserver()?->name === Apache::id() + ? '/etc/apache2/auth' + : '/etc/nginx/auth'; + + return $dir.'/site-'.$this->id.'.htpasswd'; } public function getDeployKeyName(): string diff --git a/app/Providers/ServiceTypeServiceProvider.php b/app/Providers/ServiceTypeServiceProvider.php index 1aef4f1be..fbcd5f6e0 100644 --- a/app/Providers/ServiceTypeServiceProvider.php +++ b/app/Providers/ServiceTypeServiceProvider.php @@ -14,6 +14,7 @@ use App\Services\ProcessManager\Supervisor; use App\Services\Redis\Redis; use App\Services\Valkey\Valkey; +use App\Services\Webserver\Apache; use App\Services\Webserver\Caddy; use App\Services\Webserver\Nginx; use Illuminate\Support\ServiceProvider; @@ -56,6 +57,20 @@ private function webservers(): void ->handler(Caddy::class) ->data(['creates_site_ssls' => false]) ->register(); + + RegisterServiceType::make(Apache::id()) + ->type(Apache::type()) + ->label('Apache (beta)') + ->handler(Apache::class) + ->data(['creates_site_ssls' => true]) + ->configPaths([ + [ + 'name' => 'apache2.conf', + 'path' => '/etc/apache2/apache2.conf', + 'sudo' => true, + ], + ]) + ->register(); } private function databases(): void diff --git a/app/Services/Webserver/Apache.php b/app/Services/Webserver/Apache.php new file mode 100644 index 000000000..a6bc36c7e --- /dev/null +++ b/app/Services/Webserver/Apache.php @@ -0,0 +1,263 @@ +service->server->ssh() + ->setLog($this->service->log) + ->exec( + view('ssh.services.webserver.apache.install-apache', [ + 'user' => $this->service->server->getSshUser(), + ]), + 'install-apache' + ); + + $this->deploySplash(); + + $this->service->server->systemd()->restart('apache2'); + event('service.installed', $this->service); + $this->service->server->os()->cleanup(); + } + + /** + * @throws SSHError + */ + public function uninstall(): void + { + $this->service->server->ssh()->exec( + view('ssh.services.webserver.apache.uninstall-apache'), + 'uninstall-apache' + ); + event('service.uninstalled', $this->service); + $this->service->server->os()->cleanup(); + } + + public function configGenerator(): AbstractGenerateConfig + { + return app(GenerateApacheConfig::class); + } + + public function generateVhost(Site $site, ?string $template = null): string + { + return $this->configGenerator()->generate($site, $template); + } + + /** + * @throws SSHError + */ + public function createVHost(Site $site): void + { + $ssh = $this->service->server->ssh($site->user); + + $ssh->exec( + view('ssh.services.webserver.apache.create-path', [ + 'path' => $site->path, + ]), + 'create-path', + $site->id + ); + + $this->service->server->ssh()->write( + '/etc/apache2/sites-available/'.$site->domain.'.conf', + $this->generateVhost($site), + 'root' + ); + + $this->service->server->ssh()->exec( + view('ssh.services.webserver.apache.create-vhost', [ + 'domain' => $site->domain, + ]), + 'create-vhost', + $site->id + ); + } + + /** + * @throws SSHError + */ + public function updateVHost(Site $site, ?string $vhost = null, bool $restart = false): void + { + if (! $vhost && ! $site->vhost_generation_enabled) { + return; + } + + if (! $vhost) { + $vhost = $this->generateVhost($site); + } + + $this->service->server->ssh()->write( + '/etc/apache2/sites-available/'.$site->domain.'.conf', + $vhost, + 'root' + ); + + if ($restart) { + $this->service->server->systemd()->restart('apache2'); + + return; + } + + $this->service->server->systemd()->reload('apache2'); + } + + /** + * @throws SSHError + */ + public function getVHost(Site $site): string + { + return $this->service->server->ssh()->exec( + view('ssh.services.webserver.apache.get-vhost', [ + 'domain' => $site->domain, + ]), + ); + } + + /** + * @throws SSHError + */ + public function deleteSite(Site $site): void + { + $this->service->server->ssh()->exec( + view('ssh.services.webserver.nginx.remove-basic-auth-file', [ + 'path' => $site->htpasswdPath(), + ]), + 'remove-basic-auth-file', + $site->id + ); + $this->service->server->ssh()->exec( + view('ssh.services.webserver.apache.delete-site', [ + 'domain' => $site->domain, + 'path' => $site->basePath(), + ]), + 'delete-vhost', + $site->id + ); + $this->service->restart(); + } + + /** + * @throws SSHError + */ + public function setupSSL(Ssl $ssl): void + { + $domains = ''; + foreach ($ssl->getDomains() as $domain) { + $domains .= ' -d '.$domain; + } + $command = view('ssh.services.webserver.apache.create-letsencrypt-ssl', [ + 'email' => $ssl->email, + 'name' => $ssl->id, + 'domains' => $domains, + 'webroot' => $ssl->site->getWebDirectoryPath(), + ]); + if ($ssl->type == 'custom') { + $ssl->certificate_path = '/etc/ssl/'.$ssl->id.'/cert.pem'; + $ssl->pk_path = '/etc/ssl/'.$ssl->id.'/privkey.pem'; + $ssl->save(); + $command = view('ssh.services.webserver.apache.create-custom-ssl', [ + 'path' => dirname($ssl->certificate_path), + 'certificate' => $ssl->certificate, + 'pk' => $ssl->pk, + 'certificatePath' => $ssl->certificate_path, + 'pkPath' => $ssl->pk_path, + ]); + } + $result = $this->service->server->ssh()->setLog($ssl->log)->exec( + $command, + 'create-ssl', + $ssl->site_id + ); + if (! $ssl->validateSetup($result)) { + throw new SSLCreationException; + } + } + + /** + * @throws Throwable + */ + public function removeSSL(Ssl $ssl): void + { + if ($ssl->certificate_path) { + $this->service->server->ssh()->exec( + 'sudo rm -rf '.dirname($ssl->certificate_path), + 'remove-ssl', + $ssl->site_id + ); + } + + $this->updateVHost($ssl->site); + } + + /** + * @throws SSHError + */ + public function deploySplash(): void + { + $ssh = $this->service->server->ssh(); + + $ssh->exec( + 'sudo a2dissite 000-default default-ssl 2>/dev/null || true', + 'disable-os-default-site' + ); + + $ssh->exec( + 'sudo mkdir -p /var/www/vito-splash', + 'create-vito-splash-dir' + ); + + $ssh->write( + '/var/www/vito-splash/index.html', + view('ssh.services.webserver.vito-splash'), + 'root' + ); + + $ssh->write( + '/etc/apache2/sites-available/000-vito-default.conf', + view('ssh.services.webserver.apache.default-vhost'), + 'root' + ); + + $ssh->exec( + 'sudo a2ensite 000-vito-default.conf', + 'enable-default-vhost' + ); + } + + public function version(): string + { + $version = $this->service->server->ssh()->exec( + 'apachectl -v 2>&1 | grep -oE \'Apache/[0-9]+\.[0-9]+\.[0-9]+\' | cut -d/ -f2' + ); + + return trim($version); + } +} diff --git a/app/Services/Webserver/Caddy.php b/app/Services/Webserver/Caddy.php index ff474338a..f328ad743 100755 --- a/app/Services/Webserver/Caddy.php +++ b/app/Services/Webserver/Caddy.php @@ -2,6 +2,7 @@ namespace App\Services\Webserver; +use App\Actions\Webserver\AbstractGenerateConfig; use App\Actions\Webserver\GenerateCaddyConfig; use App\Enums\SslMethod; use App\Exceptions\SSHError; @@ -128,9 +129,14 @@ public function createVHost(Site $site): void ); } + public function configGenerator(): AbstractGenerateConfig + { + return app(GenerateCaddyConfig::class); + } + public function generateVhost(Site $site, ?string $template = null): string { - return app(GenerateCaddyConfig::class)->generate($site, $template); + return $this->configGenerator()->generate($site, $template); } /** diff --git a/app/Services/Webserver/Nginx.php b/app/Services/Webserver/Nginx.php index f6456e40a..f7eb68576 100755 --- a/app/Services/Webserver/Nginx.php +++ b/app/Services/Webserver/Nginx.php @@ -2,6 +2,7 @@ namespace App\Services\Webserver; +use App\Actions\Webserver\AbstractGenerateConfig; use App\Actions\Webserver\GenerateNginxConfig; use App\Exceptions\SSHError; use App\Exceptions\SSLCreationException; @@ -71,9 +72,14 @@ public function uninstall(): void $this->service->server->os()->cleanup(); } + public function configGenerator(): AbstractGenerateConfig + { + return app(GenerateNginxConfig::class); + } + public function generateVhost(Site $site, ?string $template = null): string { - return app(GenerateNginxConfig::class)->generate($site, $template); + return $this->configGenerator()->generate($site, $template); } /** diff --git a/app/Services/Webserver/Webserver.php b/app/Services/Webserver/Webserver.php index 45b316403..4980b855a 100755 --- a/app/Services/Webserver/Webserver.php +++ b/app/Services/Webserver/Webserver.php @@ -2,6 +2,7 @@ namespace App\Services\Webserver; +use App\Actions\Webserver\AbstractGenerateConfig; use App\Enums\SslMethod; use App\Models\Site; use App\Models\Ssl; @@ -9,6 +10,8 @@ interface Webserver extends ServiceInterface { + public function configGenerator(): AbstractGenerateConfig; + public function generateVhost(Site $site, ?string $template = null): string; public function createVHost(Site $site): void; diff --git a/resources/js/pages/site-settings/index.tsx b/resources/js/pages/site-settings/index.tsx index 939c79da7..7d89407d3 100644 --- a/resources/js/pages/site-settings/index.tsx +++ b/resources/js/pages/site-settings/index.tsx @@ -123,7 +123,7 @@ export default function Databases() { - {(page.props.site.webserver === 'nginx' || page.props.site.webserver === 'caddy') && ( + {(page.props.site.webserver === 'nginx' || page.props.site.webserver === 'caddy' || page.props.site.webserver === 'apache') && ( <>
diff --git a/resources/views/ssh/services/webserver/apache/create-custom-ssl.blade.php b/resources/views/ssh/services/webserver/apache/create-custom-ssl.blade.php new file mode 100644 index 000000000..f9c182704 --- /dev/null +++ b/resources/views/ssh/services/webserver/apache/create-custom-ssl.blade.php @@ -0,0 +1,13 @@ +if ! sudo mkdir -p {{ $path }}; then + echo 'VITO_SSH_ERROR' && exit 1 +fi + +if ! echo "{{ $certificate }}" | sudo tee {{ $certificatePath }} > /dev/null; then + echo 'VITO_SSH_ERROR' && exit 1 +fi + +if ! echo "{{ $pk }}" | sudo tee {{ $pkPath }} > /dev/null; then + echo 'VITO_SSH_ERROR' && exit 1 +fi + +echo "Successfully received certificate" diff --git a/resources/views/ssh/services/webserver/apache/create-letsencrypt-ssl.blade.php b/resources/views/ssh/services/webserver/apache/create-letsencrypt-ssl.blade.php new file mode 100644 index 000000000..897007efd --- /dev/null +++ b/resources/views/ssh/services/webserver/apache/create-letsencrypt-ssl.blade.php @@ -0,0 +1,3 @@ +if ! sudo certbot certonly --webroot -w {{ $webroot }} --force-renewal --noninteractive --agree-tos --cert-name {{ $name }} -m {{ $email }} {{ $domains }} --verbose; then + echo 'VITO_SSH_ERROR' && exit 1 +fi diff --git a/resources/views/ssh/services/webserver/apache/create-path.blade.php b/resources/views/ssh/services/webserver/apache/create-path.blade.php new file mode 100644 index 000000000..9c26e8a56 --- /dev/null +++ b/resources/views/ssh/services/webserver/apache/create-path.blade.php @@ -0,0 +1,5 @@ +export DEBIAN_FRONTEND=noninteractive + +mkdir -p {{ $path }} + +chmod -R 755 {{ $path }} diff --git a/resources/views/ssh/services/webserver/apache/create-vhost.blade.php b/resources/views/ssh/services/webserver/apache/create-vhost.blade.php new file mode 100644 index 000000000..e7769e28e --- /dev/null +++ b/resources/views/ssh/services/webserver/apache/create-vhost.blade.php @@ -0,0 +1,11 @@ +if ! sudo a2ensite {!! escapeshellarg($domain.'.conf') !!} > /dev/null; then + echo 'VITO_SSH_ERROR' && exit 1 +fi + +if ! sudo apachectl configtest; then + echo 'VITO_SSH_ERROR' && exit 1 +fi + +if ! sudo service apache2 reload; then + echo 'VITO_SSH_ERROR' && exit 1 +fi diff --git a/resources/views/ssh/services/webserver/apache/default-vhost.blade.php b/resources/views/ssh/services/webserver/apache/default-vhost.blade.php new file mode 100644 index 000000000..d2b335e3c --- /dev/null +++ b/resources/views/ssh/services/webserver/apache/default-vhost.blade.php @@ -0,0 +1,15 @@ + + DocumentRoot /var/www/vito-splash + DirectoryIndex index.html + + + Options -Indexes +FollowSymLinks + AllowOverride None + Require all granted + + + Header always set X-Content-Type-Options "nosniff" + Header always set X-Frame-Options "DENY" + Header always set Referrer-Policy "strict-origin-when-cross-origin" + Header always set Cache-Control "public, max-age=3600" + diff --git a/resources/views/ssh/services/webserver/apache/delete-site.blade.php b/resources/views/ssh/services/webserver/apache/delete-site.blade.php new file mode 100644 index 000000000..b363a9d58 --- /dev/null +++ b/resources/views/ssh/services/webserver/apache/delete-site.blade.php @@ -0,0 +1,9 @@ +sudo a2dissite {!! escapeshellarg($domain.'.conf') !!} > /dev/null 2>&1 || true + +sudo rm -f /etc/apache2/sites-available/{{ $domain }}.conf + +sudo rm -rf {{ $path }} + +sudo service apache2 reload || true + +echo "Site deleted" diff --git a/resources/views/ssh/services/webserver/apache/get-vhost.blade.php b/resources/views/ssh/services/webserver/apache/get-vhost.blade.php new file mode 100644 index 000000000..ef3a8d88b --- /dev/null +++ b/resources/views/ssh/services/webserver/apache/get-vhost.blade.php @@ -0,0 +1 @@ +cat /etc/apache2/sites-available/{{ $domain }}.conf diff --git a/resources/views/ssh/services/webserver/apache/install-apache.blade.php b/resources/views/ssh/services/webserver/apache/install-apache.blade.php new file mode 100644 index 000000000..1e50ce4c1 --- /dev/null +++ b/resources/views/ssh/services/webserver/apache/install-apache.blade.php @@ -0,0 +1,33 @@ +export DEBIAN_FRONTEND=noninteractive + +sudo apt-get install apache2 -y + +# install certbot +sudo apt-get install certbot python3-certbot-apache -y + +# run PHP through PHP-FPM, not mod_php +sudo a2dismod mpm_prefork 2>/dev/null || true +sudo a2enmod mpm_event + +sudo a2enmod proxy proxy_http proxy_fcgi proxy_wstunnel proxy_balancer lbmethod_byrequests lbmethod_bybusyness rewrite ssl headers setenvif + +# run apache as the deploy user so it can read site files and FPM sockets +sudo sed -i "s/^export APACHE_RUN_USER=.*/export APACHE_RUN_USER={{ $user }}/" /etc/apache2/envvars +sudo sed -i "s/^export APACHE_RUN_GROUP=.*/export APACHE_RUN_GROUP={{ $user }}/" /etc/apache2/envvars + +# silence the global ServerName warning +echo "ServerName localhost" | sudo tee /etc/apache2/conf-available/vito.conf > /dev/null +sudo a2enconf vito + +# the packaged unit's PrivateTmp namespace breaks `systemctl reload` (226/NAMESPACE); +# ProtectHome would also hide sites served from /home. Disable both so reloads work. +sudo mkdir -p /etc/systemd/system/apache2.service.d +sudo tee /etc/systemd/system/apache2.service.d/vito-override.conf > /dev/null <<'EOF' +[Service] +PrivateTmp=false +ProtectHome=false +EOF +sudo systemctl daemon-reload + +sudo mkdir -p /etc/apache2/sites-available +sudo mkdir -p /etc/apache2/sites-enabled diff --git a/resources/views/ssh/services/webserver/apache/uninstall-apache.blade.php b/resources/views/ssh/services/webserver/apache/uninstall-apache.blade.php new file mode 100644 index 000000000..8690ce008 --- /dev/null +++ b/resources/views/ssh/services/webserver/apache/uninstall-apache.blade.php @@ -0,0 +1,12 @@ +sudo service apache2 stop + +sudo DEBIAN_FRONTEND=noninteractive apt-get purge apache2 apache2-utils apache2-bin apache2-data -y + +sudo DEBIAN_FRONTEND=noninteractive apt-get autoremove -y + +sudo rm -rf /etc/apache2 +sudo rm -rf /var/log/apache2 +sudo rm -rf /var/www/vito-splash +sudo rm -rf /etc/systemd/system/apache2.service.d + +sudo systemctl daemon-reload diff --git a/resources/views/ssh/services/webserver/apache/vhost.mustache b/resources/views/ssh/services/webserver/apache/vhost.mustache new file mode 100644 index 000000000..6d40d9490 --- /dev/null +++ b/resources/views/ssh/services/webserver/apache/vhost.mustache @@ -0,0 +1,125 @@ +{{#has_force_ssl_redirect}} + + ServerName {{force_ssl_server_name}} +{{#force_ssl_aliases}} + ServerAlias {{name}} +{{/force_ssl_aliases}} + DocumentRoot {{force_ssl_root}} + + RewriteEngine On + RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/ + RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] + +{{/has_force_ssl_redirect}} + +{{#vhosts}} + + ServerName {{server_name}} +{{#server_aliases}} + ServerAlias {{name}} +{{/server_aliases}} + DocumentRoot {{root}} + +{{#has_ssl}} + SSLEngine on + SSLCertificateFile {{ssl_certificate_path}} + SSLCertificateKeyFile {{ssl_certificate_key_path}} + +{{/has_ssl}} + Header always set X-Frame-Options "SAMEORIGIN" + Header always set X-Content-Type-Options "nosniff" + + ErrorLog ${APACHE_LOG_DIR}/{{primary_domain}}-error.log + CustomLog ${APACHE_LOG_DIR}/{{primary_domain}}-access.log combined + + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + +{{#basic_auth_enabled}} + + AuthType Basic + AuthName "{{basic_auth_realm}}" + AuthUserFile {{basic_auth_file}} + Require valid-user + + +{{/basic_auth_enabled}} + + Require all granted + + +{{#is_php}} + DirectoryIndex index.php index.html + + + SetHandler "proxy:{{php_socket}}" + + + RewriteEngine On + RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/ + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [L] +{{/is_php}} +{{#is_reverse_proxy}} + ProxyPreserveHost On + ProxyPass /.well-known/acme-challenge/ ! + ProxyPass / http://localhost:{{port}}/ + ProxyPassReverse / http://localhost:{{port}}/ + + RewriteEngine On + RewriteCond %{HTTP:Upgrade} =websocket [NC] + RewriteRule ^/?(.*) ws://localhost:{{port}}/$1 [P,L] +{{/is_reverse_proxy}} +{{#is_load_balancer}} + +{{#lb_servers}} + BalancerMember "http://{{address}}" +{{/lb_servers}} + ProxySet lbmethod={{lb_method}} + + + ProxyPreserveHost On + ProxyPass /.well-known/acme-challenge/ ! + ProxyPass / "balancer://{{balancer_name}}/" + ProxyPassReverse / "balancer://{{balancer_name}}/" +{{/is_load_balancer}} +{{#is_octane}} + ProxyPreserveHost On + + RewriteEngine On + RewriteCond %{HTTP:Upgrade} =websocket [NC] + RewriteRule ^/?(.*) ws://127.0.0.1:{{octane_port}}/$1 [P,L] + RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/ + RewriteCond %{HTTP:Upgrade} !=websocket [NC] + RewriteRule ^/?(.*) http://127.0.0.1:{{octane_port}}/$1 [P,L] + + ProxyPassReverse / http://127.0.0.1:{{octane_port}}/ +{{/is_octane}} +{{#redirects}} +{{#is_proxy}} + ProxyPass "{{from}}" "{{to}}" + ProxyPassReverse "{{from}}" "{{to}}" +{{/is_proxy}} +{{^is_proxy}} + Redirect {{mode}} {{from}} {{to}} +{{/is_proxy}} +{{/redirects}} + + +{{/vhosts}} +{{#redirect_vhosts}} + + ServerName {{server_name}} +{{#has_ssl}} + SSLEngine on + SSLCertificateFile {{ssl_certificate_path}} + SSLCertificateKeyFile {{ssl_certificate_key_path}} +{{/has_ssl}} + Redirect permanent / {{redirect_scheme}}://{{redirect_target}}/ + + +{{/redirect_vhosts}} diff --git a/tests/Feature/SiteSettings/BasicAuthTest.php b/tests/Feature/SiteSettings/BasicAuthTest.php index 8639eb858..25f195b2b 100644 --- a/tests/Feature/SiteSettings/BasicAuthTest.php +++ b/tests/Feature/SiteSettings/BasicAuthTest.php @@ -8,6 +8,7 @@ use App\Models\HostedDomain; use App\Models\Site; use App\Models\User; +use App\Services\Webserver\Apache; use App\Services\Webserver\Caddy; use App\Services\Webserver\Nginx; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -143,6 +144,30 @@ public function test_validation_rejects_new_user_without_password(): void ])->assertSessionHasErrors(); } + public function test_apache_server_writes_htpasswd_file(): void + { + $this->server->webserver()?->update([ + 'name' => Apache::id(), + ]); + + SSH::fake(); + $this->actingAs($this->user); + + $this->patch(route('site-settings.update-basic-auth', [ + 'server' => $this->server->id, + 'site' => $this->site, + ]), [ + 'enabled' => true, + 'users' => [ + ['username' => 'alice', 'password' => 'secret123'], + ], + ]) + ->assertRedirect() + ->assertSessionDoesntHaveErrors(); + + SSH::assertExecutedContains('/etc/apache2/auth/site-'.$this->site->id.'.htpasswd'); + } + public function test_caddy_server_does_not_write_htpasswd_file(): void { $this->server->webserver()?->update([ diff --git a/tests/Feature/SiteSettings/VhostTemplateTest.php b/tests/Feature/SiteSettings/VhostTemplateTest.php new file mode 100644 index 000000000..8bcbfd7d5 --- /dev/null +++ b/tests/Feature/SiteSettings/VhostTemplateTest.php @@ -0,0 +1,53 @@ +actingAs($this->user); + + $this->get(route('site-settings.vhost-template', [ + 'server' => $this->server->id, + 'site' => $this->site, + ])) + ->assertOk() + ->assertJson(fn ($json) => $json->where('template', fn (string $t) => str_contains($t, 'server {') && str_contains($t, 'fastcgi_pass'))); + } + + public function test_default_template_matches_apache_webserver(): void + { + $this->server->webserver()?->update(['name' => Apache::id()]); + + $this->actingAs($this->user); + + $this->get(route('site-settings.vhost-template', [ + 'server' => $this->server->id, + 'site' => $this->site, + ])) + ->assertOk() + ->assertJson(fn ($json) => $json->where('template', fn (string $t) => str_contains($t, 'server->webserver()?->update(['name' => Caddy::id()]); + + $this->actingAs($this->user); + + $this->get(route('site-settings.vhost-template', [ + 'server' => $this->server->id, + 'site' => $this->site, + ])) + ->assertOk() + ->assertJson(fn ($json) => $json->where('template', fn (string $t) => str_contains($t, 'php_fastcgi') || str_contains($t, 'reverse_proxy') || str_contains($t, 'root *'))); + } +} diff --git a/tests/Unit/Actions/Service/InstallTest.php b/tests/Unit/Actions/Service/InstallTest.php index 3f3a075b2..e7ec752e5 100644 --- a/tests/Unit/Actions/Service/InstallTest.php +++ b/tests/Unit/Actions/Service/InstallTest.php @@ -99,6 +99,30 @@ public function test_install_caddy(): void ]); } + public function test_install_apache(): void + { + $this->server->webserver()->delete(); + + SSH::fake('Active: active'); + + app(Install::class)->install($this->server, [ + 'type' => 'webserver', + 'name' => 'apache', + 'version' => 'latest', + ]); + + $this->assertDatabaseHas('services', [ + 'server_id' => $this->server->id, + 'name' => 'apache', + 'type' => 'webserver', + 'version' => 'latest', + 'status' => ServiceStatus::READY, + ]); + + SSH::assertExecutedContains('/etc/systemd/system/apache2.service.d/vito-override.conf'); + SSH::assertExecutedContains('PrivateTmp=false'); + } + public function test_install_mysql(): void { $this->server->database()->delete(); diff --git a/tests/Unit/Actions/Webserver/GenerateApacheConfigTest.php b/tests/Unit/Actions/Webserver/GenerateApacheConfigTest.php new file mode 100644 index 000000000..7cab59245 --- /dev/null +++ b/tests/Unit/Actions/Webserver/GenerateApacheConfigTest.php @@ -0,0 +1,111 @@ +server->webserver()?->update(['name' => Apache::id()]); + $this->site->refresh(); + + HostedDomain::factory()->primary()->create([ + 'site_id' => $this->site->id, + 'domain' => $this->site->domain, + ]); + } + + public function test_php_site_generates_virtualhost_with_fpm_handler(): void + { + $vhost = $this->site->webserver()->generateVhost($this->site); + + $this->assertStringContainsString('', $vhost); + $this->assertStringContainsString('ServerName '.$this->site->domain, $vhost); + $this->assertStringContainsString('DocumentRoot '.$this->site->getWebDirectoryPath(), $vhost); + $this->assertStringContainsString('', $vhost); + $this->assertStringContainsString('SetHandler "proxy:unix:', $vhost); + $this->assertStringContainsString('|fcgi://localhost"', $vhost); + $this->assertStringContainsString('RewriteRule ^ index.php [L]', $vhost); + $this->assertStringContainsString('RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/', $vhost); + $this->assertStringContainsString('', $vhost); + } + + public function test_acme_challenge_remains_public_when_basic_auth_enabled(): void + { + $this->site->type_data = [ + 'basic_auth' => [ + 'enabled' => true, + 'users' => [ + ['username' => 'alice', 'apr1' => '$apr1$saltxxxx$hashhashhashhashhashhh', 'bcrypt' => '$2y$10$bcrypthashhere'], + ], + ], + ]; + $this->site->save(); + + $vhost = $this->site->webserver()->generateVhost($this->site); + + $authPos = strpos($vhost, 'Require valid-user'); + $acmePos = strpos($vhost, ''); + + $this->assertNotFalse($authPos); + $this->assertNotFalse($acmePos); + $this->assertGreaterThan($authPos, $acmePos, 'ACME exemption must come after the auth Location so it wins for that path.'); + } + + public function test_reverse_proxy_excludes_acme_challenge_from_proxy(): void + { + $this->site->update([ + 'type' => 'nodejs', + 'port' => 3000, + ]); + $this->site->refresh(); + + $vhost = $this->site->webserver()->generateVhost($this->site); + + $this->assertStringContainsString('ProxyPass /.well-known/acme-challenge/ !', $vhost); + } + + public function test_basic_auth_adds_auth_directives_and_acme_exemption(): void + { + $this->site->type_data = [ + 'basic_auth' => [ + 'enabled' => true, + 'users' => [ + ['username' => 'alice', 'apr1' => '$apr1$saltxxxx$hashhashhashhashhashhh', 'bcrypt' => '$2y$10$bcrypthashhere'], + ], + ], + ]; + $this->site->save(); + + $vhost = $this->site->webserver()->generateVhost($this->site); + + $this->assertStringContainsString('AuthType Basic', $vhost); + $this->assertStringContainsString('AuthName "'.$this->site->domain.'"', $vhost); + $this->assertStringContainsString('AuthUserFile /etc/apache2/auth/site-'.$this->site->id.'.htpasswd', $vhost); + $this->assertStringContainsString('Require valid-user', $vhost); + $this->assertStringContainsString('', $vhost); + } + + public function test_reverse_proxy_site_generates_proxypass(): void + { + $this->site->update([ + 'type' => 'nodejs', + 'port' => 3000, + ]); + $this->site->refresh(); + + $vhost = $this->site->webserver()->generateVhost($this->site); + + $this->assertStringContainsString('ProxyPass / http://localhost:3000/', $vhost); + $this->assertStringContainsString('ProxyPassReverse / http://localhost:3000/', $vhost); + } +} diff --git a/tests/Unit/SSH/Services/Webserver/DeploySplashTest.php b/tests/Unit/SSH/Services/Webserver/DeploySplashTest.php index 0cb05e312..1d03f30bc 100644 --- a/tests/Unit/SSH/Services/Webserver/DeploySplashTest.php +++ b/tests/Unit/SSH/Services/Webserver/DeploySplashTest.php @@ -4,6 +4,7 @@ use App\Enums\ServiceStatus; use App\Facades\SSH; +use App\Services\Webserver\Apache; use App\Services\Webserver\Caddy; use App\Services\Webserver\Nginx; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -36,6 +37,37 @@ public function test_nginx_deploy_splash_runs_expected_commands_and_writes_defau $this->addToAssertionCount(6); } + public function test_apache_deploy_splash_runs_expected_commands_and_writes_default_vhost(): void + { + $this->server->webserver()->delete(); + $this->server->services()->create([ + 'type' => Apache::type(), + 'name' => Apache::id(), + 'version' => 'latest', + 'status' => ServiceStatus::READY, + ]); + + SSH::fake(); + + /** @var Apache $apache */ + $apache = $this->server->refresh()->webserver()->handler(); + + $apache->deploySplash(); + + SSH::assertExecutedContains('sudo a2dissite 000-default default-ssl'); + SSH::assertExecutedContains('sudo mkdir -p /var/www/vito-splash'); + SSH::assertExecutedContains('> /var/www/vito-splash/index.html'); + SSH::assertExecutedContains('> /etc/apache2/sites-available/000-vito-default.conf'); + SSH::assertExecutedContains('sudo a2ensite 000-vito-default.conf'); + + SSH::assertNotExecutedContains( + 'service apache2 reload', + 'deploySplash() must not reload apache — install() restarts it and the reload can fail on fresh installs.' + ); + + $this->addToAssertionCount(6); + } + public function test_caddy_deploy_splash_runs_expected_commands_and_writes_default_vhost(): void { $this->server->webserver()->delete(); From ad71bfc02069e5114b3352918e4f9dc9164e5706 Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Mon, 25 May 2026 10:45:58 +0100 Subject: [PATCH 2/6] post-merge fixes --- app/Actions/Site/UpdateVhostTemplate.php | 8 +------- app/Http/Controllers/SiteSettingController.php | 2 -- app/Services/Webserver/Caddy.php | 2 +- app/Services/Webserver/Nginx.php | 2 +- 4 files changed, 3 insertions(+), 11 deletions(-) diff --git a/app/Actions/Site/UpdateVhostTemplate.php b/app/Actions/Site/UpdateVhostTemplate.php index 7f758b28a..8160aa1a0 100644 --- a/app/Actions/Site/UpdateVhostTemplate.php +++ b/app/Actions/Site/UpdateVhostTemplate.php @@ -2,8 +2,6 @@ namespace App\Actions\Site; -use App\Actions\Webserver\GenerateCaddyConfig; -use App\Actions\Webserver\GenerateNginxConfig; use App\Models\Site; class UpdateVhostTemplate @@ -27,10 +25,6 @@ public function update(Site $site, array $input): void private function matchesDefault(Site $site, string $template): bool { - $generator = $site->webserver()::id() === 'caddy' - ? app(GenerateCaddyConfig::class) - : app(GenerateNginxConfig::class); - - return rtrim($template) === rtrim($generator->defaultTemplate()); + return rtrim($template) === rtrim($site->webserver()->configGenerator()->defaultTemplate()); } } diff --git a/app/Http/Controllers/SiteSettingController.php b/app/Http/Controllers/SiteSettingController.php index 31711a5ee..372019fff 100644 --- a/app/Http/Controllers/SiteSettingController.php +++ b/app/Http/Controllers/SiteSettingController.php @@ -15,8 +15,6 @@ use App\Actions\Site\UpdateVhostTemplate; use App\Actions\Site\UpdateWebDirectory; use App\Actions\Site\WorkerStartCommandUpdateResult; -use App\Actions\Webserver\GenerateCaddyConfig; -use App\Actions\Webserver\GenerateNginxConfig; use App\Exceptions\SSHError; use App\Http\Resources\SourceControlResource; use App\Models\Server; diff --git a/app/Services/Webserver/Caddy.php b/app/Services/Webserver/Caddy.php index 1cdbd49a0..a751f996e 100755 --- a/app/Services/Webserver/Caddy.php +++ b/app/Services/Webserver/Caddy.php @@ -2,8 +2,8 @@ namespace App\Services\Webserver; -use App\Actions\Webserver\AbstractGenerateConfig; use App\Actions\Site\EnsureSiteVerificationKey; +use App\Actions\Webserver\AbstractGenerateConfig; use App\Actions\Webserver\GenerateCaddyConfig; use App\DTOs\ServiceLog; use App\Enums\SslMethod; diff --git a/app/Services/Webserver/Nginx.php b/app/Services/Webserver/Nginx.php index c326e02ec..f61fc1573 100755 --- a/app/Services/Webserver/Nginx.php +++ b/app/Services/Webserver/Nginx.php @@ -2,8 +2,8 @@ namespace App\Services\Webserver; -use App\Actions\Webserver\AbstractGenerateConfig; use App\Actions\Site\EnsureSiteVerificationKey; +use App\Actions\Webserver\AbstractGenerateConfig; use App\Actions\Webserver\GenerateNginxConfig; use App\DTOs\ServiceLog; use App\Exceptions\SSHError; From 8edd4d67e51ca6e75f8634dea2bb70cf0d9d2bdc Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Mon, 25 May 2026 22:03:52 +0100 Subject: [PATCH 3/6] apache updates --- app/Actions/Site/UpdateBasicAuth.php | 14 +++--- .../Webserver/AbstractGenerateConfig.php | 2 +- app/Models/Site.php | 13 ++--- app/Services/Webserver/AbstractWebserver.php | 5 ++ app/Services/Webserver/Apache.php | 47 +++++++++++++------ app/Services/Webserver/Caddy.php | 8 ++-- app/Services/Webserver/Nginx.php | 24 +++++----- app/Services/Webserver/Webserver.php | 2 + .../remove-basic-auth-file.blade.php | 0 .../write-basic-auth-file.blade.php | 2 +- tests/Feature/ServiceLogsTest.php | 25 ++++++++++ 11 files changed, 96 insertions(+), 46 deletions(-) rename resources/views/ssh/services/webserver/{nginx => shared}/remove-basic-auth-file.blade.php (100%) rename resources/views/ssh/services/webserver/{nginx => shared}/write-basic-auth-file.blade.php (90%) diff --git a/app/Actions/Site/UpdateBasicAuth.php b/app/Actions/Site/UpdateBasicAuth.php index 769b25564..7ef53871a 100644 --- a/app/Actions/Site/UpdateBasicAuth.php +++ b/app/Actions/Site/UpdateBasicAuth.php @@ -4,8 +4,6 @@ use App\Helpers\Apr1Hasher; use App\Models\Site; -use App\Services\Webserver\Apache; -use App\Services\Webserver\Nginx; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; @@ -110,15 +108,15 @@ private function validate(Site $site, array $input): void */ private function writeAuthFile(Site $site, array $users): void { - if (! in_array($site->webserver()::id(), [Nginx::id(), Apache::id()], true)) { + $path = $site->htpasswdPath(); + + if ($path === null) { return; } - $path = $site->htpasswdPath(); - if (empty($users)) { $site->server->ssh()->exec( - view('ssh.services.webserver.nginx.remove-basic-auth-file', ['path' => $path]), + view('ssh.services.webserver.shared.remove-basic-auth-file', ['path' => $path]), 'remove-basic-auth-file', $site->id, ); @@ -130,13 +128,13 @@ private function writeAuthFile(Site $site, array $users): void $usernames = implode(', ', array_map(fn (array $u) => $u['username'], $users)); $site->server->ssh()->exec( - view('ssh.services.webserver.nginx.write-basic-auth-file', [ + view('ssh.services.webserver.shared.write-basic-auth-file', [ 'dir' => dirname($path), 'path' => $path, 'lines' => $lines, 'userCount' => count($users), 'usernames' => $usernames, - 'nginxUser' => $site->server->getSshUser(), + 'webserverUser' => $site->server->getSshUser(), ]), 'write-basic-auth-file', $site->id, diff --git a/app/Actions/Webserver/AbstractGenerateConfig.php b/app/Actions/Webserver/AbstractGenerateConfig.php index b3fff273a..cd62e1f01 100644 --- a/app/Actions/Webserver/AbstractGenerateConfig.php +++ b/app/Actions/Webserver/AbstractGenerateConfig.php @@ -225,7 +225,7 @@ protected function buildCommonData(Site $site, string $primaryDomain): array 'type_data' => $site->type_data ?? [], 'basic_auth_enabled' => $basicAuthEnabled, 'basic_auth_realm' => $site->domain, - 'basic_auth_file' => $site->htpasswdPath(), + 'basic_auth_file' => $site->htpasswdPath() ?? '', 'basic_auth_users' => $basicAuthEnabled ? array_values($basicAuth['users']) : [], 'verification_key' => $site->verification_key, ]; diff --git a/app/Models/Site.php b/app/Models/Site.php index 33fb1e2cb..c238da71c 100755 --- a/app/Models/Site.php +++ b/app/Models/Site.php @@ -13,7 +13,6 @@ use App\Helpers\SiteShellEnvironment; use App\Helpers\SSH; use App\Jobs\SSL\DeleteSiteSslJob; -use App\Services\Webserver\Apache; use App\Services\Webserver\Webserver; use App\SiteFeatures\ActionInterface; use App\SiteTypes\AbstractProxiedSiteType; @@ -713,13 +712,15 @@ public function basePath(): string return preg_replace('#/current$#', '', $this->path); } - public function htpasswdPath(): string + public function htpasswdPath(): ?string { - $dir = $this->server->webserver()?->name === Apache::id() - ? '/etc/apache2/auth' - : '/etc/nginx/auth'; + if (! $this->server->webserver()) { + return null; + } + + $dir = $this->webserver()->basicAuthDir(); - return $dir.'/site-'.$this->id.'.htpasswd'; + return $dir ? $dir.'/site-'.$this->id.'.htpasswd' : null; } public function getDeployKeyName(): string diff --git a/app/Services/Webserver/AbstractWebserver.php b/app/Services/Webserver/AbstractWebserver.php index f20f2d0c1..b2673da0a 100755 --- a/app/Services/Webserver/AbstractWebserver.php +++ b/app/Services/Webserver/AbstractWebserver.php @@ -9,6 +9,11 @@ abstract class AbstractWebserver extends AbstractService implements Webserver { + public static function type(): string + { + return 'webserver'; + } + public function creationRules(array $input): array { return [ diff --git a/app/Services/Webserver/Apache.php b/app/Services/Webserver/Apache.php index a6bc36c7e..b3c124f35 100644 --- a/app/Services/Webserver/Apache.php +++ b/app/Services/Webserver/Apache.php @@ -4,27 +4,29 @@ use App\Actions\Webserver\AbstractGenerateConfig; use App\Actions\Webserver\GenerateApacheConfig; +use App\DTOs\ServiceLog; use App\Exceptions\SSHError; use App\Exceptions\SSLCreationException; use App\Models\Site; use App\Models\Ssl; +use App\Services\HasLogs; use Throwable; -class Apache extends AbstractWebserver +class Apache extends AbstractWebserver implements HasLogs { public static function id(): string { return 'apache'; } - public static function type(): string + public function unit(): string { - return 'webserver'; + return 'apache2'; } - public function unit(): string + public function basicAuthDir(): ?string { - return 'apache2'; + return '/etc/apache2/auth'; } /** @@ -43,7 +45,7 @@ public function install(): void $this->deploySplash(); - $this->service->server->systemd()->restart('apache2'); + $this->service->server->systemd()->restart($this->unit()); event('service.installed', $this->service); $this->service->server->os()->cleanup(); } @@ -121,12 +123,12 @@ public function updateVHost(Site $site, ?string $vhost = null, bool $restart = f ); if ($restart) { - $this->service->server->systemd()->restart('apache2'); + $this->service->server->systemd()->restart($this->unit()); return; } - $this->service->server->systemd()->reload('apache2'); + $this->service->server->systemd()->reload($this->unit()); } /** @@ -146,13 +148,15 @@ public function getVHost(Site $site): string */ public function deleteSite(Site $site): void { - $this->service->server->ssh()->exec( - view('ssh.services.webserver.nginx.remove-basic-auth-file', [ - 'path' => $site->htpasswdPath(), - ]), - 'remove-basic-auth-file', - $site->id - ); + if (($htpasswdPath = $site->htpasswdPath()) !== null) { + $this->service->server->ssh()->exec( + view('ssh.services.webserver.shared.remove-basic-auth-file', [ + 'path' => $htpasswdPath, + ]), + 'remove-basic-auth-file', + $site->id + ); + } $this->service->server->ssh()->exec( view('ssh.services.webserver.apache.delete-site', [ 'domain' => $site->domain, @@ -260,4 +264,17 @@ public function version(): string return trim($version); } + + public function logs(): array + { + return [ + new ServiceLog( + key: 'apache:error', + serviceLabel: 'Apache', + label: 'Error log', + source: ServiceLog::SOURCE_FILE, + target: '/var/log/apache2/error.log', + ), + ]; + } } diff --git a/app/Services/Webserver/Caddy.php b/app/Services/Webserver/Caddy.php index a751f996e..fdd9f8f4a 100755 --- a/app/Services/Webserver/Caddy.php +++ b/app/Services/Webserver/Caddy.php @@ -44,14 +44,14 @@ public function defaultSslMethod(): SslMethod return SslMethod::LETSENCRYPT; } - public static function type(): string + public function unit(): string { - return 'webserver'; + return 'caddy'; } - public function unit(): string + public function basicAuthDir(): ?string { - return 'caddy'; + return null; } /** diff --git a/app/Services/Webserver/Nginx.php b/app/Services/Webserver/Nginx.php index f61fc1573..afb93a828 100755 --- a/app/Services/Webserver/Nginx.php +++ b/app/Services/Webserver/Nginx.php @@ -20,14 +20,14 @@ public static function id(): string return 'nginx'; } - public static function type(): string + public function unit(): string { - return 'webserver'; + return 'nginx'; } - public function unit(): string + public function basicAuthDir(): ?string { - return 'nginx'; + return '/etc/nginx/auth'; } /** @@ -165,13 +165,15 @@ public function getVHost(Site $site): string */ public function deleteSite(Site $site): void { - $this->service->server->ssh()->exec( - view('ssh.services.webserver.nginx.remove-basic-auth-file', [ - 'path' => $site->htpasswdPath(), - ]), - 'remove-basic-auth-file', - $site->id - ); + if (($htpasswdPath = $site->htpasswdPath()) !== null) { + $this->service->server->ssh()->exec( + view('ssh.services.webserver.shared.remove-basic-auth-file', [ + 'path' => $htpasswdPath, + ]), + 'remove-basic-auth-file', + $site->id + ); + } $this->service->server->ssh()->exec( view('ssh.services.webserver.nginx.delete-site', [ 'domain' => $site->domain, diff --git a/app/Services/Webserver/Webserver.php b/app/Services/Webserver/Webserver.php index 4980b855a..41f92b11c 100755 --- a/app/Services/Webserver/Webserver.php +++ b/app/Services/Webserver/Webserver.php @@ -11,6 +11,8 @@ interface Webserver extends ServiceInterface { public function configGenerator(): AbstractGenerateConfig; + + public function basicAuthDir(): ?string; public function generateVhost(Site $site, ?string $template = null): string; diff --git a/resources/views/ssh/services/webserver/nginx/remove-basic-auth-file.blade.php b/resources/views/ssh/services/webserver/shared/remove-basic-auth-file.blade.php similarity index 100% rename from resources/views/ssh/services/webserver/nginx/remove-basic-auth-file.blade.php rename to resources/views/ssh/services/webserver/shared/remove-basic-auth-file.blade.php diff --git a/resources/views/ssh/services/webserver/nginx/write-basic-auth-file.blade.php b/resources/views/ssh/services/webserver/shared/write-basic-auth-file.blade.php similarity index 90% rename from resources/views/ssh/services/webserver/nginx/write-basic-auth-file.blade.php rename to resources/views/ssh/services/webserver/shared/write-basic-auth-file.blade.php index 682fd798a..f2bcf32de 100644 --- a/resources/views/ssh/services/webserver/nginx/write-basic-auth-file.blade.php +++ b/resources/views/ssh/services/webserver/shared/write-basic-auth-file.blade.php @@ -12,7 +12,7 @@ @endforeach BASIC_AUTH_EOF -sudo chown root:{{ $nginxUser }} {{ $path }} +sudo chown root:{{ $webserverUser }} {{ $path }} sudo chmod 640 {{ $path }} echo "Wrote basic auth file {{ $path }} with {{ $userCount }} user(s): {{ $usernames }}" diff --git a/tests/Feature/ServiceLogsTest.php b/tests/Feature/ServiceLogsTest.php index 35077a103..a6a58513e 100644 --- a/tests/Feature/ServiceLogsTest.php +++ b/tests/Feature/ServiceLogsTest.php @@ -2,8 +2,10 @@ namespace Tests\Feature; +use App\Enums\ServiceStatus; use App\Facades\SSH; use App\Models\Site; +use App\Services\Webserver\Apache; use App\Models\SourceControl; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -46,6 +48,29 @@ public function test_renders_catalogue_for_installed_services(): void $this->assertContains('php:8.2:user:vito', $keys); } + public function test_apache_exposes_error_log_only(): void + { + $this->actingAs($this->user); + + $this->server->webserver()->delete(); + $this->server->services()->create([ + 'type' => Apache::type(), + 'name' => Apache::id(), + 'version' => 'latest', + 'status' => ServiceStatus::READY, + ]); + + $response = $this->get(route('logs.services', $this->server->refresh())); + + $catalogue = $response->viewData('page')['props']['catalogue']; + $entries = collect($catalogue)->keyBy('key'); + + $this->assertTrue($entries->has('apache:error')); + $this->assertSame('/var/log/apache2/error.log', $entries['apache:error']['display_target']); + $this->assertFalse($entries->has('apache:access')); + $this->assertFalse($entries->has('nginx:error')); + } + public function test_services_without_has_logs_are_skipped(): void { $this->actingAs($this->user); From b8fda721986a2897dd87fa2c027db802abe30000 Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Mon, 25 May 2026 22:06:13 +0100 Subject: [PATCH 4/6] pre-commit --- app/Services/Webserver/Webserver.php | 2 +- tests/Feature/ServiceLogsTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Services/Webserver/Webserver.php b/app/Services/Webserver/Webserver.php index 41f92b11c..8ba4f5919 100755 --- a/app/Services/Webserver/Webserver.php +++ b/app/Services/Webserver/Webserver.php @@ -11,7 +11,7 @@ interface Webserver extends ServiceInterface { public function configGenerator(): AbstractGenerateConfig; - + public function basicAuthDir(): ?string; public function generateVhost(Site $site, ?string $template = null): string; diff --git a/tests/Feature/ServiceLogsTest.php b/tests/Feature/ServiceLogsTest.php index a6a58513e..5c68053d2 100644 --- a/tests/Feature/ServiceLogsTest.php +++ b/tests/Feature/ServiceLogsTest.php @@ -5,9 +5,9 @@ use App\Enums\ServiceStatus; use App\Facades\SSH; use App\Models\Site; -use App\Services\Webserver\Apache; use App\Models\SourceControl; use App\Models\User; +use App\Services\Webserver\Apache; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Bus; From 6b5630f72da9385d6838dc2241230d28bae6691e Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Mon, 25 May 2026 22:37:55 +0100 Subject: [PATCH 5/6] apache verification --- app/Services/Webserver/Apache.php | 21 +++++++- .../services/webserver/apache/vhost.mustache | 51 +++++++++++++++++- tests/Feature/ServiceLogsTest.php | 34 ++++++++++++ .../Webserver/VerificationBlockTest.php | 53 +++++++++++++++++++ .../Webserver/GenerateApacheConfigTest.php | 34 ++++++++++++ 5 files changed, 191 insertions(+), 2 deletions(-) diff --git a/app/Services/Webserver/Apache.php b/app/Services/Webserver/Apache.php index b3c124f35..291a5223e 100644 --- a/app/Services/Webserver/Apache.php +++ b/app/Services/Webserver/Apache.php @@ -2,6 +2,7 @@ namespace App\Services\Webserver; +use App\Actions\Site\EnsureSiteVerificationKey; use App\Actions\Webserver\AbstractGenerateConfig; use App\Actions\Webserver\GenerateApacheConfig; use App\DTOs\ServiceLog; @@ -70,6 +71,8 @@ public function configGenerator(): AbstractGenerateConfig public function generateVhost(Site $site, ?string $template = null): string { + app(EnsureSiteVerificationKey::class)->ensure($site); + return $this->configGenerator()->generate($site, $template); } @@ -267,7 +270,7 @@ public function version(): string public function logs(): array { - return [ + $logs = [ new ServiceLog( key: 'apache:error', serviceLabel: 'Apache', @@ -276,5 +279,21 @@ public function logs(): array target: '/var/log/apache2/error.log', ), ]; + + $sites = $this->service->server->relationLoaded('sites') + ? $this->service->server->sites->sortBy('id') + : $this->service->server->sites()->orderBy('id')->get(['id', 'domain']); + + foreach ($sites as $site) { + $logs[] = new ServiceLog( + key: 'apache:site:'.$site->id.':error', + serviceLabel: 'Apache', + label: $site->domain.' error log', + source: ServiceLog::SOURCE_FILE, + target: '/var/log/apache2/'.$site->domain.'-error.log', + ); + } + + return $logs; } } diff --git a/resources/views/ssh/services/webserver/apache/vhost.mustache b/resources/views/ssh/services/webserver/apache/vhost.mustache index 6d40d9490..cef4a2345 100644 --- a/resources/views/ssh/services/webserver/apache/vhost.mustache +++ b/resources/views/ssh/services/webserver/apache/vhost.mustache @@ -6,8 +6,19 @@ {{/force_ssl_aliases}} DocumentRoot {{force_ssl_root}} +{{#verification_key}} + Alias "/.well-known/vito/{{verification_key}}" "/var/lib/vito/verify/{{verification_key}}" + + Options -Indexes + Require all granted + ForceType text/plain + Header always set Cache-Control "no-store" + + +{{/verification_key}} RewriteEngine On RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/ + RewriteCond %{REQUEST_URI} !^/\.well-known/vito/ RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] {{/has_force_ssl_redirect}} @@ -51,6 +62,19 @@ Require all granted +{{#verification_key}} + Alias "/.well-known/vito/{{verification_key}}" "/var/lib/vito/verify/{{verification_key}}" + + Options -Indexes + Require all granted + + + Require all granted + ForceType text/plain + Header always set Cache-Control "no-store" + + +{{/verification_key}} {{#is_php}} DirectoryIndex index.php index.html @@ -60,6 +84,7 @@ RewriteEngine On RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/ + RewriteCond %{REQUEST_URI} !^/\.well-known/vito/ RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^ index.php [L] @@ -67,6 +92,9 @@ {{#is_reverse_proxy}} ProxyPreserveHost On ProxyPass /.well-known/acme-challenge/ ! +{{#verification_key}} + ProxyPass /.well-known/vito/{{verification_key}}/ ! +{{/verification_key}} ProxyPass / http://localhost:{{port}}/ ProxyPassReverse / http://localhost:{{port}}/ @@ -84,6 +112,9 @@ ProxyPreserveHost On ProxyPass /.well-known/acme-challenge/ ! +{{#verification_key}} + ProxyPass /.well-known/vito/{{verification_key}}/ ! +{{/verification_key}} ProxyPass / "balancer://{{balancer_name}}/" ProxyPassReverse / "balancer://{{balancer_name}}/" {{/is_load_balancer}} @@ -94,6 +125,7 @@ RewriteCond %{HTTP:Upgrade} =websocket [NC] RewriteRule ^/?(.*) ws://127.0.0.1:{{octane_port}}/$1 [P,L] RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/ + RewriteCond %{REQUEST_URI} !^/\.well-known/vito/ RewriteCond %{HTTP:Upgrade} !=websocket [NC] RewriteRule ^/?(.*) http://127.0.0.1:{{octane_port}}/$1 [P,L] @@ -119,7 +151,24 @@ SSLCertificateFile {{ssl_certificate_path}} SSLCertificateKeyFile {{ssl_certificate_key_path}} {{/has_ssl}} - Redirect permanent / {{redirect_scheme}}://{{redirect_target}}/ + + Require all granted + + +{{#verification_key}} + Alias "/.well-known/vito/{{verification_key}}" "/var/lib/vito/verify/{{verification_key}}" + + Options -Indexes + Require all granted + ForceType text/plain + Header always set Cache-Control "no-store" + + +{{/verification_key}} + RewriteEngine On + RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge/ + RewriteCond %{REQUEST_URI} !^/\.well-known/vito/ + RewriteRule ^ {{redirect_scheme}}://{{redirect_target}}%{REQUEST_URI} [L,R=301] {{/redirect_vhosts}} diff --git a/tests/Feature/ServiceLogsTest.php b/tests/Feature/ServiceLogsTest.php index 5c68053d2..b0abd3409 100644 --- a/tests/Feature/ServiceLogsTest.php +++ b/tests/Feature/ServiceLogsTest.php @@ -71,6 +71,40 @@ public function test_apache_exposes_error_log_only(): void $this->assertFalse($entries->has('nginx:error')); } + public function test_apache_exposes_per_site_error_log(): void + { + $this->actingAs($this->user); + + $this->server->webserver()->delete(); + $this->server->services()->create([ + 'type' => Apache::type(), + 'name' => Apache::id(), + 'version' => 'latest', + 'status' => ServiceStatus::READY, + ]); + + $response = $this->get(route('logs.services', $this->server->refresh())); + + $catalogue = $response->viewData('page')['props']['catalogue']; + $entries = collect($catalogue)->keyBy('key'); + + $key = 'apache:site:'.$this->site->id.':error'; + $this->assertTrue($entries->has($key)); + $this->assertSame('/var/log/apache2/'.$this->site->domain.'-error.log', $entries[$key]['display_target']); + } + + public function test_nginx_does_not_expose_per_site_logs(): void + { + $this->actingAs($this->user); + + $response = $this->get(route('logs.services', $this->server)); + + $catalogue = $response->viewData('page')['props']['catalogue']; + $keys = array_column($catalogue, 'key'); + + $this->assertNotContains('nginx:site:'.$this->site->id.':error', $keys); + } + public function test_services_without_has_logs_are_skipped(): void { $this->actingAs($this->user); diff --git a/tests/Feature/Webserver/VerificationBlockTest.php b/tests/Feature/Webserver/VerificationBlockTest.php index 4e551abbd..708759330 100644 --- a/tests/Feature/Webserver/VerificationBlockTest.php +++ b/tests/Feature/Webserver/VerificationBlockTest.php @@ -6,6 +6,7 @@ use App\Enums\ServiceStatus; use App\Models\HostedDomain; use App\Models\Service; +use App\Services\Webserver\Apache; use App\Services\Webserver\Caddy; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -88,4 +89,56 @@ public function test_caddy_renders_verification_handle_when_key_present(): void $this->assertStringContainsString('handle_path /.well-known/vito/caddyKey99/*', $vhost); $this->assertStringContainsString('root * /var/lib/vito/verify/caddyKey99', $vhost); } + + public function test_apache_renders_verification_block_when_key_present(): void + { + $this->switchToApache(); + + HostedDomain::factory()->primary()->create([ + 'site_id' => $this->site->id, + 'domain' => $this->site->domain, + ]); + + $this->site->verification_key = 'apacheKey77'; + $this->site->save(); + + /** @var Service $webserver */ + $webserver = $this->server->webserver(); + $vhost = $webserver->handler()->generateVhost($this->site); + + $this->assertStringContainsString('Alias "/.well-known/vito/apacheKey77" "/var/lib/vito/verify/apacheKey77"', $vhost); + $this->assertStringContainsString('', $vhost); + $this->assertStringContainsString('Header always set Cache-Control "no-store"', $vhost); + } + + public function test_apache_omits_verification_block_when_key_missing(): void + { + $this->switchToApache(); + + HostedDomain::factory()->primary()->create([ + 'site_id' => $this->site->id, + 'domain' => $this->site->domain, + ]); + + $this->site->verification_key = null; + $this->site->vhost_template = null; + $this->site->vhost_generation_enabled = false; + $this->site->save(); + + $vhost = $this->server->webserver()->handler()->configGenerator()->generate($this->site); + + $this->assertStringNotContainsString('/.well-known/vito/', $vhost); + } + + private function switchToApache(): void + { + $this->server->services()->where('type', 'webserver')->delete(); + $this->server->services()->create([ + 'type' => Apache::type(), + 'name' => Apache::id(), + 'version' => 'latest', + 'status' => ServiceStatus::READY, + ]); + $this->server->refresh(); + } } diff --git a/tests/Unit/Actions/Webserver/GenerateApacheConfigTest.php b/tests/Unit/Actions/Webserver/GenerateApacheConfigTest.php index 7cab59245..62ccade50 100644 --- a/tests/Unit/Actions/Webserver/GenerateApacheConfigTest.php +++ b/tests/Unit/Actions/Webserver/GenerateApacheConfigTest.php @@ -2,7 +2,9 @@ namespace Tests\Unit\Actions\Webserver; +use App\Enums\SslStatus; use App\Models\HostedDomain; +use App\Models\Ssl; use App\Services\Webserver\Apache; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -108,4 +110,36 @@ public function test_reverse_proxy_site_generates_proxypass(): void $this->assertStringContainsString('ProxyPass / http://localhost:3000/', $vhost); $this->assertStringContainsString('ProxyPassReverse / http://localhost:3000/', $vhost); } + + public function test_force_ssl_redirect_serves_and_exempts_verification_challenge(): void + { + $ssl = Ssl::factory()->create([ + 'server_id' => $this->server->id, + 'site_id' => $this->site->id, + 'status' => SslStatus::CREATED, + 'type' => 'letsencrypt', + 'domains' => [$this->site->domain], + ]); + + $this->site->hostedDomains()->update(['ssl_id' => $ssl->id]); + + $this->site->update([ + 'ssl_enabled' => true, + 'force_ssl' => true, + 'verification_key' => 'forcedKey55', + ]); + $this->site->refresh(); + + $vhost = $this->site->webserver()->generateVhost($this->site); + + $this->assertStringContainsString( + "RewriteCond %{REQUEST_URI} !^/\\.well-known/vito/\n RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]", + $vhost, + 'The force-SSL redirect must exempt the verification path so port-80 verification is not 301-redirected.' + ); + $this->assertStringContainsString( + 'Alias "/.well-known/vito/forcedKey55" "/var/lib/vito/verify/forcedKey55"', + $vhost + ); + } } From e0366e4e527258e72b40c9e0a88dd81daaea79d7 Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Mon, 25 May 2026 23:19:10 +0100 Subject: [PATCH 6/6] copilot fixes --- app/Services/Webserver/Apache.php | 2 +- .../views/ssh/services/webserver/apache/delete-site.blade.php | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/Services/Webserver/Apache.php b/app/Services/Webserver/Apache.php index 291a5223e..3634f7d23 100644 --- a/app/Services/Webserver/Apache.php +++ b/app/Services/Webserver/Apache.php @@ -168,7 +168,7 @@ public function deleteSite(Site $site): void 'delete-vhost', $site->id ); - $this->service->restart(); + $this->service->reload(); } /** diff --git a/resources/views/ssh/services/webserver/apache/delete-site.blade.php b/resources/views/ssh/services/webserver/apache/delete-site.blade.php index b363a9d58..8262fcd6a 100644 --- a/resources/views/ssh/services/webserver/apache/delete-site.blade.php +++ b/resources/views/ssh/services/webserver/apache/delete-site.blade.php @@ -4,6 +4,4 @@ sudo rm -rf {{ $path }} -sudo service apache2 reload || true - echo "Site deleted"