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:
Serreau Jovann
2026-04-04 19:18:05 +02:00
parent 9fa0b1b629
commit bd71f8fcc2
7 changed files with 316 additions and 2 deletions

6
.env
View File

@@ -120,3 +120,9 @@ DISCORD_WEBHOOK=
###> esymail ###
ESYMAIL_DATABASE_URL=
###< esymail ###
###> ovh ###
OVH_KEY=
OVH_SECRET=
OVH_CUSTOMER=
###< ovh ###

View File

@@ -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

View File

@@ -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
View File

@@ -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",

View File

@@ -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
View 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;
}
}

View File

@@ -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>