From b585e5fa5548a09ef00e4674e2ce89c6abb240c7 Mon Sep 17 00:00:00 2001 From: net909 Date: Sat, 21 Dec 2024 17:07:51 +0800 Subject: [PATCH] =?UTF-8?q?SSL=E8=AF=81=E4=B9=A6=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=E4=B8=8E=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 +- app/command/Certtask.php | 33 + app/command/Dmtask.php | 2 +- app/command/Opiptask.php | 2 +- app/command/Reset.php | 47 + app/controller/Auth.php | 56 +- app/controller/Cert.php | 735 +++++++++ app/controller/Dmonitor.php | 87 +- app/controller/Index.php | 43 +- app/controller/Optimizeip.php | 2 +- app/controller/System.php | 98 ++ app/data/domain_root.txt | 1925 ++++++++++++++++++++++ app/lib/CertHelper.php | 349 ++++ app/lib/CertInterface.php | 24 + app/lib/DeployHelper.php | 1327 +++++++++++++++ app/lib/DeployInterface.php | 12 + app/lib/TOTP.php | 228 +++ app/lib/acme/ACMECert.php | 683 ++++++++ app/lib/acme/ACME_Exception.php | 24 + app/lib/acme/ACMEv2.php | 428 +++++ app/lib/cert/aliyun.php | 167 ++ app/lib/cert/customacme.php | 114 ++ app/lib/cert/google.php | 118 ++ app/lib/cert/huoshan.php | 163 ++ app/lib/cert/letsencrypt.php | 113 ++ app/lib/cert/tencent.php | 189 +++ app/lib/cert/ucloud.php | 187 +++ app/lib/cert/zerossl.php | 110 ++ app/lib/client/AWS.php | 330 ++++ app/lib/client/Aliyun.php | 95 ++ app/lib/client/AliyunNew.php | 169 ++ app/lib/client/AliyunOss.php | 261 +++ app/lib/client/BaiduCloud.php | 175 ++ app/lib/client/HuaweiCloud.php | 175 ++ app/lib/client/Qiniu.php | 104 ++ app/lib/client/TencentCloud.php | 127 ++ app/lib/client/Ucloud.php | 51 + app/lib/client/Volcengine.php | 192 +++ app/lib/deploy/aliyun.php | 649 ++++++++ app/lib/deploy/allwaf.php | 127 ++ app/lib/deploy/aws.php | 90 + app/lib/deploy/baidu.php | 71 + app/lib/deploy/baishan.php | 85 + app/lib/deploy/btpanel.php | 126 ++ app/lib/deploy/cachefly.php | 67 + app/lib/deploy/cdnfly.php | 75 + app/lib/deploy/doge.php | 120 ++ app/lib/deploy/ftp.php | 113 ++ app/lib/deploy/gcore.php | 77 + app/lib/deploy/goedge.php | 135 ++ app/lib/deploy/huawei.php | 147 ++ app/lib/deploy/huoshan.php | 96 ++ app/lib/deploy/kangle.php | 208 +++ app/lib/deploy/lecdn.php | 106 ++ app/lib/deploy/local.php | 77 + app/lib/deploy/opanel.php | 111 ++ app/lib/deploy/qiniu.php | 145 ++ app/lib/deploy/safeline.php | 104 ++ app/lib/deploy/ssh.php | 205 +++ app/lib/deploy/tencent.php | 249 +++ app/lib/deploy/ucloud.php | 132 ++ app/lib/dns/aliyun.php | 82 +- app/lib/dns/baidu.php | 149 +- app/lib/dns/dnspod.php | 123 +- app/lib/dns/huawei.php | 161 +- app/lib/dns/huoshan.php | 181 +- app/lib/mail/Aliyun.php | 55 +- app/middleware/AuthUser.php | 4 +- app/middleware/ViewOutput.php | 2 +- app/service/CertDeployService.php | 140 ++ app/service/CertOrderService.php | 426 +++++ app/service/CertTaskService.php | 92 ++ app/{lib => service}/OptimizeService.php | 6 +- app/{lib => service}/TaskRunner.php | 9 +- app/sql/install.sql | 89 +- app/sql/update.sql | 86 +- app/utils/CertDnsUtils.php | 151 ++ app/{lib => utils}/CheckUtils.php | 2 +- app/utils/DnsQueryUtils.php | 59 + app/{lib => utils}/MsgNotice.php | 79 +- app/view/auth/login.html | 70 +- app/view/cert/account_form.html | 237 +++ app/view/cert/certaccount.html | 93 ++ app/view/cert/certorder.html | 385 +++++ app/view/cert/certset.html | 115 ++ app/view/cert/cname.html | 244 +++ app/view/cert/deploy_form.html | 253 +++ app/view/cert/deployaccount.html | 93 ++ app/view/cert/deploytask.html | 286 ++++ app/view/cert/order_form.html | 157 ++ app/view/common/layout.html | 50 +- app/view/dmonitor/overview.html | 55 +- app/view/dmonitor/task.html | 12 +- app/view/index/setpwd.html | 127 ++ app/view/optimizeip/opiplist.html | 12 +- app/view/system/noticeset.html | 200 +++ app/view/system/proxyset.html | 113 ++ config/app.php | 4 +- config/console.php | 2 + public/static/css/bootstrap-table.css | 6 + public/static/images/aws.ico | Bin 0 -> 1150 bytes public/static/images/bt.ico | Bin 0 -> 4286 bytes public/static/images/cloud.png | Bin 0 -> 6872 bytes public/static/images/gcore.ico | Bin 0 -> 15086 bytes public/static/images/google.ico | Bin 0 -> 5430 bytes public/static/images/host.png | Bin 0 -> 5409 bytes public/static/images/letsencrypt.ico | Bin 0 -> 6518 bytes public/static/images/opanel.png | Bin 0 -> 9330 bytes public/static/images/qiniu.ico | Bin 0 -> 5430 bytes public/static/images/safeline.png | Bin 0 -> 5877 bytes public/static/images/server.png | Bin 0 -> 7181 bytes public/static/images/server2.png | Bin 0 -> 7111 bytes public/static/images/ssl.ico | Bin 0 -> 9662 bytes public/static/images/tencent.ico | Bin 0 -> 949 bytes public/static/images/ucloud.ico | Bin 0 -> 15086 bytes public/static/images/waf.png | Bin 0 -> 9364 bytes public/static/images/zerossl.ico | Bin 0 -> 100867 bytes public/static/js/custom.js | 2 +- route/app.php | 40 +- 119 files changed, 15923 insertions(+), 806 deletions(-) create mode 100644 app/command/Certtask.php create mode 100644 app/command/Reset.php create mode 100644 app/controller/Cert.php create mode 100644 app/controller/System.php create mode 100644 app/data/domain_root.txt create mode 100644 app/lib/CertHelper.php create mode 100644 app/lib/CertInterface.php create mode 100644 app/lib/DeployHelper.php create mode 100644 app/lib/DeployInterface.php create mode 100644 app/lib/TOTP.php create mode 100644 app/lib/acme/ACMECert.php create mode 100644 app/lib/acme/ACME_Exception.php create mode 100644 app/lib/acme/ACMEv2.php create mode 100644 app/lib/cert/aliyun.php create mode 100644 app/lib/cert/customacme.php create mode 100644 app/lib/cert/google.php create mode 100644 app/lib/cert/huoshan.php create mode 100644 app/lib/cert/letsencrypt.php create mode 100644 app/lib/cert/tencent.php create mode 100644 app/lib/cert/ucloud.php create mode 100644 app/lib/cert/zerossl.php create mode 100644 app/lib/client/AWS.php create mode 100644 app/lib/client/Aliyun.php create mode 100644 app/lib/client/AliyunNew.php create mode 100644 app/lib/client/AliyunOss.php create mode 100644 app/lib/client/BaiduCloud.php create mode 100644 app/lib/client/HuaweiCloud.php create mode 100644 app/lib/client/Qiniu.php create mode 100644 app/lib/client/TencentCloud.php create mode 100644 app/lib/client/Ucloud.php create mode 100644 app/lib/client/Volcengine.php create mode 100644 app/lib/deploy/aliyun.php create mode 100644 app/lib/deploy/allwaf.php create mode 100644 app/lib/deploy/aws.php create mode 100644 app/lib/deploy/baidu.php create mode 100644 app/lib/deploy/baishan.php create mode 100644 app/lib/deploy/btpanel.php create mode 100644 app/lib/deploy/cachefly.php create mode 100644 app/lib/deploy/cdnfly.php create mode 100644 app/lib/deploy/doge.php create mode 100644 app/lib/deploy/ftp.php create mode 100644 app/lib/deploy/gcore.php create mode 100644 app/lib/deploy/goedge.php create mode 100644 app/lib/deploy/huawei.php create mode 100644 app/lib/deploy/huoshan.php create mode 100644 app/lib/deploy/kangle.php create mode 100644 app/lib/deploy/lecdn.php create mode 100644 app/lib/deploy/local.php create mode 100644 app/lib/deploy/opanel.php create mode 100644 app/lib/deploy/qiniu.php create mode 100644 app/lib/deploy/safeline.php create mode 100644 app/lib/deploy/ssh.php create mode 100644 app/lib/deploy/tencent.php create mode 100644 app/lib/deploy/ucloud.php create mode 100644 app/service/CertDeployService.php create mode 100644 app/service/CertOrderService.php create mode 100644 app/service/CertTaskService.php rename app/{lib => service}/OptimizeService.php (96%) rename app/{lib => service}/TaskRunner.php (95%) create mode 100644 app/utils/CertDnsUtils.php rename app/{lib => utils}/CheckUtils.php (97%) create mode 100644 app/utils/DnsQueryUtils.php rename app/{lib => utils}/MsgNotice.php (57%) create mode 100644 app/view/cert/account_form.html create mode 100644 app/view/cert/certaccount.html create mode 100644 app/view/cert/certorder.html create mode 100644 app/view/cert/certset.html create mode 100644 app/view/cert/cname.html create mode 100644 app/view/cert/deploy_form.html create mode 100644 app/view/cert/deployaccount.html create mode 100644 app/view/cert/deploytask.html create mode 100644 app/view/cert/order_form.html create mode 100644 app/view/system/noticeset.html create mode 100644 app/view/system/proxyset.html create mode 100644 public/static/images/aws.ico create mode 100644 public/static/images/bt.ico create mode 100644 public/static/images/cloud.png create mode 100644 public/static/images/gcore.ico create mode 100644 public/static/images/google.ico create mode 100644 public/static/images/host.png create mode 100644 public/static/images/letsencrypt.ico create mode 100644 public/static/images/opanel.png create mode 100644 public/static/images/qiniu.ico create mode 100644 public/static/images/safeline.png create mode 100644 public/static/images/server.png create mode 100644 public/static/images/server2.png create mode 100644 public/static/images/ssl.ico create mode 100644 public/static/images/tencent.ico create mode 100644 public/static/images/ucloud.ico create mode 100644 public/static/images/waf.png create mode 100644 public/static/images/zerossl.ico diff --git a/README.md b/README.md index fec5066..17116a6 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - 提供API接口,可获取域名单独的登录链接,方便各种IDC系统对接 - 容灾切换功能,支持ping、tcp、http(s)检测协议并自动暂停/修改域名解析,并支持邮件、微信公众号通知 - CF优选IP功能,支持获取最新的Cloudflare优选IP,并自动更新到解析记录 +- SSL证书申请与自动部署功能,支持从Let's Encrypt等渠道申请SSL证书,并自动部署到各种面板、云服务商、服务器等 ### 演示截图 @@ -38,6 +39,18 @@ CF优选IP功能,添加优选IP任务 ![](https://p1.meituan.net/csc/da70c76753aee4bce044d16fadd56e5f217660.png) +SSL证书申请功能 + +![](https://blog.cccyun.cn/content/uploadfile/202412/QQ%E6%88%AA%E5%9B%BE20241221154857.png) + +![](https://blog.cccyun.cn/content/uploadfile/202412/QQ%E6%88%AA%E5%9B%BE20241221154652.png) + +SSL证书自动部署功能 + +![](https://blog.cccyun.cn/content/uploadfile/202412/QQ%E6%88%AA%E5%9B%BE20241221154702.png) + +![](https://blog.cccyun.cn/content/uploadfile/202412/QQ%E6%88%AA%E5%9B%BE20241221154804.png) + ### 部署方法 * 从[Release](https://github.com/netcccyun/dnsmgr/releases)页面下载安装包 @@ -158,9 +171,9 @@ create database dnsmgr; 在install界面链接IP填写dnsmgr-mysql -### 版权信息 +### 作者信息 -版权所有Copyright © 2023~2024 by 消失的彩虹海(https://blog.cccyun.cn) +消失的彩虹海(https://blog.cccyun.cn) ### 其他推荐 diff --git a/app/command/Certtask.php b/app/command/Certtask.php new file mode 100644 index 0000000..443e5a3 --- /dev/null +++ b/app/command/Certtask.php @@ -0,0 +1,33 @@ +setName('certtask') + ->setDescription('证书申请与部署任务'); + } + + protected function execute(Input $input, Output $output) + { + $res = Db::name('config')->cache('configs', 0)->column('value', 'key'); + Config::set($res, 'sys'); + + (new CertTaskService())->execute(); + } +} diff --git a/app/command/Dmtask.php b/app/command/Dmtask.php index 3df1c3b..f69eb53 100644 --- a/app/command/Dmtask.php +++ b/app/command/Dmtask.php @@ -12,7 +12,7 @@ use think\console\input\Option; use think\console\Output; use think\facade\Db; use think\facade\Config; -use app\lib\TaskRunner; +use app\service\TaskRunner; class Dmtask extends Command { diff --git a/app/command/Opiptask.php b/app/command/Opiptask.php index 030cf1f..df60bed 100644 --- a/app/command/Opiptask.php +++ b/app/command/Opiptask.php @@ -12,7 +12,7 @@ use think\console\input\Option; use think\console\Output; use think\facade\Db; use think\facade\Config; -use app\lib\OptimizeService; +use app\service\OptimizeService; class Opiptask extends Command { diff --git a/app/command/Reset.php b/app/command/Reset.php new file mode 100644 index 0000000..019387f --- /dev/null +++ b/app/command/Reset.php @@ -0,0 +1,47 @@ +setName('reset') + ->addArgument('type', Argument::REQUIRED, '操作类型,pwd:重置密码,totp:关闭TOTP') + ->addArgument('username', Argument::REQUIRED, '用户名') + ->addArgument('password', Argument::OPTIONAL, '密码') + ->setDescription('重置密码'); + } + + protected function execute(Input $input, Output $output) + { + $type = trim($input->getArgument('type')); + $username = trim($input->getArgument('username')); + $user = Db::name('user')->where('username', $username)->find(); + if (!$user) { + $output->writeln('用户 ' . $username . ' 不存在'); + return; + } + if ($type == 'pwd') { + $password = $input->getArgument('password'); + if (empty($password)) $password = '123456'; + Db::name('user')->where('id', $user['id'])->update(['password' => password_hash($password, PASSWORD_DEFAULT)]); + $output->writeln('用户 ' . $username . ' 密码重置成功'); + } elseif ($type == 'totp') { + Db::name('user')->where('id', $user['id'])->update(['totp_open' => 0, 'totp_secret' => null]); + $output->writeln('用户 ' . $username . ' TOTP关闭成功'); + } + } +} diff --git a/app/controller/Auth.php b/app/controller/Auth.php index e6581c4..7d7891e 100644 --- a/app/controller/Auth.php +++ b/app/controller/Auth.php @@ -3,6 +3,7 @@ namespace app\controller; use app\BaseController; +use Exception; use think\facade\Db; class Auth extends BaseController @@ -37,12 +38,14 @@ class Auth extends BaseController $user = Db::name('user')->where('username', $username)->find(); if ($user && password_verify($password, $user['password'])) { if ($user['status'] == 0) return json(['code' => -1, 'msg' => '此用户已被封禁', 'vcode' => 1]); - Db::name('log')->insert(['uid' => $user['id'], 'action' => '登录后台', 'data' => 'IP:' . $this->clientip, 'addtime' => date("Y-m-d H:i:s")]); - DB::name('user')->where('id', $user['id'])->update(['lasttime' => date("Y-m-d H:i:s")]); - $session = md5($user['id'] . $user['password']); - $expiretime = time() + 2562000; - $token = authcode("user\t{$user['id']}\t{$session}\t{$expiretime}", 'ENCODE', config_get('sys_key')); - cookie('user_token', $token, ['expire' => $expiretime, 'httponly' => true]); + if ($user['totp_open'] == 1 && !empty($user['totp_secret'])) { + session('pre_login_user', $user['id']); + if (file_exists($login_limit_file)) { + unlink($login_limit_file); + } + return json(['code' => -1, 'msg' => '需要验证动态口令', 'vcode' => 2]); + } + $this->loginUser($user); if (file_exists($login_limit_file)) { unlink($login_limit_file); } @@ -50,6 +53,7 @@ class Auth extends BaseController } else { if ($user) { Db::name('log')->insert(['uid' => $user['id'], 'action' => '登录失败', 'data' => 'IP:' . $this->clientip, 'addtime' => date("Y-m-d H:i:s")]); + if ($user['totp_open'] == 1 && !empty($user['totp_secret'])) $login_limit_count = 10; } if (!file_exists($login_limit_file)) { $login_limit = ['count' => 0, 'time' => 0]; @@ -69,6 +73,28 @@ class Auth extends BaseController return view(); } + public function totp() + { + $uid = session('pre_login_user'); + if (empty($uid)) return json(['code' => -1, 'msg' => '请重新登录']); + $code = input('post.code'); + if (empty($code)) return json(['code' => -1, 'msg' => '请输入动态口令']); + $user = Db::name('user')->where('id', $uid)->find(); + if (!$user) return json(['code' => -1, 'msg' => '用户不存在']); + if ($user['totp_open'] == 0 || empty($user['totp_secret'])) return json(['code' => -1, 'msg' => '未开启TOTP二次验证']); + try { + $totp = \app\lib\TOTP::create($user['totp_secret']); + if (!$totp->verify($code)) { + return json(['code' => -1, 'msg' => '动态口令错误']); + } + } catch (Exception $e) { + return json(['code' => -1, 'msg' => $e->getMessage()]); + } + $this->loginUser($user); + session('pre_login_user', null); + return json(['code' => 0]); + } + public function logout() { cookie('user_token', null); @@ -101,13 +127,27 @@ class Auth extends BaseController return $this->alert('error', '该域名不支持快捷登录'); } - Db::name('log')->insert(['uid' => 0, 'action' => '域名快捷登录', 'data' => 'IP:' . $this->clientip, 'addtime' => date("Y-m-d H:i:s"), 'domain' => $domain]); + $this->loginDomain($row); + return redirect('/record/' . $row['id']); + } + private function loginUser($user) + { + Db::name('log')->insert(['uid' => $user['id'], 'action' => '登录后台', 'data' => 'IP:' . $this->clientip, 'addtime' => date("Y-m-d H:i:s")]); + DB::name('user')->where('id', $user['id'])->update(['lasttime' => date("Y-m-d H:i:s")]); + $session = md5($user['id'] . $user['password']); + $expiretime = time() + 2562000; + $token = authcode("user\t{$user['id']}\t{$session}\t{$expiretime}", 'ENCODE', config_get('sys_key')); + cookie('user_token', $token, ['expire' => $expiretime, 'httponly' => true]); + } + + private function loginDomain($row) + { + Db::name('log')->insert(['uid' => 0, 'action' => '域名快捷登录', 'data' => 'IP:' . $this->clientip, 'addtime' => date("Y-m-d H:i:s"), 'domain' => $row['name']]); $session = md5($row['id'] . $row['name']); $expiretime = time() + 2562000; $token = authcode("domain\t{$row['id']}\t{$session}\t{$expiretime}", 'ENCODE', config_get('sys_key')); cookie('user_token', $token, ['expire' => $expiretime, 'httponly' => true]); - return redirect('/record/' . $row['id']); } public function verifycode() diff --git a/app/controller/Cert.php b/app/controller/Cert.php new file mode 100644 index 0000000..36bec3f --- /dev/null +++ b/app/controller/Cert.php @@ -0,0 +1,735 @@ +alert('error', '无权限'); + return view(); + } + + public function deployaccount() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + return view(); + } + + public function account_data() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + $deploy = input('get.deploy/d', 0); + $kw = $this->request->post('kw', null, 'trim'); + $offset = input('post.offset/d'); + $limit = input('post.limit/d'); + + $select = Db::name('cert_account')->where('deploy', $deploy); + if (!empty($kw)) { + $select->whereLike('name|remark', '%' . $kw . '%'); + } + $total = $select->count(); + $rows = $select->order('id', 'desc')->limit($offset, $limit)->select(); + + $list = []; + foreach ($rows as $row) { + $row['typename'] = $deploy == 1 ? DeployHelper::$deploy_config[$row['type']]['name'] : CertHelper::$cert_config[$row['type']]['name']; + $row['icon'] = $deploy == 1 ? DeployHelper::$deploy_config[$row['type']]['icon'] : CertHelper::$cert_config[$row['type']]['icon']; + $list[] = $row; + } + + return json(['total' => $total, 'rows' => $list]); + } + + public function account_op() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + $action = input('param.action'); + $deploy = input('post.deploy/d', 0); + $title = $deploy == 1 ? '自动部署账户' : 'SSL证书账户'; + + if ($action == 'add') { + $type = input('post.type'); + $name = input('post.name', null, 'trim'); + $config = input('post.config', null, 'trim'); + $remark = input('post.remark', null, 'trim'); + if ($type == 'local') $name = '复制到本机'; + if (empty($name) || empty($config)) return json(['code' => -1, 'msg' => '必填参数不能为空']); + if (Db::name('cert_account')->where('type', $type)->where('config', $config)->find()) { + return json(['code' => -1, 'msg' => $title.'已存在']); + } + Db::startTrans(); + $id = Db::name('cert_account')->insertGetId([ + 'type' => $type, + 'name' => $name, + 'config' => $config, + 'remark' => $remark, + 'deploy' => $deploy, + 'addtime' => date('Y-m-d H:i:s'), + ]); + try { + $this->checkAccount($id, $type, $deploy); + Db::commit(); + return json(['code' => 0, 'msg' => '添加'.$title.'成功!']); + } catch(Exception $e) { + Db::rollback(); + return json(['code' => -1, 'msg' => $e->getMessage()]); + } + } elseif ($action == 'edit') { + $id = input('post.id/d'); + $row = Db::name('cert_account')->where('id', $id)->find(); + if (!$row) return json(['code' => -1, 'msg' => $title.'不存在']); + $type = input('post.type'); + $name = input('post.name', null, 'trim'); + $config = input('post.config', null, 'trim'); + $remark = input('post.remark', null, 'trim'); + if ($type == 'local') $name = '复制到本机'; + if (empty($name) || empty($config)) return json(['code' => -1, 'msg' => '必填参数不能为空']); + if (Db::name('cert_account')->where('type', $type)->where('config', $config)->where('id', '<>', $id)->find()) { + return json(['code' => -1, 'msg' => $title.'已存在']); + } + Db::startTrans(); + Db::name('cert_account')->where('id', $id)->update([ + 'type' => $type, + 'name' => $name, + 'config' => $config, + 'remark' => $remark, + ]); + try { + $this->checkAccount($id, $type, $deploy); + Db::commit(); + return json(['code' => 0, 'msg' => '修改'.$title.'成功!']); + } catch(Exception $e) { + Db::rollback(); + return json(['code' => -1, 'msg' => $e->getMessage()]); + } + } elseif ($action == 'del') { + $id = input('post.id/d'); + if($deploy == 0){ + $dcount = DB::name('cert_order')->where('aid', $id)->count(); + if ($dcount > 0) return json(['code' => -1, 'msg' => '该'.$title.'下存在证书订单,无法删除']); + }else{ + $dcount = DB::name('cert_deploy')->where('aid', $id)->count(); + if ($dcount > 0) return json(['code' => -1, 'msg' => '该'.$title.'下存在自动部署任务,无法删除']); + } + Db::name('cert_account')->where('id', $id)->delete(); + return json(['code' => 0]); + } + return json(['code' => -3]); + } + + public function account_form() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + $action = input('param.action'); + $deploy = input('get.deploy/d', 0); + $title = $deploy == 1 ? '自动部署账户' : 'SSL证书账户'; + + $account = null; + if ($action == 'edit') { + $id = input('get.id/d'); + $account = Db::name('cert_account')->where('id', $id)->find(); + if (empty($account)) return $this->alert('error', $title.'不存在'); + } + + $typeList = $deploy == 1 ? DeployHelper::getList() : CertHelper::getList(); + $classList = $deploy == 1 ? DeployHelper::$class_config : CertHelper::$class_config; + + View::assign('title', $title); + View::assign('info', $account); + View::assign('typeList', $typeList); + View::assign('classList', $classList); + View::assign('action', $action); + View::assign('deploy', $deploy); + return View::fetch(); + } + + private function checkAccount($id, $type, $deploy) + { + if($deploy == 0){ + $mod = CertHelper::getModel($id); + if($mod){ + try{ + $ext = $mod->register(); + if(is_array($ext)){ + Db::name('cert_account')->where('id', $id)->update(['ext'=>json_encode($ext)]); + } + return true; + }catch(Exception $e){ + throw new Exception('验证SSL证书账户失败,' . $e->getMessage()); + } + }else{ + throw new Exception('SSL证书申请模块'.$type.'不存在'); + } + }else{ + $mod = DeployHelper::getModel($id); + if($mod){ + try{ + $mod->check(); + return true; + }catch(Exception $e){ + throw new Exception('验证自动部署账户失败,' . $e->getMessage()); + } + }else{ + throw new Exception('SSL证书申请模块'.$type.'不存在'); + } + } + } + + public function certorder() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + $types = []; + foreach(CertHelper::$cert_config as $key=>$value){ + $types[$key] = $value['name']; + } + View::assign('types', $types); + return view(); + } + + public function order_data() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + $domain = $this->request->post('domain', null, 'trim'); + $id = input('post.id'); + $type = input('post.type', null, 'trim'); + $offset = input('post.offset/d'); + $limit = input('post.limit/d'); + + $select = Db::name('cert_order')->alias('A')->join('cert_account B', 'A.aid = B.id'); + if (!empty($id)) { + $select->where('A.id', $id); + }elseif (!empty($domain)) { + $oids = Db::name('cert_domain')->where('domain', 'like', '%' . $domain . '%')->column('oid'); + $select->whereIn('A.id', $oids); + } + if (!empty($type)) { + $select->where('B.type', $type); + } + $total = $select->count(); + $rows = $select->fieldRaw('A.*,B.type,B.remark aremark')->order('id', 'desc')->limit($offset, $limit)->select(); + + $list = []; + foreach ($rows as $row) { + $row['typename'] = CertHelper::$cert_config[$row['type']]['name']; + $row['icon'] = CertHelper::$cert_config[$row['type']]['icon']; + $row['domains'] = Db::name('cert_domain')->where('oid', $row['id'])->order('sort','ASC')->column('domain'); + $row['end_day'] = $row['expiretime'] ? ceil((strtotime($row['expiretime']) - time()) / 86400) : null; + if($row['error']) $row['error'] = htmlspecialchars(str_replace("'", "\\'", $row['error'])); + $list[] = $row; + } + + return json(['total' => $total, 'rows' => $list]); + } + + public function order_op() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + $action = input('param.action'); + + if ($action == 'get') { + $id = input('post.id/d'); + $row = Db::name('cert_order')->where('id', $id)->field('fullchain,privatekey')->find(); + if (!$row) return $this->alert('error', '证书订单不存在'); + $pfx = CertHelper::getPfx($row['fullchain'], $row['privatekey']); + $row['pfx'] = base64_encode($pfx); + return json(['code' => 0, 'data' => $row]); + } elseif ($action == 'add') { + $domains = input('post.domains', [], 'trim'); + $order = [ + 'aid' => input('post.aid/d'), + 'keytype' => input('post.keytype'), + 'keysize' => input('post.keysize'), + 'addtime' => date('Y-m-d H:i:s'), + 'status' => 0, + ]; + $domains = array_map('trim', $domains); + $domains = array_filter($domains, function ($v) { + return !empty($v); + }); + $domains = array_unique($domains); + if (empty($domains)) return json(['code' => -1, 'msg' => '绑定域名不能为空']); + 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 == 'edit') { + $id = input('post.id/d'); + $row = Db::name('cert_order')->where('id', $id)->find(); + if (!$row) return json(['code' => -1, 'msg' => '证书订单不存在']); + + $domains = input('post.domains', [], 'trim'); + $order = [ + 'aid' => input('post.aid/d'), + 'keytype' => input('post.keytype'), + 'keysize' => input('post.keysize'), + 'updatetime' => date('Y-m-d H:i:s'), + ]; + $domains = array_map('trim', $domains); + $domains = array_filter($domains, function ($v) { + return !empty($v); + }); + $domains = array_unique($domains); + if (empty($domains)) return json(['code' => -1, 'msg' => '绑定域名不能为空']); + 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(); + Db::name('cert_order')->where('id', $id)->update($order); + Db::name('cert_domain')->where('oid', $id)->delete(); + $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(); + if ($dcount > 0) return json(['code' => -1, 'msg' => '该证书关联了自动部署任务,无法删除']); + try{ + (new CertOrderService($id))->cancel(); + }catch(Exception $e){ + } + Db::name('cert_order')->where('id', $id)->delete(); + Db::name('cert_domain')->where('oid', $id)->delete(); + return json(['code' => 0]); + } elseif ($action == 'setauto') { + $id = input('post.id/d'); + $isauto = input('post.isauto/d'); + Db::name('cert_order')->where('id', $id)->update(['isauto' => $isauto]); + return json(['code' => 0]); + } elseif ($action == 'reset') { + $id = input('post.id/d'); + try{ + $service = new CertOrderService($id); + $service->cancel(); + $service->reset(); + return json(['code' => 0]); + }catch(Exception $e){ + return json(['code' => -1, 'msg' => $e->getMessage()]); + } + } elseif ($action == 'revoke') { + $id = input('post.id/d'); + try{ + $service = new CertOrderService($id); + $service->revoke(); + return json(['code' => 0]); + }catch(Exception $e){ + return json(['code' => -1, 'msg' => $e->getMessage()]); + } + } elseif ($action == 'show_log') { + $processid = input('post.processid'); + $file = app()->getRuntimePath().'log/'.$processid.'.log'; + if(!file_exists($file)) return json(['code' => -1, 'msg' => '日志文件不存在']); + return json(['code' => 0, 'data' => file_get_contents($file), 'time'=>filemtime($file)]); + } + return json(['code' => -3]); + } + + private function check_order($order, $domains) + { + $account = Db::name('cert_account')->where('id', $order['aid'])->find(); + if (!$account) return ['code' => -1, 'msg' => 'SSL证书账户不存在']; + $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.'个']; + + foreach($domains as $domain){ + if(!$wildcard && strpos($domain, '*') !== false) return ['code' => -1, 'msg' => '该证书账户类型不支持泛域名']; + $mainDomain = getMainDomain($domain); + $drow = Db::name('domain')->where('name', $mainDomain)->find(); + if (!$drow) { + if (!$cname || !Db::name('cert_cname')->where('domain', $domain)->where('status', 1)->find()) { + return ['code' => -1, 'msg' => '域名'.$domain.'未在本系统添加']; + } + } + } + return true; + } + + public function order_process() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + if (function_exists("set_time_limit")) { + @set_time_limit(0); + } + if (function_exists("ignore_user_abort")) { + @ignore_user_abort(true); + } + $id = input('post.id/d'); + $reset = input('post.reset/d', 0); + try{ + $service = new CertOrderService($id); + if($reset == 1){ + $service->reset(); + } + $retcode = $service->process(true); + if($retcode == 3){ + return json(['code' => 0, 'msg' => '证书已签发成功!']); + }elseif($retcode == 1){ + return json(['code' => 0, 'msg' => '添加DNS记录成功!请等待DNS生效后点击验证']); + } + }catch(Exception $e){ + return json(['code' => -1, 'msg' => $e->getMessage()]); + } + } + + public function order_form() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + $action = input('param.action'); + + $order = null; + if ($action == 'edit') { + $id = input('get.id/d'); + $order = Db::name('cert_order')->where('id', $id)->fieldRaw('id,aid,keytype,keysize,status')->find(); + if (empty($order)) return $this->alert('error', '证书订单不存在'); + $order['domains'] = Db::name('cert_domain')->where('oid', $order['id'])->order('sort','ASC')->column('domain'); + } + + $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); + + View::assign('info', $order); + View::assign('action', $action); + return View::fetch(); + } + + + public function deploytask() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + $types = []; + foreach(DeployHelper::$deploy_config as $key=>$value){ + $types[$key] = $value['name']; + } + View::assign('types', $types); + return view(); + } + + public function deploy_data() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + $domain = $this->request->post('domain', null, 'trim'); + $oid = input('post.oid'); + $type = input('post.type', null, 'trim'); + $remark = input('post.remark', null, 'trim'); + $offset = input('post.offset/d'); + $limit = input('post.limit/d'); + + $select = Db::name('cert_deploy')->alias('A')->join('cert_account B', 'A.aid = B.id')->join('cert_order C', 'A.oid = C.id')->join('cert_account D', 'C.aid = D.id'); + if (!empty($oid)) { + $select->where('A.oid', $oid); + } elseif (!empty($domain)) { + $oids = Db::name('cert_domain')->where('domain', 'like', '%' . $domain . '%')->column('oid'); + $select->whereIn('oid', $oids); + } + if (!empty($type)) { + $select->where('B.type', $type); + } + if (!empty($remark)) { + $select->where('A.remark', $remark); + } + $total = $select->count(); + $rows = $select->fieldRaw('A.*,B.type,B.remark aremark,B.name aname,D.type certtype,D.id certaid')->order('id', 'desc')->limit($offset, $limit)->select(); + + $list = []; + foreach ($rows as $row) { + $row['typename'] = DeployHelper::$deploy_config[$row['type']]['name']; + $row['icon'] = DeployHelper::$deploy_config[$row['type']]['icon']; + $row['certtypename'] = CertHelper::$cert_config[$row['certtype']]['name']; + $row['domains'] = Db::name('cert_domain')->where('oid', $row['oid'])->order('sort','ASC')->column('domain'); + if($row['error']) $row['error'] = htmlspecialchars(str_replace("'", "\\'", $row['error'])); + $list[] = $row; + } + + return json(['total' => $total, 'rows' => $list]); + } + + public function deploy_op() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + $action = input('param.action'); + + if ($action == 'add') { + $task = [ + 'aid' => input('post.aid/d'), + 'oid' => input('post.oid/d'), + 'config' => input('post.config', null, 'trim'), + 'remark' => input('post.remark', null, 'trim'), + 'addtime' => date('Y-m-d H:i:s'), + 'status' => 0, + 'active' => 1 + ]; + if (empty($task['aid']) || empty($task['oid']) || empty($task['config'])) return json(['code' => -1, 'msg' => '必填参数不能为空']); + Db::name('cert_deploy')->insert($task); + return json(['code' => 0, 'msg' => '添加自动部署任务成功!']); + } elseif ($action == 'edit') { + $id = input('post.id/d'); + $row = Db::name('cert_deploy')->where('id', $id)->find(); + if (!$row) return json(['code' => -1, 'msg' => '自动部署任务不存在']); + + $task = [ + 'aid' => input('post.aid/d'), + 'oid' => input('post.oid/d'), + 'config' => input('post.config', null, 'trim'), + 'remark' => input('post.remark', null, 'trim'), + ]; + if (empty($task['aid']) || empty($task['oid']) || empty($task['config'])) return json(['code' => -1, 'msg' => '必填参数不能为空']); + Db::name('cert_deploy')->where('id', $id)->update($task); + return json(['code' => 0, 'msg' => '修改自动部署任务成功!']); + } elseif ($action == 'del') { + $id = input('post.id/d'); + Db::name('cert_deploy')->where('id', $id)->delete(); + return json(['code' => 0]); + } elseif ($action == 'setactive') { + $id = input('post.id/d'); + $active = input('post.active/d'); + Db::name('cert_deploy')->where('id', $id)->update(['active' => $active]); + return json(['code' => 0]); + } elseif ($action == 'reset') { + $id = input('post.id/d'); + try{ + $service = new CertDeployService($id); + $service->reset(); + return json(['code' => 0]); + }catch(Exception $e){ + return json(['code' => -1, 'msg' => $e->getMessage()]); + } + } elseif ($action == 'show_log') { + $processid = input('post.processid'); + $file = app()->getRuntimePath().'log/'.$processid.'.log'; + if(!file_exists($file)) return json(['code' => -1, 'msg' => '日志文件不存在']); + return json(['code' => 0, 'data' => file_get_contents($file), 'time'=>filemtime($file)]); + } + return json(['code' => -3]); + } + + public function deploy_process() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + if (function_exists("set_time_limit")) { + @set_time_limit(0); + } + if (function_exists("ignore_user_abort")) { + @ignore_user_abort(true); + } + $id = input('post.id/d'); + $reset = input('post.reset/d', 0); + try{ + $service = new CertDeployService($id); + if($reset == 1){ + $service->reset(); + } + $service->process(true); + return json(['code' => 0, 'msg' => 'SSL证书部署任务执行成功!']); + }catch(Exception $e){ + return json(['code' => -1, 'msg' => $e->getMessage()]); + } + } + + public function deploy_form() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + $action = input('param.action'); + + $task = null; + if ($action == 'edit') { + $id = input('get.id/d'); + $task = Db::name('cert_deploy')->alias('A')->join('cert_account B', 'A.aid = B.id')->where('A.id', $id)->fieldRaw('A.id,A.aid,A.oid,A.config,A.remark,B.type')->find(); + if (empty($task)) return $this->alert('error', '自动部署任务不存在'); + } + + $accounts = []; + foreach (Db::name('cert_account')->where('deploy', 1)->select() as $row) { + $accounts[$row['id']] = ['name'=>$row['id'].'_'.DeployHelper::$deploy_config[$row['type']]['name'], 'type'=>$row['type']]; + if (!empty($row['remark'])) { + $accounts[$row['id']]['name'] .= '(' . $row['remark'] . ')'; + } + } + View::assign('accounts', $accounts); + + $orders = []; + foreach (Db::name('cert_order')->alias('A')->join('cert_account B', 'A.aid = B.id')->where('status', '<>', 4)->fieldRaw('A.id,A.aid,B.type,B.remark aremark')->order('id', 'desc')->select() as $row) { + $domains = Db::name('cert_domain')->where('oid', $row['id'])->order('sort','ASC')->column('domain'); + $domainstr = count($domains) > 2 ? implode('、',array_slice($domains, 0, 2)).'等'.count($domains).'个域名' : implode('、',$domains); + $orders[$row['id']] = ['name'=>$row['id'].'_'.$domainstr.'('.CertHelper::$cert_config[$row['type']]['name'].')']; + } + View::assign('orders', $orders); + + View::assign('info', $task); + View::assign('action', $action); + View::assign('typeList', DeployHelper::getList()); + return View::fetch(); + } + + public function cname() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + $domains = []; + foreach (Db::name('domain')->field('id,name')->select() as $row) { + $domains[$row['id']] = $row['name']; + } + View::assign('domains', $domains); + return view(); + } + + public function cname_data() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + $kw = $this->request->post('kw', null, 'trim'); + $offset = input('post.offset/d'); + $limit = input('post.limit/d'); + + $select = Db::name('cert_cname')->alias('A')->join('domain B', 'A.did = B.id'); + if (!empty($kw)) { + $select->whereLike('A.domain', '%' . $kw . '%'); + } + $total = $select->count(); + $rows = $select->order('A.id', 'desc')->limit($offset, $limit)->field('A.*,B.name cnamedomain')->select(); + + $list = []; + foreach ($rows as $row) { + $row['host'] = $this->getCnameHost($row['domain']); + $row['record'] = $row['rr'] . '.' . $row['cnamedomain']; + $list[] = $row; + } + + return json(['total' => $total, 'rows' => $list]); + } + + private function getCnameHost($domain) + { + $main = getMainDomain($domain); + if ($main == $domain) { + return '_acme-challenge'; + } else { + return '_acme-challenge.' . substr($domain, 0, -strlen($main) - 1); + } + } + + public function cname_op() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + $action = input('param.action'); + + if ($action == 'add') { + $data = [ + 'domain' => input('post.domain', null, 'trim'), + 'rr' => input('post.rr', null, 'trim'), + 'did' => input('post.did/d'), + 'addtime' => date('Y-m-d H:i:s'), + 'status' => 0 + ]; + if (empty($data['domain']) || empty($data['rr']) || empty($data['did'])) return json(['code' => -1, 'msg' => '必填参数不能为空']); + if (!checkDomain($data['domain'])) return json(['code' => -1, 'msg' => '域名格式不正确']); + if (Db::name('cert_cname')->where('domain', $data['domain'])->find()) { + return json(['code' => -1, 'msg' => '域名'.$data['domain'].'已存在']); + } + if (Db::name('cert_cname')->where('rr', $data['rr'])->where('did', $data['did'])->find()) { + return json(['code' => -1, 'msg' => '已存在相同CNAME记录值']); + } + Db::name('cert_cname')->insert($data); + return json(['code' => 0, 'msg' => '添加CMAME代理成功!']); + } elseif ($action == 'edit') { + $id = input('post.id/d'); + $row = Db::name('cert_cname')->where('id', $id)->find(); + if (!$row) return json(['code' => -1, 'msg' => 'CMAME代理不存在']); + + $data = [ + 'rr' => input('post.rr', null, 'trim'), + 'did' => input('post.did/d'), + ]; + if ($row['rr'] != $data['rr'] || $row['did'] != $data['did']) { + $data['status'] = 0; + } + if (empty($data['rr']) || empty($data['did'])) return json(['code' => -1, 'msg' => '必填参数不能为空']); + if (Db::name('cert_cname')->where('rr', $data['rr'])->where('did', $data['did'])->where('id', '<>', $id)->find()) { + return json(['code' => -1, 'msg' => '已存在相同CNAME记录值']); + } + Db::name('cert_cname')->where('id', $id)->update($data); + return json(['code' => 0, 'msg' => '修改CMAME代理成功!']); + } elseif ($action == 'del') { + $id = input('post.id/d'); + Db::name('cert_cname')->where('id', $id)->delete(); + return json(['code' => 0]); + } elseif ($action == 'check') { + $id = input('post.id/d'); + $row = Db::name('cert_cname')->alias('A')->join('domain B', 'A.did = B.id')->where('A.id', $id)->field('A.*,B.name cnamedomain')->find(); + if (!$row) return json(['code' => -1, 'msg' => '自动部署任务不存在']); + + $status = 1; + $domain = '_acme-challenge.' . $row['domain']; + $record = $row['rr'] . '.' . $row['cnamedomain']; + $result = \app\utils\DnsQueryUtils::get_dns_records($domain, 'CNAME'); + if(!$result || !in_array($record, $result)){ + $result = \app\utils\DnsQueryUtils::query_dns_doh($domain, 'CNAME'); + if(!$result || !in_array($record, $result)){ + $status = 0; + } + } + if($status != $row['status']){ + Db::name('cert_cname')->where('id', $id)->update(['status' => $status]); + } + return json(['code' => 0, 'status' => $status]); + } + } + + public function certset() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + if ($this->request->isPost()) { + $params = input('post.'); + foreach ($params as $key => $value) { + if (empty($key)) { + continue; + } + config_set($key, $value); + Cache::delete('configs'); + } + return json(['code' => 0, 'msg' => 'succ']); + } + return View::fetch(); + } +} diff --git a/app/controller/Dmonitor.php b/app/controller/Dmonitor.php index 0ed4683..f62120f 100644 --- a/app/controller/Dmonitor.php +++ b/app/controller/Dmonitor.php @@ -3,7 +3,6 @@ namespace app\controller; use app\BaseController; -use Exception; use think\facade\Db; use think\facade\View; use think\facade\Cache; @@ -219,87 +218,15 @@ class Dmonitor extends BaseController public function noticeset() { if (!checkPermission(2)) return $this->alert('error', '无权限'); - if ($this->request->isPost()) { - $params = input('post.'); - if (isset($params['mail_type']) && isset($params['mail_name2']) && $params['mail_type'] > 0) { - $params['mail_name'] = $params['mail_name2']; - unset($params['mail_name2']); + $params = input('post.'); + foreach ($params as $key => $value) { + if (empty($key)) { + continue; } - foreach ($params as $key => $value) { - if (empty($key)) { - continue; - } - config_set($key, $value); - Cache::delete('configs'); - } - return json(['code' => 0, 'msg' => 'succ']); + config_set($key, $value); + Cache::delete('configs'); } - return View::fetch(); - } - - public function proxyset() - { - if (!checkPermission(2)) return $this->alert('error', '无权限'); - if ($this->request->isPost()) { - $params = input('post.'); - foreach ($params as $key => $value) { - if (empty($key)) { - continue; - } - config_set($key, $value); - Cache::delete('configs'); - } - return json(['code' => 0, 'msg' => 'succ']); - } - return View::fetch(); - } - - public function mailtest() - { - if (!checkPermission(2)) return $this->alert('error', '无权限'); - $mail_name = config_get('mail_recv') ? config_get('mail_recv') : config_get('mail_name'); - if (empty($mail_name)) return json(['code' => -1, 'msg' => '您还未设置邮箱!']); - $result = \app\lib\MsgNotice::send_mail($mail_name, '邮件发送测试。', '这是一封测试邮件!

来自:' . $this->request->root(true)); - if ($result === true) { - return json(['code' => 0, 'msg' => '邮件发送成功!']); - } else { - return json(['code' => -1, 'msg' => '邮件发送失败!' . $result]); - } - } - - public function tgbottest() - { - if (!checkPermission(2)) return $this->alert('error', '无权限'); - $tgbot_token = config_get('tgbot_token'); - $tgbot_chatid = config_get('tgbot_chatid'); - if (empty($tgbot_token) || empty($tgbot_chatid)) return json(['code' => -1, 'msg' => '请先保存设置']); - $content = "消息发送测试\n\n这是一封测试消息!\n\n来自:" . $this->request->root(true); - $result = \app\lib\MsgNotice::send_telegram_bot($content); - if ($result === true) { - return json(['code' => 0, 'msg' => '消息发送成功!']); - } else { - return json(['code' => -1, 'msg' => '消息发送失败!' . $result]); - } - } - - public function proxytest() - { - if (!checkPermission(2)) return $this->alert('error', '无权限'); - $proxy_server = trim($_POST['proxy_server']); - $proxy_port = $_POST['proxy_port']; - $proxy_user = trim($_POST['proxy_user']); - $proxy_pwd = trim($_POST['proxy_pwd']); - $proxy_type = $_POST['proxy_type']; - try { - check_proxy('https://dl.amh.sh/ip.htm', $proxy_server, $proxy_port, $proxy_type, $proxy_user, $proxy_pwd); - } catch (Exception $e) { - try { - check_proxy('https://myip.ipip.net/', $proxy_server, $proxy_port, $proxy_type, $proxy_user, $proxy_pwd); - } catch (Exception $e) { - return json(['code' => -1, 'msg' => $e->getMessage()]); - } - } - return json(['code' => 0]); + return json(['code' => 0, 'msg' => 'succ']); } public function clean() diff --git a/app/controller/Index.php b/app/controller/Index.php index 6a2467e..ea99692 100644 --- a/app/controller/Index.php +++ b/app/controller/Index.php @@ -8,6 +8,7 @@ use think\facade\Db; use think\facade\View; use think\facade\Cache; use app\lib\DnsHelper; +use app\utils\MsgNotice; class Index extends BaseController { @@ -120,16 +121,46 @@ class Index extends BaseController Db::name('user')->where('id', $this->request->user['id'])->update(['password' => password_hash($newpwd, PASSWORD_DEFAULT)]); return json(['code' => 0, 'msg' => 'succ']); } + View::assign('user', $this->request->user); return view(); } + public function totp() + { + if (!checkPermission(1)) return $this->alert('error', '无权限'); + $action = input('param.action'); + if ($action == 'generate') { + try { + $totp = \app\lib\TOTP::create(); + $totp->setLabel($this->request->user['username']); + $totp->setIssuer('DNS Manager'); + return json(['code' => 0, 'data' => ['secret' => $totp->getSecret(), 'qrcode' => $totp->getProvisioningUri()]]); + } catch (Exception $e) { + return json(['code' => -1, 'msg' => $e->getMessage()]); + } + } elseif ($action == 'bind') { + $secret = input('post.secret'); + $code = input('post.code'); + if (empty($secret)) return json(['code' => -1, 'msg' => '密钥不能为空']); + if (empty($code)) return json(['code' => -1, 'msg' => '请输入动态口令']); + try { + $totp = \app\lib\TOTP::create($secret); + if (!$totp->verify($code)) { + return json(['code' => -1, 'msg' => '动态口令错误']); + } + } catch (Exception $e) { + return json(['code' => -1, 'msg' => $e->getMessage()]); + } + Db::name('user')->where('id', $this->request->user['id'])->update(['totp_open' => 1, 'totp_secret' => $secret]); + return json(['code' => 0, 'msg' => 'succ']); + } elseif ($action == 'close') { + Db::name('user')->where('id', $this->request->user['id'])->update(['totp_open' => 0, 'totp_secret' => null]); + return json(['code' => 0, 'msg' => 'succ']); + } + } + public function test() { - //$a = \app\lib\DnsQueryUtils::query_dns_doh('www.cccyun.cc', 'A'); - //print_r($a); - $dnsList = json_decode('{"cccyun.net":[{"name":"@","type":"CAA","value":"0 issue \"letsencrypt.org\""},{"name":"verify","type":"TXT","value":"TXTTEST1"},{"name":"verify","type":"TXT","value":"TXTTEST2"}],"yuncname.com":[{"name":"@","type":"CAA","value":"0 issue \"letsencrypt.org\""},{"name":"verify.testhost1","type":"CNAME","value":"i.trust.com"},{"name":"verify.testhost2","type":"CNAME","value":"i.trust.com"}]}', true); - \app\lib\CertDnsUtils::addDns($dnsList, function ($txt) { - echo $txt . PHP_EOL; - }); + } } diff --git a/app/controller/Optimizeip.php b/app/controller/Optimizeip.php index 61a5941..bf76857 100644 --- a/app/controller/Optimizeip.php +++ b/app/controller/Optimizeip.php @@ -7,7 +7,7 @@ use Exception; use think\facade\Db; use think\facade\View; use think\facade\Cache; -use app\lib\OptimizeService; +use app\service\OptimizeService; class Optimizeip extends BaseController { diff --git a/app/controller/System.php b/app/controller/System.php new file mode 100644 index 0000000..9411a14 --- /dev/null +++ b/app/controller/System.php @@ -0,0 +1,98 @@ +alert('error', '无权限'); + if ($this->request->isPost()) { + $params = input('post.'); + if (isset($params['mail_type']) && isset($params['mail_name2']) && $params['mail_type'] > 0) { + $params['mail_name'] = $params['mail_name2']; + unset($params['mail_name2']); + } + foreach ($params as $key => $value) { + if (empty($key)) { + continue; + } + config_set($key, $value); + Cache::delete('configs'); + } + return json(['code' => 0, 'msg' => 'succ']); + } + return View::fetch(); + } + + public function proxyset() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + if ($this->request->isPost()) { + $params = input('post.'); + foreach ($params as $key => $value) { + if (empty($key)) { + continue; + } + config_set($key, $value); + Cache::delete('configs'); + } + return json(['code' => 0, 'msg' => 'succ']); + } + return View::fetch(); + } + + public function mailtest() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + $mail_name = config_get('mail_recv') ? config_get('mail_recv') : config_get('mail_name'); + if (empty($mail_name)) return json(['code' => -1, 'msg' => '您还未设置邮箱!']); + $result = \app\utils\MsgNotice::send_mail($mail_name, '邮件发送测试。', '这是一封测试邮件!

来自:' . $this->request->root(true)); + if ($result === true) { + return json(['code' => 0, 'msg' => '邮件发送成功!']); + } else { + return json(['code' => -1, 'msg' => '邮件发送失败!' . $result]); + } + } + + public function tgbottest() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + $tgbot_token = config_get('tgbot_token'); + $tgbot_chatid = config_get('tgbot_chatid'); + if (empty($tgbot_token) || empty($tgbot_chatid)) return json(['code' => -1, 'msg' => '请先保存设置']); + $content = "消息发送测试\n\n这是一封测试消息!\n\n来自:" . $this->request->root(true); + $result = \app\utils\MsgNotice::send_telegram_bot($content); + if ($result === true) { + return json(['code' => 0, 'msg' => '消息发送成功!']); + } else { + return json(['code' => -1, 'msg' => '消息发送失败!' . $result]); + } + } + + public function proxytest() + { + if (!checkPermission(2)) return $this->alert('error', '无权限'); + $proxy_server = trim($_POST['proxy_server']); + $proxy_port = $_POST['proxy_port']; + $proxy_user = trim($_POST['proxy_user']); + $proxy_pwd = trim($_POST['proxy_pwd']); + $proxy_type = $_POST['proxy_type']; + try { + check_proxy('https://dl.amh.sh/ip.htm', $proxy_server, $proxy_port, $proxy_type, $proxy_user, $proxy_pwd); + } catch (Exception $e) { + try { + check_proxy('https://myip.ipip.net/', $proxy_server, $proxy_port, $proxy_type, $proxy_user, $proxy_pwd); + } catch (Exception $e) { + return json(['code' => -1, 'msg' => $e->getMessage()]); + } + } + return json(['code' => 0]); + } +} \ No newline at end of file diff --git a/app/data/domain_root.txt b/app/data/domain_root.txt new file mode 100644 index 0000000..991d7d4 --- /dev/null +++ b/app/data/domain_root.txt @@ -0,0 +1,1925 @@ +ac.ae +co.ae +net.ae +org.ae +sch.ae +airport.aero +cargo.aero +charter.aero +com.af +edu.af +gov.af +net.af +org.af +co.ag +com.ag +net.ag +nom.ag +org.ag +com.ai +net.ai +off.ai +org.ai +com.al +net.al +org.al +co.am +com.am +net.am +north.am +org.am +radio.am +south.am +co.ao +it.ao +og.ao +pb.ao +com.ar +int.ar +net.ar +org.ar +co.at +or.at +asn.au +com.au +id.au +info.au +net.au +org.au +biz.az +co.az +com.az +edu.az +gov.az +info.az +int.az +mil.az +name.az +net.az +org.az +pp.az +pro.az +co.ba +co.bb +com.bb +net.bb +org.bb +ac.bd +com.bd +net.bd +org.bd +com.bh +co.bi +com.bi +edu.bi +info.bi +mo.bi +net.bi +or.bi +org.bi +auz.biz +com.bj +edu.bj +com.bm +net.bm +org.bm +com.bn +com.bo +net.bo +org.bo +tv.bo +abc.br +adm.br +adv.br +agr.br +am.br +aparecida.br +arq.br +art.br +ato.br +belem.br +bhz.br +bio.br +blog.br +bmd.br +boavista.br +bsb.br +campinas.br +caxias.br +cim.br +cng.br +cnt.br +com.br +coop.br +curitiba.br +ecn.br +eco.br +emp.br +eng.br +esp.br +etc.br +eti.br +far.br +flog.br +floripa.br +fm.br +fnd.br +fortal.br +fot.br +foz.br +fst.br +g12.br +ggf.br +gru.br +imb.br +ind.br +inf.br +jampa.br +jor.br +lel.br +macapa.br +maceio.br +manaus.br +mat.br +med.br +mil.br +mus.br +natal.br +net.br +nom.br +not.br +ntr.br +odo.br +org.br +palmas.br +poa.br +ppg.br +pro.br +psc.br +psi.br +qsl.br +radio.br +rec.br +recife.br +rep.br +rio.br +salvador.br +sjc.br +slg.br +srv.br +taxi.br +teo.br +tmp.br +trd.br +tur.br +tv.br +vet.br +vix.br +vlog.br +wiki.br +zlg.br +com.bs +net.bs +org.bs +com.bt +org.bt +ac.bw +co.bw +net.bw +org.bw +com.by +minsk.by +net.by +co.bz +com.bz +net.bz +org.bz +za.bz +com.cd +net.cd +org.cd +ac.ci +co.ci +com.ci +ed.ci +edu.ci +go.ci +in.ci +int.ci +net.ci +nom.ci +or.ci +org.ci +biz.ck +co.ck +edu.ck +gen.ck +gov.ck +info.ck +net.ck +org.ck +co.cm +com.cm +net.cm +ac.cn +ah.cn +bj.cn +com.cn +cq.cn +fj.cn +gd.cn +gs.cn +gx.cn +gz.cn +ha.cn +hb.cn +he.cn +hi.cn +hk.cn +hl.cn +hn.cn +jl.cn +js.cn +jx.cn +ln.cn +mo.cn +net.cn +nm.cn +nx.cn +org.cn +qh.cn +sc.cn +sd.cn +sh.cn +sn.cn +sx.cn +tj.cn +tw.cn +xj.cn +xz.cn +yn.cn +zj.cn +com.co +net.co +nom.co +ae.com +africa.com +ar.com +br.com +cn.com +co.com +de.com +eu.com +gb.com +gr.com +hk.com +hu.com +jpn.com +kr.com +mex.com +no.com +nv.com +pty-ltd.com +qc.com +ru.com +sa.com +se.com +uk.com +us.com +uy.com +za.com +co.cr +ed.cr +fi.cr +go.cr +or.cr +sa.cr +com.cu +com.cv +int.cv +net.cv +nome.cv +org.cv +publ.cv +com.cw +net.cw +ac.cy +biz.cy +com.cy +ekloges.cy +ltd.cy +name.cy +net.cy +org.cy +parliament.cy +press.cy +pro.cy +tm.cy +co.cz +co.de +com.de +biz.dk +co.dk +co.dm +com.dm +net.dm +org.dm +art.do +com.do +net.do +org.do +sld.do +web.do +com.dz +com.ec +fin.ec +info.ec +med.ec +net.ec +org.ec +pro.ec +co.ee +com.ee +fie.ee +med.ee +pri.ee +com.eg +edu.eg +eun.eg +gov.eg +info.eg +name.eg +net.eg +org.eg +tv.eg +com.es +edu.es +gob.es +nom.es +org.es +biz.et +com.et +info.et +name.et +net.et +org.et +biz.fj +com.fj +info.fj +name.fj +net.fj +org.fj +pro.fj +co.fk +radio.fm +aeroport.fr +asso.fr +avocat.fr +chambagri.fr +chirurgiens-dentistes.fr +com.fr +experts-comptables.fr +geometre-expert.fr +gouv.fr +medecin.fr +nom.fr +notaires.fr +pharmacien.fr +port.fr +prd.fr +presse.fr +tm.fr +veterinaire.fr +com.ge +edu.ge +gov.ge +mil.ge +net.ge +org.ge +pvt.ge +co.gg +net.gg +org.gg +com.gh +edu.gh +org.gh +com.gi +gov.gi +ltd.gi +org.gi +co.gl +com.gl +edu.gl +net.gl +org.gl +com.gn +gov.gn +net.gn +org.gn +com.gp +mobi.gp +net.gp +org.gp +com.gr +edu.gr +net.gr +org.gr +com.gt +ind.gt +net.gt +org.gt +com.gu +co.gy +com.gy +net.gy +com.hk +edu.hk +gov.hk +idv.hk +inc.hk +ltd.hk +net.hk +org.hk +公司.hk +com.hn +edu.hn +net.hn +org.hn +com.hr +adult.ht +art.ht +asso.ht +com.ht +edu.ht +firm.ht +info.ht +net.ht +org.ht +perso.ht +pol.ht +pro.ht +rel.ht +shop.ht +2000.hu +agrar.hu +bolt.hu +casino.hu +city.hu +co.hu +erotica.hu +erotika.hu +film.hu +forum.hu +games.hu +hotel.hu +info.hu +ingatlan.hu +jogasz.hu +konyvelo.hu +lakas.hu +media.hu +news.hu +org.hu +priv.hu +reklam.hu +sex.hu +shop.hu +sport.hu +suli.hu +szex.hu +tm.hu +tozsde.hu +utazas.hu +video.hu +biz.id +co.id +my.id +or.id +web.id +co.il +net.il +org.il +ac.im +co.im +com.im +ltd.co.im +net.im +org.im +plc.co.im +co.in +firm.in +gen.in +ind.in +net.in +org.in +auz.info +com.iq +co.ir +abr.it +abruzzo.it +ag.it +agrigento.it +al.it +alessandria.it +alto-adige.it +altoadige.it +an.it +ancona.it +andria-barletta-trani.it +andria-trani-barletta.it +andriabarlettatrani.it +andriatranibarletta.it +ao.it +aosta.it +aoste.it +ap.it +aq.it +aquila.it +ar.it +arezzo.it +ascoli-piceno.it +ascolipiceno.it +asti.it +at.it +av.it +avellino.it +ba.it +balsan.it +bari.it +barletta-trani-andria.it +barlettatraniandria.it +bas.it +basilicata.it +belluno.it +benevento.it +bergamo.it +bg.it +bi.it +biella.it +bl.it +bn.it +bo.it +bologna.it +bolzano.it +bozen.it +br.it +brescia.it +brindisi.it +bs.it +bt.it +bz.it +ca.it +cagliari.it +cal.it +calabria.it +caltanissetta.it +cam.it +campania.it +campidano-medio.it +campidanomedio.it +campobasso.it +carbonia-iglesias.it +carboniaiglesias.it +carrara-massa.it +carraramassa.it +caserta.it +catania.it +catanzaro.it +cb.it +ce.it +cesena-forli.it +cesenaforli.it +ch.it +chieti.it +ci.it +cl.it +cn.it +co.it +como.it +cosenza.it +cr.it +cremona.it +crotone.it +cs.it +ct.it +cuneo.it +cz.it +dell-ogliastra.it +dellogliastra.it +emilia-romagna.it +emiliaromagna.it +emr.it +en.it +enna.it +fc.it +fe.it +fermo.it +ferrara.it +fg.it +fi.it +firenze.it +florence.it +fm.it +foggia.it +forli-cesena.it +forlicesena.it +fr.it +friuli-v-giulia.it +friuli-ve-giulia.it +friuli-vegiulia.it +friuli-venezia-giulia.it +friuli-veneziagiulia.it +friuli-vgiulia.it +friuliv-giulia.it +friulive-giulia.it +friulivegiulia.it +friulivenezia-giulia.it +friuliveneziagiulia.it +friulivgiulia.it +frosinone.it +fvg.it +ge.it +genoa.it +genova.it +go.it +gorizia.it +gr.it +grosseto.it +iglesias-carbonia.it +iglesiascarbonia.it +im.it +imperia.it +is.it +isernia.it +kr.it +la-spezia.it +laquila.it +laspezia.it +latina.it +laz.it +lazio.it +lc.it +le.it +lecce.it +lecco.it +li.it +lig.it +liguria.it +livorno.it +lo.it +lodi.it +lom.it +lombardia.it +lombardy.it +lt.it +lu.it +lucania.it +lucca.it +macerata.it +mantova.it +mar.it +marche.it +massa-carrara.it +massacarrara.it +matera.it +mb.it +mc.it +me.it +medio-campidano.it +mediocampidano.it +messina.it +mi.it +milan.it +milano.it +mn.it +mo.it +modena.it +mol.it +molise.it +monza-brianza.it +monza-e-della-brianza.it +monza.it +monzabrianza.it +monzaebrianza.it +monzaedellabrianza.it +ms.it +mt.it +na.it +naples.it +napoli.it +no.it +novara.it +nu.it +nuoro.it +og.it +ogliastra.it +olbia-tempio.it +olbiatempio.it +or.it +oristano.it +ot.it +pa.it +padova.it +padua.it +palermo.it +parma.it +pavia.it +pc.it +pd.it +pe.it +perugia.it +pesaro-urbino.it +pesarourbino.it +pescara.it +pg.it +pi.it +piacenza.it +piedmont.it +piemonte.it +pisa.it +pistoia.it +pmn.it +pn.it +po.it +pordenone.it +potenza.it +pr.it +prato.it +pt.it +pu.it +pug.it +puglia.it +pv.it +pz.it +ra.it +ragusa.it +ravenna.it +rc.it +re.it +reggio-calabria.it +reggio-emilia.it +reggiocalabria.it +reggioemilia.it +rg.it +ri.it +rieti.it +rimini.it +rm.it +rn.it +ro.it +roma.it +rome.it +rovigo.it +sa.it +salerno.it +sar.it +sardegna.it +sardinia.it +sassari.it +savona.it +si.it +sic.it +sicilia.it +sicily.it +siena.it +siracusa.it +so.it +sondrio.it +sp.it +sr.it +ss.it +suedtirol.it +sv.it +ta.it +taa.it +taranto.it +te.it +tempio-olbia.it +tempioolbia.it +teramo.it +terni.it +tn.it +to.it +torino.it +tos.it +toscana.it +tp.it +tr.it +trani-andria-barletta.it +trani-barletta-andria.it +traniandriabarletta.it +tranibarlettaandria.it +trapani.it +trentino-a-adige.it +trentino-aadige.it +trentino-alto-adige.it +trentino-altoadige.it +trentino-s-tirol.it +trentino-stirol.it +trentino-sud-tirol.it +trentino-sudtirol.it +trentino-sued-tirol.it +trentino-suedtirol.it +trentino.it +trentinoa-adige.it +trentinoaadige.it +trentinoalto-adige.it +trentinoaltoadige.it +trentinos-tirol.it +trentinosud-tirol.it +trentinosudtirol.it +trentinosued-tirol.it +trentinosuedtirol.it +trento.it +treviso.it +trieste.it +ts.it +turin.it +tuscany.it +tv.it +ud.it +udine.it +umb.it +umbria.it +urbino-pesaro.it +urbinopesaro.it +va.it +val-d-aosta.it +val-daosta.it +vald-aosta.it +valdaosta.it +valle-d-aosta.it +valle-daosta.it +valled-aosta.it +valledaosta.it +vao.it +varese.it +vb.it +vc.it +vda.it +ve.it +ven.it +veneto.it +venezia.it +venice.it +verbania.it +vercelli.it +verona.it +vi.it +vibo-valentia.it +vibovalentia.it +vicenza.it +viterbo.it +vr.it +vs.it +vt.it +vv.it +co.je +net.je +org.je +com.jm +net.jm +org.jm +com.jo +name.jo +net.jo +org.jo +sch.jo +akita.jp +co.jp +gr.jp +kyoto.jp +ne.jp +or.jp +osaka.jp +saga.jp +tokyo.jp +ac.ke +co.ke +go.ke +info.ke +me.ke +mobi.ke +ne.ke +or.ke +sc.ke +com.kg +net.kg +org.kg +com.kh +edu.kh +net.kh +org.kh +biz.ki +com.ki +edu.ki +gov.ki +info.ki +mobi.ki +net.ki +org.ki +phone.ki +tel.ki +com.km +nom.km +org.km +tm.km +com.kn +co.kr +go.kr +ms.kr +ne.kr +or.kr +pe.kr +re.kr +seoul.kr +com.kw +edu.kw +net.kw +org.kw +com.ky +net.ky +org.ky +com.kz +org.kz +com.lb +edu.lb +net.lb +org.lb +co.lc +com.lc +l.lc +net.lc +org.lc +p.lc +com.lk +edu.lk +grp.lk +hotel.lk +ltd.lk +org.lk +soc.lk +web.lk +com.lr +org.lr +co.ls +net.ls +org.ls +asn.lv +com.lv +conf.lv +edu.lv +id.lv +mil.lv +net.lv +org.lv +com.ly +id.ly +med.ly +net.ly +org.ly +plc.ly +sch.ly +ac.ma +co.ma +net.ma +org.ma +press.ma +asso.mc +tm.mc +co.mg +com.mg +mil.mg +net.mg +nom.mg +org.mg +prd.mg +tm.mg +com.mk +edu.mk +inf.mk +name.mk +net.mk +org.mk +biz.mm +com.mm +net.mm +org.mm +per.mm +com.mo +net.mo +org.mo +edu.mr +org.mr +perso.mr +co.ms +com.ms +org.ms +com.mt +net.mt +org.mt +ac.mu +co.mu +com.mu +net.mu +nom.mu +or.mu +org.mu +com.mv +ac.mw +co.mw +com.mw +coop.mw +edu.mw +int.mw +net.mw +org.mw +com.mx +net.mx +org.mx +com.my +mil.my +name.my +net.my +org.my +co.mz +edu.mz +net.mz +org.mz +alt.na +co.na +com.na +edu.na +net.na +org.na +asso.nc +nom.nc +com.ne +info.ne +int.ne +org.ne +perso.ne +auz.net +gb.net +hu.net +in.net +jp.net +ru.net +se.net +uk.net +arts.nf +com.nf +firm.nf +info.nf +net.nf +org.nf +other.nf +per.nf +rec.nf +store.nf +web.nf +com.ng +edu.ng +gov.ng +i.ng +lg.gov.ng +mobi.ng +name.ng +net.ng +org.ng +sch.ng +ac.ni +biz.ni +co.ni +com.ni +edu.ni +gob.ni +in.ni +info.ni +int.ni +mil.ni +net.ni +nom.ni +org.ni +web.ni +co.nl +com.nl +net.nl +co.no +fhs.no +folkebibl.no +fylkesbibl.no +gs.no +idrett.no +museum.no +priv.no +uenorge.no +vgs.no +aero.np +asia.np +biz.np +com.np +coop.np +info.np +mil.np +mobi.np +museum.np +name.np +net.np +org.np +pro.np +travel.np +biz.nr +com.nr +info.nr +net.nr +org.nr +co.nu +ac.nz +co.net.nz +co.nz +geek.nz +gen.nz +iwi.nz +kiwi.nz +maori.nz +net.nz +org.nz +school.nz +biz.om +co.om +com.om +med.om +mil.om +museum.om +net.om +org.om +pro.om +sch.om +ae.org +hk.org +us.org +abo.pa +com.pa +edu.pa +gob.pa +ing.pa +med.pa +net.pa +nom.pa +org.pa +sld.pa +com.pe +gob.pe +mil.pe +net.pe +nom.pe +org.pe +asso.pf +com.pf +org.pf +com.pg +net.pg +org.pg +com.ph +net.ph +org.ph +biz.pk +com.pk +net.pk +org.pk +web.pk +agro.pl +aid.pl +atm.pl +augustow.pl +auto.pl +babia-gora.pl +bedzin.pl +beskidy.pl +bialowieza.pl +bialystok.pl +bielawa.pl +bieszczady.pl +biz.pl +boleslawiec.pl +bydgoszcz.pl +bytom.pl +cieszyn.pl +com.pl +czeladz.pl +czest.pl +dlugoleka.pl +edu.pl +elblag.pl +elk.pl +glogow.pl +gmina.pl +gniezno.pl +gorlice.pl +grajewo.pl +gsm.pl +ilawa.pl +info.pl +jaworzno.pl +jelenia-gora.pl +jgora.pl +kalisz.pl +karpacz.pl +kartuzy.pl +kaszuby.pl +katowice.pl +kazimierz-dolny.pl +kepno.pl +ketrzyn.pl +klodzko.pl +kobierzyce.pl +kolobrzeg.pl +konin.pl +konskowola.pl +kutno.pl +lapy.pl +lebork.pl +legnica.pl +lezajsk.pl +limanowa.pl +lomza.pl +lowicz.pl +lubin.pl +lukow.pl +mail.pl +malbork.pl +malopolska.pl +mazowsze.pl +mazury.pl +media.pl +miasta.pl +mielec.pl +mielno.pl +mil.pl +mragowo.pl +naklo.pl +net.pl +nieruchomosci.pl +nom.pl +nowaruda.pl +nysa.pl +olawa.pl +olecko.pl +olkusz.pl +olsztyn.pl +opoczno.pl +opole.pl +org.pl +ostroda.pl +ostroleka.pl +ostrowiec.pl +ostrowwlkp.pl +pc.pl +pila.pl +pisz.pl +podhale.pl +podlasie.pl +polkowice.pl +pomorskie.pl +pomorze.pl +powiat.pl +priv.pl +prochowice.pl +pruszkow.pl +przeworsk.pl +pulawy.pl +radom.pl +rawa-maz.pl +realestate.pl +rel.pl +rybnik.pl +rzeszow.pl +sanok.pl +sejny.pl +sex.pl +shop.pl +sklep.pl +skoczow.pl +slask.pl +slupsk.pl +sos.pl +sosnowiec.pl +stalowa-wola.pl +starachowice.pl +stargard.pl +suwalki.pl +swidnica.pl +swiebodzin.pl +swinoujscie.pl +szczecin.pl +szczytno.pl +szkola.pl +targi.pl +tarnobrzeg.pl +tgory.pl +tm.pl +tourism.pl +travel.pl +turek.pl +turystyka.pl +tychy.pl +ustka.pl +walbrzych.pl +warmia.pl +warszawa.pl +waw.pl +wegrow.pl +wielun.pl +wlocl.pl +wloclawek.pl +wodzislaw.pl +wolomin.pl +wroclaw.pl +zachpomor.pl +zagan.pl +zarow.pl +zgora.pl +zgorzelec.pl +co.pn +net.pn +org.pn +at.pr +biz.pr +ch.pr +com.pr +de.pr +eu.pr +fr.pr +info.pr +isla.pr +it.pr +name.pr +net.pr +nl.pr +org.pr +pro.pr +uk.pr +aaa.pro +aca.pro +acct.pro +arc.pro +avocat.pro +bar.pro +bus.pro +chi.pro +chiro.pro +cpa.pro +den.pro +dent.pro +eng.pro +jur.pro +law.pro +med.pro +min.pro +nur.pro +nurse.pro +pharma.pro +prof.pro +prx.pro +recht.pro +rel.pro +teach.pro +vet.pro +com.ps +net.ps +org.ps +co.pt +com.pt +org.pt +com.py +coop.py +edu.py +mil.py +net.py +org.py +com.qa +edu.qa +mil.qa +name.qa +net.qa +org.qa +sch.qa +com.re +arts.ro +co.ro +com.ro +firm.ro +info.ro +ne.ro +nom.ro +nt.ro +or.ro +org.ro +rec.ro +sa.ro +srl.ro +store.ro +tm.ro +www.ro +co.rs +edu.rs +in.rs +org.rs +adygeya.ru +bashkiria.ru +bir.ru +cbg.ru +com.ru +dagestan.ru +grozny.ru +kalmykia.ru +kustanai.ru +marine.ru +mordovia.ru +msk.ru +mytis.ru +nalchik.ru +net.ru +nov.ru +org.ru +pp.ru +pyatigorsk.ru +spb.ru +vladikavkaz.ru +vladimir.ru +ac.rw +co.rw +net.rw +org.rw +com.sa +edu.sa +med.sa +net.sa +org.sa +pub.sa +sch.sa +com.sb +net.sb +org.sb +com.sc +net.sc +org.sc +com.sd +info.sd +net.sd +com.se +com.sg +edu.sg +net.sg +org.sg +per.sg +ae.si +at.si +cn.si +co.si +de.si +uk.si +us.si +com.sl +edu.sl +net.sl +org.sl +art.sn +com.sn +edu.sn +org.sn +perso.sn +univ.sn +com.so +net.so +org.so +biz.ss +com.ss +me.ss +net.ss +abkhazia.su +adygeya.su +aktyubinsk.su +arkhangelsk.su +armenia.su +ashgabad.su +azerbaijan.su +balashov.su +bashkiria.su +bryansk.su +bukhara.su +chimkent.su +dagestan.su +east-kazakhstan.su +exnet.su +georgia.su +grozny.su +ivanovo.su +jambyl.su +kalmykia.su +kaluga.su +karacol.su +karaganda.su +karelia.su +khakassia.su +krasnodar.su +kurgan.su +kustanai.su +lenug.su +mangyshlak.su +mordovia.su +msk.su +murmansk.su +nalchik.su +navoi.su +north-kazakhstan.su +nov.su +obninsk.su +penza.su +pokrovsk.su +sochi.su +spb.su +tashkent.su +termez.su +togliatti.su +troitsk.su +tselinograd.su +tula.su +tuva.su +vladikavkaz.su +vladimir.su +vologda.su +com.sv +edu.sv +gob.sv +org.sv +com.sy +co.sz +org.sz +com.tc +net.tc +org.tc +pro.tc +com.td +net.td +org.td +tourism.td +ac.th +co.th +in.th +or.th +ac.tj +aero.tj +biz.tj +co.tj +com.tj +coop.tj +dyn.tj +go.tj +info.tj +int.tj +mil.tj +museum.tj +my.tj +name.tj +net.tj +org.tj +per.tj +pro.tj +web.tj +com.tl +net.tl +org.tl +agrinet.tn +com.tn +defense.tn +edunet.tn +ens.tn +fin.tn +ind.tn +info.tn +intl.tn +nat.tn +net.tn +org.tn +perso.tn +rnrt.tn +rns.tn +rnu.tn +tourism.tn +av.tr +bbs.tr +biz.tr +com.tr +dr.tr +gen.tr +info.tr +name.tr +net.tr +org.tr +tel.tr +tv.tr +web.tr +biz.tt +co.tt +com.tt +info.tt +jobs.tt +mobi.tt +name.tt +net.tt +org.tt +pro.tt +club.tw +com.tw +ebiz.tw +game.tw +idv.tw +net.tw +org.tw +ac.tz +co.tz +hotel.tz +info.tz +me.tz +mil.tz +mobi.tz +ne.tz +or.tz +sc.tz +tv.tz +biz.ua +cherkassy.ua +cherkasy.ua +chernigov.ua +chernivtsi.ua +chernovtsy.ua +ck.ua +cn.ua +co.ua +com.ua +crimea.ua +cv.ua +dn.ua +dnepropetrovsk.ua +dnipropetrovsk.ua +donetsk.ua +dp.ua +edu.ua +gov.ua +if.ua +in.ua +ivano-frankivsk.ua +kh.ua +kharkiv.ua +kharkov.ua +kherson.ua +khmelnitskiy.ua +kiev.ua +kirovograd.ua +km.ua +kr.ua +ks.ua +kyiv.ua +lg.ua +lt.ua +lugansk.ua +lutsk.ua +lviv.ua +mk.ua +net.ua +nikolaev.ua +od.ua +odesa.ua +odessa.ua +org.ua +pl.ua +poltava.ua +pp.ua +rivne.ua +rovno.ua +rv.ua +sebastopol.ua +sm.ua +sumy.ua +te.ua +ternopil.ua +uz.ua +uzhgorod.ua +vinnica.ua +vn.ua +volyn.ua +yalta.ua +zaporizhzhe.ua +zhitomir.ua +zp.ua +zt.ua +ac.ug +co.ug +com.ug +go.ug +ne.ug +or.ug +org.ug +sc.ug +ac.uk +barking-dagenham.sch.uk +barnet.sch.uk +barnsley.sch.uk +bathnes.sch.uk +beds.sch.uk +bexley.sch.uk +bham.sch.uk +blackburn.sch.uk +blackpool.sch.uk +bolton.sch.uk +bournemouth.sch.uk +bracknell-forest.sch.uk +bradford.sch.uk +brent.sch.uk +co.uk +doncaster.sch.uk +gov.uk +ltd.uk +me.uk +net.uk +org.uk +plc.uk +sch.uk +com.uy +edu.uy +net.uy +org.uy +biz.uz +co.uz +com.uz +net.uz +org.uz +com.vc +net.vc +org.vc +co.ve +com.ve +info.ve +net.ve +org.ve +web.ve +co.vi +com.vi +net.vi +org.vi +ac.vn +biz.vn +com.vn +edu.vn +health.vn +info.vn +int.vn +name.vn +net.vn +org.vn +pro.vn +com.vu +net.vu +org.vu +com.ws +net.ws +org.ws +com.ye +net.ye +org.ye +co.za +net.za +org.za +web.za +co.zm +com.zm +org.zm +co.zw +ком.рф +нет.рф +орг.рф +ак.срб +пр.срб +упр.срб +كمپنی.بھارت +कंपनी.भारत +কোম্পানি.ভারত +நிறுவனம்.இந்தியா +個人.香港 +公司.香港 +政府.香港 +教育.香港 +組織.香港 +網絡.香港 +gov.cn +com.ac +com.ad +com.ae +com.an +com.ao +com.aq +com.as +com.at +com.aw +com.ba +com.be +com.bf +com.bg +com.bv +com.bw +com.ca +com.cc +com.cf +com.cg +com.ch +com.ck +com.cl +com.cq +com.cr +com.cx +com.cz +com.dj +com.dk +com.eh +com.eu +com.ev +com.fi +com.fk +com.fm +com.fo +com.ga +com.gb +com.gd +com.gf +com.gm +com.gw +com.hm +com.hu +com.id +com.ie +com.il +com.in +com.io +com.ir +com.is +com.it +com.jp +com.ke +com.kp +com.kr +com.la +com.li +com.ls +com.lt +com.lu +com.ma +com.mc +com.md +com.me +com.mh +com.ml +com.mn +com.mp +com.mq +com.mr +com.mz +com.nc +com.no +com.nt +com.nu +com.nz +com.pm +com.pn +com.pw +com.rs +com.rw +com.sh +com.si +com.sj +com.sk +com.sm +com.sr +com.st +com.su +com.sz +com.tf +com.tg +com.th +com.tk +com.tm +com.to +com.tp +com.tv +com.tz +com.uk +com.us +com.va +com.vg +com.wf +com.za +com.zw +mil.cn +edu.kg +edu.cn \ No newline at end of file diff --git a/app/lib/CertHelper.php b/app/lib/CertHelper.php new file mode 100644 index 0000000..5fbf340 --- /dev/null +++ b/app/lib/CertHelper.php @@ -0,0 +1,349 @@ + [ + 'name' => 'Let\'s Encrypt', + 'class' => 1, + 'icon' => 'letsencrypt.ico', + 'wildcard' => true, + 'max_domains' => 100, + 'cname' => true, + 'note' => null, + 'inputs' => [ + 'email' => [ + 'name' => '邮箱地址', + 'type' => 'input', + 'placeholder' => '用于注册Let\'s Encrypt账号', + 'required' => true, + ], + 'mode' => [ + 'name' => '环境选择', + 'type' => 'radio', + 'options' => [ + 'live' => '正式环境', + 'staging' => '测试环境', + ], + 'value' => 'live' + ], + 'proxy' => [ + 'name' => '使用代理服务器', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'value' => '0' + ], + ] + ], + 'zerossl' => [ + 'name' => 'ZeroSSL', + 'class' => 1, + 'icon' => 'zerossl.ico', + 'wildcard' => true, + 'max_domains' => 100, + 'cname' => true, + 'note' => 'ZeroSSL密钥生成地址', + 'inputs' => [ + 'email' => [ + 'name' => '邮箱地址', + 'type' => 'input', + 'placeholder' => 'EAB申请邮箱', + 'required' => true, + ], + 'kid' => [ + 'name' => 'EAB KID', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'key' => [ + 'name' => 'EAB HMAC Key', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'proxy' => [ + 'name' => '使用代理服务器', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'value' => '0' + ], + ] + ], + 'google' => [ + 'name' => 'Google SSL', + 'class' => 1, + 'icon' => 'google.ico', + 'wildcard' => true, + 'max_domains' => 100, + 'cname' => true, + 'note' => '查看Google SSL账户配置说明', + 'inputs' => [ + 'email' => [ + 'name' => '邮箱地址', + 'type' => 'input', + 'placeholder' => 'EAB申请邮箱', + 'required' => true, + ], + 'kid' => [ + 'name' => 'keyId', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'key' => [ + 'name' => 'b64MacKey', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'mode' => [ + 'name' => '环境选择', + 'type' => 'radio', + 'options' => [ + 'live' => '正式环境', + 'staging' => '测试环境', + ], + 'value' => 'live' + ], + 'proxy' => [ + 'name' => '使用代理服务器', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'value' => '0' + ], + ] + ], + 'tencent' => [ + 'name' => '腾讯云免费SSL', + 'class' => 2, + 'icon' => 'tencent.ico', + 'wildcard' => false, + 'max_domains' => 1, + 'cname' => false, + 'note' => '一个账号有50张免费证书额度,证书到期或吊销可释放额度。腾讯云免费SSL简介与额度说明', + 'inputs' => [ + 'SecretId' => [ + 'name' => 'SecretId', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'SecretKey' => [ + 'name' => 'SecretKey', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'email' => [ + 'name' => '邮箱地址', + 'type' => 'input', + 'placeholder' => '申请证书时填写的邮箱', + 'required' => true, + ], + ] + ], + 'aliyun' => [ + 'name' => '阿里云免费SSL', + 'class' => 2, + 'icon' => 'aliyun.ico', + 'wildcard' => false, + 'max_domains' => 1, + 'cname' => false, + 'note' => '每个自然年有20张免费证书额度,证书到期或吊销不释放额度。需要先进入阿里云控制台-数字证书管理服务,购买个人测试证书资源包。', + 'inputs' => [ + 'AccessKeyId' => [ + 'name' => 'AccessKeyId', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'AccessKeySecret' => [ + 'name' => 'AccessKeySecret', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'username' => [ + 'name' => '姓名', + 'type' => 'input', + 'placeholder' => '申请联系人的姓名', + 'required' => true, + ], + 'phone' => [ + 'name' => '手机号码', + 'type' => 'input', + 'placeholder' => '申请联系人的手机号码', + 'required' => true, + ], + 'email' => [ + 'name' => '邮箱地址', + 'type' => 'input', + 'placeholder' => '申请联系人的邮箱地址', + 'required' => true, + ], + ] + ], + 'ucloud' => [ + 'name' => 'UCloud免费SSL', + 'class' => 2, + 'icon' => 'ucloud.ico', + 'wildcard' => false, + 'max_domains' => 1, + 'cname' => false, + 'note' => '一个账号有40张免费证书额度,证书到期或吊销可释放额度。', + 'inputs' => [ + 'PublicKey' => [ + 'name' => '公钥', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'PrivateKey' => [ + 'name' => '私钥', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'username' => [ + 'name' => '姓名', + 'type' => 'input', + 'placeholder' => '申请联系人的姓名', + 'required' => true, + ], + 'phone' => [ + 'name' => '手机号码', + 'type' => 'input', + 'placeholder' => '申请联系人的手机号码', + 'required' => true, + ], + 'email' => [ + 'name' => '邮箱地址', + 'type' => 'input', + 'placeholder' => '申请联系人的邮箱地址', + 'required' => true, + ], + ] + ], + 'customacme' => [ + 'name' => '自定义ACME', + 'class' => 1, + 'icon' => 'ssl.ico', + 'wildcard' => true, + 'max_domains' => 100, + 'cname' => true, + 'note' => null, + 'inputs' => [ + 'directory' => [ + 'name' => 'ACME地址', + 'type' => 'input', + 'placeholder' => 'ACME Directory 地址', + 'required' => true, + ], + 'email' => [ + 'name' => '邮箱地址', + 'type' => 'input', + 'placeholder' => '证书申请邮箱', + 'required' => true, + ], + 'kid' => [ + 'name' => 'EAB KID', + 'type' => 'input', + 'placeholder' => '留空则不使用EAB认证', + ], + 'key' => [ + 'name' => 'EAB HMAC Key', + 'type' => 'input', + 'placeholder' => '留空则不使用EAB认证', + ], + 'proxy' => [ + 'name' => '使用代理服务器', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'value' => '0' + ], + ] + ], + ]; + + public static $class_config = [ + 1 => '基于ACME的SSL证书', + 2 => '云服务商的SSL证书', + ]; + + public static function getList() + { + return self::$cert_config; + } + + private static function getConfig($aid) + { + $account = Db::name('cert_account')->where('id', $aid)->find(); + if (!$account) return false; + return $account; + } + + public static function getInputs($type, $config = null) + { + $config = $config ? json_decode($config, true) : []; + $inputs = self::$cert_config[$type]['inputs']; + foreach ($inputs as &$input) { + if (isset($config[$input['name']])) { + $input['value'] = $config[$input['name']]; + } + } + return $inputs; + } + + /** + * @return CertInterface|bool + */ + public static function getModel($aid) + { + $account = self::getConfig($aid); + if (!$account) return false; + $type = $account['type']; + $class = "\\app\\lib\\cert\\{$type}"; + if (class_exists($class)) { + $config = json_decode($account['config'], true); + $ext = $account['ext'] ? json_decode($account['ext'], true) : null; + $model = new $class($config, $ext); + return $model; + } + return false; + } + + /** + * @return CertInterface|bool + */ + public static function getModel2($type, $config, $ext = null) + { + $class = "\\app\\lib\\cert\\{$type}"; + if (class_exists($class)) { + $model = new $class($config, $ext); + return $model; + } + return false; + } + + public static function getPfx($fullchain, $privatekey, $pwd = '123456'){ + openssl_pkcs12_export($fullchain, $pfx, $privatekey, $pwd); + return $pfx; + } +} diff --git a/app/lib/CertInterface.php b/app/lib/CertInterface.php new file mode 100644 index 0000000..00346c2 --- /dev/null +++ b/app/lib/CertInterface.php @@ -0,0 +1,24 @@ + [ + 'name' => '宝塔面板', + 'class' => 1, + 'icon' => 'bt.ico', + 'note' => null, + 'inputs' => [ + 'url' => [ + 'name' => '面板地址', + 'type' => 'input', + 'placeholder' => '宝塔面板地址', + 'note' => '填写规则如:http://192.168.1.100:8888 ,不要带其他后缀', + 'required' => true, + ], + 'key' => [ + 'name' => '接口密钥', + 'type' => 'input', + 'placeholder' => '宝塔面板设置->面板设置->API接口', + 'required' => true, + ], + 'proxy' => [ + 'name' => '使用代理服务器', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'value' => '0' + ], + ], + 'taskinputs' => [ + 'type' => [ + 'name' => '部署类型', + 'type' => 'radio', + 'options' => [ + '0' => '宝塔面板站点的证书', + '1' => '宝塔面板本身的证书', + ], + 'value' => '0', + 'required' => true, + ], + 'sites' => [ + 'name' => '网站名称列表', + 'type' => 'textarea', + 'placeholder' => '填写要部署证书的网站名称,每行一个', + 'note' => 'PHP项目和反代项目填写创建时绑定的第一个域名,Java/Node/Go等其他项目填写项目名称', + 'show' => 'type==0', + 'required' => true, + ], + ], + ], + 'kangle' => [ + 'name' => 'Kangle', + 'class' => 1, + 'icon' => 'host.png', + 'note' => '以上登录信息为Easypanel用户面板的,非管理员面板。如选网站密码认证类型,则用户面板登录不能开启验证码。', + 'inputs' => [ + 'url' => [ + 'name' => '面板地址', + 'type' => 'input', + 'placeholder' => 'Easypanel面板地址', + 'note' => '填写规则如:http://192.168.1.100:3312 ,不要带其他后缀', + 'required' => true, + ], + 'auth' => [ + 'name' => '认证方式', + 'type' => 'radio', + 'options' => [ + '0' => '网站密码', + '1' => '面板安全码', + ], + 'value' => '0', + 'required' => true, + ], + 'username' => [ + 'name' => '网站用户名', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'password' => [ + 'name' => '网站密码', + 'type' => 'input', + 'placeholder' => '', + 'show' => 'auth==0', + 'required' => true, + ], + 'skey' => [ + 'name' => '面板安全码', + 'type' => 'input', + 'placeholder' => '管理员面板->服务器设置->面板通信安全码', + 'show' => 'auth==1', + 'required' => true, + ], + 'proxy' => [ + 'name' => '使用代理服务器', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'value' => '0' + ], + ], + 'taskinputs' => [ + 'type' => [ + 'name' => '部署类型', + 'type' => 'radio', + 'options' => [ + '0' => '网站SSL证书', + '1' => '单域名SSL证书(仅CDN支持)', + ], + 'value' => '0', + 'required' => true, + ], + 'domains' => [ + 'name' => 'CDN域名列表', + 'type' => 'textarea', + 'placeholder' => '填写要部署证书的域名,每行一个', + 'show' => 'type==1', + 'required' => true, + ], + ], + ], + 'safeline' => [ + 'name' => '雷池WAF', + 'class' => 1, + 'icon' => 'safeline.png', + 'note' => null, + 'tasknote' => '系统会根据关联SSL证书的域名,自动更新对应证书', + 'inputs' => [ + 'url' => [ + 'name' => '控制台地址', + 'type' => 'input', + 'placeholder' => '雷池WAF控制台地址', + 'note' => '填写规则如:https://192.168.1.100:9443 ,不要带其他后缀', + 'required' => true, + ], + 'token' => [ + 'name' => 'API Token', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'proxy' => [ + 'name' => '使用代理服务器', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'value' => '0' + ], + ], + 'taskinputs' => [], + ], + 'cdnfly' => [ + 'name' => 'Cdnfly', + 'class' => 1, + 'icon' => 'waf.png', + 'note' => '登录Cdnfly控制台->账户中心->API密钥,点击开启后获取', + 'inputs' => [ + 'url' => [ + 'name' => '控制台地址', + 'type' => 'input', + 'placeholder' => 'Cdnfly控制台地址', + 'note' => '填写示例:http://demo.cdnfly.cn', + 'required' => true, + ], + 'api_key' => [ + 'name' => 'api_key', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'api_secret' => [ + 'name' => 'api_secret', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'proxy' => [ + 'name' => '使用代理服务器', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'value' => '0' + ], + ], + 'taskinputs' => [ + 'id' => [ + 'name' => '证书ID', + 'type' => 'input', + 'placeholder' => '', + 'note' => '在网站管理->证书管理查看证书的ID,注意域名是否与证书匹配', + 'required' => true, + ], + ], + ], + 'lecdn' => [ + 'name' => 'LeCDN', + 'class' => 1, + 'icon' => 'waf.png', + 'note' => null, + 'inputs' => [ + 'url' => [ + 'name' => '控制台地址', + 'type' => 'input', + 'placeholder' => 'LeCDN控制台地址', + 'note' => '填写示例:http://demo.xxxx.cn', + 'required' => true, + ], + 'email' => [ + 'name' => '邮箱地址', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'password' => [ + 'name' => '密码', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'proxy' => [ + 'name' => '使用代理服务器', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'value' => '0' + ], + ], + 'taskinputs' => [ + 'id' => [ + 'name' => '证书ID', + 'type' => 'input', + 'placeholder' => '', + 'note' => '在站点->证书管理查看证书的ID,注意域名是否与证书匹配', + 'required' => true, + ], + ], + ], + 'goedge' => [ + 'name' => 'GoEdge', + 'class' => 1, + 'icon' => 'waf.png', + 'note' => '需要先开启HTTP API端口', + 'tasknote' => '系统会根据关联SSL证书的域名,自动更新对应证书', + 'inputs' => [ + 'systype' => [ + 'name' => '系统类型', + 'type' => 'radio', + 'options' => [ + '0' => 'GoEdge', + '1' => 'FlexCDN', + ], + 'value' => '0', + 'required' => true, + ], + 'url' => [ + 'name' => 'HTTP API地址', + 'type' => 'input', + 'placeholder' => 'HTTP API地址', + 'note' => 'http://你的IP:端口', + 'required' => true, + ], + 'accessKeyId' => [ + 'name' => 'AccessKey ID', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'accessKey' => [ + 'name' => 'AccessKey密钥', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'usertype' => [ + 'name' => '用户类型', + 'type' => 'radio', + 'options' => [ + 'user' => '平台用户', + 'admin' => '系统用户', + ], + 'value' => 'user', + 'required' => true, + ], + 'proxy' => [ + 'name' => '使用代理服务器', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'value' => '0' + ], + ], + 'taskinputs' => [], + ], + 'opanel' => [ + 'name' => '1Panel', + 'class' => 1, + 'icon' => 'opanel.png', + 'note' => null, + 'tasknote' => '系统会根据关联SSL证书的域名,自动更新对应证书', + 'inputs' => [ + 'url' => [ + 'name' => '面板地址', + 'type' => 'input', + 'placeholder' => '1Panel面板地址', + 'note' => '填写规则如:http://192.168.1.100:8888 ,不要带其他后缀', + 'required' => true, + ], + 'key' => [ + 'name' => '接口密钥', + 'type' => 'input', + 'placeholder' => '1Panel面板设置->API接口', + 'required' => true, + ], + 'proxy' => [ + 'name' => '使用代理服务器', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'value' => '0' + ], + ], + 'taskinputs' => [], + ], + 'aliyun' => [ + 'name' => '阿里云', + 'class' => 2, + 'icon' => 'aliyun.ico', + 'note' => '支持部署到阿里云CDN、ESA、SLB、OSS、WAF等服务', + 'tasknote' => '', + 'inputs' => [ + 'AccessKeyId' => [ + 'name' => 'AccessKeyId', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'AccessKeySecret' => [ + 'name' => 'AccessKeySecret', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + ], + 'taskinputs' => [ + 'product' => [ + 'name' => '要部署的产品', + 'type' => 'select', + 'options' => [ + ['value'=>'cdn', 'label'=>'内容分发CDN'], + ['value'=>'dcdn', 'label'=>'全站加速DCDN'], + ['value'=>'esa', 'label'=>'边缘安全加速ESA'], + ['value'=>'oss', 'label'=>'对象存储OSS'], + ['value'=>'waf', 'label'=>'Web应用防火墙3.0'], + ['value'=>'waf2', 'label'=>'Web应用防火墙2.0'], + ['value'=>'clb', 'label'=>'传统型负载均衡CLB'], + ['value'=>'alb', 'label'=>'应用型负载均衡ALB'], + ['value'=>'nlb', 'label'=>'网络型负载均衡NLB'], + ['value'=>'api', 'label'=>'API网关'], + ['value'=>'ddoscoo', 'label'=>'DDoS高防'], + ['value'=>'live', 'label'=>'视频直播'], + ['value'=>'vod', 'label'=>'视频点播'], + ['value'=>'fc', 'label'=>'函数计算3.0'], + ['value'=>'fc2', 'label'=>'函数计算2.0'], + ], + 'value' => 'cdn', + 'required' => true, + ], + 'esa_sitename' => [ + 'name' => 'ESA站点域名', + 'type' => 'input', + 'placeholder' => 'ESA添加的站点主域名', + 'show' => 'product==\'esa\'', + 'required' => true, + ], + 'oss_endpoint' => [ + 'name' => 'Endpoint地址', + 'type' => 'input', + 'placeholder' => '填写示例:oss-cn-hangzhou.aliyuncs.com', + 'show' => 'product==\'oss\'', + 'required' => true, + ], + 'oss_bucket' => [ + 'name' => 'Bucket名称', + 'type' => 'input', + 'placeholder' => '', + 'show' => 'product==\'oss\'', + 'required' => true, + ], + 'region' => [ + 'name' => '所属地域', + 'type' => 'select', + 'options' => [ + ['value'=>'cn-hangzhou', 'label'=>'中国内地'], + ['value'=>'ap-southeast-1', 'label'=>'非中国内地'], + ], + 'value' => 'cn-hangzhou', + 'show' => 'product==\'waf\'||product==\'waf2\'||product==\'ddoscoo\'', + 'required' => true, + ], + 'regionid' => [ + 'name' => '所属地域ID', + 'type' => 'input', + 'placeholder' => '填写示例:cn-hangzhou', + 'show' => 'product==\'api\'||product==\'clb\'||product==\'alb\'||product==\'nlb\'', + 'value' => 'cn-hangzhou', + 'required' => true, + ], + 'api_groupid' => [ + 'name' => 'API分组ID', + 'type' => 'input', + 'placeholder' => '', + 'show' => 'product==\'api\'', + 'required' => true, + ], + 'fc_cname' => [ + 'name' => '域名CNAME地址', + 'type' => 'input', + 'placeholder' => '填写示例:<账号ID>.cn-shanghai.fc.aliyuncs.com', + 'show' => 'product==\'fc\'||product==\'fc2\'', + 'required' => true, + ], + 'clb_id' => [ + 'name' => '负载均衡实例ID', + 'type' => 'input', + 'placeholder' => '', + 'show' => 'product==\'clb\'', + 'required' => true, + ], + 'clb_port' => [ + 'name' => 'HTTPS监听端口', + 'type' => 'input', + 'placeholder' => '', + 'value' => '443', + 'show' => 'product==\'clb\'', + 'required' => true, + ], + 'alb_listener_id' => [ + 'name' => '监听ID', + 'type' => 'input', + 'placeholder' => '', + 'show' => 'product==\'alb\'', + 'note' => '进入ALB实例详情->监听列表,复制监听ID(只支持HTTPS或QUIC监听协议)', + 'required' => true, + ], + 'nlb_listener_id' => [ + 'name' => '监听ID', + 'type' => 'input', + 'placeholder' => '', + 'show' => 'product==\'nlb\'', + 'note' => '进入NLB实例详情->监听列表,复制监听ID(只支持TCPSSL监听协议)', + 'required' => true, + ], + 'domain' => [ + 'name' => '绑定的域名', + 'type' => 'input', + 'placeholder' => '', + 'show' => 'product!=\'esa\'&&product!=\'clb\'&&product!=\'alb\'&&product!=\'nlb\'', + 'required' => true, + ], + ], + ], + 'tencent' => [ + 'name' => '腾讯云', + 'class' => 2, + 'icon' => 'tencent.ico', + 'note' => '支持部署到腾讯云CDN、EO、CLB、COS、TKE、SCF等服务', + 'tasknote' => '', + 'inputs' => [ + 'SecretId' => [ + 'name' => 'SecretId', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'SecretKey' => [ + 'name' => 'SecretKey', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + ], + 'taskinputs' => [ + 'product' => [ + 'name' => '要部署的产品', + 'type' => 'select', + 'options' => [ + ['value'=>'cdn', 'label'=>'内容分发网络CDN'], + ['value'=>'teo', 'label'=>'边缘安全加速EO'], + ['value'=>'waf', 'label'=>'Web应用防火墙WAF'], + ['value'=>'cos', 'label'=>'对象存储COS'], + ['value'=>'clb', 'label'=>'负载均衡CLB'], + ['value'=>'tke', 'label'=>'容器服务TKE'], + ['value'=>'scf', 'label'=>'云函数SCF'], + ['value'=>'ddos', 'label'=>'DDoS防护'], + ['value'=>'live', 'label'=>'云直播LIVE'], + ['value'=>'vod', 'label'=>'云点播VOD'], + ['value'=>'tse', 'label'=>'云原生API网关TSE'], + ['value'=>'tcb', 'label'=>'云开发TCB'], + ['value'=>'lighthouse', 'label'=>'轻量应用服务器'], + ], + 'value' => 'cdn', + 'required' => true, + ], + 'regionid' => [ + 'name' => '所属地域ID', + 'type' => 'input', + 'placeholder' => '填写示例:ap-guangzhou', + 'show' => 'product==\'clb\'||product==\'cos\'||product==\'tse\'||product==\'tke\'||product==\'lighthouse\'||product==\'scf\'', + 'value' => '', + 'required' => true, + ], + 'region' => [ + 'name' => '所属地域', + 'type' => 'select', + 'options' => [ + ['value'=>'ap-guangzhou', 'label'=>'中国大陆'], + ['value'=>'ap-seoul', 'label'=>'非中国大陆'], + ], + 'value' => 'ap-guangzhou', + 'show' => 'product==\'waf\'', + 'required' => true, + ], + 'clb_id' => [ + 'name' => '负载均衡ID', + 'type' => 'input', + 'placeholder' => '', + 'show' => 'product==\'clb\'', + 'required' => true, + ], + 'clb_listener_id' => [ + 'name' => '监听器ID', + 'type' => 'input', + 'placeholder' => '可留空,会自动根据域名或负载均衡ID查找', + 'show' => 'product==\'clb\'', + ], + 'clb_domain' => [ + 'name' => '绑定的域名', + 'type' => 'input', + 'placeholder' => '若监听器开启SNI,则域名必填;若关闭SNI,则域名留空', + 'show' => 'product==\'clb\'', + ], + 'tke_cluster_id' => [ + 'name' => '集群ID', + 'type' => 'input', + 'placeholder' => '', + 'show' => 'product==\'tke\'', + 'required' => true, + ], + 'tke_namespace' => [ + 'name' => '命名空间', + 'type' => 'input', + 'placeholder' => '', + 'show' => 'product==\'tke\'', + 'required' => true, + ], + 'tke_secret' => [ + 'name' => '证书的secret名称', + 'type' => 'input', + 'placeholder' => '', + 'show' => 'product==\'tke\'', + 'required' => true, + ], + 'cos_bucket' => [ + 'name' => '存储桶名称', + 'type' => 'input', + 'placeholder' => '', + 'show' => 'product==\'cos\'', + 'required' => true, + ], + 'lighthouse_id' => [ + 'name' => '实例ID', + 'type' => 'input', + 'placeholder' => '', + 'show' => 'product==\'lighthouse\'', + 'required' => true, + ], + 'domain' => [ + 'name' => '绑定的域名', + 'type' => 'input', + 'placeholder' => '', + 'show' => 'product!=\'clb\'&&product!=\'tke\'', + 'required' => true, + ], + ], + ], + 'huawei' => [ + 'name' => '华为云', + 'class' => 2, + 'icon' => 'huawei.ico', + 'note' => '支持部署到华为云CDN、ELB、WAF等服务', + 'inputs' => [ + 'AccessKeyId' => [ + 'name' => 'AccessKeyId', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'SecretAccessKey' => [ + 'name' => 'SecretAccessKey', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + ], + 'taskinputs' => [ + 'product' => [ + 'name' => '要部署的产品', + 'type' => 'select', + 'options' => [ + ['value'=>'cdn', 'label'=>'内容分发网络CDN'], + ['value'=>'elb', 'label'=>'弹性负载均衡ELB'], + ['value'=>'waf', 'label'=>'Web应用防火墙WAF'], + ], + 'value' => 'cdn', + 'required' => true, + ], + 'domain' => [ + 'name' => '绑定的域名', + 'type' => 'input', + 'placeholder' => '', + 'show' => 'product==\'cdn\'', + 'required' => true, + ], + 'project_id' => [ + 'name' => '项目ID', + 'type' => 'input', + 'placeholder' => '', + 'show' => 'product==\'elb\'||product==\'waf\'', + 'note' => '项目ID可在我的凭证->项目列表页面查看', + 'required' => true, + ], + 'region_id' => [ + 'name' => '区域ID', + 'type' => 'input', + 'placeholder' => '', + 'show' => 'product==\'elb\'||product==\'waf\'', + 'note' => '区域ID可在此页面查找', + 'required' => true, + ], + 'cert_id' => [ + 'name' => '要更新的证书ID', + 'type' => 'input', + 'placeholder' => '在ELB控制台->证书管理->查看已上传的证书ID', + 'show' => 'product==\'elb\'||product==\'waf\'', + 'required' => true, + ], + ], + ], + 'ucloud' => [ + 'name' => 'UCloud', + 'class' => 2, + 'icon' => 'ucloud.ico', + 'note' => '支持部署到UCDN', + 'inputs' => [ + 'PublicKey' => [ + 'name' => '公钥', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'PrivateKey' => [ + 'name' => '私钥', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + ], + 'taskinputs' => [ + 'domain_id' => [ + 'name' => '云分发资源ID', + 'type' => 'input', + 'placeholder' => '', + 'note' => '在云分发-域名管理-域名基本信息页面查看', + 'required' => true, + ], + ], + ], + 'qiniu' => [ + 'name' => '七牛云', + 'class' => 2, + 'icon' => 'qiniu.ico', + 'note' => '支持部署到七牛云CDN、OSS', + 'inputs' => [ + 'AccessKey' => [ + 'name' => 'AccessKey', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'SecretKey' => [ + 'name' => 'SecretKey', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + ], + 'taskinputs' => [ + 'product' => [ + 'name' => '要部署的产品', + 'type' => 'select', + 'options' => [ + ['value'=>'cdn', 'label'=>'CDN'], + ['value'=>'oss', 'label'=>'OSS'], + ], + 'value' => 'cdn', + 'required' => true, + ], + 'domain' => [ + 'name' => '绑定的域名', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + ], + ], + 'doge' => [ + 'name' => '多吉云', + 'class' => 2, + 'icon' => 'cloud.png', + 'note' => '支持部署到多吉云融合CDN', + 'inputs' => [ + 'AccessKey' => [ + 'name' => 'AccessKey', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'SecretKey' => [ + 'name' => 'SecretKey', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + ], + 'taskinputs' => [ + 'domain' => [ + 'name' => 'CDN域名', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + ], + ], + 'baidu' => [ + 'name' => '百度云', + 'class' => 2, + 'icon' => 'baidu.ico', + 'note' => '支持部署到百度云CDN', + 'inputs' => [ + 'AccessKeyId' => [ + 'name' => 'AccessKeyId', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'SecretAccessKey' => [ + 'name' => 'SecretAccessKey', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + ], + 'taskinputs' => [ + 'domain' => [ + 'name' => '绑定的域名', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + ], + ], + 'huoshan' => [ + 'name' => '火山引擎', + 'class' => 2, + 'icon' => 'huoshan.ico', + 'note' => '支持部署到火山引擎CDN', + 'inputs' => [ + 'AccessKeyId' => [ + 'name' => 'AccessKeyId', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'SecretAccessKey' => [ + 'name' => 'SecretAccessKey', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + ], + 'taskinputs' => [ + 'domain' => [ + 'name' => '绑定的域名', + 'type' => 'input', + 'placeholder' => '多个域名可使用英文逗号分隔', + 'required' => true, + ], + ], + ], + 'baishan' => [ + 'name' => '白山云', + 'class' => 2, + 'icon' => 'waf.png', + 'note' => null, + 'inputs' => [ + 'account' => [ + 'name' => '账户名', + 'type' => 'input', + 'placeholder' => '仅用作标记', + 'required' => true, + ], + 'token' => [ + 'name' => 'token', + 'type' => 'input', + 'placeholder' => '', + 'note' => '自行联系提供商申请', + 'required' => true, + ], + 'proxy' => [ + 'name' => '使用代理服务器', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'value' => '0' + ], + ], + 'taskinputs' => [ + 'id' => [ + 'name' => '证书ID', + 'type' => 'input', + 'placeholder' => '', + 'note' => '在证书管理页面查看,注意域名是否与证书匹配', + 'required' => true, + ], + ], + ], + 'allwaf' => [ + 'name' => 'AllWAF', + 'class' => 2, + 'icon' => 'waf.png', + 'note' => '在ALLWAF访问控制页面创建AccessKey', + 'tasknote' => '系统会根据关联SSL证书的域名,自动更新对应证书', + 'inputs' => [ + 'accessKeyId' => [ + 'name' => 'AccessKey ID', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'accessKey' => [ + 'name' => 'AccessKey密钥', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'proxy' => [ + 'name' => '使用代理服务器', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'value' => '0' + ], + ], + 'taskinputs' => [], + ], + 'aws' => [ + 'name' => 'AWS', + 'class' => 2, + 'icon' => 'aws.ico', + 'note' => '支持部署到Amazon CloudFront', + 'inputs' => [ + 'AccessKeyId' => [ + 'name' => 'AccessKeyId', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'SecretAccessKey' => [ + 'name' => 'SecretAccessKey', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + ], + 'taskinputs' => [ + 'product' => [ + 'name' => '要部署的产品', + 'type' => 'select', + 'options' => [ + ['value'=>'cloudfront', 'label'=>'CloudFront'], + ], + 'value' => 'cloudfront', + 'required' => true, + ], + 'distribution_id' => [ + 'name' => '分配ID', + 'type' => 'input', + 'placeholder' => 'distributions id', + 'required' => true, + ], + ], + ], + 'gcore' => [ + 'name' => 'Gcore', + 'class' => 2, + 'icon' => 'gcore.ico', + 'note' => '在 个人资料->API令牌 页面创建API令牌', + 'inputs' => [ + 'account' => [ + 'name' => '账户名', + 'type' => 'input', + 'placeholder' => '仅用作标记', + 'required' => true, + ], + 'apikey' => [ + 'name' => 'API令牌', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'proxy' => [ + 'name' => '使用代理服务器', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'value' => '0' + ], + ], + 'taskinputs' => [ + 'id' => [ + 'name' => '证书ID', + 'type' => 'input', + 'placeholder' => '', + 'note' => '在CDN->SSL证书页面查看证书的ID,注意域名是否与证书匹配', + 'required' => true, + ], + 'name' => [ + 'name' => '证书名称', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + ], + ], + 'cachefly' => [ + 'name' => 'Cachefly', + 'class' => 2, + 'icon' => 'cloud.png', + 'note' => '在 API Tokens 页面生成 API Token', + 'inputs' => [ + 'account' => [ + 'name' => '账户名', + 'type' => 'input', + 'placeholder' => '仅用作标记', + 'required' => true, + ], + 'apikey' => [ + 'name' => 'API Token', + 'type' => 'input', + 'placeholder' => '', + 'required' => true, + ], + 'proxy' => [ + 'name' => '使用代理服务器', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'value' => '0' + ], + ], + 'taskinputs' => [ + + ], + ], + 'ssh' => [ + 'name' => 'SSH服务器', + 'class' => 3, + 'icon' => 'server.png', + 'note' => '可通过SSH连接到Linux/Windows服务器并部署证书,php需要安装ssh2扩展', + 'tasknote' => '请确保路径存在且有写入权限,路径一定要以/开头(Windows路径请使用/代替\,且需要在最开头加/)', + 'inputs' => [ + 'host' => [ + 'name' => '主机地址', + 'type' => 'input', + 'placeholder' => '填写IP地址或域名', + 'required' => true, + ], + 'port' => [ + 'name' => '端口', + 'type' => 'input', + 'placeholder' => '', + 'value' => '22', + 'required' => true, + ], + 'auth' => [ + 'name' => '认证方式', + 'type' => 'radio', + 'options' => [ + '0' => '密码认证', + '1' => '密钥认证', + ], + 'value' => '0', + 'required' => true, + ], + 'username' => [ + 'name' => '用户名', + 'type' => 'input', + 'placeholder' => '登录用户名', + 'value' => 'root', + 'required' => true, + ], + 'password' => [ + 'name' => '密码', + 'type' => 'input', + 'placeholder' => '登录密码', + 'required' => true, + 'show' => 'auth==0', + ], + 'privatekey' => [ + 'name' => '私钥', + 'type' => 'textarea', + 'placeholder' => '填写私钥内容', + 'required' => true, + 'show' => 'auth==1', + ], + 'windows' => [ + 'name' => '是否Windows', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'note' => 'Windows系统需要先安装OpenSSH', + 'value' => '0', + 'required' => true, + ], + ], + 'taskinputs' => [ + 'format' => [ + 'name' => '证书类型', + 'type' => 'select', + 'options' => [ + ['value'=>'pem', 'label'=>'PEM格式(Nginx/Apache等)'], + ['value'=>'pfx', 'label'=>'PFX格式(IIS/Tomcat)'], + ], + 'value' => 'pem', + 'required' => true, + ], + 'pem_cert_file' => [ + 'name' => '证书保存路径', + 'type' => 'input', + 'placeholder' => '/path/to/cert.pem', + 'show' => 'format==\'pem\'', + 'required' => true, + ], + 'pem_key_file' => [ + 'name' => '私钥保存路径', + 'type' => 'input', + 'placeholder' => '/path/to/key.pem', + 'show' => 'format==\'pem\'', + 'required' => true, + ], + 'pfx_file' => [ + 'name' => 'PFX证书保存路径', + 'type' => 'input', + 'placeholder' => '/path/to/cert.pfx', + 'show' => 'format==\'pfx\'', + 'required' => true, + ], + 'pfx_pass' => [ + 'name' => 'PFX证书密码', + 'type' => 'input', + 'placeholder' => '留空为不设置密码', + 'show' => 'format==\'pfx\'', + ], + 'uptype' => [ + 'name' => '上传完操作', + 'type' => 'radio', + 'options' => [ + '0' => '执行指定命令', + '1' => '部署到IIS', + ], + 'value' => '0', + 'show' => 'format==\'pfx\'', + 'required' => true, + ], + 'cmd' => [ + 'name' => '上传完执行命令', + 'type' => 'textarea', + 'show' => 'format==\'pem\'||uptype==0', + 'placeholder' => '可留空,每行一条命令,如:service nginx reload', + ], + 'iis_domain' => [ + 'name' => '绑定的域名', + 'type' => 'input', + 'placeholder' => '在IIS站点绑定的https域名', + 'show' => 'format==\'pfx\'&&uptype==1', + ], + ], + ], + 'ftp' => [ + 'name' => 'FTP服务器', + 'class' => 3, + 'icon' => 'server.png', + 'note' => '可将证书上传到FTP服务器,php需要安装ftp扩展', + 'tasknote' => '请确保路径存在且有写入权限', + 'inputs' => [ + 'host' => [ + 'name' => 'FTP地址', + 'type' => 'input', + 'placeholder' => '填写IP地址或域名', + 'required' => true, + ], + 'port' => [ + 'name' => 'FTP端口', + 'type' => 'input', + 'placeholder' => '', + 'value' => '21', + 'required' => true, + ], + 'username' => [ + 'name' => '用户名', + 'type' => 'input', + 'placeholder' => 'FTP登录用户名', + 'required' => true, + ], + 'password' => [ + 'name' => '密码', + 'type' => 'input', + 'placeholder' => 'FTP登录密码', + 'required' => true, + ], + 'secure' => [ + 'name' => '是否使用SSL', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'value' => '0', + 'required' => true, + ], + ], + 'taskinputs' => [ + 'format' => [ + 'name' => '证书类型', + 'type' => 'select', + 'options' => [ + ['value'=>'pem', 'label'=>'PEM格式(Nginx/Apache等)'], + ['value'=>'pfx', 'label'=>'PFX格式(IIS)'], + ], + 'value' => 'pem', + 'required' => true, + ], + 'pem_cert_file' => [ + 'name' => '证书保存路径', + 'type' => 'input', + 'placeholder' => '/path/to/cert.pem', + 'show' => 'format==\'pem\'', + 'required' => true, + ], + 'pem_key_file' => [ + 'name' => '私钥保存路径', + 'type' => 'input', + 'placeholder' => '/path/to/key.pem', + 'show' => 'format==\'pem\'', + 'required' => true, + ], + 'pfx_file' => [ + 'name' => 'PFX证书保存路径', + 'type' => 'input', + 'placeholder' => '/path/to/cert.pfx', + 'show' => 'format==\'pfx\'', + 'required' => true, + ], + 'pfx_pass' => [ + 'name' => 'PFX证书密码', + 'type' => 'input', + 'placeholder' => '留空为不设置密码', + 'show' => 'format==\'pfx\'', + ], + ], + ], + 'local' => [ + 'name' => '复制到本机', + 'class' => 3, + 'icon' => 'server2.png', + 'note' => '将证书复制到本机指定路径', + 'tasknote' => '请确保php进程有对证书保存路径的写入权限,宝塔面板需关闭防跨站攻击,如果当前是Docker运行的,则需要做目录映射到宿主机。', + 'inputs' => [], + 'taskinputs' => [ + 'format' => [ + 'name' => '证书类型', + 'type' => 'select', + 'options' => [ + ['value'=>'pem', 'label'=>'PEM格式(Nginx/Apache等)'], + ['value'=>'pfx', 'label'=>'PFX格式(IIS)'], + ], + 'value' => 'pem', + 'required' => true, + ], + 'pem_cert_file' => [ + 'name' => '证书保存路径', + 'type' => 'input', + 'placeholder' => '/path/to/cert.pem', + 'show' => 'format==\'pem\'', + 'required' => true, + ], + 'pem_key_file' => [ + 'name' => '私钥保存路径', + 'type' => 'input', + 'placeholder' => '/path/to/key.pem', + 'show' => 'format==\'pem\'', + 'required' => true, + ], + 'pfx_file' => [ + 'name' => 'PFX证书保存路径', + 'type' => 'input', + 'placeholder' => '/path/to/cert.pfx', + 'show' => 'format==\'pfx\'', + 'required' => true, + ], + 'pfx_pass' => [ + 'name' => 'PFX证书密码', + 'type' => 'input', + 'placeholder' => '留空为不设置密码', + 'show' => 'format==\'pfx\'', + ], + 'cmd' => [ + 'name' => '复制完执行命令', + 'type' => 'textarea', + 'placeholder' => '可留空,每行一条命令,如:service nginx reload', + 'note' => '如需执行命令,php需开放exec函数', + ], + ], + ], + ]; + + public static $class_config = [ + 1 => '自建面板', + 2 => '云服务商', + 3 => '服务器', + ]; + + public static function getList() + { + return self::$deploy_config; + } + + private static function getConfig($aid) + { + $account = Db::name('cert_account')->where('id', $aid)->find(); + if (!$account) return false; + return $account; + } + + public static function getInputs($type, $config = null) + { + $config = $config ? json_decode($config, true) : []; + $inputs = self::$deploy_config[$type]['inputs']; + foreach ($inputs as &$input) { + if (isset($config[$input['name']])) { + $input['value'] = $config[$input['name']]; + } + } + return $inputs; + } + + /** + * @return DeployInterface|bool + */ + public static function getModel($aid) + { + $account = self::getConfig($aid); + if (!$account) return false; + $type = $account['type']; + $class = "\\app\\lib\\deploy\\{$type}"; + if (class_exists($class)) { + $config = json_decode($account['config'], true); + $model = new $class($config); + return $model; + } + return false; + } + + /** + * @return DeployInterface|bool + */ + public static function getModel2($type, $config) + { + $class = "\\app\\lib\\deploy\\{$type}"; + if (class_exists($class)) { + $model = new $class($config); + return $model; + } + return false; + } +} diff --git a/app/lib/DeployInterface.php b/app/lib/DeployInterface.php new file mode 100644 index 0000000..05acf28 --- /dev/null +++ b/app/lib/DeployInterface.php @@ -0,0 +1,12 @@ +generateSecret(); + } + $this->secret = $secret; + } + + public static function create(?string $secret = null) + { + return new self($secret); + } + + public function getSecret(): string + { + return $this->secret; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): void + { + $this->label = $label; + } + + public function getIssuer(): ?string + { + return $this->issuer; + } + + public function setIssuer(string $issuer): void + { + $this->issuer = $issuer; + } + + public function verify(string $otp, ?int $timestamp = null, ?int $window = null): bool + { + $timestamp = $this->getTimestamp($timestamp); + + if (null === $window) { + return $this->compareOTP($this->at($timestamp), $otp); + } + + return $this->verifyOtpWithWindow($otp, $timestamp, $window); + } + + private function verifyOtpWithWindow(string $otp, int $timestamp, int $window): bool + { + $window = abs($window); + + for ($i = 0; $i <= $window; ++$i) { + $next = $i * $this->period + $timestamp; + $previous = -$i * $this->period + $timestamp; + $valid = $this->compareOTP($this->at($next), $otp) || + $this->compareOTP($this->at($previous), $otp); + + if ($valid) { + return true; + } + } + + return false; + } + + public function getProvisioningUri(): string + { + $params = []; + if (30 !== $this->period) { + $params['period'] = $this->period; + } + if (0 !== $this->epoch) { + $params['epoch'] = $this->epoch; + } + $label = $this->getLabel(); + if (null === $label) { + throw new \InvalidArgumentException('The label is not set.'); + } + if ($this->hasColon($label)) { + throw new \InvalidArgumentException('Label must not contain a colon.'); + } + $params['issuer'] = $this->getIssuer(); + $params['secret'] = $this->getSecret(); + $query = str_replace(['+', '%7E'], ['%20', '~'], http_build_query($params)); + return sprintf('otpauth://totp/%s?%s', rawurlencode((null !== $this->getIssuer() ? $this->getIssuer() . ':' : '') . $label), $query); + } + + /** + * The OTP at the specified input. + */ + private function generateOTP(int $input): string + { + $hash = hash_hmac($this->digest, $this->intToByteString($input), $this->base32_decode($this->getSecret()), true); + + $hmac = array_values(unpack('C*', $hash)); + + $offset = ($hmac[\count($hmac) - 1] & 0xF); + $code = ($hmac[$offset + 0] & 0x7F) << 24 | ($hmac[$offset + 1] & 0xFF) << 16 | ($hmac[$offset + 2] & 0xFF) << 8 | ($hmac[$offset + 3] & 0xFF); + $otp = $code % (10 ** $this->digits); + + return str_pad((string) $otp, $this->digits, '0', STR_PAD_LEFT); + } + + private function at(int $timestamp): string + { + return $this->generateOTP($this->timecode($timestamp)); + } + + private function timecode(int $timestamp): int + { + return (int) floor(($timestamp - $this->epoch) / $this->period); + } + + private function getTimestamp(?int $timestamp): int + { + $timestamp = $timestamp ?? time(); + if ($timestamp < 0) { + throw new \InvalidArgumentException('Timestamp must be at least 0.'); + } + + return $timestamp; + } + + private function generateSecret(): string + { + return strtoupper($this->base32_encode(random_bytes(20))); + } + + private function base32_encode($data) + { + $dataSize = strlen($data); + $res = ''; + $remainder = 0; + $remainderSize = 0; + + for ($i = 0; $i < $dataSize; $i++) { + $b = ord($data[$i]); + $remainder = ($remainder << 8) | $b; + $remainderSize += 8; + while ($remainderSize > 4) { + $remainderSize -= 5; + $c = $remainder & (31 << $remainderSize); + $c >>= $remainderSize; + $res .= self::$BASE32_ALPHABET[$c]; + } + } + if ($remainderSize > 0) { + $remainder <<= (5 - $remainderSize); + $c = $remainder & 31; + $res .= self::$BASE32_ALPHABET[$c]; + } + + return $res; + } + + private function base32_decode($data) + { + $data = strtolower($data); + $dataSize = strlen($data); + $buf = 0; + $bufSize = 0; + $res = ''; + + for ($i = 0; $i < $dataSize; $i++) { + $c = $data[$i]; + $b = strpos(self::$BASE32_ALPHABET, $c); + if ($b === false) { + throw new \Exception('Encoded string is invalid, it contains unknown char #'.ord($c)); + } + $buf = ($buf << 5) | $b; + $bufSize += 5; + if ($bufSize > 7) { + $bufSize -= 8; + $b = ($buf & (0xff << $bufSize)) >> $bufSize; + $res .= chr($b); + } + } + + return $res; + } + + private function intToByteString(int $int): string + { + $result = []; + while (0 !== $int) { + $result[] = \chr($int & 0xFF); + $int >>= 8; + } + + return str_pad(implode(array_reverse($result)), 8, "\000", STR_PAD_LEFT); + } + + private function compareOTP(string $safe, string $user): bool + { + return hash_equals($safe, $user); + } + + private function hasColon(string $value): bool + { + $colons = [':', '%3A', '%3a']; + foreach ($colons as $colon) { + if (false !== mb_strpos($value, $colon)) { + return true; + } + } + + return false; + } +} diff --git a/app/lib/acme/ACMECert.php b/app/lib/acme/ACMECert.php new file mode 100644 index 0000000..592e36d --- /dev/null +++ b/app/lib/acme/ACMECert.php @@ -0,0 +1,683 @@ +_register($termsOfServiceAgreed, $contacts); + } + + public function registerEAB($termsOfServiceAgreed, $eab_kid, $eab_hmac, $contacts = array()) + { + if (!$this->resources) $this->readDirectory(); + + $protected = array( + 'alg' => 'HS256', + 'kid' => $eab_kid, + 'url' => $this->resources['newAccount'] + ); + $payload = $this->jwk_header['jwk']; + + $protected64 = $this->base64url(json_encode($protected, JSON_UNESCAPED_SLASHES)); + $payload64 = $this->base64url(json_encode($payload, JSON_UNESCAPED_SLASHES)); + + $signature = hash_hmac('sha256', $protected64 . '.' . $payload64, $this->base64url_decode($eab_hmac), true); + + return $this->_register($termsOfServiceAgreed, $contacts, array( + 'externalAccountBinding' => array( + 'protected' => $protected64, + 'payload' => $payload64, + 'signature' => $this->base64url($signature) + ) + )); + } + + private function _register($termsOfServiceAgreed = false, $contacts = array(), $extra = array()) + { + $this->log('Registering account'); + + $ret = $this->request('newAccount', array( + 'termsOfServiceAgreed' => (bool)$termsOfServiceAgreed, + 'contact' => $this->make_contacts_array($contacts) + ) + $extra); + $this->log($ret['code'] == 201 ? 'Account registered' : 'Account already registered'); + return $this->kid_header['kid']; + } + + public function update($contacts = array()) + { + $this->log('Updating account'); + $ret = $this->request($this->getAccountID(), array( + 'contact' => $this->make_contacts_array($contacts) + )); + $this->log('Account updated'); + return $ret['body']; + } + + public function getAccount() + { + $ret = parent::getAccount(); + return $this->kid_header['kid']; + } + + public function setAccount($kid) + { + $this->kid_header['kid'] = $kid; + } + + public function deactivateAccount() + { + $this->log('Deactivating account'); + $ret = $this->deactivate($this->getAccountID()); + $this->log('Account deactivated'); + return $ret; + } + + public function deactivate($url) + { + $this->log('Deactivating resource: ' . $url); + $ret = $this->request($url, array('status' => 'deactivated')); + $this->log('Resource deactivated'); + return $ret['body']; + } + + public function getTermsURL() + { + if (!$this->resources) $this->readDirectory(); + if (!isset($this->resources['meta']['termsOfService'])) { + throw new Exception('Failed to get Terms Of Service URL'); + } + return $this->resources['meta']['termsOfService']; + } + + public function getCAAIdentities() + { + if (!$this->resources) $this->readDirectory(); + if (!isset($this->resources['meta']['caaIdentities'])) { + throw new Exception('Failed to get CAA Identities'); + } + return $this->resources['meta']['caaIdentities']; + } + + public function keyChange($new_account_key_pem) + { // account key roll-over + $this->loadAccountKey($new_account_key_pem); + $account = $this->getAccountID(); + $this->resources = $this->resources; + + $this->log('Account Key Roll-Over'); + + $ret = $this->request( + 'keyChange', + $this->jws_encapsulate('keyChange', array( + 'account' => $account, + 'oldKey' => $this->jwk_header['jwk'] + ), true) + ); + $this->log('Account Key Roll-Over successful'); + + $this->loadAccountKey($new_account_key_pem); + return $ret['body']; + } + + public function revoke($pem) + { + if (false === ($res = openssl_x509_read($pem))) { + throw new Exception('Could not load certificate: ' . $pem . ' (' . $this->get_openssl_error() . ')'); + } + if (false === (openssl_x509_export($res, $certificate))) { + throw new Exception('Could not export certificate: ' . $pem . ' (' . $this->get_openssl_error() . ')'); + } + + $this->log('Revoking certificate'); + $this->request('revokeCert', array( + 'certificate' => $this->base64url($this->pem2der($certificate)) + )); + $this->log('Certificate revoked'); + } + + public function createOrder($domain_config, $settings = array()) + { + $settings = $this->parseSettings($settings); + + $domain_config = array_change_key_case($domain_config, CASE_LOWER); + $domains = array_keys($domain_config); + $authz_deactivated = false; + + // === Order === + $this->log('Creating Order'); + $ret = $this->request('newOrder', $this->makeOrder($domains, $settings)); + $order = $ret['body']; + $order_location = $ret['headers']['location']; + $this->log('Order created: ' . $order_location); + $order['location'] = $order_location; + + // === Authorization === + if ($order['status'] === 'ready' && $settings['authz_reuse']) { + $this->log('All authorizations already valid, skipping validation altogether'); + } else { + $auth_count = count($order['authorizations']); + $challenges = array(); + + foreach ($order['authorizations'] as $idx => $auth_url) { + $this->log('Fetching authorization ' . ($idx + 1) . ' of ' . $auth_count); + $ret = $this->request($auth_url, ''); + $authorization = $ret['body']; + + // wildcard authorization identifiers have no leading *. + $domain = ( // get domain and add leading *. if wildcard is used + isset($authorization['wildcard']) && + $authorization['wildcard'] ? + '*.' : '' + ) . $authorization['identifier']['value']; + + if ($authorization['status'] === 'valid') { + if ($settings['authz_reuse']) { + $this->log('Authorization of ' . $domain . ' already valid, skipping validation'); + } else { + $this->log('Authorization of ' . $domain . ' already valid, deactivating authorization'); + $this->deactivate($auth_url); + $authz_deactivated = true; + } + continue; + } + + if(!isset($domain_config[$domain])) { + $this->log('Domain ' . $domain . ' not found in domain_config'); + continue; + } + + $config = $domain_config[$domain]; + $type = $config['challenge']; + + $challenge = $this->parse_challenges($authorization, $type, $challenge_url); + + $opts = array( + 'domain' => $domain, + 'type' => $type, + 'auth_url' => $auth_url, + 'challenge_url' => $challenge_url + ); + list($opts['key'], $opts['value']) = $challenge; + + $challenges[] = $opts; + } + + if ($authz_deactivated) { + $this->log('Restarting Order after deactivating already valid authorizations'); + $settings['authz_reuse'] = true; + return $this->createOrder($domain_config, $settings); + } + + $order['challenges'] = $challenges; + } + return $order; + } + + public function authOrder($order) + { + if ($order['status'] != 'ready' && empty($order['challenges'])) { + throw new Exception('No challenges available'); + } + + // === Challenge === + if (!empty($order['challenges'])){ + foreach ($order['challenges'] as $opts) { + $this->log('Notifying server for validation of ' . $opts['domain']); + $this->request($opts['challenge_url'], new stdClass); + + $this->log('Waiting for server challenge validation'); + sleep(1); + + if (!$this->poll('pending', $opts['auth_url'], $body)) { + $this->log('Validation failed: ' . $opts['domain']); + + $error = $body['challenges'][0]['error']; + throw $this->create_ACME_Exception( + $error['type'], + 'Challenge validation failed: ' . $error['detail'] + ); + } else { + $this->log('Validation successful: ' . $opts['domain']); + } + } + } + } + + public function finalizeOrder($domains, $order, $pem) + { + // autodetect if Private Key or CSR is used + if ($key = openssl_pkey_get_private($pem)) { // Private Key detected + if (PHP_MAJOR_VERSION < 8) openssl_free_key($key); + $this->log('Generating CSR'); + $csr = $this->generateCSR($pem, $domains); + } elseif (openssl_csr_get_subject($pem)) { // CSR detected + $this->log('Using provided CSR'); + if (0 === strpos($pem, 'file://')) { + $csr = file_get_contents(substr($pem, 7)); + if (false === $csr) { + throw new Exception('Failed to read CSR from ' . $pem . ' (' . $this->get_openssl_error() . ')'); + } + } else { + $csr = $pem; + } + } else { + throw new Exception('Could not load Private Key or CSR (' . $this->get_openssl_error() . '): ' . $pem); + } + + $this->log('Finalizing Order'); + + $ret = $this->request($order['finalize'], array( + 'csr' => $this->base64url($this->pem2der($csr)) + )); + $ret = $ret['body']; + + if (isset($ret['certificate'])) { + return $this->request_certificate($ret); + } + + if ($this->poll('processing', $order['location'], $ret)) { + return $this->request_certificate($ret); + } + + throw new Exception('Order failed'); + } + + public function finalizeOrders($domains, $order, $pem) + { + $default_chain = $this->finalizeOrder($domains, $order, $pem); + + $out = array(); + $out[$this->getTopIssuerCN($default_chain)] = $default_chain; + + foreach ($this->alternate_chains as $link) { + $chain = $this->request_certificate(array('certificate' => $link), true); + $out[$this->getTopIssuerCN($chain)] = $chain; + } + + $this->log('Received ' . count($out) . ' chain(s): ' . implode(', ', array_keys($out))); + return $out; + } + + public function generateCSR($domain_key_pem, $domains) + { + if (false === ($domain_key = openssl_pkey_get_private($domain_key_pem))) { + throw new Exception('Could not load domain key: ' . $domain_key_pem . ' (' . $this->get_openssl_error() . ')'); + } + + $fn = $this->tmp_ssl_cnf($domains); + $cn = reset($domains); + $dn = array(); + if (strlen($cn) <= 64) { + $dn['commonName'] = $cn; + } + $csr = openssl_csr_new($dn, $domain_key, array( + 'config' => $fn, + 'req_extensions' => 'SAN', + 'digest_alg' => 'sha512' + )); + unlink($fn); + if (PHP_MAJOR_VERSION < 8) openssl_free_key($domain_key); + + if (false === $csr) { + throw new Exception('Could not generate CSR ! (' . $this->get_openssl_error() . ')'); + } + if (false === openssl_csr_export($csr, $out)) { + throw new Exception('Could not export CSR ! (' . $this->get_openssl_error() . ')'); + } + + return $out; + } + + private function generateKey($opts) + { + $fn = $this->tmp_ssl_cnf(); + $config = array('config' => $fn) + $opts; + if (false === ($key = openssl_pkey_new($config))) { + throw new Exception('Could not generate new private key ! (' . $this->get_openssl_error() . ')'); + } + if (false === openssl_pkey_export($key, $pem, null, $config)) { + throw new Exception('Could not export private key ! (' . $this->get_openssl_error() . ')'); + } + unlink($fn); + if (PHP_MAJOR_VERSION < 8) openssl_free_key($key); + return $pem; + } + + public function generateRSAKey($bits = 2048) + { + return $this->generateKey(array( + 'private_key_bits' => (int)$bits, + 'private_key_type' => OPENSSL_KEYTYPE_RSA + )); + } + + public function generateECKey($curve_name = '384') + { + 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]; + return $this->generateKey(array( + 'curve_name' => $curve_name, + 'private_key_type' => OPENSSL_KEYTYPE_EC + )); + } + + public function parseCertificate($cert_pem) + { + if (false === ($ret = openssl_x509_read($cert_pem))) { + throw new Exception('Could not load certificate: ' . $cert_pem . ' (' . $this->get_openssl_error() . ')'); + } + if (!is_array($ret = openssl_x509_parse($ret, true))) { + throw new Exception('Could not parse certificate (' . $this->get_openssl_error() . ')'); + } + return $ret; + } + + public function getSAN($pem) + { + $ret = $this->parseCertificate($pem); + if (!isset($ret['extensions']['subjectAltName'])) { + throw new Exception('No Subject Alternative Name (SAN) found in certificate'); + } + $out = array(); + foreach (explode(',', $ret['extensions']['subjectAltName']) as $line) { + list($type, $name) = array_map('trim', explode(':', $line)); + if ($type === 'DNS') { + $out[] = $name; + } + } + return $out; + } + + public function getRemainingDays($cert_pem) + { + $ret = $this->parseCertificate($cert_pem); + return ($ret['validTo_time_t'] - time()) / 86400; + } + + public function getRemainingPercent($cert_pem) + { + $ret = $this->parseCertificate($cert_pem); + $total = $ret['validTo_time_t'] - $ret['validFrom_time_t']; + $used = time() - $ret['validFrom_time_t']; + return (1 - max(0, min(1, $used / $total))) * 100; + } + + public function generateALPNCertificate($domain_key_pem, $domain, $token) + { + $domains = array($domain); + $csr = $this->generateCSR($domain_key_pem, $domains); + + $fn = $this->tmp_ssl_cnf($domains, '1.3.6.1.5.5.7.1.31=critical,DER:0420' . $token . "\n"); + $config = array( + 'config' => $fn, + 'x509_extensions' => 'SAN', + 'digest_alg' => 'sha512' + ); + $cert = openssl_csr_sign($csr, null, $domain_key_pem, 1, $config); + unlink($fn); + if (false === $cert) { + throw new Exception('Could not generate self signed certificate ! (' . $this->get_openssl_error() . ')'); + } + if (false === openssl_x509_export($cert, $out)) { + throw new Exception('Could not export self signed certificate ! (' . $this->get_openssl_error() . ')'); + } + return $out; + } + + public function getARI($pem, &$ari_cert_id = null) + { + $ari_cert_id = null; + $id = $this->getARICertID($pem); + + if (!$this->resources) $this->readDirectory(); + if (!isset($this->resources['renewalInfo'])) throw new Exception('ARI not supported'); + + $ret = $this->http_request($this->resources['renewalInfo'] . '/' . $id); + + if (!is_array($ret['body']['suggestedWindow'])) throw new Exception('ARI suggestedWindow not present'); + + $sw = &$ret['body']['suggestedWindow']; + + if (!isset($sw['start'])) throw new Exception('ARI suggestedWindow start not present'); + if (!isset($sw['end'])) throw new Exception('ARI suggestedWindow end not present'); + + $sw = array_map(array($this, 'parseDate'), $sw); + + $ari_cert_id = $id; + return $ret['body']; + } + + private function getARICertID($pem) + { + if (version_compare(PHP_VERSION, '7.1.2', '<')) { + throw new Exception('PHP Version >= 7.1.2 required for ARI'); // serialNumberHex - https://github.com/php/php-src/pull/1755 + } + $ret = $this->parseCertificate($pem); + + if (!isset($ret['extensions']['authorityKeyIdentifier'])) { + throw new Exception('authorityKeyIdentifier missing'); + } + $aki = hex2bin(str_replace(':', '', substr(trim($ret['extensions']['authorityKeyIdentifier']), 6))); + if (!$aki) throw new Exception('Failed to parse authorityKeyIdentifier'); + + if (!isset($ret['serialNumberHex'])) { + throw new Exception('serialNumberHex missing'); + } + $ser = hex2bin(trim($ret['serialNumberHex'])); + if (!$ser) throw new Exception('Failed to parse serialNumberHex'); + + return $this->base64url($aki) . '.' . $this->base64url($ser); + } + + private function parseDate($str) + { + $ret = strtotime(preg_replace('/(\.\d\d)\d+/', '$1', $str)); + if ($ret === false) throw new Exception('Failed to parse date: ' . $str); + return $ret; + } + + private function parseSettings($opts) + { + // authz_reuse: backwards compatibility to ACMECert v3.1.2 or older + if (!is_array($opts)) $opts = array('authz_reuse' => (bool)$opts); + if (!isset($opts['authz_reuse'])) $opts['authz_reuse'] = true; + + $diff = array_diff_key( + $opts, + array_flip(array('authz_reuse', 'notAfter', 'notBefore', 'replaces')) + ); + + if (!empty($diff)) { + throw new Exception('getCertificateChain(s): Invalid option "' . key($diff) . '"'); + } + + return $opts; + } + + private function setRFC3339Date(&$out, $key, $opts) + { + if (isset($opts[$key])) { + $out[$key] = is_string($opts[$key]) ? + $opts[$key] : + date(DATE_RFC3339, $opts[$key]); + } + } + + private function makeOrder($domains, $opts) + { + $order = array( + 'identifiers' => array_map( + function ($domain) { + return array('type' => 'dns', 'value' => $domain); + }, + $domains + ) + ); + $this->setRFC3339Date($order, 'notAfter', $opts); + $this->setRFC3339Date($order, 'notBefore', $opts); + + if (isset($opts['replaces'])) { // ARI + $order['replaces'] = $opts['replaces']; + $this->log('Replacing Certificate: ' . $opts['replaces']); + } + + return $order; + } + + private function parse_challenges($authorization, $type, &$url) + { + foreach ($authorization['challenges'] as $challenge) { + if ($challenge['type'] != $type) continue; + + $url = $challenge['url']; + + switch ($challenge['type']) { + case 'dns-01': + return array( + '_acme-challenge.' . $authorization['identifier']['value'], + $this->base64url(hash('sha256', $this->keyAuthorization($challenge['token']), true)) + ); + break; + case 'http-01': + return array( + '/.well-known/acme-challenge/' . $challenge['token'], + $this->keyAuthorization($challenge['token']) + ); + break; + case 'tls-alpn-01': + return array(null, hash('sha256', $this->keyAuthorization($challenge['token']))); + break; + } + } + throw new Exception( + 'Challenge type: "' . $type . '" not available, for this challenge use ' . + implode(' or ', array_map( + function ($a) { + return '"' . $a['type'] . '"'; + }, + $authorization['challenges'] + )) + ); + } + + private function poll($initial, $type, &$ret) + { + $max_tries = 10; // ~ 5 minutes + for ($i = 0; $i < $max_tries; $i++) { + $ret = $this->request($type); + $ret = $ret['body']; + if ($ret['status'] !== $initial) return $ret['status'] === 'valid'; + $s = pow(2, min($i, 6)); + if ($i !== $max_tries - 1) { + $this->log('Retrying in ' . ($s) . 's'); + sleep($s); + } + } + throw new Exception('Aborted after ' . $max_tries . ' tries'); + } + + private function request_certificate($ret, $alternate = false) + { + $this->log('Requesting ' . ($alternate ? 'alternate' : 'default') . ' certificate-chain'); + $ret = $this->request($ret['certificate'], ''); + if ($ret['headers']['content-type'] !== 'application/pem-certificate-chain') { + throw new Exception('Unexpected content-type: ' . $ret['headers']['content-type']); + } + + $chain = array(); + foreach ($this->splitChain($ret['body']) as $cert) { + $info = $this->parseCertificate($cert); + $chain[] = '[' . $info['issuer']['CN'] . ']'; + } + + if (!$alternate) { + if (isset($ret['headers']['link']['alternate'])) { + $this->alternate_chains = $ret['headers']['link']['alternate']; + } else { + $this->alternate_chains = array(); + } + } + + $this->log(($alternate ? 'Alternate' : 'Default') . ' certificate-chain retrieved: ' . implode(' -> ', array_reverse($chain, true))); + return $ret['body']; + } + + private function tmp_ssl_cnf($domains = null, $extension = '') + { + if (false === ($fn = tempnam(sys_get_temp_dir(), "CNF_"))) { + throw new Exception('Failed to create temp file !'); + } + if (false === @file_put_contents( + $fn, + 'HOME = .' . "\n" . + 'RANDFILE=$ENV::HOME/.rnd' . "\n" . + '[v3_ca]' . "\n" . + '[req]' . "\n" . + 'default_bits=2048' . "\n" . + ($domains ? + 'distinguished_name=req_distinguished_name' . "\n" . + '[req_distinguished_name]' . "\n" . + '[v3_req]' . "\n" . + '[SAN]' . "\n" . + 'subjectAltName=' . + implode(',', array_map(function ($domain) { + return 'DNS:' . $domain; + }, $domains)) . "\n" + : + '' + ) . $extension + )) { + throw new Exception('Failed to write tmp file: ' . $fn); + } + return $fn; + } + + private function pem2der($pem) + { + return base64_decode(implode('', array_slice( + array_map('trim', explode("\n", trim($pem))), + 1, + -1 + ))); + } + + private function make_contacts_array($contacts) + { + if (!is_array($contacts)) { + $contacts = $contacts ? array($contacts) : array(); + } + return array_map(function ($contact) { + return 'mailto:' . $contact; + }, $contacts); + } + + private function getTopIssuerCN($chain) + { + $tmp = $this->splitChain($chain); + $ret = $this->parseCertificate(end($tmp)); + return $ret['issuer']['CN']; + } + + public function splitChain($chain) + { + $delim = '-----END CERTIFICATE-----'; + return array_map(function ($item) use ($delim) { + return trim($item . $delim); + }, array_filter(explode($delim, $chain), function ($item) { + return strpos($item, '-----BEGIN CERTIFICATE-----') !== false; + })); + } +} diff --git a/app/lib/acme/ACME_Exception.php b/app/lib/acme/ACME_Exception.php new file mode 100644 index 0000000..ba251b5 --- /dev/null +++ b/app/lib/acme/ACME_Exception.php @@ -0,0 +1,24 @@ +type = $type; + $this->subproblems = $subproblems; + parent::__construct($detail); + } + function getType() + { + return $this->type; + } + function getSubproblems() + { + return $this->subproblems; + } +} diff --git a/app/lib/acme/ACMEv2.php b/app/lib/acme/ACMEv2.php new file mode 100644 index 0000000..b1fb5b8 --- /dev/null +++ b/app/lib/acme/ACMEv2.php @@ -0,0 +1,428 @@ +directory = $directory; + $this->proxy = $proxy; + } + + public function __destruct() + { + if (PHP_MAJOR_VERSION < 8 && $this->account_key) openssl_pkey_free($this->account_key); + if ($this->ch) curl_close($this->ch); + } + + public function loadAccountKey($account_key_pem) + { + if (PHP_MAJOR_VERSION < 8 && $this->account_key) openssl_pkey_free($this->account_key); + if (false === ($this->account_key = openssl_pkey_get_private($account_key_pem))) { + throw new Exception('Could not load account key: ' . $account_key_pem . ' (' . $this->get_openssl_error() . ')'); + } + + if (false === ($details = openssl_pkey_get_details($this->account_key))) { + throw new Exception('Could not get account key details: ' . $account_key_pem . ' (' . $this->get_openssl_error() . ')'); + } + + $this->bits = $details['bits']; + switch ($details['type']) { + case OPENSSL_KEYTYPE_EC: + if (version_compare(PHP_VERSION, '7.1.0') < 0) throw new Exception('PHP >= 7.1.0 required for EC keys !'); + $this->sha_bits = ($this->bits == 521 ? 512 : $this->bits); + $this->jwk_header = array( // JOSE Header - RFC7515 + 'alg' => 'ES' . $this->sha_bits, + 'jwk' => array( // JSON Web Key + 'crv' => 'P-' . $details['bits'], + 'kty' => 'EC', + 'x' => $this->base64url(str_pad($details['ec']['x'], ceil($this->bits / 8), "\x00", STR_PAD_LEFT)), + 'y' => $this->base64url(str_pad($details['ec']['y'], ceil($this->bits / 8), "\x00", STR_PAD_LEFT)) + ) + ); + break; + case OPENSSL_KEYTYPE_RSA: + $this->sha_bits = 256; + $this->jwk_header = array( // JOSE Header - RFC7515 + 'alg' => 'RS256', + 'jwk' => array( // JSON Web Key + 'e' => $this->base64url($details['rsa']['e']), // public exponent + 'kty' => 'RSA', + 'n' => $this->base64url($details['rsa']['n']) // public modulus + ) + ); + break; + default: + throw new Exception('Unsupported key type! Must be RSA or EC key.'); + break; + } + + $this->kid_header = array( + 'alg' => $this->jwk_header['alg'], + 'kid' => null + ); + + $this->thumbprint = $this->base64url( // JSON Web Key (JWK) Thumbprint - RFC7638 + hash( + 'sha256', + json_encode($this->jwk_header['jwk']), + true + ) + ); + } + + public function getAccountID() + { + if (!$this->kid_header['kid']) self::getAccount(); + return $this->kid_header['kid']; + } + + public function setLogger($value = true) + { + switch (true) { + case is_bool($value): + break; + case is_callable($value): + break; + default: + throw new Exception('setLogger: invalid value provided'); + break; + } + $this->logger = $value; + } + + public function log($txt) + { + switch (true) { + case $this->logger === true: + error_log($txt); + break; + case $this->logger === false: + break; + default: + $fn = $this->logger; + $fn($txt); + break; + } + } + + protected function create_ACME_Exception($type, $detail, $subproblems = array()) + { + $this->log('ACME_Exception: ' . $detail . ' (' . $type . ')'); + return new ACME_Exception($type, $detail, $subproblems); + } + + protected function get_openssl_error() + { + $out = array(); + $arr = error_get_last(); + if (is_array($arr)) { + $out[] = $arr['message']; + } + $out[] = openssl_error_string(); + return implode(' | ', $out); + } + + protected function getAccount() + { + $this->log('Getting account info'); + $ret = $this->request('newAccount', array('onlyReturnExisting' => true)); + $this->log('Account info retrieved'); + return $ret; + } + + protected function keyAuthorization($token) + { + return $token . '.' . $this->thumbprint; + } + + protected function readDirectory() + { + $this->log('Initializing ACME v2 environment: ' . $this->directory); + $ret = $this->http_request($this->directory); // Read ACME Directory + if ( + !is_array($ret['body']) || + !empty(array_diff_key( + array_flip(array('newNonce', 'newAccount', 'newOrder')), + $ret['body'] + )) + ) { + throw new Exception('Failed to read directory: ' . $this->directory); + } + $this->resources = $ret['body']; // store resources for later use + $this->log('Initialized'); + } + + protected function request($type, $payload = '', $retry = false) + { + if (!$this->jwk_header) { + throw new Exception('use loadAccountKey to load an account key'); + } + + if (!$this->resources) $this->readDirectory(); + + if (0 === stripos($type, 'http')) { + $this->resources['_tmp'] = $type; + $type = '_tmp'; + } + + try { + $ret = $this->http_request($this->resources[$type], json_encode( + $this->jws_encapsulate($type, $payload) + )); + } catch (ACME_Exception $e) { // retry previous request once, if replay-nonce expired/failed + if (!$retry && $e->getType() === 'urn:ietf:params:acme:error:badNonce') { + $this->log('Replay-Nonce expired, retrying previous request'); + return $this->request($type, $payload, true); + } + if (!$retry && $e->getType() === 'urn:ietf:params:acme:error:rateLimited' && $this->delay_until !== null) { + return $this->request($type, $payload, true); + } + throw $e; // rethrow all other exceptions + } + + if (!$this->kid_header['kid'] && $type === 'newAccount') { + $this->kid_header['kid'] = $ret['headers']['location']; + $this->log('AccountID: ' . $this->kid_header['kid']); + } + + return $ret; + } + + protected function jws_encapsulate($type, $payload, $is_inner_jws = false) + { // RFC7515 + if ($type === 'newAccount' || $is_inner_jws) { + $protected = $this->jwk_header; + } else { + $this->getAccountID(); + $protected = $this->kid_header; + } + + if (!$is_inner_jws) { + if (!$this->nonce) { + $ret = $this->http_request($this->resources['newNonce'], false); + } + $protected['nonce'] = $this->nonce; + $this->nonce = null; + } + + if (!isset($this->resources[$type])) { + throw new Exception('Resource "' . $type . '" not available.'); + } + + $protected['url'] = $this->resources[$type]; + + $protected64 = $this->base64url(json_encode($protected, JSON_UNESCAPED_SLASHES)); + $payload64 = $this->base64url(is_string($payload) ? $payload : json_encode($payload, JSON_UNESCAPED_SLASHES)); + + if (false === openssl_sign( + $protected64 . '.' . $payload64, + $signature, + $this->account_key, + 'SHA' . $this->sha_bits + )) { + throw new Exception('Failed to sign payload !' . ' (' . $this->get_openssl_error() . ')'); + } + + return array( + 'protected' => $protected64, + 'payload' => $payload64, + 'signature' => $this->base64url($this->jwk_header['alg'][0] == 'R' ? $signature : $this->asn2signature($signature, ceil($this->bits / 8))) + ); + } + + private function asn2signature($asn, $pad_len) + { + if ($asn[0] !== "\x30") throw new Exception('ASN.1 SEQUENCE not found !'); + $asn = substr($asn, $asn[1] === "\x81" ? 3 : 2); + if ($asn[0] !== "\x02") throw new Exception('ASN.1 INTEGER 1 not found !'); + $R = ltrim(substr($asn, 2, ord($asn[1])), "\x00"); + $asn = substr($asn, ord($asn[1]) + 2); + if ($asn[0] !== "\x02") throw new Exception('ASN.1 INTEGER 2 not found !'); + $S = ltrim(substr($asn, 2, ord($asn[1])), "\x00"); + return str_pad($R, $pad_len, "\x00", STR_PAD_LEFT) . str_pad($S, $pad_len, "\x00", STR_PAD_LEFT); + } + + protected function base64url($data) + { // RFC7515 - Appendix C + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + protected function base64url_decode($data) + { + return base64_decode(strtr($data, '-_', '+/')); + } + + private function json_decode($str) + { + $ret = json_decode($str, true); + if ($ret === null) { + throw new Exception('Could not parse JSON: ' . $str); + } + return $ret; + } + + protected function http_request($url, $data = null) + { + if ($this->ch === null) { + $this->ch = curl_init(); + } + + if ($this->delay_until !== null) { + $delta = $this->delay_until - time(); + if ($delta > 0) { + $this->log('Delaying ' . $delta . 's (rate limit)'); + sleep($delta); + } + $this->delay_until = null; + } + + $method = $data === false ? 'HEAD' : ($data === null ? 'GET' : 'POST'); + $user_agent = 'ACMECert v3.4.0 (+https://github.com/skoerfgen/ACMECert)'; + $header = ($data === null || $data === false) ? array() : array('Content-Type: application/jose+json'); + + $headers = array(); + curl_setopt_array($this->ch, array( + CURLOPT_URL => $url, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_NOBODY => $data === false, + CURLOPT_USERAGENT => $user_agent, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $header, + CURLOPT_POSTFIELDS => $data, + CURLOPT_HEADERFUNCTION => static function ($ch, $header) use (&$headers) { + $headers[] = $header; + return strlen($header); + } + )); + + if ($this->proxy) { + $proxy_server = config_get('proxy_server'); + $proxy_port = intval(config_get('proxy_port')); + $proxy_userpwd = config_get('proxy_user').':'.config_get('proxy_pwd'); + $proxy_type = config_get('proxy_type'); + if ($proxy_type == 'https') { + $proxy_type = CURLPROXY_HTTPS; + } elseif ($proxy_type == 'sock4') { + $proxy_type = CURLPROXY_SOCKS4; + } elseif ($proxy_type == 'sock5') { + $proxy_type = CURLPROXY_SOCKS5; + } else { + $proxy_type = CURLPROXY_HTTP; + } + curl_setopt($this->ch, CURLOPT_PROXYAUTH, CURLAUTH_BASIC); + curl_setopt($this->ch, CURLOPT_PROXY, $proxy_server); + curl_setopt($this->ch, CURLOPT_PROXYPORT, $proxy_port); + if ($proxy_userpwd != ':') { + curl_setopt($this->ch, CURLOPT_PROXYUSERPWD, $proxy_userpwd); + } + curl_setopt($this->ch, CURLOPT_PROXYTYPE, $proxy_type); + } + + $took = microtime(true); + $body = curl_exec($this->ch); + $took = round(microtime(true) - $took, 2) . 's'; + if ($body === false) throw new Exception('HTTP Request Error: ' . curl_error($this->ch)); + + $headers = array_reduce( // parse http response headers into array + array_filter($headers, function ($item) { + return trim($item) != ''; + }), + function ($carry, $item) use (&$code) { + $parts = explode(':', $item, 2); + if (count($parts) === 1) { + list(, $code) = explode(' ', trim($item), 3); + $carry = array(); + } else { + list($k, $v) = $parts; + $k = strtolower(trim($k)); + switch ($k) { + case 'link': + if (preg_match('/<(.*)>\s*;\s*rel=\"(.*)\"/', $v, $matches)) { + $carry[$k][$matches[2]][] = trim($matches[1]); + } + break; + case 'content-type': + list($v) = explode(';', $v, 2); + default: + $carry[$k] = trim($v); + break; + } + } + return $carry; + }, + array() + ); + $this->log(' ' . $url . ' [' . $code . '] (' . $took . ')'); + + if (!empty($headers['replay-nonce'])) $this->nonce = $headers['replay-nonce']; + + if (isset($headers['retry-after'])) { + if (is_numeric($headers['retry-after'])) { + $this->delay_until = time() + ceil($headers['retry-after']); + } else { + $this->delay_until = strtotime($headers['retry-after']); + } + $tmp = $this->delay_until - time(); + // ignore delay if not in range 1s..5min + if ($tmp > 300 || $tmp < 1) $this->delay_until = null; + } + + if (!empty($headers['content-type'])) { + switch ($headers['content-type']) { + case 'application/json': + if ($code[0] == '2') { // on non 2xx response: fall through to problem+json case + $body = $this->json_decode($body); + if (isset($body['error']) && !(isset($body['status']) && $body['status'] === 'valid')) { + $this->handleError($body['error']); + } + break; + } + case 'application/problem+json': + $body = $this->json_decode($body); + $this->handleError($body); + break; + } + } + + if ($code[0] != '2') { + throw new Exception('Invalid HTTP-Status-Code received: ' . $code . ': ' . print_r($body, true)); + } + + $ret = array( + 'code' => $code, + 'headers' => $headers, + 'body' => $body + ); + + return $ret; + } + + private function handleError($error) + { + throw $this->create_ACME_Exception( + $error['type'], + $error['detail'], + array_map(function ($subproblem) { + return $this->create_ACME_Exception( + $subproblem['type'], + (isset($subproblem['identifier']['value']) ? + '"' . $subproblem['identifier']['value'] . '": ' : + '' + ) . $subproblem['detail'] + ); + }, isset($error['subproblems']) ? $error['subproblems'] : array()) + ); + } +} diff --git a/app/lib/cert/aliyun.php b/app/lib/cert/aliyun.php new file mode 100644 index 0000000..7aff3c1 --- /dev/null +++ b/app/lib/cert/aliyun.php @@ -0,0 +1,167 @@ +AccessKeyId = $config['AccessKeyId']; + $this->AccessKeySecret = $config['AccessKeySecret']; + $this->client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, $this->Endpoint, $this->Version); + $this->config = $config; + } + + public function register() + { + if (empty($this->AccessKeyId) || empty($this->AccessKeySecret) || empty($this->config['username']) || empty($this->config['phone']) || empty($this->config['email'])) throw new Exception('必填参数不能为空'); + $param = ['Action' => 'ListUserCertificateOrder']; + $this->request($param, true); + return true; + } + + public function buyCert($domainList, &$order) + { + $param = ['Action' => 'DescribePackageState', 'ProductCode' => 'digicert-free-1-free']; + $data = $this->request($param, true); + if (!isset($data['TotalCount']) || $data['TotalCount'] == 0) throw new Exception('没有可用的免费证书资源包'); + $this->log('证书资源包总数量:' . $data['TotalCount'] . ',已使用数量:' . $data['UsedCount']); + } + + public function createOrder($domainList, &$order, $keytype, $keysize) + { + if (empty($domainList)) throw new Exception('域名列表不能为空'); + $domain = $domainList[0]; + $param = [ + 'Action' => 'CreateCertificateRequest', + 'ProductCode' => 'digicert-free-1-free', + 'Username' => $this->config['username'], + 'Phone' => $this->config['phone'], + 'Email' => $this->config['email'], + 'Domain' => $domain, + 'ValidateType' => 'DNS' + ]; + $data = $this->request($param, true); + if (empty($data['OrderId'])) throw new Exception('证书申请失败,OrderId为空'); + $order['OrderId'] = $data['OrderId']; + + sleep(3); + + $param = [ + 'Action' => 'DescribeCertificateState', + 'OrderId' => $order['OrderId'], + ]; + $data = $this->request($param, true); + + $dnsList = []; + if ($data['Type'] == 'domain_verify') { + $mainDomain = getMainDomain($domain); + $name = str_replace('.' . $mainDomain, '', $data['RecordDomain']); + $dnsList[$mainDomain][] = ['name' => $name, 'type' => $data['RecordType'], 'value' => $data['RecordValue']]; + } + + return $dnsList; + } + + public function authOrder($domainList, $order) {} + + public function getAuthStatus($domainList, $order) + { + $param = [ + 'Action' => 'DescribeCertificateState', + 'OrderId' => $order['OrderId'], + ]; + $data = $this->request($param, true); + if ($data['Type'] == 'certificate') { + return true; + } elseif ($data['Type'] == 'verify_fail') { + throw new Exception('证书审核失败'); + } else { + return false; + } + } + + public function finalizeOrder($domainList, $order, $keytype, $keysize) + { + $param = [ + 'Action' => 'DescribeCertificateState', + 'OrderId' => $order['OrderId'], + ]; + $data = $this->request($param, true); + $fullchain = $data['Certificate']; + $private_key = $data['PrivateKey']; + if (empty($fullchain) || empty($private_key)) throw new Exception('证书内容获取失败'); + + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + return ['private_key' => $private_key, 'fullchain' => $fullchain, 'issuer' => $certInfo['issuer']['CN'], 'subject' => $certInfo['subject']['CN'], 'validFrom' => $certInfo['validFrom_time_t'], 'validTo' => $certInfo['validTo_time_t']]; + } + + public function revoke($order, $pem) + { + $param = [ + 'Action' => 'CancelCertificateForPackageRequest', + 'OrderId' => $order['OrderId'], + ]; + $this->request($param); + } + + public function cancel($order) + { + $param = [ + 'Action' => 'DescribeCertificateState', + 'OrderId' => $order['OrderId'], + ]; + $data = $this->request($param, true); + if ($data['Type'] == 'domain_verify' || $data['Type'] == 'process') { + $param = [ + 'Action' => 'CancelOrderRequest', + 'OrderId' => $order['OrderId'], + ]; + $this->request($param); + usleep(500000); + } + if ($data['Type'] == 'domain_verify' || $data['Type'] == 'process' || $data['Type'] == 'payed' || $data['Type'] == 'verify_fail') { + $param = [ + 'Action' => 'DeleteCertificateRequest', + 'OrderId' => $order['OrderId'], + ]; + $this->request($param); + } + } + + public function setLogger($func) + { + $this->logger = $func; + } + + private function log($txt) + { + if ($this->logger) { + call_user_func($this->logger, $txt); + } + } + + private function request($param, $returnData = false) + { + $this->log('Request:' . json_encode($param, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + $result = $this->client->request($param); + $response = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if (!strpos($response, '"Type":"certificate"')) { + $this->log('Response:' . $response); + } + return $returnData ? $result : true; + } +} diff --git a/app/lib/cert/customacme.php b/app/lib/cert/customacme.php new file mode 100644 index 0000000..5581391 --- /dev/null +++ b/app/lib/cert/customacme.php @@ -0,0 +1,114 @@ +config = $config; + $this->ac = new ACMECert($config['directory'], $config['proxy'] == 1); + if ($ext) { + $this->ext = $ext; + $this->ac->loadAccountKey($ext['key']); + $this->ac->setAccount($ext['kid']); + } + } + + public function register() + { + if (empty($this->config['directory'])) throw new Exception('ACME地址不能为空'); + if (empty($this->config['email'])) throw new Exception('邮件地址不能为空'); + + if (!empty($this->ext['key'])) { + if (!empty($this->config['kid']) && !empty($this->config['key'])) { + $kid = $this->ac->registerEAB(true, $this->config['kid'], $this->config['key'], $this->config['email']); + } else { + $kid = $this->ac->register(true, $this->config['email']); + } + return ['kid' => $kid, 'key' => $this->ext['key']]; + } + + $key = $this->ac->generateRSAKey(2048); + $this->ac->loadAccountKey($key); + if (!empty($this->config['kid']) && !empty($this->config['key'])) { + $kid = $this->ac->registerEAB(true, $this->config['kid'], $this->config['key'], $this->config['email']); + } else { + $kid = $this->ac->register(true, $this->config['email']); + } + return ['kid' => $kid, 'key' => $key]; + } + + public function buyCert($domainList, &$order) {} + + public function createOrder($domainList, &$order, $keytype, $keysize) + { + $domain_config = []; + foreach ($domainList as $domain) { + if (empty($domain)) continue; + $domain_config[$domain] = ['challenge' => 'dns-01']; + } + if (empty($domain_config)) throw new Exception('域名列表不能为空'); + + $order = $this->ac->createOrder($domain_config); + + $dnsList = []; + if (!empty($order['challenges'])) { + foreach ($order['challenges'] as $opts) { + $mainDomain = getMainDomain($opts['domain']); + $name = str_replace('.' . $mainDomain, '', $opts['key']); + $dnsList[$mainDomain][] = ['name' => $name, 'type' => 'TXT', 'value' => $opts['value']]; + } + } + + return $dnsList; + } + + public function authOrder($domainList, $order) + { + $this->ac->authOrder($order); + } + + public function getAuthStatus($domainList, $order) + { + return true; + } + + public function finalizeOrder($domainList, $order, $keytype, $keysize) + { + if (empty($domainList)) throw new Exception('域名列表不能为空'); + + if ($keytype == 'ECC') { + if (empty($keysize)) $keysize = '384'; + $private_key = $this->ac->generateECKey($keysize); + } else { + if (empty($keysize)) $keysize = '2048'; + $private_key = $this->ac->generateRSAKey($keysize); + } + $fullchain = $this->ac->finalizeOrder($domainList, $order, $private_key); + + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + return ['private_key' => $private_key, 'fullchain' => $fullchain, 'issuer' => $certInfo['issuer']['CN'], 'subject' => $certInfo['subject']['CN'], 'validFrom' => $certInfo['validFrom_time_t'], 'validTo' => $certInfo['validTo_time_t']]; + } + + public function revoke($order, $pem) + { + $this->ac->revoke($pem); + } + + public function cancel($order) {} + + public function setLogger($func) + { + $this->ac->setLogger($func); + } +} diff --git a/app/lib/cert/google.php b/app/lib/cert/google.php new file mode 100644 index 0000000..8f10384 --- /dev/null +++ b/app/lib/cert/google.php @@ -0,0 +1,118 @@ + 'https://dv.acme-v02.api.pki.goog/directory', + 'staging' => 'https://dv.acme-v02.test-api.pki.goog/directory' + ); + private $ac; + private $config; + private $ext; + + public function __construct($config, $ext = null) + { + $this->config = $config; + if (empty($config['mode'])) $config['mode'] = 'live'; + $this->ac = new ACMECert($this->directories[$config['mode']], $config['proxy']==1); + if ($ext) { + $this->ext = $ext; + $this->ac->loadAccountKey($ext['key']); + $this->ac->setAccount($ext['kid']); + } + } + + public function register() + { + if (empty($this->config['email'])) throw new Exception('邮件地址不能为空'); + if (empty($this->config['kid']) || empty($this->config['key'])) throw new Exception('必填参数不能为空'); + + if (!empty($this->ext['key'])) { + $kid = $this->ac->registerEAB(true, $this->config['kid'], $this->config['key'], $this->config['email']); + return ['kid' => $kid, 'key' => $this->ext['key']]; + } + + $key = $this->ac->generateRSAKey(2048); + $this->ac->loadAccountKey($key); + $kid = $this->ac->registerEAB(true, $this->config['kid'], $this->config['key'], $this->config['email']); + return ['kid' => $kid, 'key' => $key]; + } + + public function buyCert($domainList, &$order) + { + } + + public function createOrder($domainList, &$order, $keytype, $keysize) + { + $domain_config = []; + foreach ($domainList as $domain) { + if (empty($domain)) continue; + $domain_config[$domain] = ['challenge' => 'dns-01']; + } + if (empty($domain_config)) throw new Exception('域名列表不能为空'); + + $order = $this->ac->createOrder($domain_config); + + $dnsList = []; + if (!empty($order['challenges'])) { + foreach ($order['challenges'] as $opts) { + $mainDomain = getMainDomain($opts['domain']); + $name = str_replace('.' . $mainDomain, '', $opts['key']); + /*if (!array_key_exists($mainDomain, $dnsList)) { + $dnsList[$mainDomain][] = ['name' => '@', 'type' => 'CAA', 'value' => '0 issue "pki.goog"']; + }*/ + $dnsList[$mainDomain][] = ['name' => $name, 'type' => 'TXT', 'value' => $opts['value']]; + } + } + + return $dnsList; + } + + public function authOrder($domainList, $order) + { + $this->ac->authOrder($order); + } + + public function getAuthStatus($domainList, $order) + { + return true; + } + + public function finalizeOrder($domainList, $order, $keytype, $keysize) + { + if (empty($domainList)) throw new Exception('域名列表不能为空'); + + if ($keytype == 'ECC') { + if (empty($keysize)) $keysize = '384'; + $private_key = $this->ac->generateECKey($keysize); + } else { + if (empty($keysize)) $keysize = '2048'; + $private_key = $this->ac->generateRSAKey($keysize); + } + $fullchain = $this->ac->finalizeOrder($domainList, $order, $private_key); + + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + return ['private_key' => $private_key, 'fullchain' => $fullchain, 'issuer' => $certInfo['issuer']['CN'], 'subject' => $certInfo['subject']['CN'], 'validFrom' => $certInfo['validFrom_time_t'], 'validTo' => $certInfo['validTo_time_t']]; + } + + public function revoke($order, $pem) + { + $this->ac->revoke($pem); + } + + public function cancel($order) + { + } + + public function setLogger($func) + { + $this->ac->setLogger($func); + } +} diff --git a/app/lib/cert/huoshan.php b/app/lib/cert/huoshan.php new file mode 100644 index 0000000..b391a10 --- /dev/null +++ b/app/lib/cert/huoshan.php @@ -0,0 +1,163 @@ +AccessKeyId = $config['AccessKeyId']; + $this->SecretAccessKey = $config['SecretAccessKey']; + $this->client = new Volcengine($this->AccessKeyId, $this->SecretAccessKey, $this->endpoint, $this->service, $this->version, $this->region); + } + + public function register() + { + if (empty($this->AccessKeyId) || empty($this->SecretAccessKey)) throw new Exception('必填参数不能为空'); + $this->request('GET', 'CertificateGetInstance', ['limit'=>1,'offset'=>0]); + return true; + } + + public function buyCert($domainList, &$order) + { + $data = $this->request('GET', 'CertificateGetOrganization'); + if(empty($data['content'])) throw new Exception('请先添加信息模板'); + $order['organization_id'] = $data['content'][0]['id']; + } + + public function createOrder($domainList, &$order, $keytype, $keysize) + { + if (empty($domainList)) throw new Exception('域名列表不能为空'); + $domain = $domainList[0]; + $param = [ + 'plan' => 'digicert_free_standard_dv', + 'common_name' => $domain, + 'organization_id' => $order['organization_id'], + 'key_alg' => strtolower($keytype), + 'validation_type' => 'dns_txt', + ]; + $instance_id = $this->request('POST', 'QuickApplyCertificate', $param); + if(empty($instance_id)) throw new Exception('证书申请失败,证书实例ID为空'); + $order['instance_id'] = $instance_id; + + sleep(3); + + $param = [ + 'instance_id' => $instance_id, + ]; + $data = $this->request('GET', 'CertificateGetDcvParam', $param); + + $dnsList = []; + if (!empty($data['domains_to_be_validated'])) { + $type = $data['validation_type'] == 'dns_cname' ? 'CNAME' : 'TXT'; + foreach ($data['domains_to_be_validated'] as $opts) { + $mainDomain = getMainDomain($domain); + $name = str_replace('.' . $mainDomain, '', $opts['validation_domain']); + $dnsList[$mainDomain][] = ['name' => $name, 'type' => $type, 'value' => $opts['value']]; + } + } + return $dnsList; + } + + public function authOrder($domainList, $order) + { + $query = [ + 'instance_id' => $order['instance_id'], + ]; + $param = [ + 'action' => '', + ]; + $this->request('POST', 'CertificateProgressInstanceOrder', $param, $query); + } + + public function getAuthStatus($domainList, $order) + { + $param = [ + 'instance_id' => $order['instance_id'], + ]; + $data = $this->request('GET', 'CertificateGetInstance', $param); + if(empty($data['content'])) throw new Exception('证书信息获取失败'); + $data = $data['content'][0]; + if($data['order_status'] == 300 && $data['certificate_exist'] == 1){ + return true; + }elseif($data['order_status'] == 302){ + throw new Exception('证书申请失败'); + }else{ + return false; + } + } + + public function finalizeOrder($domainList, $order, $keytype, $keysize) + { + $param = [ + 'instance_id' => $order['instance_id'], + ]; + $data = $this->request('GET', 'CertificateGetInstance', $param); + if (empty($data['content'])) throw new Exception('证书信息获取失败'); + $data = $data['content'][0]; + if (!isset($data['ssl']['certificate']['chain'])) throw new Exception('证书内容获取失败'); + + $fullchain = implode('', $data['ssl']['certificate']['chain']); + $private_key = $data['ssl']['certificate']['private_key']; + + return ['private_key' => $private_key, 'fullchain' => $fullchain, 'issuer' => $data['issuer'], 'subject' => $data['common_name']['CN'], 'validFrom' => intval($data['certificate_not_before_ms']/1000), 'validTo' => intval($data['certificate_not_after_ms']/1000)]; + } + + public function revoke($order, $pem) + { + $query = [ + 'instance_id' => $order['instance_id'], + ]; + $param = [ + 'action' => 'revoke', + 'reason' => '关联域名错误', + ]; + $this->request('POST', 'CertificateProgressInstanceOrder', $param, $query); + } + + public function cancel($order) + { + $query = [ + 'instance_id' => $order['instance_id'], + ]; + $param = [ + 'action' => 'cancel', + ]; + $this->request('POST', 'CertificateProgressInstanceOrder', $param, $query); + } + + public function setLogger($func) + { + $this->logger = $func; + } + + private function log($txt) + { + if ($this->logger) { + call_user_func($this->logger, $txt); + } + } + + private function request($method, $action, $params = [], $query = []) + { + $this->log('Action:'.$action.PHP_EOL.'Request:'.json_encode($params, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + $result = $this->client->request($method, $action, $params, $query); + if (is_array($result)) { + $this->log('Response:'.json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + return $result; + } +} diff --git a/app/lib/cert/letsencrypt.php b/app/lib/cert/letsencrypt.php new file mode 100644 index 0000000..66ba5cd --- /dev/null +++ b/app/lib/cert/letsencrypt.php @@ -0,0 +1,113 @@ + 'https://acme-v02.api.letsencrypt.org/directory', + 'staging' => 'https://acme-staging-v02.api.letsencrypt.org/directory' + ); + private $ac; + private $config; + private $ext; + + public function __construct($config, $ext = null) + { + $this->config = $config; + if (empty($config['mode'])) $config['mode'] = 'live'; + $this->ac = new ACMECert($this->directories[$config['mode']], $config['proxy'] == 1); + if ($ext) { + $this->ext = $ext; + $this->ac->loadAccountKey($ext['key']); + $this->ac->setAccount($ext['kid']); + } + } + + public function register() + { + if (empty($this->config['email'])) throw new Exception('邮件地址不能为空'); + + if (!empty($this->ext['key'])) { + $kid = $this->ac->register(true, $this->config['email']); + return ['kid' => $kid, 'key' => $this->ext['key']]; + } + + $key = $this->ac->generateRSAKey(2048); + $this->ac->loadAccountKey($key); + $kid = $this->ac->register(true, $this->config['email']); + return ['kid' => $kid, 'key' => $key]; + } + + public function buyCert($domainList, &$order) {} + + public function createOrder($domainList, &$order, $keytype, $keysize) + { + $domain_config = []; + foreach ($domainList as $domain) { + if (empty($domain)) continue; + $domain_config[$domain] = ['challenge' => 'dns-01']; + } + if (empty($domain_config)) throw new Exception('域名列表不能为空'); + + $order = $this->ac->createOrder($domain_config); + + $dnsList = []; + if (!empty($order['challenges'])) { + foreach ($order['challenges'] as $opts) { + $mainDomain = getMainDomain($opts['domain']); + $name = str_replace('.' . $mainDomain, '', $opts['key']); + /*if (!array_key_exists($mainDomain, $dnsList)) { + $dnsList[$mainDomain][] = ['name' => '@', 'type' => 'CAA', 'value' => '0 issue "letsencrypt.org"']; + }*/ + $dnsList[$mainDomain][] = ['name' => $name, 'type' => 'TXT', 'value' => $opts['value']]; + } + } + + return $dnsList; + } + + public function authOrder($domainList, $order) + { + $this->ac->authOrder($order); + } + + public function getAuthStatus($domainList, $order) + { + return true; + } + + public function finalizeOrder($domainList, $order, $keytype, $keysize) + { + if (empty($domainList)) throw new Exception('域名列表不能为空'); + + if ($keytype == 'ECC') { + if (empty($keysize)) $keysize = '384'; + $private_key = $this->ac->generateECKey($keysize); + } else { + if (empty($keysize)) $keysize = '2048'; + $private_key = $this->ac->generateRSAKey($keysize); + } + $fullchain = $this->ac->finalizeOrder($domainList, $order, $private_key); + + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + return ['private_key' => $private_key, 'fullchain' => $fullchain, 'issuer' => $certInfo['issuer']['CN'], 'subject' => $certInfo['subject']['CN'], 'validFrom' => $certInfo['validFrom_time_t'], 'validTo' => $certInfo['validTo_time_t']]; + } + + public function revoke($order, $pem) + { + $this->ac->revoke($pem); + } + + public function cancel($order) {} + + public function setLogger($func) + { + $this->ac->setLogger($func); + } +} diff --git a/app/lib/cert/tencent.php b/app/lib/cert/tencent.php new file mode 100644 index 0000000..fc339d9 --- /dev/null +++ b/app/lib/cert/tencent.php @@ -0,0 +1,189 @@ +SecretId = $config['SecretId']; + $this->SecretKey = $config['SecretKey']; + $this->client = new TencentCloud($this->SecretId, $this->SecretKey, $this->endpoint, $this->service, $this->version); + $this->email = $config['email']; + } + + public function register() + { + if (empty($this->SecretId) || empty($this->SecretKey) || empty($this->email)) throw new Exception('必填参数不能为空'); + $this->request('DescribeCertificates', []); + return true; + } + + public function buyCert($domainList, &$order) {} + + public function createOrder($domainList, &$order, $keytype, $keysize) + { + if (empty($domainList)) throw new Exception('域名列表不能为空'); + $domain = $domainList[0]; + $param = [ + 'DvAuthMethod' => 'DNS', + 'DomainName' => $domain, + 'ContactEmail' => $this->email, + 'CsrEncryptAlgo' => $keytype, + 'CsrKeyParameter' => $keytype == 'ECC' ? 'prime256v1' : '2048', + ]; + $data = $this->request('ApplyCertificate', $param); + if (empty($data['CertificateId'])) throw new Exception('证书申请失败,CertificateId为空'); + $order['CertificateId'] = $data['CertificateId']; + + $param = [ + 'CertificateId' => $order['CertificateId'], + ]; + $data = $this->request('DescribeCertificate', $param); + $order['OrderId'] = $data['OrderId']; + + $dnsList = []; + if (!empty($data['DvAuthDetail']['DvAuths'])) { + foreach ($data['DvAuthDetail']['DvAuths'] as $opts) { + $mainDomain = $opts['DvAuthDomain']; + $dnsList[$mainDomain][] = ['name' => $opts['DvAuthSubDomain'], 'type' => $opts['DvAuthVerifyType'] ?? 'CNAME', 'value' => $opts['DvAuthValue']]; + } + } + + return $dnsList; + } + + public function authOrder($domainList, $order) + { + $param = [ + 'CertificateId' => $order['CertificateId'], + ]; + $data = $this->request('DescribeCertificate', $param); + if ($data['Status'] == 0 || $data['Status'] == 4) { + $this->request('CompleteCertificate', $param); + sleep(3); + } + } + + public function getAuthStatus($domainList, $order) + { + $param = [ + 'CertificateId' => $order['CertificateId'], + ]; + $data = $this->request('DescribeCertificate', $param); + if ($data['Status'] == 1) { + return true; + } elseif ($data['Status'] == 2) { + throw new Exception('证书审核失败' . (empty($data['StatusMsg'] ? '' : ':' . $data['StatusMsg']))); + } else { + return false; + } + } + + public function finalizeOrder($domainList, $order, $keytype, $keysize) + { + if (!is_dir(app()->getRuntimePath() . 'cert')) mkdir(app()->getRuntimePath() . 'cert'); + $param = [ + 'CertificateId' => $order['CertificateId'], + 'ServiceType' => 'nginx', + ]; + $data = $this->request('DescribeDownloadCertificateUrl', $param); + $file_data = get_curl($data['DownloadCertificateUrl']); + $file_path = app()->getRuntimePath() . 'cert/' . $data['DownloadFilename']; + $file_name = substr($data['DownloadFilename'], 0, -4); + file_put_contents($file_path, $file_data); + + $zip = new \ZipArchive; + if ($zip->open($file_path) === true) { + $zip->extractTo(app()->getRuntimePath() . 'cert/'); + $zip->close(); + } else { + throw new Exception('解压证书失败'); + } + $cert_dir = app()->getRuntimePath() . 'cert/' . $file_name; + + $items = scandir($cert_dir); + if ($items === false) throw new Exception('解压后的证书文件夹不存在'); + $private_key = null; + $fullchain = null; + foreach ($items as $item) { + if (substr($item, -4) == '.key') { + $private_key = file_get_contents($cert_dir . '/' . $item); + } elseif (substr($item, -4) == '.crt') { + $fullchain = file_get_contents($cert_dir . '/' . $item); + } + } + if (empty($private_key) || empty($fullchain)) throw new Exception('解压后的证书文件夹内未找到证书文件'); + + clearDirectory($cert_dir); + rmdir($cert_dir); + unlink($file_path); + + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + return ['private_key' => $private_key, 'fullchain' => $fullchain, 'issuer' => $certInfo['issuer']['CN'], 'subject' => $certInfo['subject']['CN'], 'validFrom' => $certInfo['validFrom_time_t'], 'validTo' => $certInfo['validTo_time_t']]; + } + + public function revoke($order, $pem) + { + $param = [ + 'CertificateId' => $order['CertificateId'], + ]; + $action = 'RevokeCertificate'; + $data = $this->request($action, $param); + + if (!empty($data['RevokeDomainValidateAuths'])) { + $dnsList = []; + foreach ($data['RevokeDomainValidateAuths'] as $opts) { + $mainDomain = getMainDomain($opts['DomainValidateAuthDomain']); + $name = str_replace('.' . $mainDomain, '', $opts['DomainValidateAuthKey']); + $dnsList[$mainDomain][] = ['name' => $name, 'type' => 'CNAME', 'value' => $opts['DomainValidateAuthValue']]; + } + \app\utils\CertDnsUtils::addDns($dnsList, function ($txt) { + $this->log($txt); + }); + } + } + + public function cancel($order) + { + $param = [ + 'CertificateId' => $order['CertificateId'], + ]; + $action = 'CancelAuditCertificate'; + $this->request($action, $param); + } + + public function setLogger($func) + { + $this->logger = $func; + } + + private function log($txt) + { + if ($this->logger) { + call_user_func($this->logger, $txt); + } + } + + private function request($action, $param) + { + $this->log('Action:' . $action . PHP_EOL . 'Request:' . json_encode($param, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + $result = $this->client->request($action, $param); + $this->log('Response:' . json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + return $result; + } +} diff --git a/app/lib/cert/ucloud.php b/app/lib/cert/ucloud.php new file mode 100644 index 0000000..151da3e --- /dev/null +++ b/app/lib/cert/ucloud.php @@ -0,0 +1,187 @@ +PublicKey = $config['PublicKey']; + $this->PrivateKey = $config['PrivateKey']; + $this->client = new UcloudClient($this->PublicKey, $this->PrivateKey); + $this->config = $config; + } + + public function register() + { + if (empty($this->PublicKey) || empty($this->PrivateKey) || empty($this->config['username']) || empty($this->config['phone']) || empty($this->config['email'])) throw new Exception('必填参数不能为空'); + $param = ['Mode' => 'free']; + $this->request('GetCertificateList', $param); + return true; + } + + public function buyCert($domainList, &$order) + { + $param = [ + 'CertificateBrand' => 'TrustAsia', + 'CertificateName' => 'TrustAsiaC1DVFree', + 'DomainsCount' => 1, + 'ValidYear' => 1, + ]; + $data = $this->request('PurchaseCertificate', $param); + if (!isset($data['CertificateID'])) throw new Exception('证书购买失败,CertificateID为空'); + $order['CertificateID'] = $data['CertificateID']; + } + + public function createOrder($domainList, &$order, $keytype, $keysize) + { + if (empty($domainList)) throw new Exception('域名列表不能为空'); + $domain = $domainList[0]; + $param = [ + 'CertificateID' => $order['CertificateID'], + 'Domains' => $domain, + 'CSROnline' => 1, + 'CSREncryptAlgo' => ['RSA' => 'RSA', 'ECC' => 'ECDSA'][$keytype], + 'CSRKeyParameter' => ['2048' => '2048', '3072' => '3072', '256' => 'prime256v1', '384' => 'prime384v1'][$keysize], + 'CompanyName' => '公司名称', + 'CompanyAddress' => '公司地址', + 'CompanyRegion' => '北京', + 'CompanyCity' => '北京', + 'CompanyCountry' => 'CN', + 'CompanyDivision' => '部门', + 'CompanyPhone' => $this->config['phone'], + 'CompanyPostalCode' => '110100', + 'AdminName' => $this->config['username'], + 'AdminPhone' => $this->config['phone'], + 'AdminEmail' => $this->config['email'], + 'AdminTitle' => '职员', + 'DVAuthMethod' => 'DNS' + ]; + $data = $this->request('ComplementCSRInfo', $param); + + sleep(3); + + $param = [ + 'CertificateID' => $order['CertificateID'], + ]; + $data = $this->request('GetDVAuthInfo', $param); + + $dnsList = []; + if (!empty($data['Auths'])) { + foreach ($data['Auths'] as $auth) { + $mainDomain = getMainDomain($auth['Domain']); + $dnsList[$mainDomain][] = ['name' => $auth['AuthRecord'], 'type' => $auth['AuthType'] == 'DNS_TXT' ? 'TXT' : 'CNAME', 'value' => $auth['AuthValue']]; + } + } + return $dnsList; + } + + public function authOrder($domainList, $order) {} + + public function getAuthStatus($domainList, $order) + { + $param = [ + 'CertificateID' => $order['CertificateID'], + ]; + $data = $this->request('GetCertificateDetailInfo', $param); + if ($data['CertificateInfo']['StateCode'] == 'COMPLETED' || $data['CertificateInfo']['StateCode'] == 'RENEWED') { + return true; + } elseif ($data['CertificateInfo']['StateCode'] == 'REJECTED' || $data['CertificateInfo']['StateCode'] == 'SECURITY_REVIEW_FAILED') { + throw new Exception('证书审核失败:' . $data['CertificateInfo']['State']); + } else { + return false; + } + } + + public function finalizeOrder($domainList, $order, $keytype, $keysize) + { + if (!is_dir(app()->getRuntimePath() . 'cert')) mkdir(app()->getRuntimePath() . 'cert'); + $param = [ + 'CertificateID' => $order['CertificateID'], + ]; + $info = $this->request('GetCertificateDetailInfo', $param); + + $data = $this->request('DownloadCertificate', $param); + $file_data = get_curl($data['CertificateUrl']); + $file_path = app()->getRuntimePath() . 'cert/USSL_' . $order['CertificateID'] . '.zip'; + file_put_contents($file_path, $file_data); + + $zip = new \ZipArchive; + if ($zip->open($file_path) === true) { + $zip->extractTo(app()->getRuntimePath() . 'cert/'); + $zip->close(); + } else { + throw new Exception('解压证书失败'); + } + $cert_dir = app()->getRuntimePath() . 'cert/Nginx'; + + $items = scandir($cert_dir); + if ($items === false) throw new Exception('解压后的证书文件夹不存在'); + $private_key = null; + $fullchain = null; + foreach ($items as $item) { + if (substr($item, -4) == '.key') { + $private_key = file_get_contents($cert_dir . '/' . $item); + } elseif (substr($item, -4) == '.pem') { + $fullchain = file_get_contents($cert_dir . '/' . $item); + } + } + if (empty($private_key) || empty($fullchain)) throw new Exception('解压后的证书文件夹内未找到证书文件'); + + clearDirectory(app()->getRuntimePath() . 'cert'); + + return ['private_key' => $private_key, 'fullchain' => $fullchain, 'issuer' => $info['CertificateInfo']['CaOrganization'], 'subject' => $info['CertificateInfo']['Name'], 'validFrom' => $info['CertificateInfo']['IssuedDate'], 'validTo' => $info['CertificateInfo']['ExpiredDate']]; + } + + public function revoke($order, $pem) + { + $param = [ + 'CertificateID' => $order['CertificateID'], + 'Reason' => '业务终止', + ]; + $this->request('RevokeCertificate', $param); + } + + public function cancel($order) + { + $param = [ + 'CertificateID' => $order['CertificateID'], + ]; + $this->request('CancelCertificateOrder', $param); + + sleep(1); + + $param['CertificateMode'] = 'purchase'; + $this->request('DeleteSSLCertificate', $param); + } + + public function setLogger($func) + { + $this->logger = $func; + } + + private function log($txt) + { + if ($this->logger) { + call_user_func($this->logger, $txt); + } + } + + private function request($action, $params) + { + $this->log('Action:' . $action . PHP_EOL . 'Request:' . json_encode($params, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + $result = $this->client->request($action, $params); + $this->log('Response:' . json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + return $result; + } +} diff --git a/app/lib/cert/zerossl.php b/app/lib/cert/zerossl.php new file mode 100644 index 0000000..9b1ff17 --- /dev/null +++ b/app/lib/cert/zerossl.php @@ -0,0 +1,110 @@ +config = $config; + $this->ac = new ACMECert($this->directory, $config['proxy'] == 1); + if ($ext) { + $this->ext = $ext; + $this->ac->loadAccountKey($ext['key']); + $this->ac->setAccount($ext['kid']); + } + } + + public function register() + { + if (empty($this->config['email'])) throw new Exception('邮件地址不能为空'); + if (empty($this->config['kid']) || empty($this->config['key'])) throw new Exception('必填参数不能为空'); + + if (!empty($this->ext['key'])) { + $kid = $this->ac->registerEAB(true, $this->config['kid'], $this->config['key'], $this->config['email']); + return ['kid' => $kid, 'key' => $this->ext['key']]; + } + + $key = $this->ac->generateRSAKey(2048); + $this->ac->loadAccountKey($key); + $kid = $this->ac->registerEAB(true, $this->config['kid'], $this->config['key'], $this->config['email']); + return ['kid' => $kid, 'key' => $key]; + } + + public function buyCert($domainList, &$order) {} + + public function createOrder($domainList, &$order, $keytype, $keysize) + { + $domain_config = []; + foreach ($domainList as $domain) { + if (empty($domain)) continue; + $domain_config[$domain] = ['challenge' => 'dns-01']; + } + if (empty($domain_config)) throw new Exception('域名列表不能为空'); + + $order = $this->ac->createOrder($domain_config); + + $dnsList = []; + if (!empty($order['challenges'])) { + foreach ($order['challenges'] as $opts) { + $mainDomain = getMainDomain($opts['domain']); + $name = str_replace('.' . $mainDomain, '', $opts['key']); + /*if (!array_key_exists($mainDomain, $dnsList)) { + $dnsList[$mainDomain][] = ['name' => '@', 'type' => 'CAA', 'value' => '0 issue "sectigo.com"']; + }*/ + $dnsList[$mainDomain][] = ['name' => $name, 'type' => 'TXT', 'value' => $opts['value']]; + } + } + + return $dnsList; + } + + public function authOrder($domainList, $order) + { + $this->ac->authOrder($order); + } + + public function getAuthStatus($domainList, $order) + { + return true; + } + + public function finalizeOrder($domainList, $order, $keytype, $keysize) + { + if (empty($domainList)) throw new Exception('域名列表不能为空'); + + if ($keytype == 'ECC') { + if (empty($keysize)) $keysize = '384'; + $private_key = $this->ac->generateECKey($keysize); + } else { + if (empty($keysize)) $keysize = '2048'; + $private_key = $this->ac->generateRSAKey($keysize); + } + $fullchain = $this->ac->finalizeOrder($domainList, $order, $private_key); + + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + return ['private_key' => $private_key, 'fullchain' => $fullchain, 'issuer' => $certInfo['issuer']['CN'], 'subject' => $certInfo['subject']['CN'], 'validFrom' => $certInfo['validFrom_time_t'], 'validTo' => $certInfo['validTo_time_t']]; + } + + public function revoke($order, $pem) + { + $this->ac->revoke($pem); + } + + public function cancel($order) {} + + public function setLogger($func) + { + $this->ac->setLogger($func); + } +} diff --git a/app/lib/client/AWS.php b/app/lib/client/AWS.php new file mode 100644 index 0000000..4c7d5cf --- /dev/null +++ b/app/lib/client/AWS.php @@ -0,0 +1,330 @@ +AccessKeyId = $AccessKeyId; + $this->SecretAccessKey = $SecretAccessKey; + $this->endpoint = $endpoint; + $this->service = $service; + $this->version = $version; + $this->region = $region; + } + + /** + * @param string $method 请求方法 + * @param string $action 方法名称 + * @param array $params 请求参数 + * @return array + * @throws Exception + */ + public function request($method, $action, $params = []) + { + if (!empty($params)) { + $params = array_filter($params, function ($a) { + return $a !== null; + }); + } + + $body = ''; + $query = []; + if ($method == 'GET' || $method == 'DELETE') { + $query = $params; + } else { + $body = !empty($params) ? json_encode($params) : ''; + } + + $time = time(); + $date = gmdate("Ymd\THis\Z", $time); + $headers = [ + 'Host' => $this->endpoint, + 'X-Amz-Target' => $action, + 'X-Amz-Date' => $date, + //'X-Amz-Content-Sha256' => hash("sha256", $body), + ]; + if ($body) { + $headers['Content-Type'] = 'application/x-amz-json-1.1'; + } + $path = '/'; + + $authorization = $this->generateSign($method, $path, $query, $headers, $body, $date); + $headers['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); + } + + /** + * @param string $method 请求方法 + * @param string $action 方法名称 + * @param array $params 请求参数 + * @return array + * @throws Exception + */ + public function requestXml($method, $action, $params = []) + { + if (!empty($params)) { + $params = array_filter($params, function ($a) { + return $a !== null; + }); + } + + $body = ''; + $query = [ + 'Action' => $action, + 'Version' => $this->version, + ]; + if ($method == 'GET' || $method == 'DELETE') { + $query = array_merge($query, $params); + } else { + $body = !empty($params) ? http_build_query($params) : ''; + } + + $time = time(); + $date = gmdate("Ymd\THis\Z", $time); + $headers = [ + 'Host' => $this->endpoint, + 'X-Amz-Date' => $date, + ]; + + $path = '/'; + $authorization = $this->generateSign($method, $path, $query, $headers, $body, $date); + $headers['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, true); + } + + /** + * @param string $method 请求方法 + * @param string $path 请求路径 + * @param array $params 请求参数 + * @param \SimpleXMLElement $xml 请求XML + * @return array + * @throws Exception + */ + public function requestXmlN($method, $path, $params = [], $xml = null, $etag = false) + { + if (!empty($params)) { + $params = array_filter($params, function ($a) { + return $a !== null; + }); + } + + $path = '/' . $this->version . $path; + if ($method == 'GET' || $method == 'DELETE') { + $query = $params; + } else { + $body = !empty($params) ? $this->array2xml($params, $xml) : ''; + } + + $time = time(); + $date = gmdate("Ymd\THis\Z", $time); + $headers = [ + 'Host' => $this->endpoint, + 'X-Amz-Date' => $date, + //'X-Amz-Content-Sha256' => hash("sha256", $body), + ]; + if ($this->etag) { + $headers['If-Match'] = $this->etag; + } + + $authorization = $this->generateSign($method, $path, $query, $headers, $body, $date); + $headers['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, true, $etag); + } + + private function generateSign($method, $path, $query, $headers, $body, $date) + { + $algorithm = "AWS4-HMAC-SHA256"; + + // step 1: build canonical request string + $httpRequestMethod = $method; + $canonicalUri = $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 + $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 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, $xml = false, $etag = false) + { + $ch = curl_init($url); + 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); + } + if ($etag) { + curl_setopt($ch, CURLOPT_HEADER, true); + } + $response = curl_exec($ch); + $errno = curl_errno($ch); + if ($errno) { + curl_close($ch); + throw new Exception('Curl error: ' . curl_error($ch)); + } + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($etag) { + if (preg_match('/ETag: ([^\r\n]+)/', $response, $matches)) { + $this->etag = trim($matches[1]); + } + $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $response = substr($response, $headerSize); + } + curl_close($ch); + + if ($httpCode >= 200 && $httpCode < 300) { + if (empty($response)) return true; + return $xml ? $this->xml2array($response) : json_decode($response, true); + } + if ($xml) { + $arr = $this->xml2array($response); + if (isset($arr['Error']['Message'])) { + throw new Exception($arr['Error']['Message']); + } else { + throw new Exception('HTTP Code: ' . $httpCode); + } + } else { + $arr = json_decode($response, true); + if (isset($arr['message'])) { + throw new Exception($arr['message']); + } else { + throw new Exception('HTTP Code: ' . $httpCode); + } + } + } + + private function xml2array($xml) + { + if (!$xml) { + return false; + } + LIBXML_VERSION < 20900 && libxml_disable_entity_loader(true); + return json_decode(json_encode(simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA), JSON_UNESCAPED_UNICODE), true); + } + + private function array2xml($array, $xml = null) + { + if ($xml === null) { + $xml = new \SimpleXMLElement(''); + } + + foreach ($array as $key => $value) { + if (is_array($value)) { + $subNode = $xml->addChild($key); + $this->array2xml($value, $subNode); + } else { + $xml->addChild($key, $value); + } + } + + return $xml->asXML(); + } +} diff --git a/app/lib/client/Aliyun.php b/app/lib/client/Aliyun.php new file mode 100644 index 0000000..f617e23 --- /dev/null +++ b/app/lib/client/Aliyun.php @@ -0,0 +1,95 @@ +AccessKeyId = $AccessKeyId; + $this->AccessKeySecret = $AccessKeySecret; + $this->Endpoint = $Endpoint; + $this->Version = $Version; + } + + /** + * @param array $param 请求参数 + * @return bool|array + * @throws Exception + */ + public function request($param, $method = 'POST') + { + $url = 'https://' . $this->Endpoint . '/'; + $data = array( + 'Format' => 'JSON', + 'Version' => $this->Version, + 'AccessKeyId' => $this->AccessKeyId, + 'SignatureMethod' => 'HMAC-SHA1', + 'Timestamp' => gmdate('Y-m-d\TH:i:s\Z'), + 'SignatureVersion' => '1.0', + 'SignatureNonce' => random(8) + ); + $data = array_merge($data, $param); + $data['Signature'] = $this->aliyunSignature($data, $this->AccessKeySecret, $method); + if ($method == 'GET') { + $url .= '?' . http_build_query($data); + } + $ch = curl_init($url); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + if ($method == 'POST') { + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); + } + $response = curl_exec($ch); + $errno = curl_errno($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($errno) { + curl_close($ch); + throw new Exception('Curl error: ' . curl_error($ch)); + } + curl_close($ch); + + $arr = json_decode($response, true); + if ($httpCode == 200) { + return $arr; + } elseif ($arr) { + throw new Exception($arr['Message']); + } else { + throw new Exception('返回数据解析失败'); + } + } + + private function aliyunSignature($parameters, $accessKeySecret, $method) + { + ksort($parameters); + $canonicalizedQueryString = ''; + foreach ($parameters as $key => $value) { + if ($value === null) continue; + $canonicalizedQueryString .= '&' . $this->percentEncode($key) . '=' . $this->percentEncode($value); + } + $stringToSign = $method . '&%2F&' . $this->percentEncode(substr($canonicalizedQueryString, 1)); + $signature = base64_encode(hash_hmac("sha1", $stringToSign, $accessKeySecret . "&", true)); + + return $signature; + } + + private function percentEncode($str) + { + $search = ['+', '*', '%7E']; + $replace = ['%20', '%2A', '~']; + return str_replace($search, $replace, urlencode($str)); + } +} diff --git a/app/lib/client/AliyunNew.php b/app/lib/client/AliyunNew.php new file mode 100644 index 0000000..d37f358 --- /dev/null +++ b/app/lib/client/AliyunNew.php @@ -0,0 +1,169 @@ +AccessKeyId = $AccessKeyId; + $this->AccessKeySecret = $AccessKeySecret; + $this->Endpoint = $Endpoint; + $this->Version = $Version; + } + + /** + * @param string $method 请求方法 + * @param string $action 操作名称 + * @param array|null $params 请求参数 + * @return array + * @throws Exception + */ + public function request($method, $action, $path = '/', $params = null) + { + if (!empty($params)) { + $params = array_filter($params, function ($a) { return $a !== null;}); + } + + if($method == 'GET' || $method == 'DELETE'){ + $query = $params; + $body = ''; + }else{ + $query = []; + $body = !empty($params) ? json_encode($params) : ''; + } + $headers = [ + 'x-acs-action' => $action, + 'x-acs-version' => $this->Version, + 'x-acs-signature-nonce' => md5(uniqid(mt_rand(), true) . microtime()), + 'x-acs-date' => gmdate('Y-m-d\TH:i:s\Z'), + 'x-acs-content-sha256' => hash("sha256", $body), + 'Host' => $this->Endpoint, + ]; + if ($body) { + $headers['Content-Type'] = 'application/json; charset=utf-8'; + } + + $authorization = $this->generateSign($method, $path, $query, $headers, $body); + $headers['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($method, $path, $query, $headers, $body) + { + $algorithm = "ACS3-HMAC-SHA256"; + + // step 1: build canonical request string + $httpRequestMethod = $method; + $canonicalUri = $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 + $hashedCanonicalRequest = hash("sha256", $canonicalRequest); + $stringToSign = $algorithm."\n" + .$hashedCanonicalRequest; + + // step 3: sign string + $signature = hash_hmac("sha256", $stringToSign, $this->AccessKeySecret); + + // step 4: build authorization + $authorization = $algorithm . ' Credential=' . $this->AccessKeyId . ',SignedHeaders=' . $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); + 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)); + } + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $arr = json_decode($response, true); + if ($httpCode == 200) { + return $arr; + } elseif ($arr) { + if(strpos($arr['Message'], '.') > 0) $arr['Message'] = substr($arr['Message'], 0, strpos($arr['Message'], '.')+1); + throw new Exception($arr['Message']); + } else { + throw new Exception('返回数据解析失败'); + } + } +} \ No newline at end of file diff --git a/app/lib/client/AliyunOss.php b/app/lib/client/AliyunOss.php new file mode 100644 index 0000000..ab2f5b8 --- /dev/null +++ b/app/lib/client/AliyunOss.php @@ -0,0 +1,261 @@ +AccessKeyId = $AccessKeyId; + $this->AccessKeySecret = $AccessKeySecret; + $this->Endpoint = $Endpoint; + } + + public function addBucketCnameCert($bucket, $domain, $cert_id) + { + $strXml = << + + + EOF; + $xml = new \SimpleXMLElement($strXml); + $node = $xml->addChild('Cname'); + $node->addChild('Domain', $domain); + $certNode = $node->addChild('CertificateConfiguration'); + $certNode->addChild('CertId', $cert_id); + $certNode->addChild('Force', 'true'); + $body = $xml->asXML(); + + $options = [ + 'bucket' => $bucket, + 'key' => '', + ]; + $query = [ + 'cname' => '', + 'comp' => 'add' + ]; + return $this->request('POST', '/', $query, $body, $options); + } + + public function deleteBucketCnameCert($bucket, $domain) + { + $strXml = << + + + EOF; + $xml = new \SimpleXMLElement($strXml); + $node = $xml->addChild('Cname'); + $node->addChild('Domain', $domain); + $certNode = $node->addChild('CertificateConfiguration'); + $certNode->addChild('DeleteCertificate', 'true'); + $body = $xml->asXML(); + + $options = [ + 'bucket' => $bucket, + 'key' => '', + ]; + $query = [ + 'cname' => '', + 'comp' => 'add' + ]; + return $this->request('POST', '/', $query, $body, $options); + } + + public function getBucketCname($bucket) + { + $options = [ + 'bucket' => $bucket, + 'key' => '', + ]; + $query = [ + 'cname' => '', + ]; + return $this->request('GET', '/', $query, null, $options); + } + + private function request($method, $path, $query, $body, $options) + { + $hostname = $options['bucket'] . '.' . $this->Endpoint; + $query_string = $this->toQueryString($query); + $query_string = empty($query_string) ? '' : '?' . $query_string; + $requestUrl = 'https://' . $hostname . $path . $query_string; + $headers = [ + 'Content-Type' => 'application/xml', + 'Date' => gmdate('D, d M Y H:i:s \G\M\T'), + ]; + $headers['Authorization'] = $this->getAuthorization($method, $path, $query, $headers, $options); + $header = []; + foreach ($headers as $key => $value) { + $header[] = $key . ': ' . $value; + } + return $this->curl($method, $requestUrl, $body, $header); + } + + private function curl($method, $url, $body, $header) + { + $ch = curl_init($url); + 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); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($errno) { + curl_close($ch); + throw new Exception('Curl error: ' . curl_error($ch)); + } + curl_close($ch); + + if ($httpCode >= 200 && $httpCode < 300) { + if (empty($response)) return true; + return $this->xml2array($response); + } + $arr = $this->xml2array($response); + if (isset($arr['Message'])) { + throw new Exception($arr['Message']); + } else { + throw new Exception('HTTP Code: ' . $httpCode); + } + } + + private function toQueryString($params = array()) + { + $temp = array(); + uksort($params, 'strnatcasecmp'); + foreach ($params as $key => $value) { + if (is_string($key) && !is_array($value)) { + if (strlen($value) > 0) { + $temp[] = rawurlencode($key) . '=' . rawurlencode($value); + } else { + $temp[] = rawurlencode($key); + } + } + } + return implode('&', $temp); + } + + private function xml2array($xml) + { + if (!$xml) { + return false; + } + LIBXML_VERSION < 20900 && libxml_disable_entity_loader(true); + return json_decode(json_encode(simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA), JSON_UNESCAPED_UNICODE), true); + } + + private function getAuthorization($method, $url, $query, $headers, $options) + { + $method = strtoupper($method); + $date = $headers['Date']; + $resourcePath = $this->getResourcePath($options); + $stringToSign = $this->calcStringToSign($method, $date, $headers, $resourcePath, $query); + $signature = base64_encode(hash_hmac('sha1', $stringToSign, $this->AccessKeySecret, true)); + return 'OSS ' . $this->AccessKeyId . ':' . $signature; + } + + private function getResourcePath(array $options) + { + $resourcePath = '/'; + if (strlen($options['bucket']) > 0) { + $resourcePath .= $options['bucket'] . '/'; + } + if (strlen($options['key']) > 0) { + $resourcePath .= $options['key']; + } + return $resourcePath; + } + + private function calcStringToSign($method, $date, array $headers, $resourcePath, array $query) + { + /* + SignToString = + VERB + "\n" + + Content-MD5 + "\n" + + Content-Type + "\n" + + Date + "\n" + + CanonicalizedOSSHeaders + + CanonicalizedResource + Signature = base64(hmac-sha1(AccessKeySecret, SignToString)) + */ + $contentMd5 = ''; + $contentType = ''; + // CanonicalizedOSSHeaders + $signheaders = array(); + foreach ($headers as $key => $value) { + $lowk = strtolower($key); + if (strncmp($lowk, "x-oss-", 6) == 0) { + $signheaders[$lowk] = $value; + } else if ($lowk === 'content-md5') { + $contentMd5 = $value; + } else if ($lowk === 'content-type') { + $contentType = $value; + } + } + ksort($signheaders); + $canonicalizedOSSHeaders = ''; + foreach ($signheaders as $key => $value) { + $canonicalizedOSSHeaders .= $key . ':' . $value . "\n"; + } + // CanonicalizedResource + $signquery = array(); + foreach ($query as $key => $value) { + if (in_array($key, $this->signKeyList)) { + $signquery[$key] = $value; + } + } + ksort($signquery); + $sortedQueryList = array(); + foreach ($signquery as $key => $value) { + if (strlen($value) > 0) { + $sortedQueryList[] = $key . '=' . $value; + } else { + $sortedQueryList[] = $key; + } + } + $queryStringSorted = implode('&', $sortedQueryList); + $canonicalizedResource = $resourcePath; + if (!empty($queryStringSorted)) { + $canonicalizedResource .= '?' . $queryStringSorted; + } + return $method . "\n" . $contentMd5 . "\n" . $contentType . "\n" . $date . "\n" . $canonicalizedOSSHeaders . $canonicalizedResource; + } + + private $signKeyList = array( + "acl", "uploads", "location", "cors", + "logging", "website", "referer", "lifecycle", + "delete", "append", "tagging", "objectMeta", + "uploadId", "partNumber", "security-token", "x-oss-security-token", + "position", "img", "style", "styleName", + "replication", "replicationProgress", + "replicationLocation", "cname", "bucketInfo", + "comp", "qos", "live", "status", "vod", + "startTime", "endTime", "symlink", + "x-oss-process", "response-content-type", "x-oss-traffic-limit", + "response-content-language", "response-expires", + "response-cache-control", "response-content-disposition", + "response-content-encoding", "udf", "udfName", "udfImage", + "udfId", "udfImageDesc", "udfApplication", + "udfApplicationLog", "restore", "callback", "callback-var", "qosInfo", + "policy", "stat", "encryption", "versions", "versioning", "versionId", "requestPayment", + "x-oss-request-payer", "sequential", + "inventory", "inventoryId", "continuation-token", "asyncFetch", + "worm", "wormId", "wormExtend", "withHashContext", + "x-oss-enable-md5", "x-oss-enable-sha1", "x-oss-enable-sha256", + "x-oss-hash-ctx", "x-oss-md5-ctx", "transferAcceleration", + "regionList", "cloudboxes", "x-oss-ac-source-ip", "x-oss-ac-subnet-mask", "x-oss-ac-vpc-id", "x-oss-ac-forward-allow", + "metaQuery", "resourceGroup", "rtc", "x-oss-async-process", "responseHeader" + ); +} \ No newline at end of file diff --git a/app/lib/client/BaiduCloud.php b/app/lib/client/BaiduCloud.php new file mode 100644 index 0000000..551dcd8 --- /dev/null +++ b/app/lib/client/BaiduCloud.php @@ -0,0 +1,175 @@ +AccessKeyId = $AccessKeyId; + $this->SecretAccessKey = $SecretAccessKey; + $this->endpoint = $endpoint; + } + + /** + * @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 = gmdate("Y-m-d\TH:i:s\Z", $time); + $body = !empty($params) ? json_encode($params) : ''; + $headers = [ + 'Host' => $this->endpoint, + 'x-bce-date' => $date, + ]; + if ($body) { + $headers['Content-Type'] = 'application/json'; + } + + $authorization = $this->generateSign($method, $path, $query, $headers, $time); + $headers['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($method, $path, $query, $headers, $time) + { + $algorithm = "bce-auth-v1"; + + // step 1: build canonical request string + $httpRequestMethod = $method; + $canonicalUri = $this->getCanonicalUri($path); + $canonicalQueryString = $this->getCanonicalQueryString($query); + [$canonicalHeaders, $signedHeaders] = $this->getCanonicalHeaders($headers); + $canonicalRequest = $httpRequestMethod."\n" + .$canonicalUri."\n" + .$canonicalQueryString."\n" + .$canonicalHeaders; + + // step 2: calculate signing key + $date = gmdate("Y-m-d\TH:i:s\Z", $time); + $expirationInSeconds = 1800; + $authString = $algorithm . '/' . $this->AccessKeyId . '/' . $date . '/' . $expirationInSeconds; + $signingKey = hash_hmac('sha256', $authString, $this->SecretAccessKey); + + // step 3: sign string + $signature = hash_hmac("sha256", $canonicalRequest, $signingKey); + + // step 4: build authorization + $authorization = $authString . '/' . $signedHeaders . "/" . $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 '/'; + $uri = str_replace('%2F', '/', $this->escape($path)); + if (substr($uri, 0, 1) !== '/') $uri = '/' . $uri; + return $uri; + } + + private function getCanonicalQueryString($parameters) + { + if (empty($parameters)) return ''; + ksort($parameters); + $canonicalQueryString = ''; + foreach ($parameters as $key => $value) { + if ($key == 'authorization') continue; + $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 .= $this->escape($key) . ':' . $this->escape($value) . "\n"; + $signedHeaders .= $key . ';'; + } + $canonicalHeaders = substr($canonicalHeaders, 0, -1); + $signedHeaders = substr($signedHeaders, 0, -1); + return [$canonicalHeaders, $signedHeaders]; + } + + private function curl($method, $url, $body, $header) + { + $ch = curl_init($url); + 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); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($errno) { + curl_close($ch); + throw new Exception('Curl error: ' . curl_error($ch)); + } + curl_close($ch); + + if (empty($response) && $httpCode == 200) { + return true; + } + $arr = json_decode($response, true); + if ($arr) { + if (isset($arr['code']) && isset($arr['message'])) { + throw new Exception($arr['message']); + } else { + return $arr; + } + } else { + throw new Exception('返回数据解析失败'); + } + } +} \ No newline at end of file diff --git a/app/lib/client/HuaweiCloud.php b/app/lib/client/HuaweiCloud.php new file mode 100644 index 0000000..e5942b3 --- /dev/null +++ b/app/lib/client/HuaweiCloud.php @@ -0,0 +1,175 @@ +AccessKeyId = $AccessKeyId; + $this->SecretAccessKey = $SecretAccessKey; + $this->endpoint = $endpoint; + } + + /** + * @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 = gmdate("Ymd\THis\Z", $time); + $body = !empty($params) ? json_encode($params) : ''; + $headers = [ + 'Host' => $this->endpoint, + 'X-Sdk-Date' => $date, + ]; + if ($body) { + $headers['Content-Type'] = 'application/json'; + } + + $authorization = $this->generateSign($method, $path, $query, $headers, $body, $time); + $headers['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($method, $path, $query, $headers, $body, $time) + { + $algorithm = "SDK-HMAC-SHA256"; + + // step 1: build canonical request string + $httpRequestMethod = $method; + $canonicalUri = $path; + if (substr($canonicalUri, -1) != "/") $canonicalUri .= "/"; + $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); + $hashedCanonicalRequest = hash("sha256", $canonicalRequest); + $stringToSign = $algorithm . "\n" + . $date . "\n" + . $hashedCanonicalRequest; + + // step 3: sign string + $signature = hash_hmac("sha256", $stringToSign, $this->SecretAccessKey); + + // step 4: build authorization + $authorization = $algorithm . ' Access=' . $this->AccessKeyId . ", SignedHeaders=" . $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); + 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 ($arr) { + if (isset($arr['error_msg'])) { + throw new Exception($arr['error_msg']); + } elseif (isset($arr['message'])) { + throw new Exception($arr['message']); + } elseif (isset($arr['error']['error_msg'])) { + throw new Exception($arr['error']['error_msg']); + } else { + return $arr; + } + } else { + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($httpCode >= 200 && $httpCode < 300) { + return null; + } else { + throw new Exception('返回数据解析失败'); + } + } + } +} diff --git a/app/lib/client/Qiniu.php b/app/lib/client/Qiniu.php new file mode 100644 index 0000000..20feabf --- /dev/null +++ b/app/lib/client/Qiniu.php @@ -0,0 +1,104 @@ +AccessKey = $AccessKey; + $this->SecretKey = $SecretKey; + } + + /** + * @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) + { + $url = $this->ApiUrl . $path; + $query_str = null; + $body = null; + if (!empty($query)) { + $query = array_filter($query, function ($a) { + return $a !== null; + }); + $query_str = http_build_query($query); + $url .= '?' . $query_str; + } + if (!empty($params)) { + $params = array_filter($params, function ($a) { + return $a !== null; + }); + $body = json_encode($params); + } + + $sign_str = $path . ($query_str ? '?' . $query_str : '') . "\n"; + $hmac = hash_hmac('sha1', $sign_str, $this->SecretKey, true); + $sign = $this->AccessKey . ':' . $this->base64_urlSafeEncode($hmac); + + $header = [ + 'Authorization: QBox ' . $sign, + ]; + if ($body) { + $header[] = 'Content-Type: application/json'; + } + return $this->curl($method, $url, $body, $header); + } + + private function base64_urlSafeEncode($data) + { + $find = array('+', '/'); + $replace = array('-', '_'); + return str_replace($find, $replace, base64_encode($data)); + } + + private function curl($method, $url, $body, $header) + { + $ch = curl_init($url); + 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_USERAGENT, 'QiniuPHP/7.14.0 (' . php_uname("s") . '/' . php_uname("m") . ') PHP/' . phpversion()); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + if (!empty($body)) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + $response = curl_exec($ch); + $errno = curl_errno($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($errno) { + curl_close($ch); + throw new Exception('Curl error: ' . curl_error($ch)); + } + curl_close($ch); + + if ($httpCode == 200) { + $arr = json_decode($response, true); + if($arr) return $arr; + return true; + } else { + $arr = json_decode($response, true); + if ($arr && !empty($arr['error'])) { + throw new Exception($arr['error']); + } else { + throw new Exception('返回数据解析失败'); + } + } + } +} diff --git a/app/lib/client/TencentCloud.php b/app/lib/client/TencentCloud.php new file mode 100644 index 0000000..a167b30 --- /dev/null +++ b/app/lib/client/TencentCloud.php @@ -0,0 +1,127 @@ +SecretId = $SecretId; + $this->SecretKey = $SecretKey; + $this->endpoint = $endpoint; + $this->service = $service; + $this->version = $version; + $this->region = $region; + } + + /** + * @param string $action 方法名称 + * @param array $param 请求参数 + * @return array + * @throws Exception + */ + public function request($action, $param) + { + $param = array_filter($param, function ($a) { return $a !== null;}); + if (!$param) $param = (object)[]; + $payload = json_encode($param); + $time = time(); + $authorization = $this->generateSign($payload, $time); + $header = [ + 'Authorization: '.$authorization, + 'Content-Type: application/json; charset=utf-8', + 'X-TC-Action: '.$action, + 'X-TC-Timestamp: '.$time, + 'X-TC-Version: '.$this->version, + ]; + if($this->region) { + $header[] = 'X-TC-Region: '.$this->region; + } + $res = $this->curl_post($payload, $header); + return $res; + } + + private function generateSign($payload, $time) + { + $algorithm = "TC3-HMAC-SHA256"; + + // step 1: build canonical request string + $httpRequestMethod = "POST"; + $canonicalUri = "/"; + $canonicalQueryString = ""; + $canonicalHeaders = "content-type:application/json; charset=utf-8\n"."host:".$this->endpoint."\n"; + $signedHeaders = "content-type;host"; + $hashedRequestPayload = hash("SHA256", $payload); + $canonicalRequest = $httpRequestMethod."\n" + .$canonicalUri."\n" + .$canonicalQueryString."\n" + .$canonicalHeaders."\n" + .$signedHeaders."\n" + .$hashedRequestPayload; + + // step 2: build string to sign + $date = gmdate("Y-m-d", $time); + $credentialScope = $date."/".$this->service."/tc3_request"; + $hashedCanonicalRequest = hash("SHA256", $canonicalRequest); + $stringToSign = $algorithm."\n" + .$time."\n" + .$credentialScope."\n" + .$hashedCanonicalRequest; + + // step 3: sign string + $secretDate = hash_hmac("SHA256", $date, "TC3".$this->SecretKey, true); + $secretService = hash_hmac("SHA256", $this->service, $secretDate, true); + $secretSigning = hash_hmac("SHA256", "tc3_request", $secretService, true); + $signature = hash_hmac("SHA256", $stringToSign, $secretSigning); + + // step 4: build authorization + $authorization = $algorithm + ." Credential=".$this->SecretId."/".$credentialScope + .", SignedHeaders=content-type;host, Signature=".$signature; + + return $authorization; + } + + private function curl_post($payload, $header) + { + $url = 'https://'.$this->endpoint.'/'; + $ch = curl_init($url); + 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_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); + $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 ($arr) { + if (isset($arr['Response']['Error'])) { + throw new Exception($arr['Response']['Error']['Message']); + } else { + return $arr['Response']; + } + } else { + throw new Exception('返回数据解析失败'); + } + } +} \ No newline at end of file diff --git a/app/lib/client/Ucloud.php b/app/lib/client/Ucloud.php new file mode 100644 index 0000000..4d1a9d7 --- /dev/null +++ b/app/lib/client/Ucloud.php @@ -0,0 +1,51 @@ +PublicKey = $PublicKey; + $this->PrivateKey = $PrivateKey; + } + + public function request($action, $params) + { + $param = [ + 'Action' => $action, + 'PublicKey' => $this->PublicKey, + ]; + $param = array_merge($param, $params); + $param['Signature'] = $this->ucloudSignature($param); + $ua = sprintf("PHP/%s PHP-SDK/%s", phpversion(), self::VERSION); + $response = get_curl($this->ApiUrl, json_encode($param), 0, 0, 0, $ua, 0, ['Content-Type: application/json']); + $result = json_decode($response, true); + if (isset($result['RetCode']) && $result['RetCode'] == 0) { + return $result; + } elseif (isset($result['Message'])) { + throw new Exception($result['Message']); + } else { + throw new Exception('返回数据解析失败'); + } + } + + private function ucloudSignature($param) + { + ksort($param); + $str = ''; + foreach ($param as $key => $value) { + $str .= $key . $value; + } + $str .= $this->PrivateKey; + return sha1($str); + } +} diff --git a/app/lib/client/Volcengine.php b/app/lib/client/Volcengine.php new file mode 100644 index 0000000..928e599 --- /dev/null +++ b/app/lib/client/Volcengine.php @@ -0,0 +1,192 @@ +AccessKeyId = $AccessKeyId; + $this->SecretAccessKey = $SecretAccessKey; + $this->endpoint = $endpoint; + $this->service = $service; + $this->version = $version; + $this->region = $region; + } + + /** + * @param string $method 请求方法 + * @param string $action 方法名称 + * @param array $params 请求参数 + * @return array + * @throws Exception + */ + public function request($method, $action, $params = [], $querys = []) + { + if (!empty($params)) { + $params = array_filter($params, function ($a) { return $a !== null;}); + } + + $query = [ + 'Action' => $action, + 'Version' => $this->version, + ]; + + $body = ''; + if ($method == 'GET') { + $query = array_merge($query, $params); + } else { + $body = !empty($params) ? json_encode($params) : ''; + if (!empty($querys)) { + $query = array_merge($query, $querys); + } + } + + $time = time(); + $headers = [ + 'Host' => $this->endpoint, + 'X-Date' => gmdate("Ymd\THis\Z", $time), + //'X-Content-Sha256' => hash("sha256", $body), + ]; + if ($body) { + $headers['Content-Type'] = 'application/json'; + } + $path = '/'; + + $authorization = $this->generateSign($method, $path, $query, $headers, $body, $time); + $headers['Authorization'] = $authorization; + + $url = 'https://' . $this->endpoint . $path . '?' . 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 = "HMAC-SHA256"; + + // step 1: build canonical request string + $httpRequestMethod = $method; + $canonicalUri = $path; + if (substr($canonicalUri, -1) != "/") $canonicalUri .= "/"; + $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 . '/request'; + $hashedCanonicalRequest = hash("sha256", $canonicalRequest); + $stringToSign = $algorithm . "\n" + . $date . "\n" + . $credentialScope . "\n" + . $hashedCanonicalRequest; + + // step 3: sign string + $kDate = hash_hmac("sha256", $shortDate, $this->SecretAccessKey, true); + $kRegion = hash_hmac("sha256", $this->region, $kDate, true); + $kService = hash_hmac("sha256", $this->service, $kRegion, true); + $kSigning = hash_hmac("sha256", "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 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); + 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 ($arr) { + if (isset($arr['ResponseMetadata']['Error']['MessageCN'])) { + throw new Exception($arr['ResponseMetadata']['Error']['MessageCN']); + } elseif (isset($arr['ResponseMetadata']['Error']['Message'])) { + throw new Exception($arr['ResponseMetadata']['Error']['Message']); + } elseif (isset($arr['Result'])) { + return $arr['Result']; + } else { + return true; + } + } else { + throw new Exception('返回数据解析失败'); + } + } +} diff --git a/app/lib/deploy/aliyun.php b/app/lib/deploy/aliyun.php new file mode 100644 index 0000000..66d2b0a --- /dev/null +++ b/app/lib/deploy/aliyun.php @@ -0,0 +1,649 @@ +AccessKeyId = $config['AccessKeyId']; + $this->AccessKeySecret = $config['AccessKeySecret']; + } + + public function check() + { + if (empty($this->AccessKeyId) || empty($this->AccessKeySecret)) throw new Exception('必填参数不能为空'); + $client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, 'cas.aliyuncs.com', '2020-04-07'); + $param = ['Action' => 'ListUserCertificateOrder']; + $client->request($param); + return true; + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + if ($config['product'] == 'api') { + $this->deploy_api($fullchain, $privatekey, $config); + } elseif ($config['product'] == 'vod') { + $this->deploy_vod($fullchain, $privatekey, $config); + } elseif ($config['product'] == 'fc') { + $this->deploy_fc($fullchain, $privatekey, $config); + } elseif ($config['product'] == 'fc2') { + $this->deploy_fc2($fullchain, $privatekey, $config); + } else { + [$cert_id, $cert_name] = $this->get_cert_id($fullchain, $privatekey); + if (!$cert_id) throw new Exception('证书ID获取失败'); + if ($config['product'] == 'cdn') { + $this->deploy_cdn($cert_id, $cert_name, $config); + } elseif ($config['product'] == 'dcdn') { + $this->deploy_dcdn($cert_id, $cert_name, $config); + } elseif ($config['product'] == 'esa') { + $this->deploy_esa($cert_id, $config); + } elseif ($config['product'] == 'oss') { + $this->deploy_oss($cert_id, $config); + } elseif ($config['product'] == 'waf') { + $this->deploy_waf($cert_id, $config); + } elseif ($config['product'] == 'waf2') { + $this->deploy_waf2($cert_id, $config); + } elseif ($config['product'] == 'ddoscoo') { + $this->deploy_ddoscoo($cert_id, $config); + } elseif ($config['product'] == 'live') { + $this->deploy_live($cert_id, $cert_name, $config); + } elseif ($config['product'] == 'clb') { + $this->deploy_clb($cert_id, $cert_name, $config); + } elseif ($config['product'] == 'alb') { + $this->deploy_alb($cert_id, $config); + } elseif ($config['product'] == 'nlb') { + $this->deploy_nlb($cert_id, $config); + } else { + throw new Exception('未知的产品类型'); + } + $info['cert_id'] = $cert_id; + $info['cert_name'] = $cert_name; + } + } + + private function get_cert_id($fullchain, $privatekey) + { + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + $cert_name = str_replace('*.', '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t']; + $serial_no = strtolower($certInfo['serialNumberHex']); + + $client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, 'cas.aliyuncs.com', '2020-04-07'); + $param = [ + 'Action' => 'ListUserCertificateOrder', + 'Keyword' => $certInfo['subject']['CN'], + 'OrderType' => 'UPLOAD', + ]; + try { + $data = $client->request($param); + } catch (Exception $e) { + throw new Exception('查询证书列表失败:' . $e->getMessage()); + } + $cert_id = null; + if ($data['TotalCount'] > 0 && !empty($data['CertificateOrderList'])) { + foreach ($data['CertificateOrderList'] as $cert) { + if (strtolower($cert['SerialNo']) == $serial_no) { + $cert_id = $cert['CertificateId']; + $cert_name = $cert['Name']; + break; + } + } + } + if ($cert_id) { + $this->log('找到已上传的证书 CertId=' . $cert_id); + return [$cert_id, $cert_name]; + } + + $param = [ + 'Action' => 'UploadUserCertificate', + 'Name' => $cert_name, + 'Cert' => $fullchain, + 'Key' => $privatekey, + ]; + try { + $data = $client->request($param); + } catch (Exception $e) { + throw new Exception('上传证书失败:' . $e->getMessage()); + } + $this->log('证书上传成功!CertId=' . $data['CertId']); + usleep(500000); + return [$data['CertId'], $cert_name]; + } + + private function deploy_cdn($cert_id, $cert_name, $config) + { + $domain = $config['domain']; + if (empty($domain)) throw new Exception('CDN绑定域名不能为空'); + $client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, 'cdn.aliyuncs.com', '2018-05-10'); + $param = [ + 'Action' => 'SetCdnDomainSSLCertificate', + 'DomainName' => $domain, + 'CertName' => $cert_name, + 'CertType' => 'cas', + 'SSLProtocol' => 'on', + 'CertId' => $cert_id, + ]; + $client->request($param); + $this->log('CDN域名 ' . $domain . ' 部署证书成功!'); + } + + private function deploy_dcdn($cert_id, $cert_name, $config) + { + $domain = $config['domain']; + if (empty($domain)) throw new Exception('DCDN绑定域名不能为空'); + $client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, 'dcdn.aliyuncs.com', '2018-01-15'); + $param = [ + 'Action' => 'SetDcdnDomainSSLCertificate', + 'DomainName' => $domain, + 'CertName' => $cert_name, + 'CertType' => 'cas', + 'SSLProtocol' => 'on', + 'CertId' => $cert_id, + ]; + $client->request($param); + $this->log('DCDN域名 ' . $domain . ' 部署证书成功!'); + } + + private function deploy_esa($cas_id, $config) + { + $sitename = $config['esa_sitename']; + if (empty($sitename)) throw new Exception('ESA站点名称不能为空'); + + $client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, 'esa.cn-hangzhou.aliyuncs.com', '2024-09-10'); + $param = [ + 'Action' => 'ListSites', + 'SiteName' => $sitename, + 'SiteSearchType' => 'exact', + ]; + try { + $data = $client->request($param, 'GET'); + } catch (Exception $e) { + throw new Exception('查询ESA站点列表失败:' . $e->getMessage()); + } + if ($data['TotalCount'] == 0) throw new Exception('ESA站点 ' . $sitename . ' 不存在'); + $this->log('成功查询到' . $data['TotalCount'] . '个ESA站点'); + $site_id = $data['Sites'][0]['SiteId']; + + $param = [ + 'Action' => 'ListCertificates', + 'SiteId' => $site_id, + ]; + try { + $data = $client->request($param, 'GET'); + } catch (Exception $e) { + throw new Exception('查询ESA站点' . $sitename . '证书列表失败:' . $e->getMessage()); + } + $this->log('ESA站点 ' . $sitename . ' 查询到' . $data['TotalCount'] . '个SSL证书'); + + $cert_id = null; + $cert_name = null; + foreach ($data['Result'] as $cert) { + $domains = explode(',', $cert['SAN']); + $flag = true; + foreach ($domains as $domain) { + if (!in_array($domain, $config['domainList'])) { + $flag = false; + break; + } + } + if ($flag) { + $cert_id = $cert['Id']; + $cert_name = $cert['CommonName']; + break; + } + } + + $param = [ + 'Action' => 'SetCertificate', + 'SiteId' => $site_id, + 'Type' => 'cas', + 'CasId' => $cas_id, + ]; + if ($cert_id) { + $param['Update'] = 'true'; + $param['Id'] = $cert_id; + } + $client->request($param); + if ($cert_id) { + $this->log('ESA站点 ' . $sitename . ' 域名 ' . $cert_name . ' 更新证书成功!'); + } else { + $this->log('ESA站点 ' . $sitename . ' 添加证书成功!'); + } + } + + private function deploy_oss($cert_id, $config) + { + if (empty($config['domain'])) throw new Exception('OSS绑定域名不能为空'); + if (empty($config['oss_endpoint'])) throw new Exception('OSS Endpoint不能为空'); + if (empty($config['oss_bucket'])) throw new Exception('OSS Bucket不能为空'); + $client = new AliyunOSS($this->AccessKeyId, $this->AccessKeySecret, $config['oss_endpoint']); + $client->addBucketCnameCert($config['oss_bucket'], $config['domain'], $cert_id); + $this->log('OSS域名 ' . $config['domain'] . ' 部署证书成功!'); + } + + private function deploy_waf($cert_id, $config) + { + $domain = $config['domain']; + if (empty($domain)) throw new Exception('WAF绑定域名不能为空'); + + $endpoint = 'wafopenapi.' . $config['region'] . '.aliyuncs.com'; + + $client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, $endpoint, '2021-10-01'); + + $param = [ + 'Action' => 'DescribeInstance', + 'RegionId' => $config['region'], + ]; + try { + $data = $client->request($param, 'GET'); + } catch (Exception $e) { + throw new Exception('获取WAF实例详情失败:' . $e->getMessage()); + } + if (empty($data['InstanceId'])) throw new Exception('当前账号未找到WAF实例'); + $instance_id = $data['InstanceId']; + $this->log('获取WAF实例ID成功 InstanceId=' . $instance_id); + + $param = [ + 'Action' => 'DescribeDomainDetail', + 'InstanceId' => $instance_id, + 'Domain' => $domain, + 'RegionId' => $config['region'], + ]; + try { + $data = $client->request($param, 'GET'); + } catch (Exception $e) { + throw new Exception('查询CNAME接入详情失败:' . $e->getMessage()); + } + + if (isset($data['Listen']['CertId'])) { + $old_cert_id = $data['Listen']['CertId']; + if (strpos($old_cert_id, '-')) $old_cert_id = substr($old_cert_id, 0, strpos($old_cert_id, '-')); + if (!empty($old_cert_id) && $old_cert_id == $cert_id) { + $this->log('WAF域名 ' . $domain . ' 证书已配置,无需重复操作'); + return; + } + } + + $data['Listen']['CertId'] = $cert_id . '-cn-hangzhou'; + if (empty($data['Listen']['HttpsPorts'])) $data['Listen']['HttpsPorts'] = [443]; + $data['Redirect']['Backends'] = $data['Redirect']['AllBackends']; + $param = [ + 'Action' => 'ModifyDomain', + 'InstanceId' => $instance_id, + 'Domain' => $domain, + 'Listen' => json_encode($data['Listen']), + 'Redirect' => json_encode($data['Redirect']), + 'RegionId' => $config['region'], + ]; + $data = $client->request($param); + + $this->log('WAF域名 ' . $domain . ' 部署证书成功!'); + } + + private function deploy_waf2($cert_id, $config) + { + $domain = $config['domain']; + if (empty($domain)) throw new Exception('WAF绑定域名不能为空'); + + $endpoint = 'wafopenapi.' . $config['region'] . '.aliyuncs.com'; + + $client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, $endpoint, '2019-09-10'); + + $param = [ + 'Action' => 'DescribeInstanceInfo', + 'RegionId' => $config['region'], + ]; + try { + $data = $client->request($param, 'GET'); + } catch (Exception $e) { + throw new Exception('获取WAF实例详情失败:' . $e->getMessage()); + } + if (empty($data['InstanceInfo']['InstanceId'])) throw new Exception('当前账号未找到WAF实例'); + $instance_id = $data['InstanceInfo']['InstanceId']; + $this->log('获取WAF实例ID成功 InstanceId=' . $instance_id); + + $param = [ + 'Action' => 'CreateCertificateByCertificateId', + 'InstanceId' => $instance_id, + 'Domain' => $domain, + 'CertificateId' => $cert_id, + ]; + $client->request($param); + + $this->log('WAF域名 ' . $domain . ' 部署证书成功!'); + } + + private function deploy_api($fullchain, $privatekey, $config) + { + $domain = $config['domain']; + $groupid = $config['api_groupid']; + if (empty($groupid)) throw new Exception('API分组ID不能为空'); + if (empty($domain)) throw new Exception('API分组绑定域名不能为空'); + + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + $cert_name = str_replace('*.', '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t']; + + $endpoint = 'apigateway.' . $config['regionid'] . '.aliyuncs.com'; + + $client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, $endpoint, '2016-07-14'); + + $param = [ + 'Action' => 'SetDomainCertificate', + 'GroupId' => $groupid, + 'DomainName' => $domain, + 'CertificateName' => $cert_name, + 'CertificateBody' => $fullchain, + 'CertificatePrivateKey' => $privatekey, + ]; + $client->request($param); + + $this->log('API网关域名 ' . $domain . ' 部署证书成功!'); + } + + private function deploy_ddoscoo($cert_id, $config) + { + $domain = $config['domain']; + if (empty($domain)) throw new Exception('绑定域名不能为空'); + + $endpoint = 'ddoscoo.' . $config['region'] . '.aliyuncs.com'; + + $client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, $endpoint, '2020-01-01'); + + $param = [ + 'Action' => 'AssociateWebCert', + 'Domain' => $domain, + 'CertId' => $cert_id, + ]; + $client->request($param); + + $this->log('DDoS高防域名 ' . $domain . ' 部署证书成功!'); + } + + private function deploy_live($cert_id, $cert_name, $config) + { + $domain = $config['domain']; + if (empty($domain)) throw new Exception('视频直播绑定域名不能为空'); + $client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, 'live.aliyuncs.com', '2016-11-01'); + $param = [ + 'Action' => 'SetLiveDomainCertificate', + 'DomainName' => $domain, + 'CertName' => $cert_name, + 'CertType' => 'cas', + 'SSLProtocol' => 'on', + 'CertId' => $cert_id, + ]; + $client->request($param); + $this->log('设置视频直播域名 ' . $domain . ' 证书成功!'); + } + + private function deploy_vod($fullchain, $privatekey, $config) + { + $domain = $config['domain']; + if (empty($domain)) throw new Exception('视频点播绑定域名不能为空'); + $client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, 'vod.cn-shanghai.aliyuncs.com', '2017-03-21'); + $param = [ + 'Action' => 'SetVodDomainCertificate', + 'DomainName' => $domain, + 'SSLProtocol' => 'on', + 'SSLPub' => $fullchain, + 'SSLPri' => $privatekey, + ]; + $client->request($param); + $this->log('视频点播域名 ' . $domain . ' 部署证书成功!'); + } + + private function deploy_fc($fullchain, $privatekey, $config) + { + $domain = $config['domain']; + $fc_cname = $config['fc_cname']; + if (empty($domain)) throw new Exception('函数计算域名不能为空'); + if (empty($fc_cname)) throw new Exception('域名CNAME地址不能为空'); + + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + $cert_name = str_replace('*.', '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t']; + + $client = new AliyunNewClient($this->AccessKeyId, $this->AccessKeySecret, $fc_cname, '2023-03-30'); + + try { + $data = $client->request('GET', 'GetCustomDomain', '/2023-03-30/custom-domains/' . $domain); + } catch (Exception $e) { + throw new Exception('获取绑定域名信息失败:' . $e->getMessage()); + } + $this->log('获取函数计算绑定域名信息成功'); + + if (isset($data['certConfig']['certificate']) && $data['certConfig']['certificate'] == $fullchain) { + $this->log('函数计算域名 ' . $domain . ' 证书已配置,无需重复操作'); + return; + } + + if ($data['protocol'] == 'HTTP') $data['protocol'] = 'HTTP,HTTPS'; + $data['certConfig']['certName'] = $cert_name; + $data['certConfig']['certificate'] = $fullchain; + $data['certConfig']['privateKey'] = $privatekey; + + $param = [ + 'authConfig' => $data['authConfig'], + 'certConfig' => $data['certConfig'], + 'protocol' => $data['protocol'], + 'routeConfig' => $data['routeConfig'], + 'tlsConfig' => $data['tlsConfig'], + 'wafConfig' => $data['wafConfig'], + ]; + $client->request('PUT', 'UpdateCustomDomain', '/2023-03-30/custom-domains/' . $domain, $param); + + $this->log('函数计算域名 ' . $domain . ' 部署证书成功!'); + } + + private function deploy_fc2($fullchain, $privatekey, $config) + { + $domain = $config['domain']; + $fc_cname = $config['fc_cname']; + if (empty($domain)) throw new Exception('函数计算域名不能为空'); + if (empty($fc_cname)) throw new Exception('域名CNAME地址不能为空'); + + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + $cert_name = str_replace('*.', '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t']; + + $client = new AliyunNewClient($this->AccessKeyId, $this->AccessKeySecret, $fc_cname, '2021-04-06'); + + try { + $data = $client->request('GET', 'GetCustomDomain', '/2021-04-06/custom-domains/' . $domain); + } catch (Exception $e) { + throw new Exception('获取绑定域名信息失败:' . $e->getMessage()); + } + $this->log('获取函数计算绑定域名信息成功'); + + if (isset($data['certConfig']['certificate']) && $data['certConfig']['certificate'] == $fullchain) { + $this->log('函数计算域名 ' . $domain . ' 证书已配置,无需重复操作'); + return; + } + + if ($data['protocol'] == 'HTTP') $data['protocol'] = 'HTTP,HTTPS'; + $data['certConfig']['certName'] = $cert_name; + $data['certConfig']['certificate'] = $fullchain; + $data['certConfig']['privateKey'] = $privatekey; + + $param = [ + 'protocol' => $data['protocol'], + 'routeConfig' => $data['routeConfig'], + 'certConfig' => $data['certConfig'], + 'tlsConfig' => $data['tlsConfig'], + 'wafConfig' => $data['wafConfig'], + ]; + $client->request('PUT', 'UpdateCustomDomain', '/2021-04-06/custom-domains/' . $domain, $param); + + $this->log('函数计算域名 ' . $domain . ' 部署证书成功!'); + } + + private function deploy_clb($cert_id, $cert_name, $config) + { + if (empty($config['clb_id'])) throw new Exception('负载均衡实例ID不能为空'); + if (empty($config['clb_port'])) throw new Exception('HTTPS监听端口不能为空'); + + $endpoint = 'slb.' . $config['regionid'] . '.aliyuncs.com'; + $client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, $endpoint, '2014-05-15'); + + $param = [ + 'Action' => 'DescribeServerCertificates', + 'RegionId' => $config['regionid'], + ]; + try { + $data = $client->request($param); + } catch (Exception $e) { + throw new Exception('获取服务器证书列表失败:' . $e->getMessage()); + } + + $ServerCertificateId = null; + foreach ($data['ServerCertificates']['ServerCertificate'] as $cert) { + if ($cert['IsAliCloudCertificate'] == 1 && $cert['AliCloudCertificateId'] == $cert_id) { + $ServerCertificateId = $cert['ServerCertificateId']; + break; + } + } + if (!$ServerCertificateId) { + $param = [ + 'Action' => 'UploadServerCertificate', + 'RegionId' => $config['regionid'], + 'AliCloudCertificateId' => $cert_id, + 'AliCloudCertificateName' => $cert_name, + 'AliCloudCertificateRegionId' => 'cn-hangzhou', + ]; + try { + $data = $client->request($param); + } catch (Exception $e) { + throw new Exception('服务器证书添加失败:' . $e->getMessage()); + } + $ServerCertificateId = $data['ServerCertificateId']; + $this->log('服务器证书添加成功 ServerCertificateId=' . $ServerCertificateId); + } else { + $this->log('找到已添加的服务器证书 ServerCertificateId=' . $ServerCertificateId); + } + + $param = [ + 'Action' => 'DescribeLoadBalancerHTTPSListenerAttribute', + 'RegionId' => $config['regionid'], + 'LoadBalancerId' => $config['clb_id'], + 'ListenerPort' => $config['clb_port'], + ]; + try { + $data = $client->request($param); + } catch (Exception $e) { + throw new Exception('HTTPS监听配置查询失败:' . $e->getMessage()); + } + + if ($data['ServerCertificateId'] == $ServerCertificateId) { + $this->log('负载均衡HTTPS监听已配置该证书,无需重复操作'); + return; + } + + $param = [ + 'Action' => 'SetLoadBalancerHTTPSListenerAttribute', + 'RegionId' => $config['regionid'], + 'LoadBalancerId' => $config['clb_id'], + 'ListenerPort' => $config['clb_port'], + ]; + $keys = ['Bandwidth', 'XForwardedFor', 'Scheduler', 'StickySession', 'StickySessionType', 'CookieTimeout', 'Cookie', 'HealthCheck', 'HealthCheckMethod', 'HealthCheckDomain', 'HealthCheckURI', 'HealthyThreshold', 'UnhealthyThreshold', 'HealthCheckTimeout', 'HealthCheckInterval', 'HealthCheckConnectPort', 'HealthCheckHttpCode', 'ServerCertificateId', 'CACertificateId', 'VServerGroup', 'VServerGroupId', 'XForwardedFor_SLBIP', 'XForwardedFor_SLBID', 'XForwardedFor_proto', 'Gzip', 'AclId', 'AclType', 'AclStatus', 'IdleTimeout', 'RequestTimeout', 'EnableHttp2', 'TLSCipherPolicy', 'Description', 'XForwardedFor_SLBPORT', 'XForwardedFor_ClientSrcPort']; + foreach ($keys as $key) { + if (isset($data[$key])) $param[$key] = $data[$key]; + } + $param['ServerCertificateId'] = $ServerCertificateId; + $client->request($param); + $this->log('负载均衡HTTPS监听证书配置成功!'); + } + + private function deploy_alb($cert_id, $config) + { + if (empty($config['alb_listener_id'])) throw new Exception('负载均衡监听ID不能为空'); + + $endpoint = 'alb.' . $config['regionid'] . '.aliyuncs.com'; + $client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, $endpoint, '2020-06-16'); + + $param = [ + 'Action' => 'ListListenerCertificates', + 'MaxResults' => 100, + 'ListenerId' => $config['alb_listener_id'], + 'CertificateType' => 'Server', + ]; + try { + $data = $client->request($param); + } catch (Exception $e) { + throw new Exception('获取监听证书列表失败:' . $e->getMessage()); + } + foreach ($data['Certificates'] as $cert) { + if (strpos($cert['CertificateId'], '-')) $cert['CertificateId'] = substr($cert['CertificateId'], 0, strpos($cert['CertificateId'], '-')); + if ($cert['CertificateId'] == $cert_id) { + $this->log('负载均衡监听证书已添加,无需重复操作'); + return; + } + } + + $param = [ + 'Action' => 'AssociateAdditionalCertificatesWithListener', + 'ListenerId' => $config['alb_listener_id'], + 'Certificates.1.CertificateId' => $cert_id . '-cn-hangzhou', + ]; + $client->request($param); + $this->log('应用型负载均衡监听证书添加成功!'); + } + + private function deploy_nlb($cert_id, $config) + { + if (empty($config['nlb_listener_id'])) throw new Exception('负载均衡监听ID不能为空'); + + $endpoint = 'nlb.' . $config['regionid'] . '.aliyuncs.com'; + $client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, $endpoint, '2022-04-30'); + + $param = [ + 'Action' => 'ListListenerCertificates', + 'MaxResults' => 50, + 'ListenerId' => $config['nlb_listener_id'], + 'CertificateType' => 'Server', + ]; + try { + $data = $client->request($param); + } catch (Exception $e) { + throw new Exception('获取监听证书列表失败:' . $e->getMessage()); + } + foreach ($data['Certificates'] as $cert) { + if (strpos($cert['CertificateId'], '-')) $cert['CertificateId'] = substr($cert['CertificateId'], 0, strpos($cert['CertificateId'], '-')); + if ($cert['CertificateId'] == $cert_id) { + $this->log('负载均衡监听证书已添加,无需重复操作'); + return; + } + } + + $param = [ + 'Action' => 'AssociateAdditionalCertificatesWithListener', + 'ListenerId' => $config['nlb_listener_id'], + 'AdditionalCertificateIds.1' => $cert_id . '-cn-hangzhou', + ]; + $client->request($param); + $this->log('网络型负载均衡监听证书添加成功!'); + } + + 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/allwaf.php b/app/lib/deploy/allwaf.php new file mode 100644 index 0000000..515bc8c --- /dev/null +++ b/app/lib/deploy/allwaf.php @@ -0,0 +1,127 @@ +accessKeyId = $config['accessKeyId']; + $this->accessKey = $config['accessKey']; + $this->proxy = $config['proxy'] == 1; + } + + public function check() + { + if (empty($this->url) || empty($this->accessKeyId) || empty($this->accessKey)) throw new Exception('必填参数不能为空'); + $this->getAccessToken(); + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + $domains = $config['domainList']; + if (empty($domains)) throw new Exception('没有设置要部署的域名'); + + $this->getAccessToken(); + + $params = [ + 'domains' => $domains, + 'offset' => 0, + 'size' => 10, + ]; + try { + $data = $this->request('/SSLCertService/listSSLCerts', $params); + } catch (Exception $e) { + throw new Exception('获取证书列表失败:' . $e->getMessage()); + } + $list = json_decode(base64_decode($data['sslCertsJSON']), true); + if (!$list || empty($list)) { + throw new Exception('证书列表为空'); + } + $this->log('获取证书列表成功(total=' . count($list) . ')'); + + $certInfo = openssl_x509_parse($fullchain, true); + + 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'] . '更新成功!'); + } + } + + private function getAccessToken() + { + $path = '/APIAccessTokenService/getAPIAccessToken'; + $params = [ + 'type' => $this->usertype, + 'accessKeyId' => $this->accessKeyId, + 'accessKey' => $this->accessKey, + ]; + $result = $this->request($path, $params); + if (isset($result['token'])) { + $this->accessToken = $result['token']; + } else { + throw new Exception('登录成功,获取AccessToken失败'); + } + } + + private function request($path, $params = null) + { + $url = $this->url . $path; + $headers = []; + $body = null; + if ($this->accessToken) { + $headers[] = 'X-Cloud-Access-Token: ' . $this->accessToken; + } + if ($params) { + $headers[] = 'Content-Type: application/json'; + $body = json_encode($params); + } + $response = curl_client($url, $body, null, null, $headers, $this->proxy); + $result = json_decode($response['body'], true); + if (isset($result['code']) && $result['code'] == 200) { + return isset($result['data']) ? $result['data'] : null; + } elseif (isset($result['message'])) { + throw new Exception($result['message']); + } else { + if (!empty($response['body'])) $this->log('Response:' . $response['body']); + throw new Exception('返回数据解析失败'); + } + } + + 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/aws.php b/app/lib/deploy/aws.php new file mode 100644 index 0000000..e783212 --- /dev/null +++ b/app/lib/deploy/aws.php @@ -0,0 +1,90 @@ +AccessKeyId = $config['AccessKeyId']; + $this->SecretAccessKey = $config['SecretAccessKey']; + } + + public function check() + { + if (empty($this->AccessKeyId) || empty($this->SecretAccessKey)) throw new Exception('必填参数不能为空'); + $client = new AWSClient($this->AccessKeyId, $this->SecretAccessKey, 'iam.amazonaws.com', 'iam','2010-05-08', 'us-east-1'); + $client->requestXml('GET', 'GetUser'); + return true; + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + if (empty($config['distribution_id'])) throw new Exception('分配ID不能为空'); + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + $config['cert_name'] = str_replace('*.', '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t']; + + if(isset($info['cert_id']) && isset($info['cert_name']) && $info['cert_name'] == $config['cert_name']){ + $cert_id = $info['cert_id']; + $this->log('证书已上传:' . $cert_id); + }else{ + $cert_id = $this->get_cert_id($fullchain, $privatekey); + $this->log('证书上传成功:' . $cert_id); + $info['cert_id'] = $cert_id; + $info['cert_name'] = $config['cert_name']; + usleep(500000); + } + + $client = new \app\lib\client\AWS($this->AccessKeyId, $this->SecretAccessKey, 'cloudfront.amazonaws.com', 'cloudfront', '2020-05-31', 'us-east-1'); + try{ + $data = $client->requestXmlN('GET', '/distribution/'.$config['distribution_id'].'/config', [], null, true); + }catch(Exception $e){ + throw new Exception('获取分配信息失败:'.$e->getMessage()); + } + + $data['ViewerCertificate']['ACMCertificateArn'] = $cert_id; + $data['ViewerCertificate']['CloudFrontDefaultCertificate'] = false; + $xml = new \SimpleXMLElement(''); + $client->requestXmlN('PUT', '/distribution/'.$config['distribution_id'].'/config', $data, $xml); + $this->log('分配ID: ' . $config['distribution_id'] . ' 证书部署成功!'); + } + + private function get_cert_id($fullchain, $privatekey) + { + $cert = explode('-----END CERTIFICATE-----', $fullchain)[0] . '-----END CERTIFICATE-----'; + $param = [ + 'Certificate' => base64_encode($cert), + 'PrivateKey' => base64_encode($privatekey), + ]; + + $client = new \app\lib\client\AWS($this->AccessKeyId, $this->SecretAccessKey, 'acm.us-east-1.amazonaws.com', 'acm', '', 'us-east-1'); + try{ + $data = $client->request('POST', 'CertificateManager.ImportCertificate', $param); + $cert_id = $data['CertificateArn']; + }catch(Exception $e){ + throw new Exception('上传证书失败:'.$e->getMessage()); + } + return $cert_id; + } + + 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/baidu.php b/app/lib/deploy/baidu.php new file mode 100644 index 0000000..89140a6 --- /dev/null +++ b/app/lib/deploy/baidu.php @@ -0,0 +1,71 @@ +AccessKeyId = $config['AccessKeyId']; + $this->SecretAccessKey = $config['SecretAccessKey']; + } + + public function check() + { + if (empty($this->AccessKeyId) || empty($this->SecretAccessKey)) throw new Exception('必填参数不能为空'); + $client = new BaiduCloud($this->AccessKeyId, $this->SecretAccessKey, 'cdn.baidubce.com'); + $client->request('GET', '/v2/domain'); + return true; + } + + public function deploy($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']; + + $client = new BaiduCloud($this->AccessKeyId, $this->SecretAccessKey, 'cdn.baidubce.com'); + try{ + $data = $client->request('GET', '/v2/'.$config['domain'].'/certificates'); + if(isset($data['certName']) && $data['certName'] == $config['cert_name']){ + $this->log('CDN域名 ' . $config['domain'] . ' 证书已存在,无需重复部署'); + return; + } + }catch(Exception $e){ + $this->log($e->getMessage()); + } + + $param = [ + 'httpsEnable' => 'ON', + 'certificate' => [ + 'certName' => $config['cert_name'], + 'certServerData' => $fullchain, + 'certPrivateData' => $privatekey, + ], + ]; + $data = $client->request('PUT', '/v2/'.$config['domain'].'/certificates', null, $param); + $info['cert_id'] = $data['certId']; + $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/baishan.php b/app/lib/deploy/baishan.php new file mode 100644 index 0000000..de94f51 --- /dev/null +++ b/app/lib/deploy/baishan.php @@ -0,0 +1,85 @@ +token = $config['token']; + $this->proxy = $config['proxy'] == 1; + } + + public function check() + { + if (empty($this->token)) throw new Exception('token不能为空'); + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + if (empty($config['id'])) throw new Exception('证书ID不能为空'); + + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + $cert_name = str_replace('*.', '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t']; + + $params = [ + 'cert_id' => $config['id'], + 'name' => $cert_name, + 'certificate' => $fullchain, + 'key' => $privatekey, + ]; + try { + $this->request('/v2/domain/certificate?token=' . $this->token, $params); + } catch (Exception $e) { + if (strpos($e->getMessage(), 'this certificate is exists') !== false) { + $this->log('证书ID:' . $config['id'] . '已存在,无需更新'); + return; + } + throw new Exception($e->getMessage()); + } + + $this->log('证书ID:' . $config['id'] . '更新成功!'); + } + + private function request($path, $params = null) + { + $url = $this->url . $path; + $headers = []; + $body = null; + if ($params) { + $headers[] = 'Content-Type: application/json'; + $body = json_encode($params); + } + $response = curl_client($url, $body, null, null, $headers, $this->proxy); + $result = json_decode($response['body'], true); + if (isset($result['code']) && $result['code'] == 0) { + return $result; + } elseif (isset($result['message'])) { + throw new Exception($result['message']); + } else { + if (!empty($response['body'])) $this->log('Response:' . $response['body']); + throw new Exception('请求失败(httpCode=' . $response['code'] . ')'); + } + } + + 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/btpanel.php b/app/lib/deploy/btpanel.php new file mode 100644 index 0000000..596bcac --- /dev/null +++ b/app/lib/deploy/btpanel.php @@ -0,0 +1,126 @@ +url = rtrim($config['url'], '/'); + $this->key = $config['key']; + $this->proxy = $config['proxy'] == 1; + } + + public function check() + { + if (empty($this->url) || empty($this->key)) throw new Exception('请填写面板地址和接口密钥'); + + $path = '/config?action=get_config'; + $response = $this->request($path, []); + $result = json_decode($response, true); + if (isset($result['status']) && $result['status'] == 1) { + return true; + } else { + throw new Exception(isset($result['msg']) ? $result['msg'] : '面板地址无法连接'); + } + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + if ($config['type'] == '1') { + $this->deployPanel($fullchain, $privatekey); + $this->log("面板证书部署成功"); + return; + } + $sites = explode("\n", $config['sites']); + $success = 0; + $errmsg = null; + foreach ($sites as $site) { + $siteName = trim($site); + if (empty($siteName)) continue; + try { + $this->deploySite($siteName, $fullchain, $privatekey); + $this->log("网站 {$siteName} 证书部署成功"); + $success++; + } catch (Exception $e) { + $errmsg = $e->getMessage(); + $this->log("网站 {$siteName} 证书部署失败:" . $errmsg); + } + } + if ($success == 0) { + throw new Exception($errmsg ? $errmsg : '要部署的网站不存在'); + } + } + + private function deployPanel($fullchain, $privatekey) + { + $path = '/config?action=SavePanelSSL'; + $data = [ + 'privateKey' => $privatekey, + 'certPem' => $fullchain, + ]; + $response = $this->request($path, $data); + $result = json_decode($response, true); + if (isset($result['status']) && $result['status']) { + return true; + } elseif (isset($result['msg'])) { + throw new Exception($result['msg']); + } else { + throw new Exception($response ? $response : '返回数据解析失败'); + } + } + + private function deploySite($siteName, $fullchain, $privatekey) + { + $path = '/site?action=SetSSL'; + $data = [ + 'type' => '0', + 'siteName' => $siteName, + 'key' => $privatekey, + 'csr' => $fullchain, + ]; + $response = $this->request($path, $data); + $result = json_decode($response, true); + if (isset($result['status']) && $result['status']) { + return true; + } elseif (isset($result['msg'])) { + throw new Exception($result['msg']); + } else { + throw new Exception($response ? $response : '返回数据解析失败'); + } + } + + public function setLogger($func) + { + $this->logger = $func; + } + + private function log($txt) + { + if ($this->logger) { + call_user_func($this->logger, $txt); + } + } + + private function request($path, $params) + { + $url = $this->url . $path; + + $now_time = time(); + $post_data = [ + 'request_token' => md5($now_time . md5($this->key)), + 'request_time' => $now_time + ]; + $post_data = array_merge($post_data, $params); + $response = curl_client($url, $post_data, null, null, null, $this->proxy); + return $response['body']; + } +} diff --git a/app/lib/deploy/cachefly.php b/app/lib/deploy/cachefly.php new file mode 100644 index 0000000..a8ce28f --- /dev/null +++ b/app/lib/deploy/cachefly.php @@ -0,0 +1,67 @@ +apikey = $config['apikey']; + $this->proxy = $config['proxy'] == 1; + } + + public function check() + { + if (empty($this->apikey)) throw new Exception('API令牌不能为空'); + $this->request('/accounts/me'); + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + $params = [ + 'certificate' => $fullchain, + 'certificateKey' => $privatekey, + ]; + $this->request('/certificates', $params); + $this->log('证书上传成功!'); + } + + private function request($path, $params = null, $method = null) + { + $url = $this->url . $path; + $headers = ['x-cf-authorization: Bearer ' . $this->apikey]; + $body = null; + if ($params) { + $headers[] = 'Content-Type: application/json'; + $body = json_encode($params); + } + $response = curl_client($url, $body, null, null, $headers, $this->proxy, $method); + $result = json_decode($response['body'], true); + if ($response['code'] >= 200 && $response['code'] < 300) { + return $result; + } else { + if (!empty($response['body'])) $this->log('Response:' . $response['body']); + throw new Exception('请求失败(httpCode=' . $response['code'] . ')'); + } + } + + 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/cdnfly.php b/app/lib/deploy/cdnfly.php new file mode 100644 index 0000000..82a8f53 --- /dev/null +++ b/app/lib/deploy/cdnfly.php @@ -0,0 +1,75 @@ +url = rtrim($config['url'], '/'); + $this->api_key = $config['api_key']; + $this->api_secret = $config['api_secret']; + $this->proxy = $config['proxy'] == 1; + } + + public function check() + { + if (empty($this->url) || empty($this->api_key) || empty($this->api_secret)) throw new Exception('必填参数不能为空'); + $this->request('/v1/user'); + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + $id = $config['id']; + if (empty($id)) throw new Exception('证书ID不能为空'); + + $params = [ + 'type' => 'custom', + 'cert' => $fullchain, + 'key' => $privatekey, + ]; + $this->request('/v1/certs/' . $id, $params, 'PUT'); + $this->log("证书ID:{$id}更新成功!"); + } + + private function request($path, $params = null, $method = null) + { + $url = $this->url . $path; + $headers = ['api-key: ' . $this->api_key, 'api-secret: ' . $this->api_secret]; + $body = null; + if ($params) { + $headers[] = 'Content-Type: application/json'; + $body = json_encode($params); + } + $response = curl_client($url, $body, null, null, $headers, $this->proxy, $method); + $result = json_decode($response['body'], true); + if (isset($result['code']) && $result['code'] == 0) { + return isset($result['data']) ? $result['data'] : null; + } elseif (isset($result['msg'])) { + throw new Exception($result['msg']); + } else { + throw new Exception('返回数据解析失败'); + } + } + + 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/doge.php b/app/lib/deploy/doge.php new file mode 100644 index 0000000..93b1253 --- /dev/null +++ b/app/lib/deploy/doge.php @@ -0,0 +1,120 @@ +AccessKey = $config['AccessKey']; + $this->SecretKey = $config['SecretKey']; + } + + public function check() + { + if (empty($this->AccessKey) || empty($this->SecretKey)) throw new Exception('必填参数不能为空'); + $this->request('/cdn/cert/list.json'); + return true; + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + $domain = $config['domain']; + if (empty($domain)) throw new Exception('绑定的域名不能为空'); + + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + $cert_name = str_replace('*.', '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t']; + + $cert_id = $this->get_cert_id($fullchain, $privatekey, $cert_name); + + $param = [ + 'id' => $cert_id, + 'domain' => $domain, + ]; + $this->request('/cdn/cert/bind.json', $param); + + $this->log('CDN域名 ' . $domain . ' 绑定证书成功!'); + $info['cert_id'] = $cert_id; + } + + private function get_cert_id($fullchain, $privatekey, $cert_name) + { + $cert_id = null; + + $data = $this->request('/cdn/cert/list.json'); + foreach ($data['certs'] as $cert) { + if ($cert_name == $cert['note']) { + $cert_id = $cert['id']; + $this->log('证书' . $cert_name . '已存在,证书ID:' . $cert_id); + } elseif ($cert['expire'] < time() && $cert['domainCount'] == 0) { + try { + $this->request('/cdn/cert/delete.json', ['id' => $cert['id']]); + $this->log('证书' . $cert['name'] . '已过期,删除证书成功'); + } catch (Exception $e) { + $this->log('证书' . $cert['name'] . '已过期,删除证书失败:' . $e->getMessage()); + } + usleep(300000); + } + } + + if (!$cert_id) { + $param = [ + 'note' => $cert_name, + 'cert' => $fullchain, + 'private' => $privatekey, + ]; + try { + $data = $this->request('/cdn/cert/upload.json', $param); + } catch (Exception $e) { + throw new Exception('上传证书失败:' . $e->getMessage()); + } + $this->log('上传证书成功,证书ID:' . $data['id']); + $cert_id = $data['id']; + usleep(500000); + } + return $cert_id; + } + + private function request($path, $data = null, $json = false) + { + $body = null; + if($data){ + $body = $json ? json_encode($data) : http_build_query($data); + } + $signStr = $path . "\n" . $body; + $sign = hash_hmac('sha1', $signStr, $this->SecretKey); + $authorization = "TOKEN " . $this->AccessKey . ":" . $sign; + $headers = ['Authorization: ' . $authorization]; + if($body && $json) $headers[] = 'Content-Type: application/json'; + $url = 'https://api.dogecloud.com'.$path; + $response = curl_client($url, $body, null, null, $headers); + $result = json_decode($response['body'], true); + if(isset($result['code']) && $result['code'] == 200){ + return isset($result['data']) ? $result['data'] : true; + }elseif(isset($result['msg'])){ + throw new Exception($result['msg']); + }else{ + throw new Exception('请求失败'); + } + } + + 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/ftp.php b/app/lib/deploy/ftp.php new file mode 100644 index 0000000..fd0d3b8 --- /dev/null +++ b/app/lib/deploy/ftp.php @@ -0,0 +1,113 @@ +config = $config; + } + + public function check() + { + $this->connect(); + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + $conn_id = $this->connect(); + ftp_pasv($conn_id, true); + if ($config['format'] == 'pem') { + $temp_stream = fopen('php://temp', 'r+'); + fwrite($temp_stream, $fullchain); + rewind($temp_stream); + if (ftp_fput($conn_id, $config['pem_cert_file'], $temp_stream, FTP_BINARY)) { + $this->log('证书文件上传成功:' . $config['pem_cert_file']); + } else { + fclose($temp_stream); + ftp_close($conn_id); + throw new Exception('证书文件上传失败:' . $config['pem_cert_file']); + } + fclose($temp_stream); + + $temp_stream = fopen('php://temp', 'r+'); + fwrite($temp_stream, $fullchain); + rewind($temp_stream); + if (ftp_fput($conn_id, $config['pem_key_file'], $temp_stream, FTP_BINARY)) { + $this->log('私钥文件上传成功:' . $config['pem_key_file']); + } else { + fclose($temp_stream); + ftp_close($conn_id); + throw new Exception('私钥文件上传失败:' . $config['pem_key_file']); + } + fclose($temp_stream); + } elseif ($config['format'] == 'pfx') { + $pfx = \app\lib\CertHelper::getPfx($fullchain, $privatekey, $config['pfx_pass'] ? $config['pfx_pass'] : null); + + $temp_stream = fopen('php://temp', 'r+'); + fwrite($temp_stream, $pfx); + rewind($temp_stream); + if (ftp_fput($conn_id, $config['pfx_file'], $temp_stream, FTP_BINARY)) { + $this->log('PFX证书文件上传成功:' . $config['pfx_file']); + } else { + fclose($temp_stream); + ftp_close($conn_id); + throw new Exception('PFX证书文件上传失败:' . $config['pfx_file']); + } + fclose($temp_stream); + } + ftp_close($conn_id); + } + + private function connect() + { + if (!function_exists('ftp_connect')) { + throw new Exception('ftp扩展未安装'); + } + if (empty($this->config['host']) || empty($this->config['port']) || empty($this->config['username']) || empty($this->config['password'])) { + throw new Exception('必填参数不能为空'); + } + if (!filter_var($this->config['host'], FILTER_VALIDATE_IP) && !filter_var($this->config['host'], FILTER_VALIDATE_DOMAIN)) { + throw new Exception('主机地址不合法'); + } + if (!is_numeric($this->config['port']) || $this->config['port'] < 1 || $this->config['port'] > 65535) { + throw new Exception('端口不合法'); + } + + if($this->config['secure'] == '1'){ + $conn_id = ftp_ssl_connect($this->config['host'], intval($this->config['port']), 10); + if (!$conn_id) { + throw new Exception('FTP服务器无法连接(SSL)'); + } + }else{ + $conn_id = ftp_connect($this->config['host'], intval($this->config['port']), 10); + if (!$conn_id) { + throw new Exception('FTP服务器无法连接'); + } + } + if (!ftp_login($conn_id, $this->config['username'], $this->config['password'])) { + ftp_close($conn_id); + throw new Exception('FTP登录失败'); + } + return $conn_id; + } + + private function log($txt) + { + if ($this->logger) { + call_user_func($this->logger, $txt); + } + } + + public function setLogger($logger) + { + $this->logger = $logger; + } +} diff --git a/app/lib/deploy/gcore.php b/app/lib/deploy/gcore.php new file mode 100644 index 0000000..c548a32 --- /dev/null +++ b/app/lib/deploy/gcore.php @@ -0,0 +1,77 @@ +apikey = $config['apikey']; + $this->proxy = $config['proxy'] == 1; + } + + public function check() + { + if (empty($this->apikey)) throw new Exception('API令牌不能为空'); + $this->request('/iam/clients/me'); + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + $id = $config['id']; + if (empty($id)) throw new Exception('证书ID不能为空'); + + $params = [ + 'name' => $config['name'], + 'sslCertificate' => $fullchain, + 'sslPrivateKey' => $privatekey, + 'validate_root_ca' => true, + ]; + $this->request('/cdn/sslData/' . $id, $params, 'PUT'); + $this->log('证书ID:' . $id . '更新成功!'); + } + + private function request($path, $params = null, $method = null) + { + $url = $this->url . $path; + $headers = ['Authorization: APIKey ' . $this->apikey]; + $body = null; + if ($params) { + $headers[] = 'Content-Type: application/json'; + $body = json_encode($params); + } + $response = curl_client($url, $body, null, null, $headers, $this->proxy, $method); + $result = json_decode($response['body'], true); + if ($response['code'] >= 200 && $response['code'] < 300) { + return $result; + } elseif (isset($result['message']['message'])) { + throw new Exception($result['message']['message']); + } elseif (isset($result['errors'])) { + $errors = $result['errors'][array_key_first($result['errors'])]; + throw new Exception($errors[0]); + } else { + if (!empty($response['body'])) $this->log('Response:' . $response['body']); + throw new Exception('请求失败(httpCode=' . $response['code'] . ')'); + } + } + + 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 new file mode 100644 index 0000000..d6e566f --- /dev/null +++ b/app/lib/deploy/goedge.php @@ -0,0 +1,135 @@ +url = rtrim($config['url'], '/'); + $this->accessKeyId = $config['accessKeyId']; + $this->accessKey = $config['accessKey']; + $this->usertype = $config['usertype']; + $this->systype = $config['systype']; + $this->proxy = $config['proxy'] == 1; + } + + public function check() + { + if (empty($this->url) || empty($this->accessKeyId) || empty($this->accessKey)) throw new Exception('必填参数不能为空'); + $this->getAccessToken(); + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + $domains = $config['domainList']; + if (empty($domains)) throw new Exception('没有设置要部署的域名'); + + $this->getAccessToken(); + + $params = [ + 'domains' => $domains, + 'offset' => 0, + 'size' => 10, + ]; + try { + $data = $this->request('/SSLCertService/listSSLCerts', $params); + } catch (Exception $e) { + throw new Exception('获取证书列表失败:' . $e->getMessage()); + } + $list = json_decode(base64_decode($data['sslCertsJSON']), true); + if (!$list || empty($list)) { + throw new Exception('证书列表为空'); + } + $this->log('获取证书列表成功(total=' . count($list) . ')'); + + $certInfo = openssl_x509_parse($fullchain, true); + + 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'] . '更新成功!'); + } + } + + private function getAccessToken() + { + $path = '/APIAccessTokenService/getAPIAccessToken'; + $params = [ + 'type' => $this->usertype, + 'accessKeyId' => $this->accessKeyId, + 'accessKey' => $this->accessKey, + ]; + $result = $this->request($path, $params); + if (isset($result['token'])) { + $this->accessToken = $result['token']; + } else { + throw new Exception('登录成功,获取AccessToken失败'); + } + } + + private function request($path, $params = null) + { + $url = $this->url . $path; + $headers = []; + $body = null; + if ($this->accessToken) { + if ($this->systype == '1') { + $headers[] = 'X-Cloud-Access-Token: ' . $this->accessToken; + } else { + $headers[] = 'X-Edge-Access-Token: ' . $this->accessToken; + } + } + if ($params) { + $headers[] = 'Content-Type: application/json'; + $body = json_encode($params); + } + $response = curl_client($url, $body, null, null, $headers, $this->proxy); + $result = json_decode($response['body'], true); + if (isset($result['code']) && $result['code'] == 200) { + return isset($result['data']) ? $result['data'] : null; + } elseif (isset($result['message'])) { + throw new Exception($result['message']); + } else { + if (!empty($response['body'])) $this->log('Response:' . $response['body']); + throw new Exception('返回数据解析失败'); + } + } + + 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/huawei.php b/app/lib/deploy/huawei.php new file mode 100644 index 0000000..a53d5b4 --- /dev/null +++ b/app/lib/deploy/huawei.php @@ -0,0 +1,147 @@ +AccessKeyId = $config['AccessKeyId']; + $this->SecretAccessKey = $config['SecretAccessKey']; + } + + public function check() + { + if (empty($this->AccessKeyId) || empty($this->SecretAccessKey)) throw new Exception('必填参数不能为空'); + $client = new HuaweiCloud($this->AccessKeyId, $this->SecretAccessKey, 'scm.cn-north-4.myhuaweicloud.com'); + $client->request('GET', '/v3/scm/certificates'); + return true; + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + $config['cert_name'] = str_replace('*.', '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t']; + if($config['product'] == 'cdn'){ + $this->deploy_cdn($fullchain, $privatekey, $config); + }elseif($config['product'] == 'elb'){ + $this->deploy_elb($fullchain, $privatekey, $config); + }elseif($config['product'] == 'waf'){ + $this->deploy_waf($fullchain, $privatekey, $config); + } + } + + private function deploy_cdn($fullchain, $privatekey, $config) + { + if (empty($config['domain'])) throw new Exception('绑定的域名不能为空'); + $client = new HuaweiCloud($this->AccessKeyId, $this->SecretAccessKey, 'cdn.myhuaweicloud.com'); + $param = [ + 'configs' => [ + 'https' => [ + 'https_status' => 'on', + 'certificate_type' => 'server', + 'certificate_source' => 0, + 'certificate_name' => $config['cert_name'], + 'certificate_value' => $fullchain, + 'private_key' => $privatekey, + ], + ], + ]; + $client->request('PUT', '/v1.1/cdn/configuration/domains/'.$config['domain'].'/configs', null, $param); + $this->log('CDN域名 ' . $config['domain'] . ' 部署证书成功!'); + } + + private function deploy_elb($fullchain, $privatekey, $config) + { + if (empty($config['project_id'])) throw new Exception('项目ID不能为空'); + if (empty($config['region_id'])) throw new Exception('区域ID不能为空'); + if (empty($config['cert_id'])) throw new Exception('证书ID不能为空'); + $endpoint = 'elb.'.$config['region_id'].'.myhuaweicloud.com'; + $client = new HuaweiCloud($this->AccessKeyId, $this->SecretAccessKey, $endpoint); + try{ + $data = $client->request('GET', '/v3/'.$config['project_id'].'/elb/certificates/'.$config['cert_id']); + }catch(Exception $e){ + throw new Exception('证书详情查询失败:'.$e->getMessage()); + } + if(isset($data['certificate']['certificate']) && trim($data['certificate']['certificate']) == trim($fullchain)){ + $this->log('ELB证书ID '.$config['cert_id'].' 已存在,无需重复部署'); + return; + } + $param = [ + 'certificate' => [ + 'certificate' => $fullchain, + 'private_key' => $privatekey, + 'domain' => implode(',', $config['domainList']), + ], + ]; + $client->request('PUT', '/v3/'.$config['project_id'].'/elb/certificates/'.$config['cert_id'], null, $param); + $this->log('ELB证书ID ' . $config['cert_id'] . ' 更新证书成功!'); + } + + private function deploy_waf($fullchain, $privatekey, $config) + { + if (empty($config['project_id'])) throw new Exception('项目ID不能为空'); + if (empty($config['region_id'])) throw new Exception('区域ID不能为空'); + if (empty($config['cert_id'])) throw new Exception('证书ID不能为空'); + $endpoint = 'waf.'.$config['region_id'].'.myhuaweicloud.com'; + $client = new HuaweiCloud($this->AccessKeyId, $this->SecretAccessKey, $endpoint); + try{ + $data = $client->request('GET', '/v1/'.$config['project_id'].'/waf/certificates/'.$config['cert_id']); + }catch(Exception $e){ + throw new Exception('证书详情查询失败:'.$e->getMessage()); + } + if(isset($data['content']) && trim($data['content']) == trim($fullchain)){ + $this->log('WAF证书ID '.$config['cert_id'].' 已存在,无需重复部署'); + return; + } + $param = [ + 'name' => $config['cert_name'], + 'content' => $fullchain, + 'key' => $privatekey, + ]; + $client->request('PUT', '/v1/'.$config['project_id'].'/waf/certificates/'.$config['cert_id'], null, $param); + $this->log('WAF证书ID ' . $config['cert_id'] . ' 更新证书成功!'); + } + + private function get_cert_id($fullchain, $privatekey) + { + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + $cert_name = str_replace('*.', '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t']; + + $client = new HuaweiCloud($this->AccessKeyId, $this->SecretAccessKey, 'scm.cn-north-4.myhuaweicloud.com'); + $param = [ + 'name' => $cert_name, + 'certificate' => $fullchain, + 'private_key' => $privatekey, + ]; + try { + $data = $client->request('POST', '/v3/scm/certificates/import', null, $param); + } catch (Exception $e) { + throw new Exception('上传证书失败:' . $e->getMessage()); + } + $this->log('上传证书成功 certificate_id=' . $data['certificate_id']); + return $data['certificate_id']; + } + + 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/huoshan.php b/app/lib/deploy/huoshan.php new file mode 100644 index 0000000..b663758 --- /dev/null +++ b/app/lib/deploy/huoshan.php @@ -0,0 +1,96 @@ +AccessKeyId = $config['AccessKeyId']; + $this->SecretAccessKey = $config['SecretAccessKey']; + } + + public function check() + { + if (empty($this->AccessKeyId) || empty($this->SecretAccessKey)) throw new Exception('必填参数不能为空'); + $client = new Volcengine($this->AccessKeyId, $this->SecretAccessKey, 'cdn.volcengineapi.com', 'cdn', '2021-03-01', 'cn-north-1'); + $client->request('POST', 'ListCertInfo', ['Source' => 'volc_cert_center']); + return true; + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + if (empty($config['domain'])) throw new Exception('绑定的域名不能为空'); + $cert_id = $this->get_cert_id($fullchain, $privatekey); + if (!$cert_id) throw new Exception('获取证书ID失败'); + $info['cert_id'] = $cert_id; + $this->deploy_cdn($cert_id, $config); + } + + private function deploy_cdn($cert_id, $config) + { + if (empty($config['domain'])) throw new Exception('绑定的域名不能为空'); + $client = new Volcengine($this->AccessKeyId, $this->SecretAccessKey, 'cdn.volcengineapi.com', 'cdn', '2021-03-01', 'cn-north-1'); + $param = [ + 'CertId' => $cert_id, + 'Domain' => $config['domain'], + ]; + $data = $client->request('POST', 'BatchDeployCert', $param); + if (empty($data['DeployResult'])) throw new Exception('部署证书失败:DeployResult为空'); + foreach ($data['DeployResult'] as $row) { + if ($row['Status'] == 'success') { + $this->log('CDN域名 ' . $row['Domain'] . ' 部署证书成功!'); + } else { + $this->log('CDN域名 ' . $row['Domain'] . ' 部署证书失败:' . isset($row['ErrorMsg']) ? $row['ErrorMsg'] : ''); + } + } + } + + private function get_cert_id($fullchain, $privatekey) + { + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + $cert_name = str_replace('*.', '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t']; + + $client = new Volcengine($this->AccessKeyId, $this->SecretAccessKey, 'cdn.volcengineapi.com', 'cdn', '2021-03-01', 'cn-north-1'); + $param = [ + 'Source' => 'volc_cert_center', + 'Certificate' => $fullchain, + 'PrivateKey' => $privatekey, + 'Desc' => $cert_name, + 'Repeatable' => false, + ]; + try { + $data = $client->request('POST', 'AddCertificate', $param); + } catch (Exception $e) { + if(strpos($e->getMessage(), '证书已存在,ID为')!==false){ + $cert_id = trim(getSubstr($e->getMessage(), '证书已存在,ID为', '。')); + $this->log('证书已存在 CertId=' . $cert_id); + return $cert_id; + } + throw new Exception('上传证书失败:' . $e->getMessage()); + } + $this->log('上传证书成功 CertId=' . $data['CertId']); + return $data['CertId']; + } + + 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/kangle.php b/app/lib/deploy/kangle.php new file mode 100644 index 0000000..c8e15a8 --- /dev/null +++ b/app/lib/deploy/kangle.php @@ -0,0 +1,208 @@ +url = rtrim($config['url'], '/'); + $this->auth = $config['auth']; + $this->username = $config['username']; + $this->password = $config['password']; + $this->skey = $config['skey']; + $this->proxy = $config['proxy'] == 1; + } + + public function check() + { + if (empty($this->url) || empty($this->username)) throw new Exception('必填参数不能为空'); + $this->login(); + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + $this->login(); + $this->log('登录成功 cookie:' . $this->cookie); + $this->getMain(); + + if ($config['type'] == '1' && !empty($config['domains'])) { + $domains = explode("\n", $config['domains']); + $success = 0; + $errmsg = null; + foreach ($domains as $domain) { + $domain = trim($domain); + if (empty($domain)) continue; + try { + $this->deployDomain($domain, $fullchain, $privatekey); + $this->log("域名 {$domain} 证书部署成功"); + $success++; + } catch (Exception $e) { + $errmsg = $e->getMessage(); + $this->log("域名 {$domain} 证书部署失败:" . $errmsg); + } + } + if ($success == 0) { + throw new Exception($errmsg ? $errmsg : '要部署的域名不存在'); + } + } else { + $this->deployAccount($fullchain, $privatekey); + $this->log("账号级SSL证书部署成功"); + } + } + + private function deployDomain($domain, $fullchain, $privatekey) + { + $path = '/vhost/?c=ssl&a=domainSsl'; + $post = [ + 'domain' => $domain, + 'certificate' => $fullchain, + 'certificate_key' => $privatekey, + ]; + $response = curl_client($this->url . $path, http_build_query($post), null, $this->cookie, null, $this->proxy); + if (strpos($response['body'], '成功')) { + return true; + } elseif (preg_match('/alert\(\'(.*?)\'\)/i', $response['body'], $match)) { + throw new Exception(htmlspecialchars($match[1])); + } elseif (strlen($response['body']) > 3 && strlen($response['body']) < 50) { + throw new Exception(htmlspecialchars($response['body'])); + } else { + throw new Exception('原因未知(httpCode=' . $response['code'] . ')'); + } + } + + private function deployAccount($fullchain, $privatekey) + { + $path = '/vhost/?c=ssl&a=ssl'; + $post = [ + 'certificate' => $fullchain, + 'certificate_key' => $privatekey, + ]; + $response = curl_client($this->url . $path, http_build_query($post), null, $this->cookie, null, $this->proxy); + if (strpos($response['body'], '成功')) { + return true; + } elseif (preg_match('/alert\(\'(.*?)\'\)/i', $response['body'], $match)) { + throw new Exception(htmlspecialchars($match[1])); + } elseif (strlen($response['body']) > 3 && strlen($response['body']) < 50) { + throw new Exception(htmlspecialchars($response['body'])); + } else { + throw new Exception('原因未知(httpCode=' . $response['code'] . ')'); + } + } + + private function login() + { + if ($this->auth == '1') { + return $this->loginBySkey(); + } else { + return $this->loginByPwd(); + } + } + + private function loginBySkey() + { + $url = $this->url . '/vhost/index.php?c=sso&a=hello&url=' . urlencode($this->url . '/index.php?'); + $response = curl_client($url, null, null, null, null, $this->proxy); + if ($response['code'] == 302 && !empty($response['redirect_url'])) { + $cookie = ''; + if (preg_match_all('/Set-Cookie: (.*);/iU', $response['header'], $matchs)) { + foreach ($matchs[1] as $val) { + $arr = explode('=', $val); + if ($arr[1] == '' || $arr[1] == 'deleted') continue; + $cookie .= $val . '; '; + } + $query = parse_url($response['redirect_url'], PHP_URL_QUERY); + parse_str($query, $params); + if (isset($params['r'])) { + $sess_key = $params['r']; + $this->loginBySkey2($cookie, $sess_key); + $this->cookie = $cookie; + return true; + } else { + throw new Exception('获取SSO凭据失败,sess_key获取失败'); + } + } else { + throw new Exception('获取SSO凭据失败,获取cookie失败'); + } + } elseif (strlen($response['body']) > 3 && strlen($response['body']) < 50) { + throw new Exception('获取SSO凭据失败 (' . htmlspecialchars($response['body']) . ')'); + } else { + throw new Exception('获取SSO凭据失败 (httpCode=' . $response['code'] . ')'); + } + } + + private function loginBySkey2($cookie, $sess_key) + { + $s = md5($sess_key . $this->username . $sess_key . $this->skey); + $url = $this->url . '/vhost/index.php?c=sso&a=login&name=' . $this->username . '&r=' . $sess_key . '&s=' . $s; + $response = curl_client($url, null, null, $cookie, null, $this->proxy); + if ($response['code'] == 302) { + return true; + } elseif (strlen($response['body']) > 3 && strlen($response['body']) < 50) { + throw new Exception('SSO登录失败 (' . htmlspecialchars($response['body']) . ')'); + } else { + throw new Exception('SSO登录失败 (httpCode=' . $response['code'] . ')'); + } + } + + private function loginByPwd() + { + $referer = $this->url . '/vhost/index.php?c=session&a=loginForm'; + $url = $this->url . '/vhost/index.php?c=session&a=login'; + $post = [ + 'username' => $this->username, + 'passwd' => $this->password, + ]; + $response = curl_client($url, http_build_query($post), $referer, null, null, $this->proxy); + if ($response['code'] == 302) { + $cookie = ''; + if (preg_match_all('/Set-Cookie: (.*);/iU', $response['header'], $matchs)) { + foreach ($matchs[1] as $val) { + $arr = explode('=', $val); + if ($arr[1] == '' || $arr[1] == 'deleted') continue; + $cookie .= $val . '; '; + } + $this->cookie = $cookie; + return true; + } else { + throw new Exception('登录失败,获取cookie失败'); + } + } elseif (strpos($response['body'], '验证码错误')) { + throw new Exception('登录失败,需输入验证码'); + } elseif (strpos($response['body'], '密码错误')) { + throw new Exception('登录失败,用户名或密码错误'); + } else { + throw new Exception('登录失败 (httpCode=' . $response['code'] . ')'); + } + } + + private function getMain() + { + $path = '/vhost/'; + curl_client($this->url . $path, null, null, $this->cookie, null, $this->proxy); + } + + 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/lecdn.php b/app/lib/deploy/lecdn.php new file mode 100644 index 0000000..2e64c99 --- /dev/null +++ b/app/lib/deploy/lecdn.php @@ -0,0 +1,106 @@ +url = rtrim($config['url'], '/'); + $this->email = $config['email']; + $this->password = $config['password']; + $this->proxy = $config['proxy'] == 1; + } + + public function check() + { + if (empty($this->url) || empty($this->email) || empty($this->password)) throw new Exception('账号和密码不能为空'); + $this->login(); + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + $id = $config['id']; + if (empty($id)) throw new Exception('证书ID不能为空'); + + $this->login(); + + try { + $data = $this->request('/prod-api/certificate/' . $id); + } catch (Exception $e) { + throw new Exception('证书ID:' . $id . '获取失败:' . $e->getMessage()); + } + + $params = [ + 'id' => intval($id), + 'name' => $data['name'], + 'description' => $data['description'], + 'type' => 'upload', + 'ssl_pem' => base64_encode($fullchain), + 'ssl_key' => base64_encode($privatekey), + 'auto_renewal' => false, + ]; + $this->request('/prod-api/certificate/' . $id, $params, 'PUT'); + $this->log("证书ID:{$id}更新成功!"); + } + + private function login() + { + $path = '/prod-api/login'; + $params = [ + 'email' => $this->email, + 'password' => $this->password, + ]; + $result = $this->request($path, $params); + if (isset($result['access_token'])) { + $this->accessToken = $result['access_token']; + } else { + throw new Exception('登录成功,获取access_token失败'); + } + } + + private function request($path, $params = null, $method = null) + { + $url = $this->url . $path; + $headers = []; + $body = null; + if ($this->accessToken) { + $headers[] = 'Authorization: Bearer ' . $this->accessToken; + } + if ($params) { + $headers[] = 'Content-Type: application/json;charset=UTF-8'; + $body = json_encode($params); + } + $response = curl_client($url, $body, null, null, $headers, $this->proxy, $method); + $result = json_decode($response['body'], true); + if (isset($result['code']) && $result['code'] == 0) { + return isset($result['data']) ? $result['data'] : null; + } elseif (isset($result['message'])) { + throw new Exception($result['message']); + } else { + throw new Exception('返回数据解析失败'); + } + } + + 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/local.php b/app/lib/deploy/local.php new file mode 100644 index 0000000..71b7027 --- /dev/null +++ b/app/lib/deploy/local.php @@ -0,0 +1,77 @@ +log('证书已保存到:' . $config['pem_cert_file']); + } else { + throw new Exception('证书保存到' . $config['pem_cert_file'] . '失败,请检查目录权限'); + } + if (file_put_contents($config['pem_key_file'], $privatekey)) { + $this->log('私钥已保存到:' . $config['pem_key_file']); + } else { + throw new Exception('私钥保存到' . $config['pem_key_file'] . '失败,请检查目录权限'); + } + } elseif ($config['format'] == 'pfx') { + $dir = dirname($config['pfx_file']); + if (!is_dir($dir)) throw new Exception($dir.' 目录不存在'); + if (!is_writable($dir)) throw new Exception($dir.' 目录不可写'); + + $pfx = \app\lib\CertHelper::getPfx($fullchain, $privatekey, $config['pfx_pass'] ? $config['pfx_pass'] : null); + if (file_put_contents($config['pfx_file'], $pfx)) { + $this->log('PFX证书已保存到:' . $config['pfx_file']); + } else { + throw new Exception('PFX证书保存到' . $config['pfx_file'] . '失败,请检查目录权限'); + } + } + if (!empty($config['cmd'])) { + $cmds = explode("\n", $config['cmd']); + foreach($cmds as $cmd){ + $cmd = trim($cmd); + if(empty($cmd)) continue; + $this->log('执行命令:'.$cmd); + $output = []; + $ret = 0; + exec($cmd, $output, $ret); + if ($ret == 0) { + $this->log('执行命令成功:' . implode("\n", $output)); + } else { + throw new Exception('执行命令失败:' . implode("\n", $output)); + } + } + } + } + + private function log($txt) + { + if ($this->logger) { + call_user_func($this->logger, $txt); + } + } + + public function setLogger($logger) + { + $this->logger = $logger; + } +} diff --git a/app/lib/deploy/opanel.php b/app/lib/deploy/opanel.php new file mode 100644 index 0000000..31dca1a --- /dev/null +++ b/app/lib/deploy/opanel.php @@ -0,0 +1,111 @@ +url = rtrim($config['url'], '/'); + $this->key = $config['key']; + $this->proxy = $config['proxy'] == 1; + } + + public function check() + { + if (empty($this->url) || empty($this->key)) throw new Exception('请填写面板地址和接口密钥'); + $this->request('/api/v1/settings/search'); + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + $domains = $config['domainList']; + if (empty($domains)) throw new Exception('没有设置要部署的域名'); + + $params = ['page'=>1, 'pageSize'=>500]; + try { + $data = $this->request('/api/v1/websites/ssl/search', $params); + $this->log('获取证书列表成功(total=' . $data['total'] . ')'); + } catch (Exception $e) { + throw new Exception('获取证书列表失败:' . $e->getMessage()); + } + + $success = 0; + $errmsg = null; + foreach ($data['items'] as $row) { + if (empty($row['primaryDomain'])) continue; + $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 ($success == 0) { + throw new Exception($errmsg ? $errmsg : '没有要更新的证书'); + } + } + + public function setLogger($func) + { + $this->logger = $func; + } + + private function log($txt) + { + if ($this->logger) { + call_user_func($this->logger, $txt); + } + } + + private function request($path, $params = null) + { + $url = $this->url . $path; + + $timestamp = getMillisecond(); + $token = md5('1panel' . $this->key . $timestamp); + $headers = [ + '1Panel-Token: '.$token, + '1Panel-Timestamp: '.$timestamp + ]; + $body = $params ? json_encode($params) : null; + if($body) $headers[] = 'Content-Type: application/json'; + $response = curl_client($url, $body, null, null, $headers, $this->proxy); + $result = json_decode($response['body'], true); + if(isset($result['code']) && $result['code'] == 200){ + return isset($result['data']) ? $result['data'] : null; + }elseif(isset($result['message'])){ + throw new Exception($result['message']); + }else{ + throw new Exception('请求失败(httpCode=' . $response['code'] . ')'); + } + } +} diff --git a/app/lib/deploy/qiniu.php b/app/lib/deploy/qiniu.php new file mode 100644 index 0000000..1bc2c07 --- /dev/null +++ b/app/lib/deploy/qiniu.php @@ -0,0 +1,145 @@ +AccessKey = $config['AccessKey']; + $this->SecretKey = $config['SecretKey']; + $this->client = new QiniuClient($this->AccessKey, $this->SecretKey); + } + + public function check() + { + if (empty($this->AccessKey) || empty($this->SecretKey)) throw new Exception('必填参数不能为空'); + $this->client->request('GET', '/sslcert'); + return true; + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + $domain = $config['domain']; + if (empty($domain)) throw new Exception('绑定的域名不能为空'); + + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + $cert_name = str_replace('*.', '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t']; + + $cert_id = $this->get_cert_id($fullchain, $privatekey, $certInfo['subject']['CN'], $cert_name); + + if($config['product'] == 'cdn'){ + $this->deploy_cdn($domain, $cert_id); + }elseif($config['product'] == 'oss'){ + $this->deploy_oss($domain, $cert_id); + }else{ + throw new Exception('未知的产品类型'); + } + $info['cert_id'] = $cert_id; + $info['cert_name'] = $cert_name; + } + + private function deploy_cdn($domain, $cert_id) + { + try { + $data = $this->client->request('GET', '/domain/' . $domain); + } catch (Exception $e) { + throw new Exception('获取域名信息失败:' . $e->getMessage()); + } + if (isset($data['https']['certId']) && $data['https']['certId'] == $cert_id) { + $this->log('域名 ' . $domain . ' 证书已部署,无需重复操作'); + return; + } + + if (empty($data['https']['certId'])) { + $param = [ + 'certid' => $cert_id, + ]; + $this->client->request('PUT', '/domain/' . $domain . '/sslize', null, $param); + } else { + $param = [ + 'certid' => $cert_id, + 'forceHttps' => $data['https']['forceHttps'], + 'http2Enable' => $data['https']['http2Enable'], + ]; + $this->client->request('PUT', '/domain/' . $domain . '/httpsconf', null, $param); + } + $this->log('CDN域名 ' . $domain . ' 证书部署成功!'); + } + + private function deploy_oss($domain, $cert_id) + { + $param = [ + 'certid' => $cert_id, + 'domain' => $domain, + ]; + $this->client->request('POST', '/cert/bind', null, $param); + $this->log('OSS域名 ' . $domain . ' 证书部署成功!'); + } + + private function get_cert_id($fullchain, $privatekey, $common_name, $cert_name) + { + $cert_id = null; + $marker = ''; + do { + $query = ['marker' => $marker, 'limit' => 100]; + $data = $this->client->request('GET', '/sslcert', $query); + if (empty($data['certs'])) break; + $marker = $data['marker']; + foreach ($data['certs'] as $cert) { + if ($cert_name == $cert['name']) { + $cert_id = $cert['certid']; + $this->log('证书' . $cert_name . '已存在,证书ID:' . $cert_id); + } elseif ($cert['not_after'] < time()) { + try { + $this->client->request('DELETE', '/sslcert/' . $cert['certid']); + $this->log('证书' . $cert['name'] . '已过期,删除证书成功'); + } catch (Exception $e) { + $this->log('证书' . $cert['name'] . '已过期,删除证书失败:' . $e->getMessage()); + } + usleep(300000); + } + } + } while ($marker != ''); + + if (!$cert_id) { + $param = [ + 'name' => $cert_name, + 'common_name' => $common_name, + 'pri' => $privatekey, + 'ca' => $fullchain, + ]; + try { + $data = $this->client->request('POST', '/sslcert', null, $param); + } catch (Exception $e) { + throw new Exception('上传证书失败:' . $e->getMessage()); + } + $this->log('上传证书成功,证书ID:' . $data['certID']); + $cert_id = $data['certID']; + usleep(500000); + } + return $cert_id; + } + + 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/safeline.php b/app/lib/deploy/safeline.php new file mode 100644 index 0000000..66d9a46 --- /dev/null +++ b/app/lib/deploy/safeline.php @@ -0,0 +1,104 @@ +url = rtrim($config['url'], '/'); + $this->token = $config['token']; + $this->proxy = $config['proxy'] == 1; + } + + public function check() + { + if (empty($this->url) || empty($this->token)) throw new Exception('请填写控制台地址和API Token'); + $this->request('/api/open/system'); + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + $domains = $config['domainList']; + if (empty($domains)) throw new Exception('没有设置要部署的域名'); + + try { + $data = $this->request('/api/open/cert'); + $this->log('获取证书列表成功(total=' . $data['total'] . ')'); + } catch (Exception $e) { + throw new Exception('获取证书列表失败:' . $e->getMessage()); + } + + $success = 0; + $errmsg = null; + foreach ($data['nodes'] as $row) { + if (empty($row['domains'])) continue; + $flag = false; + foreach ($row['domains'] as $domain) { + if (in_array($domain, $domains)) { + $flag = true; + break; + } + } + if ($flag) { + $params = [ + 'id' => $row['id'], + 'manual' => [ + 'crt' => $fullchain, + 'key' => $privatekey, + ], + 'type' => 2, + ]; + try { + $this->request('/api/open/cert', $params); + $this->log("证书ID:{$row['id']}更新成功!"); + $success++; + } catch (Exception $e) { + $errmsg = $e->getMessage(); + $this->log("证书ID:{$row['id']}更新失败:" . $errmsg); + } + } + } + if ($success == 0) { + throw new Exception($errmsg ? $errmsg : '没有要更新的证书'); + } + } + + private function request($path, $params = null) + { + $url = $this->url . $path; + $headers = ['X-SLCE-API-TOKEN: ' . $this->token]; + $body = null; + if ($params) { + $heders[] = 'Content-Type: application/json'; + $body = json_encode($params); + } + $response = curl_client($url, $body, null, null, $headers, $this->proxy); + $result = json_decode($response['body'], true); + if ($response['code'] == 200 && $result) { + return isset($result['data']) ? $result['data'] : null; + } else { + throw new Exception(!empty($result['msg']) ? $result['msg'] : '请求失败(httpCode=' . $response['code'] . ')'); + } + } + + 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/ssh.php b/app/lib/deploy/ssh.php new file mode 100644 index 0000000..3ac10f1 --- /dev/null +++ b/app/lib/deploy/ssh.php @@ -0,0 +1,205 @@ +config = $config; + } + + public function check() + { + $this->connect(); + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + $connection = $this->connect(); + $sftp = ssh2_sftp($connection); + if ($config['format'] == 'pem') { + $stream = fopen("ssh2.sftp://$sftp{$config['pem_cert_file']}", 'w'); + if (!$stream) { + throw new Exception("无法创建证书文件:{$config['pem_cert_file']}"); + } + fwrite($stream, $fullchain); + fclose($stream); + $this->log('证书已保存到:' . $config['pem_cert_file']); + + $stream = fopen("ssh2.sftp://$sftp{$config['pem_key_file']}", 'w'); + if (!$stream) { + throw new Exception("无法创建私钥文件:{$config['pem_key_file']}"); + } + fwrite($stream, $privatekey); + fclose($stream); + $this->log('私钥已保存到:' . $config['pem_key_file']); + } elseif ($config['format'] == 'pfx') { + $pfx = \app\lib\CertHelper::getPfx($fullchain, $privatekey, $config['pfx_pass'] ? $config['pfx_pass'] : null); + + $stream = fopen("ssh2.sftp://$sftp{$config['pfx_file']}", 'w'); + if (!$stream) { + throw new Exception("无法创建PFX证书文件:{$config['pfx_file']}"); + } + fwrite($stream, $pfx); + fclose($stream); + $this->log('PFX证书已保存到:' . $config['pfx_file']); + + if ($config['uptype'] == '1' && !empty($config['iis_domain'])) { + $cert_hash = openssl_x509_fingerprint($fullchain, 'sha1'); + $this->deploy_iis($connection, $config['iis_domain'], $config['pfx_file'], $config['pfx_pass'], $cert_hash); + $config['cmd'] = null; + } + } + if (!empty($config['cmd'])) { + $cmds = explode("\n", $config['cmd']); + foreach ($cmds as $cmd) { + $cmd = trim($cmd); + if (empty($cmd)) continue; + $this->exec($connection, $cmd); + } + } + } + + private function deploy_iis($connection, $domain, $pfx_file, $pfx_pass, $cert_hash) + { + if (!strpos($domain, ':')) { + $domain .= ':443'; + } + $ret = $this->exec($connection, 'netsh http show sslcert hostnameport=' . $domain); + if (preg_match('/:\s+(\w{40})/', $ret, $match)) { + if ($match[1] == $cert_hash) { + $this->log('IIS域名 ' . $domain . ' 证书已存在,无需更新'); + return; + } + } + $p = '-p ""'; + if (!empty($pfx_pass)) $p = '-p ' . $pfx_pass; + if (substr($pfx_file, 0, 1) == '/') $pfx_file = substr($pfx_file, 1); + $this->exec($connection, 'certutil ' . $p . ' -importPFX ' . $pfx_file); + $this->exec($connection, 'netsh http delete sslcert hostnameport=' . $domain); + $this->exec($connection, 'netsh http add sslcert hostnameport=' . $domain . ' certhash=' . $cert_hash . ' certstorename=MY appid=\'{' . $this->uuid() . '}\''); + $this->log('IIS域名 ' . $domain . ' 证书已更新'); + } + + private function uuid() + { + $guid = md5(uniqid(mt_rand(), true)); + return substr($guid, 0, 8) . '-' . substr($guid, 8, 4) . '-4' . substr($guid, 12, 3) . '-' . substr($guid, 16, 4) . '-' . substr($guid, 20, 12); + } + + private function exec($connection, $cmd) + { + $this->log('执行命令:' . $cmd); + $stream = ssh2_exec($connection, $cmd); + $errorStream = ssh2_fetch_stream($stream, SSH2_STREAM_STDERR); + if (!$stream || !$errorStream) { + throw new Exception('执行命令失败'); + } + stream_set_blocking($stream, true); + stream_set_blocking($errorStream, true); + $output = stream_get_contents($stream); + $errorOutput = stream_get_contents($errorStream); + fclose($stream); + fclose($errorStream); + if (trim($errorOutput)) { + if ($this->config['windows'] == '1' && $this->containsGBKChinese($errorOutput)) { + $errorOutput = mb_convert_encoding($errorOutput, 'UTF-8', 'GBK'); + } + throw new Exception('执行命令失败:' . trim($errorOutput)); + } else { + if ($this->config['windows'] == '1' && $this->containsGBKChinese($output)) { + $output = mb_convert_encoding($output, 'UTF-8', 'GBK'); + } + $this->log('执行命令成功:' . trim($output)); + return $output; + } + } + + private function connect() + { + if (!function_exists('ssh2_connect')) { + throw new Exception('ssh2扩展未安装'); + } + if (empty($this->config['host']) || empty($this->config['port']) || empty($this->config['username']) || $this->config['auth'] == '0' && empty($this->config['password']) || $this->config['auth'] == '1' && empty($this->config['privatekey'])) { + throw new Exception('必填参数不能为空'); + } + if (!filter_var($this->config['host'], FILTER_VALIDATE_IP) && !filter_var($this->config['host'], FILTER_VALIDATE_DOMAIN)) { + throw new Exception('主机地址不合法'); + } + if (!is_numeric($this->config['port']) || $this->config['port'] < 1 || $this->config['port'] > 65535) { + throw new Exception('端口不合法'); + } + + $connection = ssh2_connect($this->config['host'], intval($this->config['port'])); + if (!$connection) { + throw new Exception('SSH连接失败'); + } + if ($this->config['auth'] == '1') { + $publicKey = $this->getPublicKey($this->config['privatekey']); + $publicKeyPath = app()->getRuntimePath() . $this->config['host'] . '.pub'; + $privateKeyPath = app()->getRuntimePath() . $this->config['host'] . '.key'; + $umask = umask(0066); + file_put_contents($privateKeyPath, $this->config['privatekey']); + file_put_contents($publicKeyPath, $publicKey); + umask($umask); + 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'])) { + throw new Exception('用户名或密码错误'); + } + } + return $connection; + } + + private function getPublicKey($privateKey) + { + $res = openssl_pkey_get_private($privateKey); + if (!$res) { + throw new Exception('加载私钥失败'); + } + $details = openssl_pkey_get_details($res); + if (!$details || !isset($details['key'])) { + throw new Exception('从私钥导出公钥失败'); + } + $buffer = pack("N", 7) . "ssh-rsa" . + $this->sshEncodeBuffer($details['rsa']['e']) . + $this->sshEncodeBuffer($details['rsa']['n']); + return "ssh-rsa " . base64_encode($buffer); + } + + private function sshEncodeBuffer($buffer) + { + $len = strlen($buffer); + if (ord($buffer[0]) & 0x80) { + $len++; + $buffer = "\x00" . $buffer; + } + return pack("Na*", $len, $buffer); + } + + private function containsGBKChinese($string) + { + return preg_match('/[\x81-\xFE][\x40-\xFE]/', $string) === 1; + } + + private function log($txt) + { + if ($this->logger) { + call_user_func($this->logger, $txt); + } + } + + public function setLogger($logger) + { + $this->logger = $logger; + } +} diff --git a/app/lib/deploy/tencent.php b/app/lib/deploy/tencent.php new file mode 100644 index 0000000..1cde46a --- /dev/null +++ b/app/lib/deploy/tencent.php @@ -0,0 +1,249 @@ +SecretId = $config['SecretId']; + $this->SecretKey = $config['SecretKey']; + $this->client = new TencentCloud($this->SecretId, $this->SecretKey, 'ssl.tencentcloudapi.com', 'ssl', '2019-12-05'); + } + + public function check() + { + if (empty($this->SecretId) || empty($this->SecretKey)) throw new Exception('必填参数不能为空'); + $this->client->request('DescribeCertificates', []); + return true; + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + $cert_id = $this->get_cert_id($fullchain, $privatekey); + if (!$cert_id) throw new Exception('证书ID获取失败'); + if ($config['product'] == 'cos') { + if (empty($config['regionid'])) throw new Exception('所属地域ID不能为空'); + if (empty($config['cos_bucket'])) throw new Exception('存储桶名称不能为空'); + if (empty($config['domain'])) throw new Exception('绑定的域名不能为空'); + $instance_id = $config['regionid'] . '#' . $config['cos_bucket'] . '#' . $config['domain']; + $this->client = new TencentCloud($this->SecretId, $this->SecretKey, 'ssl.tencentcloudapi.com', 'ssl', '2019-12-05', $config['regionid']); + } elseif ($config['product'] == 'tke') { + if (empty($config['regionid'])) throw new Exception('所属地域ID不能为空'); + if (empty($config['tke_cluster_id'])) throw new Exception('集群ID不能为空'); + if (empty($config['tke_namespace'])) throw new Exception('命名空间不能为空'); + if (empty($config['tke_secret'])) throw new Exception('secret名称不能为空'); + $instance_id = $config['tke_cluster_id'] . '|' . $config['tke_namespace'] . '|' . $config['tke_secret']; + $this->client = new TencentCloud($this->SecretId, $this->SecretKey, 'ssl.tencentcloudapi.com', 'ssl', '2019-12-05', $config['regionid']); + } elseif ($config['product'] == 'lighthouse') { + if (empty($config['regionid'])) throw new Exception('所属地域ID不能为空'); + if (empty($config['lighthouse_id'])) throw new Exception('实例ID不能为空'); + if (empty($config['domain'])) throw new Exception('绑定的域名不能为空'); + $instance_id = $config['regionid'] . '|' . $config['lighthouse_id'] . '|' . $config['domain']; + $this->client = new TencentCloud($this->SecretId, $this->SecretKey, 'ssl.tencentcloudapi.com', 'ssl', '2019-12-05', $config['regionid']); + } elseif ($config['product'] == 'clb') { + return $this->deploy_clb($cert_id, $config); + } elseif ($config['product'] == 'scf') { + return $this->deploy_scf($cert_id, $config); + } else { + if (empty($config['domain'])) throw new Exception('绑定的域名不能为空'); + if ($config['product'] == 'waf') { + $this->client = new TencentCloud($this->SecretId, $this->SecretKey, 'ssl.tencentcloudapi.com', 'ssl', '2019-12-05', $config['region']); + } elseif (in_array($config['product'], ['tse','scf'])) { + if (empty($config['regionid'])) throw new Exception('所属地域ID不能为空'); + $this->client = new TencentCloud($this->SecretId, $this->SecretKey, 'ssl.tencentcloudapi.com', 'ssl', '2019-12-05', $config['regionid']); + } + $instance_id = $config['domain']; + } + try { + $record_id = $this->deploy_common($config['product'], $cert_id, $instance_id); + $info['cert_id'] = $cert_id; + $info['record_id'] = $record_id; + } catch (Exception $e) { + if (isset($info['record_id'])) { + if ($this->deploy_query($info['record_id'])) { + $this->log(strtoupper($config['product']) . '实例 ' . $instance_id . ' 已部署证书,无需重复部署'); + return; + } + } + throw $e; + } + } + + private function get_cert_id($fullchain, $privatekey) + { + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + $cert_name = str_replace('*.', '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t']; + + $param = [ + 'CertificatePublicKey' => $fullchain, + 'CertificatePrivateKey' => $privatekey, + 'CertificateType' => 'SVR', + 'Alias' => $cert_name, + 'Repeatable' => false, + ]; + try { + $data = $this->client->request('UploadCertificate', $param); + } catch (Exception $e) { + throw new Exception('上传证书失败:' . $e->getMessage()); + } + $this->log('上传证书成功 CertificateId=' . $data['CertificateId']); + usleep(300000); + return $data['CertificateId']; + } + + private function deploy_common($product, $cert_id, $instance_id) + { + $param = [ + 'CertificateId' => $cert_id, + 'InstanceIdList' => [$instance_id], + 'ResourceType' => $product, + ]; + $data = $this->client->request('DeployCertificateInstance', $param); + $this->log(json_encode($data)); + $this->log(strtoupper($product) . '实例 ' . $instance_id . ' 部署证书成功!'); + return $data['DeployRecordId']; + } + + private function deploy_query($record_id) + { + $param = [ + 'DeployRecordId' => strval($record_id), + ]; + try { + $data = $this->client->request('DescribeHostDeployRecordDetail', $param); + if (isset($data['SuccessTotalCount']) && $data['SuccessTotalCount'] >= 1 || isset($data['RunningTotalCount']) && $data['RunningTotalCount'] >= 1) { + return true; + } + if (isset($data['FailedTotalCount']) && $data['FailedTotalCount'] >= 1 && !empty($data['DeployRecordDetailList'])) { + $errmsg = $data['DeployRecordDetailList'][0]['ErrorMsg']; + if (strpos($errmsg, '\u')) { + $errmsg = json_decode($errmsg); + } + $this->log('证书部署失败原因:' . $errmsg); + } + } catch (Exception $e) { + $this->log('查询证书部署记录失败:' . $e->getMessage()); + } + return false; + } + + private function deploy_clb($cert_id, $config) + { + if (empty($config['regionid'])) throw new Exception('所属地域ID不能为空'); + if (empty($config['clb_id'])) throw new Exception('负载均衡ID不能为空'); + $sni_switch = !empty($config['clb_domain']) ? 1 : 0; + + $client = new TencentCloud($this->SecretId, $this->SecretKey, 'clb.tencentcloudapi.com', 'clb', '2018-03-17', $config['regionid']); + $param = [ + 'LoadBalancerId' => $config['clb_id'], + 'Protocol' => 'HTTPS', + ]; + if (!empty($config['clb_listener_id'])) { + $param['ListenerIds'] = [$config['clb_listener_id']]; + } + try { + $data = $client->request('DescribeListeners', $param); + } catch (Exception $e) { + throw new Exception('获取监听器列表失败:' . $e->getMessage()); + } + if (!isset($data['TotalCount']) || $data['TotalCount'] == 0) throw new Exception('负载均衡:' . $config['clb_id'] . '监听器列表为空'); + $count = 0; + foreach ($data['Listeners'] as $listener) { + if ($listener['SniSwitch'] == $sni_switch) { + if ($sni_switch == 1) { + foreach ($listener['Rules'] as $rule) { + if ($rule['Domain'] == $config['clb_domain']) { + if (isset($rule['Certificate']['CertId']) && $cert_id == $rule['Certificate']['CertId']) { + $this->log('负载均衡监听器 ' . $listener['ListenerId'] . ' 域名 ' . $rule['Domain'] . ' 已部署证书,无需重复部署'); + } else { + $param = [ + 'LoadBalancerId' => $config['clb_id'], + 'ListenerId' => $listener['ListenerId'], + 'Domain' => $rule['Domain'], + 'Certificate' => [ + 'SSLMode' => 'UNIDIRECTIONAL', + 'CertId' => $cert_id, + ], + ]; + $client->request('ModifyDomainAttributes', $param); + $this->log('负载均衡监听器 ' . $listener['ListenerId'] . ' 域名 ' . $rule['Domain'] . ' 部署证书成功!'); + } + $count++; + } + } + } else { + if (isset($listener['Certificate']['CertId']) && $cert_id == $listener['Certificate']['CertId']) { + $this->log('负载均衡监听器 ' . $listener['ListenerId'] . ' 已部署证书,无需重复部署'); + } else { + $param = [ + 'LoadBalancerId' => $config['clb_id'], + 'ListenerId' => $listener['ListenerId'], + 'Certificate' => [ + 'SSLMode' => 'UNIDIRECTIONAL', + 'CertId' => $cert_id, + ], + ]; + $client->request('ModifyListener', $param); + $this->log('负载均衡监听器 ' . $listener['ListenerId'] . ' 部署证书成功!'); + } + $count++; + } + } + } + if ($count == 0) throw new Exception('没有找到要更新证书的监听器'); + } + + private function deploy_scf($cert_id, $config) + { + if (empty($config['regionid'])) throw new Exception('所属地域ID不能为空'); + if (empty($config['domain'])) throw new Exception('绑定的域名不能为空'); + + $client = new TencentCloud($this->SecretId, $this->SecretKey, 'scf.tencentcloudapi.com', 'scf', '2018-04-16', $config['regionid']); + $param = [ + 'Domain' => $config['domain'], + ]; + try { + $data = $client->request('GetCustomDomain', $param); + } catch (Exception $e) { + throw new Exception('获取云函数自定义域名失败:' . $e->getMessage()); + } + + if(isset($data['CertConfig']['CertificateId']) && $data['CertConfig']['CertificateId'] == $cert_id){ + $this->log('云函数自定义域名 ' . $config['domain'] . ' 已部署证书,无需重复部署'); + return; + } + $data['CertConfig']['CertificateId'] = $cert_id; + if($data['Protocol'] == 'HTTP') $data['Protocol'] = 'HTTP&HTTPS'; + + $param = [ + 'Domain' => $config['domain'], + 'Protocol' => $data['Protocol'], + 'CertConfig' => $data['CertConfig'], + ]; + $data = $client->request('UpdateCustomDomain', $param); + $this->log('云函数自定义域名 ' . $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/ucloud.php b/app/lib/deploy/ucloud.php new file mode 100644 index 0000000..f2db275 --- /dev/null +++ b/app/lib/deploy/ucloud.php @@ -0,0 +1,132 @@ +PublicKey = $config['PublicKey']; + $this->PrivateKey = $config['PrivateKey']; + $this->client = new UcloudClient($this->PublicKey, $this->PrivateKey); + } + + public function check() + { + if (empty($this->PublicKey) || empty($this->PrivateKey)) throw new Exception('必填参数不能为空'); + $param = ['Mode' => 'free']; + $this->client->request('GetCertificateList', $param); + return true; + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + $domain_id = $config['domain_id']; + if (empty($domain_id)) throw new Exception('云分发资源ID不能为空'); + + $certInfo = openssl_x509_parse($fullchain, true); + if (!$certInfo) throw new Exception('证书解析失败'); + $cert_name = str_replace(['*', '.'], '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t']; + + try { + $data = $this->client->request('GetCertificateV2', []); + } catch (Exception $e) { + throw new Exception('获取证书列表失败 ' . $e->getMessage()); + } + + $exist = false; + foreach ($data['CertList'] as $cert) { + if (trim($cert['UserCert']) == trim($fullchain)) { + $cert_name = $cert['CertName']; + $exist = true; + } + } + + if (!$exist) { + $param = [ + 'CertName' => $cert_name, + 'UserCert' => $fullchain, + 'PrivateKey' => $privatekey, + ]; + try { + $data = $this->client->request('AddCertificate', $param); + } catch (Exception $e) { + throw new Exception('添加证书失败 ' . $e->getMessage()); + } + $this->log('添加证书成功,名称:' . $cert_name); + } else { + $this->log('获取到已添加的证书,名称:' . $cert_name); + } + + try { + $data = $this->client->request('GetUcdnDomainConfig', ['DomainId.0' => $domain_id]); + } catch (Exception $e) { + throw new Exception('获取加速域名配置失败 ' . $e->getMessage()); + } + if (empty($data['DomainList'])) throw new Exception('云分发资源ID:' . $domain_id . '不存在'); + $domain = $data['DomainList'][0]['Domain']; + $HttpsStatusCn = $data['DomainList'][0]['HttpsStatusCn']; + $HttpsStatusAbroad = $data['DomainList'][0]['HttpsStatusAbroad']; + + if ($data['DomainList'][0]['CertNameCn'] == $cert_name || $data['DomainList'][0]['CertNameAbroad'] == $cert_name) { + $this->log('云分发' . $domain_id . '证书已配置,无需重复操作'); + return; + } + + try { + $data = $this->client->request('GetCertificateBaseInfoList', ['Domain' => $domain]); + } catch (Exception $e) { + throw new Exception('获取可用证书列表失败 ' . $e->getMessage()); + } + if (empty($data['CertList'])) throw new Exception('可用证书列表为空'); + + $cert_id = null; + foreach ($data['CertList'] as $cert) { + if ($cert['CertName'] == $cert_name) { + $cert_id = $cert['CertId']; + break; + } + } + if (!$cert_id) throw new Exception('证书ID不存在'); + $this->log('证书ID获取成功:' . $cert_id); + + $param = [ + 'DomainId' => $domain_id, + 'CertName' => $cert_name, + 'CertId' => $cert_id, + 'CertType' => 'ucdn', + ]; + if ($HttpsStatusCn == 'enable') $param['HttpsStatusCn'] = $HttpsStatusCn; + if ($HttpsStatusAbroad == 'enable') $param['HttpsStatusAbroad'] = $HttpsStatusAbroad; + if ($HttpsStatusCn != 'enable' && $HttpsStatusAbroad != 'enable') $param['HttpsStatusCn'] = 'enable'; + try { + $data = $this->client->request('UpdateUcdnDomainHttpsConfigV2', $param); + } catch (Exception $e) { + throw new Exception('https加速配置失败 ' . $e->getMessage()); + } + $this->log('云分发' . $domain_id . '证书配置成功!'); + $info['cert_id'] = $cert_id; + $info['cert_name'] = $cert_name; + } + + 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/dns/aliyun.php b/app/lib/dns/aliyun.php index f53c3c3..df8cd1f 100644 --- a/app/lib/dns/aliyun.php +++ b/app/lib/dns/aliyun.php @@ -3,6 +3,8 @@ namespace app\lib\dns; use app\lib\DnsInterface; +use app\lib\client\Aliyun as AliyunClient; +use Exception; class aliyun implements DnsInterface { @@ -14,11 +16,13 @@ class aliyun implements DnsInterface private $domain; private $domainid; private $domainInfo; + private AliyunClient $client; public function __construct($config) { $this->AccessKeyId = $config['ak']; $this->AccessKeySecret = $config['sk']; + $this->client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, $this->Endpoint, $this->Version); $this->domain = $config['domain']; } @@ -257,77 +261,21 @@ class aliyun implements DnsInterface return $line; } - - private function aliyunSignature($parameters, $accessKeySecret, $method) - { - ksort($parameters); - $canonicalizedQueryString = ''; - foreach ($parameters as $key => $value) { - if ($value === null) continue; - $canonicalizedQueryString .= '&' . $this->percentEncode($key) . '=' . $this->percentEncode($value); - } - $stringToSign = $method . '&%2F&' . $this->percentEncode(substr($canonicalizedQueryString, 1)); - $signature = base64_encode(hash_hmac("sha1", $stringToSign, $accessKeySecret . "&", true)); - - return $signature; - } - private function percentEncode($str) - { - $search = ['+', '*', '%7E']; - $replace = ['%20', '%2A', '~']; - return str_replace($search, $replace, urlencode($str)); - } private function request($param, $returnData = false) { if (empty($this->AccessKeyId) || empty($this->AccessKeySecret)) return false; - $result = $this->request_do($param, $returnData); - if (!$returnData && $result !== true) { - usleep(50000); - $result = $this->request_do($param, $returnData); - } - return $result; - } - private function request_do($param, $returnData = false) - { - if (empty($this->AccessKeyId) || empty($this->AccessKeySecret)) return false; - $url = 'https://' . $this->Endpoint . '/'; - $data = array( - 'Format' => 'JSON', - 'Version' => $this->Version, - 'AccessKeyId' => $this->AccessKeyId, - 'SignatureMethod' => 'HMAC-SHA1', - 'Timestamp' => gmdate('Y-m-d\TH:i:s\Z'), - 'SignatureVersion' => '1.0', - 'SignatureNonce' => random(8) - ); - $data = array_merge($data, $param); - $data['Signature'] = $this->aliyunSignature($data, $this->AccessKeySecret, 'POST'); - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - curl_setopt($ch, CURLOPT_POST, 1); - curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); - $response = curl_exec($ch); - $errno = curl_errno($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - if ($errno) { - $this->setError('Curl error: ' . curl_error($ch)); - } - curl_close($ch); - if ($errno) return false; - - $arr = json_decode($response, true); - if ($httpCode == 200) { - return $returnData ? $arr : true; - } elseif ($arr) { - $this->setError($arr['Message']); - return false; - } else { - $this->setError('返回数据解析失败'); - return false; + try{ + $result = $this->client->request($param); + }catch(Exception $e){ + try{ + usleep(50000); + $result = $this->client->request($param); + }catch(Exception $e){ + $this->setError($e->getMessage()); + return false; + } } + return $returnData ? $result : true; } private function setError($message) diff --git a/app/lib/dns/baidu.php b/app/lib/dns/baidu.php index bfc9617..4f0e500 100644 --- a/app/lib/dns/baidu.php +++ b/app/lib/dns/baidu.php @@ -3,6 +3,8 @@ namespace app\lib\dns; use app\lib\DnsInterface; +use app\lib\client\BaiduCloud; +use Exception; class baidu implements DnsInterface { @@ -12,11 +14,13 @@ class baidu implements DnsInterface private $error; private $domain; private $domainid; + private BaiduCloud $client; public function __construct($config) { $this->AccessKeyId = $config['ak']; $this->SecretAccessKey = $config['sk']; + $this->client = new BaiduCloud($this->AccessKeyId, $this->SecretAccessKey, $this->endpoint); $this->domain = $config['domain']; $this->domainid = $config['domainid']; } @@ -197,147 +201,10 @@ class baidu implements DnsInterface private function send_reuqest($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 = gmdate("Y-m-d\TH:i:s\Z", $time); - $body = !empty($params) ? json_encode($params) : ''; - $headers = [ - 'Host' => $this->endpoint, - 'x-bce-date' => $date, - ]; - if ($body) { - $headers['Content-Type'] = 'application/json'; - } - - $authorization = $this->generateSign($method, $path, $query, $headers, $time); - $headers['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($method, $path, $query, $headers, $time) - { - $algorithm = "bce-auth-v1"; - - // step 1: build canonical request string - $httpRequestMethod = $method; - $canonicalUri = $this->getCanonicalUri($path); - $canonicalQueryString = $this->getCanonicalQueryString($query); - [$canonicalHeaders, $signedHeaders] = $this->getCanonicalHeaders($headers); - $canonicalRequest = $httpRequestMethod."\n" - .$canonicalUri."\n" - .$canonicalQueryString."\n" - .$canonicalHeaders; - - // step 2: calculate signing key - $date = gmdate("Y-m-d\TH:i:s\Z", $time); - $expirationInSeconds = 1800; - $authString = $algorithm . '/' . $this->AccessKeyId . '/' . $date . '/' . $expirationInSeconds; - $signingKey = hash_hmac('sha256', $authString, $this->SecretAccessKey); - - // step 3: sign string - $signature = hash_hmac("sha256", $canonicalRequest, $signingKey); - - // step 4: build authorization - $authorization = $authString . '/' . $signedHeaders . "/" . $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 '/'; - $uri = str_replace('%2F', '/', $this->escape($path)); - if (substr($uri, 0, 1) !== '/') $uri = '/' . $uri; - return $uri; - } - - private function getCanonicalQueryString($parameters) - { - if (empty($parameters)) return ''; - ksort($parameters); - $canonicalQueryString = ''; - foreach ($parameters as $key => $value) { - if ($key == 'authorization') continue; - $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 .= $this->escape($key) . ':' . $this->escape($value) . "\n"; - $signedHeaders .= $key . ';'; - } - $canonicalHeaders = substr($canonicalHeaders, 0, -1); - $signedHeaders = substr($signedHeaders, 0, -1); - return [$canonicalHeaders, $signedHeaders]; - } - - private function curl($method, $url, $body, $header) - { - $ch = curl_init($url); - 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); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - if ($errno) { - $this->setError('Curl error: ' . curl_error($ch)); - } - curl_close($ch); - if ($errno) return false; - - if (empty($response) && $httpCode == 200) { - return true; - } - $arr = json_decode($response, true); - if ($arr) { - if (isset($arr['code']) && isset($arr['message'])) { - $this->setError($arr['message']); - return false; - } else { - return $arr; - } - } else { - $this->setError('返回数据解析失败'); + try{ + return $this->client->request($method, $path, $query, $params); + }catch(Exception $e){ + $this->setError($e->getMessage()); return false; } } diff --git a/app/lib/dns/dnspod.php b/app/lib/dns/dnspod.php index 9a079b3..78ddc31 100644 --- a/app/lib/dns/dnspod.php +++ b/app/lib/dns/dnspod.php @@ -3,6 +3,8 @@ namespace app\lib\dns; use app\lib\DnsInterface; +use app\lib\client\TencentCloud; +use Exception; class dnspod implements DnsInterface { @@ -15,11 +17,13 @@ class dnspod implements DnsInterface private $domain; private $domainid; private $domainInfo; + private TencentCloud $client; public function __construct($config) { $this->SecretId = $config['ak']; $this->SecretKey = $config['sk']; + $this->client = new TencentCloud($this->SecretId, $this->SecretKey, $this->endpoint, $this->service, $this->version); $this->domain = $config['domain']; } @@ -42,7 +46,7 @@ class dnspod implements DnsInterface $action = 'DescribeDomainList'; $offset = ($PageNumber - 1) * $PageSize; $param = ['Offset' => $offset, 'Limit' => $PageSize, 'Keyword' => $KeyWord]; - $data = $this->send_reuqest($action, $param); + $data = $this->send_request($action, $param); if ($data) { $list = []; foreach ($data['DomainList'] as $row) { @@ -77,7 +81,7 @@ class dnspod implements DnsInterface $action = 'DescribeRecordList'; $param = ['Domain' => $this->domain, 'Subdomain' => $SubDomain, 'RecordType' => $this->convertType($Type), 'RecordLineId' => $Line, 'Keyword' => $KeyWord, 'Offset' => $offset, 'Limit' => $PageSize]; } - $data = $this->send_reuqest($action, $param); + $data = $this->send_request($action, $param); if ($data) { $list = []; foreach ($data['RecordList'] as $row) { @@ -116,7 +120,7 @@ class dnspod implements DnsInterface { $action = 'DescribeRecord'; $param = ['Domain' => $this->domain, 'RecordId' => intval($RecordId)]; - $data = $this->send_reuqest($action, $param); + $data = $this->send_request($action, $param); if ($data) { return [ 'RecordId' => $data['RecordInfo']['Id'], @@ -142,7 +146,7 @@ class dnspod implements DnsInterface $action = 'CreateRecord'; $param = ['Domain' => $this->domain, 'SubDomain' => $Name, 'RecordType' => $this->convertType($Type), 'Value' => $Value, 'RecordLine' => $Line, 'RecordLineId' => $this->convertLineCode($Line), 'TTL' => intval($TTL), 'Weight' => $Weight]; if ($Type == 'MX') $param['MX'] = intval($MX); - $data = $this->send_reuqest($action, $param); + $data = $this->send_request($action, $param); return is_array($data) ? $data['RecordId'] : false; } @@ -152,7 +156,7 @@ class dnspod implements DnsInterface $action = 'ModifyRecord'; $param = ['Domain' => $this->domain, 'RecordId' => intval($RecordId), 'SubDomain' => $Name, 'RecordType' => $this->convertType($Type), 'Value' => $Value, 'RecordLine' => $Line, 'RecordLineId' => $this->convertLineCode($Line), 'TTL' => intval($TTL), 'Weight' => $Weight]; if ($Type == 'MX') $param['MX'] = intval($MX); - $data = $this->send_reuqest($action, $param); + $data = $this->send_request($action, $param); return is_array($data); } @@ -161,7 +165,7 @@ class dnspod implements DnsInterface { $action = 'ModifyRecordRemark'; $param = ['Domain' => $this->domain, 'RecordId' => intval($RecordId), 'Remark' => $Remark]; - $data = $this->send_reuqest($action, $param); + $data = $this->send_request($action, $param); return is_array($data); } @@ -170,7 +174,7 @@ class dnspod implements DnsInterface { $action = 'DeleteRecord'; $param = ['Domain' => $this->domain, 'RecordId' => intval($RecordId)]; - $data = $this->send_reuqest($action, $param); + $data = $this->send_request($action, $param); return is_array($data); } @@ -180,7 +184,7 @@ class dnspod implements DnsInterface $Status = $Status == '1' ? 'ENABLE' : 'DISABLE'; $action = 'ModifyRecordStatus'; $param = ['Domain' => $this->domain, 'RecordId' => intval($RecordId), 'Status' => $Status]; - $data = $this->send_reuqest($action, $param); + $data = $this->send_request($action, $param); return is_array($data); } @@ -190,7 +194,7 @@ class dnspod implements DnsInterface $action = 'DescribeDomainLogList'; $offset = ($PageNumber - 1) * $PageSize; $param = ['Domain' => $this->domain, 'Offset' => $offset, 'Limit' => $PageSize]; - $data = $this->send_reuqest($action, $param); + $data = $this->send_request($action, $param); if ($data) { $list = []; foreach ($data['LogList'] as $row) { @@ -206,7 +210,7 @@ class dnspod implements DnsInterface { $action = 'DescribeRecordLineCategoryList'; $param = ['Domain' => $this->domain]; - $data = $this->send_reuqest($action, $param); + $data = $this->send_request($action, $param); if ($data) { $list = []; $this->processLineList($list, $data['LineList'], null); @@ -242,7 +246,7 @@ class dnspod implements DnsInterface { $action = 'DescribeDomain'; $param = ['Domain' => $this->domain]; - $data = $this->send_reuqest($action, $param); + $data = $this->send_request($action, $param); if ($data) { $this->domainInfo = $data['DomainInfo']; return $data['DomainInfo']; @@ -255,7 +259,7 @@ class dnspod implements DnsInterface { $action = 'DescribeDomainPurview'; $param = ['Domain' => $this->domain]; - $data = $this->send_reuqest($action, $param); + $data = $this->send_request($action, $param); if ($data) { return $data['PurviewList']; } @@ -284,7 +288,7 @@ class dnspod implements DnsInterface { $action = 'DescribeRecordLineList'; $param = ['Domain' => $this->domain, 'DomainGrade' => '']; - $data = $this->send_reuqest($action, $param); + $data = $this->send_request($action, $param); if ($data) { $line_list = $data['LineList']; if (!empty($data['LineGroupList'])) { @@ -302,7 +306,7 @@ class dnspod implements DnsInterface { $action = 'DescribeUserDetail'; $param = []; - $data = $this->send_reuqest($action, $param); + $data = $this->send_request($action, $param); if ($data) { return $data['UserInfo']; } @@ -337,93 +341,12 @@ class dnspod implements DnsInterface } - private function send_reuqest($action, $param) + private function send_request($action, $param) { - $param = array_filter($param, function ($a) { return $a !== null;}); - if (!$param) $param = (object)[]; - $payload = json_encode($param); - $time = time(); - $authorization = $this->generateSign($payload, $time); - $header = [ - 'Authorization: '.$authorization, - 'Content-Type: application/json; charset=utf-8', - 'X-TC-Action: '.$action, - 'X-TC-Timestamp: '.$time, - 'X-TC-Version: '.$this->version, - ]; - return $this->curl_post($payload, $header); - } - - private function generateSign($payload, $time) - { - $algorithm = "TC3-HMAC-SHA256"; - - // step 1: build canonical request string - $httpRequestMethod = "POST"; - $canonicalUri = "/"; - $canonicalQueryString = ""; - $canonicalHeaders = "content-type:application/json; charset=utf-8\n"."host:".$this->endpoint."\n"; - $signedHeaders = "content-type;host"; - $hashedRequestPayload = hash("SHA256", $payload); - $canonicalRequest = $httpRequestMethod."\n" - .$canonicalUri."\n" - .$canonicalQueryString."\n" - .$canonicalHeaders."\n" - .$signedHeaders."\n" - .$hashedRequestPayload; - - // step 2: build string to sign - $date = gmdate("Y-m-d", $time); - $credentialScope = $date."/".$this->service."/tc3_request"; - $hashedCanonicalRequest = hash("SHA256", $canonicalRequest); - $stringToSign = $algorithm."\n" - .$time."\n" - .$credentialScope."\n" - .$hashedCanonicalRequest; - - // step 3: sign string - $secretDate = hash_hmac("SHA256", $date, "TC3".$this->SecretKey, true); - $secretService = hash_hmac("SHA256", $this->service, $secretDate, true); - $secretSigning = hash_hmac("SHA256", "tc3_request", $secretService, true); - $signature = hash_hmac("SHA256", $stringToSign, $secretSigning); - - // step 4: build authorization - $authorization = $algorithm - ." Credential=".$this->SecretId."/".$credentialScope - .", SignedHeaders=content-type;host, Signature=".$signature; - - return $authorization; - } - - private function curl_post($payload, $header) - { - $url = 'https://'.$this->endpoint.'/'; - $ch = curl_init($url); - 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_POST, 1); - curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); - $response = curl_exec($ch); - $errno = curl_errno($ch); - if ($errno) { - $this->setError('Curl error: ' . curl_error($ch)); - } - curl_close($ch); - if ($errno) return false; - - $arr = json_decode($response, true); - if ($arr) { - if (isset($arr['Response']['Error'])) { - $this->setError($arr['Response']['Error']['Message']); - return false; - } else { - return $arr['Response']; - } - } else { - $this->setError('返回数据解析失败'); + try{ + return $this->client->request($action, $param); + }catch(Exception $e){ + $this->setError($e->getMessage()); return false; } } diff --git a/app/lib/dns/huawei.php b/app/lib/dns/huawei.php index 7548a31..2bd4bd9 100644 --- a/app/lib/dns/huawei.php +++ b/app/lib/dns/huawei.php @@ -3,6 +3,8 @@ namespace app\lib\dns; use app\lib\DnsInterface; +use app\lib\client\HuaweiCloud; +use Exception; class huawei implements DnsInterface { @@ -12,11 +14,13 @@ class huawei implements DnsInterface private $error; private $domain; private $domainid; + private HuaweiCloud $client; public function __construct($config) { $this->AccessKeyId = $config['ak']; $this->SecretAccessKey = $config['sk']; + $this->client = new HuaweiCloud($this->AccessKeyId, $this->SecretAccessKey, $this->endpoint); $this->domain = $config['domain']; $this->domainid = $config['domainid']; } @@ -39,7 +43,7 @@ class huawei implements DnsInterface { $offset = ($PageNumber - 1) * $PageSize; $query = ['offset' => $offset, 'limit' => $PageSize, 'name' => $KeyWord]; - $data = $this->send_reuqest('GET', '/v2/zones', $query); + $data = $this->send_request('GET', '/v2/zones', $query); if ($data) { $list = []; foreach ($data['zones'] as $row) { @@ -68,7 +72,7 @@ class huawei implements DnsInterface $query['name'] = $SubDomain; $query['search_mode'] = 'equal'; } - $data = $this->send_reuqest('GET', '/v2.1/zones/'.$this->domainid.'/recordsets', $query); + $data = $this->send_request('GET', '/v2.1/zones/'.$this->domainid.'/recordsets', $query); if ($data) { $list = []; foreach ($data['recordsets'] as $row) { @@ -103,7 +107,7 @@ class huawei implements DnsInterface //获取解析记录详细信息 public function getDomainRecordInfo($RecordId) { - $data = $this->send_reuqest('GET', '/v2.1/zones/'.$this->domainid.'/recordsets/'.$RecordId); + $data = $this->send_request('GET', '/v2.1/zones/'.$this->domainid.'/recordsets/'.$RecordId); if ($data) { if ($data['name'] == $data['zone_name']) $data['name'] = '@'; if ($data['type'] == 'MX') list($data['mx'], $data['records']) = explode(' ', $data['records'][0]); @@ -134,7 +138,7 @@ class huawei implements DnsInterface $params = ['name' => $Name, 'type' => $this->convertType($Type), 'records' => $records, 'line' => $Line, 'ttl' => intval($TTL), 'description' => $Remark]; if ($Type == 'MX') $params['records'][0] = intval($MX) . ' ' . $Value; if ($Weight > 0) $params['weight'] = intval($Weight); - $data = $this->send_reuqest('POST', '/v2.1/zones/'.$this->domainid.'/recordsets', null, $params); + $data = $this->send_request('POST', '/v2.1/zones/'.$this->domainid.'/recordsets', null, $params); return is_array($data) ? $data['id'] : false; } @@ -147,7 +151,7 @@ class huawei implements DnsInterface $params = ['name' => $Name, 'type' => $this->convertType($Type), 'records' => $records, 'line' => $Line, 'ttl' => intval($TTL), 'description' => $Remark]; if ($Type == 'MX') $params['records'][0] = intval($MX) . ' ' . $Value; if ($Weight > 0) $params['weight'] = intval($Weight); - $data = $this->send_reuqest('PUT', '/v2.1/zones/'.$this->domainid.'/recordsets/'.$RecordId, null, $params); + $data = $this->send_request('PUT', '/v2.1/zones/'.$this->domainid.'/recordsets/'.$RecordId, null, $params); return is_array($data); } @@ -160,7 +164,7 @@ class huawei implements DnsInterface //删除解析记录 public function deleteDomainRecord($RecordId) { - $data = $this->send_reuqest('DELETE', '/v2.1/zones/'.$this->domainid.'/recordsets/'.$RecordId); + $data = $this->send_request('DELETE', '/v2.1/zones/'.$this->domainid.'/recordsets/'.$RecordId); return is_array($data); } @@ -169,7 +173,7 @@ class huawei implements DnsInterface { $Status = $Status == '1' ? 'ENABLE' : 'DISABLE'; $params = ['status' => $Status]; - $data = $this->send_reuqest('PUT', '/v2.1/recordsets/'.$RecordId.'/statuses/set', null, $params); + $data = $this->send_request('PUT', '/v2.1/recordsets/'.$RecordId.'/statuses/set', null, $params); return is_array($data); } @@ -215,7 +219,7 @@ class huawei implements DnsInterface //获取域名概览信息 public function getDomainInfo() { - return $this->send_reuqest('GET', '/v2/zones/'.$this->domainid); + return $this->send_request('GET', '/v2/zones/'.$this->domainid); } //获取域名最低TTL @@ -237,143 +241,12 @@ class huawei implements DnsInterface return $Name; } - private function send_reuqest($method, $path, $query = null, $params = null) + private function send_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 = gmdate("Ymd\THis\Z", $time); - $body = !empty($params) ? json_encode($params) : ''; - $headers = [ - 'Host' => $this->endpoint, - 'X-Sdk-Date' => $date, - ]; - if ($body) { - $headers['Content-Type'] = 'application/json'; - } - - $authorization = $this->generateSign($method, $path, $query, $headers, $body, $time); - $headers['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($method, $path, $query, $headers, $body, $time) - { - $algorithm = "SDK-HMAC-SHA256"; - - // step 1: build canonical request string - $httpRequestMethod = $method; - $canonicalUri = $path; - if (substr($canonicalUri, -1) != "/") $canonicalUri .= "/"; - $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); - $hashedCanonicalRequest = hash("sha256", $canonicalRequest); - $stringToSign = $algorithm."\n" - .$date."\n" - .$hashedCanonicalRequest; - - // step 3: sign string - $signature = hash_hmac("sha256", $stringToSign, $this->SecretAccessKey); - - // step 4: build authorization - $authorization = $algorithm . ' Access=' . $this->AccessKeyId . ", SignedHeaders=" . $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); - 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) { - $this->setError('Curl error: ' . curl_error($ch)); - } - curl_close($ch); - if ($errno) return false; - - $arr = json_decode($response, true); - if ($arr) { - if (isset($arr['error_msg'])) { - $this->setError($arr['error_msg']); - return false; - } elseif (isset($arr['message'])) { - $this->setError($arr['message']); - return false; - } else { - return $arr; - } - } else { - $this->setError('返回数据解析失败'); + try{ + return $this->client->request($method, $path, $query, $params); + }catch(Exception $e){ + $this->setError($e->getMessage()); return false; } } diff --git a/app/lib/dns/huoshan.php b/app/lib/dns/huoshan.php index e56e9e0..71dd792 100644 --- a/app/lib/dns/huoshan.php +++ b/app/lib/dns/huoshan.php @@ -3,6 +3,8 @@ namespace app\lib\dns; use app\lib\DnsInterface; +use app\lib\client\Volcengine; +use Exception; class huoshan implements DnsInterface { @@ -16,6 +18,7 @@ class huoshan implements DnsInterface private $domain; private $domainid; private $domainInfo; + private Volcengine $client; private static $trade_code_list = [ 'free_inner' => ['level' => 1, 'name' => '免费版', 'ttl' => 600], @@ -29,6 +32,7 @@ class huoshan implements DnsInterface { $this->AccessKeyId = $config['ak']; $this->SecretAccessKey = $config['sk']; + $this->client = new Volcengine($this->AccessKeyId, $this->SecretAccessKey, $this->endpoint, $this->service, $this->version, $this->region); $this->domain = $config['domain']; $this->domainid = $config['domainid']; } @@ -50,7 +54,7 @@ class huoshan implements DnsInterface public function getDomainList($KeyWord = null, $PageNumber = 1, $PageSize = 20) { $query = ['PageNumber' => $PageNumber, 'PageSize' => $PageSize, 'Key' => $KeyWord]; - $data = $this->send_reuqest('GET', 'ListZones', $query); + $data = $this->send_request('GET', 'ListZones', $query); if ($data) { $list = []; if (!empty($data['Zones'])) { @@ -76,7 +80,7 @@ class huoshan implements DnsInterface } elseif (!empty($KeyWord)) { $query += ['Host' => $KeyWord]; } - $data = $this->send_reuqest('GET', 'ListRecords', $query); + $data = $this->send_request('GET', 'ListRecords', $query); if ($data) { $list = []; foreach ($data['Records'] as $row) { @@ -110,7 +114,7 @@ class huoshan implements DnsInterface //获取解析记录详细信息 public function getDomainRecordInfo($RecordId) { - $data = $this->send_reuqest('GET', 'QueryRecord', ['RecordID' => $RecordId]); + $data = $this->send_request('GET', 'QueryRecord', ['RecordID' => $RecordId]); if ($data) { if ($data['name'] == $data['zone_name']) $data['name'] = '@'; if ($data['Type'] == 'MX') list($data['MX'], $data['Value']) = explode(' ', $data['Value']); @@ -138,7 +142,7 @@ class huoshan implements DnsInterface $params = ['ZID' => intval($this->domainid), 'Host' => $Name, 'Type' => $this->convertType($Type), 'Value' => $Value, 'Line' => $Line, 'TTL' => intval($TTL), 'Remark' => $Remark]; if ($Type == 'MX') $params['Value'] = intval($MX) . ' ' . $Value; if ($Weight > 0) $params['Weight'] = $Weight; - $data = $this->send_reuqest('POST', 'CreateRecord', $params); + $data = $this->send_request('POST', 'CreateRecord', $params); return is_array($data) ? $data['RecordID'] : false; } @@ -148,7 +152,7 @@ class huoshan implements DnsInterface $params = ['RecordID' => $RecordId, 'Host' => $Name, 'Type' => $this->convertType($Type), 'Value' => $Value, 'Line' => $Line, 'TTL' => intval($TTL), 'Remark' => $Remark]; if ($Type == 'MX') $params['Value'] = intval($MX) . ' ' . $Value; if ($Weight > 0) $params['Weight'] = $Weight; - $data = $this->send_reuqest('POST', 'UpdateRecord', $params); + $data = $this->send_request('POST', 'UpdateRecord', $params); return is_array($data); } @@ -161,7 +165,7 @@ class huoshan implements DnsInterface //删除解析记录 public function deleteDomainRecord($RecordId) { - $data = $this->send_reuqest('POST', 'DeleteRecord', ['RecordID' => $RecordId]); + $data = $this->send_request('POST', 'DeleteRecord', ['RecordID' => $RecordId]); return $data; } @@ -169,7 +173,7 @@ class huoshan implements DnsInterface public function setDomainRecordStatus($RecordId, $Status) { $params = ['RecordID' => $RecordId, 'Enable' => $Status == '1']; - $data = $this->send_reuqest('POST', 'UpdateRecordStatus', $params); + $data = $this->send_request('POST', 'UpdateRecordStatus', $params); return is_array($data); } @@ -185,7 +189,7 @@ class huoshan implements DnsInterface $domainInfo = $this->getDomainInfo(); if (!$domainInfo) return false; $level = $this->getTradeInfo($domainInfo['TradeCode'])['level']; - $data = $this->send_reuqest('GET', 'ListLines', []); + $data = $this->send_request('GET', 'ListLines', []); if ($data) { $list = []; $list['default'] = ['name' => '默认', 'parent' => null]; @@ -195,7 +199,7 @@ class huoshan implements DnsInterface $list[$row['Value']] = ['name' => $row['Name'], 'parent' => isset($row['FatherValue']) ? $row['FatherValue'] : null]; } - $data = $this->send_reuqest('GET', 'ListCustomLines', []); + $data = $this->send_request('GET', 'ListCustomLines', []); if ($data && $data['TotalCount'] > 0) { $list['N.customer_lines'] = ['name' => '自定义线路', 'parent' => null]; foreach ($data['CustomerLines'] as $row) { @@ -213,7 +217,7 @@ class huoshan implements DnsInterface { if (!empty($this->domainInfo)) return $this->domainInfo; $query = ['ZID' => intval($this->domainid)]; - $data = $this->send_reuqest('GET', 'QueryZone', $query); + $data = $this->send_request('GET', 'QueryZone', $query); if ($data) { $this->domainInfo = $data; return $data; @@ -247,159 +251,12 @@ class huoshan implements DnsInterface return self::$trade_code_list[$trade_code]; } - private function send_reuqest($method, $action, $params = []) + private function send_request($method, $action, $params = []) { - if (!empty($params)) { - $params = array_filter($params, function ($a) { return $a !== null;}); - } - - $query = [ - 'Action' => $action, - 'Version' => $this->version, - ]; - - $body = ''; - if ($method == 'GET') { - $query = array_merge($query, $params); - } else { - $body = !empty($params) ? json_encode($params) : ''; - } - - $time = time(); - $headers = [ - 'Host' => $this->endpoint, - 'X-Date' => gmdate("Ymd\THis\Z", $time), - //'X-Content-Sha256' => hash("sha256", $body), - ]; - if ($body) { - $headers['Content-Type'] = 'application/json'; - } - $path = '/'; - - $authorization = $this->generateSign($method, $path, $query, $headers, $body, $time); - $headers['Authorization'] = $authorization; - - $url = 'https://'.$this->endpoint.$path.'?'.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 = "HMAC-SHA256"; - - // step 1: build canonical request string - $httpRequestMethod = $method; - $canonicalUri = $path; - if (substr($canonicalUri, -1) != "/") $canonicalUri .= "/"; - $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 . '/request'; - $hashedCanonicalRequest = hash("sha256", $canonicalRequest); - $stringToSign = $algorithm."\n" - .$date."\n" - .$credentialScope."\n" - .$hashedCanonicalRequest; - - // step 3: sign string - $kDate = hash_hmac("sha256", $shortDate, $this->SecretAccessKey, true); - $kRegion = hash_hmac("sha256", $this->region, $kDate, true); - $kService = hash_hmac("sha256", $this->service, $kRegion, true); - $kSigning = hash_hmac("sha256", "request", $kService, true); - $signature = hash_hmac("sha256", $stringToSign, $kSigning); - - // step 4: build authorization - $credential = $this->AccessKeyId . '/' . $shortDate . '/' . $this->region . '/' . $this->service . '/request'; - $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 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); - 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) { - $this->setError('Curl error: ' . curl_error($ch)); - } - curl_close($ch); - if ($errno) return false; - - $arr = json_decode($response, true); - if ($arr) { - if (isset($arr['ResponseMetadata']['Error']['MessageCN'])) { - $this->setError($arr['ResponseMetadata']['Error']['MessageCN']); - return false; - } elseif (isset($arr['ResponseMetadata']['Error']['Message'])) { - $this->setError($arr['ResponseMetadata']['Error']['Message']); - return false; - } elseif (isset($arr['Result'])) { - return $arr['Result']; - } else { - return true; - } - } else { - $this->setError('返回数据解析失败'); + try{ + return $this->client->request($method, $action, $params); + }catch(Exception $e){ + $this->setError($e->getMessage()); return false; } } diff --git a/app/lib/mail/Aliyun.php b/app/lib/mail/Aliyun.php index 058625b..5083644 100644 --- a/app/lib/mail/Aliyun.php +++ b/app/lib/mail/Aliyun.php @@ -2,40 +2,27 @@ namespace app\lib\mail; +use app\lib\client\Aliyun as AliyunClient; + class Aliyun { private $AccessKeyId; private $AccessKeySecret; + private $Endpoint = 'dm.aliyuncs.com'; + private $Version = '2015-11-23'; + private AliyunClient $client; public function __construct($AccessKeyId, $AccessKeySecret) { $this->AccessKeyId = $AccessKeyId; $this->AccessKeySecret = $AccessKeySecret; + $this->client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, $this->Endpoint, $this->Version); } - private function aliyunSignature($parameters, $accessKeySecret, $method) - { - ksort($parameters); - $canonicalizedQueryString = ''; - foreach ($parameters as $key => $value) { - if ($value === null) continue; - $canonicalizedQueryString .= '&' . $this->percentEncode($key) . '=' . $this->percentEncode($value); - } - $stringToSign = $method . '&%2F&' . $this->percentencode(substr($canonicalizedQueryString, 1)); - $signature = base64_encode(hash_hmac("sha1", $stringToSign, $accessKeySecret . "&", true)); - return $signature; - } - private function percentEncode($str) - { - $search = ['+', '*', '%7E']; - $replace = ['%20', '%2A', '~']; - return str_replace($search, $replace, urlencode($str)); - } public function send($to, $sub, $msg, $from, $from_name) { if (empty($this->AccessKeyId) || empty($this->AccessKeySecret)) return false; - $url = 'https://dm.aliyuncs.com/'; - $data = array( + $param = [ 'Action' => 'SingleSendMail', 'AccountName' => $from, 'ReplyToAddress' => 'false', @@ -44,30 +31,12 @@ class Aliyun 'FromAlias' => $from_name, 'Subject' => $sub, 'HtmlBody' => $msg, - 'Format' => 'JSON', - 'Version' => '2015-11-23', - 'AccessKeyId' => $this->AccessKeyId, - 'SignatureMethod' => 'HMAC-SHA1', - 'Timestamp' => gmdate('Y-m-d\TH:i:s\Z'), - 'SignatureVersion' => '1.0', - 'SignatureNonce' => random(8) - ); - $data['Signature'] = $this->aliyunSignature($data, $this->AccessKeySecret, 'POST'); - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 10); - curl_setopt($ch, CURLOPT_POST, 1); - curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); - $json = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - $arr = json_decode($json, true); - if ($httpCode == 200) { + ]; + try { + $this->client->request($param); return true; - } else { - return $arr['Message']; + } catch (\Exception $e) { + return $e->getMessage(); } } } diff --git a/app/middleware/AuthUser.php b/app/middleware/AuthUser.php index a953ef3..3000e51 100644 --- a/app/middleware/AuthUser.php +++ b/app/middleware/AuthUser.php @@ -21,7 +21,7 @@ class AuthUser $user = Db::name('user')->where('id', $uid)->find(); if ($user && $user['status'] == 1) { $session = md5($user['id'].$user['password']); - if ($session == $sid && $expiretime > time()) { + if ($session === $sid && $expiretime > time()) { $islogin = true; } $user['type'] = 'user'; @@ -34,7 +34,7 @@ class AuthUser $user = Db::name('domain')->where('id', $uid)->find(); if ($user && $user['is_sso'] == 1) { $session = md5($user['id'].$user['name']); - if ($session == $sid && $expiretime > time()) { + if ($session === $sid && $expiretime > time()) { $islogin = true; } $user['username'] = $user['name']; diff --git a/app/middleware/ViewOutput.php b/app/middleware/ViewOutput.php index 2703a6a..46faaff 100644 --- a/app/middleware/ViewOutput.php +++ b/app/middleware/ViewOutput.php @@ -19,7 +19,7 @@ class ViewOutput { View::assign('islogin', $request->islogin); View::assign('user', $request->user); - View::assign('cdnpublic', 'https://s4.zstatic.net/ajax/libs/'); + View::assign('cdnpublic', 'https://cdnjs.webstatic.cn/ajax/libs/'); View::assign('skin', getAdminSkin()); return $next($request); } diff --git a/app/service/CertDeployService.php b/app/service/CertDeployService.php new file mode 100644 index 0000000..092cd41 --- /dev/null +++ b/app/service/CertDeployService.php @@ -0,0 +1,140 @@ +where('id', $tid)->find(); + if (!$task) throw new Exception('该自动部署任务不存在', 102); + $this->task = $task; + + $this->aid = $task['aid']; + $this->client = DeployHelper::getModel($this->aid); + if (!$this->client) throw new Exception('该自动部署任务类型不存在', 102); + + $this->info = $task['info'] ? json_decode($task['info'], true) : null; + } + + public function process($isManual = false) + { + if ($this->task['status'] >= 1) return; + if ($this->task['retry'] >= 6 && !$isManual) { + throw new Exception('已超出最大重试次数('.$this->task['error'].')', 103); + } + + $order = Db::name('cert_order')->where('id', $this->task['oid'])->find(); + if(!$order) throw new Exception('SSL证书订单不存在', 102); + if($order['status'] == 4) throw new Exception('SSL证书订单已吊销', 102); + if($order['status'] != 3) throw new Exception('SSL证书订单未完成签发', 102); + if(empty($order['fullchain']) || empty($order['privatekey'])) throw new Exception('SSL证书或私钥内容不存在', 102); + + $this->lockTaskData(); + try { + $this->deploy($order['fullchain'], $order['privatekey']); + } finally { + $this->unlockTaskData(); + } + } + + //部署证书 + public function deploy($fullchain, $privatekey) + { + $this->client->setLogger(function ($txt) { + $this->saveLog($txt); + }); + $this->saveLog(date('Y-m-d H:i:s')); + $config = json_decode($this->task['config'], true); + $config['domainList'] = Db::name('cert_domain')->where('oid', $this->task['oid'])->order('sort', 'asc')->column('domain'); + try { + $this->client->deploy($fullchain, $privatekey, $config, $this->info); + $this->saveResult(1); + $this->saveLog('[Success] 证书部署成功'); + } catch (Exception $e) { + $this->saveResult(-1, $e->getMessage(), date('Y-m-d H:i:s', time() + (array_key_exists($this->task['retry'], self::$retry_interval) ? self::$retry_interval[$this->task['retry']] : 3600))); + throw $e; + } finally { + if($this->info){ + Db::name('cert_deploy')->where('id', $this->task['id'])->update(['info' => json_encode($this->info)]); + } + } + } + + //重置任务 + public function reset() + { + Db::name('cert_deploy')->where('id', $this->task['id'])->data(['status' => 0, 'retry' => 0, 'retrytime' => null, 'issend' => 0])->update(); + //$file_name = app()->getRuntimePath().'log/'.$this->task['processid'].'.log'; + //if (file_exists($file_name)) unlink($file_name); + $this->task['status'] = 0; + $this->task['retry'] = 0; + } + + private function saveResult($status, $error = null, $retrytime = null) + { + $this->task['status'] = $status; + $update = ['status' => $status, 'error' => $error, 'retrytime' => $retrytime]; + if ($status == 1){ + $update['retry'] = 0; + $update['lasttime'] = date('Y-m-d H:i:s'); + } + $res = Db::name('cert_deploy')->where('id', $this->task['id'])->data($update); + if ($status < 0 || $retrytime) { + $this->task['retry']++; + $res->inc('retry'); + } + $res->update(); + if ($error) { + $this->saveLog('[Error] ' . $error); + } + } + + private function lockTaskData() + { + Db::startTrans(); + try { + $isLock = Db::name('cert_deploy')->where('id', $this->task['id'])->lock(true)->value('islock'); + if ($isLock == 1 && time() - strtotime($this->task['locktime']) < 3600) { + throw new Exception('部署任务处理中,请稍后再试'); + } + $update = ['islock' => 1, 'locktime' => date('Y-m-d H:i:s')]; + if (empty($this->task['processid'])) $this->task['processid'] = $update['processid'] = getSid(); + Db::name('cert_deploy')->where('id', $this->task['id'])->update($update); + Db::commit(); + } catch (Exception $e) { + Db::rollback(); + throw $e; + } + } + + private function unlockTaskData() + { + Db::name('cert_deploy')->where('id', $this->task['id'])->update(['islock' => 0]); + } + + private function saveLog($txt) + { + if (empty($this->task['processid'])) return; + $file_name = app()->getRuntimePath().'log/'.$this->task['processid'].'.log'; + file_put_contents($file_name, $txt . PHP_EOL, FILE_APPEND); + if(php_sapi_name() == 'cli'){ + echo $txt . PHP_EOL; + } + } +} \ No newline at end of file diff --git a/app/service/CertOrderService.php b/app/service/CertOrderService.php new file mode 100644 index 0000000..7b86da5 --- /dev/null +++ b/app/service/CertOrderService.php @@ -0,0 +1,426 @@ +where('id', $oid)->find(); + if (!$order) throw new Exception('该证书订单不存在', 102); + $this->order = $order; + + $this->aid = $order['aid']; + $account = Db::name('cert_account')->where('id', $this->aid)->find(); + if (!$account) throw new Exception('该证书账户不存在', 102); + $config = json_decode($account['config'], true); + $ext = $account['ext'] ? json_decode($account['ext'], true) : null; + $this->atype = $account['type']; + $this->client = CertHelper::getModel2($account['type'], $config, $ext); + if (!$this->client) throw new Exception('该证书类型不存在', 102); + + $domainList = Db::name('cert_domain')->where('oid', $oid)->order('sort', 'asc')->column('domain'); + if (!$domainList) throw new Exception('该证书订单没有绑定域名', 102); + $this->domainList = $domainList; + $this->info = $order['info'] ? json_decode($order['info'], true) : null; + $this->dnsList = $order['dns'] ? json_decode($order['dns'], true) : null; + } + + //执行证书申请 + public function process($isManual = false) + { + if ($this->order['status'] >= 3) return 3; + if ($this->order['retry2'] >= 3 && !$isManual) { + throw new Exception('已超出最大重试次数('.$this->order['error'].')', 103); + } + if ($this->order['status'] != 1 && $this->order['status'] != 2 && $this->order['retry'] >= 3 && !$isManual) { + if ($this->order['status'] == -2 || $this->order['status'] == -5 || $this->order['status'] == -6 || $this->order['status'] == -7) { + $this->cancel(); + if($this->order['status'] <= -5) $this->delDns(); + Db::name('cert_order')->where('id', $this->order['id'])->data(['status' => 0, 'retry' => 0, 'retrytime' => null, 'updatetime' => date('Y-m-d H:i:s')])->inc('retry2')->update(); + $this->order['status'] = 0; + $this->order['retry'] = 0; + } else { + throw new Exception('已超出最大重试次数('.$this->order['error'].')', 103); + } + } + + $cname = CertHelper::$cert_config[$this->atype]['cname']; + foreach($this->domainList as $domain){ + $mainDomain = getMainDomain($domain); + if (!Db::name('domain')->where('name', $mainDomain)->find()) { + $cname_row = Db::name('cert_cname')->where('domain', $domain)->where('status', 1)->find(); + if (!$cname || !$cname_row) { + $errmsg = '域名'.$domain.'未在本系统添加'; + Db::name('cert_order')->where('id', $this->order['id'])->data(['error'=>$errmsg]); + throw new Exception($errmsg, 103); + } else { + $this->cnameDomainList[] = $cname_row['id']; + } + } + } + + $this->lockOrder(); + try { + return $this->processOrder($isManual); + } finally { + $this->unlockOrder(); + if (($this->order['status'] == -2 || $this->order['status'] == -5 || $this->order['status'] == -6 || $this->order['status'] == -7) && $this->order['retry'] >= 3) { + Db::name('cert_order')->where('id', $this->order['id'])->data(['retrytime' => date('Y-m-d H:i:s', time() + 3600)])->update(); + } + } + } + + private function processOrder($isManual = false) + { + $this->client->setLogger(function ($txt) { + $this->saveLog($txt); + }); + // step1: 购买证书 + if ($this->order['status'] == 0 || $this->order['status'] == -1) { + $this->saveLog(date('Y-m-d H:i:s').' - 开始购买证书'); + $this->buyCert(); + } + // step2: 创建订单 + if ($this->order['status'] == 0 || $this->order['status'] == -2) { + $this->saveLog(date('Y-m-d H:i:s').' - 开始创建订单'); + $this->createOrder(); + } + // step3: 添加DNS + if ($isManual && $this->order['status'] == -3 && CertDnsUtils::verifyDns($this->dnsList)) { + $this->saveResult(1); + $this->saveLog('检测到DNS记录已添加成功'); + return 1; + } + if ($this->order['status'] == 0 || $this->order['status'] == -3) { + $this->saveLog(date('Y-m-d H:i:s').' - 开始添加DNS记录'); + $this->addDns(); + $this->saveLog('添加DNS记录成功,请等待生效后进行验证...'); + Db::name('cert_order')->where('id', $this->order['id'])->update(['retrytime' => date('Y-m-d H:i:s', time() + 300)]); + return 1; + } + // step4: 查询DNS + if ($this->order['status'] == 1 || $this->order['status'] == -4) { + $this->verifyDns(); + } + // step5: 验证订单 + if ($this->order['status'] == 1 || $this->order['status'] == -5) { + $this->saveLog(date('Y-m-d H:i:s').' - 开始验证订单'); + $this->authOrder(); + } + // step6: 查询验证结果 + if ($this->order['status'] == 2 || $this->order['status'] == -6) { + $this->saveLog(date('Y-m-d H:i:s').' - 开始查询验证结果'); + $this->getAuthStatus(); + } + // step7: 签发证书 + if ($this->order['status'] == 2 || $this->order['status'] == -7) { + $this->saveLog(date('Y-m-d H:i:s').' - 开始签发证书'); + $this->finalizeOrder(); + } + $this->delDns(); + $this->resetRetry2(); + $this->saveLog('[Success] 证书签发成功'); + Db::name('cert_deploy')->where('oid', $this->order['id'])->data(['status' => 0, 'retry' => 0, 'retrytime' => null, 'issend' => 0])->update(); + return 3; + } + + private function lockOrder() + { + Db::startTrans(); + try { + $isLock = Db::name('cert_order')->where('id', $this->order['id'])->lock(true)->value('islock'); + if ($isLock == 1 && time() - strtotime($this->order['locktime']) < 3600) { + throw new Exception('订单正在处理中,请稍后再试', 102); + } + $update = ['islock' => 1, 'locktime' => date('Y-m-d H:i:s')]; + if (empty($this->order['processid'])) $this->order['processid'] = $update['processid'] = getSid(); + Db::name('cert_order')->where('id', $this->order['id'])->update($update); + Db::commit(); + } catch (Exception $e) { + Db::rollback(); + throw $e; + } + } + + private function unlockOrder() + { + Db::name('cert_order')->where('id', $this->order['id'])->update(['islock' => 0]); + } + + private function saveResult($status, $error = null, $retrytime = null) + { + $this->order['status'] = $status; + $update = ['status' => $status, 'error' => $error, 'updatetime' => date('Y-m-d H:i:s'), 'retrytime' => $retrytime]; + $res = Db::name('cert_order')->where('id', $this->order['id'])->data($update); + if ($status < 0 || $retrytime) { + $this->order['retry']++; + $res->inc('retry'); + } + $res->update(); + if ($error) { + $this->saveLog('[Error] ' . $error); + } + } + + private function resetRetry() + { + if ($this->order['retry'] > 0) { + $this->order['retry'] = 0; + Db::name('cert_order')->where('id', $this->order['id'])->update(['retry' => 0, 'retrytime' => null]); + } + } + + private function resetRetry2() + { + if ($this->order['retry2'] > 0) { + $this->order['retry2'] = 0; + Db::name('cert_order')->where('id', $this->order['id'])->update(['retry2' => 0, 'retrytime' => null]); + } + } + + //重置订单 + public function reset() + { + Db::name('cert_order')->where('id', $this->order['id'])->data(['status' => 0, 'retry' => 0, 'retry2' => 0, 'retrytime' => null, 'processid' => null, 'updatetime' => date('Y-m-d H:i:s'), 'issend' => 0])->update(); + $file_name = app()->getRuntimePath().'log/'.$this->order['processid'].'.log'; + if (file_exists($file_name)) unlink($file_name); + $this->order['status'] = 0; + $this->order['retry'] = 0; + $this->order['retry2'] = 0; + $this->order['processid'] = null; + } + + //购买证书 + public function buyCert() + { + try { + $this->client->buyCert($this->domainList, $this->info); + } catch (Exception $e) { + $this->saveResult(-1, $e->getMessage()); + throw $e; + } + if($this->info){ + Db::name('cert_order')->where('id', $this->order['id'])->update(['info' => json_encode($this->info)]); + } + $this->order['status'] = 0; + $this->resetRetry(); + } + + //创建订单 + public function createOrder() + { + try { + if (!empty($this->cnameDomainList)) { + foreach($this->cnameDomainList as $cnameId){ + $this->checkDomainCname($cnameId); + } + } + try { + $this->dnsList = $this->client->createOrder($this->domainList, $this->info, $this->order['keytype'], $this->order['keysize']); + } catch (Exception $e) { + if (strpos($e->getMessage(), 'KeyID header contained an invalid account URL') !== false) { + $ext = $this->client->register(); + Db::name('cert_account')->where('id', $this->aid)->update(['ext' => json_encode($ext)]); + $this->dnsList = $this->client->createOrder($this->domainList, $this->info, $this->order['keytype'], $this->order['keysize']); + } else { + throw $e; + } + } + } catch (Exception $e) { + $this->saveResult(-2, $e->getMessage()); + throw $e; + } + Db::name('cert_order')->where('id', $this->order['id'])->update(['info' => json_encode($this->info), 'dns' => json_encode($this->dnsList)]); + + if (!empty($this->dnsList)) { + $dns_txt = '需验证的DNS记录如下:'; + foreach ($this->dnsList as $mainDomain => $list) { + foreach ($list as $row) { + $domain = $row['name'] . '.' . $mainDomain; + $dns_txt .= PHP_EOL.'主机记录: '.$domain.' 类型: '.$row['type'].' 记录值: '.$row['value']; + } + } + $this->saveLog($dns_txt); + } + $this->order['status'] = 0; + $this->resetRetry(); + } + + //验证DNS记录 + public function verifyDns() + { + $verify = CertDnsUtils::verifyDns($this->dnsList); + if (!$verify) { + if ($this->order['retry'] >= 10) { + $this->saveResult(-4, '未查询到DNS解析记录'); + } else { + $this->saveLog('未查询到DNS解析记录(尝试第'.($this->order['retry']+1).'次)'); + $this->saveResult(1, null, date('Y-m-d H:i:s', time() + (array_key_exists($this->order['retry'], self::$retry_interval) ? self::$retry_interval[$this->order['retry']] : 1800))); + } + throw new Exception('未查询到DNS解析记录(尝试第'.($this->order['retry']).'次),请稍后再试'); + } + if($this->order['retry'] == 0 && time() - strtotime($this->order['updatetime']) < 10){ + throw new Exception('请等待'.(10 - (time() - strtotime($this->order['updatetime']))).'秒后再试'); + } + $this->order['status'] = 1; + $this->resetRetry(); + } + + //验证订单 + public function authOrder() + { + try { + $this->client->authOrder($this->domainList, $this->info); + } catch (Exception $e) { + $this->saveResult(-5, $e->getMessage()); + throw $e; + } + $this->saveResult(2); + $this->resetRetry(); + } + + //查询验证结果 + public function getAuthStatus() + { + try { + $status = $this->client->getAuthStatus($this->domainList, $this->info); + } catch (Exception $e) { + $this->saveResult(-6, $e->getMessage()); + throw $e; + } + if(!$status){ + if ($this->order['retry'] >= 10) { + $this->saveResult(-6, '订单验证未通过'); + } else { + $this->saveLog('订单验证未通过(尝试第'.($this->order['retry']+1).'次)'); + $this->saveResult(2, null, date('Y-m-d H:i:s', time() + (array_key_exists($this->order['retry'], self::$retry_interval) ? self::$retry_interval[$this->order['retry']] : 1800))); + } + throw new Exception('订单验证未通过(尝试第'.($this->order['retry']).'次),请稍后再试'); + } + $this->order['status'] = 2; + $this->resetRetry(); + } + + //签发证书 + public function finalizeOrder() + { + try { + $result = $this->client->finalizeOrder($this->domainList, $this->info, $this->order['keytype'], $this->order['keysize']); + } catch (Exception $e) { + $this->saveResult(-7, $e->getMessage()); + throw $e; + } + $this->order['issuer'] = $result['issuer']; + Db::name('cert_order')->where('id', $this->order['id'])->update(['fullchain' => $result['fullchain'], 'privatekey' => $result['private_key'], 'issuer' => $result['issuer'], 'issuetime' => date('Y-m-d H:i:s', $result['validFrom']), 'expiretime' => date('Y-m-d H:i:s', $result['validTo'])]); + $this->saveResult(3); + $this->resetRetry(); + } + + //吊销证书 + public function revoke() + { + $this->client->setLogger(function ($txt) { + $this->saveLog($txt); + }); + try { + $this->client->revoke($this->info, $this->order['fullchain']); + } catch (Exception $e) { + throw $e; + } + $this->saveResult(4); + } + + //取消证书订单 + public function cancel(){ + $this->client->setLogger(function ($txt) { + $this->saveLog($txt); + }); + if($this->order['status'] == 1 || $this->order['status'] == 2 || $this->order['status'] < -2){ + try { + $this->client->cancel($this->info); + } catch (Exception $e) { + } + } + } + + //添加DNS记录 + public function addDns() + { + if (empty($this->dnsList)) { + $this->saveResult(1); + return; + } + try { + CertDnsUtils::addDns($this->dnsList, function ($txt) { + $this->saveLog($txt); + }, !empty($this->cnameDomainList)); + } catch (Exception $e) { + $this->saveResult(-3, $e->getMessage()); + throw $e; + } + $this->saveResult(1); + $this->resetRetry(); + } + + //删除DNS记录 + public function delDns() + { + if (empty($this->dnsList)) return; + try { + CertDnsUtils::delDns($this->dnsList, function ($txt) { + $this->saveLog($txt); + }, true); + } catch (Exception $e) { + $this->saveLog('[Error] ' . $e->getMessage()); + } + } + + //检查域名CNAME代理记录 + private function checkDomainCname($id) + { + $row = Db::name('cert_cname')->alias('A')->join('domain B', 'A.did = B.id')->where('A.id', $id)->field('A.*,B.name cnamedomain')->find(); + $domain = '_acme-challenge.' . $row['domain']; + $record = $row['rr'] . '.' . $row['cnamedomain']; + $result = \app\utils\DnsQueryUtils::get_dns_records($domain, 'CNAME'); + if (!$result || !in_array($record, $result)) { + $result = \app\utils\DnsQueryUtils::query_dns_doh($domain, 'CNAME'); + if (!$result || !in_array($record, $result)) { + if ($row['status'] == 1) { + Db::name('cert_cname')->where('id', $id)->update(['status' => 0]); + } + throw new Exception('域名' . $row['domain'] . '的CNAME代理记录未验证通过'); + } + } + } + + private function saveLog($txt) + { + if (empty($this->order['processid'])) return; + $file_name = app()->getRuntimePath().'log/'.$this->order['processid'].'.log'; + file_put_contents($file_name, $txt . PHP_EOL, FILE_APPEND); + if(php_sapi_name() == 'cli'){ + echo $txt . PHP_EOL; + } + } +} diff --git a/app/service/CertTaskService.php b/app/service/CertTaskService.php new file mode 100644 index 0000000..83741f0 --- /dev/null +++ b/app/service/CertTaskService.php @@ -0,0 +1,92 @@ +execute_deploy(); + $this->execute_order(); + echo 'done'.PHP_EOL; + } + + private function execute_order() + { + $days = config_get('cert_renewdays', 7); + $list = Db::name('cert_order')->field('id,status,issend')->whereRaw('status NOT IN (3,4) AND (retrytime IS NULL OR retrytime date('Y-m-d H:i:s', time() + $days * 86400)])->select(); + //print_r($list);exit; + $failcount = 0; + foreach ($list as $row) { + try { + $service = new CertOrderService($row['id']); + if ($row['status'] == 3) { + $service->reset(); + } + $retcode = $service->process(); + if ($retcode == 3) { + echo 'ID:'.$row['id'].' 证书已签发成功!'.PHP_EOL; + if($row['issend'] == 0) MsgNotice::cert_send($row['id'], true); + } elseif ($retcode == 1) { + echo 'ID:'.$row['id'].' 添加DNS记录成功!'.PHP_EOL; + } + break; + } catch (Exception $e) { + echo 'ID:'.$row['id'].' '.$e->getMessage().PHP_EOL; + if ($e->getCode() == 102) { + break; + } elseif ($e->getCode() == 103) { + if($row['issend'] == 0) MsgNotice::cert_send($row['id'], false); + } else { + $failcount++; + } + } + if ($failcount >= 3) break; + sleep(1); + } + } + + private function execute_deploy() + { + $start = config_get('deploy_hour_start', 0); + $end = config_get('deploy_hour_end', 23); + $hour = date('H'); + if($start <= $end){ + if($hour < $start || $hour > $end){ + echo '不在部署任务运行时间范围内'.PHP_EOL; return; + } + }else{ + if($hour < $start && $hour > $end){ + echo '不在部署任务运行时间范围内'.PHP_EOL; return; + } + } + + $list = Db::name('cert_deploy')->field('id,status,issend')->whereRaw('status IN (0,-1) AND (retrytime IS NULL OR retrytimeselect(); + //print_r($list);exit; + $count = 0; + foreach ($list as $row) { + try { + $service = new CertDeployService($row['id']); + $service->process(); + echo 'ID:'.$row['id'].' 部署任务执行成功!'.PHP_EOL; + $count++; + } catch (Exception $e) { + echo 'ID:'.$row['id'].' '.$e->getMessage().PHP_EOL; + if ($e->getCode() == 102) { + break; + } elseif ($e->getCode() == 103) { + if($row['issend'] == 0) MsgNotice::cert_send($row['id'], false); + } else { + $count++; + } + } + if ($count >= 3) break; + sleep(1); + } + } +} diff --git a/app/lib/OptimizeService.php b/app/service/OptimizeService.php similarity index 96% rename from app/lib/OptimizeService.php rename to app/service/OptimizeService.php index 964629b..41a8678 100644 --- a/app/lib/OptimizeService.php +++ b/app/service/OptimizeService.php @@ -1,10 +1,14 @@ $list) { + $drow = Db::name('domain')->alias('A')->join('account B', 'A.aid = B.id')->where('A.name', $mainDomain)->field('A.*,B.type')->find(); + if (!$drow) { + if ($cname) { + foreach ($list as $key => $row) { + if ($row['name'] == '_acme-challenge') { + $domain = $mainDomain; + } else { + $domain = str_replace('_acme-challenge.', '', $row['name']) . '.' . $mainDomain; + } + $cname_row = Db::name('cert_cname')->alias('A')->join('domain B', 'A.did = B.id')->where('A.domain', $domain)->field('A.*,B.name cnamedomain')->find(); + if ($cname_row) { + $row['name'] = $cname_row['rr']; + $cnameDomainList[$cname_row['cnamedomain']][] = $row; + unset($list[$key]); + } else { + throw new Exception('域名'.$domain.'未在本系统添加'); + } + } + } else { + throw new Exception('域名'.$mainDomain.'未在本系统添加'); + } + } + if (empty($list)) continue; + $dns = DnsHelper::getModel($drow['aid'], $drow['name'], $drow['thirdid']); + usort($list, function ($a, $b) { + return strcmp($a['name'], $b['name']); + }); + $records = []; + foreach ($list as $row) { + $domain = $row['name'] . '.' . $mainDomain; + if (!isset($records[$row['name']])) $records[$row['name']] = $dns->getSubDomainRecords($row['name'], 1, 100); + if (!$records[$row['name']]) throw new Exception('获取'.$domain.'记录列表失败,'.$dns->getError()); + + $filter_records = array_filter($records[$row['name']]['list'], function ($v) use ($row) { + return $v['Type'] == $row['type'] && $v['Value'] == $row['value']; + }); + if (!empty($filter_records)) { + foreach ($filter_records as $recordid => $record) { + unset($records[$row['name']]['list'][$recordid]); + } + continue; + } + + $filter_records = array_filter($records[$row['name']]['list'], function ($v) use ($row) { + return $v['Type'] == $row['type']; + }); + if (!empty($filter_records)) { + foreach ($filter_records as $recordid => $record) { + $dns->deleteDomainRecord($record['RecordId']); + unset($records[$row['name']]['list'][$recordid]); + $log('Delete DNS Record: '.$domain.' '.$row['type']); + } + } + + $res = $dns->addDomainRecord($row['name'], $row['type'], $row['value'], DnsHelper::$line_name[$drow['type']]['DEF'], 600); + if (!$res && $row['type'] != 'CAA') throw new Exception('添加'.$domain.'解析记录失败,' . $dns->getError()); + $log('Add DNS Record: '.$domain.' '.$row['type'].' '.$row['value']); + } + } + if (!empty($cnameDomainList)) { + self::addDns($cnameDomainList, $log); + } + } + + public static function delDns($dnsList, callable $log, $cname = false) + { + $cnameDomainList = []; + foreach ($dnsList as $mainDomain => $list) { + $drow = Db::name('domain')->alias('A')->join('account B', 'A.aid = B.id')->where('A.name', $mainDomain)->field('A.*,B.type')->find(); + if (!$drow) { + if ($cname) { + foreach ($list as $key => $row) { + if ($row['name'] == '_acme-challenge') { + $domain = $mainDomain; + } else { + $domain = str_replace('_acme-challenge.', '', $row['name']) . '.' . $mainDomain; + } + $cname_row = Db::name('cert_cname')->alias('A')->join('domain B', 'A.did = B.id')->where('A.domain', $domain)->field('A.*,B.name cnamedomain')->find(); + if ($cname_row) { + $row['name'] = $cname_row['rr']; + $cnameDomainList[$cname_row['cnamedomain']][] = $row; + unset($list[$key]); + } else { + throw new Exception('域名'.$domain.'未在本系统添加'); + } + } + } else { + throw new Exception('域名'.$mainDomain.'未在本系统添加'); + } + } + if (empty($list)) continue; + $dns = DnsHelper::getModel($drow['aid'], $drow['name'], $drow['thirdid']); + usort($list, function ($a, $b) { + return strcmp($a['name'], $b['name']); + }); + $records = []; + foreach ($list as $row) { + //if ($row['type'] == 'CAA') continue; + $domain = $row['name'] . '.' . $mainDomain; + if (!isset($records[$row['name']])) $records[$row['name']] = $dns->getSubDomainRecords($row['name'], 1, 100); + if (!$records[$row['name']]) throw new Exception('获取'.$domain.'记录列表失败,'.$dns->getError()); + + $filter_records = array_filter($records[$row['name']]['list'], function ($v) use ($row) { + return $v['Type'] == $row['type'] && ($v['Value'] == $row['value'] || rtrim($v['Value'], '.') == $row['value']); + }); + if (empty($filter_records)) continue; + + foreach ($filter_records as $record) { + $dns->deleteDomainRecord($record['RecordId']); + $log('Delete DNS Record: '.$domain.' '.$row['type'].' '.$row['value']); + } + } + } + if (!empty($cnameDomainList)) { + self::delDns($cnameDomainList, $log); + } + } + + public static function verifyDns($dnsList) + { + if (empty($dnsList)) return true; + foreach ($dnsList as $mainDomain => $list) { + foreach ($list as $row) { + if ($row['type'] == 'CAA') continue; + $domain = $row['name'] . '.' . $mainDomain; + $result = DnsQueryUtils::get_dns_records($domain, $row['type']); + if (!$result || !in_array($row['value'], $result) && !in_array(strtolower($row['value']), $result)) { + $result = DnsQueryUtils::query_dns_doh($domain, $row['type']); + if (!$result || !in_array($row['value'], $result) && !in_array(strtolower($row['value']), $result)) { + return false; + } + } + } + } + return true; + } +} diff --git a/app/lib/CheckUtils.php b/app/utils/CheckUtils.php similarity index 97% rename from app/lib/CheckUtils.php rename to app/utils/CheckUtils.php index d35b52e..ea45536 100644 --- a/app/lib/CheckUtils.php +++ b/app/utils/CheckUtils.php @@ -1,6 +1,6 @@ DNS_A, 'AAAA' => DNS_AAAA, 'CNAME' => DNS_CNAME, 'MX' => DNS_MX, 'TXT' => DNS_TXT]; + if (!array_key_exists($type, $dns_type)) return false; + $list = dns_get_record($domain, $dns_type[$type]); + if (!$list || empty($list)) return false; + $result = []; + foreach ($list as $row) { + if ($row['type'] == 'A') { + $result[] = $row['ip']; + } elseif ($row['type'] == 'AAAA') { + $result[] = $row['ipv6']; + } elseif ($row['type'] == 'CNAME') { + $result[] = $row['target']; + } elseif ($row['type'] == 'MX') { + $result[] = $row['target']; + } elseif ($row['type'] == 'TXT') { + $result[] = $row['txt']; + } + } + return $result; + } + + public static function query_dns_doh($domain, $type) + { + $dns_type = ['A' => 1, 'AAAA' => 28, 'CNAME' => 5, 'MX' => 15, 'TXT' => 16, 'SOA' => 6, 'NS' => 2, 'PTR' => 12, 'SRV' => 33, 'CAA' => 257]; + if (!array_key_exists($type, $dns_type)) return false; + $id = array_rand(self::$doh_servers); + $url = self::$doh_servers[$id].'?name='.urlencode($domain).'&type='.$dns_type[$type]; + $data = get_curl($url); + $arr = json_decode($data, true); + if (!$arr) { + unset($doh_servers[$id]); + $id = array_rand(self::$doh_servers); + $url = self::$doh_servers[$id].'?name='.urlencode($domain).'&type='.$dns_type[$type]; + $data = get_curl($url); + $arr = json_decode($data, true); + if (!$arr) return false; + } + $result = []; + if (isset($arr['Answer'])) { + foreach ($arr['Answer'] as $row) { + $value = $row['data']; + if ($row['type'] == 5) $value = trim($value, '.'); + if ($row['type'] == 16) $value = trim($value, '"'); + $result[] = $value; + } + } + return $result; + } +} diff --git a/app/lib/MsgNotice.php b/app/utils/MsgNotice.php similarity index 57% rename from app/lib/MsgNotice.php rename to app/utils/MsgNotice.php index 7d54b8f..5fb8e6c 100644 --- a/app/lib/MsgNotice.php +++ b/app/utils/MsgNotice.php @@ -1,6 +1,9 @@ field('id,aid,issuetime,expiretime,issuer,status,error')->where('id', $id)->find(); + if (!$row) return; + $type = Db::name('cert_account')->where('id', $row['aid'])->value('type'); + $domainList = Db::name('cert_domain')->where('oid', $id)->column('domain'); + if (empty($domainList)) return; + if ($result) { + if (count($domainList) > 1) { + $mail_title = $domainList[0] . '等' . count($domainList) . '个域名SSL证书签发成功通知'; + } else { + $mail_title = $domainList[0] . '域名SSL证书签发成功通知'; + } + $mail_content = '尊敬的用户,您好:您的SSL证书已签发成功!
证书账户:'.CertHelper::$cert_config[$type]['name'].'('.$row['aid'].')
证书域名:'.implode('、', $domainList).'
签发时间:'.$row['issuetime'].'
到期时间:'.$row['expiretime'].'
颁发机构:'.$row['issuer']; + } else { + $status_arr = [0 => '失败', -1 => '购买证书失败', -2 => '创建订单失败', -3 => '添加DNS失败', -4 => '验证DNS失败', -5 => '验证订单失败', -6 => '订单验证未通过', -7 => '签发证书失败']; + if(count($domainList) > 1){ + $mail_title = $domainList[0].'等'.count($domainList).'个域名SSL证书'.$status_arr[$row['status']].'通知'; + }else{ + $mail_title = $domainList[0].'域名SSL证书'.$status_arr[$row['status']].'通知'; + } + $mail_content = '尊敬的用户,您好:您的SSL证书'.$status_arr[$row['status']].'!
证书账户:'.CertHelper::$cert_config[$type]['name'].'('.$row['aid'].')
证书域名:'.implode('、', $domainList).'
失败时间:'.date('Y-m-d H:i:s').'
失败原因:'.$row['error']; + } + $mail_content .= '
'.self::$sitename.'
'.date('Y-m-d H:i:s'); + + if (config_get('cert_notice_mail') == 1 || config_get('cert_notice_mail') == 2 && !$result) { + $mail_name = config_get('mail_recv') ? config_get('mail_recv') : config_get('mail_name'); + self::send_mail($mail_name, $mail_title, $mail_content); + } + if (config_get('cert_notice_wxtpl') == 1 || config_get('cert_notice_wxtpl') == 2 && !$result) { + $content = str_replace(['
', '', ''], ["\n\n", '**', '**'], $mail_content); + self::send_wechat_tplmsg($mail_title, $content); + } + if (config_get('cert_notice_tgbot') == 1 || config_get('cert_notice_tgbot') == 2 && !$result) { + $content = str_replace('
', "\n", $mail_content); + $content = "" . $mail_title . "\n" . $content; + self::send_telegram_bot($content); + } + Db::name('cert_order')->where('id', $id)->update(['issend' => 1]); + } + + public static function deploy_send($id, $result) + { + $row = Db::name('cert_deploy')->field('id,aid,oid,remark,status,error')->where('id', $id)->find(); + if (!$row) return; + $account = Db::name('cert_account')->field('id,type,name,remark')->where('id', $row['aid'])->find(); + $domainList = Db::name('cert_domain')->where('oid', $row['oid'])->column('domain'); + $typename = DeployHelper::$deploy_config[$account['type']]['name']; + $mail_title = $typename; + if(!empty($row['remark'])) $mail_title .= '('.$row['remark'].')'; + $mail_title .= '证书部署'.($result?'成功':'失败').'通知'; + if ($result) { + $mail_content = '尊敬的用户,您好:您的SSL证书已成功部署到'.$typename.'!
自动部署账户:['.$account['id'].']'.$typename.'('.($account['remark']?$account['remark']:$account['name']).')
关联SSL证书:['.$row['oid'].']'.implode('、', $domainList).'
任务备注:'.($row['remark']?$row['remark']:'无'); + } else { + $mail_content = '尊敬的用户,您好:您的SSL证书部署失败!
失败原因:'.$row['error'].'
自动部署账户:['.$account['id'].']'.$typename.'('.($account['remark']?$account['remark']:$account['name']).')
关联SSL证书:['.$row['oid'].']'.implode('、', $domainList).'
任务备注:'.($row['remark']?$row['remark']:'无'); + } + $mail_content .= '
'.self::$sitename.'
'.date('Y-m-d H:i:s'); + + if (config_get('cert_notice_mail') == 1 || config_get('cert_notice_mail') == 2 && !$result) { + $mail_name = config_get('mail_recv') ? config_get('mail_recv') : config_get('mail_name'); + self::send_mail($mail_name, $mail_title, $mail_content); + } + if (config_get('cert_notice_wxtpl') == 1 || config_get('cert_notice_wxtpl') == 2 && !$result) { + $content = str_replace(['
', '', ''], ["\n\n", '**', '**'], $mail_content); + self::send_wechat_tplmsg($mail_title, $content); + } + if (config_get('cert_notice_tgbot') == 1 || config_get('cert_notice_tgbot') == 2 && !$result) { + $content = str_replace('
', "\n", $mail_content); + $content = "" . $mail_title . "\n" . $content; + self::send_telegram_bot($content); + } + Db::name('cert_deploy')->where('id', $id)->update(['issend' => 1]); + } + public static function send_mail($to, $sub, $msg) { $mail_type = config_get('mail_type'); diff --git a/app/view/auth/login.html b/app/view/auth/login.html index 281b9bc..947675b 100644 --- a/app/view/auth/login.html +++ b/app/view/auth/login.html @@ -30,6 +30,9 @@ a{color:#444} #login-form{margin-top:20px} #login-form .input-group{margin-bottom:15px} #login-form .form-control{font-size:14px} +#totp-form{margin-top:20px} +#totp-form .input-group{margin-bottom:15px} +#totp-form .form-control{font-size:14px} @@ -56,18 +59,49 @@ a{color:#444}
- +
+ + + + + \ No newline at end of file diff --git a/app/view/cert/account_form.html b/app/view/cert/account_form.html new file mode 100644 index 0000000..764b0c1 --- /dev/null +++ b/app/view/cert/account_form.html @@ -0,0 +1,237 @@ +{extend name="common/layout" /} +{block name="title"}{$title}{/block} +{block name="main"} + +
+
+
+

返回{if $action=='edit'}编辑{else}添加{/if}{$title}

+
+
+
+ +
+ +
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+ +
+
+
+
+
+ + 提示: +
+
+
+
+
+
+
+
+
+{/block} +{block name="script"} + + + + +{/block} \ No newline at end of file diff --git a/app/view/cert/certaccount.html b/app/view/cert/certaccount.html new file mode 100644 index 0000000..dcfb725 --- /dev/null +++ b/app/view/cert/certaccount.html @@ -0,0 +1,93 @@ +{extend name="common/layout" /} +{block name="title"}SSL证书账户管理{/block} +{block name="main"} +
+
+
+
+ +
+
+ + +
+ + 刷新 + 添加 +
+ + +
+
+
+
+
+{/block} +{block name="script"} + + + + + +{/block} \ No newline at end of file diff --git a/app/view/cert/certorder.html b/app/view/cert/certorder.html new file mode 100644 index 0000000..c3239b4 --- /dev/null +++ b/app/view/cert/certorder.html @@ -0,0 +1,385 @@ +{extend name="common/layout" /} +{block name="title"}SSL证书订单列表{/block} +{block name="main"} + +
+
+
+
+ +
+ +
+ +
+ +
+
+
+ +
+ + 刷新 + 添加 +
+ + +
+
+
+
+
+{/block} +{block name="script"} + + + + + + +{/block} \ No newline at end of file diff --git a/app/view/cert/certset.html b/app/view/cert/certset.html new file mode 100644 index 0000000..a7fa391 --- /dev/null +++ b/app/view/cert/certset.html @@ -0,0 +1,115 @@ +{extend name="common/layout" /} +{block name="title"}SSL证书计划任务{/block} +{block name="main"} +
+
+
+

计划任务说明

+
+

  • 计划任务:将以下命令添加到计划任务,1分钟1次
  • +

    cd {:app()->getRootPath()} && php think certtask

    +
    +
    + +
    +

    自动续签设置

    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    + +
    + +
    +

    自动部署设置

    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +

    通知设置

    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + +
    + +
    +
    +{/block} +{block name="script"} + + +{/block} \ No newline at end of file diff --git a/app/view/cert/cname.html b/app/view/cert/cname.html new file mode 100644 index 0000000..e7867e6 --- /dev/null +++ b/app/view/cert/cname.html @@ -0,0 +1,244 @@ +{extend name="common/layout" /} +{block name="title"}CMAME代理记录管理{/block} +{block name="main"} + + +

    CNAME代理可以让未在本系统添加的域名自动申请SSL证书,支持所有DNS服务商。

    注:仅支持基于ACME的证书类型,不支持腾讯云等云厂商SSL证书。

    +
    +
    +
    +
    + +
    +
    + + +
    + + 刷新 + 添加 +
    + + +
    +
    +
    +
    +
    +{/block} +{block name="script"} + + + + + + +{/block} \ No newline at end of file diff --git a/app/view/cert/deploy_form.html b/app/view/cert/deploy_form.html new file mode 100644 index 0000000..e2011e1 --- /dev/null +++ b/app/view/cert/deploy_form.html @@ -0,0 +1,253 @@ +{extend name="common/layout" /} +{block name="title"}自动部署任务{/block} +{block name="main"} + +
    +
    +
    +

    返回{if $action=='edit'}编辑{else}添加{/if}自动部署任务

    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    + + 提示: +
    +
    +
    +
    +
    +
    +
    +
    +
    +{/block} +{block name="script"} + + + + + + +{/block} \ No newline at end of file diff --git a/app/view/cert/deployaccount.html b/app/view/cert/deployaccount.html new file mode 100644 index 0000000..a392d80 --- /dev/null +++ b/app/view/cert/deployaccount.html @@ -0,0 +1,93 @@ +{extend name="common/layout" /} +{block name="title"}自动部署任务账户管理{/block} +{block name="main"} +
    +
    +
    +
    + +
    +
    + + +
    + + 刷新 + 添加 +
    + + +
    +
    +
    +
    +
    +{/block} +{block name="script"} + + + + + +{/block} \ No newline at end of file diff --git a/app/view/cert/deploytask.html b/app/view/cert/deploytask.html new file mode 100644 index 0000000..fe07dca --- /dev/null +++ b/app/view/cert/deploytask.html @@ -0,0 +1,286 @@ +{extend name="common/layout" /} +{block name="title"}SSL证书自动部署任务{/block} +{block name="main"} + +
    +
    +
    +
    + +
    + +
    + +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    + + 刷新 + 添加 +
    + + +
    +
    +
    +
    +
    +{/block} +{block name="script"} + + + + + + +{/block} \ No newline at end of file diff --git a/app/view/cert/order_form.html b/app/view/cert/order_form.html new file mode 100644 index 0000000..b8e9102 --- /dev/null +++ b/app/view/cert/order_form.html @@ -0,0 +1,157 @@ +{extend name="common/layout" /} +{block name="title"}SSL证书订单{/block} +{block name="main"} + +
    +
    +
    +

    返回{if $action=='edit'}修改{else}添加{/if}SSL证书订单

    +
    +
    +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    + +
    + +
    + +
    +
    + +
    +
    +
    +

    提示:添加或修改订单信息,点击提交后,不会立即执行签发,只能通过计划任务或列表手动点击来执行

    证书签发之前确保该主域名下没有CAA类型记录,避免证书验证失败。

    +
    +
    +
    +{/block} +{block name="script"} + + + + +{/block} \ No newline at end of file diff --git a/app/view/common/layout.html b/app/view/common/layout.html index 40cdc15..2d2bea0 100644 --- a/app/view/common/layout.html +++ b/app/view/common/layout.html @@ -11,7 +11,7 @@ - + @@ -202,8 +226,8 @@
    - - + + {/block} \ No newline at end of file diff --git a/app/view/dmonitor/task.html b/app/view/dmonitor/task.html index 5cdcddd..34efc01 100644 --- a/app/view/dmonitor/task.html +++ b/app/view/dmonitor/task.html @@ -57,7 +57,7 @@ $(document).ready(function(){ field: 'rr', title: '域名', formatter: function(value, row, index) { - return '' + value + '.' + row.domain + ''; + return '' + value + '.' + row.domain + ''; } }, { @@ -114,13 +114,11 @@ $(document).ready(function(){ field: 'active', title: '运行开关', formatter: function(value, row, index) { - var html = ''; - if(value == 1) { - html += '开启'; - } else { - html += '暂停'; + if(value == 1){ + return '
    '; + }else{ + return '
    '; } - return html; } }, { diff --git a/app/view/index/setpwd.html b/app/view/index/setpwd.html index 90d1601..9b4f198 100644 --- a/app/view/index/setpwd.html +++ b/app/view/index/setpwd.html @@ -25,10 +25,58 @@
    +
    +

    TOTP二次验证

    +
    +
    +
    +
    + {if $user.totp_open == 1} + +
    +
    + {else} + +
    + {/if} +
    +
    +
    +
    + +
    + {/block} {block name="script"} + + {/block} \ No newline at end of file diff --git a/app/view/optimizeip/opiplist.html b/app/view/optimizeip/opiplist.html index a633597..8a283e3 100644 --- a/app/view/optimizeip/opiplist.html +++ b/app/view/optimizeip/opiplist.html @@ -57,7 +57,7 @@ $(document).ready(function(){ field: 'rr', title: '域名', formatter: function(value, row, index) { - return '' + value + '.' + row.domain + ''; + return '' + value + '.' + row.domain + ''; } }, { @@ -98,13 +98,11 @@ $(document).ready(function(){ field: 'active', title: '任务开关', formatter: function(value, row, index) { - var html = ''; - if(value == 1) { - html += '开启'; - } else { - html += '关闭'; + if(value == 1){ + return '
    '; + }else{ + return '
    '; } - return html; } }, { diff --git a/app/view/system/noticeset.html b/app/view/system/noticeset.html new file mode 100644 index 0000000..a71a710 --- /dev/null +++ b/app/view/system/noticeset.html @@ -0,0 +1,200 @@ +{extend name="common/layout" /} +{block name="title"}通知设置{/block} +{block name="main"} +
    +
    +
    +

    发信邮箱设置

    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +

    微信公众号消息接口设置

    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + +
    +
    +

    Telegram机器人接口设置

    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    +{/block} +{block name="script"} + + +{/block} \ No newline at end of file diff --git a/app/view/system/proxyset.html b/app/view/system/proxyset.html new file mode 100644 index 0000000..f063f13 --- /dev/null +++ b/app/view/system/proxyset.html @@ -0,0 +1,113 @@ +{extend name="common/layout" /} +{block name="title"}容灾切换代理设置{/block} +{block name="main"} +
    +
    +
    +

    代理服务器设置

    +
    +
    +
    + +
    +

    +
    + +
    +

    +
    + +
    +

    +
    + +
    +

    +
    + +
    +

    + +
    +
    + +
    +
    +
    +{/block} +{block name="script"} + + +{/block} \ No newline at end of file diff --git a/config/app.php b/config/app.php index 87ae3bc..e6cd05a 100644 --- a/config/app.php +++ b/config/app.php @@ -31,7 +31,7 @@ return [ 'show_error_msg' => true, 'exception_tmpl' => \think\facade\App::getAppPath() . 'view/exception.tpl', - 'version' => '1018', + 'version' => '1021', - 'dbversion' => '1011' + 'dbversion' => '1021' ]; diff --git a/config/console.php b/config/console.php index ebb1915..2cf352c 100644 --- a/config/console.php +++ b/config/console.php @@ -7,5 +7,7 @@ return [ 'commands' => [ 'dmtask' => 'app\command\Dmtask', 'opiptask' => 'app\command\Opiptask', + 'certtask' => 'app\command\Certtask', + 'reset' => 'app\command\Reset', ], ]; diff --git a/public/static/css/bootstrap-table.css b/public/static/css/bootstrap-table.css index f7beca8..607d063 100644 --- a/public/static/css/bootstrap-table.css +++ b/public/static/css/bootstrap-table.css @@ -5,3 +5,9 @@ .form-inline .form-control{display:inline-block;width:auto;vertical-align:middle} .form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle} .bv-form .help-block{margin-bottom:0}.bv-form .tooltip-inner{text-align:left}.nav-tabs li.bv-tab-success>a{color:#3c763d}.nav-tabs li.bv-tab-error>a{color:#a94442}.bv-form .bv-icon-no-label{top:0}.bv-form .bv-icon-input-group{top:0;z-index:100} +.material-switch>input[type=checkbox]{display:none} +.material-switch>label{cursor:pointer;height:0;position:relative;width:36px;margin-left:4px} +.material-switch>label::before{background:#000;box-shadow:inset 0 0 10px rgba(0,0,0,.5);border-radius:8px;content:'';height:16px;margin-top:-7px;position:absolute;opacity:.3;transition:all .4s ease-in-out;width:36px} +.material-switch>label::after{background:#fff;border-radius:16px;box-shadow:0 0 5px rgba(0,0,0,.3);content:'';height:20px;left:-2px;margin-top:-5px;position:absolute;top:-4px;transition:all .3s ease-in-out;width:20px} +.material-switch>input[type=checkbox]:checked+label::before{background:inherit;opacity:.5} +.material-switch>input[type=checkbox]:checked+label::after{background:inherit;left:20px} diff --git a/public/static/images/aws.ico b/public/static/images/aws.ico new file mode 100644 index 0000000000000000000000000000000000000000..643489f29eba7ead92767e6540ef75c683c96766 GIT binary patch literal 1150 zcmds%Jx;?w5QQg#9#_;3P8>?#01iOm8-PR;N?Lv*Q>36k6d*+DqxdK(9TbTb#uh(D z%Nx7{YipeYkmzN+Z{Iim(Kti{d_9lw>(g|TXq$+3Ay{x4AI166T9Zq`MG*(*MeZLi z7f|fVxIAzXltG#WR+Qm5axv^7>p`h-QY~Kkr0_hu53&iQgt4oG=ROKb%z5|ieD}DA zmuq46fAW__HNJjR!P$%QkCs)>b8z1OXkO)$hgwe_zZ%`wdgt_+XGxx$)D}OwD)aE} z^N$nW)bQqA4^LKhpN5~ks_^!s{5@ZX>-yt)Rr<$IYB*gT@C;x-fmAoQX3g23`H?xq z+Jn+HjNBJzTJtMC%(X<=&m4W&>wSOh9;Q!>{K6>i?#sH~BT`#L*#=QEaQ;QKOwMqY H8D_r$NYRp- literal 0 HcmV?d00001 diff --git a/public/static/images/bt.ico b/public/static/images/bt.ico new file mode 100644 index 0000000000000000000000000000000000000000..0a5d0eaddb5136cdefd8cfa01828d9a430ed5017 GIT binary patch literal 4286 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 literal 0 HcmV?d00001 diff --git a/public/static/images/gcore.ico b/public/static/images/gcore.ico new file mode 100644 index 0000000000000000000000000000000000000000..652db763a1bcc781be5ed5c1ccc61c764c82af2c GIT binary patch literal 15086 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 Q&OFJqH1yTW)U03p7p!LX#sB~S literal 0 HcmV?d00001 diff --git a/public/static/images/letsencrypt.ico b/public/static/images/letsencrypt.ico new file mode 100644 index 0000000000000000000000000000000000000000..9196d22db2a41192f447ab2f5bf8b5c83ef8ea79 GIT binary patch literal 6518 zcmeHLeQ;D)6~CcqKZp$yvSHtDb~hi8>@GMF`Ji@e z{itT@9}YF9T0zE;?7p`FQVB(+wK$`rOveGsKtr_5)XrEtqS9hV1oH9TZj!y8bKl;# zn~+#K;$P*?+`V7toZq?Uo_pT8OGpY?M3yck&^k!LrwCa^2yr;#UR_9`of5t7SX)&g4fs}5$?J^lg1+Q+YhtBqE7JQ* zOPMsz@2^}{AbOmakq&rgSoOFkBwf}_lB}J`UsF@F7+cQY;wxAM^4~|g_UaWfsEu*{E`>5z!b3yWCKP36wN7~nWs8n0Y?BXxCJ&N>P@4Su^cj9=* z7O_~YDjDxACp1t@raHWK+Eurfc2)sy37KwRM=0*2bwfiN(pNlr`fhA_*_T051MA3} zf%=U=(>N{GR!vI2;xVzRV!~hlKt$TuK*h@34|k`L^_&OW%>84g)Uf7Ux&MQl$2VE@?j`Et)vVt?$BXB#}d?_8>S;ZK(z_P@|r(|yuG!qjOFU$G&-HLQKp zs%Y<;746sLIL#(Q^auAIr<5F{TX3D6q3L%XrYaeVohQGc_VmH2?^|W)Gkk4$_b8t%O0F~J@4m~I)dZ96kO5XKmLG8jPF=GBG51_r9J_|;r`dcTO|vN4kIbRTXKy8Q$8JRwmaSZe7Ed2QS-zt1&V>kBURrAJSEd$ zgJ=ijz5YtKjOT=f-*0_%<1f$uTFPw@f8AOK>`(IK#-y@kMs4%v(zdE1sD)5h>n+5e zwhDntWobYMwcN~X^<-TM@E086NL)T2>n(iqAy>g#vt4HS%l0PGA$bZ%QGei{hMYIX zYrvZiIjN>Qye$NQ4-*cXB`0gKR(^p>^-en?N{GQ@RTk7hzwdIkmJ05HeZ2b1=_crugpc{JL zqMM#O_2-=pk4d7FGEDy$KF`-GSEP7nKDFAC+A@1zJDwi_FhZJypQ27H|GHaRj3x%DW^GeeQzV;^$OLns3a?Q3IU%S9EV9~)75k>JTF>!srUkiQLAiiG z12twl6r=FAxd`)r09_^!TL?u|)X^-f7c=_RJdXd7^5I@2`-(x2Id>mr@8OJnms0`f zD4q|V1L!bqgR{o-K%3+FAa)k`2+)1Wkb(S6zA>YBZkS^+U=iu=h6?)N9i3Cq)_{8e z?T=UtKduAX2F~=a!oXw#oAVp;IK~3|P0$W$vGl4sK)OKhFWB!AT snfcT__%|o{V^HZBnS*LJe2YDHnd<-fF#L}p<~HeV3*5HAC)xu40SdyhUH||9 literal 0 HcmV?d00001 diff --git a/public/static/images/opanel.png b/public/static/images/opanel.png new file mode 100644 index 0000000000000000000000000000000000000000..6f82a12d56b666914ee686321551411e970d4db5 GIT binary patch literal 9330 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 k#C00090P)t-s0000I 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 literal 0 HcmV?d00001 diff --git a/public/static/images/server2.png b/public/static/images/server2.png new file mode 100644 index 0000000000000000000000000000000000000000..478f124af3a17f44286a5d7b85d5318a5bd1589f GIT binary patch literal 7111 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 EKVTwNZ~y=R literal 0 HcmV?d00001 diff --git a/public/static/images/ssl.ico b/public/static/images/ssl.ico new file mode 100644 index 0000000000000000000000000000000000000000..d83c3ae98873a339e4b3f20a71007e41e37f973f GIT binary patch literal 9662 zcmds6U5Hgx6h2OZ(ZeV{^blepB_iS6d(OQxp{UajR1_(Kv}sKdx2letK`%Ua&9tA_lRH!{>^5m@c{5=#0VV%eba}(Vivh z%(o*!!S(SlkLx_&PxYOYQySp)}8|p3k=}$xkl+WoTU%r@wqatw7>Y(B6XtY-S8eCM4Eh1QZ0|qtYdhDj=Cv;P$NC3*YC7DkZuZ~QCFSeXtUuRV${Q4e zSYX%;o0>}>8%+41ejOJ(CAHxG^X#tY)mz`ct#+Q>ss8-ycEuhMgILbP=ZbllF1gw0 zi~$*^FWI|Po$oy#@WZv*d1=4ecIq=4pQ;0wzYX3a-~?a9Ft&}f;VgZ)E@6`IKe|@_ zXR(zBR;iP}oeHi6?j^k`{{=Sqz&8wcB%9=BqmP6YwTA2YgTo)H<2@(TrJI*k&z&AU zpu>hW_;5U*E9P;!4QJ`Yczm{;!^S@Ui$=A)V}*LNYqcKBJ6=%>$i`UJNXgAc=Ng5{ zu>R5ei}+k0hLO}}!&&<1?;TojZ^L&DGy>i3ewUPAoWJB|qZ8g>6lRk8C-FtT2>(GF z&eDgly2JQ$|La_okA0%-wti9ZWxLxgx!LHHcMJ7R>0i)4#KGHQ!&&;I3-sM%&O1nB zSRX~4ap>76@ugf97=G@9;3B!%=x)xPNZ;GdCX5|4Py^j7BcJndAH*e%@%@qWb8ztT zURB@W@;)2d8>V=L>?}85KZo}rgPb3I$>-RIJ!RYL9T|jhAEtX zEG6<8J+Z!8e8O69;#G@f8yC_G&9CA)oHNTg())(p%|2~2k;=UF9^&YSf!X7QZ zxOmESsGKwS4Q3zx#xpH0&Rjd>xO8)NEbk2P$X&%H7&Q<0Q z3GAdeN&Vei_9e>IFZ0qfMlmT4#TP3uTD*Q>C&fwXpU!*JDQ`zbZhF3^+O2bQUTqv6 zJqy$l##+St*AMl-?!^vZ$Hk9!JzLC9q}=#D_>X&g(D|>?d^7dMjHn#xygLIvG~g@l z!!EE^W?QqPdQOPSYudehb}HqoJpVGE+f>WrbZxGtTBha}=GJ0BC;Mu>mh;Hx-aW_H zi}cJe)yy-VbE;J><;K0Jl)~pm{_JS#=o9_f&F3uj@^j%KV2vl&DC}}wz1{TLlCJ0A ze#-qE^oQLpyyo2bV9<;eTK9et3VXw`piH zO%(maUY1R!^w-(XkbMgW9!5ONfyuoKF&~(8QAPAW@$8I1L^{2Ng* B11|so literal 0 HcmV?d00001 diff --git a/public/static/images/tencent.ico b/public/static/images/tencent.ico new file mode 100644 index 0000000000000000000000000000000000000000..1e94a78193fe69ba0c2d8fdd92b361deb11844e1 GIT binary patch literal 949 zcmV;m14{e=0096203aX$0096X0G|T@02TlM0EtjeM-2)Z3IG5A4M|8uQUCw|AOHXW zAP5Ek0047(dh`GQ010qNS#tmY3ljhU3ljkVnw%H_00U7;L_t(oh3(f{XdP7)2H`YKj*z(v4=ab4kBxwyqy^98_Bzo~ zjAKrn*e2Yq9R6tpUt|xwFo}Ez?nAqBXj2Yz(T@A^b1v_WthS*w?m$m+GhWMiUcv*) z;lMQcZFmm1<-DPYBmWf- z9+4c%A;G;;@jv1RESoOzAA#E72c%uGx}n>vC@^*pS#g3)J{qTdS%=u4JhJub$4?8A5H7l-fb3#e4*OE=Js)4(3WQ+Prts(-Zlk`_FM z4QQDLXAABThd(C-RH}>d3C?P~H1ILjDn)xHkr8b03tSphs@LM}%sGbZm7;h4 zqc;`#5H^}PIop(?^E2|TI44)J0UO1WHep|~25+J{^IR8Hssp$tb3CaOJvy~xhlQ-c zX6(gEgIx!tBZ;z%aoi~muS|>4m-OP1?CIU0Qr&|EnWIxF`eoYURKx|?XRvDwAD}CX z9Kt$r=x6CayhHS;p>9Lr=mN< ztu0^}OKWe!i|~=DUp6DgnztM3=rZ1vj~$fGzKFHf#pN<__%eSNEJHueIP4p19yHwE zZmhYjAXyO`lAf4vn0yNtDS%KxwJ#<$Wp?k4fX$4Qv|HJcSM(*phl Xf#UiPf=C5l00000NkvXXu0mjf6{)rg literal 0 HcmV?d00001 diff --git a/public/static/images/ucloud.ico b/public/static/images/ucloud.ico new file mode 100644 index 0000000000000000000000000000000000000000..570e65069492b6375ab386581eadc5b7998ccbe3 GIT binary patch literal 15086 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 O0000+P`-qQP>5gD1d>9Cfy5SQM6nPLsfH*~ zNx%?epeCAt!3chpkIIKZ0)HS72`O?w0R@Akr3e(EH0PW4w(iWeyWQPmX0P)*nC`ya zoqO}%@BMx=yX_rgX{Yoz2+I*V9-wZTb5xq`sfMm&V?oRWLQP=OaDotuyndO@5xAA|o>RJ& ze0H0YKY7B7%g@xF9`!-tOYFIek)wr$!ðDW3Yp^b^0$e|cSQ;ew}ghOfSzQ+FVD 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 literal 0 HcmV?d00001 diff --git a/public/static/js/custom.js b/public/static/js/custom.js index 228c852..f4fc0bf 100644 --- a/public/static/js/custom.js +++ b/public/static/js/custom.js @@ -79,7 +79,7 @@ if (typeof $.fn.bootstrapTable !== "undefined") { queryParamsType: '', queryParams: function(params) { $('#searchToolbar').find(':input[name]').each(function() { - if(!$(this).is(":visible")) return; + //if(!$(this).is(":visible")) return; params[$(this).attr('name')] = $(this).val() }) updateQueryStr(params); diff --git a/route/app.php b/route/app.php index 2a6d686..e9466e1 100644 --- a/route/app.php +++ b/route/app.php @@ -21,10 +21,10 @@ Route::pattern([ Route::any('/install', 'install/index') ->middleware(ViewOutput::class); -Route::get('/verifycode', 'auth/verifycode')->middleware(SessionInit::class) -->middleware(ViewOutput::class); Route::any('/login', 'auth/login')->middleware(SessionInit::class) ->middleware(ViewOutput::class); +Route::get('/verifycode', 'auth/verifycode')->middleware(SessionInit::class); +Route::post('/auth/totp', 'auth/totp')->middleware(SessionInit::class); Route::get('/logout', 'auth/logout'); Route::any('/quicklogin', 'auth/quicklogin'); Route::any('/dmtask/status', 'dmonitor/status'); @@ -35,6 +35,7 @@ Route::group(function () { Route::post('/changeskin', 'index/changeskin'); Route::get('/cleancache', 'index/cleancache'); Route::any('/setpwd', 'index/setpwd'); + Route::any('/totp/:action', 'index/totp'); Route::get('/test', 'index/test'); Route::post('/user/data', 'user/user_data'); @@ -72,11 +73,6 @@ Route::group(function () { Route::get('/dmonitor/task/info/:id', 'dmonitor/taskinfo'); Route::any('/dmonitor/task/:action', 'dmonitor/taskform'); Route::get('/dmonitor/task', 'dmonitor/task'); - Route::any('/dmonitor/noticeset', 'dmonitor/noticeset'); - Route::any('/dmonitor/proxyset', 'dmonitor/proxyset'); - Route::get('/dmonitor/mailtest', 'dmonitor/mailtest'); - Route::get('/dmonitor/tgbottest', 'dmonitor/tgbottest'); - Route::post('/dmonitor/proxytest', 'dmonitor/proxytest'); Route::post('/dmonitor/clean', 'dmonitor/clean'); Route::any('/optimizeip/opipset', 'optimizeip/opipset'); @@ -85,6 +81,36 @@ Route::group(function () { Route::get('/optimizeip/opiplist', 'optimizeip/opiplist'); Route::any('/optimizeip/opipform/:action', 'optimizeip/opipform'); + Route::get('/cert/certaccount', 'cert/certaccount'); + Route::get('/cert/deployaccount', 'cert/deployaccount'); + Route::post('/cert/account/data', 'cert/account_data'); + Route::post('/cert/account/:action', 'cert/account_op'); + Route::get('/cert/account/:action', 'cert/account_form'); + + Route::get('/cert/certorder', 'cert/certorder'); + Route::post('/cert/order/data', 'cert/order_data'); + Route::post('/cert/order/process', 'cert/order_process'); + Route::post('/cert/order/:action', 'cert/order_op'); + Route::get('/cert/order/:action', 'cert/order_form'); + + Route::get('/cert/deploytask', 'cert/deploytask'); + Route::post('/cert/deploy/data', 'cert/deploy_data'); + Route::post('/cert/deploy/process', 'cert/deploy_process'); + Route::post('/cert/deploy/:action', 'cert/deploy_op'); + Route::get('/cert/deploy/:action', 'cert/deploy_form'); + + Route::get('/cert/cname', 'cert/cname'); + Route::post('/cert/cname/data', 'cert/cname_data'); + Route::post('/cert/cname/:action', 'cert/cname_op'); + + Route::any('/cert/certset', 'cert/certset'); + + Route::any('/system/noticeset', 'system/noticeset'); + Route::any('/system/proxyset', 'system/proxyset'); + Route::get('/system/mailtest', 'system/mailtest'); + Route::get('/system/tgbottest', 'system/tgbottest'); + Route::post('/system/proxytest', 'system/proxytest'); + })->middleware(CheckLogin::class) ->middleware(ViewOutput::class);