diff --git a/app/common.php b/app/common.php index e6f0581..2f391d9 100644 --- a/app/common.php +++ b/app/common.php @@ -166,6 +166,11 @@ function getSubstr($str, $leftStr, $rightStr) } } +function arrays_are_equal($array1, $array2) +{ + return empty(array_diff($array1, $array2)) && empty(array_diff($array2, $array1)); +} + function checkRefererHost() { if (!Request::header('referer')) { diff --git a/app/controller/Cert.php b/app/controller/Cert.php index b367492..d8d56f7 100644 --- a/app/controller/Cert.php +++ b/app/controller/Cert.php @@ -317,6 +317,72 @@ class Cert extends BaseController Db::name('cert_domain')->insertAll($domainList); Db::commit(); return json(['code' => 0, 'msg' => '修改证书订单成功!']); + } elseif ($action == 'import') { + $fullchain = input('post.fullchain', null, 'trim'); + $privatekey = input('post.privatekey', null, 'trim'); + if (!openssl_x509_read($fullchain)) return json(['code' => -1, 'msg' => '证书内容填写错误']); + if (!openssl_get_privatekey($privatekey)) return json(['code' => -1, 'msg' => '私钥内容填写错误']); + if (!openssl_x509_check_private_key($fullchain, $privatekey)) return json(['code' => -1, 'msg' => 'SSL证书与私钥不匹配']); + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo || !isset($certInfo['extensions']['subjectAltName'])) return json(['code' => -1, 'msg' => '证书内容解析失败']); + + $domains = []; + $subjectAltName = explode(',', $certInfo['extensions']['subjectAltName']); + foreach ($subjectAltName as $domain) { + $domain = trim($domain); + if (strpos($domain, 'DNS:') === 0) $domain = substr($domain, 4); + if (!empty($domain)) { + $domains[] = $domain; + } + } + $domains = array_unique($domains); + if (empty($domains)) return json(['code' => -1, 'msg' => '证书绑定域名不能为空']); + $issuetime = date('Y-m-d H:i:s', $certInfo['validFrom_time_t']); + $expiretime = date('Y-m-d H:i:s', $certInfo['validTo_time_t']); + $issuer = $certInfo['issuer']['CN']; + + $order_ids = Db::name('cert_order')->where('issuetime', $issuetime)->column('id'); + if (!empty($order_ids)) { + foreach ($order_ids as $order_id) { + $domains2 = Db::name('cert_domain')->where('oid', $order_id)->column('domain'); + if (arrays_are_equal($domains2, $domains)) { + return json(['code' => -1, 'msg' => '该证书已存在,无需重复添加']); + } + } + } + + $order = [ + 'aid' => input('post.aid/d'), + 'keytype' => input('post.keytype'), + 'keysize' => input('post.keysize'), + 'addtime' => date('Y-m-d H:i:s'), + 'updatetime' => date('Y-m-d H:i:s'), + 'issuetime' => $issuetime, + 'expiretime' => $expiretime, + 'issuer' => $issuer, + 'status' => 3, + 'fullchain' => $fullchain, + 'privatekey' => $privatekey, + ]; + if (empty($order['aid']) || empty($order['keytype']) || empty($order['keysize'])) return json(['code' => -1, 'msg' => '必填参数不能为空']); + + $res = $this->check_order($order, $domains); + if (is_array($res)) return json($res); + + Db::startTrans(); + $id = Db::name('cert_order')->insertGetId($order); + $domainList = []; + $i = 1; + foreach ($domains as $domain) { + $domainList[] = [ + 'oid' => $id, + 'domain' => $domain, + 'sort' => $i++, + ]; + } + Db::name('cert_domain')->insertAll($domainList); + Db::commit(); + return json(['code' => 0, 'msg' => '导入证书成功!']); } elseif ($action == 'del') { $id = input('post.id/d'); $dcount = DB::name('cert_deploy')->where('oid', $id)->count(); @@ -368,7 +434,11 @@ class Cert extends BaseController $max_domains = CertHelper::$cert_config[$account['type']]['max_domains']; $wildcard = CertHelper::$cert_config[$account['type']]['wildcard']; $cname = CertHelper::$cert_config[$account['type']]['cname']; - if (count($domains) > $max_domains) return ['code' => -1, 'msg' => '域名数量不能超过'.$max_domains.'个']; + if (count($domains) > $max_domains) { + if (!(count($domains) == 2 && $max_domains == 1 && ltrim($domains[0], 'www.') == ltrim($domains[1], 'www.'))) { + return ['code' => -1, 'msg' => '域名数量不能超过'.$max_domains.'个']; + } + } foreach($domains as $domain){ if(!$wildcard && strpos($domain, '*') !== false) return ['code' => -1, 'msg' => '该证书账户类型不支持泛域名']; @@ -438,6 +508,20 @@ class Cert extends BaseController return View::fetch(); } + public function order_import() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + $accounts = []; + foreach (Db::name('cert_account')->where('deploy', 0)->select() as $row) { + $accounts[$row['id']] = ['name'=>$row['id'].'_'.CertHelper::$cert_config[$row['type']]['name'], 'type'=>$row['type']]; + if (!empty($row['remark'])) { + $accounts[$row['id']]['name'] .= '(' . $row['remark'] . ')'; + } + } + View::assign('accounts', $accounts); + return View::fetch(); + } + public function deploytask() { diff --git a/app/lib/DeployHelper.php b/app/lib/DeployHelper.php index f680d36..df4b35f 100644 --- a/app/lib/DeployHelper.php +++ b/app/lib/DeployHelper.php @@ -1015,6 +1015,48 @@ class DeployHelper ], ], ], + 'ctyun' => [ + 'name' => '天翼云', + 'class' => 2, + 'icon' => 'ctyun.ico', + 'note' => '支持部署到天翼云CDN', + 'inputs' => [ + 'AccessKeyId' => [ + 'name' => 'AccessKeyId', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'SecretAccessKey' => [ + 'name' => 'SecretAccessKey', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'proxy' => [ + 'name' => '使用代理服务器', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'value' => '0' + ], + ], + 'taskinputs' => [ + 'product' => [ + 'name' => '产品', + 'type' => 'hidden', + 'value' => 'cdn', + ], + 'domain' => [ + 'name' => 'CDN域名', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + ], + ], 'allwaf' => [ 'name' => 'AllWAF', 'class' => 2, diff --git a/app/lib/client/Ctyun.php b/app/lib/client/Ctyun.php new file mode 100644 index 0000000..e60263c --- /dev/null +++ b/app/lib/client/Ctyun.php @@ -0,0 +1,163 @@ +AccessKeyId = $AccessKeyId; + $this->SecretAccessKey = $SecretAccessKey; + $this->endpoint = $endpoint; + $this->proxy = $proxy; + } + + /** + * @param string $method 请求方法 + * @param string $path 请求路径 + * @param array|null $query 请求参数 + * @param array|null $params 请求体 + * @return array + * @throws Exception + */ + public function request($method, $path, $query = null, $params = null) + { + if (!empty($query)) { + $query = array_filter($query, function ($a) { return $a !== null;}); + } + if (!empty($params)) { + $params = array_filter($params, function ($a) { return $a !== null;}); + } + + $time = time(); + $date = date("Ymd\THis\Z", $time); + $body = !empty($params) ? json_encode($params) : ''; + $headers = [ + 'Host' => $this->endpoint, + 'Eop-date' => $date, + 'ctyun-eop-request-id' => getSid(), + ]; + if ($body) { + $headers['Content-Type'] = 'application/json'; + } + + $authorization = $this->generateSign($query, $headers, $body, $date); + $headers['Eop-Authorization'] = $authorization; + + $url = 'https://' . $this->endpoint . $path; + if (!empty($query)) { + $url .= '?' . http_build_query($query); + } + $header = []; + foreach ($headers as $key => $value) { + $header[] = $key . ': ' . $value; + } + return $this->curl($method, $url, $body, $header); + } + + private function generateSign($query, $headers, $body, $date) + { + // step 1: build canonical request string + $canonicalQueryString = $this->getCanonicalQueryString($query); + [$canonicalHeaders, $signedHeaders] = $this->getCanonicalHeaders($headers); + $hashedRequestPayload = hash("sha256", $body); + + // step 2: build string to sign + $stringToSign = $canonicalHeaders . "\n" + . $canonicalQueryString . "\n" + . $hashedRequestPayload; + + // step 3: sign string + $ktime = hash_hmac("sha256", $date, $this->SecretAccessKey, true); + $kAk = hash_hmac("sha256", $this->AccessKeyId, $ktime, true); + $kdate = hash_hmac("sha256", substr($date, 0, 8), $kAk, true); + $signature = hash_hmac("sha256", $stringToSign, $kdate, true); + $signature = base64_encode($signature); + + // step 4: build authorization + $authorization = $this->AccessKeyId . " Headers=" . $signedHeaders . " Signature=" . $signature; + + return $authorization; + } + + private function escape($str) + { + $search = ['+', '*', '%7E']; + $replace = ['%20', '%2A', '~']; + return str_replace($search, $replace, urlencode($str)); + } + + private function getCanonicalQueryString($parameters) + { + if (empty($parameters)) return ''; + ksort($parameters); + $canonicalQueryString = ''; + foreach ($parameters as $key => $value) { + $canonicalQueryString .= '&' . $this->escape($key) . '=' . $this->escape($value); + } + return substr($canonicalQueryString, 1); + } + + private function getCanonicalHeaders($oldheaders) + { + $headers = array(); + foreach ($oldheaders as $key => $value) { + $headers[strtolower($key)] = trim($value); + } + ksort($headers); + + $canonicalHeaders = ''; + $signedHeaders = ''; + foreach ($headers as $key => $value) { + $canonicalHeaders .= $key . ':' . $value . "\n"; + $signedHeaders .= $key . ';'; + } + $signedHeaders = substr($signedHeaders, 0, -1); + return [$canonicalHeaders, $signedHeaders]; + } + + private function curl($method, $url, $body, $header) + { + $ch = curl_init($url); + if ($this->proxy) { + curl_set_proxy($ch); + } + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_HTTPHEADER, $header); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + if (!empty($body)) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + $response = curl_exec($ch); + $errno = curl_errno($ch); + if ($errno) { + curl_close($ch); + throw new Exception('Curl error: ' . curl_error($ch)); + } + curl_close($ch); + + $arr = json_decode($response, true); + if (isset($arr['statusCode']) && $arr['statusCode'] == 100000) { + return isset($arr['returnObj']) ? $arr['returnObj'] : true; + } elseif (isset($arr['errorMessage'])) { + throw new Exception($arr['errorMessage']); + } elseif (isset($arr['message'])) { + throw new Exception($arr['message']); + } else { + throw new Exception('返回数据解析失败'); + } + } +} diff --git a/app/lib/deploy/allwaf.php b/app/lib/deploy/allwaf.php index 515bc8c..8d7c82b 100644 --- a/app/lib/deploy/allwaf.php +++ b/app/lib/deploy/allwaf.php @@ -52,14 +52,33 @@ class allwaf implements DeployInterface $this->log('获取证书列表成功(total=' . count($list) . ')'); $certInfo = openssl_x509_parse($fullchain, true); + $cert_name = str_replace('*.', '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t']; - foreach ($list as $row) { + if (!empty($list)) { + foreach ($list as $row) { + $params = [ + 'sslCertId' => $row['id'], + 'isOn' => true, + 'name' => $row['name'], + 'description' => $row['description'], + 'serverName' => $row['serverName'], + 'isCA' => false, + 'certData' => base64_encode($fullchain), + 'keyData' => base64_encode($privatekey), + 'timeBeginAt' => $certInfo['validFrom_time_t'], + 'timeEndAt' => $certInfo['validTo_time_t'], + 'dnsNames' => $domains, + 'commonNames' => [$certInfo['issuer']['CN']], + ]; + $this->request('/SSLCertService/updateSSLCert', $params); + $this->log('证书ID:' . $row['id'] . '更新成功!'); + } + } else { $params = [ - 'sslCertId' => $row['id'], 'isOn' => true, - 'name' => $row['name'], - 'description' => $row['description'], - 'serverName' => $row['serverName'], + 'name' => $cert_name, + 'description' => $cert_name, + 'serverName' => $certInfo['subject']['CN'], 'isCA' => false, 'certData' => base64_encode($fullchain), 'keyData' => base64_encode($privatekey), @@ -68,8 +87,8 @@ class allwaf implements DeployInterface 'dnsNames' => $domains, 'commonNames' => [$certInfo['issuer']['CN']], ]; - $this->request('/SSLCertService/updateSSLCert', $params); - $this->log('证书ID:' . $row['id'] . '更新成功!'); + $result = $this->request('/SSLCertService/createSSLCert', $params); + $this->log('证书ID:' . $result['sslCertId'] . '添加成功!'); } } diff --git a/app/lib/deploy/ctyun.php b/app/lib/deploy/ctyun.php new file mode 100644 index 0000000..31eaa0c --- /dev/null +++ b/app/lib/deploy/ctyun.php @@ -0,0 +1,81 @@ +AccessKeyId = $config['AccessKeyId']; + $this->SecretAccessKey = $config['SecretAccessKey']; + $this->proxy = isset($config['proxy']) ? $config['proxy'] == 1 : false; + $this->client = new CtyunClient($this->AccessKeyId, $this->SecretAccessKey, 'ctcdn-global.ctapi.ctyun.cn', $this->proxy); + } + + public function check() + { + if (empty($this->AccessKeyId) || empty($this->SecretAccessKey)) throw new Exception('必填参数不能为空'); + $this->client->request('GET', '/v1/cert/query-cert-list'); + return true; + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + $cert_name = str_replace('*.', '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t']; + + $param = [ + 'name' => $cert_name, + 'key' => $privatekey, + 'certs' => $fullchain, + ]; + try { + $this->client->request('POST', '/v1/cert/creat-cert', null, $param); + } catch (Exception $e) { + if (strpos($e->getMessage(), '已存在重名的证书') !== false) { + $this->log('已存在重名的证书 cert_name=' . $cert_name); + } else { + throw new Exception('上传证书失败:' . $e->getMessage()); + } + } + $this->log('上传证书成功 cert_name=' . $cert_name); + + $param = [ + 'domain' => $config['domain'], + 'https_status' => 'on', + 'cert_name' => $cert_name, + ]; + try { + $this->client->request('POST', '/v1/domain/update-domain', null, $param); + } catch (Exception $e) { + if (strpos($e->getMessage(), '请求已提交,请勿重复操作!') === false) { + throw new Exception($e->getMessage()); + } + } + + $this->log('CDN域名 ' . $config['domain'] . ' 部署证书成功!'); + } + + public function setLogger($func) + { + $this->logger = $func; + } + + private function log($txt) + { + if ($this->logger) { + call_user_func($this->logger, $txt); + } + } +} diff --git a/app/lib/deploy/goedge.php b/app/lib/deploy/goedge.php index d6e566f..4ef0559 100644 --- a/app/lib/deploy/goedge.php +++ b/app/lib/deploy/goedge.php @@ -50,20 +50,39 @@ class goedge implements DeployInterface throw new Exception('获取证书列表失败:' . $e->getMessage()); } $list = json_decode(base64_decode($data['sslCertsJSON']), true); - if (!$list || empty($list)) { + if ($list === false) { throw new Exception('证书列表为空'); } $this->log('获取证书列表成功(total=' . count($list) . ')'); $certInfo = openssl_x509_parse($fullchain, true); + $cert_name = str_replace('*.', '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t']; - foreach ($list as $row) { + if (!empty($list)) { + foreach ($list as $row) { + $params = [ + 'sslCertId' => $row['id'], + 'isOn' => true, + 'name' => $row['name'], + 'description' => $row['description'], + 'serverName' => $row['serverName'], + 'isCA' => false, + 'certData' => base64_encode($fullchain), + 'keyData' => base64_encode($privatekey), + 'timeBeginAt' => $certInfo['validFrom_time_t'], + 'timeEndAt' => $certInfo['validTo_time_t'], + 'dnsNames' => $domains, + 'commonNames' => [$certInfo['issuer']['CN']], + ]; + $this->request('/SSLCertService/updateSSLCert', $params); + $this->log('证书ID:' . $row['id'] . '更新成功!'); + } + } else { $params = [ - 'sslCertId' => $row['id'], 'isOn' => true, - 'name' => $row['name'], - 'description' => $row['description'], - 'serverName' => $row['serverName'], + 'name' => $cert_name, + 'description' => $cert_name, + 'serverName' => $certInfo['subject']['CN'], 'isCA' => false, 'certData' => base64_encode($fullchain), 'keyData' => base64_encode($privatekey), @@ -72,8 +91,8 @@ class goedge implements DeployInterface 'dnsNames' => $domains, 'commonNames' => [$certInfo['issuer']['CN']], ]; - $this->request('/SSLCertService/updateSSLCert', $params); - $this->log('证书ID:' . $row['id'] . '更新成功!'); + $result = $this->request('/SSLCertService/createSSLCert', $params); + $this->log('证书ID:' . $result['sslCertId'] . '添加成功!'); } } diff --git a/app/lib/deploy/opanel.php b/app/lib/deploy/opanel.php index 9a2e423..7f9eab3 100644 --- a/app/lib/deploy/opanel.php +++ b/app/lib/deploy/opanel.php @@ -40,33 +40,35 @@ class opanel implements DeployInterface $success = 0; $errmsg = null; - foreach ($data['items'] as $row) { - if (empty($row['primaryDomain'])) continue; - $cert_domains = []; - $cert_domains[] = $row['primaryDomain']; - if(!empty($row['domains'])) $cert_domains += explode(',', $row['domains']); - $flag = false; - foreach ($cert_domains as $domain) { - if (in_array($domain, $domains)) { - $flag = true; - break; + if (!empty($data['items'])) { + foreach ($data['items'] as $row) { + if (empty($row['primaryDomain'])) continue; + $cert_domains = []; + $cert_domains[] = $row['primaryDomain']; + if(!empty($row['domains'])) $cert_domains += explode(',', $row['domains']); + $flag = false; + foreach ($cert_domains as $domain) { + if (in_array($domain, $domains)) { + $flag = true; + break; + } } - } - if ($flag) { - $params = [ - 'sslID' => $row['id'], - 'type' => 'paste', - 'certificate' => $fullchain, - 'privateKey' => $privatekey, - 'description' => '', - ]; - try { - $this->request('/api/v1/websites/ssl/upload', $params); - $this->log("证书ID:{$row['id']}更新成功!"); - $success++; - } catch (Exception $e) { - $errmsg = $e->getMessage(); - $this->log("证书ID:{$row['id']}更新失败:" . $errmsg); + if ($flag) { + $params = [ + 'sslID' => $row['id'], + 'type' => 'paste', + 'certificate' => $fullchain, + 'privateKey' => $privatekey, + 'description' => '', + ]; + try { + $this->request('/api/v1/websites/ssl/upload', $params); + $this->log("证书ID:{$row['id']}更新成功!"); + $success++; + } catch (Exception $e) { + $errmsg = $e->getMessage(); + $this->log("证书ID:{$row['id']}更新失败:" . $errmsg); + } } } } diff --git a/app/lib/deploy/safeline.php b/app/lib/deploy/safeline.php index 66d9a46..569336f 100644 --- a/app/lib/deploy/safeline.php +++ b/app/lib/deploy/safeline.php @@ -68,7 +68,15 @@ class safeline implements DeployInterface } } if ($success == 0) { - throw new Exception($errmsg ? $errmsg : '没有要更新的证书'); + $params = [ + 'manual' => [ + 'crt' => $fullchain, + 'key' => $privatekey, + ], + 'type' => 2, + ]; + $this->request('/api/open/cert', $params); + $this->log("证书上传成功!"); } } diff --git a/app/utils/MsgNotice.php b/app/utils/MsgNotice.php index ddccb58..6b01af3 100644 --- a/app/utils/MsgNotice.php +++ b/app/utils/MsgNotice.php @@ -229,7 +229,7 @@ class MsgNotice 'content' => $content, ], ]; - } elseif (strpos($url, 'open.feishu.cn')) { + } elseif (strpos($url, 'open.feishu.cn') || strpos($url, 'open.larksuite.com')) { $content = str_replace(['\*', '**'], ['*', ''], strip_tags($content)); $post = [ 'msg_type' => 'text', diff --git a/app/view/cert/certorder.html b/app/view/cert/certorder.html index c4cb874..44251e7 100644 --- a/app/view/cert/certorder.html +++ b/app/view/cert/certorder.html @@ -29,7 +29,16 @@ pre.pre-log{height: 330px;overflow-y: auto;width: 100%;background-color: rgba(51 刷新 - 添加 +