62 Commits
2.15 ... 2.18

Author SHA1 Message Date
net909
532cecc3bf 新增阿里云WAF(云产品接入)部署SSL证书 2026-05-02 22:12:22 +08:00
net909
91864aa6be 增加自定义Webhook通知方式 2026-05-02 21:19:11 +08:00
net909
9403875044 优化界面显示 2026-05-02 20:49:09 +08:00
小玖
5d53d46659 feat(域名管理): 新增DNS检测工具功能 (#455)
* feat(域名管理): 新增DNS检测工具功能

添加DNS检测工具页面和相关功能,包括:
1. 在导航菜单添加DNS检测工具入口
2. 实现DNS记录检测功能,支持多种记录类型
3. 提供多种DNS服务器选择进行检测
4. 在记录管理页面添加单条记录检测按钮
5. 实现检测结果可视化展示

* feat(domain): 添加域名分类功能

实现域名分类管理功能,包括:
1. 新增分类表和相关路由
2. 在域名管理界面添加分类筛选和批量设置
3. 实现分类的增删改查接口
4. 更新数据库结构和版本号

---------

Co-authored-by: 小玖 <232709122+xiaojiu-code@users.noreply.github.com>
2026-05-02 20:24:55 +08:00
dependabot[bot]
c73f9cd536 Bump symfony/polyfill-mbstring from 1.36.0 to 1.37.0 (#450)
Bumps [symfony/polyfill-mbstring](https://github.com/symfony/polyfill-mbstring) from 1.36.0 to 1.37.0.
- [Release notes](https://github.com/symfony/polyfill-mbstring/releases)
- [Commits](https://github.com/symfony/polyfill-mbstring/compare/v1.36.0...v1.37.0)

---
updated-dependencies:
- dependency-name: symfony/polyfill-mbstring
  dependency-version: 1.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-29 23:11:23 +08:00
dependabot[bot]
97dfc1f12f Bump symfony/polyfill-php81 from 1.36.0 to 1.37.0 (#451)
Bumps [symfony/polyfill-php81](https://github.com/symfony/polyfill-php81) from 1.36.0 to 1.37.0.
- [Release notes](https://github.com/symfony/polyfill-php81/releases)
- [Commits](https://github.com/symfony/polyfill-php81/compare/v1.36.0...v1.37.0)

---
updated-dependencies:
- dependency-name: symfony/polyfill-php81
  dependency-version: 1.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-29 23:11:11 +08:00
dependabot[bot]
a5ec8a3ff6 Bump symfony/polyfill-intl-idn from 1.36.0 to 1.37.0 (#452)
Bumps [symfony/polyfill-intl-idn](https://github.com/symfony/polyfill-intl-idn) from 1.36.0 to 1.37.0.
- [Release notes](https://github.com/symfony/polyfill-intl-idn/releases)
- [Commits](https://github.com/symfony/polyfill-intl-idn/compare/v1.36.0...v1.37.0)

---
updated-dependencies:
- dependency-name: symfony/polyfill-intl-idn
  dependency-version: 1.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-29 23:11:02 +08:00
dependabot[bot]
12bdb6cb67 Bump symfony/polyfill-php82 from 1.36.0 to 1.37.0 (#453)
Bumps [symfony/polyfill-php82](https://github.com/symfony/polyfill-php82) from 1.36.0 to 1.37.0.
- [Release notes](https://github.com/symfony/polyfill-php82/releases)
- [Commits](https://github.com/symfony/polyfill-php82/compare/v1.36.0...v1.37.0)

---
updated-dependencies:
- dependency-name: symfony/polyfill-php82
  dependency-version: 1.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-29 23:10:43 +08:00
wmwlwmwl
a99e3b8642 1.修复已有解析记录:修改清空搜索,切换域名没清空搜索,还有显示问题 2.Cloudflare自定义主机名添加CF优选解析和批量CF优选解析 (#456)
* Add files via upload

1.修复已有解析记录:修改清空搜索,切换域名没清空搜索,还有显示问题
2.Cloudflare自定义主机名添加CF优选解析和批量CF优选解析

* Add files via upload
2026-04-29 23:10:31 +08:00
net909
a1cfd470d9 修复批量修改关联证书 2026-04-28 21:18:10 +08:00
wmwlwmwl
945d91386c 1.修复智能解析页面的搜索。2.修复Cloudflare自定义主机名的分页,去掉多余代码 (#447)
* Update RewriteRule in .htaccess for cleaner routing

修复Apache环境下路由重写规则
废弃旧版 index.php/$1 写法,改用兼容新版PHP的PATH_INFO传参方式
解决访问时报错 No input file specified. 问题

* Add files via upload

1.添加DCV 委派一键添加CNAME
2.添加证书验证方法和最低 TLS 版本
3.添加批量添加 修改 删除
4.修复华为云一键txt解析失败(我没其他dns, 其他的需关注)
5.Cloudflare增强改Cloudflare自定义主机名

* 1.添加快速解析 2.Cloudflare自定义主机名添加搜索功能

* Add files via upload

1.Cloudflare自定义主机名自动获取默认线路(支持所有dns,华为云退回之前)
2.优化手机上显示问题
3.一键添加 DCV 委派支持选择要写入的解析域名

* 优化手机显示

* 添加1. 批量 DCV 委派 2. 批量主机名 TXT 验证 3. 批量证书 TXT 验证 4. 批量刷新验证

1. 批量 DCV 委派
2. 批量主机名 TXT 验证
3. 批量证书 TXT 验证
4. 批量刷新验证

* 快速解析改名智能解析,添加已有解析记录和智能批量添加

* 快速解析改名智能解析,添加已有解析记录和智能批量添加

* 由于之前复制保存的,代码有些差异

* 修复已有解析记录的备注功能

* 备注按dns显示

* 修复记录值过长无法复制,优化显示

* 优化显示

* 1.修复智能解析页面的搜索。2.修复Cloudflare自定义主机名的分页,去掉多余代码
2026-04-24 20:03:43 +08:00
wmwlwmwl
668e2b4ceb Cloudflare增强添加DCV 委派+优化,添加快速解析功能,已有解析记录和智能批量添加 (#442)
* Update RewriteRule in .htaccess for cleaner routing

修复Apache环境下路由重写规则
废弃旧版 index.php/$1 写法,改用兼容新版PHP的PATH_INFO传参方式
解决访问时报错 No input file specified. 问题

* Add files via upload

1.添加DCV 委派一键添加CNAME
2.添加证书验证方法和最低 TLS 版本
3.添加批量添加 修改 删除
4.修复华为云一键txt解析失败(我没其他dns, 其他的需关注)
5.Cloudflare增强改Cloudflare自定义主机名

* 1.添加快速解析 2.Cloudflare自定义主机名添加搜索功能

* Add files via upload

1.Cloudflare自定义主机名自动获取默认线路(支持所有dns,华为云退回之前)
2.优化手机上显示问题
3.一键添加 DCV 委派支持选择要写入的解析域名

* 优化手机显示

* 添加1. 批量 DCV 委派 2. 批量主机名 TXT 验证 3. 批量证书 TXT 验证 4. 批量刷新验证

1. 批量 DCV 委派
2. 批量主机名 TXT 验证
3. 批量证书 TXT 验证
4. 批量刷新验证

* 快速解析改名智能解析,添加已有解析记录和智能批量添加

* 快速解析改名智能解析,添加已有解析记录和智能批量添加

* 由于之前复制保存的,代码有些差异

* 修复已有解析记录的备注功能

* 备注按dns显示

* 修复记录值过长无法复制,优化显示

* 优化显示
2026-04-23 23:15:28 +08:00
net909
75a8aa97b8 合并增加 Technitium DNS 支持 2026-04-23 23:12:32 +08:00
dependabot[bot]
29bcd293ef Bump symfony/polyfill-php82 from 1.35.0 to 1.36.0 (#436)
Bumps [symfony/polyfill-php82](https://github.com/symfony/polyfill-php82) from 1.35.0 to 1.36.0.
- [Release notes](https://github.com/symfony/polyfill-php82/releases)
- [Commits](https://github.com/symfony/polyfill-php82/compare/v1.35.0...v1.36.0)

---
updated-dependencies:
- dependency-name: symfony/polyfill-php82
  dependency-version: 1.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 22:52:04 +08:00
dependabot[bot]
b267d3df86 Bump symfony/polyfill-mbstring from 1.35.0 to 1.36.0 (#437)
Bumps [symfony/polyfill-mbstring](https://github.com/symfony/polyfill-mbstring) from 1.35.0 to 1.36.0.
- [Release notes](https://github.com/symfony/polyfill-mbstring/releases)
- [Commits](https://github.com/symfony/polyfill-mbstring/compare/v1.35.0...v1.36.0)

---
updated-dependencies:
- dependency-name: symfony/polyfill-mbstring
  dependency-version: 1.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 22:51:09 +08:00
dependabot[bot]
50edcd6dac Bump symfony/polyfill-intl-idn from 1.35.0 to 1.36.0 (#438)
Bumps [symfony/polyfill-intl-idn](https://github.com/symfony/polyfill-intl-idn) from 1.35.0 to 1.36.0.
- [Release notes](https://github.com/symfony/polyfill-intl-idn/releases)
- [Commits](https://github.com/symfony/polyfill-intl-idn/compare/v1.35.0...v1.36.0)

---
updated-dependencies:
- dependency-name: symfony/polyfill-intl-idn
  dependency-version: 1.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 22:50:58 +08:00
dependabot[bot]
04980fcdd3 Bump symfony/polyfill-php81 from 1.35.0 to 1.36.0 (#439)
Bumps [symfony/polyfill-php81](https://github.com/symfony/polyfill-php81) from 1.35.0 to 1.36.0.
- [Release notes](https://github.com/symfony/polyfill-php81/releases)
- [Commits](https://github.com/symfony/polyfill-php81/compare/v1.35.0...v1.36.0)

---
updated-dependencies:
- dependency-name: symfony/polyfill-php81
  dependency-version: 1.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 22:50:45 +08:00
TomyJan
07a0f54bc1 feat: 新增 Nginx Proxy Manager 证书部署适配 (#446) 2026-04-23 22:48:55 +08:00
小玖
db418c7a11 更新同系统对接插件 (#445)
域名账户新增页可选择同系统对接,需传入对方系统网址;用户ID;API密钥

Co-authored-by: 小玖 <232709122+xiaojiu-code@users.noreply.github.com>
2026-04-22 22:49:32 +08:00
dependabot[bot]
8e4848c14c Bump symfony/polyfill-php81 from 1.34.0 to 1.35.0 (#431)
Bumps [symfony/polyfill-php81](https://github.com/symfony/polyfill-php81) from 1.34.0 to 1.35.0.
- [Release notes](https://github.com/symfony/polyfill-php81/releases)
- [Commits](https://github.com/symfony/polyfill-php81/compare/v1.34.0...v1.35.0)

---
updated-dependencies:
- dependency-name: symfony/polyfill-php81
  dependency-version: 1.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-15 18:22:17 +08:00
dependabot[bot]
8cbc1f9a18 Bump symfony/polyfill-mbstring from 1.34.0 to 1.35.0 (#432)
Bumps [symfony/polyfill-mbstring](https://github.com/symfony/polyfill-mbstring) from 1.34.0 to 1.35.0.
- [Release notes](https://github.com/symfony/polyfill-mbstring/releases)
- [Commits](https://github.com/symfony/polyfill-mbstring/compare/v1.34.0...v1.35.0)

---
updated-dependencies:
- dependency-name: symfony/polyfill-mbstring
  dependency-version: 1.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-15 18:22:09 +08:00
dependabot[bot]
ccda489e81 Bump symfony/polyfill-php82 from 1.34.0 to 1.35.0 (#433)
Bumps [symfony/polyfill-php82](https://github.com/symfony/polyfill-php82) from 1.34.0 to 1.35.0.
- [Release notes](https://github.com/symfony/polyfill-php82/releases)
- [Commits](https://github.com/symfony/polyfill-php82/compare/v1.34.0...v1.35.0)

---
updated-dependencies:
- dependency-name: symfony/polyfill-php82
  dependency-version: 1.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-15 18:21:59 +08:00
dependabot[bot]
45af1ad464 Bump symfony/polyfill-intl-idn from 1.34.0 to 1.35.0 (#434)
Bumps [symfony/polyfill-intl-idn](https://github.com/symfony/polyfill-intl-idn) from 1.34.0 to 1.35.0.
- [Release notes](https://github.com/symfony/polyfill-intl-idn/releases)
- [Commits](https://github.com/symfony/polyfill-intl-idn/compare/v1.34.0...v1.35.0)

---
updated-dependencies:
- dependency-name: symfony/polyfill-intl-idn
  dependency-version: 1.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-15 18:21:48 +08:00
net909
7e49a40057 update workflows 2026-04-13 22:47:12 +08:00
net909
b4825f1312 v2.17 2026-04-13 22:32:39 +08:00
net909
2dd4978fb3 新增火山VOD部署 2026-04-13 22:32:05 +08:00
dependabot[bot]
349c1d70e2 Bump symfony/polyfill-php82 from 1.33.0 to 1.34.0 (#425)
Bumps [symfony/polyfill-php82](https://github.com/symfony/polyfill-php82) from 1.33.0 to 1.34.0.
- [Release notes](https://github.com/symfony/polyfill-php82/releases)
- [Commits](https://github.com/symfony/polyfill-php82/compare/v1.33.0...v1.34.0)

---
updated-dependencies:
- dependency-name: symfony/polyfill-php82
  dependency-version: 1.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 20:02:58 +08:00
dependabot[bot]
a112cf0ea2 Bump symfony/polyfill-intl-idn from 1.33.0 to 1.34.0 (#426)
Bumps [symfony/polyfill-intl-idn](https://github.com/symfony/polyfill-intl-idn) from 1.33.0 to 1.34.0.
- [Release notes](https://github.com/symfony/polyfill-intl-idn/releases)
- [Commits](https://github.com/symfony/polyfill-intl-idn/compare/v1.33.0...v1.34.0)

---
updated-dependencies:
- dependency-name: symfony/polyfill-intl-idn
  dependency-version: 1.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 20:02:47 +08:00
dependabot[bot]
c1600c7b17 Bump symfony/polyfill-mbstring from 1.33.0 to 1.34.0 (#427)
Bumps [symfony/polyfill-mbstring](https://github.com/symfony/polyfill-mbstring) from 1.33.0 to 1.34.0.
- [Release notes](https://github.com/symfony/polyfill-mbstring/releases)
- [Commits](https://github.com/symfony/polyfill-mbstring/compare/v1.33.0...v1.34.0)

---
updated-dependencies:
- dependency-name: symfony/polyfill-mbstring
  dependency-version: 1.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 20:02:40 +08:00
dependabot[bot]
b8f64db33c Bump symfony/polyfill-php81 from 1.33.0 to 1.34.0 (#428)
Bumps [symfony/polyfill-php81](https://github.com/symfony/polyfill-php81) from 1.33.0 to 1.34.0.
- [Release notes](https://github.com/symfony/polyfill-php81/releases)
- [Commits](https://github.com/symfony/polyfill-php81/compare/v1.33.0...v1.34.0)

---
updated-dependencies:
- dependency-name: symfony/polyfill-php81
  dependency-version: 1.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 20:02:32 +08:00
net909
96e1c8a972 新增部署到S3存储 2026-04-11 21:13:52 +08:00
net909
bebd655bcc 合并增强 Cloudflare 相关能力 2026-04-11 20:04:52 +08:00
net909
36d42da491 Merge remote-tracking branch 'remotes/upstream/main' 2026-04-11 19:47:25 +08:00
net909
b0d831da56 新增单独的宝塔win极速版部署类型 2026-04-11 19:12:15 +08:00
net909
c420c81877 update docker-build workflows 2026-04-11 18:37:15 +08:00
net909
72492e9fd9 更新阿里云SSL证书接口 2026-04-11 18:16:15 +08:00
luo-bo
3075b8d8a5 ```
feat(dnspod): 添加域名状态修改功能并确保新域名启用

在dnspod DNS管理中添加了域名状态修改功能,在创建或查找域名后自动启用域名,
确保域名处于活动状态。新增modifyDomainStatus方法来处理域名状态变更请求,
并在createOrGetDomain方法中集成状态启用逻辑,提高域名管理的可靠性。
```
2026-04-02 20:21:07 +08:00
luo-bo
92d8f49778 ```
feat(Domain): 添加域名解析日志记录功能

在域名解析添加成功后记录操作日志,包括域名名称、操作类型、记录值、线路信息和TTL值,
便于后续审计和问题排查。
```
2026-04-02 20:12:21 +08:00
luo-bo
1084fea43b ```
feat(domain): 添加DNSPod子域托管自动委派功能

- 新增addDnsPodDelegatedSubdomain方法处理DNSPod子域托管流程
- 实现子域验证TXT记录自动添加和NS委派自动配置
- 增加相关辅助方法:findManagedParentDomainRow、buildRelativeRecordName等
- 在DNSPod驱动中添加createSubdomainValidateTxtValue和describeSubdomainValidateStatus接口
- 更新前端界面显示子域托管提示信息
- 优化域名添加成功后的消息返回逻辑
```
2026-04-02 20:12:03 +08:00
luo-bo
7670d5a387 ```
feat(cloudflare): 添加TXT记录目标域名选择功能

- 新增hostnames_txt_targets接口用于查找TXT记录的目标域名候选列表
- 实现findTxtRecordTargetDomains方法用于匹配最合适的解析域名
- 添加matchHostnameToDomainRecordName方法用于主机名与域名匹配逻辑
- 在前端页面中集成TXT记录快速添加的域名选择弹窗功能
- 更新域名快速切换功能,增加onclick事件处理
- 升级静态资源版本号以确保缓存更新
```
2026-04-02 03:33:10 +08:00
luo-bo
a9b773868d ```
feat(domain): 添加域名快速切换功能

- 在BaseController中新增getManagedDomainOptions方法,用于获取用户管理的域名选项
- 在Cloudflare和Domain控制器中集成域名选项数据传递到视图
- 更新前端模板文件以支持多域名选择下拉列表
- 扩展JavaScript中的initDomainQuickSwitch函数,增加AJAX支持选项
- 引入DnsHelper和Db门面用于域名数据查询和处理
```
2026-04-02 03:22:42 +08:00
luo-bo
141b2ead43 ```
feat(cloudflare): 添加域名快速切换功能

在Cloudflare主机名页面添加域名快速切换下拉选择器,
允许用户通过搜索和选择快速切换到其他域名。
集成Select2组件提供更好的用户体验,
支持分页加载和中文本地化。

feat(record): 实现域名管理页面快速切换

在域名记录页面增加域名快速切换功能,
包含搜索下拉框和切换按钮,
优化页面布局结构以适应新功能。

chore(deps): 引入Select2依赖库

添加Select2 4.0.13版本及其中文语言包,
用于实现域名快速切换的下拉搜索功能。
```
2026-04-02 03:18:02 +08:00
Formerly 3Kmfi6HP
b1b0655cc0 修复 Cloudflare 模块 Emoji/IDN 域名解析记录主机名显示错误 (#420) 2026-03-27 13:28:55 +08:00
luo-bo
c64a385ffc ```
feat(cloudflare): 添加快速添加TXT记录功能

- 在主机名验证对话框中增加快速添加TXT记录按钮
- 实现convertFullHostnameToRecordName函数用于转换完整主机名为记录名
- 添加quickAddTxtRecord函数处理TXT记录的快速添加逻辑
- 在前端页面中集成快速添加功能,支持一键添加证书验证所需的TXT记录
- 移除SSL配置中的bundle_method和certificate_authority字段

```
2026-03-24 03:45:22 +08:00
luo-bo
7d02f15fde ```
feat(cloudflare): 添加自定义主机名编辑和刷新验证功能

- 新增 hostnames_update 方法用于更新自定义主机名的自定义源站配置
- 新增 hostnames_refresh 方法用于重新向 Cloudflare 发起验证请求
- 添加 extractCustomHostnameSslPayload 辅助方法处理 SSL 配置参数
- 完善 formatCustomHostnameRow 方法,增加更详细的验证状态信息展示
- 在前端界面添加编辑按钮和校验对话框,支持在线查看和刷新验证记录
- 优化自定义主机名列表页面,支持实时更新和状态显示
- 新增证书校验和所有权验证的详细信息展示界面
```
2026-03-24 01:45:55 +08:00
luo-bo
918bd872d9 ```
feat(cloudflare): 更新隧道页面UI并移除域名页面的tunnel链接

- 重构隧道页面搜索工具栏,替换搜索表单为刷新按钮布局
- 添加refreshTunnelList函数用于刷新隧道列表
- 为bootstrapTable添加toolbar配置
- 从域名页面移除tunnel相关按钮链接
```
2026-03-24 01:11:39 +08:00
luo-bo
0bc745e164 ```
feat(account): 添加用户级别权限控制和Cloudflare Tunnels功能

- 在account页面添加userLevel变量用于权限判断
- 修改操作列显示逻辑,仅当用户级别为2且类型为cloudflare时显示Tunnels链接
- 统一用户级别检测方式,从request()->user改为$user变量

feat(domain): 增强Cloudflare功能并优化权限控制

- 修复模板中用户级别变量引用错误,统一使用$user['level']
- 为高级用户(级别2)添加Cloudflare增强功能按钮
- 增加对Cloudflare隧道和主机名管理的支持
- 添加行类型安全检查以防止空值错误

fix(record): 修复用户类型权限检测逻辑

- 修正解析记录页面用户类型检测条件
- 统一用户权限检查变量使用标准$user语法
- 确保Cloudflare增强功能正确基于用户级别显示
```
2026-03-24 00:43:14 +08:00
luo-bo
efed00afa3 ```
chore(git): 更新.gitignore文件以忽略临时目录

添加/.codex-tmp/dns-panel-ref/到.gitignore文件中,
避免临时子项目引用文件被提交到版本控制系统。
```
2026-03-24 00:26:44 +08:00
luo-bo
1b1605400d ```
feat(cloudflare): 添加 Cloudflare Tunnels 和增强功能支持

- 在 .gitignore 中添加 .ace-tool/ 忽略规则
- 更新 Cloudflare 配置项,添加详细的使用说明和 API 令牌认证支持
- 新增 Account ID 配置字段用于 Cloudflare Tunnels 功能
- 在账户管理页面添加 Tunnels 功能入口按钮
- 实现智能账户名称自动生成逻辑,优先使用关键认证字段
- 添加 Cloudflare 增强功能菜单项,仅对管理员可见
- 定义完整的 Cloudflare 相关路由,包括 hostnames、tunnels 等功能模块
```
2026-03-24 00:21:03 +08:00
kgbow
879e667d9a fix: 修复1panel面板ssl搜索接口参数报错 (#406) 2026-02-27 18:48:14 +08:00
net909
0813f2cdca update version 2026-02-27 18:43:57 +08:00
net909
780e01ce4f 增加腾讯云DNS域名别名管理、修复主域名判断 2026-02-27 18:36:56 +08:00
Ripic Zhang
3ea41c1c8b 增加 esa saas 证书部署 (#404)
* 增加ESA SaaS配置

* 增加部署配置

* 合并

* 合并
2026-02-24 11:09:03 +08:00
net909
e25d5d76e9 支持部署到阿里云全球加速 2026-02-17 14:30:57 +08:00
net909
c0e72908ab 阿里云ESA超过免费配额之后,自动删除最旧的证书 2026-02-17 13:45:09 +08:00
TomyJan
867785b774 fix: cf 优选增加 xingpingcn.top 接口 & perf: cf 优选 ip 数量上限 50 个 (#396)
* fix: cf 优选增加 xingpingcn.top 接口

* perf: cf 优选 ip 数量上限 50 个

* Update app/service/OptimizeService.php

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update app/service/OptimizeService.php

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update app/controller/Optimizeip.php

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: min num limit

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-12 22:23:21 +08:00
dependabot[bot]
d579ca07af Bump cccyun/php-whois from 1.2 to 1.3 (#398)
Bumps [cccyun/php-whois](https://github.com/netcccyun/php-whois) from 1.2 to 1.3.
- [Release notes](https://github.com/netcccyun/php-whois/releases)
- [Commits](https://github.com/netcccyun/php-whois/compare/1.2...1.3)

---
updated-dependencies:
- dependency-name: cccyun/php-whois
  dependency-version: '1.3'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-12 22:20:54 +08:00
net909
7161caf0a5 ssh私钥自动删除 2026-02-02 19:27:31 +08:00
net909
c91b116466 Merge branch 'main' of ssh://ssh.github.com:443/netcccyun/dnsmgr 2026-01-30 23:16:28 +08:00
耗子
b2d27b18a3 feat: 支持AcePanel 3.0部署 (#394) 2026-01-30 23:13:11 +08:00
net909
ee45ddd7ec 新增amh面板部署 2026-01-28 20:53:54 +08:00
TomyJan
9b66b020c9 fix: cf暂停@解析 (#390) 2026-01-27 11:28:56 +08:00
69 changed files with 11587 additions and 644 deletions

85
.github/docker/Dockerfile vendored Normal file
View File

@@ -0,0 +1,85 @@
ARG ALPINE_VERSION=3.19
FROM alpine:${ALPINE_VERSION}
# Setup document root
WORKDIR /app/www
# Install packages and remove default server definition
RUN apk add --no-cache \
bash \
curl \
nginx \
php82 \
php82-ctype \
php82-curl \
php82-dom \
php82-fileinfo \
php82-fpm \
php82-ftp \
php82-gd \
php82-gettext \
php82-intl \
php82-iconv \
php82-mbstring \
php82-mysqli \
php82-opcache \
php82-openssl \
php82-phar \
php82-sodium \
php82-session \
php82-simplexml \
php82-tokenizer \
php82-xml \
php82-xmlreader \
php82-xmlwriter \
php82-zip \
php82-pdo \
php82-pdo_mysql \
php82-pdo_sqlite \
php82-pecl-swoole \
php82-pecl-ssh2 \
supervisor
RUN rm -rf /var/cache/apk/* /tmp/*
# Configure nginx - http
COPY config/nginx.conf /etc/nginx/nginx.conf
# Configure PHP-FPM
ENV PHP_INI_DIR /etc/php82
COPY config/fpm-pool.conf ${PHP_INI_DIR}/php-fpm.d/www.conf
COPY config/php.ini ${PHP_INI_DIR}/conf.d/custom.ini
# Configure supervisord
COPY config/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# CACHE_BUST 须写进每条相关 RUN否则 GHA/BuildKit 可能单独命中 composer 相关层缓存vendor 仍来自旧构建
ARG CACHE_BUST=local
# Add application
RUN mkdir -p /usr/src && echo "$CACHE_BUST" >/dev/null && wget --no-cache https://github.com/netcccyun/dnsmgr/archive/refs/heads/main.zip -O /usr/src/www.zip && unzip /usr/src/www.zip -d /usr/src/ && mv /usr/src/dnsmgr-main /usr/src/www && rm -f /usr/src/www.zip
# Install composer与下面 install 一并随 CACHE_BUST 失效)
RUN echo "$CACHE_BUST" >/dev/null && wget https://getcomposer.org/download/latest-stable/composer.phar -O /usr/local/bin/composer && chmod +x /usr/local/bin/composer
RUN echo "$CACHE_BUST" >/dev/null && composer install -d /usr/src/www --no-interaction --no-dev --optimize-autoloader --no-cache
RUN adduser -D -s /sbin/nologin -g www www && chown -R www.www /usr/src/www /var/lib/nginx /var/log/nginx
# crontab
RUN echo "* * * * * cd /app/www && /usr/bin/php82 think certtask" | crontab -u www -
COPY config/run_tasks.sh /app/run_tasks.sh
RUN chmod +x /app/run_tasks.sh
# copy entrypoint script
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["sh", "/entrypoint.sh"]
# Expose the port nginx is reachable on
EXPOSE 80
# Let supervisord start nginx & php-fpm
CMD /usr/sbin/crond && /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
# Configure a healthcheck to validate that everything is up&running
HEALTHCHECK --timeout=10s CMD curl --silent --fail http://127.0.0.1/fpm-ping || exit 1

21
.github/docker/config/fpm-pool.conf vendored Normal file
View File

@@ -0,0 +1,21 @@
[global]
error_log = /dev/stderr
[www]
listen = /run/php-fpm.sock
listen.backlog = 8192
listen.allowed_clients = 127.0.0.1
listen.owner = www
listen.group = www
listen.mode = 0666
user = www
group = www
pm.status_path = /fpm-status
pm = ondemand
pm.max_children = 100
pm.process_idle_timeout = 60s;
pm.max_requests = 1000
clear_env = no
catch_workers_output = yes
decorate_workers_output = no
ping.path = /fpm-ping

94
.github/docker/config/nginx.conf vendored Normal file
View File

@@ -0,0 +1,94 @@
user www;
worker_processes auto;
error_log stderr warn;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
http {
include mime.types;
# Threat files with a unknown filetype as binary
default_type application/octet-stream;
# Define custom log format to include reponse times
log_format main_timed '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'$request_time $upstream_response_time $pipe $upstream_cache_status';
access_log /dev/stdout main_timed;
error_log /dev/stderr crit;
keepalive_timeout 65;
server_tokens off;
# Enable gzip compression by default
gzip on;
gzip_min_length 1k;
gzip_buffers 4 16k;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain application/javascript application/x-javascript text/javascript text/css application/xml;
gzip_vary on;
gzip_disable "MSIE [1-6]\.";
# Include server configs
server {
listen [::]:80 default_server;
listen 80 default_server;
server_name _;
sendfile on;
tcp_nodelay on;
absolute_redirect off;
root /app/www/public;
index index.php;
# Pass the PHP scripts to PHP-FPM listening on php-fpm.sock
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_index index.php;
include fastcgi_params;
}
#rewrite rule for pretty urls
location / {
if (!-e $request_filename){
rewrite ^(.*)$ /index.php?s=$1 last; break;
}
}
# Set the cache-control headers on assets to cache for 5 days
location ~* \.(jpg|jpeg|gif|png|ico|bmp)$ {
access_log off;
expires 30d;
}
location ~* \.(css|js)$ {
access_log off;
expires 12h;
}
# Deny access to . files, for security
location ~ /\. {
log_not_found off;
deny all;
}
# Allow fpm ping and status from localhost
location ~ ^/(fpm-status|fpm-ping)$ {
access_log off;
allow 127.0.0.1;
deny all;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.sock;
}
}
}

15
.github/docker/config/php.ini vendored Normal file
View File

@@ -0,0 +1,15 @@
[PHP]
short_open_tag = On
expose_php = Off
max_execution_time = 300
post_max_size = 50M
upload_max_filesize = 50M
[Date]
date.timezone = PRC
[Opcache]
opcache.enable=1
opcache.enable_cli=1
opcache.memory_consumption=128
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=10000
opcache.revalidate_freq=30

7
.github/docker/config/run_tasks.sh vendored Normal file
View File

@@ -0,0 +1,7 @@
#!/bin/bash
if [ -f "/app/www/.env" ]; then
php /app/www/think dmtask
else
exit 0
fi

37
.github/docker/config/supervisord.conf vendored Normal file
View File

@@ -0,0 +1,37 @@
[supervisord]
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0
pidfile=/run/supervisord.pid
[program:php-fpm]
command=php-fpm82 -F
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
autostart=true
autorestart=false
startretries=0
[program:nginx]
command=nginx -g 'daemon off;'
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
autostart=true
autorestart=false
startretries=0
[program:dmtask]
command=php think dmtask
user=www
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
autostart=true
autorestart=true
startsecs=5
startretries=99999

18
.github/docker/entrypoint.sh vendored Normal file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
set -e
if [ ! -f /app/www/public/index.php ] || [ ! -f /app/firstrun ]; then
echo 'Copying new files'
\cp -a /usr/src/www /app/
if [ -d /app/www/runtime/cache ]; then
rm -rf /app/www/runtime/*
fi
chown -R www.www /app/www
touch /app/firstrun
fi
exec "$@"

59
.github/workflows/docker-build.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
# 手动触发构建多架构镜像amd64 / arm64仅推送 latest 至 Docker Hub 与华为云 SWR。
# Dockerfile 与构建上下文位于 .github/docker/ 目录。
#
# 需在仓库 Settings → Secrets 中配置:
# DOCKERHUB_USERNAME / DOCKERHUB_TOKENDocker Hub 访问令牌)
# HUAWEI_SWR_USERNAME / HUAWEI_SWR_PASSWORD华为云 SWR 登录凭证,与本地 docker login swr.cn-east-3.myhuaweicloud.com 一致)
name: Docker Build
on:
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to Huawei SWR
uses: docker/login-action@v3
with:
registry: swr.cn-east-3.myhuaweicloud.com
username: ${{ secrets.HUAWEI_SWR_USERNAME }}
password: ${{ secrets.HUAWEI_SWR_PASSWORD }}
- name: Build and push (Docker Hub + Huawei SWR, latest only)
uses: docker/build-push-action@v6
with:
context: .github/docker
file: .github/docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
# 每次运行唯一,打破「下载源码 + composer」等层的缓存否则会一直用首次构建时的层
build-args: |
CACHE_BUST=${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }}
# 避免向仓库推送 attestations部分镜像仓库含部分 SWR 场景)无法解析导致 “fail to parse manifest.json”
provenance: false
sbom: false
tags: |
netcccyun/dnsmgr:latest
swr.cn-east-3.myhuaweicloud.com/netcccyun/dnsmgr:latest
cache-from: type=gha
cache-to: type=gha,mode=max

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@
/vendor
*.log
.env
.ace-tool/
/.codex-tmp/dns-panel-ref/

View File

@@ -302,10 +302,12 @@ function getMainDomain($host)
$domains = config('temp.domains');
if (!$domains) {
$domains = Db::name('domain')->column('name');
$domains_alias = Db::name('domain_alias')->column('name');
$domains = array_merge($domains, $domains_alias);
config(['domains'=>$domains], 'temp');
}
foreach ($domains as $domain) {
if (str_ends_with($host, $domain)) {
if ($host === $domain || str_ends_with($host, '.' . $domain)) {
return $domain;
}
}

View File

@@ -505,9 +505,12 @@ class Cert extends BaseController
$mainDomain = getMainDomain($domain);
$drow = Db::name('domain')->where('name', $mainDomain)->find();
if (!$drow) {
if (substr($domain, 0, 2) == '*.') $domain = substr($domain, 2);
if (!$cname || !Db::name('cert_cname')->where('domain', $domain)->where('status', 1)->find()) {
return ['code' => -1, 'msg' => '域名' . $domain . '未在本系统添加'];
$drow = Db::name('domain_alias')->alias('A')->join('domain B', 'A.did = B.id')->where('A.name', $mainDomain)->find();
if (!$drow) {
if (substr($domain, 0, 2) == '*.') $domain = substr($domain, 2);
if (!$cname || !Db::name('cert_cname')->where('domain', $domain)->where('status', 1)->find()) {
return ['code' => -1, 'msg' => '域名' . $domain . '未在本系统添加'];
}
}
}
}
@@ -748,7 +751,7 @@ class Cert extends BaseController
$ids = input('post.ids');
$success = 0;
$certid = 0;
if (input('post.action') == 'cert') {
if (input('post.act') == 'cert') {
$certid = input('post.certid/d');
$cert = Db::name('cert_order')->where('id', $certid)->find();
if (!$cert) return json(['code' => -1, 'msg' => '证书订单不存在']);

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ use think\facade\View;
use think\facade\Cache;
use app\lib\DnsHelper;
use app\service\ExpireNoticeService;
use app\utils\DnsQueryUtils;
use Exception;
class Domain extends BaseController
@@ -157,8 +158,10 @@ class Domain extends BaseController
}
$accounts[] = ['id' => $row['id'], 'name' => $name, 'type' => DnsHelper::$dns_config[$row['type']]['name'], 'add' => DnsHelper::$dns_config[$row['type']]['add']];
}
$categorys = Db::name('domain_category')->order('sort', 'asc')->order('id', 'desc')->select();
View::assign('accounts', $accounts);
View::assign('types', $types);
View::assign('categorys', $categorys);
return view();
}
@@ -188,6 +191,7 @@ class Domain extends BaseController
$kw = input('post.kw', null, 'trim');
$type = input('post.type', null, 'trim');
$status = input('post.status', null, 'trim');
$cid = input('post.cid', null, 'trim');
$order = input('post.order', null, 'trim');
$offset = input('post.offset/d', 0);
$limit = input('post.limit/d', 10);
@@ -206,6 +210,9 @@ class Domain extends BaseController
if (!empty($type)) {
$select->whereLike('B.type', $type);
}
if (!isNullOrEmpty($cid)) {
$select->where('A.cid', $cid);
}
if (request()->user['level'] == 1) {
$select->where('is_hide', 0)->where('A.name', 'in', request()->user['permission']);
}
@@ -235,10 +242,12 @@ class Domain extends BaseController
}
$rows = $select->fieldRaw('A.*,B.type,B.remark aremark')->limit($offset, $limit)->select();
$categorys = Db::name('domain_category')->column('name', 'id');
$list = [];
foreach ($rows as $row) {
$row['typename'] = DnsHelper::$dns_config[$row['type']]['name'];
$row['icon'] = DnsHelper::$dns_config[$row['type']]['icon'];
$row['category_name'] = isset($categorys[$row['cid']]) ? $categorys[$row['cid']] : '';
$list[] = $row;
}
@@ -290,6 +299,7 @@ class Domain extends BaseController
$is_hide = input('post.is_hide/d');
$is_sso = input('post.is_sso/d');
$is_notice = input('post.is_notice/d');
$cid = input('post.cid/d', 0);
$expiretime = input('post.expiretime', null, 'trim');
$remark = input('post.remark', null, 'trim');
if (empty($remark)) $remark = null;
@@ -297,6 +307,7 @@ class Domain extends BaseController
'is_hide' => $is_hide,
'is_sso' => $is_sso,
'is_notice' => $is_notice,
'cid' => $cid,
'expiretime' => $expiretime ? $expiretime : null,
'remark' => $remark,
]);
@@ -305,6 +316,7 @@ class Domain extends BaseController
if (!checkPermission(2)) return $this->alert('error', '无权限');
$id = input('post.id/d');
Db::name('domain')->where('id', $id)->delete();
Db::name('domain_alias')->where('did', $id)->delete();
Db::name('dmtask')->where('did', $id)->delete();
Db::name('optimizeip')->where('did', $id)->delete();
Db::name('sctask')->where('did', $id)->delete();
@@ -1004,6 +1016,68 @@ class Domain extends BaseController
return view('log');
}
public function smartparse()
{
if (request()->user['type'] == 'domain') {
return redirect('/record/' . request()->user['id']);
}
$list = Db::name('domain')->alias('A')->join('account B', 'A.aid = B.id')
->field('A.id, A.name, A.aid, B.type')
->order('A.name', 'asc')
->select();
$domainList = [];
foreach ($list as $row) {
if (request()->user['level'] == 1 && !in_array($row['name'], request()->user['permission'])) {
continue;
}
$dnsTypeName = isset(DnsHelper::$dns_config[$row['type']]) ? DnsHelper::$dns_config[$row['type']]['name'] : $row['type'];
$domainList[] = [
'id' => $row['id'],
'name' => $row['name'],
'dnsType' => $dnsTypeName
];
}
View::assign('domainList', $domainList);
return view();
}
public function quickinfo()
{
$id = input('param.id/d');
$drow = Db::name('domain')->where('id', $id)->find();
if (!$drow) {
return json(['code' => -1, 'msg' => '域名不存在']);
}
if (!checkPermission(0, $drow['name'])) return json(['code' => -1, 'msg' => '无权限']);
try {
list($recordLine, $minTTL) = $this->get_line_and_ttl($drow);
$recordLineArr = [];
foreach ($recordLine as $key => $item) {
$recordLineArr[] = ['id' => strval($key), 'name' => $item['name'], 'parent' => $item['parent']];
}
$dnstype = Db::name('account')->where('id', $drow['aid'])->value('type');
$dnsconfig = DnsHelper::$dns_config[$dnstype];
return json([
'code' => 0,
'data' => [
'recordLine' => $recordLineArr,
'minTTL' => $minTTL ? $minTTL : 1,
'weight' => $dnsconfig['weight'] ?? false,
'remark' => $dnsconfig['remark'] ?? 0
]
]);
} catch (Exception $e) {
return json(['code' => -1, 'msg' => $e->getMessage()]);
}
}
private function add_log($domain, $action, $data)
{
if (strlen($data) > 500) $data = substr($data, 0, 500);
@@ -1106,8 +1180,88 @@ class Domain extends BaseController
$dns = DnsHelper::getModel($drow['aid'], $drow['name'], $drow['thirdid']);
$domainRecords = $dns->getWeightSubDomains($page, $limit, $keyword);
if (!$domainRecords) return json(['total' => 0, 'rows' => []]);
return json(['total' => $domainRecords['total'], 'rows' => $domainRecords['list']]);
}
public function alias()
{
$id = input('param.id/d');
$drow = Db::name('domain')->where('id', $id)->find();
if (!$drow) {
return $this->alert('error', '域名不存在');
}
if (!checkPermission(0, $drow['name'])) return $this->alert('error', '无权限');
if (request()->isAjax()) {
$act = input('param.act');
if ($act == 'add') {
$alias = input('post.alias', null, 'trim');
if (empty($alias)) {
return json(['code' => -1, 'msg' => '参数不能为空']);
}
$dns = DnsHelper::getModel($drow['aid'], $drow['name'], $drow['thirdid']);
if ($dns->addDomainAlias($alias)) {
return json(['code' => 0, 'msg' => '添加域名别名成功']);
} else {
return json(['code' => -1, 'msg' => '添加域名别名失败,' . $dns->getError()]);
}
} elseif ($act == 'delete') {
$alias_id = input('post.alias_id/d');
if (empty($alias_id)) {
return json(['code' => -1, 'msg' => '参数不能为空']);
}
$dns = DnsHelper::getModel($drow['aid'], $drow['name'], $drow['thirdid']);
if ($dns->deleteDomainAlias($alias_id)) {
return json(['code' => 0, 'msg' => '删除域名别名成功']);
} else {
return json(['code' => -1, 'msg' => '删除域名别名失败,' . $dns->getError()]);
}
}
}
$dns = DnsHelper::getModel($drow['aid'], $drow['name'], $drow['thirdid']);
$domainAliasList = $dns->domainAliasList();
if ($domainAliasList === false) $domainAliasList = [];
$this->updateAliasList($id, $domainAliasList);
View::assign('domainId', $id);
View::assign('domainName', $drow['name']);
View::assign('domainAliasList', $domainAliasList);
return view();
}
private function updateAliasList($id, $domainAliasList)
{
$domainAliases = array_column($domainAliasList, 'DomainAlias');
$addList = [];
$deleteList = [];
$existList = Db::name('domain_alias')->where('did', $id)->select()->toArray();
$existAliases = array_column($existList, 'name');
foreach ($existList as $item) {
if (!in_array($item['name'], $domainAliases)) {
$deleteList[] = $item['id'];
}
}
foreach ($domainAliases as $item) {
if (!in_array($item, $existAliases)) {
$addList[] = $item;
}
}
if (!empty($deleteList)) {
Db::name('domain_alias')->where('id', 'in', $deleteList)->delete();
}
if (!empty($addList)) {
$dataList = [];
foreach ($addList as $item) {
$dataList[] = [
'did' => $id,
'name' => $item,
];
}
Db::name('domain_alias')->insertAll($dataList);
}
}
public function expire_notice()
{
@@ -1137,4 +1291,138 @@ class Domain extends BaseController
$result = (new ExpireNoticeService())->updateDomainDate($id, $drow['name']);
return json($result);
}
public function record_check()
{
$id = input('param.id/d');
$drow = Db::name('domain')->where('id', $id)->find();
if (!$drow) {
return json(['code' => -1, 'msg' => '域名不存在']);
}
if (!checkPermission(0, $drow['name'])) return json(['code' => -1, 'msg' => '无权限']);
$recordid = input('post.recordid', null, 'trim');
$name = input('post.name', null, 'trim');
$type = input('post.type', null, 'trim');
$value = input('post.value', null, 'trim');
if (empty($recordid) || empty($name) || empty($type)) {
return json(['code' => -1, 'msg' => '参数不能为空']);
}
$domain = $name === '@' ? $drow['name'] : $name . '.' . $drow['name'];
$domain = strtolower($domain);
$supported_types = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA', 'SRV', 'CAA', 'PTR', 'LOC', 'LUA'];
if (!in_array($type, $supported_types)) {
return json(['code' => -1, 'msg' => '该记录类型暂不支持检测']);
}
$dns_records = DnsQueryUtils::get_dns_records($domain, $type);
if ($dns_records === false || empty($dns_records)) {
$dns_records = DnsQueryUtils::query_dns_doh($domain, $type);
}
if ($dns_records === false || empty($dns_records)) {
return json(['code' => 0, 'data' => ['status' => 'not_found', 'message' => '未查询到该解析记录', 'actual' => []]]);
}
$dns_records = array_map('strtolower', $dns_records);
$expected_value = strtolower(rtrim(trim($value), '.'));
if (in_array($expected_value, $dns_records)) {
return json(['code' => 0, 'data' => ['status' => 'active', 'actual' => $dns_records]]);
} else {
return json(['code' => 0, 'data' => ['status' => 'mismatch', 'expected' => $expected_value, 'actual' => $dns_records]]);
}
}
public function category()
{
if (!checkPermission(2)) return $this->alert('error', '无权限');
return view();
}
public function category_data()
{
if (!checkPermission(2)) return json(['total' => 0, 'rows' => []]);
$offset = input('post.offset/d', 0);
$limit = input('post.limit/d', 10);
$select = Db::name('domain_category');
$total = $select->count();
$rows = $select->order('sort', 'asc')->order('id', 'desc')->limit($offset, $limit)->select()->toArray();
foreach ($rows as &$row) {
$row['domain_count'] = Db::name('domain')->where('cid', $row['id'])->count();
}
return json(['total' => $total, 'rows' => $rows]);
}
public function category_op()
{
if (!checkPermission(2)) return json(['code' => -1, 'msg' => '无权限']);
$action = input('param.action');
if ($action == 'add') {
$name = input('post.name', null, 'trim');
$remark = input('post.remark', null, 'trim');
$sort = input('post.sort/d', 0);
if (empty($name)) return json(['code' => -1, 'msg' => '分类名称不能为空']);
if (Db::name('domain_category')->where('name', $name)->find()) {
return json(['code' => -1, 'msg' => '分类名称已存在']);
}
Db::name('domain_category')->insert([
'name' => $name,
'remark' => $remark,
'sort' => $sort,
'addtime' => date('Y-m-d H:i:s'),
]);
return json(['code' => 0, 'msg' => '添加分类成功!']);
} elseif ($action == 'edit') {
$id = input('post.id/d');
$row = Db::name('domain_category')->where('id', $id)->find();
if (!$row) return json(['code' => -1, 'msg' => '分类不存在']);
$name = input('post.name', null, 'trim');
$remark = input('post.remark', null, 'trim');
$sort = input('post.sort/d', 0);
if (empty($name)) return json(['code' => -1, 'msg' => '分类名称不能为空']);
if (Db::name('domain_category')->where('name', $name)->where('id', '<>', $id)->find()) {
return json(['code' => -1, 'msg' => '分类名称已存在']);
}
Db::name('domain_category')->where('id', $id)->update([
'name' => $name,
'remark' => $remark,
'sort' => $sort,
]);
return json(['code' => 0, 'msg' => '修改分类成功!']);
} elseif ($action == 'del') {
$id = input('post.id/d');
$count = Db::name('domain')->where('cid', $id)->count();
if ($count > 0) return json(['code' => -1, 'msg' => '该分类下存在域名,无法删除']);
Db::name('domain_category')->where('id', $id)->delete();
return json(['code' => 0, 'msg' => '删除分类成功!']);
}
return json(['code' => -3]);
}
public function category_list()
{
if (!checkPermission(2)) return json(['code' => -1, 'msg' => '无权限']);
$list = Db::name('domain_category')->order('sort', 'asc')->order('id', 'desc')->select();
foreach ($list as &$row) {
$row['domain_count'] = Db::name('domain')->where('cid', $row['id'])->count();
}
return json(['code' => 0, 'data' => $list]);
}
public function domain_set_category()
{
if (!checkPermission(2)) return json(['code' => -1, 'msg' => '无权限']);
$ids = input('post.ids');
$cid = input('post.cid/d', 0);
if (empty($ids)) return json(['code' => -1, 'msg' => '请选择要操作的域名']);
$count = Db::name('domain')->where('id', 'in', $ids)->update(['cid' => $cid]);
return json(['code' => 0, 'msg' => '成功设置' . $count . '个域名的分类!']);
}
}

View File

@@ -85,8 +85,11 @@ class Optimizeip extends BaseController
if (empty($task['did']) || empty($task['rr']) || empty($task['ip_type']) || empty($task['recordnum']) || empty($task['ttl'])) {
return json(['code' => -1, 'msg' => '必填项不能为空']);
}
if ($task['recordnum'] > 5) {
return json(['code' => -1, 'msg' => '解析数量不能超过5个']);
if ($task['recordnum'] < 1) {
return json(['code' => -1, 'msg' => '解析数量不能少于1个']);
}
if ($task['recordnum'] > 50) {
return json(['code' => -1, 'msg' => '解析数量不能超过50个']);
}
if (Db::name('optimizeip')->where('did', $task['did'])->where('rr', $task['rr'])->find()) {
return json(['code' => -1, 'msg' => '当前域名的优选IP任务已存在']);
@@ -109,8 +112,11 @@ class Optimizeip extends BaseController
if (empty($task['did']) || empty($task['rr']) || empty($task['ip_type']) || empty($task['recordnum']) || empty($task['ttl'])) {
return json(['code' => -1, 'msg' => '必填项不能为空']);
}
if ($task['recordnum'] > 5) {
return json(['code' => -1, 'msg' => '解析数量不能超过5个']);
if ($task['recordnum'] < 1) {
return json(['code' => -1, 'msg' => '解析数量不能少于1个']);
}
if ($task['recordnum'] > 50) {
return json(['code' => -1, 'msg' => '解析数量不能超过50个']);
}
if (Db::name('optimizeip')->where('did', $task['did'])->where('rr', $task['rr'])->where('id', '<>', $id)->find()) {
return json(['code' => -1, 'msg' => '当前域名的优选IP任务已存在']);

View File

@@ -92,14 +92,29 @@ class System extends BaseController
}
}
public function customwebhooktest()
{
if (!checkPermission(2)) return $this->alert('error', '无权限');
$custom_webhook_url = config_get('custom_webhook_url');
if (empty($custom_webhook_url)) return json(['code' => -1, 'msg' => '请先保存设置']);
$content = "这是一封测试消息!\n来自:" . $this->request->root(true);
$result = \app\utils\MsgNotice::send_custom_webhook('消息发送测试', $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'];
$proxy_server = input('post.proxy_server', '', 'trim');
$proxy_port = input('post.proxy_port/d', 0);
$proxy_user = input('post.proxy_user', '', 'trim');
$proxy_pwd = input('post.proxy_pwd', '', 'trim');
$proxy_type = input('post.proxy_type', 'http', 'trim');
try {
check_proxy('https://dl.amh.sh/ip.htm', $proxy_server, $proxy_port, $proxy_type, $proxy_user, $proxy_pwd);
} catch (Exception $e) {

View File

@@ -257,7 +257,7 @@ location / {
'wildcard' => false,
'max_domains' => 1,
'cname' => false,
'note' => '每个自然年有20张免费证书额度证书到期或吊销不释放额度。需要先进入阿里云控制台-<a href="https://yundun.console.aliyun.com/?p=cas#/certExtend/free/cn-hangzhou" target="_blank" rel="noreferrer">数字证书管理服务</a>,购买个人测试证书资源包。',
'note' => '每个自然年有20张免费证书额度证书到期或吊销不释放额度。需要先进入阿里云控制台-<a href="https://yundun.console.aliyun.com/?p=cas#/instance/test/cn-hangzhou" target="_blank" rel="noreferrer">数字证书管理服务</a>,购买测试证书,并在联系人管理添加联系人。',
'inputs' => [
'AccessKeyId' => [
'name' => 'AccessKeyId',
@@ -271,24 +271,6 @@ location / {
'placeholder' => '',
'required' => true,
],
'username' => [
'name' => '姓名',
'type' => 'input',
'placeholder' => '申请联系人的姓名',
'required' => true,
],
'phone' => [
'name' => '手机号码',
'type' => 'input',
'placeholder' => '申请联系人的手机号码',
'required' => true,
],
'email' => [
'name' => '邮箱地址',
'type' => 'input',
'placeholder' => '申请联系人的邮箱地址',
'required' => true,
],
'proxy' => [
'name' => '使用代理服务器',
'type' => 'radio',

View File

@@ -11,7 +11,7 @@ class DeployHelper
'name' => '宝塔面板',
'class' => 1,
'icon' => 'bt.png',
'desc' => '支持部署到宝塔面板&aaPanel搭建的站点、Docker、邮局与面板本身',
'desc' => '支持部署到宝塔Linux面板&aaPanel搭建的站点',
'note' => null,
'inputs' => [
'url' => [
@@ -27,15 +27,6 @@ class DeployHelper
'placeholder' => '宝塔面板设置->面板设置->API接口',
'required' => true,
],
'version' => [
'name' => '面板版本',
'type' => 'radio',
'options' => [
'0' => 'Linux面板+Win经典版',
'1' => 'Win极速版',
],
'value' => '0'
],
'proxy' => [
'name' => '使用代理服务器',
'type' => 'radio',
@@ -55,6 +46,7 @@ class DeployHelper
'3' => 'Docker网站的证书',
'2' => '邮局域名的证书',
'1' => '面板本身的证书',
'4' => '反向代理的证书',
],
'value' => '0',
'required' => true,
@@ -64,7 +56,58 @@ class DeployHelper
'type' => 'textarea',
'placeholder' => '填写要部署证书的网站名称,每行一个',
'note' => 'PHP项目和反代项目填写创建时绑定的第一个域名Java/Node/Go等其他项目填写项目名称邮局和IIS站点填写绑定的域名',
'show' => 'type==0||type==2||type==3',
'show' => 'type==0||type==2||type==3||type==4',
'required' => true,
],
],
],
'btwin' => [
'name' => '宝塔Win极速版',
'class' => 1,
'icon' => 'bt.png',
'desc' => '支持部署到宝塔Windows面板极速版',
'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' => '',
'show' => 'type==0',
'required' => true,
],
'is_iis' => [
@@ -253,6 +296,59 @@ class DeployHelper
],
'taskinputs' => [],
],
'nginxproxymanager' => [
'name' => 'Nginx Proxy Manager',
'class' => 1,
'icon' => 'npm.svg',
'desc' => '更新 Nginx Proxy Manager 的自定义证书并自动绑定 Proxy Host',
'note' => '填写 Nginx Proxy Manager 面板地址与登录账号密码,系统将通过官方 API 登录并执行证书更新。',
'tasknote' => '如填写证书ID则优先更新该自定义证书留空时系统会根据当前证书订单的域名在 NPM 中匹配 Proxy Host并在首次成功后自动保存证书ID后续续期优先走该ID不再依赖域名匹配。',
'inputs' => [
'url' => [
'name' => '面板地址',
'type' => 'input',
'placeholder' => 'Nginx Proxy Manager 面板地址',
'note' => '填写规则如http://192.168.1.100:81 ,不要带 /api 等后缀',
'required' => true,
],
'email' => [
'name' => '登录邮箱',
'type' => 'input',
'placeholder' => 'NPM 登录邮箱',
'validator' => 'email',
'required' => true,
],
'password' => [
'name' => '登录密码',
'type' => 'input',
'placeholder' => 'NPM 登录密码',
'required' => true,
],
'proxy' => [
'name' => '使用代理服务器',
'type' => 'radio',
'options' => [
'0' => '否',
'1' => '是',
],
'value' => '0'
],
],
'taskinputs' => [
'id' => [
'name' => '证书ID',
'type' => 'input',
'placeholder' => '留空则按域名匹配 Proxy Host 并自动回填',
'note' => '优先级最高。填写后将直接更新该自定义证书ID仅支持 NPM 中 provider 为 other 的自定义证书。',
],
'host_id' => [
'name' => 'Proxy Host ID',
'type' => 'input',
'placeholder' => '可留空,留空则按域名自动匹配',
'note' => '可选。未填写证书ID时若填写此项则仅处理指定 Proxy Host若留空则按当前证书订单域名自动查找匹配的 Proxy Host。',
],
],
],
'btwaf' => [
'name' => '堡塔云WAF',
'class' => 1,
@@ -677,6 +773,65 @@ class DeployHelper
],
],
],
'acepanel' => [
'name' => 'AcePanel',
'class' => 1,
'icon' => 'acepanel.svg',
'desc' => '支持 AcePanel 3.0+ 版本使用',
'note' => '支持 AcePanel 3.0+ 版本使用',
'inputs' => [
'url' => [
'name' => '面板地址',
'type' => 'input',
'placeholder' => 'AcePanel 地址',
'note' => '填写规则如https://192.168.1.100:8888/xxxxxx ,带访问入口但不要带其他后缀',
'required' => true,
],
'id' => [
'name' => '访问令牌ID',
'type' => 'input',
'placeholder' => '1',
'note' => 'AcePanel 设置->用户->访问令牌',
'required' => true,
],
'token' => [
'name' => '访问令牌',
'type' => 'input',
'note' => 'AcePanel 设置->用户->访问令牌',
'placeholder' => '32位字符串',
'required' => true,
],
'proxy' => [
'name' => '使用代理服务器',
'type' => 'radio',
'options' => [
'0' => '否',
'1' => '是',
],
'value' => '0'
],
],
'taskinputs' => [
'type' => [
'name' => '部署类型',
'type' => 'radio',
'options' => [
'0' => 'AcePanel 网站的证书',
'1' => 'AcePanel 本身的证书',
],
'value' => '0',
'required' => true,
],
'sites' => [
'name' => '网站名称列表',
'type' => 'textarea',
'placeholder' => '填写要部署证书的网站名称,每行一个',
'note' => '填写创建网站时设置的网站唯一名称',
'show' => 'type==0',
'required' => true,
],
],
],
'ratpanel' => [
'name' => '耗子面板',
'class' => 1,
@@ -777,6 +932,53 @@ class DeployHelper
],
],
],
'amh' => [
'name' => 'AMH面板',
'class' => 1,
'icon' => 'amh.ico',
'desc' => '',
'note' => null,
'tasknote' => '',
'inputs' => [
'url' => [
'name' => '面板地址',
'type' => 'input',
'placeholder' => 'AMH面板地址',
'note' => '填写规则如http://192.168.1.100:8888 ,不要带其他后缀',
'required' => true,
],
'apikey' => [
'name' => 'API接口密钥',
'type' => 'input',
'placeholder' => '安装amapi软件后查看是密钥不是私钥',
'required' => true,
],
'proxy' => [
'name' => '使用代理服务器',
'type' => 'radio',
'options' => [
'0' => '否',
'1' => '是',
],
'value' => '0'
],
],
'taskinputs' => [
'env_name' => [
'name' => '环境名称',
'type' => 'input',
'placeholder' => '如lnmp01',
'required' => true,
],
'vhost_name' => [
'name' => '网站名称列表',
'type' => 'textarea',
'placeholder' => '填写要部署证书的网站标识域名,每行一个',
'note' => '网站标识域名一列的值,并非绑定域名',
'required' => true,
],
],
],
'synology' => [
'name' => '群晖面板',
'class' => 1,
@@ -1046,8 +1248,10 @@ ctrl+x 保存退出<br/>',
['value'=>'cdn', 'label'=>'内容分发CDN'],
['value'=>'dcdn', 'label'=>'全站加速DCDN'],
['value'=>'esa', 'label'=>'边缘安全加速ESA'],
['value'=>'esa_saas', 'label'=>'边缘安全加速ESA SaaS'],
['value'=>'oss', 'label'=>'对象存储OSS'],
['value'=>'waf', 'label'=>'Web应用防火墙3.0'],
['value'=>'wafres', 'label'=>'Web应用防火墙3.0(云产品接入)'],
['value'=>'waf2', 'label'=>'Web应用防火墙2.0'],
['value'=>'clb', 'label'=>'传统型负载均衡CLB'],
['value'=>'alb', 'label'=>'应用型负载均衡ALB'],
@@ -1058,6 +1262,7 @@ ctrl+x 保存退出<br/>',
['value'=>'vod', 'label'=>'视频点播'],
['value'=>'fc', 'label'=>'函数计算3.0'],
['value'=>'fc2', 'label'=>'函数计算2.0'],
['value'=>'ga', 'label'=>'全球加速'],
['value'=>'upload', 'label'=>'上传到证书管理'],
],
'value' => 'cdn',
@@ -1067,7 +1272,14 @@ ctrl+x 保存退出<br/>',
'name' => 'ESA站点域名',
'type' => 'input',
'placeholder' => 'ESA添加的站点主域名',
'show' => 'product==\'esa\'',
'show' => 'product==\'esa\' || product == \'esa_saas\'',
'required' => true,
],
'esa_saas_sitename' => [
'name' => 'ESA SAAS站点域名',
'type' => 'input',
'placeholder' => 'ESA SAAS站点域名',
'show' => 'product == \'esa_saas\'',
'required' => true,
],
'oss_endpoint' => [
@@ -1092,7 +1304,7 @@ ctrl+x 保存退出<br/>',
['value'=>'ap-southeast-1', 'label'=>'非中国内地'],
],
'value' => 'cn-hangzhou',
'show' => 'product==\'waf\'||product==\'waf2\'||product==\'ddoscoo\'||product==\'esa\'',
'show' => 'product==\'waf\'||product==\'waf2\'||product==\'wafres\'||product==\'ddoscoo\'||product==\'esa\'||product==\'esa_saas\'',
'required' => true,
],
'regionid' => [
@@ -1148,6 +1360,29 @@ ctrl+x 保存退出<br/>',
'note' => '进入NLB实例详情->监听列表复制监听ID只支持TCPSSL监听协议',
'required' => true,
],
'ga_id' => [
'name' => '全球加速实例ID',
'type' => 'input',
'placeholder' => '',
'show' => 'product==\'ga\'',
'required' => true,
],
'ga_listener_id' => [
'name' => '监听ID',
'type' => 'input',
'placeholder' => '',
'show' => 'product==\'ga\'',
'note' => '进入实例详情->监听列表复制监听ID只支持HTTPS监听协议',
'required' => true,
],
'waf_resource_id' => [
'name' => '云产品防护对象ID',
'type' => 'input',
'placeholder' => '多个ID可用,隔开',
'show' => 'product==\'wafres\'',
'note' => '进入查看防护对象对象名称一列即为云产品防护对象ID',
'required' => true,
],
'deploy_type' => [
'name' => '部署证书类型',
'type' => 'select',
@@ -1156,21 +1391,21 @@ ctrl+x 保存退出<br/>',
['value'=>'1', 'label'=>'扩展证书'],
],
'value' => '0',
'show' => 'product==\'clb\'||product==\'alb\'||product==\'nlb\'',
'show' => 'product==\'clb\'||product==\'alb\'||product==\'nlb\'||product==\'ga\'||product==\'wafres\'',
'required' => true,
],
'clb_domain' => [
'name' => '扩展域名',
'type' => 'input',
'placeholder' => '多个域名可使用,分隔',
'show' => 'product==\'clb\'&&deploy_type==1',
'show' => 'product==\'clb\'&&deploy_type==1||product==\'ga\'&&deploy_type==1',
'required' => true,
],
'domain' => [
'name' => '绑定的域名',
'type' => 'input',
'placeholder' => '',
'show' => 'product!=\'esa\'&&product!=\'clb\'&&product!=\'alb\'&&product!=\'nlb\'&&product!=\'upload\'',
'placeholder' => '多个域名可用,隔开',
'show' => 'product!=\'esa\'&&product!=\'esa_saas\'&&product!=\'clb\'&&product!=\'alb\'&&product!=\'nlb\'&&product!=\'ga\'&&product!=\'upload\'&&product!=\'wafres\'',
'required' => true,
],
],
@@ -1755,6 +1990,7 @@ ctrl+x 保存退出<br/>',
['value'=>'alb', 'label'=>'应用型负载均衡ALB'],
['value'=>'tos', 'label'=>'对象存储TOS'],
['value'=>'live', 'label'=>'视频直播'],
['value'=>'vod', 'label'=>'视频点播'],
['value'=>'imagex', 'label'=>'veImageX'],
['value'=>'upload', 'label'=>'上传到证书管理'],
],
@@ -1768,6 +2004,23 @@ ctrl+x 保存退出<br/>',
'show' => 'product==\'tos\'',
'required' => true,
],
'vod_space_name' => [
'name' => '点播空间名称',
'type' => 'input',
'placeholder' => '',
'show' => 'product==\'vod\'',
'required' => true,
],
'vod_domain_type' => [
'name' => '点播域名类型',
'type' => 'select',
'options' => [
['value'=>'play', 'label'=>'点播加速域名和自定义源站加速域名'],
['value'=>'image', 'label'=>'封面加速域名'],
],
'show' => 'product==\'vod\'',
'required' => true,
],
'domain' => [
'name' => '绑定的域名',
'type' => 'input',
@@ -1987,7 +2240,7 @@ ctrl+x 保存退出<br/>',
'domain' => [
'name' => '绑定的域名',
'type' => 'input',
'placeholder' => '',
'placeholder' => '多个域名可使用,分隔',
'required' => true,
],
],
@@ -2484,6 +2737,73 @@ ctrl+x 保存退出<br/>',
],
],
],
's3storage' => [
'name' => 'S3存储',
'class' => 3,
'icon' => 'cloud.png',
'desc' => '支持将证书上传到S3兼容存储AWS S3、MinIO等',
'note' => '支持AWS S3、MinIO、阿里云OSSS3兼容模式等S3协议兼容的对象存储服务',
'tasknote' => '证书和私钥将以PEM格式上传到指定的存储桶路径',
'inputs' => [
'AccessKeyId' => [
'name' => 'AccessKeyId',
'type' => 'input',
'placeholder' => '',
'required' => true,
],
'SecretAccessKey' => [
'name' => 'SecretAccessKey',
'type' => 'input',
'placeholder' => '',
'required' => true,
],
'endpoint' => [
'name' => 'S3 Endpoint',
'type' => 'input',
'placeholder' => '如s3.amazonaws.com 或 minio.example.com:9000',
'note' => 'AWS S3填写s3.区域.amazonaws.com其他S3兼容服务填写对应地址',
'required' => true,
],
'region' => [
'name' => '区域',
'type' => 'input',
'placeholder' => '如us-east-1',
'value' => 'us-east-1',
'required' => true,
],
'proxy' => [
'name' => '使用代理服务器',
'type' => 'radio',
'options' => [
'0' => '否',
'1' => '是',
],
'value' => '0'
],
],
'taskinputs' => [
'bucket' => [
'name' => '存储桶名称',
'type' => 'input',
'placeholder' => '',
'required' => true,
],
'cert_path' => [
'name' => '证书保存路径',
'type' => 'input',
'placeholder' => 'ssl/cert.pem',
'note' => '在存储桶内的文件路径,如 ssl/domain.com/cert.pem',
'required' => true,
],
'key_path' => [
'name' => '私钥保存路径',
'type' => 'input',
'placeholder' => 'ssl/key.pem',
'note' => '在存储桶内的文件路径,如 ssl/domain.com/key.pem',
'required' => true,
],
],
],
'local' => [
'name' => '复制到本机',
'class' => 3,

View File

@@ -374,6 +374,12 @@ class DnsHelper
'placeholder' => '',
'required' => true,
],
'apikey' => [
'name' => 'API密钥/令牌',
'type' => 'input',
'placeholder' => '',
'required' => true,
],
'auth' => [
'name' => '认证方式',
'type' => 'radio',
@@ -383,12 +389,6 @@ class DnsHelper
],
'value' => '0'
],
'apikey' => [
'name' => 'API密钥/令牌',
'type' => 'input',
'placeholder' => '',
'required' => true,
],
'proxy' => [
'name' => '使用代理服务器',
'type' => 'radio',
@@ -518,6 +518,41 @@ class DnsHelper
'page' => true,
'add' => true,
],
'technitium' => [
'name' => 'Technitium',
'icon' => 'technitium.png',
'note' => '',
'config' => [
'url' => [
'name' => 'Server URL',
'type' => 'input',
'placeholder' => 'http://127.0.0.1:5380',
'required' => true,
],
'token' => [
'name' => 'API Token',
'type' => 'input',
'placeholder' => '',
'required' => true,
],
'proxy' => [
'name' => '使用代理服务器',
'type' => 'radio',
'options' => [
'0' => '否',
'1' => '是',
],
'value' => '0'
],
],
'remark' => 2,
'status' => true,
'redirect' => false,
'log' => false,
'weight' => false,
'page' => true,
'add' => true,
],
'aliyunesa' => [
'name' => '阿里云ESA',
'icon' => 'aliyun.png',
@@ -608,6 +643,47 @@ class DnsHelper
'page' => false,
'add' => false,
],
'dnsmgr' => [
'name' => '同系统对接',
'icon' => 'logo.png',
'note' => '对接其他聚合DNS管理系统站点',
'config' => [
'base_url' => [
'name' => '站点地址',
'type' => 'input',
'placeholder' => '例如https://dns.example.com',
'required' => true,
],
'uid' => [
'name' => '用户 ID',
'type' => 'input',
'placeholder' => '',
'required' => true,
],
'key' => [
'name' => 'API 密钥',
'type' => 'input',
'placeholder' => '',
'required' => true,
],
'proxy' => [
'name' => '使用代理服务器',
'type' => 'radio',
'options' => [
'0' => '否',
'1' => '是',
],
'value' => '0'
],
],
'remark' => 2,
'status' => true,
'redirect' => true,
'log' => false,
'weight' => true,
'page' => false,
'add' => false,
],
];
public static $line_name = [
@@ -627,6 +703,7 @@ class DnsHelper
'spaceship' => ['DEF' => 'default'],
'aliyunesa' => ['DEF' => '0'],
'tencenteo' => ['DEF' => 'Default'],
'cccyun' => ['DEF' => 'default'],
];
public static function getList()
@@ -651,7 +728,7 @@ class DnsHelper
$dnstype = $account['type'];
$class = "\\app\\lib\\dns\\{$dnstype}";
if (class_exists($class)) {
$config = json_decode($account['config'], true);
$config = json_decode($account['config'] ?? '', true);
$config['domain'] = $domain;
$config['domainid'] = $domainid;
$model = new $class($config);
@@ -668,7 +745,7 @@ class DnsHelper
$dnstype = $account['type'];
$class = "\\app\\lib\\dns\\{$dnstype}";
if (class_exists($class)) {
$config = json_decode($account['config'], true);
$config = json_decode($account['config'] ?? '', true);
$config['domain'] = $account['name'];
$config['domainid'] = $account['thirdid'];
$model = new $class($config);

View File

@@ -27,18 +27,18 @@ class aliyun implements CertInterface
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'];
if (empty($this->AccessKeyId) || empty($this->AccessKeySecret)) throw new Exception('必填参数不能为空');
$param = ['Action' => 'ListInstances'];
$this->request($param, true);
return true;
}
public function buyCert($domainList, &$order)
{
$param = ['Action' => 'DescribePackageState', 'ProductCode' => 'digicert-free-1-free'];
$param = ['Action' => 'GetInstanceSummary', 'InstanceType' => 'TEST'];
$data = $this->request($param, true);
if (!isset($data['TotalCount']) || $data['TotalCount'] == 0) throw new Exception('没有可用的免费证书资源包');
$this->log('证书资源包总数量:' . $data['TotalCount'] . ',已使用数量:' . $data['UsedCount']);
if (!isset($data['InactiveCount']) || $data['InactiveCount'] == 0) throw new Exception('没有待使用的测试证书实例,请先购买测试证书');
$this->log('实例总个数:' . $data['TotalCount'] . ',实例待使用总数:' . $data['InactiveCount']);
}
public function createOrder($domainList, &$order, $keytype, $keysize)
@@ -46,31 +46,92 @@ class aliyun implements CertInterface
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'
'Action' => 'ListInstances',
'Status' => 'inactive',
'InstanceType' => 'TEST',
];
$data = $this->request($param, true);
if (empty($data['OrderId'])) throw new Exception('证书申请失败OrderId为空');
$order['OrderId'] = $data['OrderId'];
sleep(3);
if (empty($data['InstanceList'])) throw new Exception('待使用的测试证书实例列表为空');
$instanceId = $data['InstanceList'][0]['InstanceId'];
$param = [
'Action' => 'DescribeCertificateState',
'OrderId' => $order['OrderId'],
'Action' => 'ListContact',
];
$data = $this->request($param, true);
if (empty($data['ContactList'])) throw new Exception('联系人列表为空,请先添加联系人');
$contactId = $data['ContactList'][0]['ContactId'];
if ($keytype == 'ECC') $KeyAlgorithm = 'ECC_256';
else if ($keysize == '3072') $KeyAlgorithm = 'RSA_3072';
else $KeyAlgorithm = 'RSA_2048';
$param = [
'Action' => 'UpdateInstance',
'InstanceId' => $instanceId,
'Domain' => $domain,
'KeyAlgorithm' => $KeyAlgorithm,
'AutoReissue' => 'disable',
'ContactIdList.1' => $contactId,
'ValidateType' => 'DNS'
];
try {
$this->request($param);
} catch (Exception $e) {
throw new Exception('更新证书实例失败:' . $e->getMessage());
}
$param = [
'Action' => 'ApplyCertificate',
'InstanceId' => $instanceId
];
try {
$this->request($param);
} catch (Exception $e) {
throw new Exception('申请证书失败:' . $e->getMessage());
}
sleep(1);
$status = '';
do {
$param = [
'Action' => 'GetTaskAttribute',
'TaskId' => $instanceId
];
try {
$data = $this->request($param, true);
$status = $data['TaskStatus'];
} catch (Exception $e) {
throw new Exception('申请证书提交结果查询失败:' . $e->getMessage());
}
if ($status == 'processing') {
sleep(1);
} elseif ($status == 'failed') {
throw new Exception('申请证书失败:' . $data['TaskMessage']);
} else {
break;
}
} while ($status == 'processing');
$param = [
'Action' => 'GetInstanceDetail',
'InstanceId' => $instanceId
];
try {
$data = $this->request($param, true);
} catch (Exception $e) {
throw new Exception('获取实例详情失败:' . $e->getMessage());
}
$order['InstanceId'] = $instanceId;
$dnsList = [];
if ($data['Type'] == 'domain_verify') {
$mainDomain = getMainDomain($domain);
$name = substr($data['RecordDomain'], 0, -(strlen($mainDomain) + 1));
$dnsList[$mainDomain][] = ['name' => $name, 'type' => $data['RecordType'], 'value' => $data['RecordValue']];
if (!empty($data['DomainValidationList'])) {
foreach ($data['DomainValidationList'] as $opts) {
$mainDomain = getMainDomain($opts['Domain']);
$name = substr($opts['ValidationKey'] . '.' . $opts['RootDomain'], 0, - (strlen($mainDomain) + 1));
$dnsList[$mainDomain][] = ['name' => $name, 'type' => $opts['ValidationType'], 'value' => $opts['ValidationValue']];
}
}
return $dnsList;
@@ -81,13 +142,13 @@ class aliyun implements CertInterface
public function getAuthStatus($domainList, $order)
{
$param = [
'Action' => 'DescribeCertificateState',
'OrderId' => $order['OrderId'],
'Action' => 'GetInstanceDetail',
'InstanceId' => $order['InstanceId'],
];
$data = $this->request($param, true);
if ($data['Type'] == 'certificate') {
if ($data['Status'] == 'normal') {
return true;
} elseif ($data['Type'] == 'verify_fail') {
} elseif ($data['Status'] == 'closed') {
throw new Exception('证书审核失败');
} else {
return false;
@@ -97,12 +158,19 @@ class aliyun implements CertInterface
public function finalizeOrder($domainList, $order, $keytype, $keysize)
{
$param = [
'Action' => 'DescribeCertificateState',
'OrderId' => $order['OrderId'],
'Action' => 'GetInstanceDetail',
'InstanceId' => $order['InstanceId'],
];
$data = $this->request($param, true);
$fullchain = $data['Certificate'];
$private_key = $data['PrivateKey'];
if (empty($data['CertificateId'])) throw new Exception('证书ID不存在');
$param = [
'Action' => 'GetUserCertificateDetail',
'CertId' => $data['CertificateId'],
];
$data = $this->request($param, true);
$fullchain = $data['Cert'];
$private_key = $data['Key'];
if (empty($fullchain) || empty($private_key)) throw new Exception('证书内容获取失败');
$certInfo = openssl_x509_parse($fullchain, true);
@@ -113,8 +181,8 @@ class aliyun implements CertInterface
public function revoke($order, $pem)
{
$param = [
'Action' => 'CancelCertificateForPackageRequest',
'OrderId' => $order['OrderId'],
'Action' => 'RevokeCertificate',
'InstanceId' => $order['InstanceId'],
];
$this->request($param);
}
@@ -122,22 +190,14 @@ class aliyun implements CertInterface
public function cancel($order)
{
$param = [
'Action' => 'DescribeCertificateState',
'OrderId' => $order['OrderId'],
'Action' => 'GetInstanceDetail',
'InstanceId' => $order['InstanceId'],
];
$data = $this->request($param, true);
if ($data['Type'] == 'domain_verify' || $data['Type'] == 'process') {
if ($data['Status'] == 'pending') {
$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'],
'Action' => 'CancelPendingCertificate',
'InstanceId' => $order['InstanceId'],
];
$this->request($param);
}

163
app/lib/deploy/acepanel.php Normal file
View File

@@ -0,0 +1,163 @@
<?php
namespace app\lib\deploy;
use app\lib\DeployInterface;
use Exception;
class acepanel implements DeployInterface
{
private $logger;
private $url;
private $id;
private $token;
private $proxy;
public function __construct($config)
{
$this->url = rtrim($config['url'], '/');
$this->id = $config['id'];
$this->token = $config['token'];
$this->proxy = $config['proxy'] == 1;
}
public function check()
{
if (empty($this->url) || empty($this->id) || empty($this->token)) throw new Exception('请填写完整面板地址和访问令牌');
$response = $this->request('/user/info', null, 'GET');
$result = json_decode($response, true);
if (isset($result['msg']) && $result['msg'] == "success") {
return true;
} else {
throw new Exception($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) {
$site = trim($site);
if (empty($site)) continue;
try {
$this->deploySite($site, $fullchain, $privatekey);
$this->log("网站 {$site} 证书部署成功");
$success++;
} catch (Exception $e) {
$errmsg = $e->getMessage();
$this->log("网站 {$site} 证书部署失败:" . $errmsg);
}
}
if ($success == 0) {
throw new Exception($errmsg ?: '要部署的网站不存在');
}
}
private function deployPanel($fullchain, $privatekey)
{
$data = [
'cert' => $fullchain,
'key' => $privatekey,
];
$response = $this->request('/setting/cert', $data);
$result = json_decode($response, true);
if (isset($result['msg']) && $result['msg'] == "success") {
return true;
} elseif (isset($result['msg'])) {
throw new Exception($result['msg']);
} else {
throw new Exception($response ?: '返回数据解析失败');
}
}
private function deploySite($name, $fullchain, $privatekey)
{
$data = [
'name' => $name,
'cert' => $fullchain,
'key' => $privatekey,
];
$response = $this->request('/website/cert', $data);
$result = json_decode($response, true);
if (isset($result['msg']) && $result['msg'] == "success") {
return true;
} elseif (isset($result['msg'])) {
throw new Exception($result['msg']);
} else {
throw new Exception($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, $method = 'POST')
{
$url = $this->url . '/api' . $path;
$body = $method == 'GET' ? null : json_encode($params);
$sign = $this->signRequest($method, $url, $body, $this->id, $this->token);
$response = http_request($url, $body, null, null, [
'Content-Type' => 'application/json',
'X-Timestamp' => $sign['timestamp'],
'Authorization' => 'HMAC-SHA256 Credential=' . $sign['id'] . ', Signature=' . $sign['signature']
], $this->proxy, $method);
return $response['body'];
}
private function signRequest($method, $url, $body, $id, $token)
{
// 解析URL并获取路径
$parsedUrl = parse_url($url);
$path = $parsedUrl['path'];
$query = $parsedUrl['query'] ?? '';
// 规范化路径
$canonicalPath = $path;
if (!str_starts_with($path, '/api')) {
$apiPos = strpos($path, '/api');
if ($apiPos !== false) {
$canonicalPath = substr($path, $apiPos);
}
}
// 构造规范化请求
$canonicalRequest = implode("\n", [
$method,
$canonicalPath,
$query,
hash('sha256', $body ?: '')
]);
// 计算签名
$timestamp = time();
$stringToSign = implode("\n", [
'HMAC-SHA256',
$timestamp,
hash('sha256', $canonicalRequest)
]);
$signature = hash_hmac('sha256', $stringToSign, $token);
return [
'timestamp' => $timestamp,
'signature' => $signature,
'id' => $id
];
}
}

View File

@@ -54,6 +54,8 @@ class aliyun implements DeployInterface
$this->deploy_oss($cert_id, $config);
} elseif ($config['product'] == 'waf') {
$this->deploy_waf($cert_id, $config);
} elseif ($config['product'] == 'wafres') {
$this->deploy_waf_res($cert_id, $config);
} elseif ($config['product'] == 'waf2') {
$this->deploy_waf2($cert_id, $config);
} elseif ($config['product'] == 'ddoscoo') {
@@ -66,6 +68,10 @@ class aliyun implements DeployInterface
$this->deploy_alb($cert_id, $config);
} elseif ($config['product'] == 'nlb') {
$this->deploy_nlb($cert_id, $config);
} elseif ($config['product'] == 'esa_saas') {
$this->deploy_esa_saas($cert_id, $config);
} elseif ($config['product'] == 'ga') {
$this->deploy_ga($cert_id, $config);
} elseif ($config['product'] == 'upload') {
} else {
throw new Exception('未知的产品类型');
@@ -132,36 +138,98 @@ class aliyun implements DeployInterface
private function deploy_cdn($cert_id, $cert_name, $config)
{
$domain = $config['domain'];
if (empty($domain)) throw new Exception('CDN绑定域名不能为空');
if (empty($config['domain'])) throw new Exception('CDN绑定域名不能为空');
$client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, 'cdn.aliyuncs.com', '2018-05-10', $this->proxy);
$param = [
'Action' => 'SetCdnDomainSSLCertificate',
'DomainName' => $domain,
'CertName' => $cert_name,
'CertType' => 'cas',
'SSLProtocol' => 'on',
'CertId' => $cert_id,
];
$client->request($param);
$this->log('CDN域名 ' . $domain . ' 部署证书成功!');
foreach (explode(',', $config['domain']) as $domain) {
$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绑定域名不能为空');
if (empty($config['domain'])) throw new Exception('DCDN绑定域名不能为空');
$client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, 'dcdn.aliyuncs.com', '2018-01-15', $this->proxy);
foreach (explode(',', $config['domain']) as $domain) {
$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_saas($cas_id, $config)
{
$sitename = $config['esa_sitename'];
$saas_sitename = $config['esa_saas_sitename'];
if (empty($sitename)) throw new Exception('ESA站点名称不能为空');
if (empty($saas_sitename)) throw new Exception('ESA SAAS域名不能为空');
if ($config['region'] == 'ap-southeast-1') {
$endpoint = 'esa.ap-southeast-1.aliyuncs.com';
} else {
$endpoint = 'esa.cn-hangzhou.aliyuncs.com';
}
$client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, $endpoint, '2024-09-10');
$param = [
'Action' => 'SetDcdnDomainSSLCertificate',
'DomainName' => $domain,
'CertName' => $cert_name,
'CertType' => 'cas',
'SSLProtocol' => 'on',
'CertId' => $cert_id,
'Action' => 'ListSites',
'SiteName' => $sitename,
'SiteSearchType' => 'exact',
];
$client->request($param);
$this->log('DCDN域名 ' . $domain . ' 部署证书成功!');
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'];
// 查询对应的saas域名
$param = [
'Action' => 'ListCustomHostnames',
'SiteName' => $saas_sitename,
'SiteId' => $site_id,
'SiteSearchType' => 'exact',
];
try {
$saas_data = $client->request($param, 'GET');
} catch (Exception $e) {
throw new Exception('查询ESA saas域名失败' . $e->getMessage());
}
if ($saas_data['TotalCount'] == 0) throw new Exception('ESA saas站点 ' . $saas_sitename . ' 不存在');
$saas_hostname_id = $saas_data['Hostnames'][0]['HostnameId'];
$param = [
'Action' => 'UpdateCustomHostname',
'HostnameId' => $saas_hostname_id,
'SslFlag' => 'on',
'CertType' => 'cas',
'CasId' => $cas_id,
'CasRegion' => $config['region'],
];
$this->log('ESA SAAS站点部署参数 ' . json_encode($param));
try {
$saas_deploy_result = $client->request($param);
$this->log('ESA SAAS站点部署结果 ' . json_encode($saas_deploy_result));
} catch (Exception $e) {
throw new Exception('部署失败:' . $e->getMessage());
}
$this->log('ESA SAAS站点 ' . $saas_sitename . ' 证书添加成功!');
}
private function deploy_esa($cas_id, $cert_name, $config)
@@ -201,11 +269,11 @@ class aliyun implements DeployInterface
}
$this->log('ESA站点 ' . $sitename . ' 查询到' . $data['TotalCount'] . '个SSL证书');
$exist_cert_id = null;
$exist_cert_name = null;
$exist_cert_casid = null;
$exist_cert = null;
$oldest_cert = null;
if ($data['TotalCount'] > 0) {
foreach ($data['Result'] as $cert) {
if ($cert['Type'] == 'free') continue;
$domains = explode(',', $cert['SAN']);
$flag = true;
foreach ($domains as $domain) {
@@ -215,11 +283,40 @@ class aliyun implements DeployInterface
}
}
if ($flag) {
$exist_cert_id = $cert['Id'];
$exist_cert_name = $cert['Name'];
$exist_cert_casid = isset($cert['CasId']) ? $cert['CasId'] : null;
$exist_cert = $cert;
break;
}
if (!$oldest_cert) {
$oldest_cert = $cert;
} elseif (strtotime($cert['CreateTime']) < strtotime($oldest_cert['CreateTime'])) {
$oldest_cert = $cert;
}
}
}
if (!$exist_cert) { //新增证书时,若配额已满,则删除最旧的证书
$param = [
'Action' => 'ListInstanceQuotasWithUsage',
'SiteId' => $site_id,
'QuotaNames' => 'customHttpCert',
];
try {
$data = $client->request($param, 'GET');
} catch (Exception $e) {
throw new Exception('查询ESA站点证书配额失败' . $e->getMessage());
}
if (!empty($data['Quotas']) && intval($data['Quotas'][0]['Usage']) >= intval($data['Quotas'][0]['QuotaValue']) && $oldest_cert) {
$param = [
'Action' => 'DeleteCertificate',
'SiteId' => $site_id,
'Id' => $oldest_cert['Id'],
];
try {
$client->request($param, 'GET');
$this->log('ESA站点 ' . $sitename . ' 删除证书 ' . $oldest_cert['Name'] . ' 成功');
} catch (Exception $e) {
throw new Exception('ESA站点 ' . $sitename . ' 删除证书' . $oldest_cert['Name'] . '失败:' . $e->getMessage());
}
}
}
@@ -232,10 +329,10 @@ class aliyun implements DeployInterface
'Region' => $config['region'],
];
if ($exist_cert_id) {
$param['Id'] = $exist_cert_id;
if ($exist_cert) {
$param['Id'] = $exist_cert['Id'];
if ($exist_cert_casid == $cas_id) {
if (isset($exist_cert['CasId']) && $exist_cert['CasId'] == $cas_id) {
$this->log('ESA站点 ' . $sitename . ' 证书已配置,无需重复操作');
return;
}
@@ -243,8 +340,8 @@ class aliyun implements DeployInterface
$client->request($param);
if ($exist_cert_name) {
$this->log('ESA站点 ' . $sitename . ' 证书 ' . $exist_cert_name . ' 更新成功');
if ($exist_cert) {
$this->log('ESA站点 ' . $sitename . ' 证书 ' . $exist_cert['Name'] . ' 更新成功');
} else {
$this->log('ESA站点 ' . $sitename . ' 证书添加成功!');
}
@@ -256,14 +353,16 @@ class aliyun implements DeployInterface
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 . '-cn-hangzhou');
$this->log('OSS域名 ' . $config['domain'] . ' 部署证书成功!');
foreach (explode(',', $config['domain']) as $domain) {
if (empty($domain)) continue;
$client->addBucketCnameCert($config['oss_bucket'], $domain, $cert_id . '-cn-hangzhou');
$this->log('OSS域名 ' . $domain . ' 部署证书成功!');
}
}
private function deploy_waf($cert_id, $config)
{
$domain = $config['domain'];
if (empty($domain)) throw new Exception('WAF绑定域名不能为空');
if (empty($config['domain'])) throw new Exception('WAF绑定域名不能为空');
if ($config['region'] == 'ap-southeast-1') {
$cert_id .= '-ap-southeast-1';
@@ -288,62 +387,176 @@ class aliyun implements DeployInterface
$instance_id = $data['InstanceId'];
$this->log('获取WAF实例ID成功 InstanceId=' . $instance_id);
foreach (explode(',', $config['domain']) as $domain) {
$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'])) {
throw new Exception('没有找到' . $domain . '监听器');
}
if (isset($data['Listen']['CertId'])) {
$old_cert_id = $data['Listen']['CertId'];
if (!empty($old_cert_id) && $old_cert_id == $cert_id) {
$this->log('WAF域名 ' . $domain . ' 证书已配置,无需重复操作');
return;
}
}
$data['Listen']['CertId'] = $cert_id;
if (empty($data['Listen']['HttpsPorts'])) {
$data['Listen']['HttpsPorts'] = [443];
$data['Listen']['TLSVersion'] = 'tlsv1.1';
$data['Listen']['EnableTLSv3'] = true;
$data['Listen']['CipherSuite'] = 1;
}
if (count($data['Redirect']['BackendPorts']) == 1 && $data['Redirect']['BackendPorts'][0]['Protocol'] == 'http') {
$data['Redirect']['BackendPorts'][] = [
'ListenPort' => 443,
'Protocol' => 'https',
'BackendPort' => $data['Redirect']['BackendPorts'][0]['BackendPort'],
];
$data['Redirect']['FocusHttpBackend'] = true;
}
$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_waf_res($cert_id, $config)
{
if (empty($config['waf_resource_id'])) throw new Exception('云产品防护对象ID不能为空');
$deploy_type = isset($config['deploy_type']) ? intval($config['deploy_type']) : 0;
if ($config['region'] == 'ap-southeast-1') {
$cert_id .= '-ap-southeast-1';
} else {
$cert_id .= '-cn-hangzhou';
}
$endpoint = 'wafopenapi.' . $config['region'] . '.aliyuncs.com';
$client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, $endpoint, '2021-10-01', $this->proxy);
$param = [
'Action' => 'DescribeDomainDetail',
'InstanceId' => $instance_id,
'Domain' => $domain,
'Action' => 'DescribeInstance',
'RegionId' => $config['region'],
];
try {
$data = $client->request($param, 'GET');
} catch (Exception $e) {
throw new Exception('查询CNAME接入详情失败:' . $e->getMessage());
}
if (!isset($data['Listen'])) {
throw new Exception('没有找到' . $domain . '监听器');
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);
if (isset($data['Listen']['CertId'])) {
$old_cert_id = $data['Listen']['CertId'];
if (!empty($old_cert_id) && $old_cert_id == $cert_id) {
$this->log('WAF域名 ' . $domain . ' 证书已配置,无需重复操作');
return;
foreach (explode(',', $config['waf_resource_id']) as $waf_resource_id) {
$parts = explode('-', $waf_resource_id);
$resource_instance_id = $parts[count($parts) - 3] ?? '';
if (empty($resource_instance_id)) {
throw new Exception('ResourceInstanceId解析失败' . $waf_resource_id);
}
$param = [
'Action' => 'DescribeCloudResourceList',
'InstanceId' => $instance_id,
'CloudResourceId' => $waf_resource_id,
'RegionId' => $config['region'],
];
try {
$data = $client->request($param, 'GET');
} catch (Exception $e) {
throw new Exception('查询云产品接入WAF配置失败' . $e->getMessage());
}
if (empty($data['CloudResourceList'])) {
throw new Exception('WAF云产品接入实例不存在' . $waf_resource_id);
}
if ($deploy_type == 0) {
$param = [
'Action' => 'ModifyCloudResourceDefaultCert',
'InstanceId' => $instance_id,
'CloudResourceId' => $waf_resource_id,
'CertId' => $cert_id,
'RegionId' => $config['region'],
];
$client->request($param);
$this->log('WAF云产品防护对象 ' . $waf_resource_id . ' 部署默认证书成功!');
} else {
$param = [
'Action' => 'CreateCloudResourceExtensionCert',
'InstanceId' => $instance_id,
'CloudResourceId' => $waf_resource_id,
'CertId' => $cert_id,
'RegionId' => $config['region'],
];
$client->request($param);
$this->log('WAF云产品防护对象 ' . $waf_resource_id . ' 部署扩展证书成功!');
$this->clean_waf_res_expired_certs($client, $instance_id, $resource_instance_id, $waf_resource_id, $config['region']);
}
}
}
$data['Listen']['CertId'] = $cert_id;
if (empty($data['Listen']['HttpsPorts'])) {
$data['Listen']['HttpsPorts'] = [443];
$data['Listen']['TLSVersion'] = 'tlsv1.1';
$data['Listen']['EnableTLSv3'] = true;
$data['Listen']['CipherSuite'] = 1;
}
if (count($data['Redirect']['BackendPorts']) == 1 && $data['Redirect']['BackendPorts'][0]['Protocol'] == 'http') {
$data['Redirect']['BackendPorts'][] = [
'ListenPort' => 443,
'Protocol' => 'https',
'BackendPort' => $data['Redirect']['BackendPorts'][0]['BackendPort'],
];
$data['Redirect']['FocusHttpBackend'] = true;
}
$data['Redirect']['Backends'] = $data['Redirect']['AllBackends'];
private function clean_waf_res_expired_certs($client, $instance_id, $resource_instance_id, $waf_resource_id, $region)
{
$param = [
'Action' => 'ModifyDomain',
'Action' => 'DescribeResourceInstanceCerts',
'InstanceId' => $instance_id,
'Domain' => $domain,
'Listen' => json_encode($data['Listen']),
'Redirect' => json_encode($data['Redirect']),
'RegionId' => $config['region'],
'ResourceInstanceId' => $resource_instance_id,
'RegionId' => $region,
];
$data = $client->request($param);
try {
$data = $client->request($param, 'GET');
} catch (Exception $e) {
$this->log('查询扩展证书列表失败:' . $e->getMessage());
return;
}
if (empty($data['Certs'])) return;
$this->log('WAF域名 ' . $domain . ' 部署证书成功!');
$now = time();
foreach ($data['Certs'] as $cert) {
if (empty($cert['CertIdentifier']) || empty($cert['AfterDate'])) continue;
$expire_time = strtotime($cert['AfterDate']);
if ($expire_time !== false && $expire_time < $now) {
$param = [
'Action' => 'DeleteCloudResourceExtensionCert',
'InstanceId' => $instance_id,
'CloudResourceId' => $waf_resource_id,
'CertId' => $cert['CertIdentifier'],
'RegionId' => $region,
];
try {
$client->request($param);
$this->log('已删除过期扩展证书:' . $cert['CertIdentifier']);
} catch (Exception $e) {
$this->log('删除过期扩展证书失败:' . $cert['CertIdentifier'] . ' ' . $e->getMessage());
}
}
}
}
private function deploy_waf2($cert_id, $config)
{
$domain = $config['domain'];
if (empty($domain)) throw new Exception('WAF绑定域名不能为空');
if (empty($config['domain'])) throw new Exception('WAF绑定域名不能为空');
$endpoint = 'wafopenapi.' . $config['region'] . '.aliyuncs.com';
@@ -362,23 +575,24 @@ class aliyun implements DeployInterface
$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);
foreach (explode(',', $config['domain']) as $domain) {
$param = [
'Action' => 'CreateCertificateByCertificateId',
'InstanceId' => $instance_id,
'Domain' => $domain,
'CertificateId' => $cert_id,
];
$client->request($param);
$this->log('WAF域名 ' . $domain . ' 部署证书成功!');
$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分组绑定域名不能为空');
if (empty($config['domain'])) throw new Exception('API分组绑定域名不能为空');
$certInfo = openssl_x509_parse($fullchain, true);
if (!$certInfo) throw new Exception('证书解析失败');
@@ -388,76 +602,80 @@ class aliyun implements DeployInterface
$client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, $endpoint, '2016-07-14', $this->proxy);
$param = [
'Action' => 'SetDomainCertificate',
'GroupId' => $groupid,
'DomainName' => $domain,
'CertificateName' => $cert_name,
'CertificateBody' => $fullchain,
'CertificatePrivateKey' => $privatekey,
];
$client->request($param);
foreach (explode(',', $config['domain']) as $domain) {
$param = [
'Action' => 'SetDomainCertificate',
'GroupId' => $groupid,
'DomainName' => $domain,
'CertificateName' => $cert_name,
'CertificateBody' => $fullchain,
'CertificatePrivateKey' => $privatekey,
];
$client->request($param);
$this->log('API网关域名 ' . $domain . ' 部署证书成功!');
$this->log('API网关域名 ' . $domain . ' 部署证书成功!');
}
}
private function deploy_ddoscoo($cert_id, $config)
{
$domain = $config['domain'];
if (empty($domain)) throw new Exception('绑定域名不能为空');
if (empty($config['domain'])) throw new Exception('绑定域名不能为空');
$endpoint = 'ddoscoo.' . $config['region'] . '.aliyuncs.com';
$client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, $endpoint, '2020-01-01', $this->proxy);
$param = [
'Action' => 'AssociateWebCert',
'Domain' => $domain,
'CertId' => $cert_id,
];
$client->request($param);
foreach (explode(',', $config['domain']) as $domain) {
$param = [
'Action' => 'AssociateWebCert',
'Domain' => $domain,
'CertId' => $cert_id,
];
$client->request($param);
$this->log('DDoS高防域名 ' . $domain . ' 部署证书成功!');
$this->log('DDoS高防域名 ' . $domain . ' 部署证书成功!');
}
}
private function deploy_live($cert_id, $cert_name, $config)
{
$domain = $config['domain'];
if (empty($domain)) throw new Exception('视频直播绑定域名不能为空');
if (empty($config['domain'])) throw new Exception('视频直播绑定域名不能为空');
$client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, 'live.aliyuncs.com', '2016-11-01', $this->proxy);
$param = [
'Action' => 'SetLiveDomainCertificate',
'DomainName' => $domain,
'CertName' => $cert_name,
'CertType' => 'cas',
'SSLProtocol' => 'on',
'CertId' => $cert_id,
];
$client->request($param);
$this->log('设置视频直播域名 ' . $domain . ' 证书成功!');
foreach (explode(',', $config['domain']) as $domain) {
$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('视频点播绑定域名不能为空');
if (empty($config['domain'])) throw new Exception('视频点播绑定域名不能为空');
$client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, 'vod.cn-shanghai.aliyuncs.com', '2017-03-21', $this->proxy);
$param = [
'Action' => 'SetVodDomainCertificate',
'DomainName' => $domain,
'SSLProtocol' => 'on',
'SSLPub' => $fullchain,
'SSLPri' => $privatekey,
];
$client->request($param);
$this->log('视频点播域名 ' . $domain . ' 部署证书成功!');
foreach (explode(',', $config['domain']) as $domain) {
$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($config['domain'])) throw new Exception('函数计算域名不能为空');
if (empty($fc_cname)) throw new Exception('域名CNAME地址不能为空');
$certInfo = openssl_x509_parse($fullchain, true);
@@ -466,41 +684,42 @@ class aliyun implements DeployInterface
$client = new AliyunNewClient($this->AccessKeyId, $this->AccessKeySecret, $fc_cname, '2023-03-30', $this->proxy);
try {
$data = $client->request('GET', 'GetCustomDomain', '/2023-03-30/custom-domains/' . $domain);
} catch (Exception $e) {
throw new Exception('获取绑定域名信息失败:' . $e->getMessage());
foreach (explode(',', $config['domain']) as $domain) {
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 . ' 部署证书成功!');
}
$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($config['domain'])) throw new Exception('函数计算域名不能为空');
if (empty($fc_cname)) throw new Exception('域名CNAME地址不能为空');
$certInfo = openssl_x509_parse($fullchain, true);
@@ -509,33 +728,35 @@ class aliyun implements DeployInterface
$client = new AliyunNewClient($this->AccessKeyId, $this->AccessKeySecret, $fc_cname, '2021-04-06', $this->proxy);
try {
$data = $client->request('GET', 'GetCustomDomain', '/2021-04-06/custom-domains/' . $domain);
} catch (Exception $e) {
throw new Exception('获取绑定域名信息失败:' . $e->getMessage());
foreach (explode(',', $config['domain']) as $domain) {
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 . ' 部署证书成功!');
}
$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)
@@ -735,6 +956,84 @@ class aliyun implements DeployInterface
}
}
private function deploy_ga($cert_id, $config)
{
if (empty($config['ga_id'])) throw new Exception('全球加速实例ID不能为空');
if (empty($config['ga_listener_id'])) throw new Exception('全球加速监听ID不能为空');
$client = new AliyunClient($this->AccessKeyId, $this->AccessKeySecret, 'ga.cn-hangzhou.aliyuncs.com', '2019-11-20', $this->proxy);
$cert_id = $cert_id . '-cn-hangzhou';
$deploy_type = isset($config['deploy_type']) ? intval($config['deploy_type']) : 0;
if ($deploy_type == 1) {
if (empty($config['clb_domain'])) throw new Exception('扩展域名不能为空');
$param = [
'Action' => 'ListListenerCertificates',
'RegionId' => 'cn-hangzhou',
'AcceleratorId' => $config['ga_id'],
'ListenerId' => $config['ga_listener_id'],
];
try {
$data = $client->request($param);
} catch (Exception $e) {
throw new Exception('扩展域名列表查询失败:' . $e->getMessage());
}
$need_add = [];
foreach (explode(',', $config['clb_domain']) as $domain) {
$domainExists = false;
$exist_cert_id = null;
foreach ($data['Certificates'] as $cert) {
if (isset($cert['Domain']) && $domain == $cert['Domain']) {
$domainExists = true;
$exist_cert_id = $cert['CertificateId'];
}
}
if ($domainExists) {
if ($exist_cert_id == $cert_id) {
$this->log('全球加速实例监听扩展域名 ' . $domain . ' 证书已配置');
continue;
}
$param = [
'Action' => 'UpdateAdditionalCertificateWithListener',
'RegionId' => 'cn-hangzhou',
'AcceleratorId' => $config['ga_id'],
'ListenerId' => $config['ga_listener_id'],
'Domain' => $domain,
'CertificateId' => $cert_id,
];
$client->request($param);
$this->log('全球加速实例监听扩展域名 ' . $domain . ' 替换证书成功!');
} else {
$need_add[] = $domain;
}
}
if (count($need_add) > 0) {
$param = [
'Action' => 'AssociateAdditionalCertificatesWithListener',
'RegionId' => 'cn-hangzhou',
'AcceleratorId' => $config['ga_id'],
'ListenerId' => $config['ga_listener_id'],
];
foreach ($need_add as $index => $domain) {
$param['Certificates.' . ($index + 1) . '.Id'] = $cert_id;
$param['Certificates.' . ($index + 1) . '.Domain'] = $domain;
}
$client->request($param);
$this->log('全球加速实例监听扩展域名 ' . implode(',', $need_add) . ' 绑定证书成功!');
}
} else {
$param = [
'Action' => 'UpdateListener',
'RegionId' => 'cn-hangzhou',
'AcceleratorId' => $config['ga_id'],
'ListenerId' => $config['ga_listener_id'],
'Certificates.1.Id' => $cert_id,
];
$client->request($param);
$this->log('全球加速实例监听默认证书更新成功!');
}
}
public function setLogger($func)
{
$this->logger = $func;

108
app/lib/deploy/amh.php Normal file
View File

@@ -0,0 +1,108 @@
<?php
namespace app\lib\deploy;
use app\lib\DeployInterface;
use Exception;
class amh implements DeployInterface
{
private $logger;
private $url;
private $apikey;
private $proxy;
public function __construct($config)
{
$this->url = rtrim($config['url'], '/');
$this->apikey = $config['apikey'];
$this->proxy = $config['proxy'] == 1;
}
public function check()
{
if (empty($this->url) || empty($this->apikey)) throw new Exception('请填写面板地址和接口密钥');
$this->login();
return true;
}
private function login()
{
$path = '/?c=amapi&a=login';
$post_data = 'amapi_expires=' . time() + 120;
$post_data .= '&amapi_sign=' . hash_hmac('sha256', $post_data, $this->apikey);
$response = $this->request($path, $post_data);
if ($response['code'] == 302 && strpos($response['redirect_url'], 'amh_token=') !== false) {
if(preg_match('/amh_token=([A-Za-z0-9]+)/', $response['redirect_url'], $matches)) {
return $matches[1];
}else{
throw new Exception('面板返回数据异常');
}
} elseif ($response['code'] == 200 && preg_match('/<p id="error".*?>(.*?)<\/p>/s', $response['body'], $matches)) {
throw new Exception(strip_tags($matches[1]));
} else {
throw new Exception('面板地址无法连接');
}
}
public function deploy($fullchain, $privatekey, $config, &$info)
{
if (empty($config['env_name'])) throw new Exception('环境名称不能为空');
if (empty($config['vhost_name'])) throw new Exception('网站标识域名不能为空');
$amh_token = $this->login();
foreach (explode("\n", $config['vhost_name']) as $vhost_name) {
$vhost_name = trim($vhost_name);
if (empty($vhost_name)) continue;
$path = '/?c=amssl&a=admin_amssl&envs_name=' . $config['env_name'] . '&vhost_name=' . $vhost_name . '&ModuleSort=app';
$params = [
'submit_key_crt' => 'y',
'key_input1' => 'key_input1',
'key_content1' => $privatekey,
'crt_input1' => 'crt_input1',
'crt_content1' => $fullchain,
'amh_token' => $amh_token,
];
$response = $this->request($path, $params);
if (strpos($response['body'], '<p id="success"') !== false) {
$this->log("网站 {$vhost_name} 证书部署成功");
} elseif (preg_match('/<p id="error".*?>(.*?)<\/p>/s', $response['body'], $matches)) {
$errmsg = strip_tags($matches[1]);
$this->log("网站 {$vhost_name} 证书部署失败:" . $errmsg);
throw new Exception($errmsg);
} elseif (preg_match('/<p id="error".*?>(.*?)<br \/>/s', $response['body'], $matches)) {
$errmsg = $matches[1];
if (strpos($errmsg, '<br />') !== false) {
$errmsg = explode('<br />', $errmsg)[0];
}
$errmsg = strip_tags($errmsg);
$this->log("网站 {$vhost_name} 证书部署失败:" . $errmsg);
throw new Exception($errmsg);
} else {
throw new Exception("网站 {$vhost_name} 证书部署失败:未知错误");
}
}
}
public function setLogger($func)
{
$this->logger = $func;
}
private function log($txt)
{
if ($this->logger) {
call_user_func($this->logger, $txt);
}
}
private function request($path, $post_data = null)
{
$url = $this->url . $path;
$cookie = 'PHPSESSID=' . hash_hmac('md5', 'php_sessid=' . $this->apikey, $this->apikey);
$response = http_request($url, $post_data, null, $cookie, null, $this->proxy);
return $response;
}
}

View File

@@ -11,14 +11,12 @@ class btpanel implements DeployInterface
private $logger;
private $url;
private $key;
private $version;
private $proxy;
public function __construct($config)
{
$this->url = rtrim($config['url'], '/');
$this->key = $config['key'];
$this->version = isset($config['version']) ? intval($config['version']) : 0;
$this->proxy = $config['proxy'] == 1;
}
@@ -26,24 +24,13 @@ class btpanel implements DeployInterface
{
if (empty($this->url) || empty($this->key)) throw new Exception('请填写面板地址和接口密钥');
if ($this->version == 1) {
$path = '/config/get_config';
$response = $this->request($path, []);
$result = json_decode($response, true);
if (isset($result['panel']['status']) && $result['panel']['status']) {
return true;
} else {
throw new Exception(isset($result['msg']) ? $result['msg'] : '面板地址无法连接');
}
$path = '/config?action=get_config';
$response = $this->request($path, []);
$result = json_decode($response, true);
if (isset($result['status']) && ($result['status'] == 1 || isset($result['sites_path']))) {
return true;
} else {
$path = '/config?action=get_config';
$response = $this->request($path, []);
$result = json_decode($response, true);
if (isset($result['status']) && ($result['status'] == 1 || isset($result['sites_path']))) {
return true;
} else {
throw new Exception(isset($result['msg']) ? $result['msg'] : '面板地址无法连接');
}
throw new Exception(isset($result['msg']) ? $result['msg'] : '面板地址无法连接');
}
}
@@ -55,46 +42,22 @@ class btpanel implements DeployInterface
return;
}
$isIIS = $config['type'] == '0' && $this->version == 1 && isset($config['is_iis']) && $config['is_iis'] == '1';
if ($isIIS) {
$response = $this->request('/panel/get_config', []);
$result = json_decode($response, true);
if (isset($result['paths']['soft'])) {
if ($result['config']['webserver'] != 'iis') {
throw new Exception('当前安装的Web服务器不是IIS');
}
$panel_path = $result['paths']['soft'];
} else {
throw new Exception(isset($result['msg']) ? $result['msg'] : '面板地址无法连接');
}
$pfx_dir = $panel_path . '/temp/ssl/' . getMillisecond();
$pfx_path = $pfx_dir . '/cert.pfx';
$pfx_password = '123456';
$pfx = CertHelper::getPfx($fullchain, $privatekey, $pfx_password);
$data = [
['name' => 'path', 'contents' => $pfx_dir],
['name' => 'filename', 'contents' => 'cert.pfx'],
['name' => 'size', 'contents' => strlen($pfx)],
['name' => 'start', 'contents' => '0'],
['name' => 'blob', 'filename' => 'cert.pfx', 'contents' => $pfx],
['name' => 'force', 'contents' => 'true'],
];
$response = $this->request('/files/upload', $data, true);
$result = json_decode($response, true);
if (isset($result['status']) && $result['status']) {
} else {
throw new Exception(isset($result['msg']) ? $result['msg'] : '面板地址无法连接');
}
}
$sites = explode("\n", $config['sites']);
$success = 0;
$errmsg = null;
foreach ($sites as $site) {
$siteName = trim($site);
if (empty($siteName)) continue;
if ($config['type'] == '3') {
if ($config['type'] == '4') {
try {
$this->deployProxy($siteName, $fullchain, $privatekey);
$this->log("反向代理站点 {$siteName} 证书部署成功");
$success++;
} catch (Exception $e) {
$errmsg = $e->getMessage();
$this->log("反向代理站点 {$siteName} 证书部署失败:" . $errmsg);
}
} elseif ($config['type'] == '3') {
try {
$this->deployDocker($siteName, $fullchain, $privatekey);
$this->log("Docker域名 {$siteName} 证书部署成功");
@@ -112,15 +75,6 @@ class btpanel implements DeployInterface
$errmsg = $e->getMessage();
$this->log("邮局域名 {$siteName} 证书部署失败:" . $errmsg);
}
} elseif ($isIIS) {
try {
$this->deployIISSite($siteName, $pfx_path, $pfx_password);
$this->log("域名 {$siteName} 证书部署成功");
$success++;
} catch (Exception $e) {
$errmsg = $e->getMessage();
$this->log("域名 {$siteName} 证书部署失败:" . $errmsg);
}
} else {
try {
$this->deploySite($siteName, $fullchain, $privatekey);
@@ -139,113 +93,30 @@ class btpanel implements DeployInterface
private function deployPanel($fullchain, $privatekey)
{
if ($this->version == 1) {
$path = '/config/set_panel_ssl';
$data = [
'ssl_key' => $privatekey,
'ssl_pem' => $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 : '返回数据解析失败');
}
$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 {
$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 : '返回数据解析失败');
}
throw new Exception($response ? $response : '返回数据解析失败');
}
}
private function deploySite($siteName, $fullchain, $privatekey)
{
if ($this->version == 1) {
$path = '/datalist/get_data_list';
$data = [
'table' => 'sites',
'search_type' => 'PHP',
'search' => $siteName,
'p' => 1,
'limit' => 10,
'type' => -1,
];
$response = $this->request($path, $data);
$result = json_decode($response, true);
if (isset($result['data'])) {
if (empty($result['data'])) throw new Exception("网站 {$siteName} 不存在");
$siteId = null;
foreach ($result['data'] as $item) {
if ($item['name'] == $siteName) {
$siteId = $item['id'];
break;
}
}
if (is_null($siteId)) throw new Exception("网站 {$siteName} 不存在");
$path = '/site/set_site_ssl';
$data = [
'siteid' => $siteId,
'status' => 'true',
'sslType' => '',
'cert' => $fullchain,
'key' => $privatekey,
];
$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 : '返回数据解析失败');
}
return true;
} elseif (isset($result['msg'])) {
throw new Exception($result['msg']);
} else {
throw new Exception($response ? $response : '返回数据解析失败');
}
} else {
$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 : '返回数据解析失败');
}
}
}
private function deployIISSite($domain, $pfx_path, $password = '123456')
{
$path = '/site/set_site_domain_ssl';
$path = '/site?action=SetSSL';
$data = [
'domain' => $domain,
'path' => $pfx_path,
'password' => $password,
'type' => '0',
'siteName' => $siteName,
'key' => $privatekey,
'csr' => $fullchain,
];
$response = $this->request($path, $data);
$result = json_decode($response, true);
@@ -297,6 +168,25 @@ class btpanel implements DeployInterface
}
}
private function deployProxy($domain, $fullchain, $privatekey)
{
$path = '/mod/proxy/com/set_ssl';
$data = [
'site_name' => $domain,
'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;

229
app/lib/deploy/btwin.php Normal file
View File

@@ -0,0 +1,229 @@
<?php
namespace app\lib\deploy;
use app\lib\DeployInterface;
use app\lib\CertHelper;
use Exception;
class btwin implements DeployInterface
{
private $logger;
private $url;
private $key;
private $proxy;
public function __construct($config)
{
$this->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/get_config';
$response = $this->request($path, []);
$result = json_decode($response, true);
if (isset($result['panel']['status']) && $result['panel']['status']) {
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;
}
$isIIS = $config['type'] == '0' && isset($config['is_iis']) && $config['is_iis'] == '1';
if ($isIIS) {
$response = $this->request('/panel/get_config', []);
$result = json_decode($response, true);
if (isset($result['paths']['soft'])) {
if ($result['config']['webserver'] != 'iis') {
throw new Exception('当前安装的Web服务器不是IIS');
}
$panel_path = $result['paths']['soft'];
} else {
throw new Exception(isset($result['msg']) ? $result['msg'] : '面板地址无法连接');
}
$pfx_dir = $panel_path . '/temp/ssl/' . getMillisecond();
$pfx_path = $pfx_dir . '/cert.pfx';
$pfx_password = '123456';
$pfx = CertHelper::getPfx($fullchain, $privatekey, $pfx_password);
$data = [
['name' => 'path', 'contents' => $pfx_dir],
['name' => 'filename', 'contents' => 'cert.pfx'],
['name' => 'size', 'contents' => strlen($pfx)],
['name' => 'start', 'contents' => '0'],
['name' => 'blob', 'filename' => 'cert.pfx', 'contents' => $pfx],
['name' => 'force', 'contents' => 'true'],
];
$response = $this->request('/files/upload', $data, true);
$result = json_decode($response, true);
if (isset($result['status']) && $result['status']) {
} else {
throw new Exception(isset($result['msg']) ? $result['msg'] : '面板地址无法连接');
}
}
$sites = explode("\n", $config['sites']);
$success = 0;
$errmsg = null;
foreach ($sites as $site) {
$siteName = trim($site);
if (empty($siteName)) continue;
if ($isIIS) {
try {
$this->deployIISSite($siteName, $pfx_path, $pfx_password);
$this->log("域名 {$siteName} 证书部署成功");
$success++;
} catch (Exception $e) {
$errmsg = $e->getMessage();
$this->log("域名 {$siteName} 证书部署失败:" . $errmsg);
}
} else {
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/set_panel_ssl';
$data = [
'ssl_key' => $privatekey,
'ssl_pem' => $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 = '/datalist/get_data_list';
$data = [
'table' => 'sites',
'search_type' => 'PHP',
'search' => $siteName,
'p' => 1,
'limit' => 10,
'type' => -1,
];
$response = $this->request($path, $data);
$result = json_decode($response, true);
if (isset($result['data'])) {
if (empty($result['data'])) throw new Exception("网站 {$siteName} 不存在");
$siteId = null;
foreach ($result['data'] as $item) {
if ($item['name'] == $siteName) {
$siteId = $item['id'];
break;
}
}
if (is_null($siteId)) throw new Exception("网站 {$siteName} 不存在");
$path = '/site/set_site_ssl';
$data = [
'siteid' => $siteId,
'status' => 'true',
'sslType' => '',
'cert' => $fullchain,
'key' => $privatekey,
];
$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 : '返回数据解析失败');
}
return true;
} elseif (isset($result['msg'])) {
throw new Exception($result['msg']);
} else {
throw new Exception($response ? $response : '返回数据解析失败');
}
}
private function deployIISSite($domain, $pfx_path, $password = '123456')
{
$path = '/site/set_site_domain_ssl';
$data = [
'domain' => $domain,
'path' => $pfx_path,
'password' => $password,
];
$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, $file = false)
{
$url = $this->url . $path;
$now_time = time();
$headers = [];
if ($file) {
$post_data = [
['name' => 'request_token', 'contents' => md5($now_time . md5($this->key))],
['name' => 'request_time', 'contents' => $now_time],
];
$post_data = array_merge($post_data, $params);
$headers['Content-Type'] = 'multipart/form-data';
} else {
$post_data = [
'request_token' => md5($now_time . md5($this->key)),
'request_time' => $now_time
];
$post_data = array_merge($post_data, $params);
}
$response = http_request($url, $post_data, null, null, $headers, $this->proxy);
return $response['body'];
}
}

View File

@@ -63,20 +63,23 @@ class ctyun implements DeployInterface
}
$this->log('上传证书成功 cert_name=' . $config['cert_name']);
$param = [
'domain' => $config['domain'],
'https_status' => 'on',
'cert_name' => $config['cert_name'],
];
try {
$client->request('POST', '/v1/domain/update-domain', null, $param);
} catch (Exception $e) {
if (strpos($e->getMessage(), '请求已提交,请勿重复操作!') === false) {
throw new Exception($e->getMessage());
foreach (explode(',', $config['domain']) as $domain) {
if (empty($domain)) continue;
$param = [
'domain' => $domain,
'https_status' => 'on',
'cert_name' => $config['cert_name'],
];
try {
$client->request('POST', '/v1/domain/update-domain', null, $param);
} catch (Exception $e) {
if (strpos($e->getMessage(), '请求已提交,请勿重复操作!') === false) {
throw new Exception($e->getMessage());
}
}
}
$this->log('CDN域名 ' . $config['domain'] . ' 部署证书成功!');
$this->log('CDN域名 ' . $domain . ' 部署证书成功!');
}
}
private function deploy_icdn($fullchain, $privatekey, $config)
@@ -98,20 +101,23 @@ class ctyun implements DeployInterface
}
$this->log('上传证书成功 cert_name=' . $config['cert_name']);
$param = [
'domain' => $config['domain'],
'https_status' => 'on',
'cert_name' => $config['cert_name'],
];
try {
$client->request('POST', '/v1/domain/update-domain', null, $param);
} catch (Exception $e) {
if (strpos($e->getMessage(), '请求已提交,请勿重复操作!') === false) {
throw new Exception($e->getMessage());
foreach (explode(',', $config['domain']) as $domain) {
if (empty($domain)) continue;
$param = [
'domain' => $domain,
'https_status' => 'on',
'cert_name' => $config['cert_name'],
];
try {
$client->request('POST', '/v1/domain/update-domain', null, $param);
} catch (Exception $e) {
if (strpos($e->getMessage(), '请求已提交,请勿重复操作!') === false) {
throw new Exception($e->getMessage());
}
}
}
$this->log('CDN域名 ' . $config['domain'] . ' 部署证书成功!');
$this->log('CDN域名 ' . $domain . ' 部署证书成功!');
}
}
private function deploy_accessone($fullchain, $privatekey, $config)
@@ -133,81 +139,87 @@ class ctyun implements DeployInterface
}
$this->log('上传证书成功 cert_name=' . $config['cert_name']);
$param = [
'domain' => $config['domain'],
'product_code' => '020',
];
try {
$result = $client->request('POST', '/ctapi/v1/accessone/domain/config', null, $param);
} catch (Exception $e) {
throw new Exception('查询域名配置失败:' . $e->getMessage());
}
if ($result['https_status'] == 'on' && $result['cert_name'] == $config['cert_name']) {
$this->log('边缘安全加速域名 ' . $config['domain'] . ' 证书已部署,无需重复操作!');
return;
}
$result['https_status'] = 'on';
$result['cert_name'] = $config['cert_name'];
$exclude_keys = ['status', 'area_scope', 'cname', 'insert_date', 'status_date', 'record_status', 'record_num', 'customer_name', 'outlink_replace_filter', 'website_ipv6_access_mark', 'websocket_speed', 'dynamic_config', 'dynamic_ability'];
foreach ($result as $key => $value) {
if (in_array($key, $exclude_keys) || is_array($value) && empty($value)) {
unset($result[$key]);
foreach (explode(',', $config['domain']) as $domain) {
if (empty($domain)) continue;
$param = [
'domain' => $domain,
'product_code' => '020',
];
try {
$result = $client->request('POST', '/ctapi/v1/accessone/domain/config', null, $param);
} catch (Exception $e) {
throw new Exception('查询域名配置失败:' . $e->getMessage());
}
}
if (isset($result['origin'])) {
foreach ($result['origin'] as &$origin) {
$origin['weight'] = strval($origin['weight']);
}
}
try {
$client->request('POST', '/ctapi/v1/scdn/domain/modify_config', null, $result);
} catch (Exception $e) {
if (strpos($e->getMessage(), '请求已提交,请勿重复操作!') === false) {
throw new Exception($e->getMessage());
}
}
$this->log('边缘安全加速域名 ' . $config['domain'] . ' 部署证书成功!');
if ($result['https_status'] == 'on' && $result['cert_name'] == $config['cert_name']) {
$this->log('边缘安全加速域名 ' . $domain . ' 证书已部署,无需重复操作!');
return;
}
$result['https_status'] = 'on';
$result['cert_name'] = $config['cert_name'];
$exclude_keys = ['status', 'area_scope', 'cname', 'insert_date', 'status_date', 'record_status', 'record_num', 'customer_name', 'outlink_replace_filter', 'website_ipv6_access_mark', 'websocket_speed', 'dynamic_config', 'dynamic_ability'];
foreach ($result as $key => $value) {
if (in_array($key, $exclude_keys) || is_array($value) && empty($value)) {
unset($result[$key]);
}
}
if (isset($result['origin'])) {
foreach ($result['origin'] as &$origin) {
$origin['weight'] = strval($origin['weight']);
}
}
try {
$client->request('POST', '/ctapi/v1/scdn/domain/modify_config', null, $result);
} catch (Exception $e) {
if (strpos($e->getMessage(), '请求已提交,请勿重复操作!') === false) {
throw new Exception($e->getMessage());
}
}
$this->log('边缘安全加速域名 ' . $domain . ' 部署证书成功!');
}
}
private function deploy_cf($fullchain, $privatekey, $config)
{
$client = new CtyunClient($this->AccessKeyId, $this->SecretAccessKey, 'cf-global.ctapi.ctyun.cn', $this->proxy);
try {
$data = $client->request('GET', '/openapi/v1/domains/customdomains/' . $config['domain'], null, null, ['regionId' => $config['region_id']]);
} catch (Exception $e) {
throw new Exception('获取自定义域名配置失败:' . $e->getMessage());
}
if (isset($data['certConfig']['certificate']) && trim($data['certConfig']['certificate']) == trim($fullchain)) {
$this->log('函数计算域名 ' . $config['domain'] . ' 证书已部署,无需重复操作!');
return;
}
if ($data['protocol'] == 'HTTP') $data['protocol'] = 'HTTP,HTTPS';
$param = [
'domainName' => $config['domain'],
'description' => $data['description'],
'protocol' => $data['protocol'],
'certConfig' => [
'certName' => 'cert' . substr($config['cert_name'], strpos($config['cert_name'], '-') + 1),
'certificate' => $fullchain,
'privateKey' => $privatekey,
],
'authConfig' => $data['authConfig'],
'routeConfig' => $data['routeConfig'],
];
try {
$client->request('PUT', '/openapi/v1/domains/customdomains/' . $config['domain'], null, $param, ['regionId' => $config['region_id']]);
} catch (Exception $e) {
if (strpos($e->getMessage(), '请求已提交,请勿重复操作!') === false) {
throw new Exception($e->getMessage());
foreach (explode(',', $config['domain']) as $domain) {
if (empty($domain)) continue;
try {
$data = $client->request('GET', '/openapi/v1/domains/customdomains/' . $domain, null, null, ['regionId' => $config['region_id']]);
} catch (Exception $e) {
throw new Exception('获取自定义域名配置失败:' . $e->getMessage());
}
}
$this->log('函数计算域名 ' . $config['domain'] . ' 部署证书成功!');
if (isset($data['certConfig']['certificate']) && trim($data['certConfig']['certificate']) == trim($fullchain)) {
$this->log('函数计算域名 ' . $domain . ' 证书已部署,无需重复操作!');
return;
}
if ($data['protocol'] == 'HTTP') $data['protocol'] = 'HTTP,HTTPS';
$param = [
'domainName' => $domain,
'description' => $data['description'],
'protocol' => $data['protocol'],
'certConfig' => [
'certName' => 'cert' . substr($config['cert_name'], strpos($config['cert_name'], '-') + 1),
'certificate' => $fullchain,
'privateKey' => $privatekey,
],
'authConfig' => $data['authConfig'],
'routeConfig' => $data['routeConfig'],
];
try {
$client->request('PUT', '/openapi/v1/domains/customdomains/' . $domain, null, $param, ['regionId' => $config['region_id']]);
} catch (Exception $e) {
if (strpos($e->getMessage(), '请求已提交,请勿重复操作!') === false) {
throw new Exception($e->getMessage());
}
}
$this->log('函数计算域名 ' . $domain . ' 部署证书成功!');
}
}
public function setLogger($func)

View File

@@ -48,6 +48,8 @@ class huoshan implements DeployInterface
$this->deploy_clb($cert_id, $config);
} elseif ($config['product'] == 'alb') {
$this->deploy_alb($cert_id, $config);
} elseif ($config['product'] == 'vod') {
$this->deploy_vod($cert_id, $config);
}
}
}
@@ -135,6 +137,33 @@ class huoshan implements DeployInterface
}
}
private function deploy_vod($cert_id, $config)
{
if (empty($config['domain'])) throw new Exception('绑定的域名不能为空');
if (empty($config['vod_space_name'])) throw new Exception('点播空间名称不能为空');
if (empty($config['vod_domain_type'])) throw new Exception('点播域名类型不能为空');
$client = new Volcengine($this->AccessKeyId, $this->SecretAccessKey, 'vod.volcengineapi.com', 'vod', '2023-07-01', 'cn-north-1', $this->proxy);
foreach (explode(',', $config['domain']) as $domain) {
if (empty($domain)) continue;
$param = [
'SpaceName' => $config['vod_space_name'],
'DomainType' => $config['vod_domain_type'],
'Domain' => $domain,
'Config' => [
'HTTPS' => [
'Switch' => true,
'CertInfo' => [
'CertId' => $cert_id,
],
],
],
];
$client->request('POST', 'UpdateDomainConfig', $param);
$this->log('视频点播域名 ' . $domain . ' 部署证书成功!');
}
}
private function deploy_imagex($cert_id, $config)
{
if (empty($config['domain'])) throw new Exception('绑定的域名不能为空');

View File

@@ -0,0 +1,375 @@
<?php
namespace app\lib\deploy;
use app\lib\DeployInterface;
use Exception;
class nginxproxymanager implements DeployInterface
{
private $logger;
private $url;
private $email;
private $password;
private $proxy;
private $token;
public function __construct($config)
{
$this->url = rtrim($config['url'] ?? '', '/');
$this->email = trim($config['email'] ?? '');
$this->password = $config['password'] ?? '';
$this->proxy = isset($config['proxy']) && $config['proxy'] == 1;
}
public function check()
{
if (empty($this->url) || empty($this->email) || empty($this->password)) {
throw new Exception('请填写面板地址、登录邮箱和登录密码');
}
$this->login();
$this->request('GET', '/nginx/certificates');
}
public function deploy($fullchain, $privatekey, $config, &$info)
{
$domains = $config['domainList'] ?? [];
$domains = array_values(array_filter(array_map('trim', $domains)));
if (empty($domains)) {
throw new Exception('没有设置要部署的域名');
}
$this->login();
$certificateId = intval($config['id'] ?? 0);
if ($certificateId > 0) {
$this->log('使用配置中的证书ID:' . $certificateId . ' 直接更新 NPM 自定义证书');
$certificate = $this->getCertificate($certificateId);
$this->assertCustomCertificate($certificate, $certificateId);
$this->uploadCertificate($certificateId, $fullchain, $privatekey);
$this->log('证书ID:' . $certificateId . ' 更新成功!');
return;
}
$hostId = intval($config['host_id'] ?? 0);
$hosts = $this->resolveTargetHosts($domains, $hostId);
if (empty($hosts)) {
throw new Exception('未找到匹配的 Proxy Host请填写证书ID或 Proxy Host ID');
}
$this->log('匹配到 Proxy Host ' . count($hosts) . ' 个');
$resolvedCertificateId = 0;
$conflictMessage = null;
foreach ($hosts as $host) {
$hostCertificateId = intval($host['certificate_id'] ?? 0);
if ($hostCertificateId <= 0) {
continue;
}
try {
$certificate = $this->getCertificate($hostCertificateId);
$this->assertCustomCertificate($certificate, $hostCertificateId);
if ($resolvedCertificateId === 0) {
$resolvedCertificateId = $hostCertificateId;
} elseif ($resolvedCertificateId !== $hostCertificateId) {
$conflictMessage = '匹配到多个 Proxy Host但它们绑定了不同的自定义证书ID无法自动决定更新哪个证书请手动填写证书ID';
}
} catch (Exception $e) {
$this->log('Proxy Host ID:' . $host['id'] . ' 当前证书不可直接更新:' . $e->getMessage());
}
}
if ($conflictMessage !== null) {
throw new Exception($conflictMessage);
}
if ($resolvedCertificateId === 0) {
$resolvedCertificateId = $this->createCustomCertificate($domains);
$this->log('创建自定义证书成功证书ID:' . $resolvedCertificateId);
}
$this->uploadCertificate($resolvedCertificateId, $fullchain, $privatekey);
$this->log('证书ID:' . $resolvedCertificateId . ' 更新成功!');
foreach ($hosts as $host) {
$currentCertificateId = intval($host['certificate_id'] ?? 0);
if ($currentCertificateId !== $resolvedCertificateId) {
$this->updateProxyHostCertificate($host, $resolvedCertificateId);
$this->log('Proxy Host ID:' . $host['id'] . ' 已绑定到证书ID:' . $resolvedCertificateId);
} else {
$this->log('Proxy Host ID:' . $host['id'] . ' 已绑定目标证书,无需重复更新绑定');
}
}
$info['config']['id'] = (string)$resolvedCertificateId;
}
public function setLogger($func)
{
$this->logger = $func;
}
private function log($txt)
{
if ($this->logger) {
call_user_func($this->logger, $txt);
}
}
private function login()
{
$data = $this->request('POST', '/tokens', [
'identity' => $this->email,
'secret' => $this->password,
], false, false);
if (empty($data['token'])) {
if (!empty($data['requires_2fa'])) {
throw new Exception('当前 NPM 账户启用了双因素认证,暂不支持');
}
throw new Exception('登录 NPM 失败,未返回访问令牌');
}
$this->token = $data['token'];
}
private function resolveTargetHosts(array $domains, int $hostId): array
{
if ($hostId > 0) {
return [$this->getProxyHost($hostId)];
}
$hosts = $this->request('GET', '/nginx/proxy-hosts');
if (!is_array($hosts)) {
throw new Exception('获取 Proxy Host 列表失败');
}
$matched = [];
foreach ($hosts as $host) {
$hostDomains = $host['domain_names'] ?? [];
if ($this->hasIntersectDomain($domains, $hostDomains)) {
$matched[] = $this->getProxyHost(intval($host['id']));
}
}
return $matched;
}
private function hasIntersectDomain(array $domains, array $hostDomains): bool
{
foreach ($hostDomains as $hostDomain) {
$hostDomain = trim((string)$hostDomain);
if ($hostDomain === '') {
continue;
}
foreach ($domains as $domain) {
if ($this->domainMatches($domain, $hostDomain) || $this->domainMatches($hostDomain, $domain)) {
return true;
}
}
}
return false;
}
private function domainMatches(string $pattern, string $domain): bool
{
$pattern = strtolower(trim($pattern));
$domain = strtolower(trim($domain));
if ($pattern === '' || $domain === '') {
return false;
}
if ($pattern === $domain) {
return true;
}
if (str_starts_with($pattern, '*.')) {
$suffix = substr($pattern, 1);
return str_ends_with($domain, $suffix);
}
return false;
}
private function createCustomCertificate(array $domains): int
{
$result = $this->request('POST', '/nginx/certificates', [
'provider' => 'other',
'nice_name' => $this->buildCertificateName($domains),
]);
if (isset($result['owner_user_id'])) {
$this->log('NPM 新建证书归属用户ID:' . intval($result['owner_user_id']) . '(由当前登录账号决定)');
}
$certificateId = intval($result['id'] ?? 0);
if ($certificateId <= 0) {
throw new Exception('创建 NPM 自定义证书失败');
}
return $certificateId;
}
private function buildCertificateName(array $domains): string
{
return trim($domains[0]);
}
private function uploadCertificate(int $certificateId, string $fullchain, string $privatekey): void
{
[$certificate, $intermediateCertificate] = $this->splitFullchain($fullchain);
$multipart = [
[
'name' => 'certificate',
'filename' => 'certificate.pem',
'contents' => $certificate,
],
[
'name' => 'certificate_key',
'filename' => 'certificate.key',
'contents' => $privatekey,
],
];
if ($intermediateCertificate !== '') {
$multipart[] = [
'name' => 'intermediate_certificate',
'filename' => 'intermediate.pem',
'contents' => $intermediateCertificate,
];
}
$this->request(
'POST',
'/nginx/certificates/' . $certificateId . '/upload',
$multipart,
true,
true,
['Content-Type' => 'multipart/form-data']
);
}
private function splitFullchain(string $fullchain): array
{
preg_match_all('/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/s', $fullchain, $matches);
$certificates = array_values(array_filter(array_map('trim', $matches[0] ?? [])));
if (empty($certificates)) {
throw new Exception('证书内容格式错误,未找到 PEM 证书块');
}
$certificate = $certificates[0] . "\n";
$intermediateCertificate = '';
if (count($certificates) > 1) {
$intermediateCertificate = implode("\n", array_slice($certificates, 1)) . "\n";
}
return [$certificate, $intermediateCertificate];
}
private function updateProxyHostCertificate(array $host, int $certificateId): void
{
$payload = [
'certificate_id' => $certificateId,
];
$this->request('PUT', '/nginx/proxy-hosts/' . intval($host['id']), $payload);
}
private function assertCustomCertificate(array $certificate, int $certificateId): void
{
if (($certificate['provider'] ?? '') !== 'other') {
throw new Exception('证书ID:' . $certificateId . ' 不是自定义证书(provider=other),无法通过上传接口更新');
}
}
private function getCertificate(int $certificateId): array
{
$certificate = $this->request('GET', '/nginx/certificates/' . $certificateId);
if (!is_array($certificate) || empty($certificate['id'])) {
throw new Exception('证书ID:' . $certificateId . ' 不存在');
}
return $certificate;
}
private function getProxyHost(int $hostId): array
{
$host = $this->request('GET', '/nginx/proxy-hosts/' . $hostId);
if (!is_array($host) || empty($host['id'])) {
throw new Exception('Proxy Host ID:' . $hostId . ' 不存在');
}
$this->log('读取 Proxy Host ID:' . intval($host['id']) . ' owner_user_id:' . intval($host['owner_user_id'] ?? 0) . ' certificate_id:' . intval($host['certificate_id'] ?? 0));
return $host;
}
private function request(string $method, string $path, $params = null, bool $auth = true, bool $logBodyOnError = true, array $extraHeaders = [])
{
$headers = $extraHeaders;
if (!isset($headers['Content-Type']) && $params !== null && strtoupper($method) !== 'GET') {
$headers['Content-Type'] = 'application/json';
}
if ($auth) {
if (empty($this->token)) {
throw new Exception('NPM 访问令牌不存在,请先登录');
}
$headers['Authorization'] = 'Bearer ' . $this->token;
}
$requestData = $params;
if ($params !== null && isset($headers['Content-Type']) && strtolower($headers['Content-Type']) !== 'multipart/form-data') {
$requestData = json_encode($params, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$response = http_request(
$this->url . '/api' . $path,
$requestData,
null,
null,
$headers,
$this->proxy,
$method,
30
);
$body = $response['body'] ?? '';
$result = json_decode($body, true);
if ($response['code'] >= 200 && $response['code'] < 300) {
return $result;
}
if ($logBodyOnError && $body !== '') {
$this->log('Response:' . $body);
}
if (isset($result['error']['message'])) {
throw new Exception($result['error']['message']);
}
if (isset($result['message'])) {
throw new Exception($result['message']);
}
if (isset($result['error']) && is_string($result['error']) && $result['error'] !== '') {
throw new Exception($result['error']);
}
if ($body !== '') {
throw new Exception('请求失败(httpCode=' . $response['code'] . '): ' . $this->truncateResponseBody($body));
}
throw new Exception('请求失败(httpCode=' . $response['code'] . ')');
}
private function truncateResponseBody(string $body): string
{
$body = trim($body);
if ($body === '') {
return '';
}
if (mb_strlen($body) > 300) {
return mb_substr($body, 0, 300) . '...';
}
return $body;
}
}

View File

@@ -144,7 +144,7 @@ class opanel implements DeployInterface
$domains = $config['domainList'];
if (empty($domains)) throw new Exception('没有设置要部署的域名');
$params = ['page' => 1, 'pageSize' => 500];
$params = ['page' => 1, 'pageSize' => 500, 'orderBy' => 'expire_date', 'order' => 'null'];
try {
$data = $this->request("/websites/ssl/search", $params, $nodeName);
$logMsg = $nodeName ? "节点 [{$nodeName}] " : "";

View File

@@ -0,0 +1,221 @@
<?php
namespace app\lib\deploy;
use app\lib\DeployInterface;
use Exception;
class s3storage implements DeployInterface
{
private $logger;
private $AccessKeyId;
private $SecretAccessKey;
private $endpoint;
private $region;
private $proxy;
public function __construct($config)
{
$this->AccessKeyId = $config['AccessKeyId'];
$this->SecretAccessKey = $config['SecretAccessKey'];
$this->endpoint = rtrim($config['endpoint'], '/');
$this->region = !empty($config['region']) ? $config['region'] : 'us-east-1';
$this->proxy = isset($config['proxy']) ? $config['proxy'] == 1 : false;
}
public function check()
{
if (empty($this->AccessKeyId) || empty($this->SecretAccessKey) || empty($this->endpoint)) {
throw new Exception('必填参数不能为空');
}
$this->s3Request('GET', '/', '', null);
return true;
}
public function deploy($fullchain, $privatekey, $config, &$info)
{
$bucket = $config['bucket'];
if (empty($bucket)) throw new Exception('存储桶名称不能为空');
$certPath = trim($config['cert_path'], '/');
$keyPath = trim($config['key_path'], '/');
if (empty($certPath) || empty($keyPath)) throw new Exception('证书和私钥保存路径不能为空');
$this->putObject($bucket, $certPath, $fullchain);
$this->log("证书已上传到s3://{$bucket}/{$certPath}");
$this->putObject($bucket, $keyPath, $privatekey);
$this->log("私钥已上传到s3://{$bucket}/{$keyPath}");
}
private function putObject($bucket, $key, $content)
{
$path = '/' . $bucket . '/' . $key;
$this->s3Request('PUT', $path, $content, 'application/x-pem-file');
}
private function s3Request($method, $path, $body, $contentType)
{
$time = time();
$date = gmdate("Ymd\THis\Z", $time);
$shortDate = gmdate("Ymd", $time);
$host = preg_replace('#^https?://#', '', $this->endpoint);
$scheme = (strpos($this->endpoint, 'https://') === 0) ? 'https' : 'http';
if (strpos($this->endpoint, '://') === false) {
$scheme = 'https';
}
$payloadHash = hash('sha256', $body ?? '');
$headers = [
'Host' => $host,
'X-Amz-Date' => $date,
'X-Amz-Content-Sha256' => $payloadHash,
];
if ($contentType) {
$headers['Content-Type'] = $contentType;
}
$authorization = $this->generateSign($method, $path, [], $headers, $body ?? '', $date, $shortDate);
$headers['Authorization'] = $authorization;
$url = $scheme . '://' . $host . $path;
$headerArr = [];
foreach ($headers as $k => $v) {
$headerArr[] = $k . ': ' . $v;
}
$ch = curl_init($url);
if ($this->proxy) {
curl_set_proxy($ch);
}
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headerArr);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
if ($body !== null && $body !== '') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$response = curl_exec($ch);
$errno = curl_errno($ch);
if ($errno) {
$errmsg = curl_error($ch);
curl_close($ch);
throw new Exception('Curl error: ' . $errmsg);
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 200 && $httpCode < 300) {
return $response;
}
$errmsg = 'HTTP Code: ' . $httpCode;
if ($response) {
LIBXML_VERSION < 20900 && libxml_disable_entity_loader(true);
$xml = @simplexml_load_string($response);
if ($xml && isset($xml->Message)) {
$errmsg = (string)$xml->Message;
} elseif ($xml && isset($xml->Error->Message)) {
$errmsg = (string)$xml->Error->Message;
}
}
throw new Exception($errmsg);
}
private function generateSign($method, $path, $query, $headers, $body, $date, $shortDate)
{
$algorithm = 'AWS4-HMAC-SHA256';
$canonicalUri = $this->getCanonicalURI($path);
$canonicalQueryString = $this->getCanonicalQueryString($query);
[$canonicalHeaders, $signedHeaders] = $this->getCanonicalHeaders($headers);
$hashedPayload = hash('sha256', $body);
$canonicalRequest = $method . "\n"
. $canonicalUri . "\n"
. $canonicalQueryString . "\n"
. $canonicalHeaders . "\n"
. $signedHeaders . "\n"
. $hashedPayload;
$credentialScope = $shortDate . '/' . $this->region . '/s3/aws4_request';
$stringToSign = $algorithm . "\n"
. $date . "\n"
. $credentialScope . "\n"
. hash('sha256', $canonicalRequest);
$kDate = hash_hmac('sha256', $shortDate, 'AWS4' . $this->SecretAccessKey, true);
$kRegion = hash_hmac('sha256', $this->region, $kDate, true);
$kService = hash_hmac('sha256', 's3', $kRegion, true);
$kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);
$signature = hash_hmac('sha256', $stringToSign, $kSigning);
return $algorithm . ' Credential=' . $this->AccessKeyId . '/' . $credentialScope
. ', SignedHeaders=' . $signedHeaders
. ', Signature=' . $signature;
}
private function escape($str)
{
$search = ['+', '*', '%7E'];
$replace = ['%20', '%2A', '~'];
return str_replace($search, $replace, urlencode($str));
}
private function getCanonicalURI($path)
{
if (empty($path)) return '/';
$parts = explode('/', $path);
$parts = array_map(function ($item) {
return $this->escape($item);
}, $parts);
return implode('/', $parts);
}
private function getCanonicalQueryString($parameters)
{
if (empty($parameters)) return '';
ksort($parameters);
$pairs = [];
foreach ($parameters as $key => $value) {
$pairs[] = $this->escape($key) . '=' . $this->escape($value);
}
return implode('&', $pairs);
}
private function getCanonicalHeaders($oldHeaders)
{
$headers = [];
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];
}
public function setLogger($func)
{
$this->logger = $func;
}
private function log($txt)
{
if ($this->logger) {
call_user_func($this->logger, $txt);
}
}
}

View File

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

View File

@@ -25,6 +25,27 @@ class cloudflare implements DnsInterface
$this->auth = isset($config['auth']) ? intval($config['auth']) : (preg_match('/^[0-9a-f]+$/i', $this->ApiKey) ? 0 : 1);
}
/**
* 从 Cloudflare API 返回的完整域名中提取子域名(主机记录)
* 兼容 Emoji/IDN 域名Cloudflare API 返回 Punycode 格式,数据库存储 UTF-8
*/
private function extractName($fullName)
{
$domainAscii = idn_to_ascii($this->domain, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);
if ($domainAscii === false) $domainAscii = $this->domain;
if ($fullName === $domainAscii || $fullName === $this->domain) {
return '@';
}
if (str_ends_with($fullName, '.' . $domainAscii)) {
return substr($fullName, 0, -(strlen($domainAscii) + 1));
}
if (str_ends_with($fullName, '.' . $this->domain)) {
return substr($fullName, 0, -(strlen($this->domain) + 1));
}
return $fullName;
}
public function getError()
{
return $this->error;
@@ -66,8 +87,9 @@ class cloudflare implements DnsInterface
if (!isNullOrEmpty($Value)) $KeyWord = $Value;
$param = ['type' => $Type, 'search' => $KeyWord, 'page' => $PageNumber, 'per_page' => $PageSize];
if (!isNullOrEmpty($SubDomain)) {
if ($SubDomain == '@') $SubDomain = $this->domain;
else $SubDomain .= '.' . $this->domain;
$domainAscii = idn_to_ascii($this->domain, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46) ?: $this->domain;
if ($SubDomain == '@') $SubDomain = $domainAscii;
else $SubDomain .= '.' . $domainAscii;
$param['name'] = $SubDomain;
}
if (!isNullOrEmpty($Line)) {
@@ -77,8 +99,9 @@ class cloudflare implements DnsInterface
if ($data) {
$list = [];
foreach ($data['result'] as $row) {
$name = $this->domain == $row['name'] ? '@' : substr($row['name'], 0, -(strlen($this->domain) + 1));
$name = $this->extractName($row['name']);
$status = str_ends_with($name, '_pause') ? '0' : '1';
$name = $name == '__root__' ? '@' : $name;
$name = $status == '0' ? substr($name, 0, -6) : $name;
if ($row['type'] == 'SRV' && isset($row['priority'])) {
$row['content'] = $row['priority'] . ' ' . $row['content'];
@@ -114,9 +137,10 @@ class cloudflare implements DnsInterface
{
$data = $this->send_reuqest('GET', '/zones/'.$this->domainid.'/dns_records/'.$RecordId);
if ($data) {
$name = $this->domain == $data['result']['name'] ? '@' : substr($data['result']['name'], 0, -(strlen($this->domain) + 1));
$name = $this->extractName($data['result']['name']);
$status = str_ends_with($name, '_pause') ? '0' : '1';
$name = $status == '0' ? substr($name, 0, -6) : $name;
$name = $name == '__root__' ? '@' : $name;
if ($data['result']['type'] == 'SRV' && isset($data['result']['priority'])) {
$data['result']['content'] = $data['result']['priority'] . ' ' . $data['result']['content'];
}
@@ -182,6 +206,12 @@ class cloudflare implements DnsInterface
{
$info = $this->getDomainRecordInfo($RecordId);
$Name = $Status == '1' ? str_replace('_pause', '', $info['Name']) : $info['Name'] . '_pause';
// @ 作为特殊字符不能设置为解析, 故设置暂停解析的时候, 替换为 __root__
if ($Name == '__root__') {
$Name = '@';
} elseif ($Name == '@_pause') {
$Name = '__root___pause';
}
return $this->updateDomainRecord($RecordId, $Name, $info['Type'], $info['Value'], $info['Line'], $info['TTL'], $info['MX'], $info['Weight'], $info['Remark']);
}

282
app/lib/dns/dnsmgr.php Normal file
View File

@@ -0,0 +1,282 @@
<?php
namespace app\lib\dns;
use app\lib\DnsInterface;
use Exception;
class dnsmgr implements DnsInterface
{
private $uid;
private $key;
private $baseUrl;
private $error;
private $domain;
private $domainid;
private $proxy;
private $domainInfo;
public function __construct($config)
{
$this->uid = $config['uid'];
$this->key = $config['key'];
$this->baseUrl = rtrim($config['base_url'], '/');
$proxy = isset($config['proxy']) ? $config['proxy'] == 1 : false;
$this->proxy = $proxy;
$this->domain = $config['domain'];
$this->domainid = $config['domainid'];
}
public function getError()
{
return $this->error;
}
public function check()
{
if ($this->getDomainList() != false) {
return true;
}
return false;
}
public function getDomainList($KeyWord = null, $PageNumber = 1, $PageSize = 20)
{
$offset = ($PageNumber - 1) * $PageSize;
$param = [
'offset' => $offset,
'limit' => $PageSize,
];
if (!isNullOrEmpty($KeyWord)) {
$param['kw'] = $KeyWord;
}
$data = $this->send_request('/api/domain', $param);
if ($data && isset($data['rows'])) {
$list = [];
foreach ($data['rows'] as $row) {
$list[] = [
'DomainId' => $row['id'],
'Domain' => $row['name'],
'RecordCount' => $row['recordcount'],
];
}
return ['total' => $data['total'], 'list' => $list];
}
return false;
}
public function getDomainRecords($PageNumber = 1, $PageSize = 20, $KeyWord = null, $SubDomain = null, $Value = null, $Type = null, $Line = null, $Status = null)
{
$offset = ($PageNumber - 1) * $PageSize;
$param = [
'offset' => $offset,
'limit' => $PageSize,
];
if (!isNullOrEmpty($KeyWord)) $param['keyword'] = $KeyWord;
if (!isNullOrEmpty($SubDomain)) $param['subdomain'] = $SubDomain;
if (!isNullOrEmpty($Value)) $param['value'] = $Value;
if (!isNullOrEmpty($Type)) $param['type'] = $Type;
if (!isNullOrEmpty($Line)) $param['line'] = $Line;
if (!isNullOrEmpty($Status)) $param['status'] = $Status;
$data = $this->send_request('/api/record/data/' . $this->domainid, $param);
if ($data && isset($data['rows'])) {
$list = [];
foreach ($data['rows'] as $row) {
$list[] = [
'RecordId' => $row['RecordId'],
'Domain' => $row['Domain'],
'Name' => $row['Name'],
'Type' => $row['Type'],
'Value' => $row['Value'],
'Line' => $row['Line'],
'LineName' => $row['LineName'],
'TTL' => $row['TTL'],
'MX' => $row['MX'],
'Status' => $row['Status'],
'Weight' => $row['Weight'],
'Remark' => $row['Remark'],
'UpdateTime' => $row['UpdateTime'],
];
}
return ['total' => $data['total'], 'list' => $list];
}
return false;
}
public function getSubDomainRecords($SubDomain, $PageNumber = 1, $PageSize = 20, $Type = null, $Line = null)
{
if ($SubDomain == '') $SubDomain = '@';
return $this->getDomainRecords($PageNumber, $PageSize, null, $SubDomain, null, $Type, $Line);
}
public function getDomainRecordInfo($RecordId)
{
return false;
}
public function addDomainRecord($Name, $Type, $Value, $Line = 'default', $TTL = 600, $MX = 1, $Weight = null, $Remark = null)
{
$param = [
'name' => $Name,
'type' => $Type,
'value' => $Value,
'line' => $Line,
'ttl' => intval($TTL),
];
if ($Type == 'MX' && !isNullOrEmpty($MX)) {
$param['mx'] = intval($MX);
}
if (!isNullOrEmpty($Weight)) {
$param['weight'] = intval($Weight);
}
if (!isNullOrEmpty($Remark)) {
$param['remark'] = $Remark;
}
$data = $this->send_request('/api/record/add/' . $this->domainid, $param);
return $data !== false;
}
public function updateDomainRecord($RecordId, $Name, $Type, $Value, $Line = 'default', $TTL = 600, $MX = 1, $Weight = null, $Remark = null)
{
$param = [
'recordid' => $RecordId,
'name' => $Name,
'type' => $Type,
'value' => $Value,
'line' => $Line,
'ttl' => intval($TTL),
];
if ($Type == 'MX' && !isNullOrEmpty($MX)) {
$param['mx'] = intval($MX);
}
if (!isNullOrEmpty($Weight)) {
$param['weight'] = intval($Weight);
}
if (!isNullOrEmpty($Remark)) {
$param['remark'] = $Remark;
}
$data = $this->send_request('/api/record/update/' . $this->domainid, $param);
return $data !== false;
}
public function updateDomainRecordRemark($RecordId, $Remark)
{
$param = [
'recordid' => $RecordId,
'remark' => $Remark,
];
$data = $this->send_request('/api/record/remark/' . $this->domainid, $param);
return $data !== false;
}
public function deleteDomainRecord($RecordId)
{
$param = [
'recordid' => $RecordId,
];
$data = $this->send_request('/api/record/delete/' . $this->domainid, $param);
return $data !== false;
}
public function setDomainRecordStatus($RecordId, $Status)
{
$param = [
'recordid' => $RecordId,
'status' => $Status,
];
$data = $this->send_request('/api/record/status/' . $this->domainid, $param);
return $data !== false;
}
public function getDomainRecordLog($PageNumber = 1, $PageSize = 20, $KeyWord = null, $StartDate = null, $endDate = null)
{
return false;
}
public function getRecordLine()
{
$data = $this->getDomainInfo();
if ($data && isset($data['recordLine'])) {
$list = [];
foreach ($data['recordLine'] as $row) {
$list[$row['id']] = [
'name' => $row['name'],
'parent' => isset($row['parent']) ? $row['parent'] : null,
];
}
return $list;
}
return false;
}
public function getMinTTL()
{
$data = $this->getDomainInfo();
if ($data && isset($data['minTTL'])) {
return $data['minTTL'];
}
return false;
}
public function getDomainInfo()
{
if (!empty($this->domainInfo)) return $this->domainInfo;
$data = $this->send_request('/api/domain/' . $this->domainid, ['loginurl' => 0]);
if ($data) {
$this->domainInfo = $data;
return $data;
}
return false;
}
public function addDomain($Domain)
{
return false;
}
private function send_request($path, $param = [])
{
try {
$timestamp = (string)time();
$signStr = $this->uid . $timestamp . $this->key;
$sign = md5($signStr);
$url = $this->baseUrl . $path;
$param['uid'] = $this->uid;
$param['timestamp'] = $timestamp;
$param['sign'] = $sign;
$postData = http_build_query($param);
$response = http_request($url, $postData, null, null, null, $this->proxy);
$result = json_decode($response['body'], true);
if (isset($result['code']) && $result['code'] == 0) {
return isset($result['data']) ? $result['data'] : null;
} elseif (isset($result['rows']) && isset($result['total'])) {
return $result;
} elseif (isset($result['msg'])) {
$this->setError($result['msg']);
return false;
} else {
$this->setError($response['body']);
return false;
}
} catch (Exception $e) {
$this->setError($e->getMessage());
return false;
}
}
private function setError($message)
{
$this->error = $message;
}
}

View File

@@ -327,6 +327,44 @@ class dnspod implements DnsInterface
return false;
}
//域名别名列表
public function domainAliasList()
{
$action = 'DescribeDomainAliasList';
$param = [
'Domain' => $this->domain,
];
$data = $this->send_request($action, $param);
if ($data) {
return $data['DomainAliasList'];
}
return false;
}
//添加域名别名
public function addDomainAlias($alias)
{
$action = 'CreateDomainAlias';
$param = [
'Domain' => $this->domain,
'DomainAlias' => $alias,
];
$data = $this->send_request($action, $param);
return is_array($data);
}
//删除域名别名
public function deleteDomainAlias($id)
{
$action = 'DeleteDomainAlias';
$param = [
'Domain' => $this->domain,
'DomainAliasId' => $id,
];
$data = $this->send_request($action, $param);
return is_array($data);
}
private function convertLineCode($line)
{
$convert_dict = ['default' => '0', 'unicom' => '10=1', 'telecom' => '10=0', 'mobile' => '10=3', 'edu' => '10=2', 'oversea' => '3=0', 'btvn' => '10=22', 'search' => '80=0', 'internal' => '7=0'];

View File

@@ -63,7 +63,7 @@ class huawei implements DnsInterface
public function getDomainRecords($PageNumber = 1, $PageSize = 20, $KeyWord = null, $SubDomain = null, $Value = null, $Type = null, $Line = null, $Status = null)
{
$offset = ($PageNumber - 1) * $PageSize;
$query = ['type' => $Type, 'line_id' => $Line, 'name' => $KeyWord, 'offset' => $offset, 'limit' => $PageSize];
$query = ['type' => $Type, 'line_id' => $Line, 'name' => $KeyWord, 'offset' => $offset, 'limit' => $PageSize, 'records' => $Value];
if (!isNullOrEmpty($Status)) {
$Status = $Status == '1' ? 'ACTIVE' : 'DISABLE';
$query['status'] = $Status;
@@ -79,7 +79,6 @@ class huawei implements DnsInterface
foreach ($data['recordsets'] as $row) {
$name = substr($row['name'], 0, -(strlen($row['zone_name']) + 1));
if ($name == '') $name = '@';
if ($row['type'] == 'MX') list($row['mx'], $row['records']) = explode(' ', $row['records'][0]);
$list[] = [
'RecordId' => $row['id'],
'Domain' => rtrim($row['zone_name'], '.'),
@@ -113,7 +112,6 @@ class huawei implements DnsInterface
if ($data) {
$name = substr($data['name'], 0, -(strlen($data['zone_name']) + 1));
if ($name == '') $name = '@';
if ($data['type'] == 'MX') list($data['mx'], $data['records']) = explode(' ', $data['records'][0]);
return [
'RecordId' => $data['id'],
'Domain' => rtrim($data['zone_name'], '.'),
@@ -139,7 +137,6 @@ class huawei implements DnsInterface
if ($Type == 'TXT' && substr($Value, 0, 1) != '"') $Value = '"' . $Value . '"';
$records = array_reverse(explode(',', $Value));
$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_request('POST', '/v2.1/zones/'.$this->domainid.'/recordsets', null, $params);
return is_array($data) ? $data['id'] : false;
@@ -152,7 +149,6 @@ class huawei implements DnsInterface
if ($Type == 'TXT' && substr($Value, 0, 1) != '"') $Value = '"' . $Value . '"';
$records = array_reverse(explode(',', $Value));
$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_request('PUT', '/v2.1/zones/'.$this->domainid.'/recordsets/'.$RecordId, null, $params);
return is_array($data);

499
app/lib/dns/technitium.php Normal file
View File

@@ -0,0 +1,499 @@
<?php
namespace app\lib\dns;
use app\lib\DnsInterface;
use Exception;
class technitium implements DnsInterface
{
private $url;
private $token;
private $error;
private $domain;
private $domainid;
private $proxy;
function __construct($config)
{
$this->url = rtrim($config['url'], '/') . '/api';
$this->token = $config['token'];
$this->proxy = isset($config['proxy']) ? $config['proxy'] == 1 : false;
$this->domain = $config['domain'];
$this->domainid = $config['domainid'];
}
public function getError()
{
return $this->error;
}
public function check()
{
if ($this->getDomainList() !== false) {
return true;
}
return false;
}
public function getDomainList($KeyWord = null, $PageNumber = 1, $PageSize = 20)
{
$data = $this->send_request('GET', '/zones/list');
if ($data && isset($data['response']['zones'])) {
$list = [];
foreach ($data['response']['zones'] as $zone) {
$list[] = [
'DomainId' => $zone['name'],
'Domain' => $zone['name'],
'RecordCount' => 0,
];
}
if (!isNullOrEmpty($KeyWord)) {
$list = array_values(array_filter($list, function ($v) use ($KeyWord) {
return strpos($v['Domain'], $KeyWord) !== false;
}));
}
return ['total' => count($list), 'list' => $list];
}
return false;
}
public function getDomainRecords($PageNumber = 1, $PageSize = 20, $KeyWord = null, $SubDomain = null, $Value = null, $Type = null, $Line = null, $Status = null)
{
$params = ['domain' => $this->domain, 'listZone' => 'true'];
$data = $this->send_request('GET', '/zones/records/get', $params);
if ($data && isset($data['response']['records'])) {
$list = [];
$records = $data['response']['records'];
foreach ($records as $i => &$row) {
$row['id'] = $i;
$name = $row['name'] == $this->domain ? '@' : str_replace('.' . $this->domain, '', $row['name']);
$value = '';
$mx = null;
$rData = $row['rData'];
if ($row['type'] == 'A' || $row['type'] == 'AAAA') {
$value = isset($rData['ipAddress']) ? $rData['ipAddress'] : '';
} elseif ($row['type'] == 'CNAME') {
$value = isset($rData['cname']) ? $rData['cname'] : '';
} elseif ($row['type'] == 'NS') {
$value = isset($rData['nameServer']) ? $rData['nameServer'] : '';
} elseif ($row['type'] == 'MX') {
$value = isset($rData['exchange']) ? $rData['exchange'] : '';
$mx = isset($rData['preference']) ? $rData['preference'] : 1;
} elseif ($row['type'] == 'TXT') {
$value = isset($rData['text']) ? $rData['text'] : '';
} elseif ($row['type'] == 'SRV') {
$value = (isset($rData['priority']) ? $rData['priority'] : 0) . ' ' . (isset($rData['weight']) ? $rData['weight'] : 0) . ' ' . (isset($rData['port']) ? $rData['port'] : 0) . ' ' . (isset($rData['target']) ? $rData['target'] : '');
} elseif ($row['type'] == 'PTR') {
$value = isset($rData['ptrName']) ? $rData['ptrName'] : '';
} elseif ($row['type'] == 'CAA') {
$value = (isset($rData['flags']) ? $rData['flags'] : 0) . ' ' . (isset($rData['tag']) ? $rData['tag'] : '') . ' "' . (isset($rData['value']) ? $rData['value'] : '') . '"';
} elseif ($row['type'] == 'ANAME') {
$value = isset($rData['aname']) ? $rData['aname'] : '';
} elseif ($row['type'] == 'DNAME') {
$value = isset($rData['dname']) ? $rData['dname'] : '';
} elseif ($row['type'] == 'APP') {
$value = (isset($rData['appName']) ? $rData['appName'] : '') . ' ' . (isset($rData['classPath']) ? $rData['classPath'] : '');
if (!empty($rData['recordData'])) {
$value .= ' ' . $rData['recordData'];
}
}
$list[] = [
'RecordId' => $i,
'Domain' => $this->domain,
'Name' => $name,
'Type' => $row['type'],
'Value' => $value,
'Line' => 'default',
'TTL' => $row['ttl'],
'MX' => $mx,
'Status' => $row['disabled'] ? '0' : '1',
'Weight' => null,
'Remark' => isset($row['comments']) ? $row['comments'] : null,
'UpdateTime' => null,
];
}
cache('technitium_' . $this->domain, $records, 86400);
if (!isNullOrEmpty($SubDomain)) {
$list = array_values(array_filter($list, function ($v) use ($SubDomain) {
return strcasecmp($v['Name'], $SubDomain) === 0;
}));
} else {
if (!isNullOrEmpty($KeyWord)) {
$list = array_values(array_filter($list, function ($v) use ($KeyWord) {
return strpos($v['Name'], $KeyWord) !== false || strpos($v['Value'], $KeyWord) !== false;
}));
}
if (!isNullOrEmpty($Value)) {
$list = array_values(array_filter($list, function ($v) use ($Value) {
return $v['Value'] == $Value;
}));
}
if (!isNullOrEmpty($Type)) {
$list = array_values(array_filter($list, function ($v) use ($Type) {
return $v['Type'] == $Type;
}));
}
if (!isNullOrEmpty($Status)) {
$list = array_values(array_filter($list, function ($v) use ($Status) {
return $v['Status'] == $Status;
}));
}
}
return ['total' => count($list), 'list' => $list];
}
return false;
}
public function getSubDomainRecords($SubDomain, $PageNumber = 1, $PageSize = 20, $Type = null, $Line = null)
{
return $this->getDomainRecords($PageNumber, $PageSize, null, $SubDomain, null, $Type, $Line);
}
public function getDomainRecordInfo($RecordId)
{
return false;
}
private function buildRecordParams($Type, $Value, $MX = 1)
{
$params = [];
if ($Type == 'A' || $Type == 'AAAA') {
$params['ipAddress'] = $Value;
} elseif ($Type == 'CNAME') {
$params['cname'] = $Value;
} elseif ($Type == 'NS') {
$params['nameServer'] = $Value;
} elseif ($Type == 'MX') {
$params['exchange'] = $Value;
$params['preference'] = intval($MX);
} elseif ($Type == 'TXT') {
$params['text'] = $Value;
} elseif ($Type == 'SRV') {
$parts = explode(' ', $Value);
if (count($parts) == 4) {
$params['priority'] = $parts[0];
$params['weight'] = $parts[1];
$params['port'] = $parts[2];
$params['target'] = $parts[3];
}
} elseif ($Type == 'PTR') {
$params['ptrName'] = $Value;
} elseif ($Type == 'CAA') {
$parts = explode(' ', $Value, 3);
if (count($parts) == 3) {
$params['flags'] = $parts[0];
$params['tag'] = $parts[1];
$params['value'] = trim($parts[2], '"');
}
} elseif ($Type == 'ANAME') {
$params['aname'] = $Value;
} elseif ($Type == 'DNAME') {
$params['dname'] = $Value;
} elseif ($Type == 'APP') {
$parts = explode(' ', $Value, 3);
if (count($parts) >= 2) {
$params['appName'] = $parts[0];
$params['classPath'] = $parts[1];
$params['recordData'] = rtrim(isset($parts[2]) ? $parts[2] : '');
} else {
$params['appName'] = rtrim($Value);
}
}
return $params;
}
private function getOldValueParams($Type, $rData)
{
$params = [];
if ($Type == 'A' || $Type == 'AAAA') {
$params['ipAddress'] = isset($rData['ipAddress']) ? $rData['ipAddress'] : '';
} elseif ($Type == 'CNAME') {
$params['cname'] = isset($rData['cname']) ? $rData['cname'] : '';
} elseif ($Type == 'NS') {
$params['nameServer'] = isset($rData['nameServer']) ? $rData['nameServer'] : '';
} elseif ($Type == 'MX') {
$params['exchange'] = isset($rData['exchange']) ? $rData['exchange'] : '';
$params['preference'] = isset($rData['preference']) ? $rData['preference'] : 1;
} elseif ($Type == 'TXT') {
$params['text'] = isset($rData['text']) ? $rData['text'] : '';
} elseif ($Type == 'SRV') {
$params['priority'] = isset($rData['priority']) ? $rData['priority'] : 0;
$params['weight'] = isset($rData['weight']) ? $rData['weight'] : 0;
$params['port'] = isset($rData['port']) ? $rData['port'] : 0;
$params['target'] = isset($rData['target']) ? $rData['target'] : '';
} elseif ($Type == 'PTR') {
$params['ptrName'] = isset($rData['ptrName']) ? $rData['ptrName'] : '';
} elseif ($Type == 'CAA') {
$params['flags'] = isset($rData['flags']) ? $rData['flags'] : 0;
$params['tag'] = isset($rData['tag']) ? $rData['tag'] : '';
$params['value'] = isset($rData['value']) ? $rData['value'] : '';
} elseif ($Type == 'ANAME') {
$params['aname'] = isset($rData['aname']) ? $rData['aname'] : '';
} elseif ($Type == 'DNAME') {
$params['dname'] = isset($rData['dname']) ? $rData['dname'] : '';
} elseif ($Type == 'APP') {
$params['appName'] = isset($rData['appName']) ? $rData['appName'] : '';
$params['classPath'] = isset($rData['classPath']) ? $rData['classPath'] : '';
if (!empty($rData['recordData'])) {
$params['recordData'] = $rData['recordData'];
}
}
return $params;
}
private function getNewValueParams($Type, $Value, $MX = 1)
{
$params = [];
if ($Type == 'A' || $Type == 'AAAA') {
$params['newIpAddress'] = $Value;
} elseif ($Type == 'CNAME') {
$params['newCname'] = $Value;
} elseif ($Type == 'NS') {
$params['newNameServer'] = $Value;
} elseif ($Type == 'MX') {
$params['newExchange'] = $Value;
$params['newPreference'] = intval($MX);
} elseif ($Type == 'TXT') {
$params['newText'] = $Value;
} elseif ($Type == 'SRV') {
$parts = explode(' ', $Value);
if (count($parts) == 4) {
$params['newPriority'] = $parts[0];
$params['newWeight'] = $parts[1];
$params['newPort'] = $parts[2];
$params['newTarget'] = $parts[3];
}
} elseif ($Type == 'PTR') {
$params['newPtrName'] = $Value;
} elseif ($Type == 'CAA') {
$parts = explode(' ', $Value, 3);
if (count($parts) == 3) {
$params['newFlags'] = $parts[0];
$params['newTag'] = $parts[1];
$params['newValue'] = trim($parts[2], '"');
}
} elseif ($Type == 'ANAME') {
$params['newAName'] = $Value;
} elseif ($Type == 'DNAME') {
$params['newDName'] = $Value;
} elseif ($Type == 'APP') {
$parts = explode(' ', $Value, 3);
if (count($parts) >= 2) {
$params['appName'] = $parts[0];
$params['classPath'] = $parts[1];
$params['recordData'] = rtrim(isset($parts[2]) ? $parts[2] : '');
} else {
$params['appName'] = rtrim($Value);
}
}
return $params;
}
public function addDomainRecord($Name, $Type, $Value, $Line = 'default', $TTL = 600, $MX = 1, $Weight = null, $Remark = null)
{
$domain = $Name == '@' ? $this->domain : $Name . '.' . $this->domain;
$params = [
'domain' => $domain,
'zone' => $this->domain,
'type' => $Type,
'ttl' => intval($TTL)
];
if (!isNullOrEmpty($Remark)) {
$params['comments'] = $Remark;
}
$valParams = $this->buildRecordParams($Type, $Value, $MX);
if (empty($valParams) && $Type != 'SOA') {
$this->setError('不受支持的记录类型或参数解析失败');
return false;
}
$params = array_merge($params, $valParams);
$result = $this->send_request('POST', '/zones/records/add', $params);
return $result !== false;
}
public function updateDomainRecord($RecordId, $Name, $Type, $Value, $Line = 'default', $TTL = 600, $MX = 1, $Weight = null, $Remark = null)
{
$records = cache('technitium_' . $this->domain);
if (!$records || !isset($records[$RecordId])) {
$this->setError('记录不存在,请刷新页面重试');
return false;
}
$oldRecord = $records[$RecordId];
$domain = $oldRecord['name'];
$newDomain = $Name == '@' ? $this->domain : $Name . '.' . $this->domain;
if ($oldRecord['type'] == 'APP') {
$oldValue = (isset($oldRecord['rData']['appName']) ? $oldRecord['rData']['appName'] : '') . ' ' . (isset($oldRecord['rData']['classPath']) ? $oldRecord['rData']['classPath'] : '');
if (!empty($oldRecord['rData']['recordData'])) {
$oldValue .= ' ' . $oldRecord['rData']['recordData'];
}
if ($oldValue != rtrim($Value) || $domain != $newDomain) {
$this->deleteDomainRecord($RecordId);
return $this->addDomainRecord($Name, $Type, $Value, $Line, $TTL, $MX, $Weight, $Remark);
}
}
$params = [
'domain' => $domain,
'zone' => $this->domain,
'type' => $oldRecord['type'],
'ttl' => intval($TTL),
];
if ($domain != $newDomain) {
$params['newDomain'] = $newDomain;
}
$params['comments'] = empty($Remark) ? "" : $Remark;
$oldValParams = $this->getOldValueParams($oldRecord['type'], $oldRecord['rData']);
$newValParams = $this->getNewValueParams($Type, $Value, $MX);
$params = array_merge($params, $oldValParams, $newValParams);
$result = $this->send_request('POST', '/zones/records/update', $params);
return $result !== false;
}
public function updateDomainRecordRemark($RecordId, $Remark)
{
$records = cache('technitium_' . $this->domain);
if (!$records || !isset($records[$RecordId])) {
$this->setError('记录不存在,请刷新页面重试');
return false;
}
$oldRecord = $records[$RecordId];
$domain = $oldRecord['name'];
$params = [
'domain' => $domain,
'zone' => $this->domain,
'type' => $oldRecord['type'],
'comments' => $Remark,
];
$oldValParams = $this->getOldValueParams($oldRecord['type'], $oldRecord['rData']);
$params = array_merge($params, $oldValParams);
$result = $this->send_request('POST', '/zones/records/update', $params);
return $result !== false;
}
public function deleteDomainRecord($RecordId)
{
$records = cache('technitium_' . $this->domain);
if (!$records || !isset($records[$RecordId])) {
$this->setError('记录不存在,请刷新页面重试');
return false;
}
$oldRecord = $records[$RecordId];
$domain = $oldRecord['name'];
$params = [
'domain' => $domain,
'zone' => $this->domain,
'type' => $oldRecord['type'],
];
$oldValParams = $this->getOldValueParams($oldRecord['type'], $oldRecord['rData']);
$params = array_merge($params, $oldValParams);
$result = $this->send_request('POST', '/zones/records/delete', $params);
return $result !== false;
}
public function setDomainRecordStatus($RecordId, $Status)
{
$records = cache('technitium_' . $this->domain);
if (!$records || !isset($records[$RecordId])) {
$this->setError('记录不存在,请刷新页面重试');
return false;
}
$oldRecord = $records[$RecordId];
$domain = $oldRecord['name'];
$params = [
'domain' => $domain,
'zone' => $this->domain,
'type' => $oldRecord['type'],
'disable' => $Status == '0' ? 'true' : 'false',
];
$oldValParams = $this->getOldValueParams($oldRecord['type'], $oldRecord['rData']);
$params = array_merge($params, $oldValParams);
$result = $this->send_request('POST', '/zones/records/update', $params);
return $result !== false;
}
public function getDomainRecordLog($PageNumber = 1, $PageSize = 20, $KeyWord = null, $StartDate = null, $endDate = null)
{
return false;
}
public function getRecordLine()
{
return ['default' => ['name' => '默认', 'parent' => null]];
}
public function getMinTTL()
{
return false;
}
public function addDomain($Domain)
{
$params = [
'zone' => $Domain,
'type' => 'Primary'
];
$result = $this->send_request('POST', '/zones/create', $params);
if ($result && isset($result['response']['domain'])) {
return ['id' => $result['response']['domain'], 'name' => $result['response']['domain']];
}
return false;
}
private function send_request($method, $path, $params = [])
{
$url = $this->url . $path;
$params['token'] = $this->token;
$body = null;
if ($method == 'GET' || $method == 'DELETE') {
$url .= '?' . http_build_query($params);
} else {
$body = http_build_query($params);
}
try {
$response = http_request($url, $body, null, null, null, $this->proxy, $method);
} catch (Exception $e) {
$this->setError($e->getMessage());
return false;
}
$arr = json_decode($response['body'], true);
if (isset($arr['status']) && $arr['status'] == 'ok') {
return $arr;
} elseif (isset($arr['errorMessage'])) {
$this->setError($arr['errorMessage']);
return false;
} else {
$this->setError('API 请求失败');
return false;
}
}
private function setError($message)
{
$this->error = $message;
}
}

View File

@@ -99,7 +99,7 @@ class CertDeployService
if (!empty($error) && strlen($error) > 300) {
$error = mb_strcut($error, 0, 300);
}
$update = ['status' => $status, 'error' => $error, 'retrytime' => $retrytime];
$update = ['status' => $status, 'error' => $error ? str_replace(["\r", "\n"], '', $error) : null, 'retrytime' => $retrytime];
if ($status == 1){
$update['retry'] = 0;
$update['lasttime'] = date('Y-m-d H:i:s');

View File

@@ -22,6 +22,7 @@ class CertOrderService
private $dnsList;
private $domainList;
private $cnameDomainList = [];
private $domainsAliasList = [];
// 订单状态0:待提交 1:待验证 2:正在验证 3:已签发 4:已吊销 -1:购买证书失败 -2:创建订单失败 -3:添加DNS失败 -4:验证DNS失败 -5:验证订单失败 -6:订单验证未通过 -7:签发证书失败
public function __construct($oid)
@@ -72,6 +73,12 @@ class CertOrderService
if (!$drow && preg_match('/^xn--/', $mainDomain)) {
$drow = Db::name('domain')->where('name', idn_to_utf8($mainDomain))->find();
}
if (!$drow) {
$drow = Db::name('domain_alias')->alias('A')->join('domain B', 'A.did = B.id')->where('A.name', $mainDomain)->field('A.name as alias,B.name as maindomain')->find();
if ($drow) {
$this->domainsAliasList[$drow['alias']] = $drow['maindomain'];
}
}
if (!$drow) {
if (substr($domain, 0, 2) == '*.') $domain = substr($domain, 2);
$cname_row = Db::name('cert_cname')->where('domain', $domain)->where('status', 1)->find();
@@ -181,7 +188,7 @@ class CertOrderService
if (!empty($error) && strlen($error) > 300) {
$error = mb_strcut($error, 0, 300);
}
$update = ['status' => $status, 'error' => $error, 'updatetime' => date('Y-m-d H:i:s'), 'retrytime' => $retrytime];
$update = ['status' => $status, 'error' => $error ? str_replace(["\r", "\n"], '', $error) : null, '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']++;
@@ -261,6 +268,18 @@ class CertOrderService
$this->saveResult(-2, $e->getMessage());
throw $e;
}
foreach ($this->domainsAliasList as $alias => $mainDomain) {
if (isset($this->dnsList[$alias])) {
if (!isset($this->dnsList[$mainDomain])) {
$this->dnsList[$mainDomain] = $this->dnsList[$alias];
} else {
$this->dnsList[$mainDomain] = array_merge($this->dnsList[$mainDomain], $this->dnsList[$alias]);
}
unset($this->dnsList[$alias]);
}
}
Db::name('cert_order')->where('id', $this->order['id'])->update(['info' => json_encode($this->info), 'dns' => json_encode($this->dnsList)]);
if (!empty($this->dnsList)) {

View File

@@ -0,0 +1,620 @@
<?php
namespace app\service;
use Exception;
class CloudflareEnhanceService
{
private string $email = '';
private string $apiKey = '';
private int $auth = 0;
private bool $proxy = false;
private string $accountId = '';
private string $baseUrl = 'https://api.cloudflare.com/client/v4';
public function __construct(array $config = [])
{
$this->email = trim((string)($config['email'] ?? ''));
$this->apiKey = preg_replace('/\s+/', '', trim((string)($config['apikey'] ?? '')));
$this->auth = isset($config['auth']) ? intval($config['auth']) : (preg_match('/^[0-9a-f]+$/i', $this->apiKey) ? 0 : 1);
$this->proxy = isset($config['proxy']) && strval($config['proxy']) === '1';
$this->accountId = trim((string)($config['account_id'] ?? ''));
}
public function isApiTokenAuth(): bool
{
return $this->auth === 1;
}
public function getConfiguredAccountId(): string
{
return $this->accountId;
}
public function getAccounts(): array
{
try {
return $this->paginate('/accounts', [], 50);
} catch (Exception $e) {
$this->throwActionError('获取账户列表', $e, 'Account:Read');
}
}
public function getDefaultAccountId(): string
{
try {
$accounts = $this->getAccounts();
if (!empty($accounts[0]['id'])) {
return trim((string)$accounts[0]['id']);
}
} catch (Exception $e) {
}
try {
$payload = $this->requestRaw('GET', '/zones', ['page' => 1, 'per_page' => 1]);
$first = $payload['result'][0] ?? [];
$accountId = trim((string)($first['account']['id'] ?? ''));
if ($accountId !== '') {
return $accountId;
}
} catch (Exception $e) {
}
return '';
}
public function getZone(string $zoneId): array
{
try {
return $this->requestResult('GET', '/zones/' . $zoneId);
} catch (Exception $e) {
$this->throwActionError('获取域名详情', $e, 'Zone:Read');
}
}
public function listCustomHostnames(string $zoneId): array
{
try {
return $this->paginate('/zones/' . $zoneId . '/custom_hostnames', [], 100);
} catch (Exception $e) {
$this->throwActionError('获取自定义主机名列表', $e, 'SSL and Certificates:Read');
}
}
public function getCustomHostname(string $zoneId, string $hostnameId): array
{
try {
return $this->requestResult('GET', '/zones/' . $zoneId . '/custom_hostnames/' . trim($hostnameId));
} catch (Exception $e) {
$this->throwActionError('获取自定义主机名详情', $e, 'SSL and Certificates:Read');
}
}
public function createCustomHostname(string $zoneId, string $hostname, ?string $customOriginServer = null, string $sslMethod = 'http', string $minTlsVersion = '1.0'): array
{
$hostname = $this->normalizeHostname($hostname);
$payload = [
'hostname' => $hostname,
'ssl' => [
'method' => $sslMethod === 'txt' ? 'txt' : 'http',
'type' => 'dv',
'settings' => [
'min_tls_version' => $minTlsVersion
]
],
];
$origin = trim((string)$customOriginServer);
if ($origin !== '') {
$payload['custom_origin_server'] = $this->normalizeHostname($origin);
}
try {
return $this->requestResult('POST', '/zones/' . $zoneId . '/custom_hostnames', [], $payload);
} catch (Exception $e) {
$this->throwActionError('创建自定义主机名', $e, 'SSL and Certificates:Write');
}
}
public function updateCustomHostname(string $zoneId, string $hostnameId, array $payload): array
{
if (isset($payload['custom_origin_server']) && $payload['custom_origin_server'] !== null) {
$payload['custom_origin_server'] = $this->normalizeHostname($payload['custom_origin_server']);
}
if (isset($payload['hostname']) && $payload['hostname'] !== null) {
$payload['hostname'] = $this->normalizeHostname($payload['hostname']);
}
try {
return $this->requestResult('PATCH', '/zones/' . $zoneId . '/custom_hostnames/' . trim($hostnameId), [], $payload);
} catch (Exception $e) {
$this->throwActionError('更新自定义主机名', $e, 'SSL and Certificates:Write');
}
}
public function deleteCustomHostname(string $zoneId, string $hostnameId): bool
{
try {
$this->requestResult('DELETE', '/zones/' . $zoneId . '/custom_hostnames/' . $hostnameId);
return true;
} catch (Exception $e) {
$this->throwActionError('删除自定义主机名', $e, 'SSL and Certificates:Write');
}
}
public function getFallbackOrigin(string $zoneId): string
{
try {
$result = $this->requestResult('GET', '/zones/' . $zoneId . '/custom_hostnames/fallback_origin', [], null, true);
if ($result === null) {
return '';
}
return trim((string)($result['origin'] ?? ''));
} catch (Exception $e) {
if ($e->getCode() === 404) {
return '';
}
$this->throwActionError('获取 Fallback Origin', $e, 'SSL and Certificates:Read');
}
}
public function updateFallbackOrigin(string $zoneId, string $origin): string
{
try {
$result = $this->requestResult('PUT', '/zones/' . $zoneId . '/custom_hostnames/fallback_origin', [], [
'origin' => $this->normalizeHostname($origin),
]);
return trim((string)($result['origin'] ?? $origin));
} catch (Exception $e) {
$this->throwActionError('更新 Fallback Origin', $e, 'SSL and Certificates:Write');
}
}
public function deleteFallbackOrigin(string $zoneId): bool
{
try {
$this->requestResult('DELETE', '/zones/' . $zoneId . '/custom_hostnames/fallback_origin', [], null, true);
return true;
} catch (Exception $e) {
if ($e->getCode() === 404) {
return true;
}
$this->throwActionError('删除 Fallback Origin', $e, 'SSL and Certificates:Write');
}
}
public function getDcvDelegationUuid(string $zoneId): string
{
try {
$result = $this->requestResult('GET', '/zones/' . $zoneId . '/dcv_delegation/uuid', [], null, true);
if ($result === null) {
return '';
}
return trim((string)($result['uuid'] ?? ''));
} catch (Exception $e) {
$this->throwActionError('获取 DCV 委派 UUID', $e, 'SSL and Certificates:Read');
}
}
public function listTunnels(string $accountId): array
{
$this->assertTunnelSupported();
try {
return $this->paginate('/accounts/' . $accountId . '/cfd_tunnel', ['is_deleted' => 'false'], 100);
} catch (Exception $e) {
$this->throwActionError('获取 Tunnel 列表', $e, 'Cloudflare Tunnel:Read');
}
}
public function createTunnel(string $accountId, string $name): array
{
$this->assertTunnelSupported();
try {
return $this->requestResult('POST', '/accounts/' . $accountId . '/cfd_tunnel', [], [
'name' => trim($name),
'tunnel_secret' => base64_encode(random_bytes(32)),
]);
} catch (Exception $e) {
$this->throwActionError('创建 Tunnel', $e, 'Cloudflare Tunnel:Write');
}
}
public function deleteTunnel(string $accountId, string $tunnelId): bool
{
$this->assertTunnelSupported();
try {
$this->requestResult('DELETE', '/accounts/' . $accountId . '/cfd_tunnel/' . $tunnelId);
return true;
} catch (Exception $e) {
$this->throwActionError('删除 Tunnel', $e, 'Cloudflare Tunnel:Write');
}
}
public function getTunnelToken(string $accountId, string $tunnelId): string
{
$this->assertTunnelSupported();
try {
$result = $this->requestResult('GET', '/accounts/' . $accountId . '/cfd_tunnel/' . $tunnelId . '/token');
if (is_string($result)) {
return $result;
}
return trim((string)($result['token'] ?? ''));
} catch (Exception $e) {
$this->throwActionError('获取 Tunnel Token', $e, 'Cloudflare Tunnel:Read');
}
}
public function getTunnelConfig(string $accountId, string $tunnelId): array
{
$this->assertTunnelSupported();
try {
$result = $this->requestResult('GET', '/accounts/' . $accountId . '/cfd_tunnel/' . $tunnelId . '/configurations', [], null, true);
return is_array($result) ? $result : [];
} catch (Exception $e) {
$this->throwActionError('获取 Tunnel 配置', $e, 'Cloudflare Tunnel:Read');
}
}
public function updateTunnelConfig(string $accountId, string $tunnelId, array $config): array
{
$this->assertTunnelSupported();
try {
return $this->requestResult('PUT', '/accounts/' . $accountId . '/cfd_tunnel/' . $tunnelId . '/configurations', [], [
'config' => $config,
]);
} catch (Exception $e) {
$this->throwActionError('更新 Tunnel 配置', $e, 'Cloudflare Tunnel:Write');
}
}
public function listCidrRoutes(string $accountId, ?string $tunnelId = null): array
{
$this->assertTunnelSupported();
$query = ['is_deleted' => 'false'];
if (!empty($tunnelId)) {
$query['tunnel_id'] = $tunnelId;
}
try {
return $this->paginate('/accounts/' . $accountId . '/teamnet/routes', $query, 100);
} catch (Exception $e) {
$this->throwActionError('获取 CIDR 路由列表', $e, 'Cloudflare Tunnel:Read');
}
}
public function createCidrRoute(string $accountId, string $tunnelId, string $network, ?string $comment = null, ?string $virtualNetworkId = null): array
{
$this->assertTunnelSupported();
$payload = [
'network' => trim($network),
'tunnel_id' => trim($tunnelId),
];
if (!empty($comment)) {
$payload['comment'] = trim($comment);
}
if (!empty($virtualNetworkId)) {
$payload['virtual_network_id'] = trim($virtualNetworkId);
}
try {
return $this->requestResult('POST', '/accounts/' . $accountId . '/teamnet/routes', [], $payload);
} catch (Exception $e) {
$this->throwActionError('创建 CIDR 路由', $e, 'Cloudflare Tunnel:Write');
}
}
public function deleteCidrRoute(string $accountId, string $routeId): bool
{
$this->assertTunnelSupported();
try {
$this->requestResult('DELETE', '/accounts/' . $accountId . '/teamnet/routes/' . $routeId);
return true;
} catch (Exception $e) {
$this->throwActionError('删除 CIDR 路由', $e, 'Cloudflare Tunnel:Write');
}
}
public function listHostnameRoutes(string $accountId, ?string $tunnelId = null): array
{
$this->assertTunnelSupported();
$query = ['is_deleted' => 'false'];
if (!empty($tunnelId)) {
$query['tunnel_id'] = $tunnelId;
}
try {
return $this->paginate('/accounts/' . $accountId . '/zerotrust/routes/hostname', $query, 100);
} catch (Exception $e) {
$this->throwActionError('获取主机名路由列表', $e, 'Cloudflare Tunnel:Read');
}
}
public function createHostnameRoute(string $accountId, string $tunnelId, string $hostname, ?string $comment = null): array
{
$this->assertTunnelSupported();
$payload = [
'hostname' => $this->normalizeHostname($hostname),
'tunnel_id' => trim($tunnelId),
];
if (!empty($comment)) {
$payload['comment'] = trim($comment);
}
try {
return $this->requestResult('POST', '/accounts/' . $accountId . '/zerotrust/routes/hostname', [], $payload);
} catch (Exception $e) {
$this->throwActionError('创建主机名路由', $e, 'Cloudflare Tunnel:Write');
}
}
public function deleteHostnameRoute(string $accountId, string $routeId): bool
{
$this->assertTunnelSupported();
try {
$this->requestResult('DELETE', '/accounts/' . $accountId . '/zerotrust/routes/hostname/' . $routeId);
return true;
} catch (Exception $e) {
$this->throwActionError('删除主机名路由', $e, 'Cloudflare Tunnel:Write');
}
}
public function upsertTunnelCnameRecord(string $zoneId, string $hostname, string $tunnelId): array
{
$zoneId = trim($zoneId);
$hostname = $this->normalizeHostname($hostname);
$target = trim($tunnelId) . '.cfargotunnel.com';
try {
$payload = $this->requestRaw('GET', '/zones/' . $zoneId . '/dns_records', [
'name' => $hostname,
'type' => 'CNAME',
'page' => 1,
'per_page' => 100,
]);
$records = $payload['result'] ?? [];
$allByNamePayload = $this->requestRaw('GET', '/zones/' . $zoneId . '/dns_records', [
'name' => $hostname,
'page' => 1,
'per_page' => 100,
]);
$allByName = $allByNamePayload['result'] ?? [];
$otherTypes = [];
foreach ($allByName as $row) {
$type = strtoupper((string)($row['type'] ?? ''));
$name = $this->normalizeHostname($row['name'] ?? '');
if ($name === $hostname && $type !== 'CNAME') {
$otherTypes[] = $type;
}
}
if (!empty($otherTypes)) {
$otherTypes = array_unique(array_filter($otherTypes));
throw new Exception('主机名已存在非 CNAME 记录(' . implode(', ', $otherTypes) . '),无法同步 Tunnel CNAME', 400);
}
foreach ($records as $record) {
$name = $this->normalizeHostname($record['name'] ?? '');
if ($name !== $hostname) {
continue;
}
$content = $this->normalizeHostname($record['content'] ?? '');
$proxied = !empty($record['proxied']);
if ($content === $this->normalizeHostname($target) && $proxied) {
return ['action' => 'unchanged'];
}
$this->requestResult('PUT', '/zones/' . $zoneId . '/dns_records/' . $record['id'], [], [
'type' => 'CNAME',
'name' => $hostname,
'content' => $target,
'proxied' => true,
'ttl' => 1,
]);
return ['action' => 'updated'];
}
$this->requestResult('POST', '/zones/' . $zoneId . '/dns_records', [], [
'type' => 'CNAME',
'name' => $hostname,
'content' => $target,
'proxied' => true,
'ttl' => 1,
]);
return ['action' => 'created'];
} catch (Exception $e) {
$this->throwActionError('同步 Tunnel CNAME 记录', $e, 'Zone:DNS:Edit');
}
}
public function deleteTunnelCnameRecordIfMatch(string $zoneId, string $hostname, string $tunnelId): array
{
$zoneId = trim($zoneId);
$hostname = $this->normalizeHostname($hostname);
$target = $this->normalizeHostname(trim($tunnelId) . '.cfargotunnel.com');
try {
$payload = $this->requestRaw('GET', '/zones/' . $zoneId . '/dns_records', [
'name' => $hostname,
'type' => 'CNAME',
'page' => 1,
'per_page' => 100,
]);
$records = $payload['result'] ?? [];
foreach ($records as $record) {
$name = $this->normalizeHostname($record['name'] ?? '');
$content = $this->normalizeHostname($record['content'] ?? '');
if ($name === $hostname && $content === $target) {
$this->requestResult('DELETE', '/zones/' . $zoneId . '/dns_records/' . $record['id']);
return ['deleted' => true];
}
}
return ['deleted' => false];
} catch (Exception $e) {
$this->throwActionError('删除 Tunnel CNAME 记录', $e, 'Zone:DNS:Edit');
}
}
private function paginate(string $path, array $query = [], int $perPage = 100): array
{
$all = [];
$page = 1;
$maxPage = 200;
while ($page <= $maxPage) {
$payload = $this->requestRaw('GET', $path, array_merge($query, [
'page' => $page,
'per_page' => $perPage,
]));
$batch = $payload['result'] ?? [];
if (!is_array($batch)) {
$batch = [];
}
foreach ($batch as $item) {
$all[] = $item;
}
$totalPages = intval($payload['result_info']['total_pages'] ?? 0);
if ($totalPages > 0) {
if ($page >= $totalPages) {
break;
}
} elseif (count($batch) < $perPage || empty($batch)) {
break;
}
$page++;
}
return $all;
}
private function requestResult(string $method, string $path, array $query = [], ?array $body = null, bool $allowNotFound = false)
{
$payload = $this->requestRaw($method, $path, $query, $body, $allowNotFound);
if ($payload === null) {
return null;
}
return $payload['result'] ?? [];
}
private function requestRaw(string $method, string $path, array $query = [], ?array $body = null, bool $allowNotFound = false): ?array
{
$headers = $this->buildHeaders($body !== null);
$url = $this->baseUrl . $path;
if (!empty($query)) {
$url .= '?' . http_build_query($query);
}
$response = http_request(
$url,
$body,
null,
null,
$headers,
$this->proxy,
strtoupper($method),
20
);
$status = intval($response['code'] ?? 0);
if ($allowNotFound && $status === 404) {
return null;
}
$payload = json_decode($response['body'] ?? '', true);
if (!is_array($payload)) {
throw new Exception('Cloudflare 返回数据解析失败', $status > 0 ? $status : 502);
}
if (($payload['success'] ?? false) !== true) {
if ($allowNotFound && $status === 404) {
return null;
}
$message = $this->extractErrorMessage($payload);
throw new Exception($message !== '' ? $message : 'Cloudflare API 请求失败', $status > 0 ? $status : 400);
}
return $payload;
}
private function buildHeaders(bool $json = false): array
{
if ($this->apiKey === '') {
throw new Exception('Cloudflare API 凭证为空', 400);
}
if ($this->auth === 1) {
$headers = [
'Authorization' => 'Bearer ' . $this->apiKey,
];
} else {
if ($this->email === '') {
throw new Exception('当前 Cloudflare 账户缺少邮箱地址,旧版 API Key 认证需要填写邮箱', 400);
}
$headers = [
'X-Auth-Email' => $this->email,
'X-Auth-Key' => $this->apiKey,
];
}
if ($json) {
$headers['Content-Type'] = 'application/json';
}
return $headers;
}
private function assertTunnelSupported(): void
{
if (!$this->isApiTokenAuth()) {
throw new Exception('Cloudflare Tunnels 仅支持 API 令牌认证,请将当前账户的认证方式切换为 API令牌', 400);
}
}
private function normalizeHostname($hostname): string
{
$hostname = trim((string)$hostname);
if ($hostname === '') {
return '';
}
$hostname = rtrim($hostname, '.');
$hostname = convertDomainToAscii($hostname);
return strtolower($hostname);
}
private function extractErrorMessage(array $payload): string
{
if (!empty($payload['errors'][0]['message'])) {
return trim((string)$payload['errors'][0]['message']);
}
if (!empty($payload['messages'][0]['message'])) {
return trim((string)$payload['messages'][0]['message']);
}
if (!empty($payload['result']['message'])) {
return trim((string)$payload['result']['message']);
}
return '';
}
private function throwActionError(string $action, Exception $e, string $permissionHint = ''): void
{
$status = intval($e->getCode());
$message = trim($e->getMessage());
if ($status === 401) {
$message = 'Cloudflare 凭证无效或已过期,无法' . $action;
} elseif ($status === 403) {
$message = 'Cloudflare 权限不足,无法' . $action;
if ($permissionHint !== '') {
$message .= '。请确认 Token 具备 ' . $permissionHint . ' 权限';
}
} elseif ($status === 404 && $message === '') {
$message = $action . '失败:资源不存在';
} elseif ($status === 429) {
$message = 'Cloudflare API 请求过于频繁,暂时无法' . $action . ',请稍后重试';
} elseif ($status >= 500) {
$message = 'Cloudflare 服务暂时不可用,无法' . $action . ',请稍后重试';
} elseif ($message === '') {
$message = $action . '失败';
}
throw new Exception($message, $status > 0 ? $status : 400);
}
}

View File

@@ -41,7 +41,7 @@ class ExpireNoticeService
$count = $this->refreshExpiringDomainList($max_day);
if ($count > 0) return;
if (!empty($days) && (config_get('expire_notice_mail') == '1' || config_get('expire_notice_wxtpl') == '1' || config_get('expire_notice_tgbot') == '1' || config_get('expire_notice_webhook') == '1') && date('H') >= 9) {
if (!empty($days) && (config_get('expire_notice_mail') == '1' || config_get('expire_notice_wxtpl') == '1' || config_get('expire_notice_tgbot') == '1' || config_get('expire_notice_webhook') == '1' || config_get('expire_notice_custom_webhook') == '1') && date('H') >= 9) {
$this->noticeExpiringDomainList($max_day, $days);
}
}

View File

@@ -19,13 +19,14 @@ class OptimizeService
public static function get_license($api, $key)
{
if ($api == 2) {
throw new Exception('当前接口暂不支持');
throw new Exception('xingpingcn.top 接口免费使用,无需密钥,无积分限制');
} elseif ($api == 1) {
$url = 'https://api.hostmonit.com/get_license?license='.$key;
} else {
$url = 'https://www.wetest.vip/api/cf2dns/get_license?license='.$key;
}
$response = get_curl($url);
if (!$response) throw new Exception('接口请求失败');
$arr = json_decode($response, true);
if (isset($arr['code']) && $arr['code'] == 200 && isset($arr['count'])) {
return $arr['count'];
@@ -39,7 +40,9 @@ class OptimizeService
public function get_ip_address($cdn_type = 1, $ip_type = 'v4')
{
$api = config_get('optimize_ip_api', 0);
if ($api == 1) {
if ($api == 2) {
return $this->get_ip_address_xingpingcn($ip_type);
} elseif ($api == 1) {
$url = 'https://api.hostmonit.com/get_optimization_ip';
} else {
$url = 'https://www.wetest.vip/api/cf2dns/';
@@ -58,6 +61,7 @@ class OptimizeService
'type' => $ip_type,
];
$response = get_curl($url, json_encode($params), 0, 0, 0, 0, ['Content-Type' => 'application/json; charset=UTF-8']);
if (!$response) throw new Exception('接口请求失败');
$arr = json_decode($response, true);
if (isset($arr['code']) && $arr['code'] == 200) {
return $arr['info'];
@@ -70,6 +74,59 @@ class OptimizeService
}
}
/**
* 从 xingpingcn.top 获取优选IP数据
* @param string $ip_type IP类型 v4/v6
* @return array
* @throws Exception
*/
private function get_ip_address_xingpingcn($ip_type = 'v4')
{
if ($ip_type == 'v6') {
throw new Exception('xingpingcn.top 接口暂不支持IPv6');
}
$proxy = config_get('optimize_ip_proxy', '');
if (!empty($proxy)) {
$proxy = trim($proxy);
if (filter_var($proxy, FILTER_VALIDATE_URL) === false) {
throw new Exception('无效的代理地址配置URL 格式错误');
}
$scheme = parse_url($proxy, PHP_URL_SCHEME);
if (!in_array($scheme, ['http', 'https'], true)) {
throw new Exception('无效的代理地址配置:仅支持 http 和 https 协议');
}
$url = rtrim($proxy, '/') . '/xingpingcn/enhanced-FaaS-in-China/refs/heads/main/Cf.json';
} else {
$url = 'https://raw.githubusercontent.com/xingpingcn/enhanced-FaaS-in-China/refs/heads/main/Cf.json';
}
$response = get_curl($url);
if (!$response) {
throw new Exception('获取优选IP数据失败网络请求失败请检查网络连接或代理地址');
}
$arr = json_decode($response, true);
if (isset($arr['Cf']['result'])) {
$result = $arr['Cf']['result'];
$info = [];
// 转换格式dianxin->CT, liantong->CU, yidong->CM, default->DEF
if (isset($result['dianxin']) && is_array($result['dianxin'])) {
$info['CT'] = array_map(function($ip) { return ['ip' => $ip]; }, $result['dianxin']);
}
if (isset($result['liantong']) && is_array($result['liantong'])) {
$info['CU'] = array_map(function($ip) { return ['ip' => $ip]; }, $result['liantong']);
}
if (isset($result['yidong']) && is_array($result['yidong'])) {
$info['CM'] = array_map(function($ip) { return ['ip' => $ip]; }, $result['yidong']);
}
// 不使用他的默认线路数据, 因为这真的是默认. 由后续逻辑自己决定是否把CT线路当DEF来用
// if (isset($result['default']) && is_array($result['default'])) {
// $info['DEF'] = array_map(function($ip) { return ['ip' => $ip]; }, $result['default']);
// }
return $info;
} else {
throw new Exception('获取优选IP数据失败接口返回数据格式错误');
}
}
public function get_ip_address2($cdn_type = 1, $ip_type = 'v4')
{
$key = $cdn_type.'_'.$ip_type;

View File

@@ -5,7 +5,7 @@ CREATE TABLE `dnsmgr_config` (
PRIMARY KEY (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO `dnsmgr_config` VALUES ('version', '1045');
INSERT INTO `dnsmgr_config` VALUES ('version', '1049');
INSERT INTO `dnsmgr_config` VALUES ('notice_mail', '0');
INSERT INTO `dnsmgr_config` VALUES ('notice_wxtpl', '0');
INSERT INTO `dnsmgr_config` VALUES ('mail_smtp', 'smtp.qq.com');
@@ -26,6 +26,7 @@ DROP TABLE IF EXISTS `dnsmgr_domain`;
CREATE TABLE `dnsmgr_domain` (
`id` int(11) unsigned NOT NULL auto_increment,
`aid` int(11) unsigned NOT NULL,
`cid` int(11) unsigned NOT NULL DEFAULT '0',
`name` varchar(255) NOT NULL,
`thirdid` varchar(60) DEFAULT NULL,
`addtime` datetime DEFAULT NULL,
@@ -40,7 +41,8 @@ CREATE TABLE `dnsmgr_domain` (
`noticetime` datetime DEFAULT NULL,
`checkstatus` tinyint(1) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
KEY `name` (`name`)
KEY `name` (`name`),
KEY `cid` (`cid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
DROP TABLE IF EXISTS `dnsmgr_user`;
@@ -251,4 +253,25 @@ CREATE TABLE `dnsmgr_sctask` (
`remark` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `did` (`did`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
DROP TABLE IF EXISTS `dnsmgr_domain_alias`;
CREATE TABLE `dnsmgr_domain_alias` (
`id` int(11) unsigned NOT NULL auto_increment,
`did` int(11) unsigned NOT NULL,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`),
KEY `did` (`did`),
KEY `name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
DROP TABLE IF EXISTS `dnsmgr_domain_category`;
CREATE TABLE `dnsmgr_domain_category` (
`id` int(11) unsigned NOT NULL auto_increment,
`name` varchar(50) NOT NULL,
`remark` varchar(100) DEFAULT NULL,
`sort` int(11) NOT NULL DEFAULT '0',
`addtime` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `sort` (`sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -189,4 +189,27 @@ CREATE TABLE IF NOT EXISTS `dnsmgr_sctask` (
ALTER TABLE `dnsmgr_account`
ADD COLUMN `config` text DEFAULT NULL,
CHANGE COLUMN `ak` `name` varchar(255) NOT NULL;
CHANGE COLUMN `ak` `name` varchar(255) NOT NULL;
CREATE TABLE IF NOT EXISTS `dnsmgr_domain_alias` (
`id` int(11) unsigned NOT NULL auto_increment,
`did` int(11) unsigned NOT NULL,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`),
KEY `did` (`did`),
KEY `name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `dnsmgr_domain_category` (
`id` int(11) unsigned NOT NULL auto_increment,
`name` varchar(50) NOT NULL,
`remark` varchar(100) DEFAULT NULL,
`sort` int(11) NOT NULL DEFAULT '0',
`addtime` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `sort` (`sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ALTER TABLE `dnsmgr_domain`
ADD COLUMN `cid` int(11) unsigned NOT NULL DEFAULT '0',
ADD KEY `cid` (`cid`);

View File

@@ -42,12 +42,14 @@ class DnsQueryUtils
$id = array_rand(self::$doh_servers);
$url = self::$doh_servers[$id].'?name='.urlencode($domain).'&type='.$dns_type[$type];
$data = get_curl($url);
if (!$data) return false;
$arr = json_decode($data, true);
if (!$arr) {
unset(self::$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);
if (!$data) return false;
$arr = json_decode($data, true);
if (!$arr) return false;
}

View File

@@ -61,6 +61,9 @@ class MsgNotice
$content = str_replace(['<br/>', '<b>', '</b>'], ["\n", '**', '**'], $mail_content);
self::send_webhook($mail_title, $content);
}
if (config_get('notice_custom_webhook') == 1) {
self::send_custom_webhook($mail_title, $mail_content);
}
}
public static function cert_order_send($id, $result)
@@ -141,6 +144,9 @@ class MsgNotice
$content = str_replace(['*', '<br/>', '<b>', '</b>'], ['\*', "\n", '**', '**'], $mail_content);
self::send_webhook($mail_title, $content);
}
if (config_get('cert_notice_custom_webhook') == 1 || config_get('cert_notice_custom_webhook') == 2 && !$result) {
self::send_custom_webhook($mail_title, $mail_content);
}
}
public static function expire_notice_send($day, $list)
@@ -169,6 +175,9 @@ class MsgNotice
$content = str_replace(['*', '<br/>', '<b>', '</b>'], ['\*', "\n", '**', '**'], $mail_content);
self::send_webhook($mail_title, $content);
}
if (config_get('expire_notice_custom_webhook') == 1) {
self::send_custom_webhook($mail_title, $mail_content);
}
}
public static function send_mail($to, $sub, $msg)
@@ -223,6 +232,7 @@ class MsgNotice
$url = 'https://wxpusher.zjiecode.com/api/send/message';
$post = ['appToken' => $wechat_apptoken, 'content' => $content, 'summary' => $title, 'contentType' => 3, 'uids' => [$wechat_appuid]];
$result = get_curl($url, json_encode($post), 0, 0, 0, 0, ['Content-Type' => 'application/json; charset=UTF-8']);
if (!$result) return '请求失败';
$arr = json_decode($result, true);
if (isset($arr['success']) && $arr['success'] == true) {
return true;
@@ -246,6 +256,7 @@ class MsgNotice
$url = $tgbot_url.'/bot'.$tgbot_token.'/sendMessage';
$post = ['chat_id' => $tgbot_chatid, 'text' => $content, 'parse_mode' => 'HTML'];
$result = self::telegram_curl($url, http_build_query($post));
if (!$result) return '请求失败';
$arr = json_decode($result, true);
if (isset($arr['ok']) && $arr['ok'] == true) {
return true;
@@ -348,6 +359,7 @@ class MsgNotice
return '不支持的Webhook地址';
}
$result = get_curl($url, json_encode($post), 0, 0, 0, 0, ['Content-Type' => 'application/json; charset=UTF-8']);
if (!$result) return '请求失败';
$arr = json_decode($result, true);
if (isset($arr['errcode']) && $arr['errcode'] == 0 || isset($arr['code']) && $arr['code'] == 0) {
return true;
@@ -356,6 +368,85 @@ class MsgNotice
}
}
public static function send_custom_webhook($title, $content)
{
$url = config_get('custom_webhook_url');
if (!$url || !parse_url($url)) return false;
$method = strtoupper(config_get('custom_webhook_method') ?: 'POST');
$contentType = config_get('custom_webhook_content_type') ?: 'application/json';
$headersRaw = config_get('custom_webhook_headers');
$bodyTemplate = config_get('custom_webhook_body') ?: '{"title":"{title}","content":"{content}"}';
$contentFormat = config_get('custom_webhook_content_format') ?: 'text';
if ($contentFormat === 'markdown') {
$content = str_replace(['<br/>', '<b>', '</b>'], ["\n", '**', '**'], $content);
$content = strip_tags($content);
} elseif ($contentFormat === 'text') {
$content = str_replace('<br/>', "\n", $content);
$content = strip_tags($content);
}
$body = str_replace(['{title}', '{content}'], [$title, $content], $bodyTemplate);
$headers = [];
if (!empty($headersRaw)) {
$lines = explode("\n", $headersRaw);
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) continue;
$pos = strpos($line, ':');
if ($pos !== false) {
$key = trim(substr($line, 0, $pos));
$val = trim(substr($line, $pos + 1));
if ($key !== '') $headers[$key] = $val;
}
}
}
$options = [
'timeout' => 10,
'verify' => false,
'headers' => $headers,
'http_errors' => false,
];
if ($method === 'GET') {
$params = [];
if ($contentType === 'application/json') {
$decoded = json_decode($body, true);
if (is_array($decoded)) {
$params = $decoded;
}
} else {
parse_str($body, $params);
}
$connector = strpos($url, '?') !== false ? '&' : '?';
$url = $url . $connector . http_build_query($params);
} else {
$options['headers']['Content-Type'] = $contentType;
if ($contentType === 'application/json') {
json_decode($body);
if (json_last_error() !== JSON_ERROR_NONE) {
$body = json_encode(['title' => $title, 'content' => $content]);
}
}
$options['body'] = $body;
}
try {
$client = new \GuzzleHttp\Client();
$response = $client->request($method, $url, $options);
$statusCode = $response->getStatusCode();
if ($statusCode >= 200 && $statusCode < 300) {
return true;
}
return '请求失败HTTP状态码' . $statusCode;
} catch (\Exception $e) {
return '请求失败:' . $e->getMessage();
}
}
private static function telegram_curl($url, $post)
{
$ch = curl_init();

View File

@@ -61,6 +61,10 @@
<label class="col-sm-3 control-label">群机器人Webhook</label>
<div class="col-sm-9"><select class="form-control" name="cert_notice_webhook" default="{:config_get('cert_notice_webhook')}"><option value="0">关闭</option><option value="1">开启</option><option value="2">开启(仅失败时)</option></select></div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">自定义Webhook</label>
<div class="col-sm-9"><select class="form-control" name="cert_notice_custom_webhook" default="{:config_get('cert_notice_custom_webhook')}"><option value="0">关闭</option><option value="1">开启</option><option value="2">开启(仅失败时)</option></select></div>
</div>
<div class="form-group">
<div class="col-sm-offset-3 col-sm-9"><input type="submit" name="submit" value="保存" class="btn btn-primary btn-block"/></div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,607 @@
{extend name="common/layout" /}
{block name="title"}Cloudflare Tunnels - {$accountName}{/block}
{block name="main"}
<div class="row">
<div class="col-xs-12 center-block" style="float:none;">
<div class="panel panel-default panel-intro">
<div class="panel-heading">
<h3 class="panel-title">
<a href="/account" class="btn btn-sm btn-default pull-right" style="margin-top:-6px"><i class="fa fa-reply fa-fw"></i> 返回账户</a>
Cloudflare Tunnels - {$accountName}
</h3>
</div>
<div class="panel-body">
<div class="alert alert-info">
<strong>Account ID</strong>{$cfAccountId}
<br>
这里管理 Tunnel 列表、公网主机名、CIDR 路由和主机名路由。公网主机名会自动同步为对应域名下的 CNAME。
</div>
<div class="clearfix" style="margin-bottom:15px;">
<div class="pull-left">
<a href="javascript:refreshTunnelList()" class="btn btn-default" title="刷新 Tunnel 列表"><i class="fa fa-refresh"></i> 刷新</a>
<a href="javascript:openTunnelDialog()" class="btn btn-success"><i class="fa fa-plus"></i> 创建 Tunnel</a>
</div>
</div>
<table id="listTable"></table>
</div>
</div>
</div>
</div>
<div class="modal" id="modal-tunnel" role="dialog" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog">
<div class="modal-content animated flipInX">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
<h4 class="modal-title">创建 Tunnel</h4>
</div>
<div class="modal-body">
<form class="form-horizontal" id="form-tunnel">
<div class="form-group">
<label class="col-sm-3 control-label">名称</label>
<div class="col-sm-9">
<input type="text" class="form-control" name="name" placeholder="例如 edge-prod" required>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-white" data-dismiss="modal">关闭</button>
<button type="button" class="btn btn-primary" onclick="submitTunnel()">保存</button>
</div>
</div>
</div>
</div>
<div class="modal" id="modal-token" role="dialog" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-lg">
<div class="modal-content animated flipInX">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
<h4 class="modal-title">Tunnel Token</h4>
</div>
<div class="modal-body">
<div class="form-group">
<label>Tunnel</label>
<input type="text" class="form-control" id="tokenTunnelName" disabled>
</div>
<div class="form-group">
<label>Token</label>
<textarea id="tokenValue" class="form-control" rows="4" readonly></textarea>
</div>
<div class="form-group">
<label>启动命令</label>
<textarea id="tokenCommand" class="form-control" rows="3" readonly></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" onclick="copyTokenCommand()">复制启动命令</button>
<button type="button" class="btn btn-white" data-dismiss="modal">关闭</button>
</div>
</div>
</div>
</div>
<div class="modal" id="modal-public" role="dialog" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-lg">
<div class="modal-content animated flipInX">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
<h4 class="modal-title" id="publicTitle">公网主机名</h4>
</div>
<div class="modal-body">
<form class="form-inline" id="form-public">
<div class="form-group">
<input type="text" class="form-control" name="hostname" placeholder="hostname例如 app.example.com" style="width:240px;" required>
</div>
<div class="form-group">
<input type="text" class="form-control" name="service" placeholder="service例如 http://127.0.0.1:8080" style="width:260px;" required>
</div>
<div class="form-group">
<input type="text" class="form-control" name="path" placeholder="可留空,例如 /api/*" style="width:180px;">
</div>
<button type="button" class="btn btn-primary" onclick="savePublicHostname()">保存</button>
</form>
<hr>
<table id="publicTable"></table>
</div>
</div>
</div>
</div>
<div class="modal" id="modal-cidr" role="dialog" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-lg">
<div class="modal-content animated flipInX">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
<h4 class="modal-title" id="cidrTitle">CIDR 路由</h4>
</div>
<div class="modal-body">
<form class="form-inline" id="form-cidr">
<div class="form-group">
<input type="text" class="form-control" name="network" placeholder="例如 10.10.0.0/16" style="width:220px;" required>
</div>
<div class="form-group">
<input type="text" class="form-control" name="comment" placeholder="备注,可留空" style="width:240px;">
</div>
<button type="button" class="btn btn-primary" onclick="saveCidrRoute()">保存</button>
</form>
<hr>
<table id="cidrTable"></table>
</div>
</div>
</div>
</div>
<div class="modal" id="modal-hostname-route" role="dialog" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-lg">
<div class="modal-content animated flipInX">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
<h4 class="modal-title" id="hostnameRouteTitle">主机名路由</h4>
</div>
<div class="modal-body">
<form class="form-inline" id="form-hostname-route">
<div class="form-group">
<input type="text" class="form-control" name="hostname" placeholder="例如 internal.example.com" style="width:260px;" required>
</div>
<div class="form-group">
<input type="text" class="form-control" name="comment" placeholder="备注,可留空" style="width:240px;">
</div>
<button type="button" class="btn btn-primary" onclick="saveHostnameRoute()">保存</button>
</form>
<hr>
<table id="hostnameRouteTable"></table>
</div>
</div>
</div>
</div>
{/block}
{block name="script"}
<script src="/static/js/layer/layer.js"></script>
<script src="/static/js/bootstrap-table-1.21.4.min.js"></script>
<script src="/static/js/bootstrap-table-page-jump-to-1.21.4.min.js"></script>
<script src="/static/js/bootstrapValidator.min.js"></script>
<script src="/static/js/custom.js"></script>
<script>
var selectedTunnelId = '';
var selectedTunnelName = '';
$(document).ready(function(){
$("#form-tunnel").bootstrapValidator();
$("#listTable").bootstrapTable({
url: '/cloudflare/tunnels/data/{$accountId}',
method: 'post',
toolbar: '',
classes: 'table table-striped table-hover table-bordered',
uniqueId: 'id',
responseHandler: tableResponseHandler,
columns: [
{field: 'name', title: '名称'},
{field: 'id', title: 'Tunnel ID'},
{field: 'status', title: '状态', formatter: tunnelStatusFormatter},
{field: 'connection_count', title: '连接数'},
{field: 'created_at', title: '创建时间', formatter: function(v){ return v || '-'; }},
{
field: 'action',
title: '操作',
formatter: function(value, row){
return ''
+ '<a href="javascript:showToken(\''+row.id+'\', \''+htmlEscape(row.name)+'\')" class="btn btn-info btn-xs">Token</a> '
+ '<a href="javascript:openPublicHostnames(\''+row.id+'\', \''+htmlEscape(row.name)+'\')" class="btn btn-primary btn-xs">公网主机名</a> '
+ '<a href="javascript:openCidrRoutes(\''+row.id+'\', \''+htmlEscape(row.name)+'\')" class="btn btn-warning btn-xs">CIDR</a> '
+ '<a href="javascript:openHostnameRoutes(\''+row.id+'\', \''+htmlEscape(row.name)+'\')" class="btn btn-success btn-xs">主机名路由</a> '
+ '<a href="javascript:deleteTunnel(\''+row.id+'\', \''+htmlEscape(row.name)+'\')" class="btn btn-danger btn-xs">删除</a>';
}
}
]
});
$("#publicTable").bootstrapTable({
method: 'post',
classes: 'table table-striped table-hover table-bordered',
uniqueId: 'hostname',
responseHandler: tableResponseHandler,
columns: [
{field: 'hostname', title: 'Hostname'},
{field: 'path', title: 'Path', formatter: function(v){ return v || '-'; }},
{field: 'service', title: 'Service'},
{field: 'zone_name', title: '匹配域名', formatter: function(v){ return v || '-'; }},
{
field: 'action',
title: '操作',
formatter: function(value, row){
return '<a href="javascript:deletePublicHostname(\''+escapeJs(row.hostname)+'\', \''+escapeJs(row.path || '')+'\')" class="btn btn-danger btn-xs">删除</a>';
}
}
]
});
$("#cidrTable").bootstrapTable({
method: 'post',
classes: 'table table-striped table-hover table-bordered',
uniqueId: 'id',
responseHandler: tableResponseHandler,
columns: [
{field: 'network', title: 'CIDR'},
{field: 'comment', title: '备注', formatter: function(v){ return v || '-'; }},
{field: 'created_at', title: '创建时间', formatter: function(v){ return v || '-'; }},
{
field: 'action',
title: '操作',
formatter: function(value, row){
return '<a href="javascript:deleteCidrRoute(\''+row.id+'\')" class="btn btn-danger btn-xs">删除</a>';
}
}
]
});
$("#hostnameRouteTable").bootstrapTable({
method: 'post',
classes: 'table table-striped table-hover table-bordered',
uniqueId: 'id',
responseHandler: tableResponseHandler,
columns: [
{field: 'hostname', title: 'Hostname'},
{field: 'comment', title: '备注', formatter: function(v){ return v || '-'; }},
{field: 'created_at', title: '创建时间', formatter: function(v){ return v || '-'; }},
{
field: 'action',
title: '操作',
formatter: function(value, row){
return '<a href="javascript:deleteHostnameRoute(\''+row.id+'\')" class="btn btn-danger btn-xs">删除</a>';
}
}
]
});
});
function tableResponseHandler(res){
if(res.code !== 0){
layer.alert(res.msg || '请求失败', {icon: 2});
return {total: 0, rows: []};
}
return res;
}
function refreshTunnelList(){
$("#listTable").bootstrapTable('refresh');
}
function tunnelStatusFormatter(value){
var v = (value || '').toLowerCase();
if(v === 'healthy' || v === 'active'){
return '<span class="label label-success">'+htmlEscape(value)+'</span>';
}
if(v === 'inactive' || v === 'down' || v === 'degraded'){
return '<span class="label label-warning">'+htmlEscape(value || '-')+'</span>';
}
return value ? '<span class="label label-default">'+htmlEscape(value)+'</span>' : '-';
}
function openTunnelDialog(){
$("#form-tunnel")[0].reset();
$("#form-tunnel").data("bootstrapValidator").resetForm();
$("#modal-tunnel").modal('show');
}
function submitTunnel(){
$("#form-tunnel").data("bootstrapValidator").validate();
if(!$("#form-tunnel").data("bootstrapValidator").isValid()){
return;
}
var ii = layer.load(2);
$.ajax({
type: 'POST',
url: '/cloudflare/tunnels/add/{$accountId}',
data: $("#form-tunnel").serialize(),
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
$("#modal-tunnel").modal('hide');
layer.msg(res.msg, {icon: 1, time: 1000});
$("#listTable").bootstrapTable('refresh');
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
}
function deleteTunnel(tunnelId, tunnelName){
layer.confirm('确定要删除 Tunnel '+tunnelName+' 吗?', {title: '提示', icon: 0}, function(){
var ii = layer.load(2);
$.ajax({
type: 'POST',
url: '/cloudflare/tunnels/delete/{$accountId}',
data: {tunnel_id: tunnelId},
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
layer.closeAll();
layer.msg(res.msg, {icon: 1, time: 1000});
$("#listTable").bootstrapTable('refresh');
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
});
}
function showToken(tunnelId, tunnelName){
$("#tokenTunnelName").val(tunnelName + ' [' + tunnelId + ']');
$("#tokenValue").val('');
$("#tokenCommand").val('');
$("#modal-token").modal('show');
var ii = layer.load(2);
$.ajax({
type: 'POST',
url: '/cloudflare/tunnels/token/{$accountId}',
data: {tunnel_id: tunnelId},
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
var token = (res.data && res.data.token) ? res.data.token : '';
$("#tokenValue").val(token);
$("#tokenCommand").val('cloudflared tunnel run --token ' + token);
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
}
function copyTokenCommand(){
copyPlainText($("#tokenCommand").val());
}
function openPublicHostnames(tunnelId, tunnelName){
selectedTunnelId = tunnelId;
selectedTunnelName = tunnelName;
$("#publicTitle").text('公网主机名 - ' + tunnelName);
$("#form-public")[0].reset();
$("#modal-public").modal('show');
$("#publicTable").bootstrapTable('refreshOptions', {
url: '/cloudflare/tunnels/publichostnames/data/{$accountId}',
queryParams: function(){ return {tunnel_id: selectedTunnelId}; }
});
}
function savePublicHostname(){
if(!selectedTunnelId){
layer.msg('请先选择 Tunnel');
return;
}
var ii = layer.load(2);
var data = $("#form-public").serializeArray();
data.push({name: 'tunnel_id', value: selectedTunnelId});
$.ajax({
type: 'POST',
url: '/cloudflare/tunnels/publichostnames/save/{$accountId}',
data: $.param(data),
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
layer.msg(res.msg, {icon: 1, time: 1000});
$("#publicTable").bootstrapTable('refresh');
$("#listTable").bootstrapTable('refresh');
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
}
function deletePublicHostname(hostname, path){
layer.confirm('确定要删除公网主机名 '+hostname+' 吗?', {title: '提示', icon: 0}, function(){
var ii = layer.load(2);
$.ajax({
type: 'POST',
url: '/cloudflare/tunnels/publichostnames/delete/{$accountId}',
data: {tunnel_id: selectedTunnelId, hostname: hostname, path: path},
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
layer.closeAll();
$("#modal-public").modal('show');
layer.msg(res.msg, {icon: 1, time: 1000});
$("#publicTable").bootstrapTable('refresh');
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
});
}
function openCidrRoutes(tunnelId, tunnelName){
selectedTunnelId = tunnelId;
selectedTunnelName = tunnelName;
$("#cidrTitle").text('CIDR 路由 - ' + tunnelName);
$("#form-cidr")[0].reset();
$("#modal-cidr").modal('show');
$("#cidrTable").bootstrapTable('refreshOptions', {
url: '/cloudflare/tunnels/cidr/data/{$accountId}',
queryParams: function(){ return {tunnel_id: selectedTunnelId}; }
});
}
function saveCidrRoute(){
if(!selectedTunnelId){
layer.msg('请先选择 Tunnel');
return;
}
var ii = layer.load(2);
var data = $("#form-cidr").serializeArray();
data.push({name: 'tunnel_id', value: selectedTunnelId});
$.ajax({
type: 'POST',
url: '/cloudflare/tunnels/cidr/add/{$accountId}',
data: $.param(data),
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
layer.msg(res.msg, {icon: 1, time: 1000});
$("#cidrTable").bootstrapTable('refresh');
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
}
function deleteCidrRoute(routeId){
layer.confirm('确定要删除该 CIDR 路由吗?', {title: '提示', icon: 0}, function(){
var ii = layer.load(2);
$.ajax({
type: 'POST',
url: '/cloudflare/tunnels/cidr/delete/{$accountId}',
data: {tunnel_id: selectedTunnelId, route_id: routeId},
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
layer.closeAll();
$("#modal-cidr").modal('show');
layer.msg(res.msg, {icon: 1, time: 1000});
$("#cidrTable").bootstrapTable('refresh');
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
});
}
function openHostnameRoutes(tunnelId, tunnelName){
selectedTunnelId = tunnelId;
selectedTunnelName = tunnelName;
$("#hostnameRouteTitle").text('主机名路由 - ' + tunnelName);
$("#form-hostname-route")[0].reset();
$("#modal-hostname-route").modal('show');
$("#hostnameRouteTable").bootstrapTable('refreshOptions', {
url: '/cloudflare/tunnels/hostnameroutes/data/{$accountId}',
queryParams: function(){ return {tunnel_id: selectedTunnelId}; }
});
}
function saveHostnameRoute(){
if(!selectedTunnelId){
layer.msg('请先选择 Tunnel');
return;
}
var ii = layer.load(2);
var data = $("#form-hostname-route").serializeArray();
data.push({name: 'tunnel_id', value: selectedTunnelId});
$.ajax({
type: 'POST',
url: '/cloudflare/tunnels/hostnameroutes/add/{$accountId}',
data: $.param(data),
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
layer.msg(res.msg, {icon: 1, time: 1000});
$("#hostnameRouteTable").bootstrapTable('refresh');
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
}
function deleteHostnameRoute(routeId){
layer.confirm('确定要删除该主机名路由吗?', {title: '提示', icon: 0}, function(){
var ii = layer.load(2);
$.ajax({
type: 'POST',
url: '/cloudflare/tunnels/hostnameroutes/delete/{$accountId}',
data: {tunnel_id: selectedTunnelId, route_id: routeId},
dataType: 'json',
success: function(res){
layer.close(ii);
if(res.code === 0){
layer.closeAll();
$("#modal-hostname-route").modal('show');
layer.msg(res.msg, {icon: 1, time: 1000});
$("#hostnameRouteTable").bootstrapTable('refresh');
}else{
layer.alert(res.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.alert('服务器错误', {icon: 2});
}
});
});
}
function copyPlainText(text){
var temp = document.createElement('textarea');
temp.style.position = 'absolute';
temp.style.left = '-9999px';
temp.value = text || '';
document.body.appendChild(temp);
temp.select();
document.execCommand('copy');
document.body.removeChild(temp);
layer.msg('已复制到剪贴板', {icon: 1, time: 600});
}
function escapeJs(str){
return String(str || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}
function htmlEscape(str){
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
</script>
{/block}

View File

@@ -106,13 +106,16 @@
{if request()->user['type'] eq 'user'}<li class="{:checkIfActive('index')}">
<a href="/"><i class="fa fa-home fa-fw"></i> <span>后台首页</span></a>
</li>{/if}
<li class="{:checkIfActive('domain,record,record_log,record_batch_add,domain_add,weight,record_batch_add2,record_batch_edit2,expire_notice')}">
<li class="{:checkIfActive('domain,record,record_log,record_batch_add,domain_add,weight,record_batch_add2,record_batch_edit2,expire_notice,smartparse')}">
<a href="/domain"><i class="fa fa-list-ul fa-fw"></i> <span>域名管理</span></a>
</li>
{if request()->user['level'] eq 2}
<li class="{:checkIfActive('account,account_add')}">
<a href="/account"><i class="fa fa-lock fa-fw"></i> <span>域名账户</span></a>
</li>
<li class="{:checkIfActive('category')}">
<a href="/domain/category"><i class="fa fa-folder fa-fw"></i> <span>域名分类</span></a>
</li>
<li class="treeview {:checkIfActive('overview,task,taskinfo,taskform')}">
<a href="javascript:;">
<i class="fa fa-heartbeat fa-fw"></i>

View File

@@ -60,6 +60,10 @@
<label class="col-sm-4 control-label">群机器人Webhook</label>
<div class="col-sm-8"><select class="form-control" name="notice_webhook" default="{:config_get('notice_webhook')}"><option value="0">关闭</option><option value="1">开启</option></select></div>
</div>
<div class="form-group">
<label class="col-sm-4 control-label">自定义Webhook</label>
<div class="col-sm-8"><select class="form-control" name="notice_custom_webhook" default="{:config_get('notice_custom_webhook')}"><option value="0">关闭</option><option value="1">开启</option></select></div>
</div>
</form>
</div>
<div class="modal-footer">

View File

@@ -29,6 +29,7 @@
<script src="/static/js/bootstrap-table-page-jump-to-1.21.4.min.js"></script>
<script src="/static/js/custom.js"></script>
<script>
var userLevel = "{$user['level']|default=''}";
$(document).ready(function(){
updateToolbar();
const defaultPageSize = 15;
@@ -69,6 +70,10 @@ $(document).ready(function(){
title: '操作',
formatter: function(value, row, index) {
var html = '<a href="/account/edit?id='+row.id+'" class="btn btn-info btn-xs">编辑</a> <a href="javascript:delItem('+row.id+')" class="btn btn-danger btn-xs">删除</a> <a href="/domain?aid='+row.id+'" class="btn btn-default btn-xs">域名</a>';
var rowType = String(row.type || '').toLowerCase();
if(userLevel == '2' && rowType === 'cloudflare'){
html += ' <a href="/cloudflare/tunnels/'+row.id+'" class="btn btn-default btn-xs">Tunnels</a>';
}
return html;
}
},

View File

@@ -20,7 +20,7 @@
<label class="col-sm-3 control-label no-padding-right" is-required>账户类型</label>
<div class="col-sm-6">
<select name="type" class="form-control" v-model="set.type">
<option v-for="(item, key) in typeList" :value="key">{{item.name}}</option>
<option v-for="(item, key) in typeList" :value="key" :data-icon="item.icon">{{item.name}}</option>
</select>
</div>
</div>
@@ -95,6 +95,8 @@
{block name="script"}
<script src="/static/js/vue-2.7.16.min.js"></script>
<script src="/static/js/layer/layer.js"></script>
<script src="/static/js/select2-4.0.13.min.js"></script>
<script src="/static/js/select2-i18n-zh-CN-4.0.13.min.js"></script>
<script src="/static/js/bootstrapValidator.min.js"></script>
<script>
var info = {$info|json_encode|raw};
@@ -163,8 +165,28 @@ new Vue({
this.set.type = Object.keys(typeList)[0]
}
var that = this;
this.$nextTick(function () {
$('[data-toggle="tooltip"]').tooltip();
function formatType(option) {
if (!option.id) return option.text;
var icon = $(option.element).data('icon');
if (icon) {
return $('<span><img src="/static/images/' + icon + '" class="type-logo" />' + option.text + '</span>');
}
return option.text;
}
$('select[name=type]').select2({
templateResult: formatType,
templateSelection: formatType,
minimumResultsForSearch: Infinity,
width: '100%'
}).on('select2:select', function(e){
that.set.type = e.params.data.id;
});
if(that.action == 'edit'){
$('select[name=type]').val(that.set.type).trigger('change.select2');
}
})
},
methods: {

132
app/view/domain/alias.html Normal file
View File

@@ -0,0 +1,132 @@
{extend name="common/layout" /}
{block name="title"}域名别名 - {$domainName}{/block}
{block name="main"}
<style>
.table-bordered>tbody>tr>td{overflow: hidden;text-overflow: ellipsis;white-space: nowrap;max-width:200px;vertical-align:middle;}
</style>
<div class="row">
<div class="col-xs-12 center-block" style="float: none;">
<div class="panel panel-default">
<div class="panel-heading"><h3 class="panel-title"><a href="/record/{$domainId}" class="btn btn-sm btn-default pull-right" style="margin-top:-6px"><i class="fa fa-reply fa-fw"></i> 返回</a>域名别名 - {$domainName}</h3></div>
<div class="panel-body">
<div class="alert alert-info">
<i class="fa fa-info-circle"></i> 域名别名使用完全相同的解析记录,免除重复操作,仅支持专业版及以上套餐
</div>
<form id="form-add" class="form-inline" onsubmit="return addAlias()">
<div class="form-group">
<input type="text" class="form-control" name="alias" id="aliasInput" placeholder="请输入想要绑定的别名" required style="min-width:280px;">
</div>
<button type="submit" class="btn btn-primary"><i class="fa fa-plus"></i> 添加别名</button>
</form>
<hr/>
<table id="listTable" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th>域名别名</th>
<th>域名状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{volist name="domainAliasList" id="item"}
<tr data-id="{$item.Id}">
<td>{$item.DomainAlias}</td>
<td>
{if isset($item.Status)}
{if $item.Status == '2'}
<font color="green"><i class="fa fa-check-circle"></i> 正常</font>
{elseif $item.Status == '3'}
<font color="red"><i class="fa fa-ban"></i> 封禁</font>
{else/}
<font color="#b5bbc8"><i class="fa fa-pause-circle"></i> DNS不正确</font>
{/if}
{else/}
-
{/if}
</td>
<td>
<a href="javascript:delAlias({$item.Id})" class="btn btn-danger btn-xs"><i class="fa fa-trash"></i> 删除</a>
</td>
</tr>
{/volist}
{empty name="domainAliasList"}
<tr><td colspan="3" class="text-center text-muted">暂无域名别名</td></tr>
{/empty}
</tbody>
</table>
</div>
</div>
</div>
</div>
{/block}
{block name="script"}
<script src="/static/js/layer/layer.js"></script>
<script src="/static/js/custom.js"></script>
<script>
var domainId = {$domainId};
function addAlias() {
var alias = $("#aliasInput").val().trim();
if (!alias) {
layer.msg('请输入想要绑定的别名');
return false;
}
var ii = layer.load(2);
$.ajax({
type: 'POST',
url: '/record/alias/' + domainId + '?act=add',
data: { alias: alias },
dataType: 'json',
success: function(data) {
layer.close(ii);
if (data.code == 0) {
layer.alert(data.msg, { icon: 1 }, function() {
layer.closeAll();
location.reload();
});
} else {
layer.alert(data.msg || '添加失败', { icon: 2 });
}
},
error: function() {
layer.close(ii);
layer.alert('网络请求失败', { icon: 2 });
}
});
return false;
}
function delAlias(id) {
layer.confirm('确定要删除该域名别名吗?', { icon: 3, title: '提示' }, function(idx) {
var ii = layer.load(2);
$.ajax({
type: 'POST',
url: '/record/alias/' + domainId + '?act=delete',
data: { alias_id: id },
dataType: 'json',
success: function(data) {
layer.close(ii);
layer.close(idx);
if (data.code == 0) {
layer.alert(data.msg, { icon: 1 }, function() {
layer.closeAll();
location.reload();
});
} else {
layer.alert(data.msg || '删除失败', { icon: 2 });
}
},
error: function() {
layer.close(ii);
layer.close(idx);
layer.alert('网络请求失败', { icon: 2 });
}
});
});
}
</script>
{/block}

View File

@@ -0,0 +1,204 @@
{extend name="common/layout" /}
{block name="title"}域名分类管理{/block}
{block name="main"}
<div class="modal" id="modal-store" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog">
<div class="modal-content animated flipInX">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span><span class="sr-only">Close</span></button>
<h4 class="modal-title" id="modal-title">添加分类</h4>
</div>
<div class="modal-body">
<form class="form-horizontal" id="form-store">
<input type="hidden" name="id"/>
<div class="form-group">
<label class="col-sm-3 control-label">分类名称</label>
<div class="col-sm-8">
<input type="text" class="form-control" name="name" placeholder="输入分类名称" required>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">排序</label>
<div class="col-sm-8">
<input type="number" class="form-control" name="sort" value="0" placeholder="数字越小越靠前">
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">备注</label>
<div class="col-sm-8">
<input type="text" class="form-control" name="remark" placeholder="可选">
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-white" data-dismiss="modal">关闭</button>
<button type="button" class="btn btn-primary" id="store" onclick="save()">保存</button>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12 center-block" style="float: none;">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">域名分类管理</h3>
</div>
<div class="panel-body">
<form onsubmit="return searchSubmit()" method="GET" class="form-inline" id="searchToolbar">
<a href="javascript:addframe()" class="btn btn-success"><i class="fa fa-plus"></i> 添加分类</a>
<a href="javascript:searchClear()" class="btn btn-default" title="刷新列表"><i class="fa fa-refresh"></i> 刷新</a>
</form>
<table id="listTable"></table>
</div>
</div>
</div>
</div>
{/block}
{block name="script"}
<script src="/static/js/layer/layer.js"></script>
<script src="/static/js/bootstrap-table-1.21.4.min.js"></script>
<script src="/static/js/bootstrap-table-page-jump-to-1.21.4.min.js"></script>
<script src="/static/js/bootstrapValidator.min.js"></script>
<script src="/static/js/custom.js?v=1003"></script>
<script>
$(document).ready(function(){
updateToolbar();
let defaultPageSize = getCookie('category_pagesize') ? getCookie('category_pagesize') : 10;
const pageNumber = typeof window.$_GET['pageNumber'] != 'undefined' ? parseInt(window.$_GET['pageNumber']) : 1;
const pageSize = typeof window.$_GET['pageSize'] != 'undefined' ? parseInt(window.$_GET['pageSize']) : defaultPageSize;
$("#listTable").bootstrapTable({
url: '/domain/category/data',
pageNumber: pageNumber,
pageSize: pageSize,
classes: 'table table-striped table-hover table-bordered',
uniqueId: 'id',
columns: [
{
field: 'id',
title: 'ID'
},
{
field: 'name',
title: '分类名称'
},
{
field: 'remark',
title: '备注',
formatter: function(value, row, index) {
return value ? value : '-';
}
},
{
field: 'sort',
title: '排序'
},
{
field: 'domain_count',
title: '域名数量',
formatter: function(value, row, index) {
return '<span class="label label-info">' + value + '</span>';
}
},
{
field: 'addtime',
title: '添加时间'
},
{
field: 'action',
title: '操作',
formatter: function(value, row, index) {
var html = '<a href="javascript:editframe(\''+row.id+'\')" class="btn btn-primary btn-xs">修改</a>&nbsp;&nbsp;';
html += '<a href="javascript:delItem(\''+row.id+'\')" class="btn btn-danger btn-xs">删除</a>&nbsp;&nbsp;';
html += '<a href="/domain?cid='+row.id+'" class="btn btn-default btn-xs">域名</a>';
return html;
}
},
],
onPageChange: function(number, size){
if(size != defaultPageSize){
setCookie('category_pagesize', size, 24 * 3600 * 30);
}
},
});
$("#form-store").bootstrapValidator();
});
function addframe(){
$("#modal-store").modal('show');
$("#modal-title").html("添加分类");
$("#form-store input[name=id]").val('');
$("#form-store input[name=name]").val('');
$("#form-store input[name=sort]").val('0');
$("#form-store input[name=remark]").val('');
$("#form-store").data("bootstrapValidator").resetForm();
}
function editframe(id){
var row = $("#listTable").bootstrapTable('getRowByUniqueId', id);
$("#modal-store").modal('show');
$("#modal-title").html("修改分类");
$("#form-store input[name=id]").val(id);
$("#form-store input[name=name]").val(row.name);
$("#form-store input[name=sort]").val(row.sort);
$("#form-store input[name=remark]").val(row.remark);
$("#form-store").data("bootstrapValidator").resetForm();
}
function save(){
$("#form-store").data("bootstrapValidator").validate();
if(!$("#form-store").data("bootstrapValidator").isValid()){
return;
}
var id = $("#form-store input[name=id]").val();
var action = id ? 'edit' : 'add';
var ii = layer.load(2);
$.ajax({
type : 'POST',
url : '/domain/category/' + action,
data : $("#form-store").serialize(),
dataType : 'json',
success : function(data) {
layer.close(ii);
if(data.code == 0){
layer.alert(data.msg, {
icon: 1,
closeBtn: false
}, function(){
layer.closeAll();
$("#modal-store").modal('hide');
searchRefresh();
});
}else{
layer.alert(data.msg, {icon: 2});
}
}
});
}
function delItem(id) {
layer.confirm('确定要删除此分类吗?', {title: '提示', icon: 0}, function(){
var ii = layer.load(2);
$.ajax({
type : 'POST',
url : '/domain/category/del',
data : {id: id},
dataType : 'json',
success : function(data) {
layer.close(ii);
if(data.code == 0){
layer.closeAll();
layer.msg('删除成功', {icon: 1, time:800});
searchRefresh();
}else{
layer.alert(data.msg, {icon: 2});
}
}
});
});
}
</script>
{/block}

View File

@@ -107,12 +107,23 @@
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label no-padding-right">备注</label>
<div class="col-sm-9">
<input type="text" class="form-control" name="remark" placeholder="">
</div>
<label class="col-sm-3 control-label">所属分类</label>
<div class="col-sm-9">
<select name="cid" class="form-control">
<option value="0">未分类</option>
{foreach $categorys as $item}
<option value="{$item.id}">{$item.name}</option>
{/foreach}
</select>
</div>
</form>
</div>
<div class="form-group">
<label class="col-sm-3 control-label no-padding-right">备注</label>
<div class="col-sm-9">
<input type="text" class="form-control" name="remark" placeholder="">
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-white" data-dismiss="modal">关闭</button>
@@ -138,6 +149,9 @@
<option value="{$k}">{$v}</option>
{/foreach}</select>
</div>
<div class="form-group">
<select name="cid" class="form-control"><option value="">所有分类</option>{foreach $categorys as $item}<option value="{$item.id}">{$item.name}</option>{/foreach}</select>
</div>
<div class="form-group">
<select name="status" class="form-control"><option value="">所有状态</option><option value="1">即将到期</option><option value="2">已到期</option></select>
</div>
@@ -146,10 +160,10 @@
</div>
<button type="submit" class="btn btn-primary"><i class="fa fa-search"></i> 搜索</button>
<a href="javascript:searchClear()" class="btn btn-default" title="刷新域名列表"><i class="fa fa-refresh"></i> 刷新</a>
{if request()->user['level'] eq 2}<a href="javascript:addframe()" class="btn btn-success"><i class="fa fa-plus"></i> 添加</a>
{if $user['level'] eq 2}<a href="javascript:addframe()" class="btn btn-success"><i class="fa fa-plus"></i> 添加</a>
<div class="btn-group" role="group">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">批量操作 <span class="caret"></span></button>
<ul class="dropdown-menu"><li><a href="/domain/add">添加域名</a></li><li><a href="javascript:operation('editremark')">修改域名备注</a></li><li><a href="javascript:operation('opennotice')">开启到期提醒</a></li><li><a href="javascript:operation('closenotice')">关闭到期提醒</a></li><li><a href="javascript:operation('updateexpire')">刷新到期时间</a></li><li><a href="javascript:operation('delete')">删除域名</a></li><li role="separator" class="divider"></li><li><a href="javascript:operation('addrecord')">添加解析</a></li><li><a href="javascript:operation('editrecord')">修改解析</a></li></ul>
<ul class="dropdown-menu"><li><a href="/domain/add">添加域名</a></li><li><a href="javascript:operation('setcategory')">设置分类</a></li><li><a href="javascript:operation('editremark')">修改域名备注</a></li><li><a href="javascript:operation('opennotice')">开启到期提醒</a></li><li><a href="javascript:operation('closenotice')">关闭到期提醒</a></li><li><a href="javascript:operation('updateexpire')">刷新到期时间</a></li><li><a href="javascript:operation('delete')">删除域名</a></li><li role="separator" class="divider"></li><li><a href="javascript:operation('addrecord')">添加解析</a></li><li><a href="javascript:operation('editrecord')">修改解析</a></li><li><a href="/record/smartparse">智能添加解析</a></li></ul>
</div>
<a href="/domain/expirenotice" class="btn btn-default">到期提醒设置</a>{/if}
</form>
@@ -172,7 +186,7 @@
<script src="/static/js/select2-i18n-zh-CN-4.0.13.min.js"></script>
<script src="/static/js/custom.js"></script>
<script>
var userLevel = "{:request()->user['level']}";
var userLevel = "{$user['level']|default=''}";
$(document).ready(function(){
updateToolbar();
const defaultPageSize = getCookie('domain_pagesize') ? getCookie('domain_pagesize') : 15;
@@ -284,6 +298,13 @@ $(document).ready(function(){
return value==1?'<font color="green">是</font>':'<font color="red">否</font>';
}
},
{
field: 'category_name',
title: '分类',
formatter: function(value, row, index) {
return value ? '<span class="label label-default">' + value + '</span>' : '-';
}
},
{
field: 'remark',
title: '备注'
@@ -400,6 +421,7 @@ function editframe(id){
$("#form-store2 select[name=is_hide]").val(row.is_hide);
$("#form-store2 select[name=is_sso]").val(row.is_sso);
$("#form-store2 select[name=is_notice]").val(row.is_notice);
$("#form-store2 select[name=cid]").val(row.cid ? row.cid : 0);
$("#form-store2 input[name=remark]").val(row.remark);
$("#form-store2 input[name=expiretime]").datetimepicker({
@@ -504,6 +526,9 @@ function operation(action){
if(action == 'editremark'){
batch_edit_remark(ids)
return;
}else if(action == 'setcategory'){
batch_set_category(ids)
return;
}else if(action == 'addrecord'){
sessionStorage.setItem('domains', JSON.stringify(rows));
window.location.href = '/record/batchadd';
@@ -607,6 +632,56 @@ function batch_edit_remark(ids) {
}
});
}
function batch_set_category(ids) {
var categoryOptions = '<option value="0">未分类</option>';
$.ajax({
type : 'GET',
url : '/domain/category/list',
dataType : 'json',
async: false,
success : function(data) {
if(data.code == 0 && data.data){
$.each(data.data, function(index, item){
categoryOptions += '<option value="' + item.id + '">' + item.name + '</option>';
});
}
}
});
layer.open({
type: 1,
area: ['350px'],
closeBtn: 2,
title: '批量设置分类',
content: '<div style="padding:15px"><div class="form-group"><select class="form-control" name="category_id">' + categoryOptions + '</select></div></div>',
btn: ['确认', '取消'],
yes: function(){
var cid = $("select[name='category_id']").val();
var ii = layer.load(2, {shade:[0.1,'#fff']});
$.ajax({
type : 'POST',
url : '/domain/setcategory',
data : {ids:ids, cid:cid},
dataType : 'json',
success : function(data) {
layer.close(ii);
layer.alert(data.msg,{
icon: 1,
closeBtn: false
}, function(){
layer.closeAll();
searchRefresh();
});
},
error:function(data){
layer.close(ii);
layer.msg('服务器错误');
}
});
}
});
}
function updateDate(id){
var ii = layer.load(2);
$.ajax({

View File

@@ -95,6 +95,7 @@ new Vue({
},
async getDomainList(){
this.domainList = [];
this.page = 1;
while(true){
try{
layer.msg('正在获取第'+this.page+'页域名', {icon: 16, shade: 0.01});

View File

@@ -28,6 +28,10 @@
<label class="col-sm-3 control-label">群机器人Webhook</label>
<div class="col-sm-9"><select class="form-control" name="expire_notice_webhook" default="{:config_get('expire_notice_webhook')}"><option value="0">关闭</option><option value="1">开启</option></select></div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">自定义Webhook</label>
<div class="col-sm-9"><select class="form-control" name="expire_notice_custom_webhook" default="{:config_get('expire_notice_custom_webhook')}"><option value="0">关闭</option><option value="1">开启</option></select></div>
</div>
<div class="form-group">
<div class="col-sm-offset-3 col-sm-9">
<input type="submit" name="submit" value="保存" class="btn btn-primary btn-block"/>

View File

@@ -55,12 +55,12 @@ td{overflow: hidden;text-overflow: ellipsis;white-space: nowrap;max-width:360px;
<input type="text" class="form-control" name="value" placeholder="输入记录值" required>
</div>
</div>
<div class="form-group" style="display:none" id="mx_type">
{if $dnsconfig.type!='huawei'}<div class="form-group" style="display:none" id="mx_type">
<label class="col-sm-3 control-label no-padding-right">MX优先级</label>
<div class="col-sm-9">
<input type="text" class="form-control" name="mx" value="10">
</div>
</div>
</div>{/if}
<div class="form-group">
<label class="col-sm-3 control-label no-padding-right">TTL</label>
<div class="col-sm-9">
@@ -183,7 +183,9 @@ td{overflow: hidden;text-overflow: ellipsis;white-space: nowrap;max-width:360px;
<button type="submit" class="btn btn-primary"><i class="fa fa-search"></i> 搜索</button>
<a href="javascript:searchClear()" class="btn btn-default" title="刷新解析记录列表"><i class="fa fa-refresh"></i> 刷新</a>
<a href="javascript:addframe()" class="btn btn-success"><i class="fa fa-plus"></i> 添加记录</a>
{if $dnsconfig.type=='cloudflare' && $user['level'] eq 2}<a href="/cloudflare/hostnames/{$domainId}" class="btn btn-default">Cloudflare自定义主机名</a>{/if}
{if $dnsconfig.type=='aliyun'}<a href="/record/weight/{$domainId}" class="btn btn-default">权重配置</a>{/if}
{if $dnsconfig.type=='dnspod'}<a href="/record/alias/{$domainId}" class="btn btn-default">域名别名</a>{/if}
<div class="btn-group" role="group">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">批量操作 <span class="caret"></span></button>
<ul class="dropdown-menu"><li><a href="/record/batchadd/{$domainId}">添加</a></li><li><a href="javascript:operation('open')">启用</a></li><li><a href="javascript:operation('pause')">暂停</a></li><li><a href="javascript:operation('edit')">修改记录</a></li><li><a href="javascript:operation('editline')">修改线路</a></li>{if $dnsconfig.remark == 1}<li><a href="javascript:operation('editremark')">修改备注</a></li>{/if}<li><a href="javascript:operation('delete')">删除</a></li></ul>
@@ -291,7 +293,7 @@ $(document).ready(function(){
title: '记录值',
formatter: function(value, row, index) {
var copyId = 'copy-value-' + row.RecordId;
if(row.Type == 'MX') {
if(row.Type == 'MX' && dnsconfig.type!='huawei') {
return '<span id="'+copyId+'" data-value="'+htmlEscape(value)+'">'+value+'</span>'
+ '<a href="javascript:void(0);" title="复制记录值" onclick="copyToClipboard(null, \'#'+copyId+'\')" style="padding-left:6px;"><i class=\"fa fa-copy\"></i></a>'
+ '<span class="mx-priority"> | '+row.MX+'</span>';
@@ -348,6 +350,10 @@ $(document).ready(function(){
if(dnsconfig.remark == 1){
html += '<a href="javascript:setRemark(\''+row.RecordId+'\')" class="btn btn-info btn-xs">备注</a>&nbsp;&nbsp;';
}
var supportedTypes = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SOA', 'SRV', 'CAA', 'PTR', 'LOC', 'LUA', 'REDIRECT_URL', 'FORWARD_URL'];
if(supportedTypes.includes(row.Type)){
html += '<a href="javascript:checkRecord(\''+row.RecordId+'\')" class="btn btn-success btn-xs" title="检测解析生效"><i class="fa fa-check-circle-o"></i></a>&nbsp;&nbsp;';
}
if(row.Type == 'A' || row.Type == 'CNAME' || row.Type == 'AAAA' || row.Type == 'REDIRECT_URL' || row.Type == 'FORWARD_URL'){
if(row.Name === "@") var domain = "{$domainName}";
else var domain = row.Name + ".{$domainName}";
@@ -723,6 +729,48 @@ function advanceSearch(){
$("#searchbox1").slideDown();
}
}
function checkRecord(recordid) {
var row = $("#listTable").bootstrapTable('getRowByUniqueId', recordid);
var ii = layer.load(2);
$.ajax({
type : 'POST',
url : '/record/check/{$domainId}',
data : {recordid: recordid, name: row.Name, type: row.Type, value: Array.isArray(row.Value) ? row.Value[0] : row.Value},
dataType : 'json',
success : function(data) {
layer.close(ii);
if(data.code == 0){
var result = data.data;
var title = result.status === 'active' ? '<font color="green"><i class="fa fa-check-circle"></i> 解析已生效</font>' : (result.status === 'not_found' ? '<font color="red"><i class="fa fa-times-circle"></i> 未查询到解析</font>' : '<font color="red"><i class="fa fa-times-circle"></i> 解析值不匹配</font>');
var content = '<div style="padding:0 10px;">';
content += '<p><strong>主机记录:</strong>' + row.Name + '</p>';
content += '<p><strong>记录类型:</strong>' + row.Type + '</p>';
content += '<p><strong>记录值:</strong>' + htmlEscape(row.Value) + '</p>';
content += '<hr style="margin:10px 0;">';
content += '<p><strong>检测结果:</strong>' + title + '</p>';
if(result.actual && result.actual.length > 0){
content += '<p><strong>实际解析值:</strong></p>';
content += '<ul style="max-height:150px;overflow-y:auto;">';
for(var i = 0; i < result.actual.length; i++){
content += '<li>' + htmlEscape(result.actual[i]) + '</li>';
}
content += '</ul>';
}
if(result.expected){
content += '<p><strong>期望解析值:</strong>' + htmlEscape(result.expected) + '</p>';
}
content += '</div>';
layer.alert(content, {title: 'DNS解析检测', area: ['450px'], shadeClose: true});
}else{
layer.alert(data.msg, {icon: 2});
}
},
error: function(){
layer.close(ii);
layer.msg('服务器错误', {icon: 2});
}
});
}
function copyToClipboard(text, selector) {
if (!text && selector) {
var el = document.querySelector(selector);

View File

@@ -0,0 +1,825 @@
{extend name="common/layout" /}
{block name="title"}智能批量添加{/block}
{block name="main"}
<style>
.modal-body .form-group {
margin-bottom: 15px;
}
.batch-input-area {
min-height: 200px;
resize: vertical;
}
.batch-preview {
max-height: 300px;
overflow-y: auto;
margin-top: 20px;
border: 1px solid #e0e0e0;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.batch-preview table {
width: 100%;
border-collapse: collapse;
background: #fff;
margin-bottom: 0;
}
.batch-preview th,
.batch-preview td {
padding: 8px 10px;
text-align: left;
border: 0.5px solid #f0f0f0;
vertical-align: middle;
font-size: 13px;
white-space: nowrap;
}
.batch-preview th {
background-color: #f9f9f9;
font-weight: 600;
color: #333;
border-bottom: 1px solid #e0e0e0;
position: sticky;
top: 0;
z-index: 10;
}
.batch-preview tr:hover {
background-color: #fafafa;
}
.batch-preview .label {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: 600;
}
.batch-preview .label-primary {
background-color: #337ab7;
color: #fff;
}
.batch-preview .status-success {
color: #52c41a;
font-weight: 600;
font-size: 12px;
}
.domain-select-modal {
max-height: 400px;
overflow-y: auto;
}
.domain-item {
cursor: pointer;
padding: 8px 12px;
margin-bottom: 4px;
border: 2px solid #ddd;
border-radius: 3px;
transition: all 0.2s;
}
.domain-item:hover {
border-color: #337ab7;
background-color: #f5f9fc;
}
.domain-item.selected {
border-color: #337ab7;
background-color: #e7f3ff;
}
.domain-item.selected::after {
content: '✓';
float: right;
color: #337ab7;
font-weight: bold;
}
</style>
<div class="row">
<div class="col-xs-12 center-block" style="float: none;">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title"><a href="/domain" class="btn btn-sm btn-default pull-right" style="margin-top:-6px"><i class="fa fa-reply fa-fw"></i> 返回</a>智能批量添加解析</h3>
</div>
<div class="panel-body">
<form class="form-horizontal" id="batchForm">
<div class="form-group">
<label class="col-sm-3 control-label">批量数据 <span class="text-danger">*</span></label>
<div class="col-sm-6">
<textarea class="form-control batch-input-area" id="batchInput" rows="10"
placeholder="请按以下格式输入(每行一条记录):&#10;格式1主机记录 记录值&#10;格式2主机记录 记录值 域名&#10;格式3记录值 主机记录.域名&#10;格式4主机记录.域名(使用下方记录值)&#10;&#10;示例:&#10;www 1.2.3.4 example.com&#10;api app.example.com example.com&#10;1.1.1.1 www.example.com&#10;example.com&#10;&#10;说明:&#10;- 如果使用格式4将使用下方的记录值&#10;- 如果不指定域名,将使用下方选择的默认域名&#10;- 如果检测到多个不同域名会提示您选择对应的DNS配置"></textarea>
<p class="help-block">每行一条记录,支持混合输入多个域名的记录</p>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">记录值</label>
<div class="col-sm-6">
<input type="text" class="form-control" id="batchValueInput" placeholder="当使用格式4时将使用此记录值">
<p class="help-block">留空则不使用格式4</p>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">默认域名</label>
<div class="col-sm-6">
<select name="defaultDomain" id="defaultDomainSelect" class="form-control select2">
<option value="">不使用默认域名(必须每行都指定域名)</option>
{foreach $domainList as $domain}
<option value="{$domain.id}">{$domain.name} [{$domain.dnsType}]</option>
{/foreach}
</select>
<p class="help-block">当某行没有指定域名时,使用此默认域名</p>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">记录类型</label>
<div class="col-sm-6">
<select name="defaultType" id="defaultTypeSelect" class="form-control">
<option value="">自动检测</option>
<option value="A">A</option>
<option value="CNAME">CNAME</option>
<option value="AAAA">AAAA</option>
<option value="NS">NS</option>
<option value="MX">MX</option>
<option value="SRV">SRV</option>
<option value="TXT">TXT</option>
<option value="CAA">CAA</option>
</select>
<p class="help-block">留空则根据记录值自动判断类型</p>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">线路</label>
<div class="col-sm-6" id="batch_line_list">
<select name="defaultLine" id="defaultLineSelect" class="form-control" onchange="changeBatchLine(this)">
<option value="">自动选择</option>
</select>
<p class="help-block">留空则使用默认线路</p>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">TTL</label>
<div class="col-sm-6">
<input type="number" class="form-control" name="defaultTtl" id="defaultTtlInput" value="600" min="1">
<p class="help-block">默认TTL时间</p>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-3 col-sm-6">
<button type="button" class="btn btn-info" onclick="previewBatchData()"><i class="fa fa-eye"></i> 预览解析结果</button>
<button type="button" class="btn btn-primary" id="btnBatchAdd" onclick="submitBatchData()"><i class="fa fa-plus-circle"></i> 批量添加解析</button>
<button type="button" class="btn btn-default" onclick="resetBatchForm()"><i class="fa fa-refresh"></i> 重置</button>
</div>
</div>
</form>
<div class="form-group col-sm-12" id="previewSection" style="display:none;margin-top:20px;">
<label>解析预览</label>
<div class="table-responsive batch-preview">
<table style="min-width: 800px;">
<thead>
<tr>
<th style="width:5%">序号</th>
<th style="width:15%">主机记录</th>
<th style="width:10%">类型</th>
<th style="width:25%">记录值</th>
<th style="width:18%">DNS域名</th>
<th style="width:12%">线路</th>
<th style="width:8%">TTL</th>
<th style="width:7%">状态</th>
</tr>
</thead>
<tbody id="previewBody">
</tbody>
</table>
</div>
<div class="alert alert-info" id="previewSummary" style="margin-top:10px;"></div>
</div>
</div>
</div>
</div>
<div class="modal" id="modal-domain-select" role="dialog" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-md">
<div class="modal-content animated flipInX">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span><span class="sr-only">Close</span></button>
<h4 class="modal-title">选择DNS配置</h4>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="fa fa-exclamation-triangle"></i> 检测到多个不同的域名请为每个域名选择对应的DNS配置
</div>
<div class="domain-select-modal" id="domainSelectModal">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-white" data-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="confirmDomainSelection()">确定</button>
</div>
</div>
</div>
</div>
{/block}
{block name="script"}
<script src="/static/js/layer/layer.js"></script>
<script src="/static/js/select2-4.0.13.min.js"></script>
<script>
var domainList = [];
{foreach $domainList as $domain}
domainList.push({
id: '{$domain.id}',
name: '{$domain.name}',
dnsType: '{$domain.dnsType}'
});
{/foreach}
var parsedBatchData = [];
var domainMapping = {};
$(document).ready(function(){
$('#defaultDomainSelect').select2({
placeholder: '选择默认域名',
allowClear: true,
width: '100%',
language: {
noResults: function(){ return '未找到匹配的域名'; },
searching: function(){ return '搜索中...'; }
}
});
$('#defaultDomainSelect').on('change', function(){
var domainId = $(this).val();
if(domainId){
var ii = layer.load(2);
$.ajax({
type : 'POST',
url : '/record/quickinfo/' + domainId,
dataType : 'json',
success : function(data) {
layer.close(ii);
if(data.code == 0){
var lineOptions = '<option value="">自动选择</option>';
var firstOption = null;
$.each(data.data.recordLine, function(index, item){
if(item.parent == null){
if(!firstOption) firstOption = item.id;
lineOptions += '<option value="'+item.id+'">'+item.name+'</option>';
}
});
$('#batch_line_list').html('<select name="defaultLine" id="defaultLineSelect" class="form-control" onchange="changeBatchLine(this)">'+lineOptions+'</select>');
window.currentRecordLine = data.data.recordLine;
if(firstOption){
$('#defaultLineSelect').val(firstOption).trigger('change');
}
}else{
layer.alert(data.msg, {icon: 2});
}
},
error : function() {
layer.close(ii);
layer.alert('获取域名信息失败', {icon: 2});
}
});
}
});
});
function changeBatchLine(obj){
var line = $(obj).val();
var flag = false;
$("#batch_line_list").children().each(function(index, elem){
if(flag) $(elem).remove()
if(obj == elem){ flag = true; }
})
if($(obj).find("option:selected").text() == '子集线路(非必填)') return;
if(window.currentRecordLine){
var tempLine = window.currentRecordLine.filter((x) => x.parent == line)
if(tempLine.length > 0){
var option = line.substr(0,2) == 'N.' ? '' : '<option value="'+line+'">子集线路(非必填)</option>';
$.each(tempLine, function(index, item){
option += '<option value="'+item.id+'">'+item.name+'</option>';
})
$("#batch_line_list").append('<select name="defaultLine" class="form-control" onchange="changeBatchLine(this)">'+option+'</select>');
}
}
}
function resetBatchForm(){
$('#batchInput').val('');
$('#batchValueInput').val('');
$('#defaultDomainSelect').val(null).trigger('change');
$('#defaultTypeSelect').val('');
$('#defaultTtlInput').val(600);
$('#defaultLineSelect').val('').trigger('change');
$('#batch_line_list').empty();
$('#batch_line_list').append('<select name="defaultLine" id="defaultLineSelect" class="form-control" onchange="changeBatchLine(this)"><option value="">自动选择</option></select>');
$('#previewSection').hide();
parsedBatchData = [];
domainMapping = {};
}
function previewBatchData(){
var inputText = $('#batchInput').val().trim();
if(!inputText){
layer.alert('请输入批量解析数据', {icon: 2});
return;
}
var lines = inputText.split('\n');
var defaultDomainId = $('#defaultDomainSelect').val();
var defaultType = $('#defaultTypeSelect').val();
var defaultLine = $('#batch_line_list select[name=defaultLine]').last().val() || $('#defaultLineSelect').val();
var defaultTtl = $('#defaultTtlInput').val();
var batchValue = $('#batchValueInput').val();
parsedBatchData = [];
domainMapping = {};
var uniqueDomains = new Set();
var errors = [];
$.each(lines, function(index, line){
line = $.trim(line);
if(!line) return;
var parts = line.split(/\s+/);
if(parts.length == 1 && batchValue){
var domainPart = parts[0];
var found = false;
var host = '@';
var domainName = domainPart;
var sortedDomains = domainList.slice().sort(function(a, b){
return b.name.length - a.name.length;
});
$.each(sortedDomains, function(i, domain){
var dnsDomainName = domain.name;
if(domainPart === dnsDomainName){
host = '@';
domainName = domain.name;
found = true;
return false;
}
else if(domainPart.endsWith('.' + dnsDomainName)){
host = domainPart.substring(0, domainPart.length - (dnsDomainName.length + 1));
domainName = domain.name;
found = true;
return false;
}
});
if(!found){
errors.push('第' + (index + 1) + '行:域名 "' + domainPart + '" 不在你的域名列表中');
return;
}
value = batchValue;
} else if(parts.length < 2){
errors.push('第' + (index + 1) + '行格式错误:至少需要主机记录和记录值');
return;
} else if(parts.length == 2){
var hostDomainPart = parts[1];
var found = false;
var sortedDomains = domainList.slice().sort(function(a, b){
return b.name.length - a.name.length;
});
$.each(sortedDomains, function(i, domain){
var dnsDomainName = domain.name;
if(hostDomainPart.endsWith('.' + dnsDomainName)){
host = hostDomainPart.substring(0, hostDomainPart.length - (dnsDomainName.length + 1));
value = parts[0];
domainName = domain.name;
found = true;
return false;
}
});
if(!found){
$.each(domainList, function(i, domain){
if(hostDomainPart === domain.name){
host = '@';
value = parts[0];
domainName = domain.name;
found = true;
return false;
}
});
if(!found){
host = parts[0];
value = parts[1];
domainName = parts[2] || null;
}
}
} else if(parts.length >= 2){
host = parts[0];
value = parts[1];
domainName = parts[2] || null;
}
var finalDomainId;
var finalDomainName;
if(domainName){
finalDomainName = domainName;
var foundDomain = null;
$.each(domainList, function(i, d){
if(d.name.toLowerCase() === domainName.toLowerCase()){
foundDomain = d;
return false;
}
});
if(foundDomain){
finalDomainId = foundDomain.id;
domainMapping[domainName] = foundDomain.id;
}else{
errors.push('第' + (index + 1) + '行:域名 "' + domainName + '" 不在你的域名列表中');
return;
}
}else if(defaultDomainId){
finalDomainId = defaultDomainId;
var defaultDomainObj = null;
$.each(domainList, function(i, d){
if(d.id === defaultDomainId){
defaultDomainObj = d;
return false;
}
});
finalDomainName = defaultDomainObj ? defaultDomainObj.name : '';
}else{
errors.push('第' + (index + 1) + '行:未指定域名且没有设置默认域名');
return;
}
uniqueDomains.add(finalDomainName);
var type = defaultType;
if(!type){
type = getDnsType(value);
}
parsedBatchData.push({
host: host,
value: value,
type: type,
domainId: finalDomainId,
domainName: finalDomainName,
line: defaultLine,
ttl: defaultTtl,
lineNumber: index + 1,
status: 'pending'
});
});
if(errors.length > 0){
layer.alert('发现以下错误:\n\n' + errors.join('\n'), {icon: 2});
return;
}
if(parsedBatchData.length === 0){
layer.alert('没有有效的解析记录', {icon: 2});
return;
}
var uniqueDnsTypes = new Set();
var domainDnsMap = {};
$.each(parsedBatchData, function(index, row){
var domainInfo = null;
$.each(domainList, function(i, d){
if(d.id === row.domainId){
domainInfo = d;
return false;
}
});
if(domainInfo){
uniqueDnsTypes.add(domainInfo.dnsType);
domainDnsMap[row.domainId] = domainInfo.dnsType;
}
});
var uniqueDomainIds = new Set(parsedBatchData.map(r => r.domainId));
if(uniqueDomainIds.size > 1){
showDomainSelectionModal(Array.from(uniqueDomainIds));
return;
}
renderPreview();
}
function getDnsType(value){
value = value.toLowerCase();
if(/^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(value)){
return 'A';
}else if(/^([a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(value)){
return 'CNAME';
}else if(/^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/.test(value)){
return 'AAAA';
}else if(/^\d+$/.test(value) && parseInt(value) <= 65535){
return 'MX';
}else{
return 'A';
}
}
function renderPreview(){
var html = '';
var validCount = 0;
$.each(parsedBatchData, function(index, row){
var statusHtml = '<span class="status-success"><i class="fa fa-check"></i> 待添加</span>';
validCount++;
html += '<tr>';
html += '<td style="text-align:center;">' + row.lineNumber + '</td>';
html += '<td>' + (row.host == '@' ? '@ (主域名)' : row.host) + '</td>';
html += '<td style="text-align:center;"><span class="label label-primary">' + row.type + '</span></td>';
html += '<td title="' + htmlEscape(row.value) + '">' + row.value + '</td>';
html += '<td><strong>' + row.domainName + '</strong></td>';
html += '<td style="text-align:center;">' + (row.line ? row.line : '默认') + '</td>';
html += '<td style="text-align:center;">' + row.ttl + '</td>';
html += '<td style="text-align:center;">' + statusHtml + '</td>';
html += '</tr>';
});
$('#previewBody').html(html);
$('#previewSummary').html('<strong>共 ' + validCount + ' 条记录待添加</strong>');
$('#previewSection').show();
}
function showDomainSelectionModal(domains){
var html = '';
$.each(domains, function(index, domainIdentifier){
var domainName = '';
var domainId = '';
if(!isNaN(domainIdentifier)){
var domainInfo = null;
$.each(domainList, function(i, d){
if(d.id === domainIdentifier){
domainInfo = d;
return false;
}
});
if(domainInfo){
domainName = domainInfo.name;
domainId = domainInfo.id;
}
}else{
domainName = domainIdentifier;
var domainInfo = null;
$.each(domainList, function(i, d){
if(d.name === domainIdentifier){
domainInfo = d;
return false;
}
});
if(domainInfo){
domainId = domainInfo.id;
}
}
if(!domainName) return;
var matches = [];
$.each(domainList, function(i, d){
if(d.name === domainName){
matches.push(d);
}
});
if(matches.length === 0){
matches = domainList;
}
html += '<div style="margin-bottom:20px;">';
html += '<h5><strong>' + domainName + '</strong></h5>';
html += '<div class="row">';
$.each(matches, function(j, match){
var isSelected = j === 0;
html += '<div class="col-md-6">';
html += '<div class="domain-item' + (isSelected ? ' selected' : '') + '" ';
html += 'data-domain="' + domainName + '" data-id="' + match.id + '" ';
html += 'onclick="selectDomainItem(this)">';
html += '<strong>' + match.name + '</strong> [' + match.dnsType + ']';
html += '</div>';
html += '</div>';
if(isSelected){
domainMapping[domainName] = match.id;
}
});
html += '</div></div>';
});
$('#domainSelectModal').html(html);
$('#modal-domain-select').modal('show');
}
function selectDomainItem(element){
var $element = $(element);
var domainName = $element.data('domain');
var domainId = $element.data('id');
var $row = $element.closest('.row');
$row.find('.domain-item').removeClass('selected');
$element.addClass('selected');
domainMapping[domainName] = domainId;
}
function confirmDomainSelection(){
$('#modal-domain-select').modal('hide');
$.each(parsedBatchData, function(index, row){
if(domainMapping[row.domainName]){
row.domainId = domainMapping[row.domainName];
}
});
renderPreview();
layer.msg('域名配置已更新', {icon: 1, time: 1500});
}
function submitBatchData(){
if(parsedBatchData.length === 0){
layer.alert('请先预览解析结果', {icon: 2});
return;
}
layer.confirm('确定要批量添加这 <strong>' + parsedBatchData.length + '</strong> 条解析记录吗?', {
title: '确认批量添加',
icon: 0,
btn: ['确定添加', '取消']
}, function(){
executeBatchAdd();
});
}
function executeBatchAdd(){
var groupedByDomain = {};
$.each(parsedBatchData, function(index, row){
if(!groupedByDomain[row.domainId]){
groupedByDomain[row.domainId] = [];
}
groupedByDomain[row.domainId].push(row);
});
var totalSuccess = 0;
var totalFail = 0;
var completedCount = 0;
var totalCount = Object.keys(groupedByDomain).length;
var failReasons = [];
var $btn = $('#btnBatchAdd');
var btnOrigHtml = $btn.html();
$btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> 正在添加...');
var ii = layer.load(2);
$.each(groupedByDomain, function(domainId, records){
var recordLines = [];
$.each(records, function(i, r){
recordLines.push(r.host + ' ' + r.value);
});
var recordStr = recordLines.join('\n');
$.ajax({
type : 'POST',
url : '/record/batchadd/' + domainId,
data : function(){
var data = {
record: recordStr,
type: records[0].type,
ttl: records[0].ttl
};
if(records[0].line){
data.line = records[0].line;
}
return data;
}(),
dataType : 'json',
async: false,
success : function(data) {
completedCount++;
if(data.code == 0){
var match = data.msg.match(/成功(\d+)条/);
if(match){
totalSuccess += parseInt(match[1]);
}
var failMatch = data.msg.match(/失败(\d+)条/);
if(failMatch){
var failCount = parseInt(failMatch[1]);
totalFail += failCount;
if(failCount > 0){
var startIndex = records.length - failCount;
for(var i = startIndex; i < records.length; i++){
var record = records[i];
failReasons.push('记录 ' + record.host + ' [域名: ' + record.domainName + ']' + data.msg);
}
}
} else if(data.msg.indexOf('失败') !== -1){
failReasons.push('域名 ' + records[0].domainName + '' + data.msg);
}
}else{
totalFail += records.length;
$.each(records, function(i, record){
failReasons.push('记录 ' + record.host + ' [域名: ' + record.domainName + ']' + data.msg);
});
}
if(completedCount >= totalCount){
layer.close(ii);
$btn.prop('disabled', false).html(btnOrigHtml);
var msg = '批量添加完成!';
if(totalSuccess > 0){
msg += '\n成功' + totalSuccess + ' 条';
}
if(totalFail > 0){
msg += '\n失败' + totalFail + ' 条';
if(failReasons.length > 0){
msg += '\n\n失败原因';
$.each(failReasons, function(i, reason){
msg += '\n' + (i + 1) + '. ' + reason;
});
}
}
layer.alert(msg, {
icon: totalFail > 0 ? 2 : 1,
btn: ['确定'],
yes: function(index){
layer.close(index);
try {
resetBatchForm();
} catch(e) {
console.error('Error in callback:', e);
}
}
});
}
},
error : function() {
completedCount++;
totalFail += records.length;
$.each(records, function(i, record){
failReasons.push('记录 ' + record.host + ' [域名: ' + record.domainName + ']:网络错误,无法连接服务器');
});
if(completedCount >= totalCount){
layer.close(ii);
$btn.prop('disabled', false).html(btnOrigHtml);
var msg = '批量添加完成!';
if(totalSuccess > 0){
msg += '\n成功' + totalSuccess + ' 条';
}
if(totalFail > 0){
msg += '\n失败' + totalFail + ' 条';
if(failReasons.length > 0){
msg += '\n\n失败原因';
$.each(failReasons, function(i, reason){
msg += '\n' + (i + 1) + '. ' + reason;
});
}
}
layer.alert(msg, {
icon: totalFail > 0 ? 2 : 1,
btn: ['确定'],
yes: function(index){
layer.close(index);
try {
resetBatchForm();
} catch(e) {
console.error('Error in callback:', e);
}
}
});
}
}
});
});
}
function htmlEscape(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
</script>
{/block}

View File

@@ -54,14 +54,14 @@
<label class="col-sm-3 control-label no-padding-right">解析IP类型<span class="tips" title="" data-toggle="tooltip" data-placement="bottom" data-original-title="同时开启IPv6&IPv4将会请求2次接口消耗双倍积分"><i class="fa fa-question-circle"></i></span></label>
<div class="col-sm-6">
<label class="checkbox-inline" v-for="option in iptypeList">
<input type="checkbox" name="ip_type" :value="option.value" v-model="set.ip_type_select" required> {{option.label}}
<input type="checkbox" name="ip_type" :value="option.value" v-model="set.ip_type_select" :disabled="option.value=='v6' && isXingpingcn" required> {{option.label}}<span v-if="option.value=='v6' && isXingpingcn" class="text-muted">(xingpingcn.top不支持)</span>
</label><br/>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label no-padding-right">每线路解析数量<span class="tips" title="" data-toggle="tooltip" data-placement="bottom" data-original-title="数量不要超过当前域名套餐允许的最大数量,否则会添加解析失败"><i class="fa fa-question-circle"></i></span></label>
<div class="col-sm-6">
<input type="text" name="recordnum" v-model="set.recordnum" placeholder="填写每线路解析数量" class="form-control" data-bv-integer="true" min="1" max="5" required>
<input type="text" name="recordnum" v-model="set.recordnum" placeholder="填写每线路解析数量" class="form-control" data-bv-integer="true" min="1" max="50" required>
</div>
</div>
<div class="form-group">
@@ -110,6 +110,7 @@ new Vue({
el: '#app',
data: {
action: '{$action}',
optimize_ip_api: '{:config_get("optimize_ip_api", 0)}',
set: {
id: '',
remark: '',
@@ -132,8 +133,18 @@ new Vue({
4:'EdgeOne'
},
},
computed: {
isXingpingcn: function() {
return this.optimize_ip_api == '2';
}
},
watch: {
'set.ip_type_select': function(val){
// 如果使用xingpingcn.top接口自动移除v6
if(this.isXingpingcn && val.includes('v6')){
this.set.ip_type_select = val.filter(v => v !== 'v6');
return;
}
this.set.ip_type = val.join(',');
}
},
@@ -185,4 +196,4 @@ new Vue({
},
});
</script>
{/block}
{/block}

View File

@@ -14,8 +14,9 @@
<div class="panel-heading"><h3 class="panel-title">使用说明</h3></div>
<div class="panel-body">
<p><li>不支持对CloudFlare里的域名添加优选必须使用其他DNS服务商。需开通Cloudflare for SaaS且域名使用CNAME的方式解析到CloudFlare。</li></p>
<p><li>数据接口:<a href="https://www.wetest.vip/" target="_blank" rel="noreferrer">wetest.vip</a> 数据接口支持CloudFlare、CloudFront、EdgeOne<a href="https://stock.hostmonit.com/" target="_blank" rel="noreferrer">HostMonit</a> 只支持CloudFlare。</li></p>
<p><li>数据接口:<a href="https://www.wetest.vip/" target="_blank" rel="noreferrer">wetest.vip</a> 数据接口支持CloudFlare、CloudFront、EdgeOne<a href="https://stock.hostmonit.com/" target="_blank" rel="noreferrer">HostMonit</a> 只支持CloudFlare<a href="https://github.com/xingpingcn/enhanced-FaaS-in-China" target="_blank" rel="noreferrer">xingpingcn.top</a> 只支持CloudFlare免费、无需密钥</li></p>
<p><li>接口密钥默认o1zrmHAF为免费KEY可永久免费使用。</li></p>
<p><li>代理地址:如 https://ghfast.top/https://raw.githubusercontent.com/ ,留空则直接访问 https://raw.githubusercontent.com/。</li></p>
<p><li>自动更新:可查看<a href="/system/cronset">计划任务设置</a></p>
</div>
</div>
@@ -24,19 +25,23 @@
<div class="panel panel-info">
<div class="panel-heading"><h3 class="panel-title">数据接口设置</h3></div>
<div class="panel-body">
<form onsubmit="return saveSetting(this)" method="post" class="form-horizontal" role="form">
<form onsubmit="return saveSetting(this)" method="post" class="form-horizontal" role="form" id="apiSettingForm">
<div class="form-group">
<label class="col-sm-3 control-label">数据接口</label>
<div class="col-sm-9"><select class="form-control" name="optimize_ip_api" default="{:config_get('optimize_ip_api')}"><option value="0">wetest.vip</option><option value="1">HostMonit</option></select></div>
<div class="col-sm-9"><select class="form-control" name="optimize_ip_api" id="optimize_ip_api" default="{:config_get('optimize_ip_api')}"><option value="0">wetest.vip</option><option value="1">HostMonit</option><option value="2">xingpingcn.top</option></select></div>
</div>
<div class="form-group">
<div class="form-group" id="keyGroup">
<label class="col-sm-3 control-label">接口密钥</label>
<div class="col-sm-9"><input type="text" name="optimize_ip_key" value="{:config_get('optimize_ip_key', 'o1zrmHAF')}" class="form-control"/></div>
</div>
<div class="form-group" id="proxyGroup" style="display:none;">
<label class="col-sm-3 control-label">代理地址</label>
<div class="col-sm-9"><input type="text" name="optimize_ip_proxy" value="{:config_get('optimize_ip_proxy', '')}" class="form-control" placeholder="留空则直接访问GitHub"/></div>
</div>
<div class="form-group">
<div class="col-sm-offset-3 col-sm-9">
<input type="submit" name="submit" value="保存" class="btn btn-primary btn-block"/>
<a href="javascript:queryapi()" class="btn btn-default btn-block">查询积分</a>
<a href="javascript:queryapi()" class="btn btn-default btn-block" id="queryBtn">查询积分</a>
</div>
</div>
</form>
@@ -69,6 +74,25 @@ var items = $("select[default]");
for (i = 0; i < items.length; i++) {
$(items[i]).val($(items[i]).attr("default")||0);
}
// 切换接口时显示/隐藏对应设置项
function toggleApiSettings(){
var api = $("#optimize_ip_api").val();
if(api == '2'){
$("#keyGroup").hide();
$("#proxyGroup").show();
$("#queryBtn").hide();
}else{
$("#keyGroup").show();
$("#proxyGroup").hide();
$("#queryBtn").show();
}
}
$("#optimize_ip_api").change(function(){
toggleApiSettings();
});
// 页面加载时初始化
toggleApiSettings();
$('[data-toggle="tooltip"]').tooltip();
function saveSetting(obj){
var ii = layer.load(2, {shade:[0.1,'#fff']});
$.ajax({
@@ -118,4 +142,4 @@ function queryapi(){
});
}
</script>
{/block}
{/block}

View File

@@ -139,6 +139,44 @@
@用户不支持企业微信,飞书用户手机号需要填写<a href="https://open.feishu.cn/document/home/user-identity-introduction/open-id" target="_blank" rel="noreferrer">用户ID</a>
</div>
</div>
<div class="panel panel-info">
<div class="panel-heading"><h3 class="panel-title">自定义Webhook</h3></div>
<div class="panel-body">
<form onsubmit="return saveSetting(this)" method="post" class="form-horizontal" role="form">
<div class="form-group">
<label class="col-sm-3 control-label">Webhook地址</label>
<div class="col-sm-9"><input type="text" name="custom_webhook_url" value="{:config_get('custom_webhook_url')}" class="form-control" placeholder="https://example.com/webhook"/></div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">请求方式</label>
<div class="col-sm-9"><select class="form-control" name="custom_webhook_method" default="{:config_get('custom_webhook_method', 'POST')}"><option value="POST">POST</option><option value="GET">GET</option><option value="PUT">PUT</option></select></div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">Content-Type</label>
<div class="col-sm-9"><select class="form-control" name="custom_webhook_content_type" default="{:config_get('custom_webhook_content_type', 'application/json')}"><option value="application/json">application/json</option><option value="application/x-www-form-urlencoded">application/x-www-form-urlencoded</option></select></div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">自定义Headers</label>
<div class="col-sm-9"><textarea name="custom_webhook_headers" class="form-control" rows="3" placeholder='每行一个格式HeaderName: HeaderValue'>{:config_get('custom_webhook_headers')}</textarea></div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">请求Body</label>
<div class="col-sm-9"><textarea name="custom_webhook_body" class="form-control" rows="4">{php}echo htmlspecialchars(config_get('custom_webhook_body') ?: '{"title":"{title}","content":"{content}"}');{/php}</textarea>
<font color="green">支持变量:&#123;title&#125;&#123;content&#125;如果是GET方式将作为query参数拼接到url上</font></div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">消息内容格式</label>
<div class="col-sm-9"><select class="form-control" name="custom_webhook_content_format" default="{:config_get('custom_webhook_content_format', 'html')}"><option value="html">HTML</option><option value="markdown">Markdown</option><option value="text">纯文本</option></select></div>
</div>
<div class="form-group">
<div class="col-sm-offset-3 col-sm-9">
<input type="submit" name="submit" value="保存" class="btn btn-primary btn-block"/>
<a href="javascript:customwebhooktest()" class="btn btn-default btn-block">发送测试消息</a>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
{/block}
@@ -254,5 +292,25 @@ function webhooktest(){
}
});
}
function customwebhooktest(){
var ii = layer.load(2, {shade:[0.1,'#fff']});
$.ajax({
type : 'GET',
url : '/system/customwebhooktest',
dataType : 'json',
success : function(data) {
layer.close(ii);
if(data.code == 0){
layer.alert(data.msg, {icon: 1});
}else{
layer.alert(data.msg, {icon: 2})
}
},
error:function(data){
layer.close(ii);
layer.msg('服务器错误');
}
});
}
</script>
{/block}

111
composer.lock generated
View File

@@ -8,16 +8,16 @@
"packages": [
{
"name": "cccyun/php-whois",
"version": "1.2",
"version": "1.3",
"source": {
"type": "git",
"url": "https://github.com/netcccyun/php-whois.git",
"reference": "c631f1c5e26e7150501a14cd25a2380f8a077ca1"
"reference": "f02627ba0bef005aa9e336d63541f9fd288675b5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/netcccyun/php-whois/zipball/c631f1c5e26e7150501a14cd25a2380f8a077ca1",
"reference": "c631f1c5e26e7150501a14cd25a2380f8a077ca1",
"url": "https://api.github.com/repos/netcccyun/php-whois/zipball/f02627ba0bef005aa9e336d63541f9fd288675b5",
"reference": "f02627ba0bef005aa9e336d63541f9fd288675b5",
"shasum": ""
},
"require": {
@@ -62,9 +62,9 @@
"црщшы"
],
"support": {
"source": "https://github.com/netcccyun/php-whois/tree/1.2"
"source": "https://github.com/netcccyun/php-whois/tree/1.3"
},
"time": "2025-06-25T06:54:23+00:00"
"time": "2026-02-12T05:56:18+00:00"
},
{
"name": "cccyun/think-captcha",
@@ -329,16 +329,16 @@
},
{
"name": "guzzlehttp/psr7",
"version": "2.8.0",
"version": "2.9.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "21dc724a0583619cd1652f673303492272778051"
"reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051",
"reference": "21dc724a0583619cd1652f673303492272778051",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884",
"reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884",
"shasum": ""
},
"require": {
@@ -354,6 +354,7 @@
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"http-interop/http-factory-tests": "0.9.0",
"jshttp/mime-db": "1.54.0.1",
"phpunit/phpunit": "^8.5.44 || ^9.6.25"
},
"suggest": {
@@ -425,7 +426,7 @@
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/2.8.0"
"source": "https://github.com/guzzle/psr7/tree/2.9.0"
},
"funding": [
{
@@ -441,7 +442,7 @@
"type": "tidelift"
}
],
"time": "2025-08-23T21:21:41+00:00"
"time": "2026-03-10T16:41:02+00:00"
},
{
"name": "phpmailer/phpmailer",
@@ -952,16 +953,16 @@
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.33.0",
"version": "v1.34.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
"reference": "141046a8f9477948ff284fa65be2095baafb94f2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
"reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
"url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
"reference": "141046a8f9477948ff284fa65be2095baafb94f2",
"shasum": ""
},
"require": {
@@ -1011,7 +1012,7 @@
"portable"
],
"support": {
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0"
"source": "https://github.com/symfony/polyfill-ctype/tree/v1.34.0"
},
"funding": [
{
@@ -1031,11 +1032,11 @@
"type": "tidelift"
}
],
"time": "2024-09-09T11:45:10+00:00"
"time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
"version": "v1.33.0",
"version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
@@ -1098,7 +1099,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0"
},
"funding": [
{
@@ -1122,7 +1123,7 @@
},
{
"name": "symfony/polyfill-intl-normalizer",
"version": "v1.33.0",
"version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
@@ -1183,7 +1184,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0"
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0"
},
"funding": [
{
@@ -1207,16 +1208,16 @@
},
{
"name": "symfony/polyfill-mbstring",
"version": "v1.33.0",
"version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
"reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
"reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
"reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315",
"reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315",
"shasum": ""
},
"require": {
@@ -1268,7 +1269,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0"
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0"
},
"funding": [
{
@@ -1288,11 +1289,11 @@
"type": "tidelift"
}
],
"time": "2024-12-23T08:48:59+00:00"
"time": "2026-04-10T17:25:58+00:00"
},
{
"name": "symfony/polyfill-php81",
"version": "v1.33.0",
"version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php81.git",
@@ -1348,7 +1349,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0"
"source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0"
},
"funding": [
{
@@ -1372,16 +1373,16 @@
},
{
"name": "symfony/polyfill-php82",
"version": "v1.33.0",
"version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php82.git",
"reference": "5d2ed36f7734637dacc025f179698031951b1692"
"reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/5d2ed36f7734637dacc025f179698031951b1692",
"reference": "5d2ed36f7734637dacc025f179698031951b1692",
"url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/34808efe3e68f69685796f7c253a2f1d8ea9df59",
"reference": "34808efe3e68f69685796f7c253a2f1d8ea9df59",
"shasum": ""
},
"require": {
@@ -1428,7 +1429,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php82/tree/v1.33.0"
"source": "https://github.com/symfony/polyfill-php82/tree/v1.37.0"
},
"funding": [
{
@@ -1448,32 +1449,32 @@
"type": "tidelift"
}
],
"time": "2024-09-09T11:45:10+00:00"
"time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/yaml",
"version": "v7.3.5",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc"
"reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/90208e2fc6f68f613eae7ca25a2458a931b1bacc",
"reference": "90208e2fc6f68f613eae7ca25a2458a931b1bacc",
"url": "https://api.github.com/repos/symfony/yaml/zipball/c58fdf7b3d6c2995368264c49e4e8b05bcff2883",
"reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
"symfony/console": "<6.4"
},
"require-dev": {
"symfony/console": "^6.4|^7.0"
"symfony/console": "^6.4|^7.0|^8.0"
},
"bin": [
"Resources/bin/yaml-lint"
@@ -1504,7 +1505,7 @@
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v7.3.5"
"source": "https://github.com/symfony/yaml/tree/v7.4.8"
},
"funding": [
{
@@ -1524,7 +1525,7 @@
"type": "tidelift"
}
],
"time": "2025-09-27T09:00:46+00:00"
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "topthink/framework",
@@ -1908,16 +1909,16 @@
},
{
"name": "symfony/var-dumper",
"version": "v7.3.5",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-dumper.git",
"reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d"
"reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/476c4ae17f43a9a36650c69879dcf5b1e6ae724d",
"reference": "476c4ae17f43a9a36650c69879dcf5b1e6ae724d",
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd",
"reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd",
"shasum": ""
},
"require": {
@@ -1929,10 +1930,10 @@
"symfony/console": "<6.4"
},
"require-dev": {
"symfony/console": "^6.4|^7.0",
"symfony/http-kernel": "^6.4|^7.0",
"symfony/process": "^6.4|^7.0",
"symfony/uid": "^6.4|^7.0",
"symfony/console": "^6.4|^7.0|^8.0",
"symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/process": "^6.4|^7.0|^8.0",
"symfony/uid": "^6.4|^7.0|^8.0",
"twig/twig": "^3.12"
},
"bin": [
@@ -1971,7 +1972,7 @@
"dump"
],
"support": {
"source": "https://github.com/symfony/var-dumper/tree/v7.3.5"
"source": "https://github.com/symfony/var-dumper/tree/v7.4.8"
},
"funding": [
{
@@ -1991,7 +1992,7 @@
"type": "tidelift"
}
],
"time": "2025-09-27T09:00:46+00:00"
"time": "2026-03-30T13:44:50+00:00"
},
{
"name": "topthink/think-trace",
@@ -2062,5 +2063,5 @@
"ext-ssh2": "*"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.9.0"
}

View File

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

View File

@@ -4,5 +4,5 @@
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ index.php/$1 [QSA,PT,L]
RewriteRule ^(.*)$ index.php [L,E=PATH_INFO:/$1]
</IfModule>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="812" height="812">
<rect x="0" y="0" width="812" height="812" rx="80" ry="80" fill="#333333"/>
<path d="M0,0 L136,0 L138,23 L141,47 L146,91 L152,146 L155,169 L157,188 L161,222 L163,243 L167,275 L169,294 L173,329 L176,355 L178,372 L178,377 L360,377 L354,399 L342,439 L333,470 L324,501 L321,509 L302,509 L307,504 L313,497 L313,494 L296,481 L293,481 L286,489 L277,494 L270,496 L261,496 L253,493 L248,487 L246,481 L246,474 L247,473 L276,470 L296,464 L307,458 L314,451 L318,443 L320,430 L318,420 L313,412 L306,406 L294,401 L290,400 L267,400 L254,404 L243,410 L233,419 L223,434 L217,450 L215,464 L215,478 L217,490 L222,502 L226,507 L226,509 L187,509 L193,501 L199,492 L199,488 L179,479 L176,478 L169,488 L164,493 L157,496 L150,496 L142,493 L138,488 L136,480 L138,464 L142,445 L146,435 L151,429 L157,426 L162,425 L169,425 L176,428 L181,436 L182,439 L187,438 L208,430 L207,422 L201,412 L193,406 L182,401 L175,400 L155,400 L143,404 L132,410 L122,419 L115,430 L110,440 L107,450 L105,468 L106,488 L111,500 L116,509 L86,509 L84,496 L81,460 L78,432 L74,385 L-27,385 L-22,373 L-7,342 L1,324 L9,308 L14,297 L67,297 L66,293 L62,236 L60,213 L57,169 L52,104 L52,95 L45,95 L43,101 L27,136 L11,171 L-7,210 L-26,251 L-42,285 L-50,304 L-60,326 L-68,342 L-76,359 L-84,375 L-96,400 L-114,437 L-125,460 L-137,485 L-148,508 L-149,509 L-261,509 L-259,503 L-245,475 L-235,456 L-212,411 L-202,392 L-189,366 L-179,347 L-159,308 L-145,281 L-137,266 L-129,250 L-105,203 L-91,176 L-82,159 L-74,143 L-51,98 L-34,65 L-19,36 L-1,1 Z " fill="#FEFEFE" transform="translate(372,151)"/>
<path d="M0,0 L11,0 L17,3 L19,6 L19,15 L14,21 L4,25 L-14,28 L-20,28 L-18,17 L-13,9 L-5,2 Z " fill="#FBFBFB" transform="translate(642,575)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 B

View File

@@ -51,12 +51,48 @@ Route::group(function () {
Route::get('/account/:action', 'domain/account_add');
Route::get('/account', 'domain/account');
Route::get('/cloudflare/hostnames/:id', 'cloudflare/hostnames');
Route::post('/cloudflare/hostnames/data/:id', 'cloudflare/hostnames_data');
Route::post('/cloudflare/hostnames/add/:id', 'cloudflare/hostnames_add');
Route::post('/cloudflare/hostnames/update/:id', 'cloudflare/hostnames_update');
Route::post('/cloudflare/hostnames/delete/:id', 'cloudflare/hostnames_delete');
Route::post('/cloudflare/hostnames/refresh/:id', 'cloudflare/hostnames_refresh');
Route::post('/cloudflare/hostnames/txttargets/:id', 'cloudflare/hostnames_txt_targets');
Route::post('/cloudflare/hostnames/batch_add/:id', 'cloudflare/hostnames_batch_add');
Route::post('/cloudflare/hostnames/batch_delete/:id', 'cloudflare/hostnames_batch_delete');
Route::post('/cloudflare/hostnames/batch_update/:id', 'cloudflare/hostnames_batch_update');
Route::post('/cloudflare/fallback/get/:id', 'cloudflare/fallback_get');
Route::post('/cloudflare/fallback/set/:id', 'cloudflare/fallback_set');
Route::post('/cloudflare/fallback/delete/:id', 'cloudflare/fallback_delete');
Route::post('/cloudflare/dcv_delegation_uuid/:id', 'cloudflare/dcv_delegation_uuid');
Route::post('/cloudflare/get_domain_default_line', 'cloudflare/get_domain_default_line');
Route::get('/cloudflare/tunnels/:id', 'cloudflare/tunnels');
Route::post('/cloudflare/tunnels/data/:id', 'cloudflare/tunnels_data');
Route::post('/cloudflare/tunnels/add/:id', 'cloudflare/tunnels_add');
Route::post('/cloudflare/tunnels/delete/:id', 'cloudflare/tunnels_delete');
Route::post('/cloudflare/tunnels/token/:id', 'cloudflare/tunnels_token');
Route::post('/cloudflare/tunnels/publichostnames/data/:id', 'cloudflare/tunnels_public_hostnames_data');
Route::post('/cloudflare/tunnels/publichostnames/save/:id', 'cloudflare/tunnels_public_hostnames_save');
Route::post('/cloudflare/tunnels/publichostnames/delete/:id', 'cloudflare/tunnels_public_hostnames_delete');
Route::post('/cloudflare/tunnels/cidr/data/:id', 'cloudflare/tunnels_cidr_data');
Route::post('/cloudflare/tunnels/cidr/add/:id', 'cloudflare/tunnels_cidr_add');
Route::post('/cloudflare/tunnels/cidr/delete/:id', 'cloudflare/tunnels_cidr_delete');
Route::post('/cloudflare/tunnels/hostnameroutes/data/:id', 'cloudflare/tunnels_hostname_routes_data');
Route::post('/cloudflare/tunnels/hostnameroutes/add/:id', 'cloudflare/tunnels_hostname_routes_add');
Route::post('/cloudflare/tunnels/hostnameroutes/delete/:id', 'cloudflare/tunnels_hostname_routes_delete');
Route::any('/domain/expirenotice', 'domain/expire_notice');
Route::post('/domain/updatedate', 'domain/update_date');
Route::post('/domain/data', 'domain/domain_data');
Route::post('/domain/op', 'domain/domain_op');
Route::post('/domain/list', 'domain/domain_list');
Route::any('/domain/dnscheck', 'domain/dnscheck');
Route::post('/domain/category/data', 'domain/category_data');
Route::post('/domain/category/:action', 'domain/category_op');
Route::get('/domain/category/list', 'domain/category_list');
Route::post('/domain/setcategory', 'domain/domain_set_category');
Route::get('/domain/add', 'domain/domain_add');
Route::get('/domain/category', 'domain/category');
Route::get('/domain', 'domain/domain');
Route::post('/record/data/:id', 'domain/record_data');
@@ -65,6 +101,7 @@ Route::group(function () {
Route::post('/record/delete/:id', 'domain/record_delete');
Route::post('/record/status/:id', 'domain/record_status');
Route::post('/record/remark/:id', 'domain/record_remark');
Route::post('/record/check/:id', 'domain/record_check');
Route::post('/record/batch/:id', 'domain/record_batch');
Route::post('/record/batchedit/:id', 'domain/record_batch_edit');
Route::any('/record/batchadd/:id', 'domain/record_batch_add');
@@ -74,7 +111,10 @@ Route::group(function () {
Route::post('/record/list', 'domain/record_list');
Route::post('/record/weight/data/:id', 'domain/weight_data');
Route::any('/record/weight/:id', 'domain/weight');
Route::any('/record/alias/:id', 'domain/alias');
Route::get('/record/:id', 'domain/record');
Route::get('/record/smartparse', 'domain/smartparse');
Route::post('/record/quickinfo/:id', 'domain/quickinfo');
Route::get('/dmonitor/overview', 'dmonitor/overview');
Route::post('/dmonitor/task/data', 'dmonitor/task_data');
@@ -127,6 +167,7 @@ Route::group(function () {
Route::get('/system/mailtest', 'system/mailtest');
Route::get('/system/tgbottest', 'system/tgbottest');
Route::get('/system/webhooktest', 'system/webhooktest');
Route::get('/system/customwebhooktest', 'system/customwebhooktest');
Route::post('/system/proxytest', 'system/proxytest');
Route::get('/system/cronset', 'system/cronset');