Skip to content

Commit acef31a

Browse files
committed
Merge branch 'calm-dove'
2 parents da73e55 + 6d41515 commit acef31a

8 files changed

Lines changed: 126 additions & 64 deletions

File tree

README.md

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ CoyoteCert introduces `dns-persist-01`: deploy the TXT record once, leave it in
3737

3838
Let's Encrypt's `shortlived` profile issues 6-day certificates with no OCSP or CRL requirements. CoyoteCert passes the profile through and silently ignores it for CAs that don't support profiles yet.
3939

40+
### IP address certificates (RFC 8738)
41+
42+
Pass an IP address to `->identifiers()` and CoyoteCert handles the rest: `type: ip` on the ACME order, `IP:` SAN entries in the CSR. Mix hostnames and IPs freely in the same call. Useful for internal services, load balancer VIPs, and edge nodes where a hostname isn't always available.
43+
4044
### Swappable HTTP client (PSR-18)
4145

4246
The built-in curl client needs no extra dependencies. Need proxy support, custom middleware, or framework integration? Swap it for any PSR-18 client (Symfony HttpClient, Guzzle, anything else) with one builder call.
@@ -92,7 +96,7 @@ use CoyoteCert\Storage\FilesystemStorage;
9296

9397
$cert = CoyoteCert::with(new LetsEncrypt())
9498
->storage(new FilesystemStorage('/var/certs'))
95-
->domains('example.com')
99+
->identifiers('example.com')
96100
->email('admin@example.com')
97101
->challenge(new Http01Handler('/var/www/html'))
98102
->issueOrRenew();
@@ -443,7 +447,7 @@ Always requests a new certificate from the CA, regardless of what is in storage.
443447
```php
444448
$cert = CoyoteCert::with(new LetsEncrypt())
445449
->storage(new FilesystemStorage('/var/certs'))
446-
->domains('example.com')
450+
->identifiers('example.com')
447451
->email('admin@example.com')
448452
->challenge(new Http01Handler('/var/www/html'))
449453
->issue();
@@ -456,7 +460,7 @@ The recommended method for production. Returns the existing certificate if it is
456460
```php
457461
$cert = CoyoteCert::with(new LetsEncrypt())
458462
->storage(new FilesystemStorage('/var/certs'))
459-
->domains('example.com')
463+
->identifiers('example.com')
460464
->email('admin@example.com')
461465
->challenge(new Http01Handler('/var/www/html'))
462466
->issueOrRenew(daysBeforeExpiry: 30);
@@ -479,7 +483,7 @@ Check whether a renewal is needed without triggering one.
479483
```php
480484
$coyote = CoyoteCert::with(new LetsEncrypt())
481485
->storage(new FilesystemStorage('/var/certs'))
482-
->domains('example.com');
486+
->identifiers('example.com');
483487

484488
if ($coyote->needsRenewal(30)) {
485489
// issue or alert
@@ -496,25 +500,50 @@ Returns `true` when:
496500

497501
## Wildcard and multi-domain certificates
498502

499-
Pass an array of domains to `->domains()`. Wildcards require DNS-01 or dns-persist-01.
503+
Pass an array of domains to `->identifiers()`. Wildcards require DNS-01 or dns-persist-01.
500504

501505
```php
502506
// Multi-domain (SAN) certificate via HTTP-01
503507
CoyoteCert::with(new LetsEncrypt())
504-
->domains(['example.com', 'www.example.com', 'api.example.com'])
508+
->identifiers(['example.com', 'www.example.com', 'api.example.com'])
505509
->challenge(new Http01Handler('/var/www/html'))
506510
->issueOrRenew();
507511

508512
// Wildcard certificate via DNS-01
509513
CoyoteCert::with(new LetsEncrypt())
510-
->domains(['example.com', '*.example.com'])
514+
->identifiers(['example.com', '*.example.com'])
511515
->challenge(new CloudflareDns01Handler())
512516
->issueOrRenew();
513517
```
514518

515519
`*.example.com` covers one label deep (`sub.example.com`) but not the apex (`example.com`). Include both if you need both.
516520

517-
`->domains()` validates every entry against RFC-compliant hostname syntax and throws an `InvalidArgumentException` immediately for malformed input, so misconfigured domain lists are caught before any CA communication starts.
521+
`->identifiers()` validates every entry against RFC-compliant hostname syntax (or as an IP address) and throws an `AcmeException` immediately for malformed input, so misconfigured lists are caught before any CA communication starts.
522+
523+
---
524+
525+
## IP address certificates (RFC 8738)
526+
527+
`->identifiers()` accepts IPv4 and IPv6 addresses alongside hostnames. CoyoteCert automatically sets `type: ip` on ACME identifiers and `IP:` SAN entries in the CSR — no extra API calls required.
528+
529+
```php
530+
// IPv4-only certificate (e.g. with Let's Encrypt shortlived profile)
531+
CoyoteCert::with(new LetsEncrypt())
532+
->identifiers('192.0.2.1')
533+
->profile('shortlived')
534+
->challenge(new Http01Handler('/var/www/html'))
535+
->issueOrRenew();
536+
537+
// Mixed hostname + IP certificate
538+
CoyoteCert::with(new LetsEncrypt())
539+
->identifiers(['example.com', '192.0.2.1', '2001:db8::1'])
540+
->challenge(new Http01Handler('/var/www/html'))
541+
->issueOrRenew();
542+
```
543+
544+
IP SANs are validated via HTTP-01 (the CA connects to the IP directly). Wildcards cannot be combined with IP identifiers.
545+
546+
Not all CAs support IP SANs — check your CA's documentation. Let's Encrypt supports them on both the `classic` and `shortlived` profiles.
518547

519548
---
520549

@@ -540,7 +569,7 @@ $logger->pushHandler(new StreamHandler('/var/log/certs.log'));
540569
$cert = CoyoteCert::with(new LetsEncrypt())
541570
->storage(new FilesystemStorage('/var/certs'))
542571
->email('ops@example.com')
543-
->domains(['example.com', 'www.example.com'])
572+
->identifiers(['example.com', 'www.example.com'])
544573
->challenge(new Http01Handler('/var/www/html'))
545574
->logger($logger)
546575
->issueOrRenew(daysBeforeExpiry: 30);
@@ -766,7 +795,7 @@ CoyoteCert::with(AcmeProviderInterface $provider) // factory — select the CA
766795
| Method | Type | Default | Description |
767796
|---|---|---|---|
768797
| `->email(string)` | fluent | `''` | Contact email; required for ZeroSSL auto-provisioning |
769-
| `->domains(string\|array)` | fluent || Domain(s) to certify; first entry is the primary |
798+
| `->identifiers(string\|array)` | fluent || Domain(s) to certify; first entry is the primary |
770799
| `->challenge(ChallengeHandlerInterface)` | fluent || Challenge handler |
771800
| `->storage(StorageInterface)` | fluent | none | Storage backend |
772801
| `->keyType(KeyType)` | fluent | `EC_P256` | Certificate key algorithm |

src/CoyoteCert.php

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,15 @@
2929
*
3030
* $cert = CoyoteCert::with(new LetsEncrypt())
3131
* ->storage(new FilesystemStorage('/var/certs'))
32-
* ->domains(['example.com', 'www.example.com'])
32+
* ->identifiers(['example.com', 'www.example.com'])
3333
* ->challenge(new Http01Handler('/var/www/html'))
3434
* ->issue();
3535
*
3636
* Or to issue only when the certificate is close to expiry:
3737
*
3838
* $cert = CoyoteCert::with(new LetsEncrypt())
3939
* ->storage(new FilesystemStorage('/var/certs'))
40-
* ->domains('example.com')
40+
* ->identifiers('example.com')
4141
* ->challenge(new Http01Handler('/var/www/html'))
4242
* ->issueOrRenew();
4343
*/
@@ -112,14 +112,14 @@ public function httpClient(
112112
return $this;
113113
}
114114

115-
/** @param string|string[] $domains */
116-
public function domains(array|string $domains): self
115+
/** @param string|string[] $identifiers Domain names and/or IP addresses (RFC 8738). */
116+
public function identifiers(array|string $identifiers): self
117117
{
118-
$list = is_array($domains) ? array_values($domains) : [$domains];
118+
$list = is_array($identifiers) ? array_values($identifiers) : [$identifiers];
119119

120120
foreach ($list as $domain) {
121-
if (!self::isValidDomain($domain)) {
122-
throw new AcmeException(sprintf('Invalid domain name: "%s".', $domain));
121+
if (!self::isValidDomain($domain) && !filter_var($domain, FILTER_VALIDATE_IP)) {
122+
throw new AcmeException(sprintf('Invalid domain name or IP address: "%s".', $domain));
123123
}
124124
}
125125

@@ -434,7 +434,7 @@ private function validate(): void
434434
{
435435
if (empty($this->domains)) {
436436
throw new AcmeException(
437-
'No domains configured. Call ->domains() before issuing a certificate.',
437+
'No identifiers configured. Call ->identifiers() before issuing a certificate.',
438438
);
439439
}
440440

src/Endpoints/Order.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,14 @@ public function new(AccountData $accountData, array $domains, string $profile =
2222
{
2323
$identifiers = [];
2424
foreach ($domains as $domain) {
25-
if (preg_match_all('~(\*\.)~', $domain) > 1) {
25+
$isIp = (bool) filter_var($domain, FILTER_VALIDATE_IP);
26+
27+
if (!$isIp && preg_match_all('~(\*\.)~', $domain) > 1) {
2628
throw new AcmeException('Cannot create orders with multiple wildcards in one domain.');
2729
}
2830

2931
$identifiers[] = [
30-
'type' => 'dns',
32+
'type' => $isIp ? 'ip' : 'dns',
3133
'value' => $domain,
3234
];
3335
}

src/Support/OpenSsl.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,10 @@ public static function generateCsr(array $domains, OpenSSLAsymmetricKey $private
5151
{
5252
$dn = ['commonName' => $domains[0]];
5353

54-
$san = implode(',', array_map(function (string $dns): string {
55-
return 'DNS:' . $dns;
54+
$san = implode(',', array_map(function (string $identifier): string {
55+
return filter_var($identifier, FILTER_VALIDATE_IP)
56+
? 'IP:' . $identifier
57+
: 'DNS:' . $identifier;
5658
}, $domains));
5759

5860
$tempFile = tmpfile();

tests/Integration/CoyoteCertIssueTest.php

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
it('issues a certificate with default key types (RSA account, EC_P256 cert)', function () {
1010
$cert = CoyoteCert::with(pebble())
1111
->storage(new InMemoryStorage())
12-
->domains('test.example.com')
12+
->identifiers('test.example.com')
1313
->challenge(new NoOpHttp01Handler())
1414
->skipLocalTest()
1515
->issue();
@@ -25,7 +25,7 @@
2525
it('issues a certificate with an EC P-256 account key', function () {
2626
$cert = CoyoteCert::with(pebble())
2727
->storage(new InMemoryStorage())
28-
->domains('ec-account.example.com')
28+
->identifiers('ec-account.example.com')
2929
->accountKeyType(KeyType::EC_P256)
3030
->challenge(new NoOpHttp01Handler())
3131
->skipLocalTest()
@@ -39,15 +39,15 @@
3939

4040
$first = CoyoteCert::with(pebble())
4141
->storage($storage)
42-
->domains('reuse.example.com')
42+
->identifiers('reuse.example.com')
4343
->challenge(new NoOpHttp01Handler())
4444
->skipLocalTest()
4545
->issue();
4646

4747
// Second call reuses the account key already in $storage
4848
$second = CoyoteCert::with(pebble())
4949
->storage($storage)
50-
->domains('reuse.example.com')
50+
->identifiers('reuse.example.com')
5151
->challenge(new NoOpHttp01Handler())
5252
->skipLocalTest()
5353
->issue();
@@ -61,14 +61,14 @@
6161

6262
$issued = CoyoteCert::with(pebble())
6363
->storage($storage)
64-
->domains('cached.example.com')
64+
->identifiers('cached.example.com')
6565
->challenge(new NoOpHttp01Handler())
6666
->skipLocalTest()
6767
->issue();
6868

6969
$returned = CoyoteCert::with(pebble())
7070
->storage($storage)
71-
->domains('cached.example.com')
71+
->identifiers('cached.example.com')
7272
->challenge(new NoOpHttp01Handler())
7373
->skipLocalTest()
7474
->issueOrRenew(daysBeforeExpiry: 1);
@@ -79,7 +79,7 @@
7979
it('issues a certificate with the shortlived profile', function () {
8080
$cert = CoyoteCert::with(pebble())
8181
->storage(new InMemoryStorage())
82-
->domains('profile.example.com')
82+
->identifiers('profile.example.com')
8383
->profile('shortlived')
8484
->challenge(new NoOpHttp01Handler())
8585
->skipLocalTest()

tests/Integration/CoyoteCertRevokeTest.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
$cert = CoyoteCert::with(pebble())
1212
->storage($storage)
13-
->domains('revoke.example.com')
13+
->identifiers('revoke.example.com')
1414
->challenge(new NoOpHttp01Handler())
1515
->skipLocalTest()
1616
->issue();
@@ -27,7 +27,7 @@
2727

2828
$cert = CoyoteCert::with(pebble())
2929
->storage($storage)
30-
->domains('revoke-reason.example.com')
30+
->identifiers('revoke-reason.example.com')
3131
->challenge(new NoOpHttp01Handler())
3232
->skipLocalTest()
3333
->issue();
@@ -44,7 +44,7 @@
4444

4545
$cert = CoyoteCert::with(pebble())
4646
->storage($storage)
47-
->domains('revoke-nostorage.example.com')
47+
->identifiers('revoke-nostorage.example.com')
4848
->challenge(new NoOpHttp01Handler())
4949
->skipLocalTest()
5050
->issue();

0 commit comments

Comments
 (0)