diff --git a/app/Actions/Site/UpdateBasicAuth.php b/app/Actions/Site/UpdateBasicAuth.php index a19bac94d..cb9fd3f11 100644 --- a/app/Actions/Site/UpdateBasicAuth.php +++ b/app/Actions/Site/UpdateBasicAuth.php @@ -4,15 +4,12 @@ use App\Helpers\Apr1Hasher; use App\Models\Site; -use App\Services\Webserver\Nginx; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; class UpdateBasicAuth { - private const NGINX_AUTH_DIR = '/etc/nginx/auth'; - /** * @param array $input */ @@ -118,15 +115,15 @@ private function validate(Site $site, array $input): void */ private function writeAuthFile(Site $site, array $users): void { - if ($site->webserver()::id() !== Nginx::id()) { + $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, ); @@ -138,13 +135,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', [ - 'dir' => self::NGINX_AUTH_DIR, + 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/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/Actions/Webserver/AbstractGenerateConfig.php b/app/Actions/Webserver/AbstractGenerateConfig.php index 24cb6f140..cd62e1f01 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); } /** @@ -216,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/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 1edd176fa..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; @@ -166,10 +164,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(), ]); } @@ -218,13 +214,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 4f7b1201c..9958896ac 100755 --- a/app/Models/Site.php +++ b/app/Models/Site.php @@ -758,9 +758,15 @@ public function basePath(): string return preg_replace('#/current$#', '', $this->path); } - public function htpasswdPath(): string + public function htpasswdPath(): ?string { - return '/etc/nginx/auth/site-'.$this->id.'.htpasswd'; + if (! $this->server->webserver()) { + return null; + } + + $dir = $this->webserver()->basicAuthDir(); + + return $dir ? $dir.'/site-'.$this->id.'.htpasswd' : null; } 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/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 new file mode 100644 index 000000000..3634f7d23 --- /dev/null +++ b/app/Services/Webserver/Apache.php @@ -0,0 +1,299 @@ +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($this->unit()); + 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 + { + app(EnsureSiteVerificationKey::class)->ensure($site); + + 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($this->unit()); + + return; + } + + $this->service->server->systemd()->reload($this->unit()); + } + + /** + * @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 + { + 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, + 'path' => $site->basePath(), + ]), + 'delete-vhost', + $site->id + ); + $this->service->reload(); + } + + /** + * @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); + } + + public function logs(): array + { + $logs = [ + new ServiceLog( + key: 'apache:error', + serviceLabel: 'Apache', + label: 'Error log', + source: ServiceLog::SOURCE_FILE, + 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/app/Services/Webserver/Caddy.php b/app/Services/Webserver/Caddy.php index 1d8d340b6..2169b8991 100755 --- a/app/Services/Webserver/Caddy.php +++ b/app/Services/Webserver/Caddy.php @@ -3,6 +3,7 @@ namespace App\Services\Webserver; use App\Actions\Site\EnsureSiteVerificationKey; +use App\Actions\Webserver\AbstractGenerateConfig; use App\Actions\Webserver\GenerateCaddyConfig; use App\DTOs\ServiceLog; use App\Enums\SslMethod; @@ -43,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; } /** @@ -131,11 +132,16 @@ public function createVHost(Site $site): void ); } + public function configGenerator(): AbstractGenerateConfig + { + return app(GenerateCaddyConfig::class); + } + public function generateVhost(Site $site, ?string $template = null): string { app(EnsureSiteVerificationKey::class)->ensure($site); - 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 1dbb2d812..107a1be58 100755 --- a/app/Services/Webserver/Nginx.php +++ b/app/Services/Webserver/Nginx.php @@ -3,6 +3,7 @@ namespace App\Services\Webserver; use App\Actions\Site\EnsureSiteVerificationKey; +use App\Actions\Webserver\AbstractGenerateConfig; use App\Actions\Webserver\GenerateNginxConfig; use App\DTOs\ServiceLog; use App\Exceptions\SSHError; @@ -19,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'; } /** @@ -74,11 +75,16 @@ 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 { app(EnsureSiteVerificationKey::class)->ensure($site); - return app(GenerateNginxConfig::class)->generate($site, $template); + return $this->configGenerator()->generate($site, $template); } /** @@ -159,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 45b316403..8ba4f5919 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,10 @@ interface Webserver extends ServiceInterface { + public function configGenerator(): AbstractGenerateConfig; + + public function basicAuthDir(): ?string; + 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 33aa420af..d8c4cd585 100644 --- a/resources/js/pages/site-settings/index.tsx +++ b/resources/js/pages/site-settings/index.tsx @@ -136,7 +136,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..8262fcd6a --- /dev/null +++ b/resources/views/ssh/services/webserver/apache/delete-site.blade.php @@ -0,0 +1,7 @@ +sudo a2dissite {!! escapeshellarg($domain.'.conf') !!} > /dev/null 2>&1 || true + +sudo rm -f /etc/apache2/sites-available/{{ $domain }}.conf + +sudo rm -rf {{ $path }} + +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..cef4a2345 --- /dev/null +++ b/resources/views/ssh/services/webserver/apache/vhost.mustache @@ -0,0 +1,174 @@ +{{#has_force_ssl_redirect}} + + ServerName {{force_ssl_server_name}} +{{#force_ssl_aliases}} + ServerAlias {{name}} +{{/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}} + +{{#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 + + +{{#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 + + + SetHandler "proxy:{{php_socket}}" + + + 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] +{{/is_php}} +{{#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}}/ + + 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/ ! +{{#verification_key}} + ProxyPass /.well-known/vito/{{verification_key}}/ ! +{{/verification_key}} + 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 %{REQUEST_URI} !^/\.well-known/vito/ + 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}} + + 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/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 6cbf3ad7b..a243e49d9 100644 --- a/tests/Feature/ServiceLogsTest.php +++ b/tests/Feature/ServiceLogsTest.php @@ -2,10 +2,12 @@ namespace Tests\Feature; +use App\Enums\ServiceStatus; use App\Facades\SSH; use App\Models\Site; 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; @@ -46,6 +48,51 @@ 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_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_exposes_per_site_error_log(): void { $this->actingAs($this->user); 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/Feature/Webserver/VerificationBlockTest.php b/tests/Feature/Webserver/VerificationBlockTest.php index 8e0c17a1a..b09189dc9 100644 --- a/tests/Feature/Webserver/VerificationBlockTest.php +++ b/tests/Feature/Webserver/VerificationBlockTest.php @@ -8,6 +8,7 @@ use App\Models\HostedDomain; use App\Models\Service; use App\Models\Ssl; +use App\Services\Webserver\Apache; use App\Services\Webserver\Caddy; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -91,6 +92,46 @@ public function test_caddy_renders_verification_handle_when_key_present(): void $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); + } + public function test_nginx_force_ssl_redirect_serves_and_exempts_verification_challenge(): void { $ssl = Ssl::factory()->create([ @@ -168,6 +209,18 @@ public function test_caddy_omits_http_verification_block_for_http_only_site(): v $this->assertStringNotContainsString('redir https://{host}{uri} permanent', $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(); + } + private function switchToCaddy(): void { $this->server->services()->where('type', 'webserver')->delete(); 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..62ccade50 --- /dev/null +++ b/tests/Unit/Actions/Webserver/GenerateApacheConfigTest.php @@ -0,0 +1,145 @@ +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); + } + + 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 + ); + } +} 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();