Skip to content

Commit bc2b9b2

Browse files
committed
Resolve merge conflict; improve chain-selection coverage
2 parents ea1d469 + e9f2e6f commit bc2b9b2

5 files changed

Lines changed: 265 additions & 6 deletions

File tree

README.md

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ echo $cert->caBundle; // PEM intermediate chain
120120
- [Challenge handlers](#challenge-handlers)
121121
- [Storage backends](#storage-backends)
122122
- [Issuing certificates](#issuing-certificates)
123+
- [Event callbacks](#event-callbacks)
123124
- [CAA pre-check](#caa-pre-check)
124125
- [Wildcard and multi-domain certificates](#wildcard-and-multi-domain-certificates)
125126
- [IP address certificates](#ip-address-certificates-rfc-8738)
@@ -538,6 +539,47 @@ Returns `true` when:
538539

539540
---
540541

542+
## Event callbacks
543+
544+
Register callbacks on the builder to react to certificate lifecycle events without subclassing or parsing log output. Useful for reloading a web server, pushing secrets to a vault, or sending a Slack notification.
545+
546+
### onIssued
547+
548+
Fires after every successful certificate issuance — whether first-time or a renewal.
549+
550+
```php
551+
CoyoteCert::with(new LetsEncrypt())
552+
->storage(new FilesystemStorage('/var/certs'))
553+
->identifiers('example.com')
554+
->challenge(new Http01Handler('/var/www/html'))
555+
->onIssued(function (StoredCertificate $cert): void {
556+
SecretsManager::push('tls/example.com', [
557+
'cert' => $cert->certificate,
558+
'key' => $cert->privateKey,
559+
'fullchain'=> $cert->fullchain,
560+
]);
561+
})
562+
->issueOrRenew();
563+
```
564+
565+
### onRenewed
566+
567+
Fires only when an existing certificate is replaced — i.e. storage already held a cert before the new one was issued. Fires _after_ `onIssued` callbacks.
568+
569+
```php
570+
CoyoteCert::with(new LetsEncrypt())
571+
->storage(new FilesystemStorage('/var/certs'))
572+
->identifiers('example.com')
573+
->challenge(new Http01Handler('/var/www/html'))
574+
->onIssued(fn($cert) => SecretsManager::push('tls/example.com', $cert->toArray()))
575+
->onRenewed(fn($cert) => Nginx::reload())
576+
->issueOrRenew();
577+
```
578+
579+
Both methods accept any `callable` and can be called multiple times. Callbacks run in registration order, after the certificate has been saved to storage.
580+
581+
---
582+
541583
## CAA pre-check
542584

543585
[CAA (Certification Authority Authorization)](https://en.wikipedia.org/wiki/DNS_Certification_Authority_Authorization) is a DNS record type that restricts which CAs are allowed to issue certificates for a domain. If `example.com` has `CAA 0 issue "digicert.com"`, Let's Encrypt will refuse the order — but only after you have consumed a rate-limit attempt and waited for the ACME workflow to fail.
@@ -661,10 +703,8 @@ $cert = CoyoteCert::with(new LetsEncrypt())
661703
->identifiers(['example.com', 'www.example.com'])
662704
->challenge(new Http01Handler('/var/www/html'))
663705
->logger($logger)
706+
->onRenewed(fn($cert) => exec('systemctl reload nginx'))
664707
->issueOrRenew(daysBeforeExpiry: 30);
665-
666-
// Reload web server only if a new certificate was issued
667-
// (compare serial or expiry to detect renewal)
668708
```
669709

670710
Add to crontab. Daily is sufficient; `issueOrRenew()` skips the CA call when nothing is due:
@@ -768,7 +808,6 @@ $coyote->revoke($cert, RevocationReason::AffiliationChanged);
768808
$coyote->revoke($cert, RevocationReason::Superseded);
769809
$coyote->revoke($cert, RevocationReason::CessationOfOperation);
770810
$coyote->revoke($cert, RevocationReason::CertificateHold);
771-
$coyote->revoke($cert, RevocationReason::RemoveFromCrl);
772811
$coyote->revoke($cert, RevocationReason::PrivilegeWithdrawn);
773812
$coyote->revoke($cert, RevocationReason::AaCompromise);
774813
```
@@ -921,6 +960,8 @@ CoyoteCert::with(AcmeProviderInterface $provider) // factory — select the CA
921960
| `->preferredChain(string)` | fluent | `''` | Preferred chain issuer CN/O (RFC 8555 §7.4.2); falls back to default chain if no match |
922961
| `->skipLocalTest()` | fluent | off | Disable pre-flight HTTP/DNS self-check |
923962
| `->skipCaaCheck()` | fluent | off | Disable CAA DNS pre-check (internal CAs, split-horizon DNS) |
963+
| `->onIssued(callable)` | fluent | none | Callback fired after every successful issuance; receives `StoredCertificate` |
964+
| `->onRenewed(callable)` | fluent | none | Callback fired when an existing cert is replaced; receives `StoredCertificate` |
924965
| `->issue()` | terminal || Issue unconditionally; returns `StoredCertificate` |
925966
| `->renew()` | terminal || Alias for `issue()` |
926967
| `->issueOrRenew(int $days = 30)` | terminal || Issue only when needed; returns `StoredCertificate` |

src/CoyoteCert.php

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ class CoyoteCert
5858
private bool $localTest = true;
5959
private bool $skipCaaCheck = false;
6060
private string $preferredChain = '';
61+
/** @var callable[] */
62+
private array $onIssuedCallbacks = [];
63+
/** @var callable[] */
64+
private array $onRenewedCallbacks = [];
6165

6266
private function __construct(private readonly AcmeProviderInterface $provider) {}
6367

@@ -198,6 +202,31 @@ public function preferredChain(string $issuer): self
198202
return $this;
199203
}
200204

205+
/**
206+
* Register a callback invoked after every successful certificate issuance.
207+
* The callback receives the issued StoredCertificate as its sole argument.
208+
* Multiple callbacks may be registered; they run in registration order.
209+
*/
210+
public function onIssued(callable $callback): self
211+
{
212+
$this->onIssuedCallbacks[] = $callback;
213+
214+
return $this;
215+
}
216+
217+
/**
218+
* Register a callback invoked after a certificate is renewed (i.e. an existing
219+
* certificate was replaced). Fires in addition to onIssued callbacks.
220+
* The callback receives the new StoredCertificate as its sole argument.
221+
* Multiple callbacks may be registered; they run in registration order.
222+
*/
223+
public function onRenewed(callable $callback): self
224+
{
225+
$this->onRenewedCallbacks[] = $callback;
226+
227+
return $this;
228+
}
229+
201230
/**
202231
* Set the HTTP timeout in seconds for the built-in curl client.
203232
* No-op when a custom PSR-18 client is configured.
@@ -295,7 +324,10 @@ public function issue(): StoredCertificate
295324
// Refresh order — status transitions pending → ready after all challenges pass
296325
$order = $api->order()->refresh($order);
297326

298-
return $this->fetchAndStoreCertificate($api, $order);
327+
$stored = $this->fetchAndStoreCertificate($api, $order);
328+
$this->fireIssuedCallbacks($stored, isRenewal: $existingCert !== null);
329+
330+
return $stored;
299331
}
300332

301333
// ── Private issue() helpers ───────────────────────────────────────────────
@@ -377,6 +409,19 @@ private function fetchAndStoreCertificate(
377409
return $stored;
378410
}
379411

412+
private function fireIssuedCallbacks(StoredCertificate $cert, bool $isRenewal): void
413+
{
414+
foreach ($this->onIssuedCallbacks as $cb) {
415+
$cb($cert);
416+
}
417+
418+
if ($isRenewal) {
419+
foreach ($this->onRenewedCallbacks as $cb) {
420+
$cb($cert);
421+
}
422+
}
423+
}
424+
380425
/**
381426
* Alias for issue() — forces a fresh certificate regardless of expiry.
382427
*/

src/Enums/RevocationReason.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ enum RevocationReason: int
1111
case Superseded = 4;
1212
case CessationOfOperation = 5;
1313
case CertificateHold = 6;
14-
case RemoveFromCrl = 8;
1514
case PrivilegeWithdrawn = 9;
1615
case AaCompromise = 10;
1716
}

tests/Unit/CoyoteCertTest.php

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ public function cleanup(string $d, string $tok): void {}
7979
expect($c->skipCaaCheck())->toBe($c);
8080
});
8181

82+
it('preferredChain() returns self (fluent)', function () {
83+
$c = makeCoyote();
84+
expect($c->preferredChain('ISRG Root X1'))->toBe($c);
85+
});
86+
8287
it('profile() returns self (fluent)', function () {
8388
$c = makeCoyote();
8489
expect($c->profile('shortlived'))->toBe($c);
@@ -393,6 +398,120 @@ public function sendRequest(\Psr\Http\Message\RequestInterface $r): \Psr\Http\Me
393398
expect($timeoutRef->getValue($client))->toBe(30);
394399
});
395400

401+
// ── onIssued() / onRenewed() ──────────────────────────────────────────────────
402+
403+
it('onIssued() returns self (fluent)', function () {
404+
$c = makeCoyote();
405+
expect($c->onIssued(fn($cert) => null))->toBe($c);
406+
});
407+
408+
it('onRenewed() returns self (fluent)', function () {
409+
$c = makeCoyote();
410+
expect($c->onRenewed(fn($cert) => null))->toBe($c);
411+
});
412+
413+
it('onIssued() registers multiple callbacks', function () {
414+
$c = makeCoyote();
415+
$c->onIssued(fn($cert) => null);
416+
$c->onIssued(fn($cert) => null);
417+
418+
$ref = new \ReflectionProperty(CoyoteCert::class, 'onIssuedCallbacks');
419+
expect($ref->getValue($c))->toHaveCount(2);
420+
});
421+
422+
it('onRenewed() registers multiple callbacks', function () {
423+
$c = makeCoyote();
424+
$c->onRenewed(fn($cert) => null);
425+
$c->onRenewed(fn($cert) => null);
426+
427+
$ref = new \ReflectionProperty(CoyoteCert::class, 'onRenewedCallbacks');
428+
expect($ref->getValue($c))->toHaveCount(2);
429+
});
430+
431+
it('fireIssuedCallbacks() invokes onIssued callbacks with the certificate', function () {
432+
$cert = makeCoyoteCert();
433+
$coyote = makeCoyote();
434+
$received = null;
435+
436+
$coyote->onIssued(function ($c) use (&$received) {
437+
$received = $c;
438+
});
439+
440+
$method = new \ReflectionMethod(CoyoteCert::class, 'fireIssuedCallbacks');
441+
$method->invoke($coyote, $cert, false);
442+
443+
expect($received)->toBe($cert);
444+
});
445+
446+
it('fireIssuedCallbacks() does not invoke onRenewed callbacks when isRenewal is false', function () {
447+
$cert = makeCoyoteCert();
448+
$coyote = makeCoyote();
449+
$called = false;
450+
451+
$coyote->onRenewed(function () use (&$called) {
452+
$called = true;
453+
});
454+
455+
$method = new \ReflectionMethod(CoyoteCert::class, 'fireIssuedCallbacks');
456+
$method->invoke($coyote, $cert, false);
457+
458+
expect($called)->toBeFalse();
459+
});
460+
461+
it('fireIssuedCallbacks() invokes onRenewed callbacks when isRenewal is true', function () {
462+
$cert = makeCoyoteCert();
463+
$coyote = makeCoyote();
464+
$received = null;
465+
466+
$coyote->onRenewed(function ($c) use (&$received) {
467+
$received = $c;
468+
});
469+
470+
$method = new \ReflectionMethod(CoyoteCert::class, 'fireIssuedCallbacks');
471+
$method->invoke($coyote, $cert, true);
472+
473+
expect($received)->toBe($cert);
474+
});
475+
476+
it('fireIssuedCallbacks() invokes both onIssued and onRenewed when isRenewal is true', function () {
477+
$cert = makeCoyoteCert();
478+
$coyote = makeCoyote();
479+
$log = [];
480+
481+
$coyote->onIssued(function () use (&$log) {
482+
$log[] = 'issued';
483+
});
484+
$coyote->onRenewed(function () use (&$log) {
485+
$log[] = 'renewed';
486+
});
487+
488+
$method = new \ReflectionMethod(CoyoteCert::class, 'fireIssuedCallbacks');
489+
$method->invoke($coyote, $cert, true);
490+
491+
expect($log)->toBe(['issued', 'renewed']);
492+
});
493+
494+
it('fireIssuedCallbacks() invokes callbacks in registration order', function () {
495+
$cert = makeCoyoteCert();
496+
$coyote = makeCoyote();
497+
$log = [];
498+
499+
$coyote->onIssued(function () use (&$log) {
500+
$log[] = 1;
501+
});
502+
$coyote->onIssued(function () use (&$log) {
503+
$log[] = 2;
504+
});
505+
$coyote->onIssued(function () use (&$log) {
506+
$log[] = 3;
507+
});
508+
509+
$method = new \ReflectionMethod(CoyoteCert::class, 'fireIssuedCallbacks');
510+
$method->invoke($coyote, $cert, false);
511+
512+
expect($log)->toBe([1, 2, 3]);
513+
});
514+
396515
// ── extractTokenAndKeyAuth() ──────────────────────────────────────────────────
397516

398517
it('extractTokenAndKeyAuth() returns [name, value] for Dns01ValidationData', function () {

tests/Unit/Endpoints/EndpointsTest.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1480,6 +1480,61 @@ function certOrderData(): OrderData
14801480
expect($parsed['subject']['CN'])->toBe('Default Root CA');
14811481
});
14821482

1483+
it('Certificate::getBundle() skips alternate chains that return non-200 and falls back to primary', function () {
1484+
$storage = withKeyStorage();
1485+
$primaryBundle = makeCertBundle('Default Root CA');
1486+
1487+
$mock = closureMock(
1488+
getHandler: fn($url) => new Response([], $url, 200, directoryBody()),
1489+
postHandler: function ($url) use ($primaryBundle) {
1490+
if (str_contains($url, '/alt')) {
1491+
return new Response([], $url, 503, 'Service Unavailable');
1492+
}
1493+
1494+
return new Response(
1495+
['link' => '<https://acme.example/cert/1/alt>;rel="alternate"'],
1496+
$url,
1497+
200,
1498+
$primaryBundle,
1499+
);
1500+
},
1501+
);
1502+
1503+
$bundle = makeEndpointApi($mock, $storage)->certificate()->getBundle(certOrderData(), 'ISRG Root X1');
1504+
1505+
preg_match_all('~(-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----)~i', $bundle->caBundle, $m);
1506+
$parsed = openssl_x509_parse($m[1][0]);
1507+
expect($parsed['subject']['CN'])->toBe('Default Root CA');
1508+
});
1509+
1510+
it('Certificate::getBundle() skips alternate chains whose caBundle is empty', function () {
1511+
$storage = withKeyStorage();
1512+
$primaryBundle = makeCertBundle('Default Root CA');
1513+
$leafOnlyBundle = "-----BEGIN CERTIFICATE-----\nMIIBtest==\n-----END CERTIFICATE-----\n";
1514+
1515+
$mock = closureMock(
1516+
getHandler: fn($url) => new Response([], $url, 200, directoryBody()),
1517+
postHandler: function ($url) use ($primaryBundle, $leafOnlyBundle) {
1518+
if (str_contains($url, '/alt')) {
1519+
return new Response([], $url, 200, $leafOnlyBundle);
1520+
}
1521+
1522+
return new Response(
1523+
['link' => '<https://acme.example/cert/1/alt>;rel="alternate"'],
1524+
$url,
1525+
200,
1526+
$primaryBundle,
1527+
);
1528+
},
1529+
);
1530+
1531+
$bundle = makeEndpointApi($mock, $storage)->certificate()->getBundle(certOrderData(), 'ISRG Root X1');
1532+
1533+
preg_match_all('~(-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----)~i', $bundle->caBundle, $m);
1534+
$parsed = openssl_x509_parse($m[1][0]);
1535+
expect($parsed['subject']['CN'])->toBe('Default Root CA');
1536+
});
1537+
14831538
it('Certificate::getBundle() handles multiple Link headers (comma-separated) and selects matching chain', function () {
14841539
$storage = withKeyStorage();
14851540
$primaryBundle = makeCertBundle('Default Root CA');

0 commit comments

Comments
 (0)