Compare commits

..

1 Commits
2.5.1 ... 2.6.0

Author SHA1 Message Date
2318e1b1df Add Caddy Server Support Alongside Nginx (#600)
* added enum

* add config for caddy

* add svg icon

* add caddy service class

* wip

* install caddy

* create base Caddyfile with common snippets

* Create a systemd service to run Caddy in the background.

* create uninstall file

* wip

* create path

* create vhost

* get vhost

* delete site

* add php version change file

* add custom ssl

* create redirect file

* add vhost for caddy site & load balancer

* update svg

* fix caddy icon

* fix style

* add systemctl reload method

* Reload systemd after modifying the Caddy service file.

* add caddy

* added tests

* format with pint

* prevent multiple web server installations

* added error log & access log
2025-05-29 11:25:36 +02:00
24 changed files with 590 additions and 18 deletions

View File

@ -7,4 +7,6 @@ final class Webserver
const NONE = 'none';
const NGINX = 'nginx';
const CADDY = 'caddy';
}

View File

@ -52,7 +52,7 @@ public function index(Project $project): ResourceCollection
#[BodyParam(name: 'port', description: 'SSH Port if the provider is custom')]
#[BodyParam(name: 'name', description: 'The name of the server.', required: true)]
#[BodyParam(name: 'os', description: 'The os of the server', required: true)]
#[BodyParam(name: 'webserver', description: 'Web server', required: true, enum: [Webserver::NONE, Webserver::NGINX])]
#[BodyParam(name: 'webserver', description: 'Web server', required: true, enum: [Webserver::NONE, Webserver::NGINX, Webserver::CADDY])]
#[BodyParam(name: 'database', description: 'Database', required: true, enum: [Database::NONE, Database::MYSQL57, Database::MYSQL80, Database::MARIADB103, Database::MARIADB104, Database::MARIADB103, Database::POSTGRESQL12, Database::POSTGRESQL13, Database::POSTGRESQL14, Database::POSTGRESQL15, Database::POSTGRESQL16], )]
#[BodyParam(name: 'php', description: 'PHP version', required: true, enum: [PHP::V70, PHP::V71, PHP::V72, PHP::V73, PHP::V74, PHP::V80, PHP::V81, PHP::V82, PHP::V83])]
#[ResponseFromApiResource(ServerResource::class, Server::class)]

View File

@ -3,5 +3,37 @@
namespace App\SSH\Services\Webserver;
use App\SSH\Services\AbstractService;
use Closure;
abstract class AbstractWebserver extends AbstractService implements Webserver {}
abstract class AbstractWebserver extends AbstractService implements Webserver
{
public function creationRules(array $input): array
{
return [
'type' => [
'required',
function (string $attribute, mixed $value, Closure $fail): void {
$webserverExists = $this->service->server->webserver();
if ($webserverExists) {
$fail('You already have a webserver service on the server.');
}
},
],
];
}
public function deletionRules(): array
{
return [
'service' => [
function (string $attribute, mixed $value, Closure $fail): void {
$hasSite = $this->service->server->sites()
->exists();
if ($hasSite) {
$fail('Cannot uninstall webserver while you have websites using it.');
}
},
],
];
}
}

View File

@ -0,0 +1,195 @@
<?php
namespace App\SSH\Services\Webserver;
use App\Exceptions\SSHError;
use App\Exceptions\SSLCreationException;
use App\Models\Site;
use App\Models\Ssl;
use Throwable;
class Caddy extends AbstractWebserver
{
/**
* @throws SSHError
*/
public function install(): void
{
$this->service->server->ssh()->exec(
view('ssh.services.webserver.caddy.install-caddy'),
'install-caddy'
);
$this->service->server->ssh()->write(
'/etc/caddy/Caddyfile',
view('ssh.services.webserver.caddy.caddy'),
'root'
);
$this->service->server->ssh()->write(
'/etc/systemd/system/caddy.service',
view('ssh.services.webserver.caddy.caddy-systemd'),
'root'
);
$this->service->server->systemd()->reload();
$this->service->server->systemd()->restart('caddy');
$this->service->server->os()->cleanup();
}
/**
* @throws SSHError
*/
public function uninstall(): void
{
$this->service->server->ssh()->exec(
view('ssh.services.webserver.caddy.uninstall-caddy'),
'uninstall-caddy'
);
$this->service->server->os()->cleanup();
}
/**
* @throws SSHError
*/
public function createVHost(Site $site): void
{
// We need to get the isolated user first, if the site is isolated
// otherwise, use the default ssh user
$ssh = $this->service->server->ssh($site->user);
$ssh->exec(
view('ssh.services.webserver.caddy.create-path', [
'path' => $site->path,
]),
'create-path',
$site->id
);
$this->service->server->ssh()->write(
'/etc/caddy/sites-available/'.$site->domain,
$this->generateVhost($site),
'root'
);
$this->service->server->ssh()->exec(
view('ssh.services.webserver.caddy.create-vhost', [
'domain' => $site->domain,
]),
'create-vhost',
$site->id
);
}
/**
* @throws SSHError
*/
public function updateVHost(Site $site, ?string $vhost = null): void
{
$this->service->server->ssh()->write(
'/etc/caddy/sites-available/'.$site->domain,
$vhost ?? $this->generateVhost($site),
'root'
);
$this->service->server->systemd()->restart('caddy');
}
/**
* @throws SSHError
*/
public function getVHost(Site $site): string
{
return $this->service->server->ssh()->exec(
view('ssh.services.webserver.caddy.get-vhost', [
'domain' => $site->domain,
]),
);
}
/**
* @throws SSHError
*/
public function deleteSite(Site $site): void
{
$this->service->server->ssh()->exec(
view('ssh.services.webserver.caddy.delete-site', [
'domain' => $site->domain,
'path' => $site->path,
]),
'delete-vhost',
$site->id
);
$this->service->restart();
}
/**
* @throws SSHError
*/
public function changePHPVersion(Site $site, string $version): void
{
$this->service->server->ssh()->exec(
view('ssh.services.webserver.caddy.change-php-version', [
'domain' => $site->domain,
'oldVersion' => $site->php_version,
'newVersion' => $version,
]),
'change-php-version',
$site->id
);
}
/**
* @throws SSHError
*/
public function setupSSL(Ssl $ssl): void
{
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.caddy.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);
}
private function generateVhost(Site $site): string
{
$vhost = view('ssh.services.webserver.caddy.vhost', [
'site' => $site,
]);
return format_nginx_config($vhost);
}
}

View File

@ -6,7 +6,6 @@
use App\Exceptions\SSLCreationException;
use App\Models\Site;
use App\Models\Ssl;
use Closure;
use Throwable;
class Nginx extends AbstractWebserver
@ -34,21 +33,6 @@ public function install(): void
$this->service->server->os()->cleanup();
}
public function deletionRules(): array
{
return [
'service' => [
function (string $attribute, mixed $value, Closure $fail): void {
$hasSite = $this->service->server->sites()
->exists();
if ($hasSite) {
$fail('Cannot uninstall webserver while you have websites using it.');
}
},
],
];
}
/**
* @throws SSHError
*/

View File

@ -87,4 +87,16 @@ public function disable(string $unit): string
return $this->server->ssh()->exec($command, sprintf('disable-%s', $unit));
}
/**
* @throws SSHError
*/
public function reload(): string
{
$command = <<<'EOD'
sudo systemctl daemon-reload
EOD;
return $this->server->ssh()->exec($command, 'reload-systemctl');
}
}

View File

@ -26,6 +26,7 @@
'webservers' => [
\App\Enums\Webserver::NONE,
\App\Enums\Webserver::NGINX,
\App\Enums\Webserver::CADDY,
],
'php_versions' => [
\App\Enums\PHP::NONE,
@ -173,6 +174,7 @@
*/
'service_types' => [
'nginx' => 'webserver',
'caddy' => 'webserver',
'mysql' => 'database',
'mariadb' => 'database',
'postgresql' => 'database',
@ -186,6 +188,7 @@
],
'service_handlers' => [
'nginx' => \App\SSH\Services\Webserver\Nginx::class,
'caddy' => \App\SSH\Services\Webserver\Caddy::class,
'mysql' => \App\SSH\Services\Database\Mysql::class,
'mariadb' => \App\SSH\Services\Database\Mariadb::class,
'postgresql' => \App\SSH\Services\Database\Postgresql::class,
@ -201,6 +204,9 @@
'nginx' => [
'latest',
],
'caddy' => [
'latest',
],
'mysql' => [
'5.7',
'8.0',
@ -273,6 +279,17 @@
'latest' => 'nginx',
],
],
'caddy' => [
\App\Enums\OperatingSystem::UBUNTU20 => [
'latest' => 'caddy',
],
\App\Enums\OperatingSystem::UBUNTU22 => [
'latest' => 'caddy',
],
\App\Enums\OperatingSystem::UBUNTU24 => [
'latest' => 'caddy',
],
],
'mysql' => [
\App\Enums\OperatingSystem::UBUNTU20 => [
'5.7' => 'mysql',

9
resources/svg/caddy.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,19 @@
[Unit]
Description=Caddy web server
Documentation=https://caddyserver.com/docs/
After=network.target network-online.target
Requires=network-online.target
[Service]
Type=notify
ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/Caddyfile
ExecReload=/usr/bin/caddy reload --config /etc/caddy/Caddyfile
TimeoutStopSec=5s
LimitNOFILE=1048576
LimitNPROC=512
PrivateTmp=true
ProtectSystem=full
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,59 @@
{
# Global Errors Log
log {
output file /var/log/caddy/errors.log {
roll_size 100MB
roll_keep 10
roll_keep_for 720h # 30 days
}
format json {
time_format iso8601
}
level ERROR
exclude http.log.access
}
}
# Common snippets
(access_log) {
log {
output file /var/log/caddy/{args[0]}-access.log {
roll_size 100MB
roll_keep 10
roll_keep_for 720h # 30 days
}
format json {
time_format iso8601
}
}
}
(security_headers) {
header {
# Remove server and software information
-Server
-X-Powered-By
-Via
# Security headers
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
X-XSS-Protection "1; mode=block"
Content-Security-Policy "upgrade-insecure-requests"
# Enable compression
defer
}
}
(compression) {
encode {
gzip 6
zstd
minimum_length 1024
}
}
import sites-enabled/*

View File

@ -0,0 +1,9 @@
if ! sudo sed -i 's/php{{ $oldVersion }}/php{{ $newVersion }}/g' /etc/caddy/sites-available/{{ $domain }}; then
echo 'VITO_SSH_ERROR' && exit 1
fi
if ! sudo service caddy restart; then
echo 'VITO_SSH_ERROR' && exit 1
fi
echo "PHP Version Changed to {{ $newVersion }}"

View File

@ -0,0 +1,13 @@
if ! sudo mkdir -p {{ $path }}; then
echo 'VITO_SSH_ERROR' && exit 1
fi
if ! echo "{{ $certificate }}" | sudo tee {{ $certificatePath }}; then
echo 'VITO_SSH_ERROR' && exit 1
fi
if ! echo "{{ $pk }}" | sudo tee {{ $pkPath }}; then
echo 'VITO_SSH_ERROR' && exit 1
fi
echo "Successfully received certificate."

View File

@ -0,0 +1,7 @@
export DEBIAN_FRONTEND=noninteractive
rm -rf {{ $path }}
mkdir {{ $path }}
chmod -R 755 {{ $path }}

View File

@ -0,0 +1,7 @@
if ! sudo ln -s /etc/caddy/sites-available/{{ $domain }} /etc/caddy/sites-enabled/; then
echo 'VITO_SSH_ERROR' && exit 1
fi
if ! sudo service caddy restart; then
echo 'VITO_SSH_ERROR' && exit 1
fi

View File

@ -0,0 +1,7 @@
rm -rf {{ $path }}
sudo rm /etc/caddy/sites-available/{{ $domain }}
sudo rm /etc/caddy/sites-enabled/{{ $domain }}
echo "Site deleted"

View File

@ -0,0 +1 @@
cat /etc/caddy/sites-available/{{ $domain }}

View File

@ -0,0 +1,20 @@
# Add Caddy's GPG key and repository
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | \
sudo tee /etc/apt/sources.list.d/caddy-stable.list
# Install required packages
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
debian-keyring debian-archive-keyring apt-transport-https curl
# Update package list
sudo DEBIAN_FRONTEND=noninteractive apt-get update -y
# Install Caddy
sudo DEBIAN_FRONTEND=noninteractive apt-get install caddy -y
sudo mkdir /etc/caddy/sites-available
sudo mkdir /etc/caddy/sites-enabled

View File

@ -0,0 +1,3 @@
@foreach($site->activeRedirects as $redirect)
redir {{ $redirect->from }} {{ $redirect->to }} {{ $redirect->mode }}
@endforeach

View File

@ -0,0 +1,12 @@
sudo service caddy stop
sudo DEBIAN_FRONTEND=noninteractive sudo apt remove caddy -y
sudo rm -rf /etc/caddy
sudo rm -rf /var/log/caddy
sudo rm -rf /var/lib/caddy
sudo rm -rf /var/cache/caddy
sudo rm -rf /usr/share/caddy
sudo rm -rf /etc/systemd/system/caddy.service
sudo systemctl daemon-reload

View File

@ -0,0 +1,47 @@
{{ $site->domain }} {{ $site->getAliasesString() }} {
@if ($site->activeSsl)
tls {{ $site->activeSsl->certificate_path }} {{ $site->activeSsl->pk_path }}
@endif
@if ($site->activeSsl && $site->force_ssl)
redir @http https://{host}{uri} permanent
@endif
import access_log {{ $site->domain }}
import compression
import security_headers
@if ($site->type()->language() === 'php')
root * {{ $site->getWebDirectoryPath() }}
@php
$phpSocket = "unix//var/run/php/php{$site->php_version}-fpm.sock";
if ($site->isIsolated()) {
$phpSocket = "unix//run/php/php{$site->php_version}-fpm-{$site->user}.sock";
}
@endphp
try_files {path} {path}/ /index.php?{query}
php_fastcgi {{ $phpSocket }}
file_server
@endif
@if ($site->type === \App\Enums\SiteType::LOAD_BALANCER)
reverse_proxy {
@if ($site->loadBalancerServers()->count() > 0)
@foreach($site->loadBalancerServers as $server)
to {{ $server->ip }}:{{ $server->port }}
@endforeach
@else
to 127.0.0.1
@endif
@switch($site->type_data['method'] ?? \App\Enums\LoadBalancerMethod::ROUND_ROBIN)
@case(\App\Enums\LoadBalancerMethod::LEAST_CONNECTIONS)
lb_policy least_conn
@break
@case(\App\Enums\LoadBalancerMethod::IP_HASH)
lb_policy ip_hash
@break
@default
lb_policy round_robin
@endswitch
header_up Host {host}
header_up X-Real-IP {remote}
}
@endif
@include('ssh.services.webserver.caddy.redirects', ['site' => $site])
}

View File

@ -68,6 +68,31 @@ public function test_create_server(): void
]);
}
public function test_create_server_with_caddy(): void
{
Sanctum::actingAs($this->user, ['read', 'write']);
SSH::fake('Active: active'); // fake output for service installations
$this->json('POST', route('api.projects.servers.create', [
'project' => $this->user->current_project_id,
]), [
'provider' => ServerProvider::CUSTOM,
'name' => 'test',
'ip' => '1.1.1.1',
'port' => '22',
'os' => OperatingSystem::UBUNTU22,
'webserver' => Webserver::CADDY,
'database' => Database::MYSQL80,
'php' => '8.2',
])
->assertSuccessful()
->assertJsonFragment([
'name' => 'test',
'type' => ServerType::REGULAR,
]);
}
public function test_delete_server(): void
{
Sanctum::actingAs($this->user, ['read', 'write']);

View File

@ -83,6 +83,63 @@ public function test_create_regular_server(): void
]);
}
public function test_create_regular_server_with_caddy(): void
{
$this->actingAs($this->user);
SSH::fake('Active: active'); // fake output for service installations
Livewire::test(Index::class)
->callAction('create', [
'provider' => ServerProvider::CUSTOM,
'name' => 'caddy-test',
'ip' => '2.2.2.2',
'port' => '22',
'os' => OperatingSystem::UBUNTU22,
'webserver' => Webserver::CADDY,
'database' => Database::MYSQL80,
'php' => '8.2',
])
->assertSuccessful();
$this->assertDatabaseHas('servers', [
'name' => 'caddy-test',
'ip' => '2.2.2.2',
'status' => ServerStatus::READY,
]);
$this->assertDatabaseHas('services', [
'server_id' => 2,
'type' => 'php',
'version' => '8.2',
'status' => ServiceStatus::READY,
]);
$this->assertDatabaseHas('services', [
'server_id' => 2,
'type' => 'webserver',
'name' => 'caddy',
'version' => 'latest',
'status' => ServiceStatus::READY,
]);
$this->assertDatabaseHas('services', [
'server_id' => 2,
'type' => 'database',
'name' => 'mysql',
'version' => '8.0',
'status' => ServiceStatus::READY,
]);
$this->assertDatabaseHas('services', [
'server_id' => 2,
'type' => 'firewall',
'name' => 'ufw',
'version' => 'latest',
'status' => ServiceStatus::READY,
]);
}
public function test_delete_server(): void
{
$this->actingAs($this->user);

View File

@ -75,6 +75,29 @@ public function test_install_nginx(): void
$this->assertNotNull($service->type_data);
}
public function test_install_caddy(): void
{
$this->server->webserver()->delete();
SSH::fake('Active: active');
$service = app(Install::class)->install($this->server, [
'type' => 'webserver',
'name' => 'caddy',
'version' => 'latest',
]);
$this->assertDatabaseHas('services', [
'server_id' => $this->server->id,
'name' => 'caddy',
'type' => 'webserver',
'version' => 'latest',
'status' => ServiceStatus::READY,
]);
$this->assertNotNull($service->type_data);
}
public function test_install_mysql(): void
{
$this->server->database()->delete();

View File

@ -51,6 +51,18 @@ public function test_cannot_uninstall_nginx(): void
app(Uninstall::class)->uninstall($this->server->webserver());
}
/**
* Cannot uninstall caddy because some sites using it
*/
public function test_cannot_uninstall_caddy(): void
{
SSH::fake();
$this->expectException(ValidationException::class);
app(Uninstall::class)->uninstall($this->server->webserver());
}
/**
* Cannot uninstall mysql because some databases exist
*/