4 Commits

Author SHA1 Message Date
net909
d0eb096873 腾讯云支持更新证书内容接口 2025-12-25 11:49:07 +08:00
net909
ebdc34cf4b fix: 又拍云SSL不兼容的特化处理 2025-12-25 10:27:28 +08:00
耗子
b19cabcbfd fix: Passing null to parameter #5 ($passphrase) of type string is deprecated (#360) 2025-12-24 22:09:48 +08:00
深山大柠檬
64b5221787 1panel支持多个子节点部署 (#356) 2025-12-18 11:23:06 +08:00
9 changed files with 244 additions and 59 deletions

View File

@@ -304,10 +304,6 @@ class Cert extends BaseController
}
}
if ($certInfo['keytype'] == 'ECC') {
$privatekey = CertHelper::ensureECPrivateKeyFormat($privatekey);
}
$order = [
'aid' => 0,
'keytype' => $certInfo['keytype'],
@@ -371,10 +367,6 @@ class Cert extends BaseController
if ($certInfo['code'] == -1) return json($certInfo);
$domains = $certInfo['domains'];
if ($certInfo['keytype'] == 'ECC') {
$privatekey = CertHelper::ensureECPrivateKeyFormat($privatekey);
}
$order = [
'aid' => 0,
'keytype' => $certInfo['keytype'],

View File

@@ -407,24 +407,6 @@ location / {
return false;
}
/**
* 确保ECC私钥使用EC专用格式标识
* 某些程序需要EC标识才能正确识别ECC私钥
*/
public static function ensureECPrivateKeyFormat($private_key)
{
if (strpos($private_key, '-----BEGIN EC PRIVATE KEY-----') !== false) {
return $private_key;
}
if (strpos($private_key, '-----BEGIN PRIVATE KEY-----') !== false) {
$private_key = preg_replace('/^-----BEGIN PRIVATE KEY-----$/m', '-----BEGIN EC PRIVATE KEY-----', $private_key);
$private_key = preg_replace('/^-----END PRIVATE KEY-----$/m', '-----END EC PRIVATE KEY-----', $private_key);
}
return $private_key;
}
public static function getPfx($fullchain, $privatekey, $pwd = '123456')
{
openssl_pkcs12_export($fullchain, $pfx, $privatekey, $pwd);

View File

@@ -614,9 +614,9 @@ class DeployHelper
],
'node_name' => [
'name' => '子节点名称',
'type' => 'input',
'placeholder' => '',
'note' => '不填写时,将替换主控节点证书;否则,将替换被控节点证书',
'type' => 'textarea',
'placeholder' => '每行一个节点名称',
'note' => '不填写时,将替换主控节点证书;否则,将替换被控节点证书。多个节点请每行填写一个',
'show' => 'type==0',
],
],
@@ -1224,6 +1224,7 @@ ctrl+x 保存退出',
['value'=>'tse', 'label'=>'云原生API网关TSE'],
['value'=>'tcb', 'label'=>'云开发TCB'],
['value'=>'lighthouse', 'label'=>'轻量应用服务器'],
['value'=>'update', 'label'=>'更新证书内容证书ID不变'],
],
'value' => 'cdn',
'required' => true,
@@ -1327,6 +1328,14 @@ ctrl+x 保存退出',
'note' => 'CDN、EO、WAF多个域名可用,隔开其他只能填写1个域名',
'required' => true,
],
'cert_id' => [
'name' => '证书ID',
'type' => 'input',
'placeholder' => '要更新的证书ID在我的证书列表查看',
'show' => 'product==\'update\'',
'required' => true,
'note' => '当前接口需联系加白使用',
],
],
],
'huawei' => [

View File

@@ -4,7 +4,6 @@ namespace app\lib\acme;
use Exception;
use stdClass;
use app\lib\CertHelper;
/**
* ACMECert
@@ -369,12 +368,10 @@ class ACMECert extends ACMEv2
if (version_compare(PHP_VERSION, '7.1.0') < 0) throw new Exception('PHP >= 7.1.0 required for EC keys !');
$map = array('256' => 'prime256v1', '384' => 'secp384r1', '521' => 'secp521r1');
if (isset($map[$curve_name])) $curve_name = $map[$curve_name];
$pem = $this->generateKey(array(
return $this->generateKey(array(
'curve_name' => $curve_name,
'private_key_type' => OPENSSL_KEYTYPE_EC
));
return CertHelper::ensureECPrivateKeyFormat($pem);
}
public function parseCertificate($cert_pem)

View File

@@ -11,7 +11,6 @@ class opanel implements DeployInterface
private $url;
private $key;
private $proxy;
private $nodeName;
public function __construct($config)
{
@@ -28,7 +27,11 @@ class opanel implements DeployInterface
public function deploy($fullchain, $privatekey, $config, &$info)
{
// 解析节点名称列表
$nodeNames = $this->parseNodeNames($config);
if (isset($config['type']) && $config['type'] == '3') {
// 面板本身的证书部署
$params = [
'cert' => $fullchain,
'key' => $privatekey,
@@ -36,18 +39,66 @@ class opanel implements DeployInterface
'sslID' => null,
'sslType' => 'import-paste',
];
try {
$this->request('/core/settings/ssl/update', $params);
$this->log("面板证书更新成功!");
if (empty($nodeNames)) {
// 没有指定节点,部署到主控节点
try {
$this->request('/core/settings/ssl/update', $params);
$this->log("面板证书更新成功!");
return;
} catch (Exception $e) {
throw new Exception("面板证书更新失败:" . $e->getMessage());
}
} else {
// 部署到多个子节点
$successCount = 0;
$failCount = 0;
foreach ($nodeNames as $nodeName) {
try {
$this->request('/core/settings/ssl/update', $params, $nodeName);
$this->log("节点 [{$nodeName}] 面板证书更新成功!");
$successCount++;
} catch (Exception $e) {
$this->log("节点 [{$nodeName}] 面板证书更新失败:" . $e->getMessage());
$failCount++;
}
}
if ($failCount > 0 && $successCount == 0) {
throw new Exception("所有节点证书更新失败");
}
return;
} catch (Exception $e) {
throw new Exception("面板证书更新失败:" . $e->getMessage());
}
}
if (isset($config['node_name'])) $this->nodeName = $config['node_name'];
// 如果没有指定节点,则部署到主控节点
if (empty($nodeNames)) {
$this->deployToNode($fullchain, $privatekey, $config, null);
} else {
// 部署到多个子节点
$successCount = 0;
$failCount = 0;
foreach ($nodeNames as $nodeName) {
try {
$this->deployToNode($fullchain, $privatekey, $config, $nodeName);
$successCount++;
} catch (Exception $e) {
$this->log("节点 [{$nodeName}] 部署失败:" . $e->getMessage());
$failCount++;
}
}
if ($failCount > 0 && $successCount == 0) {
throw new Exception("所有节点部署失败");
}
}
}
/**
* 部署到指定节点
*/
private function deployToNode($fullchain, $privatekey, $config, $nodeName = null)
{
if (!empty($config['id'])) {
// 指定证书ID的情况
$params = [
'sslID' => intval($config['id']),
'type' => 'paste',
@@ -56,23 +107,28 @@ class opanel implements DeployInterface
'description' => '',
];
try {
$this->request('/websites/ssl/upload', $params);
$this->log("证书ID:{$config['id']}更新成功!");
$this->request('/websites/ssl/upload', $params, $nodeName);
$logMsg = $nodeName ? "节点 [{$nodeName}] 证书ID:{$config['id']}更新成功!" : "证书ID:{$config['id']}更新成功!";
$this->log($logMsg);
return;
} catch (Exception $e) {
throw new Exception("证书ID:{$config['id']}更新失败:" . $e->getMessage());
$logMsg = $nodeName ? "节点 [{$nodeName}] 证书ID:{$config['id']}更新失败:" : "证书ID:{$config['id']}更新失败:";
throw new Exception($logMsg . $e->getMessage());
}
}
// 根据域名自动匹配证书
$domains = $config['domainList'];
if (empty($domains)) throw new Exception('没有设置要部署的域名');
$params = ['page' => 1, 'pageSize' => 500];
try {
$data = $this->request("/websites/ssl/search", $params);
$this->log('获取证书列表成功(total=' . $data['total'] . ')');
$data = $this->request("/websites/ssl/search", $params, $nodeName);
$logMsg = $nodeName ? "节点 [{$nodeName}] " : "";
$this->log($logMsg . '获取证书列表成功(total=' . $data['total'] . ')');
} catch (Exception $e) {
throw new Exception('获取证书列表失败:' . $e->getMessage());
$logMsg = $nodeName ? "节点 [{$nodeName}] " : "";
throw new Exception($logMsg . '获取证书列表失败:' . $e->getMessage());
}
$success = 0;
@@ -99,12 +155,14 @@ class opanel implements DeployInterface
'description' => '',
];
try {
$this->request('/websites/ssl/upload', $params);
$this->log("证书ID:{$row['id']}更新成功!");
$this->request('/websites/ssl/upload', $params, $nodeName);
$logMsg = $nodeName ? "节点 [{$nodeName}] 证书ID:{$row['id']}更新成功!" : "证书ID:{$row['id']}更新成功!";
$this->log($logMsg);
$success++;
} catch (Exception $e) {
$errmsg = $e->getMessage();
$this->log("证书ID:{$row['id']}更新失败:" . $errmsg);
$logMsg = $nodeName ? "节点 [{$nodeName}] 证书ID:{$row['id']}更新失败:" : "证书ID:{$row['id']}更新失败:";
$this->log($logMsg . $errmsg);
}
}
}
@@ -117,8 +175,9 @@ class opanel implements DeployInterface
'privateKey' => $privatekey,
'description' => '',
];
$this->request('/websites/ssl/upload', $params);
$this->log("证书上传成功!");
$this->request('/websites/ssl/upload', $params, $nodeName);
$logMsg = $nodeName ? "节点 [{$nodeName}] 证书上传成功!" : "证书上传成功!";
$this->log($logMsg);
}
}
@@ -134,7 +193,32 @@ class opanel implements DeployInterface
}
}
private function request($path, $params = null)
/**
* 解析节点名称列表
*/
private function parseNodeNames($config)
{
if (!isset($config['node_name']) || empty($config['node_name'])) {
return [];
}
$nodeNameStr = trim($config['node_name']);
if (empty($nodeNameStr)) {
return [];
}
// 按行分割,过滤空行
$nodeNames = array_filter(
array_map('trim', explode("\n", $nodeNameStr)),
function($name) {
return !empty($name);
}
);
return array_values($nodeNames);
}
private function request($path, $params = null, $nodeName = null)
{
$url = $this->url . $path;
@@ -144,8 +228,8 @@ class opanel implements DeployInterface
'1Panel-Token' => $token,
'1Panel-Timestamp' => $timestamp,
];
if (!empty($this->nodeName)) {
$headers['CurrentNode'] = $this->nodeName;
if (!empty($nodeName)) {
$headers['CurrentNode'] = $nodeName;
}
$body = $params ? json_encode($params) : '{}';
if ($body) $headers['Content-Type'] = 'application/json';

View File

@@ -159,9 +159,14 @@ class ssh implements DeployInterface
file_put_contents($privateKeyPath, $this->config['privatekey']);
file_put_contents($publicKeyPath, $publicKey);
umask($umask);
$passphrase = $this->config['passphrase'] ?? null; // 私钥密码
if (!ssh2_auth_pubkey_file($connection, $this->config['username'], $publicKeyPath, $privateKeyPath, $passphrase)) {
throw new Exception('私钥认证失败');
if (!empty($this->config['passphrase'])) {
if (!ssh2_auth_pubkey_file($connection, $this->config['username'], $publicKeyPath, $privateKeyPath, $this->config['passphrase'])) {
throw new Exception('私钥认证失败');
}
} else {
if (!ssh2_auth_pubkey_file($connection, $this->config['username'], $publicKeyPath, $privateKeyPath)) {
throw new Exception('私钥认证失败');
}
}
} else {
if (!ssh2_auth_password($connection, $this->config['username'], $this->config['password'])) {

View File

@@ -31,6 +31,9 @@ class tencent implements DeployInterface
public function deploy($fullchain, $privatekey, $config, &$info)
{
if ($config['product'] == 'update') {
return $this->update_cert($fullchain, $privatekey, $config);
}
$cert_id = $this->get_cert_id($fullchain, $privatekey);
if (!$cert_id) throw new Exception('证书ID获取失败');
if ($config['product'] == 'cos') {
@@ -281,6 +284,95 @@ class tencent implements DeployInterface
$this->log('边缘安全加速域名 ' . $config['domain'] . ' 部署证书成功!');
}
private function update_cert($fullchain, $privatekey, $config)
{
if (empty($config['cert_id'])) throw new Exception('证书ID不能为空');
$param = [
'CertificateIds' => [$config['cert_id']],
'IsCache' => 1,
];
try {
$data = $this->client->request('CreateCertificateBindResourceSyncTask', $param);
if (empty($data['CertTaskIds'])) throw new Exception('返回任务ID为空');
} catch (Exception $e) {
throw new Exception('创建关联云资源查询任务失败:' . $e->getMessage());
}
$task_id = $data['CertTaskIds'][0]['TaskId'];
$this->log('创建关联云资源查询任务成功 TaskId=' . $task_id);
$retry = 0;
$resource_result = null;
while ($retry++ < 30) {
sleep(2);
$param = [
'TaskIds' => [$task_id],
];
try {
$data = $this->client->request('DescribeCertificateBindResourceTaskResult', $param);
if (empty($data['SyncTaskBindResourceResult'])) throw new Exception('返回结果为空');
} catch (Exception $e) {
throw new Exception('查询关联云资源任务结果失败:' . $e->getMessage());
}
$taskResult = $data['SyncTaskBindResourceResult'][0];
if ($taskResult['Status'] == 1) {
$resource_result = $taskResult['BindResourceResult'];
break;
} elseif ($taskResult['Status'] == 2) {
throw new Exception('关联云资源查询任务执行失败:' . isset($taskResult['Error']) ? $taskResult['Error']['Message'] : '未知错误');
}
};
if (!$resource_result) {
throw new Exception('关联云资源查询任务超时未完成,请稍后重试');
}
$resourceTypes = [];
$resourceTypesRegions = [];
foreach ($resource_result as $res) {
if ($res['ResourceType'] != 'clb') continue;
$totalCount = 0;
$regions = [];
foreach ($res['BindResourceRegionResult'] as $regionRes) {
if ($regionRes['TotalCount'] > 0) {
$totalCount += $regionRes['TotalCount'];
if (!empty($regionRes['Region'])) {
$regions[] = $regionRes['Region'];
}
}
}
if ($totalCount > 0) {
$resourceTypes[] = $res['ResourceType'];
if (!empty($regions)) {
$resourceTypesRegions[] = [
'ResourceType' => $res['ResourceType'],
'Regions' => $regions,
];
}
}
}
$param = [
'OldCertificateId' => $config['cert_id'],
'CertificatePublicKey' => $fullchain,
'CertificatePrivateKey' => $privatekey,
'ResourceTypes' => $resourceTypes,
'ResourceTypesRegions' => $resourceTypesRegions,
];
$retry = 0;
while ($retry++ < 10) {
try {
$data = $this->client->request('UploadUpdateCertificateInstance', $param);
} catch (Exception $e) {
throw new Exception('更新证书内容失败:' . $e->getMessage());
}
if ($data['DeployStatus'] == 1) {
break;
}
sleep(1);
}
$this->log('更新证书内容成功,可能需要一些时间完成各资源的证书更新部署');
}
public function setLogger($func)
{
$this->logger = $func;

View File

@@ -31,9 +31,15 @@ class upyun implements DeployInterface
$this->login();
$url = 'https://console.upyun.com/api/https/certificate/';
// 如果是 EC 证书,调整私钥头为 EC PRIVATE KEY
$privatekey_send = $privatekey;
if ($this->isEcCertificate($fullchain)) {
$privatekey_send = str_replace('-----BEGIN PRIVATE KEY-----', '-----BEGIN EC PRIVATE KEY-----', $privatekey_send);
$privatekey_send = str_replace('-----END PRIVATE KEY-----', '-----END EC PRIVATE KEY-----', $privatekey_send);
}
$params = [
'certificate' => $fullchain,
'private_key' => $privatekey,
'private_key' => $privatekey_send,
];
$response = http_request($url, http_build_query($params), null, $this->cookie, null, $this->proxy);
$result = json_decode($response['body'], true);
@@ -130,4 +136,22 @@ class upyun implements DeployInterface
call_user_func($this->logger, $txt);
}
}
/**
* 判断是否为 EC (ECDSA) 证书
*/
private function isEcCertificate($fullchain)
{
// 提取第一个证书
if (!preg_match('/-----BEGIN CERTIFICATE-----\s*(.+?)\s*-----END CERTIFICATE-----/s', $fullchain, $m)) {
return false;
}
$pubKey = openssl_pkey_get_public($m[0]);
if (!$pubKey) return false;
$details = openssl_pkey_get_details($pubKey);
return $details && ($details['type'] ?? 0) === OPENSSL_KEYTYPE_EC;
}
}

View File

@@ -31,7 +31,7 @@ return [
'show_error_msg' => true,
'exception_tmpl' => \think\facade\App::getAppPath() . 'view/exception.tpl',
'version' => '1043',
'version' => '1044',
'dbversion' => '1040'
];