Skip to content

Commit 3c9a255

Browse files
authored
Merge pull request #5 from ploi/feat/tenants
Added support for requesting and revoking SSL certs for tenants
2 parents 66d34f7 + a552954 commit 3c9a255

6 files changed

Lines changed: 393 additions & 25 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
namespace App\Commands\Site\Tenant;
4+
5+
use App\Commands\Command as BaseCommand;
6+
use App\Commands\Concerns\InteractWithServer;
7+
use App\Commands\Concerns\InteractWithSite;
8+
use App\Traits\EnsureHasToken;
9+
use App\Traits\HasPloiConfiguration;
10+
use function Laravel\Prompts\confirm;
11+
use function Laravel\Prompts\text;
12+
13+
class CreateTenantCommand extends BaseCommand
14+
{
15+
use EnsureHasToken, HasPloiConfiguration, InteractWithServer, InteractWithSite;
16+
17+
protected $signature = 'tenant:create {--server=} {--site=}';
18+
19+
protected $description = 'Add a tenant (or more) to your site';
20+
21+
public function handle(): void
22+
{
23+
$this->ensureHasToken();
24+
25+
[$serverId, $siteId] = $this->getServerAndSite();
26+
27+
$tenants = text(
28+
label: 'Enter the tenants, separated by comma:',
29+
required: true,
30+
validate: fn (string $value) => match (true) {
31+
strlen($value) <= 0 => 'Please enter at least one domain.',
32+
default => $this->validateDomains($value),
33+
},
34+
hint: 'e.g. example.com, anotherdomain.com'
35+
);
36+
37+
$tenants = explode(',', $tenants);
38+
39+
$data = $this->ploi->createTenant($serverId, $siteId, [
40+
'tenants' => $tenants
41+
]);
42+
43+
if($data){
44+
$this->success('Tenants created successfully');
45+
}
46+
47+
}
48+
49+
function validateDomains(string $value): ?string {
50+
$domains = array_map('trim', explode(',', $value));
51+
52+
foreach ($domains as $domain) {
53+
if (!preg_match('/^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/', $domain)) {
54+
return "Invalid domain format: '$domain'. Please use format like example.com";
55+
}
56+
}
57+
58+
return null;
59+
}
60+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
namespace App\Commands\Site\Tenant;
4+
5+
use App\Commands\Command;
6+
use App\Commands\Concerns\InteractWithServer;
7+
use App\Commands\Concerns\InteractWithSite;
8+
use App\Traits\EnsureHasToken;
9+
use App\Traits\HasPloiConfiguration;
10+
11+
use function Laravel\Prompts\error;
12+
use function Laravel\Prompts\select;
13+
use function Laravel\Prompts\spin;
14+
use function Laravel\Prompts\text;
15+
16+
class DeleteTenantCommand extends Command
17+
{
18+
use EnsureHasToken, HasPloiConfiguration, InteractWithServer, InteractWithSite;
19+
20+
protected $signature = 'tenant:delete {--server=} {--site=} {--tenant= : The name of the tenant to delete} {--force}';
21+
22+
protected $description = 'Delete a tenant';
23+
24+
protected array $site = [];
25+
26+
public function handle()
27+
{
28+
$this->ensureHasToken();
29+
30+
[$serverId, $siteId] = $this->getServerAndSite();
31+
$this->site = $this->ploi->getSiteDetails($serverId, $siteId)['data'];
32+
33+
try {
34+
$tenantName = $this->option('tenant');
35+
if (empty($tenantName)) {
36+
$tenants = $this->ploi->getTenants($serverId, $siteId)['data'];
37+
if (empty($tenants)) {
38+
error('No tenants found on the selected site and server.');
39+
40+
return 1;
41+
}
42+
43+
$tenantName = select(
44+
label: 'Select the tenant to delete:',
45+
options: collect($tenants['tenants'])->mapWithKeys(fn ($tenant) => [$tenant => $tenant])->toArray(),
46+
validate: fn ($value) => ! empty($value) ? null : 'Tenant selection is required.',
47+
);
48+
}
49+
50+
$this->warn('!! This action is irreversible !!');
51+
52+
$confirm = $this->option('force') || text(
53+
label: 'Type the tenant name to confirm deletion: '.$tenantName,
54+
validate: fn (string $value) => match (true) {
55+
$value !== $tenantName => 'The tenant name does not match.',
56+
default => null,
57+
}
58+
);
59+
60+
if (! $confirm) {
61+
$this->info('Tenant deletion aborted.');
62+
63+
return 0;
64+
}
65+
66+
spin(
67+
callback: fn () => $this->ploi->deleteTenant($serverId, $siteId, $tenantName),
68+
message: 'Deleting tenant...',
69+
);
70+
71+
$this->success('Tenant deleted successfully.');
72+
73+
} catch (\Exception $e) {
74+
error('An error occurred while deleting the tenant: '.$e->getMessage());
75+
76+
return 1;
77+
}
78+
79+
return 0;
80+
}
81+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
namespace App\Commands\Site\Tenant;
4+
5+
use App\Commands\Command as BaseCommand;
6+
use App\Commands\Concerns\InteractWithServer;
7+
use App\Commands\Concerns\InteractWithSite;
8+
use App\Traits\EnsureHasToken;
9+
use App\Traits\HasPloiConfiguration;
10+
use function Laravel\Prompts\confirm;
11+
12+
class ListTenantCommand extends BaseCommand
13+
{
14+
use EnsureHasToken, HasPloiConfiguration, InteractWithServer, InteractWithSite;
15+
16+
protected $signature = 'tenant:list {--server=} {--site=}';
17+
18+
protected $description = 'Get all tenants for a site';
19+
20+
protected array $site;
21+
22+
protected array $server;
23+
24+
public function handle(): void
25+
{
26+
$this->ensureHasToken();
27+
28+
[$serverId, $siteId] = $this->getServerAndSite();
29+
$this->site = $this->ploi->getSiteDetails($serverId, $siteId)['data'];
30+
$this->server = $this->ploi->getServerDetails($serverId)['data'];
31+
32+
$tenants = $this->ploi->getTenants($serverId, $siteId)['data'];
33+
34+
if (empty($tenants['tenants'])) {
35+
$this->warn("No tenants found for site {$tenants['main']}.");
36+
37+
if (confirm("Would you like to create a tenant?", 'yes')) {
38+
$this->call('tenant:create', [
39+
'--server' => $this->option('server') ?? $this->server['name'],
40+
'--site' => $this->option('site') ?? $this->site['domain'],
41+
]);
42+
}
43+
return;
44+
}
45+
46+
$this->line("Found {$tenants['count']} tenants for site {$tenants['main']}:");
47+
48+
$headers = ['Tenants'];
49+
$rows = collect($tenants['tenants'])->map(fn ($tenant) => [
50+
$tenant
51+
])->toArray();
52+
53+
$this->table($headers, $rows);
54+
}
55+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
namespace App\Commands\Site\Tenant;
4+
5+
use App\Commands\Command as BaseCommand;
6+
use App\Commands\Concerns\InteractWithServer;
7+
use App\Commands\Concerns\InteractWithSite;
8+
use App\Traits\EnsureHasToken;
9+
use App\Traits\HasPloiConfiguration;
10+
use function Laravel\Prompts\select;
11+
use function Laravel\Prompts\text;
12+
13+
class RequestCertificateTenantCommand extends BaseCommand
14+
{
15+
use EnsureHasToken, HasPloiConfiguration, InteractWithServer, InteractWithSite;
16+
17+
protected $signature = 'tenant:request-certificate {--server=} {--site=} {--tenant= : The tenant to request a certificate for} {--webhook} {--force : Bypasses the Ploi DNS validation}';
18+
19+
protected $description = 'Request an SSL certificate for a tenant';
20+
21+
protected array $site;
22+
protected array $server;
23+
24+
public function handle(): void
25+
{
26+
$this->ensureHasToken();
27+
28+
[$serverId, $siteId] = $this->getServerAndSite();
29+
$this->site = $this->ploi->getSiteDetails($serverId, $siteId)['data'];
30+
$this->server = $this->ploi->getServerDetails($serverId)['data'];
31+
32+
$tenants = $this->ploi->getTenants($serverId, $siteId)['data'];
33+
34+
if (empty($tenants['tenants'])) {
35+
$this->warn("No tenants found for site {$tenants['main']}.");
36+
return;
37+
}
38+
39+
$tenant = $this->option('tenant');
40+
if (!$tenant) {
41+
$tenant = select(
42+
'Select a tenant to request certificate for:',
43+
$tenants['tenants']
44+
);
45+
} elseif (!in_array($tenant, $tenants['tenants'])) {
46+
$this->error("Tenant '{$tenant}' not found.");
47+
return;
48+
}
49+
50+
$domains = text(
51+
label: 'Enter domains for the certificate (comma-separated)',
52+
placeholder: "www.{$tenant}, subdomain.{$tenant}",
53+
default: $tenant,
54+
validate: fn (string $value) => match (true) {
55+
strlen($value) <= 0 => 'Please enter at least one domain.',
56+
default => $this->validateDomains($value),
57+
}
58+
);
59+
60+
$params = [
61+
'domains' => str_replace(' ', '', $domains),
62+
'force' => $this->option('force') ?? false,
63+
];
64+
65+
if ($this->option('webhook')) {
66+
$webhook = text(
67+
label: 'Enter webhook URL',
68+
placeholder: 'https://example.com/webhook',
69+
validate: fn (string $value) => match (true) {
70+
strlen($value) <= 0 => 'Please enter a webhook URL.',
71+
!filter_var($value, FILTER_VALIDATE_URL) => 'Please enter a valid URL.',
72+
default => null,
73+
}
74+
);
75+
76+
$params['webhook'] = $webhook;
77+
}
78+
79+
try {
80+
$response = $this->ploi->requestCertificateTenant($serverId, $siteId, $tenant, $params);
81+
82+
if (isset($response['data'][0]['message']) && str_contains($response['data'][0]['message'], "Let's Encrypt certificate request has been issued")) {
83+
$this->success("Certificate request initiated for tenant: {$tenant}");
84+
$this->info("Domains included: " . $params['domains']);
85+
if (isset($params['webhook'])) {
86+
$this->info("Webhook will be triggered at: {$params['webhook']}");
87+
}
88+
} else {
89+
$this->error("Failed to request certificate: " . ($response['data'][0]['message'] ?? 'Unknown error'));
90+
}
91+
} catch (\Exception $e) {
92+
$this->error("Error requesting certificate: {$e->getMessage()}");
93+
}
94+
}
95+
96+
function validateDomains(string $value): ?string {
97+
$domains = array_map('trim', explode(',', $value));
98+
99+
foreach ($domains as $domain) {
100+
if (!preg_match('/^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/', $domain)) {
101+
return "Invalid domain format: '$domain'. Please use format like example.com";
102+
}
103+
}
104+
105+
return null;
106+
}
107+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
namespace App\Commands\Site\Tenant;
4+
5+
use App\Commands\Command as BaseCommand;
6+
use App\Commands\Concerns\InteractWithServer;
7+
use App\Commands\Concerns\InteractWithSite;
8+
use App\Traits\EnsureHasToken;
9+
use App\Traits\HasPloiConfiguration;
10+
use function Laravel\Prompts\select;
11+
use function Laravel\Prompts\text;
12+
13+
class RevokeCertificateTenantCommand extends BaseCommand
14+
{
15+
use EnsureHasToken, HasPloiConfiguration, InteractWithServer, InteractWithSite;
16+
17+
protected $signature = 'tenant:revoke-certificate {--server=} {--site=} {--tenant= : The tenant to revoke the certificate for} {--webhook}';
18+
19+
protected $description = 'Revoke an SSL certificate for a tenant';
20+
21+
protected array $site;
22+
protected array $server;
23+
24+
public function handle(): void
25+
{
26+
$this->ensureHasToken();
27+
28+
[$serverId, $siteId] = $this->getServerAndSite();
29+
$this->site = $this->ploi->getSiteDetails($serverId, $siteId)['data'];
30+
$this->server = $this->ploi->getServerDetails($serverId)['data'];
31+
32+
$tenants = $this->ploi->getTenants($serverId, $siteId)['data'];
33+
34+
if (empty($tenants['tenants'])) {
35+
$this->warn("No tenants found for site {$tenants['main']}.");
36+
return;
37+
}
38+
39+
$tenant = $this->option('tenant');
40+
if (!$tenant) {
41+
$tenant = select(
42+
'Select a tenant to revoke certificate for:',
43+
$tenants['tenants']
44+
);
45+
} elseif (!in_array($tenant, $tenants['tenants'])) {
46+
$this->error("Tenant '{$tenant}' not found.");
47+
return;
48+
}
49+
50+
$params = [];
51+
if ($this->option('webhook')) {
52+
$webhook = text(
53+
label: 'Enter webhook URL',
54+
placeholder: 'https://example.com/webhook',
55+
validate: fn (string $value) => match (true) {
56+
strlen($value) <= 0 => 'Please enter a webhook URL.',
57+
!filter_var($value, FILTER_VALIDATE_URL) => 'Please enter a valid URL.',
58+
default => null,
59+
}
60+
);
61+
62+
$params['webhook'] = $webhook;
63+
}
64+
65+
try {
66+
$response = $this->ploi->revokeCertificateTenant($serverId, $siteId, $tenant, $params);
67+
68+
if (isset($response['data'][0]['message']) && str_contains($response['data'][0]['message'], "certificate has been revoked")) {
69+
$this->success("Certificate successfully revoked for tenant: {$tenant}");
70+
if (isset($params['webhook'])) {
71+
$this->info("Webhook will be triggered at: {$params['webhook']}");
72+
}
73+
} else {
74+
$this->error("Failed to revoke certificate: " . ($response['data'][0]['message'] ?? 'Unknown error'));
75+
}
76+
} catch (\Exception $e) {
77+
$this->error("Error revoking certificate: {$e->getMessage()}");
78+
}
79+
}
80+
}

0 commit comments

Comments
 (0)