feat: gestion NDD avec auto-détection OVH/Cloudflare + service OvhService
OvhService (ovh/ovh SDK) :
- listDomains() : liste tous les NDD du compte OVH
- isDomainManaged(fqdn) : vérifie si un domaine est chez OVH
- getDomainInfo(fqdn) : infos domaine (nameServerType, offer, etc.)
- getDomainServiceInfo(fqdn) : expiration, création, status, contacts
- getZoneInfo(fqdn) : zone DNS (OVH ou externe, DNSSEC)
Ajout NDD (onglet Noms de domaine, fiche client) :
- Formulaire : nom de domaine + registrar (auto-détection ou manuel)
- autoDetectDomain() au submit :
1. Check OVH : si trouvé → registrar=OVH, isGestion=true, isBilling=true,
expiredAt depuis serviceInfos, check zone DNS OVH
2. Check Cloudflare : si zone trouvée → zoneCloudflare=active,
zoneIdCloudflare=zoneId. Si registrar=Cloudflare → gestion+billing actifs
3. Si ni OVH ni CF : registrar manuel (Gandi/Autre), isGestion=false
- Suppression NDD avec data-confirm modal
- Colonne Actions ajoutée dans la table
Configuration :
- .env : OVH_KEY, OVH_SECRET, OVH_CUSTOMER
- .env.local : credentials OVH
- ansible/vault.yml : credentials OVH pour prod
- composer.json : ovh/ovh ^3.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
6
.env
6
.env
@@ -120,3 +120,9 @@ DISCORD_WEBHOOK=
|
||||
###> esymail ###
|
||||
ESYMAIL_DATABASE_URL=
|
||||
###< esymail ###
|
||||
|
||||
###> ovh ###
|
||||
OVH_KEY=
|
||||
OVH_SECRET=
|
||||
OVH_CUSTOMER=
|
||||
###< ovh ###
|
||||
|
||||
@@ -20,6 +20,9 @@ mailcow_api_key: DF0E7E-0FD059-16226F-8ECFF1-E558B3
|
||||
docuseal_api: pgAU116mCFmeF7WQSezHqxtZW8V1fgo31u5d2FXoaKe
|
||||
docuseal_webhooks_secret: CRM_COSLAY
|
||||
discord_webhook: https://discord.com/api/webhooks/1419573620602044518/ikAdxWxsrrTqMTb5Gh_8ylcoJHlOnq7aJZvR5udoS_fCK56Jk3qpEnJHVKdD8fwuNJF3
|
||||
ovh_key: 34bc2c2eb416b67d
|
||||
ovh_secret: 12239d273975b5ab53318907fb66d355
|
||||
ovh_customer: 56c387eb9ca4b9a2de4d4d97fd3d7f22
|
||||
smime_private_key: |
|
||||
Bag Attributes
|
||||
localKeyID: 75 15 E3 C2 1D 7B 61 75 99 B9 22 D8 FD A4 19 AC 6B BE 1F 8F
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"meilisearch/meilisearch-php": "^1.16",
|
||||
"mobiledetect/mobiledetectlib": ">=4.8.10",
|
||||
"nelmio/security-bundle": "^3.9",
|
||||
"ovh/ovh": "^3.5",
|
||||
"phpdocumentor/reflection-docblock": "^6.0.3",
|
||||
"phpstan/phpdoc-parser": "^2.3.2",
|
||||
"scheb/2fa-backup-code": "^8.5",
|
||||
|
||||
53
composer.lock
generated
53
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "072dae6684c5bfe1ec9d9f2ffbd712ae",
|
||||
"content-hash": "c89df7b95449dfc734150e681bc309f2",
|
||||
"packages": [
|
||||
{
|
||||
"name": "async-aws/core",
|
||||
@@ -3579,6 +3579,57 @@
|
||||
},
|
||||
"time": "2026-02-23T10:58:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "ovh/ovh",
|
||||
"version": "v3.5.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ovh/php-ovh.git",
|
||||
"reference": "917ae0332cb6e2559d3b5045ef9107b32e07cd2d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/ovh/php-ovh/zipball/917ae0332cb6e2559d3b5045ef9107b32e07cd2d",
|
||||
"reference": "917ae0332cb6e2559d3b5045ef9107b32e07cd2d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"guzzlehttp/guzzle": "^6.0||^7.0",
|
||||
"league/oauth2-client": "^2.7",
|
||||
"php": ">=7.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-parallel-lint/php-parallel-lint": "^1.3.1",
|
||||
"phpdocumentor/shim": "^3",
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"squizlabs/php_codesniffer": "^3.6"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Ovh\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"description": "Wrapper for OVHcloud APIs",
|
||||
"keywords": [
|
||||
"api",
|
||||
"authorisation",
|
||||
"authorization",
|
||||
"client",
|
||||
"ovh",
|
||||
"ovhcloud"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/ovh/php-ovh/issues",
|
||||
"source": "https://github.com/ovh/php-ovh/tree/v3.5.0"
|
||||
},
|
||||
"time": "2025-01-02T16:09:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "paragonie/constant_time_encoding",
|
||||
"version": "v3.1.3",
|
||||
|
||||
@@ -5,8 +5,11 @@ namespace App\Controller\Admin;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\User;
|
||||
use App\Repository\CustomerRepository;
|
||||
use App\Entity\Domain;
|
||||
use App\Service\CloudflareService;
|
||||
use App\Service\MailerService;
|
||||
use App\Service\MeilisearchService;
|
||||
use App\Service\OvhService;
|
||||
use App\Service\UserManagementService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -312,7 +315,7 @@ class ClientsController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'show')]
|
||||
public function show(Customer $customer, Request $request, EntityManagerInterface $em): Response
|
||||
public function show(Customer $customer, Request $request, EntityManagerInterface $em, OvhService $ovhService, CloudflareService $cloudflareService): Response
|
||||
{
|
||||
$tab = $request->query->getString('tab', 'info');
|
||||
|
||||
@@ -329,6 +332,10 @@ class ClientsController extends AbstractController
|
||||
return $this->handleContactForm($request, $customer, $em);
|
||||
}
|
||||
|
||||
if ('POST' === $request->getMethod() && 'ndd' === $tab) {
|
||||
return $this->handleDomainForm($request, $customer, $em, $ovhService, $cloudflareService);
|
||||
}
|
||||
|
||||
$this->ensureDefaultContact($customer, $em);
|
||||
$contacts = $em->getRepository(\App\Entity\CustomerContact::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
||||
$domains = $em->getRepository(\App\Entity\Domain::class)->findBy(['customer' => $customer]);
|
||||
@@ -374,6 +381,94 @@ class ClientsController extends AbstractController
|
||||
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId(), 'tab' => 'contacts']);
|
||||
}
|
||||
|
||||
private function handleDomainForm(Request $request, Customer $customer, EntityManagerInterface $em, OvhService $ovhService, CloudflareService $cloudflareService): Response
|
||||
{
|
||||
$action = $request->request->getString('domain_action');
|
||||
|
||||
if ('create' === $action) {
|
||||
$fqdn = strtolower(trim($request->request->getString('domain_fqdn')));
|
||||
$registrar = $request->request->getString('domain_registrar') ?: null;
|
||||
|
||||
if ('' === $fqdn) {
|
||||
$this->addFlash('error', 'Le nom de domaine est requis.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId(), 'tab' => 'ndd']);
|
||||
}
|
||||
|
||||
$existing = $em->getRepository(Domain::class)->findOneBy(['fqdn' => $fqdn]);
|
||||
if (null !== $existing) {
|
||||
$this->addFlash('error', 'Le domaine '.$fqdn.' existe deja.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId(), 'tab' => 'ndd']);
|
||||
}
|
||||
|
||||
$domain = new Domain($customer, $fqdn);
|
||||
$domain->setRegistrar($registrar);
|
||||
|
||||
$this->autoDetectDomain($domain, $ovhService, $cloudflareService);
|
||||
|
||||
$em->persist($domain);
|
||||
$em->flush();
|
||||
|
||||
$this->addFlash('success', 'Domaine '.$fqdn.' ajoute.');
|
||||
}
|
||||
|
||||
if ('delete' === $action) {
|
||||
$domainId = $request->request->getInt('domain_id');
|
||||
$domain = $em->getRepository(Domain::class)->find($domainId);
|
||||
if (null !== $domain && $domain->getCustomer() === $customer) {
|
||||
$em->remove($domain);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Domaine supprime.');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId(), 'tab' => 'ndd']);
|
||||
}
|
||||
|
||||
private function autoDetectDomain(Domain $domain, OvhService $ovhService, CloudflareService $cloudflareService): void
|
||||
{
|
||||
$fqdn = $domain->getFqdn();
|
||||
|
||||
// Check OVH
|
||||
if ($ovhService->isDomainManaged($fqdn)) {
|
||||
$domain->setRegistrar('OVH');
|
||||
$domain->setIsGestion(true);
|
||||
$domain->setIsBilling(true);
|
||||
|
||||
$serviceInfo = $ovhService->getDomainServiceInfo($fqdn);
|
||||
if (null !== $serviceInfo) {
|
||||
if (isset($serviceInfo['expiration'])) {
|
||||
$domain->setExpiredAt(new \DateTimeImmutable($serviceInfo['expiration']));
|
||||
}
|
||||
if (isset($serviceInfo['creation'])) {
|
||||
$domain->setUpdatedAt(new \DateTimeImmutable());
|
||||
}
|
||||
}
|
||||
|
||||
$zoneInfo = $ovhService->getZoneInfo($fqdn);
|
||||
if (null !== $zoneInfo) {
|
||||
$domain->setZoneCloudflare(null);
|
||||
$domain->setZoneIdCloudflare(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Check Cloudflare
|
||||
if ($cloudflareService->isAvailable()) {
|
||||
$zoneId = $cloudflareService->getZoneId($fqdn);
|
||||
if (null !== $zoneId) {
|
||||
$domain->setZoneCloudflare('active');
|
||||
$domain->setZoneIdCloudflare($zoneId);
|
||||
|
||||
if (null === $domain->getRegistrar()) {
|
||||
$domain->setRegistrar('Cloudflare');
|
||||
$domain->setIsGestion(true);
|
||||
$domain->setIsBilling(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/{id}/resend-welcome', name: 'resend_welcome', methods: ['POST'])]
|
||||
public function resendWelcome(Customer $customer, MailerService $mailer, Environment $twig): Response
|
||||
{
|
||||
|
||||
127
src/Service/OvhService.php
Normal file
127
src/Service/OvhService.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Ovh\Api;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
class OvhService
|
||||
{
|
||||
private ?Api $api = null;
|
||||
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
#[Autowire(env: 'OVH_KEY')] private string $appKey = '',
|
||||
#[Autowire(env: 'OVH_SECRET')] private string $appSecret = '',
|
||||
#[Autowire(env: 'OVH_CUSTOMER')] private string $consumerKey = '',
|
||||
) {
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return '' !== $this->appKey && '' !== $this->appSecret && '' !== $this->consumerKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste tous les NDD du compte OVH.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function listDomains(): array
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->getApi()->get('/domain');
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('OVH: erreur listDomains: '.$e->getMessage());
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un domaine est géré par notre compte OVH.
|
||||
*/
|
||||
public function isDomainManaged(string $fqdn): bool
|
||||
{
|
||||
return \in_array($fqdn, $this->listDomains(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les infos d'un domaine OVH.
|
||||
*
|
||||
* @return array{domain: string, nameServerType: string, offer: string, transferLockStatus: string, whoisOwner: string}|null
|
||||
*/
|
||||
public function getDomainInfo(string $fqdn): ?array
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->getApi()->get('/domain/'.$fqdn);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('OVH: erreur getDomainInfo '.$fqdn.': '.$e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les infos du service (expiration, création, statut).
|
||||
*
|
||||
* @return array{domain: string, expiration: string, creation: string, status: string, contactBilling: string, contactTech: string, contactAdmin: string}|null
|
||||
*/
|
||||
public function getDomainServiceInfo(string $fqdn): ?array
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->getApi()->get('/domain/'.$fqdn.'/serviceInfos');
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('OVH: erreur getDomainServiceInfo '.$fqdn.': '.$e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la zone DNS (OVH ou externe).
|
||||
*
|
||||
* @return array{name: string, hasDnsAnycast: bool, dnssecSupported: bool}|null
|
||||
*/
|
||||
public function getZoneInfo(string $fqdn): ?array
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->getApi()->get('/domain/zone/'.$fqdn);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('OVH: zone non trouvee pour '.$fqdn);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function getApi(): Api
|
||||
{
|
||||
if (null === $this->api) {
|
||||
$this->api = new Api(
|
||||
$this->appKey,
|
||||
$this->appSecret,
|
||||
'ovh-eu',
|
||||
$this->consumerKey,
|
||||
);
|
||||
}
|
||||
|
||||
return $this->api;
|
||||
}
|
||||
}
|
||||
@@ -279,6 +279,29 @@
|
||||
|
||||
{# Tab: Noms de domaine #}
|
||||
{% elseif tab == 'ndd' %}
|
||||
<section class="glass p-6 mb-6">
|
||||
<h2 class="text-sm font-bold uppercase tracking-wider mb-4">Ajouter un nom de domaine</h2>
|
||||
<form method="post" action="{{ path('app_admin_clients_show', {id: customer.id, tab: 'ndd'}) }}" class="flex flex-wrap items-end gap-4">
|
||||
<input type="hidden" name="domain_action" value="create">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<label for="domain_fqdn" class="block text-xs font-bold uppercase tracking-wider mb-2">Nom de domaine *</label>
|
||||
<input type="text" id="domain_fqdn" name="domain_fqdn" required placeholder="exemple.fr" class="w-full px-4 py-3 input-glass text-sm font-medium font-mono">
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<label for="domain_registrar" class="block text-xs font-bold uppercase tracking-wider mb-2">Registrar</label>
|
||||
<select id="domain_registrar" name="domain_registrar" class="w-full px-4 py-3 glass text-sm font-bold">
|
||||
<option value="">Auto-detection</option>
|
||||
<option value="OVH">OVH</option>
|
||||
<option value="Gandi">Gandi</option>
|
||||
<option value="Cloudflare">Cloudflare</option>
|
||||
<option value="Autre">Autre</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="px-5 py-3 btn-gold text-xs font-bold uppercase tracking-wider text-gray-900">Ajouter</button>
|
||||
</form>
|
||||
<p class="text-[10px] text-gray-400 mt-2">Le domaine sera automatiquement verifie sur OVH et Cloudflare. Si detecte : gestion et facturation actives, date d'expiration synchronisee.</p>
|
||||
</section>
|
||||
|
||||
{% if domains|length > 0 %}
|
||||
<div class="glass overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
@@ -290,6 +313,7 @@
|
||||
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Gestion</th>
|
||||
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Facturation</th>
|
||||
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Expiration</th>
|
||||
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -319,6 +343,13 @@
|
||||
<span class="text-gray-300">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<form method="post" action="{{ path('app_admin_clients_show', {id: customer.id, tab: 'ndd'}) }}" data-confirm="Supprimer le domaine {{ domain.fqdn }} ?">
|
||||
<input type="hidden" name="domain_action" value="delete">
|
||||
<input type="hidden" name="domain_id" value="{{ domain.id }}">
|
||||
<button type="submit" class="px-2 py-1 border-2 border-red-600 bg-white text-red-600 font-bold uppercase text-[10px] tracking-widest hover:bg-red-600 hover:text-white transition-all">Supprimer</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user