ACME 客户端
一个功能全面的 PHP ACME v2 客户端库,用于自动化管理 Let's Encrypt、ZeroSSL 以及其他兼容 ACME 的证书颁发机构的 SSL/TLS 证书。
语言 / Language: 英文 | 中文
功能
- ACME v2 协议支持:完全兼容 ACME v2 规范
- 多 CA 支持:兼容 Let's Encrypt、ZeroSSL 及其他 ACME 提供商
- 账户管理:创建、存储和管理 ACME 账户
- 证书操作:申请、续订和吊销 SSL 证书
- 域名验证:支持 HTTP-01 和 DNS-01 挑战
- ARI 支持:自动续订信息,实现最佳续订时机
- 灵活的密钥类型:支持 RSA 和 ECC 密钥
- 全面日志记录:内置兼容 PSR-3 的日志系统
- 易于集成:简单直观的 API 设计
环境要求
- PHP 8.2 或更高版本
- OpenSSL 扩展
- cURL 扩展
- JSON 扩展
- mbstring 扩展
安装
通过 Composer 安装:
composer require alapi/acme-client快速开始
1. 创建本地账户密钥
您有两种方式来创建和管理 ACME 账户密钥:
选项 A:使用 Account 类的现有密钥
use ALAPI\Acme\Accounts\Account;
// Create account from existing private key string
$privateKeyPem = '-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----';
$account = new Account($privateKeyPem);
// Or create account with both private and public keys
$publicKeyPem = '-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----';
$account = new Account($privateKeyPem, $publicKeyPem);
// Or create account from private key only (public key will be extracted)
$account = Account::fromPrivateKey($privateKeyPem);
选项 B:使用 AccountStorage 进行基于文件的密钥管理
use ALAPI\Acme\Utils\AccountStorage;
// Create new ECC account and save to files (recommended)
$account = AccountStorage::createAndSave(
directory: 'storage',
name: 'my-account',
keyType: 'ECC',
keySize: 'P-384'
);
// Or create RSA account and save to files
$rsaAccount = AccountStorage::createAndSave(
directory: 'storage',
name: 'my-rsa-account',
keyType: 'RSA',
keySize: 4096
);
echo "Account keys created and saved successfully!\n";
2. 初始化 ACME 客户端
use ALAPI\Acme\AcmeClient;
use ALAPI\Acme\Accounts\Account;
use ALAPI\Acme\Utils\AccountStorage;
use ALAPI\Acme\Http\Clients\ClientFactory;
// Option A: Load account from files
$account = AccountStorage::loadFromFiles('storage', 'my-account');
// Option B: Create account from existing keys
$privateKey = '-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----';
$account = new Account($privateKey);
// Create HTTP client with optional proxy
$httpClient = ClientFactory::create(timeout: 30, options: [
// 'proxy' => 'http://proxy.example.com:8080'
]);
// Initialize client for Let's Encrypt production
$acmeClient = new AcmeClient(
staging: false, // Set to true for testing
localAccount: $account,
httpClient: $httpClient
);
// Or use ZeroSSL
$zeroSslClient = new AcmeClient(
localAccount: $account,
httpClient: $httpClient,
baseUrl: 'https://acme.zerossl.com/v2/DV90/directory'
);
3. 注册 ACME 账户
对于 Let's Encrypt(无需 EAB):
try {
// Register account with Let's Encrypt
$accountData = $acmeClient->account()->create(
contacts: ['mailto:admin@example.com']
);
echo "Account registered successfully!\n";
echo "Account URL: " . $accountData->url . "\n";
} catch (Exception $e) {
echo "Registration failed: " . $e->getMessage() . "\n";
}对于 ZeroSSL(需要 EAB):
try {
// Get EAB credentials from ZeroSSL dashboard
$eabKid = 'your-eab-kid';
$eabHmacKey = 'your-eab-hmac-key';
$accountData = $zeroSslClient->account()->create(
eabKid: $eabKid,
eabHmacKey: $eabHmacKey,
contacts: ['mailto:admin@example.com']
);
echo "ZeroSSL account registered successfully!\n";
} catch (Exception $e) {
echo "Registration failed: " . $e->getMessage() . "\n";
}4. 申请证书
try {
// Get account data
$accountData = $acmeClient->account()->get();
// Create new order for domains
$domains = ['example.com', 'www.example.com'];
$order = $acmeClient->order()->new($accountData, $domains);
echo "Order created: " . $order->url . "\n";
echo "Status: " . $order->status . "\n";
// Check domain validations
$validations = $acmeClient->domainValidation()->status($order);
foreach ($validations as $validation) {
$domain = $validation->identifier['value'];
echo "Domain: $domain - Status: " . $validation->status . "\n";
if ($validation->isPending()) {
// Get validation data for HTTP-01 challenge
$challenges = $acmeClient->domainValidation()->getValidationData(
[$validation],
AuthorizationChallengeEnum::HTTP
);
foreach ($challenges as $challenge) {
echo "HTTP Challenge for $domain:\n";
echo " File: " . $challenge['filename'] . "\n";
echo " Content: " . $challenge['content'] . "\n";
echo " Place it at: http://$domain/.well-known/acme-challenge/" . $challenge['filename'] . "\n\n";
}
}
}
} catch (Exception $e) {
echo "Error: " . $e->getMessage() . "\n";
}
5. 完成域名验证
在将挑战文件放置在您的网络服务器上之后:
try {
// Trigger validation for each domain
foreach ($validations as $validation) {
if ($validation->isPending()) {
$response = $acmeClient->domainValidation()->validate(
$accountData,
$validation,
AuthorizationChallengeEnum::HTTP,
localTest: true // Performs local validation first
);
echo "Validation triggered for: " . $validation->identifier['value'] . "\n";
}
}
// Wait for validation to complete
$maxAttempts = 10;
$attempt = 0;
do {
sleep(5);
$attempt++;
// Check order status
$currentOrder = $acmeClient->order()->get($accountData, $order->url);
echo "Order status: " . $currentOrder->status . "\n";
if ($currentOrder->status === 'ready') {
echo "All validations completed successfully!\n";
break;
}
if ($currentOrder->status === 'invalid') {
echo "Order validation failed!\n";
break;
}
} while ($attempt < $maxAttempts);
} catch (Exception $e) {
echo "Validation error: " . $e->getMessage() . "\n";
}6. 生成并提交 CSR
use ALAPI\Acme\Security\Cryptography\OpenSsl;try {
if ($currentOrder->status === 'ready') {
// Generate Certificate private key
$certificatePrivateKey = OpenSsl::generatePrivateKey('RSA', 2048);
// Generate Certificate Signing Request (CSR) using OpenSsl helper
$csrString = OpenSsl::generateCsr($domains, $certificatePrivateKey);
// Export private key for saving
$privateKeyString = OpenSsl::openSslKeyToString($certificatePrivateKey);
// Submit CSR to finalize order
$finalizedOrder = $acmeClient->order()->finalize(
$accountData,
$currentOrder,
$csrString
);
echo "Order finalized successfully!\n";
echo "Certificate URL: " . $finalizedOrder->certificateUrl . "\n";
// Download certificate bundle
$certificateBundle = $acmeClient->certificate()->get(
$accountData,
$finalizedOrder->certificateUrl
);
// Save certificate and private key
file_put_contents('certificate.pem', $certificateBundle->certificate);
file_put_contents('fullchain.pem', $certificateBundle->fullchain);
file_put_contents('private-key.pem', $privateKeyString);
echo "Certificate saved to certificate.pem\n";
echo "Fullchain certificate saved to fullchain.pem\n";
echo "Private key saved to private-key.pem\n";
}
} catch (Exception $e) {
echo "Certificate generation error: " . $e->getMessage() . "\n";
}
高级用法
DNS-01 挑战
对于通配符证书或无法进行 HTTP 验证的情况:
// Get DNS challenge data
$dnsChallenge = $acmeClient->domainValidation()->getValidationData(
[$validation],
AuthorizationChallengeEnum::DNS
);foreach ($dnsChallenge as $challenge) {
echo "DNS Challenge for " . $challenge['domain'] . ":\n";
echo " Record Name: " . $challenge['domain'] . "\n";
echo " Record Type: TXT\n";
echo " Record Value: " . $challenge['digest'] . "\n\n";
}
// After adding DNS records, trigger validation
$response = $acmeClient->domainValidation()->validate(
$accountData,
$validation,
AuthorizationChallengeEnum::DNS,
localTest: true
);
使用ARI进行证书续期
use ALAPI\Acme\Management\RenewalManager;// Load existing certificate
$certificatePem = file_get_contents('certificate.pem');
// Create renewal manager
$renewalManager = $acmeClient->renewalManager(defaultRenewalDays: 30);
// Check if renewal is needed
if ($renewalManager->shouldRenew($certificatePem)) {
echo "Certificate needs renewal\n";
// Get ARI information if supported
if ($acmeClient->directory()->supportsARI()) {
$renewalInfo = $acmeClient->renewalInfo()->getFromCertificate($certificatePem);
echo "Suggested renewal window:\n";
echo " Start: " . $renewalInfo->suggestedWindow['start'] . "\n";
echo " End: " . $renewalInfo->suggestedWindow['end'] . "\n";
if ($renewalInfo->shouldRenewNow()) {
echo "ARI recommends renewing now\n";
// Proceed with renewal...
}
}
} else {
echo "Certificate renewal not needed yet\n";
}
证书撤销
try {
// Load certificate to revoke
$certificatePem = file_get_contents('certificate.pem');
// Revoke certificate
$success = $acmeClient->certificate()->revoke(
$certificatePem,
reason: 1 // 0=unspecified, 1=keyCompromise, 2=cACompromise, 3=affiliationChanged, 4=superseded, 5=cessationOfOperation
);
if ($success) {
echo "Certificate revoked successfully\n";
} else {
echo "Certificate revocation failed\n";
}
} catch (Exception $e) {
echo "Revocation error: " . $e->getMessage() . "\n";
}多个证书颁发机构
// Let's Encrypt
$letsEncrypt = new AcmeClient(
staging: false,
localAccount: $account,
httpClient: $httpClient
);// ZeroSSL
$zeroSSL = new AcmeClient(
localAccount: $account,
httpClient: $httpClient,
baseUrl: 'https://acme.zerossl.com/v2/DV90/directory'
);
// Google Trust Services
$googleCA = new AcmeClient(
localAccount: $account,
httpClient: $httpClient,
baseUrl: 'https://dv.acme-v02.api.pki.goog/directory'
);
自定义 HTTP 客户端配置
use ALAPI\Acme\Http\Clients\ClientFactory;$httpClient = ClientFactory::create(30, [
'proxy' => 'http://proxy.example.com:8080',
'verify' => true, // SSL verification
'timeout' => 30,
'connect_timeout' => 10,
'headers' => [
'User-Agent' => 'MyApp ACME Client 1.0'
]
]);
日志记录
use Monolog\Logger;
use Monolog\Handler\StreamHandler;// Create logger
$logger = new Logger('acme');
$logger->pushHandler(new StreamHandler('acme.log', Logger::INFO));
// Set logger on client
$acmeClient->setLogger($logger);
配置
账户管理选项
使用 AccountStorage 进行基于文件的管理:
use ALAPI\Acme\Utils\AccountStorage;// Check if account files exist
if (AccountStorage::exists('storage', 'my-account')) {
$account = AccountStorage::loadFromFiles('storage', 'my-account');
} else {
$account = AccountStorage::createAndSave('storage', 'my-account');
}
// Load or create account automatically
$account = AccountStorage::loadOrCreate(
directory: 'storage',
name: 'my-account',
keyType: 'ECC',
keySize: 'P-384'
);
使用 Account 类处理现有密钥:
use ALAPI\Acme\Accounts\Account;// From existing private key
$privateKey = file_get_contents('/path/to/private.key');
$account = new Account($privateKey);
// With both private and public keys
$privateKey = file_get_contents('/path/to/private.key');
$publicKey = file_get_contents('/path/to/public.key');
$account = new Account($privateKey, $publicKey);
// Create new account with specific key type
$account = Account::createECC('P-384'); // or 'P-256', 'P-384'
$account = Account::createRSA(4096); // or 2048, 3072
// Get account information
echo "Key Type: " . $account->getKeyType() . "\n";
echo "Key Size: " . $account->getKeySize() . "\n";
错误处理
use ALAPI\Acme\Exceptions\AcmeException;
use ALAPI\Acme\Exceptions\AcmeAccountException;
use ALAPI\Acme\Exceptions\DomainValidationException;
use ALAPI\Acme\Exceptions\AcmeCertificateException;try {
// ACME operations here
} catch (AcmeAccountException $e) {
echo "Account error: " . $e->getMessage() . "\n";
echo "Detail: " . $e->getDetail() . "\n";
echo "Type: " . $e->getAcmeType() . "\n";
} catch (DomainValidationException $e) {
echo "Validation error: " . $e->getMessage() . "\n";
} catch (AcmeCertificateException $e) {
echo "Certificate error: " . $e->getMessage() . "\n";
} catch (AcmeException $e) {
echo "ACME error: " . $e->getMessage() . "\n";
} catch (Exception $e) {
echo "General error: " . $e->getMessage() . "\n";
}
测试
运行测试套件:
composer test运行静态分析:
composer analyse修复代码风格:
composer cs-fix安全注意事项
- 私钥:使用适当的文件权限(600)安全存储私钥
- 账户密钥:将账户密钥与证书密钥分开保存
- 测试环境:使用测试环境进行测试
- 速率限制:注意证书颁发机构的速率限制
- 验证:在触发 ACME 验证前,始终先本地验证挑战
贡献指南
- Fork 仓库
- 创建功能分支
- 进行修改
- 为新功能添加测试
- 运行测试套件
- 提交 Pull Request
许可证
本项目采用 MIT 许可证 - 详情见 LICENSE 文件。
相关链接
支持
如果遇到任何问题或有疑问:
--- Tranlated By Open Ai Tx | Last indexed: 2025-08-15 ---