Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s
Deploy Ladill Hosting / deploy (push) Failing after 17s
Shared web hosting extracted from the platform monolith, with CI deploy to /var/www/ladill-hosting matching Bird/Domains/Email. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Mail;
|
||||
|
||||
use App\Models\Domain;
|
||||
use App\Models\DomainDkimKey;
|
||||
use App\Models\EmailDomain;
|
||||
use App\Models\EmailServer;
|
||||
use App\Models\Mailbox;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MailServerProvisioner
|
||||
{
|
||||
public function __construct(
|
||||
private MailServerConnectionRegistry $connections,
|
||||
private MailServerCapacityService $capacity,
|
||||
) {}
|
||||
|
||||
public function provisionDomain(Domain $domain): int
|
||||
{
|
||||
$server = $this->resolveServer(domain: $domain);
|
||||
$name = rtrim($domain->host, '.');
|
||||
|
||||
$existing = $this->db($server)->table('domains')->where('name', $name)->first();
|
||||
|
||||
if ($existing) {
|
||||
$this->db($server)->table('domains')->where('id', $existing->id)->update(['active' => 1]);
|
||||
Log::info('MailServerProvisioner: Domain already exists, activated', ['domain' => $name, 'server_id' => $server->id]);
|
||||
|
||||
return $existing->id;
|
||||
}
|
||||
|
||||
$id = $this->db($server)->table('domains')->insertGetId([
|
||||
'name' => $name,
|
||||
'active' => 1,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
Log::info('MailServerProvisioner: Domain provisioned', ['domain' => $name, 'server_id' => $server->id, 'id' => $id]);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function provisionMailbox(Mailbox $mailbox, ?string $passwordPlain = null): int
|
||||
{
|
||||
$domain = $mailbox->domain;
|
||||
if (! $domain) {
|
||||
throw new \RuntimeException('Mailbox has no associated domain.');
|
||||
}
|
||||
|
||||
$server = $this->resolveServer(domain: $domain);
|
||||
$mailDomainId = $this->provisionDomain($domain);
|
||||
$email = strtolower(trim($mailbox->address));
|
||||
$localPart = strtolower(trim($mailbox->local_part));
|
||||
$domainName = rtrim($domain->host, '.');
|
||||
$maildir = $this->maildirPath($server, $domainName, $localPart);
|
||||
|
||||
return $this->upsertMailboxRecord($server, $mailDomainId, $email, $localPart, $maildir, $mailbox->quota_mb ?? Mailbox::defaultQuotaMb(), $passwordPlain);
|
||||
}
|
||||
|
||||
public function provisionAlias(Domain $domain, string $source, string $destination): int
|
||||
{
|
||||
$server = $this->resolveServer(domain: $domain);
|
||||
$mailDomainId = $this->provisionDomain($domain);
|
||||
|
||||
$existing = $this->db($server)->table('aliases')->where('source', $source)->first();
|
||||
|
||||
if ($existing) {
|
||||
$this->db($server)->table('aliases')->where('id', $existing->id)->update([
|
||||
'destination' => $destination,
|
||||
'active' => 1,
|
||||
]);
|
||||
|
||||
return $existing->id;
|
||||
}
|
||||
|
||||
return $this->db($server)->table('aliases')->insertGetId([
|
||||
'domain_id' => $mailDomainId,
|
||||
'source' => $source,
|
||||
'destination' => $destination,
|
||||
'active' => 1,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function deactivateDomain(Domain $domain): void
|
||||
{
|
||||
$server = $this->resolveServer(domain: $domain);
|
||||
$name = rtrim($domain->host, '.');
|
||||
$this->db($server)->table('domains')->where('name', $name)->update(['active' => 0]);
|
||||
Log::info('MailServerProvisioner: Domain deactivated', ['domain' => $name, 'server_id' => $server->id]);
|
||||
}
|
||||
|
||||
public function deactivateMailbox(Mailbox $mailbox): void
|
||||
{
|
||||
$server = $this->resolveServer(mailbox: $mailbox);
|
||||
$email = strtolower(trim($mailbox->address));
|
||||
$this->db($server)->table('mailboxes')->where('email', $email)->update(['active' => 0]);
|
||||
Log::info('MailServerProvisioner: Mailbox deactivated', ['email' => $email, 'server_id' => $server->id]);
|
||||
}
|
||||
|
||||
public function deployDkim(Domain $domain, DomainDkimKey $dkim): bool
|
||||
{
|
||||
$server = $this->resolveServer(domain: $domain);
|
||||
$signingServer = $server->mailDatabaseServer();
|
||||
|
||||
return $this->deployDkimViaSsh($signingServer, rtrim($domain->host, '.'), $dkim);
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
if (EmailServer::query()->whereIn('status', [EmailServer::STATUS_ACTIVE, EmailServer::STATUS_FULL])->exists()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$password = config('database.connections.mailserver.password');
|
||||
|
||||
return is_string($password) && $password !== '';
|
||||
}
|
||||
|
||||
public function provisionEmailDomain(EmailDomain $emailDomain): int
|
||||
{
|
||||
$server = $this->resolveServer(emailDomain: $emailDomain);
|
||||
$name = rtrim($emailDomain->domain, '.');
|
||||
|
||||
$existing = $this->db($server)->table('domains')->where('name', $name)->first();
|
||||
|
||||
if ($existing) {
|
||||
$this->db($server)->table('domains')->where('id', $existing->id)->update(['active' => 1]);
|
||||
|
||||
return $existing->id;
|
||||
}
|
||||
|
||||
$id = $this->db($server)->table('domains')->insertGetId([
|
||||
'name' => $name,
|
||||
'active' => 1,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
Log::info('MailServerProvisioner: Email domain provisioned', ['domain' => $name, 'server_id' => $server->id]);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function provisionStandaloneMailbox(Mailbox $mailbox, ?string $passwordPlain = null): int
|
||||
{
|
||||
$emailDomain = $mailbox->emailDomain;
|
||||
if (! $emailDomain) {
|
||||
throw new \RuntimeException('Standalone mailbox has no associated email domain.');
|
||||
}
|
||||
|
||||
$server = $this->resolveServer(emailDomain: $emailDomain);
|
||||
$mailDomainId = $this->provisionEmailDomain($emailDomain);
|
||||
$email = strtolower(trim($mailbox->address));
|
||||
$localPart = strtolower(trim($mailbox->local_part));
|
||||
$domainName = rtrim($emailDomain->domain, '.');
|
||||
$maildir = $this->maildirPath($server, $domainName, $localPart);
|
||||
|
||||
return $this->upsertMailboxRecord($server, $mailDomainId, $email, $localPart, $maildir, $mailbox->quota_mb ?? Mailbox::defaultQuotaMb(), $passwordPlain);
|
||||
}
|
||||
|
||||
public function deprovisionEmailDomain(EmailDomain $emailDomain): void
|
||||
{
|
||||
$server = $this->resolveServer(emailDomain: $emailDomain);
|
||||
$name = rtrim($emailDomain->domain, '.');
|
||||
|
||||
$domainRecord = $this->db($server)->table('domains')->where('name', $name)->first();
|
||||
if ($domainRecord) {
|
||||
$this->db($server)->table('mailboxes')->where('domain_id', $domainRecord->id)->update(['active' => 0]);
|
||||
$this->db($server)->table('domains')->where('id', $domainRecord->id)->update(['active' => 0]);
|
||||
}
|
||||
|
||||
Log::info('MailServerProvisioner: Email domain deprovisioned', ['domain' => $name, 'server_id' => $server->id]);
|
||||
}
|
||||
|
||||
private function upsertMailboxRecord(
|
||||
EmailServer $server,
|
||||
int $mailDomainId,
|
||||
string $email,
|
||||
string $localPart,
|
||||
string $maildir,
|
||||
int $quotaMb,
|
||||
?string $passwordPlain,
|
||||
): int {
|
||||
$existing = $this->db($server)->table('mailboxes')->where('email', $email)->first();
|
||||
|
||||
if ($existing) {
|
||||
$update = [
|
||||
'active' => 1,
|
||||
'quota_mb' => $quotaMb,
|
||||
'maildir' => $maildir,
|
||||
];
|
||||
|
||||
if ($passwordPlain !== null && $passwordPlain !== '') {
|
||||
$update['password_hash'] = $this->hashPassword($passwordPlain);
|
||||
}
|
||||
|
||||
$this->db($server)->table('mailboxes')->where('id', $existing->id)->update($update);
|
||||
|
||||
return $existing->id;
|
||||
}
|
||||
|
||||
if ($passwordPlain === null || $passwordPlain === '') {
|
||||
throw new \RuntimeException("Cannot provision new mailbox {$email} without a password.");
|
||||
}
|
||||
|
||||
$id = $this->db($server)->table('mailboxes')->insertGetId([
|
||||
'domain_id' => $mailDomainId,
|
||||
'local_part' => $localPart,
|
||||
'email' => $email,
|
||||
'password_hash' => $this->hashPassword($passwordPlain),
|
||||
'quota_mb' => $quotaMb,
|
||||
'active' => 1,
|
||||
'maildir' => $maildir,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->capacity->refreshServerMetrics($server);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
private function maildirPath(EmailServer $server, string $domainName, string $localPart): string
|
||||
{
|
||||
return "{$domainName}/{$localPart}/";
|
||||
}
|
||||
|
||||
private function deployDkimViaSsh(EmailServer $server, string $domainName, DomainDkimKey $dkim): bool
|
||||
{
|
||||
$sshHost = $server->ssh_host ?: $server->ip_address;
|
||||
$sshUser = $server->ssh_user ?: 'root';
|
||||
$sshKey = $server->ssh_private_key;
|
||||
|
||||
if (empty($sshHost) || empty($sshKey)) {
|
||||
$sshHost = config('mailinfra.mail_ssh_host');
|
||||
$sshUser = config('mailinfra.mail_ssh_user', 'root');
|
||||
$sshKeyPath = config('mailinfra.mail_ssh_key_path');
|
||||
|
||||
if (empty($sshHost) || empty($sshKeyPath)) {
|
||||
Log::warning('MailServerProvisioner: SSH not configured, skipping DKIM deployment', ['domain' => $domainName]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->deployDkimWithKeyPath($sshHost, $sshUser, $sshKeyPath, $domainName, $dkim);
|
||||
}
|
||||
|
||||
$tmpKey = tempnam(sys_get_temp_dir(), 'ladill-mail-ssh-');
|
||||
file_put_contents($tmpKey, $sshKey);
|
||||
chmod($tmpKey, 0600);
|
||||
|
||||
try {
|
||||
return $this->deployDkimWithKeyPath($sshHost, $sshUser, $tmpKey, $domainName, $dkim);
|
||||
} finally {
|
||||
@unlink($tmpKey);
|
||||
}
|
||||
}
|
||||
|
||||
private function deployDkimWithKeyPath(string $sshHost, string $sshUser, string $sshKeyPath, string $domainName, DomainDkimKey $dkim): bool
|
||||
{
|
||||
$selector = $dkim->selector;
|
||||
$privatePath = $dkim->private_key_path;
|
||||
|
||||
if (! $privatePath || ! file_exists($privatePath)) {
|
||||
Log::warning('MailServerProvisioner: DKIM private key file not found', ['domain' => $domainName, 'path' => $privatePath]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$privateKey = file_get_contents($privatePath);
|
||||
$remoteKeyDir = "/etc/opendkim/keys/{$domainName}";
|
||||
$remoteKeyFile = "{$remoteKeyDir}/{$selector}.private";
|
||||
$escapedKey = base64_encode($privateKey);
|
||||
$keyTableEntry = "{$selector}._domainkey.{$domainName} {$domainName}:{$selector}:{$remoteKeyFile}";
|
||||
$signingTableEntry = "*@{$domainName} {$selector}._domainkey.{$domainName}";
|
||||
|
||||
$commands = [
|
||||
"mkdir -p {$remoteKeyDir}",
|
||||
"chown opendkim:opendkim {$remoteKeyDir}",
|
||||
"echo '{$escapedKey}' | base64 -d > {$remoteKeyFile}",
|
||||
"chown opendkim:opendkim {$remoteKeyFile}",
|
||||
"chmod 600 {$remoteKeyFile}",
|
||||
"grep -qF '{$domainName}' /etc/opendkim/key.table || echo '{$keyTableEntry}' >> /etc/opendkim/key.table",
|
||||
"grep -qF '{$domainName}' /etc/opendkim/signing.table || echo '{$signingTableEntry}' >> /etc/opendkim/signing.table",
|
||||
'systemctl reload opendkim 2>/dev/null || service opendkim restart 2>/dev/null || true',
|
||||
];
|
||||
|
||||
$sshBase = "ssh -i {$sshKeyPath} -o StrictHostKeyChecking=no {$sshUser}@{$sshHost}";
|
||||
$fullCommand = $sshBase.' "'.implode(' && ', $commands).'"';
|
||||
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($fullCommand.' 2>&1', $output, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
Log::error('MailServerProvisioner: DKIM deployment failed', [
|
||||
'domain' => $domainName,
|
||||
'exit_code' => $exitCode,
|
||||
'output' => implode("\n", $output),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Log::info('MailServerProvisioner: DKIM deployed', ['domain' => $domainName, 'selector' => $selector]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function hashPassword(string $password): string
|
||||
{
|
||||
return '{BLF-CRYPT}'.password_hash($password, PASSWORD_BCRYPT);
|
||||
}
|
||||
|
||||
private function db(?EmailServer $server = null): ConnectionInterface
|
||||
{
|
||||
if ($server) {
|
||||
return $this->connections->connection($server->mailDatabaseServer());
|
||||
}
|
||||
|
||||
return DB::connection('mailserver');
|
||||
}
|
||||
|
||||
private function resolveServer(
|
||||
?EmailServer $server = null,
|
||||
?EmailDomain $emailDomain = null,
|
||||
?Domain $domain = null,
|
||||
?Mailbox $mailbox = null,
|
||||
): EmailServer {
|
||||
if ($server) {
|
||||
return $server;
|
||||
}
|
||||
|
||||
if ($mailbox?->emailDomain?->emailServer) {
|
||||
return $mailbox->emailDomain->emailServer;
|
||||
}
|
||||
|
||||
if ($mailbox?->domain?->emailServer) {
|
||||
return $mailbox->domain->emailServer;
|
||||
}
|
||||
|
||||
if ($emailDomain) {
|
||||
if ($emailDomain->email_server_id) {
|
||||
return $emailDomain->emailServer;
|
||||
}
|
||||
|
||||
return $this->capacity->assignServerToEmailDomain($emailDomain);
|
||||
}
|
||||
|
||||
if ($domain) {
|
||||
if ($domain->email_server_id) {
|
||||
return $domain->emailServer;
|
||||
}
|
||||
|
||||
return $this->capacity->assignServerToDomain($domain);
|
||||
}
|
||||
|
||||
$fallback = EmailServer::available()->where('is_default', true)->first()
|
||||
?? EmailServer::available()->first();
|
||||
|
||||
if ($fallback) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
throw new \RuntimeException('No email server configured. Add a primary mail pool in Admin → Email Servers or set MAIL_DB_* in .env.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user