Compare commits

...

282 Commits

Author SHA1 Message Date
耗子 fb133971db feat(waf): 接入 acewaf 管理模块 2026-08-01 00:58:32 +08:00
renovate[bot] a4e3a8d38f chore(deps): Update dependency vue-tsc to v3.3.9 (#1739)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-31 14:04:48 +00:00
renovate[bot] ab507da99a chore(deps): Update dependency vite to v8.2.0 (#1738)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-30 22:44:29 +00:00
renovate[bot] 8e8fad1287 chore(deps): Update dependency js-sha256 to v1 (#1737)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-30 18:53:19 +08:00
renovate[bot] 03e9646e57 chore(deps): Update non-major dependencies (#1736)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-29 17:57:44 +00:00
耗子 7613d7e73e Update star history chart links in README.md 2026-07-29 19:24:38 +08:00
renovate[bot] 6156c8737b chore(deps): Update dependency @vueuse/core to v14.4.0 (#1735)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-29 06:08:55 +00:00
renovate[bot] c25443189f chore(deps): Update dependency npm-run-all2 to v9.0.3 (#1734)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-28 22:55:56 +00:00
renovate[bot] ef824b2f6d chore(deps): Update non-major dependencies (#1733)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-28 02:10:29 +00:00
耗子 c938da4f4f feat: 日志查看体验优化
- 搜索支持上/下一个、匹配计数、命中行及关键词高亮
- 暂停跟随时显示新日志气泡,点击回到底部
- 限制最大保留 5000 行,防止高频日志拖垮页面
- 新增软换行开关、复制全部,字号与换行设置持久化
- 初始加载与翻页边界提示,连接状态点悬停说明

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:50:55 +08:00
renovate[bot] c2bee5b6f2 chore(deps): Lock file maintenance (#1732)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-27 06:36:52 +00:00
耗子 3f339d1a53 style: format Go sources 2026-07-27 02:55:03 +08:00
耗子 ea22c2f0a7 refactor(di): restore compile-time Wire injection
Replace the samber/do runtime container with explicit constructor dependencies and generated Wire graphs for ace and cli. Keep CLI application loading isolated and verify generated files in CI.
2026-07-27 02:54:44 +08:00
github-actions[bot] ad4559eea3 New Crowdin translations by GitHub Action (#1731)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-27 01:34:45 +08:00
h2zi cdd095e7f5 chore(l10n): update pot files 2026-07-26 16:35:07 +00:00
耗子 3a2f0db45c feat: 日志支持全屏 2026-07-25 22:48:08 +08:00
耗子 4e28fc60a3 feat: 添加通知与告警模块
- 通知渠道支持配置多个 SMTP 账号,配置整体加密落库,删除时清理规则与事件设置中的引用
- 告警规则支持 18 种指标:CPU/内存/Swap/负载/磁盘用量与 inode/磁盘与网卡速率/网站 5xx 与错误率、
  服务与项目与容器与应用与数据库运行状态、证书与网站剩余天数,可配连续命中次数与静默期
- 系统事件通知覆盖证书续签失败、备份失败、后台任务失败、计划任务失败、网站到期、防篡改拦截、
  面板健康问题、面板登录与爆破、SSH 登录与爆破
- 计划任务统一经 wrapper 执行以捕获退出码,flock 抢锁失败不计为失败
- 监控页改造为系统/告警/设置三 Tab
- 数据库驱动建连打通 context 并补齐连接超时,探测不再阻塞告警评估

close #1041

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:45:51 +08:00
renovate[bot] 693dd36b30 chore(deps): Update dependency vite-plugin-vue-devtools to v8.2.1 (#1730)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-25 14:16:08 +00:00
renovate[bot] e46ef25e28 chore(deps): Update non-major dependencies (#1729)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-25 05:32:45 +00:00
耗子 ce50aa4653 feat: 备份恢复改为后台任务
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-25 01:45:48 +08:00
耗子 d9fee94db5 feat: 优化systemd日志加载性能
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-25 01:45:40 +08:00
耗子 7f9293e562 feat: 优化SSH日志性能 2026-07-25 00:15:43 +08:00
耗子 aca88ffeea feat: 去除设置层的缓存 2026-07-25 00:05:59 +08:00
耗子 634e361e86 feat: 优化防篡改性能 2026-07-24 23:48:23 +08:00
耗子 afbc21160f Merge remote-tracking branch 'origin/main' 2026-07-24 23:40:04 +08:00
耗子 ca49160de2 feat: 优化eBPF性能 2026-07-24 23:39:58 +08:00
renovate[bot] 0e02f7ed10 chore(deps): Update non-major dependencies (#1728)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 19:13:52 +08:00
耗子 d3b5f0672a Merge remote-tracking branch 'origin/main' 2026-07-24 19:06:28 +08:00
耗子 2ad637e1d7 feat: 支持下载备份 2026-07-24 19:06:22 +08:00
renovate[bot] 5413926d06 chore(deps): Update non-major dependencies (#1727)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-24 01:52:32 +00:00
耗子 0fc912e6a2 fix: 备份临时目录改到面板目录下
未指定 TMPDIR 时备份临时目录落在面板目录 tmp 下,
避免大文件撑爆 /tmp 内存盘,任务队列的独享目录取消清理机制不变

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:24:47 +08:00
renovate[bot] b333df8d2c chore(deps): Update dependency sass to v1.101.6 (#1726)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-23 06:44:45 +00:00
renovate[bot] e2ad3407aa chore(deps): Update non-major dependencies (#1725)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-23 07:05:01 +08:00
耗子 f25cd50763 fix: Java 项目 systemd 添加 SuccessExitStatus=143 且更新时保留自定义配置
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 02:47:35 +08:00
耗子 d8d1e20c5a fix: 创建 MySQL 数据库授权已有用户报错 1410
MySQL 8 起 GRANT 不再隐式创建用户,授权已有用户时改为按
mysql.user 中实际存在的 host 逐一授权,用户不存在时明确报错,
并统一空 host 参数回退为 %

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 23:31:18 +08:00
耗子 9c977c154b feat: 迁移支持 ClickHouse 数据库并修复多处问题
- 数据库迁移新增 ClickHouse 支持(结构 + SQLInsert 数据导出,远程 multiquery 导入)
- 数据库用户先于数据库迁移,修复导入时属主角色不存在
- pg_dump 加 --clean --if-exists,mysqldump 加 --routines --events
- 修复远程解压失败、SSE 流中断被静默当作成功的问题
- 临时文件改到面板目录 tmp 下,避免撑爆 /tmp 内存盘

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 22:15:17 +08:00
renovate[bot] f347b77c72 chore(deps): Update dependency eslint-plugin-oxlint to v1.75.0 (#1724)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-22 09:57:57 +00:00
renovate[bot] d356b7f5a0 chore(deps): Update non-major dependencies (#1723)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-22 01:30:34 +00:00
耗子 1b184f5178 fix: 项目 systemd 环境变量加双引号
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 06:55:35 +08:00
耗子 f4a056b601 fix: 优化 2026-07-22 01:49:45 +08:00
github-actions[bot] 02f6011c14 New Crowdin translations by GitHub Action (#1722)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-21 18:58:59 +08:00
h2zi c0a1c177b9 chore(l10n): update pot files 2026-07-21 10:57:16 +00:00
h2zi f5eea8b4a9 chore: update mocks 2026-07-21 10:55:55 +00:00
耗子 f0d8acca31 fix: 优化 2026-07-21 18:54:59 +08:00
耗子 5d314af538 fix: 优化 2026-07-21 18:37:32 +08:00
耗子 5ad6f8fa98 fix: 优化 2026-07-21 18:32:28 +08:00
renovate[bot] fa26e643b7 chore(deps): Update non-major dependencies (#1721)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-21 09:33:29 +00:00
github-actions[bot] 1528d7dfc5 New Crowdin translations by GitHub Action (#1720)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-21 06:15:36 +08:00
耗子 8a205f6ec8 fix(taskqueue): 修复 TestRunnerCancelRunning 在 CI 上偶发失败
file::memory: DSN 每个连接得到独立私有库,GORM 连接池多连接时
主 goroutine 创建的任务对 runner goroutine 不可见,runner 查不到
waiting 任务导致 waitStatus 超时。限单连接与 SQLite 单文件语义一致。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-21 06:11:15 +08:00
h2zi 595b1c4fc7 chore(l10n): update pot files 2026-07-20 22:07:05 +00:00
耗子 e0539a908a fix: 优化性能 2026-07-21 06:01:35 +08:00
耗子 90634fdb93 fix(tamper): 事件名解析改 C 字符串截断修复 ringbuf 复用污染
- 上一版为省 32 条清零指令改成只清 name[0],但用户态 TrimRight("\x00")
  只删末尾连续 NUL,ringbuf 复用旧数据时新事件的短名后会拼进旧尾部
- 恢复完整清零 + 用户态改用 cString(IndexByte 首个 NUL 截断)
- 补 inode_file_setattr optional hook 覆盖 FS_IOC_FSSETXATTR
- 暴露 UserDrops 观察模式健康检查;remove 关闭后返回错误一致化
- batchUpdateAll 加 key/value 长度保护;注释按 WHY 收敛

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-21 05:52:49 +08:00
耗子 c01ac0cbd3 fix(tamper): eBPF 模式严格化保护并消除多类绕过
- 目录 inode 纳入对象保护 + 新增 inode_rmdir/link 源/xattr/ACL hook 覆盖旁路
- 严格模式内核直拒 mkdir 与目录移入,消除"放行后异步纳管"竞态
- mountinfo 读 s_dev 修复 btrfs 子卷保护静默失效
- 事件 name 扩至 256 字节,op 最高位携带 denied 标志区分拦截/观察
- 扩展名 fail-closed:任一无法进内核表升级整树,而非静默漏保护
- 两阶段激活(先 apply 后 attach)消除空窗;start 幂等失败回滚
- Close 与 apply/remove 通过 mu 互斥,批量更新校验完成数量
- 前端 Block New Files 描述随行为更新,拦截日志刷新按钮修复

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-21 05:32:53 +08:00
耗子 9b8141a5ea fix: pgadmin性能优化 2026-07-21 02:11:57 +08:00
耗子 5263b1b730 feat(database): 数据库工具语言跟随面板并补充按钮图标
phpMyAdmin 代理登录透传浏览器语言并显式指定面板语言,
pgAdmin 登录固化面板语言映射进会话,跳转按钮展示应用图标

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:58:58 +08:00
耗子 41bf2531f1 feat(ssh): 文件互传支持目录传输并固定暗色主题
目录递归传输,遍历源树重建目录结构逐文件传输,进度按全树
累计字节汇报,传输前二次确认并建议大目录先压缩;互传界面
固定暗色与终端一致,修复传输按钮在暗色下不可见

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:58:57 +08:00
耗子 71e7c5ab5d fix(website): 修复默认站点设置与回显失效
网站列表空类型会被当作精确匹配导致查询恒空,默认站点的
读取与设置改为显式全量查询,数据层空类型对齐为不过滤

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:58:57 +08:00
耗子 038ef2c44f fix(tamper): 修复重启恢复延迟与统计日志时效
定时任务支持启动后立即触发,面板重启即恢复保护不留空窗;
查看拦截日志前先落库缓冲即拦即见;受保护目录改按监控口径
统计,带后缀规则不再恒为零

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:58:57 +08:00
耗子 aba65c4cdf refactor(safe): 安全菜单扁平化并将防篡改并入防火墙页
安全菜单直达防火墙视图,防篡改作为扫描感知后的标签页呈现,
自身子页改为竖排标签;修复规则弹窗网站列表 422、eBPF 激活
弹窗按钮文案、拦截新建开关对齐与导入按钮换行

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:58:37 +08:00
耗子 5ef34ab1ba refactor(app): 编译参数改为安装时输入
移除独立的编译参数按钮,应用安装弹窗内嵌可折叠的参数表单,
编译型环境安装改为带参数输入的弹窗,提交安装前自动保存

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:58:21 +08:00
github-actions[bot] 83750d15c5 New Crowdin translations by GitHub Action (#1719)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-20 23:54:40 +08:00
h2zi 824f06970d chore(l10n): update pot files 2026-07-20 15:42:53 +00:00
renovate[bot] 7ad1b721f7 chore(deps): Update module github.com/samber/do/v2 to v2.1.0 (#1718)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-20 23:38:57 +08:00
耗子 1f899d1e47 chore(deps): 升级 monaco-editor 至 0.56.0 适配 ESM 结构重组
0.56.0 重组了 ESM 模块并启用 exports 映射,语言包与 worker
的深路径导入改为 exports 子路径形式

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:30:22 +08:00
耗子 a2e46faab9 feat(task): 任务队列支持取消运行中与等待中的任务
任务执行接入 context 并放入独立进程组,取消时整组杀死;
新增可选 CancelShell 清理钩子,远程下载、压缩、备份任务
取消后自动清理残留文件,备份改用独享临时目录避免误伤并发备份;
防重改为按语言无关的任务标识 Key 匹配

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:16:49 +08:00
耗子 8fa5c6d881 feat(file): 文件分享下载支持面板间互传
生成免登录的临时下载链接,强制设置有效期并支持下载次数限制,
过期分享由定时任务每小时清理,链接可直接用于其他面板的远程下载。

Closes #1641

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:47:11 +08:00
github-actions[bot] 293da754ce New Crowdin translations by GitHub Action (#1717)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-20 22:14:17 +08:00
h2zi 0078625289 chore(l10n): update pot files 2026-07-20 14:00:57 +00:00
耗子 1bfe08e52e feat(ui): 新增安全菜单归并防火墙与防篡改
侧边栏以安全父菜单取代防火墙原位置,防火墙与防篡改作为子菜单,
旧路径重定向兼容书签与持久化标签页,补充防篡改菜单标题翻译映射

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 21:58:25 +08:00
耗子 58da2ce5c3 Merge remote-tracking branch 'origin/renovate/non-major-dependencies' 2026-07-20 20:49:59 +08:00
h2zi 8672a8456b chore(l10n): update pot files 2026-07-20 12:40:29 +00:00
renovate[bot] f00f0c7f14 chore(deps): Update module github.com/pkg/sftp to v1.13.11 2026-07-20 12:40:18 +00:00
renovate[bot] c80faa5f0f chore(deps): Update non-major dependencies (#1709)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-20 20:38:59 +08:00
renovate[bot] 1baa7c03a8 chore(deps): Update actions/setup-go action to v7 (#1715)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-20 20:16:26 +08:00
耗子 25331a7a24 fix(ci): 修复 errcheck 告警与防篡改设置校验规则
统一 defer 关闭与错误路径清理为显式忽略返回值,
tamper 设置 Mode 校验改用项目 DSL 支持的 required 规则

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:57:56 +08:00
耗子 d5a635c6dd feat(database): 数据库页按已添加服务器类型动态显示标签页
用户反馈类型标签页太多显示乱,改为仅展示已添加服务器的类型,
默认定位到第一个可用类型;服务器增删改后经事件总线联动刷新,
修复事件总线整型解绑误伤其他组件监听的问题

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:14:03 +08:00
耗子 8fc21039c0 feat(pgadmin): 全量同步 PG 服务器并保留增强 Cookie 保护
一键登录改为一次性合并面板全部 PostgreSQL 服务器(dump 查缺仅追加,
不影响手动添加的条目);代理登录透传浏览器 IP/UA,通过 Flask-Paranoid
的 sha256(IP|UA) 会话绑定校验,无需关闭 ENHANCED_COOKIE_PROTECTION;
适配 9.x React 登录页的 JSON 形态 CSRF token,Docker 实测全链路通过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:14:03 +08:00
耗子 b5b5388b5c feat(app): 上架 pgAdmin 应用
pip+venv 本地部署,管理页支持改端口与重置密码,数据库 PostgreSQL 页
一键同步服务器并免登录进入 pgAdmin(凭据经 pgpass 免密)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 09:50:20 +08:00
耗子 874abf005d feat(app): 应用安装支持自定义编译参数
编译型应用与 PHP/Python 环境支持持久化的前置脚本与 configure 参数,
保存在 storage/customize 随安装更新自动生效,并接入面板备份与修复

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:27:39 +08:00
耗子 3c7385114f fix(file): 行内更多按钮补齐防篡改保护操作,与右键菜单一致
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 06:01:46 +08:00
耗子 2492534c28 feat(file): 文件管理集成防篡改状态展示与右键保护操作
- 列表/图标视图受保护条目显示盾牌图标,与裸 immutable 锁区分
- 右键菜单添加/移除保护(防篡改运行时显示,弹窗二次确认)
- 新增 /tamper/check_paths 批量状态查询与 /tamper/protect 单路径切换
- 保护切换经规则/排除联动:命中排除则去除排除项,目录可直建整树规则,
  路径为规则根则删规则,规则内子路径则加入排除

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 05:53:22 +08:00
耗子 5db94ee61c fix(tamper): 修复登记表脏映射与新建监控竞态并统一英文日志
- Unlock/Relock 同步维护事件回填表与统计,移动/替换文件后不再残留脏路径
- 新建目录补扫竞态窗口内已写入的文件,堵住 mkdir 后立即落文件的漏检
- chattr 整树模式下新建拦截需临时解除父目录 +a 才能删除
- inotify 监控失败聚合告警,便于发现 max_user_watches 耗尽
- 扫描器单网卡挂载失败仅跳过,聚合缓冲加上限防伪造源洪水耗尽内存
- ringbuf 解析改主机字节序,日志与错误串统一英文

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 05:53:14 +08:00
耗子 f151e29659 fix(tamper): 修复 eBPF 防护绕过并优化两处 BPF map 资源占用
- inode_rename 增查 new_dentry,堵住改名覆盖受保护文件的绕过
- 新增 inode_setattr 拦截点,堵住 truncate/chmod/chown 绕过
- 两处 hash map 加 BPF_F_NO_PREALLOC,消除数十 MB 内核内存预占
- 保护集合改批量更新,大目录树免逐键 syscall
- 扫描检测器 Supported() 在 lo 试挂 TCX,低版本内核不再误报支持
- lsm= 注入正则加词边界,防误改同后缀参数

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 05:28:26 +08:00
耗子 bea4654316 fix(tamper): eBPF 保护键改用 dev+inode 复合键消除跨文件系统误拦
仅用 inode 号做 protected map 的键会在不同文件系统间冲突:tmpfs
(/tmp、/dev/shm、/run)的 inode 号小而密集,极易与 /www 下受保护
文件的 inode 相同,导致这些 tmpfs 文件的合法写/删/改名被内核 EPERM
误拒。改为读取 inode->i_sb->s_dev 与 i_ino 组成 (dev, ino) 复合键,
用户态将 stat 的 glibc dev 编码转换为内核 s_dev 格式(MKDEV)以对齐。

顺带优化:ringbuf 事件解析改为按偏移零分配读取;移除引擎中未使用的
Clear/Count/Events 死代码。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 05:07:57 +08:00
h2zi 2b28b16338 chore: update mocks 2026-07-19 20:21:37 +00:00
耗子 a0b5a356a8 feat(tamper): 面板内置网站文件防篡改
双模式运行时可选:
- chattr 模式:inode 属性锁定(+i/+a),内核 VFS 层事前强制,全发行版通用
- eBPF-LSM 模式:纯 Go asm 手写 LSM 程序 + 运行时 BTF 查偏移(零编译),
  拦截写/删/改名并溯源进程,bpf LSM 未激活时引导改 grub 重启激活

含独立审计库、拦截日志、保护规则、fsnotify 新建监控、面板写入自动解锁恢复,
以及备份修复适配。参考 pkg/firewall/scan 的 eBPF 运行时加载范式

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 04:20:37 +08:00
耗子 c9d5fb264f feat(website): 默认设置支持切换默认站点
在内置默认配置与网站配置之间迁移 default_server 标志,
支持将未匹配域名的请求指向任意网站或恢复面板内置默认页

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 03:16:34 +08:00
耗子 90b37f5e6a feat(ssh): 终端支持主机间 SFTP 文件互传
终端窗口新增文件传输标签页,双栏浏览任意主机(含面板本机)目录,
选中文件后通过面板中转互传,传输队列实时展示进度速度并支持取消

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 03:05:51 +08:00
耗子 f72e7fa31e feat(database): MySQL 管理页支持 phpMyAdmin 一键登录跳转
支持选择面板中添加的任意 MySQL 服务器,以对应凭据代理登录并转发会话 Cookie

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 02:54:44 +08:00
耗子 1ca986e779 feat(container): 支持查看容器详情与编辑容器
详情弹窗双 Tab:格式化展示挂载/端口/网络/环境变量等基本信息,原始 Tab 直接展示 inspect 输出
编辑复用创建表单,由 inspect 回填配置,确认后删除旧容器按新配置重建

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 02:15:53 +08:00
耗子 a883cac315 feat(toolbox): 数据迁移支持忽略环境一致性验证
环境检查未通过时可勾选忽略,弹警告二次确认后放行下一步

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 02:15:43 +08:00
耗子 f904a0e53b feat(firewall): 端口规则支持 xlsx 导出与导入
导出按固定表头生成 xlsx,导入按表头定位列并逐条下发,返回成功失败数

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 02:15:33 +08:00
耗子 e6325c3348 chore(deps): 升级 vite 至 8.1.5 修复构建产物白屏
rolldown 1.0.2 起 tree-shaker 将 runtime-dom 初始化 wrapper 内联删除后
未同步清理跨 chunk 调用点,导致懒加载 chunk 运行时 ReferenceError 白屏,
rolldown 1.1.2(vite 8.1.0+)已根治,同步移除 Renovate 对 vite 的忽略

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:00:45 +08:00
耗子 d02f556e1c fix: 应用列表无运行状态时显示 - 而非未知标签
s3fs 等无 systemd 服务概念的应用不再渲染误导性的未知标签

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 23:56:45 +08:00
耗子 2e1d403e8b fix: phpMyAdmin 运行状态跟随 nginx 服务
phpMyAdmin 由 nginx 站点承载,状态固定返回 n/a 会在应用列表显示为未知,令用户困惑

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 23:56:45 +08:00
renovate[bot] 7d2aa8401d chore(deps): Update major dependencies (#1714)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-18 23:46:16 +08:00
耗子 c2d194b489 chore(deps): Renovate 忽略 TypeScript major 升级
vue-tsc 尚未支持 TypeScript 7 (tsgo),vuejs/language-tools#5381
仍被上游阻塞,待支持后移除该规则

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:07:10 +08:00
耗子 b5bdfdb110 fix(file): 补全单文件格式压缩入口并适配压缩测试
.bz2/.xz/.zst 后缀已被识别为单文件压缩格式,但 Compress 未提供
对应入口导致报 unsupported format,与 .gz 一并收敛为统一分支,
解压侧同步收敛,测试改为表驱动覆盖归档与单文件两类格式

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:07:05 +08:00
耗子 1450c00ec6 fix(file): 支持 .gz 单文件压缩并补全 zst 解压入口
压缩弹窗此前提供 .gz 选项但后端无对应实现,现后端按单文件 gzip 压缩,
前端多选文件时禁用 .gz 并自动回退 .zip;另补上前端 zst 后缀的解压入口

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:18:31 +08:00
耗子 c2505688a6 fix(file): 兼容单独压缩的 .xz/.bz2/.zst 文件解压
此前 .xz/.bz2/.zst 后缀一律按 tar 归档解压,导致非 tar 打包的单独压缩文件
(如 gitea-xxx.xz、xxx.sql.bz2)报 This does not look like a tar archive,
现参照 .gz 的处理方式区分 .tar.* 与单独压缩格式

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:13:07 +08:00
耗子 349dd36cc7 style(file): 消除 oxlint no-useless-spread 告警
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:16:09 +08:00
耗子 a9e16db711 fix(file): 修复文件管理与编辑器的状态同步问题并收敛操作入口
- 编辑器保存按状态栏行分隔符规范化,修复粘贴 CRLF 内容后 LF 文件被存成 CRLF
- 删除/翻页/排序后清理失效的选中项与目录大小缓存,修复表头复选框残留部分勾选
- 删除/重命名/移动文件后同步编辑器已打开的标签页(closePath/movePath)
- 编辑器提升至 IndexView 承载,修复切换标签页销毁未保存内容;最小化时禁用全局快捷键
- 权限弹窗打开时自取文件信息,修复顶栏入口打开时初值残留
- 批量删除/改权限改用 allSettled,部分失败时也刷新列表
- 文件操作收敛至 useFileOps/useEditorOps 统一入口,selected 归位 fileStore 按标签页隔离
- 全局事件总线事件表类型化

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:07:10 +08:00
renovate[bot] bd680a4082 chore(deps): Lock file maintenance (#1712)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-13 11:15:45 +00:00
耗子 8f226e988f fix: 修复端口清理、服务重载与本地 MySQL 连接问题
- 关闭 HTTPS 时清理 IPv6 等 SSL 监听,避免残留 ssl/quic 参数致 nginx 无法启动
- Web 服务未运行时跳过 reload,避免停止状态保存网站配置误报重载失败
- reload 失败补全 nginx -t / apachectl configtest 输出(原走 stderr 被丢弃)
- 本地 MySQL 优先走 unix socket 连接,规避 skip-name-resolve 下 root@localhost 无法反解匹配

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 05:17:46 +08:00
renovate[bot] a6df473e02 chore(deps): Update module github.com/libtnb/chix to v2 (#1710)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-11 04:04:49 +08:00
耗子 5efb32a661 refactor(biz): 用例层承载业务编排,data 层退为数据/外部原语
将压在 data 层胖 repo 的业务逻辑上移到 biz 用例层,让三层名副其实:
- 34 个 XxxUsecase 从纯透传空壳变为承载领域规则、跨 repo 编排、操作审计的
  真正业务层;data 层 repo 退为数据访问与外部系统原语
- 24 条原压在 repo 层的跨 repo 编排上移为 usecase→usecase/repo 依赖,仅保留
  3 处嵌在外部原语内无法剥离的 repo→repo(backup→setting/website、website→setting)
- 新增 internal/biz/helper.go:operatorID / IsNotFound / containerSock
- 表现层(service/job/command/apps/middleware)零改动,用例对外签名冻结

经全部 34 域逐语句对抗审查确认行为等价,7 处漂移已修复或知情接受。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:16:10 +08:00
renovate[bot] 7fb6953773 chore(deps): Update pnpm to v11.11.0 (#1708)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-10 01:50:33 +00:00
renovate[bot] 7a1566b642 chore(deps): Update module github.com/libtnb/validator/contrib/openapi to v0.2.0 (#1707)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-09 19:28:42 +00:00
耗子 a8a09de21f Merge branch 'refactor/samber-do-architecture'
# Conflicts:
#	go.mod
#	go.sum
2026-07-09 22:35:27 +08:00
耗子 cbed8e4797 refactor(job): 定时任务改为声明式贡献,测试脱离环境变量
- 每个任务合并为单个 NewXxx(i do.Injector) (Job, error) 构造函数,
  直接返回声明式 Job{Spec, Task},删除 JobFn 闭包与 Register 包装层
- 调度器改为 registry.Collect[job.Job] 统一装配,与路由贡献模型对称
- InitGlobal 导出并读取 conf.App.Root(默认 /opt/ace),
  返回 error 不再 Fatalf,修复原先无视配置 root 字段的硬编码
- 容器装配测试改用内存配置 + do.OverrideValue 注入,
  不再依赖磁盘配置与 PANEL_CONFIG 环境变量(原测试实际会失败)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 22:31:56 +08:00
renovate[bot] 4f1cfc4dee chore(deps): Update non-major dependencies (#1706)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-09 13:55:07 +00:00
耗子 443516a2cf refactor!: 迁移至 samber/do 依赖注入与三层架构
依赖注入:
- 移除 google/wire(含 wire.go/wire_gen.go 与全部 ProviderSet),
  改用 samber/do v2 单注入器 + 双入口惰性构建
- 贡献模型替代命令式注册:路由 routes:、命令 commands:、
  任务 jobs: 前缀经 internal/registry 收集与校验
- 构造函数统一 func NewXxx(i do.Injector) (T, error)

三层架构:
- 补全用例层:每个 biz.XxxRepo 配 XxxUsecase,
  service/command/job 表现层只依赖用例,不再直接引用仓储

路由与文档:
- route/http.go 按域拆为声明式 Endpoint 贡献,
  Endpoint 承载登录白名单与端点限流语义
- 调试模式下提供 OpenAPI 3.1 文档:/openapi.json 与 /docs(Scalar),
  从 validate 标签生成

目录与依赖:
- internal/http/{middleware,request,rule} 拍平至 internal/*
- CLI 命令拆至 internal/command
- 日志轮转 timberjack 换为 libtnb/logrotate
- 升级 validator/cron/sessions/gormstore/sqlite/securecookie
- 数据库迁移保持 gormigrate 不变

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 21:54:22 +08:00
renovate[bot] 2483fabb0c chore(deps): Update non-major dependencies (#1704)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-09 00:47:07 +00:00
renovate[bot] 3b02563d71 chore(deps): Update non-major dependencies (#1702)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-08 12:32:48 +00:00
耗子 6f0593fbf8 feat: 升级 libtnb/validator v0.2.1,用上新校验能力
- 切片字段加 unique 防重复配置(域名/监听/IP/白名单/网卡/磁盘设备/
  NTP 服务器/应用标识/压缩路径/TLS 版本/索引/定时任务目标)
- 网站统计日期范围 End 加 after_or_equal:Start(结束不早于开始)
- 新增 tags_test.go:CheckRules 体检关键请求 + 全包 validate tag
  编译校验,坏规则名/语法错/未注册自定义规则在测试期即报错

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:11:28 +08:00
renovate[bot] c5fb80a4fb chore(deps): Update module github.com/go-chi/chi/v5 to v5.3.1 (#1701)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-06 20:10:51 +00:00
renovate[bot] e1cb687bbc chore(deps): Lock file maintenance (#1700)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-06 05:09:03 +00:00
renovate[bot] 555ba71067 chore(deps): Update pnpm to v11.10.0 (#1699)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-05 01:37:23 +00:00
renovate[bot] 27b0cd0168 chore(deps): Update dependency tsx to v4.23.0 (#1698)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 21:11:42 +00:00
耗子 260690a25e fix(web): 修复快速切换 Tab 时文件编辑器内容错乱
编辑器改为每个 tab 一个独立 Monaco model,切换 tab 用 setModel 代
替 setValue;每个 model 的 onDidChangeContent 用闭包 boundPath 回
写,杜绝异步事件与 activeTabPath 错位时把 A 的内容写进 B 的问题。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-04 00:27:19 +08:00
耗子 3bdd3711b0 fix: SQLite使用NORMAL模式启动 2026-07-03 16:29:31 +08:00
耗子 5c77144f07 fix: 修复验证器规则报错 2026-07-03 08:59:04 +08:00
github-actions[bot] 94fe6753da New Crowdin translations by GitHub Action (#1697)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-03 04:48:47 +08:00
h2zi fe67fccde3 chore(l10n): update pot files 2026-07-02 20:29:58 +00:00
耗子 6b733fc2af fix(web): 修正 HealthBanner 中扫描感知的术语措辞
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-03 04:27:34 +08:00
h2zi 44f243e94f chore(l10n): update pot files 2026-07-02 20:22:59 +00:00
耗子 756834a59a chore(web): 同步 pnpm-lock 至 vite 8.1.3
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-03 04:21:14 +08:00
耗子 f024381660 feat(panel): 数据库故障降级、健康告警与检测
- 网站统计聚合器 flush 失败也 commit(丢弃已 drain 增量),防止 DB
  长期不可写时内存无界累积导致 OOM/CPU 100%
- 势态感知同样在 upsert 失败时上报健康问题
- 新增 internal/app.Health 全局健康状态注册表,通过 /home/health 暴露
- 前端 layout 顶部新增 HealthBanner,DB 故障时红色横幅提醒并给出修复引导
- acepanel fix 检测数据库损坏改用 PRAGMA quick_check,能识别 malformed
  等仅在查询时才暴露的静默损坏
- 主库与辅助库 DSN 一律设为 synchronous(FULL),减少宿主 write-back
  缓存丢失 fsync 导致的损坏概率
- 移除 debug.SetMemoryLimit(256MB):软上限在活对象超限时会驱动 GC 反复
  追赶不可回收对象,反而放大 CPU 打满

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-03 04:21:10 +08:00
耗子 3098d9fb64 fix(web): 升级 vite 至 8.1.3 修复生产构建白屏
vite 8.0.14+ 因 rolldown 1.0.2/1.0.3 tree-shaking 决策错误
(rolldown#9515),懒加载 chunk 调用未定义的 init_runtime_dom_esm_bundler
导致白屏。rolldown 1.1.2 (vite 8.1.0+) 修复。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-03 03:50:53 +08:00
github-actions[bot] fa71b6f4ef New Crowdin translations by GitHub Action (#1696)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-03 02:30:50 +08:00
h2zi 39b5f10012 chore(l10n): update pot files 2026-07-02 18:18:00 +00:00
h2zi 79fa002bd6 chore: update mocks 2026-07-02 18:15:21 +00:00
耗子 00a5acbc45 feat(panel): 重写自升级流程并精简面板备份 (close #1674)
升级改为覆盖式:只替换程序文件,storage 原地不动,中断安全无需 fix;新增 WS 实时进度,前端等待重启自动刷新。面板备份改为仅打包核心文件(ace/panel.db/config.yml/cert/cli),排除统计/扫描大库,体积大幅下降。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 02:12:52 +08:00
耗子 65e2750102 fix(website): 修复 IP 作为域名的处理(尤其 IPv6)
- 抽取 IsIPv6/WrapIPv6/UnwrapIPv6 到 pkg/tools,复用于 acme panelSolver
- server_name 写入时为 IPv6 套方括号、读取时剥离,Domains 统一裸地址口径,证书 SAN 判定自然正确
- 前端打开网站链接时为 IPv6 套方括号,避免拼出非法 URL

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:21:04 +08:00
耗子 93e013ec56 feat(file): 文件压缩/解压改为后台任务 (close #1675)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-03 00:51:27 +08:00
renovate[bot] 07e608a3c0 chore(deps): Update dependency tsx to v4.22.5 (#1695)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 00:32:26 +08:00
renovate[bot] a255e9e1d8 chore(deps): Update actions/checkout action to v7 (#1678)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-03 00:32:19 +08:00
耗子 f5ad988576 feat: 替换验证器实现 2026-07-03 00:22:23 +08:00
耗子 2e18c25054 feat(website): 纯静态网站默认写入 SPA 路由回退配置
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:58:31 +08:00
耗子 a98142ea93 fix(website): reload 失败时返回 web 服务配置测试详情
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:22:17 +08:00
耗子 4d8f6fad9f fix: X-Debug 调试日志仅在调试模式下生效
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:22:17 +08:00
renovate[bot] 67a7a41469 chore(deps): Update module github.com/bddjr/hlfhr to v1.6.1 (#1694)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-02 19:54:47 +08:00
renovate[bot] afae33c69a chore(deps): Update module github.com/shirou/gopsutil/v4 to v4.26.6 (#1693)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-02 00:56:45 +00:00
renovate[bot] 62c5271c37 chore(deps): Update dependency vue-tsc to v3.3.6 (#1692)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 23:05:34 +00:00
renovate[bot] ac116a7680 chore(deps): Update non-major dependencies (#1691)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-30 09:46:34 +00:00
renovate[bot] c1092576b2 chore(deps): Lock file maintenance (#1690)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-29 18:52:14 +08:00
renovate[bot] fea51108d2 chore(deps): Update module github.com/urfave/cli/v3 to v3.10.1 (#1689)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-28 21:49:54 +08:00
renovate[bot] 69dae9be95 chore(deps): Update dependency vite-plugin-vue-devtools to v8.1.5 (#1688)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-28 15:55:22 +08:00
耗子 e7b2323c70 Merge remote-tracking branch 'origin/main' 2026-06-28 15:53:15 +08:00
耗子 371f80451b fix: 移除部分默认配置 2026-06-28 15:53:08 +08:00
耗子 6e3b35b3db fix(acme): 修复 IP/通配符证书签发失败
- nginx server_name 中的 IPv6 字面量补上方括号,避免匹配不上 LE 带方括号的 Host 头被 default_server 接管
- Apache 改用 IP-based VirtualHost(ServerName 不支持 IPv6 字面量,会让 httpd -t 失败)
- DNS-01 改用 AppendRecords 单条追加(与 Caddy/certmagic 对齐),避免 SetRecords
  在 Cloudflare 等 provider 下按 (name,type) 覆盖同名 TXT,导致主域+通配符同时签发时
  主域 challenge 被通配符 challenge 覆盖

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-28 15:48:57 +08:00
renovate[bot] 2798d7fb5d chore(deps): Update dependency eslint to v10.6.0 (#1685)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-27 01:57:16 +00:00
renovate[bot] f3696332af chore(deps): Update non-major dependencies (#1684)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-26 17:02:38 +00:00
renovate[bot] a510b81cf5 chore(deps): Update non-major dependencies (#1683)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-25 17:51:27 +00:00
renovate[bot] 4b675c587f chore(deps): Update non-major dependencies (#1682)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-23 21:50:22 +00:00
renovate[bot] 3efc9e2f66 chore(deps): Update non-major dependencies (#1681)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-23 01:52:45 +00:00
耗子 3715e6db5e fix(web): 修复网站编辑大小/时间单位反推导致的下拉失效与跳变
将单位作为独立 UI 状态保存到对象上(_size_unit / _units),
不再从字节/纳秒反推单位,避免:
- 空值切换 GB 等单位时因 buildSize(0,...)=0 导致单位丢失
- 输入 1024 MB 因恰好是 GB 整数倍而被自动跳成 1 GB
- 输入 60 秒被自动跳成 1 分钟等同类问题

覆盖 EditModal.vue 中 client_max_body_size 与 5 个 duration 字段,
以及 NginxIndex.vue 中 stream upstream resolver_timeout。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 02:41:01 +08:00
耗子 29247e6944 refactor(pkg/dns): 精简 netplan 结构体与去除接口遍历重复
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-22 17:37:44 +08:00
renovate[bot] 6303ac943a chore(deps): Lock file maintenance (#1680)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-22 05:05:43 +00:00
renovate[bot] 8a89a40bd3 chore(deps): Update non-major dependencies (#1679)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-22 01:59:38 +08:00
renovate[bot] 3589bec397 chore(deps): Update non-major dependencies (#1677)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-20 03:11:43 +00:00
耗子 c2a8200817 fix: 修复 CI lint 与前端类型检查报错
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-20 02:14:22 +08:00
renovate[bot] 4a22299111 chore(deps): Update non-major dependencies (#1676)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-20 02:02:50 +08:00
耗子 54354c369a fix: fail2ban SSH 多 Port 时 jail 配置换行
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-18 20:17:22 +08:00
耗子 0181dadc05 fix: 文件上传前 precheck 冲突 支持跳过/重命名/覆盖
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-18 00:57:15 +08:00
耗子 30b593f90a Merge remote-tracking branch 'origin/main' 2026-06-17 18:24:29 +08:00
耗子 c22d6a430d fix: Chmod/Chown 递归操作前解锁 immutable 文件 2026-06-17 18:24:15 +08:00
renovate[bot] 08b09d58e8 chore(deps): Update non-major dependencies to v4.0.1 (#1671)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-16 16:00:07 +08:00
dependabot[bot] 3d3188630e chore(deps-dev): bump vite from 8.0.13 to 8.0.16 in /web (#1670)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.13 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.16
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 15:59:32 +08:00
renovate[bot] f5c23b8a2a chore(deps): Update non-major dependencies (#1668)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-16 03:50:02 +00:00
Copilot 78718db0a5 feat: info 命令仅首次运行重置密码,后续显示星号,支持 -f 强制重置 (#1669)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-16 01:32:53 +08:00
renovate[bot] 072cbb0132 chore(deps): Update non-major dependencies to v11.7.0 (#1667)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-15 22:41:20 +08:00
renovate[bot] c3732477be chore(deps): Update non-major dependencies (#1666)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-15 15:13:30 +08:00
renovate[bot] 03d617fea1 chore(deps): Lock file maintenance (#1665)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-15 05:50:15 +00:00
renovate[bot] 39f0d2564c chore(deps): Update non-major dependencies to v3.10.0 (#1664)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-14 16:59:38 +00:00
renovate[bot] c66cdfbc1f chore(deps): Update non-major dependencies to v8.1.3 (#1663)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-14 17:30:39 +08:00
renovate[bot] 97332f9650 chore(deps): Update non-major dependencies to v3.3.5 (#1662)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-13 13:56:45 +00:00
renovate[bot] be7aea4a68 chore(deps): Update non-major dependencies (#1661)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-12 22:42:14 +00:00
renovate[bot] a71b4009fb chore(deps): Update major dependencies to v7 (#1658)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-12 16:42:32 +08:00
耗子 ef58fcd6eb fix: 证书签发使用 CA 默认证书链
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:50:33 +08:00
renovate[bot] e8d19a8171 chore(deps): Update non-major dependencies (#1660)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-12 09:08:09 +08:00
renovate[bot] d2ba8190ca chore(deps): Update non-major dependencies (#1659)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-10 22:00:32 +00:00
renovate[bot] 31bd29ae7a chore(deps): Update non-major dependencies to v4.0.0-rc.5 (#1657)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-09 17:41:23 +08:00
renovate[bot] 003feef87c chore(deps): Update non-major dependencies (#1656)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-08 22:29:43 +00:00
耗子 0328215564 fix: 编辑网站问题优化 2026-06-08 23:06:06 +08:00
renovate[bot] 3e0c1dd574 chore(deps): Update non-major dependencies (#1653) 2026-06-08 17:24:42 +08:00
renovate[bot] c06f7a3629 chore(deps): Lock file maintenance (#1652)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-08 04:59:06 +00:00
github-actions[bot] 14790a1551 New Crowdin translations by GitHub Action (#1650)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-08 02:30:11 +08:00
h2zi 21593ca29d chore(l10n): update pot files 2026-06-07 18:20:25 +00:00
耗子 1a8bd4e400 chore: go mod tidy 2026-06-08 02:18:54 +08:00
耗子 865f12c75b feat: 支持计划任务备份clickhouse和redis 2026-06-08 02:17:09 +08:00
耗子 8c28ac19ec feat: 定时备份任务支持 ClickHouse/Redis/Valkey
cron 备份脚本生成在 mysql/postgresql 之外扩展 clickhouse/redis/valkey;
前端计划任务表单新增对应备份类型(按安装状态显示),Redis/Valkey 整实例
无库名,选中后自动以实例名作为备份目标。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:45:09 +08:00
github-actions[bot] ee15bfa67c New Crowdin translations by GitHub Action (#1649)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-08 01:33:12 +08:00
h2zi c04fdfc7aa chore(l10n): update pot files 2026-06-07 17:21:48 +00:00
耗子 ded7909fdb refactor: 重写 apache/nginx 配置解析器为 AST 驱动
apache 包废弃脆弱的字符级 lexer,重写为三层单遍解析器(逻辑行合并续行 +
shell 分词 + 递归下降)+ 统一有序 AST(VirtualHost 并入 Block、参数分离
引号信息)。修复嵌套块被静默丢弃、不支持续行符、特殊字符粘连、order 表
VirtualHost 排序等缺陷;proxy/redirect/vhost 读写全部改为 AST 驱动,删除
所有正则反解析与脆弱的 Sscanf/strings.Index。

nginx 包片段读端(proxy/redirect/upstream)从正则反解析改为 gonginx AST 遍历。

types.Vhost 接口清理:用 ConfigScope 类型常量替代 stringly-typed 的
"site"/"shared",用 SetRawConfig 独立方法替代 skipComment 可变布尔参数。

新增 robustness_test.go:用 apache 官方及社区真实配置验证语义无损、幂等、边界容错。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 00:00:48 +08:00
耗子 b5083dd8fa feat: 优化s3 sdk 2026-06-07 21:47:15 +08:00
耗子 c7e078b7f8 Merge remote-tracking branch 'origin/main' 2026-06-07 21:21:33 +08:00
耗子 3a937db506 fix: ipv6证书问题 2026-06-07 21:21:28 +08:00
renovate[bot] f92c661f33 chore(deps): Update non-major dependencies to v1.52.0 (#1648)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-06 21:02:41 +00:00
renovate[bot] 8c29bba051 chore(deps): Update non-major dependencies to v24.13.1 (#1647)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-06 04:43:21 +00:00
耗子 40e6f8e08f refactor: 重写s3 sdk 2026-06-05 23:33:35 +08:00
耗子 7d0a6ced8c Merge remote-tracking branch 'origin/main' 2026-06-05 19:54:50 +08:00
耗子 104cbc00f1 chore: run go mod tidy 2026-06-05 19:54:45 +08:00
h2zi 75c4f44262 chore: update mocks 2026-06-05 11:43:55 +00:00
耗子 ef44ab8f0d Merge remote-tracking branch 'origin/main' 2026-06-05 19:42:59 +08:00
耗子 568ce6b270 feat: 新增容器无极日志 2026-06-05 19:42:53 +08:00
renovate[bot] 20df3c5f81 chore(deps): Update non-major dependencies to v11.5.2 (#1646)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-05 18:55:46 +08:00
耗子 8e3999ccbe feat: 新增redis/valkey备份 2026-06-05 15:51:12 +08:00
耗子 b4efbe823f Merge remote-tracking branch 'origin/main' 2026-06-05 14:26:15 +08:00
耗子 ca218b31ec feat: 新增clickhouse复制 2026-06-05 14:26:09 +08:00
renovate[bot] e1823854bd chore(deps): Update non-major dependencies (#1645)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-04 17:56:05 +00:00
renovate[bot] 28bd1809cc chore(deps): Update non-major dependencies (#1644)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-04 00:43:01 +00:00
耗子 120baf3bd7 fix: 修复清理日志成功提示显示异常
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 04:22:59 +08:00
耗子 cbabd963df fix: 获取备份路径时自动创建目录
修复上传 MySQL 等备份文件时,因备份目录(如 /backup/mysql/)不存在导致
报错"文件不存在"的问题。将创建目录逻辑收敛到 GetDefaultPath,所有调用方均受益。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 04:05:51 +08:00
renovate[bot] 6a4f91e6cc chore(deps): Update pnpm to v11.5.1 (#1643)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 20:15:01 +08:00
renovate[bot] 8792d9b5cf chore(deps): Update non-major dependencies (#1642)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 03:40:34 +00:00
耗子 e667923c51 Merge remote-tracking branch 'origin/main' 2026-06-01 18:25:58 +08:00
耗子 c2d01c4371 fix: 修复问题 2026-06-01 18:25:52 +08:00
renovate[bot] 1b713c8f32 chore(deps): Update module github.com/shirou/gopsutil/v4 to v4.26.5 (#1640)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-01 16:25:06 +08:00
renovate[bot] 561db6a861 chore(deps): Update non-major dependencies (#1639)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-31 22:25:26 +00:00
耗子 9f3f0c8876 fix: 修复日志残留僵尸进程的问题 2026-05-31 22:43:25 +08:00
耗子 a06af8cd12 fix: 修复日志残留僵尸进程的问题 2026-05-31 22:31:26 +08:00
renovate[bot] a2c2797942 chore(deps): Update non-major dependencies (#1638)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-30 01:09:25 +00:00
renovate[bot] c39f3e29b8 chore(deps): Update non-major dependencies (#1637)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-30 00:34:07 +08:00
renovate[bot] 19f3d80717 chore(deps): Update non-major dependencies (#1635)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-27 18:47:53 +00:00
h2zi b5bdcaded6 chore: update mocks 2026-05-27 15:56:25 +00:00
fish 419cf50a37 Fix/postgresql port sync (#1636)
* refactor(postgresql): 优化配置处理并改进端口管理

- 引入 `configPath()` 方法,集中获取配置文件路径。
- 新增 `getPort()` 方法,用于从配置中动态获取 PostgreSQL 端口。
- 更新方法以使用新的配置处理机制,确保对 PostgreSQL 配置文件的访问一致性。
- 增强了配置更新期间端口变更时的错误处理和服务重启逻辑。

* fix(home): 修复数据库统计接口使用固定端口连接数据库的问题

- 更新了 HomeService,使其包含一个新的 databaseServerRepo,用于管理数据库服务器配置。
- 修改了 NewHomeService 构造函数,使其接受新的 databaseServerRepo 参数。
- 重构了数据库连接逻辑,使其利用数据库服务器存储库进行 PostgreSQL 连接。

* fix: optimize code

---------

Co-authored-by: 耗子 <haozi@loli.email>
2026-05-27 23:55:25 +08:00
耗子 b8311af661 Merge remote-tracking branch 'origin/main' 2026-05-27 21:33:45 +08:00
耗子 c8805779ed fix: optimize code 2026-05-27 21:33:39 +08:00
renovate[bot] 5a156a84cb chore(deps): Update module github.com/DeRuina/timberjack to v1.4.3 (#1634)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-27 09:14:00 +00:00
renovate[bot] 10415d3ac9 chore(deps): Update non-major dependencies (#1633)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-27 02:25:56 +00:00
耗子 16025c080b fix: 过滤非备份文件 2026-05-26 03:07:20 +08:00
耗子 f7fcd0ca8a fix: build 2026-05-26 02:39:49 +08:00
耗子 220d86348c fix: lint 2026-05-26 02:37:58 +08:00
耗子 215a8cf4d6 fix: build 2026-05-26 02:34:02 +08:00
耗子 3df31d8e7f fix: build 2026-05-26 02:24:19 +08:00
renovate[bot] 430974254e chore(deps): Lock file maintenance (#1631)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 02:12:52 +08:00
耗子 5884d1aeed Merge remote-tracking branch 'origin/main' 2026-05-26 02:10:06 +08:00
耗子 61769323a6 fix: web 2026-05-26 02:10:00 +08:00
renovate[bot] 3e5779bd1c chore(deps): Update non-major dependencies (#1632)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 02:08:43 +08:00
renovate[bot] d70688503b chore(deps): Update non-major dependencies (#1629)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: 耗子 <haozi@loli.email>
2026-05-26 02:02:36 +08:00
耗子 85cd8ef2bb fix: lint 2026-05-26 02:01:31 +08:00
renovate[bot] 5fd4bf2270 chore(deps): Update major dependencies to v9 (#1630)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 01:58:24 +08:00
耗子 0a162cbf57 Merge remote-tracking branch 'origin/main' 2026-05-26 01:55:21 +08:00
耗子 a19bf8f4bf fix: web 2026-05-26 01:55:15 +08:00
github-actions[bot] 03127e7f2a New Crowdin translations by GitHub Action (#1628)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-05-26 01:24:37 +08:00
h2zi 1a58daa007 chore(l10n): update pot files 2026-05-25 17:23:04 +00:00
耗子 bac3dcc86d fix: openresty服务名写错了 2026-05-26 01:15:27 +08:00
耗子 4739f6ea78 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	go.mod
2026-05-26 01:10:40 +08:00
耗子 5077e087d5 chore: 更新依赖 2026-05-26 01:09:43 +08:00
耗子 ef9789a6b2 feat: 安全加固 2026-05-26 00:21:08 +08:00
renovate[bot] 24af0f21b1 chore(deps): Update module github.com/go-chi/chi/v5 to v5.3.0 (#1627)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 23:52:15 +08:00
renovate[bot] f115e6f93c chore(deps): Update module github.com/go-webauthn/webauthn to v0.17.4 (#1626)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 23:39:53 +08:00
renovate[bot] 4b8779bfb4 chore(deps): Lock file maintenance (#1625)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 12:39:52 +00:00
renovate[bot] 4407d4aec2 chore(deps): Update non-major dependencies (#1622)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-25 09:36:07 +00:00
耗子 5ef6074870 fix: 网站/数据库/项目删除及应用卸载恢复 5 秒倒计时
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:52:09 +08:00
耗子 2427e90591 fix: 列表操作按钮使用 NFlex 统一间距避免重叠
Closes #1610

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:44:18 +08:00
耗子 292f22a1c2 feat: 压缩解压及面板备份支持 tar.zst 格式
Closes #1619

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:35:10 +08:00
renovate[bot] 7439cf489e chore(deps): Update non-major dependencies (#1621)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-21 20:49:47 +00:00
renovate[bot] 471570db77 chore(deps): Update dependency tsx to v4.22.1 (#1620)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-20 11:15:50 +00:00
renovate[bot] bd24e6de20 chore(deps): Update dependency oxfmt to ^0.50.0 (#1618)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-19 00:57:45 +00:00
renovate[bot] c81c13be1a chore(deps): Lock file maintenance (#1617)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 04:34:08 +00:00
renovate[bot] 0bb3aea1df chore(deps): Update non-major dependencies (#1616)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-17 20:47:36 +00:00
renovate[bot] 8f144e3358 chore(deps): Update non-major dependencies (#1615)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-16 20:35:10 +00:00
renovate[bot] fc74228e7e chore(deps): Update pnpm to v11.1.1 (#1614)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-15 17:01:17 +00:00
renovate[bot] d1eec7f7d6 chore(deps): Update non-major dependencies (#1613)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-15 05:38:03 +00:00
renovate[bot] 05e6a10a4e chore(deps): Update dependency vite to v8.0.12 (#1612)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 21:07:42 +08:00
耗子 db7e342a23 feat: 禁止删除 acepanel-network 内置网络
acepanel-network 由面板自身使用,误删会导致容器编排异常。后端
Remove 拦截该网络的删除请求,前端批量删除时一并将其排除。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:39:01 +08:00
耗子 5495a17748 fix: 修复裸域名与泛域名共用 ACME TXT 记录时相互覆盖
abc.com 与 *.abc.com 共用 _acme-challenge.abc.com,需同时存在两条
TXT 记录,但 SetRecords 以 (name, type) 为单位覆盖,导致后写入的
challenge 覆盖前者,DNS 验证一直卡死。改为按记录名累积所有 keyAuth
一次性写入。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:39:01 +08:00
renovate[bot] d152f4c59f chore(deps): Update module modernc.org/sqlite to v1.50.1 (#1611)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 01:01:33 +00:00
renovate[bot] 823635c4d8 chore(deps): Update module github.com/urfave/cli/v3 to v3.9.0 (#1609)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-13 01:47:51 +00:00
renovate[bot] aa6c1fb47e chore(deps): Update module github.com/go-webauthn/webauthn to v0.17.3 (#1608)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-12 18:51:32 +00:00
renovate[bot] bcff04bc36 chore(deps): Update non-major dependencies (#1606)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-12 04:35:33 +00:00
renovate[bot] 8aaf4e5c78 chore(deps): Update module github.com/libtnb/cron to v0.2.1 (#1605)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 21:08:51 +08:00
renovate[bot] c47ba0eac9 chore(deps): Update dependency vite-plugin-vue-devtools to v8.1.2 (#1603)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 09:55:43 +00:00
renovate[bot] 9db21de85b chore(deps): Lock file maintenance (#1604)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 05:33:46 +00:00
renovate[bot] a0a98d7c4e chore(deps): Update pnpm to v11.0.8 (#1602)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-10 08:54:33 +00:00
renovate[bot] ae6a43a780 chore(deps): Update pnpm to v11.0.7 (#1601)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-10 15:18:36 +08:00
renovate[bot] 6e87b0f1b3 chore(deps): Update non-major dependencies (#1600)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 17:32:24 +00:00
耗子 e76b2ee7a9 fix: 文件管理右键删除增加二次确认
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:43:29 +08:00
耗子 37a883d0bc fix: 移除删除确认默认 5 秒倒计时
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 15:40:10 +08:00
renovate[bot] ac48313948 chore(deps): Update non-major dependencies (#1599)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 01:46:56 +00:00
645 changed files with 62501 additions and 28104 deletions
+6 -5
View File
@@ -11,14 +11,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: true
run_install: |
- cwd: web
package_json_file: web/package.json
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: 'pnpm'
@@ -44,11 +45,11 @@ jobs:
fail-fast: true
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
cache: true
go-version: 'stable'
+5 -4
View File
@@ -10,21 +10,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
cache: true
go-version: 'stable'
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: true
run_install: |
- cwd: web
package_json_file: web/package.json
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: 'pnpm'
+5 -4
View File
@@ -12,20 +12,21 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: true
run_install: |
- cwd: web
package_json_file: web/package.json
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: 'pnpm'
cache-dependency-path: web/pnpm-lock.yaml
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
cache: true
go-version: 'stable'
+12 -7
View File
@@ -12,12 +12,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
cache: true
go-version: 'stable'
- name: Check Wire generated code
run: |
go generate ./cmd/ace ./cmd/cli
git diff --exit-code -- cmd/ace/wire_gen.go cmd/cli/wire_gen.go
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
@@ -28,9 +32,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
cache: true
go-version: 'stable'
@@ -42,14 +46,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: true
run_install: |
- cwd: web
package_json_file: web/package.json
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: 'pnpm'
+2 -2
View File
@@ -10,9 +10,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
cache: true
go-version: 'stable'
+3 -3
View File
@@ -11,9 +11,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
cache: true
go-version: 'stable'
@@ -28,7 +28,7 @@ jobs:
- name: Run tests
run: sudo go test -v -coverprofile="coverage.out" ./...
- name: Upload coverage report to Codecov
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v7
with:
disable_search: true
files: coverage.out
+2 -2
View File
@@ -11,8 +11,8 @@ _cgo_gotypes.go
_cgo_export.*
# 编译文件
ace
cli
/ace
/cli
*.com
*.class
*.dll
+4 -4
View File
@@ -82,10 +82,10 @@ bash <(curl -sSLm 10 https://dl.acepanel.net/helper.sh)
## Star 历史
<a href="https://star-history.com/#acepanel/panel&Date">
<a href="https://www.star-history.com/?repos=acepanel%2Fpanel&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=acepanel/panel&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=acepanel/panel&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=acepanel/panel&type=Date" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=acepanel/panel&type=date&theme=dark&legend=top-left&sealed_token=A7PSvKwCQys65o30bBWFVq0j-xWQ7q0kkPMbHsRTelnAZTLIVnCIIS18tyeO0LeMqQ4fsyoN1oPeM9dPIHpa1x03AV47vz7Q880xdcO7CH-y6qzXLCMO6g" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=acepanel/panel&type=date&legend=top-left&sealed_token=A7PSvKwCQys65o30bBWFVq0j-xWQ7q0kkPMbHsRTelnAZTLIVnCIIS18tyeO0LeMqQ4fsyoN1oPeM9dPIHpa1x03AV47vz7Q880xdcO7CH-y6qzXLCMO6g" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=acepanel/panel&type=date&legend=top-left&sealed_token=A7PSvKwCQys65o30bBWFVq0j-xWQ7q0kkPMbHsRTelnAZTLIVnCIIS18tyeO0LeMqQ4fsyoN1oPeM9dPIHpa1x03AV47vz7Q880xdcO7CH-y6qzXLCMO6g" />
</picture>
</a>
+16 -7
View File
@@ -1,25 +1,34 @@
package main
import (
"errors"
"fmt"
"os"
"runtime/debug"
_ "time/tzdata"
)
func main() {
if err := run(); err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Error:", err)
os.Exit(1)
}
}
func run() error {
if os.Geteuid() != 0 {
panic("panel must run as root")
return errors.New("panel must run as root")
}
debug.SetGCPercent(10)
debug.SetMemoryLimit(256 << 20)
ace, err := initAce()
ace, cleanup, err := initAce()
if err != nil {
panic(err)
return err
}
if cleanup != nil {
defer cleanup()
}
if err = ace.Run(); err != nil {
panic(err)
}
return ace.Run()
}
+12 -4
View File
@@ -7,15 +7,23 @@ import (
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps"
"github.com/acepanel/panel/v3/internal/biz"
"github.com/acepanel/panel/v3/internal/bootstrap"
"github.com/acepanel/panel/v3/internal/data"
"github.com/acepanel/panel/v3/internal/http/middleware"
"github.com/acepanel/panel/v3/internal/job"
"github.com/acepanel/panel/v3/internal/route"
"github.com/acepanel/panel/v3/internal/service"
)
// initAce init application.
func initAce() (*app.Ace, error) {
panic(wire.Build(bootstrap.ProviderSet, middleware.ProviderSet, route.ProviderSet, service.ProviderSet, data.ProviderSet, apps.ProviderSet, job.ProviderSet, app.NewAce))
func initAce() (*app.Ace, func(), error) {
panic(wire.Build(
bootstrap.ProviderSet,
apps.ProviderSet,
biz.ProviderSet,
data.ProviderSet,
service.ProviderSet,
route.ProviderSet,
job.ProviderSet,
app.NewAce,
))
}
+864 -143
View File
File diff suppressed because it is too large Load Diff
+22 -11
View File
@@ -1,21 +1,32 @@
package main
import (
"errors"
"os"
_ "time/tzdata"
"github.com/gookit/color"
)
func main() {
if os.Geteuid() != 0 {
panic("panel must run as root")
}
cli, err := initCli()
if err != nil {
panic(err)
}
if err = cli.Run(); err != nil {
panic(err)
if err := run(); err != nil {
color.Errorf("|-%v\n", err)
os.Exit(1)
}
}
func run() error {
if os.Geteuid() != 0 {
return errors.New("panel must run as root")
}
cli, cleanup, err := initCli()
if err != nil {
return err
}
if cleanup != nil {
defer cleanup()
}
return cli.Run()
}
+11 -5
View File
@@ -6,14 +6,20 @@ import (
"github.com/google/wire"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps"
"github.com/acepanel/panel/v3/internal/biz"
"github.com/acepanel/panel/v3/internal/bootstrap"
"github.com/acepanel/panel/v3/internal/command"
"github.com/acepanel/panel/v3/internal/data"
"github.com/acepanel/panel/v3/internal/route"
"github.com/acepanel/panel/v3/internal/service"
)
// initCli init command line.
func initCli() (*app.Cli, error) {
panic(wire.Build(bootstrap.ProviderSet, route.ProviderSet, service.ProviderSet, data.ProviderSet, apps.ProviderSet, app.NewCli))
func initCli() (*app.Cli, func(), error) {
panic(wire.Build(
bootstrap.ProviderSet,
biz.ProviderSet,
data.ProviderSet,
service.ProviderSet,
command.ProviderSet,
app.NewCli,
))
}
+189 -88
View File
@@ -8,39 +8,10 @@ package main
import (
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/apps/apache"
"github.com/acepanel/panel/v3/internal/apps/clickhouse"
"github.com/acepanel/panel/v3/internal/apps/codeserver"
"github.com/acepanel/panel/v3/internal/apps/docker"
"github.com/acepanel/panel/v3/internal/apps/elasticsearch"
"github.com/acepanel/panel/v3/internal/apps/fail2ban"
"github.com/acepanel/panel/v3/internal/apps/frp"
"github.com/acepanel/panel/v3/internal/apps/gitea"
"github.com/acepanel/panel/v3/internal/apps/grafana"
"github.com/acepanel/panel/v3/internal/apps/kafka"
"github.com/acepanel/panel/v3/internal/apps/mariadb"
"github.com/acepanel/panel/v3/internal/apps/memcached"
"github.com/acepanel/panel/v3/internal/apps/minio"
"github.com/acepanel/panel/v3/internal/apps/mongodb"
"github.com/acepanel/panel/v3/internal/apps/mysql"
"github.com/acepanel/panel/v3/internal/apps/nginx"
"github.com/acepanel/panel/v3/internal/apps/openresty"
"github.com/acepanel/panel/v3/internal/apps/opensearch"
"github.com/acepanel/panel/v3/internal/apps/percona"
"github.com/acepanel/panel/v3/internal/apps/phpmyadmin"
"github.com/acepanel/panel/v3/internal/apps/podman"
"github.com/acepanel/panel/v3/internal/apps/postgresql"
"github.com/acepanel/panel/v3/internal/apps/prometheus"
"github.com/acepanel/panel/v3/internal/apps/pureftpd"
"github.com/acepanel/panel/v3/internal/apps/redis"
"github.com/acepanel/panel/v3/internal/apps/rocketmq"
"github.com/acepanel/panel/v3/internal/apps/rsync"
"github.com/acepanel/panel/v3/internal/apps/s3fs"
"github.com/acepanel/panel/v3/internal/apps/supervisor"
"github.com/acepanel/panel/v3/internal/apps/valkey"
"github.com/acepanel/panel/v3/internal/biz"
"github.com/acepanel/panel/v3/internal/bootstrap"
"github.com/acepanel/panel/v3/internal/command"
"github.com/acepanel/panel/v3/internal/data"
"github.com/acepanel/panel/v3/internal/route"
"github.com/acepanel/panel/v3/internal/service"
)
@@ -50,70 +21,200 @@ import (
// Injectors from wire.go:
// initCli init command line.
func initCli() (*app.Cli, error) {
func initCli() (*app.Cli, func(), error) {
config, err := bootstrap.NewConf()
if err != nil {
return nil, err
return nil, nil, err
}
locale, err := bootstrap.NewT(config)
if err != nil {
return nil, err
return nil, nil, err
}
db, err := bootstrap.NewDB(config)
if err != nil {
return nil, err
return nil, nil, err
}
logger := bootstrap.NewLog(config)
cacheRepo := data.NewCacheRepo(db)
taskRunner := bootstrap.NewRunner(db, logger)
taskRepo := data.NewTaskRepo(locale, db, logger, taskRunner)
appRepo := data.NewAppRepo(locale, config, db, logger, cacheRepo, taskRepo)
userRepo := data.NewUserRepo(locale, db, logger)
userPasskeyRepo := data.NewUserPasskeyRepo(db)
settingRepo := data.NewSettingRepo(locale, db, logger, config, taskRepo)
databaseServerRepo := data.NewDatabaseServerRepo(locale, db, logger)
databaseUserRepo := data.NewDatabaseUserRepo(locale, db, logger, databaseServerRepo)
databaseRepo := data.NewDatabaseRepo(locale, db, logger, databaseServerRepo, databaseUserRepo)
certRepo := data.NewCertRepo(locale, db, logger, settingRepo)
certAccountRepo := data.NewCertAccountRepo(locale, db, userRepo, logger)
websiteRepo := data.NewWebsiteRepo(locale, db, logger, cacheRepo, databaseRepo, databaseServerRepo, databaseUserRepo, certRepo, certAccountRepo, settingRepo)
backupRepo := data.NewBackupRepo(locale, config, db, logger, settingRepo, websiteRepo)
cliService := service.NewCliService(locale, config, db, appRepo, cacheRepo, userRepo, userPasskeyRepo, settingRepo, backupRepo, websiteRepo, databaseServerRepo, certRepo, certAccountRepo)
cli := route.NewCli(locale, cliService)
command := bootstrap.NewCli(locale, cli)
gormigrate := bootstrap.NewMigrate(db)
apacheApp := apache.NewApp(locale)
clickhouseApp := clickhouse.NewApp(locale, settingRepo, databaseServerRepo)
codeserverApp := codeserver.NewApp()
dockerApp := docker.NewApp()
elasticsearchApp := elasticsearch.NewApp(locale)
fail2banApp := fail2ban.NewApp(locale, websiteRepo)
frpApp := frp.NewApp()
giteaApp := gitea.NewApp()
grafanaApp := grafana.NewApp(locale)
kafkaApp := kafka.NewApp(locale)
mariadbApp := mariadb.NewApp(locale, settingRepo, databaseServerRepo)
memcachedApp := memcached.NewApp(locale)
minioApp := minio.NewApp()
mongodbApp := mongodb.NewApp(locale, settingRepo, databaseServerRepo)
mysqlApp := mysql.NewApp(locale, settingRepo, databaseServerRepo)
nginxApp := nginx.NewApp(locale)
openrestyApp := openresty.NewApp(locale)
opensearchApp := opensearch.NewApp(locale)
perconaApp := percona.NewApp(locale, settingRepo, databaseServerRepo)
phpmyadminApp := phpmyadmin.NewApp(locale)
podmanApp := podman.NewApp()
postgresqlApp := postgresql.NewApp(locale, settingRepo, databaseServerRepo)
prometheusApp := prometheus.NewApp(locale, config, taskRepo)
pureftpdApp := pureftpd.NewApp(locale)
redisApp := redis.NewApp(locale, databaseServerRepo)
rocketmqApp := rocketmq.NewApp(locale)
rsyncApp := rsync.NewApp(locale)
s3fsApp := s3fs.NewApp(locale)
supervisorApp := supervisor.NewApp(locale)
valkeyApp := valkey.NewApp(locale, databaseServerRepo)
loader := bootstrap.NewLoader(apacheApp, clickhouseApp, codeserverApp, dockerApp, elasticsearchApp, fail2banApp, frpApp, giteaApp, grafanaApp, kafkaApp, mariadbApp, memcachedApp, minioApp, mongodbApp, mysqlApp, nginxApp, openrestyApp, opensearchApp, perconaApp, phpmyadminApp, podmanApp, postgresqlApp, prometheusApp, pureftpdApp, redisApp, rocketmqApp, rsyncApp, s3fsApp, supervisorApp, valkeyApp)
appCli := app.NewCli(command, gormigrate, loader)
return appCli, nil
logger, cleanup, err := bootstrap.NewLogger(config)
if err != nil {
return nil, nil, err
}
slogLogger := bootstrap.NewSlog(logger)
appRepo, err := data.NewAppRepo(config, db, locale, slogLogger)
if err != nil {
cleanup()
return nil, nil, err
}
cacheRepo, err := data.NewCacheRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
notifyChannelRepo, err := data.NewNotifyChannelRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
settingRepo, err := data.NewSettingRepo(config, db)
if err != nil {
cleanup()
return nil, nil, err
}
notifyUsecase, err := biz.NewNotifyUsecase(locale, slogLogger, notifyChannelRepo, settingRepo)
if err != nil {
cleanup()
return nil, nil, err
}
taskRunner, err := bootstrap.NewRunner(notifyUsecase, db, locale, slogLogger)
if err != nil {
cleanup()
return nil, nil, err
}
taskRepo, err := data.NewTaskRepo(db, locale, slogLogger, taskRunner)
if err != nil {
cleanup()
return nil, nil, err
}
appUsecase, err := biz.NewAppUsecase(locale, appRepo, cacheRepo, taskRepo)
if err != nil {
cleanup()
return nil, nil, err
}
websiteRepo, err := data.NewWebsiteRepo(db, locale, settingRepo)
if err != nil {
cleanup()
return nil, nil, err
}
backupRepo, err := data.NewBackupRepo(config, db, locale, slogLogger, settingRepo, websiteRepo)
if err != nil {
cleanup()
return nil, nil, err
}
backupUsecase, err := biz.NewBackupUsecase(notifyUsecase, locale, slogLogger, backupRepo)
if err != nil {
cleanup()
return nil, nil, err
}
cacheUsecase := biz.NewCacheUsecase(cacheRepo)
certAccountRepo, err := data.NewCertAccountRepo(db, locale, slogLogger)
if err != nil {
cleanup()
return nil, nil, err
}
userRepo, err := data.NewUserRepo(db, locale)
if err != nil {
cleanup()
return nil, nil, err
}
certAccountUsecase, err := biz.NewCertAccountUsecase(locale, slogLogger, certAccountRepo, userRepo)
if err != nil {
cleanup()
return nil, nil, err
}
certRepo, err := data.NewCertRepo(db, locale, slogLogger)
if err != nil {
cleanup()
return nil, nil, err
}
certUsecase, err := biz.NewCertUsecase(locale, slogLogger, certRepo, settingRepo)
if err != nil {
cleanup()
return nil, nil, err
}
cronRepo, err := data.NewCronRepo(db, locale)
if err != nil {
cleanup()
return nil, nil, err
}
cronUsecase := biz.NewCronUsecase(cronRepo, slogLogger)
databaseServerRepo, err := data.NewDatabaseServerRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
databaseServerUsecase, err := biz.NewDatabaseServerUsecase(locale, slogLogger, databaseServerRepo)
if err != nil {
cleanup()
return nil, nil, err
}
settingUsecase, err := biz.NewSettingUsecase(locale, slogLogger, settingRepo, taskRepo)
if err != nil {
cleanup()
return nil, nil, err
}
userPasskeyRepo, err := data.NewUserPasskeyRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
userPasskeyUsecase := biz.NewUserPasskeyUsecase(userPasskeyRepo)
userUsecase, err := biz.NewUserUsecase(locale, slogLogger, userRepo)
if err != nil {
cleanup()
return nil, nil, err
}
databaseUserRepo, err := data.NewDatabaseUserRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
databaseUserUsecase, err := biz.NewDatabaseUserUsecase(slogLogger, databaseServerRepo, databaseUserRepo)
if err != nil {
cleanup()
return nil, nil, err
}
databaseRepo, err := data.NewDatabaseRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
databaseUsecase, err := biz.NewDatabaseUsecase(databaseUserUsecase, locale, slogLogger, databaseRepo, databaseServerRepo)
if err != nil {
cleanup()
return nil, nil, err
}
tamperRepo, err := data.NewTamperRepo(db)
if err != nil {
cleanup()
return nil, nil, err
}
tamperUsecase, err := biz.NewTamperUsecase(notifyUsecase, settingUsecase, locale, slogLogger, tamperRepo)
if err != nil {
cleanup()
return nil, nil, err
}
websiteStatRepo, err := data.NewWebsiteStatRepo()
if err != nil {
cleanup()
return nil, nil, err
}
websiteStatUsecase := biz.NewWebsiteStatUsecase(websiteStatRepo)
websiteUsecase, err := biz.NewWebsiteUsecase(certAccountUsecase, certUsecase, databaseUsecase, databaseUserUsecase, tamperUsecase, websiteStatUsecase, locale, slogLogger, databaseServerRepo, websiteRepo)
if err != nil {
cleanup()
return nil, nil, err
}
cliService, err := service.NewCliService(appUsecase, backupUsecase, cacheUsecase, certAccountUsecase, certUsecase, cronUsecase, databaseServerUsecase, notifyUsecase, settingUsecase, userPasskeyUsecase, userUsecase, websiteUsecase, config, db, locale)
if err != nil {
cleanup()
return nil, nil, err
}
v := command.Commands(locale, cliService)
cliCommand, err := bootstrap.NewCli(locale, v)
if err != nil {
cleanup()
return nil, nil, err
}
gormigrate, err := bootstrap.NewMigrate(db)
if err != nil {
cleanup()
return nil, nil, err
}
cli, err := app.NewCli(cliCommand, gormigrate)
if err != nil {
cleanup()
return nil, nil, err
}
return cli, func() {
cleanup()
}, nil
}
+64 -51
View File
@@ -3,33 +3,31 @@ module github.com/acepanel/panel/v3
go 1.26
require (
github.com/DeRuina/timberjack v1.4.2
github.com/andybalholm/brotli v1.2.1
github.com/bddjr/hlfhr v1.5.1
github.com/andybalholm/brotli v1.2.2
github.com/bddjr/hlfhr v1.6.1
github.com/beevik/ntp v1.5.0
github.com/cilium/ebpf v0.21.0
github.com/coder/websocket v1.8.14
github.com/cilium/ebpf v0.22.0
github.com/coder/websocket v1.8.15
github.com/containerd/errdefs v1.0.0
github.com/coreos/go-systemd/v22 v22.7.0
github.com/creack/pty v1.1.24
github.com/dchest/captcha v1.1.0
github.com/expr-lang/expr v1.17.8
github.com/fsnotify/fsnotify v1.10.1
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/httplog/v3 v3.3.0
github.com/go-gormigrate/gormigrate/v2 v2.1.5
github.com/go-chi/chi/v5 v5.3.1
github.com/go-chi/httplog/v3 v3.4.0
github.com/go-gormigrate/gormigrate/v2 v2.1.6
github.com/go-sql-driver/mysql v1.10.0
github.com/go-webauthn/webauthn v0.17.2
github.com/go-webauthn/webauthn v0.17.4
github.com/gomodule/redigo v1.9.3
github.com/google/wire v0.7.0
github.com/gookit/color v1.6.1
github.com/gookit/validate v1.5.7
github.com/hashicorp/go-version v1.9.0
github.com/leonelquinteros/gotext v1.7.2
github.com/lib/pq v1.12.3
github.com/libdns/alidns v1.0.7
github.com/libdns/cloudflare v0.2.2
github.com/libdns/cloudns v1.1.0
github.com/libdns/cloudns v1.2.0
github.com/libdns/gcore v0.0.0-20250427050847-9964da923833
github.com/libdns/huaweicloud v1.0.1
github.com/libdns/libdns v1.1.1
@@ -37,69 +35,76 @@ require (
github.com/libdns/porkbun v1.1.0
github.com/libdns/tencentcloud v1.4.3
github.com/libdns/westcn v1.0.2
github.com/libtnb/cache v1.2.0
github.com/libtnb/chix v1.3.2
github.com/libtnb/cron v0.2.0
github.com/libtnb/gormstore v1.2.0
github.com/libtnb/sessions v1.4.0
github.com/libtnb/sqlite v1.1.1
github.com/libtnb/utils v1.2.1
github.com/medama-io/go-useragent v1.2.3
github.com/libtnb/chix/v2 v2.1.1
github.com/libtnb/cron v0.5.2
github.com/libtnb/gormstore v1.3.0
github.com/libtnb/logrotate v0.1.2
github.com/libtnb/sessions v1.5.0
github.com/libtnb/sqlite v1.2.1
github.com/libtnb/utils v1.2.2
github.com/libtnb/validator v0.4.1
github.com/libtnb/validator/contrib/openapi v0.2.0
github.com/medama-io/go-useragent v1.2.4
github.com/mholt/acmez/v3 v3.1.6
github.com/moby/moby/api v1.54.2
github.com/moby/moby/client v0.4.1
github.com/moby/moby/api v1.55.0
github.com/moby/moby/client v0.5.1
github.com/orandin/slog-gorm v1.4.0
github.com/pkg/sftp v1.13.10
github.com/pkg/sftp v1.13.11
github.com/pquerna/otp v1.5.0
github.com/rhnvrm/simples3 v0.11.1
github.com/samber/lo v1.53.0
github.com/sethvargo/go-limiter v1.1.0
github.com/shirou/gopsutil/v4 v4.26.4
github.com/sethvargo/go-limiter v1.2.0
github.com/shirou/gopsutil/v4 v4.26.6
github.com/spf13/cast v1.10.0
github.com/stretchr/testify v1.11.1
github.com/studio-b12/gowebdav v0.12.0
github.com/studio-b12/gowebdav v0.13.0
github.com/tufanbarisyildirim/gonginx v0.0.0-20260220081509-8e17ce617db3
github.com/urfave/cli/v3 v3.8.0
github.com/urfave/cli/v3 v3.10.1
github.com/valyala/fastjson v1.6.10
go.yaml.in/yaml/v4 v4.0.0-rc.4
golang.org/x/crypto v0.50.0
golang.org/x/net v0.53.0
gorm.io/gorm v1.31.1
modernc.org/sqlite v1.50.0
resty.dev/v3 v3.0.0-beta.6
github.com/wneessen/go-mail v0.8.1
github.com/xuri/excelize/v2 v2.11.0
go.yaml.in/yaml/v4 v4.0.0-rc.6
golang.org/x/crypto v0.54.0
golang.org/x/net v0.57.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
gorm.io/gorm v1.31.2
modernc.org/sqlite v1.55.0
resty.dev/v3 v3.0.0-rc.3
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/G-Core/gcore-dns-sdk-go v0.3.3 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/bddjr/shuttingdown v0.1.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/boyter/go-string v1.0.5 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.3 // indirect
github.com/gofiber/schema v1.7.1 // indirect
github.com/gofiber/utils/v2 v2.0.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/gofiber/schema v1.8.2 // indirect
github.com/gofiber/utils/v2 v2.2.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gookit/filter v1.2.3 // indirect
github.com/gookit/goutil v0.7.4 // indirect
github.com/imega/luaformatter v0.0.0-20211025140405-86b0a68d6bef // indirect
github.com/jaevor/go-nanoid v1.4.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/libtnb/securecookie v1.2.0 // indirect
github.com/libtnb/securecookie v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
@@ -107,10 +112,13 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/richardlehane/mscfb v1.0.7 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/timtadh/data-structures v0.6.2 // indirect
github.com/timtadh/lexmachine v0.2.3 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
@@ -118,21 +126,26 @@ require (
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
modernc.org/libc v1.72.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.47.0 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
replace (
github.com/mholt/acmez/v3 => github.com/libtnb/acmez/v3 v3.0.0-20260406165834-a264acd02292
github.com/moby/moby/client => github.com/libtnb/moby/client v0.0.0-20260127104057-d4b7902a560b
github.com/rhnvrm/simples3 => github.com/devhaozi/simples3 v0.0.0-20260124160558-447c94ecedff
github.com/stretchr/testify => github.com/libtnb/testify v0.0.0-20260406170114-25da2dad39e7
)
tool github.com/google/wire
tool github.com/google/wire/cmd/wire
+149 -114
View File
@@ -17,8 +17,6 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/DeRuina/timberjack v1.4.2 h1:4bKlzhKdsR+2oNkgef9mqb4n11ICow8VK88RfzJPzN8=
github.com/DeRuina/timberjack v1.4.2/go.mod h1:RLoeQrwrCGIEF8gO5nV5b/gMD0QIy7bzQhBUgpp1EqE=
github.com/G-Core/gcore-dns-sdk-go v0.3.3 h1:McILJSbJ5nOcT0MI0aBYhEuufCF329YbqKwFIN0RjCI=
github.com/G-Core/gcore-dns-sdk-go v0.3.3/go.mod h1:35t795gOfzfVanhzkFyUXEzaBuMXwETmJldPpP28MN4=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
@@ -26,13 +24,15 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/bddjr/hlfhr v1.5.1 h1:FxphatB9VSv8AV/50jwXX3QR12IJFD7fv2P99NymXqs=
github.com/bddjr/hlfhr v1.5.1/go.mod h1:YmFjslYd0ulNO0CtykhkhRUXJpfdq9sPVHQ4G67D610=
github.com/bddjr/hlfhr v1.6.1 h1:bzl0zsL2pl7Gvlbx6l8jZjjv9GbBTeOaX4/+RaXyNXc=
github.com/bddjr/hlfhr v1.6.1/go.mod h1:cHRve//dit7d8ZCPUUFZAYW5WCm3LdMoiqX9i9GJdms=
github.com/bddjr/shuttingdown v0.1.0 h1:1thUjnzTXNbzexEFkh638ebjln8F4KlqXfLIlBXfdbs=
github.com/bddjr/shuttingdown v0.1.0/go.mod h1:+vG4Zp8uhFLT40DBABiA/XxJDHbzybe8RR72i29DFI4=
github.com/beevik/ntp v1.5.0 h1:y+uj/JjNwlY2JahivxYvtmv4ehfi3h74fAuABB9ZSM4=
github.com/beevik/ntp v1.5.0/go.mod h1:mJEhBrwT76w9D+IfOEGvuzyuudiW9E52U2BaTrMOYow=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@@ -45,11 +45,11 @@ github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl
github.com/boyter/go-string v1.0.5 h1:/xcOlWdgelLYLVkUU0xBLfioGjZ9KIMUMI/RXG138YY=
github.com/boyter/go-string v1.0.5/go.mod h1:Mww9cDld2S2cdJ0tQffBhsZFMQRA2OJdcjWYZXvZ4Ss=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cilium/ebpf v0.21.0 h1:4dpx1J/B/1apeTmWBH5BkVLayHTkFrMovVPnHEk+l3k=
github.com/cilium/ebpf v0.21.0/go.mod h1:1kHKv6Kvh5a6TePP5vvvoMa1bclRyzUXELSs272fmIQ=
github.com/cilium/ebpf v0.22.0 h1:v2ktp0roffpMOj2MMf3idtCQZOsAoC4BJbAJN+ke2bY=
github.com/cilium/ebpf v0.22.0/go.mod h1:CDzZbe2hC5JjlDC+CY3KFCzlYwN4gbxppYM+Z10bQt4=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
@@ -67,14 +67,12 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ=
github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo=
github.com/devhaozi/simples3 v0.0.0-20260124160558-447c94ecedff h1:R8fAbRKWR+NVWIkjdK45CXS2cZzXQn8QuoM0Dij2Jso=
github.com/devhaozi/simples3 v0.0.0-20260124160558-447c94ecedff/go.mod h1:c2xW30bukipkBlWNnXG1wDjq3gykQ6ww2AB/9NHMLMY=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -84,30 +82,35 @@ github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLA
github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/httplog/v3 v3.3.0 h1:Gr6Y7nSzbpyCyRwKPOVKjDH3BH6TH5uvRNDsTZWDpvU=
github.com/go-chi/httplog/v3 v3.3.0/go.mod h1:N/J1l5l1fozUrqIVuT8Z/HzNeSy8TF2EFyokPLe6y2w=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/httplog/v3 v3.4.0 h1:gO4fvt8HEtFwHq926HoKe1aV2DymfPJuZy4+U4zwT3I=
github.com/go-chi/httplog/v3 v3.4.0/go.mod h1:tDhJo9G+F4mioDgX4pKbyA0uVZwCtHejoSsDkvJkFkU=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gormigrate/gormigrate/v2 v2.1.5 h1:1OyorA5LtdQw12cyJDEHuTrEV3GiXiIhS4/QTTa/SM8=
github.com/go-gormigrate/gormigrate/v2 v2.1.5/go.mod h1:mj9ekk/7CPF3VjopaFvWKN2v7fN3D9d3eEOAXRhi/+M=
github.com/go-gormigrate/gormigrate/v2 v2.1.6 h1:VtX+l1Stj2v5RGubVQk0LS/8EPGXR+ldcOyCmlmKoyg=
github.com/go-gormigrate/gormigrate/v2 v2.1.6/go.mod h1:PZpedQc4tWaxn6kvXicwhinh3L0seLpMc5ReKRX5id4=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
@@ -118,14 +121,14 @@ github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.2 h1:e9YtSZTVnxnMWFezXi6JvnqOSxmH4Er8QDHK2a/mM40=
github.com/go-webauthn/webauthn v0.17.2/go.mod h1:mQC6L0lZ5Kiu35G70zeB2WnrW4+vbHjR8Koq4HdVaMg=
github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA=
github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk=
github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI=
github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/utils/v2 v2.0.0 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0sM=
github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE=
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/gofiber/schema v1.8.2 h1:wq+LO2xEGlsqma/8Akp9PUebQ6vcsYmF0xYQ4F2ijvU=
github.com/gofiber/schema v1.8.2/go.mod h1:iyAMJztdyky7Pk2U7bwUP3EHjzMqUNjZ/hglE0nYO/g=
github.com/gofiber/utils/v2 v2.2.0 h1:YSSmCzQponq/f9uSOg2HtXC5qK1Dmor0o6DqaQVz8GE=
github.com/gofiber/utils/v2 v2.2.0/go.mod h1:Ieopk6sQh7rbhQ12aBNCJtJuG0gxAg0nz63sFCrrOmE=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -150,14 +153,14 @@ github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
@@ -168,12 +171,6 @@ github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0=
github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E=
github.com/gookit/color v1.6.1 h1:KoTnDxJPRgrL0SoX0f8rCFg2zI0t4E3GZZBMo2nN8LU=
github.com/gookit/color v1.6.1/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs=
github.com/gookit/filter v1.2.3 h1:Zo7cBOtsVzAoa/jtf+Ury6zlsbJXqInFdUpbbnB2vMM=
github.com/gookit/filter v1.2.3/go.mod h1:nFLJcOV8dRgS1iiX23gUQgmHUhpuS40qCvAGgIvA1pM=
github.com/gookit/goutil v0.7.4 h1:OWgUngToNz+bPlX5aP+EMG31DraEU63uvKMwwT3vseM=
github.com/gookit/goutil v0.7.4/go.mod h1:vJS9HXctYTCLtCsZot5L5xF+O1oR17cDYO9R0HxBmnU=
github.com/gookit/validate v1.5.7 h1:W0nqxAbgCJ4ND/oR+Tx8yyYn6l+PkFVvGHlG7LxpYsc=
github.com/gookit/validate v1.5.7/go.mod h1:VeThM1saXWVJIG/Yn627PJL9yvap0s4bsjPUIV8I0UI=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
@@ -226,8 +223,6 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
@@ -251,8 +246,8 @@ github.com/libdns/alidns v1.0.7 h1:0BhTUFlWIUyb5U7wujGqsyoeZkleUR/hMCtdZDS8Oc8=
github.com/libdns/alidns v1.0.7/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec=
github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI=
github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60=
github.com/libdns/cloudns v1.1.0 h1:W+1MadtxKySn3b5RITFTsXgTIvr5VoO5x97cewjlDcs=
github.com/libdns/cloudns v1.1.0/go.mod h1:/22V6tYYDALDpM4pw/RGGJ+X2F1Luibty9kKpKvkqBM=
github.com/libdns/cloudns v1.2.0 h1:V299bWj6HHTsXSzoEkRsKNBb9V/HGj3zp4OYzJ31JX8=
github.com/libdns/cloudns v1.2.0/go.mod h1:zKx/cXy9W3f4bGjbEtOx+B435flBrz1uibNr3h27+aU=
github.com/libdns/gcore v0.0.0-20250427050847-9964da923833 h1:/gsawtsq03cI7qgK65v16tYHIh40omL9YEzHWqnc63I=
github.com/libdns/gcore v0.0.0-20250427050847-9964da923833/go.mod h1:jZJEV7pCTOJFlaUhHty+YwR05dzoSmInXK/vT3wOeVg=
github.com/libdns/huaweicloud v1.0.1 h1:TdfpPY//3/QPhEGCyvR8u2DTlGxzpyaIsqofH5kYFyA=
@@ -269,26 +264,28 @@ github.com/libdns/westcn v1.0.2 h1:PA2M3tME5/0T3klPMzSHvGk1EWnGNNkJiKxaJjFalnM=
github.com/libdns/westcn v1.0.2/go.mod h1:iKpk8jjOU+793Yp8nHoihPTCR6N1KWtm4/r8BB9mVnk=
github.com/libtnb/acmez/v3 v3.0.0-20260406165834-a264acd02292 h1:FF1ZlwFFjpwT6CtBodr/NV4OMJPMC7plTmfzqhXrlSk=
github.com/libtnb/acmez/v3 v3.0.0-20260406165834-a264acd02292/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY=
github.com/libtnb/cache v1.2.0 h1:6MHHRTzP2GgNs1gD9VJoTIeQ3ud20tkkgvbOtzIf0Zs=
github.com/libtnb/cache v1.2.0/go.mod h1:QzCzSS5KBP2dcwMZtwKZO94eFMOViYMkUuxeLallZTE=
github.com/libtnb/chix v1.3.2 h1:LkA/+OaHeMyTiQoWEcCQKAMSKDZ0LxBXSENQO5d+OKk=
github.com/libtnb/chix v1.3.2/go.mod h1:ItssStBa/ov7v4qujyYzORqIf7Z6DO7Q4carOM7pyAQ=
github.com/libtnb/cron v0.2.0 h1:Dgn8kgooA+bYGC16OHnufGLQlvtFDmnoM0NVjyLOfrE=
github.com/libtnb/cron v0.2.0/go.mod h1:nrOHxS4Ernkn1E//vs64xzC58hGfoL0+lYSW2zsROGI=
github.com/libtnb/gormstore v1.2.0 h1:VdNTiG8Ah+glDFNxtj+ZrBhcqiLFxMviqlz2gez6zWU=
github.com/libtnb/gormstore v1.2.0/go.mod h1:8A5QzeZxi1MpSmjUVsHTDAL6KnU84feIXMutFLPawwA=
github.com/libtnb/moby/client v0.0.0-20260127104057-d4b7902a560b h1:73OlJdl4Zk6Ipnm7KKeE6yCjfd7nk0BhTArujx6WJCU=
github.com/libtnb/moby/client v0.0.0-20260127104057-d4b7902a560b/go.mod h1:bBQRIiaGR9EZBC+x5hnwunNgcDOts7E1Oa7AowJwMFU=
github.com/libtnb/securecookie v1.2.0 h1:2uc0PBDm0foeSTrcZ9QTX1IEjf6kFEwfgEYSIXQSKrA=
github.com/libtnb/securecookie v1.2.0/go.mod h1:ja+wNGnQzYqcqXQnJWu6icsaWi5JEBwNEMJ2ReTVDxA=
github.com/libtnb/sessions v1.4.0 h1:oQn4BckstpvqujSXejUcGAF4SQwTKFPIBe/Hv7s0aOs=
github.com/libtnb/sessions v1.4.0/go.mod h1:PvxiaI3PCD+kzjPmIrpV3s9e4ACps6OUaXgmKCYlUFo=
github.com/libtnb/sqlite v1.1.1 h1:p1Ud8vHTsPzgmChPUuJFkesrzl9ajD/pYrmoWf1cgig=
github.com/libtnb/sqlite v1.1.1/go.mod h1:PNlcqMVWU+pMIUR499fHvRLUdxrNVVVXwEZkW1c9stE=
github.com/libtnb/chix/v2 v2.1.1 h1:QQwHXUyPaUUp/KOwzXf2NB0IRKb9v2pqIdOIItzBPiE=
github.com/libtnb/chix/v2 v2.1.1/go.mod h1:OEY9v0976U/IJzP6n8VdhgXH17VL2muo3EQWsIUyxm8=
github.com/libtnb/cron v0.5.2 h1:Yy9a/S82bAS4KzE1SSMjNLtigPkejgMPUp9BHGeb1pw=
github.com/libtnb/cron v0.5.2/go.mod h1:nrOHxS4Ernkn1E//vs64xzC58hGfoL0+lYSW2zsROGI=
github.com/libtnb/gormstore v1.3.0 h1:OvVTYHZ9MCHI3Anf2ITtJLunAefyBQWG96EmHCE0eLM=
github.com/libtnb/gormstore v1.3.0/go.mod h1:Z/n6VlVdLPmvunSM8sYGNHVebTTN5yUSTZsyNxW2IZc=
github.com/libtnb/logrotate v0.1.2 h1:q9FLtYQYtzAlOB9bs91nPppqt3GNTUOonrBOivrtiOc=
github.com/libtnb/logrotate v0.1.2/go.mod h1:yqC9N3C5mg0fBlquK+cWLB7xG5plQrP0ma84hMRj3ic=
github.com/libtnb/securecookie v1.4.0 h1:SkKHO7T5I4aRGV7/6fnYYsleQDnnDzeAmTDA0GMPD98=
github.com/libtnb/securecookie v1.4.0/go.mod h1:mg1i9HfstsYBGwCfQdU+3Z1GuieyZRAxbkFUnrzchJU=
github.com/libtnb/sessions v1.5.0 h1:gvTRkSR8lpp1PB0Nms/kc+gh1BwJ411fxLjnZLYItLs=
github.com/libtnb/sessions v1.5.0/go.mod h1:/Q/+lO7DcnJJEU1nL59ScPV/V8ct34F8BjoEMwwxjRw=
github.com/libtnb/sqlite v1.2.1 h1:lrhN3yG49pj1gmtMMP6Es0lhKIkzDeqBul2EWOLyjpY=
github.com/libtnb/sqlite v1.2.1/go.mod h1:pGq13BirCjkM6ihkWWxs0PGFhBiwUyA0PqdSmgDWfmM=
github.com/libtnb/testify v0.0.0-20260406170114-25da2dad39e7 h1:ZzONsNhw69uC/wJQlMj66n1zfsRgKAElfJ//E9G380U=
github.com/libtnb/testify v0.0.0-20260406170114-25da2dad39e7/go.mod h1:HeQeTfKU6tj2Lx1z79UacwYeDioo6M4ZD7BDDI6+rrg=
github.com/libtnb/utils v1.2.1 h1:LJmReRREnpqfHyy9PZtNgBh3ZaIGct81b8ZaAsolMkM=
github.com/libtnb/utils v1.2.1/go.mod h1:o6LEDeC42PXI21uLWdWJWTVYvR9BtAZfzzTGJVQoQiU=
github.com/libtnb/utils v1.2.2 h1:HUqgAIb+TIrZPXRytYB5XqzkEXG2U4a4/ZGpprNtL5A=
github.com/libtnb/utils v1.2.2/go.mod h1:KUCx2+Phw3cLXOwuQDy6XwFOliaYuU1rpAuJQuHf+lQ=
github.com/libtnb/validator v0.4.1 h1:TYDC1C1yp93d02huzCqEc1aU/u5PAtC3jqGPtEdZchU=
github.com/libtnb/validator v0.4.1/go.mod h1:j08Lydxwy7ModkoKeYIHxvZvAtnzzdE8xi2J+SGVrgc=
github.com/libtnb/validator/contrib/openapi v0.2.0 h1:y47A16Lqtkc9/uifRIJ2XC+Rkgghj2fmHPrclcPXPtA=
github.com/libtnb/validator/contrib/openapi v0.2.0/go.mod h1:2vCdRniTmXWBtG4gro9B7s6ko4v3WdoSKjvhsWdTn3g=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
@@ -296,13 +293,15 @@ github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaO
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/medama-io/go-useragent v1.2.3 h1:jTv5NI+dn2hAe6zlagfXe/Y4934/YPzqxvP/gP0DjCQ=
github.com/medama-io/go-useragent v1.2.3/go.mod h1:H9GYWth4IN8vAFZh5LeARza7VwM4jK9uk7Tb9huVzLw=
github.com/medama-io/go-useragent v1.2.4 h1:kDux3lCjZvBpccKus/goaZl6YMnrl9D8Tx/wb4HqRMI=
github.com/medama-io/go-useragent v1.2.4/go.mod h1:H9GYWth4IN8vAFZh5LeARza7VwM4jK9uk7Tb9huVzLw=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
@@ -316,8 +315,10 @@ github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:F
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
@@ -337,9 +338,11 @@ github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJ
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE=
github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
@@ -356,6 +359,10 @@ github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7z
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -365,12 +372,12 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/sethvargo/go-limiter v1.1.0 h1:eLeZVQ2zqJOiEs03GguqmBVG6/T6lsZB+6PP1t7J6fA=
github.com/sethvargo/go-limiter v1.1.0/go.mod h1:01b6tW25Ap+MeLYBuD4aHunMrJoNO5PVUFdS9rac3II=
github.com/shamaton/msgpack/v3 v3.0.0 h1:xl40uxWkSpwBCSTvS5wyXvJRsC6AcVcYeox9PspKiZg=
github.com/shamaton/msgpack/v3 v3.0.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY=
github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sethvargo/go-limiter v1.2.0 h1:XKL1vsaQ2zztVJrnZSzpRWCq/aLQqMllJ/3D/0bt/cw=
github.com/sethvargo/go-limiter v1.2.0/go.mod h1:RC+qY2R7PAK81mBCrZEJlUlKnXSIqqQ8B7G44UgZ/1E=
github.com/shamaton/msgpack/v3 v3.2.0 h1:1q2Ms+MWmuRju+PuDMSFDB7p7621npeX4zprJN5Zck8=
github.com/shamaton/msgpack/v3 v3.2.0/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo=
github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs=
github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
@@ -390,9 +397,11 @@ github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5q
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/studio-b12/gowebdav v0.12.0 h1:kFRtQECt8jmVAvA6RHBz3geXUGJHUZA6/IKpOVUs5kM=
github.com/studio-b12/gowebdav v0.12.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ=
github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/timtadh/data-structures v0.5.3/go.mod h1:9R4XODhJ8JdWFEI8P/HJKqxuJctfBQw6fDibMQny2oU=
github.com/timtadh/data-structures v0.6.1/go.mod h1:uYUnI1cQi/5yMCc7s23I+x8Mn8BCMf4WgK+7/4QSEk4=
github.com/timtadh/data-structures v0.6.2 h1:zybDnU5NLjJ7WKMDJpvVwczQuf1wSLBgdRHZ9O4AqJ0=
@@ -410,15 +419,23 @@ github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZ
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/tufanbarisyildirim/gonginx v0.0.0-20260220081509-8e17ce617db3 h1:ClWHRIz18WRVT3RD9y9uTwEWU9eEi+VYFEIumg05FLk=
github.com/tufanbarisyildirim/gonginx v0.0.0-20260220081509-8e17ce617db3/go.mod h1:ALbEe81QPWOZjDKCKNWodG2iqCMtregG8+ebQgjx2+4=
github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/urfave/cli/v3 v3.10.1 h1:7Kx9H50hrHbRbyxgO1KP6/BcbiGRz0uYh5YyQ30JEEY=
github.com/urfave/cli/v3 v3.10.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM=
github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=
github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
@@ -426,30 +443,46 @@ github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q=
golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -461,8 +494,8 @@ golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -477,8 +510,8 @@ golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -487,8 +520,8 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -505,16 +538,16 @@ golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -534,8 +567,8 @@ golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
@@ -569,44 +602,46 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
resty.dev/v3 v3.0.0-beta.6 h1:ghRdNpoE8/wBCv+kTKIOauW1aCrSIeTq7GxtfYgtevU=
resty.dev/v3 v3.0.0-beta.6/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM=
resty.dev/v3 v3.0.0-rc.3 h1:k24LZ03Cb4Ue5e6O/Pfxu5TQRBBYGES6wm2wceia+Io=
resty.dev/v3 v3.0.0-rc.3/go.mod h1:NTOerrC/4T7/FE6tXIZGIysXXBdgNqwMZuKtxpea9NM=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
+2 -3
View File
@@ -13,7 +13,6 @@ import (
"github.com/bddjr/hlfhr"
"github.com/go-chi/chi/v5"
"github.com/go-gormigrate/gormigrate/v2"
"github.com/gookit/validate"
"github.com/libtnb/cron"
"github.com/acepanel/panel/v3/pkg/config"
@@ -31,7 +30,7 @@ type Ace struct {
runner types.TaskRunner
}
func NewAce(conf *config.Config, router *chi.Mux, server *hlfhr.Server, reloader *tlscert.Reloader, migrator *gormigrate.Gormigrate, cron *cron.Cron, runner types.TaskRunner, _ *validate.Validation) *Ace {
func NewAce(router *chi.Mux, conf *config.Config, cron *cron.Cron, migrator *gormigrate.Gormigrate, server *hlfhr.Server, reloader *tlscert.Reloader, runner types.TaskRunner) (*Ace, error) {
return &Ace{
conf: conf,
router: router,
@@ -40,7 +39,7 @@ func NewAce(conf *config.Config, router *chi.Mux, server *hlfhr.Server, reloader
migrator: migrator,
cron: cron,
runner: runner,
}
}, nil
}
func (r *Ace) Run() error {
+4 -10
View File
@@ -5,10 +5,7 @@ import (
"os"
"github.com/go-gormigrate/gormigrate/v2"
"github.com/gookit/color"
"github.com/urfave/cli/v3"
"github.com/acepanel/panel/v3/pkg/apploader"
)
type Cli struct {
@@ -16,12 +13,12 @@ type Cli struct {
migrator *gormigrate.Gormigrate
}
func NewCli(cmd *cli.Command, migrator *gormigrate.Gormigrate, _ *apploader.Loader) *Cli {
func NewCli(cmd *cli.Command, migrator *gormigrate.Gormigrate) (*Cli, error) {
IsCli = true
return &Cli{
cmd: cmd,
migrator: migrator,
}
}, nil
}
func (r *Cli) Run() error {
@@ -29,9 +26,6 @@ func (r *Cli) Run() error {
// 这里不处理错误,这么做是为了在异常时用户可以用 fix 命令尝试修复
_ = r.migrator.Migrate()
if err := r.cmd.Run(context.TODO(), os.Args); err != nil {
color.Errorf("|-%v\n", err)
}
return nil
// 错误必须向上返回,调用方据此以非零码退出,后台任务才能正确判定失败
return r.cmd.Run(context.TODO(), os.Args)
}
+73
View File
@@ -0,0 +1,73 @@
package app
import (
"sort"
"sync"
"time"
)
// HealthLevelError 需要用户立即处理
const HealthLevelError = "error"
// HealthLevelWarning 提示性问题,通常已被系统自动降级处理
const HealthLevelWarning = "warning"
// HealthIssue 单条健康问题
// Key 为稳定标识符(如 database:stat),前端据此选择翻译文案
// Message 为原始错误详情,供诊断参考
type HealthIssue struct {
Key string `json:"key"`
Level string `json:"level"`
Message string `json:"message"`
Since time.Time `json:"since"`
}
type healthRegistry struct {
mu sync.RWMutex
issues map[string]HealthIssue
}
// Health 全局健康状态注册表,供各后台任务上报/清除故障
var Health = &healthRegistry{issues: make(map[string]HealthIssue)}
// Report 上报或更新一条健康问题
// 同 key 重复上报时保留首次上报时间,仅更新 message 和 level
func (h *healthRegistry) Report(key, level, message string) {
h.mu.Lock()
defer h.mu.Unlock()
if existing, ok := h.issues[key]; ok {
existing.Level = level
existing.Message = message
h.issues[key] = existing
return
}
h.issues[key] = HealthIssue{
Key: key,
Level: level,
Message: message,
Since: time.Now(),
}
}
// Clear 清除指定 key 的健康问题
func (h *healthRegistry) Clear(key string) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.issues, key)
}
// Snapshot 返回当前所有健康问题,按 Since 升序(越早发生越靠前)
func (h *healthRegistry) Snapshot() []HealthIssue {
h.mu.RLock()
defer h.mu.RUnlock()
result := make([]HealthIssue, 0, len(h.issues))
for _, issue := range h.issues {
result = append(result, issue)
}
sort.Slice(result, func(i, j int) bool {
return result[i].Since.Before(result[j].Since)
})
return result
}
+23
View File
@@ -0,0 +1,23 @@
package acewaf
import (
"github.com/go-chi/chi/v5"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct{}
func NewApp() (*App, error) {
return &App{}, nil
}
func (s *App) Route(_ chi.Router) {
// WAF 管理路由由 route 贡献(route/waf.go)统一提供
}
func (s *App) Status() string {
ok, _ := systemctl.Status("acewaf")
return types.AggregateAppStatus(ok)
}
+2 -2
View File
@@ -22,10 +22,10 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) *App {
func NewApp(t *gotext.Locale) (*App, error) {
return &App{
t: t,
}
}, nil
}
func (s *App) Route(r chi.Router) {
+13 -30
View File
@@ -3,6 +3,7 @@ package apps
import (
"github.com/google/wire"
"github.com/acepanel/panel/v3/internal/apps/acewaf"
"github.com/acepanel/panel/v3/internal/apps/apache"
"github.com/acepanel/panel/v3/internal/apps/clickhouse"
"github.com/acepanel/panel/v3/internal/apps/codeserver"
@@ -22,6 +23,7 @@ import (
"github.com/acepanel/panel/v3/internal/apps/openresty"
"github.com/acepanel/panel/v3/internal/apps/opensearch"
"github.com/acepanel/panel/v3/internal/apps/percona"
"github.com/acepanel/panel/v3/internal/apps/pgadmin"
"github.com/acepanel/panel/v3/internal/apps/phpmyadmin"
"github.com/acepanel/panel/v3/internal/apps/podman"
"github.com/acepanel/panel/v3/internal/apps/postgresql"
@@ -36,34 +38,15 @@ import (
)
var ProviderSet = wire.NewSet(
apache.NewApp,
clickhouse.NewApp,
codeserver.NewApp,
docker.NewApp,
elasticsearch.NewApp,
fail2ban.NewApp,
frp.NewApp,
gitea.NewApp,
grafana.NewApp,
kafka.NewApp,
mariadb.NewApp,
memcached.NewApp,
minio.NewApp,
mongodb.NewApp,
mysql.NewApp,
nginx.NewApp,
openresty.NewApp,
opensearch.NewApp,
percona.NewApp,
phpmyadmin.NewApp,
podman.NewApp,
postgresql.NewApp,
prometheus.NewApp,
pureftpd.NewApp,
redis.NewApp,
rocketmq.NewApp,
rsync.NewApp,
s3fs.NewApp,
supervisor.NewApp,
valkey.NewApp,
apache.NewApp, clickhouse.NewApp, codeserver.NewApp,
docker.NewApp, elasticsearch.NewApp, fail2ban.NewApp,
frp.NewApp, gitea.NewApp, grafana.NewApp,
kafka.NewApp, mariadb.NewApp, memcached.NewApp,
minio.NewApp, mongodb.NewApp, mysql.NewApp,
nginx.NewApp, openresty.NewApp, opensearch.NewApp,
percona.NewApp, pgadmin.NewApp, phpmyadmin.NewApp, podman.NewApp,
postgresql.NewApp, prometheus.NewApp, pureftpd.NewApp,
redis.NewApp, rocketmq.NewApp, rsync.NewApp,
s3fs.NewApp, supervisor.NewApp, valkey.NewApp,
acewaf.NewApp,
)
+5 -5
View File
@@ -27,12 +27,12 @@ type App struct {
databaseServerRepo biz.DatabaseServerRepo
}
func NewApp(t *gotext.Locale, setting biz.SettingRepo, databaseServer biz.DatabaseServerRepo) *App {
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo) (*App, error) {
return &App{
t: t,
settingRepo: setting,
databaseServerRepo: databaseServer,
}
settingRepo: settingRepo,
databaseServerRepo: databaseServerRepo,
}, nil
}
func (s *App) Route(r chi.Router) {
@@ -66,7 +66,7 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
// 获取版本
versionResp, err := client.R().Get(fmt.Sprintf("http://127.0.0.1:%s/?query=SELECT+version()&user=default&password=%s", port, password))
if err != nil || !versionResp.IsSuccess() {
if err != nil || !versionResp.IsStatusSuccess() {
service.Success(w, []types.NV{})
return
}
+6 -6
View File
@@ -7,21 +7,21 @@ type UpdateConfig struct {
// SetDefaultPassword 设置 default 用户密码
type SetDefaultPassword struct {
Password string `form:"password" json:"password" validate:"required|password"`
Password string `form:"password" json:"password" validate:"required && password"`
}
// ConfigTune ClickHouse 配置调整
type ConfigTune struct {
// 网络
ListenHost string `form:"listen_host" json:"listen_host"`
HTTPPort string `form:"http_port" json:"http_port"`
TCPPort string `form:"tcp_port" json:"tcp_port"`
HTTPPort string `form:"http_port" json:"http_port" validate:"number && min:1 && max:65535"`
TCPPort string `form:"tcp_port" json:"tcp_port" validate:"number && min:1 && max:65535"`
// 性能
MaxMemoryUsage string `form:"max_memory_usage" json:"max_memory_usage"`
MaxThreads string `form:"max_threads" json:"max_threads"`
// 路径
Path string `form:"path" json:"path"`
TmpPath string `form:"tmp_path" json:"tmp_path"`
Path string `form:"path" json:"path" validate:"unix_path"`
TmpPath string `form:"tmp_path" json:"tmp_path" validate:"unix_path"`
// 日志
LogLevel string `form:"log_level" json:"log_level"`
LogLevel string `form:"log_level" json:"log_level" validate:"in:trace,debug,information,warning,error"`
}
+2 -2
View File
@@ -13,8 +13,8 @@ import (
type App struct{}
func NewApp() *App {
return &App{}
func NewApp() (*App, error) {
return &App{}, nil
}
func (s *App) Route(r chi.Router) {
+2 -2
View File
@@ -16,8 +16,8 @@ import (
type App struct{}
func NewApp() *App {
return &App{}
func NewApp() (*App, error) {
return &App{}, nil
}
func (s *App) Route(r chi.Router) {
+16 -16
View File
@@ -12,22 +12,22 @@ type LogOpts struct {
// Settings Docker daemon 设置
type Settings struct {
RegistryMirrors []string `json:"registry-mirrors,omitempty"` // 注册表镜像
InsecureRegistries []string `json:"insecure-registries,omitempty"` // 非安全镜像仓库
LiveRestore bool `json:"live-restore,omitempty"` // Live restore
LogDriver string `json:"log-driver,omitempty"` // 日志驱动
LogOpts LogOpts `json:"log-opts"` // 日志配置选项
CgroupDriver string `json:"cgroup-driver,omitempty"` // cgroup 驱动(从 exec-opts 中提取)
Hosts []string `json:"hosts,omitempty"` // Socket 路径
DataRoot string `json:"data-root,omitempty"` // 数据目录
StorageDriver string `json:"storage-driver,omitempty"` // 存储驱动
DNS []string `json:"dns,omitempty"` // DNS 配置
FirewallBackend string `json:"firewall-backend,omitempty"` // 防火墙后端 (iptables/nftables)
Iptables *bool `json:"iptables,omitempty"` // iptables 规则
Ip6tables *bool `json:"ip6tables,omitempty"` // ip6tables 规则
IpForward *bool `json:"ip-forward,omitempty"` // IP 转发
IPv6 *bool `json:"ipv6,omitempty"` // IPv6 支持
Bip string `json:"bip,omitempty"` // 默认 bridge 网络 IP 段
RegistryMirrors []string `json:"registry-mirrors,omitempty"` // 注册表镜像
InsecureRegistries []string `json:"insecure-registries,omitempty"` // 非安全镜像仓库
LiveRestore bool `json:"live-restore,omitempty"` // Live restore
LogDriver string `json:"log-driver,omitempty"` // 日志驱动
LogOpts LogOpts `json:"log-opts"` // 日志配置选项
CgroupDriver string `json:"cgroup-driver,omitempty" validate:"in:systemd,cgroupfs"` // cgroup 驱动(从 exec-opts 中提取)
Hosts []string `json:"hosts,omitempty"` // Socket 路径
DataRoot string `json:"data-root,omitempty"` // 数据目录
StorageDriver string `json:"storage-driver,omitempty"` // 存储驱动
DNS []string `json:"dns,omitempty"` // DNS 配置
FirewallBackend string `json:"firewall-backend,omitempty" validate:"in:iptables,nftables"` // 防火墙后端 (iptables/nftables)
Iptables *bool `json:"iptables,omitempty"` // iptables 规则
Ip6tables *bool `json:"ip6tables,omitempty"` // ip6tables 规则
IpForward *bool `json:"ip-forward,omitempty"` // IP 转发
IPv6 *bool `json:"ipv6,omitempty"` // IPv6 支持
Bip string `json:"bip,omitempty"` // 默认 bridge 网络 IP 段
}
// UpdateSettings 更新设置请求
+3 -3
View File
@@ -25,8 +25,8 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) *App {
return &App{t: t}
func NewApp(t *gotext.Locale) (*App, error) {
return &App{t: t}, nil
}
func (s *App) Route(r chi.Router) {
@@ -57,7 +57,7 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
client := resty.New().SetTimeout(10 * time.Second)
defer func(client *resty.Client) { _ = client.Close() }(client)
resp, err := client.R().Get(fmt.Sprintf("http://127.0.0.1:%s/_cluster/health", port))
if err != nil || !resp.IsSuccess() {
if err != nil || !resp.IsStatusSuccess() {
service.Success(w, []types.NV{})
return
}
+5 -5
View File
@@ -8,7 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix"
"github.com/libtnb/chix/v2"
"github.com/libtnb/utils/str"
"github.com/samber/lo"
"github.com/spf13/cast"
@@ -27,11 +27,11 @@ type App struct {
websiteRepo biz.WebsiteRepo
}
func NewApp(t *gotext.Locale, website biz.WebsiteRepo) *App {
func NewApp(t *gotext.Locale, websiteRepo biz.WebsiteRepo) (*App, error) {
return &App{
t: t,
websiteRepo: website,
}
websiteRepo: websiteRepo,
}, nil
}
func (s *App) Route(r chi.Router) {
@@ -177,7 +177,7 @@ ignoreregex =
switch jailName {
case "ssh":
filter = "sshd"
port, err = shell.Execf("cat /etc/ssh/sshd_config | grep 'Port ' | awk '{print $2}'")
port, err = shell.Execf("cat /etc/ssh/sshd_config | grep 'Port ' | awk '{print $2}' | paste -sd ','")
case "mysql":
filter = "mysqld-auth"
port, err = shell.Execf("cat %s/server/mysql/conf/my.cnf | grep 'port' | head -n 1 | awk '{print $3}'", app.Root)
+7 -7
View File
@@ -2,12 +2,12 @@ package fail2ban
type Add struct {
Name string `json:"name" validate:"required"`
Type string `json:"type" validate:"required"`
MaxRetry int `json:"maxretry" validate:"required"`
FindTime int `json:"findtime" validate:"required"`
BanTime int `json:"bantime" validate:"required"`
WebsiteName string `json:"website_name"`
WebsiteMode string `json:"website_mode"`
Type string `json:"type" validate:"required && in:service,website"`
MaxRetry int `json:"maxretry" validate:"required && min:1"`
FindTime int `json:"findtime" validate:"required && min:1"`
BanTime int `json:"bantime" validate:"required && min:1"`
WebsiteName string `json:"website_name" validate:"required_if:Type,website"`
WebsiteMode string `json:"website_mode" validate:"required_if:Type,website && in:cc,path"`
WebsitePath string `json:"website_path"`
}
@@ -21,7 +21,7 @@ type BanList struct {
type Unban struct {
Name string `json:"name" validate:"required"`
IP string `json:"ip" validate:"required"`
IP string `json:"ip" validate:"required && ip"`
}
type SetWhiteList struct {
+2 -2
View File
@@ -15,8 +15,8 @@ import (
type App struct{}
func NewApp() *App {
return &App{}
func NewApp() (*App, error) {
return &App{}, nil
}
func (s *App) Route(r chi.Router) {
+5 -5
View File
@@ -1,16 +1,16 @@
package frp
type Name struct {
Name string `form:"name" json:"name" validate:"required|in:frps,frpc"`
Name string `form:"name" json:"name" validate:"required && in:frps,frpc"`
}
type UpdateConfig struct {
Name string `form:"name" json:"name" validate:"required|in:frps,frpc"`
Name string `form:"name" json:"name" validate:"required && in:frps,frpc"`
Config string `form:"config" json:"config" validate:"required"`
}
type UpdateUser struct {
Name string `form:"name" json:"name" validate:"required|in:frps,frpc"`
User string `form:"user" json:"user" validate:"required|regex:^[a-zA-Z0-9_-]+$"`
Group string `form:"group" json:"group" validate:"required|regex:^[a-zA-Z0-9_-]+$"`
Name string `form:"name" json:"name" validate:"required && in:frps,frpc"`
User string `form:"user" json:"user" validate:"required && regex:\"^[a-zA-Z0-9_-]+$\""`
Group string `form:"group" json:"group" validate:"required && regex:\"^[a-zA-Z0-9_-]+$\""`
}
+2 -2
View File
@@ -15,8 +15,8 @@ import (
type App struct{}
func NewApp() *App {
return &App{}
func NewApp() (*App, error) {
return &App{}, nil
}
func (s *App) Route(r chi.Router) {
+3 -3
View File
@@ -24,8 +24,8 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) *App {
return &App{t: t}
func NewApp(t *gotext.Locale) (*App, error) {
return &App{t: t}, nil
}
func (s *App) Route(r chi.Router) {
@@ -67,7 +67,7 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
client := resty.New().SetTimeout(10 * time.Second)
defer func(client *resty.Client) { _ = client.Close() }(client)
resp, err := client.R().Get(fmt.Sprintf("http://127.0.0.1:%s/api/health", port))
if err != nil || !resp.IsSuccess() {
if err != nil || !resp.IsStatusSuccess() {
service.Success(w, []types.NV{})
return
}
+2 -2
View File
@@ -37,9 +37,9 @@ type ConfigTune struct {
// DataSource 数据源
type DataSource struct {
Name string `form:"name" json:"name" validate:"required"`
Type string `form:"type" json:"type" validate:"required|in:prometheus,mysql,postgres,influxdb,loki,elasticsearch,tempo,jaeger,zipkin,graphite,alertmanager,opentsdb,mssql,testdata,grafana-pyroscope-datasource,grafana-opensearch-datasource"`
Type string `form:"type" json:"type" validate:"required && in:prometheus,mysql,postgres,influxdb,loki,elasticsearch,tempo,jaeger,zipkin,graphite,alertmanager,opentsdb,mssql,testdata,grafana-pyroscope-datasource,grafana-opensearch-datasource"`
URL string `form:"url" json:"url" validate:"required"`
Access string `form:"access" json:"access"`
Access string `form:"access" json:"access" validate:"in:proxy,direct"`
IsDefault bool `form:"is_default" json:"is_default"`
// 数据库类型专用
Database string `form:"database" json:"database"`
+2 -2
View File
@@ -20,8 +20,8 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) *App {
return &App{t: t}
func NewApp(t *gotext.Locale) (*App, error) {
return &App{t: t}, nil
}
func (s *App) Route(r chi.Router) {
+3 -5
View File
@@ -2,20 +2,18 @@ package mariadb
import (
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/acepanel/panel/v3/internal/apps/mysql"
"github.com/acepanel/panel/v3/internal/biz"
)
type App struct {
mysql *mysql.App
}
func NewApp(t *gotext.Locale, setting biz.SettingRepo, databaseServer biz.DatabaseServerRepo) *App {
func NewApp(mysqlApp *mysql.App) (*App, error) {
return &App{
mysql: mysql.NewApp(t, setting, databaseServer),
}
mysql: mysqlApp,
}, nil
}
func (s *App) Route(r chi.Router) {
+2 -2
View File
@@ -20,10 +20,10 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) *App {
func NewApp(t *gotext.Locale) (*App, error) {
return &App{
t: t,
}
}, nil
}
func (s *App) Route(r chi.Router) {
+5 -5
View File
@@ -6,10 +6,10 @@ type UpdateConfig struct {
// ConfigTune Memcached 配置调整
type ConfigTune struct {
Port string `form:"port" json:"port"`
UDPPort string `form:"udp_port" json:"udp_port"`
Port string `form:"port" json:"port" validate:"number && min:1 && max:65535"`
UDPPort string `form:"udp_port" json:"udp_port" validate:"number && min:1 && max:65535"`
ListenAddress string `form:"listen_address" json:"listen_address"`
Memory string `form:"memory" json:"memory"`
MaxConnections string `form:"max_connections" json:"max_connections"`
Threads string `form:"threads" json:"threads"`
Memory string `form:"memory" json:"memory" validate:"number && min:1"`
MaxConnections string `form:"max_connections" json:"max_connections" validate:"number && min:1"`
Threads string `form:"threads" json:"threads" validate:"number && min:1"`
}
+2 -2
View File
@@ -13,8 +13,8 @@ import (
type App struct{}
func NewApp() *App {
return &App{}
func NewApp() (*App, error) {
return &App{}, nil
}
func (s *App) Route(r chi.Router) {
+4 -4
View File
@@ -26,12 +26,12 @@ type App struct {
databaseServerRepo biz.DatabaseServerRepo
}
func NewApp(t *gotext.Locale, setting biz.SettingRepo, databaseServer biz.DatabaseServerRepo) *App {
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo) (*App, error) {
return &App{
t: t,
settingRepo: setting,
databaseServerRepo: databaseServer,
}
settingRepo: settingRepo,
databaseServerRepo: databaseServerRepo,
}, nil
}
func (s *App) Route(r chi.Router) {
+5 -5
View File
@@ -7,19 +7,19 @@ type UpdateConfig struct {
// SetAdminPassword 设置 admin 密码
type SetAdminPassword struct {
Password string `form:"password" json:"password" validate:"required|password"`
Password string `form:"password" json:"password" validate:"required && password"`
}
// ConfigTune MongoDB 配置调整
type ConfigTune struct {
// 存储
DbPath string `form:"db_path" json:"db_path"`
DbPath string `form:"db_path" json:"db_path" validate:"unix_path"`
CacheSizeGB string `form:"cache_size_gb" json:"cache_size_gb"`
// 网络
Port string `form:"port" json:"port"`
Port string `form:"port" json:"port" validate:"number && min:1 && max:65535"`
BindIp string `form:"bind_ip" json:"bind_ip"`
// 日志
SystemLogPath string `form:"system_log_path" json:"system_log_path"`
SystemLogPath string `form:"system_log_path" json:"system_log_path" validate:"unix_path"`
// 安全
Authorization string `form:"authorization" json:"authorization"`
Authorization string `form:"authorization" json:"authorization" validate:"in:enabled,disabled"`
}
+12 -29
View File
@@ -28,12 +28,12 @@ type App struct {
databaseServerRepo biz.DatabaseServerRepo
}
func NewApp(t *gotext.Locale, setting biz.SettingRepo, databaseServer biz.DatabaseServerRepo) *App {
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo) (*App, error) {
return &App{
t: t,
settingRepo: setting,
databaseServerRepo: databaseServer,
}
settingRepo: settingRepo,
databaseServerRepo: databaseServerRepo,
}, nil
}
func (s *App) Route(r chi.Router) {
@@ -222,7 +222,7 @@ func (s *App) SetRootPassword(w http.ResponseWriter, r *http.Request) {
}
oldRootPassword, _ := s.settingRepo.Get(biz.SettingKeyMySQLRootPassword)
mysql, err := db.NewMySQL("root", oldRootPassword, s.getSock(), "unix")
mysql, err := db.NewMySQL(r.Context(), "root", oldRootPassword, s.getSock(), "unix")
if err != nil {
// 尝试安全模式直接改密
if err = db.MySQLResetRootPassword(req.Password); err != nil {
@@ -247,30 +247,6 @@ func (s *App) SetRootPassword(w http.ResponseWriter, r *http.Request) {
service.Success(w, nil)
}
func (s *App) getSock() string {
if io.Exists("/tmp/mysql.sock") {
return "/tmp/mysql.sock"
}
if io.Exists(app.Root + "/server/mysql/config/my.cnf") {
config, _ := io.Read(app.Root + "/server/mysql/config/my.cnf")
re := regexp.MustCompile(`socket\s*=\s*(['"]?)([^'"]+)`)
matches := re.FindStringSubmatch(config)
if len(matches) > 2 {
return matches[2]
}
}
if io.Exists("/etc/my.cnf") {
config, _ := io.Read("/etc/my.cnf")
re := regexp.MustCompile(`socket\s*=\s*(['"]?)([^'"]+)`)
matches := re.FindStringSubmatch(config)
if len(matches) > 2 {
return matches[2]
}
}
return "/tmp/mysql.sock"
}
// GetConfigTune 获取 MySQL 配置调整参数
func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(app.Root + "/server/mysql/conf/my.cnf")
@@ -369,6 +345,13 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
service.Success(w, nil)
}
func (s *App) getSock() string {
if sock := db.MySQLSocket(app.Root+"/server/mysql/config/my.cnf", "/etc/my.cnf"); sock != "" {
return sock
}
return "/tmp/mysql.sock"
}
// getINIValue 从 INI 格式内容中获取指定键的值
func (s *App) getINIValue(content string, key string) string {
lines := strings.SplitSeq(content, "\n")
+3 -3
View File
@@ -5,13 +5,13 @@ type UpdateConfig struct {
}
type SetRootPassword struct {
Password string `form:"password" json:"password" validate:"required|password"`
Password string `form:"password" json:"password" validate:"required && password"`
}
// ConfigTune MySQL 配置调整
type ConfigTune struct {
// 常规设置
Port string `form:"port" json:"port"`
Port string `form:"port" json:"port" validate:"number && min:1 && max:65535"`
MaxConnections string `form:"max_connections" json:"max_connections"`
MaxConnectErrors string `form:"max_connect_errors" json:"max_connect_errors"`
DefaultStorageEngine string `form:"default_storage_engine" json:"default_storage_engine"`
@@ -32,7 +32,7 @@ type ConfigTune struct {
// InnoDB
InnodbBufferPoolSize string `form:"innodb_buffer_pool_size" json:"innodb_buffer_pool_size"`
InnodbLogBufferSize string `form:"innodb_log_buffer_size" json:"innodb_log_buffer_size"`
InnodbFlushLogAtTrxCommit string `form:"innodb_flush_log_at_trx_commit" json:"innodb_flush_log_at_trx_commit"`
InnodbFlushLogAtTrxCommit string `form:"innodb_flush_log_at_trx_commit" json:"innodb_flush_log_at_trx_commit" validate:"in:0,1,2"`
InnodbLockWaitTimeout string `form:"innodb_lock_wait_timeout" json:"innodb_lock_wait_timeout"`
InnodbMaxDirtyPagesPct string `form:"innodb_max_dirty_pages_pct" json:"innodb_max_dirty_pages_pct"`
InnodbReadIoThreads string `form:"innodb_read_io_threads" json:"innodb_read_io_threads"`
+4 -3
View File
@@ -25,10 +25,11 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) *App {
func NewApp(t *gotext.Locale) (*App, error) {
return &App{
t: t,
}
}, nil
}
func (s *App) Route(r chi.Router) {
@@ -103,7 +104,7 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
client := resty.New().SetTimeout(10 * time.Second)
defer func(client *resty.Client) { _ = client.Close() }(client)
resp, err := client.R().Get("http://127.0.0.1/nginx_status")
if err != nil || !resp.IsSuccess() {
if err != nil || !resp.IsStatusSuccess() {
service.Success(w, []types.NV{})
return
}
+15 -15
View File
@@ -39,22 +39,22 @@ type ConfigTune struct {
}
type StreamServer struct {
Name string `form:"name" json:"name" validate:"required|regex:^[a-zA-Z0-9_-]+$"` // 配置名称,用于文件命名
Listen string `form:"listen" json:"listen" validate:"required"` // 监听地址,如: "12345", "0.0.0.0:12345", "[::]:12345"
UDP bool `form:"udp" json:"udp"` // 是否 UDP 协议
ProxyPass string `form:"proxy_pass" json:"proxy_pass" validate:"required"` // 代理地址,如: "127.0.0.1:3306", "upstream_name"
ProxyProtocol bool `form:"proxy_protocol" json:"proxy_protocol"` // 是否启用 PROXY 协议
ProxyTimeout time.Duration `form:"proxy_timeout" json:"proxy_timeout"` // 代理超时时间
ProxyConnectTimeout time.Duration `form:"proxy_connect_timeout" json:"proxy_connect_timeout"` // 代理连接超时时间
SSL bool `form:"ssl" json:"ssl"` // 是否启用 SSL
SSLCertificate string `form:"ssl_certificate" json:"ssl_certificate" validate:"requiredIf:SSL,true"` // SSL 证书路径
SSLCertificateKey string `form:"ssl_certificate_key" json:"ssl_certificate_key" validate:"requiredIf:SSL,true"` // SSL 私钥路径
Name string `form:"name" json:"name" validate:"required && regex:\"^[a-zA-Z0-9_-]+$\""` // 配置名称,用于文件命名
Listen string `form:"listen" json:"listen" validate:"required"` // 监听地址,如: "12345", "0.0.0.0:12345", "[::]:12345"
UDP bool `form:"udp" json:"udp"` // 是否 UDP 协议
ProxyPass string `form:"proxy_pass" json:"proxy_pass" validate:"required"` // 代理地址,如: "127.0.0.1:3306", "upstream_name"
ProxyProtocol bool `form:"proxy_protocol" json:"proxy_protocol"` // 是否启用 PROXY 协议
ProxyTimeout time.Duration `form:"proxy_timeout" json:"proxy_timeout"` // 代理超时时间
ProxyConnectTimeout time.Duration `form:"proxy_connect_timeout" json:"proxy_connect_timeout"` // 代理连接超时时间
SSL bool `form:"ssl" json:"ssl"` // 是否启用 SSL
SSLCertificate string `form:"ssl_certificate" json:"ssl_certificate" validate:"required_if:SSL,true"` // SSL 证书路径
SSLCertificateKey string `form:"ssl_certificate_key" json:"ssl_certificate_key" validate:"required_if:SSL,true"` // SSL 私钥路径
}
type StreamUpstream struct {
Name string `form:"name" json:"name" validate:"required|regex:^[a-zA-Z0-9_-]+$"` // 上游名称
Servers map[string]string `form:"servers" json:"servers" validate:"required"` // 上游服务器及配置,如: map["127.0.0.1:3306"] = "weight=5"
Algo string `form:"algo" json:"algo"` // 负载均衡算法,如: "least_conn", "hash $remote_addr"
Resolver []string `form:"resolver" json:"resolver"` // DNS 解析器,如: ["8.8.8.8", "ipv6=off"]
ResolverTimeout time.Duration `form:"resolver_timeout" json:"resolver_timeout"` // DNS 解析超时时间
Name string `form:"name" json:"name" validate:"required && regex:\"^[a-zA-Z0-9_-]+$\""` // 上游名称
Servers map[string]string `form:"servers" json:"servers" validate:"required"` // 上游服务器及配置,如: map["127.0.0.1:3306"] = "weight=5"
Algo string `form:"algo" json:"algo"` // 负载均衡算法,如: "least_conn", "hash $remote_addr"
Resolver []string `form:"resolver" json:"resolver"` // DNS 解析器,如: ["8.8.8.8", "ipv6=off"]
ResolverTimeout time.Duration `form:"resolver_timeout" json:"resolver_timeout"` // DNS 解析超时时间
}
+4 -5
View File
@@ -2,7 +2,6 @@ package openresty
import (
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/acepanel/panel/v3/internal/apps/nginx"
"github.com/acepanel/panel/v3/pkg/systemctl"
@@ -13,10 +12,10 @@ type App struct {
nginx *nginx.App
}
func NewApp(t *gotext.Locale) *App {
func NewApp(nginxApp *nginx.App) (*App, error) {
return &App{
nginx: nginx.NewApp(t),
}
nginx: nginxApp,
}, nil
}
func (s *App) Route(r chi.Router) {
@@ -24,6 +23,6 @@ func (s *App) Route(r chi.Router) {
}
func (s *App) Status() string {
ok, _ := systemctl.Status("openresty")
ok, _ := systemctl.Status("nginx")
return types.AggregateAppStatus(ok)
}
+4 -3
View File
@@ -25,8 +25,9 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) *App {
return &App{t: t}
func NewApp(t *gotext.Locale) (*App, error) {
return &App{t: t}, nil
}
func (s *App) Route(r chi.Router) {
@@ -57,7 +58,7 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
client := resty.New().SetTimeout(10 * time.Second)
defer func(client *resty.Client) { _ = client.Close() }(client)
resp, err := client.R().Get(fmt.Sprintf("http://127.0.0.1:%s/_cluster/health", port))
if err != nil || !resp.IsSuccess() {
if err != nil || !resp.IsStatusSuccess() {
service.Success(w, []types.NV{})
return
}
+3 -5
View File
@@ -2,20 +2,18 @@ package percona
import (
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/acepanel/panel/v3/internal/apps/mysql"
"github.com/acepanel/panel/v3/internal/biz"
)
type App struct {
mysql *mysql.App
}
func NewApp(t *gotext.Locale, setting biz.SettingRepo, databaseServer biz.DatabaseServerRepo) *App {
func NewApp(mysqlApp *mysql.App) (*App, error) {
return &App{
mysql: mysql.NewApp(t, setting, databaseServer),
}
mysql: mysqlApp,
}, nil
}
func (s *App) Route(r chi.Router) {
+548
View File
@@ -0,0 +1,548 @@
package pgadmin
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
stdio "io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix/v2"
"github.com/spf13/cast"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/biz"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/config"
"github.com/acepanel/panel/v3/pkg/firewall"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/shell"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/types"
)
type App struct {
t *gotext.Locale
conf *config.Config
databaseServerRepo biz.DatabaseServerRepo
}
func NewApp(conf *config.Config, t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo) (*App, error) {
return &App{
t: t,
conf: conf,
databaseServerRepo: databaseServerRepo,
}, nil
}
// pgadminLanguage 面板语言映射为 pgAdmin 语言代码
func (s *App) pgadminLanguage() string {
switch s.conf.App.Locale {
case "":
return ""
case "zh_CN":
return "zh_Hans_CN"
case "zh_TW":
return "zh_Hant_TW"
default:
return s.conf.App.Locale
}
}
// clientIP 获取请求来源 IP,面板位于反代之后时优先取配置的 IP 头
func (s *App) clientIP(r *http.Request) string {
ip := r.RemoteAddr
if header := s.conf.HTTP.IPHeader; header != "" && r.Header.Get(header) != "" {
ip = strings.TrimSpace(strings.Split(r.Header.Get(header), ",")[0])
}
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
return ip
}
func (s *App) Route(r chi.Router) {
r.Get("/info", s.Info)
r.Post("/port", s.UpdatePort)
r.Post("/login", s.Login)
r.Post("/update_username", s.UpdateUsername)
r.Post("/reset_password", s.ResetPassword)
}
func (s *App) Status() string {
ok, _ := systemctl.Status("pgadmin")
return types.AggregateAppStatus(ok)
}
func (s *App) path() string {
return fmt.Sprintf("%s/server/pgadmin", app.Root)
}
// port 从 systemd 环境文件中解析监听端口
func (s *App) port() (uint, error) {
conf, err := io.Read(fmt.Sprintf("%s/pgadmin.conf", s.path()))
if err != nil {
return 0, err
}
match := regexp.MustCompile(`PGADMIN_LISTEN=.+:(\d+)`).FindStringSubmatch(conf)
if len(match) < 2 {
return 0, errors.New(s.t.Get("pgAdmin port not found"))
}
return cast.ToUint(match[1]), nil
}
// credential 读取安装时生成的初始凭据(邮箱与密码)
func (s *App) credential() (string, string) {
raw, err := io.Read(fmt.Sprintf("%s/credential", s.path()))
if err != nil {
return "", ""
}
lines := strings.Split(strings.TrimSpace(raw), "\n")
if len(lines) < 2 {
return strings.TrimSpace(lines[0]), ""
}
return strings.TrimSpace(lines[0]), strings.TrimSpace(lines[1])
}
func (s *App) Info(w http.ResponseWriter, r *http.Request) {
port, err := s.port()
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
email, password := s.credential()
service.Success(w, chix.M{
"port": port,
"email": email,
"password": password,
})
}
func (s *App) UpdatePort(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[UpdatePort](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
conf := fmt.Sprintf("%s/pgadmin.conf", s.path())
content, err := io.Read(conf)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
content = regexp.MustCompile(`PGADMIN_LISTEN=(.+):\d+`).ReplaceAllString(content, "PGADMIN_LISTEN=${1}:"+cast.ToString(req.Port))
if err = io.Write(conf, content, 0600); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
fw := firewall.NewFirewall()
err = fw.Port(firewall.FireInfo{
Type: firewall.TypeNormal,
PortStart: req.Port,
PortEnd: req.Port,
Strategy: firewall.StrategyAccept,
Direction: firewall.DirectionIn,
}, firewall.OperationAdd)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Restart("pgadmin"); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to restart pgAdmin: %v", err))
return
}
service.Success(w, nil)
}
// serversFile pgAdmin dump-servers/load-servers 的 JSON 结构
type serversFile struct {
Servers map[string]serverEntry `json:"Servers"`
}
type serverEntry struct {
Name string `json:"Name"`
Group string `json:"Group"`
Host string `json:"Host"`
Port int `json:"Port"`
MaintenanceDB string `json:"MaintenanceDB"`
Username string `json:"Username"`
SSLMode string `json:"SSLMode,omitempty"`
PassFile string `json:"PassFile,omitempty"`
}
// escapePgpass 转义 pgpass 字段中的反斜杠与冒号
func escapePgpass(s string) string {
return strings.NewReplacer(`\`, `\\`, `:`, `\:`).Replace(s)
}
// existingServers 只读 pgAdmin 配置库查询 Servers 组内已注册的服务器
// CLI 每次调用都要冷启动整个 pgAdmin 应用,直读库快数个量级
func (s *App) existingServers(email string) (map[string]struct{}, error) {
db, err := sql.Open("sqlite", fmt.Sprintf("file:%s/data/pgadmin.db?mode=ro", s.path()))
if err != nil {
return nil, err
}
defer func() { _ = db.Close() }()
rows, err := db.Query(`SELECT s.host, s.port, s.username FROM server s
JOIN servergroup g ON s.servergroup_id = g.id
JOIN "user" u ON s.user_id = u.id
WHERE u.email = ? AND g.name = 'Servers'`, email)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
existing := make(map[string]struct{})
for rows.Next() {
var host, username sql.NullString
var port sql.NullInt64
if err = rows.Scan(&host, &port, &username); err != nil {
return nil, err
}
existing[fmt.Sprintf("%s:%d:%s", host.String, port.Int64, username.String)] = struct{}{}
}
return existing, rows.Err()
}
// dumpExistingServers 通过 CLI 导出查询已注册服务器,直读配置库失败时的回退路径
func (s *App) dumpExistingServers(email string) map[string]struct{} {
dump := filepath.Join(os.TempDir(), "pgadmin-servers.json")
defer func() { _ = io.Remove(dump) }()
_, _ = shell.Execf("%s/cli dump-servers '%s' --user '%s'", s.path(), dump, email)
existing := make(map[string]struct{})
if raw, err := io.Read(dump); err == nil {
var dumped serversFile
if err = json.Unmarshal([]byte(raw), &dumped); err == nil {
for _, item := range dumped.Servers {
// 只认默认 Servers 组内的条目,历史版本同步进其他分组的会在此组补齐
if item.Group != "Servers" {
continue
}
existing[fmt.Sprintf("%s:%d:%s", item.Host, item.Port, item.Username)] = struct{}{}
}
}
}
return existing
}
// syncServers 将面板中全部 PostgreSQL 服务器合并注册到 pgAdmin,凭据写入 pgpass 实现免密
// 仅追加 pgAdmin 中缺失的服务器,不影响用户在 pgAdmin 中手动添加的内容
func (s *App) syncServers(ctx context.Context, email string) error {
servers, _, err := s.databaseServerRepo.List(ctx, 1, 10000, string(biz.DatabaseTypePostgresql))
if err != nil {
return err
}
if len(servers) == 0 {
return nil
}
// pgAdmin server 模式下 PassFile 以用户 storage 目录为根,目录名为邮箱 @ 转 _
storageDir := fmt.Sprintf("%s/data/storage/%s", s.path(), strings.ReplaceAll(email, "@", "_"))
pgpass := filepath.Join(storageDir, "pgpass")
// 重写 pgpass 中面板服务器的凭据行,保留其他行
prefixes := make([]string, 0, len(servers))
entries := make([]string, 0, len(servers))
for _, server := range servers {
prefix := fmt.Sprintf("%s:%d:*:%s:", escapePgpass(server.Host), server.Port, escapePgpass(server.Username))
prefixes = append(prefixes, prefix)
entries = append(entries, prefix+escapePgpass(server.Password))
}
var lines []string
if raw, err := io.Read(pgpass); err == nil {
for line := range strings.SplitSeq(strings.TrimSpace(raw), "\n") {
if line == "" {
continue
}
panelOwned := false
for _, prefix := range prefixes {
if strings.HasPrefix(line, prefix) {
panelOwned = true
break
}
}
if !panelOwned {
lines = append(lines, line)
}
}
}
lines = append(lines, entries...)
if err = os.MkdirAll(storageDir, 0700); err != nil {
return err
}
if err = io.Write(pgpass, strings.Join(lines, "\n")+"\n", 0600); err != nil {
return err
}
// 查询 pgAdmin 已有服务器用于查缺,直读配置库,异常时回退 CLI 导出
existing, err := s.existingServers(email)
if err != nil {
existing = s.dumpExistingServers(email)
}
// 一次性合并导入缺失的服务器
missing := make(map[string]serverEntry)
for i, server := range servers {
if _, ok := existing[fmt.Sprintf("%s:%d:%s", server.Host, server.Port, server.Username)]; ok {
continue
}
missing[cast.ToString(i+1)] = serverEntry{
Name: server.Name,
Group: "Servers",
Host: server.Host,
Port: int(server.Port),
MaintenanceDB: "postgres",
Username: server.Username,
SSLMode: "prefer",
PassFile: "/pgpass",
}
}
if len(missing) > 0 {
load := filepath.Join(os.TempDir(), "pgadmin-servers-add.json")
defer func() { _ = io.Remove(load) }()
payload, err := json.Marshal(serversFile{Servers: missing})
if err != nil {
return err
}
if err = io.Write(load, string(payload), 0600); err != nil {
return err
}
if out, err := shell.Execf("%s/cli load-servers '%s' --user '%s'", s.path(), load, email); err != nil {
return errors.Join(err, errors.New(out))
}
// CLI 以 root 运行,修正数据目录属主避免服务写入失败
if _, err = shell.Execf("chown -R www:www %s/data", s.path()); err != nil {
return err
}
return nil
}
// 常态路径仅写入了 pgpass,精确修正属主即可
if err = io.Chown(storageDir, "www", "www"); err != nil {
return err
}
return io.Chown(pgpass, "www", "www")
}
// Login 同步面板全部 PostgreSQL 服务器后代理登录 pgAdmin 并将会话 Cookie 转发给浏览器
// 面板与 pgAdmin 同主机不同端口,Cookie 按主机共享,浏览器凭转发的 Cookie 即为已登录态
func (s *App) Login(w http.ResponseWriter, r *http.Request) {
port, err := s.port()
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
email, password := s.credential()
if email == "" || password == "" {
service.Error(w, http.StatusInternalServerError, s.t.Get("pgAdmin credential file not found"))
return
}
if err = s.syncServers(r.Context(), email); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to sync servers to pgAdmin: %v", err))
return
}
client := &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
// 透传浏览器 IP 与 UA,pgAdmin 增强 Cookie 保护将会话绑定到 sha256(IP|UA),
// 伪装成浏览器身份登录后浏览器直连即可通过校验,无需关闭该保护
clientIP := s.clientIP(r)
clientUA := r.UserAgent()
// 获取登录页以取得会话 Cookie 与 CSRF token
loginURL := fmt.Sprintf("http://127.0.0.1:%d/login", port)
pageReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, loginURL, nil)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
pageReq.Header.Set("User-Agent", clientUA)
pageReq.Header.Set("X-Forwarded-For", clientIP)
pageResp, err := client.Do(pageReq)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to request pgAdmin: %v", err))
return
}
defer func() { _ = pageResp.Body.Close() }()
page, err := stdio.ReadAll(stdio.LimitReader(pageResp.Body, 4<<20))
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to request pgAdmin: %v", err))
return
}
// 登录页为 React 渲染,CSRF token 在内嵌 JSON 中,保留 input 形态兼容旧版本
csrf := regexp.MustCompile(`"csrfToken":\s*"([^"]+)"`).FindStringSubmatch(string(page))
if len(csrf) < 2 {
csrf = regexp.MustCompile(`name="csrf_token"[^>]*value="([^"]+)"`).FindStringSubmatch(string(page))
}
if len(csrf) < 2 {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to parse pgAdmin login page"))
return
}
form := url.Values{}
form.Set("csrf_token", csrf[1])
form.Set("email", email)
form.Set("password", password)
// 语言跟随面板设置,pgAdmin 会将 language 字段固化进会话
if lang := s.pgadminLanguage(); lang != "" {
form.Set("language", lang)
}
loginReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/authenticate/login", port), strings.NewReader(form.Encode()))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginReq.Header.Set("User-Agent", clientUA)
loginReq.Header.Set("X-Forwarded-For", clientIP)
for _, cookie := range pageResp.Cookies() {
loginReq.AddCookie(cookie)
}
loginResp, err := client.Do(loginReq)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to request pgAdmin: %v", err))
return
}
defer func() { _ = loginResp.Body.Close() }()
_, _ = stdio.Copy(stdio.Discard, stdio.LimitReader(loginResp.Body, 4<<20))
// 登录成功时 pgAdmin 返回 302 且跳转目标不是登录页
location := loginResp.Header.Get("Location")
if loginResp.StatusCode != http.StatusFound || strings.Contains(location, "/login") {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to login pgAdmin, please check the credential of pgAdmin"))
return
}
// 改写 SameSite 以兼容面板 https 跳转 http 的场景
for _, cookie := range append(pageResp.Cookies(), loginResp.Cookies()...) {
cookie.SameSite = http.SameSiteLaxMode
cookie.Secure = false
http.SetCookie(w, cookie)
}
service.Success(w, chix.M{
"port": port,
})
}
// ResetPassword 通过 CLI 重置管理员密码并同步凭据文件
// UpdateUsername 修改管理员账号,迁移服务器配置与用户存储目录
func (s *App) UpdateUsername(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[UpdateUsername](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
oldEmail, password := s.credential()
if oldEmail == "" || password == "" {
service.Error(w, http.StatusInternalServerError, s.t.Get("pgAdmin credential file not found"))
return
}
if req.Username == oldEmail {
service.Success(w, nil)
return
}
// 以当前密码创建新管理员账号
if out, err := shell.Execf("%s/cli add-user '%s' '%s' --admin", s.path(), req.Username, password); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to create new account: %v", errors.Join(err, errors.New(out))))
return
}
// 迁移服务器连接配置到新账号
dump := filepath.Join(os.TempDir(), "pgadmin-servers-migrate.json")
defer func() { _ = io.Remove(dump) }()
_, _ = shell.Execf("%s/cli dump-servers '%s' --user '%s'", s.path(), dump, oldEmail)
if io.Exists(dump) {
_, _ = shell.Execf("%s/cli load-servers '%s' --user '%s'", s.path(), dump, req.Username)
}
// 删除旧账号
if out, err := shell.Execf("%s/cli delete-user '%s' --yes", s.path(), oldEmail); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to delete old account: %v", errors.Join(err, errors.New(out))))
return
}
// 迁移用户存储目录(pgpass 等),目录名为邮箱 @ 转 _
oldDir := fmt.Sprintf("%s/data/storage/%s", s.path(), strings.ReplaceAll(oldEmail, "@", "_"))
newDir := fmt.Sprintf("%s/data/storage/%s", s.path(), strings.ReplaceAll(req.Username, "@", "_"))
if io.Exists(oldDir) && !io.Exists(newDir) {
_ = os.Rename(oldDir, newDir)
}
// CLI 以 root 运行,修正数据目录属主避免服务写入失败
if _, err = shell.Execf("chown -R www:www %s/data", s.path()); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
// 更新凭据文件,下次登录按新账号重新同步
if err = io.Write(fmt.Sprintf("%s/credential", s.path()), req.Username+"\n"+password+"\n", 0600); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
func (s *App) ResetPassword(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[ResetPassword](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
email, _ := s.credential()
if email == "" {
service.Error(w, http.StatusInternalServerError, s.t.Get("pgAdmin credential file not found"))
return
}
// cli 为安装脚本生成的稳定入口,屏蔽上游命令名随大版本变化
if out, err := shell.Execf("%s/cli update-user '%s' --password '%s'", s.path(), email, req.Password); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to reset password: %v", errors.Join(err, errors.New(out))))
return
}
// CLI 以 root 运行,修正数据目录属主避免服务写入失败
if _, err = shell.Execf("chown -R www:www %s/data", s.path()); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = io.Write(fmt.Sprintf("%s/credential", s.path()), email+"\n"+req.Password+"\n", 0600); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, nil)
}
+13
View File
@@ -0,0 +1,13 @@
package pgadmin
type UpdatePort struct {
Port uint `form:"port" json:"port" validate:"required && number && min:1 && max:65535"`
}
type ResetPassword struct {
Password string `form:"password" json:"password" validate:"required && password"`
}
type UpdateUsername struct {
Username string `form:"username" json:"username" validate:"required && email"`
}
+180 -16
View File
@@ -1,19 +1,27 @@
package phpmyadmin
import (
"errors"
"fmt"
"html"
stdio "io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix"
"github.com/libtnb/chix/v2"
"github.com/libtnb/utils/str"
"github.com/spf13/cast"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/biz"
"github.com/acepanel/panel/v3/internal/service"
"github.com/acepanel/panel/v3/pkg/config"
"github.com/acepanel/panel/v3/pkg/firewall"
"github.com/acepanel/panel/v3/pkg/io"
"github.com/acepanel/panel/v3/pkg/shell"
@@ -22,31 +30,38 @@ import (
)
type App struct {
t *gotext.Locale
t *gotext.Locale
conf *config.Config
databaseServerRepo biz.DatabaseServerRepo
}
func NewApp(t *gotext.Locale) *App {
func NewApp(conf *config.Config, t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo) (*App, error) {
return &App{
t: t,
}
t: t,
conf: conf,
databaseServerRepo: databaseServerRepo,
}, nil
}
func (s *App) Route(r chi.Router) {
r.Get("/info", s.Info)
r.Post("/port", s.UpdatePort)
r.Post("/login", s.Login)
r.Get("/config", s.GetConfig)
r.Post("/config", s.UpdateConfig)
}
// Status phpMyAdmin 由 nginx 站点承载,运行状态与 nginx 一致
func (s *App) Status() string {
return types.AppStatusNA
ok, _ := systemctl.Status("nginx")
return types.AggregateAppStatus(ok)
}
func (s *App) Info(w http.ResponseWriter, r *http.Request) {
// info 获取 phpMyAdmin 的访问目录与端口
func (s *App) info() (string, int, error) {
files, err := os.ReadDir(fmt.Sprintf("%s/server/phpmyadmin", app.Root))
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("phpMyAdmin directory not found"))
return
return "", 0, errors.New(s.t.Get("phpMyAdmin directory not found"))
}
var phpmyadmin string
@@ -56,24 +71,173 @@ func (s *App) Info(w http.ResponseWriter, r *http.Request) {
}
}
if len(phpmyadmin) == 0 {
service.Error(w, http.StatusInternalServerError, s.t.Get("phpMyAdmin directory not found"))
return
return "", 0, errors.New(s.t.Get("phpMyAdmin directory not found"))
}
conf, err := io.Read(fmt.Sprintf("%s/sites/phpmyadmin/config/nginx.conf", app.Root))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
return "", 0, err
}
match := regexp.MustCompile(`listen\s+(\d+);`).FindStringSubmatch(conf)
if len(match) == 0 {
service.Error(w, http.StatusInternalServerError, s.t.Get("phpMyAdmin port not found"))
return "", 0, errors.New(s.t.Get("phpMyAdmin port not found"))
}
return phpmyadmin, cast.ToInt(match[1]), nil
}
func (s *App) Info(w http.ResponseWriter, r *http.Request) {
path, port, err := s.info()
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
service.Success(w, chix.M{
"path": phpmyadmin,
"port": cast.ToInt(match[1]),
"path": path,
"port": port,
})
}
// ensureConfig 为存量安装补写 config.inc.php,允许登录到任意 MySQL 服务器
func (s *App) ensureConfig(path string) error {
config := fmt.Sprintf("%s/server/phpmyadmin/%s/config.inc.php", app.Root, path)
if io.Exists(config) {
return nil
}
content := fmt.Sprintf(`<?php
declare(strict_types=1);
$cfg['blowfish_secret'] = '%s';
$cfg['AllowArbitraryServer'] = true;
`, str.Random(32))
return io.Write(config, content, 0644)
}
// Login 代理登录 phpMyAdmin 并将会话 Cookie 转发给浏览器
// 面板与 phpMyAdmin 同主机不同端口,Cookie 按主机共享,浏览器凭转发的 Cookie 即为已登录态
func (s *App) Login(w http.ResponseWriter, r *http.Request) {
req, err := service.Bind[Login](r)
if err != nil {
service.Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
server, err := s.databaseServerRepo.Get(r.Context(), req.ServerID)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if server.Type != biz.DatabaseTypeMysql {
service.Error(w, http.StatusUnprocessableEntity, s.t.Get("server %s is not a MySQL server", server.Name))
return
}
path, port, err := s.info()
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = s.ensureConfig(path); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
loginURL := fmt.Sprintf("http://127.0.0.1:%d/%s/index.php?route=/", port, path)
client := &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
// 获取登录页以取得会话 Cookie 与 CSRF token
// 转发浏览器语言,避免语言协商落空后回落英文
pageReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, loginURL, nil)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
pageReq.Header.Set("Accept-Language", r.Header.Get("Accept-Language"))
pageResp, err := client.Do(pageReq)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to request phpMyAdmin: %v", err))
return
}
defer func() { _ = pageResp.Body.Close() }()
page, err := stdio.ReadAll(stdio.LimitReader(pageResp.Body, 4<<20))
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to request phpMyAdmin: %v", err))
return
}
token := regexp.MustCompile(`name="token" value="([^"]+)"`).FindStringSubmatch(string(page))
session := regexp.MustCompile(`name="set_session" value="([^"]+)"`).FindStringSubmatch(string(page))
if len(token) < 2 {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to parse phpMyAdmin login page"))
return
}
form := url.Values{}
form.Set("route", "/")
// 语言跟随面板设置,登录后固化进 phpMyAdmin 会话
if s.conf.App.Locale != "" {
form.Set("lang", s.conf.App.Locale)
}
form.Set("token", html.UnescapeString(token[1]))
if len(session) >= 2 {
form.Set("set_session", html.UnescapeString(session[1]))
}
form.Set("pma_username", server.Username)
form.Set("pma_password", server.Password)
form.Set("server", "1")
// 本地默认端口走 phpMyAdmin 默认配置(socket 连接),其余场景显式指定目标服务器
// host 为 localhost 但端口非默认时须用 127.0.0.1 强制走 TCP,否则 mysqli 会忽略端口走 socket
isLocal := server.Host == "localhost" || server.Host == "127.0.0.1"
if !isLocal || server.Port != 3306 {
host := server.Host
if host == "localhost" {
host = "127.0.0.1"
}
form.Set("pma_servername", fmt.Sprintf("%s %d", host, server.Port))
}
loginReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, loginURL, strings.NewReader(form.Encode()))
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
loginReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
loginReq.Header.Set("Accept-Language", r.Header.Get("Accept-Language"))
for _, cookie := range pageResp.Cookies() {
loginReq.AddCookie(cookie)
}
loginResp, err := client.Do(loginReq)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to request phpMyAdmin: %v", err))
return
}
defer func() { _ = loginResp.Body.Close() }()
_, _ = stdio.Copy(stdio.Discard, stdio.LimitReader(loginResp.Body, 4<<20))
// 登录成功时 phpMyAdmin 返回 302 并携带会话 Cookie
if loginResp.StatusCode != http.StatusFound {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to login phpMyAdmin, please check the credentials and status of server %s", server.Name))
return
}
// 改写 SameSite 以兼容面板 https 跳转 http 的场景
for _, cookie := range append(pageResp.Cookies(), loginResp.Cookies()...) {
cookie.SameSite = http.SameSiteLaxMode
cookie.Secure = false
http.SetCookie(w, cookie)
}
service.Success(w, chix.M{
"path": path,
"port": port,
})
}
+5 -1
View File
@@ -5,5 +5,9 @@ type UpdateConfig struct {
}
type UpdatePort struct {
Port uint `form:"port" json:"port" validate:"required|number|min:1|max:65535"`
Port uint `form:"port" json:"port" validate:"required && number && min:1 && max:65535"`
}
type Login struct {
ServerID uint `form:"server_id" json:"server_id" validate:"required"`
}
+2 -2
View File
@@ -13,8 +13,8 @@ import (
type App struct{}
func NewApp() *App {
return &App{}
func NewApp() (*App, error) {
return &App{}, nil
}
func (s *App) Route(r chi.Router) {
+59 -21
View File
@@ -9,6 +9,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/spf13/cast"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/biz"
@@ -26,12 +27,15 @@ type App struct {
databaseServerRepo biz.DatabaseServerRepo
}
func NewApp(t *gotext.Locale, setting biz.SettingRepo, databaseServer biz.DatabaseServerRepo) *App {
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo, settingRepo biz.SettingRepo) (*App, error) {
setting := settingRepo
databaseServer := databaseServerRepo
return &App{
t: t,
settingRepo: setting,
databaseServerRepo: databaseServer,
}
}, nil
}
func (s *App) Route(r chi.Router) {
@@ -55,7 +59,7 @@ func (s *App) Status() string {
// GetConfig 获取配置
func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) {
// 获取配置
config, err := io.Read(fmt.Sprintf("%s/server/postgresql/data/postgresql.conf", app.Root))
config, err := io.Read(s.configPath())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
@@ -72,13 +76,14 @@ func (s *App) UpdateConfig(w http.ResponseWriter, r *http.Request) {
return
}
if err = io.Write(fmt.Sprintf("%s/server/postgresql/data/postgresql.conf", app.Root), req.Config, 0644); err != nil {
oldPort := s.getPort()
if err = io.Write(s.configPath(), req.Config, 0644); err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
}
if err = systemctl.Reload("postgresql"); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to reload PostgreSQL: %v", err))
if err = s.applyConfig(req.Config, oldPort); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to apply PostgreSQL config: %v", err))
return
}
@@ -136,13 +141,15 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to set PGPASSWORD env: %v", err))
return
}
defer func() { _ = os.Unsetenv("PGPASSWORD") }()
start, err := shell.Execf(`psql -h 127.0.0.1 -U postgres -t -c "select pg_postmaster_start_time();" | head -1 | cut -d'.' -f1`)
port := s.getPort()
start, err := shell.Execf(`psql -h 127.0.0.1 -p %d -U postgres -t -c "select pg_postmaster_start_time();" | head -1 | cut -d'.' -f1`, port)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get PostgreSQL start time: %v", err))
return
}
pid, err := shell.Execf(`psql -h 127.0.0.1 -U postgres -t -c "select pg_backend_pid();"`)
pid, err := shell.Execf(`psql -h 127.0.0.1 -p %d -U postgres -t -c "select pg_backend_pid();"`, port)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get PostgreSQL backend pid: %v", err))
return
@@ -152,22 +159,17 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get PostgreSQL process: %v", err))
return
}
connections, err := shell.Execf(`psql -h 127.0.0.1 -U postgres -t -c "SELECT count(*) FROM pg_stat_activity WHERE NOT pid=pg_backend_pid();"`)
connections, err := shell.Execf(`psql -h 127.0.0.1 -p %d -U postgres -t -c "SELECT count(*) FROM pg_stat_activity WHERE NOT pid=pg_backend_pid();"`, port)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get PostgreSQL connections: %v", err))
return
}
storage, err := shell.Execf(`psql -h 127.0.0.1 -U postgres -t -c "select pg_size_pretty(pg_database_size('postgres'));"`)
storage, err := shell.Execf(`psql -h 127.0.0.1 -p %d -U postgres -t -c "select pg_size_pretty(pg_database_size('postgres'));"`, port)
if err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to get PostgreSQL database size: %v", err))
return
}
if err = os.Unsetenv("PGPASSWORD"); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to unset PGPASSWORD env: %v", err))
return
}
data := []types.NV{
{Name: s.t.Get("Start Time"), Value: start},
{Name: s.t.Get("Process PID"), Value: pid},
@@ -210,10 +212,11 @@ func (s *App) SetPostgresPassword(w http.ResponseWriter, r *http.Request) {
}
oldPassword, _ := s.settingRepo.Get(biz.SettingKeyPostgresPassword)
postgres, err := db.NewPostgres("postgres", oldPassword, "127.0.0.1", 5432)
port := s.getPort()
postgres, err := db.NewPostgres(r.Context(), "postgres", oldPassword, "127.0.0.1", port)
if err != nil {
// 直接修改密码
if _, err = shell.Execf(`su - postgres -c "psql -c \"ALTER USER postgres WITH PASSWORD '%s';\""`, req.Password); err != nil {
if _, err = shell.Execf(`su - postgres -c "psql -p %d -c \"ALTER USER postgres WITH PASSWORD '%s';\""`, port, req.Password); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to set postgres password: %v", err))
return
}
@@ -237,7 +240,7 @@ func (s *App) SetPostgresPassword(w http.ResponseWriter, r *http.Request) {
// GetConfigTune 获取 PostgreSQL 配置调整参数
func (s *App) GetConfigTune(w http.ResponseWriter, r *http.Request) {
config, err := io.Read(fmt.Sprintf("%s/server/postgresql/data/postgresql.conf", app.Root))
config, err := io.Read(s.configPath())
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
return
@@ -284,7 +287,8 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
return
}
confPath := fmt.Sprintf("%s/server/postgresql/data/postgresql.conf", app.Root)
confPath := s.configPath()
oldPort := s.getPort()
config, err := io.Read(confPath)
if err != nil {
service.Error(w, http.StatusInternalServerError, "%v", err)
@@ -324,14 +328,48 @@ func (s *App) UpdateConfigTune(w http.ResponseWriter, r *http.Request) {
return
}
if err = systemctl.Reload("postgresql"); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to reload PostgreSQL: %v", err))
if err = s.applyConfig(config, oldPort); err != nil {
service.Error(w, http.StatusInternalServerError, s.t.Get("failed to apply PostgreSQL config: %v", err))
return
}
service.Success(w, nil)
}
func (s *App) configPath() string {
return fmt.Sprintf("%s/server/postgresql/data/postgresql.conf", app.Root)
}
// getPort 读取 PostgreSQL 端口
func (s *App) getPort() uint {
config, err := io.Read(s.configPath())
if err != nil {
return 5432
}
return s.parsePort(config)
}
// parsePort 从 config 内容解析端口,未配置时返回默认值
func (s *App) parsePort(config string) uint {
port := cast.ToUint(s.getPGValue(config, "port"))
if port == 0 {
return 5432
}
return port
}
// applyConfig 让 PostgreSQL 配置生效
func (s *App) applyConfig(newConfig string, oldPort uint) error {
newPort := s.parsePort(newConfig)
if oldPort == newPort {
return systemctl.Reload("postgresql")
}
if err := systemctl.Restart("postgresql"); err != nil {
return err
}
return s.databaseServerRepo.UpdatePort("local_postgresql", newPort)
}
// getPGValue 从 PostgreSQL 配置内容中获取指定键的值
func (s *App) getPGValue(content string, key string) string {
lines := strings.SplitSeq(content, "\n")
+2 -2
View File
@@ -5,14 +5,14 @@ type UpdateConfig struct {
}
type SetPostgresPassword struct {
Password string `form:"password" json:"password" validate:"required|password"`
Password string `form:"password" json:"password" validate:"required && password"`
}
// ConfigTune PostgreSQL 配置调整
type ConfigTune struct {
// 连接设置
ListenAddresses string `form:"listen_addresses" json:"listen_addresses"`
Port string `form:"port" json:"port"`
Port string `form:"port" json:"port" validate:"number && min:1 && max:65535"`
MaxConnections string `form:"max_connections" json:"max_connections"`
SuperuserReservedConnections string `form:"superuser_reserved_connections" json:"superuser_reserved_connections"`
// 内存设置
+6 -3
View File
@@ -29,8 +29,9 @@ type App struct {
taskRepo biz.TaskRepo
}
func NewApp(t *gotext.Locale, conf *config.Config, taskRepo biz.TaskRepo) *App {
return &App{t: t, conf: conf, taskRepo: taskRepo}
func NewApp(conf *config.Config, t *gotext.Locale, taskRepo biz.TaskRepo) (*App, error) {
return &App{t: t, conf: conf, taskRepo: taskRepo}, nil
}
func (s *App) Route(r chi.Router) {
@@ -73,7 +74,7 @@ func (s *App) Load(w http.ResponseWriter, r *http.Request) {
client := resty.New().SetTimeout(10 * time.Second)
defer func(client *resty.Client) { _ = client.Close() }(client)
resp, err := client.R().Get("http://127.0.0.1:9090/api/v1/status/runtimeinfo")
if err != nil || !resp.IsSuccess() {
if err != nil || !resp.IsStatusSuccess() {
service.Success(w, []types.NV{})
return
}
@@ -259,6 +260,7 @@ func (s *App) InstallExporter(w http.ResponseWriter, r *http.Request) {
cmd := fmt.Sprintf(`curl -sSLm 10 --retry 3 'https://%s/prometheus/exporters/exporter.sh' | bash -s -- 'install' '%s'`, s.conf.App.DownloadEndpoint, url.PathEscape(req.Slug))
task := new(biz.Task)
task.Key = "prometheus:exporter:" + req.Slug
task.Name = s.t.Get("Install Prometheus exporter %s", req.Slug)
task.Status = biz.TaskStatusWaiting
task.Shell = cmd
@@ -286,6 +288,7 @@ func (s *App) UninstallExporter(w http.ResponseWriter, r *http.Request) {
cmd := fmt.Sprintf(`curl -sSLm 10 --retry 3 'https://%s/prometheus/exporters/exporter.sh' | bash -s -- 'uninstall' '%s'`, s.conf.App.DownloadEndpoint, url.PathEscape(req.Slug))
task := new(biz.Task)
task.Key = "prometheus:exporter:" + req.Slug
task.Name = s.t.Get("Uninstall Prometheus exporter %s", req.Slug)
task.Status = biz.TaskStatusWaiting
task.Shell = cmd
+4 -3
View File
@@ -8,7 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix"
"github.com/libtnb/chix/v2"
"github.com/samber/lo"
"github.com/spf13/cast"
@@ -25,10 +25,11 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) *App {
func NewApp(t *gotext.Locale) (*App, error) {
return &App{
t: t,
}
}, nil
}
func (s *App) Route(r chi.Router) {
+9 -9
View File
@@ -2,7 +2,7 @@ package pureftpd
type Create struct {
Username string `form:"username" json:"username" validate:"required"`
Password string `form:"password" json:"password" validate:"required|password"`
Password string `form:"password" json:"password" validate:"required && password"`
Path string `form:"path" json:"path" validate:"required"`
}
@@ -12,21 +12,21 @@ type Delete struct {
type ChangePassword struct {
Username string `form:"username" json:"username" validate:"required"`
Password string `form:"password" json:"password" validate:"required|password"`
Password string `form:"password" json:"password" validate:"required && password"`
}
type UpdatePort struct {
Port uint `form:"port" json:"port" validate:"required|number|min:1|max:65535"`
Port uint `form:"port" json:"port" validate:"required && number && min:1 && max:65535"`
}
// ConfigTune Pure-FTPd 配置调整
type ConfigTune struct {
MaxClientsNumber string `form:"max_clients_number" json:"max_clients_number"`
MaxClientsPerIP string `form:"max_clients_per_ip" json:"max_clients_per_ip"`
MaxIdleTime string `form:"max_idle_time" json:"max_idle_time"`
MaxClientsNumber string `form:"max_clients_number" json:"max_clients_number" validate:"number && min:1"`
MaxClientsPerIP string `form:"max_clients_per_ip" json:"max_clients_per_ip" validate:"number && min:1"`
MaxIdleTime string `form:"max_idle_time" json:"max_idle_time" validate:"number && min:0"`
MaxLoad string `form:"max_load" json:"max_load"`
PassivePortRange string `form:"passive_port_range" json:"passive_port_range"`
AnonymousOnly string `form:"anonymous_only" json:"anonymous_only"`
NoAnonymous string `form:"no_anonymous" json:"no_anonymous"`
MaxDiskUsage string `form:"max_disk_usage" json:"max_disk_usage"`
AnonymousOnly string `form:"anonymous_only" json:"anonymous_only" validate:"in:yes,no"`
NoAnonymous string `form:"no_anonymous" json:"no_anonymous" validate:"in:yes,no"`
MaxDiskUsage string `form:"max_disk_usage" json:"max_disk_usage" validate:"number && min:1 && max:100"`
}
+4 -2
View File
@@ -24,11 +24,13 @@ type App struct {
databaseServerRepo biz.DatabaseServerRepo
}
func NewApp(t *gotext.Locale, databaseServer biz.DatabaseServerRepo) *App {
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo) (*App, error) {
databaseServer := databaseServerRepo
return &App{
t: t,
databaseServerRepo: databaseServer,
}
}, nil
}
func (s *App) Route(r chi.Router) {
+7 -7
View File
@@ -8,15 +8,15 @@ type UpdateConfig struct {
type ConfigTune struct {
// 常规设置
Bind string `form:"bind" json:"bind"`
Port string `form:"port" json:"port"`
Databases string `form:"databases" json:"databases"`
Port string `form:"port" json:"port" validate:"number && min:1 && max:65535"`
Databases string `form:"databases" json:"databases" validate:"number && min:1"`
Requirepass string `form:"requirepass" json:"requirepass"`
Timeout string `form:"timeout" json:"timeout"`
TCPKeepalive string `form:"tcp_keepalive" json:"tcp_keepalive"`
Timeout string `form:"timeout" json:"timeout" validate:"number"`
TCPKeepalive string `form:"tcp_keepalive" json:"tcp_keepalive" validate:"number"`
// 内存
Maxmemory string `form:"maxmemory" json:"maxmemory"`
MaxmemoryPolicy string `form:"maxmemory_policy" json:"maxmemory_policy"`
MaxmemoryPolicy string `form:"maxmemory_policy" json:"maxmemory_policy" validate:"in:noeviction,allkeys-lru,allkeys-lfu,allkeys-random,volatile-lru,volatile-lfu,volatile-random,volatile-ttl"`
// 持久化
Appendonly string `form:"appendonly" json:"appendonly"`
Appendfsync string `form:"appendfsync" json:"appendfsync"`
Appendonly string `form:"appendonly" json:"appendonly" validate:"in:yes,no"`
Appendfsync string `form:"appendfsync" json:"appendfsync" validate:"in:always,everysec,no"`
}
+3 -2
View File
@@ -20,8 +20,9 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) *App {
return &App{t: t}
func NewApp(t *gotext.Locale) (*App, error) {
return &App{t: t}, nil
}
func (s *App) Route(r chi.Router) {
+8 -8
View File
@@ -8,15 +8,15 @@ type UpdateConfig struct {
// ConfigTune RocketMQ 配置调整
type ConfigTune struct {
// Broker 基础
BrokerName string `form:"broker_name" json:"broker_name"` // brokerName
ListenPort string `form:"listen_port" json:"listen_port"` // listenPort
NamesrvAddr string `form:"namesrv_addr" json:"namesrv_addr"` // namesrvAddr
BrokerRole string `form:"broker_role" json:"broker_role"` // brokerRole
FlushDiskType string `form:"flush_disk_type" json:"flush_disk_type"` // flushDiskType
BrokerName string `form:"broker_name" json:"broker_name"` // brokerName
ListenPort string `form:"listen_port" json:"listen_port" validate:"number && min:1 && max:65535"` // listenPort
NamesrvAddr string `form:"namesrv_addr" json:"namesrv_addr"` // namesrvAddr
BrokerRole string `form:"broker_role" json:"broker_role" validate:"in:ASYNC_MASTER,SYNC_MASTER,SLAVE"` // brokerRole
FlushDiskType string `form:"flush_disk_type" json:"flush_disk_type" validate:"in:ASYNC_FLUSH,SYNC_FLUSH"` // flushDiskType
// 存储
StorePathRootDir string `form:"store_path_root_dir" json:"store_path_root_dir"` // storePathRootDir
StorePathCommitLog string `form:"store_path_commit_log" json:"store_path_commit_log"` // storePathCommitLog
MaxMessageSize string `form:"max_message_size" json:"max_message_size"` // maxMessageSize
StorePathRootDir string `form:"store_path_root_dir" json:"store_path_root_dir" validate:"unix_path"` // storePathRootDir
StorePathCommitLog string `form:"store_path_commit_log" json:"store_path_commit_log" validate:"unix_path"` // storePathCommitLog
MaxMessageSize string `form:"max_message_size" json:"max_message_size" validate:"number"` // maxMessageSize
// JVM - NameServer
NamesrvHeapInitSize string `form:"namesrv_heap_init_size" json:"namesrv_heap_init_size"` // -Xms (namesrv)
NamesrvHeapMaxSize string `form:"namesrv_heap_max_size" json:"namesrv_heap_max_size"` // -Xmx (namesrv)
+4 -3
View File
@@ -8,7 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix"
"github.com/libtnb/chix/v2"
"github.com/libtnb/utils/str"
"github.com/acepanel/panel/v3/internal/service"
@@ -22,10 +22,11 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) *App {
func NewApp(t *gotext.Locale) (*App, error) {
return &App{
t: t,
}
}, nil
}
func (s *App) Route(r chi.Router) {
+4 -3
View File
@@ -9,7 +9,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix"
"github.com/libtnb/chix/v2"
"github.com/samber/lo"
"github.com/spf13/cast"
@@ -23,10 +23,11 @@ type App struct {
t *gotext.Locale
}
func NewApp(t *gotext.Locale) *App {
func NewApp(t *gotext.Locale) (*App, error) {
return &App{
t: t,
}
}, nil
}
func (s *App) Route(r chi.Router) {
+2 -2
View File
@@ -4,8 +4,8 @@ type Create struct {
Ak string `form:"ak" json:"ak" validate:"required"`
Sk string `form:"sk" json:"sk" validate:"required"`
Bucket string `form:"bucket" json:"bucket" validate:"required"`
URL string `form:"url" json:"url" validate:"required"`
Path string `form:"path" json:"path" validate:"required"`
URL string `form:"url" json:"url" validate:"required && url"`
Path string `form:"path" json:"path" validate:"required && unix_path"`
}
type Delete struct {
+4 -3
View File
@@ -6,7 +6,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix"
"github.com/libtnb/chix/v2"
"github.com/spf13/cast"
"github.com/acepanel/panel/v3/internal/service"
@@ -22,7 +22,8 @@ type App struct {
name string
}
func NewApp(t *gotext.Locale) *App {
func NewApp(t *gotext.Locale) (*App, error) {
var name string
if os.IsRHEL() {
name = "supervisord"
@@ -33,7 +34,7 @@ func NewApp(t *gotext.Locale) *App {
return &App{
t: t,
name: name,
}
}, nil
}
func (s *App) Route(r chi.Router) {
+2 -2
View File
@@ -16,7 +16,7 @@ type ProcessName struct {
type CreateProcess struct {
Name string `form:"name" json:"name" validate:"required"`
User string `form:"user" json:"user" validate:"required"`
Path string `form:"path" json:"path" validate:"required"`
Path string `form:"path" json:"path" validate:"required && unix_path"`
Command string `form:"command" json:"command" validate:"required"`
Num int `form:"num" json:"num" validate:"required|min:1"`
Num int `form:"num" json:"num" validate:"required && min:1"`
}
+4 -2
View File
@@ -24,11 +24,13 @@ type App struct {
databaseServerRepo biz.DatabaseServerRepo
}
func NewApp(t *gotext.Locale, databaseServer biz.DatabaseServerRepo) *App {
func NewApp(t *gotext.Locale, databaseServerRepo biz.DatabaseServerRepo) (*App, error) {
databaseServer := databaseServerRepo
return &App{
t: t,
databaseServerRepo: databaseServer,
}
}, nil
}
func (s *App) Route(r chi.Router) {
+7 -7
View File
@@ -8,15 +8,15 @@ type UpdateConfig struct {
type ConfigTune struct {
// 常规设置
Bind string `form:"bind" json:"bind"`
Port string `form:"port" json:"port"`
Databases string `form:"databases" json:"databases"`
Port string `form:"port" json:"port" validate:"number && min:1 && max:65535"`
Databases string `form:"databases" json:"databases" validate:"number && min:1"`
Requirepass string `form:"requirepass" json:"requirepass"`
Timeout string `form:"timeout" json:"timeout"`
TCPKeepalive string `form:"tcp_keepalive" json:"tcp_keepalive"`
Timeout string `form:"timeout" json:"timeout" validate:"number"`
TCPKeepalive string `form:"tcp_keepalive" json:"tcp_keepalive" validate:"number"`
// 内存
Maxmemory string `form:"maxmemory" json:"maxmemory"`
MaxmemoryPolicy string `form:"maxmemory_policy" json:"maxmemory_policy"`
MaxmemoryPolicy string `form:"maxmemory_policy" json:"maxmemory_policy" validate:"in:noeviction,allkeys-lru,allkeys-lfu,volatile-lru,volatile-lfu,allkeys-random,volatile-random,volatile-ttl"`
// 持久化
Appendonly string `form:"appendonly" json:"appendonly"`
Appendfsync string `form:"appendfsync" json:"appendfsync"`
Appendonly string `form:"appendonly" json:"appendonly" validate:"in:yes,no"`
Appendfsync string `form:"appendfsync" json:"appendfsync" validate:"in:always,everysec,no"`
}
+984
View File
@@ -0,0 +1,984 @@
package biz
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log/slog"
"slices"
"strings"
"sync"
"time"
"github.com/leonelquinteros/gotext"
"github.com/samber/lo"
lop "github.com/samber/lo/parallel"
"github.com/spf13/cast"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/apploader"
"github.com/acepanel/panel/v3/pkg/shell"
"github.com/acepanel/panel/v3/pkg/sshlog"
"github.com/acepanel/panel/v3/pkg/systemctl"
"github.com/acepanel/panel/v3/pkg/tools"
"github.com/acepanel/panel/v3/pkg/types"
)
// 告警指标类型
const (
AlertTypeCPU = "cpu" // CPU 使用率 %
AlertTypeMemory = "memory" // 内存使用率 %
AlertTypeSwap = "swap" // Swap 使用率 %
AlertTypeLoad1 = "load1" // 1 分钟平均负载
AlertTypeLoad5 = "load5" // 5 分钟平均负载
AlertTypeLoad15 = "load15" // 15 分钟平均负载
AlertTypeDisk = "disk" // 磁盘使用率 %,目标为挂载点
AlertTypeDiskInode = "disk_inode" // 磁盘 inode 使用率 %,目标为挂载点
AlertTypeDiskRead = "disk_read" // 磁盘读取速率 MB/s,目标为设备名
AlertTypeDiskWrite = "disk_write" // 磁盘写入速率 MB/s,目标为设备名
AlertTypeNetIn = "net_in" // 网卡下行速率 MB/s,目标为网卡名
AlertTypeNetOut = "net_out" // 网卡上行速率 MB/s,目标为网卡名
AlertTypeWebsite5xx = "website_5xx" // 网站本小时 5xx 次数,目标为网站名
AlertTypeWebsiteError = "website_error" // 网站本小时错误率 %,目标为网站名
AlertTypeService = "service" // 服务未运行,目标为服务名
AlertTypeProject = "project" // 项目未运行,目标为项目名
AlertTypeContainer = "container" // 容器未运行,目标为容器名
AlertTypeApp = "app" // 应用未运行,目标为应用标识
AlertTypeDatabase = "database" // 数据库服务器不可达,目标为服务器名
AlertTypeCertExpire = "cert_expire" // 证书剩余天数,目标为域名
AlertTypeWebsiteExpire = "website_expire" // 网站剩余天数,目标为网站名
)
// statusAlertTypes 状态类指标,语义固定为「不在运行」,不需要运算符与阈值
var statusAlertTypes = []string{AlertTypeService, AlertTypeProject, AlertTypeContainer, AlertTypeApp, AlertTypeDatabase}
// IsStatusAlert 是否为状态类指标
func IsStatusAlert(typ string) bool {
return slices.Contains(statusAlertTypes, typ)
}
const (
// alertRetryDelay 通知发送失败后的重试间隔
alertRetryDelay = 5 * time.Minute
// sshFailThreshold 单次检查窗口内触发爆破告警的失败次数
sshFailThreshold uint = 5
// sshFailSilence 同一来源两次爆破告警的最小间隔
sshFailSilence = 30 * time.Minute
)
// 告警比较运算符
const (
AlertOperatorGT = "gt"
AlertOperatorGTE = "gte"
AlertOperatorLT = "lt"
AlertOperatorLTE = "lte"
)
// AlertRule 告警规则
type AlertRule struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"not null;default:''" json:"name"`
Type string `gorm:"not null;default:''" json:"type"`
Target string `gorm:"not null;default:''" json:"target"` // 挂载点/网卡/磁盘/服务名/域名,空表示全部
Operator string `gorm:"not null;default:'gt'" json:"operator"` // gt/gte/lt/lte
Threshold float64 `gorm:"not null;default:0" json:"threshold"`
Duration uint `gorm:"not null;default:1" json:"duration"` // 连续满足次数
Silence uint `gorm:"not null;default:30" json:"silence"` // 静默期(分钟)
Channels []uint `gorm:"serializer:json" json:"channels"` // 通知渠道 ID
Enabled bool `gorm:"not null;default:true" json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Alert 告警记录
type Alert struct {
ID uint `gorm:"primaryKey" json:"id"`
RuleID uint `gorm:"not null;default:0;index" json:"rule_id"`
RuleName string `gorm:"not null;default:''" json:"rule_name"`
Type string `gorm:"not null;default:''" json:"type"`
Target string `gorm:"not null;default:''" json:"target"`
Value float64 `gorm:"not null;default:0" json:"value"`
Message string `gorm:"not null;default:''" json:"message"`
Notified bool `gorm:"not null;default:false" json:"notified"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
// AlertMetric 单个目标的取值
type AlertMetric struct {
Target string
Value float64
}
type AlertRepo interface {
ListRules(page, limit uint) ([]*AlertRule, int64, error)
AllRules() ([]*AlertRule, error)
GetRule(id uint) (*AlertRule, error)
CreateRule(rule *AlertRule) error
UpdateRule(rule *AlertRule) error
DeleteRule(id uint) error
AddAlert(alert *Alert) error
ListAlerts(page, limit uint) ([]*Alert, int64, error)
ClearAlerts() error
ClearAlertsBefore(t time.Time) error
CertExpiry() ([]*AlertMetric, error)
WebsiteExpiry() ([]*AlertMetric, error)
ProjectNames() ([]string, error)
DatabaseServers() ([]*DatabaseServer, error)
WebsiteHourStats() ([]*WebsiteHourStat, error)
}
// WebsiteHourStat 网站当前小时的请求统计
type WebsiteHourStat struct {
Site string
Requests uint64
Errors uint64
Status5xx uint64
}
// ioSnapshot 网卡/磁盘累计计数快照及据此算出的速率(字节/秒)
type ioSnapshot struct {
in uint64
out uint64
inRate uint64
outRate uint64
at time.Time
}
type AlertUsecase struct {
repo AlertRepo
notify *NotifyUsecase
setting SettingRepo
container ContainerRepo
app AppRepo
database DatabaseServerRepo
loader *apploader.Loader
log *slog.Logger
t *gotext.Locale
mu sync.Mutex
hits map[string]uint // 连续命中次数
fired map[string]time.Time // 上次通知时间
netSnaps map[string]ioSnapshot // 网卡累计流量快照
diskSnaps map[string]ioSnapshot // 磁盘累计 IO 快照
healthKeys map[string]struct{} // 已通知的健康问题
sshFired map[string]time.Time // SSH 爆破上次通知时间
sshAt time.Time // SSH 日志上次检查时间
dbProbe map[string]bool // 本轮数据库连通性探测结果
cleanedAt time.Time
}
func NewAlertUsecase(notifyUsecase *NotifyUsecase, loader *apploader.Loader, t *gotext.Locale, log *slog.Logger, alertRepo AlertRepo, appRepo AppRepo, containerRepo ContainerRepo, databaseServerRepo DatabaseServerRepo, settingRepo SettingRepo) (*AlertUsecase, error) {
return &AlertUsecase{
repo: alertRepo,
notify: notifyUsecase,
setting: settingRepo,
container: containerRepo,
app: appRepo,
database: databaseServerRepo,
loader: loader,
log: log,
t: t,
hits: make(map[string]uint),
fired: make(map[string]time.Time),
netSnaps: make(map[string]ioSnapshot),
diskSnaps: make(map[string]ioSnapshot),
healthKeys: make(map[string]struct{}),
sshFired: make(map[string]time.Time),
}, nil
}
func (uc *AlertUsecase) ListRules(page, limit uint) ([]*AlertRule, int64, error) {
return uc.repo.ListRules(page, limit)
}
func (uc *AlertUsecase) GetRule(id uint) (*AlertRule, error) {
return uc.repo.GetRule(id)
}
func (uc *AlertUsecase) CreateRule(ctx context.Context, req *request.AlertRuleCreate) (*AlertRule, error) {
rule := &AlertRule{
Name: req.Name,
Type: req.Type,
Target: req.Target,
Operator: req.Operator,
Threshold: req.Threshold,
Duration: req.Duration,
Silence: req.Silence,
Channels: req.Channels,
Enabled: req.Enabled,
}
normalizeRule(rule)
if err := uc.repo.CreateRule(rule); err != nil {
return nil, err
}
uc.log.Info("alert rule created", slog.String("type", OperationTypeMonitor), slog.Uint64("operator_id", operatorID(ctx)), slog.String("name", req.Name))
return rule, nil
}
func (uc *AlertUsecase) UpdateRule(ctx context.Context, req *request.AlertRuleUpdate) error {
rule, err := uc.repo.GetRule(req.ID)
if err != nil {
return err
}
rule.Name = req.Name
rule.Type = req.Type
rule.Target = req.Target
rule.Operator = req.Operator
rule.Threshold = req.Threshold
rule.Duration = req.Duration
rule.Silence = req.Silence
rule.Channels = req.Channels
rule.Enabled = req.Enabled
normalizeRule(rule)
if err = uc.repo.UpdateRule(rule); err != nil {
return err
}
// 规则变更后重置命中状态,避免沿用旧阈值的计数
uc.mu.Lock()
uc.clearState(rule.ID)
uc.mu.Unlock()
uc.log.Info("alert rule updated", slog.String("type", OperationTypeMonitor), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(req.ID)), slog.String("name", req.Name))
return nil
}
func (uc *AlertUsecase) DeleteRule(ctx context.Context, id uint) error {
rule, err := uc.repo.GetRule(id)
if err != nil {
return err
}
if err = uc.repo.DeleteRule(id); err != nil {
return err
}
uc.mu.Lock()
uc.clearState(id)
uc.mu.Unlock()
uc.log.Info("alert rule deleted", slog.String("type", OperationTypeMonitor), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(id)), slog.String("name", rule.Name))
return nil
}
func (uc *AlertUsecase) ListAlerts(page, limit uint) ([]*Alert, int64, error) {
return uc.repo.ListAlerts(page, limit)
}
func (uc *AlertUsecase) ClearAlerts() error {
return uc.repo.ClearAlerts()
}
// Evaluate 评估全部启用的规则,命中则记录并通知
func (uc *AlertUsecase) Evaluate(ctx context.Context) error {
rules, err := uc.repo.AllRules()
if err != nil {
return err
}
// 探测结果按轮缓存,进入新一轮先失效
uc.mu.Lock()
uc.dbProbe = nil
uc.mu.Unlock()
uc.cleanup()
uc.checkHealth(ctx)
uc.checkSSH(ctx)
var enabled []*AlertRule
for _, rule := range rules {
if rule.Enabled {
enabled = append(enabled, rule)
}
}
if len(enabled) == 0 {
uc.mu.Lock()
clear(uc.hits)
clear(uc.fired)
uc.mu.Unlock()
return nil
}
info := tools.CurrentInfo(nil, nil)
now := time.Now()
uc.updateSnapshots(info, now)
alive := make(map[string]struct{})
for _, rule := range enabled {
metrics, err := uc.collect(ctx, rule, info)
if err != nil {
uc.log.Warn("failed to collect alert metric", slog.String("rule", rule.Name), slog.Any("err", err))
continue
}
for _, metric := range metrics {
key := stateKey(rule.ID, metric.Target)
alive[key] = struct{}{}
uc.evaluateMetric(ctx, rule, metric, key, now)
}
}
// 清理已消失的目标状态
uc.mu.Lock()
for key := range uc.hits {
if _, ok := alive[key]; !ok {
delete(uc.hits, key)
}
}
uc.mu.Unlock()
return nil
}
func (uc *AlertUsecase) evaluateMetric(ctx context.Context, rule *AlertRule, metric *AlertMetric, key string, now time.Time) {
uc.mu.Lock()
if !matchThreshold(rule, metric.Value) {
delete(uc.hits, key)
uc.mu.Unlock()
return
}
uc.hits[key]++
hits := uc.hits[key]
duration := max(rule.Duration, 1)
if hits < duration {
uc.mu.Unlock()
return
}
// 静默期内不重复告警
if last, ok := uc.fired[key]; ok && now.Sub(last) < time.Duration(rule.Silence)*time.Minute {
uc.mu.Unlock()
return
}
uc.fired[key] = now
uc.mu.Unlock()
alert := &Alert{
RuleID: rule.ID,
RuleName: rule.Name,
Type: rule.Type,
Target: metric.Target,
Value: metric.Value,
Message: uc.buildMessage(rule, metric),
}
sent, err := uc.notify.Send(ctx, rule.Channels, uc.t.Get("[AcePanel] Alert: %s", rule.Name), NotifyBody(alert.Message, [][2]string{
{uc.t.Get("Rule"), rule.Name},
{uc.t.Get("Metric"), uc.metricLabel(rule.Type, metric.Target)},
{uc.t.Get("Current Value"), uc.formatValue(rule.Type, metric.Value)},
{uc.t.Get("Threshold"), uc.formatValue(rule.Type, rule.Threshold)},
{uc.t.Get("Time"), now.Format(time.DateTime)},
}))
if err != nil {
uc.log.Warn("failed to send alert notification", slog.String("rule", rule.Name), slog.Any("err", err))
}
alert.Notified = sent > 0
// 一条都没送达时缩短静默期,让下一轮重试,避免临时故障吞掉整个静默窗口
if len(rule.Channels) > 0 && sent == 0 {
uc.mu.Lock()
uc.fired[key] = now.Add(alertRetryDelay - time.Duration(rule.Silence)*time.Minute)
uc.mu.Unlock()
}
if err := uc.repo.AddAlert(alert); err != nil {
uc.log.Warn("failed to save alert record", slog.String("rule", rule.Name), slog.Any("err", err))
}
}
// collect 采集规则对应的目标取值
func (uc *AlertUsecase) collect(ctx context.Context, rule *AlertRule, info types.CurrentInfo) ([]*AlertMetric, error) {
switch rule.Type {
case AlertTypeCPU:
return []*AlertMetric{{Value: info.Percent}}, nil
case AlertTypeMemory:
if info.Mem == nil {
return nil, nil
}
return []*AlertMetric{{Value: info.Mem.UsedPercent}}, nil
case AlertTypeSwap:
if info.Swap == nil || info.Swap.Total == 0 {
return nil, nil
}
return []*AlertMetric{{Value: info.Swap.UsedPercent}}, nil
case AlertTypeLoad1, AlertTypeLoad5, AlertTypeLoad15:
if info.Load == nil {
return nil, nil
}
switch rule.Type {
case AlertTypeLoad1:
return []*AlertMetric{{Value: info.Load.Load1}}, nil
case AlertTypeLoad5:
return []*AlertMetric{{Value: info.Load.Load5}}, nil
default:
return []*AlertMetric{{Value: info.Load.Load15}}, nil
}
case AlertTypeDisk, AlertTypeDiskInode:
metrics := make([]*AlertMetric, 0)
for _, usage := range info.DiskUsage {
if rule.Target != "" && rule.Target != usage.Path {
continue
}
value := usage.UsedPercent
if rule.Type == AlertTypeDiskInode {
value = usage.InodesUsedPercent
}
metrics = append(metrics, &AlertMetric{Target: usage.Path, Value: value})
}
return metrics, nil
case AlertTypeNetIn, AlertTypeNetOut, AlertTypeDiskRead, AlertTypeDiskWrite:
return uc.rateMetrics(rule), nil
case AlertTypeWebsite5xx, AlertTypeWebsiteError:
stats, err := uc.repo.WebsiteHourStats()
if err != nil {
return nil, err
}
metrics := make([]*AlertMetric, 0, len(stats))
for _, item := range stats {
if rule.Target != "" && rule.Target != item.Site {
continue
}
if rule.Type == AlertTypeWebsite5xx {
metrics = append(metrics, &AlertMetric{Target: item.Site, Value: float64(item.Status5xx)})
continue
}
// 无请求时错误率无意义
if item.Requests == 0 {
continue
}
metrics = append(metrics, &AlertMetric{Target: item.Site, Value: float64(item.Errors) / float64(item.Requests) * 100})
}
return metrics, nil
case AlertTypeService:
if rule.Target == "" {
return nil, nil
}
return []*AlertMetric{{Target: rule.Target, Value: notRunning(systemctl.Status(rule.Target))}}, nil
case AlertTypeProject:
names, err := uc.repo.ProjectNames()
if err != nil {
return nil, err
}
// 项目即 systemd 单元,单元名与项目名一致
return lop.Map(filterNames(rule.Target, names), func(name string, _ int) *AlertMetric {
return &AlertMetric{Target: name, Value: notRunning(systemctl.Status(name))}
}), nil
case AlertTypeContainer:
containers, err := uc.container.ListAll(containerSock(uc.setting))
if err != nil {
return nil, err
}
metrics := make([]*AlertMetric, 0, len(containers))
for _, item := range containers {
if rule.Target != "" && rule.Target != item.Name {
continue
}
value := float64(0)
if item.State != "running" {
value = 1
}
metrics = append(metrics, &AlertMetric{Target: item.Name, Value: value})
}
return metrics, nil
case AlertTypeApp:
installed, err := uc.app.Installed()
if err != nil {
return nil, err
}
targets := lo.Filter(installed, func(item *App, _ int) bool {
if rule.Target != "" && rule.Target != item.Slug {
return false
}
_, ok := uc.loader.Get(item.Slug)
return ok
})
// 状态查询逐个访问 systemd,并发采集
return lo.Compact(lop.Map(targets, func(item *App, _ int) *AlertMetric {
a, _ := uc.loader.Get(item.Slug)
status := a.Status()
// 无 systemd 服务的应用没有运行状态
if status == types.AppStatusNA {
return nil
}
value := float64(0)
if status != types.AppStatusRunning {
value = 1
}
return &AlertMetric{Target: item.Slug, Value: value}
})), nil
case AlertTypeDatabase:
servers, err := uc.repo.DatabaseServers()
if err != nil {
return nil, err
}
reachable := uc.probeDatabases(ctx, servers)
metrics := make([]*AlertMetric, 0, len(servers))
for _, item := range servers {
if rule.Target != "" && rule.Target != item.Name {
continue
}
value := float64(0)
if !reachable[item.Name] {
value = 1
}
metrics = append(metrics, &AlertMetric{Target: item.Name, Value: value})
}
return metrics, nil
case AlertTypeCertExpire:
metrics, err := uc.repo.CertExpiry()
if err != nil {
return nil, err
}
return filterTarget(rule.Target, metrics), nil
case AlertTypeWebsiteExpire:
metrics, err := uc.repo.WebsiteExpiry()
if err != nil {
return nil, err
}
return filterTarget(rule.Target, metrics), nil
}
return nil, fmt.Errorf("unsupported alert type: %s", rule.Type)
}
// notRunning 将运行状态转为告警取值,未运行记为 1,配合 normalizeRule 的 >=1 判定
func notRunning(running bool, _ error) float64 {
if running {
return 0
}
return 1
}
// filterNames 按目标名筛选,目标为空表示全部
func filterNames(target string, names []string) []string {
if target == "" {
return names
}
if slices.Contains(names, target) {
return []string{target}
}
return nil
}
// filterTarget 按目标名筛选,目标为空表示全部
// 证书目标是逗号分隔的多域名,允许用其中任一域名命中
func filterTarget(target string, metrics []*AlertMetric) []*AlertMetric {
if target == "" {
return metrics
}
filtered := make([]*AlertMetric, 0)
for _, metric := range metrics {
if metric.Target == target || slices.Contains(strings.Split(metric.Target, ","), target) {
filtered = append(filtered, metric)
}
}
return filtered
}
// probeDatabases 并发探测数据库连通性,结果在本轮评估内复用,避免多条规则重复整批探测
// 单台耗时上限由 pkg/db 各驱动的连接超时保证(5~10 秒),并发后整批不会拖长分钟级评估
func (uc *AlertUsecase) probeDatabases(ctx context.Context, servers []*DatabaseServer) map[string]bool {
uc.mu.Lock()
cached := uc.dbProbe
uc.mu.Unlock()
if cached != nil {
return cached
}
probe := lo.FromEntries(lop.Map(servers, func(item *DatabaseServer, _ int) lo.Entry[string, bool] {
return lo.Entry[string, bool]{Key: item.Name, Value: uc.database.CheckServer(ctx, item)}
}))
uc.mu.Lock()
uc.dbProbe = probe
uc.mu.Unlock()
return probe
}
// rateMetrics 从快照读取速率指标(MB/s)
func (uc *AlertUsecase) rateMetrics(rule *AlertRule) []*AlertMetric {
uc.mu.Lock()
defer uc.mu.Unlock()
snaps := uc.netSnaps
if rule.Type == AlertTypeDiskRead || rule.Type == AlertTypeDiskWrite {
snaps = uc.diskSnaps
}
metrics := make([]*AlertMetric, 0, len(snaps))
for name, snap := range snaps {
if rule.Target != "" && rule.Target != name {
continue
}
value := snap.inRate
if rule.Type == AlertTypeNetOut || rule.Type == AlertTypeDiskWrite {
value = snap.outRate
}
metrics = append(metrics, &AlertMetric{Target: name, Value: float64(value) / 1024 / 1024})
}
return metrics
}
// updateSnapshots 用两次采集的累计值计算速率,结果暂存于快照
func (uc *AlertUsecase) updateSnapshots(info types.CurrentInfo, now time.Time) {
uc.mu.Lock()
defer uc.mu.Unlock()
nets := make(map[string]ioSnapshot, len(info.Net))
for _, item := range info.Net {
if item.Name == "lo" {
continue
}
nets[item.Name] = rateOf(uc.netSnaps[item.Name], item.BytesRecv, item.BytesSent, now)
}
uc.netSnaps = nets
disks := make(map[string]ioSnapshot, len(info.DiskIO))
for _, item := range info.DiskIO {
disks[item.Name] = rateOf(uc.diskSnaps[item.Name], item.ReadBytes, item.WriteBytes, now)
}
uc.diskSnaps = disks
}
// checkHealth 上报新出现的面板健康问题,问题恢复后重新出现会再次通知
// 同步发送并只在送达后记入去重集合,否则一次发送失败就会让持续存在的问题再也不告警
func (uc *AlertUsecase) checkHealth(ctx context.Context) {
issues := app.Health.Snapshot()
uc.mu.Lock()
fresh := make([]app.HealthIssue, 0, len(issues))
notified := make(map[string]struct{}, len(issues))
for _, issue := range issues {
if _, ok := uc.healthKeys[issue.Key]; ok {
notified[issue.Key] = struct{}{}
continue
}
fresh = append(fresh, issue)
}
// 已消失的问题在此被丢弃,恢复后再次出现会重新通知
uc.healthKeys = notified
uc.mu.Unlock()
for _, issue := range fresh {
if err := uc.notify.SendEventSync(ctx, NotifyEventHealth, uc.t.Get("[AcePanel] Panel Health Issue"), NotifyBody(uc.t.Get("panel reported a health issue"), [][2]string{
{uc.t.Get("Item"), issue.Key},
{uc.t.Get("Level"), issue.Level},
{uc.t.Get("Detail"), issue.Message},
{uc.t.Get("Time"), issue.Since.Format(time.DateTime)},
})); err != nil {
uc.log.Warn("failed to send health notification", slog.String("item", issue.Key), slog.Any("err", err))
continue
}
uc.mu.Lock()
uc.healthKeys[issue.Key] = struct{}{}
uc.mu.Unlock()
}
}
// checkSSH 增量检查 sshd 日志,上报登录成功与爆破尝试
func (uc *AlertUsecase) checkSSH(ctx context.Context) {
now := time.Now()
uc.mu.Lock()
since := uc.sshAt
uc.sshAt = now
uc.mu.Unlock()
// 首次仅记录时间,避免面板启动时把历史日志全推一遍
if since.IsZero() {
return
}
raw, err := shell.ExecfWithContext(ctx, `journalctl -u sshd -u ssh --no-pager -o json --since "@%d" 2>/dev/null`, since.Unix())
if err != nil {
// 读取失败则回退检查点,下一轮重扫该窗口,避免丢掉这段时间的登录记录
uc.mu.Lock()
uc.sshAt = since
uc.mu.Unlock()
return
}
if raw == "" {
return
}
failures := make(map[string]uint)
scanner := bufio.NewScanner(strings.NewReader(raw))
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
var entry struct {
Message string `json:"MESSAGE"`
}
if json.Unmarshal(scanner.Bytes(), &entry) != nil {
continue
}
record := sshlog.ParseMessage(entry.Message)
if record == nil {
continue
}
switch record.Status {
case sshlog.StatusAccepted:
uc.notify.SendEvent(NotifyEventSSHLogin, uc.t.Get("[AcePanel] SSH Login"), NotifyBody(uc.t.Get("SSH login succeeded"), [][2]string{
{uc.t.Get("Username"), record.User},
{uc.t.Get("IP"), record.IP},
{uc.t.Get("Method"), record.Method},
{uc.t.Get("Time"), now.Format(time.DateTime)},
}))
case sshlog.StatusFailed, sshlog.StatusInvalidUser:
failures[record.IP]++
}
}
for ip, count := range failures {
if count < sshFailThreshold || !uc.sshShouldFire(ip, now) {
continue
}
uc.notify.SendEvent(NotifyEventSSHBruteforce, uc.t.Get("[AcePanel] SSH Brute-force Attempts"), NotifyBody(uc.t.Get("too many failed SSH login attempts"), [][2]string{
{uc.t.Get("IP"), ip},
{uc.t.Get("Failed Attempts"), cast.ToString(count)},
{uc.t.Get("Time"), now.Format(time.DateTime)},
}))
}
}
// sshShouldFire 判断某来源是否已过静默期,并顺带清理过期记录
func (uc *AlertUsecase) sshShouldFire(ip string, now time.Time) bool {
uc.mu.Lock()
defer uc.mu.Unlock()
for key, at := range uc.sshFired {
if now.Sub(at) > sshFailSilence {
delete(uc.sshFired, key)
}
}
if at, ok := uc.sshFired[ip]; ok && now.Sub(at) < sshFailSilence {
return false
}
uc.sshFired[ip] = now
return true
}
// cleanup 按保留天数清理历史告警记录
func (uc *AlertUsecase) cleanup() {
uc.mu.Lock()
if time.Since(uc.cleanedAt) < 6*time.Hour {
uc.mu.Unlock()
return
}
uc.cleanedAt = time.Now()
uc.mu.Unlock()
days, err := uc.setting.GetInt(SettingKeyAlertLogDays, 30)
if err != nil || days <= 0 {
return
}
if err = uc.repo.ClearAlertsBefore(time.Now().AddDate(0, 0, -days)); err != nil {
uc.log.Warn("failed to clear expired alerts", slog.Any("err", err))
}
}
// clearState 清除某条规则的运行时状态,调用前需持有锁
func (uc *AlertUsecase) clearState(ruleID uint) {
prefix := fmt.Sprintf("%d:", ruleID)
for key := range uc.hits {
if strings.HasPrefix(key, prefix) {
delete(uc.hits, key)
}
}
for key := range uc.fired {
if strings.HasPrefix(key, prefix) {
delete(uc.fired, key)
}
}
}
func (uc *AlertUsecase) buildMessage(rule *AlertRule, metric *AlertMetric) string {
label := uc.metricLabel(rule.Type, metric.Target)
switch rule.Type {
case AlertTypeService:
return uc.t.Get("service %s is not running", metric.Target)
case AlertTypeProject:
return uc.t.Get("project %s is not running", metric.Target)
case AlertTypeContainer:
return uc.t.Get("container %s is not running", metric.Target)
case AlertTypeApp:
return uc.t.Get("app %s is not running", metric.Target)
case AlertTypeDatabase:
return uc.t.Get("database server %s is unreachable", metric.Target)
case AlertTypeCertExpire:
return uc.t.Get("certificate %s expires in %s days", metric.Target, uc.formatValue(rule.Type, metric.Value))
case AlertTypeWebsiteExpire:
return uc.t.Get("website %s expires in %s days", metric.Target, uc.formatValue(rule.Type, metric.Value))
}
return uc.t.Get("%s is %s, %s threshold %s", label, uc.formatValue(rule.Type, metric.Value), uc.operatorLabel(rule.Operator), uc.formatValue(rule.Type, rule.Threshold))
}
func (uc *AlertUsecase) metricLabel(typ, target string) string {
var label string
switch typ {
case AlertTypeCPU:
label = uc.t.Get("CPU usage")
case AlertTypeMemory:
label = uc.t.Get("memory usage")
case AlertTypeSwap:
label = uc.t.Get("swap usage")
case AlertTypeLoad1:
label = uc.t.Get("1 minute load")
case AlertTypeLoad5:
label = uc.t.Get("5 minutes load")
case AlertTypeLoad15:
label = uc.t.Get("15 minutes load")
case AlertTypeDisk:
label = uc.t.Get("disk usage")
case AlertTypeDiskInode:
label = uc.t.Get("disk inode usage")
case AlertTypeDiskRead:
label = uc.t.Get("disk read speed")
case AlertTypeDiskWrite:
label = uc.t.Get("disk write speed")
case AlertTypeNetIn:
label = uc.t.Get("network download speed")
case AlertTypeNetOut:
label = uc.t.Get("network upload speed")
case AlertTypeWebsite5xx:
label = uc.t.Get("website 5xx responses this hour")
case AlertTypeWebsiteError:
label = uc.t.Get("website error rate this hour")
case AlertTypeService:
label = uc.t.Get("service status")
case AlertTypeProject:
label = uc.t.Get("project status")
case AlertTypeContainer:
label = uc.t.Get("container status")
case AlertTypeApp:
label = uc.t.Get("app status")
case AlertTypeDatabase:
label = uc.t.Get("database server status")
case AlertTypeCertExpire:
label = uc.t.Get("certificate expiry")
case AlertTypeWebsiteExpire:
label = uc.t.Get("website expiry")
default:
label = typ
}
if target != "" {
return fmt.Sprintf("%s (%s)", label, target)
}
return label
}
func (uc *AlertUsecase) operatorLabel(operator string) string {
switch operator {
case AlertOperatorGTE:
return uc.t.Get("greater than or equal to")
case AlertOperatorLT:
return uc.t.Get("less than")
case AlertOperatorLTE:
return uc.t.Get("less than or equal to")
default:
return uc.t.Get("greater than")
}
}
func (uc *AlertUsecase) formatValue(typ string, value float64) string {
switch typ {
case AlertTypeCPU, AlertTypeMemory, AlertTypeSwap, AlertTypeDisk, AlertTypeDiskInode, AlertTypeWebsiteError:
return fmt.Sprintf("%.2f%%", value)
case AlertTypeNetIn, AlertTypeNetOut, AlertTypeDiskRead, AlertTypeDiskWrite:
return fmt.Sprintf("%.2f MB/s", value)
case AlertTypeCertExpire, AlertTypeWebsiteExpire, AlertTypeWebsite5xx:
return fmt.Sprintf("%.0f", value)
}
if IsStatusAlert(typ) {
return uc.t.Get("not running")
}
return fmt.Sprintf("%.2f", value)
}
// normalizeRule 补齐规则的默认值,状态类规则固定为「不在运行」
func normalizeRule(rule *AlertRule) {
if IsStatusAlert(rule.Type) {
rule.Operator = AlertOperatorGTE
rule.Threshold = 1
}
if rule.Duration < 1 {
rule.Duration = 1
}
}
func matchThreshold(rule *AlertRule, value float64) bool {
switch rule.Operator {
case AlertOperatorGTE:
return value >= rule.Threshold
case AlertOperatorLT:
return value < rule.Threshold
case AlertOperatorLTE:
return value <= rule.Threshold
default:
return value > rule.Threshold
}
}
// rateOf 依据上次累计值计算每秒增量,首次采集速率为 0
func rateOf(prev ioSnapshot, in, out uint64, now time.Time) ioSnapshot {
snap := ioSnapshot{in: in, out: out, at: now}
if prev.at.IsZero() {
return snap
}
elapsed := now.Sub(prev.at).Seconds()
if elapsed < 1 {
elapsed = 1
}
if in >= prev.in {
snap.inRate = uint64(float64(in-prev.in) / elapsed)
}
if out >= prev.out {
snap.outRate = uint64(float64(out-prev.out) / elapsed)
}
return snap
}
func stateKey(ruleID uint, target string) string {
return fmt.Sprintf("%d:%s", ruleID, target)
}
+29
View File
@@ -0,0 +1,29 @@
package biz
import "testing"
// 多域名证书的目标是逗号分隔的域名列表,用其中任一域名都应能命中规则
func TestFilterTargetMatchesAnyCertDomain(t *testing.T) {
metrics := []*AlertMetric{
{Target: "a.example.com,b.example.com", Value: 5},
{Target: "c.example.com", Value: 30},
}
cases := []struct {
target string
want int
}{
{"", 2},
{"a.example.com", 1},
{"b.example.com", 1},
{"c.example.com", 1},
{"a.example.com,b.example.com", 1},
{"missing.example.com", 0},
}
for _, c := range cases {
if got := filterTarget(c.target, metrics); len(got) != c.want {
t.Fatalf("target %q matched %d metrics, want %d", c.target, len(got), c.want)
}
}
}
+293 -8
View File
@@ -1,8 +1,15 @@
package biz
import (
"encoding/json"
"errors"
"slices"
"time"
"github.com/leonelquinteros/gotext"
"github.com/samber/lo"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/pkg/api"
"github.com/acepanel/panel/v3/pkg/types"
)
@@ -18,19 +25,297 @@ type App struct {
UpdatedAt time.Time `json:"updated_at"`
}
// AppCustom 自定义编译参数(前置脚本在 configure 前执行,参数追加到 configure 末尾)
type AppCustom struct {
PreScript string `json:"pre_script"`
Args string `json:"args"`
}
// 支持自定义编译参数的应用与环境类型(源码编译类)
var (
customCompileApps = []string{"apache", "memcached", "nginx", "openresty", "pureftpd", "s3fs"}
customCompileEnvTypes = []string{"php", "python"}
)
// CustomCompileApp 判断应用是否支持自定义编译参数
func CustomCompileApp(slug string) bool {
return slices.Contains(customCompileApps, slug)
}
// CustomCompileEnv 判断环境类型是否支持自定义编译参数
func CustomCompileEnv(typ string) bool {
return slices.Contains(customCompileEnvTypes, typ)
}
type AppRepo interface {
Categories() []types.LV
All() api.Apps
Get(slug string) (*api.App, error)
UpdateExist(slug string) bool
Installed() ([]*App, error)
GetInstalled(slug string) (*App, error)
GetInstalledAll(query string, cond ...string) ([]*App, error)
GetHomeShow() ([]map[string]string, error)
ListHomeShow() ([]*App, error)
IsInstalled(query string, cond ...any) (bool, error)
Install(channel, slug string) error
UnInstall(slug string) error
Update(slug string) error
UpdateShow(slug string, show bool) error
UpdateOrder(slugs []string) error
DownloadCallback(slug string)
CheckPanelVersion() error
ResolveScript(item *api.App, matchChannel, action, execVersion string) (string, error)
ExecScript(script string) error
PreCheck(item *api.App, catalog api.Apps) error
GetCustom(slug string) (*AppCustom, error)
SaveCustom(slug string, custom *AppCustom) error
}
type AppUsecase struct {
repo AppRepo
cache CacheRepo
task TaskRepo
t *gotext.Locale
}
func NewAppUsecase(t *gotext.Locale, appRepo AppRepo, cacheRepo CacheRepo, taskRepo TaskRepo) (*AppUsecase, error) {
return &AppUsecase{
repo: appRepo,
cache: cacheRepo,
task: taskRepo,
t: t,
}, nil
}
func (uc *AppUsecase) Categories() []types.LV {
cached, err := uc.cache.Get(CacheKeyCategories)
if err != nil {
return nil
}
var categories api.Categories
if err = json.Unmarshal([]byte(cached), &categories); err != nil {
return nil
}
slices.SortFunc(categories, func(a, b *api.Category) int {
return a.Order - b.Order
})
return lo.Map(categories, func(item *api.Category, _ int) types.LV {
return types.LV{Label: item.Name, Value: item.Slug}
})
}
func (uc *AppUsecase) All() api.Apps {
cached, err := uc.cache.Get(CacheKeyApps)
if err != nil {
return nil
}
var apps api.Apps
if err = json.Unmarshal([]byte(cached), &apps); err != nil {
return nil
}
return apps
}
func (uc *AppUsecase) Get(slug string) (*api.App, error) {
for item := range slices.Values(uc.All()) {
if item.Slug == slug {
return item, nil
}
}
return nil, errors.New(uc.t.Get("app %s not found", slug))
}
func (uc *AppUsecase) UpdateExist(slug string) bool {
item, err := uc.Get(slug)
if err != nil {
return false
}
installed, err := uc.repo.GetInstalled(slug)
if err != nil {
return false
}
for channel := range slices.Values(item.Channels) {
if channel.Slug == installed.Channel {
if channel.Version != installed.Version {
return true
}
}
}
return false
}
func (uc *AppUsecase) Installed() ([]*App, error) {
return uc.repo.Installed()
}
func (uc *AppUsecase) GetInstalled(slug string) (*App, error) {
return uc.repo.GetInstalled(slug)
}
func (uc *AppUsecase) GetInstalledAll(query string, cond ...string) ([]*App, error) {
return uc.repo.GetInstalledAll(query, cond...)
}
func (uc *AppUsecase) GetHomeShow() ([]map[string]string, error) {
apps, err := uc.repo.ListHomeShow()
if err != nil {
return nil, err
}
filtered := make([]map[string]string, 0)
for item := range slices.Values(apps) {
loaded, err := uc.Get(item.Slug)
if err != nil {
continue
}
filtered = append(filtered, map[string]string{
"name": loaded.Name,
"description": loaded.Description,
"slug": loaded.Slug,
"version": item.Version,
})
}
return filtered, nil
}
func (uc *AppUsecase) IsInstalled(query string, cond ...any) (bool, error) {
return uc.repo.IsInstalled(query, cond...)
}
func (uc *AppUsecase) Install(channel, slug string) error {
item, err := uc.Get(slug)
if err != nil {
return err
}
// 恢复原编排:非法面板版本应在存在性检查前报错
if err = uc.repo.CheckPanelVersion(); err != nil {
return err
}
if installed, _ := uc.repo.IsInstalled(slug); installed {
return errors.New(uc.t.Get("app %s already installed", slug))
}
script, err := uc.repo.ResolveScript(item, channel, "install", "")
if err != nil {
return err
}
if err = uc.repo.PreCheck(item, uc.All()); err != nil {
return err
}
uc.repo.DownloadCallback(slug)
if app.IsCli {
return uc.repo.ExecScript(script)
}
task := new(Task)
task.Key = "app:" + slug
task.Name = uc.t.Get("Install app %s", item.Name)
task.Status = TaskStatusWaiting
task.Shell = script
return uc.task.Push(task)
}
func (uc *AppUsecase) UnInstall(slug string) error {
item, err := uc.Get(slug)
if err != nil {
return err
}
// 恢复原编排:非法面板版本应在存在性检查前报错
if err = uc.repo.CheckPanelVersion(); err != nil {
return err
}
if installed, _ := uc.repo.IsInstalled(slug); !installed {
return errors.New(uc.t.Get("app %s not installed", item.Name))
}
installed, err := uc.repo.GetInstalled(slug)
if err != nil {
return err
}
script, err := uc.repo.ResolveScript(item, installed.Channel, "uninstall", installed.Version)
if err != nil {
return err
}
if err = uc.repo.PreCheck(item, uc.All()); err != nil {
return err
}
if app.IsCli {
return uc.repo.ExecScript(script)
}
task := new(Task)
task.Key = "app:" + slug
task.Name = uc.t.Get("Uninstall app %s", item.Name)
task.Status = TaskStatusWaiting
task.Shell = script
return uc.task.Push(task)
}
func (uc *AppUsecase) Update(slug string) error {
item, err := uc.Get(slug)
if err != nil {
return err
}
// 恢复原编排:非法面板版本应在存在性检查前报错
if err = uc.repo.CheckPanelVersion(); err != nil {
return err
}
if installed, _ := uc.repo.IsInstalled(slug); !installed {
return errors.New(uc.t.Get("app %s not installed", item.Name))
}
installed, err := uc.repo.GetInstalled(slug)
if err != nil {
return err
}
script, err := uc.repo.ResolveScript(item, installed.Channel, "update", "")
if err != nil {
return err
}
if err = uc.repo.PreCheck(item, uc.All()); err != nil {
return err
}
uc.repo.DownloadCallback(slug)
if app.IsCli {
return uc.repo.ExecScript(script)
}
task := new(Task)
task.Key = "app:" + slug
task.Name = uc.t.Get("Update app %s", item.Name)
task.Status = TaskStatusWaiting
task.Shell = script
return uc.task.Push(task)
}
func (uc *AppUsecase) UpdateShow(slug string, show bool) error {
return uc.repo.UpdateShow(slug, show)
}
func (uc *AppUsecase) GetCustom(slug string) (*AppCustom, error) {
return uc.repo.GetCustom(slug)
}
func (uc *AppUsecase) SaveCustom(slug string, custom *AppCustom) error {
return uc.repo.SaveCustom(slug, custom)
}
func (uc *AppUsecase) UpdateOrder(slugs []string) error {
return uc.repo.UpdateOrder(slugs)
}
+114 -9
View File
@@ -2,6 +2,10 @@ package biz
import (
"context"
"log/slog"
"time"
"github.com/leonelquinteros/gotext"
"github.com/acepanel/panel/v3/pkg/types"
)
@@ -9,25 +13,126 @@ import (
type BackupType string
const (
BackupTypePath BackupType = "path"
BackupTypeWebsite BackupType = "website"
BackupTypeMySQL BackupType = "mysql"
BackupTypePostgres BackupType = "postgresql"
BackupTypeRedis BackupType = "redis"
BackupTypePanel BackupType = "panel"
BackupTypePath BackupType = "path"
BackupTypeWebsite BackupType = "website"
BackupTypeMySQL BackupType = "mysql"
BackupTypePostgres BackupType = "postgresql"
BackupTypeClickHouse BackupType = "clickhouse"
BackupTypeRedis BackupType = "redis"
BackupTypeValkey BackupType = "valkey"
BackupTypePanel BackupType = "panel"
)
type BackupRepo interface {
List(typ BackupType) ([]*types.BackupFile, error)
GetStorage(id uint) (*BackupStorage, error)
Create(ctx context.Context, typ BackupType, target string, account uint) error
CreatePanel() error
Delete(ctx context.Context, typ BackupType, name string) error
Restore(ctx context.Context, typ BackupType, backup, target string) error
Delete(typ BackupType, name string) error
Restore(typ BackupType, backup, target string) error
ClearExpired(path, prefix string, save uint) error
ClearStorageExpired(account uint, typ BackupType, prefix string, save uint) error
CutoffLog(path, target string) (string, error)
CutoffUpload(account uint, typ BackupType, name string, files []string) error
GetDefaultPath(typ BackupType) string
FixPanel() error
UpdatePanel(version, url, checksum string) error
UpdatePanel(version, url, checksum string, progress func(string)) error
}
type BackupUsecase struct {
repo BackupRepo
log *slog.Logger
notify *NotifyUsecase
t *gotext.Locale
}
func NewBackupUsecase(notifyUsecase *NotifyUsecase, t *gotext.Locale, log *slog.Logger, backupRepo BackupRepo) (*BackupUsecase, error) {
return &BackupUsecase{
repo: backupRepo,
log: log,
notify: notifyUsecase,
t: t,
}, nil
}
func (uc *BackupUsecase) List(typ BackupType) ([]*types.BackupFile, error) {
return uc.repo.List(typ)
}
func (uc *BackupUsecase) Create(ctx context.Context, typ BackupType, target string, account uint) error {
err := uc.repo.Create(ctx, typ, target, account)
if err == nil {
return nil
}
// 定时备份由 CLI 执行,命令返回即退出,异步通知来不及发出,必须同步发送
if sendErr := uc.notify.SendEventSync(ctx, NotifyEventBackup, uc.t.Get("[AcePanel] Backup Failed"), NotifyBody(uc.t.Get("backup task failed"), [][2]string{
{uc.t.Get("Type"), string(typ)},
{uc.t.Get("Target"), target},
{uc.t.Get("Error"), err.Error()},
{uc.t.Get("Time"), time.Now().Format(time.DateTime)},
})); sendErr != nil {
uc.log.Warn("failed to send backup failure notification", slog.Any("err", sendErr))
}
return err
}
func (uc *BackupUsecase) CreatePanel() error {
return uc.repo.CreatePanel()
}
func (uc *BackupUsecase) Delete(ctx context.Context, typ BackupType, name string) error {
if err := uc.repo.Delete(typ, name); err != nil {
return err
}
// 记录日志
uc.log.Info("backup deleted", slog.String("type", OperationTypeBackup), slog.Uint64("operator_id", operatorID(ctx)), slog.String("backup_type", string(typ)), slog.String("name", name))
return nil
}
func (uc *BackupUsecase) Restore(ctx context.Context, typ BackupType, backup, target string) error {
if err := uc.repo.Restore(typ, backup, target); err != nil {
return err
}
// 记录日志
uc.log.Info("backup restored",
slog.String("type", OperationTypeBackup),
slog.Uint64("operator_id", operatorID(ctx)),
slog.String("backup_type", string(typ)),
slog.String("target", target),
)
return nil
}
func (uc *BackupUsecase) ClearExpired(path, prefix string, save uint) error {
return uc.repo.ClearExpired(path, prefix, save)
}
func (uc *BackupUsecase) ClearStorageExpired(account uint, typ BackupType, prefix string, save uint) error {
return uc.repo.ClearStorageExpired(account, typ, prefix, save)
}
func (uc *BackupUsecase) CutoffLog(path, target string) (string, error) {
return uc.repo.CutoffLog(path, target)
}
func (uc *BackupUsecase) CutoffUpload(account uint, typ BackupType, name string, files []string) error {
return uc.repo.CutoffUpload(account, typ, name, files)
}
func (uc *BackupUsecase) GetDefaultPath(typ BackupType) string {
return uc.repo.GetDefaultPath(typ)
}
func (uc *BackupUsecase) FixPanel() error {
return uc.repo.FixPanel()
}
func (uc *BackupUsecase) UpdatePanel(version, url, checksum string, progress func(string)) error {
return uc.repo.UpdatePanel(version, url, checksum, progress)
}
+109 -6
View File
@@ -2,13 +2,15 @@ package biz
import (
"context"
"log/slog"
"time"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/utils/crypt"
"gorm.io/gorm"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/http/request"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/types"
)
@@ -129,9 +131,110 @@ func (r *BackupStorage) AfterFind(tx *gorm.DB) error {
}
type BackupAccountRepo interface {
List(page, limit uint) ([]*BackupStorage, int64, error)
Get(id uint) (*BackupStorage, error)
Create(ctx context.Context, req *request.BackupStorageCreate) (*BackupStorage, error)
Update(ctx context.Context, req *request.BackupStorageUpdate) error
Delete(ctx context.Context, id uint) error
ListPaged(page, limit uint) ([]*BackupStorage, int64, error)
GetByID(id uint) (*BackupStorage, error)
Create(account *BackupStorage) error
Update(account *BackupStorage) error
Delete(id uint) error
}
type BackupAccountUsecase struct {
repo BackupAccountRepo
setting SettingRepo
t *gotext.Locale
log *slog.Logger
}
func NewBackupAccountUsecase(t *gotext.Locale, log *slog.Logger, backupAccountRepo BackupAccountRepo, settingRepo SettingRepo) (*BackupAccountUsecase, error) {
return &BackupAccountUsecase{
repo: backupAccountRepo,
setting: settingRepo,
t: t,
log: log,
}, nil
}
func (uc *BackupAccountUsecase) List(page, limit uint) ([]*BackupStorage, int64, error) {
// 本地存储
localStorage, err := uc.Get(0)
if err != nil {
return nil, 0, err
}
dbAccounts, total, err := uc.repo.ListPaged(page, limit)
if err != nil {
return nil, 0, err
}
accounts := make([]*BackupStorage, 0, len(dbAccounts)+1)
if page == 1 {
accounts = append(accounts, localStorage)
}
accounts = append(accounts, dbAccounts...)
return accounts, total + 1, nil
}
func (uc *BackupAccountUsecase) Get(id uint) (*BackupStorage, error) {
if id == 0 {
path, err := uc.setting.Get(SettingKeyBackupPath)
if err != nil {
return nil, err
}
return &BackupStorage{
ID: 0,
Type: BackupStorageTypeLocal,
Name: uc.t.Get("Local Storage"),
Info: types.BackupStorageInfo{
Path: path,
},
}, nil
}
return uc.repo.GetByID(id)
}
func (uc *BackupAccountUsecase) Create(ctx context.Context, req *request.BackupStorageCreate) (*BackupStorage, error) {
account := &BackupStorage{
Type: BackupStorageType(req.Type),
Name: req.Name,
Info: req.Info,
}
if err := uc.repo.Create(account); err != nil {
return nil, err
}
uc.log.Info("backup storage created", slog.String("type", OperationTypeBackup), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(account.ID)), slog.String("account_type", req.Type), slog.String("name", req.Name))
return account, nil
}
func (uc *BackupAccountUsecase) Update(ctx context.Context, req *request.BackupStorageUpdate) error {
account, err := uc.Get(req.ID)
if err != nil {
return err
}
account.Type = BackupStorageType(req.Type)
account.Name = req.Name
account.Info = req.Info
if err = uc.repo.Update(account); err != nil {
return err
}
uc.log.Info("backup storage updated", slog.String("type", OperationTypeBackup), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(req.ID)), slog.String("account_type", req.Type))
return nil
}
func (uc *BackupAccountUsecase) Delete(ctx context.Context, id uint) error {
if err := uc.repo.Delete(id); err != nil {
return err
}
uc.log.Info("backup storage deleted", slog.String("type", OperationTypeBackup), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(id)))
return nil
}
+18
View File
@@ -0,0 +1,18 @@
package biz
import "github.com/google/wire"
var ProviderSet = wire.NewSet(
NewAlertUsecase, NewAppUsecase, NewBackupUsecase, NewBackupAccountUsecase,
NewCacheUsecase, NewCertUsecase, NewCertAccountUsecase,
NewCertDNSUsecase, NewContainerUsecase, NewContainerComposeUsecase,
NewContainerImageUsecase, NewContainerNetworkUsecase, NewContainerVolumeUsecase,
NewCronUsecase, NewDatabaseUsecase, NewDatabaseRedisUsecase,
NewDatabaseElasticsearchUsecase, NewDatabaseServerUsecase, NewDatabaseUserUsecase,
NewEnvironmentUsecase, NewFileShareUsecase, NewLogUsecase, NewMonitorUsecase,
NewNotifyUsecase, NewProjectUsecase, NewSafeUsecase, NewScanEventUsecase,
NewSettingUsecase, NewSSHUsecase, NewTamperUsecase, NewTaskUsecase,
NewTemplateUsecase, NewUserUsecase, NewUserPasskeyUsecase,
NewUserTokenUsecase, NewWebHookUsecase, NewWebsiteUsecase,
NewWebsiteStatUsecase, NewWafUsecase,
)
+89 -5
View File
@@ -1,6 +1,13 @@
package biz
import "time"
import (
"encoding/json"
"slices"
"time"
"github.com/acepanel/panel/v3/pkg/api"
"github.com/acepanel/panel/v3/pkg/apploader"
)
type CacheKey string
@@ -21,8 +28,85 @@ type Cache struct {
type CacheRepo interface {
Get(key CacheKey, defaultValue ...string) (string, error)
Set(key CacheKey, value string) error
UpdateCategories() error
UpdateApps() error
UpdateEnvironments() error
UpdateTemplates() error
FetchCategories() (*api.Categories, error)
FetchApps() (*api.Apps, error)
FetchEnvironments() (*api.Environments, error)
FetchTemplates() (*api.Templates, error)
}
type CacheUsecase struct {
repo CacheRepo
}
func NewCacheUsecase(repo CacheRepo) *CacheUsecase {
return &CacheUsecase{repo: repo}
}
func (uc *CacheUsecase) Get(key CacheKey, defaultValue ...string) (string, error) {
return uc.repo.Get(key, defaultValue...)
}
func (uc *CacheUsecase) Set(key CacheKey, value string) error {
return uc.repo.Set(key, value)
}
func (uc *CacheUsecase) UpdateCategories() error {
categories, err := uc.repo.FetchCategories()
if err != nil {
return err
}
encoded, err := json.Marshal(categories)
if err != nil {
return err
}
return uc.repo.Set(CacheKeyCategories, string(encoded))
}
func (uc *CacheUsecase) UpdateApps() error {
remote, err := uc.repo.FetchApps()
if err != nil {
return err
}
// 去除本地不存在的应用
*remote = slices.Clip(slices.DeleteFunc(*remote, func(item *api.App) bool {
return !slices.Contains(apploader.SupportedSlugs(), item.Slug)
}))
encoded, err := json.Marshal(remote)
if err != nil {
return err
}
return uc.repo.Set(CacheKeyApps, string(encoded))
}
func (uc *CacheUsecase) UpdateEnvironments() error {
environments, err := uc.repo.FetchEnvironments()
if err != nil {
return err
}
encoded, err := json.Marshal(environments)
if err != nil {
return err
}
return uc.repo.Set(CacheKeyEnvironment, string(encoded))
}
func (uc *CacheUsecase) UpdateTemplates() error {
templates, err := uc.repo.FetchTemplates()
if err != nil {
return err
}
encoded, err := json.Marshal(templates)
if err != nil {
return err
}
return uc.repo.Set(CacheKeyTemplates, string(encoded))
}
+386 -13
View File
@@ -2,12 +2,21 @@ package biz
import (
"context"
"errors"
"fmt"
"log/slog"
"path/filepath"
"slices"
"strings"
"time"
"github.com/leonelquinteros/gotext"
mholtacme "github.com/mholt/acmez/v3/acme"
"github.com/acepanel/panel/v3/internal/http/request"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/acme"
pkgcert "github.com/acepanel/panel/v3/pkg/cert"
"github.com/acepanel/panel/v3/pkg/types"
)
@@ -37,16 +46,380 @@ type CertRepo interface {
List(page, limit uint) ([]*types.CertList, int64, error)
Get(id uint) (*Cert, error)
GetByWebsite(WebsiteID uint) (*Cert, error)
Upload(ctx context.Context, req *request.CertUpload) (*Cert, error)
Create(ctx context.Context, req *request.CertCreate) (*Cert, error)
Update(ctx context.Context, req *request.CertUpdate) error
Delete(ctx context.Context, id uint) error
ObtainAuto(id uint) (*acme.Certificate, error)
ObtainAutoWithProgressCallback(ctx context.Context, id uint, progressCallback func(string)) (*acme.Certificate, error)
ObtainPanel(account *CertAccount, ips []string) ([]byte, []byte, error)
ObtainSelfSigned(id uint) error
Renew(id uint) (*acme.Certificate, error)
RenewWithProgressCallback(ctx context.Context, id uint, progressCallback func(string)) (*acme.Certificate, error)
RefreshRenewalInfo(id uint) (mholtacme.RenewalInfo, error)
Deploy(ID, WebsiteID uint, enableHTTPS bool) error
Create(req *request.CertCreate) (*Cert, error)
CreateUploaded(cert *Cert) error
Update(req *request.CertUpdate) error
Delete(id uint) error
Save(cert *Cert) error
GetClient(cert *Cert) (*acme.Client, error)
GenerateSelfSigned(domains []string) ([]byte, []byte, error)
RunScript(cert *Cert) error
ObtainPanel(account *CertAccount, ips []string, webServer string) ([]byte, []byte, error)
LoadWebsite(WebsiteID uint) (*Website, error)
WriteCertFiles(cert *Cert, certPath, keyPath string) error
EnableWebsiteSSL(website *Website, certPath, keyPath, webServer string, tlsVersions []string) error
ReloadWebserver(webServer string) error
}
type CertUsecase struct {
repo CertRepo
setting SettingRepo
t *gotext.Locale
log *slog.Logger
}
func NewCertUsecase(t *gotext.Locale, log *slog.Logger, certRepo CertRepo, settingRepo SettingRepo) (*CertUsecase, error) {
return &CertUsecase{
repo: certRepo,
setting: settingRepo,
t: t,
log: log,
}, nil
}
func (uc *CertUsecase) List(page, limit uint) ([]*types.CertList, int64, error) {
return uc.repo.List(page, limit)
}
func (uc *CertUsecase) Get(id uint) (*Cert, error) {
return uc.repo.Get(id)
}
func (uc *CertUsecase) GetByWebsite(WebsiteID uint) (*Cert, error) {
return uc.repo.GetByWebsite(WebsiteID)
}
func (uc *CertUsecase) Upload(ctx context.Context, req *request.CertUpload) (*Cert, error) {
info, err := pkgcert.ParseCert([]byte(req.Cert))
if err != nil {
return nil, errors.New(uc.t.Get("failed to parse certificate: %v", err))
}
if _, err = pkgcert.ParseKey([]byte(req.Key)); err != nil {
return nil, errors.New(uc.t.Get("failed to parse private key: %v", err))
}
// 合并 DNSNames 和 IPAddresses
domains := info.DNSNames
for _, ip := range info.IPAddresses {
domains = append(domains, ip.String())
}
cert := &Cert{
Type: "upload",
Domains: domains,
Cert: req.Cert,
Key: req.Key,
}
if err = uc.repo.CreateUploaded(cert); err != nil {
return nil, err
}
// 记录日志
uc.log.Info("cert uploaded", slog.String("type", OperationTypeCert), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(cert.ID)))
return cert, nil
}
func (uc *CertUsecase) Create(ctx context.Context, req *request.CertCreate) (*Cert, error) {
cert, err := uc.repo.Create(req)
if err != nil {
return nil, err
}
// 记录日志
uc.log.Info("cert created", slog.String("type", OperationTypeCert), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(cert.ID)), slog.String("cert_type", req.Type))
return cert, nil
}
func (uc *CertUsecase) Update(ctx context.Context, req *request.CertUpdate) error {
info, err := pkgcert.ParseCert([]byte(req.Cert))
if err == nil && req.Type == "upload" {
// 合并 DNSNames 和 IPAddresses
req.Domains = info.DNSNames
for _, ip := range info.IPAddresses {
req.Domains = append(req.Domains, ip.String())
}
}
if req.Type == "upload" && req.AutoRenewal {
return errors.New(uc.t.Get("upload certificate cannot be set to auto renewal"))
}
if err = uc.repo.Update(req); err != nil {
return err
}
// 记录日志
uc.log.Info("cert updated", slog.String("type", OperationTypeCert), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(req.ID)))
return nil
}
func (uc *CertUsecase) Delete(ctx context.Context, id uint) error {
if err := uc.repo.Delete(id); err != nil {
return err
}
// 记录日志
uc.log.Info("cert deleted", slog.String("type", OperationTypeCert), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(id)))
return nil
}
func (uc *CertUsecase) ObtainAuto(id uint) (*acme.Certificate, error) {
return uc.ObtainAutoWithProgressCallback(context.Background(), id, nil)
}
func (uc *CertUsecase) ObtainAutoWithProgressCallback(ctx context.Context, id uint, progressCallback func(string)) (*acme.Certificate, error) {
report := func(msg string) {
if progressCallback != nil {
progressCallback(msg)
}
}
report(uc.t.Get("initializing ACME client"))
cert, err := uc.repo.Get(id)
if err != nil {
return nil, err
}
client, err := uc.repo.GetClient(cert)
if err != nil {
return nil, err
}
webServer, _ := uc.setting.Get(SettingKeyWebserver)
if cert.DNS != nil {
client.UseDns(cert.DNS.Type, cert.DNS.Data, acme.DnsOption{
Alias: cert.Alias,
DnsServer: cert.DNS.Data.DnsServer,
SkipVerify: cert.DNS.Data.SkipVerify,
ProgressCallback: progressCallback,
})
} else {
if cert.Website == nil {
return nil, errors.New(uc.t.Get("this certificate is not associated with a website and cannot be obtained. You can try to obtain it manually"))
}
hasWildcard := slices.ContainsFunc(cert.Domains, func(d string) bool {
return strings.Contains(d, "*")
})
if hasWildcard {
return nil, errors.New(uc.t.Get("wildcard domains cannot use HTTP verification"))
}
conf := fmt.Sprintf("%s/sites/%s/config/site/001-acme.conf", app.Root, cert.Website.Name)
client.UseHTTP(conf, webServer)
}
report(uc.t.Get("issuing certificate, domains: %s", strings.Join(cert.Domains, ", ")))
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
ssl, err := client.ObtainCertificate(ctx, cert.Domains, acme.KeyType(cert.Type))
if err != nil {
return nil, err
}
report(uc.t.Get("obtaining and saving certificate"))
cert.RenewalInfo = *ssl.RenewalInfo
cert.CertURL = ssl.URL
cert.Cert = string(ssl.ChainPEM)
cert.Key = string(ssl.PrivateKey)
if err = uc.repo.Save(cert); err != nil {
return nil, err
}
if cert.Website != nil {
report(uc.t.Get("deploying certificate to website"))
return &ssl, uc.Deploy(cert.ID, cert.WebsiteID, false)
}
if err = uc.repo.RunScript(cert); err != nil {
return nil, err
}
return &ssl, nil
}
func (uc *CertUsecase) ObtainPanel(account *CertAccount, ips []string) ([]byte, []byte, error) {
webServer, _ := uc.setting.Get(SettingKeyWebserver)
return uc.repo.ObtainPanel(account, ips, webServer)
}
func (uc *CertUsecase) ObtainSelfSigned(id uint) error {
cert, err := uc.repo.Get(id)
if err != nil {
return err
}
crt, key, err := uc.repo.GenerateSelfSigned(cert.Domains)
if err != nil {
return err
}
cert.Cert = string(crt)
cert.Key = string(key)
if err = uc.repo.Save(cert); err != nil {
return err
}
if cert.Website != nil {
return uc.Deploy(cert.ID, cert.WebsiteID, false)
}
if err = uc.repo.RunScript(cert); err != nil {
return err
}
return nil
}
func (uc *CertUsecase) Renew(id uint) (*acme.Certificate, error) {
return uc.RenewWithProgressCallback(context.Background(), id, nil)
}
func (uc *CertUsecase) RenewWithProgressCallback(ctx context.Context, id uint, progressCallback func(string)) (*acme.Certificate, error) {
report := func(msg string) {
if progressCallback != nil {
progressCallback(msg)
}
}
report(uc.t.Get("preparing renewal"))
cert, err := uc.repo.Get(id)
if err != nil {
return nil, err
}
client, err := uc.repo.GetClient(cert)
if err != nil {
return nil, err
}
if cert.CertURL == "" {
return nil, errors.New(uc.t.Get("this certificate has not been obtained successfully and cannot be renewed"))
}
webServer, _ := uc.setting.Get(SettingKeyWebserver)
if cert.DNS != nil {
client.UseDns(cert.DNS.Type, cert.DNS.Data, acme.DnsOption{
Alias: cert.Alias,
DnsServer: cert.DNS.Data.DnsServer,
SkipVerify: cert.DNS.Data.SkipVerify,
ProgressCallback: progressCallback,
})
} else {
if cert.Website == nil {
return nil, errors.New(uc.t.Get("this certificate is not associated with a website and cannot be obtained. You can try to obtain it manually"))
} else {
for _, domain := range cert.Domains {
if strings.Contains(domain, "*") {
return nil, errors.New(uc.t.Get("wildcard domains cannot use HTTP verification"))
}
}
conf := fmt.Sprintf("%s/sites/%s/config/site/001-acme.conf", app.Root, cert.Website.Name)
client.UseHTTP(conf, webServer)
}
}
report(uc.t.Get("renewing certificate, domains: %s", strings.Join(cert.Domains, ", ")))
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
ssl, err := client.RenewCertificate(ctx, cert.CertURL, cert.Domains, acme.KeyType(cert.Type))
if err != nil {
// 续签失败,尝试重签
report(uc.t.Get("renewal failed, attempting re-issuance"))
ssl, err = client.ObtainCertificate(ctx, cert.Domains, acme.KeyType(cert.Type))
if err != nil {
return nil, err
}
}
report(uc.t.Get("obtaining and saving certificate"))
cert.RenewalInfo = *ssl.RenewalInfo
cert.CertURL = ssl.URL
cert.Cert = string(ssl.ChainPEM)
cert.Key = string(ssl.PrivateKey)
if err = uc.repo.Save(cert); err != nil {
return nil, err
}
if cert.Website != nil {
report(uc.t.Get("deploying certificate to website"))
return &ssl, uc.Deploy(cert.ID, cert.WebsiteID, false)
}
return &ssl, nil
}
func (uc *CertUsecase) RefreshRenewalInfo(id uint) (mholtacme.RenewalInfo, error) {
cert, err := uc.repo.Get(id)
if err != nil {
return mholtacme.RenewalInfo{}, err
}
client, err := uc.repo.GetClient(cert)
if err != nil {
return mholtacme.RenewalInfo{}, err
}
crt, err := pkgcert.ParseCert([]byte(cert.Cert))
if err != nil {
return mholtacme.RenewalInfo{}, err
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer cancel()
renewInfo, err := client.GetRenewalInfo(ctx, crt)
if err != nil {
return mholtacme.RenewalInfo{}, err
}
cert.RenewalInfo = renewInfo
if err = uc.repo.Save(cert); err != nil {
return mholtacme.RenewalInfo{}, err
}
return renewInfo, nil
}
func (uc *CertUsecase) Deploy(ID, WebsiteID uint, enableHTTPS bool) error {
cert, err := uc.repo.Get(ID)
if err != nil {
return err
}
if cert.Cert == "" || cert.Key == "" {
return errors.New(uc.t.Get("this certificate has not been obtained successfully and cannot be deployed"))
}
website, err := uc.repo.LoadWebsite(WebsiteID)
if err != nil {
return err
}
configDir := filepath.Join(app.Root, "sites", website.Name, "config")
certPath := filepath.Join(configDir, "fullchain.pem")
keyPath := filepath.Join(configDir, "private.key")
if err = uc.repo.WriteCertFiles(cert, certPath, keyPath); err != nil {
return err
}
// 开启 HTTPS
if enableHTTPS && !website.SSL {
// 原 getVhost 首步读 webserver 设置并传播错误,保持该语义
webServer, err := uc.setting.Get(SettingKeyWebserver)
if err != nil {
return err
}
tlsVersions, _ := uc.setting.GetSlice(SettingKeyWebsiteTLSVersions)
if err = uc.repo.EnableWebsiteSSL(website, certPath, keyPath, webServer, tlsVersions); err != nil {
return err
}
}
webServer, _ := uc.setting.Get(SettingKeyWebserver)
return uc.repo.ReloadWebserver(webServer)
}
func (uc *CertUsecase) Save(cert *Cert) error {
return uc.repo.Save(cert)
}
+190 -5
View File
@@ -2,9 +2,15 @@ package biz
import (
"context"
"errors"
"log/slog"
"time"
"github.com/acepanel/panel/v3/internal/http/request"
"github.com/leonelquinteros/gotext"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/acme"
"github.com/acepanel/panel/v3/pkg/cert"
)
type CertAccount struct {
@@ -23,9 +29,188 @@ type CertAccount struct {
type CertAccountRepo interface {
List(page, limit uint) ([]*CertAccount, int64, error)
GetDefault(userID uint) (*CertAccount, error)
Get(id uint) (*CertAccount, error)
Create(ctx context.Context, req *request.CertAccountCreate) (*CertAccount, error)
Update(ctx context.Context, req *request.CertAccountUpdate) error
Delete(ctx context.Context, id uint) error
GetByCAEmail(ca, email string) (*CertAccount, error)
Create(account *CertAccount) error
Save(account *CertAccount) error
Delete(id uint) error
GetGoogleEAB() (*acme.EAB, error)
GetZeroSSLEAB(email string) (*acme.EAB, error)
RegisterAccount(email, ca string, eab *acme.EAB, keyType acme.KeyType) (*acme.Client, error)
}
type CertAccountUsecase struct {
repo CertAccountRepo
userRepo UserRepo
t *gotext.Locale
log *slog.Logger
}
func NewCertAccountUsecase(t *gotext.Locale, log *slog.Logger, certAccountRepo CertAccountRepo, userRepo UserRepo) (*CertAccountUsecase, error) {
return &CertAccountUsecase{
repo: certAccountRepo,
userRepo: userRepo,
t: t,
log: log,
}, nil
}
func (uc *CertAccountUsecase) List(page, limit uint) ([]*CertAccount, int64, error) {
return uc.repo.List(page, limit)
}
func (uc *CertAccountUsecase) GetDefault(userID uint) (*CertAccount, error) {
user, err := uc.userRepo.Get(userID)
if err != nil {
return nil, err
}
account, err := uc.repo.GetByCAEmail("letsencrypt", user.Email)
if err == nil {
return account, nil
}
req := &request.CertAccountCreate{
CA: "letsencrypt",
Email: user.Email,
KeyType: string(acme.KeyEC256),
}
return uc.Create(context.Background(), req)
}
func (uc *CertAccountUsecase) Get(id uint) (*CertAccount, error) {
return uc.repo.Get(id)
}
func (uc *CertAccountUsecase) Create(ctx context.Context, req *request.CertAccountCreate) (*CertAccount, error) {
account := new(CertAccount)
account.CA = req.CA
account.Email = req.Email
account.Kid = req.Kid
account.HmacEncoded = req.HmacEncoded
account.KeyType = req.KeyType
var err error
var client *acme.Client
switch account.CA {
case "googlecn":
eab, eabErr := uc.repo.GetGoogleEAB()
if eabErr != nil {
return nil, eabErr
}
account.Kid = eab.KeyID
account.HmacEncoded = eab.MACKey
client, err = uc.repo.RegisterAccount(account.Email, acme.CAGoogleCN, eab, acme.KeyType(account.KeyType))
case "google":
client, err = uc.repo.RegisterAccount(account.Email, acme.CAGoogle, &acme.EAB{KeyID: account.Kid, MACKey: account.HmacEncoded}, acme.KeyType(account.KeyType))
case "letsencrypt":
client, err = uc.repo.RegisterAccount(account.Email, acme.CALetsEncrypt, nil, acme.KeyType(account.KeyType))
case "litessl":
client, err = uc.repo.RegisterAccount(account.Email, acme.CALiteSSL, &acme.EAB{KeyID: account.Kid, MACKey: account.HmacEncoded}, acme.KeyType(account.KeyType))
case "zerossl":
eab, eabErr := uc.repo.GetZeroSSLEAB(account.Email)
if eabErr != nil {
return nil, eabErr
}
account.Kid = eab.KeyID
account.HmacEncoded = eab.MACKey
client, err = uc.repo.RegisterAccount(account.Email, acme.CAZeroSSL, eab, acme.KeyType(account.KeyType))
case "sslcom":
client, err = uc.repo.RegisterAccount(account.Email, acme.CASSLcom, &acme.EAB{KeyID: account.Kid, MACKey: account.HmacEncoded}, acme.KeyType(account.KeyType))
default:
return nil, errors.New(uc.t.Get("unsupported CA"))
}
if err != nil {
return nil, errors.New(uc.t.Get("failed to register account: %v", err))
}
privateKey, err := cert.EncodeKey(client.Account.PrivateKey)
if err != nil {
return nil, errors.New(uc.t.Get("failed to get private key"))
}
account.PrivateKey = string(privateKey)
if err = uc.repo.Create(account); err != nil {
return nil, err
}
// 记录日志
uc.log.Info("cert account created", slog.String("type", OperationTypeCert), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(account.ID)), slog.String("ca", req.CA), slog.String("email", req.Email))
return account, nil
}
func (uc *CertAccountUsecase) Update(ctx context.Context, req *request.CertAccountUpdate) error {
account, err := uc.repo.Get(req.ID)
if err != nil {
return err
}
account.CA = req.CA
account.Email = req.Email
account.Kid = req.Kid
account.HmacEncoded = req.HmacEncoded
account.KeyType = req.KeyType
var client *acme.Client
switch account.CA {
case "googlecn":
eab, eabErr := uc.repo.GetGoogleEAB()
if eabErr != nil {
return eabErr
}
account.Kid = eab.KeyID
account.HmacEncoded = eab.MACKey
client, err = uc.repo.RegisterAccount(account.Email, acme.CAGoogleCN, eab, acme.KeyType(account.KeyType))
case "google":
client, err = uc.repo.RegisterAccount(account.Email, acme.CAGoogle, &acme.EAB{KeyID: account.Kid, MACKey: account.HmacEncoded}, acme.KeyType(account.KeyType))
case "letsencrypt":
client, err = uc.repo.RegisterAccount(account.Email, acme.CALetsEncrypt, nil, acme.KeyType(account.KeyType))
case "litessl":
client, err = uc.repo.RegisterAccount(account.Email, acme.CALiteSSL, &acme.EAB{KeyID: account.Kid, MACKey: account.HmacEncoded}, acme.KeyType(account.KeyType))
case "zerossl":
eab, eabErr := uc.repo.GetZeroSSLEAB(account.Email)
if eabErr != nil {
return eabErr
}
account.Kid = eab.KeyID
account.HmacEncoded = eab.MACKey
client, err = uc.repo.RegisterAccount(account.Email, acme.CAZeroSSL, eab, acme.KeyType(account.KeyType))
case "sslcom":
client, err = uc.repo.RegisterAccount(account.Email, acme.CASSLcom, &acme.EAB{KeyID: account.Kid, MACKey: account.HmacEncoded}, acme.KeyType(account.KeyType))
default:
return errors.New(uc.t.Get("unsupported CA"))
}
if err != nil {
return errors.New(uc.t.Get("failed to register account: %v", err))
}
privateKey, err := cert.EncodeKey(client.Account.PrivateKey)
if err != nil {
return errors.New(uc.t.Get("failed to get private key: %v", err))
}
account.PrivateKey = string(privateKey)
if err = uc.repo.Save(account); err != nil {
return err
}
// 记录日志
uc.log.Info("cert account updated", slog.String("type", OperationTypeCert), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(req.ID)), slog.String("ca", req.CA))
return nil
}
func (uc *CertAccountUsecase) Delete(ctx context.Context, id uint) error {
if err := uc.repo.Delete(id); err != nil {
return err
}
// 记录日志
uc.log.Info("cert account deleted", slog.String("type", OperationTypeCert), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(id)))
return nil
}
+61 -4
View File
@@ -2,9 +2,10 @@ package biz
import (
"context"
"log/slog"
"time"
"github.com/acepanel/panel/v3/internal/http/request"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/acme"
)
@@ -22,7 +23,63 @@ type CertDNS struct {
type CertDNSRepo interface {
List(page, limit uint) ([]*CertDNS, int64, error)
Get(id uint) (*CertDNS, error)
Create(ctx context.Context, req *request.CertDNSCreate) (*CertDNS, error)
Update(ctx context.Context, req *request.CertDNSUpdate) error
Delete(ctx context.Context, id uint) error
Create(req *request.CertDNSCreate) (*CertDNS, error)
Update(req *request.CertDNSUpdate) error
Delete(id uint) error
}
type CertDNSUsecase struct {
repo CertDNSRepo
log *slog.Logger
}
func NewCertDNSUsecase(repo CertDNSRepo, log *slog.Logger) *CertDNSUsecase {
return &CertDNSUsecase{repo: repo, log: log}
}
func (uc *CertDNSUsecase) List(page, limit uint) ([]*CertDNS, int64, error) {
return uc.repo.List(page, limit)
}
func (uc *CertDNSUsecase) Get(id uint) (*CertDNS, error) {
return uc.repo.Get(id)
}
func (uc *CertDNSUsecase) Create(ctx context.Context, req *request.CertDNSCreate) (*CertDNS, error) {
certDNS, err := uc.repo.Create(req)
if err != nil {
return nil, err
}
// 记录日志
uc.log.Info("cert dns created", slog.String("type", OperationTypeCert), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(certDNS.ID)), slog.String("name", req.Name))
return certDNS, nil
}
func (uc *CertDNSUsecase) Update(ctx context.Context, req *request.CertDNSUpdate) error {
if err := uc.repo.Update(req); err != nil {
return err
}
// 记录日志
uc.log.Info("cert dns updated", slog.String("type", OperationTypeCert), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(req.ID)), slog.String("name", req.Name))
return nil
}
func (uc *CertDNSUsecase) Delete(ctx context.Context, id uint) error {
certDNS, err := uc.repo.Get(id)
if err != nil {
return err
}
if err = uc.repo.Delete(id); err != nil {
return err
}
// 记录日志
uc.log.Info("cert dns deleted", slog.String("type", OperationTypeCert), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(id)), slog.String("name", certDNS.Name))
return nil
}
+114 -14
View File
@@ -1,22 +1,122 @@
package biz
import (
"github.com/acepanel/panel/v3/internal/http/request"
"slices"
"strings"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/types"
)
type ContainerRepo interface {
ListAll() ([]types.Container, error)
ListByName(name string) ([]types.Container, error)
Create(req *request.ContainerCreate) (string, error)
Remove(id string) error
Start(id string) error
Stop(id string) error
Restart(id string) error
Pause(id string) error
Unpause(id string) error
Kill(id string) error
Rename(id string, newName string) error
Logs(id string) (string, error)
Prune() error
ListAll(sock string) ([]types.Container, error)
Inspect(sock string, id string) (any, error)
Create(sock string, req *request.ContainerCreate) (string, error)
Remove(sock string, id string) error
Start(sock string, id string) error
Stop(sock string, id string) error
Restart(sock string, id string) error
Pause(sock string, id string) error
Unpause(sock string, id string) error
Kill(sock string, id string) error
Rename(sock string, id string, newName string) error
Logs(sock string, id string, tail int) (string, error)
Prune(sock string) error
}
type ContainerUsecase struct {
repo ContainerRepo
setting SettingRepo
}
func NewContainerUsecase(repo ContainerRepo, setting SettingRepo) *ContainerUsecase {
return &ContainerUsecase{repo: repo, setting: setting}
}
func (uc *ContainerUsecase) ListAll() ([]types.Container, error) {
sock := containerSock(uc.setting)
return uc.repo.ListAll(sock)
}
func (uc *ContainerUsecase) ListByName(name string) ([]types.Container, error) {
sock := containerSock(uc.setting)
containers, err := uc.repo.ListAll(sock)
if err != nil {
return nil, err
}
containers = slices.DeleteFunc(containers, func(item types.Container) bool {
return !strings.Contains(item.Name, name)
})
return containers, nil
}
func (uc *ContainerUsecase) Inspect(id string) (any, error) {
sock := containerSock(uc.setting)
return uc.repo.Inspect(sock, id)
}
func (uc *ContainerUsecase) Create(req *request.ContainerCreate) (string, error) {
sock := containerSock(uc.setting)
return uc.repo.Create(sock, req)
}
// Update 删除旧容器后按新配置重建同名容器
func (uc *ContainerUsecase) Update(id string, req *request.ContainerCreate) (string, error) {
sock := containerSock(uc.setting)
if err := uc.repo.Remove(sock, id); err != nil {
return "", err
}
return uc.repo.Create(sock, req)
}
func (uc *ContainerUsecase) Remove(id string) error {
sock := containerSock(uc.setting)
return uc.repo.Remove(sock, id)
}
func (uc *ContainerUsecase) Start(id string) error {
sock := containerSock(uc.setting)
return uc.repo.Start(sock, id)
}
func (uc *ContainerUsecase) Stop(id string) error {
sock := containerSock(uc.setting)
return uc.repo.Stop(sock, id)
}
func (uc *ContainerUsecase) Restart(id string) error {
sock := containerSock(uc.setting)
return uc.repo.Restart(sock, id)
}
func (uc *ContainerUsecase) Pause(id string) error {
sock := containerSock(uc.setting)
return uc.repo.Pause(sock, id)
}
func (uc *ContainerUsecase) Unpause(id string) error {
sock := containerSock(uc.setting)
return uc.repo.Unpause(sock, id)
}
func (uc *ContainerUsecase) Kill(id string) error {
sock := containerSock(uc.setting)
return uc.repo.Kill(sock, id)
}
func (uc *ContainerUsecase) Rename(id string, newName string) error {
sock := containerSock(uc.setting)
return uc.repo.Rename(sock, id, newName)
}
func (uc *ContainerUsecase) Logs(id string, tail int) (string, error) {
sock := containerSock(uc.setting)
return uc.repo.Logs(sock, id, tail)
}
func (uc *ContainerUsecase) Prune() error {
sock := containerSock(uc.setting)
return uc.repo.Prune(sock)
}
+40 -1
View File
@@ -9,5 +9,44 @@ type ContainerComposeRepo interface {
Update(name, compose string, envs []types.KV) error
Up(name string, force bool) error
Down(name string) error
Remove(name string) error
RemoveDir(name string) error
}
type ContainerComposeUsecase struct {
repo ContainerComposeRepo
}
func NewContainerComposeUsecase(repo ContainerComposeRepo) *ContainerComposeUsecase {
return &ContainerComposeUsecase{repo: repo}
}
func (uc *ContainerComposeUsecase) List() ([]types.ContainerCompose, error) {
return uc.repo.List()
}
func (uc *ContainerComposeUsecase) Get(name string) (string, []types.KV, error) {
return uc.repo.Get(name)
}
func (uc *ContainerComposeUsecase) Create(name, compose string, envs []types.KV) error {
return uc.repo.Create(name, compose, envs)
}
func (uc *ContainerComposeUsecase) Update(name, compose string, envs []types.KV) error {
return uc.repo.Update(name, compose, envs)
}
func (uc *ContainerComposeUsecase) Up(name string, force bool) error {
return uc.repo.Up(name, force)
}
func (uc *ContainerComposeUsecase) Down(name string) error {
return uc.repo.Down(name)
}
func (uc *ContainerComposeUsecase) Remove(name string) error {
if err := uc.repo.Down(name); err != nil {
return err
}
return uc.repo.RemoveDir(name)
}
+40 -6
View File
@@ -1,14 +1,48 @@
package biz
import (
"github.com/acepanel/panel/v3/internal/http/request"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/types"
)
type ContainerImageRepo interface {
List() ([]types.ContainerImage, error)
Exist(name string) (bool, error)
Pull(req *request.ContainerImagePull) error
Remove(id string) error
Prune() error
List(sock string) ([]types.ContainerImage, error)
Exist(sock string, name string) (bool, error)
Pull(sock string, req *request.ContainerImagePull) error
Remove(sock string, id string) error
Prune(sock string) error
}
type ContainerImageUsecase struct {
repo ContainerImageRepo
setting SettingRepo
}
func NewContainerImageUsecase(repo ContainerImageRepo, setting SettingRepo) *ContainerImageUsecase {
return &ContainerImageUsecase{repo: repo, setting: setting}
}
func (uc *ContainerImageUsecase) List() ([]types.ContainerImage, error) {
sock := containerSock(uc.setting)
return uc.repo.List(sock)
}
func (uc *ContainerImageUsecase) Exist(name string) (bool, error) {
sock := containerSock(uc.setting)
return uc.repo.Exist(sock, name)
}
func (uc *ContainerImageUsecase) Pull(req *request.ContainerImagePull) error {
sock := containerSock(uc.setting)
return uc.repo.Pull(sock, req)
}
func (uc *ContainerImageUsecase) Remove(id string) error {
sock := containerSock(uc.setting)
return uc.repo.Remove(sock, id)
}
func (uc *ContainerImageUsecase) Prune() error {
sock := containerSock(uc.setting)
return uc.repo.Prune(sock)
}
+34 -5
View File
@@ -1,13 +1,42 @@
package biz
import (
"github.com/acepanel/panel/v3/internal/http/request"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/types"
)
type ContainerNetworkRepo interface {
List() ([]types.ContainerNetwork, error)
Create(req *request.ContainerNetworkCreate) (string, error)
Remove(id string) error
Prune() error
List(sock string) ([]types.ContainerNetwork, error)
Create(sock string, req *request.ContainerNetworkCreate) (string, error)
Remove(sock string, id string) error
Prune(sock string) error
}
type ContainerNetworkUsecase struct {
repo ContainerNetworkRepo
setting SettingRepo
}
func NewContainerNetworkUsecase(repo ContainerNetworkRepo, setting SettingRepo) *ContainerNetworkUsecase {
return &ContainerNetworkUsecase{repo: repo, setting: setting}
}
func (uc *ContainerNetworkUsecase) List() ([]types.ContainerNetwork, error) {
sock := containerSock(uc.setting)
return uc.repo.List(sock)
}
func (uc *ContainerNetworkUsecase) Create(req *request.ContainerNetworkCreate) (string, error) {
sock := containerSock(uc.setting)
return uc.repo.Create(sock, req)
}
func (uc *ContainerNetworkUsecase) Remove(id string) error {
sock := containerSock(uc.setting)
return uc.repo.Remove(sock, id)
}
func (uc *ContainerNetworkUsecase) Prune() error {
sock := containerSock(uc.setting)
return uc.repo.Prune(sock)
}
+34 -5
View File
@@ -1,13 +1,42 @@
package biz
import (
"github.com/acepanel/panel/v3/internal/http/request"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/types"
)
type ContainerVolumeRepo interface {
List() ([]types.ContainerVolume, error)
Create(req *request.ContainerVolumeCreate) (string, error)
Remove(id string) error
Prune() error
List(sock string) ([]types.ContainerVolume, error)
Create(sock string, req *request.ContainerVolumeCreate) (string, error)
Remove(sock string, id string) error
Prune(sock string) error
}
type ContainerVolumeUsecase struct {
repo ContainerVolumeRepo
setting SettingRepo
}
func NewContainerVolumeUsecase(repo ContainerVolumeRepo, setting SettingRepo) *ContainerVolumeUsecase {
return &ContainerVolumeUsecase{repo: repo, setting: setting}
}
func (uc *ContainerVolumeUsecase) List() ([]types.ContainerVolume, error) {
sock := containerSock(uc.setting)
return uc.repo.List(sock)
}
func (uc *ContainerVolumeUsecase) Create(req *request.ContainerVolumeCreate) (string, error) {
sock := containerSock(uc.setting)
return uc.repo.Create(sock, req)
}
func (uc *ContainerVolumeUsecase) Remove(id string) error {
sock := containerSock(uc.setting)
return uc.repo.Remove(sock, id)
}
func (uc *ContainerVolumeUsecase) Prune() error {
sock := containerSock(uc.setting)
return uc.repo.Prune(sock)
}
+181 -5
View File
@@ -2,9 +2,10 @@ package biz
import (
"context"
"log/slog"
"time"
"github.com/acepanel/panel/v3/internal/http/request"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/types"
)
@@ -25,8 +26,183 @@ type CronRepo interface {
Count() (int64, error)
List(page, limit uint) ([]*Cron, int64, error)
Get(id uint) (*Cron, error)
Create(ctx context.Context, req *request.CronCreate) error
Update(ctx context.Context, req *request.CronUpdate) error
Delete(ctx context.Context, id uint) error
Status(id uint, status bool) error
Create(cron *Cron) error
Save(cron *Cron) error
Delete(cron *Cron) error
GenerateScript(typ string, config types.CronConfig, rawScript string) string
WriteNewScript(script string) (string, string, error)
WriteScript(path, script string) error
Dos2Unix(path string) error
AddToSystem(cron *Cron) error
DeleteFromSystem(cron *Cron) error
RemoveScriptFiles(shellPath string) error
}
// CronUsecase 计划任务业务逻辑
type CronUsecase struct {
repo CronRepo
log *slog.Logger
}
func NewCronUsecase(repo CronRepo, log *slog.Logger) *CronUsecase {
return &CronUsecase{repo: repo, log: log}
}
func (uc *CronUsecase) Count() (int64, error) {
return uc.repo.Count()
}
func (uc *CronUsecase) List(page, limit uint) ([]*Cron, int64, error) {
return uc.repo.List(page, limit)
}
func (uc *CronUsecase) Get(id uint) (*Cron, error) {
return uc.repo.Get(id)
}
func (uc *CronUsecase) Create(ctx context.Context, req *request.CronCreate) error {
config := types.CronConfig{
Type: req.SubType,
Flock: req.Flock,
Targets: req.Targets,
Storage: req.Storage,
Keep: req.Keep,
URL: req.URL,
Method: req.Method,
Headers: req.Headers,
Body: req.Body,
Timeout: req.Timeout,
Insecure: req.Insecure,
Retries: req.Retries,
}
script := uc.repo.GenerateScript(req.Type, config, req.Script)
shellPath, logPath, err := uc.repo.WriteNewScript(script)
if err != nil {
return err
}
cron := new(Cron)
cron.Name = req.Name
cron.Type = req.Type
cron.Status = true
cron.Time = req.Time
cron.Shell = shellPath
cron.Log = logPath
cron.Config = config
if err := uc.repo.Create(cron); err != nil {
return err
}
if err := uc.repo.AddToSystem(cron); err != nil {
return err
}
// 记录日志
uc.log.Info("cron created", slog.String("type", OperationTypeCron), slog.Uint64("operator_id", operatorID(ctx)), slog.String("name", req.Name), slog.String("cron_type", req.Type))
return nil
}
func (uc *CronUsecase) Update(ctx context.Context, req *request.CronUpdate) error {
cron, err := uc.repo.Get(req.ID)
if err != nil {
return err
}
cron.Time = req.Time
cron.Name = req.Name
// 根据类型重新生成脚本
if req.Type != "shell" {
config := types.CronConfig{
Type: req.SubType,
Flock: req.Flock,
Targets: req.Targets,
Storage: req.Storage,
Keep: req.Keep,
URL: req.URL,
Method: req.Method,
Headers: req.Headers,
Body: req.Body,
Timeout: req.Timeout,
Insecure: req.Insecure,
Retries: req.Retries,
}
cron.Config = config
script := uc.repo.GenerateScript(req.Type, config, "")
if err = uc.repo.WriteScript(cron.Shell, script); err != nil {
return err
}
} else {
cron.Config.Flock = req.Flock
if err = uc.repo.WriteScript(cron.Shell, req.Script); err != nil {
return err
}
}
if err = uc.repo.Save(cron); err != nil {
return err
}
if err = uc.repo.Dos2Unix(cron.Shell); err != nil {
return err
}
if err = uc.repo.DeleteFromSystem(cron); err != nil {
return err
}
if cron.Status {
if err = uc.repo.AddToSystem(cron); err != nil {
return err
}
}
// 记录日志
uc.log.Info("cron updated", slog.String("type", OperationTypeCron), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(req.ID)), slog.String("name", cron.Name))
return nil
}
func (uc *CronUsecase) Delete(ctx context.Context, id uint) error {
cron, err := uc.repo.Get(id)
if err != nil {
return err
}
if err = uc.repo.DeleteFromSystem(cron); err != nil {
return err
}
if err = uc.repo.RemoveScriptFiles(cron.Shell); err != nil {
return err
}
if err = uc.repo.Delete(cron); err != nil {
return err
}
// 记录日志
uc.log.Info("cron deleted", slog.String("type", OperationTypeCron), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(id)), slog.String("name", cron.Name))
return nil
}
func (uc *CronUsecase) Status(id uint, status bool) error {
cron, err := uc.repo.Get(id)
if err != nil {
return err
}
if err = uc.repo.DeleteFromSystem(cron); err != nil {
return err
}
if status {
if err = uc.repo.AddToSystem(cron); err != nil {
return err
}
}
cron.Status = status
return uc.repo.Save(cron)
}
+226 -5
View File
@@ -2,8 +2,13 @@ package biz
import (
"context"
"errors"
"log/slog"
"github.com/acepanel/panel/v3/internal/http/request"
"github.com/leonelquinteros/gotext"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/db"
)
type DatabaseType string
@@ -28,8 +33,224 @@ type Database struct {
}
type DatabaseRepo interface {
List(page, limit uint, typ string) ([]*Database, int64, error)
Create(ctx context.Context, req *request.DatabaseCreate) error
Delete(ctx context.Context, serverID uint, name string) error
Comment(req *request.DatabaseComment) error
ListServers(typ string) ([]*DatabaseServer, error)
DatabasesOf(ctx context.Context, server *DatabaseServer) ([]*Database, error)
Operator(ctx context.Context, server *DatabaseServer) (db.Operator, error)
Mongo(ctx context.Context, server *DatabaseServer) (*db.MongoDB, error)
}
// DatabaseUsecase 数据库业务用例
type DatabaseUsecase struct {
repo DatabaseRepo
server DatabaseServerRepo
user *DatabaseUserUsecase
t *gotext.Locale
log *slog.Logger
}
func NewDatabaseUsecase(databaseUserUsecase *DatabaseUserUsecase, t *gotext.Locale, log *slog.Logger, databaseRepo DatabaseRepo, databaseServerRepo DatabaseServerRepo) (*DatabaseUsecase, error) {
return &DatabaseUsecase{
repo: databaseRepo,
server: databaseServerRepo,
user: databaseUserUsecase,
t: t,
log: log,
}, nil
}
func (uc *DatabaseUsecase) List(ctx context.Context, page, limit uint, typ string) ([]*Database, int64, error) {
servers, err := uc.repo.ListServers(typ)
if err != nil {
return nil, 0, err
}
database := make([]*Database, 0)
for _, server := range servers {
databases, err := uc.repo.DatabasesOf(ctx, server)
if err != nil {
continue
}
database = append(database, databases...)
}
if len(database) < int((page-1)*limit) {
return []*Database{}, int64(len(database)), nil
}
return database[(page-1)*limit:], int64(len(database)), nil
}
func (uc *DatabaseUsecase) Create(ctx context.Context, req *request.DatabaseCreate) error {
server, err := uc.server.Get(ctx, req.ServerID)
if err != nil {
return err
}
// MongoDB 独立处理,不走 Operator 接口
if server.Type == DatabaseTypeMongoDB {
mongo, mongoErr := uc.repo.Mongo(ctx, server)
if mongoErr != nil {
return mongoErr
}
defer mongo.Close()
if mongoErr = mongo.DatabaseCreate(req.Name); mongoErr != nil {
return mongoErr
}
uc.log.Info("database created", slog.String("type", OperationTypeDatabase), slog.Uint64("operator_id", operatorID(ctx)), slog.String("name", req.Name), slog.Uint64("server_id", uint64(req.ServerID)))
return nil
}
operator, err := uc.repo.Operator(ctx, server)
if err != nil {
return err
}
defer operator.Close()
switch server.Type {
case DatabaseTypeMysql:
if req.CreateUser {
if err = uc.user.Create(ctx, &request.DatabaseUserCreate{
ServerID: req.ServerID,
Username: req.Username,
Password: req.Password,
Host: req.Host,
}); err != nil {
return err
}
}
if err = operator.DatabaseCreate(req.Name); err != nil {
return err
}
if req.Username != "" {
// 授权已有用户时按其实际 host 授权,MySQL 8 起 GRANT 不再隐式创建用户
hosts := []string{req.Host}
if !req.CreateUser {
if hosts = uc.mysqlUserHosts(operator, req.Username); len(hosts) == 0 {
return errors.New(uc.t.Get("database user %s does not exist", req.Username))
}
}
for _, host := range hosts {
if err = operator.PrivilegesGrant(req.Username, req.Name, host); err != nil {
return err
}
}
}
case DatabaseTypePostgresql:
if req.CreateUser {
if err = uc.user.Create(ctx, &request.DatabaseUserCreate{
ServerID: req.ServerID,
Username: req.Username,
Password: req.Password,
Host: req.Host,
}); err != nil {
return err
}
}
if err = operator.DatabaseCreate(req.Name); err != nil {
return err
}
if req.Username != "" {
if err = operator.PrivilegesGrant(req.Username, req.Name); err != nil {
return err
}
}
if err = operator.(*db.Postgres).DatabaseComment(req.Name, req.Comment); err != nil {
return err
}
case DatabaseTypeClickHouse:
if req.CreateUser {
if err = uc.user.Create(ctx, &request.DatabaseUserCreate{
ServerID: req.ServerID,
Username: req.Username,
Password: req.Password,
}); err != nil {
return err
}
}
if err = operator.DatabaseCreate(req.Name); err != nil {
return err
}
if req.Username != "" {
if err = operator.PrivilegesGrant(req.Username, req.Name); err != nil {
return err
}
}
}
// 记录日志
uc.log.Info("database created", slog.String("type", OperationTypeDatabase), slog.Uint64("operator_id", operatorID(ctx)), slog.String("name", req.Name), slog.Uint64("server_id", uint64(req.ServerID)))
return nil
}
// mysqlUserHosts 查询 MySQL 用户实际存在的 host 列表
func (uc *DatabaseUsecase) mysqlUserHosts(operator db.Operator, user string) []string {
rows, err := operator.Query("SELECT host FROM mysql.user WHERE user = ?", user)
if err != nil {
return nil
}
defer func() { _ = rows.Close() }()
var hosts []string
for rows.Next() {
var host string
if rows.Scan(&host) == nil {
hosts = append(hosts, host)
}
}
return hosts
}
func (uc *DatabaseUsecase) Delete(ctx context.Context, serverID uint, name string) error {
server, err := uc.server.Get(ctx, serverID)
if err != nil {
return err
}
switch server.Type {
case DatabaseTypeMongoDB:
mongo, mongoErr := uc.repo.Mongo(ctx, server)
if mongoErr != nil {
return mongoErr
}
defer mongo.Close()
if mongoErr = mongo.DatabaseDrop(name); mongoErr != nil {
return mongoErr
}
case DatabaseTypeSQLite:
return errors.New(uc.t.Get("sqlite does not support dropping tables from here"))
default:
operator, opErr := uc.repo.Operator(ctx, server)
if opErr != nil {
return opErr
}
defer operator.Close()
if opErr = operator.DatabaseDrop(name); opErr != nil {
return opErr
}
}
// 记录日志
uc.log.Info("database deleted", slog.String("type", OperationTypeDatabase), slog.Uint64("operator_id", operatorID(ctx)), slog.String("name", name), slog.Uint64("server_id", uint64(serverID)))
return nil
}
func (uc *DatabaseUsecase) Comment(ctx context.Context, req *request.DatabaseComment) error {
server, err := uc.server.Get(ctx, req.ServerID)
if err != nil {
return err
}
switch server.Type {
case DatabaseTypePostgresql:
operator, opErr := uc.repo.Operator(ctx, server)
if opErr != nil {
return opErr
}
defer operator.Close()
return operator.(*db.Postgres).DatabaseComment(req.Name, req.Comment)
default:
return errors.New(uc.t.Get("%s does not support database comment", server.Type))
}
}
+47 -8
View File
@@ -1,16 +1,55 @@
package biz
import (
"github.com/acepanel/panel/v3/internal/http/request"
"context"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/db"
)
type DatabaseElasticsearchRepo interface {
Indices(req *request.DatabaseESIndices) ([]db.ESIndex, error)
IndexCreate(req *request.DatabaseESIndexCreate) error
IndexDelete(req *request.DatabaseESIndexDelete) error
Data(req *request.DatabaseESData) ([]db.ESDocument, int64, error)
DocumentGet(req *request.DatabaseESDocumentGet) (*db.ESDocument, error)
DocumentSet(req *request.DatabaseESDocumentSet) error
DocumentDelete(req *request.DatabaseESDocumentDelete) error
Indices(ctx context.Context, req *request.DatabaseESIndices) ([]db.ESIndex, error)
IndexCreate(ctx context.Context, req *request.DatabaseESIndexCreate) error
IndexDelete(ctx context.Context, req *request.DatabaseESIndexDelete) error
Data(ctx context.Context, req *request.DatabaseESData) ([]db.ESDocument, int64, error)
DocumentGet(ctx context.Context, req *request.DatabaseESDocumentGet) (*db.ESDocument, error)
DocumentSet(ctx context.Context, req *request.DatabaseESDocumentSet) error
DocumentDelete(ctx context.Context, req *request.DatabaseESDocumentDelete) error
}
// DatabaseElasticsearchUsecase Elasticsearch 业务用例
type DatabaseElasticsearchUsecase struct {
repo DatabaseElasticsearchRepo
}
func NewDatabaseElasticsearchUsecase(repo DatabaseElasticsearchRepo) *DatabaseElasticsearchUsecase {
return &DatabaseElasticsearchUsecase{repo: repo}
}
func (uc *DatabaseElasticsearchUsecase) Indices(ctx context.Context, req *request.DatabaseESIndices) ([]db.ESIndex, error) {
return uc.repo.Indices(ctx, req)
}
func (uc *DatabaseElasticsearchUsecase) IndexCreate(ctx context.Context, req *request.DatabaseESIndexCreate) error {
return uc.repo.IndexCreate(ctx, req)
}
func (uc *DatabaseElasticsearchUsecase) IndexDelete(ctx context.Context, req *request.DatabaseESIndexDelete) error {
return uc.repo.IndexDelete(ctx, req)
}
func (uc *DatabaseElasticsearchUsecase) Data(ctx context.Context, req *request.DatabaseESData) ([]db.ESDocument, int64, error) {
return uc.repo.Data(ctx, req)
}
func (uc *DatabaseElasticsearchUsecase) DocumentGet(ctx context.Context, req *request.DatabaseESDocumentGet) (*db.ESDocument, error) {
return uc.repo.DocumentGet(ctx, req)
}
func (uc *DatabaseElasticsearchUsecase) DocumentSet(ctx context.Context, req *request.DatabaseESDocumentSet) error {
return uc.repo.DocumentSet(ctx, req)
}
func (uc *DatabaseElasticsearchUsecase) DocumentDelete(ctx context.Context, req *request.DatabaseESDocumentDelete) error {
return uc.repo.DocumentDelete(ctx, req)
}
+52 -9
View File
@@ -1,17 +1,60 @@
package biz
import (
"github.com/acepanel/panel/v3/internal/http/request"
"context"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/db"
)
type DatabaseRedisRepo interface {
Databases(req *request.DatabaseRedisDatabases) (int, error)
Data(req *request.DatabaseRedisData) ([]db.RedisKV, int, error)
KeyGet(req *request.DatabaseRedisKeyGet) (*db.RedisKV, error)
KeySet(req *request.DatabaseRedisKeySet) error
KeyDelete(req *request.DatabaseRedisKeyDelete) error
KeyTTL(req *request.DatabaseRedisKeyTTL) error
KeyRename(req *request.DatabaseRedisKeyRename) error
Clear(req *request.DatabaseRedisClear) error
Databases(ctx context.Context, req *request.DatabaseRedisDatabases) (int, error)
Data(ctx context.Context, req *request.DatabaseRedisData) ([]db.RedisKV, int, error)
KeyGet(ctx context.Context, req *request.DatabaseRedisKeyGet) (*db.RedisKV, error)
KeySet(ctx context.Context, req *request.DatabaseRedisKeySet) error
KeyDelete(ctx context.Context, req *request.DatabaseRedisKeyDelete) error
KeyTTL(ctx context.Context, req *request.DatabaseRedisKeyTTL) error
KeyRename(ctx context.Context, req *request.DatabaseRedisKeyRename) error
Clear(ctx context.Context, req *request.DatabaseRedisClear) error
}
// DatabaseRedisUsecase Redis 业务用例
type DatabaseRedisUsecase struct {
repo DatabaseRedisRepo
}
func NewDatabaseRedisUsecase(repo DatabaseRedisRepo) *DatabaseRedisUsecase {
return &DatabaseRedisUsecase{repo: repo}
}
func (uc *DatabaseRedisUsecase) Databases(ctx context.Context, req *request.DatabaseRedisDatabases) (int, error) {
return uc.repo.Databases(ctx, req)
}
func (uc *DatabaseRedisUsecase) Data(ctx context.Context, req *request.DatabaseRedisData) ([]db.RedisKV, int, error) {
return uc.repo.Data(ctx, req)
}
func (uc *DatabaseRedisUsecase) KeyGet(ctx context.Context, req *request.DatabaseRedisKeyGet) (*db.RedisKV, error) {
return uc.repo.KeyGet(ctx, req)
}
func (uc *DatabaseRedisUsecase) KeySet(ctx context.Context, req *request.DatabaseRedisKeySet) error {
return uc.repo.KeySet(ctx, req)
}
func (uc *DatabaseRedisUsecase) KeyDelete(ctx context.Context, req *request.DatabaseRedisKeyDelete) error {
return uc.repo.KeyDelete(ctx, req)
}
func (uc *DatabaseRedisUsecase) KeyTTL(ctx context.Context, req *request.DatabaseRedisKeyTTL) error {
return uc.repo.KeyTTL(ctx, req)
}
func (uc *DatabaseRedisUsecase) KeyRename(ctx context.Context, req *request.DatabaseRedisKeyRename) error {
return uc.repo.KeyRename(ctx, req)
}
func (uc *DatabaseRedisUsecase) Clear(ctx context.Context, req *request.DatabaseRedisClear) error {
return uc.repo.Clear(ctx, req)
}
+194 -7
View File
@@ -1,13 +1,20 @@
package biz
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"time"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/utils/crypt"
"gorm.io/gorm"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/http/request"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/db"
)
type DatabaseServerStatus string
@@ -62,14 +69,194 @@ func (r *DatabaseServer) AfterFind(tx *gorm.DB) error {
type DatabaseServerRepo interface {
Count() (int64, error)
List(page, limit uint, typ string) ([]*DatabaseServer, int64, error)
Get(id uint) (*DatabaseServer, error)
GetByName(name string) (*DatabaseServer, error)
Create(req *request.DatabaseServerCreate) error
Update(req *request.DatabaseServerUpdate) error
List(ctx context.Context, page, limit uint, typ string) ([]*DatabaseServer, int64, error)
Get(ctx context.Context, id uint) (*DatabaseServer, error)
GetByName(ctx context.Context, name string) (*DatabaseServer, error)
Create(server *DatabaseServer) error
Save(server *DatabaseServer) error
UpdateRemark(req *request.DatabaseServerUpdateRemark) error
UpdatePassword(name string, password string) error
UpdatePort(name string, port uint) error
Delete(id uint) error
ClearUsers(id uint) error
Sync(id uint) error
ListUsers(serverID uint) ([]*DatabaseUser, error)
CreateUser(user *DatabaseUser) error
Operator(ctx context.Context, server *DatabaseServer) (db.Operator, error)
CheckServer(ctx context.Context, server *DatabaseServer) bool
}
// DatabaseServerUsecase 数据库服务器业务用例
type DatabaseServerUsecase struct {
repo DatabaseServerRepo
t *gotext.Locale
log *slog.Logger
}
func NewDatabaseServerUsecase(t *gotext.Locale, log *slog.Logger, databaseServerRepo DatabaseServerRepo) (*DatabaseServerUsecase, error) {
return &DatabaseServerUsecase{
repo: databaseServerRepo,
t: t,
log: log,
}, nil
}
func (uc *DatabaseServerUsecase) Count() (int64, error) {
return uc.repo.Count()
}
func (uc *DatabaseServerUsecase) List(ctx context.Context, page, limit uint, typ string) ([]*DatabaseServer, int64, error) {
return uc.repo.List(ctx, page, limit, typ)
}
func (uc *DatabaseServerUsecase) Get(ctx context.Context, id uint) (*DatabaseServer, error) {
return uc.repo.Get(ctx, id)
}
func (uc *DatabaseServerUsecase) GetByName(ctx context.Context, name string) (*DatabaseServer, error) {
return uc.repo.GetByName(ctx, name)
}
func (uc *DatabaseServerUsecase) Create(ctx context.Context, req *request.DatabaseServerCreate) error {
databaseServer := &DatabaseServer{
Name: req.Name,
Type: DatabaseType(req.Type),
Host: req.Host,
Port: req.Port,
Username: req.Username,
Password: req.Password,
Remark: req.Remark,
}
if !uc.repo.CheckServer(ctx, databaseServer) {
return errors.New(uc.t.Get("check server connection failed"))
}
return uc.repo.Create(databaseServer)
}
func (uc *DatabaseServerUsecase) Update(ctx context.Context, req *request.DatabaseServerUpdate) error {
server, err := uc.repo.Get(ctx, req.ID)
if err != nil {
return err
}
server.Name = req.Name
server.Host = req.Host
server.Port = req.Port
server.Username = req.Username
server.Password = req.Password
server.Remark = req.Remark
if !uc.repo.CheckServer(ctx, server) {
return errors.New(uc.t.Get("check server connection failed"))
}
return uc.repo.Save(server)
}
func (uc *DatabaseServerUsecase) UpdateRemark(req *request.DatabaseServerUpdateRemark) error {
return uc.repo.UpdateRemark(req)
}
func (uc *DatabaseServerUsecase) UpdatePassword(name string, password string) error {
return uc.repo.UpdatePassword(name, password)
}
func (uc *DatabaseServerUsecase) UpdatePort(name string, port uint) error {
return uc.repo.UpdatePort(name, port)
}
func (uc *DatabaseServerUsecase) Delete(id uint) error {
return uc.repo.Delete(id)
}
func (uc *DatabaseServerUsecase) ClearUsers(id uint) error {
return uc.repo.ClearUsers(id)
}
func (uc *DatabaseServerUsecase) Sync(ctx context.Context, id uint) error {
server, err := uc.repo.Get(ctx, id)
if err != nil {
return err
}
// 非 Operator 类型不支持用户同步
switch server.Type {
case DatabaseTypeRedis, DatabaseTypeMongoDB, DatabaseTypeSQLite, DatabaseTypeElasticsearch:
return fmt.Errorf("sync is not supported for %s", server.Type)
}
users, err := uc.repo.ListUsers(id)
if err != nil {
return err
}
operator, err := uc.repo.Operator(ctx, server)
if err != nil {
return err
}
defer operator.Close()
switch server.Type {
case DatabaseTypeMysql:
allUsers, err := operator.Users()
if err != nil {
return err
}
for user := range slices.Values(allUsers) {
if !slices.ContainsFunc(users, func(a *DatabaseUser) bool {
return a.Username == user.User && a.Host == user.Host
}) && !slices.Contains([]string{"root", "mysql.sys", "mysql.session", "mysql.infoschema"}, user.User) {
newUser := &DatabaseUser{
ServerID: id,
Username: user.User,
Host: user.Host,
Remark: uc.t.Get("sync from server %s", server.Name),
}
if err = uc.repo.CreateUser(newUser); err != nil {
uc.log.Warn("sync mysql database user failed", slog.String("type", OperationTypeDatabaseServer), slog.Uint64("operator_id", 0), slog.Any("err", err))
}
}
}
case DatabaseTypePostgresql:
allUsers, err := operator.Users()
if err != nil {
return err
}
for user := range slices.Values(allUsers) {
if !slices.ContainsFunc(users, func(a *DatabaseUser) bool {
return a.Username == user.User
}) && !slices.Contains([]string{"postgres"}, user.User) {
newUser := &DatabaseUser{
ServerID: id,
Username: user.User,
Remark: uc.t.Get("sync from server %s", server.Name),
}
if err = uc.repo.CreateUser(newUser); err != nil {
uc.log.Warn("sync postgresql database user failed", slog.String("type", OperationTypeDatabaseServer), slog.Uint64("operator_id", 0), slog.Any("err", err))
}
}
}
case DatabaseTypeClickHouse:
allUsers, err := operator.Users()
if err != nil {
return err
}
for user := range slices.Values(allUsers) {
if !slices.ContainsFunc(users, func(a *DatabaseUser) bool {
return a.Username == user.User
}) && !slices.Contains([]string{"default"}, user.User) {
newUser := &DatabaseUser{
ServerID: id,
Username: user.User,
Remark: uc.t.Get("sync from server %s", server.Name),
}
if err = uc.repo.CreateUser(newUser); err != nil {
uc.log.Warn("sync clickhouse database user failed", slog.String("type", OperationTypeDatabaseServer), slog.Uint64("operator_id", 0), slog.Any("err", err))
}
}
}
}
return nil
}
+203 -8
View File
@@ -2,13 +2,16 @@ package biz
import (
"context"
"log/slog"
"slices"
"time"
"github.com/libtnb/utils/crypt"
"gorm.io/gorm"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/http/request"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/db"
)
type DatabaseUserStatus string
@@ -64,11 +67,203 @@ func (r *DatabaseUser) AfterFind(tx *gorm.DB) error {
type DatabaseUserRepo interface {
Count() (int64, error)
List(page, limit uint, typ string) ([]*DatabaseUser, int64, error)
Get(id uint) (*DatabaseUser, error)
Create(ctx context.Context, req *request.DatabaseUserCreate) error
Update(req *request.DatabaseUserUpdate) error
UpdateRemark(req *request.DatabaseUserUpdateRemark) error
Delete(ctx context.Context, id uint) error
DeleteByNames(serverID uint, names []string) error
List(ctx context.Context, page, limit uint, typ string) ([]*DatabaseUser, int64, error)
Get(ctx context.Context, id uint) (*DatabaseUser, error)
UpdateRemark(ctx context.Context, req *request.DatabaseUserUpdateRemark) error
Operator(ctx context.Context, server *DatabaseServer) (db.Operator, error)
Upsert(user *DatabaseUser) error
Save(user *DatabaseUser) error
DeleteByID(id uint) error
ListByNames(serverID uint, names []string) ([]*DatabaseUser, error)
DeleteByServerNames(serverID uint, names []string) error
}
// DatabaseUserUsecase 数据库用户业务用例
type DatabaseUserUsecase struct {
repo DatabaseUserRepo
server DatabaseServerRepo
log *slog.Logger
}
func NewDatabaseUserUsecase(log *slog.Logger, databaseServerRepo DatabaseServerRepo, databaseUserRepo DatabaseUserRepo) (*DatabaseUserUsecase, error) {
return &DatabaseUserUsecase{
repo: databaseUserRepo,
server: databaseServerRepo,
log: log,
}, nil
}
func (uc *DatabaseUserUsecase) Count() (int64, error) {
return uc.repo.Count()
}
func (uc *DatabaseUserUsecase) List(ctx context.Context, page, limit uint, typ string) ([]*DatabaseUser, int64, error) {
return uc.repo.List(ctx, page, limit, typ)
}
func (uc *DatabaseUserUsecase) Get(ctx context.Context, id uint) (*DatabaseUser, error) {
return uc.repo.Get(ctx, id)
}
func (uc *DatabaseUserUsecase) Create(ctx context.Context, req *request.DatabaseUserCreate) error {
server, err := uc.server.Get(ctx, req.ServerID)
if err != nil {
return err
}
operator, err := uc.repo.Operator(ctx, server)
if err != nil {
return err
}
defer operator.Close()
// 创建用户
if err = operator.UserCreate(req.Username, req.Password, req.Host); err != nil {
return err
}
// 创建数据库并授权
for name := range slices.Values(req.Privileges) {
if err = operator.DatabaseCreate(name); err != nil {
return err
}
if err = operator.PrivilegesGrant(req.Username, name, req.Host); err != nil {
return err
}
}
user := &DatabaseUser{
ServerID: req.ServerID,
Username: req.Username,
Host: req.Host,
Password: req.Password,
Remark: req.Remark,
}
if err = uc.repo.Upsert(user); err != nil {
return err
}
// 记录日志
uc.log.Info("database user created", slog.String("type", OperationTypeDatabaseUser), slog.Uint64("operator_id", operatorID(ctx)), slog.String("username", req.Username), slog.Uint64("server_id", uint64(req.ServerID)))
return nil
}
func (uc *DatabaseUserUsecase) Update(ctx context.Context, req *request.DatabaseUserUpdate) error {
user, err := uc.repo.Get(ctx, req.ID)
if err != nil {
return err
}
server, err := uc.server.Get(ctx, user.ServerID)
if err != nil {
return err
}
operator, err := uc.repo.Operator(ctx, server)
if err != nil {
return err
}
defer operator.Close()
// 更新密码
if req.Password != "" {
if err = operator.UserPassword(user.Username, req.Password, user.Host); err != nil {
return err
}
user.Password = req.Password
}
// 撤销被移除的权限
currentPrivileges, _ := operator.UserPrivileges(user.Username, user.Host)
for name := range slices.Values(currentPrivileges) {
if !slices.Contains(req.Privileges, name) {
_ = operator.PrivilegesRevoke(user.Username, name, user.Host)
}
}
// 创建数据库并授权
for name := range slices.Values(req.Privileges) {
if err = operator.DatabaseCreate(name); err != nil {
return err
}
if err = operator.PrivilegesGrant(user.Username, name, user.Host); err != nil {
return err
}
}
user.Remark = req.Remark
return uc.repo.Save(user)
}
func (uc *DatabaseUserUsecase) UpdateRemark(ctx context.Context, req *request.DatabaseUserUpdateRemark) error {
return uc.repo.UpdateRemark(ctx, req)
}
func (uc *DatabaseUserUsecase) Delete(ctx context.Context, id uint) error {
user, err := uc.repo.Get(ctx, id)
if err != nil {
return err
}
server, err := uc.server.Get(ctx, user.ServerID)
if err != nil {
return err
}
operator, err := uc.repo.Operator(ctx, server)
if err != nil {
return err
}
defer operator.Close()
_ = operator.UserDrop(user.Username, user.Host)
if err = uc.repo.DeleteByID(id); err != nil {
return err
}
// 记录日志
uc.log.Info("database user deleted", slog.String("type", OperationTypeDatabaseUser), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(id)), slog.String("username", user.Username))
return nil
}
func (uc *DatabaseUserUsecase) DeleteByNames(ctx context.Context, serverID uint, names []string) error {
server, err := uc.server.Get(ctx, serverID)
if err != nil {
return err
}
operator, err := uc.repo.Operator(ctx, server)
if err != nil {
return err
}
defer operator.Close()
switch server.Type {
case DatabaseTypeMysql:
users, err := uc.repo.ListByNames(serverID, names)
if err != nil {
return err
}
for name := range slices.Values(names) {
host := "localhost"
for u := range slices.Values(users) {
if u.Username == name {
host = u.Host
break
}
}
_ = operator.UserDrop(name, host)
}
case DatabaseTypePostgresql, DatabaseTypeClickHouse:
for name := range slices.Values(names) {
_ = operator.UserDrop(name)
}
}
return uc.repo.DeleteByServerNames(serverID, names)
}
+154 -7
View File
@@ -1,18 +1,165 @@
package biz
import (
"encoding/json"
"errors"
"fmt"
"slices"
"github.com/leonelquinteros/gotext"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/pkg/api"
"github.com/acepanel/panel/v3/pkg/types"
)
type EnvironmentRepo interface {
Types() []types.LV
All(typ ...string) api.Environments
IsInstalled(typ, slug string) bool
InstalledSlugs(typ string) []string
InstalledVersion(typ, slug string) string
HasUpdate(typ, slug string) bool
Install(typ, slug string) error
Uninstall(typ, slug string) error
Update(typ, slug string) error
ScriptCommand(typ, action, slug, version string) string
ExecScript(cmd string) error
}
type EnvironmentUsecase struct {
repo EnvironmentRepo
cache CacheRepo
task TaskRepo
t *gotext.Locale
}
func NewEnvironmentUsecase(t *gotext.Locale, cacheRepo CacheRepo, environmentRepo EnvironmentRepo, taskRepo TaskRepo) (*EnvironmentUsecase, error) {
return &EnvironmentUsecase{
repo: environmentRepo,
cache: cacheRepo,
task: taskRepo,
t: t,
}, nil
}
func (uc *EnvironmentUsecase) Types() []types.LV {
return []types.LV{
{Label: "Go", Value: "go"},
{Label: "Java", Value: "java"},
{Label: "Node.js", Value: "nodejs"},
{Label: "PHP", Value: "php"},
{Label: "Python", Value: "python"},
{Label: ".NET", Value: "dotnet"},
}
}
func (uc *EnvironmentUsecase) All(typ ...string) api.Environments {
cached, err := uc.cache.Get(CacheKeyEnvironment)
if err != nil {
return nil
}
var environments api.Environments
if err = json.Unmarshal([]byte(cached), &environments); err != nil {
return nil
}
// 过滤
environments = slices.DeleteFunc(environments, func(env *api.Environment) bool {
return len(typ) > 0 && typ[0] != "" && env.Type != typ[0]
})
return environments
}
func (uc *EnvironmentUsecase) getByTypeAndSlug(typ, slug string) *api.Environment {
all := uc.All()
for _, env := range all {
if env.Type == typ && env.Slug == slug {
return env
}
}
return nil
}
func (uc *EnvironmentUsecase) IsInstalled(typ, slug string) bool {
return uc.repo.IsInstalled(typ, slug)
}
func (uc *EnvironmentUsecase) InstalledSlugs(typ string) []string {
var slugs []string
all := uc.All()
for _, env := range all {
if env.Type != typ {
continue
}
if uc.repo.IsInstalled(typ, env.Slug) {
slugs = append(slugs, env.Slug)
}
}
return slugs
}
func (uc *EnvironmentUsecase) InstalledVersion(typ, slug string) string {
return uc.repo.InstalledVersion(typ, slug)
}
func (uc *EnvironmentUsecase) HasUpdate(typ, slug string) bool {
if !uc.repo.IsInstalled(typ, slug) {
return false
}
env := uc.getByTypeAndSlug(typ, slug)
if env == nil {
return false
}
mainlineVersion := env.Version
installedVersion := uc.repo.InstalledVersion(typ, slug)
return mainlineVersion != installedVersion && mainlineVersion != "" && installedVersion != ""
}
func (uc *EnvironmentUsecase) Install(typ, slug string) error {
if installed := uc.repo.IsInstalled(typ, slug); installed {
return errors.New(uc.t.Get("environment %s-%s already installed", typ, slug))
}
return uc.do(typ, slug, "install")
}
func (uc *EnvironmentUsecase) Uninstall(typ, slug string) error {
if installed := uc.repo.IsInstalled(typ, slug); !installed {
return errors.New(uc.t.Get("environment %s-%s not installed", typ, slug))
}
return uc.do(typ, slug, "uninstall")
}
func (uc *EnvironmentUsecase) Update(typ, slug string) error {
if installed := uc.repo.IsInstalled(typ, slug); !installed {
return errors.New(uc.t.Get("environment %s-%s not installed", typ, slug))
}
return uc.do(typ, slug, "update")
}
func (uc *EnvironmentUsecase) do(typ, slug, action string) error {
env := uc.getByTypeAndSlug(typ, slug)
if env == nil {
return fmt.Errorf("environment not found: %s-%s", typ, slug)
}
cmd := uc.repo.ScriptCommand(typ, action, env.Slug, env.Version)
if app.IsCli {
return uc.repo.ExecScript(cmd)
}
var name string
switch action {
case "install":
name = uc.t.Get("Install environment %s", env.Name)
case "uninstall":
name = uc.t.Get("Uninstall environment %s", env.Name)
case "update":
name = uc.t.Get("Update environment %s", env.Name)
}
task := new(Task)
task.Key = fmt.Sprintf("environment:%s:%s", typ, slug)
task.Name = name
task.Status = TaskStatusWaiting
task.Shell = cmd
return uc.task.Push(task)
}
+83
View File
@@ -0,0 +1,83 @@
package biz
import (
"context"
"log/slog"
"time"
"github.com/acepanel/panel/v3/internal/request"
)
type FileShare struct {
ID uint `gorm:"primaryKey" json:"id"`
Token string `gorm:"not null;uniqueIndex" json:"token"` // 唯一标识(用于下载 URL
Path string `gorm:"not null;index" json:"path"` // 文件路径(同一文件可有多条分享)
Downloads uint `gorm:"not null;default:0" json:"downloads"` // 已下载次数
MaxDownloads uint `gorm:"not null;default:0" json:"max_downloads"` // 最大下载次数(0 不限)
ExpiredAt time.Time `gorm:"not null;index" json:"expired_at"` // 过期时间
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type FileShareRepo interface {
List() ([]*FileShare, error)
Get(id uint) (*FileShare, error)
Create(path string, maxDownloads uint, expiredAt time.Time) (*FileShare, error)
Delete(id uint) error
Consume(token string, count bool) (*FileShare, error)
ClearExpired() (int64, error)
}
type FileShareUsecase struct {
repo FileShareRepo
log *slog.Logger
}
func NewFileShareUsecase(log *slog.Logger, fileShareRepo FileShareRepo) (*FileShareUsecase, error) {
return &FileShareUsecase{
repo: fileShareRepo,
log: log,
}, nil
}
func (uc *FileShareUsecase) List() ([]*FileShare, error) {
return uc.repo.List()
}
// Create 创建分享,同一路径可重复分享为多条独立记录
func (uc *FileShareUsecase) Create(ctx context.Context, req *request.FileShareCreate) (*FileShare, error) {
share, err := uc.repo.Create(req.Path, req.MaxDownloads, time.Now().Add(time.Duration(req.ExpireHours)*time.Hour))
if err != nil {
return nil, err
}
// 记录日志
uc.log.Info("file share created", slog.String("type", OperationTypeFile), slog.Uint64("operator_id", operatorID(ctx)), slog.String("path", req.Path))
return share, nil
}
func (uc *FileShareUsecase) Delete(ctx context.Context, id uint) error {
share, err := uc.repo.Get(id)
if err != nil {
return err
}
if err = uc.repo.Delete(id); err != nil {
return err
}
// 记录日志
uc.log.Info("file share deleted", slog.String("type", OperationTypeFile), slog.Uint64("operator_id", operatorID(ctx)), slog.String("path", share.Path))
return nil
}
// Consume 校验分享有效性,count 为真时原子计数一次下载
func (uc *FileShareUsecase) Consume(token string, count bool) (*FileShare, error) {
return uc.repo.Consume(token, count)
}
func (uc *FileShareUsecase) ClearExpired() (int64, error) {
return uc.repo.ClearExpired()
}
+41
View File
@@ -0,0 +1,41 @@
package biz
import (
"context"
"errors"
"fmt"
"strings"
"github.com/spf13/cast"
"gorm.io/gorm"
)
// operatorID 从 context 获取操作员 ID,无法获取时返回 0(系统操作)
func operatorID(ctx context.Context) uint64 {
if ctx == nil {
return 0
}
userID := ctx.Value("user_id")
if userID == nil {
return 0
}
return cast.ToUint64(userID)
}
// IsNotFound 判断错误是否为记录不存在
func IsNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}
// containerSock 从设置读取容器 socket 路径,未配置或失败时返回默认值
func containerSock(setting SettingRepo) string {
sock, _ := setting.Get(SettingKeyContainerSock)
if sock == "" {
sock = "/var/run/docker.sock"
}
// 自动补全 scheme
if !strings.Contains(sock, "://") {
sock = fmt.Sprintf("unix://%s", sock)
}
return sock
}
+17
View File
@@ -31,6 +31,7 @@ const (
OperationTypeMonitor = "monitor"
OperationTypeWebhook = "webhook"
OperationTypeUser = "user"
OperationTypeWaf = "waf"
)
// LogEntry 日志条目
@@ -52,3 +53,19 @@ type LogRepo interface {
// ListDates 获取可用的日志日期列表
ListDates(logType string) ([]string, error)
}
type LogUsecase struct {
repo LogRepo
}
func NewLogUsecase(repo LogRepo) *LogUsecase {
return &LogUsecase{repo: repo}
}
func (uc *LogUsecase) List(logType string, limit int, date string) ([]LogEntry, error) {
return uc.repo.List(logType, limit, date)
}
func (uc *LogUsecase) ListDates(logType string) ([]string, error) {
return uc.repo.ListDates(logType)
}
+61 -3
View File
@@ -3,7 +3,9 @@ package biz
import (
"time"
"github.com/acepanel/panel/v3/internal/http/request"
"github.com/spf13/cast"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/types"
)
@@ -15,8 +17,64 @@ type Monitor struct {
}
type MonitorRepo interface {
GetSetting() (*request.MonitorSetting, error)
UpdateSetting(setting *request.MonitorSetting) error
Clear() error
List(start, end time.Time) ([]*Monitor, error)
}
type MonitorUsecase struct {
repo MonitorRepo
setting SettingRepo
}
func NewMonitorUsecase(repo MonitorRepo, setting SettingRepo) *MonitorUsecase {
return &MonitorUsecase{repo: repo, setting: setting}
}
func (uc *MonitorUsecase) GetSetting() (*request.MonitorSetting, error) {
monitor, err := uc.setting.Get(SettingKeyMonitor)
if err != nil {
return nil, err
}
monitorDays, err := uc.setting.Get(SettingKeyMonitorDays)
if err != nil {
return nil, err
}
monitorInterval, err := uc.setting.GetInt(SettingKeyMonitorInterval, 1)
if err != nil {
return nil, err
}
alertDays, err := uc.setting.GetInt(SettingKeyAlertLogDays, 30)
if err != nil {
return nil, err
}
setting := new(request.MonitorSetting)
setting.Enabled = cast.ToBool(monitor)
setting.Days = cast.ToUint(monitorDays)
setting.Interval = uint(monitorInterval)
setting.AlertDays = uint(alertDays)
return setting, nil
}
func (uc *MonitorUsecase) UpdateSetting(setting *request.MonitorSetting) error {
if err := uc.setting.Set(SettingKeyMonitor, cast.ToString(setting.Enabled)); err != nil {
return err
}
if err := uc.setting.Set(SettingKeyMonitorDays, cast.ToString(setting.Days)); err != nil {
return err
}
if err := uc.setting.Set(SettingKeyMonitorInterval, cast.ToString(setting.Interval)); err != nil {
return err
}
return uc.setting.Set(SettingKeyAlertLogDays, cast.ToString(setting.AlertDays))
}
func (uc *MonitorUsecase) Clear() error {
return uc.repo.Clear()
}
func (uc *MonitorUsecase) List(start, end time.Time) ([]*Monitor, error) {
return uc.repo.List(start, end)
}
+318
View File
@@ -0,0 +1,318 @@
package biz
import (
"context"
"encoding/json"
"errors"
"fmt"
"html"
"log/slog"
"slices"
"strings"
"time"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/utils/crypt"
"gorm.io/gorm"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/notify"
)
// NotifyChannel 通知渠道
type NotifyChannel struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"not null;default:''" json:"name"`
Type string `gorm:"not null;default:''" json:"type"` // smtp
Config json.RawMessage `gorm:"not null;default:''" json:"config"` // 渠道配置,含凭据,落库前整体加密
Enabled bool `gorm:"not null;default:true" json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (r *NotifyChannel) BeforeSave(tx *gorm.DB) error {
crypter, err := crypt.NewXChacha20Poly1305([]byte(app.Key))
if err != nil {
return err
}
encrypted, err := crypter.Encrypt(r.Config)
if err != nil {
return err
}
r.Config = json.RawMessage(encrypted)
return nil
}
func (r *NotifyChannel) AfterFind(tx *gorm.DB) error {
crypter, err := crypt.NewXChacha20Poly1305([]byte(app.Key))
if err != nil {
return err
}
if config, err := crypter.Decrypt(string(r.Config)); err == nil {
r.Config = config
}
return nil
}
// NotifyEvent 系统事件类型
type NotifyEvent string
const (
NotifyEventCertRenew NotifyEvent = "cert_renew" // 证书续签失败
NotifyEventBackup NotifyEvent = "backup" // 备份失败
NotifyEventTaskFailed NotifyEvent = "task_failed" // 后台任务失败
NotifyEventCronFailed NotifyEvent = "cron_failed" // 计划任务执行失败
NotifyEventWebsiteExpire NotifyEvent = "website_expire" // 网站到期关停
NotifyEventTamper NotifyEvent = "tamper" // 防篡改拦截
NotifyEventHealth NotifyEvent = "health" // 面板健康问题
NotifyEventLogin NotifyEvent = "login" // 面板登录
NotifyEventLoginFailed NotifyEvent = "login_failed" // 面板登录失败过多
NotifyEventSSHLogin NotifyEvent = "ssh_login" // SSH 登录
NotifyEventSSHBruteforce NotifyEvent = "ssh_bruteforce" // SSH 爆破
)
type NotifyChannelRepo interface {
List(page, limit uint) ([]*NotifyChannel, int64, error)
All() ([]*NotifyChannel, error)
Get(id uint) (*NotifyChannel, error)
GetByIDs(ids []uint) ([]*NotifyChannel, error)
Create(channel *NotifyChannel) error
Update(channel *NotifyChannel) error
Delete(id uint) error
}
// notifyMaxPending 异步事件通知的并发上限,防止高频事件堆积 goroutine
const notifyMaxPending = 32
type NotifyUsecase struct {
repo NotifyChannelRepo
setting SettingRepo
log *slog.Logger
t *gotext.Locale
pending chan struct{}
}
func NewNotifyUsecase(t *gotext.Locale, log *slog.Logger, notifyChannelRepo NotifyChannelRepo, settingRepo SettingRepo) (*NotifyUsecase, error) {
return &NotifyUsecase{
repo: notifyChannelRepo,
setting: settingRepo,
log: log,
t: t,
pending: make(chan struct{}, notifyMaxPending),
}, nil
}
func (uc *NotifyUsecase) List(page, limit uint) ([]*NotifyChannel, int64, error) {
return uc.repo.List(page, limit)
}
func (uc *NotifyUsecase) All() ([]*NotifyChannel, error) {
return uc.repo.All()
}
func (uc *NotifyUsecase) Get(id uint) (*NotifyChannel, error) {
return uc.repo.Get(id)
}
func (uc *NotifyUsecase) Create(ctx context.Context, req *request.NotifyChannelCreate) (*NotifyChannel, error) {
// 提前构造一次,配置不合法直接拒绝入库
if _, err := notify.New(req.Type, req.Config); err != nil {
return nil, errors.New(uc.t.Get("invalid channel config: %v", err))
}
channel := &NotifyChannel{
Name: req.Name,
Type: req.Type,
Config: req.Config,
Enabled: req.Enabled,
}
if err := uc.repo.Create(channel); err != nil {
return nil, err
}
uc.log.Info("notify channel created", slog.String("type", OperationTypeSetting), slog.Uint64("operator_id", operatorID(ctx)), slog.String("name", req.Name))
// 落库时 Config 已被加密,重新读取以返回解密后的实体
return uc.repo.Get(channel.ID)
}
func (uc *NotifyUsecase) Update(ctx context.Context, req *request.NotifyChannelUpdate) error {
channel, err := uc.repo.Get(req.ID)
if err != nil {
return err
}
if _, err = notify.New(req.Type, req.Config); err != nil {
return errors.New(uc.t.Get("invalid channel config: %v", err))
}
channel.Name = req.Name
channel.Type = req.Type
channel.Config = req.Config
channel.Enabled = req.Enabled
if err = uc.repo.Update(channel); err != nil {
return err
}
uc.log.Info("notify channel updated", slog.String("type", OperationTypeSetting), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(req.ID)), slog.String("name", req.Name))
return nil
}
func (uc *NotifyUsecase) Delete(ctx context.Context, id uint) error {
channel, err := uc.repo.Get(id)
if err != nil {
return err
}
if err = uc.repo.Delete(id); err != nil {
return err
}
uc.log.Info("notify channel deleted", slog.String("type", OperationTypeSetting), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(id)), slog.String("name", channel.Name))
return nil
}
// Test 向指定渠道发送一条测试消息
func (uc *NotifyUsecase) Test(ctx context.Context, id uint) error {
channel, err := uc.repo.Get(id)
if err != nil {
return err
}
return uc.dispatch(ctx, channel, uc.t.Get("[AcePanel] Test Notification"),
NotifyBody(uc.t.Get("This is a test notification from AcePanel, receiving it means the channel is configured correctly."), [][2]string{
{uc.t.Get("Channel"), channel.Name},
{uc.t.Get("Time"), time.Now().Format(time.DateTime)},
}))
}
// Send 向指定渠道列表发送通知,返回成功发送的渠道数,部分渠道失败不影响其他渠道
func (uc *NotifyUsecase) Send(ctx context.Context, channelIDs []uint, subject, body string) (int, error) {
if len(channelIDs) == 0 {
return 0, nil
}
channels, err := uc.repo.GetByIDs(channelIDs)
if err != nil {
return 0, err
}
var sent int
var errs []error
for _, channel := range channels {
if !channel.Enabled {
continue
}
if err = uc.dispatch(ctx, channel, subject, body); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", channel.Name, err))
continue
}
sent++
}
return sent, errors.Join(errs...)
}
// SendEvent 发送系统事件通知,不阻塞业务流程
// 待发送数超过上限时丢弃并告知,避免慢渠道拖垮调用方
func (uc *NotifyUsecase) SendEvent(event NotifyEvent, subject, body string) {
select {
case uc.pending <- struct{}{}:
default:
uc.log.Warn("event notification dropped, too many pending sends", slog.String("event", string(event)))
return
}
go func() {
defer func() { <-uc.pending }()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := uc.SendEventSync(ctx, event, subject, body); err != nil {
uc.log.Warn("failed to send event notification", slog.String("event", string(event)), slog.Any("err", err))
}
}()
}
// SendEventSync 同步发送系统事件通知,未订阅该事件或未配置渠道时静默跳过
// 供 CLI 等短生命周期进程使用,异步发送会随进程退出丢失
func (uc *NotifyUsecase) SendEventSync(ctx context.Context, event NotifyEvent, subject, body string) error {
setting, err := uc.GetSetting()
if err != nil || len(setting.Channels) == 0 || !slices.Contains(setting.Events, string(event)) {
return err
}
_, err = uc.Send(ctx, setting.Channels, subject, body)
return err
}
func (uc *NotifyUsecase) GetSetting() (*request.NotifySetting, error) {
events, err := uc.setting.GetSlice(SettingKeyNotifyEvents)
if err != nil {
return nil, err
}
channelsStr, err := uc.setting.Get(SettingKeyNotifyEventChannels)
if err != nil {
return nil, err
}
channels := make([]uint, 0)
if channelsStr != "" {
_ = json.Unmarshal([]byte(channelsStr), &channels)
}
return &request.NotifySetting{
Events: events,
Channels: channels,
}, nil
}
func (uc *NotifyUsecase) UpdateSetting(setting *request.NotifySetting) error {
if err := uc.setting.SetSlice(SettingKeyNotifyEvents, setting.Events); err != nil {
return err
}
channels, err := json.Marshal(setting.Channels)
if err != nil {
return err
}
return uc.setting.Set(SettingKeyNotifyEventChannels, string(channels))
}
func (uc *NotifyUsecase) dispatch(ctx context.Context, channel *NotifyChannel, subject, body string) error {
notifier, err := notify.New(channel.Type, channel.Config)
if err != nil {
return err
}
return notifier.Send(ctx, &notify.Message{Subject: subject, Body: body})
}
// NotifyBody 构建通知正文,rows 为「名称,值」明细
func NotifyBody(summary string, rows [][2]string) string {
var sb strings.Builder
sb.WriteString(`<p>`)
sb.WriteString(html.EscapeString(summary))
sb.WriteString(`</p>`)
if len(rows) > 0 {
sb.WriteString(`<table cellpadding="6" cellspacing="0" style="border-collapse:collapse;border:1px solid #ddd">`)
for _, row := range rows {
sb.WriteString(`<tr><td style="border:1px solid #ddd;background:#fafafa">`)
sb.WriteString(html.EscapeString(row[0]))
sb.WriteString(`</td><td style="border:1px solid #ddd">`)
sb.WriteString(html.EscapeString(row[1]))
sb.WriteString(`</td></tr>`)
}
sb.WriteString(`</table>`)
}
return sb.String()
}
+139 -6
View File
@@ -2,9 +2,16 @@ package biz
import (
"context"
"errors"
"log/slog"
"path/filepath"
"strings"
"time"
"github.com/acepanel/panel/v3/internal/http/request"
"github.com/leonelquinteros/gotext"
"github.com/samber/lo"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/types"
)
@@ -19,9 +26,135 @@ type Project struct {
type ProjectRepo interface {
Count() (int64, error)
List(typ types.ProjectType, page, limit uint) ([]*types.ProjectDetail, int64, error)
Get(id uint) (*types.ProjectDetail, error)
Create(ctx context.Context, req *request.ProjectCreate) (*types.ProjectDetail, error)
Update(ctx context.Context, req *request.ProjectUpdate) error
Delete(ctx context.Context, id uint) error
List(typ types.ProjectType, page, limit uint) ([]*Project, int64, error)
GetEntity(id uint) (*Project, error)
ParseDetail(project *Project) (*types.ProjectDetail, error)
NameExists(name string) (bool, error)
Create(project *Project, req *request.ProjectCreate) error
Save(project *Project) error
Delete(project *Project) error
RenameUnitFile(old, new string) error
RemoveUnitFile(name string) error
UpdateUnitFile(name string, req *request.ProjectUpdate) error
}
type ProjectUsecase struct {
repo ProjectRepo
log *slog.Logger
t *gotext.Locale
}
func NewProjectUsecase(t *gotext.Locale, log *slog.Logger, projectRepo ProjectRepo) (*ProjectUsecase, error) {
return &ProjectUsecase{
repo: projectRepo,
log: log,
t: t,
}, nil
}
func (uc *ProjectUsecase) Count() (int64, error) {
return uc.repo.Count()
}
func (uc *ProjectUsecase) List(typ types.ProjectType, page, limit uint) ([]*types.ProjectDetail, int64, error) {
projects, total, err := uc.repo.List(typ, page, limit)
if err != nil {
return nil, 0, err
}
details := lo.Map(projects, func(p *Project, _ int) *types.ProjectDetail {
detail, err := uc.repo.ParseDetail(p)
if err != nil {
// 如果解析失败,返回基本信息
return &types.ProjectDetail{
ID: p.ID,
Name: p.Name,
Type: p.Type,
}
}
return detail
})
return details, total, nil
}
func (uc *ProjectUsecase) Get(id uint) (*types.ProjectDetail, error) {
project, err := uc.repo.GetEntity(id)
if err != nil {
return nil, err
}
return uc.repo.ParseDetail(project)
}
func (uc *ProjectUsecase) Create(ctx context.Context, req *request.ProjectCreate) (*types.ProjectDetail, error) {
// 检查项目名是否已存在
exists, err := uc.repo.NameExists(req.Name)
if err != nil {
return nil, err
}
if exists {
return nil, errors.New(uc.t.Get("project name already exists"))
}
project := &Project{
Name: req.Name,
Type: req.Type,
Path: lo.If(!strings.HasPrefix(req.RootDir, "/"), filepath.Join("/", req.RootDir)).Else(req.RootDir),
}
if err := uc.repo.Create(project, req); err != nil {
return nil, err
}
// 记录日志
uc.log.Info("project created", slog.String("type", OperationTypeProject), slog.Uint64("operator_id", operatorID(ctx)), slog.String("name", req.Name), slog.String("project_type", string(req.Type)))
return uc.repo.ParseDetail(project)
}
func (uc *ProjectUsecase) Update(ctx context.Context, req *request.ProjectUpdate) error {
project, err := uc.repo.GetEntity(req.ID)
if err != nil {
return err
}
// 如果名称变更,需要重命名 unit 文件
if req.Name != project.Name {
if err := uc.repo.RenameUnitFile(project.Name, req.Name); err != nil {
return err
}
project.Name = req.Name
}
project.Path = lo.If(!strings.HasPrefix(req.RootDir, "/"), filepath.Join("/", req.RootDir)).Else(req.RootDir)
if err := uc.repo.Save(project); err != nil {
return err
}
// 记录日志
uc.log.Info("project updated", slog.String("type", OperationTypeProject), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(req.ID)), slog.String("name", project.Name))
// 更新 systemd unit 文件
return uc.repo.UpdateUnitFile(project.Name, req)
}
func (uc *ProjectUsecase) Delete(ctx context.Context, id uint) error {
project, err := uc.repo.GetEntity(id)
if err != nil {
return err
}
// 删除 systemd unit 文件
if err := uc.repo.RemoveUnitFile(project.Name); err != nil {
return err
}
if err := uc.repo.Delete(project); err != nil {
return err
}
// 记录日志
uc.log.Info("project deleted", slog.String("type", OperationTypeProject), slog.Uint64("operator_id", operatorID(ctx)), slog.Uint64("id", uint64(id)), slog.String("name", project.Name))
return nil
}
+39 -2
View File
@@ -1,8 +1,45 @@
package biz
import "context"
import (
"context"
"fmt"
"log/slog"
)
type SafeRepo interface {
GetPingStatus() (bool, error)
UpdatePingStatus(ctx context.Context, status bool) error
FirewallRunning() (bool, error)
SetPingStatus(status bool) error
}
type SafeUsecase struct {
repo SafeRepo
log *slog.Logger
}
func NewSafeUsecase(repo SafeRepo, log *slog.Logger) *SafeUsecase {
return &SafeUsecase{repo: repo, log: log}
}
func (uc *SafeUsecase) GetPingStatus() (bool, error) {
return uc.repo.GetPingStatus()
}
func (uc *SafeUsecase) UpdatePingStatus(ctx context.Context, status bool) error {
running, err := uc.repo.FirewallRunning()
if err != nil {
return err
}
if !running {
return fmt.Errorf("failed to update ping status: firewall is not running")
}
if err = uc.repo.SetPingStatus(status); err != nil {
return err
}
// 记录日志
uc.log.Info("ping status updated", slog.String("type", OperationTypeSafe), slog.Uint64("operator_id", operatorID(ctx)), slog.Bool("status", status))
return nil
}
+138 -3
View File
@@ -1,6 +1,11 @@
package biz
import "time"
import (
"encoding/json"
"time"
"github.com/spf13/cast"
)
// ScanEvent 扫描事件模型
type ScanEvent struct {
@@ -75,8 +80,138 @@ type ScanEventRepo interface {
TopSourceIPs(start, end string, limit uint) ([]*ScanSourceRank, error)
TopPorts(start, end string, limit uint) ([]*ScanPortRank, error)
ClearBefore(date string) error
GetSetting() (*ScanSetting, error)
UpdateSetting(setting *ScanSetting) error
Clear() error
VacuumDB() error
}
// ScanEventUsecase 扫描事件业务逻辑
type ScanEventUsecase struct {
repo ScanEventRepo
setting SettingRepo
}
func NewScanEventUsecase(repo ScanEventRepo, setting SettingRepo) *ScanEventUsecase {
return &ScanEventUsecase{repo: repo, setting: setting}
}
func (uc *ScanEventUsecase) Upsert(events []*ScanEvent) error {
return uc.repo.Upsert(events)
}
func (uc *ScanEventUsecase) List(start, end, sourceIP string, port uint, location string, page, limit uint) ([]*ScanEvent, uint, error) {
return uc.repo.List(start, end, sourceIP, port, location, page, limit)
}
func (uc *ScanEventUsecase) Summary(start, end string) (*ScanSummary, error) {
return uc.repo.Summary(start, end)
}
func (uc *ScanEventUsecase) Trend(start, end string) ([]*ScanDayTrend, error) {
return uc.repo.Trend(start, end)
}
func (uc *ScanEventUsecase) TopSourceIPs(start, end string, limit uint) ([]*ScanSourceRank, error) {
return uc.repo.TopSourceIPs(start, end, limit)
}
func (uc *ScanEventUsecase) TopPorts(start, end string, limit uint) ([]*ScanPortRank, error) {
return uc.repo.TopPorts(start, end, limit)
}
func (uc *ScanEventUsecase) ClearBefore(date string) error {
return uc.repo.ClearBefore(date)
}
func (uc *ScanEventUsecase) GetSetting() (*ScanSetting, error) {
enabled, err := uc.setting.GetBool(SettingKeyScanAware)
if err != nil {
return nil, err
}
days, err := uc.setting.GetInt(SettingKeyScanAwareDays, 30)
if err != nil {
return nil, err
}
interfacesStr, err := uc.setting.Get(SettingKeyScanAwareInterfaces)
if err != nil {
return nil, err
}
var interfaces []string
if interfacesStr != "" {
_ = json.Unmarshal([]byte(interfacesStr), &interfaces)
}
autoBlock, err := uc.setting.GetBool(SettingKeyScanAwareAutoBlock)
if err != nil {
return nil, err
}
blockThreshold, err := uc.setting.GetInt(SettingKeyScanAwareBlockThreshold, 100)
if err != nil {
return nil, err
}
blockWindow, err := uc.setting.GetInt(SettingKeyScanAwareBlockWindow, 5)
if err != nil {
return nil, err
}
blockDuration, err := uc.setting.GetInt(SettingKeyScanAwareBlockDuration, 0)
if err != nil {
return nil, err
}
whitelist, err := uc.setting.GetSlice(SettingKeyScanAwareWhitelist)
if err != nil {
return nil, err
}
return &ScanSetting{
Enabled: enabled,
Days: uint(days),
Interfaces: interfaces,
AutoBlock: autoBlock,
BlockThreshold: uint(blockThreshold),
BlockWindow: uint(blockWindow),
BlockDuration: uint(blockDuration),
Whitelist: whitelist,
}, nil
}
func (uc *ScanEventUsecase) UpdateSetting(setting *ScanSetting) error {
if err := uc.setting.Set(SettingKeyScanAware, cast.ToString(setting.Enabled)); err != nil {
return err
}
if err := uc.setting.Set(SettingKeyScanAwareDays, cast.ToString(setting.Days)); err != nil {
return err
}
interfacesJSON, err := json.Marshal(setting.Interfaces)
if err != nil {
return err
}
if err = uc.setting.Set(SettingKeyScanAwareInterfaces, string(interfacesJSON)); err != nil {
return err
}
if err = uc.setting.Set(SettingKeyScanAwareAutoBlock, cast.ToString(setting.AutoBlock)); err != nil {
return err
}
if err = uc.setting.Set(SettingKeyScanAwareBlockThreshold, cast.ToString(setting.BlockThreshold)); err != nil {
return err
}
if err = uc.setting.Set(SettingKeyScanAwareBlockWindow, cast.ToString(setting.BlockWindow)); err != nil {
return err
}
if err = uc.setting.Set(SettingKeyScanAwareBlockDuration, cast.ToString(setting.BlockDuration)); err != nil {
return err
}
return uc.setting.SetSlice(SettingKeyScanAwareWhitelist, setting.Whitelist)
}
func (uc *ScanEventUsecase) Clear() error {
return uc.repo.Clear()
}
func (uc *ScanEventUsecase) VacuumDB() error {
return uc.repo.VacuumDB()
}

Some files were not shown because too many files have changed in this diff Show More