From 6ffa9e003ada83b535dd1b1b559971b8f3c0de7e Mon Sep 17 00:00:00 2001 From: net909 Date: Mon, 29 Sep 2025 10:45:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=A4=A9=E7=BF=BC=E4=BA=91?= =?UTF-8?q?=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/DeployHelper.php | 48 ++++++++ app/lib/client/Ksyun.php | 209 ++++++++++++++++++++++++++++++++ app/lib/deploy/ksyun.php | 79 ++++++++++++ app/view/dmonitor/taskform.html | 2 +- public/static/images/ksyun.ico | Bin 0 -> 4286 bytes 5 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 app/lib/client/Ksyun.php create mode 100644 app/lib/deploy/ksyun.php create mode 100644 public/static/images/ksyun.ico diff --git a/app/lib/DeployHelper.php b/app/lib/DeployHelper.php index 4adbf24..f8b3b06 100644 --- a/app/lib/DeployHelper.php +++ b/app/lib/DeployHelper.php @@ -1564,6 +1564,54 @@ ctrl+x 保存退出', ], ], ], + 'ksyun' => [ + 'name' => '金山云', + 'class' => 2, + 'icon' => 'ksyun.ico', + 'desc' => '支持部署到金山云CDN', + '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' => 'select', + 'options' => [ + ['value'=>'cdn', 'label'=>'CDN'], + ], + 'value' => 'cdn', + 'required' => true, + ], + 'domain' => [ + 'name' => '绑定的域名', + 'type' => 'input', + 'placeholder' => '多个域名可使用,分隔', + 'show' => 'product==\'cdn\'', + 'required' => true, + ], + ], + ], 'huoshan' => [ 'name' => '火山引擎', 'class' => 2, diff --git a/app/lib/client/Ksyun.php b/app/lib/client/Ksyun.php new file mode 100644 index 0000000..4cea059 --- /dev/null +++ b/app/lib/client/Ksyun.php @@ -0,0 +1,209 @@ +AccessKeyId = $AccessKeyId; + $this->SecretAccessKey = $SecretAccessKey; + $this->endpoint = $endpoint; + $this->service = $service; + $this->region = $region; + $this->proxy = $proxy; + } + + /** + * @param string $method 请求方法 + * @param string $action 方法名称 + * @param array $params 请求参数 + * @return array + * @throws Exception + */ + public function request($method, $action, $version, $path = '/', $params = []) + { + if (!empty($params)) { + $params = array_filter($params, function ($a) { + return $a !== null; + }); + } + + $body = ''; + $query = []; + if ($method == 'GET') { + $query = $params; + } else { + $body = !empty($params) ? json_encode($params) : ''; + } + + $time = time(); + $headers = [ + 'Host' => $this->endpoint, + 'X-Amz-Date' => gmdate("Ymd\THis\Z", $time), + 'X-Version' => $version, + 'X-Action' => $action, + ]; + + $authorization = $this->generateSign($method, $path, $query, $headers, $body, $time); + $headers['Authorization'] = $authorization; + $headers['Accept'] = 'application/json'; + if ($body) { + $headers['Content-Type'] = 'application/json'; + } + + $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($method, $path, $query, $headers, $body, $time) + { + $algorithm = "AWS4-HMAC-SHA256"; + + // step 1: build canonical request string + $httpRequestMethod = $method; + $canonicalUri = $this->getCanonicalURI($path); + $canonicalQueryString = $this->getCanonicalQueryString($query); + [$canonicalHeaders, $signedHeaders] = $this->getCanonicalHeaders($headers); + $hashedRequestPayload = hash("sha256", $body); + $canonicalRequest = $httpRequestMethod . "\n" + . $canonicalUri . "\n" + . $canonicalQueryString . "\n" + . $canonicalHeaders . "\n" + . $signedHeaders . "\n" + . $hashedRequestPayload; + + // step 2: build string to sign + $date = gmdate("Ymd\THis\Z", $time); + $shortDate = substr($date, 0, 8); + $credentialScope = $shortDate . '/' . $this->region . '/' . $this->service . '/aws4_request'; + $hashedCanonicalRequest = hash("sha256", $canonicalRequest); + $stringToSign = $algorithm . "\n" + . $date . "\n" + . $credentialScope . "\n" + . $hashedCanonicalRequest; + + // step 3: sign string + $kDate = hash_hmac("sha256", $shortDate, 'AWS4' . $this->SecretAccessKey, true); + $kRegion = hash_hmac("sha256", $this->region, $kDate, true); + $kService = hash_hmac("sha256", $this->service, $kRegion, true); + $kSigning = hash_hmac("sha256", "aws4_request", $kService, true); + $signature = hash_hmac("sha256", $stringToSign, $kSigning); + + // step 4: build authorization + $credential = $this->AccessKeyId . '/' . $credentialScope; + $authorization = $algorithm . ' Credential=' . $credential . ", SignedHeaders=" . $signedHeaders . ", Signature=" . $signature; + + return $authorization; + } + + private function escape($str) + { + $search = ['+', '*', '%7E']; + $replace = ['%20', '%2A', '~']; + return str_replace($search, $replace, urlencode($str)); + } + + private function getCanonicalURI($path) + { + if (empty($path)) return '/'; + $pattens = explode('/', $path); + $pattens = array_map(function ($item) { + return $this->escape($item); + }, $pattens); + $canonicalURI = implode('/', $pattens); + return $canonicalURI; + } + + private function getCanonicalQueryString($parameters) + { + if (empty($parameters)) return ''; + ksort($parameters); + $canonicalQueryString = ''; + foreach ($parameters as $key => $value) { + if (!is_array($value)) { + $canonicalQueryString .= '&' . $this->escape($key) . '=' . $this->escape($value); + } else { + sort($value); + foreach ($value as $v) { + $canonicalQueryString .= '&' . $this->escape($key) . '=' . $this->escape($v); + } + } + } + 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) { + $errmsg = curl_error($ch); + curl_close($ch); + throw new Exception('Curl error: ' . $errmsg); + } + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $arr = json_decode($response, true); + if ($httpCode == 200) { + return $arr; + } else { + if (isset($arr['Error']['Message'])) { + throw new Exception($arr['Error']['Message']); + } else { + throw new Exception('返回数据解析失败(http_code=' . $httpCode . ')'); + } + } + } +} diff --git a/app/lib/deploy/ksyun.php b/app/lib/deploy/ksyun.php new file mode 100644 index 0000000..0a559cc --- /dev/null +++ b/app/lib/deploy/ksyun.php @@ -0,0 +1,79 @@ +AccessKeyId = $config['AccessKeyId']; + $this->SecretAccessKey = $config['SecretAccessKey']; + $this->proxy = isset($config['proxy']) ? $config['proxy'] == 1 : false; + } + + public function check() + { + if (empty($this->AccessKeyId) || empty($this->SecretAccessKey)) throw new Exception('必填参数不能为空'); + $client = new KsyunClient($this->AccessKeyId, $this->SecretAccessKey, 'cdn.api.ksyun.com', 'cdn', 'cn-shanghai-2', $this->proxy); + $client->request('GET', 'GetCertificates', '2016-09-01', '/2016-09-01/cert/GetCertificates'); + return true; + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + $this->deploy_cdn($fullchain, $privatekey, $config, $info); + } + + public function deploy_cdn($fullchain, $privatekey, $config, &$info) + { + if (empty($config['domain'])) throw new Exception('绑定的域名不能为空'); + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + $config['cert_name'] = str_replace('*.', '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t']; + $domains = explode(',', $config['domain']); + + $client = new KsyunClient($this->AccessKeyId, $this->SecretAccessKey, 'cdn.api.ksyun.com', 'cdn', 'cn-shanghai-2', $this->proxy); + $param = [ + 'PageSize' => 100, + 'PageNumber' => 1, + ]; + $domain_ids = []; + $result = $client->request('GET', 'GetCdnDomains', '2019-06-01', '/2019-06-01/domain/GetCdnDomains', $param); + foreach ($result['Domains'] as $row) { + if (in_array($row['DomainName'], $domains)) { + $domain_ids[] = $row['DomainId']; + } + } + if (count($domain_ids) == 0) throw new Exception('未找到对应的CDN域名'); + $param = [ + 'Enable' => 'on', + 'DomainIds' => implode(',', $domain_ids), + 'CertificateName' => $config['cert_name'], + 'ServerCertificate' => $fullchain, + 'PrivateKey' => $privatekey, + ]; + $result = $client->request('POST', 'ConfigCertificate', '2016-09-01', '/2016-09-01/cert/ConfigCertificate', $param); + $this->log('CDN证书部署成功,证书ID:' . $result['CertificateId']); + } + + public function setLogger($func) + { + $this->logger = $func; + } + + private function log($txt) + { + if ($this->logger) { + call_user_func($this->logger, $txt); + } + } +} diff --git a/app/view/dmonitor/taskform.html b/app/view/dmonitor/taskform.html index 4f004ca..aa6807d 100644 --- a/app/view/dmonitor/taskform.html +++ b/app/view/dmonitor/taskform.html @@ -55,7 +55,7 @@
- +
diff --git a/public/static/images/ksyun.ico b/public/static/images/ksyun.ico new file mode 100644 index 0000000000000000000000000000000000000000..2810e3831086bf892f8c44ce5f45c91668b2179e GIT binary patch literal 4286 zcmcImdr*{B6#ozt#>~tqvob|<#xezbq4-G9BJ)uynof~9I$Hjr&1hq0O;CyuYHBq; zGEK)sgLI+?rY4dP#?(qnM#mgq<38ABmtA(3g?;_{oo{zxSC)rPE%W1k-~H}AzjMw# z=iH+xQT&UGQ{=z0x09lDQ55A#f>TNgA@k~w-|pnsU(qW46ipkBZHX?#L}D#*kT`xD zzTx{KBA#g9-@orp=)%?p_EU~Fi8^fIXd=)W^i#Aaxo$qujS$_#WHd!b@M;#=N!`vV`aW0OyW1kP~pvCaVbQs)8hyHC^^qHK6 z#AR$y0X~c0C0``gTJPs*Em9xXW6?Y_)^D%I(zmNHZa@VF-rJ|3LnsId^p??W)710S3IBY(#pJ0cm|JFr+i>q@H7X>#=o<4OKQTOcpOnj@2UjQ!8FiudH7` zwvQeIVk7CJ*+v-0B74WOdV!G z?gv&>m_4vLyx9AL0}B_Lv3i{q$A52Fzx#X6zhXx6L)1AcOb;h{`_kv)dx?ko+LCaO z7B7x6py+T7;b>u$Q8761)rrhNlgvTVI+ONr^uo1HPXJHifiy$UNttYx8VeR z>~Q)ygy`ZG7gnydB7dJl^@G{!#i=v3*t*e%UEAz9cfkcG^$;EAO*3IYOal+|O=?p+ zLh8B7AKzAsnQ2DUx_qj=Uwv)IsJ?ptH=fD#R3l1`I8_@ftGw8_!-l*~R@~INRlN%K zI51_b0g23s#QU_EJKcoxau+;a9|{gQFzOM%tN@>!0c(8~CM`EXg#h{5Nm8F>ki1kv(I2=`nYP37PL&uq)q=bstw_ z*5pbxSA(3vF{zsl`2~!P8ZUFmjpyQ;=a=(O=lN-k9yM-`GTt&KQ8xWBj`=uqq7fs> zFY*(4RzrK~FsO4wOifKTV8#R^hR5lU(qE5NIgAaB2VS24=G``b9tM0J&@s$@nUh@I zxZS44pv3xb^zGgPI}Xs_I}7a?o7~8TdLJ!cVnOjSCkppE@EvnsTjA%vblr`a)GLAU z6ZB08zw|Bj^YP68aYHMxgTB=mJnC5l{}u1A!Jr2l$3$@dTW^|inOwnN9rNfEYsC`A zY*NRF`&fh2o~fQ+WQh)`%)7;N%=lub4QDUZsUnXYa$@vTVL24GFLS8{w`jAi#)qPx zYml93!K>pdF}OoSZVkDlFOhyNOiu7Uf%W1U>apr0e@TUK=)5V?_6eHivRd_TPQ#-9Nuce(II{unsG;tZLn{GndY@HtgDAM^>g4YjUe` zfHhjH_s80iGAE`zYf#Uy8JQRPP3=QC${i$qs?_69{TiO>gV8b+LD~R`OdyeGoG^R{T{SGIP$yb&^^$nHhmU%u3x#I zTO<8_kS^jF&_QBTVnlkyyRkKNPhr64lIsyUZ=aNPL~LN6)Mx1xLS?q*S#nHlV5~Jh z@7p;ey~{p=y}9&?+7;ACaM5 zQF5qVpr>z*Xa$xYKvpz-iUN