49 Commits
2.16 ... 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
53 changed files with 10519 additions and 580 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

@@ -751,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,
]);
@@ -1005,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);
@@ -1218,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

@@ -92,6 +92,20 @@ 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', '无权限');

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#/instance/TEST/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,
@@ -1155,6 +1251,7 @@ ctrl+x 保存退出<br/>',
['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'],
@@ -1207,7 +1304,7 @@ ctrl+x 保存退出<br/>',
['value'=>'ap-southeast-1', 'label'=>'非中国内地'],
],
'value' => 'cn-hangzhou',
'show' => 'product==\'waf\'||product==\'waf2\'||product==\'ddoscoo\'||product==\'esa\'||product==\'esa_saas\'',
'show' => 'product==\'waf\'||product==\'waf2\'||product==\'wafres\'||product==\'ddoscoo\'||product==\'esa\'||product==\'esa_saas\'',
'required' => true,
],
'regionid' => [
@@ -1278,6 +1375,14 @@ ctrl+x 保存退出<br/>',
'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',
@@ -1286,7 +1391,7 @@ ctrl+x 保存退出<br/>',
['value'=>'1', 'label'=>'扩展证书'],
],
'value' => '0',
'show' => 'product==\'clb\'||product==\'alb\'||product==\'nlb\'||product==\'ga\'',
'show' => 'product==\'clb\'||product==\'alb\'||product==\'nlb\'||product==\'ga\'||product==\'wafres\'',
'required' => true,
],
'clb_domain' => [
@@ -1299,8 +1404,8 @@ ctrl+x 保存退出<br/>',
'domain' => [
'name' => '绑定的域名',
'type' => 'input',
'placeholder' => '',
'show' => 'product!=\'esa\'&&product!=\'esa_saas\'&&product!=\'clb\'&&product!=\'alb\'&&product!=\'nlb\'&&product!=\'ga\'&&product!=\'upload\'',
'placeholder' => '多个域名可用,隔开',
'show' => 'product!=\'esa\'&&product!=\'esa_saas\'&&product!=\'clb\'&&product!=\'alb\'&&product!=\'nlb\'&&product!=\'ga\'&&product!=\'upload\'&&product!=\'wafres\'',
'required' => true,
],
],
@@ -1885,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'=>'上传到证书管理'],
],
@@ -1898,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',
@@ -2117,7 +2240,7 @@ ctrl+x 保存退出<br/>',
'domain' => [
'name' => '绑定的域名',
'type' => 'input',
'placeholder' => '',
'placeholder' => '多个域名可使用,分隔',
'required' => true,
],
],
@@ -2614,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

@@ -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);
}

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') {
@@ -136,36 +138,38 @@ 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);
$param = [
'Action' => 'SetDcdnDomainSSLCertificate',
'DomainName' => $domain,
'CertName' => $cert_name,
'CertType' => 'cas',
'SSLProtocol' => 'on',
'CertId' => $cert_id,
];
$client->request($param);
$this->log('DCDN域名 ' . $domain . ' 部署证书成功!');
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)
@@ -349,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';
@@ -381,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';
@@ -455,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('证书解析失败');
@@ -481,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);
@@ -559,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);
@@ -602,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)

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

@@ -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

@@ -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,7 +99,7 @@ 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;
@@ -115,7 +137,7 @@ 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;

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

@@ -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;

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

@@ -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

@@ -26,6 +26,7 @@ class OptimizeService
$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'];
@@ -60,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'];
@@ -98,7 +100,7 @@ class OptimizeService
$url = 'https://raw.githubusercontent.com/xingpingcn/enhanced-FaaS-in-China/refs/heads/main/Cf.json';
}
$response = get_curl($url);
if ($response === '') {
if (!$response) {
throw new Exception('获取优选IP数据失败网络请求失败请检查网络连接或代理地址');
}
$arr = json_decode($response, true);

View File

@@ -5,7 +5,7 @@ CREATE TABLE `dnsmgr_config` (
PRIMARY KEY (`key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO `dnsmgr_config` VALUES ('version', '1048');
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`;
@@ -261,4 +263,15 @@ CREATE TABLE `dnsmgr_domain_alias` (
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

@@ -198,4 +198,18 @@ CREATE TABLE IF NOT EXISTS `dnsmgr_domain_alias` (
PRIMARY KEY (`id`),
KEY `did` (`did`),
KEY `name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
) 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

@@ -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

@@ -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

@@ -183,6 +183,7 @@ 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">
@@ -349,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}";
@@ -724,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

@@ -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}

99
composer.lock generated
View File

@@ -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' => '1048',
'version' => '1050',
'dbversion' => '1048'
'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>

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');
@@ -76,6 +113,8 @@ Route::group(function () {
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');
@@ -128,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');